@sigrun/sigrun 0.1.4 → 0.1.6

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 (82) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/README.md +24 -2
  3. package/THIRD_PARTY_NOTICES.md +23 -0
  4. package/dist/blocks/builders.js +125 -0
  5. package/dist/blocks/images.js +57 -0
  6. package/dist/blocks/index.js +20 -0
  7. package/dist/blocks/limits.js +26 -0
  8. package/dist/blocks/placements.js +99 -0
  9. package/dist/blocks/schema.js +559 -0
  10. package/dist/blocks/validate.js +602 -0
  11. package/dist/config.js +31 -0
  12. package/dist/extension.d.ts +476 -9
  13. package/dist/harness/agent-env.js +62 -0
  14. package/dist/harness/boot.js +8 -1
  15. package/dist/harness/event-bus.js +33 -0
  16. package/dist/harness/identity/users.js +3 -2
  17. package/dist/harness/loader.js +369 -13
  18. package/dist/harness/packages.js +71 -2
  19. package/dist/harness/seam-host.js +69 -2
  20. package/dist/harness/settings.js +5 -0
  21. package/dist/harness/workspace.js +6 -4
  22. package/dist/jobs/fire.js +11 -0
  23. package/dist/jobs/spec.js +19 -0
  24. package/dist/serve.js +29 -39
  25. package/dist/server/auth/pipeline.js +16 -1
  26. package/dist/server/conversation-ops.js +482 -10
  27. package/dist/server/gateway.js +122 -0
  28. package/dist/server/helpers.js +26 -0
  29. package/dist/server/host-bridge.js +141 -4
  30. package/dist/server/index.js +27 -0
  31. package/dist/server/notifications.js +6 -2
  32. package/dist/server/routes/conversations.js +37 -93
  33. package/dist/server/routes/events.js +39 -0
  34. package/dist/server/routes/ext-assets.js +84 -0
  35. package/dist/server/routes/extensions.js +31 -1
  36. package/dist/server/routes/files.js +38 -83
  37. package/dist/server/routes/meta.js +35 -1
  38. package/dist/server/routes/providers.js +5 -21
  39. package/dist/server/routes/submissions.js +36 -111
  40. package/dist/server/routes/surfaces.js +387 -0
  41. package/dist/server/routes/users.js +6 -1
  42. package/dist/server/session-meta.js +12 -6
  43. package/dist/server/shutdown.js +85 -0
  44. package/dist/server/surfaces/access-cache.js +43 -0
  45. package/dist/server/surfaces/dedup.js +67 -0
  46. package/dist/server/surfaces/instance.js +42 -0
  47. package/dist/server/surfaces/mime.js +73 -0
  48. package/dist/server/surfaces/rate-limit.js +63 -0
  49. package/dist/server/surfaces/service.js +493 -0
  50. package/dist/server/surfaces/watchers.js +94 -0
  51. package/node_modules/@sigrun/shared/CHANGELOG.md +9 -0
  52. package/node_modules/@sigrun/shared/dist/blocks/format.d.ts +28 -0
  53. package/node_modules/@sigrun/shared/dist/blocks/format.js +199 -0
  54. package/node_modules/@sigrun/shared/dist/blocks/icons.d.ts +12 -0
  55. package/node_modules/@sigrun/shared/dist/blocks/icons.js +64 -0
  56. package/node_modules/@sigrun/shared/dist/blocks/index.d.ts +25 -0
  57. package/node_modules/@sigrun/shared/dist/blocks/index.js +21 -0
  58. package/node_modules/@sigrun/shared/dist/blocks/instance.d.ts +29 -0
  59. package/node_modules/@sigrun/shared/dist/blocks/instance.js +51 -0
  60. package/node_modules/@sigrun/shared/dist/blocks/lttb.d.ts +16 -0
  61. package/node_modules/@sigrun/shared/dist/blocks/lttb.js +67 -0
  62. package/node_modules/@sigrun/shared/dist/blocks/ops.d.ts +44 -0
  63. package/node_modules/@sigrun/shared/dist/blocks/ops.js +294 -0
  64. package/node_modules/@sigrun/shared/dist/blocks/text.d.ts +43 -0
  65. package/node_modules/@sigrun/shared/dist/blocks/text.js +790 -0
  66. package/node_modules/@sigrun/shared/dist/blocks/transport.d.ts +77 -0
  67. package/node_modules/@sigrun/shared/dist/blocks/transport.js +11 -0
  68. package/node_modules/@sigrun/shared/dist/blocks/types.d.ts +541 -0
  69. package/node_modules/@sigrun/shared/dist/blocks/types.js +17 -0
  70. package/node_modules/@sigrun/shared/dist/jobs.d.ts +9 -0
  71. package/node_modules/@sigrun/shared/dist/live.d.ts +71 -0
  72. package/node_modules/@sigrun/shared/dist/live.js +82 -0
  73. package/node_modules/@sigrun/shared/dist/session-meta.d.ts +19 -3
  74. package/node_modules/@sigrun/shared/dist/settings.d.ts +7 -0
  75. package/node_modules/@sigrun/shared/dist/views.d.ts +81 -3
  76. package/node_modules/@sigrun/shared/package.json +10 -2
  77. package/package.json +11 -5
  78. package/webui/dist/assets/index-CiYInlLx.css +1 -0
  79. package/webui/dist/assets/index-JDfOQV9z.js +52 -0
  80. package/webui/dist/index.html +2 -2
  81. package/webui/dist/assets/index-BiSMljer.css +0 -1
  82. package/webui/dist/assets/index-hVLvsizV.js +0 -50
