@sigrun/sigrun 0.7.0 → 0.9.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 +78 -59
- package/README.md +32 -0
- package/dist/config.js +47 -0
- package/dist/curation/host.js +125 -21
- package/dist/curation/runs.js +15 -4
- package/dist/extension.d.ts +331 -4
- package/dist/harness/boot.js +114 -3
- package/dist/harness/complete.js +127 -0
- package/dist/harness/decision-tool.js +93 -0
- package/dist/harness/decisions.js +477 -0
- package/dist/harness/event-bus.js +12 -0
- package/dist/harness/interactions.js +81 -0
- package/dist/harness/interactive-tools.js +97 -0
- package/dist/harness/loader.js +285 -1
- package/dist/harness/packages.js +7 -0
- package/dist/harness/published-hooks.js +208 -0
- package/dist/harness/seam-host.js +149 -1
- package/dist/harness/settings.js +41 -0
- package/dist/history/git.js +6 -2
- package/dist/jobs/fire.js +10 -0
- package/dist/jobs/observe.js +27 -2
- package/dist/notices/events.js +8 -0
- package/dist/notices/service.js +297 -0
- package/dist/notices/store.js +331 -0
- package/dist/notices/validate.js +311 -0
- package/dist/server/conversation-ops.js +50 -0
- package/dist/server/core-views/skills-tree.js +3 -12
- package/dist/server/core-views/skills.js +11 -23
- package/dist/server/host-bridge.js +62 -1
- package/dist/server/index.js +26 -0
- package/dist/server/routes/events.js +20 -0
- package/dist/server/routes/extensions.js +5 -0
- package/dist/server/routes/interactions.js +48 -1
- package/dist/server/routes/meta.js +17 -0
- package/dist/server/routes/notices.js +94 -0
- package/dist/server/routes/providers.js +65 -8
- package/dist/server/routes/submissions.js +3 -0
- package/dist/server/routes/users.js +4 -0
- package/dist/server/surfaces/dedup.js +6 -0
- package/dist/server/visibility.js +30 -0
- package/dist/settings/registry.js +18 -0
- package/node_modules/@sigrun/shared/CHANGELOG.md +12 -0
- package/node_modules/@sigrun/shared/dist/decisions.d.ts +95 -0
- package/node_modules/@sigrun/shared/dist/decisions.js +16 -0
- package/node_modules/@sigrun/shared/dist/interactions.d.ts +14 -0
- package/node_modules/@sigrun/shared/dist/notices.d.ts +112 -0
- package/node_modules/@sigrun/shared/dist/notices.js +49 -0
- package/node_modules/@sigrun/shared/dist/settings.d.ts +32 -0
- package/node_modules/@sigrun/shared/dist/views.d.ts +63 -0
- package/node_modules/@sigrun/shared/package.json +9 -1
- package/package.json +7 -7
- package/webui/dist/assets/index-D5KowS7k.css +1 -0
- package/webui/dist/assets/index-DGIrLC6F.js +55 -0
- package/webui/dist/index.html +2 -2
- package/webui/dist/assets/index-BI5k-Oci.js +0 -55
- package/webui/dist/assets/index-EGluyRXk.css +0 -1
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Notice input validation (notices spec §1): shape, the `NOTICE_LIMITS`
|
|
3
|
+
* bounds, the key and action-id grammars, https-only `open`, duplicate
|
|
4
|
+
* action ids, and — on the post path only — that every `extension`
|
|
5
|
+
* action names a handler the posting extension declared.
|
|
6
|
+
*
|
|
7
|
+
* Two entry points, because a STORED notice outlives its extension:
|
|
8
|
+
* `validateNoticeShape` is what the store re-validates a loaded entry
|
|
9
|
+
* with (spec §3: a notice whose extension is disabled or unloaded stays
|
|
10
|
+
* dismissible), while `validateNoticeInput` adds the declared-handler
|
|
11
|
+
* gate a fresh post must pass. Both rebuild the accepted input from the
|
|
12
|
+
* validated fields only, so an unknown key — including a stored
|
|
13
|
+
* `id`/`owner`/`at`, which are the store's own checks — never rides into
|
|
14
|
+
* the record.
|
|
15
|
+
*
|
|
16
|
+
* Every rejection names the offending field (`actions[0].url`); the
|
|
17
|
+
* caller renders `<field>: <message>`, the spec's fail-loud posture.
|
|
18
|
+
* Access checks (does the user exist, can they see the conversation)
|
|
19
|
+
* belong to the service, not here.
|
|
20
|
+
*/
|
|
21
|
+
import { ICON_NAMES, canonicalJson } from "@sigrun/shared/blocks";
|
|
22
|
+
import { NOTICE_ACTION_ID_RE, NOTICE_KEY_RE, NOTICE_LIMITS, } from "@sigrun/shared/notices";
|
|
23
|
+
/**
|
|
24
|
+
* Every `Tone` member as a runtime set. The `Record<Tone, true>` annotation
|
|
25
|
+
* is the drift guard: a member added to the blocks union without a line
|
|
26
|
+
* here fails the typecheck rather than being rejected at post time.
|
|
27
|
+
*/
|
|
28
|
+
const TONES = new Set(Object.keys({
|
|
29
|
+
neutral: true,
|
|
30
|
+
accent: true,
|
|
31
|
+
good: true,
|
|
32
|
+
warn: true,
|
|
33
|
+
bad: true,
|
|
34
|
+
info: true,
|
|
35
|
+
}));
|
|
36
|
+
/** The closed icon set (the block contract's own names). */
|
|
37
|
+
const ICONS = new Set(ICON_NAMES);
|
|
38
|
+
/** The three action kinds (spec §1). */
|
|
39
|
+
const ACTION_KINDS = ["navigate", "open", "extension"];
|
|
40
|
+
/** The core targets a `navigate` action may name (the blocks `CoreTarget`). */
|
|
41
|
+
const TARGET_KEYS = ["conversation", "job", "settings", "view"];
|
|
42
|
+
/**
|
|
43
|
+
* Validate a notice's shape, bounds and grammars — no handler check.
|
|
44
|
+
* Used on load (a stored entry) and as the first half of the post path.
|
|
45
|
+
*/
|
|
46
|
+
export function validateNoticeShape(input) {
|
|
47
|
+
if (!isRecord(input)) {
|
|
48
|
+
return invalid("notice", "a notice must be an object");
|
|
49
|
+
}
|
|
50
|
+
const user = input.user;
|
|
51
|
+
if (typeof user !== "string" || user === "") {
|
|
52
|
+
return invalid("user", "must be a non-empty string");
|
|
53
|
+
}
|
|
54
|
+
const title = input.title;
|
|
55
|
+
if (typeof title !== "string" || title === "") {
|
|
56
|
+
return invalid("title", "must be a non-empty string");
|
|
57
|
+
}
|
|
58
|
+
if (title.length > NOTICE_LIMITS.title) {
|
|
59
|
+
return invalid("title", `longer than ${NOTICE_LIMITS.title} characters`);
|
|
60
|
+
}
|
|
61
|
+
const conversationId = input.conversationId;
|
|
62
|
+
if (conversationId !== undefined) {
|
|
63
|
+
if (typeof conversationId !== "number" || !Number.isInteger(conversationId)) {
|
|
64
|
+
return invalid("conversationId", "must be an integer");
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const key = input.key;
|
|
68
|
+
if (key !== undefined) {
|
|
69
|
+
if (typeof key !== "string" || !NOTICE_KEY_RE.test(key)) {
|
|
70
|
+
return invalid("key", `must match ${NOTICE_KEY_RE.source}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const body = input.body;
|
|
74
|
+
if (body !== undefined) {
|
|
75
|
+
if (typeof body !== "string") {
|
|
76
|
+
return invalid("body", "must be a string");
|
|
77
|
+
}
|
|
78
|
+
if (body.length > NOTICE_LIMITS.body) {
|
|
79
|
+
return invalid("body", `longer than ${NOTICE_LIMITS.body} characters`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const tone = input.tone;
|
|
83
|
+
if (tone !== undefined && !isTone(tone)) {
|
|
84
|
+
return invalid("tone", `must be one of ${[...TONES].join("|")}`);
|
|
85
|
+
}
|
|
86
|
+
const actions = validateActions(input.actions);
|
|
87
|
+
if (!actions.ok)
|
|
88
|
+
return actions;
|
|
89
|
+
return {
|
|
90
|
+
ok: true,
|
|
91
|
+
value: {
|
|
92
|
+
user,
|
|
93
|
+
title,
|
|
94
|
+
...(conversationId === undefined ? {} : { conversationId }),
|
|
95
|
+
...(key === undefined ? {} : { key }),
|
|
96
|
+
...(body === undefined ? {} : { body }),
|
|
97
|
+
...(tone === undefined ? {} : { tone }),
|
|
98
|
+
...(actions.value === undefined ? {} : { actions: actions.value }),
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* `validateNoticeShape` plus the declared-handler gate: every `extension`
|
|
104
|
+
* action must name a handler the posting extension declared (the loader's
|
|
105
|
+
* `noticeActions` keys). Used on post only — a stored notice survives its
|
|
106
|
+
* extension being unloaded, so load re-validation never calls this.
|
|
107
|
+
*/
|
|
108
|
+
export function validateNoticeInput(input, declaredHandlers) {
|
|
109
|
+
const shape = validateNoticeShape(input);
|
|
110
|
+
if (!shape.ok)
|
|
111
|
+
return shape;
|
|
112
|
+
for (const [index, action] of (shape.value.actions ?? []).entries()) {
|
|
113
|
+
if (action.kind !== "extension")
|
|
114
|
+
continue;
|
|
115
|
+
if (!declaredHandlers.has(action.name)) {
|
|
116
|
+
return invalid(`actions[${index}].name`, `no declared handler named ${JSON.stringify(action.name)}`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return shape;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* The action list: the count bound, then each action's own shape (the
|
|
123
|
+
* duplicate-id check rides on the id pass, so the first offending action
|
|
124
|
+
* wins). Undefined = the notice carries no actions.
|
|
125
|
+
*/
|
|
126
|
+
function validateActions(input) {
|
|
127
|
+
if (input === undefined)
|
|
128
|
+
return { ok: true };
|
|
129
|
+
if (!Array.isArray(input)) {
|
|
130
|
+
return invalid("actions", "must be an array");
|
|
131
|
+
}
|
|
132
|
+
if (input.length > NOTICE_LIMITS.actions) {
|
|
133
|
+
return invalid("actions", `more than ${NOTICE_LIMITS.actions} actions`);
|
|
134
|
+
}
|
|
135
|
+
const actions = [];
|
|
136
|
+
const ids = new Set();
|
|
137
|
+
for (let index = 0; index < input.length; index += 1) {
|
|
138
|
+
const action = validateAction(input[index], `actions[${index}]`);
|
|
139
|
+
if (!action.ok)
|
|
140
|
+
return action;
|
|
141
|
+
if (ids.has(action.value.id)) {
|
|
142
|
+
return invalid(`actions[${index}].id`, `duplicate action id ${JSON.stringify(action.value.id)}`);
|
|
143
|
+
}
|
|
144
|
+
ids.add(action.value.id);
|
|
145
|
+
actions.push(action.value);
|
|
146
|
+
}
|
|
147
|
+
return { ok: true, value: actions };
|
|
148
|
+
}
|
|
149
|
+
/** One action: the shared id/label/icon, then its kind's own fields. */
|
|
150
|
+
function validateAction(input, field) {
|
|
151
|
+
if (!isRecord(input)) {
|
|
152
|
+
return invalid(field, "must be an object");
|
|
153
|
+
}
|
|
154
|
+
const id = input.id;
|
|
155
|
+
if (typeof id !== "string" || !NOTICE_ACTION_ID_RE.test(id)) {
|
|
156
|
+
return invalid(`${field}.id`, `must match ${NOTICE_ACTION_ID_RE.source}`);
|
|
157
|
+
}
|
|
158
|
+
const label = input.label;
|
|
159
|
+
if (typeof label !== "string" || label === "") {
|
|
160
|
+
return invalid(`${field}.label`, "must be a non-empty string");
|
|
161
|
+
}
|
|
162
|
+
if (label.length > NOTICE_LIMITS.label) {
|
|
163
|
+
return invalid(`${field}.label`, `longer than ${NOTICE_LIMITS.label} characters`);
|
|
164
|
+
}
|
|
165
|
+
const icon = input.icon;
|
|
166
|
+
if (icon !== undefined && !isIconName(icon)) {
|
|
167
|
+
return invalid(`${field}.icon`, "must be a known icon name");
|
|
168
|
+
}
|
|
169
|
+
const base = { id, label, ...(icon === undefined ? {} : { icon }) };
|
|
170
|
+
const kind = input.kind;
|
|
171
|
+
if (!ACTION_KINDS.some((known) => known === kind)) {
|
|
172
|
+
return invalid(`${field}.kind`, `must be one of ${ACTION_KINDS.join("|")}`);
|
|
173
|
+
}
|
|
174
|
+
if (kind === "navigate") {
|
|
175
|
+
const target = validateTarget(input.target, `${field}.target`);
|
|
176
|
+
if (!target.ok)
|
|
177
|
+
return target;
|
|
178
|
+
return { ok: true, value: { ...base, kind, target: target.value } };
|
|
179
|
+
}
|
|
180
|
+
if (kind === "open") {
|
|
181
|
+
const url = input.url;
|
|
182
|
+
if (typeof url !== "string" || !url.startsWith("https://")) {
|
|
183
|
+
return invalid(`${field}.url`, "must be an https URL");
|
|
184
|
+
}
|
|
185
|
+
if (url.length > NOTICE_LIMITS.url) {
|
|
186
|
+
return invalid(`${field}.url`, `longer than ${NOTICE_LIMITS.url} characters`);
|
|
187
|
+
}
|
|
188
|
+
return { ok: true, value: { ...base, kind, url } };
|
|
189
|
+
}
|
|
190
|
+
const name = input.name;
|
|
191
|
+
if (typeof name !== "string" || name === "") {
|
|
192
|
+
return invalid(`${field}.name`, "must be a non-empty string");
|
|
193
|
+
}
|
|
194
|
+
const payload = input.payload;
|
|
195
|
+
if (payload !== undefined) {
|
|
196
|
+
if (!isJsonObject(payload)) {
|
|
197
|
+
return invalid(`${field}.payload`, "must be a JSON object");
|
|
198
|
+
}
|
|
199
|
+
// The bound is canonical JSON bytes (spec §1): key order never
|
|
200
|
+
// changes whether a payload fits.
|
|
201
|
+
if (Buffer.byteLength(canonicalJson(payload), "utf8") > NOTICE_LIMITS.payloadBytes) {
|
|
202
|
+
return invalid(`${field}.payload`, `larger than ${NOTICE_LIMITS.payloadBytes} bytes`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
const confirm = input.confirm;
|
|
206
|
+
if (confirm !== undefined) {
|
|
207
|
+
if (typeof confirm !== "string") {
|
|
208
|
+
return invalid(`${field}.confirm`, "must be a string");
|
|
209
|
+
}
|
|
210
|
+
if (confirm.length > NOTICE_LIMITS.confirm) {
|
|
211
|
+
return invalid(`${field}.confirm`, `longer than ${NOTICE_LIMITS.confirm} characters`);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return {
|
|
215
|
+
ok: true,
|
|
216
|
+
value: {
|
|
217
|
+
...base,
|
|
218
|
+
kind: "extension",
|
|
219
|
+
name,
|
|
220
|
+
...(payload === undefined ? {} : { payload }),
|
|
221
|
+
...(confirm === undefined ? {} : { confirm }),
|
|
222
|
+
},
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* A `navigate` target: exactly ONE core key, its value shaped as the
|
|
227
|
+
* blocks `CoreTarget` member declares it (a view target carries the
|
|
228
|
+
* extension slug, the view id and optional params).
|
|
229
|
+
*/
|
|
230
|
+
function validateTarget(input, field) {
|
|
231
|
+
if (!isRecord(input)) {
|
|
232
|
+
return invalid(field, "must be a CoreTarget object");
|
|
233
|
+
}
|
|
234
|
+
const present = TARGET_KEYS.filter((key) => input[key] !== undefined);
|
|
235
|
+
if (present.length !== 1) {
|
|
236
|
+
return invalid(field, `must carry exactly one of ${TARGET_KEYS.join("|")}`);
|
|
237
|
+
}
|
|
238
|
+
const key = present[0];
|
|
239
|
+
const raw = input[key];
|
|
240
|
+
if (key === "conversation") {
|
|
241
|
+
if (typeof raw !== "number" || !Number.isInteger(raw)) {
|
|
242
|
+
return invalid(`${field}.conversation`, "must be an integer");
|
|
243
|
+
}
|
|
244
|
+
return { ok: true, value: { conversation: raw } };
|
|
245
|
+
}
|
|
246
|
+
if (key === "job") {
|
|
247
|
+
if (typeof raw !== "string" || raw === "") {
|
|
248
|
+
return invalid(`${field}.job`, "must be a non-empty string");
|
|
249
|
+
}
|
|
250
|
+
return { ok: true, value: { job: raw } };
|
|
251
|
+
}
|
|
252
|
+
if (key === "settings") {
|
|
253
|
+
if (typeof raw !== "string" || raw === "") {
|
|
254
|
+
return invalid(`${field}.settings`, "must be a non-empty string");
|
|
255
|
+
}
|
|
256
|
+
return { ok: true, value: { settings: raw } };
|
|
257
|
+
}
|
|
258
|
+
if (!isRecord(raw)) {
|
|
259
|
+
return invalid(`${field}.view`, "must be an object");
|
|
260
|
+
}
|
|
261
|
+
const extension = raw.extension;
|
|
262
|
+
const id = raw.id;
|
|
263
|
+
if (typeof extension !== "string" || extension === "") {
|
|
264
|
+
return invalid(`${field}.view.extension`, "must be a non-empty string");
|
|
265
|
+
}
|
|
266
|
+
if (typeof id !== "string" || id === "") {
|
|
267
|
+
return invalid(`${field}.view.id`, "must be a non-empty string");
|
|
268
|
+
}
|
|
269
|
+
const params = raw.params;
|
|
270
|
+
if (params !== undefined && !isJsonObject(params)) {
|
|
271
|
+
return invalid(`${field}.view.params`, "must be a JSON object");
|
|
272
|
+
}
|
|
273
|
+
return {
|
|
274
|
+
ok: true,
|
|
275
|
+
value: {
|
|
276
|
+
view: { extension, id, ...(params === undefined ? {} : { params }) },
|
|
277
|
+
},
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
function invalid(field, message) {
|
|
281
|
+
return { ok: false, field, message };
|
|
282
|
+
}
|
|
283
|
+
/** A plain record (arrays and null excluded). */
|
|
284
|
+
function isRecord(value) {
|
|
285
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
286
|
+
}
|
|
287
|
+
/** A member of the blocks `Tone` union. */
|
|
288
|
+
function isTone(value) {
|
|
289
|
+
return typeof value === "string" && TONES.has(value);
|
|
290
|
+
}
|
|
291
|
+
/** A member of the block contract's closed icon set. */
|
|
292
|
+
function isIconName(value) {
|
|
293
|
+
return typeof value === "string" && ICONS.has(value);
|
|
294
|
+
}
|
|
295
|
+
/** A structurally valid JSON object (a `JsonObject`, never an array). */
|
|
296
|
+
function isJsonObject(value) {
|
|
297
|
+
return isRecord(value) && Object.values(value).every(isJsonValue);
|
|
298
|
+
}
|
|
299
|
+
/** A structurally valid JSON value. */
|
|
300
|
+
function isJsonValue(value) {
|
|
301
|
+
if (value === null)
|
|
302
|
+
return true;
|
|
303
|
+
const type = typeof value;
|
|
304
|
+
if (type === "string" || type === "boolean")
|
|
305
|
+
return true;
|
|
306
|
+
if (type === "number")
|
|
307
|
+
return Number.isFinite(value);
|
|
308
|
+
if (Array.isArray(value))
|
|
309
|
+
return value.every(isJsonValue);
|
|
310
|
+
return isRecord(value) && Object.values(value).every(isJsonValue);
|
|
311
|
+
}
|
|
@@ -20,7 +20,9 @@ import { AgentDoc, configure } from "@earendil-works/pi-durable";
|
|
|
20
20
|
import { accessOf, isSharing, } from "@sigrun/shared/access";
|
|
21
21
|
import { extname } from "node:path";
|
|
22
22
|
import { touchActivity } from "../harness/activity.js";
|
|
23
|
+
import { excludeInteractiveTools } from "../harness/interactive-tools.js";
|
|
23
24
|
import { ensureRequestId, recordAuthor, } from "../curation/attribution.js";
|
|
25
|
+
import { QueuedHumanInputsDoc, declinePendingOnMessage, } from "../harness/interactions.js";
|
|
24
26
|
import { resolveServedModelRefreshing } from "../harness/providers/refresh.js";
|
|
25
27
|
import { isSuperuser } from "./auth/principal.js";
|
|
26
28
|
import { isConfinedPath, parseSubmissionInput } from "./content-parts.js";
|
|
@@ -243,6 +245,16 @@ export async function createConversationOp(ctx, input = {}) {
|
|
|
243
245
|
if (input.source !== undefined)
|
|
244
246
|
draft.source = input.source;
|
|
245
247
|
await opened.rewriteUserSelection(tx, conversation.id, undefined, input.owner);
|
|
248
|
+
// Ask-user Task 3 (spec §6): a conversation created FOR a job hides
|
|
249
|
+
// the interactive tools — the creation-time filter edits the stored
|
|
250
|
+
// enabled-tools filter, so the tool is neither offered nor reachable
|
|
251
|
+
// from nested `executeTool()` calls. An explicit `agent.tools`
|
|
252
|
+
// selection naming one loses it the same way. A SECOND commit after
|
|
253
|
+
// `harness.createConversation` is fine: document access is
|
|
254
|
+
// unrestricted here.
|
|
255
|
+
if (input.job !== undefined) {
|
|
256
|
+
await excludeInteractiveTools(tx, conversation.id, opened.interactiveTools);
|
|
257
|
+
}
|
|
246
258
|
}, BACKGROUND_CONTEXT);
|
|
247
259
|
const view = await readView(conversation);
|
|
248
260
|
return { id: conversation.id, view };
|
|
@@ -390,7 +402,45 @@ export async function submitOp(ctx, conversation, input, opts = {}) {
|
|
|
390
402
|
// The submit pass-through is retired (§6.7): the memory seam's
|
|
391
403
|
// runSettled owns capture, so submissions admit directly.
|
|
392
404
|
const submission = await conversation.submit(draft, BACKGROUND_CONTEXT);
|
|
405
|
+
// Ask-user Task 2: a human message (no entry, declinesQuestions: true)
|
|
406
|
+
// declines every pending declinable question and records itself in the
|
|
407
|
+
// queued-inputs ledger when it was admitted while busy. A rejected
|
|
408
|
+
// submit (whenBusy: "reject" while busy) throws above and reaches none
|
|
409
|
+
// of this.
|
|
410
|
+
if (opts.declinesQuestions === true && opts.entry === undefined) {
|
|
411
|
+
const declined = await declinePendingOnMessage(opened.harness, conversation.id, opts.user, Date.now(), BACKGROUND_CONTEXT);
|
|
412
|
+
void declined; // the returned callIds are observational only
|
|
413
|
+
const admitted = await submission.status(BACKGROUND_CONTEXT);
|
|
414
|
+
if (admitted.status === "queued") {
|
|
415
|
+
await opened.harness.commit(async (tx) => {
|
|
416
|
+
const doc = await tx.doc(QueuedHumanInputsDoc, conversation.id);
|
|
417
|
+
const byId = doc.byId;
|
|
418
|
+
byId[String(submission.id)] = {
|
|
419
|
+
...(opts.user !== undefined ? { user: opts.user } : {}),
|
|
420
|
+
};
|
|
421
|
+
}, BACKGROUND_CONTEXT);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
393
424
|
await recordAuthor(conversation, { submissionId: submission.id }, opts.user, BACKGROUND_CONTEXT);
|
|
425
|
+
// The implied clear (notices spec §3.2 rule 2): the user's next prompt
|
|
426
|
+
// in a conversation is them dealing with that conversation's notices,
|
|
427
|
+
// so every one of THEIR notices naming it goes. Attribution is the
|
|
428
|
+
// submit path's own (`opts.user` — the route's principal, the bridge's
|
|
429
|
+
// mapped user, the answer's acting user), exactly like authorship. A
|
|
430
|
+
// job or curator internal run submits through `conversation.submit`
|
|
431
|
+
// directly, never this op, so a scheduled run clears nothing. It sits
|
|
432
|
+
// behind the attribution pair so the notice store's file I/O never
|
|
433
|
+
// widens the window where the live run's input has no `bySubmission`
|
|
434
|
+
// key; a failing clear is logged, never fatal — a notice system must
|
|
435
|
+
// not break a turn.
|
|
436
|
+
if (opts.user !== undefined) {
|
|
437
|
+
try {
|
|
438
|
+
await ctx.notices.clearConversationFor(opts.user, Number(conversation.id));
|
|
439
|
+
}
|
|
440
|
+
catch (error) {
|
|
441
|
+
console.warn(`notices: clearing conversation ${String(conversation.id)} for ${opts.user} failed: ${String(error)}`);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
394
444
|
// Admission stamps activity: the sidebar's activity sort must move the
|
|
395
445
|
// moment a prompt lands — before the run settles.
|
|
396
446
|
await touchActivity(opened.harness, conversation.id, opened.now, BACKGROUND_CONTEXT);
|
|
@@ -346,20 +346,11 @@ function skillActions(params, historyAvailable, deleted) {
|
|
|
346
346
|
}
|
|
347
347
|
return buttons;
|
|
348
348
|
}
|
|
349
|
-
/** The form fields (new skill vs edit: the name is read-only on an
|
|
349
|
+
/** The form fields (new skill vs edit: the name is read-only on an
|
|
350
|
+
* existing skill). A new skill always lands in the workspace: the
|
|
351
|
+
* learned root is the curator's, so the form offers no choice. */
|
|
350
352
|
function formFields(editingExisting) {
|
|
351
|
-
const whereChoice = {
|
|
352
|
-
type: "choice",
|
|
353
|
-
name: "where",
|
|
354
|
-
label: "Where",
|
|
355
|
-
required: true,
|
|
356
|
-
options: [
|
|
357
|
-
{ id: "workspace", label: "Workspace" },
|
|
358
|
-
{ id: "learned", label: "Learned" },
|
|
359
|
-
],
|
|
360
|
-
};
|
|
361
353
|
return [
|
|
362
|
-
...(editingExisting ? [] : [whereChoice]),
|
|
363
354
|
{
|
|
364
355
|
type: "text",
|
|
365
356
|
name: "name",
|
|
@@ -436,7 +436,7 @@ export function createSkillsView(deps) {
|
|
|
436
436
|
/** The form's pre-filled values (a new skill starts empty; an edit reads the current text). */
|
|
437
437
|
async function formDataOf(params, selection) {
|
|
438
438
|
if (params.skill === undefined || selection === undefined) {
|
|
439
|
-
return { name: "", description: "", body: ""
|
|
439
|
+
return { name: "", description: "", body: "" };
|
|
440
440
|
}
|
|
441
441
|
try {
|
|
442
442
|
const text = await readSkillFile(selection.skill.rootDir, selection.skill.name, "SKILL.md");
|
|
@@ -445,7 +445,6 @@ export function createSkillsView(deps) {
|
|
|
445
445
|
name: selection.skill.name,
|
|
446
446
|
description: parsed?.description ?? selection.skill.description,
|
|
447
447
|
body: parsed?.body ?? "",
|
|
448
|
-
where: "workspace",
|
|
449
448
|
};
|
|
450
449
|
}
|
|
451
450
|
catch {
|
|
@@ -453,7 +452,6 @@ export function createSkillsView(deps) {
|
|
|
453
452
|
name: selection.skill.name,
|
|
454
453
|
description: selection.skill.description,
|
|
455
454
|
body: "",
|
|
456
|
-
where: "workspace",
|
|
457
455
|
};
|
|
458
456
|
}
|
|
459
457
|
}
|
|
@@ -722,34 +720,24 @@ export function createSkillsView(deps) {
|
|
|
722
720
|
const user = userOf(ctx);
|
|
723
721
|
if (user === undefined)
|
|
724
722
|
return { ok: false, error: { message: "forbidden" } };
|
|
725
|
-
|
|
723
|
+
// New skills land in the workspace only: the learned root is
|
|
724
|
+
// the curator's (a user edits, pins or deletes learned skills,
|
|
725
|
+
// never creates one).
|
|
726
|
+
const where = fieldOf(payload, "where");
|
|
727
|
+
if (where !== undefined && where !== "workspace") {
|
|
728
|
+
return {
|
|
729
|
+
ok: false,
|
|
730
|
+
error: { message: "new skills go in the workspace; learned skills come from the curator", field: "where" },
|
|
731
|
+
};
|
|
732
|
+
}
|
|
726
733
|
const name = fieldOf(payload, "name");
|
|
727
734
|
const description = fieldOf(payload, "description");
|
|
728
|
-
const body = fieldOf(payload, "body");
|
|
729
735
|
if (name === undefined || !validSkillName(name)) {
|
|
730
736
|
return { ok: false, error: { message: "a valid skill name is required (lowercase letters, digits, dashes)", field: "name" } };
|
|
731
737
|
}
|
|
732
738
|
if (description === undefined || description.trim() === "") {
|
|
733
739
|
return { ok: false, error: { message: "a description is required", field: "description" } };
|
|
734
740
|
}
|
|
735
|
-
if (where === "learned") {
|
|
736
|
-
const curation = deps.curationOf(user);
|
|
737
|
-
if (curation === undefined) {
|
|
738
|
-
return { ok: false, error: { message: "learned skills are unavailable" } };
|
|
739
|
-
}
|
|
740
|
-
try {
|
|
741
|
-
await curation.store.apply(name, { kind: "create", content: skillFileText(name, description.trim(), body ?? "") }, { actor: "user" });
|
|
742
|
-
}
|
|
743
|
-
catch (error) {
|
|
744
|
-
return failure(error);
|
|
745
|
-
}
|
|
746
|
-
deps.bump();
|
|
747
|
-
return {
|
|
748
|
-
ok: true,
|
|
749
|
-
toast: `Created ${name}`,
|
|
750
|
-
navigate: { type: "navigate", params: { skill: name, root: "learned" } },
|
|
751
|
-
};
|
|
752
|
-
}
|
|
753
741
|
return workspaceWrite(ctx, payload, { mode: "create" });
|
|
754
742
|
},
|
|
755
743
|
save: async (ctx, payload) => {
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
|
19
19
|
import { accessOf as accessOfShared } from "@sigrun/shared/access";
|
|
20
|
+
import { completeOnce } from "../harness/complete.js";
|
|
20
21
|
import { effectiveAdmin } from "../harness/identity/groups.js";
|
|
21
22
|
import { userWire } from "./routes/users.js";
|
|
22
23
|
import { accessPrincipalOf } from "./auth/access-principal.js";
|
|
@@ -24,6 +25,7 @@ import { abortOp, compactOp, configureOp, ConversationOpError, createConversatio
|
|
|
24
25
|
import { listModels } from "./helpers.js";
|
|
25
26
|
import { notifyExtension } from "./notifications.js";
|
|
26
27
|
import { SessionMetaDoc } from "./session-meta.js";
|
|
28
|
+
import { isJobRunConversation } from "../harness/interactive-tools.js";
|
|
27
29
|
import { accountLinkWire, serializeConversationView } from "./views.js";
|
|
28
30
|
/**
|
|
29
31
|
* Host-side callers see effective admin derived from stored groups;
|
|
@@ -70,6 +72,9 @@ export function createHostBridge(ctx) {
|
|
|
70
72
|
...(opts?.whenBusy === undefined
|
|
71
73
|
? {}
|
|
72
74
|
: { whenBusy: opts.whenBusy }),
|
|
75
|
+
// Ask-user Task 2: a host submit counts as a human message
|
|
76
|
+
// (declines pending questions) only when it carries a user.
|
|
77
|
+
declinesQuestions: opts?.user !== undefined,
|
|
73
78
|
});
|
|
74
79
|
},
|
|
75
80
|
async watch(id, listener) {
|
|
@@ -193,6 +198,32 @@ export function createHostBridge(ctx) {
|
|
|
193
198
|
state: job.state,
|
|
194
199
|
};
|
|
195
200
|
},
|
|
201
|
+
async activeRun(conversationId) {
|
|
202
|
+
const jobs = await opened.jobs.list();
|
|
203
|
+
let best;
|
|
204
|
+
for (const job of jobs) {
|
|
205
|
+
const run = job.lastRun;
|
|
206
|
+
if (run === undefined)
|
|
207
|
+
continue;
|
|
208
|
+
if (run.outcome !== "running")
|
|
209
|
+
continue;
|
|
210
|
+
if (run.conversationId !== Number(conversationId))
|
|
211
|
+
continue;
|
|
212
|
+
if (best !== undefined && run.startedAt <= best.startedAt)
|
|
213
|
+
continue;
|
|
214
|
+
best = { jobId: job.id, owner: job.owner, startedAt: run.startedAt };
|
|
215
|
+
}
|
|
216
|
+
// startedAt is the tiebreak only — the wire shape is {jobId, owner}.
|
|
217
|
+
return best === undefined ? undefined : { jobId: best.jobId, owner: best.owner };
|
|
218
|
+
},
|
|
219
|
+
async isJobRun(conversationId) {
|
|
220
|
+
// The detection reads COMMITTED durable state (session meta,
|
|
221
|
+
// the live doc, submission records) — it never participates in
|
|
222
|
+
// the caller's transaction, so the bridge's own background
|
|
223
|
+
// context is the honest one (the HandlerContext arg is a
|
|
224
|
+
// cancellation surface only).
|
|
225
|
+
return await isJobRunConversation(opened.harness, conversationId, BACKGROUND_CONTEXT);
|
|
226
|
+
},
|
|
196
227
|
},
|
|
197
228
|
users: {
|
|
198
229
|
// The directory in wire form (emails are admin material; the
|
|
@@ -252,11 +283,32 @@ export function createHostBridge(ctx) {
|
|
|
252
283
|
},
|
|
253
284
|
},
|
|
254
285
|
// Gateway C4b: the model catalog — the SAME source function the
|
|
255
|
-
// `GET /v1/models` route serves (one construction, never two)
|
|
286
|
+
// `GET /v1/models` route serves (one construction, never two) —
|
|
287
|
+
// plus one-shot completions (warden Task 4), read-live settings
|
|
288
|
+
// per call so a `namingModel` change applies without a restart.
|
|
289
|
+
// Warden Task 6: the decision registry — the seam host's
|
|
290
|
+
// `decisions` members delegate here; `slug` is the CALLING
|
|
291
|
+
// extension's identity (read at call time), which the registry
|
|
292
|
+
// stamps as `caller` on the provider's context.
|
|
293
|
+
decisions: {
|
|
294
|
+
async evaluate(slug, request, opts = {}) {
|
|
295
|
+
return opened.decisions.evaluate(slug, request, opts);
|
|
296
|
+
},
|
|
297
|
+
async list() {
|
|
298
|
+
return opened.decisions.list();
|
|
299
|
+
},
|
|
300
|
+
},
|
|
256
301
|
models: {
|
|
257
302
|
async list() {
|
|
258
303
|
return listModels(opened);
|
|
259
304
|
},
|
|
305
|
+
async complete(input) {
|
|
306
|
+
return completeOnce({
|
|
307
|
+
models: opened.models,
|
|
308
|
+
defaultModel: opened.defaultModel,
|
|
309
|
+
readSettings: () => opened.settings.read(),
|
|
310
|
+
}, input);
|
|
311
|
+
},
|
|
260
312
|
},
|
|
261
313
|
commands: {
|
|
262
314
|
// The merged catalog rows (`GET /v1/commands`' core + extension
|
|
@@ -304,6 +356,15 @@ export function createHostBridge(ctx) {
|
|
|
304
356
|
},
|
|
305
357
|
},
|
|
306
358
|
notify: (slug, input) => notifyExtension(ctx, slug, input),
|
|
359
|
+
// Notices Task 3: the calling extension's slug is the posting owner
|
|
360
|
+
// (the service enforces the owner scoping); rejections surface to the
|
|
361
|
+
// caller as rejected promises, the same fail-loud posture as ambient
|
|
362
|
+
// writes.
|
|
363
|
+
notices: {
|
|
364
|
+
post: (slug, input) => ctx.notices.post(slug, input),
|
|
365
|
+
clear: (slug, selector) => ctx.notices.clear(slug, selector),
|
|
366
|
+
list: (slug, user) => ctx.notices.listForExtension(slug, user),
|
|
367
|
+
},
|
|
307
368
|
surfaces: {
|
|
308
369
|
// UI-blocks U3 Task 4 (spec §7.3): the same bumpRev the actions
|
|
309
370
|
// route takes — the rev advance and the hub event are one step.
|
package/dist/server/index.js
CHANGED
|
@@ -35,6 +35,8 @@ import { join } from "node:path";
|
|
|
35
35
|
import { LoginFlows } from "./login/flows.js";
|
|
36
36
|
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
|
37
37
|
import { SessionMetaDoc } from "./session-meta.js";
|
|
38
|
+
import { NoticesService } from "../notices/service.js";
|
|
39
|
+
import { visibleToUser } from "./visibility.js";
|
|
38
40
|
import { buildCommandCatalog, SLASH_COMMANDS } from "./commands.js";
|
|
39
41
|
import { effectiveMethods } from "../config.js";
|
|
40
42
|
import { registerAuthPipeline } from "./auth/pipeline.js";
|
|
@@ -64,6 +66,7 @@ import { registerInteractionRoutes } from "./routes/interactions.js";
|
|
|
64
66
|
import { registerMeRoutes } from "./routes/me.js";
|
|
65
67
|
import { registerMetaRoutes } from "./routes/meta.js";
|
|
66
68
|
import { registerMetricsRoutes } from "./routes/metrics.js";
|
|
69
|
+
import { registerNoticeRoutes } from "./routes/notices.js";
|
|
67
70
|
import { registerOnboardingRoutes } from "./routes/onboarding.js";
|
|
68
71
|
import { registerProbeRoutes } from "./routes/probes.js";
|
|
69
72
|
import { registerProviderRoutes } from "./routes/providers.js";
|
|
@@ -201,6 +204,27 @@ export async function createServer(opened, config, contributedBundles = [], opti
|
|
|
201
204
|
// effective method list), and the boot env (the settings routes'
|
|
202
205
|
// `effective` block reads it).
|
|
203
206
|
const sse = new SseRegistry();
|
|
207
|
+
// The notices service (notices Task 3): over the per-user store files,
|
|
208
|
+
// with `visibleToUser` as its arbitrary-user visibility answer and the
|
|
209
|
+
// hub as its notice.changed sink (the events route's notice.changed
|
|
210
|
+
// case narrows delivery to the notice user's own streams).
|
|
211
|
+
const noticesService = new NoticesService({
|
|
212
|
+
opened,
|
|
213
|
+
userArea: (user) => join(opened.extensionStores.roots.dataDir, "users", user),
|
|
214
|
+
visible: (conversationId, user) => visibleToUser(
|
|
215
|
+
// SAFETY: `visibleToUser` reads only `opened` off the context
|
|
216
|
+
// (its own doc); the full ServerContext does not exist yet.
|
|
217
|
+
{ opened }, conversationId, user),
|
|
218
|
+
publish: (event) => {
|
|
219
|
+
opened.events.publish(event);
|
|
220
|
+
},
|
|
221
|
+
now: () => new Date(),
|
|
222
|
+
log: (line) => console.warn(line),
|
|
223
|
+
}, {
|
|
224
|
+
...(options?.noticeActionTimeoutMs === undefined
|
|
225
|
+
? {}
|
|
226
|
+
: { actionTimeoutMs: options.noticeActionTimeoutMs }),
|
|
227
|
+
});
|
|
204
228
|
// The chat-surface seam's unsubscribe (filled in just below, after
|
|
205
229
|
// the events hub exists to publish onto; never reassigned after).
|
|
206
230
|
let unsubscribeChatSurfaceChanged = () => { };
|
|
@@ -366,6 +390,7 @@ export async function createServer(opened, config, contributedBundles = [], opti
|
|
|
366
390
|
publishSettingsChanged: opened.publishSettingsChanged,
|
|
367
391
|
watchersByConversation: new Map(),
|
|
368
392
|
surfaces: surfacesService,
|
|
393
|
+
notices: noticesService,
|
|
369
394
|
sse,
|
|
370
395
|
imageProxy: { cache: imageCache, deps: imageDeps, key: imageKey },
|
|
371
396
|
...(options?.gateway === undefined ? {} : { gateway: options.gateway }),
|
|
@@ -452,6 +477,7 @@ export async function createServer(opened, config, contributedBundles = [], opti
|
|
|
452
477
|
registerEventRoutes(app, ctx);
|
|
453
478
|
registerSubmissionRoutes(app, ctx);
|
|
454
479
|
registerInteractionRoutes(app, ctx);
|
|
480
|
+
registerNoticeRoutes(app, ctx);
|
|
455
481
|
registerFileRoutes(app, ctx);
|
|
456
482
|
registerProviderRoutes(app, ctx);
|
|
457
483
|
registerSettingsRoutes(app, ctx);
|
|
@@ -178,6 +178,26 @@ export function registerEventRoutes(app, ctx) {
|
|
|
178
178
|
treeRev: event.treeRev,
|
|
179
179
|
};
|
|
180
180
|
}
|
|
181
|
+
// Notices (notices Task 4, spec §4): the hub copy carries the
|
|
182
|
+
// recipient user (NoticeChangedInternal — routing metadata,
|
|
183
|
+
// never wire); the stream delivers ONLY to the notice user's
|
|
184
|
+
// own principal, and the DELIVERED frame is the wire shape
|
|
185
|
+
// (the user stripped — a notice event never crosses to another
|
|
186
|
+
// principal's stream, and its wire carries no user).
|
|
187
|
+
case "notice.changed": {
|
|
188
|
+
// SAFETY: every notice.changed the hub sees comes from the
|
|
189
|
+
// notices service, which always attaches the recipient user
|
|
190
|
+
// (NoticeChangedInternal) — the wire union's member omits it
|
|
191
|
+
// because the DELIVERED frame never carries it.
|
|
192
|
+
const internal = event;
|
|
193
|
+
if (principal.user !== internal.user)
|
|
194
|
+
return undefined;
|
|
195
|
+
return {
|
|
196
|
+
type: "notice.changed",
|
|
197
|
+
ids: internal.ids,
|
|
198
|
+
change: internal.change,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
181
201
|
// An unknown member is NEVER delivered — deny by construction,
|
|
182
202
|
// not by enumerating the leak shapes. The satisfies check keeps
|
|
183
203
|
// this arm exhaustive: a new HostEventWire member without a
|