@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
|
@@ -17,24 +17,31 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
|
19
19
|
import { accessOf, isSharing, } from "@sigrun/shared/access";
|
|
20
|
+
import { extname } from "node:path";
|
|
20
21
|
import { touchActivity } from "../harness/activity.js";
|
|
21
22
|
import { resolveServedModelRefreshing } from "../harness/providers/refresh.js";
|
|
22
|
-
import {
|
|
23
|
+
import { isSuperuser } from "./auth/principal.js";
|
|
24
|
+
import { isConfinedPath, parseSubmissionInput, resolveParts } from "./content-parts.js";
|
|
23
25
|
import { CommandUsageError, expandInput, EXPANDED_ENTRY_KIND, } from "../commands/expand.js";
|
|
24
|
-
import { conversationEnv, readView, SCAN_PAGE_SIZE, storeAgentChange, WHEN_BUSY_MODES, } from "./helpers.js";
|
|
25
|
-
import { DEFAULT_SESSION_META, SessionMetaDoc, } from "./session-meta.js";
|
|
26
|
+
import { conversationEnv, readView, SCAN_PAGE_SIZE, storeAgentChange, THINKING_LEVELS, WHEN_BUSY_MODES, } from "./helpers.js";
|
|
27
|
+
import { applySessionMetaPatch, DEFAULT_SESSION_META, SessionMetaDoc, validateSessionMetaPatch, } from "./session-meta.js";
|
|
26
28
|
/**
|
|
27
|
-
* One client error from a conversation op: the exact
|
|
28
|
-
* serve (`{error: "invalid_input"}`, `{error: "invalid_field", field}`)
|
|
29
|
-
*
|
|
30
|
-
*
|
|
29
|
+
* One client error from a conversation op: the exact body the routes
|
|
30
|
+
* serve (`{error: "invalid_input"}`, `{error: "invalid_field", field}`)
|
|
31
|
+
* plus the HTTP status it maps to — 400 for the validation vocabulary,
|
|
32
|
+
* 404 for `no_active_submission`, 403 for the sharing gate — so one
|
|
33
|
+
* route-side mapping (`reply.code(err.status).send(err.body)`) covers
|
|
34
|
+
* every op error, and the bridge caller sees the same fail-loud
|
|
35
|
+
* vocabulary.
|
|
31
36
|
*/
|
|
32
37
|
export class ConversationOpError extends Error {
|
|
33
38
|
body;
|
|
34
|
-
|
|
39
|
+
status;
|
|
40
|
+
constructor(body, status = 400) {
|
|
35
41
|
super(body.error);
|
|
36
42
|
this.body = body;
|
|
37
43
|
this.name = "ConversationOpError";
|
|
44
|
+
this.status = status;
|
|
38
45
|
}
|
|
39
46
|
}
|
|
40
47
|
/** The §7.1 ceiling for `?waitMs` — a positive integer at most this. */
|
|
@@ -66,6 +73,57 @@ async function waitForSubmission(submission, waitMs) {
|
|
|
66
73
|
clearTimeout(timer);
|
|
67
74
|
}
|
|
68
75
|
}
|
|
76
|
+
/**
|
|
77
|
+
* Validate + resolve the `agent` selection (gateway C4): shape first
|
|
78
|
+
* (an object whose members are string arrays — non-object →
|
|
79
|
+
* `invalid_field` `agent`, a non-array member or non-string entry →
|
|
80
|
+
* `invalid_field` naming `agent.<member>`), then names against the
|
|
81
|
+
* registry snapshot (`extension(name)` for extensions; `tools()`
|
|
82
|
+
* entries' `tool` for tools — `AgentChange.tools` takes
|
|
83
|
+
* ToolRegistration OBJECTS, not names). An unknown name throws
|
|
84
|
+
* `invalid_field` naming `agent.<member>`.
|
|
85
|
+
*/
|
|
86
|
+
function resolveAgentSelection(ctx, agent) {
|
|
87
|
+
const snapshot = ctx.opened.registry.snapshot();
|
|
88
|
+
const resolved = {};
|
|
89
|
+
if (agent.extensions !== undefined) {
|
|
90
|
+
const extensions = [];
|
|
91
|
+
for (const name of agent.extensions) {
|
|
92
|
+
const extension = snapshot.extension(name);
|
|
93
|
+
if (extension === undefined) {
|
|
94
|
+
throw new ConversationOpError({
|
|
95
|
+
error: "invalid_field",
|
|
96
|
+
field: "agent.extensions",
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
extensions.push(extension);
|
|
100
|
+
}
|
|
101
|
+
resolved.extensions = extensions;
|
|
102
|
+
}
|
|
103
|
+
if (agent.tools !== undefined) {
|
|
104
|
+
// Tools may repeat across extensions (RegistrySnapshot.tools); a
|
|
105
|
+
// name selects the FIRST registered tool with that name — the same
|
|
106
|
+
// resolution `resolveAgent`'s composed map applies.
|
|
107
|
+
const byName = new Map();
|
|
108
|
+
for (const entry of snapshot.tools()) {
|
|
109
|
+
if (!byName.has(entry.tool.name))
|
|
110
|
+
byName.set(entry.tool.name, entry.tool);
|
|
111
|
+
}
|
|
112
|
+
const tools = [];
|
|
113
|
+
for (const name of agent.tools) {
|
|
114
|
+
const tool = byName.get(name);
|
|
115
|
+
if (tool === undefined) {
|
|
116
|
+
throw new ConversationOpError({
|
|
117
|
+
error: "invalid_field",
|
|
118
|
+
field: "agent.tools",
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
tools.push(tool);
|
|
122
|
+
}
|
|
123
|
+
resolved.tools = tools;
|
|
124
|
+
}
|
|
125
|
+
return resolved;
|
|
126
|
+
}
|
|
69
127
|
/**
|
|
70
128
|
* Create one conversation the server-create way: ownerless ownership, the
|
|
71
129
|
* boot default model plus the store's agent settings (re-read per create),
|
|
@@ -87,14 +145,47 @@ export async function createConversationOp(ctx, input = {}) {
|
|
|
87
145
|
if (input.source !== undefined && typeof input.source !== "string") {
|
|
88
146
|
throw new ConversationOpError({ error: "invalid_field", field: "source" });
|
|
89
147
|
}
|
|
148
|
+
// The agent selection (gateway C4): shape fail-loud first, then name
|
|
149
|
+
// resolution against the registry snapshot (unknown names throw).
|
|
150
|
+
if (input.agent !== undefined) {
|
|
151
|
+
if (typeof input.agent !== "object" ||
|
|
152
|
+
input.agent === null ||
|
|
153
|
+
Array.isArray(input.agent)) {
|
|
154
|
+
throw new ConversationOpError({ error: "invalid_field", field: "agent" });
|
|
155
|
+
}
|
|
156
|
+
for (const member of ["extensions", "tools"]) {
|
|
157
|
+
const value = input.agent[member];
|
|
158
|
+
if (value === undefined)
|
|
159
|
+
continue;
|
|
160
|
+
if (!Array.isArray(value) ||
|
|
161
|
+
value.some((name) => typeof name !== "string")) {
|
|
162
|
+
throw new ConversationOpError({
|
|
163
|
+
error: "invalid_field",
|
|
164
|
+
field: `agent.${member}`,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
const selection = input.agent === undefined
|
|
170
|
+
? undefined
|
|
171
|
+
: resolveAgentSelection(ctx, input.agent);
|
|
90
172
|
const conversation = await opened.harness.createConversation({
|
|
91
173
|
ownership: { kind: "ownerless" },
|
|
92
174
|
// A conversation with no agent model cannot generate (no_model):
|
|
93
175
|
// server-created conversations start on the boot default model,
|
|
94
176
|
// like the root conversation, and carry the store's
|
|
95
177
|
// defaultTools/perModelThinking — re-read per create, so PATCHes
|
|
96
|
-
// reach new conversations without a restart.
|
|
97
|
-
|
|
178
|
+
// reach new conversations without a restart. The C4 `agent`
|
|
179
|
+
// selection rides alongside: `extensions`/`tools` pin the stored
|
|
180
|
+
// filters by name while the store's `modelTools` narrowing still
|
|
181
|
+
// applies (both come from storeAgentChange + the spread).
|
|
182
|
+
agent: {
|
|
183
|
+
...storeAgentChange(opened, opened.defaultModel),
|
|
184
|
+
...(selection?.extensions === undefined
|
|
185
|
+
? {}
|
|
186
|
+
: { extensions: selection.extensions }),
|
|
187
|
+
...(selection?.tools === undefined ? {} : { tools: selection.tools }),
|
|
188
|
+
},
|
|
98
189
|
}, BACKGROUND_CONTEXT);
|
|
99
190
|
// Creation IS activity: stamp lastActivityAt now, or the sidebar's
|
|
100
191
|
// recency sort puts a never-submitted conversation at the BOTTOM of
|
|
@@ -306,3 +397,384 @@ export async function readMetaOp(opened, id) {
|
|
|
306
397
|
return ((await opened.harness.snapshot(SessionMetaDoc, id, BACKGROUND_CONTEXT)) ??
|
|
307
398
|
DEFAULT_SESSION_META);
|
|
308
399
|
}
|
|
400
|
+
/**
|
|
401
|
+
* Configure one conversation's model and/or thinking level (the
|
|
402
|
+
* configure route's body, verbatim): EXACTLY `model` + `thinking` — any
|
|
403
|
+
* other key is `invalid_field`; a body changing neither is
|
|
404
|
+
* `invalid_body`; `model` is `"provider/modelId"` split on the FIRST
|
|
405
|
+
* `/` (a modelId may itself contain slashes) with both parts non-empty;
|
|
406
|
+
* `thinking` must be a pi-ai level. A model change re-applies the
|
|
407
|
+
* store's `perModelThinking` override for the NEW model unless the
|
|
408
|
+
* request pins `thinking` (the store is the deployment default, the
|
|
409
|
+
* explicit field the caller's win — re-read here so a PATCH reaches
|
|
410
|
+
* later configures). Returns the configured surface projection
|
|
411
|
+
* (`{model?, thinkingLevel}`).
|
|
412
|
+
*/
|
|
413
|
+
export async function configureOp(ctx, conversation, body) {
|
|
414
|
+
const { opened } = ctx;
|
|
415
|
+
if (body === undefined ||
|
|
416
|
+
body === null ||
|
|
417
|
+
typeof body !== "object" ||
|
|
418
|
+
Array.isArray(body)) {
|
|
419
|
+
throw new ConversationOpError({ error: "invalid_body" });
|
|
420
|
+
}
|
|
421
|
+
const record = body;
|
|
422
|
+
const unknownKey = Object.keys(record).find((key) => key !== "model" && key !== "thinking");
|
|
423
|
+
if (unknownKey !== undefined) {
|
|
424
|
+
throw new ConversationOpError({
|
|
425
|
+
error: "invalid_field",
|
|
426
|
+
field: unknownKey,
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
const model = record.model;
|
|
430
|
+
const thinking = record.thinking;
|
|
431
|
+
if (model === undefined && thinking === undefined) {
|
|
432
|
+
// A configure that changes nothing is a client error, not a no-op.
|
|
433
|
+
throw new ConversationOpError({ error: "invalid_body" });
|
|
434
|
+
}
|
|
435
|
+
let parsedModel;
|
|
436
|
+
if (model !== undefined) {
|
|
437
|
+
if (typeof model !== "string" || !model.includes("/")) {
|
|
438
|
+
throw new ConversationOpError({ error: "invalid_field", field: "model" });
|
|
439
|
+
}
|
|
440
|
+
const slash = model.indexOf("/");
|
|
441
|
+
const provider = model.slice(0, slash);
|
|
442
|
+
const modelId = model.slice(slash + 1);
|
|
443
|
+
if (provider.length === 0 || modelId.length === 0) {
|
|
444
|
+
throw new ConversationOpError({ error: "invalid_field", field: "model" });
|
|
445
|
+
}
|
|
446
|
+
parsedModel = { provider, modelId };
|
|
447
|
+
}
|
|
448
|
+
if (thinking !== undefined &&
|
|
449
|
+
(typeof thinking !== "string" || !THINKING_LEVELS.includes(thinking))) {
|
|
450
|
+
throw new ConversationOpError({ error: "invalid_field", field: "thinking" });
|
|
451
|
+
}
|
|
452
|
+
// perModelThinking: a model change re-applies the store's override for
|
|
453
|
+
// the NEW model unless the request pins `thinking` — the map is the
|
|
454
|
+
// deployment default, the explicit field the caller's win.
|
|
455
|
+
const storeThinking = storeThinkingFor(opened, parsedModel);
|
|
456
|
+
await conversation.configure({
|
|
457
|
+
...(parsedModel === undefined ? {} : { model: parsedModel }),
|
|
458
|
+
...(thinking !== undefined
|
|
459
|
+
? { thinkingLevel: thinking }
|
|
460
|
+
: storeThinking === undefined
|
|
461
|
+
? {}
|
|
462
|
+
: { thinkingLevel: storeThinking }),
|
|
463
|
+
}, BACKGROUND_CONTEXT);
|
|
464
|
+
// Read the agent back and project the configured surface only —
|
|
465
|
+
// resolved extensions/tools/sections stay server-side.
|
|
466
|
+
const agent = await conversation.agent(BACKGROUND_CONTEXT);
|
|
467
|
+
return {
|
|
468
|
+
...(agent.model === undefined ? {} : { model: agent.model }),
|
|
469
|
+
thinkingLevel: agent.thinkingLevel,
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
/** The store's per-model thinking override for `model` (re-read per call). */
|
|
473
|
+
function storeThinkingFor(opened, model) {
|
|
474
|
+
if (model === undefined)
|
|
475
|
+
return undefined;
|
|
476
|
+
return opened.settings.read().perModelThinking?.[`${model.provider}/${model.modelId}`];
|
|
477
|
+
}
|
|
478
|
+
/**
|
|
479
|
+
* Withdraw/settle the conversation's active submission (the abort
|
|
480
|
+
* route's body, verbatim): `inspect()` is the read-only union of placed
|
|
481
|
+
* run inputs and queued inbox items (all conversations); filtered to
|
|
482
|
+
* this conversation, the LOWEST id is the active one — anything queued
|
|
483
|
+
* behind a busy conversation was admitted later and has a higher id.
|
|
484
|
+
* Nothing active is the 404 `no_active_submission`; a `not_found` abort
|
|
485
|
+
* result (the record vanished between inspect and abort) is the same
|
|
486
|
+
* honest answer. `already_placed`/`settled` still report which state
|
|
487
|
+
* the submission was found in (abort is idempotent).
|
|
488
|
+
*/
|
|
489
|
+
export async function abortOp(ctx, conversation) {
|
|
490
|
+
const { opened } = ctx;
|
|
491
|
+
const inspection = await opened.harness.inspect(BACKGROUND_CONTEXT);
|
|
492
|
+
// Lowest live id = oldest admitted = the placed run input over later-
|
|
493
|
+
// queued inbox items. reduce() with a first-element seed is ordering-
|
|
494
|
+
// independent (a future pi-durable inspect() ordering change cannot
|
|
495
|
+
// silently target the wrong submission); empty → undefined.
|
|
496
|
+
const active = inspection.submissions
|
|
497
|
+
.filter((s) => s.conversationId === conversation.id)
|
|
498
|
+
.reduce((min, s) => (min === undefined || s.id < min.id ? s : min), undefined);
|
|
499
|
+
if (active === undefined) {
|
|
500
|
+
throw new ConversationOpError({ error: "no_active_submission" }, 404);
|
|
501
|
+
}
|
|
502
|
+
const result = await opened.harness.abortSubmission(active.id, BACKGROUND_CONTEXT, conversation.id);
|
|
503
|
+
// `not_found` means the submission's record vanished between `inspect`
|
|
504
|
+
// and `abort` — the conversation is no longer busy, so the honest answer
|
|
505
|
+
// is the same as nothing-active.
|
|
506
|
+
if (result === "not_found") {
|
|
507
|
+
throw new ConversationOpError({ error: "no_active_submission" }, 404);
|
|
508
|
+
}
|
|
509
|
+
return { result, submissionId: active.id };
|
|
510
|
+
}
|
|
511
|
+
/**
|
|
512
|
+
* Admit one manual compaction (the compact route's op): `instructions`
|
|
513
|
+
* is validated here — a non-string is `invalid_field`, an empty string
|
|
514
|
+
* reads as none (the route's normalization) — and `Conversation.compact`
|
|
515
|
+
* creates the conversation-owned durable task with `reason: "manual"`:
|
|
516
|
+
* it compacts even when idle and places its summary through a write
|
|
517
|
+
* submission. Manual compaction ignores the policy's `enabled`; the
|
|
518
|
+
* task id is handed back (the SSE stream carries the `pi.compaction`
|
|
519
|
+
* entry when the summary lands). Returns the admit shape `{taskId}`;
|
|
520
|
+
* the caller owns the 202.
|
|
521
|
+
*/
|
|
522
|
+
export async function compactOp(ctx, conversation, instructions) {
|
|
523
|
+
// The route's instructions validation, moved in: the HTTP caller's
|
|
524
|
+
// raw value and the host's typed value take the SAME check (host
|
|
525
|
+
// inputs are typed, but the op still validates).
|
|
526
|
+
if (instructions !== undefined && typeof instructions !== "string") {
|
|
527
|
+
throw new ConversationOpError({
|
|
528
|
+
error: "invalid_field",
|
|
529
|
+
field: "instructions",
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
const normalized = instructions === "" ? undefined : instructions;
|
|
533
|
+
const taskId = await conversation.compact(normalized, BACKGROUND_CONTEXT);
|
|
534
|
+
return { taskId };
|
|
535
|
+
}
|
|
536
|
+
/**
|
|
537
|
+
* Fork one conversation's history at a visible entry (the fork route's
|
|
538
|
+
* body, verbatim): absent `at` defaults to the newest entry (400
|
|
539
|
+
* `empty_conversation` when the parent has no history at all); an
|
|
540
|
+
* explicit `at` must be an id the parent actually had (400
|
|
541
|
+
* `invalid_field` — never a silent empty fork). The fork inherits the
|
|
542
|
+
* parent's agent model (falling back to the boot default like create),
|
|
543
|
+
* and ONE commit resets the copied meta: name/autoName/attempts/sharing
|
|
544
|
+
* dropped, the owner re-stamped from `opts.owner` (undefined = the key
|
|
545
|
+
* removed), the autoName seeded `"<source> (fork)"` when the source had
|
|
546
|
+
* a display name, and activity stamped so the fork sorts where it
|
|
547
|
+
* happened. Returns the create shape `{id, view}`.
|
|
548
|
+
*/
|
|
549
|
+
export async function forkOp(ctx, conversation, opts = {}) {
|
|
550
|
+
const { opened } = ctx;
|
|
551
|
+
// The route's `at` type check, verbatim: absent body = newest entry;
|
|
552
|
+
// a present `at` must be a number (the host's typed EntryId passes).
|
|
553
|
+
if (opts.at !== undefined && typeof opts.at !== "number") {
|
|
554
|
+
throw new ConversationOpError({ error: "invalid_field", field: "at" });
|
|
555
|
+
}
|
|
556
|
+
let at = opts.at;
|
|
557
|
+
if (at === undefined) {
|
|
558
|
+
// The entries query is newest-first: the first item of the first
|
|
559
|
+
// page is the newest entry. An empty page means there is nothing
|
|
560
|
+
// to fork — the parent conversation has no history at all.
|
|
561
|
+
const page = await conversation.entries({}, 1, undefined, BACKGROUND_CONTEXT);
|
|
562
|
+
if (page.items.length === 0) {
|
|
563
|
+
throw new ConversationOpError({ error: "empty_conversation" });
|
|
564
|
+
}
|
|
565
|
+
at = page.items[0].id;
|
|
566
|
+
}
|
|
567
|
+
else {
|
|
568
|
+
// Validate `at` against the parent's own history: `fork()` accepts
|
|
569
|
+
// any EntryId, but an id this conversation never had is a client
|
|
570
|
+
// error, not a silent empty fork.
|
|
571
|
+
const page = await conversation.entries({ maxEntryId: at }, 1, undefined, BACKGROUND_CONTEXT);
|
|
572
|
+
const newest = page.items[0];
|
|
573
|
+
if (newest === undefined || newest.id !== at) {
|
|
574
|
+
throw new ConversationOpError({ error: "invalid_field", field: "at" });
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
// The fork inherits the parent's agent model — read the resolved
|
|
578
|
+
// agent, fall back to the boot default like create.
|
|
579
|
+
const agent = await conversation.agent(BACKGROUND_CONTEXT);
|
|
580
|
+
const fork = await conversation.fork(at, {
|
|
581
|
+
ownership: { kind: "ownerless" },
|
|
582
|
+
agent: { model: agent.model ?? opened.defaultModel },
|
|
583
|
+
}, BACKGROUND_CONTEXT);
|
|
584
|
+
// The fork's sidebar identity: the copied meta doc carries the
|
|
585
|
+
// source's name/autoName and the namer's attempt count; ONE commit
|
|
586
|
+
// drops all three (a fork is a new conversation, not the source's
|
|
587
|
+
// name — and it starts naming-fresh: inheriting an exhausted attempt
|
|
588
|
+
// count would make the fork forever unnameable), re-seeds it with
|
|
589
|
+
// "<source display name> (fork)" as its AUTO name when the source
|
|
590
|
+
// had one, and stamps activity so the fresh fork sorts where it
|
|
591
|
+
// happened. §5.9 (Review Focus 3): the doc copied the source's
|
|
592
|
+
// `owner` and `sharing` (fork: "current") — the SAME commit resets
|
|
593
|
+
// them: a fork is the FORKER's private copy (owner = the caller,
|
|
594
|
+
// sharing deleted; an ownerless caller means no owner at all — the
|
|
595
|
+
// key is removed).
|
|
596
|
+
const sourceMeta = await opened.harness.snapshot(SessionMetaDoc, conversation.id, BACKGROUND_CONTEXT);
|
|
597
|
+
const sourceName = sourceMeta?.name ?? sourceMeta?.autoName;
|
|
598
|
+
await fork.commit(async (tx) => {
|
|
599
|
+
const draft = await tx.doc(SessionMetaDoc, fork.id);
|
|
600
|
+
delete draft.name;
|
|
601
|
+
delete draft.autoName;
|
|
602
|
+
delete draft.autoNameAttempts;
|
|
603
|
+
delete draft.sharing;
|
|
604
|
+
if (opts.owner === undefined)
|
|
605
|
+
delete draft.owner;
|
|
606
|
+
else
|
|
607
|
+
draft.owner = opts.owner;
|
|
608
|
+
if (sourceName !== undefined)
|
|
609
|
+
draft.autoName = `${sourceName} (fork)`;
|
|
610
|
+
draft.lastActivityAt = opened.now().toISOString();
|
|
611
|
+
}, BACKGROUND_CONTEXT);
|
|
612
|
+
const view = await readView(fork);
|
|
613
|
+
return { id: fork.id, view };
|
|
614
|
+
}
|
|
615
|
+
/**
|
|
616
|
+
* Patch one conversation's session meta (the meta PATCH route's body,
|
|
617
|
+
* verbatim): validate FIRST (a rejected patch never touches the doc),
|
|
618
|
+
* then — when a principal is given and the patch carries `sharing` —
|
|
619
|
+
* the owner-or-superuser gate (403 `forbidden`), then the
|
|
620
|
+
* read-modify-write merge inside ONE commit callback (concurrent
|
|
621
|
+
* patches cannot lose each other's fields; the mutation line
|
|
622
|
+
* serializes commits). The response is a FRESH post-commit snapshot —
|
|
623
|
+
* reading a newly-SET nested draft value trips chord's settled-overlay
|
|
624
|
+
* rule, so the reply must never read the mutated draft.
|
|
625
|
+
*/
|
|
626
|
+
export async function patchMetaOp(ctx, conversation, body, opts = {}) {
|
|
627
|
+
const { opened } = ctx;
|
|
628
|
+
// Validate FIRST — a rejected patch never touches the doc.
|
|
629
|
+
const validated = validateSessionMetaPatch(body);
|
|
630
|
+
if ("error" in validated) {
|
|
631
|
+
throw new ConversationOpError(validated);
|
|
632
|
+
}
|
|
633
|
+
// §5.9: `sharing` additionally requires the OWNER or a superuser —
|
|
634
|
+
// write access (granted by a share) must not let a recipient re-share
|
|
635
|
+
// the resource. Host callers pass no principal (trusted).
|
|
636
|
+
if (opts.principal !== undefined && validated.sharing !== undefined) {
|
|
637
|
+
const meta = await opened.harness.snapshot(SessionMetaDoc, conversation.id, BACKGROUND_CONTEXT);
|
|
638
|
+
const isOwner = opts.principal.user !== undefined &&
|
|
639
|
+
opts.principal.user === meta?.owner;
|
|
640
|
+
if (!isOwner && !isSuperuser(opts.principal)) {
|
|
641
|
+
throw new ConversationOpError({ error: "forbidden" }, 403);
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
// Read-modify-write inside ONE commit: tx.doc materializes the doc
|
|
645
|
+
// (initial()) when absent and the patch mutates the tracked draft.
|
|
646
|
+
await conversation.commit(async (tx) => {
|
|
647
|
+
const draft = await tx.doc(SessionMetaDoc, conversation.id);
|
|
648
|
+
applySessionMetaPatch(draft, validated);
|
|
649
|
+
}, BACKGROUND_CONTEXT);
|
|
650
|
+
// The response is a FRESH snapshot, not a draft projection (see the
|
|
651
|
+
// doc comment).
|
|
652
|
+
return ((await opened.harness.snapshot(SessionMetaDoc, conversation.id, BACKGROUND_CONTEXT)) ?? DEFAULT_SESSION_META);
|
|
653
|
+
}
|
|
654
|
+
// ─── Upload + raw read (gateway C4b — the files routes' ops) ───────────────
|
|
655
|
+
/**
|
|
656
|
+
* The one upload-name rule (gateway C4b): no `/` or `\`, not `.` or
|
|
657
|
+
* `..`, non-empty. The single validator BOTH upload routes and the host
|
|
658
|
+
* `conversations.upload` op share — the routes pre-validate with it to
|
|
659
|
+
* keep their exact `{error: "invalid_body"}` wire bodies while the op
|
|
660
|
+
* throws the host-side `invalid_field` `name` vocabulary.
|
|
661
|
+
*/
|
|
662
|
+
export function validUploadName(name) {
|
|
663
|
+
return (name.length > 0 &&
|
|
664
|
+
!name.includes("/") &&
|
|
665
|
+
!name.includes("\\") &&
|
|
666
|
+
name !== "." &&
|
|
667
|
+
name !== "..");
|
|
668
|
+
}
|
|
669
|
+
/**
|
|
670
|
+
* The extension→mime map (spec §7.8): the common workspace-file
|
|
671
|
+
* extensions a client renders inline; an absent extension serves
|
|
672
|
+
* `application/octet-stream`. The KEY is the LOWERCASE extension
|
|
673
|
+
* including the dot (the lookup lowercases, so `Photo.PNG` maps too).
|
|
674
|
+
* One map for the raw route AND the host `readFile` op (its `mime`
|
|
675
|
+
* field) — never two.
|
|
676
|
+
*/
|
|
677
|
+
export const MIME_BY_EXTENSION = new Map([
|
|
678
|
+
[".png", "image/png"],
|
|
679
|
+
[".jpg", "image/jpeg"],
|
|
680
|
+
[".jpeg", "image/jpeg"],
|
|
681
|
+
[".gif", "image/gif"],
|
|
682
|
+
[".webp", "image/webp"],
|
|
683
|
+
[".svg", "image/svg+xml"],
|
|
684
|
+
[".pdf", "application/pdf"],
|
|
685
|
+
[".txt", "text/plain"],
|
|
686
|
+
[".md", "text/markdown"],
|
|
687
|
+
[".json", "application/json"],
|
|
688
|
+
[".html", "text/html"],
|
|
689
|
+
[".css", "text/css"],
|
|
690
|
+
[".js", "text/javascript"],
|
|
691
|
+
]);
|
|
692
|
+
/**
|
|
693
|
+
* Write one upload into the conversation's upload namespace (gateway
|
|
694
|
+
* C4b, the raw-upload/base64 routes' shared core): validates the NAME
|
|
695
|
+
* (the one rule — a bad name throws `invalid_field` `name`; the routes
|
|
696
|
+
* pre-validate with `validUploadName` to keep their `invalid_body`
|
|
697
|
+
* bodies byte-identical), writes `uploads/<conversationId>/<name>`
|
|
698
|
+
* through the conversation's workspace env (correct for remote
|
|
699
|
+
* workspaces), and returns the path exactly as a content part takes it.
|
|
700
|
+
* A failed env write is a server-side fault: fail loud.
|
|
701
|
+
*/
|
|
702
|
+
export async function uploadOp(ctx, conversation, file) {
|
|
703
|
+
if (!validUploadName(file.name)) {
|
|
704
|
+
throw new ConversationOpError({ error: "invalid_field", field: "name" });
|
|
705
|
+
}
|
|
706
|
+
// `mime` is validated (never persisted — the workspace write is the
|
|
707
|
+
// file's own bytes; a content part carries its own mime) because JS
|
|
708
|
+
// extensions cross this signature untyped at runtime: the op is the
|
|
709
|
+
// host caller's only validation point, like compactOp's instructions.
|
|
710
|
+
if (file.mime !== undefined && typeof file.mime !== "string") {
|
|
711
|
+
throw new ConversationOpError({ error: "invalid_field", field: "mime" });
|
|
712
|
+
}
|
|
713
|
+
// v1 namespaces uploads per conversation (workspaces are static and
|
|
714
|
+
// shared in v1, so un-namespaced names would clobber across
|
|
715
|
+
// conversations).
|
|
716
|
+
const path = `uploads/${conversation.id}/${file.name}`;
|
|
717
|
+
const env = await conversationEnv(ctx.opened, conversation);
|
|
718
|
+
const written = await env.writeFile(path, file.bytes, BACKGROUND_CONTEXT);
|
|
719
|
+
if (!written.ok) {
|
|
720
|
+
throw written.error;
|
|
721
|
+
}
|
|
722
|
+
return { path };
|
|
723
|
+
}
|
|
724
|
+
/**
|
|
725
|
+
* Read one workspace file raw (gateway C4b, the files/raw route's op):
|
|
726
|
+
* the route's confinement + cap vocabulary as op errors — non-confined/
|
|
727
|
+
* empty/`.`/directory → `invalid_path` (400), absent → `not_found`
|
|
728
|
+
* (404), over `server.maxFileReadBytes` → `too_large` (413; the HTTP
|
|
729
|
+
* route keeps its own `file_too_large` + `maxBytes` body). The read is
|
|
730
|
+
* `readBinaryFile` (the op buffers — the host caller wants the bytes;
|
|
731
|
+
* the ROUTE keeps its chunked streaming through `readFileMetaOp`'s env
|
|
732
|
+
* so it never buffers) with the extension-derived mime from the shared
|
|
733
|
+
* map (unknown extensions read as `application/octet-stream`, the raw
|
|
734
|
+
* route's own default — one behavior, never two).
|
|
735
|
+
*/
|
|
736
|
+
export async function readFileOp(ctx, conversation, path) {
|
|
737
|
+
const { env, mime } = await readFileMetaOp(ctx, conversation, path);
|
|
738
|
+
const read = await env.readBinaryFile(path, BACKGROUND_CONTEXT);
|
|
739
|
+
if (!read.ok) {
|
|
740
|
+
if (read.error.code === "not_found") {
|
|
741
|
+
throw new ConversationOpError({ error: "not_found" }, 404);
|
|
742
|
+
}
|
|
743
|
+
throw read.error;
|
|
744
|
+
}
|
|
745
|
+
return { bytes: read.value, mime };
|
|
746
|
+
}
|
|
747
|
+
/**
|
|
748
|
+
* The raw-read pre-checks (gateway C4b): the shared validation BOTH the
|
|
749
|
+
* `files/raw` route and the host `readFileOp` run — the content route's
|
|
750
|
+
* confinement rule (`isConfinedPath`; empty and `.` rejected too), the
|
|
751
|
+
* directory rejection, and the `server.maxFileReadBytes` cap — plus the
|
|
752
|
+
* size/mime the caller needs to serve the file and the conversation env
|
|
753
|
+
* it serves through (one env build per read, never two). The route maps
|
|
754
|
+
* `too_large` onto its own `file_too_large` + `maxBytes` body; every
|
|
755
|
+
* other op error maps 1:1 onto the route's existing responses.
|
|
756
|
+
*/
|
|
757
|
+
export async function readFileMetaOp(ctx, conversation, path) {
|
|
758
|
+
if (!isConfinedPath(path) || path.length === 0 || path === ".") {
|
|
759
|
+
throw new ConversationOpError({ error: "invalid_path" });
|
|
760
|
+
}
|
|
761
|
+
const env = await conversationEnv(ctx.opened, conversation);
|
|
762
|
+
const info = await env.fileInfo(path, BACKGROUND_CONTEXT);
|
|
763
|
+
if (!info.ok) {
|
|
764
|
+
if (info.error.code === "not_found") {
|
|
765
|
+
throw new ConversationOpError({ error: "not_found" }, 404);
|
|
766
|
+
}
|
|
767
|
+
throw info.error;
|
|
768
|
+
}
|
|
769
|
+
if (info.value.kind === "directory") {
|
|
770
|
+
throw new ConversationOpError({ error: "invalid_path" });
|
|
771
|
+
}
|
|
772
|
+
if (info.value.size > ctx.config.maxFileReadBytes) {
|
|
773
|
+
throw new ConversationOpError({ error: "too_large" }, 413);
|
|
774
|
+
}
|
|
775
|
+
return {
|
|
776
|
+
env,
|
|
777
|
+
size: info.value.size,
|
|
778
|
+
mime: MIME_BY_EXTENSION.get(extname(path).toLowerCase()) ?? "application/octet-stream",
|
|
779
|
+
};
|
|
780
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The gateway engine lifecycle (gateway C2, spec §8.0).
|
|
3
|
+
*
|
|
4
|
+
* `startGateway` wraps the ONE loaded engine (boot's
|
|
5
|
+
* `OpenedHarness.gatewayEngine` — first declarer wins) in a
|
|
6
|
+
* {@link GatewayHandle}: the serve composition creates the handle BEFORE
|
|
7
|
+
* `createServer` (threaded through `ServerContext.gateway` so the meta
|
|
8
|
+
* route reaches it) and starts it after `app.listen`.
|
|
9
|
+
*
|
|
10
|
+
* The no-engine deploy (core ships no engine) gets a degenerate handle:
|
|
11
|
+
* every method resolves, `health()` reads "ok" but is NEVER consulted —
|
|
12
|
+
* the meta route keys `health.gateway` on `opened.gatewayEngine`, not on
|
|
13
|
+
* the handle, so a deploy with channel adapters and no engine reports NO
|
|
14
|
+
* gateway health (the adapters are inert; the extensions inventory marks
|
|
15
|
+
* their rows `inert: "no gateway"`).
|
|
16
|
+
*
|
|
17
|
+
* Failure posture (the spec's Review Focus 1): a `start` rejection is
|
|
18
|
+
* logged once (`gateway: engine start failed`) and LATCHES degraded —
|
|
19
|
+
* `started` still RESOLVES (a failed engine must not wedge boot or the
|
|
20
|
+
* ordered shutdown); a throwing `health()` reads "degraded" and logs
|
|
21
|
+
* once per process (never per call). Health stays degraded until
|
|
22
|
+
* restart — recovery is a new process, never a live re-start.
|
|
23
|
+
*/
|
|
24
|
+
/**
|
|
25
|
+
* The degenerate no-engine handle: everything resolves, health reads
|
|
26
|
+
* "ok" (never consulted — the meta route keys on
|
|
27
|
+
* `opened.gatewayEngine`, not the handle).
|
|
28
|
+
*/
|
|
29
|
+
function noEngineHandle() {
|
|
30
|
+
return {
|
|
31
|
+
started: Promise.resolve(),
|
|
32
|
+
health: () => "ok",
|
|
33
|
+
stopIntake: () => Promise.resolve(),
|
|
34
|
+
stop: () => Promise.resolve(),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* The once-per-process no-engine warning flag: adapters contributed
|
|
39
|
+
* channels but no engine loaded, so the adapters are INERT (the
|
|
40
|
+
* `/v1/extensions` inventory marks their rows `inert: "no gateway"`).
|
|
41
|
+
* Logged once per process — `startGateway` may be called more than once
|
|
42
|
+
* over one boot (tests), and a deploy reboot re-runs it legitimately
|
|
43
|
+
* only on a new process.
|
|
44
|
+
*/
|
|
45
|
+
let noEngineWarned = false;
|
|
46
|
+
/** Start the loaded gateway engine, wrapping it in the shutdown-side handle. */
|
|
47
|
+
export function startGateway(opened) {
|
|
48
|
+
const binding = opened.gatewayEngine;
|
|
49
|
+
if (binding === undefined) {
|
|
50
|
+
if (opened.channelBindings.length > 0 && !noEngineWarned) {
|
|
51
|
+
noEngineWarned = true;
|
|
52
|
+
console.warn("channels contributed but no gateway engine is loaded");
|
|
53
|
+
}
|
|
54
|
+
return noEngineHandle();
|
|
55
|
+
}
|
|
56
|
+
const engine = binding.engine;
|
|
57
|
+
// The shutdown signal: `stop()` aborts the controller the engine's
|
|
58
|
+
// start ctx carried — receive loops observe it (the same contract as a
|
|
59
|
+
// `ChannelContext.signal`).
|
|
60
|
+
const abort = new AbortController();
|
|
61
|
+
const ctx = {
|
|
62
|
+
channels: opened.channelBindings.flatMap((binding) => binding.channels.map((channel) => ({
|
|
63
|
+
extension: binding.extension,
|
|
64
|
+
channel,
|
|
65
|
+
}))),
|
|
66
|
+
signal: abort.signal,
|
|
67
|
+
};
|
|
68
|
+
// Latched by a start rejection: health reads degraded until restart.
|
|
69
|
+
let startFailed = false;
|
|
70
|
+
// LAZY start: the handle is created BEFORE `createServer` (the meta
|
|
71
|
+
// route needs it at construction) but the ENGINE starts on the first
|
|
72
|
+
// `started` read — the serve composition reads it after `app.listen`
|
|
73
|
+
// (gateway C2's lifecycle: start after listen). One promise forever
|
|
74
|
+
// (re-reads join the same start).
|
|
75
|
+
let startPromise;
|
|
76
|
+
const beginStart = () => {
|
|
77
|
+
startPromise ??= engine
|
|
78
|
+
.start(ctx)
|
|
79
|
+
.catch((error) => {
|
|
80
|
+
startFailed = true;
|
|
81
|
+
console.error("gateway: engine start failed", error);
|
|
82
|
+
});
|
|
83
|
+
return startPromise;
|
|
84
|
+
};
|
|
85
|
+
// IDEMPOTENT stop, the mirror of beginStart: the FIRST call runs
|
|
86
|
+
// `abort.abort()` + `engine.stop()`; every later call (and any racing
|
|
87
|
+
// the first) joins the SAME promise — a double-stop never calls
|
|
88
|
+
// `engine.stop()` twice (the interface documents that contract).
|
|
89
|
+
let stopPromise;
|
|
90
|
+
const stopOnce = () => {
|
|
91
|
+
stopPromise ??= (async () => {
|
|
92
|
+
abort.abort();
|
|
93
|
+
await engine.stop();
|
|
94
|
+
})();
|
|
95
|
+
return stopPromise;
|
|
96
|
+
};
|
|
97
|
+
// A throwing health() logs ONCE per process — the meta route calls
|
|
98
|
+
// health per request, and a persistently-throwing engine would spam
|
|
99
|
+
// the log on every poll otherwise.
|
|
100
|
+
let healthFailureLogged = false;
|
|
101
|
+
return {
|
|
102
|
+
get started() {
|
|
103
|
+
return beginStart();
|
|
104
|
+
},
|
|
105
|
+
health: () => {
|
|
106
|
+
if (startFailed)
|
|
107
|
+
return "degraded";
|
|
108
|
+
try {
|
|
109
|
+
return engine.health();
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
if (!healthFailureLogged) {
|
|
113
|
+
healthFailureLogged = true;
|
|
114
|
+
console.error("gateway: engine health failed", error);
|
|
115
|
+
}
|
|
116
|
+
return "degraded";
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
stopIntake: () => engine.stopIntake(),
|
|
120
|
+
stop: stopOnce,
|
|
121
|
+
};
|
|
122
|
+
}
|