@@ -69,12 +69,23 @@ export const NPM_TIMEOUT_MS = 120_000;
69
69
  * own. {@link ensureHostModuleLinks} links sigrun's own copies into the
70
70
  * packages dir's node_modules so the dynamic import of such a package
71
71
  * resolves them — the SAME module instances the host runs.
72
+ *
73
+ * `@sigrun/sigrun` itself is here too (spec §14.1): the builders behind
74
+ * `@sigrun/sigrun/blocks` must emit the SERVER's block-set version, so
75
+ * extensions import the running copy — never a bundled or
76
+ * registry-fetched one (the loader's exact-match rule quarantines a
77
+ * tree that resolves a different version). Sigrun is the RUNNING
78
+ * package, not a dependency: {@link resolveHostModuleDir} resolves it
79
+ * to its own root instead of walking node_modules.
72
80
  */
81
+ /** The package this code ships in — the host runtime module that is the host itself. */
82
+ const SELF_MODULE_NAME = "@sigrun/sigrun";
73
83
  export const HOST_RUNTIME_MODULES = [
74
84
  "@earendil-works/pi-ai",
75
85
  "@earendil-works/pi-durable",
76
86
  "@earendil-works/chord",
77
87
  "typebox",
88
+ SELF_MODULE_NAME,
78
89
  ];
79
90
  /**
80
91
  * Read the `version` field of the package at `moduleDir` (following
@@ -101,13 +112,47 @@ export function hostModuleVersion(name) {
101
112
  const dir = resolveHostModuleDir(name);
102
113
  return dir === undefined ? undefined : installedVersionAt(dir);
103
114
  }
115
+ /**
116
+ * The running package's own root: the nearest ancestor of THIS module
117
+ * whose manifest is named `@sigrun/sigrun`. The node_modules walk below
118
+ * can never find the running package — sigrun is not a dependency of
119
+ * itself, and a source checkout carries no
120
+ * `node_modules/@sigrun/sigrun` anywhere above `src/` (the published
121
+ * install resolves through its OWN root, which is this same dir).
122
+ * `undefined` when no ancestor carries the manifest.
123
+ */
124
+ function selfPackageRoot() {
125
+ let dir = dirname(fileURLToPath(import.meta.url));
126
+ for (let hops = 0; hops < 10; hops++) {
127
+ const manifest = join(dir, "package.json");
128
+ if (existsSync(manifest)) {
129
+ try {
130
+ const parsed = JSON.parse(readFileSync(manifest, "utf8"));
131
+ if (parsed.name === SELF_MODULE_NAME)
132
+ return dir;
133
+ }
134
+ catch {
135
+ // Unreadable manifest — keep walking.
136
+ }
137
+ }
138
+ const parent = dirname(dir);
139
+ if (parent === dir)
140
+ return undefined;
141
+ dir = parent;
142
+ }
143
+ return undefined;
144
+ }
104
145
  /**
105
146
  * Resolve a host runtime module's package dir from sigrun's own tree: the
106
147
  * nearest `node_modules/<name>` walking up from THIS module's location
107
148
  * (the repo root in every deploy shape — sigrun runs from source). The
108
149
  * walk stops at the filesystem root; `undefined` when nothing resolves.
150
+ * The ONE exception is sigrun itself (see {@link selfPackageRoot}): the
151
+ * link target is the running package's own root.
109
152
  */
