8000 feat(automations): approvals as activities and an approval.waiting notification by adeelahmad · Pull Request #457 · adeelahmad/lense · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion docs/automations.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@ merged is called built.
| The Timeline page with filters, grouping, review keys and new items, and Home's Latest card ([Timeline](timeline.md)) | built |
| Approvals: an automation's action that asks first waits for your pick (approve, decline, edit, allow from now on), told once in your chat app, capped and expiring (`app/domain/approvals.py`) | built |
| Deciding an automation's action with a yes in the chat it told you in | built |
| The Approvals page and its badge, approval activities and notifications, agents' approval tools | planned |
| Approvals as activities (`approval.waiting`, `approval.decided`) and the `approval.waiting` notification | built |
| The Approvals page and its badge, agents' approval tools | planned |
| Storing and running automations, joins, variables, the studio | planned |

## Contract
Expand Down Expand Up @@ -122,6 +123,8 @@ and the paths holding outside text are in `GET /api/v1/activities/kinds` (`event
| `backup.finished` | | `log` | `{type: Document, id: lens:backup/<name>, url: /settings/backups}`, `label` (manual, scheduled), `ok`, `error`, `kind` (export, folder), `bytes`, `files`, `copied` (to the storage connection) | A scheduled backup finished: scheduled-20261015-020000.surql.gz |
| `lens.release` | | `log` | `{type: Note, id: lens:release/<tag>, url: its release notes}`, `tag`, `title`, `current` (what this server runs), `published_at` | Lens v0.7.0 is out (this server runs 0.6.0) |
| `webhook.received` | | `log` | `{type: Note, hook: the webhook's id, payload: what was sent (JSON, a form's fields or text), content_type, query}`; the actor is the webhook (`Service`, `lens:automation_hook/<id>`, "Webhook for <automation>") | Webhook for Front door was called |
| `approval.waiting` | | `approvals` | `{type: Question, id: lens:approval/<id>, name: what it would do, url: /approvals?id=<id>}`, `status`, `action`, `effect`, `automation`, `automation_name`, `run`, `node`, `about` (the activity that caused it), `expires_at`; its person alone sees it | School emails asks for your pick: Post in the Matterbridge gateway family: “Trip on Friday, bring a packed lunch” |
| `approval.decided` | | `approval_decisions` | as `approval.waiting`, with `status` (done, failed, declined, expired) and `error`; `Accept` when approved, `Reject` when declined or expired | Sam approved: Post in the Matterbridge gateway family: “Trip on Friday, bring a packed lunch” |

A new asset is one activity, of the most specific kind its row tells, decided by its content type and where it came
from, never by reading its file: an email (content type email, or from an IMAP or Gmail mailbox), a calendar event
Expand Down Expand Up @@ -205,6 +208,17 @@ Each is logged once, however many processes see it happen.
- `lens.release` is a newer Lens release, once per release, when Lens tells the admins and notification targets
([Updates](updates.md#being-told)).

#### Approvals as activities

Built (`app/domain/events_approvals.py`). An automation's action that asks first ([Approvals](#approvals)) is
`approval.waiting` when it starts to wait, read from the approval by `created_at`, and `approval.decided` when it is
approved (done or failed), declined or expires, read by `decided_at`. Both are the person's own (`lens.audience`
owner): the timeline shows them to that person alone, not even to admins, and the person's automations can react to
them ("when an approval has waited a day, remind me"). The actor of `approval.waiting` is the automation (the
assistant for its own approvals); of `approval.decided`, the person who decided, or Lens when it expired. An
automation's approval carries its run as `lens.cause`, so a follower reacting to one can't loop for ever. What it would
do (`object.name`) and why it failed hold text the run read from outside Lens, and are listed in `lens.untrusted`.

### Sources and the event log

Built. A source (`events.Source`, added with `events.register_source`) reads rows Lens already keeps as activities,
Expand Down Expand Up @@ -241,6 +255,9 @@ A kind belongs to the source named in its `source`, and a source may list more i
breaking ties, as `routine.ran`.
- `chat_messages` (`app/domain/events_streams.py`) reads `chat_message` rows with role user by `created_at` over the
index on (role, created_at), the id breaking ties, as `chat.message`.
- `approvals` (`app/domain/events_approvals.py`) reads `approval` by `created_at`, the id breaking ties, as
`approval.waiting`; `approval_decisions` reads the decided ones by `decided_at`, as `approval.decided`. Both page
only the viewer's own approvals.
- `log` reads `event_log`: activities no row of their own records (a deletion, a webhook call), written by
`events.emit(db, kind, *, actor, obj, space, summary, target=None, tag=None, key=None, published=None, spaces=None,
audience=None, owner=None, untrusted=None)`, which gives the id (`log-<n>`). Emitting a `key` again changes
Expand Down Expand Up @@ -624,6 +641,11 @@ Undecided approvals expire after `automations.approval_days` (Settings → Autom
called from the scheduler once an hour marks them `expired`, nothing runs, and the run says so. The assistant's own
approvals don't expire.

Each approval is an activity too: `approval.waiting` when it asks and `approval.decided` when it is decided or
expires, seen by its person alone ([Approvals as activities](#approvals-as-activities)). A namespace's notification
target can send `approval.waiting` to a team chat or webhook ([Notifications](notifications.md#events)); it is off
until a target asks for it.

The inbox reads `GET /api/v1/approvals` (newest first; `status`, `origin=automation|chat`, `automation`, `chat_id`,
and `before` with `limit` to page), `GET /api/v1/approvals/{aid}` and `GET /api/v1/approvals/count` (`{waiting,
automations}`, for a badge).
Expand Down
5 changes: 4 additions & 1 deletion docs/notifications.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,12 +92,15 @@ check that the timestamp is recent too, to refuse replays.
| `batch.finished` | a batch run finished, or its sample did; each namespace it ran in gets its own counts | `batch`, `label`, `status`, `counts`, `sample` |
| `recording.added` | an asset (a recording, document, image or web page) was added, however it came | `recording`, `title`, `source`, `created_at` |
| `lens.release` | a new Lens release is out, once per release ([Updates](updates.md)) | `tag`, `name`, `current`, `published_at`, `notes_url` |
| `approval.waiting` | an automation's action waits for its person's pick ([Approvals](automations.md#approvals)); one decided before the notifier saw it isn't sent | `approval`, `automation`, `automation_name`, `summary`, `action`, `effect`, `expires_at`, `created_at` |

New targets get `job.failed` and `batch.finished`. A batch run's own runs aren't sent one by one: the batch run is,
when it finishes. More than five things added to a namespace at once (a watched folder's new files, an import) are one
message (`data.recordings`, `data.count`).

`text` is the message chat targets get, and `url` links to the run, batch run or asset in the web app.
`text` is the message chat targets get, and `url` links to the run, batch run, asset or approval in the web app. What
an approval would do holds text its run read from outside Lens, so web addresses in its `text` are broken up
(`hxxps://evil[.]example`), except the web app's own.

