@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.
Files changed (130) hide show
  1. package/CHANGELOG.md +204 -0
  2. package/CONTEXT.md +163 -0
  3. package/LICENSE +21 -0
  4. package/README.md +691 -2
  5. package/THIRD_PARTY_NOTICES.md +28 -0
  6. package/dist/backup/marker.js +80 -0
  7. package/dist/backup/run-backup.js +339 -0
  8. package/dist/cli.js +952 -0
  9. package/dist/commands/expand.js +245 -0
  10. package/dist/commands/templates.js +272 -0
  11. package/dist/config.js +1146 -0
  12. package/dist/extension.d.ts +431 -0
  13. package/dist/harness/activity.js +42 -0
  14. package/dist/harness/ambient.js +261 -0
  15. package/dist/harness/auth.js +130 -0
  16. package/dist/harness/boot.js +865 -0
  17. package/dist/harness/builtins.js +191 -0
  18. package/dist/harness/event-bus.js +1203 -0
  19. package/dist/harness/health.js +30 -0
  20. package/dist/harness/identity/index.js +37 -0
  21. package/dist/harness/identity/invites.js +94 -0
  22. package/dist/harness/identity/json-store.js +108 -0
  23. package/dist/harness/identity/setup-code.js +81 -0
  24. package/dist/harness/identity/tokens.js +101 -0
  25. package/dist/harness/identity/users.js +93 -0
  26. package/dist/harness/interactions.js +283 -0
  27. package/dist/harness/loader.js +1236 -0
  28. package/dist/harness/memory-default.js +35 -0
  29. package/dist/harness/naming.js +193 -0
  30. package/dist/harness/packages.js +1002 -0
  31. package/dist/harness/providers/auth-bridge.js +60 -0
  32. package/dist/harness/providers/index.js +277 -0
  33. package/dist/harness/providers/models-store.js +105 -0
  34. package/dist/harness/providers/refresh.js +161 -0
  35. package/dist/harness/providers/scheduler.js +89 -0
  36. package/dist/harness/registry.js +9 -0
  37. package/dist/harness/reserved-sections.js +24 -0
  38. package/dist/harness/seam-host.js +239 -0
  39. package/dist/harness/sections/context-files.js +78 -0
  40. package/dist/harness/sections/model-guidance.js +77 -0
  41. package/dist/harness/settings.js +1053 -0
  42. package/dist/harness/stores.js +450 -0
  43. package/dist/harness/tools.js +500 -0
  44. package/dist/harness/workspace.js +73 -0
  45. package/dist/index.js +39 -0
  46. package/dist/jobs/docs.js +257 -0
  47. package/dist/jobs/fire.js +342 -0
  48. package/dist/jobs/index.js +228 -0
  49. package/dist/jobs/observe.js +165 -0
  50. package/dist/jobs/reconcile.js +245 -0
  51. package/dist/jobs/schedule.js +77 -0
  52. package/dist/jobs/spec.js +248 -0
  53. package/dist/jobs/task.js +190 -0
  54. package/dist/jobs/transfer.js +53 -0
  55. package/dist/serve.js +164 -0
  56. package/dist/server/auth/claim-lock.js +28 -0
  57. package/dist/server/auth/ip.js +51 -0
  58. package/dist/server/auth/login-limiter.js +52 -0
  59. package/dist/server/auth/loopback.js +47 -0
  60. package/dist/server/auth/pipeline.js +330 -0
  61. package/dist/server/auth/principal.js +26 -0
  62. package/dist/server/commands.js +198 -0
  63. package/dist/server/content-parts.js +142 -0
  64. package/dist/server/context.js +13 -0
  65. package/dist/server/conversation-handler.js +61 -0
  66. package/dist/server/conversation-ops.js +310 -0
  67. package/dist/server/events/hub.js +39 -0
  68. package/dist/server/events/types.js +8 -0
  69. package/dist/server/helpers.js +351 -0
  70. package/dist/server/host-bridge.js +131 -0
  71. package/dist/server/index.js +232 -0
  72. package/dist/server/notifications.js +185 -0
  73. package/dist/server/ownership.js +67 -0
  74. package/dist/server/readiness.js +36 -0
  75. package/dist/server/routes/auth.js +172 -0
  76. package/dist/server/routes/conversations.js +341 -0
  77. package/dist/server/routes/events.js +161 -0
  78. package/dist/server/routes/extension-routes.js +127 -0
  79. package/dist/server/routes/extensions.js +168 -0
  80. package/dist/server/routes/files.js +308 -0
  81. package/dist/server/routes/interactions.js +260 -0
  82. package/dist/server/routes/jobs.js +271 -0
  83. package/dist/server/routes/meta.js +146 -0
  84. package/dist/server/routes/metrics.js +219 -0
  85. package/dist/server/routes/onboarding.js +268 -0
  86. package/dist/server/routes/probes.js +53 -0
  87. package/dist/server/routes/providers.js +198 -0
  88. package/dist/server/routes/settings.js +48 -0
  89. package/dist/server/routes/stream.js +131 -0
  90. package/dist/server/routes/submissions.js +317 -0
  91. package/dist/server/routes/users.js +169 -0
  92. package/dist/server/schema-fixture.js +950 -0
  93. package/dist/server/session-meta.js +247 -0
  94. package/dist/server/static.js +169 -0
  95. package/dist/server/views.js +71 -0
  96. package/dist/skills/index.js +418 -0
  97. package/node_modules/@sigrun/shared/CHANGELOG.md +16 -0
  98. package/node_modules/@sigrun/shared/LICENSE +21 -0
  99. package/node_modules/@sigrun/shared/README.md +10 -0
  100. package/node_modules/@sigrun/shared/dist/access.d.ts +55 -0
  101. package/node_modules/@sigrun/shared/dist/access.js +72 -0
  102. package/node_modules/@sigrun/shared/dist/ambient.d.ts +111 -0
  103. package/node_modules/@sigrun/shared/dist/ambient.js +23 -0
  104. package/node_modules/@sigrun/shared/dist/commands.d.ts +75 -0
  105. package/node_modules/@sigrun/shared/dist/commands.js +25 -0
  106. package/node_modules/@sigrun/shared/dist/conversations.d.ts +31 -0
  107. package/node_modules/@sigrun/shared/dist/conversations.js +94 -0
  108. package/node_modules/@sigrun/shared/dist/interactions.d.ts +91 -0
  109. package/node_modules/@sigrun/shared/dist/interactions.js +21 -0
  110. package/node_modules/@sigrun/shared/dist/jobs.d.ts +100 -0
  111. package/node_modules/@sigrun/shared/dist/jobs.js +19 -0
  112. package/node_modules/@sigrun/shared/dist/json.d.ts +20 -0
  113. package/node_modules/@sigrun/shared/dist/json.js +13 -0
  114. package/node_modules/@sigrun/shared/dist/session-meta.d.ts +103 -0
  115. package/node_modules/@sigrun/shared/dist/session-meta.js +13 -0
  116. package/node_modules/@sigrun/shared/dist/settings.d.ts +401 -0
  117. package/node_modules/@sigrun/shared/dist/settings.js +19 -0
  118. package/node_modules/@sigrun/shared/dist/themes.d.ts +50 -0
  119. package/node_modules/@sigrun/shared/dist/themes.js +156 -0
  120. package/node_modules/@sigrun/shared/dist/turns.d.ts +102 -0
  121. package/node_modules/@sigrun/shared/dist/turns.js +166 -0
  122. package/node_modules/@sigrun/shared/dist/view-delta.d.ts +54 -0
  123. package/node_modules/@sigrun/shared/dist/view-delta.js +135 -0
  124. package/node_modules/@sigrun/shared/dist/views.d.ts +742 -0
  125. package/node_modules/@sigrun/shared/dist/views.js +38 -0
  126. package/node_modules/@sigrun/shared/package.json +83 -0
  127. package/package.json +90 -5
  128. package/webui/dist/assets/index-Rl0KhCLa.css +1 -0
  129. package/webui/dist/assets/index-rOiARZLF.js +50 -0
  130. 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
+ }