harnery 0.13.0 → 0.15.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/README.md +2 -0
- package/dist/commander.d.ts.map +1 -1
- package/dist/commander.js +4 -0
- package/dist/commands/supervisor.d.ts +4 -0
- package/dist/commands/supervisor.d.ts.map +1 -0
- package/dist/commands/supervisor.js +238 -0
- package/dist/commands/work.d.ts +4 -0
- package/dist/commands/work.d.ts.map +1 -0
- package/dist/commands/work.js +281 -0
- package/dist/core/supervisor/index.d.ts +3 -0
- package/dist/core/supervisor/index.d.ts.map +1 -0
- package/dist/core/supervisor/index.js +2 -0
- package/dist/core/supervisor/read.d.ts +2 -0
- package/dist/core/supervisor/read.d.ts.map +1 -0
- package/dist/core/supervisor/read.js +1 -0
- package/dist/core/supervisor/runner.d.ts +34 -0
- package/dist/core/supervisor/runner.d.ts.map +1 -0
- package/dist/core/supervisor/runner.js +198 -0
- package/dist/core/supervisor/state.d.ts +69 -0
- package/dist/core/supervisor/state.d.ts.map +1 -0
- package/dist/core/supervisor/state.js +413 -0
- package/dist/core/work/index.d.ts +3 -0
- package/dist/core/work/index.d.ts.map +1 -0
- package/dist/core/work/index.js +2 -0
- package/dist/core/work/read.d.ts +2 -0
- package/dist/core/work/read.d.ts.map +1 -0
- package/dist/core/work/read.js +1 -0
- package/dist/core/work/runner.d.ts +10 -0
- package/dist/core/work/runner.d.ts.map +1 -0
- package/dist/core/work/runner.js +63 -0
- package/dist/core/work/state.d.ts +109 -0
- package/dist/core/work/state.d.ts.map +1 -0
- package/dist/core/work/state.js +698 -0
- package/dist/core/workflow/engine.d.ts.map +1 -1
- package/dist/core/workflow/engine.js +41 -9
- package/dist/core/workflow/index.d.ts +1 -1
- package/dist/core/workflow/index.d.ts.map +1 -1
- package/dist/core/workflow/proof.d.ts +1 -0
- package/dist/core/workflow/proof.d.ts.map +1 -1
- package/dist/core/workflow/proof.js +2 -0
- package/dist/core/workflow/run-state.d.ts +3 -0
- package/dist/core/workflow/run-state.d.ts.map +1 -1
- package/dist/core/workflow/run-state.js +14 -0
- package/dist/core/workflow/specialists.d.ts +7 -0
- package/dist/core/workflow/specialists.d.ts.map +1 -0
- package/dist/core/workflow/specialists.js +78 -0
- package/dist/core/workflow/types.d.ts +24 -0
- package/dist/core/workflow/types.d.ts.map +1 -1
- package/package.json +21 -1
- package/src/commander.ts +4 -0
- package/src/commands/supervisor.ts +315 -0
- package/src/commands/work.ts +367 -0
- package/src/core/supervisor/index.ts +24 -0
- package/src/core/supervisor/read.ts +14 -0
- package/src/core/supervisor/runner.ts +278 -0
- package/src/core/supervisor/state.ts +552 -0
- package/src/core/work/index.ts +24 -0
- package/src/core/work/read.ts +15 -0
- package/src/core/work/runner.ts +81 -0
- package/src/core/work/state.ts +900 -0
- package/src/core/workflow/engine.ts +42 -9
- package/src/core/workflow/index.ts +1 -0
- package/src/core/workflow/proof.ts +3 -0
- package/src/core/workflow/run-state.ts +20 -0
- package/src/core/workflow/specialists.ts +97 -0
- package/src/core/workflow/types.ts +25 -0
|
@@ -0,0 +1,698 @@
|
|
|
1
|
+
import { randomBytes, randomUUID } from "node:crypto";
|
|
2
|
+
import { appendFileSync, chmodSync, closeSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
3
|
+
import { hostname } from "node:os";
|
|
4
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
5
|
+
import { readWorkflowApproval } from "../workflow/approvals.js";
|
|
6
|
+
import { readWorkflowProof } from "../workflow/proof.js";
|
|
7
|
+
import { readWorkflowRunManifest, workflowScriptDigest } from "../workflow/run-state.js";
|
|
8
|
+
export const WORK_INTENT_SCHEMA_VERSION = 1;
|
|
9
|
+
export const WORK_EVENT_SCHEMA_VERSION = 1;
|
|
10
|
+
const WORK_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
|
|
11
|
+
const RUN_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/;
|
|
12
|
+
const MAX_TITLE = 200;
|
|
13
|
+
const MAX_OBJECTIVE = 4_000;
|
|
14
|
+
const MAX_ACCEPTANCE = 50;
|
|
15
|
+
const MAX_ACCEPTANCE_ITEM = 500;
|
|
16
|
+
const MAX_DEPENDENCIES = 50;
|
|
17
|
+
const MAX_SOURCE_REF = 1_000;
|
|
18
|
+
const MAX_ACTOR = 200;
|
|
19
|
+
const MAX_REASON = 2_000;
|
|
20
|
+
const MAX_INTENT_BYTES = 128 * 1024;
|
|
21
|
+
const MAX_EVENTS_BYTES = 4 * 1024 * 1024;
|
|
22
|
+
const MAX_EVENT_BYTES = 16 * 1024;
|
|
23
|
+
const MAX_EVENTS = 1_000;
|
|
24
|
+
const FOREIGN_LEASE_STALE_MS = 24 * 60 * 60 * 1_000;
|
|
25
|
+
export function createWorkItem(input) {
|
|
26
|
+
const coordRoot = resolve(input.coordRoot);
|
|
27
|
+
const id = input.id ?? newWorkId();
|
|
28
|
+
assertWorkId(id);
|
|
29
|
+
const workflowPath = resolve(input.workflowPath);
|
|
30
|
+
if (!isAbsolute(workflowPath) || !existsSync(workflowPath)) {
|
|
31
|
+
throw new Error(`work workflow does not exist at ${workflowPath}`);
|
|
32
|
+
}
|
|
33
|
+
const dependencies = uniqueStrings(input.dependencies ?? [], "work dependencies", MAX_DEPENDENCIES);
|
|
34
|
+
for (const dependency of dependencies) {
|
|
35
|
+
assertWorkId(dependency);
|
|
36
|
+
if (dependency === id)
|
|
37
|
+
throw new Error("work item cannot depend on itself");
|
|
38
|
+
readWorkIntent(coordRoot, dependency);
|
|
39
|
+
}
|
|
40
|
+
assertNoDependencyCycle(coordRoot, id, dependencies);
|
|
41
|
+
const maxAttempts = input.maxAttempts ?? 3;
|
|
42
|
+
if (!Number.isSafeInteger(maxAttempts) || maxAttempts < 1 || maxAttempts > 100) {
|
|
43
|
+
throw new Error("work maxAttempts must be an integer from 1 to 100");
|
|
44
|
+
}
|
|
45
|
+
const acceptance = (input.acceptance ?? []).map((value, index) => boundedString(value, `work acceptance[${index}]`, MAX_ACCEPTANCE_ITEM));
|
|
46
|
+
if (acceptance.length > MAX_ACCEPTANCE) {
|
|
47
|
+
throw new Error(`work acceptance exceeds ${MAX_ACCEPTANCE} criteria`);
|
|
48
|
+
}
|
|
49
|
+
const source = normalizeSource(input.source);
|
|
50
|
+
const intent = {
|
|
51
|
+
schema_version: WORK_INTENT_SCHEMA_VERSION,
|
|
52
|
+
id,
|
|
53
|
+
title: boundedString(input.title, "work title", MAX_TITLE),
|
|
54
|
+
objective: boundedString(input.objective, "work objective", MAX_OBJECTIVE),
|
|
55
|
+
acceptance,
|
|
56
|
+
dependencies,
|
|
57
|
+
workflow: { path: workflowPath, sha256: workflowScriptDigest(workflowPath) },
|
|
58
|
+
max_attempts: maxAttempts,
|
|
59
|
+
source,
|
|
60
|
+
created_at: new Date().toISOString(),
|
|
61
|
+
};
|
|
62
|
+
const dir = workDir(coordRoot, id);
|
|
63
|
+
mkdirSync(dirname(dir), { recursive: true, mode: 0o700 });
|
|
64
|
+
mkdirSync(dir, { mode: 0o700 });
|
|
65
|
+
chmodSync(dir, 0o700);
|
|
66
|
+
writePrivateJson(join(dir, "intent.json"), intent, MAX_INTENT_BYTES);
|
|
67
|
+
const release = acquireWorkLease(coordRoot, id);
|
|
68
|
+
try {
|
|
69
|
+
appendWorkEvent(coordRoot, id, {
|
|
70
|
+
event: "work.created",
|
|
71
|
+
actor: boundedActor(input.actor),
|
|
72
|
+
reason: "durable work intent created",
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
finally {
|
|
76
|
+
release();
|
|
77
|
+
}
|
|
78
|
+
return readWorkItem(coordRoot, id);
|
|
79
|
+
}
|
|
80
|
+
export function readWorkItem(coordRoot, workId) {
|
|
81
|
+
const intent = readWorkIntent(coordRoot, workId);
|
|
82
|
+
const events = readWorkEvents(coordRoot, workId);
|
|
83
|
+
return { intent, events, projection: deriveWorkProjection(coordRoot, intent, events) };
|
|
84
|
+
}
|
|
85
|
+
export function listWorkItems(coordRoot) {
|
|
86
|
+
const base = join(resolve(coordRoot), ".harnery", "work");
|
|
87
|
+
if (!existsSync(base))
|
|
88
|
+
return [];
|
|
89
|
+
const records = [];
|
|
90
|
+
for (const name of readdirSync(base)) {
|
|
91
|
+
if (!WORK_ID.test(name) || !existsSync(join(base, name, "intent.json")))
|
|
92
|
+
continue;
|
|
93
|
+
records.push(readWorkItem(coordRoot, name));
|
|
94
|
+
}
|
|
95
|
+
return records.sort((a, b) => b.intent.created_at.localeCompare(a.intent.created_at));
|
|
96
|
+
}
|
|
97
|
+
export function reconcileWorkItem(coordRoot, workId, actor) {
|
|
98
|
+
const before = readWorkItem(coordRoot, workId);
|
|
99
|
+
const last = [...before.events].reverse().find((event) => event.event === "work.reconciled");
|
|
100
|
+
if (last?.state === before.projection.state &&
|
|
101
|
+
last.reason === before.projection.reason &&
|
|
102
|
+
last.next_action === before.projection.next_action) {
|
|
103
|
+
return before;
|
|
104
|
+
}
|
|
105
|
+
// Reconciliation is observational. An active runner owns the append lease;
|
|
106
|
+
// report its live projection now and let the runner append the transition
|
|
107
|
+
// when it releases the lease.
|
|
108
|
+
if (workLeaseIsLive(coordRoot, workId))
|
|
109
|
+
return before;
|
|
110
|
+
const release = acquireWorkLease(coordRoot, workId);
|
|
111
|
+
try {
|
|
112
|
+
const current = readWorkItemIgnoringLease(coordRoot, workId);
|
|
113
|
+
const latest = [...current.events].reverse().find((event) => event.event === "work.reconciled");
|
|
114
|
+
if (latest?.state !== current.projection.state ||
|
|
115
|
+
latest.reason !== current.projection.reason ||
|
|
116
|
+
latest.next_action !== current.projection.next_action) {
|
|
117
|
+
appendWorkEvent(coordRoot, workId, {
|
|
118
|
+
event: "work.reconciled",
|
|
119
|
+
actor: boundedActor(actor, "reconciler"),
|
|
120
|
+
reason: current.projection.reason,
|
|
121
|
+
state: current.projection.state,
|
|
122
|
+
next_action: current.projection.next_action,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
finally {
|
|
127
|
+
release();
|
|
128
|
+
}
|
|
129
|
+
return readWorkItem(coordRoot, workId);
|
|
130
|
+
}
|
|
131
|
+
export function reconcileAllWorkItems(coordRoot, actor) {
|
|
132
|
+
return listWorkItems(coordRoot).map((record) => reconcileWorkItem(coordRoot, record.intent.id, actor));
|
|
133
|
+
}
|
|
134
|
+
export function acceptWorkItem(coordRoot, workId, input = {}) {
|
|
135
|
+
return appendGovernanceEvent(coordRoot, workId, "work.accepted", ["in_review"], input);
|
|
136
|
+
}
|
|
137
|
+
export function cancelWorkItem(coordRoot, workId, input = {}) {
|
|
138
|
+
return appendGovernanceEvent(coordRoot, workId, "work.cancelled", ["waiting", "ready", "blocked", "in_review"], input);
|
|
139
|
+
}
|
|
140
|
+
export function reopenWorkItem(coordRoot, workId, input = {}) {
|
|
141
|
+
return appendGovernanceEvent(coordRoot, workId, "work.reopened", ["blocked", "in_review", "succeeded", "cancelled"], input);
|
|
142
|
+
}
|
|
143
|
+
export function assertWorkId(workId) {
|
|
144
|
+
if (!WORK_ID.test(workId))
|
|
145
|
+
throw new Error(`invalid work id ${JSON.stringify(workId)}`);
|
|
146
|
+
}
|
|
147
|
+
export function newWorkId() {
|
|
148
|
+
return `work-${new Date().toISOString().replace(/[:.]/g, "-")}-${randomBytes(3).toString("hex")}`;
|
|
149
|
+
}
|
|
150
|
+
export function newWorkflowRunId() {
|
|
151
|
+
return `wf-${new Date().toISOString().replace(/[:.]/g, "-")}-${randomBytes(3).toString("hex")}`;
|
|
152
|
+
}
|
|
153
|
+
function deriveWorkProjection(coordRoot, intent, events, ignoreLease = false) {
|
|
154
|
+
const latestGovernance = [...events]
|
|
155
|
+
.reverse()
|
|
156
|
+
.find((event) => ["work.accepted", "work.cancelled", "work.reopened"].includes(event.event));
|
|
157
|
+
const terminal = latestGovernance?.event === "work.accepted" || latestGovernance?.event === "work.cancelled";
|
|
158
|
+
const attemptEvents = events.filter((event) => event.event === "attempt.started");
|
|
159
|
+
const attempts = attemptEvents.map((event) => inspectAttempt(coordRoot, event));
|
|
160
|
+
const base = {
|
|
161
|
+
id: intent.id,
|
|
162
|
+
title: intent.title,
|
|
163
|
+
unresolved_dependencies: [],
|
|
164
|
+
attempts,
|
|
165
|
+
attempts_used: attempts.length,
|
|
166
|
+
attempts_remaining: Math.max(0, intent.max_attempts - attempts.length),
|
|
167
|
+
latest_run_id: attempts.at(-1)?.run_id,
|
|
168
|
+
approval_id: attempts.at(-1)?.approval_id,
|
|
169
|
+
proof_path: attempts.at(-1)?.proof_path,
|
|
170
|
+
updated_at: events.at(-1)?.ts ?? intent.created_at,
|
|
171
|
+
};
|
|
172
|
+
if (terminal) {
|
|
173
|
+
const accepted = latestGovernance?.event === "work.accepted";
|
|
174
|
+
return {
|
|
175
|
+
...base,
|
|
176
|
+
state: accepted ? "succeeded" : "cancelled",
|
|
177
|
+
reason: latestGovernance?.reason ?? (accepted ? "work accepted" : "work cancelled"),
|
|
178
|
+
next_action: "none",
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
const reopenSeq = latestGovernance?.event === "work.reopened" ? latestGovernance.seq : 0;
|
|
182
|
+
const currentAttempts = attemptEvents
|
|
183
|
+
.filter((event) => event.seq > reopenSeq)
|
|
184
|
+
.map((event) => inspectAttempt(coordRoot, event));
|
|
185
|
+
const unresolved = intent.dependencies.filter((dependency) => {
|
|
186
|
+
try {
|
|
187
|
+
return readWorkItem(coordRoot, dependency).projection.state !== "succeeded";
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
return true;
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
if (unresolved.length > 0) {
|
|
194
|
+
return {
|
|
195
|
+
...base,
|
|
196
|
+
state: "waiting",
|
|
197
|
+
reason: `waiting for ${unresolved.length} dependenc${unresolved.length === 1 ? "y" : "ies"}`,
|
|
198
|
+
next_action: "wait_for_dependencies",
|
|
199
|
+
unresolved_dependencies: unresolved,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
if (!ignoreLease && workLeaseIsLive(coordRoot, intent.id)) {
|
|
203
|
+
return {
|
|
204
|
+
...base,
|
|
205
|
+
state: "running",
|
|
206
|
+
reason: "workflow attempt holds the work lease",
|
|
207
|
+
next_action: "wait_for_run",
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
const latest = currentAttempts.at(-1);
|
|
211
|
+
if (!latest) {
|
|
212
|
+
return {
|
|
213
|
+
...base,
|
|
214
|
+
state: "ready",
|
|
215
|
+
reason: reopenSeq > 0 ? "work reopened for a new attempt" : "dependencies are satisfied",
|
|
216
|
+
next_action: "run",
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
if (latest.status === "running") {
|
|
220
|
+
return {
|
|
221
|
+
...base,
|
|
222
|
+
state: "running",
|
|
223
|
+
reason: `workflow attempt ${latest.number} is running`,
|
|
224
|
+
next_action: "wait_for_run",
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
if (latest.status === "parked") {
|
|
228
|
+
const approval = latest.approval_id
|
|
229
|
+
? readWorkflowApproval(coordRoot, latest.approval_id)
|
|
230
|
+
: undefined;
|
|
231
|
+
const resolved = approval?.status === "approved" || approval?.status === "denied";
|
|
232
|
+
return {
|
|
233
|
+
...base,
|
|
234
|
+
state: "awaiting_approval",
|
|
235
|
+
reason: resolved
|
|
236
|
+
? `approval ${latest.approval_id} is resolved; resume the same attempt`
|
|
237
|
+
: `workflow attempt ${latest.number} is parked for approval`,
|
|
238
|
+
next_action: resolved ? "resume" : "resolve_approval",
|
|
239
|
+
approval_id: latest.approval_id,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
if (latest.status === "succeeded") {
|
|
243
|
+
return {
|
|
244
|
+
...base,
|
|
245
|
+
state: "in_review",
|
|
246
|
+
reason: `workflow attempt ${latest.number} produced passing proof; acceptance remains explicit`,
|
|
247
|
+
next_action: "review",
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
const attemptsRemaining = intent.max_attempts - attempts.length;
|
|
251
|
+
return {
|
|
252
|
+
...base,
|
|
253
|
+
state: "blocked",
|
|
254
|
+
reason: latest.status === "failed"
|
|
255
|
+
? `workflow attempt ${latest.number} failed`
|
|
256
|
+
: `workflow attempt ${latest.number} ended without terminal evidence`,
|
|
257
|
+
next_action: attemptsRemaining > 0 ? "retry" : "none",
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
function inspectAttempt(coordRoot, event) {
|
|
261
|
+
const runId = event.run_id;
|
|
262
|
+
const attempt = {
|
|
263
|
+
number: event.attempt,
|
|
264
|
+
run_id: runId,
|
|
265
|
+
started_at: event.ts,
|
|
266
|
+
status: "lost",
|
|
267
|
+
};
|
|
268
|
+
const proofPath = join(coordRoot, ".harnery", "workflows", runId, "proof.json");
|
|
269
|
+
const manifestPath = join(coordRoot, ".harnery", "workflows", runId, "run.json");
|
|
270
|
+
if (existsSync(manifestPath)) {
|
|
271
|
+
const manifest = readWorkflowRunManifest(coordRoot, runId);
|
|
272
|
+
if (manifest.work_item_id !== event.work_id) {
|
|
273
|
+
throw new Error(`workflow run ${runId} does not belong to work item ${event.work_id}`);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
if (existsSync(proofPath)) {
|
|
277
|
+
if (!existsSync(manifestPath)) {
|
|
278
|
+
throw new Error(`workflow run ${runId} has proof without a work-linked manifest`);
|
|
279
|
+
}
|
|
280
|
+
const proof = readWorkflowProof(coordRoot, runId);
|
|
281
|
+
attempt.proof_path = proofPath;
|
|
282
|
+
attempt.status =
|
|
283
|
+
proof.run.status === "succeeded" &&
|
|
284
|
+
proof.acceptance.summary.unsatisfied === 0 &&
|
|
285
|
+
proof.acceptance.summary.unknown === 0
|
|
286
|
+
? "succeeded"
|
|
287
|
+
: "failed";
|
|
288
|
+
return attempt;
|
|
289
|
+
}
|
|
290
|
+
const journalPath = join(coordRoot, ".harnery", "workflows", runId, "journal.jsonl");
|
|
291
|
+
if (existsSync(journalPath)) {
|
|
292
|
+
let parked;
|
|
293
|
+
let resumed = false;
|
|
294
|
+
for (const line of readBoundedLines(journalPath, MAX_EVENTS_BYTES, "workflow journal")) {
|
|
295
|
+
const value = parseObject(line, `workflow run ${runId} journal`);
|
|
296
|
+
if (value.event === "run.parked" && typeof value.approval_id === "string") {
|
|
297
|
+
parked = value.approval_id;
|
|
298
|
+
resumed = false;
|
|
299
|
+
}
|
|
300
|
+
if (value.event === "run.resume")
|
|
301
|
+
resumed = true;
|
|
302
|
+
}
|
|
303
|
+
if (parked && !resumed) {
|
|
304
|
+
attempt.status = "parked";
|
|
305
|
+
attempt.approval_id = parked;
|
|
306
|
+
return attempt;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
if (workflowResumeLeaseIsLive(coordRoot, runId))
|
|
310
|
+
attempt.status = "running";
|
|
311
|
+
return attempt;
|
|
312
|
+
}
|
|
313
|
+
function appendGovernanceEvent(coordRoot, workId, event, allowed, input) {
|
|
314
|
+
const release = acquireWorkLease(coordRoot, workId);
|
|
315
|
+
try {
|
|
316
|
+
const current = readWorkItemIgnoringLease(coordRoot, workId);
|
|
317
|
+
if (!allowed.includes(current.projection.state)) {
|
|
318
|
+
throw new Error(`work item ${workId} cannot ${event.replace("work.", "")} from state ${current.projection.state}`);
|
|
319
|
+
}
|
|
320
|
+
appendWorkEvent(coordRoot, workId, {
|
|
321
|
+
event,
|
|
322
|
+
actor: boundedActor(input.actor),
|
|
323
|
+
reason: boundedOptional(input.reason, "work reason", MAX_REASON),
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
finally {
|
|
327
|
+
release();
|
|
328
|
+
}
|
|
329
|
+
return reconcileWorkItem(coordRoot, workId, input.actor);
|
|
330
|
+
}
|
|
331
|
+
/** @internal Runner seam; not a separate persistence contract. */
|
|
332
|
+
export function appendReconcileIfChanged(coordRoot, record, actor) {
|
|
333
|
+
const last = [...record.events].reverse().find((event) => event.event === "work.reconciled");
|
|
334
|
+
if (last?.state === record.projection.state &&
|
|
335
|
+
last.reason === record.projection.reason &&
|
|
336
|
+
last.next_action === record.projection.next_action) {
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
appendWorkEvent(coordRoot, record.intent.id, {
|
|
340
|
+
event: "work.reconciled",
|
|
341
|
+
actor,
|
|
342
|
+
reason: record.projection.reason,
|
|
343
|
+
state: record.projection.state,
|
|
344
|
+
next_action: record.projection.next_action,
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
/** @internal Runner seam; not a separate persistence contract. */
|
|
348
|
+
export function appendWorkEvent(coordRoot, workId, input) {
|
|
349
|
+
const events = readWorkEvents(coordRoot, workId);
|
|
350
|
+
if (events.length === 0 && input.event !== "work.created") {
|
|
351
|
+
appendWorkEvent(coordRoot, workId, {
|
|
352
|
+
event: "work.created",
|
|
353
|
+
actor: "recovery",
|
|
354
|
+
reason: "recovered creation receipt from immutable intent",
|
|
355
|
+
});
|
|
356
|
+
return appendWorkEvent(coordRoot, workId, input);
|
|
357
|
+
}
|
|
358
|
+
if (events.length >= MAX_EVENTS)
|
|
359
|
+
throw new Error(`work item ${workId} exceeds ${MAX_EVENTS} events`);
|
|
360
|
+
const event = {
|
|
361
|
+
schema_version: WORK_EVENT_SCHEMA_VERSION,
|
|
362
|
+
work_id: workId,
|
|
363
|
+
seq: events.length + 1,
|
|
364
|
+
ts: new Date().toISOString(),
|
|
365
|
+
...input,
|
|
366
|
+
};
|
|
367
|
+
validateWorkEvent(event, workId, event.seq);
|
|
368
|
+
const line = `${JSON.stringify(event)}\n`;
|
|
369
|
+
if (Buffer.byteLength(line) > MAX_EVENT_BYTES) {
|
|
370
|
+
throw new Error(`work event exceeds ${MAX_EVENT_BYTES} bytes`);
|
|
371
|
+
}
|
|
372
|
+
const path = join(workDir(coordRoot, workId), "events.jsonl");
|
|
373
|
+
const existingBytes = existsSync(path) ? statSync(path).size : 0;
|
|
374
|
+
if (existingBytes + Buffer.byteLength(line) > MAX_EVENTS_BYTES) {
|
|
375
|
+
throw new Error("work event log would exceed its limit");
|
|
376
|
+
}
|
|
377
|
+
appendFileSync(path, line, { encoding: "utf8", mode: 0o600 });
|
|
378
|
+
chmodSync(path, 0o600);
|
|
379
|
+
return event;
|
|
380
|
+
}
|
|
381
|
+
function readWorkIntent(coordRoot, workId) {
|
|
382
|
+
assertWorkId(workId);
|
|
383
|
+
const path = join(workDir(coordRoot, workId), "intent.json");
|
|
384
|
+
if (!existsSync(path))
|
|
385
|
+
throw new Error(`work item ${workId} does not exist`);
|
|
386
|
+
const size = statSync(path).size;
|
|
387
|
+
if (size <= 0 || size > MAX_INTENT_BYTES)
|
|
388
|
+
throw new Error(`work intent has invalid size ${size}`);
|
|
389
|
+
const intent = parseObject(readFileSync(path, "utf8"), `work intent ${workId}`);
|
|
390
|
+
validateWorkIntent(intent, workId);
|
|
391
|
+
return intent;
|
|
392
|
+
}
|
|
393
|
+
function readWorkEvents(coordRoot, workId) {
|
|
394
|
+
assertWorkId(workId);
|
|
395
|
+
const path = join(workDir(coordRoot, workId), "events.jsonl");
|
|
396
|
+
if (!existsSync(path))
|
|
397
|
+
return [];
|
|
398
|
+
const events = readBoundedLines(path, MAX_EVENTS_BYTES, `work item ${workId} events`).map((line, index) => {
|
|
399
|
+
const event = parseObject(line, `work item ${workId} event ${index + 1}`);
|
|
400
|
+
validateWorkEvent(event, workId, index + 1);
|
|
401
|
+
return event;
|
|
402
|
+
});
|
|
403
|
+
validateWorkHistory(events, workId);
|
|
404
|
+
return events;
|
|
405
|
+
}
|
|
406
|
+
/** @internal Runner seam; not a separate persistence contract. */
|
|
407
|
+
export function readWorkItemIgnoringLease(coordRoot, workId) {
|
|
408
|
+
const intent = readWorkIntent(coordRoot, workId);
|
|
409
|
+
const events = readWorkEvents(coordRoot, workId);
|
|
410
|
+
return { intent, events, projection: deriveWorkProjection(coordRoot, intent, events, true) };
|
|
411
|
+
}
|
|
412
|
+
function validateWorkIntent(intent, workId) {
|
|
413
|
+
if (intent.schema_version !== WORK_INTENT_SCHEMA_VERSION ||
|
|
414
|
+
intent.id !== workId ||
|
|
415
|
+
!validTimestamp(intent.created_at) ||
|
|
416
|
+
!isAbsolute(intent.workflow?.path) ||
|
|
417
|
+
!/^[a-f0-9]{64}$/.test(intent.workflow.sha256) ||
|
|
418
|
+
!Number.isSafeInteger(intent.max_attempts) ||
|
|
419
|
+
intent.max_attempts < 1 ||
|
|
420
|
+
intent.max_attempts > 100) {
|
|
421
|
+
throw new Error(`work intent ${workId} has an unsupported or mismatched schema`);
|
|
422
|
+
}
|
|
423
|
+
boundedString(intent.title, "work title", MAX_TITLE);
|
|
424
|
+
boundedString(intent.objective, "work objective", MAX_OBJECTIVE);
|
|
425
|
+
if (!Array.isArray(intent.acceptance) || intent.acceptance.length > MAX_ACCEPTANCE) {
|
|
426
|
+
throw new Error("work acceptance has an invalid shape");
|
|
427
|
+
}
|
|
428
|
+
intent.acceptance.forEach((value, index) => {
|
|
429
|
+
boundedString(value, `work acceptance[${index}]`, MAX_ACCEPTANCE_ITEM);
|
|
430
|
+
});
|
|
431
|
+
uniqueStrings(intent.dependencies, "work dependencies", MAX_DEPENDENCIES).forEach(assertWorkId);
|
|
432
|
+
normalizeSource(intent.source);
|
|
433
|
+
}
|
|
434
|
+
function validateWorkEvent(event, workId, seq) {
|
|
435
|
+
const eventTypes = [
|
|
436
|
+
"work.created",
|
|
437
|
+
"work.reconciled",
|
|
438
|
+
"attempt.started",
|
|
439
|
+
"attempt.resumed",
|
|
440
|
+
"work.accepted",
|
|
441
|
+
"work.cancelled",
|
|
442
|
+
"work.reopened",
|
|
443
|
+
];
|
|
444
|
+
if (event.schema_version !== WORK_EVENT_SCHEMA_VERSION ||
|
|
445
|
+
event.work_id !== workId ||
|
|
446
|
+
event.seq !== seq ||
|
|
447
|
+
!validTimestamp(event.ts) ||
|
|
448
|
+
!eventTypes.includes(event.event)) {
|
|
449
|
+
throw new Error(`work item ${workId} event ${seq} has an unsupported or mismatched schema`);
|
|
450
|
+
}
|
|
451
|
+
boundedActor(event.actor);
|
|
452
|
+
boundedOptional(event.reason, "work reason", MAX_REASON);
|
|
453
|
+
if (event.event === "attempt.started" || event.event === "attempt.resumed") {
|
|
454
|
+
if (!event.run_id ||
|
|
455
|
+
!RUN_ID.test(event.run_id) ||
|
|
456
|
+
!Number.isSafeInteger(event.attempt) ||
|
|
457
|
+
(event.attempt ?? 0) < 1) {
|
|
458
|
+
throw new Error(`work item ${workId} event ${seq} has invalid attempt data`);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
if (event.event === "work.reconciled") {
|
|
462
|
+
const states = [
|
|
463
|
+
"waiting",
|
|
464
|
+
"ready",
|
|
465
|
+
"running",
|
|
466
|
+
"awaiting_approval",
|
|
467
|
+
"in_review",
|
|
468
|
+
"blocked",
|
|
469
|
+
"succeeded",
|
|
470
|
+
"cancelled",
|
|
471
|
+
];
|
|
472
|
+
const actions = [
|
|
473
|
+
"wait_for_dependencies",
|
|
474
|
+
"run",
|
|
475
|
+
"wait_for_run",
|
|
476
|
+
"resolve_approval",
|
|
477
|
+
"resume",
|
|
478
|
+
"retry",
|
|
479
|
+
"review",
|
|
480
|
+
"none",
|
|
481
|
+
];
|
|
482
|
+
if (!event.state ||
|
|
483
|
+
!states.includes(event.state) ||
|
|
484
|
+
!event.next_action ||
|
|
485
|
+
!actions.includes(event.next_action)) {
|
|
486
|
+
throw new Error(`work item ${workId} event ${seq} has invalid reconciliation data`);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
function validateWorkHistory(events, workId) {
|
|
491
|
+
if (events.length === 0)
|
|
492
|
+
return;
|
|
493
|
+
if (events[0]?.event !== "work.created") {
|
|
494
|
+
throw new Error(`work item ${workId} history must begin with work.created`);
|
|
495
|
+
}
|
|
496
|
+
if (events.filter((event) => event.event === "work.created").length !== 1) {
|
|
497
|
+
throw new Error(`work item ${workId} history has duplicate creation receipts`);
|
|
498
|
+
}
|
|
499
|
+
const attempts = new Map();
|
|
500
|
+
let attemptCount = 0;
|
|
501
|
+
for (const event of events) {
|
|
502
|
+
if (event.event === "attempt.started") {
|
|
503
|
+
attemptCount++;
|
|
504
|
+
if (event.attempt !== attemptCount || attempts.has(event.run_id)) {
|
|
505
|
+
throw new Error(`work item ${workId} has invalid attempt ordering`);
|
|
506
|
+
}
|
|
507
|
+
attempts.set(event.run_id, event.attempt);
|
|
508
|
+
}
|
|
509
|
+
if (event.event === "attempt.resumed" &&
|
|
510
|
+
attempts.get(event.run_id) !== event.attempt) {
|
|
511
|
+
throw new Error(`work item ${workId} resumes an unknown attempt`);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
/** @internal Runner seam; not a separate persistence contract. */
|
|
516
|
+
export function assertWorkflowUnchanged(intent) {
|
|
517
|
+
if (!existsSync(intent.workflow.path))
|
|
518
|
+
throw new Error(`work workflow no longer exists at ${intent.workflow.path}`);
|
|
519
|
+
if (workflowScriptDigest(intent.workflow.path) !== intent.workflow.sha256) {
|
|
520
|
+
throw new Error(`work workflow changed since ${intent.id} was created; create a new work item`);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
function assertNoDependencyCycle(coordRoot, id, dependencies) {
|
|
524
|
+
const visit = (candidate, seen) => {
|
|
525
|
+
if (candidate === id)
|
|
526
|
+
throw new Error(`work dependency cycle reaches ${id}`);
|
|
527
|
+
if (seen.has(candidate))
|
|
528
|
+
return;
|
|
529
|
+
seen.add(candidate);
|
|
530
|
+
for (const dependency of readWorkIntent(coordRoot, candidate).dependencies)
|
|
531
|
+
visit(dependency, seen);
|
|
532
|
+
};
|
|
533
|
+
for (const dependency of dependencies)
|
|
534
|
+
visit(dependency, new Set());
|
|
535
|
+
}
|
|
536
|
+
function workDir(coordRoot, workId) {
|
|
537
|
+
assertWorkId(workId);
|
|
538
|
+
return join(resolve(coordRoot), ".harnery", "work", workId);
|
|
539
|
+
}
|
|
540
|
+
/** @internal Runner seam; not a separate persistence contract. */
|
|
541
|
+
export function acquireWorkLease(coordRoot, workId) {
|
|
542
|
+
readWorkIntent(coordRoot, workId);
|
|
543
|
+
const path = join(workDir(coordRoot, workId), "lease.json");
|
|
544
|
+
const owner = {
|
|
545
|
+
pid: process.pid,
|
|
546
|
+
host: hostname(),
|
|
547
|
+
created_at: new Date().toISOString(),
|
|
548
|
+
nonce: randomUUID(),
|
|
549
|
+
};
|
|
550
|
+
const acquire = () => {
|
|
551
|
+
try {
|
|
552
|
+
const fd = openSync(path, "wx", 0o600);
|
|
553
|
+
try {
|
|
554
|
+
writeFileSync(fd, `${JSON.stringify(owner)}\n`, "utf8");
|
|
555
|
+
}
|
|
556
|
+
finally {
|
|
557
|
+
closeSync(fd);
|
|
558
|
+
}
|
|
559
|
+
chmodSync(path, 0o600);
|
|
560
|
+
return true;
|
|
561
|
+
}
|
|
562
|
+
catch (error) {
|
|
563
|
+
if (error.code !== "EEXIST")
|
|
564
|
+
throw error;
|
|
565
|
+
return false;
|
|
566
|
+
}
|
|
567
|
+
};
|
|
568
|
+
if (!acquire()) {
|
|
569
|
+
const existing = readLease(path);
|
|
570
|
+
if (existing && leaseIsLive(existing)) {
|
|
571
|
+
throw new Error(`work item ${workId} is already active under pid ${existing.pid} on ${existing.host}`);
|
|
572
|
+
}
|
|
573
|
+
unlinkSync(path);
|
|
574
|
+
if (!acquire())
|
|
575
|
+
throw new Error(`work item ${workId} lease raced with another process`);
|
|
576
|
+
}
|
|
577
|
+
return () => {
|
|
578
|
+
try {
|
|
579
|
+
const existing = readLease(path);
|
|
580
|
+
if (existing?.nonce === owner.nonce)
|
|
581
|
+
unlinkSync(path);
|
|
582
|
+
}
|
|
583
|
+
catch {
|
|
584
|
+
// A stale private lease is recoverable by the next mutation.
|
|
585
|
+
}
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
function workLeaseIsLive(coordRoot, workId) {
|
|
589
|
+
const lease = readLease(join(workDir(coordRoot, workId), "lease.json"));
|
|
590
|
+
return lease ? leaseIsLive(lease) : false;
|
|
591
|
+
}
|
|
592
|
+
function workflowResumeLeaseIsLive(coordRoot, runId) {
|
|
593
|
+
const lease = readLease(join(coordRoot, ".harnery", "workflows", runId, "resume.lock"));
|
|
594
|
+
return lease ? leaseIsLive(lease) : false;
|
|
595
|
+
}
|
|
596
|
+
function readLease(path) {
|
|
597
|
+
try {
|
|
598
|
+
const value = parseObject(readFileSync(path, "utf8"), "work lease");
|
|
599
|
+
return typeof value.pid === "number" &&
|
|
600
|
+
Number.isSafeInteger(value.pid) &&
|
|
601
|
+
value.pid > 0 &&
|
|
602
|
+
typeof value.host === "string" &&
|
|
603
|
+
typeof value.created_at === "string"
|
|
604
|
+
? {
|
|
605
|
+
pid: value.pid,
|
|
606
|
+
host: value.host,
|
|
607
|
+
created_at: value.created_at,
|
|
608
|
+
nonce: typeof value.nonce === "string" ? value.nonce : undefined,
|
|
609
|
+
}
|
|
610
|
+
: null;
|
|
611
|
+
}
|
|
612
|
+
catch {
|
|
613
|
+
return null;
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
function leaseIsLive(lease) {
|
|
617
|
+
if (lease.host !== hostname()) {
|
|
618
|
+
const age = Date.now() - Date.parse(lease.created_at);
|
|
619
|
+
return Number.isFinite(age) && age < FOREIGN_LEASE_STALE_MS;
|
|
620
|
+
}
|
|
621
|
+
try {
|
|
622
|
+
process.kill(lease.pid, 0);
|
|
623
|
+
return true;
|
|
624
|
+
}
|
|
625
|
+
catch (error) {
|
|
626
|
+
return error.code === "EPERM";
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
function writePrivateJson(path, value, maxBytes) {
|
|
630
|
+
const body = `${JSON.stringify(value, null, 2)}\n`;
|
|
631
|
+
if (Buffer.byteLength(body) > maxBytes)
|
|
632
|
+
throw new Error(`private record exceeds ${maxBytes} bytes`);
|
|
633
|
+
writeFileSync(path, body, { encoding: "utf8", flag: "wx", mode: 0o600 });
|
|
634
|
+
chmodSync(path, 0o600);
|
|
635
|
+
}
|
|
636
|
+
function readBoundedLines(path, maxBytes, field) {
|
|
637
|
+
const size = statSync(path).size;
|
|
638
|
+
if (size > maxBytes)
|
|
639
|
+
throw new Error(`${field} exceeds ${maxBytes} bytes`);
|
|
640
|
+
const body = readFileSync(path, "utf8");
|
|
641
|
+
if (body.length > 0 && !body.endsWith("\n"))
|
|
642
|
+
throw new Error(`${field} has a truncated final line`);
|
|
643
|
+
const lines = body.split("\n").filter(Boolean);
|
|
644
|
+
if (lines.length > MAX_EVENTS)
|
|
645
|
+
throw new Error(`${field} exceeds ${MAX_EVENTS} records`);
|
|
646
|
+
for (const line of lines) {
|
|
647
|
+
if (Buffer.byteLength(line) > MAX_EVENT_BYTES)
|
|
648
|
+
throw new Error(`${field} contains an oversized record`);
|
|
649
|
+
}
|
|
650
|
+
return lines;
|
|
651
|
+
}
|
|
652
|
+
function parseObject(input, field) {
|
|
653
|
+
try {
|
|
654
|
+
const value = JSON.parse(input);
|
|
655
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
656
|
+
throw new Error("expected object");
|
|
657
|
+
return value;
|
|
658
|
+
}
|
|
659
|
+
catch (error) {
|
|
660
|
+
throw new Error(`cannot parse ${field}: ${error.message}`);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
function normalizeSource(source) {
|
|
664
|
+
if (!source)
|
|
665
|
+
return undefined;
|
|
666
|
+
if (!(source.kind === "human" || source.kind === "workflow" || source.kind === "external")) {
|
|
667
|
+
throw new Error("work source kind must be human, workflow, or external");
|
|
668
|
+
}
|
|
669
|
+
return { kind: source.kind, ref: boundedOptional(source.ref, "work source ref", MAX_SOURCE_REF) };
|
|
670
|
+
}
|
|
671
|
+
function uniqueStrings(values, field, max) {
|
|
672
|
+
if (!Array.isArray(values) || values.length > max)
|
|
673
|
+
throw new Error(`${field} must contain at most ${max} entries`);
|
|
674
|
+
const result = values.map((value, index) => boundedString(value, `${field}[${index}]`, 100));
|
|
675
|
+
if (new Set(result).size !== result.length)
|
|
676
|
+
throw new Error(`${field} contains duplicates`);
|
|
677
|
+
return result;
|
|
678
|
+
}
|
|
679
|
+
/** @internal Runner seam; not a separate persistence contract. */
|
|
680
|
+
export function boundedActor(value, fallback = "operator") {
|
|
681
|
+
return boundedString(value ?? process.env.USER ?? fallback, "work actor", MAX_ACTOR);
|
|
682
|
+
}
|
|
683
|
+
function boundedString(value, field, max) {
|
|
684
|
+
if (typeof value !== "string")
|
|
685
|
+
throw new Error(`${field} must be a string`);
|
|
686
|
+
const normalized = value.trim();
|
|
687
|
+
if (!normalized)
|
|
688
|
+
throw new Error(`${field} must not be empty`);
|
|
689
|
+
if (normalized.length > max)
|
|
690
|
+
throw new Error(`${field} exceeds ${max} characters`);
|
|
691
|
+
return normalized;
|
|
692
|
+
}
|
|
693
|
+
function boundedOptional(value, field, max) {
|
|
694
|
+
return value === undefined ? undefined : boundedString(value, field, max);
|
|
695
|
+
}
|
|
696
|
+
function validTimestamp(value) {
|
|
697
|
+
return typeof value === "string" && Number.isFinite(Date.parse(value));
|
|
698
|
+
}
|