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