Reviews — waiting on a person or a system
Reviews — waiting on a person or a system #
A run can wait for something outside it: a press on a button, a form someone fills in, a reply in a thread, a question answered. Each answer arrives on an edge of a record the run holds — a message's Interactions, a form's Submissions, a thread's Replies. Write the record, then await that edge: the run parks, holding nothing open, and resumes where it left off with the answer bound and every other binding it had — minutes or days later.
Reach for one only when a judgement is genuinely needed. A value you can compute, extract, or default with a ?: set-if-empty field is not one.
waiting-on-a-person #
msg = write deals-[:Messages]-> {
Message: "New company: ${`Company Name`}. Pursue it?"
Buttons: [{ Text: "Pursue", Value: "pursue" }, { Text: "Pass", Value: "pass" }]
}
r = race(
() => {
press = await msg-[:Interactions]->
return only(press-[:Actions]->)?.Value
},
() => { await sleep(2d) },
)
if (r[0] == "pursue") {
write crm-[:Companies]-> { unique by (FUZZY Name), Name: `Company Name` }
}
if (r[0] == null) {
write msg-[:Replies]-> { Message: "Nobody decided in time — holding." }
}
- The write that puts the question makes the record the answer lands on. There is nothing else to mint: no id, no address.
awaittakes one hop off one record and binds one record: what arrived.- The wait opens just before the write runs, so an answer that comes while the write is still finishing counts.
- Give every wait a timeout. Put it in a
racewith asleep, as here. An answer that never comes otherwise leaves the run parked for good. - What
awaitbinds is always there, so nothing after it needs a== nulltest (a record deleted while the run waits:deleted-records). - Only an edge whose system announces what arrives on it can be awaited: a message's
InteractionsandReplies, a form'sSubmissions, a question'sResponse. Each system's chapter says which of its edges announce. Awaiting any other edge is refused when you save, with the read or theawait untilto write instead.
await-and-await-new #
await x-[:Replies]->takes the first match already on the edge, in the order the system keeps it, or else the first to arrive.await new x-[:Replies]->takes only the first to arrive afterxentered the run: when it was written (just before the write), read, or handed in as a parameter.- The record that started the run, and each record its arrival brought with it (a change brings its deal), entered at delivery. So a change of that deal in the same delivery counts; the change that started the run never does.
- A record the run walks to that the arrival did not bring (the deal's company) enters at the walk: only what arrives after that counts.
- On an edge that only announces — a press, a form's answer — the two are the same: nothing is there before it arrives.
- On an edge whose system keeps no order, plain
awaitis refused when you save, because there is no "first already there": writeawait new. - A
WHEREon the hop says which arrival counts. - Two plain
awaits on the same record's edge share one wait, and both bind the same first match. - The head is a name bound to one record, and the wait takes one hop off it. To wait at the end of a longer walk, bind the record the last hop leaves from, then await its edge.
thread = only(ops-[m:Messages WHERE m.Message == go.Text]->)
if (thread == null) { fail("no message \"${go.Text}\" in #ops") }
write thread-[:Replies]-> { Message: "Any update on this?" }
r = race(
() => {
reply = await new thread-[x:Replies WHERE x.User == thread.User]->
return reply.Message
},
() => { await sleep(1d) },
)
new skips the replies already in the thread, and the WHERE takes only its author's, so the nudge the run just posted never answers it.
buttons #
Buttons are a field of the message the run writes. A press arrives on the message's Interactions.
msg = write approvals-[:Messages]-> {
Message: "Approve the expense?"
Buttons: [{ Text: "Approve", Value: "approve" }, { Text: "Reject", Value: "reject" }]
}
r = race(
() => {
press = await msg-[:Interactions]->
return only(press-[:Actions]->)?.Value
},
() => { await sleep(1d) },
)
if (r[0] == "approve") { … }
- A press is an
Interaction: when it happened (At), the controls used (Actions, each with theValueandTextit was written with), and who pressed (By). Valueis one of the values the buttons were written with, sor[0] == "aprove"is refused when you save, with a did-you-mean.- A value built at run time leaves
Valueplain text, with a warning naming the path it was written at (Buttons[].Value). - Which systems carry buttons, and their limits, is in each system's chapter (
system:slack). - To take every press, from anyone, each in its own run, write the message with a
Topicbeside itsButtonsand listen on the presses:listen to chat-[:Channels]->-[m:Messages WHERE m.Topic == "lunch-poll"]->-[:Interactions]-> fire count_vote. The run keeps its tally in a table;system:slackshows the whole poll.
host-links #
A link a person opens to answer is a form from @lf/forms. Reach for one when the person is outside every system the automation uses, or when the answer has several typed fields. Its fields are a node you declare, and the answer is typed by it.
forms = connect `@lf/forms`
node Refund {
Approve: <boolean>
Note: <text | null>
}
function `Refund Check`(go: <manual-[:Invocation]->>) {
form = write forms-[:Forms]-> { Title: "Approve the refund for ${go.Text}?", Fields: Refund }
write ops-[:Messages]-> { Message: "Approve or decline: ${form.Url}" }
r = race(
() => {
answer = await form-[:Submissions]->
if (answer.Approve) {
write ops-[:Messages]-> { Message: "Refund approved. ${coalesce(answer.Note, "")}" }
}
return answer.Approve
},
() => { await sleep(2d) },
)
if (r[0] == null) { fail("nobody answered the refund form in two days") }
}
form.Urlis the link. Send it wherever the person will see it: a channel, an email, a text message.- The link is served at the host's public address (under
listen-fire,lf.yaml'spublicUrl). A browser gets the form; a program fetching it as JSON gets the fields it must send. answer.Approveis a boolean andanswer.Noteis text or null, becauseFieldsnamedRefund. Reading a field the node does not declare is refused when you save.Fieldsnames a declarednode: a record built in place is refused.- The node may not declare a field every answer already has, such as
Id: that is refused when you save. - A form takes one answer; a different second one is turned away. It never expires, so the race's
sleepis its deadline. - A form is written, never listed: only the run that wrote it holds the link.
questions #
A question gives what buttons and a form do not: a page that renders the right control for whoever opens it, and an entry in that person's queue of things to decide. Connect its package, @lf/ask, and write one along a family edge, which is the kind of question and fixes the type of the answer.
Every family takes Prompt (the question) and an optional Detail (a longer explanation shown under it), plus the parameters below.
- Check — approve or decline.
Answeris a boolean,trueif they approved. - Provide — supply one value.
Answer Type(required) is"text","number","date", or"boolean";Answeris that type. - Choose — pick ONE.
Options(required, at least one) is the set;Answeris the option they chose. - Select — pick a SUBSET.
Options(required) again;Answeris the list they ticked, possibly empty. - Review — an acknowledgement. Nothing to fill in: the person confirms they have read the
Detail, andAnswercarries no value. - Form — collect several named fields at once.
Fields(required) names them; name them literally and each becomes a field of the answer,a.Budget, every one text.
q = write asks-[:Choose]-> { Prompt: "What next?", Options: ["pursue", "hold", "pass"] }
write ops-[:Messages]-> { Message: "What next? ${q.Url}" }
r = race(
() => {
a = await q-[:Response]->
return a.Answer
},
() => { await sleep(2d) },
)
if (r[0] == "pursue") { … }
q.Urlis the page. Nothing is sent for you: interpolate it into a message yourself.- Spell a parameter out as a literal and the answer narrows: a
Chooseover["pursue", "hold", "pass"]answers one of exactly those, and comparing it to anything else is refused when you save.Answer Type: "number"answers a number. A literalFieldsnarrows into one field per name. - Options built at run time give plain text: still correct, just unnarrowed.
Fieldsbuilt at run time leave one opaque value,a.Answer, which you can pass on into a structured field but not pick apart, compare or branch on. To act on a value, writeProvide,Choose,Select, or aFormwhoseFieldsare spelled out. - When the run was started from a chat interface, a question nobody was sent is relayed: the person in the chat is shown it, and their decision is what answers it — never the assistant's own judgement.
the-pick-idiom #
Letting a person pick a live record — one company out of several you found — needs no special family. Offer the names, then filter the graph on the answer.
found = crm-[c:Companies WHERE c.Categories == "Lead"]-> {
return c.Name
}
q = write asks-[:Choose]-> { Prompt: "Which company did they mean?", Options: found }
write ops-[:Messages]-> { Message: "Which one? ${q.Url}" }
r = race(
() => {
a = await q-[:Response]->
return a.Answer
},
() => { await sleep(1d) },
)
name = r[0]
if (name == null) { fail("nobody picked a company") }
pick = only(crm-[co:Companies WHERE co.Name == name]->)
if (pick == null) { fail("no company named ${name}") }
write pick-[:Notes]-> { Title: "Chosen", Content: "Picked from ${count(found)} candidates." }
The answer names the record; the lookup after it keys on that name.
deleted-records #
A record deleted while a run waits on it leaves the wait waiting; race every wait on a person or a system with a sleep.
When the record a wait hangs off is deleted — the message, the form, the question — nothing more arrives on it, and many systems cannot say it went. So the wait never ends: what follows the await never runs, and the run stays parked.
- In a
racearm, the arm never settles, so thesleepbeside it takes the race. - In an
allarm or amapmember, theallor themapnever finishes.
So what await binds is always there where you read it, and so is what a function that awaits returns. listen-fire upgrade from 0.12 warns on each await that no sleep races (MOV_AWAIT_MAY_HANG).
captured-values #
A wait's WHERE that reads a record the run holds — thread.User in await new thread-[x:Replies WHERE x.User == thread.User]-> — reads it once, when the wait is reached, and keeps that value while the run waits, as a closure keeps what it closed over. A change to that record while the run waits does not change which arrival counts.
- What the
WHEREreads from a system, such as a walk off a connection, is read at every check. - To follow a value that may change while the run waits, test it after the wait against the record re-read with
refresh.
timeout-and-escalation #
A deadline, a reminder and an escalation are all composed from race and sleep:
r = race(
() => {
press = await msg-[:Interactions]->
return only(press-[:Actions]->)?.Value
},
() => {
await sleep(6h)
write msg-[:Replies]-> { Message: "Still waiting on this one." }
await sleep(2d)
},
)
if (r[0] == null) {
write deals-[:Messages]-> { Message: "Nobody decided in time — holding." }
}
awaitmarks where the run itself waits: on the answer (await msg-[:Interactions]->) and on the clock (await sleep(2d)), inside the arms.racesays only how several waits combine — it settles on the first of them — so it is written withoutawait.- Each arm is a function, passed as an argument:
() => { … }written where it stands, or the name of one declared elsewhere. An arm takes no parameters — it already sees everything in scope where you wrote it — and it starts the moment the combinator does. - The value is a receipt: one slot per arm, in the order written, holding whatever that arm
returned.r[0]reads the first arm's slot,r[1]the second. An arm that hands nothing back has an always-null slot. - Under
race, only the arm that settled first has its slot filled and every other slot is null — so a null test on a slot is the branch. Arms still parked when one settles are dropped where they stand: their waiting stops, and whatever they had already done stays done. Arms that settle in the same moment all land. - To wait for every one instead, use
all(several-at-once): every one runs to the end and every slot is filled. - Arms run at once. Arms writing the same
unique bykey take turns, so the record is made once. - Durations are bare unit-suffixed literals:
30s,90m,6h,2d,1h30m. - To wait on a condition instead — "until this record is marked done" — hand
untilthe condition and the cadence to re-check it on:await until(() => { refresh company return company.Stage == "Won" }, { every: 1h })(…) => { … }is a closure: a body written where it stands and run later, holding everything in scope at the point you wrote it. The run resumes the moment the closure returns true.refreshre-reads a handle so each check sees the record as it is now; a condition that needs no refresh can be a plain boolean expression instead. A condition takes no parameters and may only read — a write or anawaitinside one is refused, as is a cadence under a minute. - Which of the two you write is the edge's to decide, and you are told when you save. Where the system announces what lands — a press, a reply, a form's answer —
awaitthe edge: the run sleeps and is woken the moment it arrives. Where nothing announces it,untilwith anevery:is the form, because someone has to say how often to look.
several-at-once #
Put one decision in several places by writing the message in each, and give each its own arm. An arm is written where you know the place, so it can hand that back along with the answer:
dealflow = only(team-[ch:Channels WHERE ch.Name == "dealflow"]->)
portfolio = only(team-[ch:Channels WHERE ch.Name == "portfolio"]->)
general = only(team-[ch:Channels WHERE ch.Name == "general"]->)
if (dealflow == null || portfolio == null || general == null) { fail("a channel is missing") }
in_dealflow = write dealflow-[:Messages]-> {
Message: "Approve this?"
Buttons: [{ Text: "Approve", Value: "approve" }]
}
in_portfolio = write portfolio-[:Messages]-> {
Message: "Approve this?"
Buttons: [{ Text: "Approve", Value: "approve" }]
}
r = race(
() => {
press = await in_dealflow-[:Interactions]->
return { channel: "dealflow", at: press.At }
},
() => {
press = await in_portfolio-[:Interactions]->
return { channel: "portfolio", at: press.At }
},
() => { await sleep(1d) },
)
answered = coalesce(r[0], r[1])
if (answered != null) {
write general-[:Messages]-> { Message: "approved in #${answered.channel} at ${answered.at}" }
}
- Whoever presses first settles it, wherever they were.
- Return a dict to hand back more than one thing at once; its entries read by name afterwards (
answered.channel).
Wait on different things and take every answer with all, which runs them at once. Put the all in a race with a sleep, so one answer that never comes cannot hold the run for good:
r = race(
() => {
both = all(
() => {
b = await budget-[:Response]->
return b.Answer
},
() => {
l = await legal-[:Response]->
return l.Answer
},
)
return both[0] && both[1]
},
() => { await sleep(3d) },
)
if (r[0] == true) { … }
- Nothing
allruns is dropped, so every slot is filled. There is noawaitin front: a function that waits parks the run, andallfinishes when every one has.
how-long-a-wait-lives #
What a person can act on lives as long as its system keeps it: a message's buttons, a form's link. The run waits only while it is parked on them: once its race settles or the run ends, a later press or answer wakes nothing, though a form answered then still records its answer. Something that must react at any time, forever, is a listen (buttons).