@sigrun/sigrun 0.1.5 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/CHANGELOG.md +136 -0
  2. package/README.md +352 -36
  3. package/THIRD_PARTY_NOTICES.md +23 -0
  4. package/dist/blocks/schema.js +9 -2
  5. package/dist/blocks/validate.js +57 -8
  6. package/dist/cli.js +26 -2
  7. package/dist/config.js +67 -1
  8. package/dist/extension.d.ts +189 -15
  9. package/dist/harness/agent-env.js +62 -0
  10. package/dist/harness/ambient.js +39 -185
  11. package/dist/harness/blocks/entries.js +47 -0
  12. package/dist/harness/blocks/state.js +58 -0
  13. package/dist/harness/blocks/tools.js +274 -0
  14. package/dist/harness/boot.js +253 -20
  15. package/dist/harness/builtins.js +15 -11
  16. package/dist/harness/event-bus.js +105 -10
  17. package/dist/harness/identity/accounts.js +186 -0
  18. package/dist/harness/identity/groups.js +14 -0
  19. package/dist/harness/identity/index.js +13 -6
  20. package/dist/harness/identity/json-store.js +15 -4
  21. package/dist/harness/identity/setup-code-check.js +9 -0
  22. package/dist/harness/identity/tokens.js +28 -5
  23. package/dist/harness/identity/users.js +111 -12
  24. package/dist/harness/interactions.js +292 -136
  25. package/dist/harness/loader.js +271 -17
  26. package/dist/harness/packages.js +1 -1
  27. package/dist/harness/reserved-sections.js +21 -0
  28. package/dist/harness/seam-host.js +80 -6
  29. package/dist/harness/settings.js +63 -2
  30. package/dist/harness/stores.js +241 -4
  31. package/dist/harness/user-services.js +391 -0
  32. package/dist/harness/user-tools.js +187 -0
  33. package/dist/harness/workspace.js +61 -55
  34. package/dist/jobs/fire.js +10 -16
  35. package/dist/sandbox/availability.js +162 -0
  36. package/dist/sandbox/command-observer.js +59 -0
  37. package/dist/sandbox/grants.js +159 -0
  38. package/dist/sandbox/helper.js +29 -0
  39. package/dist/sandbox/isolation.js +29 -0
  40. package/dist/sandbox/launch.js +20 -0
  41. package/dist/sandbox/os-account.js +49 -0
  42. package/dist/sandbox/policy.js +253 -0
  43. package/dist/sandbox/pool.js +548 -0
  44. package/dist/sandbox/protocol.js +167 -0
  45. package/dist/sandbox/sandboxed-env.js +245 -0
  46. package/dist/sandbox/unavailable-env.js +49 -0
  47. package/dist/sandbox/worker.js +281 -0
  48. package/dist/serve.js +82 -7
  49. package/dist/server/auth/access-principal.js +16 -0
  50. package/dist/server/auth/pipeline.js +47 -33
  51. package/dist/server/blocks/answers.js +94 -0
  52. package/dist/server/blocks/chat-surfaces.js +393 -0
  53. package/dist/server/blocks/notes.js +216 -0
  54. package/dist/server/blocks/summaries.js +328 -0
  55. package/dist/server/commands.js +5 -0
  56. package/dist/server/content-parts-lazy.js +13 -0
  57. package/dist/server/conversation-handler.js +2 -5
  58. package/dist/server/conversation-ops.js +138 -37
  59. package/dist/server/helpers.js +18 -6
  60. package/dist/server/host-bridge.js +40 -20
  61. package/dist/server/images/cache.js +205 -0
  62. package/dist/server/images/convert.js +13 -0
  63. package/dist/server/images/fetch.js +302 -0
  64. package/dist/server/images/ranges.js +42 -0
  65. package/dist/server/images/rewrite.js +94 -0
  66. package/dist/server/images/sign.js +84 -0
  67. package/dist/server/images/sniff.js +62 -0
  68. package/dist/server/index.js +144 -4
  69. package/dist/server/login/flows.js +126 -0
  70. package/dist/server/login/http.js +31 -0
  71. package/dist/server/login/resolve.js +133 -0
  72. package/dist/server/login/setup-claim.js +55 -0
  73. package/dist/server/login/usernames.js +15 -0
  74. package/dist/server/ownership.js +13 -0
  75. package/dist/server/routes/admin.js +58 -0
  76. package/dist/server/routes/auth.js +88 -2
  77. package/dist/server/routes/conversations.js +3 -6
  78. package/dist/server/routes/events.js +28 -3
  79. package/dist/server/routes/extensions.js +37 -5
  80. package/dist/server/routes/images.js +267 -0
  81. package/dist/server/routes/interactions.js +57 -68
  82. package/dist/server/routes/jobs.js +2 -8
  83. package/dist/server/routes/login.js +223 -0
  84. package/dist/server/routes/me.js +90 -0
  85. package/dist/server/routes/meta.js +15 -0
  86. package/dist/server/routes/onboarding.js +94 -84
  87. package/dist/server/routes/settings-sections.js +190 -0
  88. package/dist/server/routes/settings.js +16 -4
  89. package/dist/server/routes/submissions.js +1 -1
  90. package/dist/server/routes/surfaces.js +175 -23
  91. package/dist/server/routes/users.js +116 -20
  92. package/dist/server/schema-fixture.js +213 -0
  93. package/dist/server/session-meta.js +3 -0
  94. package/dist/server/shutdown.js +11 -4
  95. package/dist/server/static.js +18 -0
  96. package/dist/server/surfaces/dedup.js +9 -2
  97. package/dist/server/surfaces/service.js +181 -13
  98. package/dist/server/surfaces/settings-surfaces.js +597 -0
  99. package/dist/server/turns/submit-turn.js +26 -0
  100. package/dist/server/views.js +16 -0
  101. package/dist/settings/core-sections.js +532 -0
  102. package/dist/settings/pending.js +181 -0
  103. package/dist/settings/registry.js +453 -0
  104. package/dist/skills/index.js +6 -0
  105. package/node_modules/@sigrun/shared/CHANGELOG.md +15 -0
  106. package/node_modules/@sigrun/shared/dist/access.d.ts +4 -2
  107. package/node_modules/@sigrun/shared/dist/access.js +11 -4
  108. package/node_modules/@sigrun/shared/dist/ambient.d.ts +17 -74
  109. package/node_modules/@sigrun/shared/dist/ambient.js +8 -6
  110. package/node_modules/@sigrun/shared/dist/blocks/text.js +16 -2
  111. package/node_modules/@sigrun/shared/dist/blocks/transport.d.ts +4 -0
  112. package/node_modules/@sigrun/shared/dist/blocks/types.d.ts +21 -4
  113. package/node_modules/@sigrun/shared/dist/interactions.d.ts +22 -48
  114. package/node_modules/@sigrun/shared/dist/interactions.js +9 -7
  115. package/node_modules/@sigrun/shared/dist/settings.d.ts +27 -2
  116. package/node_modules/@sigrun/shared/dist/themes.d.ts +2 -3
  117. package/node_modules/@sigrun/shared/dist/themes.js +2 -3
  118. package/node_modules/@sigrun/shared/dist/turns.d.ts +14 -0
  119. package/node_modules/@sigrun/shared/dist/turns.js +53 -3
  120. package/node_modules/@sigrun/shared/dist/views.d.ts +175 -12
  121. package/node_modules/@sigrun/shared/dist/views.js +16 -0
  122. package/node_modules/@sigrun/shared/package.json +1 -1
  123. package/package.json +13 -8
  124. package/webui/dist/assets/index-CxB969qp.js +55 -0
  125. package/webui/dist/assets/index-yqkL6BSS.css +1 -0
  126. package/webui/dist/index.html +2 -2
  127. package/webui/dist/assets/index-B4-GYqEb.js +0 -50
  128. package/webui/dist/assets/index-BiSMljer.css +0 -1
