@kylecheng3146/agent-ops 0.5.2 → 0.6.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 +64 -0
- package/dist/packages/cli/src/args.js +214 -10
- package/dist/packages/cli/src/bin.js +77 -17
- package/dist/packages/cli/src/cli.js +42 -10
- package/dist/packages/cli/src/commands/advance.js +173 -0
- package/dist/packages/cli/src/commands/review-show.js +189 -0
- package/dist/packages/cli/src/commands/review.js +201 -34
- package/dist/packages/cli/src/commands/run.js +111 -0
- package/dist/packages/cli/src/commands/task.js +62 -17
- package/dist/packages/cli/src/commands/verify.js +1 -0
- package/dist/packages/cli/src/context.js +63 -1
- package/dist/packages/cli/src/hook-process.js +31 -1
- package/dist/packages/cli/src/owned-run-step.js +65 -0
- package/dist/packages/cli/src/parallel-deps.js +18 -5
- package/dist/packages/cli/src/run-deps.js +314 -0
- package/dist/packages/cli/src/run-entry.js +943 -0
- package/dist/packages/cli/src/run-setup-entry.js +32 -0
- package/dist/packages/cli/src/run-step-entry.js +58 -0
- package/dist/runtime/src/config/explain.js +6 -0
- package/dist/runtime/src/config/hash.js +1 -1
- package/dist/runtime/src/config/merge.js +9 -1
- package/dist/runtime/src/contracts.js +2 -2
- package/dist/runtime/src/hooks/completion-gate.js +8 -2
- package/dist/runtime/src/install/doctor.js +2 -0
- package/dist/runtime/src/install/harness.js +1 -1
- package/dist/runtime/src/install/preauth.js +10 -1
- package/dist/runtime/src/parallel/finish.js +217 -39
- package/dist/runtime/src/parallel/guard.js +5 -1
- package/dist/runtime/src/parallel/integrate.js +416 -0
- package/dist/runtime/src/parallel/manage.js +4 -1
- package/dist/runtime/src/parallel/receipt.js +153 -0
- package/dist/runtime/src/parallel/service.js +45 -3
- package/dist/runtime/src/review/attestation.js +60 -11
- package/dist/runtime/src/review/execute.js +24 -1
- package/dist/runtime/src/review/packet.js +35 -1
- package/dist/runtime/src/review/render.js +71 -0
- package/dist/runtime/src/review/runner.js +12 -1
- package/dist/runtime/src/review/scope.js +18 -1
- package/dist/runtime/src/review/slots.js +35 -10
- package/dist/runtime/src/run/authorization.js +60 -0
- package/dist/runtime/src/run/budget.js +30 -0
- package/dist/runtime/src/run/controls.js +139 -0
- package/dist/runtime/src/run/convergence.js +177 -0
- package/dist/runtime/src/run/hosts/claude.js +180 -0
- package/dist/runtime/src/run/hosts/codex.js +279 -0
- package/dist/runtime/src/run/hosts/index.js +4 -0
- package/dist/runtime/src/run/hosts/protocol.js +254 -0
- package/dist/runtime/src/run/hosts/types.js +1 -0
- package/dist/runtime/src/run/hosts/util.js +136 -0
- package/dist/runtime/src/run/integration.js +204 -0
- package/dist/runtime/src/run/macOS.js +189 -0
- package/dist/runtime/src/run/ownership.js +22 -0
- package/dist/runtime/src/run/planning.js +25 -0
- package/dist/runtime/src/run/policy-transition.js +1076 -0
- package/dist/runtime/src/run/policy.js +176 -0
- package/dist/runtime/src/run/ratchet.js +71 -0
- package/dist/runtime/src/run/scheduler.js +260 -0
- package/dist/runtime/src/run/service.js +425 -0
- package/dist/runtime/src/run/supervisor.js +635 -0
- package/dist/runtime/src/run/transport.js +159 -0
- package/dist/runtime/src/run/types.js +1 -0
- package/dist/runtime/src/run/usage.js +81 -0
- package/dist/runtime/src/run/verification.js +64 -0
- package/dist/runtime/src/run/worker-gate.js +48 -0
- package/dist/runtime/src/schema/acceptance.js +91 -0
- package/dist/runtime/src/schema/validate.js +72 -7
- package/dist/runtime/src/task/completion.js +26 -6
- package/dist/runtime/src/task/contract.js +17 -0
- package/dist/runtime/src/task/pin-finding.js +81 -0
- package/dist/runtime/src/task/render.js +3 -0
- package/dist/runtime/src/task/service.js +361 -10
- package/dist/runtime/src/task/store.js +57 -7
- package/dist/runtime/src/verify/acceptance-coverage.js +85 -0
- package/dist/runtime/src/verify/acceptance-protocol.js +252 -0
- package/dist/runtime/src/verify/acceptance-replay.js +239 -0
- package/dist/runtime/src/verify/adapters/index.js +5 -0
- package/dist/runtime/src/verify/adapters/jest.js +142 -0
- package/dist/runtime/src/verify/adapters/node-reporter.js +20 -0
- package/dist/runtime/src/verify/adapters/node.js +254 -0
- package/dist/runtime/src/verify/adapters/pytest.js +188 -0
- package/dist/runtime/src/verify/adapters/rust.js +84 -0
- package/dist/runtime/src/verify/adapters/shared.js +82 -0
- package/dist/runtime/src/verify/adapters/vitest.js +218 -0
- package/dist/runtime/src/verify/evidence.js +5 -2
- package/dist/runtime/src/verify/service.js +24 -8
- package/dist/runtime/src/verify/source-fingerprint.js +1 -1
- package/dist/runtime/src/verify/spawn.js +13 -2
- package/docs/en/guides/configuration.md +10 -0
- package/docs/en/spec/acceptance-and-evidence.md +58 -0
- package/docs/en/spec/delegation.md +4 -4
- package/docs/en/spec/loop-engineering.md +49 -0
- package/docs/en/spec/review.md +24 -2
- package/docs/zh-TW/guides/configuration.md +9 -0
- package/docs/zh-TW/spec/acceptance-and-evidence.md +31 -1
- package/docs/zh-TW/spec/delegation.md +4 -4
- package/docs/zh-TW/spec/loop-engineering.md +22 -0
- package/docs/zh-TW/spec/review.md +24 -2
- package/package.json +1 -1
- package/schemas/config.schema.json +221 -19
- package/schemas/evidence.schema.json +129 -6
- package/schemas/task.schema.json +268 -6
- package/templates/acceptance/agent_ops_acceptance.rs +36 -0
- package/templates/acceptance/agent_ops_pytest.py +46 -0
|
@@ -1,13 +1,19 @@
|
|
|
1
|
+
import { canonicalJson } from "../config/hash.js";
|
|
1
2
|
import { randomUUID } from "node:crypto";
|
|
2
3
|
import { TASK_SCHEMA_VERSION } from "../contracts.js";
|
|
3
4
|
import { AgentOpsError } from "../fs/paths.js";
|
|
4
|
-
import { validateEvidence, validateTask } from "../schema/validate.js";
|
|
5
|
+
import { validateEvidence, validateTask, validateTaskAgainstConfig } from "../schema/validate.js";
|
|
5
6
|
import { renderTaskMarkdown } from "./render.js";
|
|
6
7
|
import { checkTaskCompletionEvidence, findIncompleteSubtask } from "./completion.js";
|
|
7
8
|
import { calculateConfigHash, FileEvidenceStore, isPassingVerificationEvidence } from "../verify/evidence.js";
|
|
8
9
|
import { resolveReviewScope } from "../review/scope.js";
|
|
9
10
|
import { calculateSourceFingerprint } from "../verify/source-fingerprint.js";
|
|
11
|
+
import { acceptanceCoverage } from "../verify/acceptance-coverage.js";
|
|
12
|
+
import { loadFindingPin } from "./pin-finding.js";
|
|
13
|
+
import { findReviewAttestation } from "../review/attestation.js";
|
|
10
14
|
import { advanceFailureFingerprint } from "../verify/fingerprint.js";
|
|
15
|
+
import { taskContractHash, treeContractHash } from "./contract.js";
|
|
16
|
+
import { commitIdentity } from "../schema/acceptance.js";
|
|
11
17
|
const SESSION_ID_PATTERN = /^[^\0\r\n]{1,256}$/u;
|
|
12
18
|
function defaultTaskId() {
|
|
13
19
|
return `task-${randomUUID()}`;
|
|
@@ -86,11 +92,25 @@ export class TaskService {
|
|
|
86
92
|
if (input.sessionId !== undefined) {
|
|
87
93
|
assertSessionId(input.sessionId);
|
|
88
94
|
}
|
|
95
|
+
let createdSourceCommit = input.createdSourceCommit;
|
|
96
|
+
if (createdSourceCommit === undefined && this.#completion !== undefined) {
|
|
97
|
+
const head = await this.#completion.gitRunner.run(["rev-parse", "--verify", "HEAD"]);
|
|
98
|
+
const resolved = head.stdout.toString().trim();
|
|
99
|
+
if (head.exitCode === 0 && commitIdentity(resolved))
|
|
100
|
+
createdSourceCommit = resolved;
|
|
101
|
+
}
|
|
102
|
+
if (createdSourceCommit !== undefined && !commitIdentity(createdSourceCommit))
|
|
103
|
+
throw taskError("TASK_BASELINE_INVALID", "Task creation commit must be a resolved SHA.");
|
|
104
|
+
const criteria = structuredClone(input.criteria).map(criterion => ({ ...criterion,
|
|
105
|
+
...(criterion.acceptance === undefined ? {} : { acceptance: { ...criterion.acceptance,
|
|
106
|
+
baselineCommit: criterion.acceptance.baselineCommit ?? createdSourceCommit } }) }));
|
|
89
107
|
const task = {
|
|
90
108
|
schemaVersion: TASK_SCHEMA_VERSION,
|
|
91
109
|
id: this.#generateId(),
|
|
92
110
|
title: input.title,
|
|
93
|
-
|
|
111
|
+
...(input.intent === undefined ? {} : { intent: input.intent }),
|
|
112
|
+
criteria,
|
|
113
|
+
...(input.goal === undefined ? {} : { goal: input.goal }),
|
|
94
114
|
...(input.parentTaskId === undefined
|
|
95
115
|
? {}
|
|
96
116
|
: { parentTaskId: input.parentTaskId })
|
|
@@ -99,6 +119,16 @@ export class TaskService {
|
|
|
99
119
|
if (!validation.ok) {
|
|
100
120
|
throw taskError("TASK_INVALID", validation.errors[0]?.message ?? "Task input is invalid.");
|
|
101
121
|
}
|
|
122
|
+
if (validation.value.criteria.some(c => c.finding !== undefined))
|
|
123
|
+
throw taskError("TASK_PIN_REQUIRED", "New finding identities must be validated with pin-finding against a saved failed review.");
|
|
124
|
+
if (validation.value.criteria.some(c => c.acceptance !== undefined)) {
|
|
125
|
+
if (this.#completion === undefined)
|
|
126
|
+
throw taskError("TASK_BASELINE_CONTEXT_REQUIRED", "Typed acceptance requires repository context.");
|
|
127
|
+
const configured = validateTaskAgainstConfig(validation.value, await this.#completion.loadConfig());
|
|
128
|
+
if (!configured.ok)
|
|
129
|
+
throw taskError("TASK_INVALID", configured.errors[0]?.message ?? "Invalid runner binding.");
|
|
130
|
+
await this.#retainBaselines(validation.value);
|
|
131
|
+
}
|
|
102
132
|
const now = assertTimestamp(this.#now());
|
|
103
133
|
return await this.#store.mutate((state) => {
|
|
104
134
|
if (state.tasks.some((record) => record.task.id === validation.value.id)) {
|
|
@@ -114,9 +144,15 @@ export class TaskService {
|
|
|
114
144
|
if (parent.status !== "active") {
|
|
115
145
|
throw taskError("TASK_PARENT_NOT_ACTIVE", "Only an active task can take new subtasks.");
|
|
116
146
|
}
|
|
147
|
+
if (parent.task.goal !== undefined) {
|
|
148
|
+
if (validation.value.goal !== undefined && validation.value.goal !== parent.task.goal)
|
|
149
|
+
throw taskError("TASK_GOAL_IMMUTABLE", "Subtasks must retain their parent original goal.");
|
|
150
|
+
validation.value.goal = parent.task.goal;
|
|
151
|
+
}
|
|
117
152
|
}
|
|
118
153
|
const record = {
|
|
119
154
|
task: validation.value,
|
|
155
|
+
...(createdSourceCommit === undefined ? {} : { createdSourceCommit }),
|
|
120
156
|
status: "active",
|
|
121
157
|
evidence: {},
|
|
122
158
|
createdAt: now,
|
|
@@ -153,6 +189,230 @@ export class TaskService {
|
|
|
153
189
|
return cloneRecord(record);
|
|
154
190
|
});
|
|
155
191
|
}
|
|
192
|
+
async #retainBaselines(task) {
|
|
193
|
+
if (this.#completion === undefined)
|
|
194
|
+
throw taskError("TASK_BASELINE_CONTEXT_REQUIRED", "Baseline retention needs Git context.");
|
|
195
|
+
for (const criterion of task.criteria) {
|
|
196
|
+
const sha = criterion.acceptance?.baselineCommit;
|
|
197
|
+
if (sha === undefined)
|
|
198
|
+
continue;
|
|
199
|
+
const result = await this.#completion.gitRunner.run(["rev-parse", "--verify", sha + "^{commit}"]);
|
|
200
|
+
if (result.exitCode !== 0 || result.stdout.toString().trim() !== sha)
|
|
201
|
+
throw taskError("BASELINE_UNAVAILABLE", "Criterion baseline is not the exact available commit.");
|
|
202
|
+
const retained = await this.#completion.gitRunner.run(["update-ref", "refs/agent-ops/baselines/" + task.id + "/" + criterion.id + "/" + sha, sha]);
|
|
203
|
+
if (retained.exitCode !== 0)
|
|
204
|
+
throw taskError("BASELINE_RETENTION_FAILED", "Could not retain criterion baseline.");
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
async revise(taskId, input) {
|
|
208
|
+
if (!/^[a-f0-9]{64}$/u.test(input.expectedContractHash) || input.reason.trim() === "" || input.reason.length > 8192 || input.reason.includes("\0") ||
|
|
209
|
+
(input.diagnostics ?? []).some(d => d.length > 4096 || d.includes("\0")))
|
|
210
|
+
throw taskError("TASK_REVISION_INVALID", "Revision requires expected contract and bounded reason/diagnostics.");
|
|
211
|
+
const previous = await this.status({ taskId });
|
|
212
|
+
if (previous.status !== "active")
|
|
213
|
+
throw taskError("TASK_NOT_ACTIVE", "Only active tasks can revise acceptance.");
|
|
214
|
+
if (taskContractHash(previous.task) !== input.expectedContractHash)
|
|
215
|
+
throw taskError("TASK_CONTRACT_CHANGED", "Task contract changed.");
|
|
216
|
+
const baseline = input.baseline ?? previous.createdSourceCommit;
|
|
217
|
+
const criteria = structuredClone(input.criteria).map(criterion => {
|
|
218
|
+
if (criterion.acceptance === undefined)
|
|
219
|
+
return criterion;
|
|
220
|
+
const sha = criterion.acceptance.baselineCommit ?? baseline;
|
|
221
|
+
if (sha === undefined)
|
|
222
|
+
throw taskError("BASELINE_REQUIRED", "Legacy tasks need an explicit baseline before typed acceptance.");
|
|
223
|
+
const old = previous.task.criteria.find(c => c.id === criterion.id)?.acceptance;
|
|
224
|
+
if (old !== undefined && sha !== old.baselineCommit)
|
|
225
|
+
throw taskError("BASELINE_IMMUTABLE", "Create a replacement criterion instead of changing its baseline.");
|
|
226
|
+
return { ...criterion, acceptance: { ...criterion.acceptance, baselineCommit: sha } };
|
|
227
|
+
});
|
|
228
|
+
const next = { ...previous.task, schemaVersion: TASK_SCHEMA_VERSION,
|
|
229
|
+
contractRevision: (previous.task.contractRevision ?? 0) + 1, criteria };
|
|
230
|
+
const checked = validateTask(next);
|
|
231
|
+
if (!checked.ok)
|
|
232
|
+
throw taskError("TASK_INVALID", checked.errors[0]?.message ?? "Invalid criterion revision.");
|
|
233
|
+
const records = (await this.#store.read()).tasks;
|
|
234
|
+
for (const criterion of next.criteria)
|
|
235
|
+
await this.#validateFindingSource(previous, criterion, records);
|
|
236
|
+
let policyConfigHash = previous.policyConfigHash;
|
|
237
|
+
if (this.#completion !== undefined) {
|
|
238
|
+
const config = await this.#completion.loadConfig();
|
|
239
|
+
const configured = validateTaskAgainstConfig(next, config);
|
|
240
|
+
if (!configured.ok)
|
|
241
|
+
throw taskError("TASK_INVALID", configured.errors[0]?.message ?? "Invalid binding.");
|
|
242
|
+
policyConfigHash = calculateConfigHash(config);
|
|
243
|
+
}
|
|
244
|
+
if (criteria.some(c => c.acceptance !== undefined))
|
|
245
|
+
await this.#retainBaselines(next);
|
|
246
|
+
const now = assertTimestamp(this.#now());
|
|
247
|
+
return await this.#store.mutate(state => {
|
|
248
|
+
const current = findTask(state, taskId);
|
|
249
|
+
if (current.status !== "active" || JSON.stringify(current) !== JSON.stringify(previous))
|
|
250
|
+
throw taskError("TASK_CONTRACT_CHANGED", "Concurrent revision preserved; retry from current task.");
|
|
251
|
+
const result = { ...current, task: next, evidence: {}, failureFingerprint: null,
|
|
252
|
+
policyConfigHash, completionBase: null, updatedAt: now,
|
|
253
|
+
revisions: [...(current.revisions ?? []), { previousTask: current.task, previousEvidence: current.evidence,
|
|
254
|
+
previousFailure: current.failureFingerprint, previousHash: input.expectedContractHash,
|
|
255
|
+
currentHash: taskContractHash(next), reason: input.reason, diagnostics: [...(input.diagnostics ?? [])], at: now }] };
|
|
256
|
+
const { reviewBase: _reviewBase, noChangePaths: _paths, ...withoutReview } = result;
|
|
257
|
+
replaceTask(state, withoutReview);
|
|
258
|
+
return cloneRecord(withoutReview);
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
async replan(taskId, input) {
|
|
262
|
+
if (input.reason.trim() === "" || input.reason.includes("\0") || input.reason.length > 8192 ||
|
|
263
|
+
input.tasks.length === 0 || input.tasks.length > 128)
|
|
264
|
+
throw taskError("TASK_REPLAN_INVALID", "Replan requires bounded children and a reason.");
|
|
265
|
+
const snapshot = await this.#store.read();
|
|
266
|
+
const root = findTask(snapshot, taskId);
|
|
267
|
+
if (root.status !== "active")
|
|
268
|
+
throw taskError("TASK_NOT_ACTIVE", "Only active roots can be replanned.");
|
|
269
|
+
const ids = new Set([taskId]);
|
|
270
|
+
for (let n = 0; n !== ids.size;) {
|
|
271
|
+
n = ids.size;
|
|
272
|
+
for (const r of snapshot.tasks)
|
|
273
|
+
if (r.supersededBy === undefined && r.task.parentTaskId !== undefined && ids.has(r.task.parentTaskId))
|
|
274
|
+
ids.add(r.task.id);
|
|
275
|
+
}
|
|
276
|
+
const previous = snapshot.tasks.filter(r => ids.has(r.task.id) && r.supersededBy === undefined);
|
|
277
|
+
if (treeContractHash(previous.map(r => r.task)) !== input.expectedTreeContractHash)
|
|
278
|
+
throw taskError("TASK_CONTRACT_CHANGED", "Task tree changed.");
|
|
279
|
+
if (previous.some(r => r.status !== "active"))
|
|
280
|
+
throw taskError("TASK_REPLAN_INVALID", "Replan cannot replace completed or archived contracts.");
|
|
281
|
+
if (input.tasks.reduce((count, t) => count + t.replaces.length, 0) > 512)
|
|
282
|
+
throw taskError("TASK_REPLAN_MAPPING_INVALID", "Replan provenance exceeds its bounded history limit.");
|
|
283
|
+
const sources = new Map(previous.flatMap(r => r.task.criteria.map(c => [r.task.id + ":" + c.id, c])));
|
|
284
|
+
const covered = new Set();
|
|
285
|
+
const now = assertTimestamp(this.#now());
|
|
286
|
+
const children = [];
|
|
287
|
+
const config = this.#completion === undefined ? undefined : await this.#completion.loadConfig();
|
|
288
|
+
for (const planned of input.tasks) {
|
|
289
|
+
if (planned.replaces.length === 0 || new Set(planned.replaces).size !== planned.replaces.length ||
|
|
290
|
+
planned.replaces.some(ref => !sources.has(ref)))
|
|
291
|
+
throw taskError("TASK_REPLAN_MAPPING_INVALID", "Every child must reference existing criterion identities.");
|
|
292
|
+
for (const ref of planned.replaces)
|
|
293
|
+
covered.add(ref);
|
|
294
|
+
for (const c of planned.criteria) {
|
|
295
|
+
const originals = planned.replaces.map(ref => sources.get(ref)).filter(old => old.id === c.id);
|
|
296
|
+
if (originals.some(original => original.acceptance !== undefined && c.acceptance?.baselineCommit !== original.acceptance.baselineCommit))
|
|
297
|
+
throw taskError("BASELINE_IMMUTABLE", "Replan must preserve criterion baselines.");
|
|
298
|
+
}
|
|
299
|
+
for (const criterion of planned.criteria) {
|
|
300
|
+
if (criterion.finding === undefined)
|
|
301
|
+
continue;
|
|
302
|
+
const inherited = planned.replaces.map(ref => sources.get(ref)).find(c => c.finding?.pinId === criterion.finding.pinId);
|
|
303
|
+
if (inherited !== undefined) {
|
|
304
|
+
if (canonicalJson(inherited.finding) !== canonicalJson(criterion.finding))
|
|
305
|
+
throw taskError("TASK_PIN_IMMUTABLE", "Replan must retain immutable finding identity.");
|
|
306
|
+
}
|
|
307
|
+
else {
|
|
308
|
+
let authorized = false;
|
|
309
|
+
for (const record of previous.filter(r => planned.replaces.some(ref => ref.startsWith(r.task.id + ":")))) {
|
|
310
|
+
try {
|
|
311
|
+
await this.#validateFindingSource(record, criterion, snapshot.tasks);
|
|
312
|
+
authorized = true;
|
|
313
|
+
break;
|
|
314
|
+
}
|
|
315
|
+
catch { }
|
|
316
|
+
}
|
|
317
|
+
if (!authorized)
|
|
318
|
+
throw taskError("TASK_PIN_REQUIRED", "New replan pins require a saved failed report bound to the replaced contracts.");
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
for (const ref of planned.replaces) {
|
|
322
|
+
const pin = sources.get(ref).finding?.pinId;
|
|
323
|
+
if (pin !== undefined && !planned.criteria.some(c => c.finding?.pinId === pin))
|
|
324
|
+
throw taskError("TASK_REPLAN_PIN_REQUIRED", "Split contracts must preserve mapped regression pin identities.");
|
|
325
|
+
}
|
|
326
|
+
const task = { schemaVersion: TASK_SCHEMA_VERSION, id: planned.id ?? this.#generateId(),
|
|
327
|
+
title: planned.title, intent: planned.intent, goal: root.task.goal ?? root.task.intent ?? root.task.title,
|
|
328
|
+
criteria: structuredClone(planned.criteria), parentTaskId: taskId };
|
|
329
|
+
const checked = config === undefined ? validateTask(task) : validateTaskAgainstConfig(task, config);
|
|
330
|
+
if (!checked.ok)
|
|
331
|
+
throw taskError("TASK_INVALID", checked.errors[0]?.message ?? "Invalid split task.");
|
|
332
|
+
if (task.criteria.some(c => c.acceptance !== undefined))
|
|
333
|
+
await this.#retainBaselines(task);
|
|
334
|
+
children.push({ task, status: "active", evidence: {}, createdAt: now, updatedAt: now,
|
|
335
|
+
completedAt: null, archivedAt: null, failureFingerprint: null, completionBase: null,
|
|
336
|
+
policyConfigHash: config === undefined ? root.policyConfigHash : calculateConfigHash(config),
|
|
337
|
+
...(root.createdSourceCommit === undefined ? {} : { createdSourceCommit: root.createdSourceCommit }) });
|
|
338
|
+
}
|
|
339
|
+
if ([...sources.keys()].some(ref => !covered.has(ref)))
|
|
340
|
+
throw taskError("TASK_REPLAN_COVERAGE_REQUIRED", "Split must account for every previous criterion.");
|
|
341
|
+
if (new Set(children.map(c => c.task.id)).size !== children.length || children.some(c => snapshot.tasks.some(r => r.task.id === c.task.id)))
|
|
342
|
+
throw taskError("TASK_ID_CONFLICT", "Split task IDs must be new and unique.");
|
|
343
|
+
return await this.#store.mutate(state => {
|
|
344
|
+
if (JSON.stringify(state.tasks) !== JSON.stringify(snapshot.tasks))
|
|
345
|
+
throw taskError("TASK_CONTRACT_CHANGED", "Concurrent tree change preserved.");
|
|
346
|
+
const updated = { ...root.task, schemaVersion: TASK_SCHEMA_VERSION, contractRevision: (root.task.contractRevision ?? 0) + 1 };
|
|
347
|
+
const newRoot = { ...root, task: updated, evidence: {}, failureFingerprint: null, updatedAt: now, completionBase: null,
|
|
348
|
+
revisions: [...(root.revisions ?? []), { previousTask: root.task, previousEvidence: root.evidence, previousFailure: root.failureFingerprint,
|
|
349
|
+
previousHash: taskContractHash(root.task), currentHash: taskContractHash(updated), reason: input.reason, at: now,
|
|
350
|
+
diagnostics: input.tasks.flatMap((t, i) => t.replaces.map(ref => ref + " -> " + children[i].task.id)) }] };
|
|
351
|
+
const { reviewBase: _base, noChangePaths: _paths, ...clearedRoot } = newRoot;
|
|
352
|
+
replaceTask(state, clearedRoot);
|
|
353
|
+
for (const old of previous.filter(r => r.task.id !== taskId)) {
|
|
354
|
+
const successors = children.filter((_child, i) => input.tasks[i].replaces.some(ref => ref.startsWith(old.task.id + ":"))).map(c => c.task.id);
|
|
355
|
+
replaceTask(state, { ...old, status: "archived", archivedAt: now, updatedAt: now, supersededBy: successors });
|
|
356
|
+
}
|
|
357
|
+
state.tasks.push(...children);
|
|
358
|
+
for (let i = 0; i < state.sessions.length; i++) {
|
|
359
|
+
const attachment = state.sessions[i];
|
|
360
|
+
if (ids.has(attachment.taskId))
|
|
361
|
+
state.sessions[i] = { ...attachment, taskId, attachedAt: now };
|
|
362
|
+
}
|
|
363
|
+
return children.map(cloneRecord);
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
/** Import a child worktree's intent and criteria, never its source-bound proof. */
|
|
367
|
+
async importDelivery(parentTaskId, delivered) {
|
|
368
|
+
if (delivered.length === 0 || delivered.some(({ status, task }) => (status === "archived" && delivered.find(r => r.task.id === task.id)?.supersededBy === undefined) || task.intent === undefined || task.intent.trim() === "")) {
|
|
369
|
+
throw taskError("TASK_DELIVERY_INVALID", "Every delivered task needs a recorded intent and must not be archived.");
|
|
370
|
+
}
|
|
371
|
+
await this.#store.mutate((state) => {
|
|
372
|
+
const parent = findTask(state, parentTaskId);
|
|
373
|
+
if (parent.status !== "active") {
|
|
374
|
+
throw taskError("TASK_PARENT_NOT_ACTIVE", "Only an active parent can accept delivery.");
|
|
375
|
+
}
|
|
376
|
+
const ids = new Set(delivered.map(({ task }) => task.id));
|
|
377
|
+
if (ids.size !== delivered.length) {
|
|
378
|
+
throw taskError("TASK_DELIVERY_INVALID", "Delivered task IDs must be unique.");
|
|
379
|
+
}
|
|
380
|
+
for (const record of delivered) {
|
|
381
|
+
const task = {
|
|
382
|
+
...record.task,
|
|
383
|
+
...(record.task.parentTaskId === undefined ? { parentTaskId } : {})
|
|
384
|
+
};
|
|
385
|
+
if (task.parentTaskId !== parentTaskId && !ids.has(task.parentTaskId)) {
|
|
386
|
+
throw taskError("TASK_DELIVERY_INVALID", `Task ${task.id} has a parent outside this delivery.`);
|
|
387
|
+
}
|
|
388
|
+
if (record.policyConfigHash !== parent.policyConfigHash || !validateTask(task).ok) {
|
|
389
|
+
throw taskError("TASK_DELIVERY_INVALID", `Task ${task.id} has an incompatible policy or definition.`);
|
|
390
|
+
}
|
|
391
|
+
const existing = state.tasks.find((item) => item.task.id === task.id);
|
|
392
|
+
if (existing !== undefined) {
|
|
393
|
+
if (JSON.stringify(existing.task) !== JSON.stringify(task)) {
|
|
394
|
+
throw taskError("TASK_ID_CONFLICT", `Task ${task.id} conflicts with an existing task.`);
|
|
395
|
+
}
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
state.tasks.push({
|
|
399
|
+
task,
|
|
400
|
+
...(record.createdSourceCommit === undefined ? {} : { createdSourceCommit: record.createdSourceCommit }),
|
|
401
|
+
...(record.revisions === undefined ? {} : { revisions: structuredClone(record.revisions) }),
|
|
402
|
+
...(record.supersededBy === undefined ? {} : { supersededBy: [...record.supersededBy] }),
|
|
403
|
+
status: record.supersededBy === undefined ? "active" : "archived",
|
|
404
|
+
evidence: {},
|
|
405
|
+
createdAt: record.createdAt,
|
|
406
|
+
updatedAt: this.#now(),
|
|
407
|
+
completedAt: null,
|
|
408
|
+
archivedAt: record.supersededBy === undefined ? null : record.archivedAt,
|
|
409
|
+
failureFingerprint: null,
|
|
410
|
+
policyConfigHash: record.policyConfigHash,
|
|
411
|
+
completionBase: null
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
});
|
|
415
|
+
}
|
|
156
416
|
async list(filter = {}) {
|
|
157
417
|
const state = await this.#store.read();
|
|
158
418
|
return state.tasks
|
|
@@ -251,7 +511,8 @@ export class TaskService {
|
|
|
251
511
|
const fingerprint = async () => {
|
|
252
512
|
try {
|
|
253
513
|
const scope = await resolveReviewScope({ root: completion.root, runner: completion.gitRunner,
|
|
254
|
-
...(completion.base === undefined ? {} : { base: completion.base })
|
|
514
|
+
...(completion.base === undefined ? {} : { base: completion.base }),
|
|
515
|
+
...((current.noChangePaths ?? completion.noChangePaths) === undefined ? {} : { noChangePaths: current.noChangePaths ?? completion.noChangePaths }) });
|
|
255
516
|
// Recorded so the completion gate can recompute this exact range once
|
|
256
517
|
// the work is committed and the worktree has nothing left to measure.
|
|
257
518
|
completionBase = scope.mode === "base" ? scope.resolvedBase : null;
|
|
@@ -317,7 +578,7 @@ export class TaskService {
|
|
|
317
578
|
* A PASS review's base: set records it, null clears it (the review covered
|
|
318
579
|
* uncommitted work), undefined leaves the record's value alone.
|
|
319
580
|
*/
|
|
320
|
-
reviewBase) {
|
|
581
|
+
reviewBase, noChangePaths) {
|
|
321
582
|
const now = assertTimestamp(this.#now());
|
|
322
583
|
return await this.#store.mutate((state) => {
|
|
323
584
|
const current = findTask(state, taskId);
|
|
@@ -347,6 +608,7 @@ export class TaskService {
|
|
|
347
608
|
...rest,
|
|
348
609
|
evidence,
|
|
349
610
|
updatedAt: now,
|
|
611
|
+
...(noChangePaths === undefined ? {} : { noChangePaths: [...noChangePaths] }),
|
|
350
612
|
...(base === undefined ? {} : { reviewBase: base })
|
|
351
613
|
};
|
|
352
614
|
replaceTask(state, updated);
|
|
@@ -374,12 +636,15 @@ export class TaskService {
|
|
|
374
636
|
const validation = validateEvidence(value);
|
|
375
637
|
return validation.ok && validation.value.taskId === snapshot.task.id &&
|
|
376
638
|
validation.value.criterionId === criterionId && validation.value.commandId === commandId &&
|
|
377
|
-
validation.value.configHash === configHash && validation.value.sourceFingerprint === sourceFingerprint
|
|
639
|
+
validation.value.configHash === configHash && validation.value.sourceFingerprint === sourceFingerprint &&
|
|
640
|
+
(!(snapshot.task.goal !== undefined || (snapshot.task.contractRevision ?? 0) > 0 || snapshot.task.criteria.some(c => c.acceptance !== undefined)) ||
|
|
641
|
+
validation.value.taskContractHash === taskContractHash(snapshot.task))
|
|
378
642
|
? validation.value : null;
|
|
379
643
|
};
|
|
380
644
|
for (const criterion of snapshot.task.criteria) {
|
|
381
|
-
const commands = config.verification.commands.filter(({ id, required }) => required &&
|
|
382
|
-
|
|
645
|
+
const commands = config.verification.commands.filter(({ id, required }) => required &&
|
|
646
|
+
(criterion.acceptance !== undefined || criterion.verifierIds.includes(id)));
|
|
647
|
+
if (commands.length === 0 && criterion.acceptance === undefined)
|
|
383
648
|
throw taskError("TASK_REVERIFICATION_CHANGED", "Completed task criteria lack required coverage.");
|
|
384
649
|
for (const command of commands) {
|
|
385
650
|
let original = false;
|
|
@@ -394,7 +659,7 @@ export class TaskService {
|
|
|
394
659
|
}
|
|
395
660
|
for (const reference of evidenceInput[criterion.id] ?? []) {
|
|
396
661
|
const evidence = matches(await store.load(reference), criterion.id, command.id);
|
|
397
|
-
if (evidence !== null && evidence.schemaVersion
|
|
662
|
+
if (evidence !== null && evidence.schemaVersion >= 3 &&
|
|
398
663
|
!snapshot.evidence[criterion.id]?.includes(reference) && isPassingVerificationEvidence(command, evidence))
|
|
399
664
|
fresh = true;
|
|
400
665
|
}
|
|
@@ -406,13 +671,25 @@ export class TaskService {
|
|
|
406
671
|
for (const [criterionId, references] of Object.entries(appended)) {
|
|
407
672
|
for (const reference of references) {
|
|
408
673
|
const validation = validateEvidence(await store.load(reference));
|
|
409
|
-
|
|
674
|
+
const criterion = snapshot.task.criteria.find(({ id }) => id === criterionId);
|
|
675
|
+
const permitted = validation.ok && criterion !== undefined && (criterion.acceptance === undefined
|
|
676
|
+
? criterion.verifierIds.includes(validation.value.commandId)
|
|
677
|
+
: validation.value.acceptance === undefined
|
|
678
|
+
? config.verification.commands.some(command => command.id === validation.value.commandId)
|
|
679
|
+
: criterion.acceptance.bindings.some(binding => binding.runnerId === validation.value.commandId));
|
|
680
|
+
if (!validation.ok || !permitted || matches(validation.value, criterionId, validation.value.commandId) === null) {
|
|
410
681
|
throw taskError("TASK_EVIDENCE_INVALID", "Fresh verification references must belong to this task, criterion, source and config.");
|
|
411
682
|
}
|
|
412
683
|
}
|
|
413
684
|
}
|
|
685
|
+
for (const record of [snapshot, { ...snapshot, evidence: appended }]) {
|
|
686
|
+
const rows = await acceptanceCoverage(record, config, sourceFingerprint, store);
|
|
687
|
+
if (rows.some(row => (row.mode === "behavioral" || row.mode === "invariant") && row.status !== "proven"))
|
|
688
|
+
throw taskError("TASK_REVERIFICATION_CHANGED", "Completed mechanical contracts require both original and fresh paired acceptance proof.");
|
|
689
|
+
}
|
|
414
690
|
const scope = await resolveReviewScope({ root: completion.root, runner: completion.gitRunner,
|
|
415
|
-
...(completion.base === undefined ? {} : { base: completion.base })
|
|
691
|
+
...(completion.base === undefined ? {} : { base: completion.base }),
|
|
692
|
+
...(snapshot.noChangePaths === undefined ? {} : { noChangePaths: snapshot.noChangePaths }) });
|
|
416
693
|
if (sourceFingerprint !== await calculateSourceFingerprint(completion.root, scope, completion.gitRunner) ||
|
|
417
694
|
configHash !== calculateConfigHash(await completion.loadConfig())) {
|
|
418
695
|
throw taskError("TASK_REVERIFICATION_CHANGED", "Source or config changed during reverification.");
|
|
@@ -480,4 +757,78 @@ export class TaskService {
|
|
|
480
757
|
});
|
|
481
758
|
});
|
|
482
759
|
}
|
|
760
|
+
async coverage(taskId) {
|
|
761
|
+
const record = await this.status({ taskId });
|
|
762
|
+
const originalMechanical = record.task.criteria.filter(c => {
|
|
763
|
+
const original = record.revisions?.map(r => r.previousTask.criteria.find(old => old.id === c.id)).find(Boolean) ?? c;
|
|
764
|
+
return original.acceptance?.mode === "behavioral" || original.acceptance?.mode === "invariant";
|
|
765
|
+
}).length;
|
|
766
|
+
const unavailable = () => ({ originalMechanical, reviewed: false, rows: record.task.criteria.map(c => ({ criterionId: c.id,
|
|
767
|
+
mode: c.acceptance?.mode ?? "legacy", status: "undischarged", evidenceReferences: [],
|
|
768
|
+
reason: "Current source-bound coverage is unavailable; verify and review the current contract." })) });
|
|
769
|
+
if (this.#completion === undefined)
|
|
770
|
+
return unavailable();
|
|
771
|
+
try {
|
|
772
|
+
const config = await this.#completion.loadConfig();
|
|
773
|
+
const base = this.#completion.base ?? record.reviewBase ?? record.completionBase ?? record.createdSourceCommit;
|
|
774
|
+
const scope = await resolveReviewScope({ root: this.#completion.root, runner: this.#completion.gitRunner,
|
|
775
|
+
...(base === undefined ? {} : { base }), ...(record.noChangePaths === undefined ? {} : { noChangePaths: record.noChangePaths }) });
|
|
776
|
+
const fingerprint = await calculateSourceFingerprint(this.#completion.root, scope, this.#completion.gitRunner);
|
|
777
|
+
const rows = await acceptanceCoverage(record, config, fingerprint, new FileEvidenceStore(this.#completion.root, this.#completion.root));
|
|
778
|
+
const attestation = await findReviewAttestation(this.#completion.root, fingerprint, taskId);
|
|
779
|
+
const reviewed = attestation !== null && (attestation.schemaVersion === 3 ? attestation.taskContracts?.[taskId] === taskContractHash(record.task) :
|
|
780
|
+
record.task.goal === undefined && (record.task.contractRevision ?? 0) === 0 && record.task.criteria.every(c => c.acceptance === undefined));
|
|
781
|
+
return { rows, reviewed, originalMechanical };
|
|
782
|
+
}
|
|
783
|
+
catch {
|
|
784
|
+
return unavailable();
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
async #validateFindingSource(record, criterion, records) {
|
|
788
|
+
const finding = criterion.finding;
|
|
789
|
+
if (finding === undefined)
|
|
790
|
+
return;
|
|
791
|
+
const existing = record.task.criteria.find(c => c.finding?.pinId === finding.pinId);
|
|
792
|
+
if (existing !== undefined) {
|
|
793
|
+
if (canonicalJson(existing.finding) !== canonicalJson(finding))
|
|
794
|
+
throw taskError("TASK_PIN_IMMUTABLE", "Finding source identity cannot change.");
|
|
795
|
+
return;
|
|
796
|
+
}
|
|
797
|
+
if (this.#completion === undefined)
|
|
798
|
+
throw taskError("TASK_PIN_REQUIRED", "New pins require a saved review source.");
|
|
799
|
+
const { finding: _finding, ...unbound } = criterion;
|
|
800
|
+
const resolved = await loadFindingPin(this.#completion.root, record, finding.reportDigest + ":" + finding.findingIndex, unbound, records);
|
|
801
|
+
if (canonicalJson(resolved.finding) !== canonicalJson(finding) || resolved.acceptance?.baselineCommit !== criterion.acceptance?.baselineCommit)
|
|
802
|
+
throw taskError("TASK_PIN_REQUIRED", "Pin identity and baseline must match the saved failed report.");
|
|
803
|
+
}
|
|
804
|
+
async pinFinding(taskId, expectedContractHash, reference, criterion, reason) {
|
|
805
|
+
if (this.#completion === undefined)
|
|
806
|
+
throw taskError("TASK_COMPLETION_UNAVAILABLE", "Pin requires a repository and saved review artifacts.");
|
|
807
|
+
const state = await this.#store.read();
|
|
808
|
+
const record = findTask(state, taskId);
|
|
809
|
+
if (record.status !== "active" || taskContractHash(record.task) !== expectedContractHash)
|
|
810
|
+
throw taskError("TASK_CONTRACT_CHANGED", "Pin requires the current active contract.");
|
|
811
|
+
const pinned = await loadFindingPin(this.#completion.root, record, reference, criterion, state.tasks);
|
|
812
|
+
const existing = record.task.criteria.find(c => c.finding?.pinId === pinned.finding?.pinId);
|
|
813
|
+
if (existing === undefined && record.task.criteria.length === 5)
|
|
814
|
+
return { record: null, pendingCriterion: pinned };
|
|
815
|
+
if (existing === undefined && record.task.criteria.some(c => c.id === pinned.id))
|
|
816
|
+
throw taskError("TASK_CRITERION_ID_CONFLICT", "A new pin requires a new criterion ID.");
|
|
817
|
+
const criteria = existing === undefined ? [...record.task.criteria, pinned] : record.task.criteria.map(c => c.id === existing.id ? pinned : c);
|
|
818
|
+
const updated = await this.revise(taskId, { expectedContractHash, criteria,
|
|
819
|
+
reason: reason ?? "Pin saved review finding " + reference + " against the immutable original goal.", diagnostics: ["finding:" + reference, "pin:" + pinned.finding.pinId] });
|
|
820
|
+
return { record: updated, pendingCriterion: pinned };
|
|
821
|
+
}
|
|
822
|
+
async treeContract(taskId) {
|
|
823
|
+
const state = await this.#store.read();
|
|
824
|
+
findTask(state, taskId);
|
|
825
|
+
const ids = new Set([taskId]);
|
|
826
|
+
for (let size = 0; size !== ids.size;) {
|
|
827
|
+
size = ids.size;
|
|
828
|
+
for (const record of state.tasks)
|
|
829
|
+
if (record.supersededBy === undefined && record.task.parentTaskId !== undefined && ids.has(record.task.parentTaskId))
|
|
830
|
+
ids.add(record.task.id);
|
|
831
|
+
}
|
|
832
|
+
return treeContractHash(state.tasks.filter(record => ids.has(record.task.id) && record.supersededBy === undefined).map(record => record.task));
|
|
833
|
+
}
|
|
483
834
|
}
|
|
@@ -3,6 +3,8 @@ import { TASK_SCHEMA_VERSION } from "../contracts.js";
|
|
|
3
3
|
import { AgentOpsError } from "../fs/paths.js";
|
|
4
4
|
import { validateTask } from "../schema/validate.js";
|
|
5
5
|
import { readPrivateFile, withPrivateFileLock, writePrivateFile } from "../security/permissions.js";
|
|
6
|
+
import { taskContractHash } from "./contract.js";
|
|
7
|
+
import { commitIdentity, acceptanceSourcePath } from "../schema/acceptance.js";
|
|
6
8
|
const DEFAULT_MAX_BYTES = 1024 * 1024;
|
|
7
9
|
const SESSION_ID_PATTERN = /^[^\0\r\n]{1,256}$/u;
|
|
8
10
|
function isMissing(error) {
|
|
@@ -66,12 +68,9 @@ function parseTaskRecord(value) {
|
|
|
66
68
|
];
|
|
67
69
|
// Every record written before an optional field existed must still parse, so
|
|
68
70
|
// the accepted shapes are every combination of them.
|
|
69
|
-
const optionalKeys = ["completionBase", "failureFingerprint", "policyConfigHash", "reviewBase"];
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
...optionalKeys.filter((_key, position) => (mask & (1 << position)) !== 0)
|
|
73
|
-
].sort().join("\0")));
|
|
74
|
-
if (!isRecord(value) || !allowedKeys.has(Object.keys(value).sort().join("\0"))) {
|
|
71
|
+
const optionalKeys = ["completionBase", "failureFingerprint", "policyConfigHash", "reviewBase", "createdSourceCommit", "revisions", "supersededBy", "noChangePaths"];
|
|
72
|
+
if (!isRecord(value) || !baseKeys.every(k => Object.hasOwn(value, k)) ||
|
|
73
|
+
Object.keys(value).some(k => !baseKeys.includes(k) && !optionalKeys.includes(k))) {
|
|
75
74
|
return invalidState("Task state contains an invalid task record.");
|
|
76
75
|
}
|
|
77
76
|
const task = validateTask(value.task);
|
|
@@ -150,8 +149,43 @@ function parseTaskRecord(value) {
|
|
|
150
149
|
(typeof value.reviewBase !== "string" || !/^[a-f0-9]{40,64}$/u.test(value.reviewBase))) {
|
|
151
150
|
return invalidState("Task state contains an invalid review base.");
|
|
152
151
|
}
|
|
152
|
+
if (value.noChangePaths !== undefined && (!Array.isArray(value.noChangePaths) || value.noChangePaths.length === 0 || value.noChangePaths.length > 128 ||
|
|
153
|
+
value.noChangePaths.some(p => typeof p !== "string" || !acceptanceSourcePath(p)) || new Set(value.noChangePaths).size !== value.noChangePaths.length))
|
|
154
|
+
return invalidState("Invalid no-change scope.");
|
|
155
|
+
if (value.supersededBy !== undefined && (!Array.isArray(value.supersededBy) || value.supersededBy.length === 0 || value.supersededBy.length > 128 ||
|
|
156
|
+
value.supersededBy.some(v => typeof v !== "string" || !/^[a-z][a-z0-9-]{0,127}$/u.test(v)) || new Set(value.supersededBy).size !== value.supersededBy.length || status !== "archived"))
|
|
157
|
+
return invalidState("Invalid supersession mapping.");
|
|
158
|
+
if (value.createdSourceCommit !== undefined && !commitIdentity(value.createdSourceCommit))
|
|
159
|
+
return invalidState("Invalid task creation commit.");
|
|
160
|
+
if (value.revisions !== undefined) {
|
|
161
|
+
if (!Array.isArray(value.revisions) || value.revisions.length > 1024)
|
|
162
|
+
return invalidState("Invalid revision history.");
|
|
163
|
+
let previousCurrent;
|
|
164
|
+
for (const revision of value.revisions) {
|
|
165
|
+
if (!isRecord(revision) || !hasExactKeys(revision, ["previousTask", "previousEvidence", "previousFailure", "previousHash", "currentHash", "reason", "at", "diagnostics"]) ||
|
|
166
|
+
typeof revision.reason !== "string" || revision.reason.trim() === "" || revision.reason.length > 8192 || revision.reason.includes("\0") ||
|
|
167
|
+
!isTimestamp(revision.at) || !Array.isArray(revision.diagnostics) || revision.diagnostics.length > 512 ||
|
|
168
|
+
revision.diagnostics.some(d => typeof d !== "string" || d.length > 4096 || d.includes("\0")) ||
|
|
169
|
+
typeof revision.currentHash !== "string" || !/^[a-f0-9]{64}$/u.test(revision.currentHash))
|
|
170
|
+
return invalidState("Invalid revision metadata.");
|
|
171
|
+
const before = validateTask(revision.previousTask);
|
|
172
|
+
if (!before.ok || before.value.id !== task.value.id || taskContractHash(before.value) !== revision.previousHash ||
|
|
173
|
+
(previousCurrent !== undefined && revision.previousHash !== previousCurrent))
|
|
174
|
+
return invalidState("Invalid revision chain.");
|
|
175
|
+
parseEvidence(revision.previousEvidence, before.value, false);
|
|
176
|
+
if (revision.previousFailure !== null && (!isRecord(revision.previousFailure) || typeof revision.previousFailure.value !== "string" || !/^[a-f0-9]{64}$/u.test(revision.previousFailure.value)))
|
|
177
|
+
return invalidState("Invalid revision failure provenance.");
|
|
178
|
+
previousCurrent = revision.currentHash;
|
|
179
|
+
}
|
|
180
|
+
if (previousCurrent !== undefined && previousCurrent !== taskContractHash(task.value))
|
|
181
|
+
return invalidState("Revision history does not match current contract.");
|
|
182
|
+
}
|
|
153
183
|
return {
|
|
154
184
|
task: task.value,
|
|
185
|
+
...(value.noChangePaths === undefined ? {} : { noChangePaths: value.noChangePaths }),
|
|
186
|
+
...(value.createdSourceCommit === undefined ? {} : { createdSourceCommit: value.createdSourceCommit }),
|
|
187
|
+
...(value.revisions === undefined ? {} : { revisions: value.revisions }),
|
|
188
|
+
...(value.supersededBy === undefined ? {} : { supersededBy: value.supersededBy }),
|
|
155
189
|
status,
|
|
156
190
|
evidence,
|
|
157
191
|
createdAt: value.createdAt,
|
|
@@ -204,7 +238,7 @@ function parseState(source) {
|
|
|
204
238
|
}
|
|
205
239
|
if (!isRecord(value) ||
|
|
206
240
|
!hasExactKeys(value, ["schemaVersion", "sessions", "tasks"]) ||
|
|
207
|
-
value.schemaVersion !== TASK_SCHEMA_VERSION ||
|
|
241
|
+
(value.schemaVersion !== 1 && value.schemaVersion !== TASK_SCHEMA_VERSION) ||
|
|
208
242
|
!Array.isArray(value.tasks) ||
|
|
209
243
|
!Array.isArray(value.sessions)) {
|
|
210
244
|
return invalidState("Task state has an unsupported structure.");
|
|
@@ -217,6 +251,22 @@ function parseState(source) {
|
|
|
217
251
|
return invalidState("Task and session identifiers must be unique.");
|
|
218
252
|
}
|
|
219
253
|
const tasksById = new Map(tasks.map((record) => [record.task.id, record]));
|
|
254
|
+
for (const old of tasks) {
|
|
255
|
+
for (const successor of old.supersededBy ?? []) {
|
|
256
|
+
if (successor === old.task.id || !tasksById.has(successor))
|
|
257
|
+
return invalidState("Supersession must name a distinct known task.");
|
|
258
|
+
const seen = new Set([old.task.id]);
|
|
259
|
+
const visit = (id) => {
|
|
260
|
+
if (seen.has(id))
|
|
261
|
+
return invalidState("Cyclic task supersession.");
|
|
262
|
+
seen.add(id);
|
|
263
|
+
for (const next of tasksById.get(id)?.supersededBy ?? [])
|
|
264
|
+
visit(next);
|
|
265
|
+
seen.delete(id);
|
|
266
|
+
};
|
|
267
|
+
visit(successor);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
220
270
|
if (sessions.some(({ taskId }) => {
|
|
221
271
|
const record = tasksById.get(taskId);
|
|
222
272
|
return record === undefined || record.status === "archived";
|