@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,257 @@
1
+ /**
2
+ * Jobs Task 2 (spec §8.1): the three job record families.
3
+ *
4
+ * Every job — from any source — is one `sigrun.job` session-scoped
5
+ * document-family record keyed by jobId, holding the current definition +
6
+ * status. History lives in two more families: `sigrun.job.revision`
7
+ * (one immutable snapshot per version, key `<jobId>:<version>`) and
8
+ * `sigrun.job.run` (one record per fire, key `<jobId>:<fireKey>` — the
9
+ * fire's idempotency record). All three are document families precisely
10
+ * because they commit ATOMICALLY with task creation and run-conversation
11
+ * creation (spec ruling 13; a separate database could not).
12
+ *
13
+ * Writes:
14
+ * - `writeJobChange` is the ONE job-record change path: it bumps
15
+ * `version`, stamps `updatedAt`, and writes the matching revision in
16
+ * the SAME commit (callers pass the ambient tx).
17
+ * - `patchJob` is the small status-edit path (state/reason/nextRun/
18
+ * lastRun) — no version bump, no revision.
19
+ * - Retention (`pruneHistory`, ruling 14): keep the newest `limits.runs`
20
+ * runs and the newest `limits.revisions` revisions plus any revision a
21
+ * retained run references; the rest RETIRE (pi-durable keeps retired
22
+ * incarnations — listing is bounded, the database is not; §12 owns
23
+ * compaction).
24
+ *
25
+ * Enumeration is `storage.scanDocuments` over the session scope — the
26
+ * Harness exposes no family listing — filtered by key prefix for one
27
+ * job's runs/revisions. Task records are never enumerated for listing.
28
+ */
29
+ import { defineDocFamily, } from "@earendil-works/pi-durable";
30
+ // ---------------------------------------------------------------------------
31
+ // The three families
32
+ // ---------------------------------------------------------------------------
33
+ /** `sigrun.job`, key = jobId. The definition + status row. */
34
+ export const JobDoc = defineDocFamily({
35
+ kind: "sigrun.job",
36
+ version: 1,
37
+ scope: "session",
38
+ family: true,
39
+ initial: (seed) => seed,
40
+ });
41
+ /** `sigrun.job.revision`, key = `<jobId>:<version>` — immutable snapshots. */
42
+ export const JobRevisionDoc = defineDocFamily({
43
+ kind: "sigrun.job.revision",
44
+ version: 1,
45
+ scope: "session",
46
+ family: true,
47
+ initial: (seed) => seed,
48
+ });
49
+ /** `sigrun.job.run`, key = `<jobId>:<fireKey>` — the idempotency record. */
50
+ export const JobRunDoc = defineDocFamily({
51
+ kind: "sigrun.job.run",
52
+ version: 1,
53
+ scope: "session",
54
+ family: true,
55
+ initial: (seed) => seed,
56
+ });
57
+ // ---------------------------------------------------------------------------
58
+ // Reads (storage scans — the harness exposes no family listing)
59
+ // ---------------------------------------------------------------------------
60
+ const SCAN_PAGE = 500;
61
+ /** All live job records, keyed by id (enumeration = records, never tasks). */
62
+ export async function scanJobs(storage, ctx) {
63
+ const out = [];
64
+ let cursor;
65
+ for (;;) {
66
+ const page = await storage.scanDocuments({
67
+ scope: { kind: "session" },
68
+ at: "current",
69
+ kind: JobDoc.definition.kind,
70
+ }, SCAN_PAGE, cursor, ctx);
71
+ for (const item of page.items) {
72
+ if (item.key === undefined)
73
+ continue;
74
+ const stored = await storage.document(item.id, "current", ctx);
75
+ if (stored === undefined)
76
+ continue;
77
+ out.push({ id: item.key, record: stored.value });
78
+ }
79
+ if (page.next === undefined)
80
+ return out;
81
+ cursor = page.next;
82
+ }
83
+ }
84
+ /** One job record by id (undefined when absent or retired). */
85
+ export async function readJob(harness, id, ctx) {
86
+ return await harness.snapshot(JobDoc, id, ctx);
87
+ }
88
+ /**
89
+ * One job's run records, newest first (by `startedAt`), capped at `limit`.
90
+ * The scan walks every live `sigrun.job.run` record and filters by the
91
+ * `<jobId>:` key prefix — O(total retained runs), fine at household scale
92
+ * (spec §8.1: indexed per-job queries are §12).
93
+ */
94
+ export async function listRuns(storage, jobId, limit, ctx) {
95
+ const prefix = `${jobId}:`;
96
+ const runs = [];
97
+ let cursor;
98
+ for (;;) {
99
+ const page = await storage.scanDocuments({
100
+ scope: { kind: "session" },
101
+ at: "current",
102
+ kind: JobRunDoc.definition.kind,
103
+ }, SCAN_PAGE, cursor, ctx);
104
+ for (const item of page.items) {
105
+ if (item.key === undefined || !item.key.startsWith(prefix))
106
+ continue;
107
+ const stored = await storage.document(item.id, "current", ctx);
108
+ if (stored === undefined)
109
+ continue;
110
+ runs.push(stored.value);
111
+ }
112
+ if (page.next === undefined)
113
+ break;
114
+ cursor = page.next;
115
+ }
116
+ runs.sort((a, b) => Date.parse(b.startedAt) - Date.parse(a.startedAt));
117
+ return runs.slice(0, Math.max(0, limit));
118
+ }
119
+ /** One job's revisions, newest first (by version). */
120
+ export async function listRevisions(storage, jobId, ctx) {
121
+ const prefix = `${jobId}:`;
122
+ const revisions = [];
123
+ let cursor;
124
+ for (;;) {
125
+ const page = await storage.scanDocuments({
126
+ scope: { kind: "session" },
127
+ at: "current",
128
+ kind: JobRevisionDoc.definition.kind,
129
+ }, SCAN_PAGE, cursor, ctx);
130
+ for (const item of page.items) {
131
+ if (item.key === undefined || !item.key.startsWith(prefix))
132
+ continue;
133
+ const stored = await storage.document(item.id, "current", ctx);
134
+ if (stored === undefined)
135
+ continue;
136
+ revisions.push(stored.value);
137
+ }
138
+ if (page.next === undefined)
139
+ break;
140
+ cursor = page.next;
141
+ }
142
+ revisions.sort((a, b) => b.version - a.version);
143
+ return revisions;
144
+ }
145
+ // ---------------------------------------------------------------------------
146
+ // Writes
147
+ // ---------------------------------------------------------------------------
148
+ /**
149
+ * The ONE job-record change path: bump `version`, stamp `updatedAt`, and
150
+ * write the matching immutable revision — all in the caller's commit.
151
+ * `next` omits `version`/`updatedAt` (this function derives them); every
152
+ * definition-relevant field (`spec`/`owner`/`sharing`/`enabled`) moves
153
+ * only through here, so history is complete by construction.
154
+ */
155
+ export async function writeJobChange(tx, id, next, changedBy, nowIso) {
156
+ // The seed only applies when the record is absent (first creation);
157
+ // for a change the draft starts at the CURRENT value. Carry the
158
+ // caller's `next` over it field by field (status fields the caller
159
+ // omitted — state/reason/nextRun/lastRun — are preserved, per the
160
+ // reconciliation rule: "lastRun is preserved").
161
+ const seed = {
162
+ ...next,
163
+ version: 0,
164
+ updatedAt: nowIso,
165
+ };
166
+ const draft = await tx.doc(JobDoc, id, seed);
167
+ const priorVersion = draft.version;
168
+ const version = priorVersion + 1;
169
+ // Apply `next` onto the draft (object-optional keys ride through).
170
+ const target = draft;
171
+ for (const [key, value] of Object.entries(next)) {
172
+ if (value === undefined)
173
+ delete target[key];
174
+ else
175
+ target[key] = value;
176
+ }
177
+ target.version = version;
178
+ target.updatedAt = nowIso;
179
+ const applied = target;
180
+ const revision = {
181
+ jobId: id,
182
+ version,
183
+ source: applied.source,
184
+ spec: JSON.parse(JSON.stringify(applied.spec)),
185
+ ...(applied.owner === undefined ? {} : { owner: applied.owner }),
186
+ ...(applied.sharing === undefined ? {} : { sharing: applied.sharing }),
187
+ enabled: applied.enabled,
188
+ changedAt: nowIso,
189
+ changedBy,
190
+ };
191
+ await tx.doc(JobRevisionDoc, `${id}:${version}`, revision);
192
+ }
193
+ /**
194
+ * A small status edit on the job record (state/reason/nextRun/lastRun) —
195
+ * NO version bump, NO revision. The change runs in one `harness.commit`,
196
+ * so concurrent status writes cannot lose each other's fields.
197
+ */
198
+ export async function patchJob(harness, id, change, ctx) {
199
+ // Existence + the seed are read OUTSIDE the commit (Tx has no
200
+ // `snapshot`, and Session reads queue on the line the commit holds).
201
+ // The seed is ignored when the record exists — tx.doc returns the live
202
+ // draft — so the pre-read never shadows a concurrent change.
203
+ const prior = await harness.snapshot(JobDoc, id, ctx);
204
+ if (prior === undefined)
205
+ return;
206
+ await harness.commit(async (tx) => {
207
+ const draft = await tx.doc(JobDoc, id, { ...prior });
208
+ change(draft);
209
+ }, ctx);
210
+ }
211
+ /**
212
+ * Retention (ruling 14), applied when a run settles and when a revision
213
+ * is written: retire the oldest runs beyond `limits.runs` and the oldest
214
+ * revisions beyond `limits.revisions`, EXCEPT a revision any retained run
215
+ * references (a run's outcome must always map to its exact definition).
216
+ *
217
+ * The storage handle is a PARAMETER (the Task-3 carry-forward): callers
218
+ * thread `OpenedHarness.storage` — the private-field reach this function
219
+ * used to make is deleted.
220
+ */
221
+ export async function pruneHistory(storage, harness, jobId, limits, ctx) {
222
+ const runs = await listRuns(storage, jobId, Number.MAX_SAFE_INTEGER, ctx);
223
+ const revisions = await listRevisions(storage, jobId, ctx);
224
+ const keptRuns = runs.slice(0, Math.max(0, limits.runs));
225
+ const dropRuns = runs.slice(Math.max(0, limits.runs));
226
+ const referencedVersions = new Set(keptRuns.map((run) => run.version));
227
+ const keptRevisions = [];
228
+ const dropRevisions = [];
229
+ for (const revision of revisions) {
230
+ if (keptRevisions.length < Math.max(0, limits.revisions) ||
231
+ referencedVersions.has(revision.version)) {
232
+ keptRevisions.push(revision);
233
+ }
234
+ else {
235
+ dropRevisions.push(revision);
236
+ }
237
+ }
238
+ await harness.commit(async (tx) => {
239
+ for (const run of dropRuns) {
240
+ await tx.retireDoc(JobRunDoc, runKeyOf(run));
241
+ }
242
+ for (const revision of dropRevisions) {
243
+ await tx.retireDoc(JobRevisionDoc, `${jobId}:${revision.version}`);
244
+ }
245
+ }, ctx);
246
+ }
247
+ /**
248
+ * The fire key a stored run was written under, re-derived for retiring:
249
+ * scheduled runs key on `<jobId>:<fireAt epoch ms>`; manual runs on
250
+ * `<jobId>:manual:<startedAt epoch ms>` (the manual fire stamps it).
251
+ */
252
+ function runKeyOf(run) {
253
+ if (run.manual) {
254
+ return `${run.jobId}:manual:${Date.parse(run.startedAt)}`;
255
+ }
256
+ return `${run.jobId}:${Date.parse(run.fireAt)}`;
257
+ }
@@ -0,0 +1,342 @@
1
+ /**
2
+ * Jobs Task 2 (spec §8.1 "The executor" + crash safety): `fireJob`.
3
+ *
4
+ * ONE fire path for the schedule task, run-now, and (Task 3+) external
5
+ * triggers. The run record IS the fire's idempotency record: keyed
6
+ * `<jobId>:<fireAt>` (scheduled) or `<jobId>:<manual ms>` (run-now), it
7
+ * is written ATOMICALLY with the run's creation:
8
+ *
9
+ * - `conversation: "new"` prompt: inside the SAME commit that creates
10
+ * the run conversation (`harness.createConversation({…, init})` — the
11
+ * `init` callback runs in the creating commit). The conversation and
12
+ * its run record are atomic; a crash either lands both or neither.
13
+ * - fixed-conversation prompt: the run record commits FIRST (its own
14
+ * `harness.commit`), then the task submits with
15
+ * `requestId = "job:" + fireKey`. On re-entry a present run record
16
+ * means "submitted or about to be", so the task submits again and the
17
+ * conversation-scoped requestId dedups.
18
+ * - `task` action: the run record and `tx.createTask(…, {ownership:
19
+ * {kind:"task", taskId: <schedule task>}})` share ONE
20
+ * `runtime.commit`. The schedule task's `runtime` is threaded in for
21
+ * this branch only.
22
+ *
23
+ * Overlap: with the default `overlap: "skip"` and the job's `lastRun`
24
+ * still `running`, the fire writes an immediately-settled `skipped` run
25
+ * (plus the `lastRun` refresh) and returns — the skipped fire is visible
26
+ * history, never a silent drop. `overlap: "queue"` admits anyway (the
27
+ * conversation's inbox queues the input).
28
+ *
29
+ * Blocked: absent/disabled record, or a `task` action naming an
30
+ * unregistered task (reason pinned by the plan) — `state: "blocked"` is
31
+ * patched onto the record and no run is written.
32
+ *
33
+ * The run's TERMINAL outcome is settled elsewhere (the event-bus
34
+ * `runSettled` observer for prompts, Task 3's task-settle observation for
35
+ * task runs); `fireJob` returns once the fire is admitted.
36
+ */
37
+ import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
38
+ import { parseModelSpec } from "../config.js";
39
+ import { expandInput } from "../commands/expand.js";
40
+ import { SessionMetaDoc } from "../server/session-meta.js";
41
+ import { JobRunDoc, patchJob, readJob } from "./docs.js";
42
+ /**
43
+ * Fire one job under `fireKey`. `fireAt` is the epoch ms the key pins
44
+ * (the scheduled slot, or `deps.now()` for run-now). `runtime` is
45
+ * present only when the schedule task drives the fire (the `task`
46
+ * branch needs its commit line).
47
+ */
48
+ export async function fireJob(deps, id, fireKey, fireAt, context, runtime) {
49
+ const record = await readJob(deps.harness, id, context);
50
+ if (record === undefined || !record.enabled) {
51
+ return { outcome: "blocked" };
52
+ }
53
+ const action = record.spec.action;
54
+ const manual = fireKey.slice(id.length + 1).startsWith("manual:");
55
+ const startedAt = new Date(deps.now()).toISOString();
56
+ const baseRun = {
57
+ jobId: id,
58
+ version: record.version,
59
+ fireAt: new Date(fireAt).toISOString(),
60
+ manual,
61
+ outcome: "running",
62
+ startedAt,
63
+ };
64
+ // Idempotency: a present run record means this fire already ran (or
65
+ // is running) — reuse its conversation/task and never double-create.
66
+ const prior = await deps.harness.snapshot(JobRunDoc, fireKey, context);
67
+ if (prior !== undefined) {
68
+ if (prior.outcome === "skipped")
69
+ return { outcome: "skipped" };
70
+ if (prior.outcome !== "running") {
71
+ // Already settled (succeeded/failed): the Task-3 re-entry fix —
72
+ // a re-entered fire whose run record already settled reports
73
+ // the SETTLED outcome, never "running" (a crash-recovered fire
74
+ // re-entering after the observer settled must not re-admit).
75
+ return {
76
+ outcome: "blocked",
77
+ ...(prior.conversationId === undefined
78
+ ? {}
79
+ : { conversationId: prior.conversationId }),
80
+ };
81
+ }
82
+ // Still running: re-submit (the requestId dedups) or nothing (task
83
+ // runs re-admit by re-creating with the same record — see below).
84
+ }
85
+ // Overlap: the previous run is still live.
86
+ const last = record.lastRun;
87
+ if (prior === undefined &&
88
+ (record.spec.overlap ?? "skip") === "skip" &&
89
+ last !== undefined &&
90
+ last.outcome === "running") {
91
+ const skipped = {
92
+ ...baseRun,
93
+ outcome: "skipped",
94
+ settledAt: startedAt,
95
+ };
96
+ await deps.harness.commit(async (tx) => {
97
+ await tx.doc(JobRunDoc, fireKey, skipped);
98
+ }, context);
99
+ await patchJob(deps.harness, id, (draft) => {
100
+ draft.lastRun = skipped;
101
+ }, context);
102
+ return { outcome: "skipped" };
103
+ }
104
+ if (action.kind === "prompt") {
105
+ return await firePrompt(deps, id, record, fireKey, baseRun, prior, context);
106
+ }
107
+ return await fireTaskAction(deps, id, record, fireKey, baseRun, prior, context, runtime);
108
+ }
109
+ /**
110
+ * Expand a job prompt through the wired roots (Task 2c): a prompt
111
+ * starting with `/` that names a skill or template becomes the expanded
112
+ * text the model sees; anything else is returned verbatim. Expansion
113
+ * errors (an unterminated quote) are LOUD — the fire fails rather than
114
+ * admitting a half-parsed prompt.
115
+ */
116
+ async function jobPromptText(deps, conversationId, prompt, context) {
117
+ if (deps.commandExpansion === undefined || !prompt.startsWith("/")) {
118
+ return prompt;
119
+ }
120
+ const conversation = await deps.harness.conversation(conversationId, context);
121
+ if (conversation === undefined)
122
+ return prompt;
123
+ const env = await conversationEnv(deps, conversation);
124
+ const expansion = await expandInput([{ type: "text", text: prompt }], deps.commandExpansion, env, context);
125
+ const part = expansion.parts[0];
126
+ return part !== undefined && part.type === "text" ? part.text : prompt;
127
+ }
128
+ /**
129
+ * The conversation workspace env for a job's conversation — the same
130
+ * `envBuilder` seam the harness and the submit op use (the expansion's
131
+ * workspace template root reads through it).
132
+ */
133
+ async function conversationEnv(deps, conversation) {
134
+ const builder = deps.envBuilder;
135
+ if (builder === undefined) {
136
+ throw new Error("fireJob: no env builder wired (commandExpansion requires envBuilder)");
137
+ }
138
+ const agent = await conversation.agent(BACKGROUND_CONTEXT);
139
+ const built = await builder({
140
+ conversationId: conversation.id,
141
+ ...(agent.cwd === undefined ? {} : { cwd: agent.cwd }),
142
+ read: deps.harness,
143
+ }, BACKGROUND_CONTEXT);
144
+ if (built === undefined) {
145
+ throw new Error("envBuilder returned no ExecutionEnv");
146
+ }
147
+ return built;
148
+ }
149
+ // ---------------------------------------------------------------------------
150
+ // The prompt action
151
+ // ---------------------------------------------------------------------------
152
+ async function firePrompt(deps, id, record, fireKey, baseRun, prior, context) {
153
+ const action = record.spec.action;
154
+ if (action.kind !== "prompt")
155
+ throw new Error("unreachable");
156
+ // Fixed conversation: the run record commits first, then the submit
157
+ // (re-entry re-submits; the requestId dedups).
158
+ if (action.conversation !== "new") {
159
+ const conversationId = action.conversation;
160
+ const conversation = await deps.harness.conversation(conversationId, context);
161
+ if (conversation === undefined) {
162
+ return { outcome: "blocked" };
163
+ }
164
+ const run = {
165
+ ...baseRun,
166
+ conversationId,
167
+ };
168
+ if (prior === undefined) {
169
+ await deps.harness.commit(async (tx) => {
170
+ await tx.doc(JobRunDoc, fireKey, run);
171
+ }, context);
172
+ await patchJob(deps.harness, id, (draft) => {
173
+ draft.lastRun = run;
174
+ }, context);
175
+ }
176
+ await conversation.submit({
177
+ type: "input",
178
+ content: [
179
+ {
180
+ type: "text",
181
+ text: await jobPromptText(deps, conversationId, action.prompt, context),
182
+ },
183
+ ],
184
+ requestId: `job:${fireKey}`,
185
+ }, context);
186
+ return { outcome: "running", conversationId };
187
+ }
188
+ // conversation: "new" — the run record + conversation in ONE commit.
189
+ let conversationId = prior?.conversationId;
190
+ if (conversationId === undefined) {
191
+ const model = action.model !== undefined
192
+ ? parseModelSpec(action.model, "job action model")
193
+ : deps.defaultModel;
194
+ const conversation = await deps.harness.createConversation({
195
+ ownership: { kind: "ownerless" },
196
+ agent: {
197
+ model,
198
+ ...(action.thinking === undefined
199
+ ? {}
200
+ : { thinkingLevel: action.thinking }),
201
+ },
202
+ init: async (tx, newId) => {
203
+ const run = {
204
+ ...baseRun,
205
+ conversationId: newId,
206
+ };
207
+ await tx.doc(JobRunDoc, fireKey, run);
208
+ const meta = await tx.doc(SessionMetaDoc, newId);
209
+ meta.source = "cron";
210
+ meta.jobId = id;
211
+ if (record.owner !== undefined)
212
+ meta.owner = record.owner;
213
+ if (action.runSharing !== undefined)
214
+ meta.sharing = action.runSharing;
215
+ meta.name = runName(record.spec.name, deps.now(), record, deps);
216
+ },
217
+ }, context);
218
+ conversationId = conversation.id;
219
+ const run = { ...baseRun, conversationId };
220
+ await patchJob(deps.harness, id, (draft) => {
221
+ draft.lastRun = run;
222
+ }, context);
223
+ }
224
+ const conversation = await deps.harness.conversation(conversationId, context);
225
+ if (conversation === undefined) {
226
+ // The run record exists but its conversation vanished: a blocked
227
+ // fire (never silently succeed).
228
+ return { outcome: "blocked" };
229
+ }
230
+ await conversation.submit({
231
+ type: "input",
232
+ content: [
233
+ {
234
+ type: "text",
235
+ text: await jobPromptText(deps, conversationId, action.prompt, context),
236
+ },
237
+ ],
238
+ requestId: `job:${fireKey}`,
239
+ }, context);
240
+ return { outcome: "running", conversationId };
241
+ }
242
+ /** `<job name> · <YYYY-MM-DD HH:mm in the job's timezone>` (the plan's name). */
243
+ function runName(name, at, record, deps) {
244
+ const schedule = record.spec.schedule;
245
+ const tz = ("cron" in schedule ? schedule.timezone : undefined) ?? deps.defaultTz;
246
+ const parts = new Intl.DateTimeFormat("en-CA", {
247
+ timeZone: tz,
248
+ year: "numeric",
249
+ month: "2-digit",
250
+ day: "2-digit",
251
+ hour: "2-digit",
252
+ minute: "2-digit",
253
+ hour12: false,
254
+ }).formatToParts(new Date(at));
255
+ const field = (type) => parts.find((part) => part.type === type)?.value ?? "";
256
+ return `${name} · ${field("year")}-${field("month")}-${field("day")} ${field("hour")}:${field("minute")}`;
257
+ }
258
+ // ---------------------------------------------------------------------------
259
+ // The task action
260
+ // ---------------------------------------------------------------------------
261
+ async function fireTaskAction(deps, id, record, fireKey, baseRun, prior, context, runtime) {
262
+ const action = record.spec.action;
263
+ if (action.kind !== "task")
264
+ throw new Error("unreachable");
265
+ // The target task must be registered by an installed extension.
266
+ const snapshot = depsRegistry(deps, runtime);
267
+ const target = snapshot.task(action.task);
268
+ if (target === undefined) {
269
+ const reason = `task ${action.task} is not registered`;
270
+ await patchJob(deps.harness, id, (draft) => {
271
+ draft.state = "blocked";
272
+ draft.reason = reason;
273
+ }, context);
274
+ return { outcome: "blocked" };
275
+ }
276
+ if (prior !== undefined) {
277
+ // Re-entry: the record (and its task) exist. The task's terminal
278
+ // outcome settles the run (Task 3's observer); nothing to re-do.
279
+ return {
280
+ outcome: "running",
281
+ ...(prior.conversationId === undefined
282
+ ? {}
283
+ : { conversationId: prior.conversationId }),
284
+ };
285
+ }
286
+ // The run record + the created task share ONE commit. With the
287
+ // schedule task driving, that's the task's own commit line (the child
288
+ // task is owned by it, so deleting the job cascades). Run-now without
289
+ // a runtime creates the child conversation-owned on root — the
290
+ // cascade is Task 3's delete path's concern (abortTask by scan).
291
+ const run = { ...baseRun };
292
+ if (runtime !== undefined) {
293
+ let taskId;
294
+ await runtime.commit(async (tx) => {
295
+ taskId = await tx.createTask(target, (action.input ?? {}), { ownership: { kind: "task", taskId: runtime.taskId } });
296
+ await tx.doc(JobRunDoc, fireKey, {
297
+ ...run,
298
+ taskId: taskId,
299
+ });
300
+ return undefined;
301
+ }, context);
302
+ run.taskId = taskId;
303
+ }
304
+ else {
305
+ // Run-now (no schedule-task runtime): the child task is
306
+ // conversation-owned on the ROOT conversation — Task 3's spec for
307
+ // the run-now path (Task 2's shape omitted the conversationId and
308
+ // threw on a Session commit, which requires it for a
309
+ // conversation-owned task).
310
+ const rootConversation = await deps.harness.root(context);
311
+ await deps.harness.commit(async (tx) => {
312
+ const taskId = await tx.createTask(target, (action.input ?? {}), {
313
+ ownership: { kind: "conversation" },
314
+ conversationId: rootConversation.id,
315
+ });
316
+ await tx.doc(JobRunDoc, fireKey, {
317
+ ...run,
318
+ taskId: taskId,
319
+ });
320
+ }, context);
321
+ }
322
+ await patchJob(deps.harness, id, (draft) => {
323
+ draft.lastRun = run;
324
+ }, context);
325
+ return { outcome: "running" };
326
+ }
327
+ /**
328
+ * The registry snapshot the task-action lookup reads: the driving
329
+ * runtime's when inside the schedule task (the phase's snapshot), else
330
+ * the `registry` threaded through `JobRuntimeDeps` (sigrun's boot-owned
331
+ * `OpenedHarness.registry` — the BINDING Task-3 carry-forward, replacing
332
+ * the harness private-field reach).
333
+ */
334
+ function depsRegistry(deps, runtime) {
335
+ if (runtime !== undefined)
336
+ return runtime.registry;
337
+ const registry = deps.registry;
338
+ if (registry === undefined) {
339
+ throw new Error("fireJob: no registry available outside a task");
340
+ }
341
+ return registry.snapshot();
342
+ }