@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.
Files changed (130) hide show
  1. package/CHANGELOG.md +204 -0
  2. package/CONTEXT.md +163 -0
  3. package/LICENSE +21 -0
  4. package/README.md +691 -2
  5. package/THIRD_PARTY_NOTICES.md +28 -0
  6. package/dist/backup/marker.js +80 -0
  7. package/dist/backup/run-backup.js +339 -0
  8. package/dist/cli.js +952 -0
  9. package/dist/commands/expand.js +245 -0
  10. package/dist/commands/templates.js +272 -0
  11. package/dist/config.js +1146 -0
  12. package/dist/extension.d.ts +431 -0
  13. package/dist/harness/activity.js +42 -0
  14. package/dist/harness/ambient.js +261 -0
  15. package/dist/harness/auth.js +130 -0
  16. package/dist/harness/boot.js +865 -0
  17. package/dist/harness/builtins.js +191 -0
  18. package/dist/harness/event-bus.js +1203 -0
  19. package/dist/harness/health.js +30 -0
  20. package/dist/harness/identity/index.js +37 -0
  21. package/dist/harness/identity/invites.js +94 -0
  22. package/dist/harness/identity/json-store.js +108 -0
  23. package/dist/harness/identity/setup-code.js +81 -0
  24. package/dist/harness/identity/tokens.js +101 -0
  25. package/dist/harness/identity/users.js +93 -0
  26. package/dist/harness/interactions.js +283 -0
  27. package/dist/harness/loader.js +1236 -0
  28. package/dist/harness/memory-default.js +35 -0
  29. package/dist/harness/naming.js +193 -0
  30. package/dist/harness/packages.js +1002 -0
  31. package/dist/harness/providers/auth-bridge.js +60 -0
  32. package/dist/harness/providers/index.js +277 -0
  33. package/dist/harness/providers/models-store.js +105 -0
  34. package/dist/harness/providers/refresh.js +161 -0
  35. package/dist/harness/providers/scheduler.js +89 -0
  36. package/dist/harness/registry.js +9 -0
  37. package/dist/harness/reserved-sections.js +24 -0
  38. package/dist/harness/seam-host.js +239 -0
  39. package/dist/harness/sections/context-files.js +78 -0
  40. package/dist/harness/sections/model-guidance.js +77 -0
  41. package/dist/harness/settings.js +1053 -0
  42. package/dist/harness/stores.js +450 -0
  43. package/dist/harness/tools.js +500 -0
  44. package/dist/harness/workspace.js +73 -0
  45. package/dist/index.js +39 -0
  46. package/dist/jobs/docs.js +257 -0
  47. package/dist/jobs/fire.js +342 -0
  48. package/dist/jobs/index.js +228 -0
  49. package/dist/jobs/observe.js +165 -0
  50. package/dist/jobs/reconcile.js +245 -0
  51. package/dist/jobs/schedule.js +77 -0
  52. package/dist/jobs/spec.js +248 -0
  53. package/dist/jobs/task.js +190 -0
  54. package/dist/jobs/transfer.js +53 -0
  55. package/dist/serve.js +164 -0
  56. package/dist/server/auth/claim-lock.js +28 -0
  57. package/dist/server/auth/ip.js +51 -0
  58. package/dist/server/auth/login-limiter.js +52 -0
  59. package/dist/server/auth/loopback.js +47 -0
  60. package/dist/server/auth/pipeline.js +330 -0
  61. package/dist/server/auth/principal.js +26 -0
  62. package/dist/server/commands.js +198 -0
  63. package/dist/server/content-parts.js +142 -0
  64. package/dist/server/context.js +13 -0
  65. package/dist/server/conversation-handler.js +61 -0
  66. package/dist/server/conversation-ops.js +310 -0
  67. package/dist/server/events/hub.js +39 -0
  68. package/dist/server/events/types.js +8 -0
  69. package/dist/server/helpers.js +351 -0
  70. package/dist/server/host-bridge.js +131 -0
  71. package/dist/server/index.js +232 -0
  72. package/dist/server/notifications.js +185 -0
  73. package/dist/server/ownership.js +67 -0
  74. package/dist/server/readiness.js +36 -0
  75. package/dist/server/routes/auth.js +172 -0
  76. package/dist/server/routes/conversations.js +341 -0
  77. package/dist/server/routes/events.js +161 -0
  78. package/dist/server/routes/extension-routes.js +127 -0
  79. package/dist/server/routes/extensions.js +168 -0
  80. package/dist/server/routes/files.js +308 -0
  81. package/dist/server/routes/interactions.js +260 -0
  82. package/dist/server/routes/jobs.js +271 -0
  83. package/dist/server/routes/meta.js +146 -0
  84. package/dist/server/routes/metrics.js +219 -0
  85. package/dist/server/routes/onboarding.js +268 -0
  86. package/dist/server/routes/probes.js +53 -0
  87. package/dist/server/routes/providers.js +198 -0
  88. package/dist/server/routes/settings.js +48 -0
  89. package/dist/server/routes/stream.js +131 -0
  90. package/dist/server/routes/submissions.js +317 -0
  91. package/dist/server/routes/users.js +169 -0
  92. package/dist/server/schema-fixture.js +950 -0
  93. package/dist/server/session-meta.js +247 -0
  94. package/dist/server/static.js +169 -0
  95. package/dist/server/views.js +71 -0
  96. package/dist/skills/index.js +418 -0
  97. package/node_modules/@sigrun/shared/CHANGELOG.md +16 -0
  98. package/node_modules/@sigrun/shared/LICENSE +21 -0
  99. package/node_modules/@sigrun/shared/README.md +10 -0
  100. package/node_modules/@sigrun/shared/dist/access.d.ts +55 -0
  101. package/node_modules/@sigrun/shared/dist/access.js +72 -0
  102. package/node_modules/@sigrun/shared/dist/ambient.d.ts +111 -0
  103. package/node_modules/@sigrun/shared/dist/ambient.js +23 -0
  104. package/node_modules/@sigrun/shared/dist/commands.d.ts +75 -0
  105. package/node_modules/@sigrun/shared/dist/commands.js +25 -0
  106. package/node_modules/@sigrun/shared/dist/conversations.d.ts +31 -0
  107. package/node_modules/@sigrun/shared/dist/conversations.js +94 -0
  108. package/node_modules/@sigrun/shared/dist/interactions.d.ts +91 -0
  109. package/node_modules/@sigrun/shared/dist/interactions.js +21 -0
  110. package/node_modules/@sigrun/shared/dist/jobs.d.ts +100 -0
  111. package/node_modules/@sigrun/shared/dist/jobs.js +19 -0
  112. package/node_modules/@sigrun/shared/dist/json.d.ts +20 -0
  113. package/node_modules/@sigrun/shared/dist/json.js +13 -0
  114. package/node_modules/@sigrun/shared/dist/session-meta.d.ts +103 -0
  115. package/node_modules/@sigrun/shared/dist/session-meta.js +13 -0
  116. package/node_modules/@sigrun/shared/dist/settings.d.ts +401 -0
  117. package/node_modules/@sigrun/shared/dist/settings.js +19 -0
  118. package/node_modules/@sigrun/shared/dist/themes.d.ts +50 -0
  119. package/node_modules/@sigrun/shared/dist/themes.js +156 -0
  120. package/node_modules/@sigrun/shared/dist/turns.d.ts +102 -0
  121. package/node_modules/@sigrun/shared/dist/turns.js +166 -0
  122. package/node_modules/@sigrun/shared/dist/view-delta.d.ts +54 -0
  123. package/node_modules/@sigrun/shared/dist/view-delta.js +135 -0
  124. package/node_modules/@sigrun/shared/dist/views.d.ts +742 -0
  125. package/node_modules/@sigrun/shared/dist/views.js +38 -0
  126. package/node_modules/@sigrun/shared/package.json +83 -0
  127. package/package.json +90 -5
  128. package/webui/dist/assets/index-Rl0KhCLa.css +1 -0
  129. package/webui/dist/assets/index-rOiARZLF.js +50 -0
  130. 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";