@sigrun/sigrun 0.2.1 → 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 (51) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +234 -1
  3. package/dist/cli.js +117 -19
  4. package/dist/config.js +21 -2
  5. package/dist/extension.d.ts +7 -1
  6. package/dist/harness/ambient.js +2 -0
  7. package/dist/harness/blocks/state.js +1 -0
  8. package/dist/harness/boot.js +86 -6
  9. package/dist/harness/context.js +25 -0
  10. package/dist/harness/event-bus.js +25 -14
  11. package/dist/harness/identity/accounts.js +3 -2
  12. package/dist/harness/identity/index.js +2 -1
  13. package/dist/harness/identity/invites.js +26 -9
  14. package/dist/harness/identity/link-sweep.js +47 -0
  15. package/dist/harness/identity/typed-username.js +15 -0
  16. package/dist/harness/identity/users.js +10 -7
  17. package/dist/harness/interactions.js +2 -0
  18. package/dist/harness/loader.js +57 -1
  19. package/dist/harness/migration-snapshot.js +97 -0
  20. package/dist/harness/reports.js +100 -0
  21. package/dist/harness/seam-host.js +11 -1
  22. package/dist/harness/settings.js +52 -4
  23. package/dist/jobs/docs.js +3 -0
  24. package/dist/jobs/fire.js +2 -0
  25. package/dist/jobs/parked.js +38 -0
  26. package/dist/jobs/task.js +37 -8
  27. package/dist/serve.js +23 -14
  28. package/dist/server/auth/ip.js +9 -4
  29. package/dist/server/auth/own-host.js +115 -0
  30. package/dist/server/auth/pipeline.js +64 -48
  31. package/dist/server/auth/proxy-diagnostic.js +60 -0
  32. package/dist/server/auth/proxy-identity.js +134 -0
  33. package/dist/server/auth/proxy-only.js +28 -0
  34. package/dist/server/login/resolve.js +88 -26
  35. package/dist/server/routes/admin.js +97 -0
  36. package/dist/server/routes/login.js +8 -0
  37. package/dist/server/routes/metrics.js +18 -2
  38. package/dist/server/routes/onboarding.js +97 -37
  39. package/dist/server/session-meta.js +1 -0
  40. package/dist/server/shutdown.js +93 -16
  41. package/dist/settings/registry.js +6 -0
  42. package/node_modules/@sigrun/shared/CHANGELOG.md +7 -0
  43. package/node_modules/@sigrun/shared/dist/settings.d.ts +14 -0
  44. package/node_modules/@sigrun/shared/dist/usernames.d.ts +8 -0
  45. package/node_modules/@sigrun/shared/dist/usernames.js +39 -0
  46. package/node_modules/@sigrun/shared/dist/views.d.ts +6 -4
  47. package/node_modules/@sigrun/shared/package.json +5 -1
  48. package/package.json +14 -12
  49. package/webui/dist/assets/{index-SO2W4jjk.js → index-CEOzj1C4.js} +11 -11
  50. package/webui/dist/index.html +1 -1
  51. package/dist/server/login/usernames.js +0 -15
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Pre-migration snapshot (spec D4): before pi-durable opens a SQLite store,
3
+ * preserve the last schema the previous release can read.
4
+ *
5
+ * The probe deliberately opens read-write but issues no writes. A read-only
6
+ * connection cannot recover a WAL left by an unclean shutdown, so the read-
7
+ * write open lets SQLite perform crash recovery before `VACUUM INTO` writes a
8
+ * consistent, independent snapshot. Probe/open failures SKIP so the real
9
+ * storage opener can report the underlying error; a failure of a needed
10
+ * snapshot instead fails boot through `MigrationSnapshotError`.
11
+ *
12
+ * This runs before every durable schema migration, not only v1 → v2.
13
+ * Snapshots are never removed automatically. JSONL storage has no schema
14
+ * migration and therefore does not pass through this helper.
15
+ */
16
+ import { existsSync } from "node:fs";
17
+ import { DatabaseSync } from "node:sqlite";
18
+ import { CURRENT_SQLITE_SCHEMA_VERSION } from "@earendil-works/pi-durable/storage/sqlite";
19
+ export const SKIP_MIGRATION_SNAPSHOT_ENV = "SIGRUN_SKIP_MIGRATION_SNAPSHOT";
20
+ /** A required pre-migration snapshot could not be written. */
21
+ export class MigrationSnapshotError extends Error {
22
+ constructor(message, options) {
23
+ super(message, options);
24
+ this.name = "MigrationSnapshotError";
25
+ }
26
+ }
27
+ /** UTC compact stamp through milliseconds; one-millisecond boots never collide. */
28
+ function compactUtcStamp(date) {
29
+ return date.toISOString().replace(/[-:]/g, "").replace(".", "");
30
+ }
31
+ /** Quote one SQLite string literal (the snapshot path can contain `'`). */
32
+ function sqlString(value) {
33
+ return `'${value.replaceAll("'", "''")}'`;
34
+ }
35
+ function messageOf(error) {
36
+ return error instanceof Error ? error.message : String(error);
37
+ }
38
+ /**
39
+ * Return the snapshot path when a migration is needed, otherwise `undefined`.
40
+ * Throws `MigrationSnapshotError` when a required snapshot cannot be written.
41
+ */
42
+ export async function snapshotBeforeMigration(storagePath, opts) {
43
+ if (opts.env[SKIP_MIGRATION_SNAPSHOT_ENV] === "1") {
44
+ opts.log(`sigrun: durable migration snapshot skipped (${SKIP_MIGRATION_SNAPSHOT_ENV}=1): ${storagePath}`);
45
+ return undefined;
46
+ }
47
+ if (!existsSync(storagePath))
48
+ return undefined;
49
+ let db;
50
+ try {
51
+ db = new DatabaseSync(storagePath);
52
+ }
53
+ catch (error) {
54
+ opts.log(`sigrun: durable migration snapshot skipped; probe could not open ${storagePath}: ${messageOf(error)}`);
55
+ return undefined;
56
+ }
57
+ try {
58
+ let version;
59
+ try {
60
+ const row = db
61
+ .prepare("SELECT version FROM durable_schema WHERE singleton = 1")
62
+ .get();
63
+ version = row?.version;
64
+ }
65
+ catch (error) {
66
+ // A file without durable_schema is not a durable store. Invalid
67
+ // database files also defer to the real storage opener so it can
68
+ // report its own, more useful error.
69
+ const message = messageOf(error);
70
+ if (message.includes("no such table: durable_schema") ||
71
+ message.includes("file is not a database")) {
72
+ opts.log(`sigrun: durable migration snapshot skipped; durable schema probe failed for ${storagePath}: ${message}`);
73
+ return undefined;
74
+ }
75
+ // An environmental failure while probing an existing durable file
76
+ // can surface here too (SQLite may need journal/WAL sidecars even
77
+ // for its first SELECT). Do NOT turn that into a silent skip: a
78
+ // snapshot cannot be assured, so stop before migration.
79
+ throw new MigrationSnapshotError(`could not probe ${storagePath} before durable schema migration: ${messageOf(error)}; set ${SKIP_MIGRATION_SNAPSHOT_ENV}=1 only when you have an independent backup`, { cause: error });
80
+ }
81
+ if (version === undefined || version >= CURRENT_SQLITE_SCHEMA_VERSION) {
82
+ return undefined;
83
+ }
84
+ const snapshotPath = `${storagePath}.pre-durable-schema-v${CURRENT_SQLITE_SCHEMA_VERSION}-${compactUtcStamp(opts.now())}.bak`;
85
+ try {
86
+ db.exec(`VACUUM INTO ${sqlString(snapshotPath)}`);
87
+ }
88
+ catch (error) {
89
+ throw new MigrationSnapshotError(`could not snapshot ${storagePath} to ${snapshotPath} before durable schema migration v${version} → v${CURRENT_SQLITE_SCHEMA_VERSION}: ${messageOf(error)}; set ${SKIP_MIGRATION_SNAPSHOT_ENV}=1 only when you have an independent backup`, { cause: error });
90
+ }
91
+ opts.log(`sigrun: durable migration snapshot ${snapshotPath} (v${version} → v${CURRENT_SQLITE_SCHEMA_VERSION})`);
92
+ return snapshotPath;
93
+ }
94
+ finally {
95
+ db.close();
96
+ }
97
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * pi-durable 1.4.0 `HarnessOptions.onReport` visibility (D5).
3
+ *
4
+ * Reports can arrive DURING `Harness.open`, before a Harness exists for
5
+ * follow-up work. This collector is therefore synchronous and small: count
6
+ * and log immediately; retain task_parked identities for boot's post-open
7
+ * drain. It never reads transcripts, and no failure in logging or an
8
+ * observer can escape into pi-durable.
9
+ */
10
+ export const REPORT_TYPES = [
11
+ "session_failed",
12
+ "task_faulted",
13
+ "task_parked",
14
+ "migration_failed",
15
+ "task_outdated",
16
+ "task_error",
17
+ "extension_error",
18
+ "host_error",
19
+ "listener_error",
20
+ ];
21
+ const MESSAGE_LIMIT = 500;
22
+ /** A message without a throw of its own (unknown values can be hostile). */
23
+ function errorMessage(error) {
24
+ try {
25
+ if (error instanceof Error)
26
+ return error.message;
27
+ }
28
+ catch {
29
+ // A cross-realm/hostile error can reject instanceof; fall through.
30
+ }
31
+ try {
32
+ return String(error);
33
+ }
34
+ catch {
35
+ return Object.prototype.toString.call(error);
36
+ }
37
+ }
38
+ /** One structured log field. JSON quoting keeps newlines out of the line. */
39
+ function field(name, value) {
40
+ return `${name}=${JSON.stringify(value)}`;
41
+ }
42
+ /** Format one report without exposing transcript content or ordinary stacks. */
43
+ function formatReport(error, info) {
44
+ const fields = [];
45
+ if ("task" in info && info.task !== undefined) {
46
+ fields.push(["task", info.task.id], ["kind", info.task.kind], ["conversation", info.task.conversationId]);
47
+ }
48
+ if ("extension" in info && info.extension !== undefined) {
49
+ fields.push(["extension", info.extension]);
50
+ }
51
+ if ("conversationId" in info && info.conversationId !== undefined) {
52
+ fields.push(["conversation", info.conversationId]);
53
+ }
54
+ if ("callback" in info)
55
+ fields.push(["callback", info.callback]);
56
+ if ("reason" in info)
57
+ fields.push(["reason", info.reason]);
58
+ fields.push(["message", errorMessage(error).slice(0, MESSAGE_LIMIT)]);
59
+ if (info.type === "session_failed" && error instanceof Error && error.stack !== undefined) {
60
+ fields.push(["stack", error.stack]);
61
+ }
62
+ return [
63
+ `sigrun: durable ${info.type}`,
64
+ ...fields.map(([name, value]) => field(String(name), value)),
65
+ ].join(" ");
66
+ }
67
+ /** Create the one process-local report collector passed to Harness.open. */
68
+ export function createDurableReports(opts) {
69
+ const totals = Object.fromEntries(REPORT_TYPES.map((type) => [type, 0]));
70
+ let parked = [];
71
+ return {
72
+ onReport(error, info) {
73
+ // Count and queue before logging: a throwing sink cannot lose the
74
+ // report, and pi-durable never receives a callback exception.
75
+ totals[info.type] += 1;
76
+ if (info.type === "task_parked")
77
+ parked.push(info.task);
78
+ try {
79
+ opts.log(formatReport(error, info));
80
+ }
81
+ catch {
82
+ // A host log sink is not allowed to fail durable scheduling.
83
+ }
84
+ try {
85
+ opts.onReport?.(error, info);
86
+ }
87
+ catch {
88
+ // Test/observer seams have the same containment rule.
89
+ }
90
+ },
91
+ counts() {
92
+ return { ...totals };
93
+ },
94
+ takeParked() {
95
+ const drained = parked;
96
+ parked = [];
97
+ return drained;
98
+ },
99
+ };
100
+ }
@@ -18,6 +18,7 @@
18
18
  */
19
19
  import { AmbientDoc, AmbientOwnersDoc, validateAmbient, } from "./ambient.js";
20
20
  import { InteractionDoc, InteractionsIndexDoc, applyAnswer, asInteractionRecord, indexPending, indexSettled, sweepSecretDelivery, takeSecretDelivery, validateInteraction, } from "./interactions.js";
21
+ import { toDurableContext } from "./context.js";
21
22
  import { scopedExtensionStores, } from "./stores.js";
22
23
  /** One seam violation. Name carries the surface ("interaction"/"ambient"). */
23
24
  export class SeamError extends Error {
@@ -62,7 +63,9 @@ export function createSeamHost(identity, kinds, bind, sharedStores, perUserTools
62
63
  const h = await bind.harness();
63
64
  // The caller's context gates the wait: an aborted request/tool call
64
65
  // unwinds instead of waiting forever for a harness that never opens.
65
- context.abortSignal?.throwIfAborted();
66
+ // Normalize here too so this accessor remains safe for future callers;
67
+ // each method below also normalizes its own binding for h.* forwarding.
68
+ toDurableContext(context).abortSignal?.throwIfAborted();
66
69
  return h;
67
70
  }
68
71
  // The server-provided host bridge (W5 Task 5): the conversation/
@@ -120,6 +123,7 @@ export function createSeamHost(identity, kinds, bind, sharedStores, perUserTools
120
123
  const problem = validateInteraction(record);
121
124
  if (problem !== null)
122
125
  throw new SeamError("interaction", problem);
126
+ context = toDurableContext(context);
123
127
  const h = await harness(context);
124
128
  await h.commit(async (tx) => {
125
129
  // Maintain the index in the SAME commit as the record.
@@ -132,6 +136,7 @@ export function createSeamHost(identity, kinds, bind, sharedStores, perUserTools
132
136
  }, context);
133
137
  },
134
138
  async answerInteraction(conversationId, callId, answer, context, opts) {
139
+ context = toDurableContext(context);
135
140
  const h = await harness(context);
136
141
  return await h.commit(async (tx) => {
137
142
  const draft = await tx.doc(InteractionDoc, conversationId, callId, callId);
@@ -150,6 +155,7 @@ export function createSeamHost(identity, kinds, bind, sharedStores, perUserTools
150
155
  }, context);
151
156
  },
152
157
  async settleInteraction(conversationId, callId, status, context) {
158
+ context = toDurableContext(context);
153
159
  const h = await harness(context);
154
160
  // The sweep (Review Focus 4) is INSIDE the commit, applied only on
155
161
  // the terminal transition: sweeping before the pending check would
@@ -177,6 +183,7 @@ export function createSeamHost(identity, kinds, bind, sharedStores, perUserTools
177
183
  // path to takeSecretDelivery — an inspector (the gateway
178
184
  // notifier, outcome edits, answer correlation) can never eat
179
185
  // the owning extension's one-shot raw-secret delivery.
186
+ context = toDurableContext(context);
180
187
  const h = await harness(context);
181
188
  const stored = await h.snapshot(InteractionDoc, conversationId, callId, context);
182
189
  if (stored === undefined)
@@ -184,6 +191,7 @@ export function createSeamHost(identity, kinds, bind, sharedStores, perUserTools
184
191
  return asInteractionRecord(stored);
185
192
  },
186
193
  async snapshotInteraction(conversationId, callId, context) {
194
+ context = toDurableContext(context);
187
195
  const h = await harness(context);
188
196
  const stored = await h.snapshot(InteractionDoc, conversationId, callId, context);
189
197
  if (stored === undefined)
@@ -211,6 +219,7 @@ export function createSeamHost(identity, kinds, bind, sharedStores, perUserTools
211
219
  if (!ambientKinds.has(record.key)) {
212
220
  throw new SeamError("ambient", `surface key ${JSON.stringify(record.key)} is not declared by ${identity.name} (declare it in the factory result's ambientKinds)`);
213
221
  }
222
+ context = toDurableContext(context);
214
223
  const h = await harness(context);
215
224
  await h.commit(async (tx) => {
216
225
  // Allocation: one owner per key. The claim happens in the same
@@ -245,6 +254,7 @@ export function createSeamHost(identity, kinds, bind, sharedStores, perUserTools
245
254
  if (!ambientKinds.has(key)) {
246
255
  throw new SeamError("ambient", `surface key ${JSON.stringify(key)} is not declared by ${identity.name}`);
247
256
  }
257
+ context = toDurableContext(context);
248
258
  const h = await harness(context);
249
259
  await h.commit(async (tx) => {
250
260
  const draft = await tx.doc(AmbientDoc, conversationId, key, key);
@@ -500,6 +500,7 @@ function validateServer(server) {
500
500
  "reserveTokens",
501
501
  "keepRecentTokens",
502
502
  "backgroundTokens",
503
+ "modelOverrides",
503
504
  ]);
504
505
  if (compaction.enabled !== undefined && typeof compaction.enabled !== "boolean") {
505
506
  throw new SettingsValidationError("server.compaction.enabled", "must be a boolean");
@@ -511,10 +512,38 @@ function validateServer(server) {
511
512
  // backgroundTokens' minimum is 0 — the designed "background off"
512
513
  // value, same as `SIGRUN_COMPACTION_BACKGROUND_TOKENS`.
513
514
  const minimum = key === "backgroundTokens" ? 0 : 1;
514
- if (typeof v !== "number" ||
515
- !Number.isInteger(v) ||
516
- v < minimum) {
517
- throw new SettingsValidationError(`server.compaction.${key}`, minimum === 0 ? "must be a non-negative integer" : "must be a positive integer");
515
+ assertIntegerMinimum(`server.compaction.${key}`, v, minimum);
516
+ }
517
+ if (compaction.modelOverrides !== undefined) {
518
+ const overrides = asRecord(compaction.modelOverrides);
519
+ if (overrides === undefined) {
520
+ throw new SettingsValidationError("server.compaction.modelOverrides", "must be an object");
521
+ }
522
+ // Each entry is a partial policy with the SAME fields and
523
+ // minimums as the parent block; unknown inner keys fail naming
524
+ // the full `...modelOverrides.<model>.<field>` path.
525
+ for (const [modelKey, entry] of Object.entries(overrides)) {
526
+ const policy = asRecord(entry);
527
+ if (policy === undefined) {
528
+ throw new SettingsValidationError(`server.compaction.modelOverrides.${modelKey}`, "must be an object");
529
+ }
530
+ const prefix = `server.compaction.modelOverrides.${modelKey}.`;
531
+ checkKeys(prefix, policy, [
532
+ "enabled",
533
+ "reserveTokens",
534
+ "keepRecentTokens",
535
+ "backgroundTokens",
536
+ ]);
537
+ if (policy.enabled !== undefined && typeof policy.enabled !== "boolean") {
538
+ throw new SettingsValidationError(`${prefix}enabled`, "must be a boolean");
539
+ }
540
+ for (const key of ["reserveTokens", "keepRecentTokens", "backgroundTokens"]) {
541
+ const v = policy[key];
542
+ if (v === undefined)
543
+ continue;
544
+ const minimum = key === "backgroundTokens" ? 0 : 1;
545
+ assertIntegerMinimum(`${prefix}${key}`, v, minimum);
546
+ }
518
547
  }
519
548
  }
520
549
  }
@@ -710,6 +739,21 @@ function isPositiveNumber(value) {
710
739
  function isNonEmptyString(value) {
711
740
  return typeof value === "string" && value !== "";
712
741
  }
742
+ /**
743
+ * The shared compaction numeric rule (parent block and per-model overrides):
744
+ * an integer at or above `minimum` — `0` for `backgroundTokens` (the designed
745
+ * "background off" value), `1` for the other token counts. Throws naming the
746
+ * full dotted field path.
747
+ */
748
+ function assertIntegerMinimum(field, value, minimum) {
749
+ if (typeof value !== "number" ||
750
+ !Number.isInteger(value) ||
751
+ value < minimum) {
752
+ throw new SettingsValidationError(field, minimum === 0
753
+ ? "must be a non-negative integer"
754
+ : "must be a positive integer");
755
+ }
756
+ }
713
757
  function isNonEmptyStringArray(value) {
714
758
  return (Array.isArray(value) &&
715
759
  value.every((entry) => isNonEmptyString(entry)));
@@ -865,6 +909,10 @@ export class SettingsStore {
865
909
  ...(sub.compaction ?? {}),
866
910
  };
867
911
  for (const [compKey, compValue] of Object.entries(subValue)) {
912
+ // modelOverrides REPLACES the whole map by
913
+ // construction: it is a leaf value here, so this
914
+ // per-key assign swaps it verbatim rather than
915
+ // merging into the previous map.
868
916
  if (compValue === null)
869
917
  delete mergedCompaction[compKey];
870
918
  else
package/dist/jobs/docs.js CHANGED
@@ -35,6 +35,7 @@ export const JobDoc = defineDocFamily({
35
35
  kind: "sigrun.job",
36
36
  version: 1,
37
37
  scope: "session",
38
+ checkpointWhen: () => false,
38
39
  family: true,
39
40
  initial: (seed) => seed,
40
41
  });
@@ -43,6 +44,7 @@ export const JobRevisionDoc = defineDocFamily({
43
44
  kind: "sigrun.job.revision",
44
45
  version: 1,
45
46
  scope: "session",
47
+ checkpointWhen: () => false,
46
48
  family: true,
47
49
  initial: (seed) => seed,
48
50
  });
@@ -51,6 +53,7 @@ export const JobRunDoc = defineDocFamily({
51
53
  kind: "sigrun.job.run",
52
54
  version: 1,
53
55
  scope: "session",
56
+ checkpointWhen: () => false,
54
57
  family: true,
55
58
  initial: (seed) => seed,
56
59
  });
package/dist/jobs/fire.js CHANGED
@@ -325,6 +325,8 @@ async function fireTaskAction(deps, id, record, fireKey, baseRun, prior, context
325
325
  }, context);
326
326
  }
327
327
  await patchJob(deps.harness, id, (draft) => {
328
+ draft.state = "waiting";
329
+ delete draft.reason;
328
330
  draft.lastRun = run;
329
331
  }, context);
330
332
  return { outcome: "running" };
@@ -0,0 +1,38 @@
1
+ /**
2
+ * D5 parked-schedule recovery: translate pi-durable's task_parked identity
3
+ * into the job record's status fields after Harness.open has returned.
4
+ */
5
+ import { patchJob } from "./docs.js";
6
+ import { SCHEDULE_TASK_KIND } from "./task.js";
7
+ /** Patch each parked schedule job; individual failures are logged and dropped. */
8
+ export async function applyParkedJobs(harness, parked, context) {
9
+ if (parked.length === 0)
10
+ return;
11
+ let inspection;
12
+ try {
13
+ inspection = await harness.inspect(context);
14
+ }
15
+ catch (error) {
16
+ console.error("jobs: parked task inspection failed:", error);
17
+ return;
18
+ }
19
+ for (const task of parked) {
20
+ if (task.kind !== SCHEDULE_TASK_KIND)
21
+ continue;
22
+ try {
23
+ const record = inspection.tasks.find((candidate) => candidate.record.id === task.id)?.record;
24
+ if (record === undefined)
25
+ continue;
26
+ const jobId = record.input.jobId;
27
+ if (typeof jobId !== "string")
28
+ continue;
29
+ await patchJob(harness, jobId, (draft) => {
30
+ draft.state = "blocked";
31
+ draft.reason = "restart_limit";
32
+ }, context);
33
+ }
34
+ catch (error) {
35
+ console.error(`jobs: parked task ${String(task.id)} status patch failed:`, error);
36
+ }
37
+ }
38
+ }
package/dist/jobs/task.js CHANGED
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * Jobs Task 2 (spec §8.1 "The executor"): the `sigrun.schedule` task —
3
3
  * one long-lived background pi-durable task per ENABLED job, looping
4
- * `waiting` → `firing` → `waiting` over a durable `runtime.sleep`.
4
+ * `waiting` → `due` → `firing` → `waiting` over durable
5
+ * `runtime.sleepUntil` sleeps (pi-durable 1.4.0).
5
6
  *
6
7
  * The input is only `{jobId, hash, firstAt?}` (the plan's Global
7
8
  * Constraints): the task NEVER trusts its input's spec — EVERY phase
@@ -12,7 +13,12 @@
12
13
  * fire (`nextFireAt(schedule, now, tz)`).
13
14
  *
14
15
  * The phases:
15
- * - `waiting`: durable `runtime.sleep(nextAt)`. On wake: under
16
+ * - `waiting`: re-read + stale check, derive `nextAt`, patch the job's
17
+ * status fields, then END with
18
+ * `runtime.sleepUntil(nextAt, {phase:"due", nextAt})` — the task holds
19
+ * no invocation while asleep; the scheduler wakes it from one
20
+ * foreground timer. NOTHING runs after the sleep in this phase.
21
+ * - `due` (the old post-sleep body): re-read + stale → terminal; under
16
22
  * `catchUp: "none"` and `now - nextAt > 60_000`, the missed slot is
17
23
  * recorded `skipped` and the task re-commits `waiting` at the NEXT
18
24
  * slot from NOW. Otherwise it commits `{phase: "firing", fireAt:
@@ -26,6 +32,13 @@
26
32
  * completed (a past one-shot `at`).
27
33
  *
28
34
  * `abort` commits terminal (aborted) — the disable/delete path's signal.
35
+ * 1.4.0 makes an abort-requested SLEEPING task schedulable, so the
36
+ * handler also runs while the task sleeps.
37
+ *
38
+ * Records persisted mid-`waiting` by 0.2.x (pi-durable 1.3.0, whose
39
+ * in-process `runtime.sleep` committed no state) re-enter `waiting` on
40
+ * reopen and sleep durably. No migration code; the definition version
41
+ * stays 1.
29
42
  *
30
43
  * The record's status fields (`state`, `nextRun`, `lastRun`) ride
31
44
  * `patchJob` — small status edits, never version bumps.
@@ -43,8 +56,8 @@ export const SCHEDULE_TASK_VERSION = 1;
43
56
  const CATCHUP_GRACE_MS = 60_000;
44
57
  /**
45
58
  * The per-job executor. `deps` threads the harness + the injectable
46
- * clock (`deps.now` — the tests' lever; `runtime.sleep` reads the same
47
- * clock through the harness, which is why the phases re-check it).
59
+ * clock (`deps.now` — the tests' lever; `runtime.sleepUntil` deadlines
60
+ * live on the same harness clock, which is why the phases re-check it).
48
61
  */
49
62
  export function createScheduleTask(deps) {
50
63
  return defineTask({
@@ -80,9 +93,21 @@ export function createScheduleTask(deps) {
80
93
  }
81
94
  nextAt = first;
82
95
  }
83
- // The durable wait — the crash point the battery kills inside.
84
- await runtime.sleep(nextAt, context);
85
- // Re-check the record after the sleep (it may have changed).
96
+ // Patch the job's status fields BEFORE sleeping (D3): the
97
+ // record shows the durable deadline while the task sleeps.
98
+ await patchJob(deps.harness, task.input.jobId, (draft) => {
99
+ draft.state = "waiting";
100
+ delete draft.reason;
101
+ draft.nextRun = new Date(nextAt).toISOString();
102
+ }, context);
103
+ // The durable wait — the phase ENDS here. The scheduler
104
+ // resumes the task at the `due` checkpoint when the harness
105
+ // clock reaches nextAt (or immediately when it already has).
106
+ await runtime.sleepUntil(nextAt, { phase: "due", nextAt }, context);
107
+ },
108
+ due: async (task, runtime, context) => {
109
+ const nextAt = task.state.checkpoint.nextAt;
110
+ // Re-check the record on wake (it may have changed).
86
111
  const after = await readJob(deps.harness, task.input.jobId, context);
87
112
  if (stale(after, task.input.hash)) {
88
113
  await runtime.commit(() => ({
@@ -126,6 +151,7 @@ export function createScheduleTask(deps) {
126
151
  }), context);
127
152
  await patchJob(deps.harness, task.input.jobId, (draft) => {
128
153
  draft.state = "waiting";
154
+ delete draft.reason;
129
155
  draft.nextRun = new Date(next).toISOString();
130
156
  }, context);
131
157
  return;
@@ -136,6 +162,7 @@ export function createScheduleTask(deps) {
136
162
  }), context);
137
163
  await patchJob(deps.harness, task.input.jobId, (draft) => {
138
164
  draft.state = "firing";
165
+ delete draft.reason;
139
166
  }, context);
140
167
  },
141
168
  firing: async (task, runtime, context) => {
@@ -156,6 +183,7 @@ export function createScheduleTask(deps) {
156
183
  if (next === undefined) {
157
184
  await patchJob(deps.harness, task.input.jobId, (draft) => {
158
185
  draft.state = "waiting";
186
+ delete draft.reason;
159
187
  delete draft.nextRun;
160
188
  }, context);
161
189
  await runtime.commit(() => ({
@@ -170,6 +198,7 @@ export function createScheduleTask(deps) {
170
198
  }), context);
171
199
  await patchJob(deps.harness, task.input.jobId, (draft) => {
172
200
  draft.state = "waiting";
201
+ delete draft.reason;
173
202
  draft.nextRun = new Date(next).toISOString();
174
203
  }, context);
175
204
  },
@@ -186,5 +215,5 @@ export function createScheduleTask(deps) {
186
215
  function stale(record, hash) {
187
216
  return (record === undefined || record.hash !== hash || record.enabled === false);
188
217
  }
189
- /** Awaiting `runtime.sleep` on the harness's background context. */
218
+ /** The schedule task's commits run on the harness's background context. */
190
219
  export const SCHEDULE_CONTEXT = BACKGROUND_CONTEXT;
package/dist/serve.js CHANGED
@@ -32,7 +32,22 @@ import { defaultSettingsDir, SettingsStore } from "./harness/settings.js";
32
32
  import { startGateway } from "./server/gateway.js";
33
33
  import { SIGRUN_VERSION } from "./server/schema-fixture.js";
34
34
  import { createServer } from "./server/index.js";
35
- import { createShutdown } from "./server/shutdown.js";
35
+ import { createShutdown, PROVIDER_REFRESH_SHUTDOWN_CAP_MS, shutdownBudgetMs, } from "./server/shutdown.js";
36
+ /**
37
+ * Arm the admin-restart force-exit watchdog. The injected timer/exit seams
38
+ * make the contract testable without exiting the suite's process. Returns
39
+ * the disarm function used after a clean ordered shutdown.
40
+ */
41
+ export function armRestartWatchdog(budgetMs, opts) {
42
+ const setTimer = opts?.setTimer ?? setTimeout;
43
+ const exit = opts?.exit ?? process.exit;
44
+ const watchdog = setTimer(() => {
45
+ console.error(`sigrun: restart shutdown exceeded ${budgetMs}ms; forcing exit`);
46
+ exit(1);
47
+ }, budgetMs);
48
+ watchdog.unref?.();
49
+ return () => clearTimeout(watchdog);
50
+ }
36
51
  /**
37
52
  * The deploy entry's default listen host: the IPv6 wildcard, which is a
38
53
  * DUAL-STACK listener on Linux (`net.ipv6.bindv6only` is 0) — it accepts
@@ -246,19 +261,13 @@ export async function serve(env, options) {
246
261
  // the callback defers to it (the route fires post-listen).
247
262
  onRestart: () => bootedReady
248
263
  .then(async (handle) => {
249
- // Force-exit lever (the review's finding 4): the ordered
250
- // shutdown's drain is unbounded — a wedged close would be
251
- // an unrecoverable outage (the supervisor never sees the
252
- // process die, so it never restarts it). The watchdog is
253
- // unref'd: the normal path (shutdown settles, exit below)
254
- // is unaffected.
255
- const watchdog = setTimeout(() => {
256
- console.error("sigrun: restart shutdown exceeded 30s; forcing exit");
257
- process.exit(1);
258
- }, 30_000);
259
- watchdog.unref();
264
+ // A wedged stage must never leave the supervisor waiting for
265
+ // a process that cannot die. The watchdog shares the ordered
266
+ // shutdown's computed fixed budget; it is unref'd, so the
267
+ // normal path (shutdown settles, exit below) is unaffected.
268
+ const disarmWatchdog = armRestartWatchdog(shutdownBudgetMs(config.memorySeam));
260
269
  await handle.shutdownOnce();
261
- clearTimeout(watchdog);
270
+ disarmWatchdog();
262
271
  process.exit(0);
263
272
  })
264
273
  .catch((error) => {
@@ -293,7 +302,7 @@ export async function serve(env, options) {
293
302
  joined = true;
294
303
  })
295
304
  .catch(() => { });
296
- const BOOT_REFRESH_JOIN_MS = 2_000;
305
+ const BOOT_REFRESH_JOIN_MS = PROVIDER_REFRESH_SHUTDOWN_CAP_MS;
297
306
  await Promise.race([
298
307
  join,
299
308
  new Promise((resolve) => {
@@ -6,6 +6,14 @@
6
6
  * allowlist spells v4 ranges.
7
7
  */
8
8
  import { BlockList, isIP } from "node:net";
9
+ /** Fold a v4-mapped v6 spelling (`::ffff:10.42.3.4`) to the v4 address. */
10
+ export function normalizePeerAddress(address) {
11
+ const trimmed = address.trim();
12
+ if (trimmed.startsWith("::ffff:") && isIP(trimmed.slice(7)) === 4) {
13
+ return trimmed.slice(7);
14
+ }
15
+ return trimmed;
16
+ }
9
17
  /**
10
18
  * Whether `address` (a socket remote address) falls in any of `cidrs`
11
19
  * (CIDR ranges or bare IPs). Malformed entries never match; an
@@ -14,10 +22,7 @@ import { BlockList, isIP } from "node:net";
14
22
  export function ipAllowed(address, cidrs) {
15
23
  if (address === undefined)
16
24
  return false;
17
- let candidate = address.trim();
18
- if (candidate.startsWith("::ffff:") && isIP(candidate.slice(7)) === 4) {
19
- candidate = candidate.slice(7);
20
- }
25
+ const candidate = normalizePeerAddress(address);
21
26
  const family = isIP(candidate);
22
27
  if (family === 0)
23
28
  return false;