@sigrun/sigrun 0.1.6 → 0.2.1
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 +192 -0
- package/README.md +445 -42
- package/dist/blocks/build-stamp.json +13 -0
- package/dist/blocks/builders.d.ts +57 -0
- package/dist/blocks/images.d.ts +26 -0
- package/dist/blocks/index.d.ts +20 -0
- package/dist/blocks/limits.d.ts +25 -0
- package/dist/blocks/placements.d.ts +26 -0
- package/dist/blocks/schema.d.ts +38 -0
- package/dist/blocks/schema.js +9 -2
- package/dist/blocks/validate.d.ts +73 -0
- package/dist/blocks/validate.js +57 -8
- package/dist/cli.js +26 -2
- package/dist/config.js +67 -1
- package/dist/extension.d.ts +230 -22
- 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 +259 -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 +92 -6
- package/dist/harness/sections/context-files.js +1 -1
- 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 -57
- package/dist/jobs/fire.js +10 -16
- package/dist/sandbox/availability.js +211 -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 +31 -0
- package/dist/sandbox/os-account.js +49 -0
- package/dist/sandbox/policy.js +274 -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 +261 -0
- package/dist/serve.js +213 -25
- package/dist/server/auth/access-principal.js +16 -0
- package/dist/server/auth/pipeline.js +99 -36
- 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 +213 -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 +152 -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/notifications.js +21 -4
- 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 +274 -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 +19 -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 +11 -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 +202 -12
- package/node_modules/@sigrun/shared/dist/views.js +16 -0
- package/node_modules/@sigrun/shared/package.json +1 -1
- package/package.json +13 -9
- package/webui/dist/assets/index-SO2W4jjk.js +55 -0
- package/webui/dist/assets/index-yqkL6BSS.css +1 -0
- package/webui/dist/index.html +2 -2
- package/CONTEXT.md +0 -163
- package/webui/dist/assets/index-CiYInlLx.css +0 -1
- package/webui/dist/assets/index-JDfOQV9z.js +0 -52
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Change-record summaries (UI-blocks U5 Task 2, spec §8.3): core writes
|
|
3
|
+
* each `ChangeRecord.summary` from the TREE, using the labels the user
|
|
4
|
+
* saw — the item's `title` slot value, its 1-based position, the
|
|
5
|
+
* group's (or the enclosing section's) title, and the form field's
|
|
6
|
+
* `label`.
|
|
7
|
+
*
|
|
8
|
+
* The pinned strings (the spec's own examples):
|
|
9
|
+
* - `moved 'Clair de Lune' from 4th to 1st in 'Ranking'`
|
|
10
|
+
* - `checked 'milk' in 'Shopping'` (and `unchecked …`)
|
|
11
|
+
* - `set 'Notes' to '…'` (a `turn: false` submit writes the form's
|
|
12
|
+
* values at its `bind`; one `set` per field, joined by `"; "`;
|
|
13
|
+
* values over {@link SUMMARY_VALUE_MAX} characters truncate with `…`)
|
|
14
|
+
*
|
|
15
|
+
* Never throws: every lookup is optional-typed (a missing binding, a
|
|
16
|
+
* payload shape the walk does not know) and falls back to the plainest
|
|
17
|
+
* label available — a summary is a human-read string, not a write path.
|
|
18
|
+
*/
|
|
19
|
+
import { resolvePointer } from "@sigrun/shared/blocks";
|
|
20
|
+
/** The value-render cap (the brief): values over 80 characters truncate with `…`. */
|
|
21
|
+
export const SUMMARY_VALUE_MAX = 80;
|
|
22
|
+
/** Render one value: plain JSON form, capped at 80 characters (`…`). */
|
|
23
|
+
function renderValue(value) {
|
|
24
|
+
const text = typeof value === "string" ? value : JSON.stringify(value);
|
|
25
|
+
if (text.length <= SUMMARY_VALUE_MAX)
|
|
26
|
+
return text;
|
|
27
|
+
return `${text.slice(0, SUMMARY_VALUE_MAX)}…`;
|
|
28
|
+
}
|
|
29
|
+
/** The 1-based ordinal the position line reads ("1st", "2nd", "11th"). */
|
|
30
|
+
export function ordinal(index) {
|
|
31
|
+
const oneBased = index + 1;
|
|
32
|
+
const tail100 = oneBased % 100;
|
|
33
|
+
if (tail100 >= 11 && tail100 <= 13)
|
|
34
|
+
return `${oneBased}th`;
|
|
35
|
+
switch (oneBased % 10) {
|
|
36
|
+
case 1:
|
|
37
|
+
return `${oneBased}st`;
|
|
38
|
+
case 2:
|
|
39
|
+
return `${oneBased}nd`;
|
|
40
|
+
case 3:
|
|
41
|
+
return `${oneBased}rd`;
|
|
42
|
+
default:
|
|
43
|
+
return `${oneBased}th`;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/** A `move`/`edit` payload endpoint (the `{group?, index}` shape §6.3 sends). */
|
|
47
|
+
function readSpot(value) {
|
|
48
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
const record = value;
|
|
52
|
+
return {
|
|
53
|
+
...(typeof record.group === "string" ? { group: record.group } : {}),
|
|
54
|
+
...(typeof record.index === "number" ? { index: record.index } : {}),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
/** Resolve a bindable against `data` (`item` scopes `./` bindings). */
|
|
58
|
+
function bound(value, data, item) {
|
|
59
|
+
if (value === undefined)
|
|
60
|
+
return undefined;
|
|
61
|
+
if (typeof value === "string")
|
|
62
|
+
return value;
|
|
63
|
+
const resolved = resolvePointer(data, value.$, item);
|
|
64
|
+
return typeof resolved === "string" ? resolved : undefined;
|
|
65
|
+
}
|
|
66
|
+
/** A plain-record check (the tree walk's node shape). */
|
|
67
|
+
function isRec(value) {
|
|
68
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
69
|
+
}
|
|
70
|
+
/** Yield every node of a tree, structurally (the service.ts walk). */
|
|
71
|
+
function* walkNodes(root) {
|
|
72
|
+
yield root;
|
|
73
|
+
const childLists = [
|
|
74
|
+
root.root,
|
|
75
|
+
root.children,
|
|
76
|
+
root.fields,
|
|
77
|
+
root.main,
|
|
78
|
+
root.sidebar,
|
|
79
|
+
root.detail,
|
|
80
|
+
];
|
|
81
|
+
for (const list of childLists) {
|
|
82
|
+
if (Array.isArray(list)) {
|
|
83
|
+
for (const child of list) {
|
|
84
|
+
if (isRec(child)) {
|
|
85
|
+
if (isRec(child.block)) {
|
|
86
|
+
yield* walkNodes(child.block);
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
yield* walkNodes(child);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
else if (isRec(list)) {
|
|
95
|
+
yield* walkNodes(list);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (isRec(root.item))
|
|
99
|
+
yield* walkNodes(root.item);
|
|
100
|
+
}
|
|
101
|
+
/** The label the user saw above one block: the nearest enclosing section title. */
|
|
102
|
+
function sectionTitleAround(tree, inner, data) {
|
|
103
|
+
const wanted = inner;
|
|
104
|
+
const visit = (node, title) => {
|
|
105
|
+
const nextTitle = node.type === "section" && node.title !== undefined
|
|
106
|
+
? bound(node.title, data, undefined)
|
|
107
|
+
: title;
|
|
108
|
+
if (node === wanted)
|
|
109
|
+
return nextTitle;
|
|
110
|
+
const childLists = [node.root, node.children, node.fields, node.main, node.sidebar, node.detail];
|
|
111
|
+
for (const list of childLists) {
|
|
112
|
+
if (Array.isArray(list)) {
|
|
113
|
+
for (const child of list) {
|
|
114
|
+
if (!isRec(child))
|
|
115
|
+
continue;
|
|
116
|
+
const hit = isRec(child.block)
|
|
117
|
+
? visit(child.block, nextTitle)
|
|
118
|
+
: visit(child, nextTitle);
|
|
119
|
+
if (hit !== undefined)
|
|
120
|
+
return hit;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
else if (isRec(list)) {
|
|
124
|
+
const hit = visit(list, nextTitle);
|
|
125
|
+
if (hit !== undefined)
|
|
126
|
+
return hit;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (isRec(node.item)) {
|
|
130
|
+
const hit = visit(node.item, nextTitle);
|
|
131
|
+
if (hit !== undefined)
|
|
132
|
+
return hit;
|
|
133
|
+
}
|
|
134
|
+
return undefined;
|
|
135
|
+
};
|
|
136
|
+
// SAFETY: the walk reads only structural record fields (type/children/
|
|
137
|
+
// item/…) — the wire `Tree` and the loose `JsonObject` view agree on
|
|
138
|
+
// those, and every read is typeof-guarded below.
|
|
139
|
+
const found = visit(tree.root, undefined);
|
|
140
|
+
return found;
|
|
141
|
+
}
|
|
142
|
+
/** The group's display title: the declared group's `title` (a binding resolves
|
|
143
|
+
* against the surface data the actor saw), else its id. */
|
|
144
|
+
function groupTitleOf(collection, groupId, data) {
|
|
145
|
+
const declared = (collection.groups ?? []).find((candidate) => candidate.id === groupId);
|
|
146
|
+
if (declared === undefined)
|
|
147
|
+
return groupId;
|
|
148
|
+
return (bound(declared.title, data, undefined) ?? declared.id);
|
|
149
|
+
}
|
|
150
|
+
/** One summary line, quoted and labelled; empty when nothing was found. */
|
|
151
|
+
function moveSummary(tree, data, payload) {
|
|
152
|
+
const itemId = payload.itemId;
|
|
153
|
+
if (typeof itemId !== "string")
|
|
154
|
+
return undefined;
|
|
155
|
+
const from = readSpot(payload.from);
|
|
156
|
+
const to = readSpot(payload.to);
|
|
157
|
+
if (from === undefined || to === undefined)
|
|
158
|
+
return undefined;
|
|
159
|
+
// The collection owning this move: the one whose items array holds
|
|
160
|
+
// the item (multiple collections may bind the same name handler;
|
|
161
|
+
// the first holding the item wins).
|
|
162
|
+
// SAFETY: the walk reads only structural record fields (children/items/
|
|
163
|
+
// type names) the wire Tree and the loose JsonObject view agree on;
|
|
164
|
+
// every field read below is typeof-guarded.
|
|
165
|
+
for (const node of walkNodes(tree.root)) {
|
|
166
|
+
if (node.type !== "collection")
|
|
167
|
+
continue;
|
|
168
|
+
const itemsPath = node.items;
|
|
169
|
+
if (typeof itemsPath !== "object" || itemsPath === null)
|
|
170
|
+
continue;
|
|
171
|
+
const pointer = itemsPath.$;
|
|
172
|
+
if (typeof pointer !== "string")
|
|
173
|
+
continue;
|
|
174
|
+
const items = resolvePointer(data, pointer);
|
|
175
|
+
if (!Array.isArray(items))
|
|
176
|
+
continue;
|
|
177
|
+
const at = items.findIndex((candidate) => isRec(candidate) && candidate.id === itemId);
|
|
178
|
+
if (at < 0)
|
|
179
|
+
continue;
|
|
180
|
+
const item = items[at];
|
|
181
|
+
const card = isRec(node.item) ? node.item : undefined;
|
|
182
|
+
const title = card === undefined
|
|
183
|
+
? undefined
|
|
184
|
+
: bound(card.title, data, item);
|
|
185
|
+
if (title === undefined)
|
|
186
|
+
continue;
|
|
187
|
+
// SAFETY: the walk narrowed `node` to a collection record with an
|
|
188
|
+
// items binding and an item template; the remaining props are read
|
|
189
|
+
// only through the declared-block type's own names.
|
|
190
|
+
const collection = node;
|
|
191
|
+
// A grouped collection labels by the GROUP's title (the label the
|
|
192
|
+
// user saw over the row); an ungrouped one by the section's.
|
|
193
|
+
const scope = collection.groupBy === undefined
|
|
194
|
+
? (sectionTitleAround(tree, node, data) ?? "")
|
|
195
|
+
: groupTitleOf(collection, to.group ?? from.group ?? "", data);
|
|
196
|
+
const where = scope === "" ? "" : ` in '${scope}'`;
|
|
197
|
+
// The `from` position names the item's located (current) index —
|
|
198
|
+
// the payload's from.index is only a hint: an interleaved edit
|
|
199
|
+
// may have moved the row before this action's commit ran, and
|
|
200
|
+
// the summary must name the positions at APPLY time. A grouped
|
|
201
|
+
// collection renders WITHIN-GROUP positions (the client's
|
|
202
|
+
// to.index is the drop index inside the lane), so the "from"
|
|
203
|
+
// ordinal counts only the item's own lane members — the raw
|
|
204
|
+
// array index would name a position the user never saw.
|
|
205
|
+
let fromIndex = at;
|
|
206
|
+
const groupBy = collection.groupBy;
|
|
207
|
+
if (groupBy !== undefined && isRec(item)) {
|
|
208
|
+
const groupValue = item[groupBy];
|
|
209
|
+
let before = 0;
|
|
210
|
+
for (let earlier = 0; earlier < at; earlier++) {
|
|
211
|
+
const candidate = items[earlier];
|
|
212
|
+
if (isRec(candidate) && candidate[groupBy] === groupValue) {
|
|
213
|
+
before += 1;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
fromIndex = before;
|
|
217
|
+
}
|
|
218
|
+
return `moved '${title}' from ${ordinal(fromIndex)} to ${ordinal(to.index ?? fromIndex)}${where}`;
|
|
219
|
+
}
|
|
220
|
+
return undefined;
|
|
221
|
+
}
|
|
222
|
+
/** The check summary: `checked 'milk' in 'Shopping'`. */
|
|
223
|
+
function checkSummary(tree, data, payload) {
|
|
224
|
+
const itemId = payload.itemId;
|
|
225
|
+
if (typeof itemId !== "string")
|
|
226
|
+
return undefined;
|
|
227
|
+
// SAFETY: the walk reads only structural record fields the wire Tree
|
|
228
|
+
// and the loose JsonObject view agree on; every read is guarded.
|
|
229
|
+
for (const node of walkNodes(tree.root)) {
|
|
230
|
+
if (node.type !== "collection" || typeof node.checkable !== "string")
|
|
231
|
+
continue;
|
|
232
|
+
const itemsPath = node.items;
|
|
233
|
+
if (typeof itemsPath !== "object" || itemsPath === null)
|
|
234
|
+
continue;
|
|
235
|
+
const pointer = itemsPath.$;
|
|
236
|
+
if (typeof pointer !== "string")
|
|
237
|
+
continue;
|
|
238
|
+
const items = resolvePointer(data, pointer);
|
|
239
|
+
if (!Array.isArray(items))
|
|
240
|
+
continue;
|
|
241
|
+
const at = items.findIndex((candidate) => isRec(candidate) && candidate.id === itemId);
|
|
242
|
+
if (at < 0)
|
|
243
|
+
continue;
|
|
244
|
+
const item = items[at];
|
|
245
|
+
const card = isRec(node.item) ? node.item : undefined;
|
|
246
|
+
const title = card === undefined
|
|
247
|
+
? undefined
|
|
248
|
+
: bound(card.title, data, item);
|
|
249
|
+
if (title === undefined)
|
|
250
|
+
continue;
|
|
251
|
+
const scope = sectionTitleAround(tree, node, data) ?? "";
|
|
252
|
+
const where = scope === "" ? "" : ` in '${scope}'`;
|
|
253
|
+
// `value` omitted means the plain toggle-on the client sends for a
|
|
254
|
+
// check (planAction defaults true): the verb must agree with the
|
|
255
|
+
// written value.
|
|
256
|
+
const verb = payload.value === false ? "unchecked" : "checked";
|
|
257
|
+
return `${verb} '${title}'${where}`;
|
|
258
|
+
}
|
|
259
|
+
return undefined;
|
|
260
|
+
}
|
|
261
|
+
/** A form field's declared label (a binding resolves against `data`). */
|
|
262
|
+
function fieldLabel(field, data) {
|
|
263
|
+
const label = bound(field.label, data, undefined);
|
|
264
|
+
return label ?? field.name;
|
|
265
|
+
}
|
|
266
|
+
/** The submit summary: one `set 'Label' to 'value'` per field, `"; "`-joined. */
|
|
267
|
+
function submitSummary(tree, data, payload) {
|
|
268
|
+
const values = payload.values;
|
|
269
|
+
if (!isRec(values))
|
|
270
|
+
return undefined;
|
|
271
|
+
const parts = [];
|
|
272
|
+
// SAFETY: the walk reads only structural record fields the wire Tree
|
|
273
|
+
// and the loose JsonObject view agree on; every read is guarded.
|
|
274
|
+
for (const node of walkNodes(tree.root)) {
|
|
275
|
+
if (node.type !== "form" || !Array.isArray(node.children))
|
|
276
|
+
continue;
|
|
277
|
+
const fields = [];
|
|
278
|
+
for (const child of node.children) {
|
|
279
|
+
if (!isRec(child))
|
|
280
|
+
continue;
|
|
281
|
+
if (typeof child.name === "string" && "label" in child) {
|
|
282
|
+
// SAFETY: the record passed the field shape check (a `name` +
|
|
283
|
+
// a `label` member); Field's own members are read only through
|
|
284
|
+
// `fieldLabel`'s guarded accessors.
|
|
285
|
+
fields.push(child);
|
|
286
|
+
}
|
|
287
|
+
else if (child.type === "section" && Array.isArray(child.children)) {
|
|
288
|
+
for (const grandchild of child.children) {
|
|
289
|
+
if (isRec(grandchild) && typeof grandchild.name === "string") {
|
|
290
|
+
// SAFETY: same field-shape check as above.
|
|
291
|
+
fields.push(grandchild);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
// Only the form(s) whose declared fields appear in the payload.
|
|
297
|
+
const named = fields.filter((field) => field.name in values);
|
|
298
|
+
if (named.length === 0)
|
|
299
|
+
continue;
|
|
300
|
+
for (const field of named) {
|
|
301
|
+
const value = values[field.name];
|
|
302
|
+
parts.push(`set '${fieldLabel(field, data)}' to '${renderValue(value ?? null)}'`);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
if (parts.length === 0)
|
|
306
|
+
return undefined;
|
|
307
|
+
return parts.join("; ");
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* The summary for one action (spec §8.3). `data` is the surface's data
|
|
311
|
+
* BEFORE the edit applies — the positions and titles the user saw.
|
|
312
|
+
*/
|
|
313
|
+
export function summarizeAction(tree, data, action, payload) {
|
|
314
|
+
const record = isRec(payload) ? payload : {};
|
|
315
|
+
const fallback = "name" in action && typeof action.name === "string"
|
|
316
|
+
? `${action.type} ${action.name}`
|
|
317
|
+
: action.type;
|
|
318
|
+
if (action.type === "move") {
|
|
319
|
+
return moveSummary(tree, data, record) ?? fallback;
|
|
320
|
+
}
|
|
321
|
+
if (action.type === "edit") {
|
|
322
|
+
return checkSummary(tree, data, record) ?? fallback;
|
|
323
|
+
}
|
|
324
|
+
if (action.type === "submit") {
|
|
325
|
+
return submitSummary(tree, data, record) ?? fallback;
|
|
326
|
+
}
|
|
327
|
+
return fallback;
|
|
328
|
+
}
|
package/dist/server/commands.js
CHANGED
|
@@ -101,6 +101,11 @@ export const SLASH_COMMANDS = new Map([
|
|
|
101
101
|
description: "List jobs (name, next run, last outcome)",
|
|
102
102
|
endpoint: { method: "GET", path: "/v1/jobs" },
|
|
103
103
|
},
|
|
104
|
+
{
|
|
105
|
+
name: "settings",
|
|
106
|
+
description: "Open the settings panel (sections + search)",
|
|
107
|
+
endpoint: { method: "GET", path: "/v1/settings/sections" },
|
|
108
|
+
},
|
|
104
109
|
].map((command) => [command.name, command]));
|
|
105
110
|
/**
|
|
106
111
|
* Merge the core slash-command table with every extension's `commands`
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Resolve parts against a lazily-available env: text parts never touch it. */
|
|
2
|
+
import { resolveParts, } from "./content-parts.js";
|
|
3
|
+
/**
|
|
4
|
+
* The lazy-env resolve: text parts (the whole answer-projection case)
|
|
5
|
+
* resolve without the env; the first file/image part awaits it. A sandbox
|
|
6
|
+
* host that cannot build the env therefore still admits answers.
|
|
7
|
+
*/
|
|
8
|
+
export async function resolvePartsLazy(parts, envPromise, context) {
|
|
9
|
+
if (parts.every((part) => part.type === "text")) {
|
|
10
|
+
return resolveParts(parts, {}, context);
|
|
11
|
+
}
|
|
12
|
+
return resolveParts(parts, await envPromise, context);
|
|
13
|
+
}
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
*/
|
|
23
23
|
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
|
24
24
|
import { accessOf } from "@sigrun/shared/access";
|
|
25
|
-
import {
|
|
25
|
+
import { accessPrincipalOf } from "./auth/access-principal.js";
|
|
26
26
|
import { conversationId } from "./helpers.js";
|
|
27
27
|
import { SessionMetaDoc } from "./session-meta.js";
|
|
28
28
|
/**
|
|
@@ -43,10 +43,7 @@ export function loadConversation(ctx, need = "read") {
|
|
|
43
43
|
.send({ error: "conversation_not_found" });
|
|
44
44
|
}
|
|
45
45
|
const meta = await ctx.opened.harness.snapshot(SessionMetaDoc, conversation.id, BACKGROUND_CONTEXT);
|
|
46
|
-
const access = accessOf({
|
|
47
|
-
user: request.principal.user,
|
|
48
|
-
superuser: isSuperuser(request.principal),
|
|
49
|
-
}, meta ?? {});
|
|
46
|
+
const access = accessOf(accessPrincipalOf(request.principal, ctx.opened.identity.users), meta ?? {});
|
|
50
47
|
if (access === "none") {
|
|
51
48
|
// Hidden is 404 on every :id route (spec §5.9).
|
|
52
49
|
return await reply
|
|
@@ -16,14 +16,17 @@
|
|
|
16
16
|
* caller) to map — the same split the inline route bodies had.
|
|
17
17
|
*/
|
|
18
18
|
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
|
19
|
+
import { AgentDoc, configure } from "@earendil-works/pi-durable";
|
|
19
20
|
import { accessOf, isSharing, } from "@sigrun/shared/access";
|
|
20
21
|
import { extname } from "node:path";
|
|
21
22
|
import { touchActivity } from "../harness/activity.js";
|
|
22
23
|
import { resolveServedModelRefreshing } from "../harness/providers/refresh.js";
|
|
23
24
|
import { isSuperuser } from "./auth/principal.js";
|
|
24
|
-
import { isConfinedPath, parseSubmissionInput
|
|
25
|
+
import { isConfinedPath, parseSubmissionInput } from "./content-parts.js";
|
|
26
|
+
import { resolvePartsLazy } from "./content-parts-lazy.js";
|
|
25
27
|
import { CommandUsageError, expandInput, EXPANDED_ENTRY_KIND, } from "../commands/expand.js";
|
|
26
28
|
import { conversationEnv, readView, SCAN_PAGE_SIZE, storeAgentChange, THINKING_LEVELS, WHEN_BUSY_MODES, } from "./helpers.js";
|
|
29
|
+
import { writePendingNote } from "./blocks/notes.js";
|
|
27
30
|
import { applySessionMetaPatch, DEFAULT_SESSION_META, SessionMetaDoc, validateSessionMetaPatch, } from "./session-meta.js";
|
|
28
31
|
/**
|
|
29
32
|
* One client error from a conversation op: the exact body the routes
|
|
@@ -165,6 +168,15 @@ export async function createConversationOp(ctx, input = {}) {
|
|
|
165
168
|
});
|
|
166
169
|
}
|
|
167
170
|
}
|
|
171
|
+
for (const name of input.agent.extensions ?? []) {
|
|
172
|
+
const at = name.indexOf("@");
|
|
173
|
+
if (at > 0 && name.slice(at + 1) !== input.owner) {
|
|
174
|
+
throw new ConversationOpError({
|
|
175
|
+
error: "invalid_field",
|
|
176
|
+
field: "agent.extensions",
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
}
|
|
168
180
|
}
|
|
169
181
|
const selection = input.agent === undefined
|
|
170
182
|
? undefined
|
|
@@ -198,13 +210,16 @@ export async function createConversationOp(ctx, input = {}) {
|
|
|
198
210
|
if (input.owner !== undefined)
|
|
199
211
|
draft.owner = input.owner;
|
|
200
212
|
// Copy-on-assignment (the tracker requires alias-free roots):
|
|
201
|
-
// the people-scope
|
|
213
|
+
// the people-scope arrays are cloned; the closed scopes spread.
|
|
202
214
|
if (input.sharing !== undefined) {
|
|
203
215
|
draft.sharing =
|
|
204
216
|
input.sharing.scope === "people"
|
|
205
217
|
? {
|
|
206
218
|
scope: "people",
|
|
207
219
|
users: [...input.sharing.users],
|
|
220
|
+
...(input.sharing.groups === undefined
|
|
221
|
+
? {}
|
|
222
|
+
: { groups: [...input.sharing.groups] }),
|
|
208
223
|
level: input.sharing.level,
|
|
209
224
|
}
|
|
210
225
|
: { ...input.sharing };
|
|
@@ -213,6 +228,7 @@ export async function createConversationOp(ctx, input = {}) {
|
|
|
213
228
|
draft.name = input.name;
|
|
214
229
|
if (input.source !== undefined)
|
|
215
230
|
draft.source = input.source;
|
|
231
|
+
await opened.rewriteUserSelection(tx, conversation.id, undefined, input.owner);
|
|
216
232
|
}, BACKGROUND_CONTEXT);
|
|
217
233
|
const view = await readView(conversation);
|
|
218
234
|
return { id: conversation.id, view };
|
|
@@ -264,6 +280,11 @@ export async function submitOp(ctx, conversation, input, opts = {}) {
|
|
|
264
280
|
(typeof whenBusy !== "string" || !WHEN_BUSY_MODES.includes(whenBusy))) {
|
|
265
281
|
throw new ConversationOpError({ error: "invalid_field", field: "whenBusy" });
|
|
266
282
|
}
|
|
283
|
+
// The §8.3 change note precedes EVERY turn admission (the note write
|
|
284
|
+
// precedes `expandInput`: an expansion failure — `invalid_input` —
|
|
285
|
+
// never strands unnoted edits without at least the note decision
|
|
286
|
+
// point). A block answer's surface is excluded (§8.4 folded it).
|
|
287
|
+
await writePendingNote(opened.harness, conversation, opts.skipNoteFor, BACKGROUND_CONTEXT);
|
|
267
288
|
// Resolve path-parts to inline content NOW, against the live
|
|
268
289
|
// workspace: missing paths become explicit error parts; other
|
|
269
290
|
// workspace failures throw — fail-loud. Command expansion (Task 2c)
|
|
@@ -277,36 +298,67 @@ export async function submitOp(ctx, conversation, input, opts = {}) {
|
|
|
277
298
|
// ordinary user input: it maps to the same `invalid_input` 400 shape
|
|
278
299
|
// `parseSubmissionInput` uses, never a 500. Anything else expansion
|
|
279
300
|
// throws is a genuine bug and still falls through as one.
|
|
280
|
-
|
|
301
|
+
// The workspace env resolves lazily (a promise, awaited only by
|
|
302
|
+
// resolvePartsLazy's file/image parts): an answer submission (opts.entry)
|
|
303
|
+
// never needs the filesystem, and on a host where the sandbox helper is
|
|
304
|
+
// unavailable the env would throw — the answer must not depend on it.
|
|
305
|
+
// A THUNK, not an eager promise: conversationEnv throws (not rejects)
|
|
306
|
+
// on hosts where the sandbox helper is unavailable, and an eager
|
|
307
|
+
// creation would fail the ANSWER path too (opts.entry never needs the
|
|
308
|
+
// filesystem). The thunk is invoked only by the expansion branch.
|
|
309
|
+
const envThunk = () => conversationEnv(opened, conversation);
|
|
281
310
|
let expansion;
|
|
282
|
-
|
|
283
|
-
|
|
311
|
+
if (opts.entry !== undefined) {
|
|
312
|
+
// A server-built entry (a block answer) skips expansion entirely:
|
|
313
|
+
// its content is a projection that never starts with `/`.
|
|
314
|
+
expansion = { parts: parsed.parts };
|
|
315
|
+
}
|
|
316
|
+
else {
|
|
317
|
+
try {
|
|
318
|
+
const env = await envThunk();
|
|
319
|
+
expansion = await expandInput(parsed.parts, opened.commandExpansion, env, BACKGROUND_CONTEXT);
|
|
320
|
+
}
|
|
321
|
+
catch (error) {
|
|
322
|
+
if (error instanceof CommandUsageError) {
|
|
323
|
+
throw new ConversationOpError({ error: "invalid_input" });
|
|
324
|
+
}
|
|
325
|
+
throw error;
|
|
326
|
+
}
|
|
284
327
|
}
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
328
|
+
// The entry disjunction (the brief's enforced invariant): `opts.entry`
|
|
329
|
+
// and expansion are mutually exclusive BY CONSTRUCTION — the answer's
|
|
330
|
+
// content is a server-built projection that never starts with `/`. A
|
|
331
|
+
// state where both would be set (an entry-carrying call whose input
|
|
332
|
+
// expansion claims) is a programming error, never a silent overwrite,
|
|
333
|
+
// and throws BEFORE any submission is admitted.
|
|
334
|
+
if (opts.entry !== undefined) {
|
|
335
|
+
const first = parsed.parts[0];
|
|
336
|
+
if (first !== undefined &&
|
|
337
|
+
first.type === "text" &&
|
|
338
|
+
first.text.startsWith("/")) {
|
|
339
|
+
throw new Error("submitOp: opts.entry with an expandable input is a programming error (entry and the expanded entry are mutually exclusive)");
|
|
288
340
|
}
|
|
289
|
-
throw error;
|
|
290
341
|
}
|
|
291
|
-
const content = await
|
|
342
|
+
const content = await resolvePartsLazy(expansion.parts, envThunk(), BACKGROUND_CONTEXT);
|
|
343
|
+
const entry = opts.entry !== undefined
|
|
344
|
+
? opts.entry
|
|
345
|
+
: expansion.typed === undefined
|
|
346
|
+
? undefined
|
|
347
|
+
: {
|
|
348
|
+
kind: EXPANDED_ENTRY_KIND,
|
|
349
|
+
data: {
|
|
350
|
+
typed: expansion.typed,
|
|
351
|
+
...(expansion.command === undefined
|
|
352
|
+
? {}
|
|
353
|
+
: { command: expansion.command }),
|
|
354
|
+
},
|
|
355
|
+
};
|
|
292
356
|
const draft = {
|
|
293
357
|
type: "input",
|
|
294
358
|
content,
|
|
295
359
|
...(opts.requestId === undefined ? {} : { requestId: opts.requestId }),
|
|
296
360
|
...(whenBusy === undefined ? {} : { whenBusy: whenBusy }),
|
|
297
|
-
...(
|
|
298
|
-
? {}
|
|
299
|
-
: {
|
|
300
|
-
entry: {
|
|
301
|
-
kind: EXPANDED_ENTRY_KIND,
|
|
302
|
-
data: {
|
|
303
|
-
typed: expansion.typed,
|
|
304
|
-
...(expansion.command === undefined
|
|
305
|
-
? {}
|
|
306
|
-
: { command: expansion.command }),
|
|
307
|
-
},
|
|
308
|
-
},
|
|
309
|
-
}),
|
|
361
|
+
...(entry === undefined ? {} : { entry }),
|
|
310
362
|
};
|
|
311
363
|
// The submit pass-through is retired (§6.7): the memory seam's
|
|
312
364
|
// runSettled owns capture, so submissions admit directly.
|
|
@@ -419,7 +471,7 @@ export async function configureOp(ctx, conversation, body) {
|
|
|
419
471
|
throw new ConversationOpError({ error: "invalid_body" });
|
|
420
472
|
}
|
|
421
473
|
const record = body;
|
|
422
|
-
const unknownKey = Object.keys(record).find((key) => key !== "model" && key !== "thinking");
|
|
474
|
+
const unknownKey = Object.keys(record).find((key) => key !== "model" && key !== "thinking" && key !== "extensions");
|
|
423
475
|
if (unknownKey !== undefined) {
|
|
424
476
|
throw new ConversationOpError({
|
|
425
477
|
error: "invalid_field",
|
|
@@ -428,7 +480,21 @@ export async function configureOp(ctx, conversation, body) {
|
|
|
428
480
|
}
|
|
429
481
|
const model = record.model;
|
|
430
482
|
const thinking = record.thinking;
|
|
431
|
-
|
|
483
|
+
const extensions = record.extensions;
|
|
484
|
+
if (extensions !== undefined) {
|
|
485
|
+
if (!Array.isArray(extensions) ||
|
|
486
|
+
extensions.some((name) => typeof name !== "string")) {
|
|
487
|
+
throw new ConversationOpError({ error: "invalid_field", field: "extensions" });
|
|
488
|
+
}
|
|
489
|
+
const owner = (await opened.harness.snapshot(SessionMetaDoc, conversation.id, BACKGROUND_CONTEXT))?.owner;
|
|
490
|
+
for (const name of extensions) {
|
|
491
|
+
const at = name.indexOf("@");
|
|
492
|
+
if (at > 0 && name.slice(at + 1) !== owner) {
|
|
493
|
+
throw new ConversationOpError({ error: "invalid_field", field: "extensions" });
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
if (model === undefined && thinking === undefined && extensions === undefined) {
|
|
432
498
|
// A configure that changes nothing is a client error, not a no-op.
|
|
433
499
|
throw new ConversationOpError({ error: "invalid_body" });
|
|
434
500
|
}
|
|
@@ -455,6 +521,13 @@ export async function configureOp(ctx, conversation, body) {
|
|
|
455
521
|
const storeThinking = storeThinkingFor(opened, parsedModel);
|
|
456
522
|
await conversation.configure({
|
|
457
523
|
...(parsedModel === undefined ? {} : { model: parsedModel }),
|
|
524
|
+
...(extensions === undefined
|
|
525
|
+
? {}
|
|
526
|
+
: {
|
|
527
|
+
extensions: extensions.map((name) => ({
|
|
528
|
+
name,
|
|
529
|
+
})),
|
|
530
|
+
}),
|
|
458
531
|
...(thinking !== undefined
|
|
459
532
|
? { thinkingLevel: thinking }
|
|
460
533
|
: storeThinking === undefined
|
|
@@ -481,10 +554,9 @@ function storeThinkingFor(opened, model) {
|
|
|
481
554
|
* run inputs and queued inbox items (all conversations); filtered to
|
|
482
555
|
* this conversation, the LOWEST id is the active one — anything queued
|
|
483
556
|
* behind a busy conversation was admitted later and has a higher id.
|
|
484
|
-
* Nothing active is the 404 `no_active_submission
|
|
485
|
-
*
|
|
486
|
-
*
|
|
487
|
-
* the submission was found in (abort is idempotent).
|
|
557
|
+
* Nothing active is the 404 `no_active_submission`. The 200 result
|
|
558
|
+
* reports the pre-abort state: queued input withdraws as `aborted`,
|
|
559
|
+
* while a placed run reports `already_placed` and is genuinely stopped.
|
|
488
560
|
*/
|
|
489
561
|
export async function abortOp(ctx, conversation) {
|
|
490
562
|
const { opened } = ctx;
|
|
@@ -499,13 +571,15 @@ export async function abortOp(ctx, conversation) {
|
|
|
499
571
|
if (active === undefined) {
|
|
500
572
|
throw new ConversationOpError({ error: "no_active_submission" }, 404);
|
|
501
573
|
}
|
|
502
|
-
|
|
503
|
-
//
|
|
504
|
-
//
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
574
|
+
// inspect() returns only queued/placed records, so those are the only
|
|
575
|
+
// reachable result values. AbortResponse still permits "settled" for
|
|
576
|
+
// compatibility, but this op cannot honestly derive it from inspect.
|
|
577
|
+
const result = active.status === "queued" ? "aborted" : "already_placed";
|
|
578
|
+
// The old abortSubmission-only path WITHDREW queued inputs but could
|
|
579
|
+
// not stop a placed generation. Conversation.abort subsumes both: it
|
|
580
|
+
// withdraws queued inputs, marks and signals every live non-background
|
|
581
|
+
// task, then resolves once the conversation scope is idle (C4 /stop).
|
|
582
|
+
await conversation.abort(BACKGROUND_CONTEXT);
|
|
509
583
|
return { result, submissionId: active.id };
|
|
510
584
|
}
|
|
511
585
|
/**
|
|
@@ -577,9 +651,30 @@ export async function forkOp(ctx, conversation, opts = {}) {
|
|
|
577
651
|
// The fork inherits the parent's agent model — read the resolved
|
|
578
652
|
// agent, fall back to the boot default like create.
|
|
579
653
|
const agent = await conversation.agent(BACKGROUND_CONTEXT);
|
|
654
|
+
const inheritedSelection = (await opened.harness.snapshot(AgentDoc, conversation.id, BACKGROUND_CONTEXT))?.extensions;
|
|
655
|
+
// Preserve the stored selection VERBATIM here. `AgentChange` wants
|
|
656
|
+
// Extension-shaped names and maps them back to strings; looking names
|
|
657
|
+
// up in the registry would silently drop unresolved/late-install names.
|
|
658
|
+
const inheritedExtensions = Array.isArray(inheritedSelection)
|
|
659
|
+
? inheritedSelection.map((name) => ({ name }))
|
|
660
|
+
: inheritedSelection === undefined
|
|
661
|
+
? undefined
|
|
662
|
+
: {
|
|
663
|
+
...(inheritedSelection.add === undefined
|
|
664
|
+
? {}
|
|
665
|
+
: { add: inheritedSelection.add.map((name) => ({ name })) }),
|
|
666
|
+
...(inheritedSelection.remove === undefined
|
|
667
|
+
? {}
|
|
668
|
+
: { remove: inheritedSelection.remove.map((name) => ({ name })) }),
|
|
669
|
+
};
|
|
580
670
|
const fork = await conversation.fork(at, {
|
|
581
671
|
ownership: { kind: "ownerless" },
|
|
582
|
-
agent: {
|
|
672
|
+
agent: {
|
|
673
|
+
model: agent.model ?? opened.defaultModel,
|
|
674
|
+
...(inheritedExtensions === undefined
|
|
675
|
+
? {}
|
|
676
|
+
: { extensions: inheritedExtensions }),
|
|
677
|
+
},
|
|
583
678
|
}, BACKGROUND_CONTEXT);
|
|
584
679
|
// The fork's sidebar identity: the copied meta doc carries the
|
|
585
680
|
// source's name/autoName and the namer's attempt count; ONE commit
|
|
@@ -596,6 +691,11 @@ export async function forkOp(ctx, conversation, opts = {}) {
|
|
|
596
691
|
const sourceMeta = await opened.harness.snapshot(SessionMetaDoc, conversation.id, BACKGROUND_CONTEXT);
|
|
597
692
|
const sourceName = sourceMeta?.name ?? sourceMeta?.autoName;
|
|
598
693
|
await fork.commit(async (tx) => {
|
|
694
|
+
// A relative source cwd belongs to the source owner's workspace.
|
|
695
|
+
// Do not let a fork by someone else silently retarget it into theirs.
|
|
696
|
+
if (sourceMeta?.owner !== opts.owner) {
|
|
697
|
+
await configure(tx, fork.id, { cwd: null });
|
|
698
|
+
}
|
|
599
699
|
const draft = await tx.doc(SessionMetaDoc, fork.id);
|
|
600
700
|
delete draft.name;
|
|
601
701
|
delete draft.autoName;
|
|
@@ -608,6 +708,7 @@ export async function forkOp(ctx, conversation, opts = {}) {
|
|
|
608
708
|
if (sourceName !== undefined)
|
|
609
709
|
draft.autoName = `${sourceName} (fork)`;
|
|
610
710
|
draft.lastActivityAt = opened.now().toISOString();
|
|
711
|
+
await opened.rewriteUserSelection(tx, fork.id, sourceMeta?.owner, opts.owner);
|
|
611
712
|
}, BACKGROUND_CONTEXT);
|
|
612
713
|
const view = await readView(fork);
|
|
613
714
|
return { id: fork.id, view };
|