@sigrun/sigrun 0.1.4 → 0.1.5
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 +29 -0
- package/README.md +24 -2
- package/dist/blocks/builders.js +125 -0
- package/dist/blocks/images.js +57 -0
- package/dist/blocks/index.js +20 -0
- package/dist/blocks/limits.js +26 -0
- package/dist/blocks/placements.js +99 -0
- package/dist/blocks/schema.js +559 -0
- package/dist/blocks/validate.js +602 -0
- package/dist/config.js +31 -0
- package/dist/extension.d.ts +476 -9
- package/dist/harness/boot.js +8 -1
- package/dist/harness/event-bus.js +33 -0
- package/dist/harness/identity/users.js +3 -2
- package/dist/harness/loader.js +369 -13
- package/dist/harness/packages.js +71 -2
- package/dist/harness/seam-host.js +69 -2
- package/dist/harness/settings.js +5 -0
- package/dist/jobs/fire.js +11 -0
- package/dist/jobs/spec.js +19 -0
- package/dist/serve.js +29 -39
- package/dist/server/auth/pipeline.js +16 -1
- package/dist/server/conversation-ops.js +482 -10
- package/dist/server/gateway.js +122 -0
- package/dist/server/helpers.js +26 -0
- package/dist/server/host-bridge.js +141 -4
- package/dist/server/index.js +27 -0
- package/dist/server/notifications.js +6 -2
- package/dist/server/routes/conversations.js +37 -93
- package/dist/server/routes/events.js +39 -0
- package/dist/server/routes/ext-assets.js +84 -0
- package/dist/server/routes/extensions.js +31 -1
- package/dist/server/routes/files.js +38 -83
- package/dist/server/routes/meta.js +35 -1
- package/dist/server/routes/providers.js +5 -21
- package/dist/server/routes/submissions.js +36 -111
- package/dist/server/routes/surfaces.js +387 -0
- package/dist/server/routes/users.js +6 -1
- package/dist/server/session-meta.js +12 -6
- package/dist/server/shutdown.js +85 -0
- package/dist/server/surfaces/access-cache.js +43 -0
- package/dist/server/surfaces/dedup.js +67 -0
- package/dist/server/surfaces/instance.js +42 -0
- package/dist/server/surfaces/mime.js +73 -0
- package/dist/server/surfaces/rate-limit.js +63 -0
- package/dist/server/surfaces/service.js +493 -0
- package/dist/server/surfaces/watchers.js +94 -0
- package/node_modules/@sigrun/shared/dist/blocks/format.d.ts +28 -0
- package/node_modules/@sigrun/shared/dist/blocks/format.js +199 -0
- package/node_modules/@sigrun/shared/dist/blocks/icons.d.ts +12 -0
- package/node_modules/@sigrun/shared/dist/blocks/icons.js +64 -0
- package/node_modules/@sigrun/shared/dist/blocks/index.d.ts +25 -0
- package/node_modules/@sigrun/shared/dist/blocks/index.js +21 -0
- package/node_modules/@sigrun/shared/dist/blocks/instance.d.ts +29 -0
- package/node_modules/@sigrun/shared/dist/blocks/instance.js +51 -0
- package/node_modules/@sigrun/shared/dist/blocks/lttb.d.ts +16 -0
- package/node_modules/@sigrun/shared/dist/blocks/lttb.js +67 -0
- package/node_modules/@sigrun/shared/dist/blocks/ops.d.ts +44 -0
- package/node_modules/@sigrun/shared/dist/blocks/ops.js +294 -0
- package/node_modules/@sigrun/shared/dist/blocks/text.d.ts +43 -0
- package/node_modules/@sigrun/shared/dist/blocks/text.js +790 -0
- package/node_modules/@sigrun/shared/dist/blocks/transport.d.ts +77 -0
- package/node_modules/@sigrun/shared/dist/blocks/transport.js +11 -0
- package/node_modules/@sigrun/shared/dist/blocks/types.d.ts +541 -0
- package/node_modules/@sigrun/shared/dist/blocks/types.js +17 -0
- package/node_modules/@sigrun/shared/dist/jobs.d.ts +9 -0
- package/node_modules/@sigrun/shared/dist/live.d.ts +71 -0
- package/node_modules/@sigrun/shared/dist/live.js +82 -0
- package/node_modules/@sigrun/shared/dist/session-meta.d.ts +19 -3
- package/node_modules/@sigrun/shared/dist/settings.d.ts +7 -0
- package/node_modules/@sigrun/shared/dist/views.d.ts +81 -3
- package/node_modules/@sigrun/shared/package.json +10 -2
- package/package.json +9 -3
- package/webui/dist/assets/index-B4-GYqEb.js +50 -0
- package/webui/dist/index.html +1 -1
- package/webui/dist/assets/index-hVLvsizV.js +0 -50
package/dist/server/helpers.js
CHANGED
|
@@ -273,6 +273,32 @@ export async function conversationEnv(opened, conversation) {
|
|
|
273
273
|
}
|
|
274
274
|
/** The upload read cap for `files/content` (binary-safe; 1 MiB). */
|
|
275
275
|
export const FILE_CONTENT_MAX_BYTES = 1024 * 1024;
|
|
276
|
+
/**
|
|
277
|
+
* The served model catalog as picker entries (gateway C4b): the ONE
|
|
278
|
+
* construction behind `GET /v1/models` `.models` AND the host's
|
|
279
|
+
* `models.list()` — id/provider/modelId/name/contextWindow/maxTokens/
|
|
280
|
+
* reasoning/input plus the four rate fields (tiers dropped for v1, the
|
|
281
|
+
* picker doesn't use them — no unsound cast). Provider internals
|
|
282
|
+
* (baseUrl/headers/auth) never cross either surface.
|
|
283
|
+
*/
|
|
284
|
+
export function listModels(opened) {
|
|
285
|
+
return opened.models.getModels().map((model) => ({
|
|
286
|
+
id: `${model.provider}/${model.id}`,
|
|
287
|
+
provider: model.provider,
|
|
288
|
+
modelId: model.id,
|
|
289
|
+
name: model.name,
|
|
290
|
+
contextWindow: model.contextWindow,
|
|
291
|
+
maxTokens: model.maxTokens,
|
|
292
|
+
reasoning: model.reasoning,
|
|
293
|
+
input: model.input,
|
|
294
|
+
cost: {
|
|
295
|
+
input: model.cost.input,
|
|
296
|
+
output: model.cost.output,
|
|
297
|
+
cacheRead: model.cost.cacheRead,
|
|
298
|
+
cacheWrite: model.cost.cacheWrite,
|
|
299
|
+
},
|
|
300
|
+
}));
|
|
301
|
+
}
|
|
276
302
|
/**
|
|
277
303
|
* One provider's auth readiness, derived from boot-retained state (spec
|
|
278
304
|
* §6.4 + provider plugins Task 6): the same three-priority chain
|
|
@@ -18,9 +18,25 @@
|
|
|
18
18
|
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
|
19
19
|
import { accessOf as accessOfShared, } from "@sigrun/shared/access";
|
|
20
20
|
import { isSuperuser } from "./auth/principal.js";
|
|
21
|
-
import { createConversationOp, listConversationsOp, readMetaOp, submitOp, } from "./conversation-ops.js";
|
|
21
|
+
import { abortOp, compactOp, configureOp, ConversationOpError, createConversationOp, forkOp, listConversationsOp, patchMetaOp, readFileOp, readMetaOp, submitOp, uploadOp, } from "./conversation-ops.js";
|
|
22
|
+
import { listModels } from "./helpers.js";
|
|
22
23
|
import { SessionMetaDoc } from "./session-meta.js";
|
|
23
24
|
import { serializeConversationView } from "./views.js";
|
|
25
|
+
/**
|
|
26
|
+
* The effective-admin projection of one directory user (gateway C8):
|
|
27
|
+
* host-side callers (chat commands, no request headers) see the STORED
|
|
28
|
+
* flag OR the persisted group grant, plus the raw `groupAdmin` as a
|
|
29
|
+
* definite boolean. The `/v1/users` wire keeps `admin` = the stored
|
|
30
|
+
* flag (the users-admin UI toggles that one); the two surfaces are
|
|
31
|
+
* deliberately different.
|
|
32
|
+
*/
|
|
33
|
+
function effectiveAdminOf(record) {
|
|
34
|
+
return {
|
|
35
|
+
...record,
|
|
36
|
+
admin: record.admin || record.groupAdmin === true,
|
|
37
|
+
groupAdmin: record.groupAdmin === true,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
24
40
|
/** Build the host bridge over one server context. */
|
|
25
41
|
export function createHostBridge(ctx) {
|
|
26
42
|
const { opened } = ctx;
|
|
@@ -93,16 +109,78 @@ export function createHostBridge(ctx) {
|
|
|
93
109
|
await conversationOf(id);
|
|
94
110
|
return await readMetaOp(opened, id);
|
|
95
111
|
},
|
|
112
|
+
// Gateway C4a: the remaining conversation ops, each the SAME op
|
|
113
|
+
// its HTTP route runs — resolved through conversationOf (unknown id
|
|
114
|
+
// → the bridge's loud `conversation <id> not found`).
|
|
115
|
+
async abort(id) {
|
|
116
|
+
const conversation = await conversationOf(id);
|
|
117
|
+
// `/stop` on an idle chat is not an error (the plan's ruling): the
|
|
118
|
+
// op's 404 no_active_submission resolves instead — the HTTP route
|
|
119
|
+
// keeps its exact 404.
|
|
120
|
+
try {
|
|
121
|
+
await abortOp(ctx, conversation);
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
if (error instanceof ConversationOpError &&
|
|
125
|
+
error.status === 404 &&
|
|
126
|
+
error.body.error === "no_active_submission") {
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
throw error;
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
async configure(id, opts = {}) {
|
|
133
|
+
const conversation = await conversationOf(id);
|
|
134
|
+
// Thin over the op (host inputs are typed — the op still
|
|
135
|
+
// validates exactly like the route's body).
|
|
136
|
+
await configureOp(ctx, conversation, {
|
|
137
|
+
...(opts.model === undefined ? {} : { model: opts.model }),
|
|
138
|
+
...(opts.thinking === undefined ? {} : { thinking: opts.thinking }),
|
|
139
|
+
});
|
|
140
|
+
},
|
|
141
|
+
async compact(id) {
|
|
142
|
+
const conversation = await conversationOf(id);
|
|
143
|
+
await compactOp(ctx, conversation);
|
|
144
|
+
},
|
|
145
|
+
async fork(id, opts = {}) {
|
|
146
|
+
const conversation = await conversationOf(id);
|
|
147
|
+
const forked = await forkOp(ctx, conversation, {
|
|
148
|
+
...(opts.at === undefined ? {} : { at: opts.at }),
|
|
149
|
+
...(opts.owner === undefined ? {} : { owner: opts.owner }),
|
|
150
|
+
});
|
|
151
|
+
return forked.id;
|
|
152
|
+
},
|
|
153
|
+
async patchMeta(id, patch) {
|
|
154
|
+
const conversation = await conversationOf(id);
|
|
155
|
+
// No principal: host callers are trusted — the sharing gate is
|
|
156
|
+
// the HTTP route's own concern (the plan's ruling).
|
|
157
|
+
return await patchMetaOp(ctx, conversation, patch);
|
|
158
|
+
},
|
|
159
|
+
// Gateway C4b: the files routes' ops, each the SAME op its HTTP
|
|
160
|
+
// route runs — the upload routes' shared write core and the
|
|
161
|
+
// files/raw route's confinement + cap (the host sees the op's own
|
|
162
|
+
// error vocabulary: invalid_field name / invalid_path / not_found /
|
|
163
|
+
// too_large).
|
|
164
|
+
async upload(id, file) {
|
|
165
|
+
const conversation = await conversationOf(id);
|
|
166
|
+
return await uploadOp(ctx, conversation, file);
|
|
167
|
+
},
|
|
168
|
+
async readFile(id, path) {
|
|
169
|
+
const conversation = await conversationOf(id);
|
|
170
|
+
return await readFileOp(ctx, conversation, path);
|
|
171
|
+
},
|
|
96
172
|
},
|
|
97
173
|
users: {
|
|
98
174
|
// The directory in wire form (emails are admin material; the
|
|
99
175
|
// bridge is in-process server code, the same trust as the
|
|
100
|
-
// admin-gated /v1/users routes)
|
|
176
|
+
// admin-gated /v1/users routes) — with EFFECTIVE admin (gateway
|
|
177
|
+
// C8): a persisted group grant counts for host-side checks.
|
|
101
178
|
async list() {
|
|
102
|
-
return opened.identity.users.list();
|
|
179
|
+
return opened.identity.users.list().map(effectiveAdminOf);
|
|
103
180
|
},
|
|
104
181
|
async get(user) {
|
|
105
|
-
|
|
182
|
+
const record = opened.identity.users.get(user);
|
|
183
|
+
return record === undefined ? undefined : effectiveAdminOf(record);
|
|
106
184
|
},
|
|
107
185
|
},
|
|
108
186
|
access: {
|
|
@@ -127,5 +205,64 @@ export function createHostBridge(ctx) {
|
|
|
127
205
|
return meta?.owner;
|
|
128
206
|
},
|
|
129
207
|
},
|
|
208
|
+
// Gateway C4b: the model catalog — the SAME source function the
|
|
209
|
+
// `GET /v1/models` route serves (one construction, never two).
|
|
210
|
+
models: {
|
|
211
|
+
async list() {
|
|
212
|
+
return listModels(opened);
|
|
213
|
+
},
|
|
214
|
+
},
|
|
215
|
+
commands: {
|
|
216
|
+
// The merged catalog rows (`GET /v1/commands`' core + extension
|
|
217
|
+
// half — the live skill/template expand entries are the composer's
|
|
218
|
+
// per-request concern, not this surface's).
|
|
219
|
+
async list() {
|
|
220
|
+
return ctx.commandCatalog.commands;
|
|
221
|
+
},
|
|
222
|
+
// The invoke route's resolution, in-process: the runnable map
|
|
223
|
+
// (core names and dropped/collided losers throw), the principal
|
|
224
|
+
// built from the directory with EFFECTIVE admin and method "host",
|
|
225
|
+
// and the extension's seam host exactly as the route passes it. A
|
|
226
|
+
// throwing run PROPAGATES (the HTTP route maps 500 command_failed;
|
|
227
|
+
// the host caller owns its own handling).
|
|
228
|
+
async run(conversationId, name, args, opts) {
|
|
229
|
+
const merged = ctx.commandCatalog.runnable.get(name);
|
|
230
|
+
if (merged === undefined) {
|
|
231
|
+
throw new Error(`command_not_found: ${name}`);
|
|
232
|
+
}
|
|
233
|
+
const record = opened.identity.users.get(opts.user);
|
|
234
|
+
if (record === undefined) {
|
|
235
|
+
throw new Error(`unknown user "${opts.user}"`);
|
|
236
|
+
}
|
|
237
|
+
// Effective admin (gateway C8): the stored flag OR the persisted
|
|
238
|
+
// group grant — host-side code has no request headers to
|
|
239
|
+
// recompute one.
|
|
240
|
+
const principal = {
|
|
241
|
+
user: record.user,
|
|
242
|
+
...(record.name === undefined ? {} : { name: record.name }),
|
|
243
|
+
...(record.email === undefined ? {} : { email: record.email }),
|
|
244
|
+
admin: effectiveAdminOf(record).admin,
|
|
245
|
+
service: false,
|
|
246
|
+
method: "host",
|
|
247
|
+
};
|
|
248
|
+
const host = ctx.opened.seamHosts.get(merged.extension);
|
|
249
|
+
const result = await merged.run({
|
|
250
|
+
conversationId,
|
|
251
|
+
args,
|
|
252
|
+
principal,
|
|
253
|
+
host: host,
|
|
254
|
+
});
|
|
255
|
+
return result === undefined || result.notice === undefined
|
|
256
|
+
? {}
|
|
257
|
+
: { notice: result.notice };
|
|
258
|
+
},
|
|
259
|
+
},
|
|
260
|
+
surfaces: {
|
|
261
|
+
// UI-blocks U3 Task 4 (spec §7.3): the same bumpRev the actions
|
|
262
|
+
// route takes — the rev advance and the hub event are one step.
|
|
263
|
+
changed(slug, id, key, update) {
|
|
264
|
+
ctx.surfaces.bumpRev(slug, id, key, update);
|
|
265
|
+
},
|
|
266
|
+
},
|
|
130
267
|
};
|
|
131
268
|
}
|
package/dist/server/index.js
CHANGED
|
@@ -46,6 +46,7 @@ import { registerConversationRoutes } from "./routes/conversations.js";
|
|
|
46
46
|
import { registerEventRoutes } from "./routes/events.js";
|
|
47
47
|
import { registerExtensionRoutes as registerExtensionSettingsRoutes } from "./routes/extensions.js";
|
|
48
48
|
import { registerExtensionRoutes as mountExtensionRoutes } from "./routes/extension-routes.js";
|
|
49
|
+
import { registerExtAssetRoutes } from "./routes/ext-assets.js";
|
|
49
50
|
import { registerFileRoutes } from "./routes/files.js";
|
|
50
51
|
import { registerJobRoutes } from "./routes/jobs.js";
|
|
51
52
|
import { registerInteractionRoutes } from "./routes/interactions.js";
|
|
@@ -58,6 +59,8 @@ import { registerSettingsRoutes } from "./routes/settings.js";
|
|
|
58
59
|
import { registerStreamRoutes } from "./routes/stream.js";
|
|
59
60
|
import { startNotifications } from "./notifications.js";
|
|
60
61
|
import { registerSubmissionRoutes } from "./routes/submissions.js";
|
|
62
|
+
import { registerSurfaceRoutes } from "./routes/surfaces.js";
|
|
63
|
+
import { SurfaceService } from "./surfaces/service.js";
|
|
61
64
|
import { registerUserRoutes } from "./routes/users.js";
|
|
62
65
|
import { resolveWebuiBundle, serveWebuiBundle, } from "./static.js";
|
|
63
66
|
/**
|
|
@@ -164,6 +167,20 @@ export async function createServer(opened, config, contributedBundles = [], opti
|
|
|
164
167
|
// OUTSIDE before app.close() — a decoration is the typed, discoverable
|
|
165
168
|
// way to hand that handle up without widening ServerContext's consumers.
|
|
166
169
|
app.decorate("sse", sse);
|
|
170
|
+
const surfacesService = new SurfaceService(opened.extensionSurfaces, {
|
|
171
|
+
maxUploadBytes: config.maxUploadBytes,
|
|
172
|
+
// Task 4: bumpRev (actions AND the host seam's changed()) fans the
|
|
173
|
+
// surface.changed event out to the hub; the events route's
|
|
174
|
+
// visibility case delivers it to watching streams only.
|
|
175
|
+
publishChanged: (event) => {
|
|
176
|
+
// SAFETY: the hub is typed on the wire union; surface.changed's
|
|
177
|
+
// hub copy carries the internal `streams` field the events route
|
|
178
|
+
// reads and strips before delivery (spec §7.3's routing can never
|
|
179
|
+
// reverse the hashed k).
|
|
180
|
+
opened.events.publish(event);
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
app.decorate("surfaces", surfacesService);
|
|
167
184
|
const ctx = {
|
|
168
185
|
opened,
|
|
169
186
|
config,
|
|
@@ -174,7 +191,9 @@ export async function createServer(opened, config, contributedBundles = [], opti
|
|
|
174
191
|
methods,
|
|
175
192
|
env,
|
|
176
193
|
watchersByConversation: new Map(),
|
|
194
|
+
surfaces: surfacesService,
|
|
177
195
|
sse,
|
|
196
|
+
...(options?.gateway === undefined ? {} : { gateway: options.gateway }),
|
|
178
197
|
};
|
|
179
198
|
// The host bridge (W5 Task 5, spec §9.5/§9.7): ONE bridge over this
|
|
180
199
|
// server's context, bound onto every seam host — the extension-facing
|
|
@@ -217,6 +236,14 @@ export async function createServer(opened, config, contributedBundles = [], opti
|
|
|
217
236
|
// web-standard Request/Response adapter — per-route auth rides the
|
|
218
237
|
// pipeline's stage-4b public skip and this module's admin check.
|
|
219
238
|
mountExtensionRoutes(app, ctx);
|
|
239
|
+
// The extension surface routes (UI-blocks U3 Task 2, spec §7.2):
|
|
240
|
+
// render/data/actions under the reserved `surfaces/` prefix, over the
|
|
241
|
+
// shared SurfaceService's access cache and revision state.
|
|
242
|
+
registerSurfaceRoutes(app, ctx);
|
|
243
|
+
// The extension asset route (UI-blocks U3 Task 3, spec §11.1/§7.2):
|
|
244
|
+
// `/v1/ext/<slug>/assets/*` serves the package's declared
|
|
245
|
+
// `sigrun.assets` dir, realpath-contained, sniffed content types.
|
|
246
|
+
registerExtAssetRoutes(app, ctx);
|
|
220
247
|
// The jobs routes (W4 Task 4, spec §8.4): the access-scoped CRUD +
|
|
221
248
|
// run-now + history surface over `OpenedHarness.jobs` (Task 3's
|
|
222
249
|
// service). Hidden-is-404 per §5.9; `file:`/`ext:` ids are immutable
|
|
@@ -73,6 +73,7 @@ export function startNotifications(ctx) {
|
|
|
73
73
|
type: base.type,
|
|
74
74
|
conversationId: base.conversationId,
|
|
75
75
|
jobId: base.jobId,
|
|
76
|
+
...(base.callId === undefined ? {} : { callId: base.callId }),
|
|
76
77
|
user,
|
|
77
78
|
title: base.title,
|
|
78
79
|
body: base.body,
|
|
@@ -100,7 +101,7 @@ export function startNotifications(ctx) {
|
|
|
100
101
|
}
|
|
101
102
|
};
|
|
102
103
|
/** interaction.pending → the owner plus every directory user with write. */
|
|
103
|
-
const handleInteraction = async (conversationId) => {
|
|
104
|
+
const handleInteraction = async (conversationId, callId) => {
|
|
104
105
|
const meta = await metaOf(conversationId);
|
|
105
106
|
const owner = meta?.owner;
|
|
106
107
|
const sharing = meta?.sharing;
|
|
@@ -116,6 +117,7 @@ export function startNotifications(ctx) {
|
|
|
116
117
|
type: "interaction.pending",
|
|
117
118
|
conversationId,
|
|
118
119
|
jobId: undefined,
|
|
120
|
+
callId,
|
|
119
121
|
title: "Sigrun needs you",
|
|
120
122
|
body: displayName(meta, conversationId),
|
|
121
123
|
}, recipients);
|
|
@@ -136,6 +138,7 @@ export function startNotifications(ctx) {
|
|
|
136
138
|
type: "run.settled",
|
|
137
139
|
conversationId,
|
|
138
140
|
jobId: undefined,
|
|
141
|
+
callId: undefined,
|
|
139
142
|
title: "Reply ready",
|
|
140
143
|
body: displayName(meta, conversationId),
|
|
141
144
|
}, [meta?.owner]);
|
|
@@ -159,6 +162,7 @@ export function startNotifications(ctx) {
|
|
|
159
162
|
type: "job.failed",
|
|
160
163
|
conversationId: run.conversationId,
|
|
161
164
|
jobId: job.id,
|
|
165
|
+
callId: undefined,
|
|
162
166
|
title: `Job ${job.name} failed`,
|
|
163
167
|
// The observer always settles a failed run with its error
|
|
164
168
|
// text; the fallback only covers a hand-corrupted record.
|
|
@@ -170,7 +174,7 @@ export function startNotifications(ctx) {
|
|
|
170
174
|
// the bus's bounded side lane (resolution + delivery alike never
|
|
171
175
|
// run inline here).
|
|
172
176
|
if (event.type === "interaction.pending") {
|
|
173
|
-
void bus.runBounded("notifications interaction.pending", () => handleInteraction(event.conversationId));
|
|
177
|
+
void bus.runBounded("notifications interaction.pending", () => handleInteraction(event.conversationId, event.callId));
|
|
174
178
|
}
|
|
175
179
|
else if (event.type === "run.settled") {
|
|
176
180
|
void bus.runBounded("notifications run.settled", () => handleRunSettled(event.conversationId));
|
|
@@ -8,10 +8,9 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
|
10
10
|
import { isSuperuser } from "../auth/principal.js";
|
|
11
|
-
import { createConversationOp, listConversationsOp, readMetaOp, } from "../conversation-ops.js";
|
|
11
|
+
import { ConversationOpError, createConversationOp, forkOp, listConversationsOp, patchMetaOp, readMetaOp, } from "../conversation-ops.js";
|
|
12
12
|
import { loadConversation } from "../conversation-handler.js";
|
|
13
13
|
import { ID_PARAMS_SCHEMA, readView, stampSessionOwner, storeAgentChange, } from "../helpers.js";
|
|
14
|
-
import { applySessionMetaPatch, DEFAULT_SESSION_META, SessionMetaDoc, validateSessionMetaPatch, } from "../session-meta.js";
|
|
15
14
|
import { JSONL_CONTENT_TYPE, } from "../views.js";
|
|
16
15
|
import { base64BodyLimit } from "../../config.js";
|
|
17
16
|
/** Register the conversation lifecycle + session-meta routes. */
|
|
@@ -82,16 +81,16 @@ export function registerConversationRoutes(app, ctx) {
|
|
|
82
81
|
return readView(conversation);
|
|
83
82
|
});
|
|
84
83
|
// Fork: a history fork at one visible entry (spec §5 session
|
|
85
|
-
// management).
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
// the
|
|
84
|
+
// management). The shared fork op (gateway C4a) is the SAME code path
|
|
85
|
+
// the host bridge drives — `at` validation against the parent history,
|
|
86
|
+
// the empty_conversation guard, the agent-model inherit, and the fork
|
|
87
|
+
// meta commit (owner reset + "<source> (fork)" autoName +
|
|
88
|
+
// lastActivityAt) live in the op; this route owns the body shape and
|
|
89
|
+
// the 201. The response is the create shape: `{id, view}`.
|
|
90
90
|
app.post("/v1/conversations/:id/fork", {
|
|
91
91
|
schema: { params: ID_PARAMS_SCHEMA },
|
|
92
92
|
preHandler: loadConversation(ctx, "write"),
|
|
93
93
|
}, async (request, reply) => {
|
|
94
|
-
const conversation = request.conversation;
|
|
95
94
|
// Absent body is valid (`at` defaults to the newest entry); the
|
|
96
95
|
// object may carry ONLY `at` — the compact/configure convention.
|
|
97
96
|
const body = request.body;
|
|
@@ -107,73 +106,30 @@ export function registerConversationRoutes(app, ctx) {
|
|
|
107
106
|
.code(400)
|
|
108
107
|
.send({ error: "invalid_field", field: unknownKey });
|
|
109
108
|
}
|
|
110
|
-
|
|
111
|
-
|
|
109
|
+
at = record.at;
|
|
110
|
+
if (at !== undefined && typeof at !== "number") {
|
|
112
111
|
return await reply
|
|
113
112
|
.code(400)
|
|
114
113
|
.send({ error: "invalid_field", field: "at" });
|
|
115
114
|
}
|
|
116
|
-
at = record.at;
|
|
117
115
|
}
|
|
118
|
-
|
|
119
|
-
// The
|
|
120
|
-
//
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
116
|
+
try {
|
|
117
|
+
// The route passes the principal's user as the fork's owner (the
|
|
118
|
+
// host passes its own opts.owner; undefined = the key removed).
|
|
119
|
+
const forked = await forkOp(ctx, request.conversation, {
|
|
120
|
+
...(at === undefined ? {} : { at }),
|
|
121
|
+
...(request.principal.user === undefined
|
|
122
|
+
? {}
|
|
123
|
+
: { owner: request.principal.user }),
|
|
124
|
+
});
|
|
125
|
+
return await reply.code(201).send(forked);
|
|
127
126
|
}
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
// error, not a silent empty fork.
|
|
132
|
-
const page = await conversation.entries({ maxEntryId: at }, 1, undefined, BACKGROUND_CONTEXT);
|
|
133
|
-
const newest = page.items[0];
|
|
134
|
-
if (newest === undefined || newest.id !== at) {
|
|
135
|
-
return await reply
|
|
136
|
-
.code(400)
|
|
137
|
-
.send({ error: "invalid_field", field: "at" });
|
|
127
|
+
catch (error) {
|
|
128
|
+
if (error instanceof ConversationOpError) {
|
|
129
|
+
return await reply.code(error.status).send(error.body);
|
|
138
130
|
}
|
|
131
|
+
throw error;
|
|
139
132
|
}
|
|
140
|
-
// The fork inherits the parent's agent model — read the resolved
|
|
141
|
-
// agent, fall back to the boot default like create.
|
|
142
|
-
const agent = await conversation.agent(BACKGROUND_CONTEXT);
|
|
143
|
-
const fork = await conversation.fork(at, {
|
|
144
|
-
ownership: { kind: "ownerless" },
|
|
145
|
-
agent: { model: agent.model ?? opened.defaultModel },
|
|
146
|
-
}, BACKGROUND_CONTEXT);
|
|
147
|
-
// The fork's sidebar identity: the copied meta doc
|
|
148
|
-
// carries the source's name/autoName and the namer's attempt count;
|
|
149
|
-
// ONE commit drops all three (a fork is a new conversation, not the
|
|
150
|
-
// source's name — and it starts naming-fresh: inheriting an exhausted
|
|
151
|
-
// attempt count would make the fork forever unnameable), re-seeds
|
|
152
|
-
// it with "<source display name> (fork)"
|
|
153
|
-
// as its AUTO name when the source had one, and stamps activity so
|
|
154
|
-
// the fresh fork sorts where it happened.
|
|
155
|
-
// §5.9 (Review Focus 3): the doc copied the source's `owner` and
|
|
156
|
-
// `sharing` (fork: "current") — the SAME commit resets them: a fork
|
|
157
|
-
// is the FORKER's private copy (owner = the caller, sharing deleted;
|
|
158
|
-
// a header-less caller means no owner at all — the key is removed).
|
|
159
|
-
const sourceMeta = await opened.harness.snapshot(SessionMetaDoc, conversation.id, BACKGROUND_CONTEXT);
|
|
160
|
-
const sourceName = sourceMeta?.name ?? sourceMeta?.autoName;
|
|
161
|
-
await fork.commit(async (tx) => {
|
|
162
|
-
const draft = await tx.doc(SessionMetaDoc, fork.id);
|
|
163
|
-
delete draft.name;
|
|
164
|
-
delete draft.autoName;
|
|
165
|
-
delete draft.autoNameAttempts;
|
|
166
|
-
delete draft.sharing;
|
|
167
|
-
if (request.principal.user === undefined)
|
|
168
|
-
delete draft.owner;
|
|
169
|
-
else
|
|
170
|
-
draft.owner = request.principal.user;
|
|
171
|
-
if (sourceName !== undefined)
|
|
172
|
-
draft.autoName = `${sourceName} (fork)`;
|
|
173
|
-
draft.lastActivityAt = opened.now().toISOString();
|
|
174
|
-
}, BACKGROUND_CONTEXT);
|
|
175
|
-
const view = await readView(fork);
|
|
176
|
-
return await reply.code(201).send({ id: fork.id, view });
|
|
177
133
|
});
|
|
178
134
|
// Export: the conversation's history as JSONL — pi's portable
|
|
179
135
|
// format. One entry JSON object per line (the same per-entry
|
|
@@ -308,34 +264,22 @@ export function registerConversationRoutes(app, ctx) {
|
|
|
308
264
|
schema: { params: ID_PARAMS_SCHEMA },
|
|
309
265
|
preHandler: loadConversation(ctx, "write"),
|
|
310
266
|
}, async (request, reply) => {
|
|
311
|
-
|
|
312
|
-
//
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
267
|
+
// The shared patch op (gateway C4a) is the SAME code path the
|
|
268
|
+
// host bridge drives — validate-first, the owner-or-superuser
|
|
269
|
+
// sharing gate (the route passes its principal; the host passes
|
|
270
|
+
// none, trusted), the one-commit merge, and the post-commit
|
|
271
|
+
// snapshot response all live in the op. This route owns the HTTP
|
|
272
|
+
// mapping only.
|
|
273
|
+
try {
|
|
274
|
+
return await patchMetaOp(ctx, request.conversation, request.body, {
|
|
275
|
+
principal: request.principal,
|
|
276
|
+
});
|
|
316
277
|
}
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
if (validated.sharing !== undefined) {
|
|
321
|
-
const meta = await opened.harness.snapshot(SessionMetaDoc, conversation.id, BACKGROUND_CONTEXT);
|
|
322
|
-
const isOwner = request.principal.user !== undefined &&
|
|
323
|
-
request.principal.user === meta?.owner;
|
|
324
|
-
if (!isOwner && !isSuperuser(request.principal)) {
|
|
325
|
-
return await reply.code(403).send({ error: "forbidden" });
|
|
278
|
+
catch (error) {
|
|
279
|
+
if (error instanceof ConversationOpError) {
|
|
280
|
+
return await reply.code(error.status).send(error.body);
|
|
326
281
|
}
|
|
282
|
+
throw error;
|
|
327
283
|
}
|
|
328
|
-
// Read-modify-write inside ONE commit: tx.doc materializes the doc
|
|
329
|
-
// (initial()) when absent and the patch mutates the tracked draft.
|
|
330
|
-
await conversation.commit(async (tx) => {
|
|
331
|
-
const draft = await tx.doc(SessionMetaDoc, conversation.id);
|
|
332
|
-
applySessionMetaPatch(draft, validated);
|
|
333
|
-
}, BACKGROUND_CONTEXT);
|
|
334
|
-
// The response is a FRESH snapshot, not a draft projection:
|
|
335
|
-
// reading a newly-SET nested draft value (`sharing`, §5.9) trips
|
|
336
|
-
// chord's settled-overlay rule, so the reply must never read the
|
|
337
|
-
// mutated draft — a post-commit snapshot is exactly what was
|
|
338
|
-
// stored.
|
|
339
|
-
return ((await opened.harness.snapshot(SessionMetaDoc, conversation.id, BACKGROUND_CONTEXT)) ?? DEFAULT_SESSION_META);
|
|
340
284
|
});
|
|
341
285
|
}
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
* read-side posture.
|
|
30
30
|
*/
|
|
31
31
|
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
|
32
|
+
import { randomUUID } from "node:crypto";
|
|
32
33
|
import { accessOf } from "@sigrun/shared/access";
|
|
33
34
|
import { SessionMetaDoc } from "../session-meta.js";
|
|
34
35
|
/** The heartbeat cadence (the /stream pattern: proxy idle-timeout survival). */
|
|
@@ -53,6 +54,17 @@ export function registerEventRoutes(app, ctx) {
|
|
|
53
54
|
const send = (event) => {
|
|
54
55
|
raw.write(`event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`);
|
|
55
56
|
};
|
|
57
|
+
// The W3 amendment (spec §7.3): the FIRST frame names this
|
|
58
|
+
// connection — `event: hello` / `data: {"streamId": "<uuid>"}`,
|
|
59
|
+
// written BEFORE the hub subscription. Channels send the id as
|
|
60
|
+
// `X-Sigrun-Stream` on surface/data fetches (the watcher registry's
|
|
61
|
+
// entry); after a reconnect (a NEW streamId) they re-fetch every
|
|
62
|
+
// visible surface, which re-registers the entries. The hello is a
|
|
63
|
+
// CONNECTION-LOCAL frame (channels ignore unknown event types), so
|
|
64
|
+
// it is deliberately NOT a HostEventWire member — the exhaustive
|
|
65
|
+
// visibility switch covers only principal-facing hub events.
|
|
66
|
+
const streamId = randomUUID();
|
|
67
|
+
send({ type: "hello", streamId }); // SAFETY: hello is a connection-local frame, not a hub event — the wire writer is typed on HostEventWire only because every OTHER frame is one.
|
|
56
68
|
let finished = false;
|
|
57
69
|
const heartbeat = setInterval(() => {
|
|
58
70
|
raw.write(": ping\n\n");
|
|
@@ -117,6 +129,30 @@ export function registerEventRoutes(app, ctx) {
|
|
|
117
129
|
// principal-facing wire event.
|
|
118
130
|
case "taskSettled":
|
|
119
131
|
return undefined;
|
|
132
|
+
// UI-blocks U3 Task 4 (spec §7.3): delivered to a watching
|
|
133
|
+
// stream ONLY. The hub copy is the service's internal event
|
|
134
|
+
// (SurfaceChangedInternal) which carries the resolved stream
|
|
135
|
+
// list — the service computed it off the PLAIN key at publish
|
|
136
|
+
// time (the registry can never reverse the wire's hashed `k`).
|
|
137
|
+
// The DELIVERED frame is the wire shape (the internal field
|
|
138
|
+
// stripped — the stream list is routing metadata, never wire).
|
|
139
|
+
case "surface.changed": {
|
|
140
|
+
// SAFETY: the hub carries the service's internal event (the
|
|
141
|
+
// wire shape plus the publish-time stream list); every
|
|
142
|
+
// surface.changed the hub sees comes from bumpRev, which
|
|
143
|
+
// always attaches `streams`.
|
|
144
|
+
const internal = event;
|
|
145
|
+
if (!internal.streams.includes(streamId))
|
|
146
|
+
return undefined;
|
|
147
|
+
return {
|
|
148
|
+
type: "surface.changed",
|
|
149
|
+
slug: event.slug,
|
|
150
|
+
id: event.id,
|
|
151
|
+
k: event.k,
|
|
152
|
+
rev: event.rev,
|
|
153
|
+
treeRev: event.treeRev,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
120
156
|
// An unknown member is NEVER delivered — deny by construction,
|
|
121
157
|
// not by enumerating the leak shapes. The satisfies check keeps
|
|
122
158
|
// this arm exhaustive: a new HostEventWire member without a
|
|
@@ -146,6 +182,9 @@ export function registerEventRoutes(app, ctx) {
|
|
|
146
182
|
finished = true;
|
|
147
183
|
clearInterval(heartbeat);
|
|
148
184
|
unsubscribe();
|
|
185
|
+
// The watcher registry's stream-close rule (spec §7.3): every
|
|
186
|
+
// entry this stream held drops with it.
|
|
187
|
+
ctx.surfaces.watcherRegistry.dropStream(streamId);
|
|
149
188
|
raw.end();
|
|
150
189
|
};
|
|
151
190
|
// Client disconnect: `response` close (socket termination) — NOT
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extension asset routes (UI-blocks U3 Task 3, spec §11.1/§7.2):
|
|
3
|
+
* `GET /v1/ext/<slug>/assets/<path>` serves files from the package's
|
|
4
|
+
* declared `sigrun.assets` directory to every signed-in user (assets
|
|
5
|
+
* are public to signed-in users, §11.1). The Content-Type is sniffed
|
|
6
|
+
* from the bytes (never the filename) and every response carries
|
|
7
|
+
* `X-Content-Type-Options: nosniff`.
|
|
8
|
+
*
|
|
9
|
+
* Containment: the requested path resolves against the assets dir and
|
|
10
|
+
* BOTH the dir and the target are `realpath`ed — a `..` segment or a
|
|
11
|
+
* symlink out of the dir answers 404, indistinguishable from a missing
|
|
12
|
+
* file. A package with no declared `sigrun.assets` answers 404 for
|
|
13
|
+
* every asset request.
|
|
14
|
+
*/
|
|
15
|
+
import { open, realpath } from "node:fs/promises";
|
|
16
|
+
import { join, relative, sep } from "node:path";
|
|
17
|
+
import { sniffMime } from "../surfaces/mime.js";
|
|
18
|
+
/** One read's chunk size for the sniff buffer + small files. */
|
|
19
|
+
const READ_CHUNK_BYTES = 1024 * 1024;
|
|
20
|
+
/**
|
|
21
|
+
* Resolve one requested asset path inside the package's assets dir:
|
|
22
|
+
* the realpath'd absolute file, or `undefined` when the request escapes
|
|
23
|
+
* the dir (a `..` segment or a symlink out) or the file is missing.
|
|
24
|
+
*/
|
|
25
|
+
async function resolveContained(assetsDir, path) {
|
|
26
|
+
let realDir;
|
|
27
|
+
try {
|
|
28
|
+
realDir = await realpath(assetsDir);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return undefined; // the declared dir doesn't exist
|
|
32
|
+
}
|
|
33
|
+
const candidate = join(realDir, path);
|
|
34
|
+
let realFile;
|
|
35
|
+
try {
|
|
36
|
+
realFile = await realpath(candidate);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return undefined; // missing file (or a dangling symlink)
|
|
40
|
+
}
|
|
41
|
+
const rel = relative(realDir, realFile);
|
|
42
|
+
if (rel === "" || rel.startsWith("..") || rel.includes(`..${sep}`)) {
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
return realFile;
|
|
46
|
+
}
|
|
47
|
+
/** Register the asset route over the shared context's bindings. */
|
|
48
|
+
export function registerExtAssetRoutes(app, ctx) {
|
|
49
|
+
app.get("/v1/ext/:slug/assets/*", async (request, reply) => {
|
|
50
|
+
const { slug } = request.params;
|
|
51
|
+
const path = request.params["*"];
|
|
52
|
+
const binding = ctx.opened.extensionSurfaces.find((entry) => entry.extension === slug);
|
|
53
|
+
// Unknown slug, or a package with no declared assets dir: 404.
|
|
54
|
+
if (binding?.assetsDir === undefined) {
|
|
55
|
+
return await reply.code(404).send({ error: "asset_not_found" });
|
|
56
|
+
}
|
|
57
|
+
const file = await resolveContained(binding.assetsDir, path);
|
|
58
|
+
if (file === undefined) {
|
|
59
|
+
return await reply.code(404).send({ error: "asset_not_found" });
|
|
60
|
+
}
|
|
61
|
+
const handle = await open(file, "r");
|
|
62
|
+
try {
|
|
63
|
+
const stat = await handle.stat();
|
|
64
|
+
if (!stat.isFile()) {
|
|
65
|
+
return await reply.code(404).send({ error: "asset_not_found" });
|
|
66
|
+
}
|
|
67
|
+
const bytes = Buffer.alloc(stat.size);
|
|
68
|
+
let offset = 0;
|
|
69
|
+
while (offset < bytes.byteLength) {
|
|
70
|
+
const { bytesRead } = await handle.read(bytes, offset, Math.min(READ_CHUNK_BYTES, bytes.byteLength - offset), offset);
|
|
71
|
+
if (bytesRead === 0)
|
|
72
|
+
break;
|
|
73
|
+
offset += bytesRead;
|
|
74
|
+
}
|
|
75
|
+
return await reply
|
|
76
|
+
.header("content-type", sniffMime(new Uint8Array(bytes)))
|
|
77
|
+
.header("x-content-type-options", "nosniff")
|
|
78
|
+
.send(bytes);
|
|
79
|
+
}
|
|
80
|
+
finally {
|
|
81
|
+
await handle.close();
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
}
|