@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
@@ -89,6 +89,7 @@ import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
89
89
  import { createRequire } from "node:module";
90
90
  import { basename, dirname, isAbsolute, join, resolve } from "node:path";
91
91
  import { fileURLToPath } from "node:url";
92
+ import { satisfies, validRange } from "semver";
92
93
  import { HOST_RUNTIME_MODULES, hostModuleVersion, readPackageAssetsDir, readPackageName, readPackageVersion, resolveExtensionEntry, } from "./packages.js";
93
94
  import { RESERVED_CORE_SECTION_KEYS, RESERVED_EXTENSION_SLUGS, } from "./reserved-sections.js";
94
95
  import { slugifyName } from "./stores.js";
@@ -997,6 +998,57 @@ function checkHostModules(moduleDir) {
997
998
  `host runs ${hostVersion} (same major, allowed)`);
998
999
  }
999
1000
  }
1001
+ /**
1002
+ * The Earendil host modules whose DECLARED peer ranges the loader checks.
1003
+ * Other peers (typebox and @sigrun/sigrun) have different compatibility
1004
+ * contracts and are deliberately outside this guard.
1005
+ */
1006
+ const DECLARED_PEER_MODULES = [
1007
+ "@earendil-works/pi-durable",
1008
+ "@earendil-works/chord",
1009
+ "@earendil-works/pi-ai",
1010
+ ];
1011
+ /**
1012
+ * The declared-peer guard (pi-durable 1.4.0 D7): after the resolved-version
1013
+ * guard, reject an Earendil peer range that excludes the running host.
1014
+ * Non-semver specifiers (file:, link:, git:, URLs, workspace:) and wildcards
1015
+ * pass; a missing or unreadable manifest also passes because the guard never
1016
+ * quarantines what it cannot read.
1017
+ *
1018
+ * @param moduleDir the extension ENTRY's directory; the owning package
1019
+ * manifest is the nearest manifest at or above it.
1020
+ * @returns the quarantine reason, or undefined when the declaration admits
1021
+ * the host or cannot be evaluated.
1022
+ */
1023
+ function checkDeclaredPeers(moduleDir) {
1024
+ const manifestPath = nearestManifest(moduleDir);
1025
+ if (manifestPath === undefined)
1026
+ return undefined;
1027
+ let peers;
1028
+ try {
1029
+ const parsed = JSON.parse(readFileSync(manifestPath, "utf8"));
1030
+ if (typeof parsed.peerDependencies !== "object" ||
1031
+ parsed.peerDependencies === null ||
1032
+ Array.isArray(parsed.peerDependencies)) {
1033
+ return undefined;
1034
+ }
1035
+ peers = parsed.peerDependencies;
1036
+ }
1037
+ catch {
1038
+ return undefined;
1039
+ }
1040
+ for (const name of DECLARED_PEER_MODULES) {
1041
+ const range = peers[name];
1042
+ if (typeof range !== "string" || validRange(range) === null)
1043
+ continue;
1044
+ const hostVersion = hostModuleVersion(name);
1045
+ if (hostVersion !== undefined &&
1046
+ !satisfies(hostVersion, range)) {
1047
+ return `peer ${name} ${range} excludes host ${hostVersion}`;
1048
+ }
1049
+ }
1050
+ return undefined;
1051
+ }
1000
1052
  /**
1001
1053
  * The extension module's DIRECTORY — the anchor a contributed bundle's
1002
1054
  * relative `dir` resolves against (build-and-webui Task 6: an
@@ -1497,8 +1549,12 @@ export async function loadExtensions(specifiers, registry, options = {}) {
1497
1549
  // here rides the existing per-extension try (quarantine, boot
1498
1550
  // continues) exactly like the reserved-section check below.
1499
1551
  const moduleDir = extensionModuleDir(loaded);
1500
- if (moduleDir !== undefined)
1552
+ if (moduleDir !== undefined) {
1501
1553
  checkHostModules(moduleDir);
1554
+ const peerReason = checkDeclaredPeers(moduleDir);
1555
+ if (peerReason !== undefined)
1556
+ throw new Error(peerReason);
1557
+ }
1502
1558
  // Two-phase host: the factory receives the host at CALL time, but
1503
1559
  // the extension name and kinds it declares are only known from its
1504
1560
  // RESULT. The loader therefore invokes the host factory TWICE with
@@ -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
@@ -171,7 +177,21 @@ export function createSeamHost(identity, kinds, bind, sharedStores, perUserTools
171
177
  return true;
172
178
  }, context);
173
179
  },
180
+ async peekInteraction(conversationId, callId, context) {
181
+ // The non-consuming inspection read (release-readiness-2 §1):
182
+ // the SAME stored projection as snapshotInteraction, with NO
183
+ // path to takeSecretDelivery — an inspector (the gateway
184
+ // notifier, outcome edits, answer correlation) can never eat
185
+ // the owning extension's one-shot raw-secret delivery.
186
+ context = toDurableContext(context);
187
+ const h = await harness(context);
188
+ const stored = await h.snapshot(InteractionDoc, conversationId, callId, context);
189
+ if (stored === undefined)
190
+ return undefined;
191
+ return asInteractionRecord(stored);
192
+ },
174
193
  async snapshotInteraction(conversationId, callId, context) {
194
+ context = toDurableContext(context);
175
195
  const h = await harness(context);
176
196
  const stored = await h.snapshot(InteractionDoc, conversationId, callId, context);
177
197
  if (stored === undefined)
@@ -199,6 +219,7 @@ export function createSeamHost(identity, kinds, bind, sharedStores, perUserTools
199
219
  if (!ambientKinds.has(record.key)) {
200
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)`);
201
221
  }
222
+ context = toDurableContext(context);
202
223
  const h = await harness(context);
203
224
  await h.commit(async (tx) => {
204
225
  // Allocation: one owner per key. The claim happens in the same
@@ -233,6 +254,7 @@ export function createSeamHost(identity, kinds, bind, sharedStores, perUserTools
233
254
  if (!ambientKinds.has(key)) {
234
255
  throw new SeamError("ambient", `surface key ${JSON.stringify(key)} is not declared by ${identity.name}`);
235
256
  }
257
+ context = toDurableContext(context);
236
258
  const h = await harness(context);
237
259
  await h.commit(async (tx) => {
238
260
  const draft = await tx.doc(AmbientDoc, conversationId, key, key);
@@ -53,7 +53,7 @@ export const IDENTITY_TEXT = "You are sigrun, an AI assistant running as a persi
53
53
  * The persona sigrun ships when no `SOUL.md` exists. A present `SOUL.md`
54
54
  * replaces it wholesale; the default is never merged in.
55
55
  */
56
- export const DEFAULT_PERSONA = `You are Sigrun. You choose, and you say why — a valkyrie doesn't dither over the field. You have opinions and you give them straight: if a plan is bad, say it's bad and say what would make it good. Hedging is a way of lying politely.
56
+ export const DEFAULT_PERSONA = `You are Sigrun. You choose, and you say why — choosing is the job, and dithering over the field isn't. You have opinions and you give them straight: if a plan is bad, say it's bad and say what would make it good. Hedging is a way of lying politely.
57
57
 
58
58
  Match your reply to the weight of the ask. No filler openers, no restating the request, no narrating what the user can already see. When you don't know, say so and name what would settle it.
59
59
 
@@ -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;