@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.
- package/CHANGELOG.md +50 -0
- package/README.md +234 -1
- package/dist/cli.js +117 -19
- package/dist/config.js +21 -2
- package/dist/extension.d.ts +7 -1
- package/dist/harness/ambient.js +2 -0
- package/dist/harness/blocks/state.js +1 -0
- package/dist/harness/boot.js +86 -6
- package/dist/harness/context.js +25 -0
- package/dist/harness/event-bus.js +25 -14
- package/dist/harness/identity/accounts.js +3 -2
- package/dist/harness/identity/index.js +2 -1
- package/dist/harness/identity/invites.js +26 -9
- package/dist/harness/identity/link-sweep.js +47 -0
- package/dist/harness/identity/typed-username.js +15 -0
- package/dist/harness/identity/users.js +10 -7
- package/dist/harness/interactions.js +2 -0
- package/dist/harness/loader.js +57 -1
- package/dist/harness/migration-snapshot.js +97 -0
- package/dist/harness/reports.js +100 -0
- package/dist/harness/seam-host.js +11 -1
- package/dist/harness/settings.js +52 -4
- package/dist/jobs/docs.js +3 -0
- package/dist/jobs/fire.js +2 -0
- package/dist/jobs/parked.js +38 -0
- package/dist/jobs/task.js +37 -8
- package/dist/serve.js +23 -14
- package/dist/server/auth/ip.js +9 -4
- package/dist/server/auth/own-host.js +115 -0
- package/dist/server/auth/pipeline.js +64 -48
- package/dist/server/auth/proxy-diagnostic.js +60 -0
- package/dist/server/auth/proxy-identity.js +134 -0
- package/dist/server/auth/proxy-only.js +28 -0
- package/dist/server/login/resolve.js +88 -26
- package/dist/server/routes/admin.js +97 -0
- package/dist/server/routes/login.js +8 -0
- package/dist/server/routes/metrics.js +18 -2
- package/dist/server/routes/onboarding.js +97 -37
- package/dist/server/session-meta.js +1 -0
- package/dist/server/shutdown.js +93 -16
- package/dist/settings/registry.js +6 -0
- package/node_modules/@sigrun/shared/CHANGELOG.md +7 -0
- package/node_modules/@sigrun/shared/dist/settings.d.ts +14 -0
- package/node_modules/@sigrun/shared/dist/usernames.d.ts +8 -0
- package/node_modules/@sigrun/shared/dist/usernames.js +39 -0
- package/node_modules/@sigrun/shared/dist/views.d.ts +6 -4
- package/node_modules/@sigrun/shared/package.json +5 -1
- package/package.json +14 -12
- package/webui/dist/assets/{index-SO2W4jjk.js → index-CEOzj1C4.js} +11 -11
- package/webui/dist/index.html +1 -1
- 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
|
-
|
|
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);
|
package/dist/harness/settings.js
CHANGED
|
@@ -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
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
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
|
|
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`:
|
|
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.
|
|
47
|
-
*
|
|
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
|
-
//
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
/**
|
|
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
|
-
//
|
|
250
|
-
//
|
|
251
|
-
//
|
|
252
|
-
//
|
|
253
|
-
|
|
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
|
-
|
|
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 =
|
|
305
|
+
const BOOT_REFRESH_JOIN_MS = PROVIDER_REFRESH_SHUTDOWN_CAP_MS;
|
|
297
306
|
await Promise.race([
|
|
298
307
|
join,
|
|
299
308
|
new Promise((resolve) => {
|
package/dist/server/auth/ip.js
CHANGED
|
@@ -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
|
-
|
|
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;
|