@sigrun/sigrun 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +204 -0
- package/CONTEXT.md +163 -0
- package/LICENSE +21 -0
- package/README.md +691 -2
- package/THIRD_PARTY_NOTICES.md +28 -0
- package/dist/backup/marker.js +80 -0
- package/dist/backup/run-backup.js +339 -0
- package/dist/cli.js +952 -0
- package/dist/commands/expand.js +245 -0
- package/dist/commands/templates.js +272 -0
- package/dist/config.js +1146 -0
- package/dist/extension.d.ts +431 -0
- package/dist/harness/activity.js +42 -0
- package/dist/harness/ambient.js +261 -0
- package/dist/harness/auth.js +130 -0
- package/dist/harness/boot.js +865 -0
- package/dist/harness/builtins.js +191 -0
- package/dist/harness/event-bus.js +1203 -0
- package/dist/harness/health.js +30 -0
- package/dist/harness/identity/index.js +37 -0
- package/dist/harness/identity/invites.js +94 -0
- package/dist/harness/identity/json-store.js +108 -0
- package/dist/harness/identity/setup-code.js +81 -0
- package/dist/harness/identity/tokens.js +101 -0
- package/dist/harness/identity/users.js +93 -0
- package/dist/harness/interactions.js +283 -0
- package/dist/harness/loader.js +1236 -0
- package/dist/harness/memory-default.js +35 -0
- package/dist/harness/naming.js +193 -0
- package/dist/harness/packages.js +1002 -0
- package/dist/harness/providers/auth-bridge.js +60 -0
- package/dist/harness/providers/index.js +277 -0
- package/dist/harness/providers/models-store.js +105 -0
- package/dist/harness/providers/refresh.js +161 -0
- package/dist/harness/providers/scheduler.js +89 -0
- package/dist/harness/registry.js +9 -0
- package/dist/harness/reserved-sections.js +24 -0
- package/dist/harness/seam-host.js +239 -0
- package/dist/harness/sections/context-files.js +78 -0
- package/dist/harness/sections/model-guidance.js +77 -0
- package/dist/harness/settings.js +1053 -0
- package/dist/harness/stores.js +450 -0
- package/dist/harness/tools.js +500 -0
- package/dist/harness/workspace.js +73 -0
- package/dist/index.js +39 -0
- package/dist/jobs/docs.js +257 -0
- package/dist/jobs/fire.js +342 -0
- package/dist/jobs/index.js +228 -0
- package/dist/jobs/observe.js +165 -0
- package/dist/jobs/reconcile.js +245 -0
- package/dist/jobs/schedule.js +77 -0
- package/dist/jobs/spec.js +248 -0
- package/dist/jobs/task.js +190 -0
- package/dist/jobs/transfer.js +53 -0
- package/dist/serve.js +164 -0
- package/dist/server/auth/claim-lock.js +28 -0
- package/dist/server/auth/ip.js +51 -0
- package/dist/server/auth/login-limiter.js +52 -0
- package/dist/server/auth/loopback.js +47 -0
- package/dist/server/auth/pipeline.js +330 -0
- package/dist/server/auth/principal.js +26 -0
- package/dist/server/commands.js +198 -0
- package/dist/server/content-parts.js +142 -0
- package/dist/server/context.js +13 -0
- package/dist/server/conversation-handler.js +61 -0
- package/dist/server/conversation-ops.js +310 -0
- package/dist/server/events/hub.js +39 -0
- package/dist/server/events/types.js +8 -0
- package/dist/server/helpers.js +351 -0
- package/dist/server/host-bridge.js +131 -0
- package/dist/server/index.js +232 -0
- package/dist/server/notifications.js +185 -0
- package/dist/server/ownership.js +67 -0
- package/dist/server/readiness.js +36 -0
- package/dist/server/routes/auth.js +172 -0
- package/dist/server/routes/conversations.js +341 -0
- package/dist/server/routes/events.js +161 -0
- package/dist/server/routes/extension-routes.js +127 -0
- package/dist/server/routes/extensions.js +168 -0
- package/dist/server/routes/files.js +308 -0
- package/dist/server/routes/interactions.js +260 -0
- package/dist/server/routes/jobs.js +271 -0
- package/dist/server/routes/meta.js +146 -0
- package/dist/server/routes/metrics.js +219 -0
- package/dist/server/routes/onboarding.js +268 -0
- package/dist/server/routes/probes.js +53 -0
- package/dist/server/routes/providers.js +198 -0
- package/dist/server/routes/settings.js +48 -0
- package/dist/server/routes/stream.js +131 -0
- package/dist/server/routes/submissions.js +317 -0
- package/dist/server/routes/users.js +169 -0
- package/dist/server/schema-fixture.js +950 -0
- package/dist/server/session-meta.js +247 -0
- package/dist/server/static.js +169 -0
- package/dist/server/views.js +71 -0
- package/dist/skills/index.js +418 -0
- package/node_modules/@sigrun/shared/CHANGELOG.md +16 -0
- package/node_modules/@sigrun/shared/LICENSE +21 -0
- package/node_modules/@sigrun/shared/README.md +10 -0
- package/node_modules/@sigrun/shared/dist/access.d.ts +55 -0
- package/node_modules/@sigrun/shared/dist/access.js +72 -0
- package/node_modules/@sigrun/shared/dist/ambient.d.ts +111 -0
- package/node_modules/@sigrun/shared/dist/ambient.js +23 -0
- package/node_modules/@sigrun/shared/dist/commands.d.ts +75 -0
- package/node_modules/@sigrun/shared/dist/commands.js +25 -0
- package/node_modules/@sigrun/shared/dist/conversations.d.ts +31 -0
- package/node_modules/@sigrun/shared/dist/conversations.js +94 -0
- package/node_modules/@sigrun/shared/dist/interactions.d.ts +91 -0
- package/node_modules/@sigrun/shared/dist/interactions.js +21 -0
- package/node_modules/@sigrun/shared/dist/jobs.d.ts +100 -0
- package/node_modules/@sigrun/shared/dist/jobs.js +19 -0
- package/node_modules/@sigrun/shared/dist/json.d.ts +20 -0
- package/node_modules/@sigrun/shared/dist/json.js +13 -0
- package/node_modules/@sigrun/shared/dist/session-meta.d.ts +103 -0
- package/node_modules/@sigrun/shared/dist/session-meta.js +13 -0
- package/node_modules/@sigrun/shared/dist/settings.d.ts +401 -0
- package/node_modules/@sigrun/shared/dist/settings.js +19 -0
- package/node_modules/@sigrun/shared/dist/themes.d.ts +50 -0
- package/node_modules/@sigrun/shared/dist/themes.js +156 -0
- package/node_modules/@sigrun/shared/dist/turns.d.ts +102 -0
- package/node_modules/@sigrun/shared/dist/turns.js +166 -0
- package/node_modules/@sigrun/shared/dist/view-delta.d.ts +54 -0
- package/node_modules/@sigrun/shared/dist/view-delta.js +135 -0
- package/node_modules/@sigrun/shared/dist/views.d.ts +742 -0
- package/node_modules/@sigrun/shared/dist/views.js +38 -0
- package/node_modules/@sigrun/shared/package.json +83 -0
- package/package.json +90 -5
- package/webui/dist/assets/index-Rl0KhCLa.css +1 -0
- package/webui/dist/assets/index-rOiARZLF.js +50 -0
- package/webui/dist/index.html +37 -0
|
@@ -0,0 +1,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
|
+
}
|