@sigrun/sigrun 0.2.0 → 0.3.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 (72) hide show
  1. package/CHANGELOG.md +145 -7
  2. package/README.md +333 -13
  3. package/dist/blocks/build-stamp.json +13 -0
  4. package/dist/blocks/builders.d.ts +57 -0
  5. package/dist/blocks/images.d.ts +26 -0
  6. package/dist/blocks/index.d.ts +20 -0
  7. package/dist/blocks/limits.d.ts +25 -0
  8. package/dist/blocks/placements.d.ts +26 -0
  9. package/dist/blocks/schema.d.ts +38 -0
  10. package/dist/blocks/validate.d.ts +73 -0
  11. package/dist/cli.js +117 -19
  12. package/dist/config.js +21 -2
  13. package/dist/extension.d.ts +48 -8
  14. package/dist/harness/ambient.js +2 -0
  15. package/dist/harness/blocks/state.js +1 -0
  16. package/dist/harness/boot.js +92 -6
  17. package/dist/harness/builtins.js +1 -1
  18. package/dist/harness/context.js +25 -0
  19. package/dist/harness/event-bus.js +25 -14
  20. package/dist/harness/identity/accounts.js +3 -2
  21. package/dist/harness/identity/index.js +2 -1
  22. package/dist/harness/identity/invites.js +26 -9
  23. package/dist/harness/identity/link-sweep.js +47 -0
  24. package/dist/harness/identity/typed-username.js +15 -0
  25. package/dist/harness/identity/users.js +10 -7
  26. package/dist/harness/interactions.js +2 -0
  27. package/dist/harness/loader.js +57 -1
  28. package/dist/harness/migration-snapshot.js +97 -0
  29. package/dist/harness/reports.js +100 -0
  30. package/dist/harness/seam-host.js +23 -1
  31. package/dist/harness/sections/context-files.js +1 -1
  32. package/dist/harness/settings.js +52 -4
  33. package/dist/jobs/docs.js +3 -0
  34. package/dist/jobs/fire.js +2 -0
  35. package/dist/jobs/parked.js +38 -0
  36. package/dist/jobs/task.js +37 -8
  37. package/dist/sandbox/availability.js +69 -20
  38. package/dist/sandbox/launch.js +11 -0
  39. package/dist/sandbox/policy.js +24 -3
  40. package/dist/sandbox/worker.js +4 -24
  41. package/dist/serve.js +162 -40
  42. package/dist/server/auth/ip.js +9 -4
  43. package/dist/server/auth/own-host.js +115 -0
  44. package/dist/server/auth/pipeline.js +93 -28
  45. package/dist/server/auth/proxy-diagnostic.js +60 -0
  46. package/dist/server/auth/proxy-identity.js +134 -0
  47. package/dist/server/auth/proxy-only.js +28 -0
  48. package/dist/server/images/cache.js +13 -5
  49. package/dist/server/index.js +10 -2
  50. package/dist/server/login/resolve.js +88 -26
  51. package/dist/server/notifications.js +21 -4
  52. package/dist/server/routes/admin.js +97 -0
  53. package/dist/server/routes/images.js +16 -9
  54. package/dist/server/routes/login.js +8 -0
  55. package/dist/server/routes/me.js +1 -1
  56. package/dist/server/routes/meta.js +4 -0
  57. package/dist/server/routes/metrics.js +18 -2
  58. package/dist/server/routes/onboarding.js +97 -37
  59. package/dist/server/session-meta.js +1 -0
  60. package/dist/server/shutdown.js +93 -16
  61. package/dist/settings/registry.js +6 -0
  62. package/node_modules/@sigrun/shared/CHANGELOG.md +12 -0
  63. package/node_modules/@sigrun/shared/dist/settings.d.ts +14 -0
  64. package/node_modules/@sigrun/shared/dist/usernames.d.ts +8 -0
  65. package/node_modules/@sigrun/shared/dist/usernames.js +39 -0
  66. package/node_modules/@sigrun/shared/dist/views.d.ts +33 -4
  67. package/node_modules/@sigrun/shared/package.json +5 -1
  68. package/package.json +16 -15
  69. package/webui/dist/assets/{index-CxB969qp.js → index-CEOzj1C4.js} +11 -11
  70. package/webui/dist/index.html +1 -1
  71. package/CONTEXT.md +0 -163
  72. package/dist/server/login/usernames.js +0 -15
