@sigrun/sigrun 0.0.0-stage → 0.1.1

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 +214 -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 +89 -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,228 @@
1
+ /**
2
+ * Jobs Task 3 — the `JobsService`: the service surface over the three
3
+ * job record families (spec §8.3 + §8.4's service half; Task 4 owns the
4
+ * routes).
5
+ *
6
+ * ONE object per opened harness, constructed at boot and exposed as
7
+ * `OpenedHarness.jobs`. Every method is a thin composition over the
8
+ * Task-2 primitives (`writeJobChange`, `scanJobs`, `listRuns`,
9
+ * `listRevisions`, `patchJob`, `pruneHistory`) plus the Task-3
10
+ * reconciliation (`reconcileJobs` — every API write reconciles that one
11
+ * job) and `fireJob` (run-now).
12
+ *
13
+ * - `list`/`get`/`runs`/`revisions` READ records only — the job record
14
+ * is the listing table; task records are never enumerated for
15
+ * listing, and `runs`/`revisions` still answer for a RETIRED job
16
+ * until retention trims them.
17
+ * - `createApi`/`updateApi`/`deleteApi`/`transfer` WRITE through
18
+ * `writeJobChange` (version bump + revision, one commit); declared
19
+ * ids (`file:`/`ext:`) throw {@link JobDeclaredError}.
20
+ * - `runNow` fires `fireJob` with the manual fire key.
21
+ * - `reconcile` delegates to {@link reconcileJobs} (boot's post-resume
22
+ * call and the ownership participant's follow-up).
23
+ *
24
+ * `access` on the wire rows is NOT computed here — the route derives it
25
+ * per principal (§5.9 `accessOf`); the service serves raw rows.
26
+ */
27
+ import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
28
+ import { JobDoc, listRevisions, listRuns, pruneHistory, readJob, scanJobs, writeJobChange, } from "./docs.js";
29
+ import { fireJob } from "./fire.js";
30
+ import { reconcileJobs, } from "./reconcile.js";
31
+ import { specHash } from "./spec.js";
32
+ /** Thrown when an API write targets a `file:`/`ext:` job id (409 `job_declared`). */
33
+ export class JobDeclaredError extends Error {
34
+ constructor(id) {
35
+ super(`job ${id} is declared by its source — edit the source instead`);
36
+ this.name = "JobDeclaredError";
37
+ }
38
+ }
39
+ /** Thrown when an API write targets a missing job. */
40
+ export class JobNotFoundError extends Error {
41
+ constructor(id) {
42
+ super(`job ${id} does not exist`);
43
+ this.name = "JobNotFoundError";
44
+ }
45
+ }
46
+ /** The live `jobHistory` limits (spec §8.1, ruling 14 — read per call). */
47
+ function historyLimits(settings) {
48
+ const jobHistory = settings().jobHistory;
49
+ return {
50
+ runs: jobHistory?.runs ?? 50,
51
+ revisions: jobHistory?.revisions ?? 50,
52
+ };
53
+ }
54
+ /** The wire row of a record (access left for the caller: the ROUTE
55
+ * computes it per principal; the raw row carries the neutral default).
56
+ * Exported for boot's `job.changed` publication (W4 Task 4). */
57
+ export function jobWireOf(id, record) {
58
+ return {
59
+ id,
60
+ name: record.spec.name,
61
+ source: record.source,
62
+ ...(record.owner === undefined ? {} : { owner: record.owner }),
63
+ ...(record.sharing === undefined
64
+ ? {}
65
+ : { sharing: JSON.parse(JSON.stringify(record.sharing)) }),
66
+ schedule: record.spec.schedule,
67
+ action: record.spec.action,
68
+ enabled: record.enabled,
69
+ state: record.state,
70
+ ...(record.reason === undefined ? {} : { reason: record.reason }),
71
+ version: record.version,
72
+ ...(record.nextRun === undefined ? {} : { nextRun: record.nextRun }),
73
+ ...(record.lastRun === undefined
74
+ ? {}
75
+ : { lastRun: record.lastRun }),
76
+ // The route computes access per principal; the service's raw row
77
+ // carries the neutral default.
78
+ access: "write",
79
+ };
80
+ }
81
+ /** Create the service (boot constructs exactly one per opened harness). */
82
+ export function createJobsService(deps) {
83
+ const ctx = BACKGROUND_CONTEXT;
84
+ const limits = () => historyLimits(deps.settings);
85
+ // `job.changed` observation (W4 Task 4): ONE commit subscription over
86
+ // the `sigrun.job` family invokes the injected hook after EVERY record
87
+ // change — the service's own API writes, the executor's status patches
88
+ // (task.ts), the run-settle observers, the ownership transfer, and
89
+ // reconciliation's declared upserts/retires all land through
90
+ // `harness.commit` on this doc family, so no call-site threading can
91
+ // miss a path. Per-call-site hooks would double-publish against this;
92
+ // the subscription is the only publish trigger.
93
+ if (deps.onChange !== undefined) {
94
+ const onChange = deps.onChange;
95
+ deps.harness.subscribeCommits((publication) => {
96
+ const seen = new Set();
97
+ for (const change of publication.changes) {
98
+ if (change.type !== "document")
99
+ continue;
100
+ if (change.record.kind !== JobDoc.definition.kind)
101
+ continue;
102
+ const jobId = change.record.key;
103
+ if (jobId === undefined || seen.has(jobId))
104
+ continue;
105
+ seen.add(jobId);
106
+ onChange(jobId);
107
+ }
108
+ });
109
+ }
110
+ /** Reconcile that one job's executor after a write. */
111
+ const reconcileOne = async () => await reconcileJobs({
112
+ harness: deps.harness,
113
+ storage: deps.storage,
114
+ runtime: deps.runtime,
115
+ rootId: deps.rootId,
116
+ sources: deps.sources,
117
+ }, ctx);
118
+ return {
119
+ async list() {
120
+ const records = await scanJobs(deps.storage, ctx);
121
+ return records.map(({ id, record }) => jobWireOf(id, record));
122
+ },
123
+ async get(id) {
124
+ const record = await readJob(deps.harness, id, ctx);
125
+ return record === undefined ? undefined : jobWireOf(id, record);
126
+ },
127
+ async createApi(spec, owner) {
128
+ const id = `api:${crypto.randomUUID()}`;
129
+ const hash = specHash(id, spec);
130
+ const nowIso = new Date(deps.runtime.now()).toISOString();
131
+ await deps.harness.commit(async (tx) => {
132
+ await writeJobChange(tx, id, {
133
+ source: "api",
134
+ spec: JSON.parse(JSON.stringify(spec)),
135
+ // The record's owner is the SERVER-STAMPED creator (the
136
+ // route passes principal.user; a body-carried spec.owner
137
+ // never reaches here — the route strips it, §5.9). The
138
+ // record's sharing mirrors spec.sharing exactly like a
139
+ // declared record (the PATCH share surface).
140
+ ...(owner === undefined ? {} : { owner }),
141
+ ...(spec.sharing === undefined ? {} : { sharing: spec.sharing }),
142
+ enabled: spec.enabled ?? true,
143
+ createdAt: nowIso,
144
+ hash,
145
+ state: "waiting",
146
+ }, owner ?? "api", nowIso);
147
+ }, ctx);
148
+ await pruneHistory(deps.storage, deps.harness, id, limits(), ctx);
149
+ await reconcileOne();
150
+ const record = await readJob(deps.harness, id, ctx);
151
+ if (record === undefined)
152
+ throw new JobNotFoundError(id);
153
+ return jobWireOf(id, record);
154
+ },
155
+ async updateApi(id, patch, changedBy) {
156
+ const prior = await readJob(deps.harness, id, ctx);
157
+ if (prior === undefined)
158
+ throw new JobNotFoundError(id);
159
+ if (prior.source !== "api")
160
+ throw new JobDeclaredError(id);
161
+ const spec = {
162
+ ...prior.spec,
163
+ ...patch,
164
+ };
165
+ const enabled = patch.enabled ?? prior.enabled;
166
+ const hash = specHash(id, spec);
167
+ const nowIso = new Date(deps.runtime.now()).toISOString();
168
+ await deps.harness.commit(async (tx) => {
169
+ await writeJobChange(tx, id, {
170
+ source: "api",
171
+ spec: JSON.parse(JSON.stringify(spec)),
172
+ // The record's owner is preserved (the transfer path and
173
+ // the create stamp own it; a PATCH body carrying `owner` is
174
+ // rejected at the route, §5.9's server-stamped posture),
175
+ // while sharing mirrors the MERGED spec's sharing — the
176
+ // PATCH share surface (a patch without `sharing` keeps the
177
+ // prior share through the spread merge).
178
+ ...(prior.owner === undefined ? {} : { owner: prior.owner }),
179
+ ...(spec.sharing === undefined ? {} : { sharing: spec.sharing }),
180
+ enabled,
181
+ createdAt: prior.createdAt,
182
+ hash,
183
+ // Status fields the caller omitted are preserved
184
+ // by writeJobChange's draft-merge; state resets to
185
+ // waiting so a re-enable's executor re-derives it.
186
+ state: enabled ? "waiting" : "disabled",
187
+ }, changedBy, nowIso);
188
+ }, ctx);
189
+ await pruneHistory(deps.storage, deps.harness, id, limits(), ctx);
190
+ await reconcileOne();
191
+ const record = await readJob(deps.harness, id, ctx);
192
+ if (record === undefined)
193
+ throw new JobNotFoundError(id);
194
+ return jobWireOf(id, record);
195
+ },
196
+ async runs(id, limit) {
197
+ return await listRuns(deps.storage, id, Math.max(0, limit), ctx);
198
+ },
199
+ async revisions(id) {
200
+ return await listRevisions(deps.storage, id, ctx);
201
+ },
202
+ async deleteApi(id, changedBy) {
203
+ const prior = await readJob(deps.harness, id, ctx);
204
+ if (prior === undefined)
205
+ throw new JobNotFoundError(id);
206
+ if (prior.source !== "api")
207
+ throw new JobDeclaredError(id);
208
+ const nowIso = new Date(deps.runtime.now()).toISOString();
209
+ await deps.harness.commit(async (tx) => {
210
+ // The final `enabled:false` revision, then the retire.
211
+ await writeJobChange(tx, id, { ...prior, enabled: false }, changedBy, nowIso);
212
+ await tx.retireDoc(JobDoc, id);
213
+ }, ctx);
214
+ await reconcileOne();
215
+ },
216
+ async runNow(id) {
217
+ // ONE clock reading: the manual fire key and the run's fireAt must
218
+ // agree — the settle path re-derives the run-record key from
219
+ // fireAt, and two readings would write the settle under a
220
+ // different key than the fire stored the run under.
221
+ const now = deps.runtime.now();
222
+ const fireKey = `${id}:manual:${now}`;
223
+ return await fireJob(deps.runtime, id, fireKey, now, ctx);
224
+ },
225
+ reconcile: reconcileOne,
226
+ };
227
+ }
228
+ export { reconcileJobs } from "./reconcile.js";
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Jobs Task 3 (spec §8.1 "Run status"): the two run-settle observers.
3
+ *
4
+ * - **The `runSettled` handler** (registered on the event bus as
5
+ * `sigrun:jobs`): a settled run whose conversation's `SessionMeta`
6
+ * carries a `jobId` settles the job's RUNNING run record matching that
7
+ * `conversationId` — `succeeded` (answered) or `failed` (unanswered,
8
+ * `error: reason`) + `settledAt` — refreshes the job record's
9
+ * `lastRun` copy, then prunes history. It sees ONLY prompt runs (the
10
+ * bus derives settled runs from `submission` writes).
11
+ * - **The `taskSettled` listener** (the host-event reduction the bus
12
+ * emits from terminal `task` table writes): settles `task`-action run
13
+ * records whose `taskId` matches — `completed` → `succeeded`, any
14
+ * other terminal → `failed` + the error text — then the same
15
+ * lastRun/prune refresh.
16
+ *
17
+ * Both are idempotent by construction: they settle only a run whose
18
+ * outcome is still `running`, so a re-delivery (the bus's at-least-once
19
+ * posture) is a no-op. Failures are caught and logged — an observer must
20
+ * never break the run or the drain loop.
21
+ */
22
+ import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
23
+ import { SessionMetaDoc } from "../server/session-meta.js";
24
+ import { JobDoc, JobRunDoc, listRuns, patchJob, pruneHistory, scanJobs } from "./docs.js";
25
+ /** The `sigrun.job.run` key a run record was written under. */
26
+ function runKeyOf(run) {
27
+ if (run.manual) {
28
+ return `${run.jobId}:manual:${Date.parse(run.startedAt)}`;
29
+ }
30
+ return `${run.jobId}:${Date.parse(run.fireAt)}`;
31
+ }
32
+ /** The retention limits read live per prune (§8.1 ruling 14). */
33
+ function limitsOf(settings) {
34
+ const jobHistory = settings().jobHistory;
35
+ return {
36
+ runs: jobHistory?.runs ?? 50,
37
+ revisions: jobHistory?.revisions ?? 50,
38
+ };
39
+ }
40
+ /** Settle one run record + the job's lastRun copy + prune. */
41
+ async function settleRun(deps, run, settled, ctx) {
42
+ await deps.harness.commit(async (tx) => {
43
+ // The seed only applies when the doc is absent (it never is — the
44
+ // fire created it); mutate the LIVE draft instead. Carried field by
45
+ // field so a concurrent writer's fields cannot be lost wholesale.
46
+ // SAFETY: the draft is the JsonObject-backed JobRunWire draft —
47
+ // pi-durable's Draft<T> tracks mutations on the same object, so the
48
+ // Object.assign lands in the tracked doc, not a copy.
49
+ const draft = await tx.doc(JobRunDoc, runKeyOf(run), settled);
50
+ Object.assign(draft, settled);
51
+ }, ctx);
52
+ await patchJob(deps.harness, run.jobId, (draft) => {
53
+ draft.lastRun = settled;
54
+ }, ctx);
55
+ await pruneHistory(deps.storage, deps.harness, run.jobId, limitsOf(deps.settings), ctx);
56
+ }
57
+ /**
58
+ * Register both observers (boot calls this after the bus opens, before
59
+ * `resume()` — recovery settles are observed). Returns the unsubscribe.
60
+ */
61
+ export function registerJobObservers(deps) {
62
+ const ctx = BACKGROUND_CONTEXT;
63
+ const nowIso = () => new Date(deps.runtime.now()).toISOString();
64
+ // The runSettled handler (prompt runs): registered as `sigrun:jobs`,
65
+ // install order = run order (after every loaded extension, like the
66
+ // auto-namer — a job observation never delays an extension's).
67
+ const runSettled = async (event) => {
68
+ try {
69
+ const meta = await deps.harness.snapshot(SessionMetaDoc, event.conversationId, ctx);
70
+ const jobId = meta?.jobId;
71
+ if (typeof jobId !== "string")
72
+ return;
73
+ const runs = await listRuns(deps.storage, jobId, Number.MAX_SAFE_INTEGER, ctx);
74
+ const run = runs.find((one) => one.outcome === "running" &&
75
+ // SAFETY: ConversationId is the branded number the run record's
76
+ // plain `conversationId` field carries; compared across both forms.
77
+ one.conversationId === event.conversationId);
78
+ if (run === undefined)
79
+ return;
80
+ const answered = event.outcome.status === "answered";
81
+ const settled = {
82
+ ...run,
83
+ outcome: answered ? "succeeded" : "failed",
84
+ settledAt: nowIso(),
85
+ ...(answered || event.outcome.status !== "unanswered"
86
+ ? {}
87
+ : { error: event.outcome.reason }),
88
+ };
89
+ await settleRun(deps, run, settled, ctx);
90
+ }
91
+ catch (error) {
92
+ console.error("jobs: runSettled observer failed:", error);
93
+ }
94
+ };
95
+ const unregister = deps.eventBus.registerExtension("sigrun:jobs", {
96
+ runSettled,
97
+ });
98
+ // The taskSettled listener (task runs): the host-event reduction the
99
+ // bus emits synchronously from terminal task-table writes. The settle
100
+ // work runs on a microtask side lane (the listener contract is
101
+ // no-blocking-work); each settle is idempotent, so ordering with the
102
+ // drain loop's deliveries does not matter.
103
+ //
104
+ // LOAD GUARD: tool-call tasks (every bash/read/write a conversation
105
+ // runs) also produce terminal task writes — a jobs-harnessing scan per
106
+ // terminal task would tax every busy conversation. The listener first
107
+ // checks `jobsMayExist`, a conservative flag: false only until the
108
+ // first `sigrun.job` document write is observed (the commit
109
+ // subscription below — job records appear ONLY through writes), and
110
+ // never reset afterwards (retirement is not a return to no-jobs for
111
+ // this process). A no-jobs harness pays ONE boolean check per terminal
112
+ // task, never a scan.
113
+ let jobsMayExist = false;
114
+ const offJobWrites = deps.harness.subscribeCommits((publication) => {
115
+ for (const change of publication.changes) {
116
+ if (change.type !== "document")
117
+ continue;
118
+ if (change.record.kind === JobDoc.definition.kind) {
119
+ jobsMayExist = true;
120
+ return;
121
+ }
122
+ }
123
+ });
124
+ const offTaskSettled = deps.eventBus.onHostEvent((event) => {
125
+ if (event.type !== "taskSettled")
126
+ return;
127
+ if (!jobsMayExist)
128
+ return;
129
+ const { taskId, status, error } = event;
130
+ void (async () => {
131
+ try {
132
+ // The run records' jobId is unknown here (the event carries
133
+ // only the task id) — scan every live job's running runs and
134
+ // match by taskId. Household scale: one scan over the runs of
135
+ // jobs whose action is `task` would need the record read per
136
+ // job; the scan below walks ALL live job records once, then
137
+ // each one's runs (the same shape reconcile's pass-2 uses).
138
+ const jobs = await scanJobs(deps.storage, ctx);
139
+ for (const { id } of jobs) {
140
+ const runs = await listRuns(deps.storage, id, Number.MAX_SAFE_INTEGER, ctx);
141
+ const run = runs.find((one) => one.outcome === "running" && one.taskId === taskId);
142
+ if (run === undefined)
143
+ continue;
144
+ const succeeded = status === "completed";
145
+ const settled = {
146
+ ...run,
147
+ outcome: succeeded ? "succeeded" : "failed",
148
+ settledAt: nowIso(),
149
+ ...(succeeded || error === undefined ? {} : { error }),
150
+ };
151
+ await settleRun(deps, run, settled, ctx);
152
+ return;
153
+ }
154
+ }
155
+ catch (error) {
156
+ console.error("jobs: taskSettled observer failed:", error);
157
+ }
158
+ })();
159
+ });
160
+ return () => {
161
+ unregister();
162
+ offTaskSettled();
163
+ offJobWrites();
164
+ };
165
+ }
@@ -0,0 +1,245 @@
1
+ /**
2
+ * Jobs Task 3 (spec §8.3 "Records → tasks"): reconciliation — the ONE
3
+ * function that maps job RECORDS onto schedule-task EXECUTORS.
4
+ *
5
+ * Two passes, in order:
6
+ *
7
+ * 1. **Declared → records.** Every file (settings.json `jobs[]`) and
8
+ * extension (factory-result `jobs[]`) declaration upserts its record
9
+ * through `writeJobChange` — `changedBy` = `"file"` /
10
+ * `"extension:<slug>"`, so a change bumps `version` and writes a
11
+ * revision; an UNCHANGED declaration (same source, spec, owner,
12
+ * sharing, enabled, hash) writes nothing. Records whose declaration
13
+ * vanished are RETIRED (a final `enabled:false` revision rides the
14
+ * same commit). `api` records are never touched here — the API owns
15
+ * them.
16
+ *
17
+ * 2. **Records → tasks.** Live tasks come from
18
+ * `storage.scanTasks({kind: "sigrun.schedule"})`, paged, filtered
19
+ * in-memory to non-terminal — used ONLY to match executors, never
20
+ * for listing (the job record is the listing table). For every
21
+ * record:
22
+ * - enabled with no live task of the matching `hash` → abort any
23
+ * live task for that `jobId`, then create one (a single
24
+ * `harness.commit` carrying `tx.createTask`, conversation-owned on
25
+ * the ROOT conversation, `background: true`);
26
+ * - a live task whose record is retired, disabled, or hash-mismatched
27
+ * → `harness.abortTask` (the task's own stale-executor rule then
28
+ * settles it honestly);
29
+ * - disabled records get `state: "disabled"`.
30
+ *
31
+ * Boot calls `reconcile` immediately before `resume()` (the scheduler
32
+ * starts executors at resume, the next line — awaiting anything after
33
+ * resume would race post-boot registry installs against recovered agent
34
+ * resolution); every API write calls it for
35
+ * one job. It is IDEMPOTENT — a second run with unchanged state creates
36
+ * nothing, aborts nothing, writes nothing.
37
+ */
38
+ import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
39
+ import { JobDoc, patchJob, scanJobs, writeJobChange, } from "./docs.js";
40
+ import { nextFireAt } from "./schedule.js";
41
+ import { specHash } from "./spec.js";
42
+ import { createScheduleTask } from "./task.js";
43
+ const SCAN_PAGE = 500;
44
+ /** The file id of a declared name. */
45
+ export function fileJobId(name) {
46
+ return `file:${name}`;
47
+ }
48
+ /** The extension id of a declared name. */
49
+ export function extensionJobId(slug, name) {
50
+ return `ext:${slug}:${name}`;
51
+ }
52
+ /** Every non-terminal schedule task, keyed by jobId. */
53
+ export async function liveScheduleTasksByJob(storage, ctx) {
54
+ const byJob = new Map();
55
+ let cursor;
56
+ for (;;) {
57
+ const page = await storage.scanTasks({ kind: "sigrun.schedule" }, SCAN_PAGE, cursor, ctx);
58
+ for (const record of page.items) {
59
+ if (record.state.status === "terminal" ||
60
+ record.state.status === "completing") {
61
+ continue;
62
+ }
63
+ // An abort-marked task is not a live executor: `abortTask`
64
+ // returns "marked" and completes asynchronously, so a pending
65
+ // abort must not count as the job's executor (reconcile would
66
+ // skip creating the replacement, or re-abort a settling task).
67
+ if (record.abortRequested === true)
68
+ continue;
69
+ const jobId = record.input.jobId;
70
+ if (typeof jobId !== "string")
71
+ continue;
72
+ const list = byJob.get(jobId) ?? [];
73
+ list.push(record);
74
+ byJob.set(jobId, list);
75
+ }
76
+ if (page.next === undefined)
77
+ return byJob;
78
+ cursor = page.next;
79
+ }
80
+ }
81
+ /** The record a declaration reconciles to (omitted status fields preserved). */
82
+ function declaredRecord(id, spec, hash, nowIso) {
83
+ return {
84
+ source: id.startsWith("ext:") ? "extension" : "file",
85
+ spec,
86
+ ...(spec.owner === undefined ? {} : { owner: spec.owner }),
87
+ ...(spec.sharing === undefined ? {} : { sharing: spec.sharing }),
88
+ enabled: spec.enabled ?? true,
89
+ createdAt: nowIso,
90
+ hash,
91
+ state: "waiting",
92
+ };
93
+ }
94
+ /** Whether a record already mirrors a declaration exactly (no write). */
95
+ function mirrorsDeclaration(record, id, spec, hash) {
96
+ return (record.hash === hash &&
97
+ record.source === (id.startsWith("ext:") ? "extension" : "file") &&
98
+ record.spec.name === spec.name &&
99
+ JSON.stringify(record.spec) === JSON.stringify(spec) &&
100
+ record.enabled === (spec.enabled ?? true) &&
101
+ record.owner === spec.owner &&
102
+ (record.sharing === undefined) === (spec.sharing === undefined) &&
103
+ (record.sharing === undefined ||
104
+ JSON.stringify(record.sharing) === JSON.stringify(spec.sharing)));
105
+ }
106
+ /**
107
+ * Reconcile every source into records, then every record into tasks.
108
+ * See the module doc for the pass order and semantics.
109
+ */
110
+ export async function reconcileJobs(deps, ctx = BACKGROUND_CONTEXT) {
111
+ const nowIso = new Date(deps.runtime.now()).toISOString();
112
+ // ── Pass 1: declared → records ───────────────────────────────────
113
+ const declared = [
114
+ ...deps.sources().file.map((spec) => ({
115
+ id: fileJobId(spec.name),
116
+ spec,
117
+ declaredBy: "file",
118
+ })),
119
+ ...deps.sources().extensions.flatMap(({ extension, jobs }) => jobs.map((spec) => ({
120
+ id: extensionJobId(extension, spec.name),
121
+ spec,
122
+ declaredBy: `extension:${extension}`,
123
+ }))),
124
+ ];
125
+ const declaredIds = new Set(declared.map((job) => job.id));
126
+ const records = new Map();
127
+ for (const { id, record } of await scanRecords(deps.storage, ctx)) {
128
+ records.set(id, record);
129
+ }
130
+ for (const job of declared) {
131
+ const hash = specHash(job.id, job.spec);
132
+ const prior = records.get(job.id);
133
+ if (prior === undefined) {
134
+ await deps.harness.commit(async (tx) => {
135
+ await writeJobChange(tx, job.id, declaredRecord(job.id, job.spec, hash, nowIso), job.declaredBy, nowIso);
136
+ }, ctx);
137
+ continue;
138
+ }
139
+ if (mirrorsDeclaration(prior, job.id, job.spec, hash))
140
+ continue;
141
+ // A changed declaration (or a re-appeared retired record):
142
+ // writeJobChange carries `next` over the draft, preserving the
143
+ // status fields (state/reason/nextRun/lastRun) the declaration
144
+ // does not own — "lastRun is preserved".
145
+ await deps.harness.commit(async (tx) => {
146
+ await writeJobChange(tx, job.id, declaredRecord(job.id, job.spec, hash, nowIso), job.declaredBy, nowIso);
147
+ }, ctx);
148
+ }
149
+ // Retire declared-source records whose declaration is gone.
150
+ for (const [id, record] of records) {
151
+ if (record.source === "api")
152
+ continue;
153
+ if (declaredIds.has(id))
154
+ continue;
155
+ await retireRecord(deps.harness, id, record, nowIso, ctx);
156
+ }
157
+ // ── Pass 2: records → tasks ─────────────────────────────────────
158
+ const after = new Map();
159
+ for (const { id, record } of await scanRecords(deps.storage, ctx)) {
160
+ after.set(id, record);
161
+ }
162
+ const liveByJob = await liveScheduleTasksByJob(deps.storage, ctx);
163
+ // Retired ids first: a record retired this pass (or on an earlier
164
+ // boot/crash) no longer appears in `after`, so its live executor — if
165
+ // any survived — must be aborted here, keyed on the live-task scan.
166
+ for (const [id, tasks] of liveByJob) {
167
+ if (after.has(id))
168
+ continue;
169
+ for (const task of tasks) {
170
+ await deps.harness.abortTask(task.id, ctx);
171
+ }
172
+ }
173
+ for (const [id, record] of after) {
174
+ const live = liveByJob.get(id) ?? [];
175
+ const matching = live.filter((task) => task.input.hash === record.hash);
176
+ if (record.enabled) {
177
+ if (matching.length === 0) {
178
+ for (const task of live) {
179
+ await deps.harness.abortTask(task.id, ctx);
180
+ }
181
+ await createExecutor(deps, id, record, ctx);
182
+ }
183
+ // A matching live task exists: leave it (the same task id
184
+ // survives an unchanged reboot). Extra stale live tasks for
185
+ // the same id are still pruned.
186
+ for (const task of live) {
187
+ if (task.input.hash !== record.hash) {
188
+ await deps.harness.abortTask(task.id, ctx);
189
+ }
190
+ }
191
+ }
192
+ else {
193
+ for (const task of live) {
194
+ await deps.harness.abortTask(task.id, ctx);
195
+ }
196
+ if (record.state !== "disabled") {
197
+ await patchRecord(deps.harness, id, (draft) => {
198
+ draft.state = "disabled";
199
+ }, ctx);
200
+ }
201
+ }
202
+ }
203
+ }
204
+ /** All live job records (the records-only scan). */
205
+ async function scanRecords(storage, ctx) {
206
+ // scanJobs already pages + reads current values.
207
+ return scanJobs(storage, ctx);
208
+ }
209
+ /** Retire one declared-source record with a final `enabled:false` revision. */
210
+ async function retireRecord(harness, id, record, nowIso, ctx) {
211
+ if (!record.enabled) {
212
+ // Already disabled (a prior delete-style revision exists): just
213
+ // retire the record.
214
+ await harness.commit(async (tx) => {
215
+ await tx.retireDoc(JobDoc, id);
216
+ }, ctx);
217
+ return;
218
+ }
219
+ await harness.commit(async (tx) => {
220
+ await writeJobChange(tx, id, {
221
+ ...record,
222
+ enabled: false,
223
+ }, "retire", nowIso);
224
+ await tx.retireDoc(JobDoc, id);
225
+ }, ctx);
226
+ }
227
+ /** One small status edit (state/reason) — no version bump. */
228
+ async function patchRecord(harness, id, change, ctx) {
229
+ await patchJob(harness, id, change, ctx);
230
+ }
231
+ /** Create the schedule task for one enabled record (ONE commit). */
232
+ async function createExecutor(deps, id, record, ctx) {
233
+ const firstAt = nextFireAt(record.spec.schedule, deps.runtime.now(), deps.runtime.defaultTz);
234
+ await deps.harness.commit(async (tx) => {
235
+ await tx.createTask(createScheduleTask(deps.runtime), {
236
+ jobId: id,
237
+ hash: record.hash,
238
+ ...(firstAt === undefined ? {} : { firstAt }),
239
+ }, {
240
+ ownership: { kind: "conversation" },
241
+ conversationId: deps.rootId,
242
+ background: true,
243
+ });
244
+ }, ctx);
245
+ }