@sigrun/sigrun 0.0.0-stage → 0.1.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 +204 -0
- package/CONTEXT.md +163 -0
- package/LICENSE +21 -0
- package/README.md +691 -2
- package/THIRD_PARTY_NOTICES.md +28 -0
- package/dist/backup/marker.js +80 -0
- package/dist/backup/run-backup.js +339 -0
- package/dist/cli.js +952 -0
- package/dist/commands/expand.js +245 -0
- package/dist/commands/templates.js +272 -0
- package/dist/config.js +1146 -0
- package/dist/extension.d.ts +431 -0
- package/dist/harness/activity.js +42 -0
- package/dist/harness/ambient.js +261 -0
- package/dist/harness/auth.js +130 -0
- package/dist/harness/boot.js +865 -0
- package/dist/harness/builtins.js +191 -0
- package/dist/harness/event-bus.js +1203 -0
- package/dist/harness/health.js +30 -0
- package/dist/harness/identity/index.js +37 -0
- package/dist/harness/identity/invites.js +94 -0
- package/dist/harness/identity/json-store.js +108 -0
- package/dist/harness/identity/setup-code.js +81 -0
- package/dist/harness/identity/tokens.js +101 -0
- package/dist/harness/identity/users.js +93 -0
- package/dist/harness/interactions.js +283 -0
- package/dist/harness/loader.js +1236 -0
- package/dist/harness/memory-default.js +35 -0
- package/dist/harness/naming.js +193 -0
- package/dist/harness/packages.js +1002 -0
- package/dist/harness/providers/auth-bridge.js +60 -0
- package/dist/harness/providers/index.js +277 -0
- package/dist/harness/providers/models-store.js +105 -0
- package/dist/harness/providers/refresh.js +161 -0
- package/dist/harness/providers/scheduler.js +89 -0
- package/dist/harness/registry.js +9 -0
- package/dist/harness/reserved-sections.js +24 -0
- package/dist/harness/seam-host.js +239 -0
- package/dist/harness/sections/context-files.js +78 -0
- package/dist/harness/sections/model-guidance.js +77 -0
- package/dist/harness/settings.js +1053 -0
- package/dist/harness/stores.js +450 -0
- package/dist/harness/tools.js +500 -0
- package/dist/harness/workspace.js +73 -0
- package/dist/index.js +39 -0
- package/dist/jobs/docs.js +257 -0
- package/dist/jobs/fire.js +342 -0
- package/dist/jobs/index.js +228 -0
- package/dist/jobs/observe.js +165 -0
- package/dist/jobs/reconcile.js +245 -0
- package/dist/jobs/schedule.js +77 -0
- package/dist/jobs/spec.js +248 -0
- package/dist/jobs/task.js +190 -0
- package/dist/jobs/transfer.js +53 -0
- package/dist/serve.js +164 -0
- package/dist/server/auth/claim-lock.js +28 -0
- package/dist/server/auth/ip.js +51 -0
- package/dist/server/auth/login-limiter.js +52 -0
- package/dist/server/auth/loopback.js +47 -0
- package/dist/server/auth/pipeline.js +330 -0
- package/dist/server/auth/principal.js +26 -0
- package/dist/server/commands.js +198 -0
- package/dist/server/content-parts.js +142 -0
- package/dist/server/context.js +13 -0
- package/dist/server/conversation-handler.js +61 -0
- package/dist/server/conversation-ops.js +310 -0
- package/dist/server/events/hub.js +39 -0
- package/dist/server/events/types.js +8 -0
- package/dist/server/helpers.js +351 -0
- package/dist/server/host-bridge.js +131 -0
- package/dist/server/index.js +232 -0
- package/dist/server/notifications.js +185 -0
- package/dist/server/ownership.js +67 -0
- package/dist/server/readiness.js +36 -0
- package/dist/server/routes/auth.js +172 -0
- package/dist/server/routes/conversations.js +341 -0
- package/dist/server/routes/events.js +161 -0
- package/dist/server/routes/extension-routes.js +127 -0
- package/dist/server/routes/extensions.js +168 -0
- package/dist/server/routes/files.js +308 -0
- package/dist/server/routes/interactions.js +260 -0
- package/dist/server/routes/jobs.js +271 -0
- package/dist/server/routes/meta.js +146 -0
- package/dist/server/routes/metrics.js +219 -0
- package/dist/server/routes/onboarding.js +268 -0
- package/dist/server/routes/probes.js +53 -0
- package/dist/server/routes/providers.js +198 -0
- package/dist/server/routes/settings.js +48 -0
- package/dist/server/routes/stream.js +131 -0
- package/dist/server/routes/submissions.js +317 -0
- package/dist/server/routes/users.js +169 -0
- package/dist/server/schema-fixture.js +950 -0
- package/dist/server/session-meta.js +247 -0
- package/dist/server/static.js +169 -0
- package/dist/server/views.js +71 -0
- package/dist/skills/index.js +418 -0
- package/node_modules/@sigrun/shared/CHANGELOG.md +16 -0
- package/node_modules/@sigrun/shared/LICENSE +21 -0
- package/node_modules/@sigrun/shared/README.md +10 -0
- package/node_modules/@sigrun/shared/dist/access.d.ts +55 -0
- package/node_modules/@sigrun/shared/dist/access.js +72 -0
- package/node_modules/@sigrun/shared/dist/ambient.d.ts +111 -0
- package/node_modules/@sigrun/shared/dist/ambient.js +23 -0
- package/node_modules/@sigrun/shared/dist/commands.d.ts +75 -0
- package/node_modules/@sigrun/shared/dist/commands.js +25 -0
- package/node_modules/@sigrun/shared/dist/conversations.d.ts +31 -0
- package/node_modules/@sigrun/shared/dist/conversations.js +94 -0
- package/node_modules/@sigrun/shared/dist/interactions.d.ts +91 -0
- package/node_modules/@sigrun/shared/dist/interactions.js +21 -0
- package/node_modules/@sigrun/shared/dist/jobs.d.ts +100 -0
- package/node_modules/@sigrun/shared/dist/jobs.js +19 -0
- package/node_modules/@sigrun/shared/dist/json.d.ts +20 -0
- package/node_modules/@sigrun/shared/dist/json.js +13 -0
- package/node_modules/@sigrun/shared/dist/session-meta.d.ts +103 -0
- package/node_modules/@sigrun/shared/dist/session-meta.js +13 -0
- package/node_modules/@sigrun/shared/dist/settings.d.ts +401 -0
- package/node_modules/@sigrun/shared/dist/settings.js +19 -0
- package/node_modules/@sigrun/shared/dist/themes.d.ts +50 -0
- package/node_modules/@sigrun/shared/dist/themes.js +156 -0
- package/node_modules/@sigrun/shared/dist/turns.d.ts +102 -0
- package/node_modules/@sigrun/shared/dist/turns.js +166 -0
- package/node_modules/@sigrun/shared/dist/view-delta.d.ts +54 -0
- package/node_modules/@sigrun/shared/dist/view-delta.js +135 -0
- package/node_modules/@sigrun/shared/dist/views.d.ts +742 -0
- package/node_modules/@sigrun/shared/dist/views.js +38 -0
- package/node_modules/@sigrun/shared/package.json +83 -0
- package/package.json +90 -5
- package/webui/dist/assets/index-Rl0KhCLa.css +1 -0
- package/webui/dist/assets/index-rOiARZLF.js +50 -0
- package/webui/dist/index.html +37 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* §8.2 schedule math (jobs Task 1) — when a job fires next.
|
|
3
|
+
*
|
|
4
|
+
* Cron semantics belong to croner (spec §8.2: "Cron parsing uses
|
|
5
|
+
* croner … DST is exactly the class of bug not to hand-roll"); this
|
|
6
|
+
* module only dispatches the three schedule shapes and never
|
|
7
|
+
* re-derives a time. `nextFireAt` is the scheduler's single walk step:
|
|
8
|
+
* given a schedule and an instant, the next fire STRICTLY AFTER that
|
|
9
|
+
* instant, or `undefined` when the schedule can never fire again (a
|
|
10
|
+
* one-shot `at` already past).
|
|
11
|
+
*
|
|
12
|
+
* `every` intervals anchor at `after` itself — after downtime the next
|
|
13
|
+
* fire is computed from now, never from a missed slot (the plan's
|
|
14
|
+
* Review Focus 5: `catchUp` decides whether a missed fire is recorded,
|
|
15
|
+
* and the schedule never fires once per missed interval).
|
|
16
|
+
*
|
|
17
|
+
* `cronFor` is the ONE croner parse in the tree: validation
|
|
18
|
+
* (src/jobs/spec.ts rejects a bad pattern via croner's constructor
|
|
19
|
+
* throwing) and evaluation (below) must agree, so they share both the
|
|
20
|
+
* options and the pattern. `mode: "5-part"` enforces the Global
|
|
21
|
+
* Constraints' 5-field grammar — croner's auto mode would silently
|
|
22
|
+
* reinterpret a 6-field pattern as second-level precision instead of
|
|
23
|
+
* rejecting it.
|
|
24
|
+
*/
|
|
25
|
+
import { Cron } from "croner";
|
|
26
|
+
/** The `every` grammar (Global Constraints): `^\d+(s|m|h|d)$`, ≥ 60 s. */
|
|
27
|
+
const EVERY_PATTERN = /^\d+(s|m|h|d)$/;
|
|
28
|
+
const EVERY_UNIT_MS = {
|
|
29
|
+
s: 1_000,
|
|
30
|
+
m: 60_000,
|
|
31
|
+
h: 3_600_000,
|
|
32
|
+
d: 86_400_000,
|
|
33
|
+
};
|
|
34
|
+
/** The floor the Global Constraints put on `every` intervals. */
|
|
35
|
+
const EVERY_MIN_MS = 60_000;
|
|
36
|
+
/**
|
|
37
|
+
* The single croner parse (see the module header): a `Cron` whose
|
|
38
|
+
* constructor throws on an invalid 5-field pattern. No callback is
|
|
39
|
+
* passed, so croner never arms a timer — this is pure pattern math.
|
|
40
|
+
*/
|
|
41
|
+
export function cronFor(cron, timezone) {
|
|
42
|
+
return new Cron(cron, { timezone, mode: "5-part" });
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Parse an `every` interval into milliseconds. Throws on anything
|
|
46
|
+
* outside `^\\d+(s|m|h|d)$` and on intervals below the 60 s floor.
|
|
47
|
+
*/
|
|
48
|
+
export function parseEvery(text) {
|
|
49
|
+
const match = EVERY_PATTERN.exec(text);
|
|
50
|
+
if (match === null) {
|
|
51
|
+
throw new Error(`every must match ${EVERY_PATTERN.source} (got ${JSON.stringify(text)})`);
|
|
52
|
+
}
|
|
53
|
+
const ms = Number.parseInt(match[0], 10) * EVERY_UNIT_MS[match[1]];
|
|
54
|
+
if (ms < EVERY_MIN_MS) {
|
|
55
|
+
throw new Error(`every must be at least 60 s (got ${JSON.stringify(text)})`);
|
|
56
|
+
}
|
|
57
|
+
return ms;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* The next fire strictly after `after`, in epoch ms — or `undefined`
|
|
61
|
+
* when the schedule will never fire again (a one-shot `at` at or
|
|
62
|
+
* before `after`). A cron's timezone is the schedule's, else
|
|
63
|
+
* `defaultTz` (the resolved `server.timezone`).
|
|
64
|
+
*/
|
|
65
|
+
export function nextFireAt(schedule, after, defaultTz) {
|
|
66
|
+
if ("cron" in schedule) {
|
|
67
|
+
const next = cronFor(schedule.cron, schedule.timezone ?? defaultTz).nextRun(new Date(after));
|
|
68
|
+
return next === null ? undefined : next.getTime();
|
|
69
|
+
}
|
|
70
|
+
if ("every" in schedule) {
|
|
71
|
+
// Anchored at `after` (see the module header): the next fire is
|
|
72
|
+
// one interval from the instant asked about.
|
|
73
|
+
return after + parseEvery(schedule.every);
|
|
74
|
+
}
|
|
75
|
+
const fireAt = Date.parse(schedule.at);
|
|
76
|
+
return Number.isNaN(fireAt) || fireAt <= after ? undefined : fireAt;
|
|
77
|
+
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* §8.2 job-spec validation and hashing (jobs Task 1).
|
|
3
|
+
*
|
|
4
|
+
* Every job source — api (`POST /v1/jobs`), file (settings.json
|
|
5
|
+
* `jobs[]`), extension (factory-result `jobs[]`) — funnels through
|
|
6
|
+
* `validateJobSpec` before a job record exists; boot reconciliation
|
|
7
|
+
* then compares `specHash` to decide whether a declared job's schedule
|
|
8
|
+
* task may keep running (same hash) or must be recreated (changed).
|
|
9
|
+
*
|
|
10
|
+
* Validation maps every rejection to a `field` path so callers can
|
|
11
|
+
* attribute the 400 to the offending input. Cron validation IS
|
|
12
|
+
* croner's parse (`cronFor` in ./schedule.js — the constructor throws
|
|
13
|
+
* on a bad 5-field pattern), so what the validator accepts is exactly
|
|
14
|
+
* what the scheduler can evaluate; nothing is validated by a second
|
|
15
|
+
* grammar that could drift.
|
|
16
|
+
*
|
|
17
|
+
* `specHash` canonicalizes (recursively sorted object keys) before
|
|
18
|
+
* hashing, so key order — where the spec came from, which JSON parser
|
|
19
|
+
* read it — never changes a job's identity.
|
|
20
|
+
*/
|
|
21
|
+
import { createHash } from "node:crypto";
|
|
22
|
+
import { isSharing } from "@sigrun/shared/access";
|
|
23
|
+
import { cronFor, parseEvery } from "./schedule.js";
|
|
24
|
+
/** Job names double as the file/extension id suffix (Global Constraints). */
|
|
25
|
+
const NAME_PATTERN = /^[a-z0-9][a-z0-9._-]*$/;
|
|
26
|
+
/**
|
|
27
|
+
* Refine `unknown` (a request body, a settings block, an extension
|
|
28
|
+
* declaration) into a `JobSpec`, or reject with the first offending
|
|
29
|
+
* field. The accepted spec is rebuilt field-by-field: only the
|
|
30
|
+
* declared, validated fields survive, so extra keys in the input never
|
|
31
|
+
* ride into the record (or into `specHash`).
|
|
32
|
+
*/
|
|
33
|
+
export function validateJobSpec(value) {
|
|
34
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
35
|
+
return invalid("spec", "a job spec must be an object");
|
|
36
|
+
}
|
|
37
|
+
const record = value;
|
|
38
|
+
if (typeof record.name !== "string" || !NAME_PATTERN.test(record.name)) {
|
|
39
|
+
return invalid("name", `name must be a string matching ${NAME_PATTERN.source}`);
|
|
40
|
+
}
|
|
41
|
+
const schedule = validateSchedule(record.schedule);
|
|
42
|
+
if (!schedule.ok)
|
|
43
|
+
return schedule;
|
|
44
|
+
const action = validateAction(record.action);
|
|
45
|
+
if (!action.ok)
|
|
46
|
+
return action;
|
|
47
|
+
if (record.overlap !== undefined && record.overlap !== "skip" && record.overlap !== "queue") {
|
|
48
|
+
return invalid("overlap", 'overlap must be "skip" or "queue"');
|
|
49
|
+
}
|
|
50
|
+
if (record.catchUp !== undefined && record.catchUp !== "once" && record.catchUp !== "none") {
|
|
51
|
+
return invalid("catchUp", 'catchUp must be "once" or "none"');
|
|
52
|
+
}
|
|
53
|
+
if (record.enabled !== undefined && typeof record.enabled !== "boolean") {
|
|
54
|
+
return invalid("enabled", "enabled must be a boolean");
|
|
55
|
+
}
|
|
56
|
+
if (record.owner !== undefined && typeof record.owner !== "string") {
|
|
57
|
+
return invalid("owner", "owner must be a string");
|
|
58
|
+
}
|
|
59
|
+
if (record.sharing !== undefined && !isSharing(record.sharing)) {
|
|
60
|
+
return invalid("sharing", "sharing must be a Sharing value");
|
|
61
|
+
}
|
|
62
|
+
const spec = {
|
|
63
|
+
name: record.name,
|
|
64
|
+
schedule: schedule.schedule,
|
|
65
|
+
action: action.action,
|
|
66
|
+
};
|
|
67
|
+
if (record.overlap !== undefined)
|
|
68
|
+
spec.overlap = record.overlap;
|
|
69
|
+
if (record.catchUp !== undefined)
|
|
70
|
+
spec.catchUp = record.catchUp;
|
|
71
|
+
if (record.enabled !== undefined)
|
|
72
|
+
spec.enabled = record.enabled;
|
|
73
|
+
if (record.owner !== undefined)
|
|
74
|
+
spec.owner = record.owner;
|
|
75
|
+
if (record.sharing !== undefined)
|
|
76
|
+
spec.sharing = record.sharing;
|
|
77
|
+
return { ok: true, spec };
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* sha256 of the canonical JSON (recursively key-sorted) of
|
|
81
|
+
* `{ id, spec }` — one job version's identity, as boot reconciliation
|
|
82
|
+
* compares it.
|
|
83
|
+
*/
|
|
84
|
+
export function specHash(id, spec) {
|
|
85
|
+
return createHash("sha256")
|
|
86
|
+
.update(JSON.stringify(canonicalize({ id, spec })))
|
|
87
|
+
.digest("hex");
|
|
88
|
+
}
|
|
89
|
+
function invalid(field, message) {
|
|
90
|
+
return { ok: false, field, message };
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* A schedule is exactly ONE of the three shapes (the wire type is a
|
|
94
|
+
* union): a schedule carrying two discriminants matches no member, so
|
|
95
|
+
* it is rejected rather than silently dispatched on the first present.
|
|
96
|
+
*/
|
|
97
|
+
function validateSchedule(value) {
|
|
98
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
99
|
+
return invalid("schedule", "schedule must be an object");
|
|
100
|
+
}
|
|
101
|
+
const record = value;
|
|
102
|
+
const discriminants = ["cron", "every", "at"].filter((key) => record[key] !== undefined);
|
|
103
|
+
if (discriminants.length !== 1) {
|
|
104
|
+
return invalid("schedule", 'schedule must carry exactly one of "cron", "every", or "at"');
|
|
105
|
+
}
|
|
106
|
+
if (record.cron !== undefined) {
|
|
107
|
+
if (typeof record.cron !== "string") {
|
|
108
|
+
return invalid("schedule.cron", "cron must be a string");
|
|
109
|
+
}
|
|
110
|
+
if (record.timezone !== undefined) {
|
|
111
|
+
if (typeof record.timezone !== "string") {
|
|
112
|
+
return invalid("schedule.timezone", "timezone must be a string");
|
|
113
|
+
}
|
|
114
|
+
// croner validates the name lazily (at evaluation, not parse), so
|
|
115
|
+
// the gate checks it here — a bad name would otherwise crash the
|
|
116
|
+
// scheduler instead of rejecting the spec.
|
|
117
|
+
if (!isIanaTimeZone(record.timezone)) {
|
|
118
|
+
return invalid("schedule.timezone", `timezone must be an IANA name (got ${JSON.stringify(record.timezone)})`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
try {
|
|
122
|
+
cronFor(record.cron, record.timezone);
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
return invalid("schedule.cron", error instanceof Error ? error.message : String(error));
|
|
126
|
+
}
|
|
127
|
+
const schedule = { cron: record.cron };
|
|
128
|
+
if (record.timezone !== undefined)
|
|
129
|
+
schedule.timezone = record.timezone;
|
|
130
|
+
return { ok: true, schedule };
|
|
131
|
+
}
|
|
132
|
+
if (record.every !== undefined) {
|
|
133
|
+
if (typeof record.every !== "string") {
|
|
134
|
+
return invalid("schedule.every", "every must be a string");
|
|
135
|
+
}
|
|
136
|
+
try {
|
|
137
|
+
parseEvery(record.every);
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
return invalid("schedule.every", error instanceof Error ? error.message : String(error));
|
|
141
|
+
}
|
|
142
|
+
return { ok: true, schedule: { every: record.every } };
|
|
143
|
+
}
|
|
144
|
+
// The one remaining discriminant is "at".
|
|
145
|
+
if (typeof record.at !== "string" || Number.isNaN(Date.parse(record.at))) {
|
|
146
|
+
return invalid("schedule.at", "at must be an ISO 8601 timestamp");
|
|
147
|
+
}
|
|
148
|
+
return { ok: true, schedule: { at: record.at } };
|
|
149
|
+
}
|
|
150
|
+
function validateAction(value) {
|
|
151
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
152
|
+
return invalid("action", "action must be an object");
|
|
153
|
+
}
|
|
154
|
+
const record = value;
|
|
155
|
+
if (record.kind === "prompt") {
|
|
156
|
+
if (typeof record.prompt !== "string") {
|
|
157
|
+
return invalid("action.prompt", "prompt must be a string");
|
|
158
|
+
}
|
|
159
|
+
if (record.conversation !== "new" &&
|
|
160
|
+
!(typeof record.conversation === "number" &&
|
|
161
|
+
Number.isInteger(record.conversation))) {
|
|
162
|
+
return invalid("action.conversation", 'conversation must be "new" or a conversation id');
|
|
163
|
+
}
|
|
164
|
+
if (record.model !== undefined && typeof record.model !== "string") {
|
|
165
|
+
return invalid("action.model", "model must be a string");
|
|
166
|
+
}
|
|
167
|
+
if (record.thinking !== undefined && typeof record.thinking !== "string") {
|
|
168
|
+
return invalid("action.thinking", "thinking must be a string");
|
|
169
|
+
}
|
|
170
|
+
if (record.runSharing !== undefined && !isSharing(record.runSharing)) {
|
|
171
|
+
return invalid("action.runSharing", "runSharing must be a Sharing value");
|
|
172
|
+
}
|
|
173
|
+
const action = {
|
|
174
|
+
kind: "prompt",
|
|
175
|
+
prompt: record.prompt,
|
|
176
|
+
// Guarded above: "new" or an integer conversation id.
|
|
177
|
+
conversation: record.conversation,
|
|
178
|
+
};
|
|
179
|
+
if (record.model !== undefined)
|
|
180
|
+
action.model = record.model;
|
|
181
|
+
if (record.thinking !== undefined)
|
|
182
|
+
action.thinking = record.thinking;
|
|
183
|
+
if (record.runSharing !== undefined)
|
|
184
|
+
action.runSharing = record.runSharing;
|
|
185
|
+
return { ok: true, action };
|
|
186
|
+
}
|
|
187
|
+
if (record.kind === "task") {
|
|
188
|
+
if (typeof record.task !== "string") {
|
|
189
|
+
return invalid("action.task", "task must be a string");
|
|
190
|
+
}
|
|
191
|
+
if (record.input !== undefined && !isJsonValue(record.input)) {
|
|
192
|
+
return invalid("action.input", "input must be a JSON value");
|
|
193
|
+
}
|
|
194
|
+
const action = {
|
|
195
|
+
kind: "task",
|
|
196
|
+
task: record.task,
|
|
197
|
+
};
|
|
198
|
+
if (record.input !== undefined)
|
|
199
|
+
action.input = record.input;
|
|
200
|
+
return { ok: true, action };
|
|
201
|
+
}
|
|
202
|
+
return invalid("action.kind", `action.kind must be "prompt" or "task" (got ${JSON.stringify(record.kind)})`);
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* `Intl` accepts exactly the IANA names `DateTimeFormat` resolves —
|
|
206
|
+
* the same resolver croner's evaluation path uses — and throws on
|
|
207
|
+
* everything else.
|
|
208
|
+
*/
|
|
209
|
+
export function isIanaTimeZone(name) {
|
|
210
|
+
try {
|
|
211
|
+
new Intl.DateTimeFormat("en-US", { timeZone: name });
|
|
212
|
+
return true;
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
/** Structural JSON check for the `task` action's optional `input`. */
|
|
219
|
+
function isJsonValue(value) {
|
|
220
|
+
if (value === null ||
|
|
221
|
+
typeof value === "string" ||
|
|
222
|
+
typeof value === "number" ||
|
|
223
|
+
typeof value === "boolean") {
|
|
224
|
+
return true;
|
|
225
|
+
}
|
|
226
|
+
if (Array.isArray(value))
|
|
227
|
+
return value.every(isJsonValue);
|
|
228
|
+
if (typeof value === "object") {
|
|
229
|
+
return Object.values(value).every(isJsonValue);
|
|
230
|
+
}
|
|
231
|
+
return false;
|
|
232
|
+
}
|
|
233
|
+
/** Canonical form for hashing: key order never affects the digest. */
|
|
234
|
+
function canonicalize(value) {
|
|
235
|
+
if (Array.isArray(value))
|
|
236
|
+
return value.map(canonicalize);
|
|
237
|
+
if (typeof value === "object" && value !== null) {
|
|
238
|
+
const record = value;
|
|
239
|
+
const sorted = {};
|
|
240
|
+
for (const key of Object.keys(record).sort()) {
|
|
241
|
+
if (record[key] === undefined)
|
|
242
|
+
continue;
|
|
243
|
+
sorted[key] = canonicalize(record[key]);
|
|
244
|
+
}
|
|
245
|
+
return sorted;
|
|
246
|
+
}
|
|
247
|
+
return value;
|
|
248
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Jobs Task 2 (spec §8.1 "The executor"): the `sigrun.schedule` task —
|
|
3
|
+
* one long-lived background pi-durable task per ENABLED job, looping
|
|
4
|
+
* `waiting` → `firing` → `waiting` over a durable `runtime.sleep`.
|
|
5
|
+
*
|
|
6
|
+
* The input is only `{jobId, hash, firstAt?}` (the plan's Global
|
|
7
|
+
* Constraints): the task NEVER trusts its input's spec — EVERY phase
|
|
8
|
+
* re-reads the job record, and a record that is absent, disabled, or
|
|
9
|
+
* hash-mismatched makes the task commit TERMINAL (it is a stale executor;
|
|
10
|
+
* reconciliation owns the replacement). `firstAt` exists because
|
|
11
|
+
* `initial(input)` sees only the input: the creator computes the first
|
|
12
|
+
* fire (`nextFireAt(schedule, now, tz)`).
|
|
13
|
+
*
|
|
14
|
+
* The phases:
|
|
15
|
+
* - `waiting`: durable `runtime.sleep(nextAt)`. On wake: under
|
|
16
|
+
* `catchUp: "none"` and `now - nextAt > 60_000`, the missed slot is
|
|
17
|
+
* recorded `skipped` and the task re-commits `waiting` at the NEXT
|
|
18
|
+
* slot from NOW. Otherwise it commits `{phase: "firing", fireAt:
|
|
19
|
+
* nextAt}`.
|
|
20
|
+
* - `firing`: `fireJob(…, fireKey = <jobId>:<fireAt>)` — the run record
|
|
21
|
+
* doubles as the idempotency record, so a crash mid-fire re-enters
|
|
22
|
+
* here and reuses it. Then `nextAt = nextFireAt(schedule, max(now,
|
|
23
|
+
* fireAt), tz)` — FROM NOW, never from the missed slot (Review Focus
|
|
24
|
+
* 5: catchUp decides whether a missed fire is recorded; the schedule
|
|
25
|
+
* never fires once per missed interval). No next slot → terminal
|
|
26
|
+
* completed (a past one-shot `at`).
|
|
27
|
+
*
|
|
28
|
+
* `abort` commits terminal (aborted) — the disable/delete path's signal.
|
|
29
|
+
*
|
|
30
|
+
* The record's status fields (`state`, `nextRun`, `lastRun`) ride
|
|
31
|
+
* `patchJob` — small status edits, never version bumps.
|
|
32
|
+
*/
|
|
33
|
+
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
|
34
|
+
import { defineTask } from "@earendil-works/pi-durable";
|
|
35
|
+
import { JobRunDoc, patchJob, readJob } from "./docs.js";
|
|
36
|
+
import { fireJob } from "./fire.js";
|
|
37
|
+
import { nextFireAt } from "./schedule.js";
|
|
38
|
+
/** The persisted task kind (TaskRecord.kind). */
|
|
39
|
+
export const SCHEDULE_TASK_KIND = "sigrun.schedule";
|
|
40
|
+
/** The definition version persisted with the task. */
|
|
41
|
+
export const SCHEDULE_TASK_VERSION = 1;
|
|
42
|
+
/** Under `catchUp: "none"`, a fire missed by more than this is skipped. */
|
|
43
|
+
const CATCHUP_GRACE_MS = 60_000;
|
|
44
|
+
/**
|
|
45
|
+
* 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).
|
|
48
|
+
*/
|
|
49
|
+
export function createScheduleTask(deps) {
|
|
50
|
+
return defineTask({
|
|
51
|
+
name: SCHEDULE_TASK_KIND,
|
|
52
|
+
version: SCHEDULE_TASK_VERSION,
|
|
53
|
+
// `initial` sees only the input: the creator computed `firstAt`
|
|
54
|
+
// (nextFireAt at creation); absent, the first phase re-derives it
|
|
55
|
+
// from the record at its first run.
|
|
56
|
+
initial: (input) => ({
|
|
57
|
+
phase: "waiting",
|
|
58
|
+
nextAt: input.firstAt ?? 0,
|
|
59
|
+
}),
|
|
60
|
+
phases: {
|
|
61
|
+
waiting: async (task, runtime, context) => {
|
|
62
|
+
const record = await readJob(deps.harness, task.input.jobId, context);
|
|
63
|
+
if (stale(record, task.input.hash)) {
|
|
64
|
+
await runtime.commit(() => ({
|
|
65
|
+
status: "terminal",
|
|
66
|
+
outcome: { status: "completed", result: undefined },
|
|
67
|
+
}), context);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
let nextAt = task.state.checkpoint.nextAt;
|
|
71
|
+
if (nextAt === 0) {
|
|
72
|
+
// No creator-computed firstAt (a re-derived first fire).
|
|
73
|
+
const first = nextFireAt(record.spec.schedule, deps.now(), deps.defaultTz);
|
|
74
|
+
if (first === undefined) {
|
|
75
|
+
await runtime.commit(() => ({
|
|
76
|
+
status: "terminal",
|
|
77
|
+
outcome: { status: "completed", result: undefined },
|
|
78
|
+
}), context);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
nextAt = first;
|
|
82
|
+
}
|
|
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).
|
|
86
|
+
const after = await readJob(deps.harness, task.input.jobId, context);
|
|
87
|
+
if (stale(after, task.input.hash)) {
|
|
88
|
+
await runtime.commit(() => ({
|
|
89
|
+
status: "terminal",
|
|
90
|
+
outcome: { status: "completed", result: undefined },
|
|
91
|
+
}), context);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const now = deps.now();
|
|
95
|
+
const catchUp = after.spec.catchUp ?? "once";
|
|
96
|
+
if (catchUp === "none" && now - nextAt > CATCHUP_GRACE_MS) {
|
|
97
|
+
// Missed beyond the grace: record `skipped` (settled
|
|
98
|
+
// immediately), then schedule the NEXT slot from now.
|
|
99
|
+
const skipped = {
|
|
100
|
+
jobId: task.input.jobId,
|
|
101
|
+
version: after.version,
|
|
102
|
+
fireAt: new Date(nextAt).toISOString(),
|
|
103
|
+
manual: false,
|
|
104
|
+
outcome: "skipped",
|
|
105
|
+
startedAt: new Date(now).toISOString(),
|
|
106
|
+
settledAt: new Date(now).toISOString(),
|
|
107
|
+
};
|
|
108
|
+
const fireKey = `${task.input.jobId}:${nextAt}`;
|
|
109
|
+
await deps.harness.commit(async (tx) => {
|
|
110
|
+
await tx.doc(JobRunDoc, fireKey, skipped);
|
|
111
|
+
}, context);
|
|
112
|
+
await patchJob(deps.harness, task.input.jobId, (draft) => {
|
|
113
|
+
draft.lastRun = skipped;
|
|
114
|
+
}, context);
|
|
115
|
+
const next = nextFireAt(after.spec.schedule, now, deps.defaultTz);
|
|
116
|
+
if (next === undefined) {
|
|
117
|
+
await runtime.commit(() => ({
|
|
118
|
+
status: "terminal",
|
|
119
|
+
outcome: { status: "completed", result: undefined },
|
|
120
|
+
}), context);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
await runtime.commit(() => ({
|
|
124
|
+
status: "running",
|
|
125
|
+
checkpoint: { phase: "waiting", nextAt: next },
|
|
126
|
+
}), context);
|
|
127
|
+
await patchJob(deps.harness, task.input.jobId, (draft) => {
|
|
128
|
+
draft.state = "waiting";
|
|
129
|
+
draft.nextRun = new Date(next).toISOString();
|
|
130
|
+
}, context);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
await runtime.commit(() => ({
|
|
134
|
+
status: "running",
|
|
135
|
+
checkpoint: { phase: "firing", fireAt: nextAt },
|
|
136
|
+
}), context);
|
|
137
|
+
await patchJob(deps.harness, task.input.jobId, (draft) => {
|
|
138
|
+
draft.state = "firing";
|
|
139
|
+
}, context);
|
|
140
|
+
},
|
|
141
|
+
firing: async (task, runtime, context) => {
|
|
142
|
+
const record = await readJob(deps.harness, task.input.jobId, context);
|
|
143
|
+
if (stale(record, task.input.hash)) {
|
|
144
|
+
await runtime.commit(() => ({
|
|
145
|
+
status: "terminal",
|
|
146
|
+
outcome: { status: "completed", result: undefined },
|
|
147
|
+
}), context);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
const fireAt = task.state.checkpoint.fireAt;
|
|
151
|
+
const fireKey = `${task.input.jobId}:${fireAt}`;
|
|
152
|
+
await fireJob(deps, task.input.jobId, fireKey, fireAt, context, runtime);
|
|
153
|
+
// From NOW, never from the missed slot (Review Focus 5).
|
|
154
|
+
const now = deps.now();
|
|
155
|
+
const next = nextFireAt(record.spec.schedule, Math.max(now, fireAt), deps.defaultTz);
|
|
156
|
+
if (next === undefined) {
|
|
157
|
+
await patchJob(deps.harness, task.input.jobId, (draft) => {
|
|
158
|
+
draft.state = "waiting";
|
|
159
|
+
delete draft.nextRun;
|
|
160
|
+
}, context);
|
|
161
|
+
await runtime.commit(() => ({
|
|
162
|
+
status: "terminal",
|
|
163
|
+
outcome: { status: "completed", result: undefined },
|
|
164
|
+
}), context);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
await runtime.commit(() => ({
|
|
168
|
+
status: "running",
|
|
169
|
+
checkpoint: { phase: "waiting", nextAt: next },
|
|
170
|
+
}), context);
|
|
171
|
+
await patchJob(deps.harness, task.input.jobId, (draft) => {
|
|
172
|
+
draft.state = "waiting";
|
|
173
|
+
draft.nextRun = new Date(next).toISOString();
|
|
174
|
+
}, context);
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
abort: async (_task, runtime, context) => {
|
|
178
|
+
await runtime.commit(() => ({
|
|
179
|
+
status: "terminal",
|
|
180
|
+
outcome: { status: "aborted", reason: "job disabled or deleted" },
|
|
181
|
+
}), context);
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
/** The stale-executor rule: absent/disabled/hash-mismatched record. */
|
|
186
|
+
function stale(record, hash) {
|
|
187
|
+
return (record === undefined || record.hash !== hash || record.enabled === false);
|
|
188
|
+
}
|
|
189
|
+
/** Awaiting `runtime.sleep` on the harness's background context. */
|
|
190
|
+
export const SCHEDULE_CONTEXT = BACKGROUND_CONTEXT;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Jobs Task 3 (spec §5.8 + §8.3): the ownership-transfer participant.
|
|
3
|
+
*
|
|
4
|
+
* Registered by `createServer` behind the core conversation participant
|
|
5
|
+
* (`registerCoreOwnershipParticipant` first — conversations transfer
|
|
6
|
+
* before the jobs records do). For a `from → to` transfer it rewrites
|
|
7
|
+
* every `api` job record whose `owner === from` to `owner: to` through
|
|
8
|
+
* `writeJobChange` — which recomputes NOTHING itself (the caller does)
|
|
9
|
+
* but bumps `version` and writes a `changedBy: "transfer"` revision —
|
|
10
|
+
* then calls `jobs.reconcile()`: the owner rides the spec hash, so the
|
|
11
|
+
* changed hash makes reconciliation abort the old executor and create a
|
|
12
|
+
* new one (the plan's "new hash → task recreated").
|
|
13
|
+
*
|
|
14
|
+
* Declared (`file`/`extension`) records are NOT rewritten here — their
|
|
15
|
+
* source owns the owner field; the next boot's reconciliation warns when
|
|
16
|
+
* a declared owner no longer exists in the directory (the job runs
|
|
17
|
+
* ownerless until the source is edited — spec §8.3's posture).
|
|
18
|
+
*/
|
|
19
|
+
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
|
20
|
+
import { registerOwnershipParticipant } from "../server/ownership.js";
|
|
21
|
+
import { scanJobs, writeJobChange } from "./docs.js";
|
|
22
|
+
import { specHash } from "./spec.js";
|
|
23
|
+
/**
|
|
24
|
+
* Register the jobs transfer participant on `ctx` (createServer calls
|
|
25
|
+
* this right after the core conversation participant).
|
|
26
|
+
*/
|
|
27
|
+
export function registerJobsOwnershipParticipant(ctx) {
|
|
28
|
+
const participant = async (from, to) => {
|
|
29
|
+
const { opened } = ctx;
|
|
30
|
+
const jobs = opened.jobs;
|
|
31
|
+
const storage = opened.storage;
|
|
32
|
+
const nowIso = new Date().toISOString();
|
|
33
|
+
const records = await scanJobs(storage, BACKGROUND_CONTEXT);
|
|
34
|
+
for (const { id, record } of records) {
|
|
35
|
+
if (record.source !== "api")
|
|
36
|
+
continue;
|
|
37
|
+
if (record.owner !== from)
|
|
38
|
+
continue;
|
|
39
|
+
const spec = { ...record.spec, owner: to };
|
|
40
|
+
const next = {
|
|
41
|
+
...record,
|
|
42
|
+
spec,
|
|
43
|
+
owner: to,
|
|
44
|
+
hash: specHash(id, spec),
|
|
45
|
+
};
|
|
46
|
+
await opened.harness.commit(async (tx) => {
|
|
47
|
+
await writeJobChange(tx, id, next, "transfer", nowIso);
|
|
48
|
+
}, BACKGROUND_CONTEXT);
|
|
49
|
+
}
|
|
50
|
+
await jobs.reconcile();
|
|
51
|
+
};
|
|
52
|
+
registerOwnershipParticipant(ctx, participant);
|
|
53
|
+
}
|