@sigrun/sigrun 0.0.0-stage → 0.1.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 +204 -0
- package/CONTEXT.md +163 -0
- package/LICENSE +21 -0
- package/README.md +691 -2
- package/THIRD_PARTY_NOTICES.md +28 -0
- package/dist/backup/marker.js +80 -0
- package/dist/backup/run-backup.js +339 -0
- package/dist/cli.js +952 -0
- package/dist/commands/expand.js +245 -0
- package/dist/commands/templates.js +272 -0
- package/dist/config.js +1146 -0
- package/dist/extension.d.ts +431 -0
- package/dist/harness/activity.js +42 -0
- package/dist/harness/ambient.js +261 -0
- package/dist/harness/auth.js +130 -0
- package/dist/harness/boot.js +865 -0
- package/dist/harness/builtins.js +191 -0
- package/dist/harness/event-bus.js +1203 -0
- package/dist/harness/health.js +30 -0
- package/dist/harness/identity/index.js +37 -0
- package/dist/harness/identity/invites.js +94 -0
- package/dist/harness/identity/json-store.js +108 -0
- package/dist/harness/identity/setup-code.js +81 -0
- package/dist/harness/identity/tokens.js +101 -0
- package/dist/harness/identity/users.js +93 -0
- package/dist/harness/interactions.js +283 -0
- package/dist/harness/loader.js +1236 -0
- package/dist/harness/memory-default.js +35 -0
- package/dist/harness/naming.js +193 -0
- package/dist/harness/packages.js +1002 -0
- package/dist/harness/providers/auth-bridge.js +60 -0
- package/dist/harness/providers/index.js +277 -0
- package/dist/harness/providers/models-store.js +105 -0
- package/dist/harness/providers/refresh.js +161 -0
- package/dist/harness/providers/scheduler.js +89 -0
- package/dist/harness/registry.js +9 -0
- package/dist/harness/reserved-sections.js +24 -0
- package/dist/harness/seam-host.js +239 -0
- package/dist/harness/sections/context-files.js +78 -0
- package/dist/harness/sections/model-guidance.js +77 -0
- package/dist/harness/settings.js +1053 -0
- package/dist/harness/stores.js +450 -0
- package/dist/harness/tools.js +500 -0
- package/dist/harness/workspace.js +73 -0
- package/dist/index.js +39 -0
- package/dist/jobs/docs.js +257 -0
- package/dist/jobs/fire.js +342 -0
- package/dist/jobs/index.js +228 -0
- package/dist/jobs/observe.js +165 -0
- package/dist/jobs/reconcile.js +245 -0
- package/dist/jobs/schedule.js +77 -0
- package/dist/jobs/spec.js +248 -0
- package/dist/jobs/task.js +190 -0
- package/dist/jobs/transfer.js +53 -0
- package/dist/serve.js +164 -0
- package/dist/server/auth/claim-lock.js +28 -0
- package/dist/server/auth/ip.js +51 -0
- package/dist/server/auth/login-limiter.js +52 -0
- package/dist/server/auth/loopback.js +47 -0
- package/dist/server/auth/pipeline.js +330 -0
- package/dist/server/auth/principal.js +26 -0
- package/dist/server/commands.js +198 -0
- package/dist/server/content-parts.js +142 -0
- package/dist/server/context.js +13 -0
- package/dist/server/conversation-handler.js +61 -0
- package/dist/server/conversation-ops.js +310 -0
- package/dist/server/events/hub.js +39 -0
- package/dist/server/events/types.js +8 -0
- package/dist/server/helpers.js +351 -0
- package/dist/server/host-bridge.js +131 -0
- package/dist/server/index.js +232 -0
- package/dist/server/notifications.js +185 -0
- package/dist/server/ownership.js +67 -0
- package/dist/server/readiness.js +36 -0
- package/dist/server/routes/auth.js +172 -0
- package/dist/server/routes/conversations.js +341 -0
- package/dist/server/routes/events.js +161 -0
- package/dist/server/routes/extension-routes.js +127 -0
- package/dist/server/routes/extensions.js +168 -0
- package/dist/server/routes/files.js +308 -0
- package/dist/server/routes/interactions.js +260 -0
- package/dist/server/routes/jobs.js +271 -0
- package/dist/server/routes/meta.js +146 -0
- package/dist/server/routes/metrics.js +219 -0
- package/dist/server/routes/onboarding.js +268 -0
- package/dist/server/routes/probes.js +53 -0
- package/dist/server/routes/providers.js +198 -0
- package/dist/server/routes/settings.js +48 -0
- package/dist/server/routes/stream.js +131 -0
- package/dist/server/routes/submissions.js +317 -0
- package/dist/server/routes/users.js +169 -0
- package/dist/server/schema-fixture.js +950 -0
- package/dist/server/session-meta.js +247 -0
- package/dist/server/static.js +169 -0
- package/dist/server/views.js +71 -0
- package/dist/skills/index.js +418 -0
- package/node_modules/@sigrun/shared/CHANGELOG.md +16 -0
- package/node_modules/@sigrun/shared/LICENSE +21 -0
- package/node_modules/@sigrun/shared/README.md +10 -0
- package/node_modules/@sigrun/shared/dist/access.d.ts +55 -0
- package/node_modules/@sigrun/shared/dist/access.js +72 -0
- package/node_modules/@sigrun/shared/dist/ambient.d.ts +111 -0
- package/node_modules/@sigrun/shared/dist/ambient.js +23 -0
- package/node_modules/@sigrun/shared/dist/commands.d.ts +75 -0
- package/node_modules/@sigrun/shared/dist/commands.js +25 -0
- package/node_modules/@sigrun/shared/dist/conversations.d.ts +31 -0
- package/node_modules/@sigrun/shared/dist/conversations.js +94 -0
- package/node_modules/@sigrun/shared/dist/interactions.d.ts +91 -0
- package/node_modules/@sigrun/shared/dist/interactions.js +21 -0
- package/node_modules/@sigrun/shared/dist/jobs.d.ts +100 -0
- package/node_modules/@sigrun/shared/dist/jobs.js +19 -0
- package/node_modules/@sigrun/shared/dist/json.d.ts +20 -0
- package/node_modules/@sigrun/shared/dist/json.js +13 -0
- package/node_modules/@sigrun/shared/dist/session-meta.d.ts +103 -0
- package/node_modules/@sigrun/shared/dist/session-meta.js +13 -0
- package/node_modules/@sigrun/shared/dist/settings.d.ts +401 -0
- package/node_modules/@sigrun/shared/dist/settings.js +19 -0
- package/node_modules/@sigrun/shared/dist/themes.d.ts +50 -0
- package/node_modules/@sigrun/shared/dist/themes.js +156 -0
- package/node_modules/@sigrun/shared/dist/turns.d.ts +102 -0
- package/node_modules/@sigrun/shared/dist/turns.js +166 -0
- package/node_modules/@sigrun/shared/dist/view-delta.d.ts +54 -0
- package/node_modules/@sigrun/shared/dist/view-delta.js +135 -0
- package/node_modules/@sigrun/shared/dist/views.d.ts +742 -0
- package/node_modules/@sigrun/shared/dist/views.js +38 -0
- package/node_modules/@sigrun/shared/package.json +83 -0
- package/package.json +90 -5
- package/webui/dist/assets/index-Rl0KhCLa.css +1 -0
- package/webui/dist/assets/index-rOiARZLF.js +50 -0
- package/webui/dist/index.html +37 -0
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The session-meta document (spec §5): conversation-scoped sidebar state.
|
|
3
|
+
*
|
|
4
|
+
* SPLIT (build-and-webui Task 1): the WIRE types (`SessionMeta`,
|
|
5
|
+
* `SessionMetaPatch`, `SessionMetaPatchError`) live in the
|
|
6
|
+
* `@sigrun/shared` package (`shared/src/session-meta.ts` — the canonical
|
|
7
|
+
* declarations both clients import); this module owns the document and
|
|
8
|
+
* patch RUNTIME and re-exports the types for server-side importers.
|
|
9
|
+
*
|
|
10
|
+
* One latest-only singleton doc per conversation (kind
|
|
11
|
+
* `"sigrun.session-meta"`): pin/tag/archive flags, the composer draft
|
|
12
|
+
* (survives reloads — `draft.attachments` are workspace-relative paths from
|
|
13
|
+
* the uploads endpoint), the session source, the user-facing session name
|
|
14
|
+
* (T5d), and the multi-user `owner` seam (spec §5: "exists in schema from
|
|
15
|
+
* day 1; the flow does not" — written by the auth hook at create/fork/
|
|
16
|
+
* import and read by the listing route; still no interactive flow in v1).
|
|
17
|
+
*
|
|
18
|
+
* Docs are standalone tokens: `defineDoc` needs no registry install (a
|
|
19
|
+
* gated extension's policy doc is the reference pattern). Reads serve `DEFAULT_SESSION_META` when the doc was
|
|
20
|
+
* never written; only a PATCH materializes it — a read that wrote would
|
|
21
|
+
* churn a doc revision on every sidebar poll, and defaults are free.
|
|
22
|
+
*
|
|
23
|
+
* PATCH semantics (spec §5: "PATCH is a MERGE; explicit `null` CLEARS a
|
|
24
|
+
* field"): each top-level key shallow-sets; `tagged` REPLACES wholesale
|
|
25
|
+
* (arrays never merge element-wise); `draft` sub-merges its own fields;
|
|
26
|
+
* `null` resets the field to its default value — `owner: null` removes the
|
|
27
|
+
* key entirely. The merge runs as a read-modify-write inside ONE
|
|
28
|
+
* `Conversation.commit` callback, so concurrent patches cannot lose each
|
|
29
|
+
* other's fields (the mutation line serializes commits).
|
|
30
|
+
*
|
|
31
|
+
* Server-owned fields (`autoName`, `autoNameAttempts`, `lastActivityAt`)
|
|
32
|
+
* are written by host code only (activity.ts, the A2 auto-namer); they
|
|
33
|
+
* are NOT patch surface — the strict validator keeps rejecting them as
|
|
34
|
+
* `invalid_field`.
|
|
35
|
+
*/
|
|
36
|
+
import { defineDoc } from "@earendil-works/pi-durable";
|
|
37
|
+
import { isSharing } from "@sigrun/shared/access";
|
|
38
|
+
/** What a never-written doc — and every explicit `null` clear — means. */
|
|
39
|
+
export const DEFAULT_SESSION_META = {
|
|
40
|
+
pinned: false,
|
|
41
|
+
tagged: [],
|
|
42
|
+
archived: false,
|
|
43
|
+
draft: { text: "", attachments: [] },
|
|
44
|
+
source: "user",
|
|
45
|
+
};
|
|
46
|
+
/** The per-conversation session-meta document (latest-only singleton). */
|
|
47
|
+
export const SessionMetaDoc = defineDoc({
|
|
48
|
+
kind: "sigrun.session-meta",
|
|
49
|
+
version: 1,
|
|
50
|
+
scope: "conversation",
|
|
51
|
+
history: "latest",
|
|
52
|
+
fork: "current",
|
|
53
|
+
// Defensive copy (the alias-free-ownership pattern): the tracker takes
|
|
54
|
+
// alias-free ownership of the root, so the shared DEFAULT constant must
|
|
55
|
+
// never become a tracked value.
|
|
56
|
+
initial: () => ({
|
|
57
|
+
...DEFAULT_SESSION_META,
|
|
58
|
+
tagged: [...DEFAULT_SESSION_META.tagged],
|
|
59
|
+
draft: {
|
|
60
|
+
...DEFAULT_SESSION_META.draft,
|
|
61
|
+
attachments: [...DEFAULT_SESSION_META.draft.attachments],
|
|
62
|
+
},
|
|
63
|
+
}),
|
|
64
|
+
});
|
|
65
|
+
/**
|
|
66
|
+
* Validate a PATCH body strictly (mirroring the configure endpoint):
|
|
67
|
+
* non-object/array/empty → `invalid_body`; an unknown top-level key →
|
|
68
|
+
* `invalid_field` naming the key; an unknown `draft` sub-key →
|
|
69
|
+
* `invalid_field` naming `draft.<key>`; wrong value types → `invalid_body`.
|
|
70
|
+
* `null` is valid everywhere (it is the clear operator, not a type error).
|
|
71
|
+
*
|
|
72
|
+
* Server-only keys (spec §5.9): a patch carrying `owner` or `source` is a
|
|
73
|
+
* 400 `invalid_field` EVEN when the value would once have been valid —
|
|
74
|
+
* `owner` moves via the auth hook alone, and `source` is server-owned.
|
|
75
|
+
* `sharing` is validated via `isSharing` (a malformed shape names the
|
|
76
|
+
* field).
|
|
77
|
+
*/
|
|
78
|
+
export function validateSessionMetaPatch(body) {
|
|
79
|
+
if (body === undefined ||
|
|
80
|
+
body === null ||
|
|
81
|
+
typeof body !== "object" ||
|
|
82
|
+
Array.isArray(body)) {
|
|
83
|
+
return { error: "invalid_body" };
|
|
84
|
+
}
|
|
85
|
+
const record = body;
|
|
86
|
+
// A patch that changes nothing is a client error, not a no-op (the
|
|
87
|
+
// configure endpoint's rule — fail loud).
|
|
88
|
+
if (Object.keys(record).length === 0) {
|
|
89
|
+
return { error: "invalid_body" };
|
|
90
|
+
}
|
|
91
|
+
const patch = {};
|
|
92
|
+
for (const key of Object.keys(record)) {
|
|
93
|
+
const value = record[key];
|
|
94
|
+
switch (key) {
|
|
95
|
+
case "pinned":
|
|
96
|
+
case "archived": {
|
|
97
|
+
if (value !== null && typeof value !== "boolean") {
|
|
98
|
+
return { error: "invalid_body" };
|
|
99
|
+
}
|
|
100
|
+
if (key === "pinned")
|
|
101
|
+
patch.pinned = value;
|
|
102
|
+
else
|
|
103
|
+
patch.archived = value;
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
case "tagged": {
|
|
107
|
+
if (value === null) {
|
|
108
|
+
patch.tagged = null;
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
if (!Array.isArray(value) ||
|
|
112
|
+
value.some((tag) => typeof tag !== "string")) {
|
|
113
|
+
return { error: "invalid_body" };
|
|
114
|
+
}
|
|
115
|
+
patch.tagged = value;
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
case "draft": {
|
|
119
|
+
if (value === null) {
|
|
120
|
+
patch.draft = null;
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
if (typeof value !== "object" || Array.isArray(value)) {
|
|
124
|
+
return { error: "invalid_body" };
|
|
125
|
+
}
|
|
126
|
+
const sub = {};
|
|
127
|
+
for (const subKey of Object.keys(value)) {
|
|
128
|
+
const subValue = value[subKey];
|
|
129
|
+
switch (subKey) {
|
|
130
|
+
case "text":
|
|
131
|
+
if (subValue !== null && typeof subValue !== "string") {
|
|
132
|
+
return { error: "invalid_body" };
|
|
133
|
+
}
|
|
134
|
+
sub.text = subValue;
|
|
135
|
+
break;
|
|
136
|
+
case "attachments":
|
|
137
|
+
if (subValue !== null &&
|
|
138
|
+
(!Array.isArray(subValue) ||
|
|
139
|
+
subValue.some((path) => typeof path !== "string"))) {
|
|
140
|
+
return { error: "invalid_body" };
|
|
141
|
+
}
|
|
142
|
+
sub.attachments = subValue;
|
|
143
|
+
break;
|
|
144
|
+
default:
|
|
145
|
+
return { error: "invalid_field", field: `draft.${subKey}` };
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
// An empty draft sub-object changes nothing — the same client-error
|
|
149
|
+
// rule the top-level empty body gets (fail-loud consistency).
|
|
150
|
+
if (Object.keys(sub).length === 0) {
|
|
151
|
+
return { error: "invalid_body" };
|
|
152
|
+
}
|
|
153
|
+
patch.draft = sub;
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
case "source":
|
|
157
|
+
case "owner": {
|
|
158
|
+
// Server-only (spec §5.9): the KEY itself is the client error,
|
|
159
|
+
// whatever the value — even a `null` clear.
|
|
160
|
+
return { error: "invalid_field", field: key };
|
|
161
|
+
}
|
|
162
|
+
case "sharing": {
|
|
163
|
+
if (value === null) {
|
|
164
|
+
patch.sharing = null;
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
if (!isSharing(value)) {
|
|
168
|
+
return { error: "invalid_field", field: "sharing" };
|
|
169
|
+
}
|
|
170
|
+
patch.sharing = value;
|
|
171
|
+
break;
|
|
172
|
+
}
|
|
173
|
+
case "name": {
|
|
174
|
+
if (value !== null && typeof value !== "string") {
|
|
175
|
+
return { error: "invalid_body" };
|
|
176
|
+
}
|
|
177
|
+
patch.name = value;
|
|
178
|
+
break;
|
|
179
|
+
}
|
|
180
|
+
default:
|
|
181
|
+
return { error: "invalid_field", field: key };
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return patch;
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Apply a validated patch to a session-meta draft (the merge/clear table):
|
|
188
|
+
*
|
|
189
|
+
* - `null` resets the field to its `DEFAULT_SESSION_META` value;
|
|
190
|
+
* `name: null` and `sharing: null` remove the key entirely.
|
|
191
|
+
* - non-null values shallow-set; `tagged` replaces wholesale.
|
|
192
|
+
* - `draft: null` resets the whole draft; a draft object sub-merges: a
|
|
193
|
+
* sub-key present sets (or `null`-clears) that sub-field, absent sub-keys
|
|
194
|
+
* keep the stored value.
|
|
195
|
+
*
|
|
196
|
+
* Array/object values are copied on assignment: the tracker requires
|
|
197
|
+
* alias-free roots, and the wire body must never become tracked state.
|
|
198
|
+
*/
|
|
199
|
+
export function applySessionMetaPatch(draft, patch) {
|
|
200
|
+
if (patch.pinned !== undefined)
|
|
201
|
+
draft.pinned = patch.pinned ?? false;
|
|
202
|
+
if (patch.archived !== undefined)
|
|
203
|
+
draft.archived = patch.archived ?? false;
|
|
204
|
+
if (patch.tagged !== undefined) {
|
|
205
|
+
draft.tagged = patch.tagged === null ? [] : [...patch.tagged];
|
|
206
|
+
}
|
|
207
|
+
// `sharing` clears the same way as `name`: absent = private, and it is
|
|
208
|
+
// not in DEFAULT_SESSION_META, so the clear target is key removal.
|
|
209
|
+
if (patch.sharing !== undefined) {
|
|
210
|
+
if (patch.sharing === null)
|
|
211
|
+
delete draft.sharing;
|
|
212
|
+
else if (patch.sharing.scope === "people") {
|
|
213
|
+
draft.sharing = {
|
|
214
|
+
scope: "people",
|
|
215
|
+
users: [...patch.sharing.users],
|
|
216
|
+
level: patch.sharing.level,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
else if (patch.sharing.scope === "household") {
|
|
220
|
+
draft.sharing = { scope: "household", level: patch.sharing.level };
|
|
221
|
+
}
|
|
222
|
+
else {
|
|
223
|
+
draft.sharing = { scope: "private" };
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
// `name` clears the same way: absent = unnamed.
|
|
227
|
+
if (patch.name !== undefined) {
|
|
228
|
+
if (patch.name === null)
|
|
229
|
+
delete draft.name;
|
|
230
|
+
else
|
|
231
|
+
draft.name = patch.name;
|
|
232
|
+
}
|
|
233
|
+
if (patch.draft !== undefined) {
|
|
234
|
+
if (patch.draft === null) {
|
|
235
|
+
draft.draft = { text: "", attachments: [] };
|
|
236
|
+
}
|
|
237
|
+
else {
|
|
238
|
+
if (patch.draft.text !== undefined) {
|
|
239
|
+
draft.draft.text = patch.draft.text ?? "";
|
|
240
|
+
}
|
|
241
|
+
if (patch.draft.attachments !== undefined) {
|
|
242
|
+
draft.draft.attachments =
|
|
243
|
+
patch.draft.attachments === null ? [] : [...patch.draft.attachments];
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The webui channel-bundle serving seam (Task 20, spec §4.1/§6).
|
|
3
|
+
*
|
|
4
|
+
* The M2 webui is a CHANNEL BUNDLE: deploy-time static assets (the vite build
|
|
5
|
+
* of the `webui/` workspace package), never a runtime extension, never
|
|
6
|
+
* quarantined. This module resolves the bundle from config and, when present,
|
|
7
|
+
* mounts it at `/` via `@fastify/static` with an SPA fallback:
|
|
8
|
+
*
|
|
9
|
+
* - `GET /` and asset paths serve from the bundle root (fastify-static's
|
|
10
|
+
* default index behavior);
|
|
11
|
+
* - unknown GETs outside `/v1` serve the bundle's `index.html` so
|
|
12
|
+
* client-side-route deep links work (the standard SPA fallback) — the
|
|
13
|
+
* index document is read ONCE at resolve time (a channel bundle is a
|
|
14
|
+
* deploy-time asset; a redeploy restarts the process, so there is no
|
|
15
|
+
* per-request fs read and no stale-content window);
|
|
16
|
+
* - `/v1/*` unknowns (and non-GET unknowns) keep Fastify's default-shape 404
|
|
17
|
+
* JSON, so the API error surface is identical with or without a bundle.
|
|
18
|
+
*
|
|
19
|
+
* Resolution (build-and-webui Task 5, at SERVE time — extension-contributed
|
|
20
|
+
* bundles only exist after the loader runs, so `createServer` threads the
|
|
21
|
+
* registry as the `contributedBundles` parameter):
|
|
22
|
+
*
|
|
23
|
+
* 1. `webui.enabled === false` → no mount, API-only (nothing else resolves
|
|
24
|
+
* — an invalid selector under a closed gate is not an error).
|
|
25
|
+
* 2. A selector that is a NAME (`^[a-z][a-z0-9_-]*$`) matching a
|
|
26
|
+
* contributed bundle → that bundle (its name is the reported channel).
|
|
27
|
+
* 3. A selector that is a PATH (contains `/` or `\`, or starts with `.`) →
|
|
28
|
+
* that directory, cwd-relative; a missing `index.html` is a boot error.
|
|
29
|
+
* A selector EQUAL to the default (`webui/dist`) keeps the DEFAULT's
|
|
30
|
+
* semantics — the bootstrap mirror story: a mirrored default must not
|
|
31
|
+
* flip to cwd-relative resolution.
|
|
32
|
+
* 4. Unset → the built-in default bundle; an absent default is the
|
|
33
|
+
* supported API-only deploy (no mount, `capabilities.channels` []).
|
|
34
|
+
*
|
|
35
|
+
* An EXPLICIT selector (name or path) that resolves to nothing is a boot
|
|
36
|
+
* error naming it — a misconfigured deploy intended the webui and must not
|
|
37
|
+
* boot API-only silently. A selector that is neither path-shaped nor a
|
|
38
|
+
* valid name (uppercase, spaces, …) is a validation error.
|
|
39
|
+
*/
|
|
40
|
+
import { existsSync } from "node:fs";
|
|
41
|
+
import { readFile } from "node:fs/promises";
|
|
42
|
+
import { dirname, join, resolve } from "node:path";
|
|
43
|
+
import { fileURLToPath } from "node:url";
|
|
44
|
+
import fastifyStatic from "@fastify/static";
|
|
45
|
+
import { DEFAULT_WEBUI_BUNDLE, } from "../config.js";
|
|
46
|
+
/** The channel id reported in `/v1/meta` `capabilities.channels`. */
|
|
47
|
+
export const WEBUI_CHANNEL = "webui";
|
|
48
|
+
/**
|
|
49
|
+
* The package root this module ships in (build-and-webui Task 3):
|
|
50
|
+
* `…/<package>/dist/server` (the built module) — `../..` is the package
|
|
51
|
+
* dir, both in the repo tree (`…/src/server` → the repo root, where
|
|
52
|
+
* `webui/dist` is the workspace's vite output) and in an installed
|
|
53
|
+
* package (`…/node_modules/sigrun/dist/server` → the package carrying
|
|
54
|
+
* `webui/dist` in its npm `files`). This is what the DEFAULT bundle
|
|
55
|
+
* anchors to — see {@link webuiBundleDir}.
|
|
56
|
+
*/
|
|
57
|
+
const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
58
|
+
/**
|
|
59
|
+
* Where the bundle directory for a selector lives on disk (build-and-webui
|
|
60
|
+
* Task 3, plan review focus #1). EXPLICIT paths are operator-authored and
|
|
61
|
+
* resolve against the process cwd — the operator's relative path means
|
|
62
|
+
* what they wrote, wherever sigrun was started from. The DEFAULT
|
|
63
|
+
* (`webui/dist`) anchors to the PACKAGE ROOT: an installed `sigrun` CLI run
|
|
64
|
+
* from an arbitrary working directory must find the bundle it ships, not
|
|
65
|
+
* the operator's cwd (an ABSOLUTE selector never reaches the `join` — it
|
|
66
|
+
* takes the `resolve` branch, which passes it through unchanged). A
|
|
67
|
+
* selector UNSET or
|
|
68
|
+
* EQUAL to the default keeps the default's anchor — the classification
|
|
69
|
+
* that pins the bootstrap mirror story (a mirrored `webui/dist` must not
|
|
70
|
+
* read as an explicit cwd-relative selection).
|
|
71
|
+
*/
|
|
72
|
+
export function webuiBundleDir(config) {
|
|
73
|
+
return config.webui.bundle === undefined ||
|
|
74
|
+
config.webui.bundle === DEFAULT_WEBUI_BUNDLE
|
|
75
|
+
? join(PACKAGE_ROOT, DEFAULT_WEBUI_BUNDLE)
|
|
76
|
+
: resolve(config.webui.bundle);
|
|
77
|
+
}
|
|
78
|
+
/** A name-shaped selector: the contributed-bundle grammar (spec §4.1). */
|
|
79
|
+
const BUNDLE_NAME = /^[a-z][a-z0-9_-]*$/;
|
|
80
|
+
/** Syntactically a PATH (spec §4.1): contains `/` or `\`, or starts with `.`. */
|
|
81
|
+
function isPathSelector(selector) {
|
|
82
|
+
return (selector.includes("/") ||
|
|
83
|
+
selector.includes("\\") ||
|
|
84
|
+
selector.startsWith("."));
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Read a bundle directory at serve time. `subject` names the selection in
|
|
88
|
+
* the boot error when the directory has no `index.html`.
|
|
89
|
+
*/
|
|
90
|
+
async function readBundle(dir, channel, subject) {
|
|
91
|
+
const indexPath = join(dir, "index.html");
|
|
92
|
+
if (!existsSync(indexPath)) {
|
|
93
|
+
throw new Error(`${subject} has no index.html at ${dir} — the configured webui bundle is missing (build it, or unset SIGRUN_WEBUI_BUNDLE / server.webui.bundle for an API-only deploy)`);
|
|
94
|
+
}
|
|
95
|
+
return { dir, indexHtml: await readFile(indexPath, "utf8"), channel };
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Resolve the configured bundle (Task 5's serve-time order: enabled gate →
|
|
99
|
+
* contributed NAME → PATH → default). Returns `undefined` for an absent
|
|
100
|
+
* default-path bundle or a closed gate (the API-only deploys); THROWS on
|
|
101
|
+
* an explicit selector that resolves to nothing and on a selector that is
|
|
102
|
+
* neither path-shaped nor a valid name.
|
|
103
|
+
*
|
|
104
|
+
* `contributedBundles` is the extension-contributed registry (Task 6
|
|
105
|
+
* threads it through `createServer`; an empty registry is the no-extension
|
|
106
|
+
* deploy).
|
|
107
|
+
*/
|
|
108
|
+
export async function resolveWebuiBundle(config, contributedBundles = []) {
|
|
109
|
+
// 1. The gate: `false` is a positively API-only deploy — nothing
|
|
110
|
+
// mounts and nothing else resolves (not even a bad selector).
|
|
111
|
+
if (!config.webui.enabled)
|
|
112
|
+
return undefined;
|
|
113
|
+
const selector = config.webui.bundle;
|
|
114
|
+
// 4. Unset — and the default's own value mirrored back through any
|
|
115
|
+
// selector slot — resolves the built-in default; an absent default
|
|
116
|
+
// is the supported API-only deploy (bare checkout, no webui build).
|
|
117
|
+
if (selector === undefined || selector === DEFAULT_WEBUI_BUNDLE) {
|
|
118
|
+
const dir = webuiBundleDir(config);
|
|
119
|
+
return existsSync(join(dir, "index.html"))
|
|
120
|
+
? await readBundle(dir, WEBUI_CHANNEL, `webui bundle ${JSON.stringify(selector)}`)
|
|
121
|
+
: undefined;
|
|
122
|
+
}
|
|
123
|
+
// 3. A PATH: cwd-relative (operator intent), missing index.html is a
|
|
124
|
+
// boot error — today's explicit rule.
|
|
125
|
+
if (isPathSelector(selector)) {
|
|
126
|
+
return await readBundle(resolve(selector), WEBUI_CHANNEL, `webui bundle ${JSON.stringify(selector)}`);
|
|
127
|
+
}
|
|
128
|
+
// 2. A NAME: the contributed registry. Anything else is a selector
|
|
129
|
+
// grammar error — never guessed at.
|
|
130
|
+
if (!BUNDLE_NAME.test(selector)) {
|
|
131
|
+
throw new Error(`webui bundle selector ${JSON.stringify(selector)} is neither a contributed-bundle name (lowercase letters, digits, "-" and "_", starting with a letter) nor a path (contains "/" or "\\", or starts with ".") — fix SIGRUN_WEBUI_BUNDLE / server.webui.bundle`);
|
|
132
|
+
}
|
|
133
|
+
const contributed = contributedBundles.find((bundle) => bundle.name === selector);
|
|
134
|
+
if (contributed === undefined) {
|
|
135
|
+
const available = contributedBundles.map((bundle) => bundle.name);
|
|
136
|
+
throw new Error(`webui bundle ${JSON.stringify(selector)} matches no contributed bundle (contributed: ${available.length === 0 ? "none" : available.join(", ")}) — fix SIGRUN_WEBUI_BUNDLE / server.webui.bundle or install the extension that contributes it`);
|
|
137
|
+
}
|
|
138
|
+
return await readBundle(contributed.dir, contributed.name, `webui bundle ${JSON.stringify(selector)} contributed by extension ${JSON.stringify(contributed.extension)}`);
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Mount the bundle at `/` and install the SPA fallback as the
|
|
142
|
+
* not-found handler. Explicit `/v1/*` (and probe) routes win over the static
|
|
143
|
+
* wildcard in Fastify's radix tree, so registration order against the API
|
|
144
|
+
* routes does not matter.
|
|
145
|
+
*/
|
|
146
|
+
export async function serveWebuiBundle(app, bundle) {
|
|
147
|
+
await app.register(fastifyStatic, {
|
|
148
|
+
root: bundle.dir,
|
|
149
|
+
prefix: "/",
|
|
150
|
+
});
|
|
151
|
+
app.setNotFoundHandler(async (request, reply) => {
|
|
152
|
+
const path = (request.raw.url ?? "/").split("?")[0] ?? "/";
|
|
153
|
+
if (request.method === "GET" &&
|
|
154
|
+
path !== "/v1" &&
|
|
155
|
+
!path.startsWith("/v1/")) {
|
|
156
|
+
return await reply
|
|
157
|
+
.code(200)
|
|
158
|
+
.type("text/html; charset=utf-8")
|
|
159
|
+
.send(bundle.indexHtml);
|
|
160
|
+
}
|
|
161
|
+
// Fastify's default 404 body shape, restated so the API error surface
|
|
162
|
+
// is byte-identical whether or not a bundle is mounted.
|
|
163
|
+
return await reply.code(404).send({
|
|
164
|
+
message: `Route ${request.method}:${path} not found`,
|
|
165
|
+
error: "Not Found",
|
|
166
|
+
statusCode: 404,
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* §5 wire serializers (Tasks 9a–9d) — the server half of the channel
|
|
3
|
+
* contract.
|
|
4
|
+
*
|
|
5
|
+
* SPLIT (build-and-webui Task 1): the wire TYPES live in the
|
|
6
|
+
* `@sigrun/shared` package (`shared/src/views.ts` — the canonical
|
|
7
|
+
* declarations both clients import; zero-dependency leaf, the intended
|
|
8
|
+
* basis for a generated OpenAPI schema later). This module owns the
|
|
9
|
+
* RUNTIME the server needs: the single serializer every §5 endpoint
|
|
10
|
+
* that returns a conversation goes through, plus a re-export surface so
|
|
11
|
+
* server-side importers keep resolving every wire type from here.
|
|
12
|
+
*
|
|
13
|
+
* Purity (test/views-purity.test.ts): this module may import TYPES from
|
|
14
|
+
* upstream (pi-durable/pi-ai, `import type` only) and re-export the
|
|
15
|
+
* shared package's wire types, but no server/framework runtime modules,
|
|
16
|
+
* no route logic, no `node:` imports — the webui/tui never import this
|
|
17
|
+
* file at runtime, and the rule keeps it that way.
|
|
18
|
+
*
|
|
19
|
+
* Spec §5: the server serves pi-durable's NATIVE views, not a bespoke
|
|
20
|
+
* endpoint list — so the conversation wire payload IS the
|
|
21
|
+
* `ConversationView` (`{ conversation, entries, docs }`; JSON-safe by
|
|
22
|
+
* construction: JsonObject docs, EntryRecords whose `model` messages
|
|
23
|
+
* are plain pi-ai JSON) PLUS the sigrun-side `steer` field (T9d,
|
|
24
|
+
* below). The captured-schema fixture (`schema-fixture.ts`,
|
|
25
|
+
* `view-schema.json`) pins the UPSTREAM shape only — `steer` is a
|
|
26
|
+
* sigrun projection, not upstream drift — and `test/schema-drift.test.ts`
|
|
27
|
+
* trips on upstream drift while asserting the wire is exactly view +
|
|
28
|
+
* `steer`.
|
|
29
|
+
*
|
|
30
|
+
* `serializeConversationView` is the single chokepoint — including each
|
|
31
|
+
* SSE watch frame (T9d) — so the fixture continues to describe the
|
|
32
|
+
* wire.
|
|
33
|
+
*/
|
|
34
|
+
/**
|
|
35
|
+
* Derive the steer field from the view's own docs — no extra inspect()/
|
|
36
|
+
* storage call per frame (the watch fires per revision; the docs are
|
|
37
|
+
* already in the frame).
|
|
38
|
+
*
|
|
39
|
+
* - `busy`: `docs["pi.live"].run` present — LiveState's run control
|
|
40
|
+
* (taskId + inputs) exists "present exactly while busy"
|
|
41
|
+
* (pi-durable live.d.ts).
|
|
42
|
+
* - `queued`: `docs["pi.inbox"].items` length — the built-in queue of
|
|
43
|
+
* submissions waiting for a boundary; absent doc = 0.
|
|
44
|
+
*/
|
|
45
|
+
function deriveSteer(view) {
|
|
46
|
+
// docs is an untyped Record<string, JsonObject>; both shapes are
|
|
47
|
+
// narrowed structurally (unexpected shapes fail soft to idle — upstream
|
|
48
|
+
// drift would surface in the schema fixture first).
|
|
49
|
+
const live = view.docs["pi.live"];
|
|
50
|
+
const busy = typeof live === "object" &&
|
|
51
|
+
live !== null &&
|
|
52
|
+
typeof live.run === "object" &&
|
|
53
|
+
live.run !== null;
|
|
54
|
+
const inbox = view.docs["pi.inbox"];
|
|
55
|
+
const items = typeof inbox === "object" && inbox !== null
|
|
56
|
+
? inbox.items
|
|
57
|
+
: undefined;
|
|
58
|
+
const queued = Array.isArray(items) ? items.length : 0;
|
|
59
|
+
return { busy, queued };
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Serialize one conversation view onto the wire: the upstream view plus
|
|
63
|
+
* the derived `steer` field. A shallow spread (upstream frames are
|
|
64
|
+
* immutable and JSON-safe; `conversation`/`entries`/`docs` are shared
|
|
65
|
+
* by reference — the projection adds one key, it never rewrites
|
|
66
|
+
* upstream state).
|
|
67
|
+
*/
|
|
68
|
+
export function serializeConversationView(view) {
|
|
69
|
+
return { ...view, steer: deriveSteer(view) };
|
|
70
|
+
}
|
|
71
|
+
export { JSONL_CONTENT_TYPE } from "@sigrun/shared/views";
|