@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.
- package/CHANGELOG.md +214 -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 +89 -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,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
|
+
}
|