@@ -139,7 +139,13 @@ export type ExtensionLifecyclePayload = {
139
139
  export type ExtensionRunSettledHandler = (event: RunSettledEvent, api: MemoryEventApi, context: HandlerContext) => void | Promise<void>;
140
140
  /** A factory-result lifecycle handler for one event, deadline-bounded. */
141
141
  export type ExtensionLifecycleHandler<K extends keyof ExtensionLifecyclePayload> = (event: ExtensionLifecyclePayload[K], api: MemoryEventApi, context: HandlerContext) => void | Promise<void>;
142
- /** A factory-result `harnessShutdown` handler (no event payload — shutdown is conversation-less). */
142
+ /**
143
+ * A factory-result `harnessShutdown` handler (no event payload — shutdown is
144
+ * conversation-less). All extension handlers run CONCURRENTLY under one
145
+ * shared hook deadline, in no guaranteed relative execution or commit order.
146
+ * Handlers may still commit; a wedged handler is abandoned without undoing
147
+ * other handlers' durable commits.
148
+ */
143
149
  export type ExtensionHarnessShutdownHandler = (api: MemoryEventApi, context: HandlerContext) => void | Promise<void>;
144
150
  /**
145
151
  * The `userDeleted` payload (core hardening W1): which directory user
@@ -349,14 +355,18 @@ export interface ExtensionCommandContribution {
349
355
  readonly notice?: string;
350
356
  } | void>;
351
357
  }
352
- /** One notification core delivers to every registered notifier (best-effort). */
353
- export interface NotificationEvent {
354
- readonly type: "interaction.pending" | "job.failed" | "run.settled";
358
+ /** The settled-run outcome: the notification-safe projection of the host
359
+ * event's `outcome`/`reason` (status and reason only — no answer text). */
360
+ export type RunSettledOutcome = {
361
+ readonly status: "answered";
362
+ } | {
363
+ readonly status: "unanswered";
364
+ readonly reason: string;
365
+ };
366
+ /** Fields shared by every notification variant. */
367
+ interface NotificationEventBase {
355
368
  readonly conversationId?: number;
356
369
  readonly jobId?: string;
357
- /** Gateway C6: the pending record's id — set on `interaction.pending` only
358
- * (read the record via `host.snapshotInteraction`). */
359
- readonly callId?: string;
360
370
  /** The recipient. */
361
371
  readonly user: string | undefined;
362
372
  readonly title: string;
@@ -364,6 +374,21 @@ export interface NotificationEvent {
364
374
  /** `${server.publicUrl}/#/c/<conversationId>` when `server.publicUrl` is set; absent otherwise. */
365
375
  readonly url?: string;
366
376
  }
377
+ /** One notification core delivers to every registered notifier (best-effort). */
378
+ export type NotificationEvent = NotificationEventBase & ({
379
+ readonly type: "interaction.pending";
380
+ /** Gateway C6: the pending record's id — set on `interaction.pending` only
381
+ * (inspect it via `host.peekInteraction`; the OWNING extension's
382
+ * delivery read is `host.snapshotInteraction`). */
383
+ readonly callId?: string;
384
+ } | {
385
+ readonly type: "job.failed";
386
+ } | {
387
+ readonly type: "run.settled";
388
+ /** Whether the run answered or not (model error, abort, …), with
389
+ * the wire reason verbatim. Never infer success from the body. */
390
+ readonly outcome: RunSettledOutcome;
391
+ });
367
392
  /** One notifier transport (ntfy, Signal, web push — core ships none). */
368
393
  export interface NotifierContribution {
369
394
  readonly name: string;
@@ -800,7 +825,22 @@ export interface SigrunHost {
800
825
  }): Promise<boolean>;
801
826
  /** Mark a pending interaction expired/cancelled/skipped (extension-owned semantics). */
802
827
  settleInteraction(conversationId: ConversationId, callId: string, status: "skipped" | "expired" | "cancelled", context: HandlerContext): Promise<boolean>;
803
- /** Read one interaction record (the poll loop's read). */
828
+ /**
829
+ * Inspect one interaction record WITHOUT consuming anything: returns
830
+ * the same stored projection as {@link SigrunHost.snapshotInteraction}
831
+ * (secret values stay `SECRET_MASK`), but never touches the one-shot
832
+ * secret-delivery `take` path. Safe to repeat; neither extends nor
833
+ * shortens secret TTL; creates, settles, and mutates nothing. Use for
834
+ * inspection/rendering (notifiers, outcome edits, correlation).
835
+ */
836
+ peekInteraction(conversationId: ConversationId, callId: string, context: HandlerContext): Promise<Readonly<InteractionRecord> | undefined>;
837
+ /**
838
+ * The OWNING extension's delivery read: the first applicable read of
839
+ * an answered secret record merges its raw secret values back ONCE;
840
+ * subsequent reads see the stored mask. Inspection-only callers must
841
+ * use {@link SigrunHost.peekInteraction} instead — a consuming read
842
+ * by a non-owner silently destroys the owner's one-shot delivery.
843
+ */
804
844
  snapshotInteraction(conversationId: ConversationId, callId: string, context: HandlerContext): Promise<Readonly<InteractionRecord> | undefined>;
805
845
  /** Fully replace one ambient surface's state (write = replacement); the key must be unowned or yours. */
806
846
  setAmbient(conversationId: ConversationId, record: {
@@ -42,6 +42,7 @@ export const AmbientDoc = defineDocFamily({
42
42
  kind: "sigrun.ambient",
43
43
  version: 1,
44
44
  scope: "conversation",
45
+ checkpointWhen: () => false,
45
46
  history: "latest",
46
47
  fork: "current",
47
48
  family: true,
@@ -57,6 +58,7 @@ export const AmbientOwnersDoc = defineDoc({
57
58
  kind: "sigrun.ambient.owners",
58
59
  version: 1,
59
60
  scope: "session",
61
+ checkpointWhen: () => false,
60
62
  initial: () => ({ owners: {} }),
61
63
  });
62
64
  const PLACES = new Set(["status", "footer", "panel"]);
@@ -28,6 +28,7 @@ export const BlocksStateDoc = defineDocFamily({
28
28
  kind: "sigrun.blocks.state",
29
29
  version: 1,
30
30
  scope: "conversation",
31
+ checkpointWhen: () => false,
31
32
  history: "latest",
32
33
  fork: "current",
33
34
  family: true,
@@ -12,10 +12,14 @@ import { join } from "node:path";
12
12
  import { Harness, } from "@earendil-works/pi-durable";
13
13
  import { openNodeSqliteStorage } from "@earendil-works/pi-durable/storage/sqlite/node";
14
14
  import { effectiveMethods } from "../config.js";
15
+ import { requiresProxyVouch } from "../server/auth/proxy-only.js";
15
16
  import { wireActivity } from "./activity.js";
17
+ import { snapshotBeforeMigration } from "./migration-snapshot.js";
16
18
  import { createScheduleTask } from "../jobs/task.js";
17
19
  import { createJobsService, jobWireOf } from "../jobs/index.js";
18
20
  import { reconcileJobs } from "../jobs/reconcile.js";
21
+ import { applyParkedJobs } from "../jobs/parked.js";
22
+ import { createDurableReports } from "./reports.js";
19
23
  import { registerJobObservers } from "../jobs/observe.js";
20
24
  import { JobDoc, JobRunDoc, listRevisions } from "../jobs/docs.js";
21
25
  import { EventBus } from "./event-bus.js";
@@ -49,6 +53,7 @@ import { resolveWorkerEntry, sandboxLauncher, WorkerPool } from "../sandbox/pool
49
53
  import { IsolationState } from "../sandbox/isolation.js";
50
54
  import { probeOsUsers } from "../sandbox/availability.js";
51
55
  import { createAdmissionGuard } from "./identity/users.js";
56
+ import { sweepAccountLinks } from "./identity/link-sweep.js";
52
57
  import { SessionMetaDoc } from "../server/session-meta.js";
53
58
  import { probeIsolation, runHelperProbe, selfTestHelper, } from "../sandbox/availability.js";
54
59
  import { resolveBuiltins, } from "./builtins.js";
@@ -169,6 +174,31 @@ export async function openHarness(config, options = {}) {
169
174
  // I/O here (each file materializes on its first write), so this is
170
175
  // pure construction over paths boot already owns.
171
176
  const identity = options.identity ?? openIdentityStores(roots);
177
+ // The account-link sweep (username-derivation Task 8): once at boot,
178
+ // before serving — dangling links are removed, proxy-source crash
179
+ // orphans are logged with their repair command (never auto-repaired:
180
+ // the original proxy username is not derivable from the key).
181
+ // BEST-EFFORT posture: a sweep failure (e.g. a corrupt accounts.json)
182
+ // must not crash-loop a serving deployment; the stores' own readers
183
+ // still fail loud on the request paths if the file is truly corrupt.
184
+ try {
185
+ const linkSweep = await sweepAccountLinks(identity.users, identity.accounts);
186
+ if (linkSweep.removedDangling > 0) {
187
+ console.log(`identity: removed ${linkSweep.removedDangling} dangling account link(s)`);
188
+ }
189
+ for (const orphan of linkSweep.orphans) {
190
+ console.log(`identity: proxy user "${orphan}" has no proxy link; repair with: sigrun user link ${orphan} --proxy <username>`);
191
+ }
192
+ }
193
+ catch (error) {
194
+ console.warn(`identity: account-link sweep failed: ${error instanceof Error ? error.message : String(error)}`);
195
+ }
196
+ // The caller-supplied host wins (serving spec §1: `serve()` resolves
197
+ // the ONE effective host — options.host → SIGRUN_LISTEN_HOST →
198
+ // DEFAULT_LISTEN_HOST — and passes it here). The env read below is
199
+ // NOT a second source on the serve path (the option is always set
200
+ // there); it remains for direct `openHarness` callers that never
201
+ // bind a listener (tests) and must derive the same auth posture.
172
202
  const listenHost = options.listenHost ?? env.SIGRUN_LISTEN_HOST ?? "0.0.0.0";
173
203
  const methods = effectiveMethods(config.auth, listenHost);
174
204
  const isolationState = new IsolationState({
@@ -203,8 +233,10 @@ export async function openHarness(config, options = {}) {
203
233
  // is 0600 + atomic, and the cache root is never a backup section).
204
234
  // `SIGRUN_SETUP_CODE` pins the value instead (the k8s Secret shape).
205
235
  // Claimed boots (every identity-seeded test, every post-onboarding
206
- // deploy) write nothing.
207
- if (!identity.users.isClaimed()) {
236
+ // deploy) write nothing. A proxy-only non-loopback boot writes and
237
+ // logs NOTHING either (C2): account creation there is vouch-only, so
238
+ // a setup code would be an unvouched account-creation path.
239
+ if (!identity.users.isClaimed() && !requiresProxyVouch(methods, listenHost)) {
208
240
  // `identity.cacheDir`, not `roots.cacheDir`: the claim route reads
209
241
  // `readSetupCode(identity.cacheDir)`, and an injected identity
210
242
  // bundle (the test seam) may root elsewhere than the resolved
@@ -263,7 +295,18 @@ export async function openHarness(config, options = {}) {
263
295
  now: () => Date.now(),
264
296
  managed: managedPaths,
265
297
  });
266
- const storage = await openNodeSqliteStorage(config.storagePath);
298
+ const storage = await (async () => {
299
+ // D4: preserve the last schema the previous release can read BEFORE
300
+ // openNodeSqliteStorage applies its one-way durable migration. The
301
+ // injected clock keeps snapshots deterministic in tests; a needed
302
+ // snapshot failure aborts boot here, before any migration write.
303
+ await snapshotBeforeMigration(config.storagePath, {
304
+ env,
305
+ now: options.now ?? (() => new Date()),
306
+ log: (line) => console.log(line),
307
+ });
308
+ return await openNodeSqliteStorage(config.storagePath);
309
+ })();
267
310
  const registry = createRegistry();
268
311
  // Late-filled by the loader: factories publish through the same registry
269
312
  // instance, and selection rewrites read the declared set LIVE.
@@ -654,6 +697,25 @@ export async function openHarness(config, options = {}) {
654
697
  onReadinessChange: observeReadiness,
655
698
  snapshotReadiness,
656
699
  });
700
+ // Per-model compaction overrides (D9): after the boot refresh the
701
+ // catalog is as complete as boot gets, so an override key matching no
702
+ // known model is almost always a typo (or a stale chain name). Warn
703
+ // once per unmatched key — never an error: catalogs move and an
704
+ // unmatched override is simply inert. pi-durable's own key composition
705
+ // is `${provider}/${modelId}`, so a chain-shaped model id yields a
706
+ // doubled prefix (e.g. "bifrost/bifrost/<chain>").
707
+ void bootRefreshDone.then(() => {
708
+ const overrides = config.compaction.modelOverrides ??
709
+ bootSettings.server?.compaction?.modelOverrides;
710
+ if (overrides === undefined)
711
+ return;
712
+ const known = new Set(models.getModels().map((model) => `${model.provider}/${model.id}`));
713
+ for (const key of Object.keys(overrides)) {
714
+ if (!known.has(key)) {
715
+ console.warn(`compaction override "${key}" matches no known model`);
716
+ }
717
+ }
718
+ });
657
719
  // The boot-wired single-provider refresh (W3 Task 6): the route
658
720
  // surface's refresh — the same bound policy `resolveServedModelRefreshing`
659
721
  // runs on a miss, with the same readiness observation so a provider a
@@ -718,15 +780,23 @@ export async function openHarness(config, options = {}) {
718
780
  const fileSettings = settingsStore.harnessSettings();
719
781
  const callerSettings = options.settings;
720
782
  // The ONE boot clock (options.now ?? real): the activity stamper AND
721
- // the harness clock share it (the schedule task's `runtime.sleep`
722
- // reads the harness clock; pinning the seam pins both — the jobs
723
- // tests' time-injection lever).
783
+ // the harness clock share it (the schedule task's `sleepUntil`
784
+ // deadlines live on the harness clock; pinning the seam pins both —
785
+ // the jobs tests' time-injection lever).
724
786
  const clockNow = options.now ?? (() => new Date());
787
+ // D5: reports may arrive inside Harness.open. The collector logs/counts
788
+ // synchronously and queues parks; job patches drain after binding below.
789
+ const reports = createDurableReports({
790
+ log: (line) => console.error(line),
791
+ onReport: options.onReport,
792
+ });
725
793
  const harness = await Harness.open(storage, {
726
794
  models,
727
795
  registry,
728
796
  env: envBuilder,
729
797
  now: () => clockNow().getTime(),
798
+ setTimer: options.setTimer,
799
+ onReport: reports.onReport,
730
800
  // Context-management policy (Task 8): config's `SIGRUN_COMPACTION_*`
731
801
  // tuning, with any caller-set field winning over it. Upstream merges
732
802
  // the result over `DEFAULT_COMPACTION_POLICY` per field. Task 8a: the
@@ -738,6 +808,14 @@ export async function openHarness(config, options = {}) {
738
808
  // snapshots the file's boot-time values, silently freezing the
739
809
  // queue modes and clobbering the caller seam (the round-1 defects).
740
810
  // `extensions`/`toolExecution` have no file backing — plain passthrough.
811
+ // Compaction (D9): ONLY `modelOverrides` comes from the file here —
812
+ // it has no env spelling, so this adds a file tier without touching
813
+ // the four env-backed knobs' precedence (env over file, resolved by
814
+ // loadConfig). serve already folds the file into config; the
815
+ // conditional spread keeps a config-resolved map authoritative and
816
+ // covers direct `openHarness` boots. Read ONCE (boot-time), so a
817
+ // PATCH is restart-pending exactly like the other compaction keys.
818
+ // Order: caller seam > config (env over file) > this file fallback.
741
819
  settings: {
742
820
  get steeringMode() {
743
821
  return callerSettings?.steeringMode ?? fileSettings.steeringMode;
@@ -761,6 +839,10 @@ export async function openHarness(config, options = {}) {
761
839
  toolExecution: callerSettings?.toolExecution,
762
840
  compaction: {
763
841
  ...config.compaction,
842
+ ...(config.compaction.modelOverrides === undefined &&
843
+ bootSettings.server?.compaction?.modelOverrides !== undefined
844
+ ? { modelOverrides: bootSettings.server.compaction.modelOverrides }
845
+ : {}),
764
846
  ...callerSettings?.compaction,
765
847
  },
766
848
  },
@@ -780,6 +862,9 @@ export async function openHarness(config, options = {}) {
780
862
  // JobsService — happens after this point.
781
863
  jobRuntimeDeps.harness = harness;
782
864
  jobRuntimeDeps.storage = storage;
865
+ // Drain the restart-limit parks reported during open before scheduling
866
+ // resumes. A failed status patch is logged and dropped by applyParkedJobs.
867
+ await applyParkedJobs(harness, reports.takeParked(), BACKGROUND_CONTEXT);
783
868
  // The memory-seam event bus (Tasks 2-4): subscribe BEFORE `resume()` so
784
869
  // recovery settles (faulted/orphaned submissions the scheduler drains)
785
870
  // are observed like any other commit. Extension handlers register under
@@ -1057,6 +1142,7 @@ export async function openHarness(config, options = {}) {
1057
1142
  userToolSweep,
1058
1143
  models,
1059
1144
  jobs,
1145
+ reports,
1060
1146
  jobRunTotals: () => jobRunTotals,
1061
1147
  defaultModel,
1062
1148
  providerCollisions: sink.collisions,
@@ -38,7 +38,7 @@ import { basename, dirname, isAbsolute, resolve } from "node:path";
38
38
  import { fileURLToPath, pathToFileURL } from "node:url";
39
39
  import { parsePackageSpecifier, readPackageName } from "./packages.js";
40
40
  /**
41
- * The four built-ins, in load order (codemode before tool-search: both
41
+ * The five built-ins, in load order (codemode before tool-search: both
42
42
  * wrap pi-durable's own extensions and neither depends on the other, but
43
43
  * the order pins the tool-collision story should names ever overlap).
44
44
  * Specifiers are the published package names (Task 3) — npm keys a file:
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Normalize the extension contract's structural invocation context to the
3
+ * full chord Context required by pi-durable 1.4.0.
4
+ *
5
+ * Extensions deliberately depend only on HandlerContext (`abortSignal`), so
6
+ * they may pass a plain object. Durable publishes read telemetry and other
7
+ * context values, however; forwarding that literal would make the first
8
+ * entry/submission write fail at the host boundary. Deriving from
9
+ * BACKGROUND_CONTEXT supplies the runtime defaults while preserving caller
10
+ * cancellation. A real Context passes through BY IDENTITY so its telemetry
11
+ * parent and application values are never discarded.
12
+ */
13
+ import { BACKGROUND_CONTEXT, withAbortSignal, } from "@earendil-works/chord/context";
14
+ /** Upgrade a structural handler context, or retain an existing chord context. */
15
+ export function toDurableContext(context) {
16
+ const candidate = context;
17
+ if (typeof candidate.telemetry === "object" &&
18
+ candidate.telemetry !== null &&
19
+ typeof candidate.value === "function") {
20
+ return context;
21
+ }
22
+ return context.abortSignal === undefined
23
+ ? BACKGROUND_CONTEXT
24
+ : withAbortSignal(context.abortSignal, BACKGROUND_CONTEXT);
25
+ }
@@ -64,6 +64,7 @@ import { SessionMetaDoc } from "../server/session-meta.js";
64
64
  import { EXPANDED_ENTRY_KIND } from "../commands/expand.js";
65
65
  import { InteractionDoc, TERMINAL as INTERACTION_TERMINAL } from "./interactions.js";
66
66
  import { AmbientDoc } from "./ambient.js";
67
+ import { toDurableContext } from "./context.js";
67
68
  import { beginUserDeletion } from "./stores.js";
68
69
  /** Project a SessionMeta doc value into the tracked basis. */
69
70
  function metaBasisOf(value) {
@@ -84,9 +85,9 @@ function metaBasisOf(value) {
84
85
  return basis;
85
86
  }
86
87
  /** How long shutdown waits for in-flight worker runs before abandoning (spec §3). */
87
- const WORKER_SHUTDOWN_AWAIT_MS = 30_000;
88
+ export const WORKER_SHUTDOWN_AWAIT_MS = 30_000;
88
89
  /** The default last-admission window (gateway spec §8.13). */
89
- const SHUTDOWN_SETTLE_WAIT_MS = 10_000;
90
+ export const SHUTDOWN_SETTLE_WAIT_MS = 10_000;
90
91
  /** Short pause between live-run checks while the commit side lane works. */
91
92
  const SHUTDOWN_SETTLE_POLL_MS = 10;
92
93
  /**
@@ -398,10 +399,13 @@ export class EventBus {
398
399
  * 3. drain pending settles and wait (bounded) for placed live runs —
399
400
  * a run settling during this last admission window delivers before
400
401
  * the shutdown flush; a wedged run logs its conversation ids;
401
- * 4. deliver `harnessShutdown` to every extension in install order,
402
- * each deadline-bounded. Handlers may commit — admission is still
403
- * open in this window (the seal is `Harness.close`, the caller's
404
- * next step), which is exactly the flush window the spec promises;
402
+ * 4. deliver `harnessShutdown` to every extension CONCURRENTLY under
403
+ * ONE shared hook deadline (D6). Handlers may commit — admission is
404
+ * still open in this window (the seal is `Harness.close`, the
405
+ * caller's next step), which is exactly the flush window the spec
406
+ * promises — but their relative execution and commit order is not
407
+ * guaranteed. A wedged handler is abandoned at the shared deadline;
408
+ * other handlers' completed commits remain durable;
405
409
  * 5. close the bus (idempotent; boot's `subscribeClose` wiring makes a
406
410
  * later `harness.close` a no-op here).
407
411
  */
@@ -418,11 +422,18 @@ export class EventBus {
418
422
  // harnessShutdown carries no conversation: the conversation-scoped
419
423
  // reads resolve empty and `document` serves any conversation.
420
424
  const api = this.#apiFor(undefined);
421
- for (const [name, handlers] of this.#extensions) {
422
- const handler = handlers.harnessShutdown;
423
- if (handler === undefined)
424
- continue;
425
- await this.#bounded(`extension ${name} harnessShutdown`, () => handler(api, context));
425
+ const registered = [...this.#extensions.entries()].filter(([, handlers]) => handlers.harnessShutdown !== undefined);
426
+ const calls = registered.map(([name, handlers]) => this.#bounded(`extension ${name} harnessShutdown`, () => handlers.harnessShutdown(api, context)));
427
+ let timer;
428
+ const sharedDeadline = new Promise((resolve) => {
429
+ timer = setTimeout(resolve, Math.max(0, this.#hookDeadlineMs));
430
+ });
431
+ try {
432
+ await Promise.race([Promise.allSettled(calls), sharedDeadline]);
433
+ }
434
+ finally {
435
+ if (timer !== undefined)
436
+ clearTimeout(timer);
426
437
  }
427
438
  this.close();
428
439
  }
@@ -1266,15 +1277,15 @@ export class EventBus {
1266
1277
  return {
1267
1278
  entry: async () => undefined,
1268
1279
  entries: async () => ({ items: [] }),
1269
- document: ((token, id, context) => harness.snapshot(token, id, context)),
1280
+ document: ((token, id, context) => harness.snapshot(token, id, toDurableContext(context))),
1270
1281
  };
1271
1282
  }
1272
1283
  return {
1273
1284
  entry: (id, context) => this.#readEntry(conversationId, id, context),
1274
1285
  entries: (query, limit, cursor, context) => this.#entriesOf(conversationId, query, limit, cursor, context),
1275
1286
  // The snapshot overloads are generic per call; the narrowed shape
1276
- // carries one conversation-doc read, forwarded verbatim.
1277
- document: ((token, id, context) => harness.snapshot(token, id, context)),
1287
+ // carries one conversation-doc read with the D1 context upgrade.
1288
+ document: ((token, id, context) => harness.snapshot(token, id, toDurableContext(context))),
1278
1289
  };
1279
1290
  }
1280
1291
  /** `Conversation.entries` over the settled conversation (read failures resolve empty). */
@@ -35,6 +35,7 @@ const VIAS = new Set([
35
35
  "self",
36
36
  "pairing",
37
37
  "admin",
38
+ "proxy",
38
39
  ]);
39
40
  /**
40
41
  * A `(provider, subject)` already linked to a different user. The
@@ -52,8 +53,8 @@ export class AccountLinkConflictError extends Error {
52
53
  this.subject = subject;
53
54
  }
54
55
  }
55
- /** `chat:<channel>` / `login:<slug>:<id>` — lowercase, no spaces. */
56
- const PROVIDER_PATTERN = /^(login|chat):[a-z0-9][a-z0-9._:-]*$/;
56
+ /** `proxy` / `chat:<channel>` / `login:<slug>:<id>` — lowercase, no spaces. */
57
+ const PROVIDER_PATTERN = /^(proxy|(login|chat):[a-z0-9][a-z0-9._:-]*)$/;
57
58
  /** A subject is opaque, non-empty, and bounded. */
58
59
  const SUBJECT_MAX_LENGTH = 512;
59
60
  /**
@@ -21,7 +21,8 @@ import { AccountStore, AccountLinkConflictError, } from "./accounts.js";
21
21
  import { TokenStore, generateToken, hashToken, } from "./tokens.js";
22
22
  import { InviteStore, DEFAULT_INVITE_EXPIRY_MS, } from "./invites.js";
23
23
  import { ensureSetupCode, readSetupCode, clearSetupCode, generateCode, setupCodePath, CODE_ALPHABET, } from "./setup-code.js";
24
- export { UserDirectory, effectiveAdmin, storedGroups, AccountStore, AccountLinkConflictError, TokenStore, generateToken, hashToken, InviteStore, DEFAULT_INVITE_EXPIRY_MS, ensureSetupCode, readSetupCode, clearSetupCode, generateCode, setupCodePath, CODE_ALPHABET, };
24
+ import { sweepAccountLinks, } from "./link-sweep.js";
25
+ export { UserDirectory, effectiveAdmin, storedGroups, AccountStore, AccountLinkConflictError, TokenStore, generateToken, hashToken, InviteStore, DEFAULT_INVITE_EXPIRY_MS, ensureSetupCode, readSetupCode, clearSetupCode, generateCode, setupCodePath, CODE_ALPHABET, sweepAccountLinks, };
25
26
  /** The file names each store owns under its root. */
26
27
  const USERS_FILE = "users.json";
27
28
  const TOKENS_FILE = "api-tokens.json";
@@ -33,8 +33,17 @@ function matchesCode(record, digest) {
33
33
  return false;
34
34
  return timingSafeEqual(Buffer.from(record.sha256, "hex"), Buffer.from(digest, "hex"));
35
35
  }
36
+ /** Locate the valid, unused row for one code, or undefined without effects. */
37
+ function validInviteIndex(records, code, now) {
38
+ const digest = hashToken(code);
39
+ const index = records.findIndex((record) => matchesCode(record, digest));
40
+ if (index === -1)
41
+ return -1;
42
+ const record = records[index];
43
+ return record.usedBy === undefined && now <= record.expiresAt ? index : -1;
44
+ }
36
45
  /**
37
- * The file-backed invite store. `redeem` is the single mutator; both
46
+ * The file-backed invite store. `redeem` is the single mutator; all three
38
47
  * methods serialize on the store's {@link MutationQueue}.
39
48
  */
40
49
  export class InviteStore {
@@ -66,6 +75,19 @@ export class InviteStore {
66
75
  return { id: record.id, code, expiresAt: record.expiresAt };
67
76
  });
68
77
  }
78
+ /**
79
+ * Validate a code without consuming it: the same digest, single-use, and
80
+ * expiry rules as {@link redeem}, returning its grant while leaving the row
81
+ * untouched. Callers must still redeem last; this is a preflight, not a
82
+ * reservation against another writer.
83
+ */
84
+ peek(code, now = Date.now()) {
85
+ return this.mutations.run(async () => {
86
+ const records = this.list();
87
+ const index = validInviteIndex(records, code, now);
88
+ return index === -1 ? undefined : { admin: records[index].admin };
89
+ });
90
+ }
69
91
  /**
70
92
  * Redeem one code for `user`: undefined when no record matches the
71
93
  * digest, when the row is already used (single-use, forever), or
@@ -77,15 +99,10 @@ export class InviteStore {
77
99
  redeem(code, user, now = Date.now()) {
78
100
  return this.mutations.run(async () => {
79
101
  const records = this.list();
80
- const digest = hashToken(code);
81
- const index = records.findIndex((record) => matchesCode(record, digest));
82
- const record = index === -1 ? undefined : records[index];
83
- if (record === undefined)
84
- return undefined;
85
- if (record.usedBy !== undefined)
86
- return undefined;
87
- if (now > record.expiresAt)
102
+ const index = validInviteIndex(records, code, now);
103
+ if (index === -1)
88
104
  return undefined;
105
+ const record = records[index];
89
106
  records[index] = { ...record, usedBy: user };
90
107
  await writeJson(this.path, records);
91
108
  return { admin: record.admin };
@@ -0,0 +1,47 @@
1
+ /**
2
+ * The boot-time account-link sweep (username-derivation Task 8, spec
3
+ * §11.3): runs ONCE at boot, after the identity stores open and before
4
+ * the listener binds — never on the request path, and never inside the
5
+ * claim lock (boot is single-threaded before serving).
6
+ *
7
+ * Two repair classes:
8
+ *
9
+ * - DANGLING LINKS: an `accounts.json` row whose `user` no longer
10
+ * exists (a hand-edited file, or a crash between a user deletion and
11
+ * its link cleanup). The sweep REMOVES them — the link names an
12
+ * identity that cannot resolve, so keeping it only preserves a
13
+ * conflict block against a future re-add.
14
+ *
15
+ * - CRASH ORPHANS: a `source === "proxy"` user with NO proxy link
16
+ * (Task 3's crash window: the user row landed, the link write never
17
+ * did). The sweep LISTS them, never repairs: the original proxy
18
+ * username is not derivable from the directory key (collisions
19
+ * suffix it), so the operator repairs with
20
+ * `sigrun user link <key> --proxy <username>`.
21
+ */
22
+ /**
23
+ * Remove links whose user is gone and list proxy-source users with no
24
+ * proxy link. After the initial reads and the serialized unlinks, the
25
+ * orphan pass deliberately RE-READS accounts.json so a removed link
26
+ * cannot leave stale state in the orphan list.
27
+ */
28
+ export async function sweepAccountLinks(users, accounts) {
29
+ const known = new Set(users.list().map((record) => record.user));
30
+ const links = accounts.list();
31
+ const dangling = links.filter((link) => !known.has(link.user));
32
+ let removedDangling = 0;
33
+ for (const link of dangling) {
34
+ if (await accounts.unlink(link.provider, link.subject)) {
35
+ removedDangling += 1;
36
+ }
37
+ }
38
+ const linkedProxyUsers = new Set(accounts
39
+ .list()
40
+ .filter((link) => link.provider === "proxy")
41
+ .map((link) => link.user));
42
+ const orphans = users
43
+ .list()
44
+ .filter((record) => record.source === "proxy" && !linkedProxyUsers.has(record.user))
45
+ .map((record) => record.user);
46
+ return { removedDangling, orphans };
47
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Resolve an operator-typed username to its canonical key. Typed entry
3
+ * points refuse collisions rather than silently suffixing them; the caller
4
+ * owns the surrounding claim/admission locking and supplies `taken`.
5
+ */
6
+ import { deriveUsername } from "@sigrun/shared/usernames";
7
+ /** Derive a typed username, refusing unusable input and existing keys. */
8
+ export function normalizeTypedUsername(input, taken) {
9
+ const key = deriveUsername(input);
10
+ if (key === undefined)
11
+ return { ok: false, error: "invalid_username" };
12
+ if (taken(key))
13
+ return { ok: false, error: "username_taken" };
14
+ return { ok: true, key };
15
+ }
@@ -11,9 +11,8 @@
11
11
  * after the first add (`isClaimed`); `admin` is a property of the USER
12
12
  * (tokens carry their user's rights — never their own admin flag).
13
13
  */
14
+ import { isUsernameKey } from "@sigrun/shared/usernames";
14
15
  import { MutationQueue, readRecords, writeJson } from "./json-store.js";
15
- /** The username rule (spec §5.8): lowercase-first, then `[a-z0-9._-]`. */
16
- const USERNAME_PATTERN = /^[a-z0-9][a-z0-9._-]*$/;
17
16
  /**
18
17
  * Strip the retired C8 field at every write boundary. A legacy on-disk
19
18
  * value is readable for compatibility, but never survives another write
@@ -61,9 +60,13 @@ export class UserDirectory {
61
60
  async writeRecords(records) {
62
61
  await writeJson(this.path, records.map(withoutLegacyGroupAdmin));
63
62
  }
64
- /** Run the same admission guard used by add, before consuming an invite. */
63
+ /** Run the shared admission guard before consuming an invite. Writes nothing. */
65
64
  checkAdmission() {
66
- this.options.admit?.(this.list().length);
65
+ this.checkAdmissionFor(this.list());
66
+ }
67
+ checkAdmissionFor(records) {
68
+ if (records.length >= 1)
69
+ this.options.admit?.(records.length);
67
70
  }
68
71
  /** Every user, insertion order (the first add is the onboarding claim). */
69
72
  list() {
@@ -88,15 +91,15 @@ export class UserDirectory {
88
91
  */
89
92
  add(user) {
90
93
  return this.mutations.run(async () => {
91
- if (!USERNAME_PATTERN.test(user.user)) {
92
- throw new Error(`invalid username "${user.user}": must match ^[a-z0-9][a-z0-9._-]*$`);
94
+ if (!isUsernameKey(user.user)) {
95
+ throw new Error(`invalid username "${user.user}": must match ^[a-z0-9][a-z0-9._-]*$ and be at most 128 characters`);
93
96
  }
94
97
  const records = this.list();
95
98
  if (records.some((record) => record.user === user.user)) {
96
99
  throw new Error(`user "${user.user}" already exists`);
97
100
  }
98
101
  if (records.length >= 1) {
99
- this.options.admit?.(records.length);
102
+ this.checkAdmissionFor(records);
100
103
  if (records.length === 1 && records[0].workspace === undefined && this.options.firstWorkspace !== undefined) {
101
104
  records[0] = { ...records[0], workspace: this.options.firstWorkspace };
102
105
  }
@@ -62,6 +62,7 @@ export const InteractionDoc = defineDocFamily({
62
62
  kind: "sigrun.interaction",
63
63
  version: 1,
64
64
  scope: "conversation",
65
+ checkpointWhen: () => false,
65
66
  history: "latest",
66
67
  fork: "current",
67
68
  family: true,
@@ -76,6 +77,7 @@ export const InteractionsIndexDoc = defineDoc({
76
77
  kind: "sigrun.interactions.index",
77
78
  version: 1,
78
79
  scope: "session",
80
+ checkpointWhen: () => false,
79
81
  initial: () => ({ pending: {} }),
80
82
  });
81
83
  /** Terminal statuses: answering one of these is a no-op (first wins).