## How it's sent

Expand Down
8 changes: 5 additions & 3 deletions fastapi_backend/app/domain/approvals.py
Original file line number Diff line number Diff line change
Expand Up @@ -346,13 +346,14 @@ def decide(db, cfg, row, user, decision="approve", params=None, via="web"):
except Exception as e: # noqa: BLE001 - recorded on the approval and the run
error = f"{type(e).__name__}: {e}"[:500] if not isinstance(e, ValueError) else str(e)[:500]
status = "failed" if error else "done"
db.q(
"UPDATE $r SET status = $s, result = $res, error = $e, intent = $i",
db.q( # decided_at again: the approval.decided activity is read by it, once the outcome is known
"UPDATE $r SET status = $s, result = $res, error = $e, intent = $i, decided_at = $t",
r=R("approval", row["id"]),
s=status,
res=result,
e=error,
i={**intent, "status": status},
t=store.now(),
)
_record(db, intent, status, result if not error else {"error": error})
out = {"status": status, **({"error": error} if error else {"result": result})}
Expand Down Expand Up @@ -420,8 +421,9 @@ def sweep(db, cfg, now=None):
if a.get("intent"):
_record(db, a["intent"], "expired")
stuck = db.rows(
"UPDATE approval SET status = 'failed', error = 'the server stopped while it was carried out' "
"UPDATE approval SET status = 'failed', error = 'the server stopped while it was carried out', decided_at = $n "
"WHERE status = 'running' AND decided_at < $c RETURN record::id(id) AS id, intent",
n=_iso(now),
c=_iso(now - dt.timedelta(seconds=STUCK_SECONDS)),
)
for a in stuck:
Expand Down
1 change: 1 addition & 0 deletions fastapi_backend/app/domain/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -1123,3 +1123,4 @@ def tidy(db, cfg, now=None):

from . import events_content # noqa: E402, F401 - the recordings source, registered with the stream once it is loaded
from . import events_streams # noqa: E402, F401 - sensors, chat, webhooks and system kinds, the same way
from . import events_approvals # noqa: E402, F401 - approvals waiting and decided, after the content sources it reuses
287 changes: 287 additions & 0 deletions fastapi_backend/app/domain/events_approvals.py
BD0
Original file line number Diff line number Diff line change
@@ -0,0 +1,287 @@
"""Approvals as activities (docs/automations.md, Approvals): `approval.waiting` when something starts waiting for a
person's pick, read by `approval.created_at`, and `approval.decided` when it is approved (done or failed), declined
or expires, read by `approval.decided_at`. Both are the person's own (`lens.audience` owner): the timeline shows them
to that person alone, and their automations can react ("when an approval waits more than a day, remind me"). An
automation's approval carries its run as `lens.cause`.
"""

from __future__ import annotations

import re

from . import events
from .events_content import _num, _paging, _s, _sample, _text, actors
from .store import R

FIELDS = (
"id AS rid, record::id(id) AS id, account, chat, tool, summary, status, created_at, decided_at, decided_by, origin, "
"intent.action AS action, intent.effect AS effect, space, expires_at, why.activity AS about, automation, error"
)
DECIDED = ("done", "failed", "declined", "expired")
OUT = {"done": "approved", "failed": "approved, but it failed", "declined": "declined", "expired": "expired with no pick"}


def _depths(db, rows):
"""The run's own depth of cause plus one, for each run an approval came from (1 while runs aren't stored)."""
runs = sorted({o["run"] for o in (r.get("origin") or {} for r in rows) if _num(o.get("run")) is not None})
got = {}
if runs:
try:
got = {
x["id"]: x.get("cause_depth")
for x in db.rows(
"SELECT record::id(id) AS id, cause_depth FROM automation_run WHERE id IN $ids",
ids=[R("automation_run", n) for n in runs],
)
}
except Exception: # noqa: BLE001 - runs aren't stored yet
got = {}
return {n: (got.get(n) if isinstance(got.get(n), int) else 0) + 1 for n in runs}


def _object(r):
o = r.get("origin") or {}
return {
k: v
for k, v in {
"type": "Question",
"id": f"lens:approval/{r['id']}",
"name": _text(r.get("summary"), 500) or "An approval",
"url": f"/approvals?id={r['id']}",
"status": r.get("status"),
"tool": r.get("tool"),
"action": r.get("action"),
"effect": r.get("effect"),
"automation": _num(o.get("automation")),
"automation_name": r.get("automation"),
"run": _num(o.get("run")),
"node": o.get("node"),
"chat": _num(r.get("chat")),
"about": r.get("about"),
"expires_at": r.get("expires_at"),
"error": _text(r.get("error"), 500),
}.items()
if v is not None
}


def _cause(r, depths):
o = r.get("origin") or {}
if _num(o.get("automation")) is None or _num(o.get("run")) is None:
return None
return {"automation": o["automation"], "run": o["run"], "depth": depths.get(o["run"], 1)}


def _asker(r):
o = r.get("origin") or {}
return f"automation:{o['automation']}" if _num(o.get("automation")) is not None else "assistant"


def _waiting(db, rows):
rows = [r for r in rows if r and r.get("created_at")]
if not rows:
return []
who = actors(db, [(_asker(r), r.get("space")) for r in rows])
depths = _depths(db, rows)
out = []
for r in rows:
try:
published = events.stamp(r["created_at"])
except (TypeError, ValueError):
continue
actor = who[(_asker(r), r.get("space"))]
obj = _object(r)
out.append(
events.build(
f"ask-approval-{r['id']}",
"approval.waiting",
actor=actor,
obj=obj,
summary=f"{actor['name']} asks for your pick: {obj['name']}",
space=r.get("space"),
source="approvals",
pos=[r["created_at"], r["id"]],
published=published,
cause=_cause(r, depths),
audience="owner",
owner=r.get("account"),
)
)
return out


def _decided(db, rows):
rows = [r for r in rows if r and r.get("decided_at") and r.get("status") in DECIDED]
if not rows:
return []
label = {r["id"]: None if r["status"] == "expired" else r.get("decided_by") for r in rows}
who = actors(db, [(label[r["id"]], r.get("space")) for r in rows])
depths = _depths(db, rows)
out = []
for r in rows:
try:
published = events.stamp(r["decided_at"])
except (TypeError, ValueError):
continue
actor = who[(label[r["id"]], r.get("space"))]
obj = _object(r)
said = f"{actor['name']} {OUT[r['status']]}" if r["status"] != "expired" else "Expired with no pick"
a = events.build(
f"decide-approval-{r['id']}",
"approval.decided",
actor=actor,
obj=obj,
summary=f"{said}: {obj['name']}",
space=r.get("space"),
source="approval_decisions",
pos=[r["decided_at"], r["id"]],
published=published,
cause=_cause(r, depths),
audience="owner",
owner=r.get("account"),
)
a["type"] = "Accept" if r["status"] in ("done", "failed") else "Reject"
out.append(a)
return out


class _ApprovalSource(events.Source):
col = "created_at"
rx = re.compile(r"^ask-approval-(\d+)$")
more = ""

def _make(self, db, rows):
return _waiting(db, rows)

def read(self, db, cfg, pos, spaces, limit, hint=None):
p: dict = {}
where = f"{events.after_sql(self.col, pos, p, table='approval')} AND {events.space_sql(spaces, p)}{self.more}"
rows = db.rows(f"SELECT {FIELDS} FROM approval WHERE {where} ORDER BY {self.col}, rid LIMIT {int(limit)}", **p)
return self._make(db, rows)

def page(self, db, cfg, before, who, flt, limit):
if who.get("account") is None:
return []
p: dict = {"me": who["account"]}
where = _paging(self.col, "approval", before, {**who, "admin": True}, flt, p) + ["account = $me"]
rows = db.rows(
f"SELECT {FIELDS} FROM approval WHERE {' AND '.join(where)}{self.more} ORDER BY {self.col} DESC, rid DESC LIMIT {int(limit)}",
**p,
)
return self._make(db, rows)

def get(self, db, cfg, activity_id):
m = self.rx.match(activity_id)
if not m:
return None
r = db.one(f"SELECT {FIELDS} FROM $r", r=R("approval", int(m.group(1))))
got = self._make(db, [r]) if r else []
return got[0] if got else None

def get_many(self, db, cfg, ids):
ns = sorted({int(m.group(1)) for m in (self.rx.match(i) for i in ids) if m})
if not ns:
return []
return self._make(db, db.rows(f"SELECT {FIELDS} FROM approval WHERE id IN $rs", rs=[R("approval", n) for n in ns]))


class ApprovalsSource(_ApprovalSource):
"""approval rows as approval.waiting, read by (created_at, id)."""

name = "approvals"


class ApprovalDecisionsSource(_ApprovalSource):
"""decided approval rows as approval.decided, read by (decided_at, id)."""

name = "approval_decisions"
col = "decided_at"
rx = re.compile(r"^decide-approval-(\d+)$")
more = " AND status IN ['done', 'failed', 'declined', 'expired']"

def _make(self, db, rows):
return _decided(db, rows)


events.register_source(ApprovalsSource())
events.register_source(ApprovalDecisionsSource())

_OBJECT = {
"type": "object",
"properties": {
"type": _s("Object type", enum=["Question"]),
"id": _s("Approval"),
"name": _s("What it would do"),
"url": _s("Link"),
"status": _s("Status", enum=["pending", "running", "done", "failed", "declined", "expired"]),
"tool": _s("Tool"),
"action": _s("Action"),
"effect": _s("Effect", enum=["read", "self", "lens", "out"]),
"automation": _s("Automation", "integer"),
"automation_name": _s("Automation name"),
"run": _s("Run", "integer"),
"node": _s("Node"),
"chat": _s("Conversation", "integer"),
"about": _s("The activity that caused it"),
"expires_at": _s("Expires", format="date-time"),
"error": _s("Why it failed"),
},
}
AUTOMATION = {"type": "Application", "id": "lens:automation/4", "name": "School emails"}
_ASKED = {
"type": "Question",
"id": "lens:approval/12",
"name": "Post in the Matterbridge gateway family: “Trip on Friday, bring a packed lunch”",
"url": "/approvals?id=12",
"status": "pending",
"tool": "action",
"action": "notify",
"effect": "out",
"automation": 4,
"automation_name": "School emails",
"run": 51,
"node": "n7",
"about": "create-recording-7",
"expires_at": "2026-10-22T16:24:10+00:00",
}
UNTRUSTED = ("object.name", "object.error")
events.kind(
"approval.waiting",
label="Waiting for your approval",
source="approvals",
as_type="Create",
object_type="Question",
fields={"object": _OBJECT},
untrusted=UNTRUSTED,
sample=_sample(
"ask-approval-12",
"approval.waiting",
"Create",
"approvals",
["2026-10-15T16:24:10+00:00", 12],
AUTOMATION,
_ASKED,
"School emails asks for your pick: Post in the Matterbridge gateway family: “Trip on Friday, bring a packed lunch”",
UNTRUSTED,
),
)
events.kind(
"approval.decided",
label="Approval decided",
source="approval_decisions",
as_type="Accept",
object_type="Question",
fields={"object": _OBJECT},
untrusted=UNTRUSTED,
sample=_sample(
"decide-approval-12",
"approval.decided",
"Accept",
"approval_decisions",
["2026-10-15T16:40:02+00:00", 12],
{"type": "Person", "id": "lens:account/1", "name": "Sam"},
{**_ASKED, "status": "done"},
"Sam approved: Post in the Matterbridge gateway family: “Trip on Friday, bring a packed lunch”",
UNTRUSTED,
),
)
Loading
Loading
0