@osovv/vv-opencode 1.3.7 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/README.md +42 -2
- package/dist/commands/completion.js +10 -2
- package/dist/commands/completion.js.map +1 -1
- package/dist/commands/patch-provider.d.ts +97 -1
- package/dist/commands/patch-provider.js +97 -5
- package/dist/commands/patch-provider.js.map +1 -1
- package/dist/lib/orchestration.d.ts +2 -2
- package/dist/lib/orchestration.js +31 -2
- package/dist/lib/orchestration.js.map +1 -1
- package/dist/lib/spec-lint.d.ts +71 -1
- package/dist/lib/spec-lint.js +566 -8
- package/dist/lib/spec-lint.js.map +1 -1
- package/dist/lib/vvoc-config.d.ts +3 -3
- package/dist/lib/vvoc-preset-registry.d.ts +25 -1
- package/dist/lib/vvoc-preset-registry.js +22 -2
- package/dist/lib/vvoc-preset-registry.js.map +1 -1
- package/dist/plugins/hashline-edit/index.js.map +1 -1
- package/dist/plugins/web-tools/providers/brave.js +14 -8
- package/dist/plugins/web-tools/providers/brave.js.map +1 -1
- package/dist/plugins/workflow/checkpoint-io.d.ts +33 -0
- package/dist/plugins/workflow/checkpoint-io.js +224 -0
- package/dist/plugins/workflow/checkpoint-io.js.map +1 -0
- package/dist/plugins/workflow/checkpoints.d.ts +182 -0
- package/dist/plugins/workflow/checkpoints.js +1067 -0
- package/dist/plugins/workflow/checkpoints.js.map +1 -0
- package/dist/plugins/workflow/delegated.d.ts +158 -0
- package/dist/plugins/workflow/delegated.js +613 -0
- package/dist/plugins/workflow/delegated.js.map +1 -0
- package/dist/plugins/workflow/index.js +368 -20
- package/dist/plugins/workflow/index.js.map +1 -1
- package/dist/plugins/workflow/persistence.d.ts +32 -3
- package/dist/plugins/workflow/persistence.js +497 -70
- package/dist/plugins/workflow/persistence.js.map +1 -1
- package/dist/plugins/workflow/repair.js +3 -1
- package/dist/plugins/workflow/repair.js.map +1 -1
- package/dist/plugins/workflow/snapshots.d.ts +59 -0
- package/dist/plugins/workflow/snapshots.js +261 -0
- package/dist/plugins/workflow/snapshots.js.map +1 -0
- package/dist/plugins/workflow/state.d.ts +20 -2
- package/dist/plugins/workflow/state.js +127 -12
- package/dist/plugins/workflow/state.js.map +1 -1
- package/dist/plugins/workflow/tooling.d.ts +45 -0
- package/dist/plugins/workflow/tooling.js +344 -12
- package/dist/plugins/workflow/tooling.js.map +1 -1
- package/dist/plugins/workflow/transitions.js +2 -2
- package/dist/plugins/workflow/transitions.js.map +1 -1
- package/dist/tui/context/analyze.js +3 -1
- package/dist/tui/context/analyze.js.map +1 -1
- package/package.json +1 -1
- package/schemas/vvoc/v3.json +5 -3
- package/templates/agents/vv-code-reviewer.md +3 -1
- package/templates/agents/vv-implementer.md +5 -2
- package/templates/agents/vv-spec-reviewer.md +3 -0
- package/templates/skills/vv-execute/SKILL.md +62 -15
- package/templates/skills/vv-plan/SKILL.md +13 -3
- package/templates/skills/vv-plan/references/plan-template.xml +45 -0
- package/templates/skills/vv-review/SKILL.md +1 -0
|
@@ -1,46 +1,77 @@
|
|
|
1
1
|
// FILE: src/plugins/workflow/persistence.ts
|
|
2
|
-
// VERSION: 0.
|
|
2
|
+
// VERSION: 0.3.0
|
|
3
3
|
// START_MODULE_CONTRACT
|
|
4
4
|
// PURPOSE: Hydrate and snapshot work-item workflow state from/to per-session JSON
|
|
5
5
|
// files under $XDG_DATA_HOME/vvoc/workflow/<sessionId>/workflow-state.json.
|
|
6
|
-
// SCOPE: Read/write WorkItemStoreData (nextId, records, keyIndexBySession
|
|
7
|
-
// serializable JSON
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
6
|
+
// SCOPE: Read/write WorkItemStoreData (nextId, records, keyIndexBySession,
|
|
7
|
+
// planRuns) as serializable JSON. Version 2 snapshots additionally persist
|
|
8
|
+
// delegated attempts, decisions, acceptances, rework history, and registered
|
|
9
|
+
// plan runs with checkpoint generations through an atomic temporary-file
|
|
10
|
+
// replacement. Version 1 files hydrate conservatively as legacy records with
|
|
11
|
+
// an empty plan-run registry and never synthesize acceptance or approval.
|
|
12
|
+
// Strict validation rejects malformed or contradictory new state instead of
|
|
13
|
+
// silently restarting a run. A checked loader distinguishes missing, valid,
|
|
14
|
+
// and invalid state and surfaces I/O failures.
|
|
15
|
+
// DEPENDS: [node:fs, node:fs/promises, node:path, src/lib/vvoc-paths.ts,
|
|
16
|
+
// src/plugins/workflow/checkpoints.ts (types), src/plugins/workflow/delegated.ts,
|
|
17
|
+
// src/plugins/workflow/state.ts]
|
|
18
|
+
// LINKS: M-WORKFLOW-PERSISTENCE, M-CONFIG-LAYERS, M-WORKFLOW-STATE, M-WORKFLOW-DELEGATED, M-WORKFLOW-CHECKPOINTS, V-M-WORKFLOW-PERSISTENCE
|
|
13
19
|
// ROLE: RUNTIME
|
|
14
20
|
// MAP_MODE: EXPORTS
|
|
15
21
|
// END_MODULE_CONTRACT
|
|
16
22
|
//
|
|
17
23
|
// START_MODULE_MAP
|
|
24
|
+
// PERSISTED_WORKFLOW_STATE_VERSION - Current persisted snapshot version.
|
|
18
25
|
// PersistedWorkflowState - JSON-serializable shape of a per-session workflow state.
|
|
26
|
+
// SerializedDelegatedPlanRun - JSON form of one registered plan run.
|
|
27
|
+
// HydratedWorkflowStateResult - Missing/valid/invalid triage returned by the checked loader.
|
|
28
|
+
// SnapshotWorkflowStateResult - Write outcome returned by the checked snapshot path.
|
|
19
29
|
// getWorkflowSessionDir - Resolve per-session directory path.
|
|
20
|
-
// hydrateWorkflowState -
|
|
21
|
-
//
|
|
30
|
+
// hydrateWorkflowState - Legacy nullable hydrate kept for compatibility.
|
|
31
|
+
// hydrateWorkflowStateChecked - Checked loader distinguishing missing, valid, and invalid state.
|
|
32
|
+
// snapshotWorkflowState - Legacy fire-and-forget snapshot kept for compatibility.
|
|
33
|
+
// snapshotWorkflowStateChecked - Atomic temporary-file replacement snapshot surfacing failures.
|
|
22
34
|
// deleteWorkflowSessionDir - Remove per-session workflow directory on session delete.
|
|
23
35
|
// END_MODULE_MAP
|
|
24
36
|
//
|
|
25
37
|
// START_CHANGE_SUMMARY
|
|
26
|
-
// LAST_CHANGE: [
|
|
38
|
+
// LAST_CHANGE: [C-DELEGATED-WORKFLOW-ASTRA-PRESETS - Added version 2 snapshots with delegated records and plan runs, strict validation, atomic writes, and a checked loader.]
|
|
27
39
|
// END_CHANGE_SUMMARY
|
|
28
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
40
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
29
41
|
import { rm } from "node:fs/promises";
|
|
30
42
|
import { join } from "node:path";
|
|
31
43
|
import { getGlobalVvocDataDir } from "../../lib/vvoc-paths.js";
|
|
44
|
+
import { normalizeDeclaredScopePath } from "../../lib/spec-lint.js";
|
|
45
|
+
import { DELEGATED_BASE_ATTEMPTS, DELEGATED_EVIDENCE_MAX_CHARS, DELEGATED_EVIDENCE_MAX_REFS, DELEGATED_RATIONALE_MAX_CHARS, } from "./delegated.js";
|
|
46
|
+
// START_BLOCK_SERIALIZATION_TYPES
|
|
47
|
+
export const PERSISTED_WORKFLOW_STATE_VERSION = 2;
|
|
32
48
|
// END_BLOCK_SERIALIZATION_TYPES
|
|
33
49
|
const VALID_STATES = new Set([
|
|
34
50
|
"open",
|
|
35
51
|
"awaiting_implementer",
|
|
36
52
|
"awaiting_reviews",
|
|
53
|
+
"awaiting_acceptance",
|
|
37
54
|
"needs_context",
|
|
38
55
|
"blocked",
|
|
39
56
|
"ready_to_close",
|
|
40
57
|
"closed",
|
|
41
58
|
]);
|
|
59
|
+
const DELEGATED_RESULT_STATUSES = new Set([
|
|
60
|
+
"DONE",
|
|
61
|
+
"DONE_WITH_CONCERNS",
|
|
62
|
+
"NEEDS_CONTEXT",
|
|
63
|
+
"BLOCKED",
|
|
64
|
+
]);
|
|
65
|
+
const CHECKPOINT_OUTCOMES = new Set(["passed", "failed", "stale"]);
|
|
66
|
+
const LAST_OUTCOMES = new Set([
|
|
67
|
+
"passed",
|
|
68
|
+
"failed",
|
|
69
|
+
"stale",
|
|
70
|
+
"stopped",
|
|
71
|
+
"incomplete",
|
|
72
|
+
]);
|
|
42
73
|
function isWorkItemMode(value) {
|
|
43
|
-
return value === "implementation" || value === "review_only";
|
|
74
|
+
return value === "implementation" || value === "review_only" || value === "delegated";
|
|
44
75
|
}
|
|
45
76
|
function isReviewerRole(value) {
|
|
46
77
|
return value === "spec" || value === "code";
|
|
@@ -90,17 +121,186 @@ function isReviewRound(value) {
|
|
|
90
121
|
(round.status === "active" || round.status === "completed") &&
|
|
91
122
|
typeof round.createdAt === "string");
|
|
92
123
|
}
|
|
93
|
-
function
|
|
124
|
+
function isValidScopeList(value) {
|
|
125
|
+
if (!Array.isArray(value) || value.length === 0)
|
|
126
|
+
return false;
|
|
127
|
+
const seen = new Set();
|
|
128
|
+
for (const entry of value) {
|
|
129
|
+
if (typeof entry !== "string")
|
|
130
|
+
return false;
|
|
131
|
+
const normalized = normalizeDeclaredScopePath(entry);
|
|
132
|
+
if (!normalized.ok || normalized.path !== entry)
|
|
133
|
+
return false;
|
|
134
|
+
if (seen.has(entry))
|
|
135
|
+
return false;
|
|
136
|
+
seen.add(entry);
|
|
137
|
+
}
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
function validateDelegatedText(value, maxChars, label, errors) {
|
|
141
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
142
|
+
errors.push(`${label} must be a non-empty string`);
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
if (value.trim().length > maxChars) {
|
|
146
|
+
errors.push(`${label} exceeds ${maxChars} characters`);
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
function validateBoundedEvidence(value, label, errors) {
|
|
152
|
+
if (!Array.isArray(value) || value.length === 0 || value.length > DELEGATED_EVIDENCE_MAX_REFS) {
|
|
153
|
+
errors.push(`${label} must contain 1 to ${DELEGATED_EVIDENCE_MAX_REFS} references`);
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
for (const reference of value) {
|
|
157
|
+
if (typeof reference !== "string" || reference.trim() === "") {
|
|
158
|
+
errors.push(`${label} references must be non-empty strings`);
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
if (reference.trim().length > DELEGATED_EVIDENCE_MAX_CHARS) {
|
|
162
|
+
errors.push(`${label} references exceed ${DELEGATED_EVIDENCE_MAX_CHARS} characters`);
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
/** Validate the delegated-mode record extension; returns every contradiction found. */
|
|
169
|
+
function validateDelegatedState(record, sessionId, errors) {
|
|
170
|
+
const delegated = record.delegated;
|
|
171
|
+
if (!delegated || typeof delegated !== "object") {
|
|
172
|
+
errors.push(`${record.workItemId}: delegated records require a delegated state object`);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
if (record.requiredReviewers.length !== 0) {
|
|
176
|
+
errors.push(`${record.workItemId}: delegated records must persist an empty requiredReviewers array`);
|
|
177
|
+
}
|
|
178
|
+
if ((delegated.planRunId === undefined) !== (delegated.planTaskId === undefined)) {
|
|
179
|
+
errors.push(`${record.workItemId}: planRunId and planTaskId must be persisted together or absent`);
|
|
180
|
+
}
|
|
181
|
+
if (!isValidScopeList(delegated.writeScope)) {
|
|
182
|
+
errors.push(`${record.workItemId}: delegated writeScope must be a non-empty canonical path list`);
|
|
183
|
+
}
|
|
184
|
+
const attempts = delegated.attempts;
|
|
185
|
+
if (!Array.isArray(attempts)) {
|
|
186
|
+
errors.push(`${record.workItemId}: delegated attempts must be an array`);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
let inFlight = 0;
|
|
190
|
+
attempts.forEach((attempt, index) => {
|
|
191
|
+
if (attempt.attempt !== index + 1) {
|
|
192
|
+
errors.push(`${record.workItemId}: delegated attempts must sequence 1..n contiguously`);
|
|
193
|
+
}
|
|
194
|
+
if (typeof attempt.callId !== "string" || attempt.callId === "") {
|
|
195
|
+
errors.push(`${record.workItemId}: attempt ${attempt.attempt} requires a bound callId`);
|
|
196
|
+
}
|
|
197
|
+
if (attempt.status === "in_flight") {
|
|
198
|
+
inFlight += 1;
|
|
199
|
+
if (attempt.resultStatus !== undefined || attempt.completedAt !== undefined) {
|
|
200
|
+
errors.push(`${record.workItemId}: in-flight attempt ${attempt.attempt} must not carry a result`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
else if (attempt.status === "completed") {
|
|
204
|
+
if (!DELEGATED_RESULT_STATUSES.has(attempt.resultStatus)) {
|
|
205
|
+
errors.push(`${record.workItemId}: completed attempt ${attempt.attempt} lacks a valid resultStatus`);
|
|
206
|
+
}
|
|
207
|
+
if (typeof attempt.completedAt !== "string") {
|
|
208
|
+
errors.push(`${record.workItemId}: completed attempt ${attempt.attempt} requires completedAt`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
else {
|
|
212
|
+
errors.push(`${record.workItemId}: attempt ${attempt.attempt} has invalid status ${attempt.status}`);
|
|
213
|
+
}
|
|
214
|
+
if (attempt.resultExcerpt !== undefined && !isWorkflowResultExcerpt(attempt.resultExcerpt)) {
|
|
215
|
+
errors.push(`${record.workItemId}: attempt ${attempt.attempt} carries a malformed excerpt`);
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
if (inFlight > 1) {
|
|
219
|
+
errors.push(`${record.workItemId}: at most one delegated attempt may be in flight`);
|
|
220
|
+
}
|
|
221
|
+
if (delegated.reworkHistory.some((rework) => !rework.reworkId || !rework.authorizedByCheckpoint)) {
|
|
222
|
+
errors.push(`${record.workItemId}: rework history entries require ids and checkpoint authorization`);
|
|
223
|
+
}
|
|
224
|
+
if (attempts.length > DELEGATED_BASE_ATTEMPTS + delegated.reworkHistory.length) {
|
|
225
|
+
errors.push(`${record.workItemId}: attempts exceed the base budget plus authorized rework grants`);
|
|
226
|
+
}
|
|
227
|
+
const decisions = delegated.decisions;
|
|
228
|
+
if (!Array.isArray(decisions)) {
|
|
229
|
+
errors.push(`${record.workItemId}: delegated decisions must be an array`);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
const decidedAttempts = new Set();
|
|
233
|
+
for (const decision of decisions) {
|
|
234
|
+
if (decision.decision !== "accept" && decision.decision !== "request_changes") {
|
|
235
|
+
errors.push(`${record.workItemId}: decision ${decision.decisionId} has an invalid decision value`);
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
if (decision.decisionId !== `dec-${record.workItemId}-a${decision.attempt}`) {
|
|
239
|
+
errors.push(`${record.workItemId}: decision id ${decision.decisionId} does not match its attempt`);
|
|
240
|
+
}
|
|
241
|
+
if (decidedAttempts.has(decision.attempt)) {
|
|
242
|
+
errors.push(`${record.workItemId}: attempt ${decision.attempt} has more than one decision`);
|
|
243
|
+
}
|
|
244
|
+
decidedAttempts.add(decision.attempt);
|
|
245
|
+
if (!attempts.some((attempt) => attempt.attempt === decision.attempt && attempt.status === "completed")) {
|
|
246
|
+
errors.push(`${record.workItemId}: decision ${decision.decisionId} targets a non-completed attempt`);
|
|
247
|
+
}
|
|
248
|
+
validateDelegatedText(decision.rationale, DELEGATED_RATIONALE_MAX_CHARS, `${record.workItemId}: decision ${decision.decisionId} rationale`, errors);
|
|
249
|
+
validateBoundedEvidence(decision.evidence, `${record.workItemId}: decision ${decision.decisionId} evidence`, errors);
|
|
250
|
+
if (decision.concernsDisposition !== undefined) {
|
|
251
|
+
validateDelegatedText(decision.concernsDisposition, DELEGATED_RATIONALE_MAX_CHARS, `${record.workItemId}: decision ${decision.decisionId} concernsDisposition`, errors);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
const acceptances = delegated.acceptances;
|
|
255
|
+
if (!Array.isArray(acceptances)) {
|
|
256
|
+
errors.push(`${record.workItemId}: delegated acceptances must be an array`);
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
const reworkIds = new Set(delegated.reworkHistory.map((rework) => rework.reworkId));
|
|
260
|
+
const currentAcceptance = [...acceptances]
|
|
261
|
+
.reverse()
|
|
262
|
+
.find((acceptance) => !acceptance.revokedAt);
|
|
263
|
+
for (const acceptance of acceptances) {
|
|
264
|
+
const matchingDecision = decisions.find((decision) => decision.decisionId === acceptance.decisionId && decision.decision === "accept");
|
|
265
|
+
if (!matchingDecision) {
|
|
266
|
+
errors.push(`${record.workItemId}: acceptance ${acceptance.decisionId} has no matching accept decision`);
|
|
267
|
+
}
|
|
268
|
+
if (acceptance.revokedAt !== undefined &&
|
|
269
|
+
(acceptance.revokedByReworkId === undefined || !reworkIds.has(acceptance.revokedByReworkId))) {
|
|
270
|
+
errors.push(`${record.workItemId}: revoked acceptance ${acceptance.decisionId} references an unknown rework`);
|
|
271
|
+
}
|
|
272
|
+
if (acceptance.concernsDisposition !== undefined) {
|
|
273
|
+
validateDelegatedText(acceptance.concernsDisposition, DELEGATED_RATIONALE_MAX_CHARS, `${record.workItemId}: acceptance ${acceptance.decisionId} concernsDisposition`, errors);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
const latestCompleted = [...attempts]
|
|
277
|
+
.filter((attempt) => attempt.status === "completed")
|
|
278
|
+
.sort((left, right) => right.attempt - left.attempt)[0];
|
|
279
|
+
if (record.state === "awaiting_acceptance") {
|
|
280
|
+
if (!latestCompleted) {
|
|
281
|
+
errors.push(`${record.workItemId}: awaiting_acceptance without a completed attempt`);
|
|
282
|
+
}
|
|
283
|
+
else if (decidedAttempts.has(latestCompleted.attempt)) {
|
|
284
|
+
errors.push(`${record.workItemId}: awaiting_acceptance but the latest attempt already has a decision`);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
if (record.state === "ready_to_close" && !currentAcceptance) {
|
|
288
|
+
errors.push(`${record.workItemId}: ready_to_close without a currently applicable acceptance`);
|
|
289
|
+
}
|
|
290
|
+
if (sessionId !== record.sessionId) {
|
|
291
|
+
errors.push(`${record.workItemId}: record session mismatch`);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
function isWorkItemRecord(value, sessionId, errors) {
|
|
94
295
|
if (!value || typeof value !== "object")
|
|
95
296
|
return false;
|
|
96
297
|
const record = value;
|
|
97
|
-
|
|
298
|
+
const baseValid = record.sessionId === sessionId &&
|
|
98
299
|
typeof record.workItemId === "string" &&
|
|
99
300
|
typeof record.key === "string" &&
|
|
100
301
|
typeof record.title === "string" &&
|
|
101
302
|
isWorkItemMode(record.mode) &&
|
|
102
303
|
Array.isArray(record.requiredReviewers) &&
|
|
103
|
-
record.requiredReviewers.length > 0 &&
|
|
104
304
|
record.requiredReviewers.every(isReviewerRole) &&
|
|
105
305
|
VALID_STATES.has(record.state) &&
|
|
106
306
|
Number.isInteger(record.completedReviewRoundCount) &&
|
|
@@ -109,7 +309,158 @@ function isWorkItemRecord(value, sessionId) {
|
|
|
109
309
|
(record.resultExcerpt === undefined || isWorkflowResultExcerpt(record.resultExcerpt)) &&
|
|
110
310
|
typeof record.createdAt === "string" &&
|
|
111
311
|
typeof record.updatedAt === "string" &&
|
|
112
|
-
(record.currentRound === undefined || isReviewRound(record.currentRound))
|
|
312
|
+
(record.currentRound === undefined || isReviewRound(record.currentRound));
|
|
313
|
+
if (!baseValid)
|
|
314
|
+
return false;
|
|
315
|
+
if (record.mode === "delegated") {
|
|
316
|
+
const recordErrors = [];
|
|
317
|
+
validateDelegatedState(record, sessionId, recordErrors);
|
|
318
|
+
errors.push(...recordErrors);
|
|
319
|
+
return recordErrors.length === 0;
|
|
320
|
+
}
|
|
321
|
+
if (record.requiredReviewers.length === 0) {
|
|
322
|
+
errors.push(`${record.workItemId}: ${record.mode} records require non-empty requiredReviewers`);
|
|
323
|
+
return false;
|
|
324
|
+
}
|
|
325
|
+
if (record.delegated !== undefined) {
|
|
326
|
+
errors.push(`${record.workItemId}: delegated state on a ${record.mode} record is contradictory`);
|
|
327
|
+
return false;
|
|
328
|
+
}
|
|
329
|
+
return true;
|
|
330
|
+
}
|
|
331
|
+
function validatePlanRun(run, recordsById, sessionId, errors) {
|
|
332
|
+
if (!run || typeof run !== "object") {
|
|
333
|
+
errors.push("plan run entries must be objects");
|
|
334
|
+
return false;
|
|
335
|
+
}
|
|
336
|
+
const candidate = run;
|
|
337
|
+
if (typeof candidate.runId !== "string" || candidate.runId === "") {
|
|
338
|
+
errors.push("plan run requires a runId");
|
|
339
|
+
return false;
|
|
340
|
+
}
|
|
341
|
+
if (candidate.sessionId !== sessionId) {
|
|
342
|
+
errors.push(`plan run ${candidate.runId} belongs to another session`);
|
|
343
|
+
return false;
|
|
344
|
+
}
|
|
345
|
+
if (typeof candidate.planPath !== "string" || typeof candidate.specPath !== "string") {
|
|
346
|
+
errors.push(`plan run ${candidate.runId} requires canonical plan and spec paths`);
|
|
347
|
+
return false;
|
|
348
|
+
}
|
|
349
|
+
if (!Array.isArray(candidate.tasks) || !Array.isArray(candidate.checkpoints)) {
|
|
350
|
+
errors.push(`plan run ${candidate.runId} requires task and checkpoint arrays`);
|
|
351
|
+
return false;
|
|
352
|
+
}
|
|
353
|
+
if (!candidate.definition || candidate.definition.mode !== "delegated") {
|
|
354
|
+
errors.push(`plan run ${candidate.runId} requires its delegated definition`);
|
|
355
|
+
return false;
|
|
356
|
+
}
|
|
357
|
+
if (candidate.status !== "active" && candidate.status !== "sealed") {
|
|
358
|
+
errors.push(`plan run ${candidate.runId} has invalid status ${candidate.status}`);
|
|
359
|
+
return false;
|
|
360
|
+
}
|
|
361
|
+
if (candidate.status === "sealed" && typeof candidate.sealedAt !== "string") {
|
|
362
|
+
errors.push(`plan run ${candidate.runId} is sealed without sealedAt`);
|
|
363
|
+
return false;
|
|
364
|
+
}
|
|
365
|
+
const taskIds = new Set();
|
|
366
|
+
for (const task of candidate.tasks) {
|
|
367
|
+
if (typeof task.taskId !== "string" || typeof task.workItemId !== "string") {
|
|
368
|
+
errors.push(`plan run ${candidate.runId} has a malformed task binding`);
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
if (taskIds.has(task.taskId)) {
|
|
372
|
+
errors.push(`plan run ${candidate.runId} binds task ${task.taskId} twice`);
|
|
373
|
+
}
|
|
374
|
+
taskIds.add(task.taskId);
|
|
375
|
+
const record = recordsById.get(task.workItemId);
|
|
376
|
+
if (!record ||
|
|
377
|
+
record.mode !== "delegated" ||
|
|
378
|
+
record.delegated?.planRunId !== candidate.runId ||
|
|
379
|
+
record.delegated?.planTaskId !== task.taskId) {
|
|
380
|
+
errors.push(`plan run ${candidate.runId} task ${task.taskId} is not bound to work item ${task.workItemId}`);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
const checkpointIds = new Set();
|
|
384
|
+
for (const checkpoint of candidate.checkpoints) {
|
|
385
|
+
if (typeof checkpoint.checkpointId !== "string" ||
|
|
386
|
+
!checkpointIds.add(checkpoint.checkpointId)) {
|
|
387
|
+
errors.push(`plan run ${candidate.runId} has duplicate or malformed checkpoint ids`);
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
if (checkpoint.kind !== "milestone" && checkpoint.kind !== "final") {
|
|
391
|
+
errors.push(`checkpoint ${checkpoint.checkpointId} has invalid kind ${checkpoint.kind}`);
|
|
392
|
+
}
|
|
393
|
+
if (!Array.isArray(checkpoint.reviewers) ||
|
|
394
|
+
checkpoint.reviewers.length === 0 ||
|
|
395
|
+
!checkpoint.reviewers.every(isReviewerRole)) {
|
|
396
|
+
errors.push(`checkpoint ${checkpoint.checkpointId} requires a non-empty spec/code reviewer set`);
|
|
397
|
+
}
|
|
398
|
+
if (!["pending", "in_review", "passed", "failed"].includes(checkpoint.status)) {
|
|
399
|
+
errors.push(`checkpoint ${checkpoint.checkpointId} has invalid status ${checkpoint.status}`);
|
|
400
|
+
}
|
|
401
|
+
if (checkpoint.lastOutcome !== undefined && !LAST_OUTCOMES.has(checkpoint.lastOutcome)) {
|
|
402
|
+
errors.push(`checkpoint ${checkpoint.checkpointId} has invalid lastOutcome ${checkpoint.lastOutcome}`);
|
|
403
|
+
}
|
|
404
|
+
const history = checkpoint.history;
|
|
405
|
+
if (!Array.isArray(history)) {
|
|
406
|
+
errors.push(`checkpoint ${checkpoint.checkpointId} requires a history array`);
|
|
407
|
+
continue;
|
|
408
|
+
}
|
|
409
|
+
for (const entry of history) {
|
|
410
|
+
if (!CHECKPOINT_OUTCOMES.has(entry.outcome)) {
|
|
411
|
+
errors.push(`checkpoint ${checkpoint.checkpointId} history has invalid outcome ${entry.outcome}`);
|
|
412
|
+
}
|
|
413
|
+
if (typeof entry.generation !== "number" || typeof entry.fingerprint !== "string") {
|
|
414
|
+
errors.push(`checkpoint ${checkpoint.checkpointId} history entry is malformed`);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
const review = checkpoint.currentReview;
|
|
418
|
+
if (checkpoint.status === "in_review") {
|
|
419
|
+
if (!review ||
|
|
420
|
+
review.generation !== checkpoint.attempts ||
|
|
421
|
+
review.generation !== history.length + 1) {
|
|
422
|
+
errors.push(`checkpoint ${checkpoint.checkpointId} in_review without a consistent current generation`);
|
|
423
|
+
}
|
|
424
|
+
else {
|
|
425
|
+
for (const reviewer of Object.keys(review.results ?? {})) {
|
|
426
|
+
if (!checkpoint.reviewers.includes(reviewer)) {
|
|
427
|
+
errors.push(`checkpoint ${checkpoint.checkpointId} recorded an undeclared reviewer ${reviewer}`);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
if (Object.keys(review.reviewerCallIds ?? {}).some((reviewer) => !checkpoint.reviewers.includes(reviewer))) {
|
|
431
|
+
errors.push(`checkpoint ${checkpoint.checkpointId} bound an undeclared reviewer call`);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
else if (review !== undefined) {
|
|
436
|
+
errors.push(`checkpoint ${checkpoint.checkpointId} is ${checkpoint.status} but still carries a current review`);
|
|
437
|
+
}
|
|
438
|
+
if (checkpoint.status === "passed" && history[history.length - 1]?.outcome !== "passed") {
|
|
439
|
+
errors.push(`checkpoint ${checkpoint.checkpointId} passed without a passing history entry`);
|
|
440
|
+
}
|
|
441
|
+
if (checkpoint.status === "failed" &&
|
|
442
|
+
!["failed", "stale"].includes(history[history.length - 1]?.outcome ?? "")) {
|
|
443
|
+
errors.push(`checkpoint ${checkpoint.checkpointId} failed without a failing history entry`);
|
|
444
|
+
}
|
|
445
|
+
if (checkpoint.attempts !== history.length + (checkpoint.status === "in_review" ? 1 : 0)) {
|
|
446
|
+
errors.push(`checkpoint ${checkpoint.checkpointId} attempts do not match its generations`);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
const finals = candidate.checkpoints.filter((checkpoint) => checkpoint.kind === "final");
|
|
450
|
+
if (finals.length !== 1) {
|
|
451
|
+
errors.push(`plan run ${candidate.runId} must persist exactly one final checkpoint`);
|
|
452
|
+
}
|
|
453
|
+
else if (candidate.status === "sealed" && finals[0].status !== "passed") {
|
|
454
|
+
errors.push(`plan run ${candidate.runId} is sealed without a passed final checkpoint`);
|
|
455
|
+
}
|
|
456
|
+
return errors.length === 0;
|
|
457
|
+
}
|
|
458
|
+
function serializePlanRun(run) {
|
|
459
|
+
return {
|
|
460
|
+
...run,
|
|
461
|
+
tasks: [...run.tasks.values()],
|
|
462
|
+
checkpoints: [...run.checkpoints.values()],
|
|
463
|
+
};
|
|
113
464
|
}
|
|
114
465
|
/**
|
|
115
466
|
* Resolve the per-session workflow data directory.
|
|
@@ -124,76 +475,130 @@ export function getWorkflowSessionDir(sessionId) {
|
|
|
124
475
|
function getWorkflowStatePath(sessionId) {
|
|
125
476
|
return join(getWorkflowSessionDir(sessionId), "workflow-state.json");
|
|
126
477
|
}
|
|
127
|
-
// START_CONTRACT:
|
|
128
|
-
// PURPOSE: Read and
|
|
129
|
-
// WorkItemStoreData suitable for restoring an in-memory store. Returns null
|
|
130
|
-
// when the file is missing, corrupt, or incomplete. Never throws.
|
|
478
|
+
// START_CONTRACT: hydrateWorkflowStateChecked
|
|
479
|
+
// PURPOSE: Read and validate the per-session workflow state, distinguishing missing, valid, and invalid files and surfacing I/O failures.
|
|
131
480
|
// INPUTS: { sessionId: string - OpenCode session identifier }
|
|
132
|
-
// OUTPUTS: {
|
|
133
|
-
// SIDE_EFFECTS: [
|
|
134
|
-
// LINKS: [M-WORKFLOW-PERSISTENCE]
|
|
135
|
-
// END_CONTRACT:
|
|
136
|
-
export function
|
|
481
|
+
// OUTPUTS: { HydratedWorkflowStateResult - missing, validated store data, or collected validation errors }
|
|
482
|
+
// SIDE_EFFECTS: [Reads workflow-state.json; never writes and never throws]
|
|
483
|
+
// LINKS: [M-WORKFLOW-PERSISTENCE, M-WORKFLOW-STATE]
|
|
484
|
+
// END_CONTRACT: hydrateWorkflowStateChecked
|
|
485
|
+
export function hydrateWorkflowStateChecked(sessionId) {
|
|
486
|
+
const filePath = getWorkflowStatePath(sessionId);
|
|
487
|
+
let raw;
|
|
137
488
|
try {
|
|
138
|
-
const filePath = getWorkflowStatePath(sessionId);
|
|
139
489
|
if (!existsSync(filePath)) {
|
|
140
|
-
return
|
|
141
|
-
}
|
|
142
|
-
const raw = readFileSync(filePath, "utf-8");
|
|
143
|
-
const parsed = JSON.parse(raw);
|
|
144
|
-
// Validate minimal expected shape
|
|
145
|
-
if (parsed.version !== 1 || !Array.isArray(parsed.records)) {
|
|
146
|
-
return null;
|
|
490
|
+
return { status: "missing" };
|
|
147
491
|
}
|
|
148
|
-
|
|
149
|
-
|
|
492
|
+
raw = readFileSync(filePath, "utf-8");
|
|
493
|
+
}
|
|
494
|
+
catch (error) {
|
|
495
|
+
return {
|
|
496
|
+
status: "invalid",
|
|
497
|
+
errors: [`workflow state could not be read: ${error.message}`],
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
let parsed;
|
|
501
|
+
try {
|
|
502
|
+
parsed = JSON.parse(raw);
|
|
503
|
+
}
|
|
504
|
+
catch (error) {
|
|
505
|
+
return {
|
|
506
|
+
status: "invalid",
|
|
507
|
+
errors: [`workflow state is not valid JSON: ${error.message}`],
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
if (parsed.version !== 1 && parsed.version !== PERSISTED_WORKFLOW_STATE_VERSION) {
|
|
511
|
+
return {
|
|
512
|
+
status: "invalid",
|
|
513
|
+
errors: [`unsupported persisted version ${String(parsed.version)}`],
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
if (!Array.isArray(parsed.records)) {
|
|
517
|
+
return { status: "invalid", errors: ["persisted records must be an array"] };
|
|
518
|
+
}
|
|
519
|
+
const errors = [];
|
|
520
|
+
const records = new Map();
|
|
521
|
+
for (const record of parsed.records) {
|
|
522
|
+
const recordErrors = [];
|
|
523
|
+
if (!isWorkItemRecord(record, sessionId, recordErrors)) {
|
|
524
|
+
errors.push(...(recordErrors.length > 0
|
|
525
|
+
? recordErrors
|
|
526
|
+
: [
|
|
527
|
+
`record ${String(record?.workItemId ?? "(unknown)")} failed base validation`,
|
|
528
|
+
]));
|
|
529
|
+
continue;
|
|
150
530
|
}
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
531
|
+
records.set(`${sessionId}::${record.workItemId}`, record);
|
|
532
|
+
}
|
|
533
|
+
const keyIndex = new Map();
|
|
534
|
+
for (const [key, workItemId] of Object.entries(parsed.keyIndex ?? {})) {
|
|
535
|
+
keyIndex.set(key, workItemId);
|
|
536
|
+
}
|
|
537
|
+
const keyIndexBySession = new Map();
|
|
538
|
+
keyIndexBySession.set(sessionId, keyIndex);
|
|
539
|
+
const planRuns = new Map();
|
|
540
|
+
if (parsed.version === PERSISTED_WORKFLOW_STATE_VERSION) {
|
|
541
|
+
if (!Array.isArray(parsed.planRuns)) {
|
|
542
|
+
return { status: "invalid", errors: ["version 2 state requires a planRuns array"] };
|
|
156
543
|
}
|
|
157
|
-
const
|
|
158
|
-
for (const
|
|
159
|
-
|
|
544
|
+
const recordsByBareId = new Map([...records.values()].map((record) => [record.workItemId, record]));
|
|
545
|
+
for (const serialized of parsed.planRuns) {
|
|
546
|
+
const runErrors = [];
|
|
547
|
+
if (!validatePlanRun(serialized, recordsByBareId, sessionId, runErrors)) {
|
|
548
|
+
errors.push(...runErrors);
|
|
549
|
+
continue;
|
|
550
|
+
}
|
|
551
|
+
const run = {
|
|
552
|
+
...serialized,
|
|
553
|
+
tasks: new Map(serialized.tasks.map((task) => [task.taskId, task])),
|
|
554
|
+
checkpoints: new Map(serialized.checkpoints.map((checkpoint) => [checkpoint.checkpointId, checkpoint])),
|
|
555
|
+
};
|
|
556
|
+
planRuns.set(run.runId, run);
|
|
160
557
|
}
|
|
161
|
-
const keyIndexBySession = new Map();
|
|
162
|
-
keyIndexBySession.set(sessionId, keyIndex);
|
|
163
|
-
return {
|
|
164
|
-
nextId: parsed.nextId,
|
|
165
|
-
records,
|
|
166
|
-
keyIndexBySession,
|
|
167
|
-
};
|
|
168
558
|
}
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
return null;
|
|
559
|
+
if (errors.length > 0) {
|
|
560
|
+
return { status: "invalid", errors };
|
|
172
561
|
}
|
|
562
|
+
return {
|
|
563
|
+
status: "valid",
|
|
564
|
+
data: {
|
|
565
|
+
nextId: typeof parsed.nextId === "number" ? parsed.nextId : records.size + 1,
|
|
566
|
+
records,
|
|
567
|
+
keyIndexBySession,
|
|
568
|
+
planRuns,
|
|
569
|
+
},
|
|
570
|
+
};
|
|
173
571
|
}
|
|
174
|
-
// START_CONTRACT:
|
|
175
|
-
// PURPOSE:
|
|
176
|
-
//
|
|
177
|
-
//
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
//
|
|
572
|
+
// START_CONTRACT: hydrateWorkflowState
|
|
573
|
+
// PURPOSE: Legacy nullable hydrate kept for compatibility with existing callers.
|
|
574
|
+
// INPUTS: { sessionId: string - OpenCode session identifier }
|
|
575
|
+
// OUTPUTS: { WorkItemStoreData | null - restored store data or null when missing/invalid }
|
|
576
|
+
// SIDE_EFFECTS: [none]
|
|
577
|
+
// LINKS: [M-WORKFLOW-PERSISTENCE, hydrateWorkflowStateChecked]
|
|
578
|
+
// END_CONTRACT: hydrateWorkflowState
|
|
579
|
+
export function hydrateWorkflowState(sessionId) {
|
|
580
|
+
const result = hydrateWorkflowStateChecked(sessionId);
|
|
581
|
+
return result.status === "valid" ? result.data : null;
|
|
582
|
+
}
|
|
583
|
+
// START_CONTRACT: snapshotWorkflowStateChecked
|
|
584
|
+
// PURPOSE: Persist WorkItemStoreData through an atomic temporary-file replacement, surfacing failures.
|
|
585
|
+
// INPUTS: { sessionId: string - session scope, data: WorkItemStoreData - store snapshot }
|
|
586
|
+
// OUTPUTS: { SnapshotWorkflowStateResult - write outcome with the failure reason }
|
|
587
|
+
// SIDE_EFFECTS: [Writes workflow-state.json via a temporary file and rename]
|
|
181
588
|
// LINKS: [M-WORKFLOW-PERSISTENCE]
|
|
182
|
-
// END_CONTRACT:
|
|
183
|
-
export function
|
|
589
|
+
// END_CONTRACT: snapshotWorkflowStateChecked
|
|
590
|
+
export function snapshotWorkflowStateChecked(sessionId, data) {
|
|
184
591
|
try {
|
|
185
592
|
const dir = getWorkflowSessionDir(sessionId);
|
|
186
593
|
if (!existsSync(dir)) {
|
|
187
594
|
mkdirSync(dir, { recursive: true });
|
|
188
595
|
}
|
|
189
|
-
// Convert records Map to array
|
|
190
596
|
const records = [];
|
|
191
597
|
for (const record of data.records.values()) {
|
|
192
598
|
if (record.sessionId === sessionId) {
|
|
193
599
|
records.push(record);
|
|
194
600
|
}
|
|
195
601
|
}
|
|
196
|
-
// Convert keyIndex for this session to plain object
|
|
197
602
|
const sessionKeyIndex = data.keyIndexBySession.get(sessionId);
|
|
198
603
|
const keyIndex = {};
|
|
199
604
|
if (sessionKeyIndex) {
|
|
@@ -201,21 +606,43 @@ export function snapshotWorkflowState(sessionId, data) {
|
|
|
201
606
|
keyIndex[key] = workItemId;
|
|
202
607
|
}
|
|
203
608
|
}
|
|
609
|
+
const planRuns = [];
|
|
610
|
+
for (const run of data.planRuns.values()) {
|
|
611
|
+
if (run.sessionId === sessionId) {
|
|
612
|
+
planRuns.push(serializePlanRun(run));
|
|
613
|
+
}
|
|
614
|
+
}
|
|
204
615
|
const persisted = {
|
|
205
|
-
|
|
616
|
+
// Version 2 carries delegated attempts, decisions, and plan runs;
|
|
617
|
+
// PERSISTED_WORKFLOW_STATE_VERSION mirrors this literal for hydration.
|
|
618
|
+
version: 2,
|
|
206
619
|
updatedAt: new Date().toISOString(),
|
|
207
620
|
sessionId,
|
|
208
621
|
nextId: data.nextId,
|
|
209
622
|
records,
|
|
210
623
|
keyIndex,
|
|
624
|
+
planRuns,
|
|
211
625
|
};
|
|
212
|
-
|
|
626
|
+
const targetPath = getWorkflowStatePath(sessionId);
|
|
627
|
+
const temporaryPath = `${targetPath}.tmp-${process.pid}-${Date.now()}`;
|
|
628
|
+
writeFileSync(temporaryPath, JSON.stringify(persisted, null, 2), "utf-8");
|
|
629
|
+
renameSync(temporaryPath, targetPath);
|
|
630
|
+
return { ok: true };
|
|
213
631
|
}
|
|
214
|
-
catch {
|
|
215
|
-
|
|
216
|
-
// The caller (index.ts) will log this via client.app.log
|
|
632
|
+
catch (error) {
|
|
633
|
+
return { ok: false, error: error.message };
|
|
217
634
|
}
|
|
218
635
|
}
|
|
636
|
+
// START_CONTRACT: snapshotWorkflowState
|
|
637
|
+
// PURPOSE: Legacy fire-and-forget snapshot kept for compatibility with existing callers.
|
|
638
|
+
// INPUTS: { sessionId: string, data: WorkItemStoreData }
|
|
639
|
+
// OUTPUTS: { void }
|
|
640
|
+
// SIDE_EFFECTS: [Delegates to snapshotWorkflowStateChecked and swallows failures]
|
|
641
|
+
// LINKS: [M-WORKFLOW-PERSISTENCE, snapshotWorkflowStateChecked]
|
|
642
|
+
// END_CONTRACT: snapshotWorkflowState
|
|
643
|
+
export function snapshotWorkflowState(sessionId, data) {
|
|
644
|
+
void snapshotWorkflowStateChecked(sessionId, data);
|
|
645
|
+
}
|
|
219
646
|
// START_CONTRACT: deleteWorkflowSessionDir
|
|
220
647
|
// PURPOSE: Remove the per-session workflow data directory. No-op if it does
|
|
221
648
|
// not exist. Never throws.
|