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." }
}

await-and-await-new #

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 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") }
}

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.

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") { … }

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.

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.

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." }
}

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}" }
}

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) { … }

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).