110
153
  function resolveHostModuleDir(name) {
154
+ if (name === SELF_MODULE_NAME)
155
+ return selfPackageRoot();
111
156
  let dir = dirname(fileURLToPath(import.meta.url));
112
157
  for (let hops = 0; hops < 10; hops++) {
113
158
  const candidate = join(dir, "node_modules", ...name.split("/"));
@@ -210,13 +255,25 @@ export class PackageSyncError extends Error {
210
255
  }
211
256
  /**
212
257
  * One `npm <args>` invocation against the packages dir (execFile — no
213
- * shell, so no quoting surface). Failures and timeouts reject with a
258
+ * shell, so no quoting surface). `--legacy-peer-deps` disables npm's
259
+ * peer-resolution for every managed install — pi's own posture for its
260
+ * extension installs: an extension declaring `@sigrun/sigrun` (or
261
+ * pi-durable/pi-ai) a `peerDependency: "*"` must resolve the HOST's
262
+ * linked copy, never an npm-solved registry copy whose version would
263
+ * quarantine the extension at load. Failures and timeouts reject with a
214
264
  * `PackageSyncError` carrying npm's stderr tail; `verbose` captures the
215
265
  * full output for the error message.
216
266
  */
217
267
  function runNpm(args, packagesDir, verbose) {
218
268
  return new Promise((resolve, reject) => {
219
- execFile("npm", [...args, "--prefix", packagesDir, "--no-audit", "--no-fund"], { timeout: NPM_TIMEOUT_MS, maxBuffer: 8 * 1024 * 1024 }, (error, stdout, stderr) => {
269
+ execFile("npm", [
270
+ ...args,
271
+ "--prefix",
272
+ packagesDir,
273
+ "--no-audit",
274
+ "--no-fund",
275
+ "--legacy-peer-deps",
276
+ ], { timeout: NPM_TIMEOUT_MS, maxBuffer: 8 * 1024 * 1024 }, (error, stdout, stderr) => {
220
277
  if (error === null) {
221
278
  if (verbose === true && stderr !== "") {
222
279
  console.warn(`packages: npm ${args[0] ?? ""}: ${stderr.trim()}`);
@@ -346,6 +403,18 @@ export function readPackageVersion(absPath) {
346
403
  const version = readManifest(absPath)?.version;
347
404
  return typeof version === "string" && version !== "" ? version : undefined;
348
405
  }
406
+ /**
407
+ * The declared `sigrun.assets` directory (spec §11.1), resolved
408
+ * absolute against the package root; `undefined` when the manifest
409
+ * declares none (or no manifest reads). A non-string declaration is
410
+ * ignored — assets are simply absent.
411
+ */
412
+ export function readPackageAssetsDir(absPath) {
413
+ const declared = readManifest(absPath)?.sigrun?.assets;
414
+ if (typeof declared !== "string" || declared === "")
415
+ return undefined;
416
+ return resolve(absPath, declared);
417
+ }
349
418
  /**
350
419
  * The identity a specifier installs under: registry specs key by their
351
420
  * parsed name; path specs key by the target package's OWN manifest name
@@ -103,13 +103,16 @@ export function createSeamHost(identity, kinds, bind, sharedStores) {
103
103
  indexPending(index, conversationId, record.callId);
104
104
  }, context);
105
105
  },
106
- async answerInteraction(conversationId, callId, answer, context) {
106
+ async answerInteraction(conversationId, callId, answer, context, opts) {
107
107
  const h = await harness(context);
108
108
  return await h.commit(async (tx) => {
109
109
  const draft = await tx.doc(InteractionDoc, conversationId, callId, callId);
110
110
  if (draft.status !== "pending")
111
111
  return false;
112
- const applied = applyAnswer(draft, answer, "extension", Date.now());
112
+ // Gateway C5: a host caller naming a user answers AS that human
113
+ // (the route's path — the same applyAnswer line); without a user
114
+ // the extension answered itself.
115
+ const applied = applyAnswer(draft, answer, opts?.user === undefined ? "extension" : "human", Date.now(), opts?.user);
113
116
  if (applied) {
114
117
  const index = await tx.doc(InteractionsIndexDoc);
115
118
  indexSettled(index, conversationId, callId);
@@ -193,6 +196,7 @@ export function createSeamHost(identity, kinds, bind, sharedStores) {
193
196
  ...(opts.owner === undefined ? {} : { owner: opts.owner }),
194
197
  ...(opts.sharing === undefined ? {} : { sharing: opts.sharing }),
195
198
  ...(opts.name === undefined ? {} : { name: opts.name }),
199
+ ...(opts.agent === undefined ? {} : { agent: opts.agent }),
196
200
  // The source default: the CALLING extension's slug (spec §9.5).
197
201
  source: opts.source ?? identity.name,
198
202
  });
@@ -213,6 +217,30 @@ export function createSeamHost(identity, kinds, bind, sharedStores) {
213
217
  const bridge = await hostBridge();
214
218
  return bridge.conversations.meta(id);
215
219
  },
220
+ // The C4a ops: straight delegation (the bridge resolves the id and
221
+ // runs the op its HTTP twin runs).
222
+ async abort(id) {
223
+ return (await hostBridge()).conversations.abort(id);
224
+ },
225
+ async configure(id, opts) {
226
+ return (await hostBridge()).conversations.configure(id, opts);
227
+ },
228
+ async compact(id) {
229
+ return (await hostBridge()).conversations.compact(id);
230
+ },
231
+ async fork(id, opts) {
232
+ return (await hostBridge()).conversations.fork(id, opts);
233
+ },
234
+ async patchMeta(id, patch) {
235
+ return (await hostBridge()).conversations.patchMeta(id, patch);
236
+ },
237
+ // The C4b ops: same straight delegation.
238
+ async upload(id, file) {
239
+ return (await hostBridge()).conversations.upload(id, file);
240
+ },
241
+ async readFile(id, path) {
242
+ return (await hostBridge()).conversations.readFile(id, path);
243
+ },
216
244
  },
217
245
  users: {
218
246
  async list() {
@@ -232,6 +260,45 @@ export function createSeamHost(identity, kinds, bind, sharedStores) {
232
260
  return (await hostBridge()).identity.actingUser(conversationId);
233
261
  },
234
262
  },
263
+ // Gateway C4b: straight delegation (the bridge owns both bodies).
264
+ models: {
265
+ async list() {
266
+ return (await hostBridge()).models.list();
267
+ },
268
+ },
269
+ commands: {
270
+ async list() {
271
+ return (await hostBridge()).commands.list();
272
+ },
273
+ async run(conversationId, name, args, opts) {
274
+ return (await hostBridge()).commands.run(conversationId, name, args, opts);
275
+ },
276
+ },
277
+ // The surfaces seam (UI-blocks U3 Task 4): `changed` awaits the
278
+ // bridge (so a factory-body call waits, never resolves early) and
279
+ // delegates to the server-side SurfaceService — the rev bump + the
280
+ // hub event are the service's one code path (the actions route and
281
+ // the host seam share it).
282
+ surfaces: {
283
+ changed(id, key, update) {
284
+ const host = identity; // capture for the async lane
285
+ const ran = () => {
286
+ // The Review Focus 4 observability seam (the fixture
287
+ // surfaces-early pattern): a call that WAITED for the
288
+ // bridge is distinguishable from an early no-op. Inert in
289
+ // production (nothing sets it).
290
+ globalThis.__sigrunSurfaceChanged?.(host.name);
291
+ };
292
+ void hostBridge()
293
+ .then((bridge) => {
294
+ ran();
295
+ bridge.surfaces.changed(identity.name, id, key, update);
296
+ })
297
+ .catch((error) => {
298
+ console.error(`seam host: surfaces.changed (${identity.name}/${id}) failed:`, error);
299
+ });
300
+ },
301
+ },
235
302
  bindBridge(bridge) {
236
303
  bridgeReleased(bridge);
237
304
  },
@@ -413,6 +413,7 @@ function validateServer(server) {
413
413
  "providers",
414
414
  "auth",
415
415
  "metrics",
416
+ "dev",
416
417
  "backup",
417
418
  ]);
418
419
  // The jobs default timezone (§8.2): an IANA name — `Intl` accepts
@@ -611,6 +612,10 @@ function validateServer(server) {
611
612
  throw new SettingsValidationError("server.metrics.public", "must be a boolean");
612
613
  }
613
614
  }
615
+ // The development flag (§14.2/§14.4) — a flat boolean, no section.
616
+ if (record.dev !== undefined && typeof record.dev !== "boolean") {
617
+ throw new SettingsValidationError("server.dev", "must be a boolean");
618
+ }
614
619
  if (record.backup !== undefined) {
615
620
  const backup = asRecord(record.backup);
616
621
  if (backup === undefined) {
@@ -15,15 +15,17 @@
15
15
  * adapter below: pi-durable passes it through `EnvTarget.cwd` and applies
16
16
  * nothing itself, so the adapter must root the env at it.
17
17
  */
18
- import { NodeExecutionEnv } from "@earendil-works/pi-durable/env/node";
18
+ import { AgentExecutionEnv } from "./agent-env.js";
19
19
  import { isAbsolute, resolve } from "node:path";
20
20
  /**
21
21
  * Build the static workspace service.
22
22
  *
23
23
  * v1 resolves every conversation from `prefs.defaultWorkspace` — the spec's
24
24
  * static configuration — so the service carries no state of its own. The
25
- * returned env is an upstream `NodeExecutionEnv` (pi-durable's local Node
26
- * `ExecutionEnv`): its `FileSystem.id` is the stable `"node:local"` (every
25
+ * returned env is an {@link AgentExecutionEnv}: upstream's local Node
26
+ * `ExecutionEnv` with commands confined to the agent environment
27
+ * allowlist (multi-user spec §8). Its `FileSystem.id` is the stable
28
+ * `"node:local"` (every
27
29
  * local environment sees the same files), and its `Shell` runs commands
28
30
  * rooted at the workspace `cwd`. A fresh instance per call matches how
29
31
  * upstream builds envs; equal ids see the same files whatever their `cwd`.
@@ -37,7 +39,7 @@ export function createWorkspaceService() {
37
39
  // type reaching here means the preferences are not v1's.
38
40
  throw new Error(`Unsupported workspace type: ${JSON.stringify(workspace.type)}`);
39
41
  }
40
- return new NodeExecutionEnv({ cwd: workspace.path });
42
+ return new AgentExecutionEnv({ cwd: workspace.path });
41
43
  },
42
44
  };
43
45
  }
package/dist/jobs/fire.js CHANGED
@@ -188,6 +188,12 @@ async function firePrompt(deps, id, record, fireKey, baseRun, prior, context) {
188
188
  // conversation: "new" — the run record + conversation in ONE commit.
189
189
  let conversationId = prior?.conversationId;
190
190
  if (conversationId === undefined) {
191
+ // Gateway C7: the deliver targets need a deliverer. With no engine
192
+ // loaded the stamped targets point at nothing — ONE warning per run
193
+ // (the fire itself proceeds; the stamp stays for whoever reads it).
194
+ if (action.deliver !== undefined && !deps.gatewayLoaded) {
195
+ console.warn("deliver targets ignored: no gateway");
196
+ }
191
197
  const model = action.model !== undefined
192
198
  ? parseModelSpec(action.model, "job action model")
193
199
  : deps.defaultModel;
@@ -207,7 +213,12 @@ async function firePrompt(deps, id, record, fireKey, baseRun, prior, context) {
207
213
  await tx.doc(JobRunDoc, fireKey, run);
208
214
  const meta = await tx.doc(SessionMetaDoc, newId);
209
215
  meta.source = "cron";
216
+ // Gateway C7: the job stamp — typed SessionMeta fields (the
217
+ // pre-C7 cast is gone). Copy, never alias: the doc tracker
218
+ // takes alias-free ownership of the array.
210
219
  meta.jobId = id;
220
+ if (action.deliver !== undefined)
221
+ meta.deliver = [...action.deliver];
211
222
  if (record.owner !== undefined)
212
223
  meta.owner = record.owner;
213
224
  if (action.runSharing !== undefined)
package/dist/jobs/spec.js CHANGED
@@ -170,6 +170,23 @@ function validateAction(value) {
170
170
  if (record.runSharing !== undefined && !isSharing(record.runSharing)) {
171
171
  return invalid("action.runSharing", "runSharing must be a Sharing value");
172
172
  }
173
+ // Gateway C7 (spec §8.0): deliver targets ride ONLY new-conversation
174
+ // prompt jobs — a conversation bound to a chat already delivers
175
+ // there, so deliver on a fixed id is a spec error naming this field.
176
+ // Shape-only: the target grammar belongs to the gateway.
177
+ if (record.deliver !== undefined) {
178
+ if (record.conversation !== "new") {
179
+ return invalid("action.deliver", 'deliver requires conversation "new"');
180
+ }
181
+ if (!Array.isArray(record.deliver) ||
182
+ record.deliver.length < 1 ||
183
+ record.deliver.length > 8 ||
184
+ record.deliver.some((target) => typeof target !== "string" ||
185
+ target.length < 1 ||
186
+ target.length > 256)) {
187
+ return invalid("action.deliver", "deliver must be an array of 1-8 strings, each 1-256 chars");
188
+ }
189
+ }
173
190
  const action = {
174
191
  kind: "prompt",
175
192
  prompt: record.prompt,
@@ -182,6 +199,8 @@ function validateAction(value) {
182
199
  action.thinking = record.thinking;
183
200
  if (record.runSharing !== undefined)
184
201
  action.runSharing = record.runSharing;
202
+ if (record.deliver !== undefined)
203
+ action.deliver = [...record.deliver];
185
204
  return { ok: true, action };
186
205
  }
187
206
  if (record.kind === "task") {
package/dist/serve.js CHANGED
@@ -28,8 +28,10 @@ import { envServerSection, loadConfig } from "./config.js";
28
28
  import { openHarness } from "./harness/boot.js";
29
29
  import { packagesDirOf, syncPackages } from "./harness/packages.js";
30
30
  import { defaultSettingsDir, SettingsStore } from "./harness/settings.js";
31
+ import { startGateway } from "./server/gateway.js";
31
32
  import { SIGRUN_VERSION } from "./server/schema-fixture.js";
32
33
  import { createServer } from "./server/index.js";
34
+ import { createShutdown } from "./server/shutdown.js";
33
35
  /**
34
36
  * Boot the harness + server composition and listen on
35
37
  * `options.host ?? "0.0.0.0"` + `<port>` (the default is the container's
@@ -88,44 +90,43 @@ export async function serve(env, options) {
88
90
  const { mkdir } = await import("node:fs/promises");
89
91
  await mkdir(config.workspace.path, { recursive: true });
90
92
  const opened = await openHarness(config, { extensionEntries: extensionResolutions });
93
+ // The gateway handle (gateway C2) exists BEFORE the server: the meta
94
+ // route reaches it through ServerContext, so the engine's health is
95
+ // reportable from the first request. The ENGINE STARTS after listen
96
+ // (below) — an engine supervising chat transports starts once the
97
+ // server it delivers through is up.
98
+ const gateway = startGateway(opened);
91
99
  // The contributed-bundle registry (build-and-webui Task 6): boot folded
92
100
  // the loader's extension contributions into the name-keyed registry;
93
101
  // the server's serve-time NAME-selector resolution reads it here.
94
102
  const app = await createServer(opened, config, opened.webuiBundles, {
95
103
  listenHost: options?.host ?? "0.0.0.0",
96
104
  env,
105
+ gateway,
97
106
  });
98
107
  const host = options?.host ?? "0.0.0.0";
99
108
  await app.listen({ port: config.port, host });
109
+ // The engine starts with the listener live (gateway C2's lifecycle:
110
+ // start after listen — a failed start latches degraded inside the
111
+ // handle, never wedges boot). AWAITED: serve() resolves only once the
112
+ // engine start completed (or failed-and-latched) — the constraint's
113
+ // intent; the lazy `started` never rejects, so this await cannot throw.
114
+ await gateway.started;
100
115
  // Machine-greppable: the deploy-entry test waits for this line, and an
101
116
  // operator reading pod logs sees the bound port without guessing. The
102
117
  // version rides the same line (what an operator quotes in a bug report).
103
118
  console.log(`sigrun v${SIGRUN_VERSION} listening on http://${host}:${config.port}`);
104
- const shutdown = async () => {
105
- const failures = [];
106
- // SSE streams FIRST (the ctrl+c fix): hijacked event streams never
107
- // end on their own, and app.close()'s drain waits for in-flight
108
- // requests — with any webui tab open, shutdown hung forever on its
109
- // /v1/events connection. End every live stream BEFORE the drain.
110
- try {
111
- app.sse.endAll();
112
- }
113
- catch (error) {
114
- failures.push(error);
115
- }
116
- try {
117
- await app.close();
118
- }
119
- catch (error) {
120
- failures.push(error);
121
- }
122
- try {
123
- await opened.eventBus.shutdownSequence(BACKGROUND_CONTEXT);
124
- }
125
- catch (error) {
126
- failures.push(error);
127
- }
128
- try {
119
+ // The ordered shutdown (gateway C2's extraction of the inline original
120
+ // into src/server/shutdown.ts): SSE streams FIRST, the app drain, the
121
+ // engine's intake close, the event-bus sequence (drain + flush), the
122
+ // engine stop, the refresh stop + bounded boot-refresh join, the
123
+ // harness close — each failure collected, all steps always run.
124
+ const shutdown = createShutdown({
125
+ endStreams: () => app.sse.endAll(),
126
+ closeApp: () => app.close(),
127
+ gateway,
128
+ eventBusShutdown: () => opened.eventBus.shutdownSequence(BACKGROUND_CONTEXT),
129
+ stopProviderRefresh: async () => {
129
130
  // The interval refresh scheduler (provider plugins Task 5) stops
130
131
  // BEFORE the harness closes. The T4 boot refresh promise is raced
131
132
  // against a bounded join: a wedged catalog fetch must not hold the
@@ -150,20 +151,9 @@ export async function serve(env, options) {
150
151
  if (!joined) {
151
152
  console.warn(`sigrun: boot refresh still in flight after ${BOOT_REFRESH_JOIN_MS}ms at shutdown — abandoning the join`);
152
153
  }
153
- }
154
- catch (error) {
155
- failures.push(error);
156
- }
157
- try {
158
- await opened.harness.close(BACKGROUND_CONTEXT);
159
- }
160
- catch (error) {
161
- failures.push(error);
162
- }
163
- if (failures.length > 0) {
164
- throw new AggregateError(failures, "sigrun shutdown failed");
165
- }
166
- };
154
+ },
155
+ closeHarness: () => opened.harness.close(BACKGROUND_CONTEXT),
156
+ });
167
157
  let stopping;
168
158
  return {
169
159
  port: config.port,
@@ -209,6 +209,7 @@ async function resolveProxy(request, ctx) {
209
209
  if (existing === undefined && !proxy.autoProvision) {
210
210
  return { status: 403, error: "user_not_provisioned" };
211
211
  }
212
+ const groupAdmin = groups.some((group) => adminGroups.includes(group));
212
213
  const record = existing ??
213
214
  (await users.add({
214
215
  user,
@@ -219,9 +220,23 @@ async function resolveProxy(request, ctx) {
219
220
  ? {}
220
221
  : { email: headerOf(request, proxy.emailHeader) }),
221
222
  admin: false,
223
+ // Gateway C8: the grant is stamped at provision — host-side
224
+ // checks (chat commands, no headers) read it from the record.
225
+ ...(adminGroups.length > 0 ? { groupAdmin } : {}),
222
226
  source: "proxy",
223
227
  }));
224
- const groupAdmin = groups.some((group) => adminGroups.includes(group));
228
+ // Gateway C8 write-through: host-side code has no request headers, so
229
+ // the computed group grant must live on the directory record. Only a
230
+ // CHANGE writes (the steady state never rewrites the file), and only
231
+ // while `adminGroups` is configured — an empty list leaves the field
232
+ // untouched (a group-exit clears it at this user's next request under
233
+ // a non-empty list, never under an empty one). Absent counts as false;
234
+ // the auto-provisioned record was added with the flag already set, so
235
+ // the comparison is against the RESOLVED record, never `existing`.
236
+ if (adminGroups.length > 0 &&
237
+ (record.groupAdmin ?? false) !== groupAdmin) {
238
+ await users.update(user, { groupAdmin });
239
+ }
225
240
  return {
226
241
  user: record.user,
227
242
  ...(record.name === undefined ? {} : { name: record.name }),