@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.
- package/CHANGELOG.md +136 -0
- package/README.md +352 -36
- package/THIRD_PARTY_NOTICES.md +23 -0
- package/dist/blocks/schema.js +9 -2
- package/dist/blocks/validate.js +57 -8
- package/dist/cli.js +26 -2
- package/dist/config.js +67 -1
- package/dist/extension.d.ts +189 -15
- package/dist/harness/agent-env.js +62 -0
- package/dist/harness/ambient.js +39 -185
- package/dist/harness/blocks/entries.js +47 -0
- package/dist/harness/blocks/state.js +58 -0
- package/dist/harness/blocks/tools.js +274 -0
- package/dist/harness/boot.js +253 -20
- package/dist/harness/builtins.js +15 -11
- package/dist/harness/event-bus.js +105 -10
- package/dist/harness/identity/accounts.js +186 -0
- package/dist/harness/identity/groups.js +14 -0
- package/dist/harness/identity/index.js +13 -6
- package/dist/harness/identity/json-store.js +15 -4
- package/dist/harness/identity/setup-code-check.js +9 -0
- package/dist/harness/identity/tokens.js +28 -5
- package/dist/harness/identity/users.js +111 -12
- package/dist/harness/interactions.js +292 -136
- package/dist/harness/loader.js +271 -17
- package/dist/harness/packages.js +1 -1
- package/dist/harness/reserved-sections.js +21 -0
- package/dist/harness/seam-host.js +80 -6
- package/dist/harness/settings.js +63 -2
- package/dist/harness/stores.js +241 -4
- package/dist/harness/user-services.js +391 -0
- package/dist/harness/user-tools.js +187 -0
- package/dist/harness/workspace.js +61 -55
- package/dist/jobs/fire.js +10 -16
- package/dist/sandbox/availability.js +162 -0
- package/dist/sandbox/command-observer.js +59 -0
- package/dist/sandbox/grants.js +159 -0
- package/dist/sandbox/helper.js +29 -0
- package/dist/sandbox/isolation.js +29 -0
- package/dist/sandbox/launch.js +20 -0
- package/dist/sandbox/os-account.js +49 -0
- package/dist/sandbox/policy.js +253 -0
- package/dist/sandbox/pool.js +548 -0
- package/dist/sandbox/protocol.js +167 -0
- package/dist/sandbox/sandboxed-env.js +245 -0
- package/dist/sandbox/unavailable-env.js +49 -0
- package/dist/sandbox/worker.js +281 -0
- package/dist/serve.js +82 -7
- package/dist/server/auth/access-principal.js +16 -0
- package/dist/server/auth/pipeline.js +47 -33
- package/dist/server/blocks/answers.js +94 -0
- package/dist/server/blocks/chat-surfaces.js +393 -0
- package/dist/server/blocks/notes.js +216 -0
- package/dist/server/blocks/summaries.js +328 -0
- package/dist/server/commands.js +5 -0
- package/dist/server/content-parts-lazy.js +13 -0
- package/dist/server/conversation-handler.js +2 -5
- package/dist/server/conversation-ops.js +138 -37
- package/dist/server/helpers.js +18 -6
- package/dist/server/host-bridge.js +40 -20
- package/dist/server/images/cache.js +205 -0
- package/dist/server/images/convert.js +13 -0
- package/dist/server/images/fetch.js +302 -0
- package/dist/server/images/ranges.js +42 -0
- package/dist/server/images/rewrite.js +94 -0
- package/dist/server/images/sign.js +84 -0
- package/dist/server/images/sniff.js +62 -0
- package/dist/server/index.js +144 -4
- package/dist/server/login/flows.js +126 -0
- package/dist/server/login/http.js +31 -0
- package/dist/server/login/resolve.js +133 -0
- package/dist/server/login/setup-claim.js +55 -0
- package/dist/server/login/usernames.js +15 -0
- package/dist/server/ownership.js +13 -0
- package/dist/server/routes/admin.js +58 -0
- package/dist/server/routes/auth.js +88 -2
- package/dist/server/routes/conversations.js +3 -6
- package/dist/server/routes/events.js +28 -3
- package/dist/server/routes/extensions.js +37 -5
- package/dist/server/routes/images.js +267 -0
- package/dist/server/routes/interactions.js +57 -68
- package/dist/server/routes/jobs.js +2 -8
- package/dist/server/routes/login.js +223 -0
- package/dist/server/routes/me.js +90 -0
- package/dist/server/routes/meta.js +15 -0
- package/dist/server/routes/onboarding.js +94 -84
- package/dist/server/routes/settings-sections.js +190 -0
- package/dist/server/routes/settings.js +16 -4
- package/dist/server/routes/submissions.js +1 -1
- package/dist/server/routes/surfaces.js +175 -23
- package/dist/server/routes/users.js +116 -20
- package/dist/server/schema-fixture.js +213 -0
- package/dist/server/session-meta.js +3 -0
- package/dist/server/shutdown.js +11 -4
- package/dist/server/static.js +18 -0
- package/dist/server/surfaces/dedup.js +9 -2
- package/dist/server/surfaces/service.js +181 -13
- package/dist/server/surfaces/settings-surfaces.js +597 -0
- package/dist/server/turns/submit-turn.js +26 -0
- package/dist/server/views.js +16 -0
- package/dist/settings/core-sections.js +532 -0
- package/dist/settings/pending.js +181 -0
- package/dist/settings/registry.js +453 -0
- package/dist/skills/index.js +6 -0
- package/node_modules/@sigrun/shared/CHANGELOG.md +15 -0
- package/node_modules/@sigrun/shared/dist/access.d.ts +4 -2
- package/node_modules/@sigrun/shared/dist/access.js +11 -4
- package/node_modules/@sigrun/shared/dist/ambient.d.ts +17 -74
- package/node_modules/@sigrun/shared/dist/ambient.js +8 -6
- package/node_modules/@sigrun/shared/dist/blocks/text.js +16 -2
- package/node_modules/@sigrun/shared/dist/blocks/transport.d.ts +4 -0
- package/node_modules/@sigrun/shared/dist/blocks/types.d.ts +21 -4
- package/node_modules/@sigrun/shared/dist/interactions.d.ts +22 -48
- package/node_modules/@sigrun/shared/dist/interactions.js +9 -7
- package/node_modules/@sigrun/shared/dist/settings.d.ts +27 -2
- package/node_modules/@sigrun/shared/dist/themes.d.ts +2 -3
- package/node_modules/@sigrun/shared/dist/themes.js +2 -3
- package/node_modules/@sigrun/shared/dist/turns.d.ts +14 -0
- package/node_modules/@sigrun/shared/dist/turns.js +53 -3
- package/node_modules/@sigrun/shared/dist/views.d.ts +175 -12
- package/node_modules/@sigrun/shared/dist/views.js +16 -0
- package/node_modules/@sigrun/shared/package.json +1 -1
- package/package.json +13 -8
- package/webui/dist/assets/index-CxB969qp.js +55 -0
- package/webui/dist/assets/index-yqkL6BSS.css +1 -0
- package/webui/dist/index.html +2 -2
- package/webui/dist/assets/index-B4-GYqEb.js +0 -50
- 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
|
+
}
|