@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.
- package/CHANGELOG.md +145 -7
- package/README.md +333 -13
- package/dist/blocks/build-stamp.json +13 -0
- package/dist/blocks/builders.d.ts +57 -0
- package/dist/blocks/images.d.ts +26 -0
- package/dist/blocks/index.d.ts +20 -0
- package/dist/blocks/limits.d.ts +25 -0
- package/dist/blocks/placements.d.ts +26 -0
- package/dist/blocks/schema.d.ts +38 -0
- package/dist/blocks/validate.d.ts +73 -0
- package/dist/cli.js +117 -19
- package/dist/config.js +21 -2
- package/dist/extension.d.ts +48 -8
- package/dist/harness/ambient.js +2 -0
- package/dist/harness/blocks/state.js +1 -0
- package/dist/harness/boot.js +92 -6
- package/dist/harness/builtins.js +1 -1
- 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 +23 -1
- package/dist/harness/sections/context-files.js +1 -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/sandbox/availability.js +69 -20
- package/dist/sandbox/launch.js +11 -0
- package/dist/sandbox/policy.js +24 -3
- package/dist/sandbox/worker.js +4 -24
- package/dist/serve.js +162 -40
- package/dist/server/auth/ip.js +9 -4
- package/dist/server/auth/own-host.js +115 -0
- package/dist/server/auth/pipeline.js +93 -28
- 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/images/cache.js +13 -5
- package/dist/server/index.js +10 -2
- package/dist/server/login/resolve.js +88 -26
- package/dist/server/notifications.js +21 -4
- package/dist/server/routes/admin.js +97 -0
- package/dist/server/routes/images.js +16 -9
- package/dist/server/routes/login.js +8 -0
- package/dist/server/routes/me.js +1 -1
- package/dist/server/routes/meta.js +4 -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 +12 -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 +33 -4
- package/node_modules/@sigrun/shared/package.json +5 -1
- package/package.json +16 -15
- package/webui/dist/assets/{index-CxB969qp.js → index-CEOzj1C4.js} +11 -11
- package/webui/dist/index.html +1 -1
- package/CONTEXT.md +0 -163
- package/dist/server/login/usernames.js +0 -15
package/dist/harness/loader.js
CHANGED
|
@@ -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
|
-
|
|
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 —
|
|
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
|
|
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;
|