@@ -0,0 +1,393 @@
1
+ /**
2
+ * The chat-surface handler (UI-blocks U5 Task 2, spec §8.2/§8.3): core
3
+ * applies a chat block's edits itself — there is no extension handler.
4
+ *
5
+ * - `move` applies a `DataOp.move` to the collection's bound array
6
+ * (a cross-group move ALSO sets the item's `groupBy` field, spec
7
+ * §8.3), with the destination resolved exactly like the channel's
8
+ * optimistic overlay (useSurface.applyMoveOverlay), so the confirmed
9
+ * result matches what the user saw while the action was in flight;
10
+ * - `edit` (a check) applies a `set` at the item's bound field;
11
+ * - a `turn: false` `submit` writes the form's values at its `bind`.
12
+ *
13
+ * Each applied edit appends one `ChangeRecord` (spec §8.3) whose
14
+ * `summary` comes from {@link summarizeAction} (labels the user saw),
15
+ * bumps the family's `rev`, and announces the change through the
16
+ * Task-1 seam (`chatSurfaceChanged` — the server binds it to the hub).
17
+ * More than {@link MAX_CHANGES} records with `notedThrough` past them
18
+ * are trimmed on the next append (Task 1's `trimChanges`).
19
+ *
20
+ * Ops apply inside ONE conversation commit (a failed batch unwinds
21
+ * before anything persists), and the commit returns only the new `rev`
22
+ * and the applied `ops` — plain JSON echoes the route answers, never a
23
+ * draft value (the chord settle hazard Task 1 documented).
24
+ */
25
+ import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
26
+ import { applyOps, DataOpError } from "@sigrun/shared/blocks";
27
+ import { BlocksEntry, } from "../../harness/blocks/entries.js";
28
+ import { BlocksStateDoc, readBlocksState, trimChanges, } from "../../harness/blocks/state.js";
29
+ import { chatSurfaceChanged, parseEntryId } from "../../harness/blocks/tools.js";
30
+ import { summarizeAction } from "./summaries.js";
31
+ /** A plain-record check (the tree walk's node shape). */
32
+ function isRec(value) {
33
+ return typeof value === "object" && value !== null && !Array.isArray(value);
34
+ }
35
+ /**
36
+ * Locate one chat block: its `sigrun.blocks` entry (visible through
37
+ * THIS conversation — `Conversation.entries` is fork-aware, so a fork
38
+ * sees only blocks whose entries are in its history) plus the state
39
+ * family's current member. `undefined` when the entry id names no
40
+ * block in this conversation.
41
+ */
42
+ export async function readChatBlock(harness, conversationId, entryId) {
43
+ if (!/^\d+$/.test(entryId))
44
+ return undefined;
45
+ const conversation = await harness.conversation(conversationId, BACKGROUND_CONTEXT);
46
+ if (conversation === undefined)
47
+ return undefined;
48
+ const parsed = parseEntryId(entryId);
49
+ if (parsed === undefined)
50
+ return undefined;
51
+ const page = await conversation.entries({ minEntryId: parsed, maxEntryId: parsed }, 1, undefined, BACKGROUND_CONTEXT);
52
+ const found = page.items[0];
53
+ if (found === undefined || found.kind !== BlocksEntry.kind)
54
+ return undefined;
55
+ const payload = found.data;
56
+ const state = await readBlocksState(harness, conversationId, entryId, BACKGROUND_CONTEXT);
57
+ if (state === undefined)
58
+ return undefined;
59
+ return { title: payload.title, tree: payload.tree, state };
60
+ }
61
+ /**
62
+ * Resolve the tree's handler labels (spec §6.1: handler names are free
63
+ * labels in chat — no `handlers` set at validation) to the node each
64
+ * names: a collection's `onMove`/`onCheck`, a form's `onSubmit`. The
65
+ * LAST declaration wins (matching the channel's own scan direction).
66
+ */
67
+ export function resolveChatHandlers(tree) {
68
+ const handlers = new Map();
69
+ const visit = (node) => {
70
+ if (node.type === "collection") {
71
+ if (typeof node.onMove === "string") {
72
+ handlers.set(node.onMove, { kind: "move", node });
73
+ }
74
+ if (typeof node.onCheck === "string") {
75
+ handlers.set(node.onCheck, { kind: "check", node });
76
+ }
77
+ }
78
+ else if (node.type === "form" && typeof node.onSubmit === "string") {
79
+ handlers.set(node.onSubmit, { kind: "submit", node });
80
+ }
81
+ const childLists = [
82
+ node.root,
83
+ node.children,
84
+ node.fields,
85
+ node.main,
86
+ node.sidebar,
87
+ node.detail,
88
+ ];
89
+ for (const list of childLists) {
90
+ if (Array.isArray(list)) {
91
+ for (const child of list) {
92
+ if (!isRec(child))
93
+ continue;
94
+ if (isRec(child.block))
95
+ visit(child.block);
96
+ else
97
+ visit(child);
98
+ }
99
+ }
100
+ else if (isRec(list)) {
101
+ visit(list);
102
+ }
103
+ }
104
+ if (isRec(node.item))
105
+ visit(node.item);
106
+ };
107
+ // SAFETY: the walk reads only the structural record fields (type,
108
+ // children, item, handler props) the wire Tree and the loose
109
+ // JsonObject view agree on; every read is typeof-guarded.
110
+ visit(tree.root);
111
+ return handlers;
112
+ }
113
+ /**
114
+ * Whether the handler `actionName` names starts a turn (spec §6.2/§8.4):
115
+ * a form's `submit` defaults to `turn: true` (an answer) and may set
116
+ * `turn: false` to stay an edit. ONLY submit handlers start turns: a
117
+ * move/check `turn` flag is never consulted (the collection node's
118
+ * `turn` is not part of the declared wire contract — shared
119
+ * CollectionBlock has none — so depending on it here would lean on an
120
+ * undeclared, unvalidated contract). Chat handler names are free
121
+ * labels, so this reads the RESOLVED node — the only place the default
122
+ * rule lives.
123
+ */
124
+ export function chatActionStartsTurn(tree, actionName) {
125
+ const handler = resolveChatHandlers(tree).get(actionName);
126
+ if (handler === undefined)
127
+ return false;
128
+ if (handler.kind !== "submit")
129
+ return false;
130
+ return handler.node.turn !== false;
131
+ }
132
+ /** A `move`/`edit` payload endpoint ({group?, index}). */
133
+ function readSpot(value) {
134
+ if (!isRec(value))
135
+ return undefined;
136
+ return {
137
+ ...(typeof value.group === "string" ? { group: value.group } : {}),
138
+ ...(typeof value.index === "number" ? { index: value.index } : {}),
139
+ };
140
+ }
141
+ /** Read the value at one data pointer (`/a/0/b`); undefined when absent. */
142
+ function dataAt(data, pointer) {
143
+ if (pointer === "/" || pointer === "")
144
+ return data;
145
+ const tokens = pointer.replace(/^\//, "").split("/");
146
+ let node = data;
147
+ for (const token of tokens) {
148
+ if (Array.isArray(node)) {
149
+ if (!/^\d+$/.test(token))
150
+ return undefined;
151
+ const index = Number(token);
152
+ if (index >= node.length)
153
+ return undefined;
154
+ node = node[index];
155
+ }
156
+ else if (isRec(node)) {
157
+ const next = node[token];
158
+ if (next === undefined)
159
+ return undefined;
160
+ node = next;
161
+ }
162
+ else {
163
+ return undefined;
164
+ }
165
+ }
166
+ return node;
167
+ }
168
+ /** The collection's bound-array pointer (`/cards`), when it binds one. */
169
+ function itemsPointer(collection) {
170
+ const items = collection.items;
171
+ if (!isRec(items))
172
+ return undefined;
173
+ const pointer = items.$;
174
+ return typeof pointer === "string" ? pointer : undefined;
175
+ }
176
+ /** The index of one item id inside its collection's bound array. */
177
+ function itemIndexAt(items, itemId) {
178
+ return items.findIndex((candidate) => isRec(candidate) && candidate.id === itemId);
179
+ }
180
+ /**
181
+ * The ABSOLUTE array index a group-relative drop lands at, mirroring
182
+ * the channel's overlay math (useSurface.applyMoveOverlay): the
183
+ * `index`-th member of `group` in the POST-REMOVAL array, or the
184
+ * array's end when the index is past the group's members (a lane-end
185
+ * drop). Grouped on `groupBy`; `undefined` when the group is unknown.
186
+ */
187
+ function absoluteDropIndex(removed, groupBy, group, index) {
188
+ const members = removed
189
+ .map((entry, position) => ({ entry, position }))
190
+ .filter(({ entry }) => isRec(entry) && entry[groupBy] === group)
191
+ .map(({ position }) => position);
192
+ if (index < members.length)
193
+ return members[index] ?? removed.length;
194
+ return removed.length;
195
+ }
196
+ /** Compute one action's ops against the CURRENT data (spec §8.3). */
197
+ function planAction(handlers, actionName, payload, data) {
198
+ const handler = handlers.get(actionName);
199
+ if (handler === undefined)
200
+ return undefined;
201
+ const record = isRec(payload) ? payload : {};
202
+ if (handler.kind === "move") {
203
+ const base = itemsPointer(handler.node);
204
+ if (base === undefined)
205
+ return undefined;
206
+ const itemId = record.itemId;
207
+ if (typeof itemId !== "string")
208
+ return undefined;
209
+ const items = dataAt(data, base);
210
+ if (!Array.isArray(items))
211
+ return undefined;
212
+ const fromIndex = itemIndexAt(items, itemId);
213
+ if (fromIndex < 0)
214
+ return undefined;
215
+ const to = readSpot(record.to);
216
+ if (to === undefined || to.index === undefined)
217
+ return undefined;
218
+ const groupBy = typeof handler.node.groupBy === "string" ? handler.node.groupBy : undefined;
219
+ // Post-removal resolution (DataOp.move resolves its destination
220
+ // against the post-removal array too, so the two agree).
221
+ const removed = items.filter((_, index) => index !== fromIndex);
222
+ const at = groupBy !== undefined && to.group !== undefined
223
+ ? absoluteDropIndex(removed, groupBy, to.group, to.index)
224
+ : to.index;
225
+ const ops = [];
226
+ // A cross-group move sets the groupBy field FIRST (spec §8.3) —
227
+ // the item arrives at the destination already grouped, and the
228
+ // set's index still addresses the pre-move array.
229
+ if (groupBy !== undefined && to.group !== undefined) {
230
+ const current = items[fromIndex];
231
+ if (isRec(current) &&
232
+ current[groupBy] !== to.group) {
233
+ ops.push({
234
+ op: "set",
235
+ path: `${base}/${fromIndex}/${groupBy}`,
236
+ value: to.group,
237
+ });
238
+ }
239
+ }
240
+ ops.push({ op: "move", from: `${base}/${fromIndex}`, path: `${base}/${at}` });
241
+ return { ops, actionKind: "reorder" };
242
+ }
243
+ if (handler.kind === "check") {
244
+ const field = handler.node.checkable;
245
+ if (typeof field !== "string")
246
+ return undefined;
247
+ const base = itemsPointer(handler.node);
248
+ if (base === undefined)
249
+ return undefined;
250
+ const itemId = record.itemId;
251
+ if (typeof itemId !== "string")
252
+ return undefined;
253
+ const items = dataAt(data, base);
254
+ if (!Array.isArray(items))
255
+ return undefined;
256
+ const at = itemIndexAt(items, itemId);
257
+ if (at < 0)
258
+ return undefined;
259
+ const value = record.value === undefined ? true : record.value;
260
+ return {
261
+ ops: [{ op: "set", path: `${base}/${at}/${field}`, value }],
262
+ actionKind: "edit",
263
+ };
264
+ }
265
+ // submit (turn:false): write the form's values at its bind.
266
+ const bind = handler.node.bind;
267
+ if (typeof bind !== "string")
268
+ return undefined;
269
+ const values = record.values;
270
+ if (!isRec(values))
271
+ return undefined;
272
+ const root = bind === "/" || bind === "" ? "" : bind.replace(/\/$/, "");
273
+ const ops = [];
274
+ // The bind object itself is created when absent (`set` can create the
275
+ // FINAL member but not intermediate parents) — a form writing at a
276
+ // fresh bind is the normal first-submit case.
277
+ if (root !== "" && dataAt(data, root) === undefined) {
278
+ ops.push({ op: "set", path: root, value: {} });
279
+ }
280
+ for (const name of Object.keys(values)) {
281
+ ops.push({
282
+ op: "set",
283
+ path: `${root}/${name}`,
284
+ value: values[name],
285
+ });
286
+ }
287
+ return { ops, actionKind: "submit" };
288
+ }
289
+ /** The `Action` summarizeAction reads for one plan (kind + label). */
290
+ function actionFor(actionKind, actionName) {
291
+ if (actionKind === "reorder")
292
+ return { type: "move", name: actionName };
293
+ if (actionKind === "edit")
294
+ return { type: "edit", name: actionName };
295
+ return { type: "submit", name: actionName };
296
+ }
297
+ /** The next change-record seq, monotonic per surface even right after a
298
+ * trim emptied the array (notedThrough anchors the sequence). */
299
+ function nextSeq(changes, notedThrough) {
300
+ return Math.max(changes[changes.length - 1]?.seq ?? 0, notedThrough) + 1;
301
+ }
302
+ /**
303
+ * Apply one user edit (the core handler, spec §8.3): read the entry
304
+ * (the tree is immutable, so its labels are always current), then —
305
+ * INSIDE ONE conversation commit — read the CURRENT draft state,
306
+ * compute the ops against it (planAction), and write ops + change
307
+ * record + `rev` bump. Planning inside the commit is what keeps an
308
+ * index-addressed op from relocating the WRONG row when a concurrent
309
+ * move (or an agent `update_blocks`) lands between the read and the
310
+ * write: `draft.data` at commit time IS the data the op addresses
311
+ * (Review Focus 3, spec §6.3 convergence), and the summary names the
312
+ * positions/labels the actor saw AT APPLY TIME (draft.data again,
313
+ * never a pre-commit snapshot). A failed op batch unwinds the commit
314
+ * (no partial write) and answers a reject naming the op index.
315
+ * `undefined` outcome means the entry id names no block in this
316
+ * conversation (the route answers 404).
317
+ */
318
+ export async function applyChatAction(harness, conversationId, entryId, by, actionName, payload) {
319
+ const conversation = await harness.conversation(conversationId, BACKGROUND_CONTEXT);
320
+ if (conversation === undefined)
321
+ return undefined;
322
+ const block = await readChatBlock(harness, conversationId, entryId);
323
+ if (block === undefined)
324
+ return undefined;
325
+ const handlers = resolveChatHandlers(block.tree);
326
+ if (!handlers.has(actionName))
327
+ return undefined;
328
+ let applied;
329
+ try {
330
+ applied = await conversation.commit(async (tx) => {
331
+ const draft = await tx.doc(BlocksStateDoc, conversationId, entryId, entryId);
332
+ // The plan is derived from the DRAFT — the data as of the
333
+ // commit, not the pre-commit snapshot — so every index
334
+ // addresses the row the op is about to move.
335
+ const plan = planAction(handlers, actionName, payload, draft.data);
336
+ if (plan === undefined) {
337
+ // Signal out of the commit (a throw unwinds it); the
338
+ // caller answers the reject.
339
+ return { rev: -1, ops: [] };
340
+ }
341
+ // The summary reads the labels the user saw AT APPLY TIME — the
342
+ // draft's PRE-edit data (the payload's positions are the
343
+ // pre-move ones), so it is computed BEFORE the ops apply.
344
+ const summary = summarizeAction(block.tree, draft.data, actionFor(plan.actionKind, actionName), payload);
345
+ // applyOps throws DataOpError on a bad path — the throw unwinds
346
+ // the commit before anything persists.
347
+ draft.data = applyDataOps(draft.data, plan.ops);
348
+ draft.rev += 1;
349
+ draft.changes = [
350
+ ...draft.changes,
351
+ {
352
+ seq: nextSeq(draft.changes, draft.notedThrough),
353
+ at: Date.now(),
354
+ by,
355
+ action: plan.actionKind,
356
+ summary,
357
+ },
358
+ ];
359
+ // The trim (spec §8.2): once more than MAX_CHANGES accumulate,
360
+ // records at or below notedThrough drop on THIS append.
361
+ trimChanges(draft);
362
+ // Only the rev + ops escape the commit — never a draft value
363
+ // (the chord settle hazard Task 1 documented); rev/ops are the
364
+ // values the route echoes back, plain JSON.
365
+ return { rev: draft.rev, ops: plan.ops };
366
+ }, BACKGROUND_CONTEXT);
367
+ }
368
+ catch (error) {
369
+ if (error instanceof DataOpError) {
370
+ return {
371
+ ok: false,
372
+ error: { message: `op ${error.opIndex} failed: ${error.message}` },
373
+ };
374
+ }
375
+ throw error;
376
+ }
377
+ if (applied.rev === -1) {
378
+ return { ok: false, error: { message: `unappliable action payload: ${actionName}` } };
379
+ }
380
+ chatSurfaceChanged({
381
+ type: "surface.changed",
382
+ conversationId,
383
+ entryId,
384
+ rev: applied.rev,
385
+ });
386
+ return { ok: true, rev: applied.rev, ops: applied.ops };
387
+ }
388
+ /** applyOps over a plan's ops. */
389
+ function applyDataOps(data, ops) {
390
+ // SAFETY: planAction built every op as a literal DataOp; applyOps
391
+ // re-validates each path and throws DataOpError on anything else.
392
+ return applyOps(data, ops);
393
+ }
@@ -0,0 +1,216 @@
1
+ /**
2
+ * The §8.3 change note (UI-blocks U5 Task 3): before the agent's next
3
+ * turn, core writes ONE `sigrun.blocks.note` entry covering every
4
+ * surface with change records above `notedThrough`, then advances each
5
+ * surface's `notedThrough` in one commit.
6
+ *
7
+ * The note is a `type: "write"` submission whose entry carries the note
8
+ * as a `model` user message — when the conversation is idle pi-durable
9
+ * appends it at once; when busy, both the note and the input queue in
10
+ * `pi.inbox`, and pi-durable places every queued write before any queued
11
+ * user item at each boundary (`harness/inbox.js` applyBoundary), so the
12
+ * note always precedes its input, a steer included.
13
+ *
14
+ * Exactly once (§8.3): the write's `requestId` is
15
+ * `blocks-note:<conversationId>:<sha256 of the sorted (entryId,
16
+ * notedThrough) pairs BEFORE advancing>` — a key over the pre-advance
17
+ * positions only, never the carried changes, so new edits arriving after
18
+ * the note compute cannot change the key; a crash between the admission
19
+ * and the advance re-sends under the SAME key and pi-durable returns the
20
+ * existing submission (`submissionByRequest`).
21
+ */
22
+ import { createHash } from "node:crypto";
23
+ import { toText } from "@sigrun/shared/blocks";
24
+ import { PROJECTION_LOCALE, PROJECTION_WIDTH, } from "../../harness/blocks/tools.js";
25
+ import { BlocksEntry, BlocksNoteEntry, } from "../../harness/blocks/entries.js";
26
+ import { BlocksStateDoc, } from "../../harness/blocks/state.js";
27
+ /** The note's header line (spec §8.3, verbatim). */
28
+ export const NOTE_HEADER = "Changes made to your UI since your last turn:";
29
+ /** The per-surface change-line cap (spec §8.3): the oldest fold into "…and N earlier changes". */
30
+ export const MAX_NOTE_CHANGE_LINES = 50;
31
+ /**
32
+ * The test seam for the crash-recovery case: when armed, the NEXT
33
+ * `writePendingNote` throws right after the note's admission (before the
34
+ * `notedThrough` advance) — the durable record of the crash window the
35
+ * exactly-once key exists for. One-shot: the throw consumes the arm.
36
+ */
37
+ let throwAfterNextAdmission = false;
38
+ /** Arm the crash seam (see the field above). Test-only. */
39
+ export function throwAfterNextNoteAdmissionForTesting() {
40
+ throwAfterNextAdmission = true;
41
+ }
42
+ /**
43
+ * Render one surface's note section (spec §8.3): the bullet line naming
44
+ * the surface and each editor (`• <title> (<user>): <summaries joined by
45
+ * "; ">` — one line per distinct author, in first-appearance order) then
46
+ * `Current state of <title>: <toText of the surface at its CURRENT data>`.
47
+ * Over {@link MAX_NOTE_CHANGE_LINES} change lines for a surface, the
48
+ * oldest fold into "…and N earlier changes".
49
+ */
50
+ function renderSurface(surface) {
51
+ const lines = [];
52
+ // Group the pending records by author, first-appearance order.
53
+ const byAuthor = new Map();
54
+ for (const record of surface.pending) {
55
+ const list = byAuthor.get(record.by) ?? [];
56
+ list.push(record.summary);
57
+ byAuthor.set(record.by, list);
58
+ }
59
+ for (const [author, summaries] of byAuthor) {
60
+ if (summaries.length > MAX_NOTE_CHANGE_LINES) {
61
+ const earlier = summaries.length - MAX_NOTE_CHANGE_LINES;
62
+ const kept = summaries.slice(earlier);
63
+ lines.push(`• ${surface.title} (${author}): …and ${earlier} earlier changes; ${kept.join("; ")}`);
64
+ }
65
+ else {
66
+ lines.push(`• ${surface.title} (${author}): ${summaries.join("; ")}`);
67
+ }
68
+ }
69
+ const projection = toText(surface.tree, surface.state.data, {
70
+ width: PROJECTION_WIDTH,
71
+ locale: PROJECTION_LOCALE,
72
+ });
73
+ lines.push(`Current state of ${surface.title}: ${projection}`);
74
+ return lines.join("\n");
75
+ }
76
+ /**
77
+ * Compute the pending change note for one conversation (spec §8.3):
78
+ * every `sigrun.blocks` entry in the conversation whose state carries
79
+ * change records above `notedThrough`. `skipEntry` excludes ONE surface
80
+ * (the block answer folds its own surface's changes into the answer
81
+ * instead — §8.4). `undefined` when nothing is unnoted.
82
+ */
83
+ export async function pendingNote(harness, conversationId, skipEntry, ctx) {
84
+ const conversation = await harness.conversation(conversationId, ctx);
85
+ if (conversation === undefined)
86
+ return undefined;
87
+ // Every block entry in the conversation (oldest first — the note
88
+ // lists surfaces in transcript order).
89
+ const page = await conversation.entries({}, 500, undefined, ctx);
90
+ const blocks = [];
91
+ for (const entry of [...page.items].reverse()) {
92
+ if (entry.kind !== BlocksEntry.kind)
93
+ continue;
94
+ blocks.push({
95
+ entryId: String(entry.id),
96
+ payload: entry.data,
97
+ });
98
+ }
99
+ const surfaces = [];
100
+ for (const block of blocks) {
101
+ if (block.entryId === skipEntry)
102
+ continue;
103
+ const state = await harness.snapshot(BlocksStateDoc, conversationId, block.entryId, ctx);
104
+ if (state === undefined)
105
+ continue;
106
+ const pending = state.changes.filter((record) => record.seq > state.notedThrough);
107
+ if (pending.length === 0)
108
+ continue;
109
+ surfaces.push({
110
+ entryId: block.entryId,
111
+ title: block.payload.title,
112
+ state,
113
+ pending,
114
+ tree: block.payload.tree,
115
+ });
116
+ }
117
+ if (surfaces.length === 0)
118
+ return undefined;
119
+ // The exactly-once key: the sorted (entryId, notedThrough) pairs as
120
+ // they stand BEFORE this note advances them (spec §8.3).
121
+ const pairs = surfaces
122
+ .map((surface) => [surface.entryId, String(surface.state.notedThrough)])
123
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
124
+ const digest = createHash("sha256")
125
+ .update(JSON.stringify(pairs))
126
+ .digest("hex");
127
+ const text = [
128
+ NOTE_HEADER,
129
+ ...surfaces.map((surface) => renderSurface(surface)),
130
+ ].join("\n");
131
+ return {
132
+ text,
133
+ requestId: `blocks-note:${conversationId}:${digest}`,
134
+ advance: surfaces.map((surface) => ({
135
+ entryId: surface.entryId,
136
+ // The covered top seq: the newest pending record. New edits
137
+ // landing after this compute advance only as far as the note
138
+ // covers — they stay pending for the NEXT note.
139
+ through: surface.pending[surface.pending.length - 1].seq,
140
+ })),
141
+ };
142
+ }
143
+ /**
144
+ * Write the pending change note for one conversation, when there is one:
145
+ * compute it, admit the `sigrun.blocks.note` write (model-facing, so a
146
+ * busy conversation queues it ahead of the input the caller submits
147
+ * next), then advance each covered surface's `notedThrough` in ONE
148
+ * commit. `skipEntry` excludes one surface (the block-answer fold,
149
+ * §8.4).
150
+ *
151
+ * Order is the exactly-once contract (§8.3): admission FIRST (a crash
152
+ * after it re-sends under the same requestId — `pendingNote` computes
153
+ * the key from the pre-advance positions, which a crashed advance never
154
+ * moved — and pi-durable returns the existing submission), the advance
155
+ * SECOND. The caller submits the turn's input after this returns.
156
+ */
157
+ export async function writePendingNote(harness, conversation, skipEntry, ctx) {
158
+ const note = await pendingNote(harness, conversation.id, skipEntry, ctx);
159
+ if (note === undefined)
160
+ return;
161
+ const message = {
162
+ role: "user",
163
+ content: note.text,
164
+ timestamp: Date.now(),
165
+ };
166
+ const submission = await conversation.submit({
167
+ type: "write",
168
+ entry: {
169
+ kind: BlocksNoteEntry.kind,
170
+ model: [message],
171
+ // The coverage rides the entry (the durable artifact): a
172
+ // deduped retry — pi-durable returns the EXISTING
173
+ // submission and discards this text+data — must advance
174
+ // only as far as the ADMITTED note named, never this
175
+ // attempt's freshly computed `advance`.
176
+ data: { advance: note.advance },
177
+ },
178
+ requestId: note.requestId,
179
+ }, ctx);
180
+ // The crash seam (test-only, see the field's docblock): admission
181
+ // landed, the advance never runs — the retry path's premise.
182
+ if (throwAfterNextAdmission) {
183
+ throwAfterNextAdmission = false;
184
+ throw new Error("injected crash after note admission");
185
+ }
186
+ // Read the ADMITTED note's coverage from its entry, not this
187
+ // compute's: a deduped submission's entry id names the entry the
188
+ // FIRST admission wrote. Entries are conversation-scoped (the
189
+ // submission's `entry` id is only meaningful inside its
190
+ // conversation), so the read goes through the conversation handle.
191
+ const status = await submission.status(ctx);
192
+ const admittedEntryId = status.type === "write" && status.status === "done"
193
+ ? status.entry
194
+ : undefined;
195
+ let advance = note.advance;
196
+ if (admittedEntryId !== undefined) {
197
+ const page = await conversation.entries({ minEntryId: admittedEntryId, maxEntryId: admittedEntryId }, 1, undefined, ctx);
198
+ const data = page.items[0]?.data;
199
+ if (data !== undefined && typeof data === "object" && !Array.isArray(data)) {
200
+ const admitted = data.advance;
201
+ if (Array.isArray(admitted))
202
+ advance = admitted;
203
+ }
204
+ }
205
+ // Advance `notedThrough` per covered surface in ONE commit (the note
206
+ // is durable; the advance just moves the watermark).
207
+ await conversation.commit(async (tx) => {
208
+ for (const { entryId, through } of advance) {
209
+ const draft = await tx.doc(BlocksStateDoc, conversation.id, entryId, entryId);
210
+ // Never move the watermark BACKWARD (a retried note after a
211
+ // newer advance is a no-op).
212
+ if (draft.notedThrough < through)
213
+ draft.notedThrough = through;
214
+ }
215
+ }, ctx);
216
+ }