@brainervirus/workit-core 0.11.0 → 1.0.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 +5 -3
- package/package.json +8 -3
- package/scripts/analyze-release-scope.ts +4 -1
- package/scripts/doctor-check.ts +3 -1
- package/scripts/install-codex-plugin.sh +28 -0
- package/scripts/install-cursor-plugin.sh +1 -0
- package/scripts/install-opencode-plugin.sh +1 -0
- package/scripts/install-pi-package.sh +56 -0
- package/scripts/rewrite-workspace-deps.ts +22 -5
- package/scripts/sync-release-manifests.ts +8 -5
- package/scripts/sync-runtime.sh +7 -3
- package/scripts/validate-cursor-marketplace.ts +16 -62
- package/skills/workit-babysit/SKILL.md +33 -0
- package/skills/workit-behavioral-tdd/SKILL.md +53 -0
- package/skills/workit-blast-radius/SKILL.md +31 -0
- package/skills/workit-challenge/SKILL.md +62 -0
- package/skills/workit-debug/SKILL.md +61 -0
- package/skills/workit-deslop/SKILL.md +36 -0
- package/skills/workit-diagram/SKILL.md +32 -0
- package/skills/workit-green-run/SKILL.md +29 -0
- package/skills/workit-handoff/SKILL.md +43 -0
- package/skills/workit-implement/SKILL.md +46 -0
- package/skills/workit-mockup/SKILL.md +28 -0
- package/skills/workit-plan/SKILL.md +66 -0
- package/skills/workit-review/SKILL.md +60 -0
- package/skills/workit-steer/SKILL.md +32 -0
- package/src/core/authority.ts +918 -0
- package/src/core/boundary.ts +29 -0
- package/src/core/branch.ts +26 -255
- package/src/core/changelog.ts +95 -18
- package/src/core/commit-flavors.ts +65 -0
- package/src/core/config-conversion.ts +183 -0
- package/src/core/config.ts +42 -0
- package/src/core/cutover.ts +721 -0
- package/src/core/detect-hosts.ts +107 -0
- package/src/core/doctor.ts +428 -18
- package/src/core/external-action-effects.ts +1171 -0
- package/src/core/external-action.ts +541 -0
- package/src/core/init.ts +83 -32
- package/src/core/methods.ts +125 -0
- package/src/core/policy-resolver.ts +523 -0
- package/src/core/pr-create.ts +7 -1
- package/src/core/registration.ts +69 -4
- package/src/core/repo-context.ts +59 -1
- package/src/core/setup.ts +28 -0
- package/src/core/skill-manifests.ts +40 -42
- package/src/core/support-matrix.ts +8 -3
- package/src/core/sync-runtime.ts +15 -3
- package/src/core/task-context.ts +179 -0
- package/src/core/task-contract.ts +1136 -0
- package/src/core/task-engine.ts +2562 -0
- package/src/core/task-evaluation.ts +679 -0
- package/src/core/task-store.ts +1071 -0
- package/src/core/tracker-issues.ts +237 -0
- package/src/core/uninstall.ts +67 -3
- package/src/core/vcs-config.ts +53 -22
- package/src/core/workers.ts +166 -0
- package/src/core/workspaces.ts +3 -1
- package/src/core/youtrack-tools.ts +2 -0
- package/src/core/youtrack.ts +145 -10
- package/src/core.ts +126 -0
- package/templates/execution-contract.md +17 -50
- package/templates/plan-template.md +3 -3
- package/templates/spec-template.md +15 -3
- package/templates/workit-contract.md +12 -0
- package/commands/wk-changelog.md +0 -2
- package/commands/wk-commit.md +0 -2
- package/commands/wk-docs-refresh.md +0 -2
- package/commands/wk-handoff.md +0 -2
- package/commands/wk-implement.md +0 -2
- package/commands/wk-init.md +0 -2
- package/commands/wk-issue-update.md +0 -2
- package/commands/wk-meetings.md +0 -2
- package/commands/wk-pr.md +0 -2
- package/commands/wk-release-notes.md +0 -2
- package/commands/wk-status.md +0 -2
- package/commands/wk-verify.md +0 -2
- package/scripts/update-superpowers.sh +0 -82
- package/scripts/vendor-assets.ts +0 -37
- package/skills/wk-changelog/SKILL.md +0 -15
- package/skills/wk-commit/SKILL.md +0 -16
- package/skills/wk-docs-refresh/SKILL.md +0 -15
- package/skills/wk-handoff/SKILL.md +0 -20
- package/skills/wk-implement/SKILL.md +0 -47
- package/skills/wk-init/SKILL.md +0 -31
- package/skills/wk-issue-update/SKILL.md +0 -27
- package/skills/wk-issue-update/references/youtrack-update-style.md +0 -81
- package/skills/wk-meetings/SKILL.md +0 -17
- package/skills/wk-pr/SKILL.md +0 -27
- package/skills/wk-release-notes/SKILL.md +0 -15
- package/skills/wk-status/SKILL.md +0 -16
- package/skills/wk-verify/SKILL.md +0 -16
- package/src/core/detector.ts +0 -239
- package/src/core/flow-state.ts +0 -3308
- package/src/core/handoff-context.ts +0 -136
- package/src/core/handoff-tools.ts +0 -133
- package/src/core/menu.ts +0 -70
- package/src/core/plan-tasks.ts +0 -43
- package/src/core/reminder.ts +0 -124
- package/src/core/sdd.ts +0 -353
- package/src/state.ts +0 -22
- package/templates/superpowers-doc-contract.md +0 -75
- package/vendor/superpowers/skills/brainstorming/SKILL.md +0 -159
- package/vendor/superpowers/skills/brainstorming/scripts/frame-template.html +0 -213
- package/vendor/superpowers/skills/brainstorming/scripts/helper.js +0 -167
- package/vendor/superpowers/skills/brainstorming/scripts/server.cjs +0 -723
- package/vendor/superpowers/skills/brainstorming/scripts/start-server.sh +0 -209
- package/vendor/superpowers/skills/brainstorming/scripts/stop-server.sh +0 -120
- package/vendor/superpowers/skills/brainstorming/spec-document-reviewer-prompt.md +0 -49
- package/vendor/superpowers/skills/brainstorming/visual-companion.md +0 -291
- package/vendor/superpowers/skills/dispatching-parallel-agents/SKILL.md +0 -185
- package/vendor/superpowers/skills/executing-plans/SKILL.md +0 -70
- package/vendor/superpowers/skills/finishing-a-development-branch/SKILL.md +0 -241
- package/vendor/superpowers/skills/receiving-code-review/SKILL.md +0 -213
- package/vendor/superpowers/skills/requesting-code-review/SKILL.md +0 -103
- package/vendor/superpowers/skills/requesting-code-review/code-reviewer.md +0 -172
- package/vendor/superpowers/skills/subagent-driven-development/SKILL.md +0 -428
- package/vendor/superpowers/skills/subagent-driven-development/implementer-prompt.md +0 -139
- package/vendor/superpowers/skills/subagent-driven-development/scripts/review-package +0 -44
- package/vendor/superpowers/skills/subagent-driven-development/scripts/sdd-workspace +0 -22
- package/vendor/superpowers/skills/subagent-driven-development/scripts/task-brief +0 -40
- package/vendor/superpowers/skills/subagent-driven-development/task-reviewer-prompt.md +0 -188
- package/vendor/superpowers/skills/systematic-debugging/CREATION-LOG.md +0 -119
- package/vendor/superpowers/skills/systematic-debugging/SKILL.md +0 -296
- package/vendor/superpowers/skills/systematic-debugging/condition-based-waiting-example.ts +0 -158
- package/vendor/superpowers/skills/systematic-debugging/condition-based-waiting.md +0 -115
- package/vendor/superpowers/skills/systematic-debugging/defense-in-depth.md +0 -122
- package/vendor/superpowers/skills/systematic-debugging/find-polluter.sh +0 -63
- package/vendor/superpowers/skills/systematic-debugging/root-cause-tracing.md +0 -169
- package/vendor/superpowers/skills/systematic-debugging/test-academic.md +0 -14
- package/vendor/superpowers/skills/systematic-debugging/test-pressure-1.md +0 -58
- package/vendor/superpowers/skills/systematic-debugging/test-pressure-2.md +0 -68
- package/vendor/superpowers/skills/systematic-debugging/test-pressure-3.md +0 -69
- package/vendor/superpowers/skills/test-driven-development/SKILL.md +0 -371
- package/vendor/superpowers/skills/test-driven-development/testing-anti-patterns.md +0 -299
- package/vendor/superpowers/skills/using-git-worktrees/SKILL.md +0 -202
- package/vendor/superpowers/skills/using-superpowers/SKILL.md +0 -62
- package/vendor/superpowers/skills/using-superpowers/references/antigravity-tools.md +0 -23
- package/vendor/superpowers/skills/using-superpowers/references/codex-tools.md +0 -39
- package/vendor/superpowers/skills/using-superpowers/references/pi-tools.md +0 -16
- package/vendor/superpowers/skills/verification-before-completion/SKILL.md +0 -139
- package/vendor/superpowers/skills/writing-plans/SKILL.md +0 -174
- package/vendor/superpowers/skills/writing-plans/plan-document-reviewer-prompt.md +0 -49
- package/vendor/superpowers/skills/writing-skills/SKILL.md +0 -689
- package/vendor/superpowers/skills/writing-skills/anthropic-best-practices.md +0 -1150
- package/vendor/superpowers/skills/writing-skills/examples/CLAUDE_MD_TESTING.md +0 -189
- package/vendor/superpowers/skills/writing-skills/graphviz-conventions.dot +0 -172
- package/vendor/superpowers/skills/writing-skills/persuasion-principles.md +0 -187
- package/vendor/superpowers/skills/writing-skills/render-graphs.js +0 -168
- package/vendor/superpowers/skills/writing-skills/testing-skills-with-subagents.md +0 -384
|
@@ -0,0 +1,1071 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import { hostname } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import * as z from "zod";
|
|
6
|
+
import {
|
|
7
|
+
acquireFileLockSync,
|
|
8
|
+
type FileLockSyncHandle,
|
|
9
|
+
type FileLockSyncAcquireOptions,
|
|
10
|
+
} from "@openclaw/fs-safe/file-lock";
|
|
11
|
+
import {
|
|
12
|
+
SCHEMA_VERSION,
|
|
13
|
+
canonicalJson,
|
|
14
|
+
failure,
|
|
15
|
+
intentSchema,
|
|
16
|
+
newId,
|
|
17
|
+
newRevision,
|
|
18
|
+
provenanceSchema,
|
|
19
|
+
refSchema,
|
|
20
|
+
sha256,
|
|
21
|
+
success,
|
|
22
|
+
taskRecordSchema,
|
|
23
|
+
workspaceRecordSchema,
|
|
24
|
+
type Id,
|
|
25
|
+
type Intent,
|
|
26
|
+
type Ref,
|
|
27
|
+
type Provenance,
|
|
28
|
+
type Result,
|
|
29
|
+
type Revision,
|
|
30
|
+
type TaskRecord,
|
|
31
|
+
type Utc,
|
|
32
|
+
type WorkspaceRecord,
|
|
33
|
+
} from "./task-contract";
|
|
34
|
+
|
|
35
|
+
export type MutationContext = { now: Utc; revision: Revision };
|
|
36
|
+
export type TaskMutation = (task: TaskRecord, context: MutationContext) => Result<TaskRecord>;
|
|
37
|
+
export type WorkspaceMutation = (
|
|
38
|
+
workspace: WorkspaceRecord,
|
|
39
|
+
context: MutationContext,
|
|
40
|
+
) => Result<WorkspaceRecord>;
|
|
41
|
+
export type CoupledMutation = {
|
|
42
|
+
taskId: Id;
|
|
43
|
+
expectedTaskRevision?: Revision;
|
|
44
|
+
expectedRevision?: Revision;
|
|
45
|
+
expectedWorkspaceRevision: Revision;
|
|
46
|
+
now?: Utc;
|
|
47
|
+
task: TaskMutation;
|
|
48
|
+
workspace: WorkspaceMutation;
|
|
49
|
+
};
|
|
50
|
+
export type CoupledSnapshot = { task: TaskRecord; workspace: WorkspaceRecord };
|
|
51
|
+
export type CreateInput = {
|
|
52
|
+
intent: Intent;
|
|
53
|
+
provenance: Provenance;
|
|
54
|
+
expectedWorkspaceRevision: Revision | null;
|
|
55
|
+
now?: Utc;
|
|
56
|
+
};
|
|
57
|
+
export type ImportInput = {
|
|
58
|
+
task: TaskRecord;
|
|
59
|
+
expectedWorkspaceRevision: Revision | null;
|
|
60
|
+
workspaceId?: Id;
|
|
61
|
+
now?: Utc;
|
|
62
|
+
};
|
|
63
|
+
export type RecoveryInput = {
|
|
64
|
+
expectedBytes: string;
|
|
65
|
+
snapshotDigest: string;
|
|
66
|
+
reason: string;
|
|
67
|
+
authorityRefs: Ref[];
|
|
68
|
+
expectedWorkspaceRevision: Revision;
|
|
69
|
+
processEvidence: (
|
|
70
|
+
lock: MetadataLock | null,
|
|
71
|
+
writer: WorkspaceRecord["writer"],
|
|
72
|
+
) => Result<ProcessEvidence>;
|
|
73
|
+
};
|
|
74
|
+
export type MetadataLock = {
|
|
75
|
+
pid: number;
|
|
76
|
+
processStart: string | null;
|
|
77
|
+
host: string;
|
|
78
|
+
nonce: string;
|
|
79
|
+
};
|
|
80
|
+
export type ProcessEvidence = {
|
|
81
|
+
state: "stopped" | "accounted_for";
|
|
82
|
+
pid: number;
|
|
83
|
+
processStart: string | null;
|
|
84
|
+
ownerDigest: string | null;
|
|
85
|
+
};
|
|
86
|
+
const processEvidenceSchema = z
|
|
87
|
+
.object({
|
|
88
|
+
state: z.enum(["stopped", "accounted_for"]),
|
|
89
|
+
pid: z.number().int().nonnegative(),
|
|
90
|
+
processStart: z.string().nullable(),
|
|
91
|
+
ownerDigest: z
|
|
92
|
+
.string()
|
|
93
|
+
.regex(/^[0-9a-f]{64}$/)
|
|
94
|
+
.nullable(),
|
|
95
|
+
})
|
|
96
|
+
.strict();
|
|
97
|
+
const metadataLockSchema = z
|
|
98
|
+
.object({
|
|
99
|
+
pid: z.number().int().nonnegative().safe(),
|
|
100
|
+
processStart: z.string().nullable(),
|
|
101
|
+
host: z.string().min(1),
|
|
102
|
+
nonce: z.string().min(1),
|
|
103
|
+
})
|
|
104
|
+
.strict();
|
|
105
|
+
export type RecoveryCandidate = {
|
|
106
|
+
target: "task" | "workspace";
|
|
107
|
+
path: string;
|
|
108
|
+
digest: string;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const now = (): Utc => new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
112
|
+
const jsonBytes = (value: unknown): string => `${canonicalJson(value)}\n`;
|
|
113
|
+
const digestBytes = (value: string | Buffer): string =>
|
|
114
|
+
createHash("sha256").update(value).digest("hex");
|
|
115
|
+
const isObject = (value: unknown): value is Record<string, unknown> =>
|
|
116
|
+
typeof value === "object" && value !== null;
|
|
117
|
+
const validId = (value: string): boolean =>
|
|
118
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(value);
|
|
119
|
+
const validDigest = (value: string): boolean => /^[0-9a-f]{64}$/.test(value);
|
|
120
|
+
const parseMetadataLock = (raw: string): MetadataLock => {
|
|
121
|
+
let value: unknown;
|
|
122
|
+
try {
|
|
123
|
+
value = JSON.parse(raw);
|
|
124
|
+
} catch {
|
|
125
|
+
throw Object.assign(new Error("metadata lock is invalid"), { code: "metadata_lock_invalid" });
|
|
126
|
+
}
|
|
127
|
+
const parsed = metadataLockSchema.safeParse(value);
|
|
128
|
+
if (!parsed.success)
|
|
129
|
+
throw Object.assign(new Error("metadata lock is invalid"), { code: "metadata_lock_invalid" });
|
|
130
|
+
return parsed.data;
|
|
131
|
+
};
|
|
132
|
+
const sameMetadataLock = (left: unknown, right: MetadataLock): boolean => {
|
|
133
|
+
const parsed = metadataLockSchema.safeParse(left);
|
|
134
|
+
return parsed.success && canonicalJson(parsed.data) === canonicalJson(right);
|
|
135
|
+
};
|
|
136
|
+
type LockSnapshot = { raw: string; data: MetadataLock };
|
|
137
|
+
|
|
138
|
+
export class TaskStore {
|
|
139
|
+
readonly root: string;
|
|
140
|
+
|
|
141
|
+
constructor(root: string) {
|
|
142
|
+
this.root = fs.existsSync(root) ? fs.realpathSync(root) : path.resolve(root);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
readTask(taskId: Id): Result<TaskRecord> {
|
|
146
|
+
if (!validId(taskId)) return failure("invalid_input", "task ID is invalid", { taskId });
|
|
147
|
+
const result = this.readRecord<TaskRecord>(
|
|
148
|
+
path.join(this.tasksDir, `${taskId}.json`),
|
|
149
|
+
taskRecordSchema,
|
|
150
|
+
);
|
|
151
|
+
if (!result.exists) return failure("not_found", "task not found", { taskId });
|
|
152
|
+
if (result.result.ok && result.result.data.id !== taskId)
|
|
153
|
+
return failure("recovery_required", "task filename and record ID differ", { taskId });
|
|
154
|
+
if (result.result.ok) {
|
|
155
|
+
const workspace = this.readWorkspace();
|
|
156
|
+
if (!workspace.ok) return workspace as Result<never>;
|
|
157
|
+
if (!workspace.data || result.result.data.workspaceId !== workspace.data.id)
|
|
158
|
+
return failure("recovery_required", "task workspace binding is invalid", { taskId });
|
|
159
|
+
}
|
|
160
|
+
return result.result;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
listTasks(): Result<TaskRecord[]> {
|
|
164
|
+
if (!fs.existsSync(this.tasksDir)) return success(null, null, []);
|
|
165
|
+
let names: string[];
|
|
166
|
+
try {
|
|
167
|
+
names = fs.readdirSync(this.tasksDir).filter((name) => name.endsWith(".json"));
|
|
168
|
+
} catch (error) {
|
|
169
|
+
return failure("storage_error", `unable to list tasks: ${String(error)}`, {
|
|
170
|
+
path: this.tasksDir,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
const workspace = this.readWorkspace();
|
|
174
|
+
if (!workspace.ok) return workspace as Result<never>;
|
|
175
|
+
if (!workspace.data)
|
|
176
|
+
return failure("recovery_required", "task workspace binding is invalid", {
|
|
177
|
+
path: this.workspacePath,
|
|
178
|
+
});
|
|
179
|
+
const tasks: TaskRecord[] = [];
|
|
180
|
+
for (const name of names.sort()) {
|
|
181
|
+
const item = this.readRecord<TaskRecord>(path.join(this.tasksDir, name), taskRecordSchema);
|
|
182
|
+
if (!item.exists) continue;
|
|
183
|
+
if (!item.result.ok) return item.result;
|
|
184
|
+
if (!validId(name.slice(0, -5)) || item.result.data.id !== name.slice(0, -5))
|
|
185
|
+
return failure("recovery_required", "task filename and record ID differ", { path: name });
|
|
186
|
+
if (item.result.data.workspaceId !== workspace.data.id)
|
|
187
|
+
return failure("recovery_required", "task workspace binding is invalid", { path: name });
|
|
188
|
+
tasks.push(item.result.data);
|
|
189
|
+
}
|
|
190
|
+
return success(null, null, tasks);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
readWorkspace(): Result<WorkspaceRecord | null> {
|
|
194
|
+
const item = this.readRecord<WorkspaceRecord>(this.workspacePath, workspaceRecordSchema);
|
|
195
|
+
if (!item.exists) return success(null, null, null);
|
|
196
|
+
if (item.result.ok && item.result.data.root !== this.root)
|
|
197
|
+
return failure("recovery_required", "workspace root binding is invalid", {
|
|
198
|
+
path: this.workspacePath,
|
|
199
|
+
});
|
|
200
|
+
return item.result;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
create(
|
|
204
|
+
input: CreateInput | Intent,
|
|
205
|
+
provenance?: Provenance,
|
|
206
|
+
expectedWorkspaceRevision?: Revision | null,
|
|
207
|
+
) {
|
|
208
|
+
const value: CreateInput =
|
|
209
|
+
"intent" in input
|
|
210
|
+
? input
|
|
211
|
+
: {
|
|
212
|
+
intent: input,
|
|
213
|
+
provenance: provenance!,
|
|
214
|
+
expectedWorkspaceRevision: expectedWorkspaceRevision ?? null,
|
|
215
|
+
};
|
|
216
|
+
return this.withLock<TaskRecord>(() => {
|
|
217
|
+
const current = this.readWorkspace();
|
|
218
|
+
if (!current.ok) return current;
|
|
219
|
+
if (
|
|
220
|
+
current.data
|
|
221
|
+
? value.expectedWorkspaceRevision !== current.data.revision
|
|
222
|
+
: value.expectedWorkspaceRevision !== null
|
|
223
|
+
) {
|
|
224
|
+
return failure("revision_conflict", "workspace revision does not match", {
|
|
225
|
+
expectedWorkspaceRevision: value.expectedWorkspaceRevision,
|
|
226
|
+
actualWorkspaceRevision: current.data?.revision ?? null,
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
if (!value.provenance) return failure("invalid_input", "provenance is required");
|
|
230
|
+
if (
|
|
231
|
+
!intentSchema.safeParse(value.intent).success ||
|
|
232
|
+
!provenanceSchema.safeParse(value.provenance).success
|
|
233
|
+
)
|
|
234
|
+
return failure("invalid_input", "task intent or provenance is invalid");
|
|
235
|
+
const previousWorkspaceBytes = current.data ? this.snapshotBytes(this.workspacePath) : null;
|
|
236
|
+
if (current.data && !previousWorkspaceBytes)
|
|
237
|
+
return failure("storage_error", "workspace snapshot disappeared during creation");
|
|
238
|
+
const workspace: WorkspaceRecord = current.data
|
|
239
|
+
? { ...current.data, revision: newRevision(), root: this.root }
|
|
240
|
+
: {
|
|
241
|
+
schemaVersion: SCHEMA_VERSION,
|
|
242
|
+
id: newId(),
|
|
243
|
+
revision: newRevision(),
|
|
244
|
+
root: this.root,
|
|
245
|
+
writer: null,
|
|
246
|
+
};
|
|
247
|
+
const timestamp = value.now ?? now();
|
|
248
|
+
const task: TaskRecord = {
|
|
249
|
+
schemaVersion: SCHEMA_VERSION,
|
|
250
|
+
id: newId(),
|
|
251
|
+
workspaceId: workspace.id,
|
|
252
|
+
revision: newRevision(),
|
|
253
|
+
createdAt: timestamp,
|
|
254
|
+
updatedAt: timestamp,
|
|
255
|
+
origin: null,
|
|
256
|
+
intent: {
|
|
257
|
+
id: newId(),
|
|
258
|
+
recordedAt: timestamp,
|
|
259
|
+
provenance: value.provenance,
|
|
260
|
+
data: value.intent,
|
|
261
|
+
},
|
|
262
|
+
constraints: [],
|
|
263
|
+
status: "active",
|
|
264
|
+
closure: null,
|
|
265
|
+
progress: { summary: "", nextAction: null, blockers: [] },
|
|
266
|
+
assessments: [],
|
|
267
|
+
policy: null,
|
|
268
|
+
policyChanges: [],
|
|
269
|
+
candidates: [],
|
|
270
|
+
evidence: [],
|
|
271
|
+
decisions: [],
|
|
272
|
+
findings: [],
|
|
273
|
+
workers: [],
|
|
274
|
+
};
|
|
275
|
+
const writtenWorkspace = this.replaceSnapshot(
|
|
276
|
+
this.workspacePath,
|
|
277
|
+
workspace,
|
|
278
|
+
previousWorkspaceBytes,
|
|
279
|
+
);
|
|
280
|
+
if (!writtenWorkspace.ok) return writtenWorkspace;
|
|
281
|
+
const writtenTask = this.replaceSnapshot(this.taskPath(task.id), task, null);
|
|
282
|
+
if (!writtenTask.ok) {
|
|
283
|
+
if (current.data && previousWorkspaceBytes) {
|
|
284
|
+
const restored = this.replaceSnapshot(
|
|
285
|
+
this.workspacePath,
|
|
286
|
+
current.data,
|
|
287
|
+
this.snapshotBytes(this.workspacePath),
|
|
288
|
+
);
|
|
289
|
+
if (!restored.ok)
|
|
290
|
+
return failure(
|
|
291
|
+
"external_outcome_unknown",
|
|
292
|
+
"task creation failed and workspace restoration is uncertain",
|
|
293
|
+
{ operation: "create", outcome: "unknown" },
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
return failure(
|
|
297
|
+
"external_outcome_unknown",
|
|
298
|
+
"workspace created but task write is uncertain",
|
|
299
|
+
{ operation: "create", outcome: "unknown" },
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
return success(task.revision, workspace.revision, task);
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
importTask(input: ImportInput): Result<TaskRecord> {
|
|
307
|
+
return this.withLock<TaskRecord>(() => {
|
|
308
|
+
const current = this.readWorkspace();
|
|
309
|
+
if (!current.ok) return current;
|
|
310
|
+
if (
|
|
311
|
+
current.data
|
|
312
|
+
? input.expectedWorkspaceRevision !== current.data.revision
|
|
313
|
+
: input.expectedWorkspaceRevision !== null
|
|
314
|
+
)
|
|
315
|
+
return failure("revision_conflict", "workspace revision does not match", {
|
|
316
|
+
expectedWorkspaceRevision: input.expectedWorkspaceRevision,
|
|
317
|
+
actualWorkspaceRevision: current.data?.revision ?? null,
|
|
318
|
+
});
|
|
319
|
+
const previousWorkspaceBytes = current.data ? this.snapshotBytes(this.workspacePath) : null;
|
|
320
|
+
if (current.data && !previousWorkspaceBytes)
|
|
321
|
+
return failure("storage_error", "workspace snapshot disappeared during import");
|
|
322
|
+
const timestamp = input.now ?? now();
|
|
323
|
+
const workspace: WorkspaceRecord = current.data
|
|
324
|
+
? { ...current.data, revision: newRevision(), root: this.root }
|
|
325
|
+
: {
|
|
326
|
+
schemaVersion: SCHEMA_VERSION,
|
|
327
|
+
id: input.workspaceId ?? newId(),
|
|
328
|
+
revision: newRevision(),
|
|
329
|
+
root: this.root,
|
|
330
|
+
writer: null,
|
|
331
|
+
};
|
|
332
|
+
const task = {
|
|
333
|
+
...input.task,
|
|
334
|
+
workspaceId: workspace.id,
|
|
335
|
+
revision: newRevision(),
|
|
336
|
+
createdAt: timestamp,
|
|
337
|
+
updatedAt: timestamp,
|
|
338
|
+
};
|
|
339
|
+
const validTask = taskRecordSchema.safeParse(task);
|
|
340
|
+
if (!validTask.success)
|
|
341
|
+
return failure("invalid_input", "imported task does not satisfy its schema");
|
|
342
|
+
const writtenWorkspace = this.replaceSnapshot(
|
|
343
|
+
this.workspacePath,
|
|
344
|
+
workspace,
|
|
345
|
+
previousWorkspaceBytes,
|
|
346
|
+
);
|
|
347
|
+
if (!writtenWorkspace.ok) return writtenWorkspace;
|
|
348
|
+
const writtenTask = this.replaceSnapshot(this.taskPath(task.id), validTask.data, null);
|
|
349
|
+
if (!writtenTask.ok) {
|
|
350
|
+
if (current.data && previousWorkspaceBytes) {
|
|
351
|
+
const restored = this.replaceSnapshot(
|
|
352
|
+
this.workspacePath,
|
|
353
|
+
current.data,
|
|
354
|
+
this.snapshotBytes(this.workspacePath),
|
|
355
|
+
);
|
|
356
|
+
if (!restored.ok)
|
|
357
|
+
return failure(
|
|
358
|
+
"external_outcome_unknown",
|
|
359
|
+
"import failed and workspace restoration is uncertain",
|
|
360
|
+
{ operation: "import", outcome: "unknown" },
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
return failure(
|
|
364
|
+
"external_outcome_unknown",
|
|
365
|
+
`workspace created but task import is uncertain: ${writtenTask.error}`,
|
|
366
|
+
{
|
|
367
|
+
operation: "import",
|
|
368
|
+
outcome: "unknown",
|
|
369
|
+
path: writtenTask.details.path,
|
|
370
|
+
},
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
return success(validTask.data.revision, workspace.revision, validTask.data);
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
mutateTask(
|
|
378
|
+
taskId: Id,
|
|
379
|
+
expected: Revision,
|
|
380
|
+
update: TaskMutation,
|
|
381
|
+
timestamp?: Utc,
|
|
382
|
+
): Result<TaskRecord> {
|
|
383
|
+
return this.withLock(() => {
|
|
384
|
+
const current = this.readTask(taskId);
|
|
385
|
+
if (!current.ok) return current;
|
|
386
|
+
if (current.data.revision !== expected) return this.conflict(expected, current.data.revision);
|
|
387
|
+
const previousBytes = this.snapshotBytes(this.taskPath(taskId));
|
|
388
|
+
if (!previousBytes)
|
|
389
|
+
return failure("storage_error", "task snapshot disappeared during mutation");
|
|
390
|
+
const context = { now: timestamp ?? now(), revision: newRevision() };
|
|
391
|
+
let changed: Result<TaskRecord>;
|
|
392
|
+
try {
|
|
393
|
+
changed = update(current.data, Object.freeze({ ...context }));
|
|
394
|
+
} catch (error) {
|
|
395
|
+
return failure("storage_error", `task mutation failed: ${String(error)}`);
|
|
396
|
+
}
|
|
397
|
+
if (!changed.ok) return changed;
|
|
398
|
+
const record = {
|
|
399
|
+
...changed.data,
|
|
400
|
+
id: taskId,
|
|
401
|
+
workspaceId: current.data.workspaceId,
|
|
402
|
+
createdAt: current.data.createdAt,
|
|
403
|
+
revision: context.revision,
|
|
404
|
+
updatedAt: context.now,
|
|
405
|
+
};
|
|
406
|
+
const valid = taskRecordSchema.safeParse(record);
|
|
407
|
+
if (!valid.success)
|
|
408
|
+
return failure("invalid_input", "task mutation produced an invalid record");
|
|
409
|
+
const written = this.replaceSnapshot(this.taskPath(taskId), valid.data, previousBytes);
|
|
410
|
+
return written.ok ? success(valid.data.revision, null, valid.data) : written;
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
mutateWorkspace(expected: Revision, update: WorkspaceMutation): Result<WorkspaceRecord> {
|
|
415
|
+
return this.withLock(() => {
|
|
416
|
+
const current = this.readWorkspace();
|
|
417
|
+
if (!current.ok) return current;
|
|
418
|
+
if (!current.data) return failure("not_found", "workspace not found");
|
|
419
|
+
if (current.data.revision !== expected) return this.conflict(expected, current.data.revision);
|
|
420
|
+
const previousBytes = this.snapshotBytes(this.workspacePath);
|
|
421
|
+
if (!previousBytes)
|
|
422
|
+
return failure("storage_error", "workspace snapshot disappeared during mutation");
|
|
423
|
+
const context = { now: now(), revision: newRevision() };
|
|
424
|
+
let changed: Result<WorkspaceRecord>;
|
|
425
|
+
try {
|
|
426
|
+
changed = update(current.data, Object.freeze({ ...context }));
|
|
427
|
+
} catch (error) {
|
|
428
|
+
return failure("storage_error", `workspace mutation failed: ${String(error)}`);
|
|
429
|
+
}
|
|
430
|
+
if (!changed.ok) return changed;
|
|
431
|
+
const record = {
|
|
432
|
+
...changed.data,
|
|
433
|
+
id: current.data.id,
|
|
434
|
+
root: this.root,
|
|
435
|
+
revision: context.revision,
|
|
436
|
+
};
|
|
437
|
+
const valid = workspaceRecordSchema.safeParse(record);
|
|
438
|
+
if (!valid.success)
|
|
439
|
+
return failure("invalid_input", "workspace mutation produced an invalid record");
|
|
440
|
+
const written = this.replaceSnapshot(this.workspacePath, valid.data, previousBytes);
|
|
441
|
+
return written.ok ? success(valid.data.revision, valid.data.revision, valid.data) : written;
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
mutateTaskAndWorkspace(input: CoupledMutation): Result<CoupledSnapshot> {
|
|
446
|
+
return this.withLock(() => {
|
|
447
|
+
const task = this.readTask(input.taskId);
|
|
448
|
+
if (!task.ok) return task;
|
|
449
|
+
const workspace = this.readWorkspace();
|
|
450
|
+
if (!workspace.ok) return workspace;
|
|
451
|
+
if (!workspace.data) return failure("not_found", "workspace not found");
|
|
452
|
+
const expectedTaskRevision = input.expectedTaskRevision ?? input.expectedRevision!;
|
|
453
|
+
if (!expectedTaskRevision) return failure("invalid_input", "task revision is required");
|
|
454
|
+
if (task.data.revision !== expectedTaskRevision)
|
|
455
|
+
return this.conflict(expectedTaskRevision, task.data.revision);
|
|
456
|
+
if (workspace.data.revision !== input.expectedWorkspaceRevision)
|
|
457
|
+
return this.conflict(input.expectedWorkspaceRevision, workspace.data.revision);
|
|
458
|
+
const previousWorkspaceBytes = this.snapshotBytes(this.workspacePath);
|
|
459
|
+
const previousTaskBytes = this.snapshotBytes(this.taskPath(input.taskId));
|
|
460
|
+
if (!previousWorkspaceBytes || !previousTaskBytes)
|
|
461
|
+
return failure("storage_error", "snapshot disappeared during coupled mutation");
|
|
462
|
+
const workspaceContext = { now: input.now ?? now(), revision: newRevision() };
|
|
463
|
+
let changedWorkspace: Result<WorkspaceRecord>;
|
|
464
|
+
try {
|
|
465
|
+
changedWorkspace = input.workspace(workspace.data, Object.freeze({ ...workspaceContext }));
|
|
466
|
+
} catch (error) {
|
|
467
|
+
return failure("storage_error", `workspace mutation failed: ${String(error)}`);
|
|
468
|
+
}
|
|
469
|
+
if (!changedWorkspace.ok) return changedWorkspace;
|
|
470
|
+
const nextWorkspace = workspaceRecordSchema.safeParse({
|
|
471
|
+
...changedWorkspace.data,
|
|
472
|
+
id: workspace.data.id,
|
|
473
|
+
root: this.root,
|
|
474
|
+
revision: workspaceContext.revision,
|
|
475
|
+
});
|
|
476
|
+
if (!nextWorkspace.success)
|
|
477
|
+
return failure("invalid_input", "workspace mutation produced an invalid record");
|
|
478
|
+
const reserved = this.replaceSnapshot(
|
|
479
|
+
this.workspacePath,
|
|
480
|
+
nextWorkspace.data,
|
|
481
|
+
previousWorkspaceBytes,
|
|
482
|
+
);
|
|
483
|
+
if (!reserved.ok) return reserved;
|
|
484
|
+
const taskContext = { now: input.now ?? now(), revision: newRevision() };
|
|
485
|
+
let changedTask: Result<TaskRecord>;
|
|
486
|
+
try {
|
|
487
|
+
changedTask = input.task(task.data, Object.freeze({ ...taskContext }));
|
|
488
|
+
} catch (error) {
|
|
489
|
+
this.markUncertain(nextWorkspace.data);
|
|
490
|
+
return failure(
|
|
491
|
+
"external_outcome_unknown",
|
|
492
|
+
`workspace reserved but task mutation threw: ${String(error)}`,
|
|
493
|
+
{ operation: "coupled_mutation", outcome: "unknown" },
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
if (!changedTask.ok) {
|
|
497
|
+
this.markUncertain(nextWorkspace.data);
|
|
498
|
+
return failure(
|
|
499
|
+
"external_outcome_unknown",
|
|
500
|
+
"workspace reserved but task update is uncertain",
|
|
501
|
+
{ operation: "coupled_mutation", outcome: "unknown" },
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
const nextTask = taskRecordSchema.safeParse({
|
|
505
|
+
...changedTask.data,
|
|
506
|
+
id: input.taskId,
|
|
507
|
+
workspaceId: task.data.workspaceId,
|
|
508
|
+
createdAt: task.data.createdAt,
|
|
509
|
+
revision: taskContext.revision,
|
|
510
|
+
updatedAt: taskContext.now,
|
|
511
|
+
});
|
|
512
|
+
if (!nextTask.success) {
|
|
513
|
+
this.markUncertain(nextWorkspace.data);
|
|
514
|
+
return failure(
|
|
515
|
+
"external_outcome_unknown",
|
|
516
|
+
"workspace reserved but task update is uncertain",
|
|
517
|
+
{ operation: "coupled_mutation", outcome: "unknown" },
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
const written = this.replaceSnapshot(
|
|
521
|
+
this.taskPath(input.taskId),
|
|
522
|
+
nextTask.data,
|
|
523
|
+
previousTaskBytes,
|
|
524
|
+
);
|
|
525
|
+
if (!written.ok) {
|
|
526
|
+
this.markUncertain(nextWorkspace.data);
|
|
527
|
+
return failure(
|
|
528
|
+
"external_outcome_unknown",
|
|
529
|
+
"workspace reserved but task replacement is uncertain",
|
|
530
|
+
{ operation: "coupled_mutation", outcome: "unknown" },
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
return success(nextTask.data.revision, nextWorkspace.data.revision, {
|
|
534
|
+
task: nextTask.data,
|
|
535
|
+
workspace: nextWorkspace.data,
|
|
536
|
+
});
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
recoveryCandidates(): Result<RecoveryCandidate[]> {
|
|
541
|
+
if (!fs.existsSync(this.recoveryDir)) return success(null, null, []);
|
|
542
|
+
try {
|
|
543
|
+
const candidates: RecoveryCandidate[] = [];
|
|
544
|
+
for (const name of fs.readdirSync(this.recoveryDir)) {
|
|
545
|
+
const match = /^(task|workspace)\.([^.]+)\.([0-9a-f]{64})\.json$/.exec(name);
|
|
546
|
+
if (match)
|
|
547
|
+
candidates.push({
|
|
548
|
+
target: match[1] as "task" | "workspace",
|
|
549
|
+
path: path.join(this.recoveryDir, name),
|
|
550
|
+
digest: match[3],
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
return success(null, null, candidates);
|
|
554
|
+
} catch (error) {
|
|
555
|
+
return failure("storage_error", `unable to list recovery: ${String(error)}`, {
|
|
556
|
+
path: this.recoveryDir,
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
recoverTask(taskId: Id, input: RecoveryInput): Result<TaskRecord> {
|
|
562
|
+
return this.recover("task", taskId, input);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
recoverWorkspace(input: RecoveryInput): Result<WorkspaceRecord> {
|
|
566
|
+
return this.recover("workspace", null, input);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
private recover(
|
|
570
|
+
target: "task" | "workspace",
|
|
571
|
+
taskId: Id | null,
|
|
572
|
+
input: RecoveryInput,
|
|
573
|
+
): Result<any> {
|
|
574
|
+
if (taskId !== null && !validId(taskId))
|
|
575
|
+
return failure("invalid_input", "task ID is invalid", { taskId });
|
|
576
|
+
if (
|
|
577
|
+
!input.reason ||
|
|
578
|
+
!Array.isArray(input.authorityRefs) ||
|
|
579
|
+
typeof input.processEvidence !== "function" ||
|
|
580
|
+
!validDigest(input.expectedBytes) ||
|
|
581
|
+
!validDigest(input.snapshotDigest)
|
|
582
|
+
)
|
|
583
|
+
return failure("invalid_input", "recovery authority is invalid");
|
|
584
|
+
if (input.authorityRefs.some((ref) => !refSchema.safeParse(ref).success))
|
|
585
|
+
return failure("invalid_input", "recovery references are invalid");
|
|
586
|
+
try {
|
|
587
|
+
const workspace = this.readWorkspace();
|
|
588
|
+
if (!workspace.ok && target === "task") return workspace;
|
|
589
|
+
const workspaceValue = workspace.ok ? workspace.data : null;
|
|
590
|
+
if (workspaceValue && workspaceValue.revision !== input.expectedWorkspaceRevision)
|
|
591
|
+
return this.conflict(input.expectedWorkspaceRevision, workspaceValue.revision);
|
|
592
|
+
const file = target === "workspace" ? this.workspacePath : this.taskPath(taskId!);
|
|
593
|
+
const currentBytes = this.snapshotBytes(file);
|
|
594
|
+
if (!currentBytes) return failure("not_found", "snapshot not found");
|
|
595
|
+
if (digestBytes(currentBytes) !== input.expectedBytes)
|
|
596
|
+
return failure("revision_conflict", "snapshot bytes do not match expected bytes");
|
|
597
|
+
let selectedBytes = currentBytes;
|
|
598
|
+
if (digestBytes(currentBytes) !== input.snapshotDigest) {
|
|
599
|
+
const candidate = this.findRecovery(target, taskId, input.snapshotDigest, workspaceValue);
|
|
600
|
+
if (!candidate)
|
|
601
|
+
return failure("recovery_required", "validated recovery snapshot not found");
|
|
602
|
+
selectedBytes = candidate;
|
|
603
|
+
}
|
|
604
|
+
const selectedRecord =
|
|
605
|
+
target === "workspace"
|
|
606
|
+
? this.parseBytes<WorkspaceRecord>(selectedBytes, workspaceRecordSchema)
|
|
607
|
+
: this.parseBytes<TaskRecord>(selectedBytes, taskRecordSchema);
|
|
608
|
+
if (!selectedRecord.ok) return selectedRecord;
|
|
609
|
+
if (target === "workspace") {
|
|
610
|
+
const parsed = selectedRecord as Result<WorkspaceRecord>;
|
|
611
|
+
if (!parsed.ok || parsed.data.root !== this.root)
|
|
612
|
+
return failure("recovery_required", "workspace recovery binding is invalid");
|
|
613
|
+
} else {
|
|
614
|
+
const parsed = selectedRecord as Result<TaskRecord>;
|
|
615
|
+
if (
|
|
616
|
+
!parsed.ok ||
|
|
617
|
+
parsed.data.id !== taskId ||
|
|
618
|
+
!workspaceValue ||
|
|
619
|
+
parsed.data.workspaceId !== workspaceValue.id
|
|
620
|
+
)
|
|
621
|
+
return failure("recovery_required", "task recovery binding is invalid");
|
|
622
|
+
}
|
|
623
|
+
const lock = this.readLockSnapshot();
|
|
624
|
+
if (!lock.ok) return lock;
|
|
625
|
+
const writer =
|
|
626
|
+
target === "workspace"
|
|
627
|
+
? (selectedRecord.data as WorkspaceRecord).writer
|
|
628
|
+
: (workspaceValue?.writer ?? null);
|
|
629
|
+
const evidence = this.processEvidence(input, lock.data?.data ?? null, writer);
|
|
630
|
+
if (!evidence.ok) return evidence;
|
|
631
|
+
if (
|
|
632
|
+
lock.data &&
|
|
633
|
+
(evidence.data.pid !== lock.data.data.pid ||
|
|
634
|
+
evidence.data.processStart !== lock.data.data.processStart)
|
|
635
|
+
)
|
|
636
|
+
return failure("recovery_required", "process evidence does not match metadata lock");
|
|
637
|
+
if (
|
|
638
|
+
writer &&
|
|
639
|
+
(evidence.data.state !== "accounted_for" ||
|
|
640
|
+
evidence.data.ownerDigest !== sha256(canonicalJson(writer)))
|
|
641
|
+
)
|
|
642
|
+
return failure("recovery_required", "workspace writer is not accounted for");
|
|
643
|
+
const recoveryGate = { reclaimed: false };
|
|
644
|
+
const result = this.withLock<any>(
|
|
645
|
+
(handle) => {
|
|
646
|
+
if (lock.data && !recoveryGate.reclaimed)
|
|
647
|
+
return failure("recovery_required", "metadata lock changed during recovery", {
|
|
648
|
+
path: this.lockPath,
|
|
649
|
+
});
|
|
650
|
+
if (!handle.verifyStillHeld())
|
|
651
|
+
return failure("recovery_required", "metadata lock was compromised", {
|
|
652
|
+
path: this.lockPath,
|
|
653
|
+
});
|
|
654
|
+
const reacquiredBytes = this.snapshotBytes(file);
|
|
655
|
+
if (!reacquiredBytes)
|
|
656
|
+
return failure("recovery_required", "snapshot disappeared during recovery");
|
|
657
|
+
if (digestBytes(reacquiredBytes) !== input.expectedBytes)
|
|
658
|
+
return failure("revision_conflict", "snapshot changed during recovery");
|
|
659
|
+
if (target === "task") {
|
|
660
|
+
const currentWorkspace = this.readWorkspace();
|
|
661
|
+
if (!currentWorkspace.ok || !currentWorkspace.data)
|
|
662
|
+
return failure("recovery_required", "workspace changed during recovery");
|
|
663
|
+
if (currentWorkspace.data.revision !== input.expectedWorkspaceRevision)
|
|
664
|
+
return this.conflict(input.expectedWorkspaceRevision, currentWorkspace.data.revision);
|
|
665
|
+
}
|
|
666
|
+
if (target === "workspace") {
|
|
667
|
+
const parsed = this.parseBytes<WorkspaceRecord>(selectedBytes, workspaceRecordSchema);
|
|
668
|
+
if (!parsed.ok) return parsed;
|
|
669
|
+
const value = { ...parsed.data, revision: newRevision(), writer: null };
|
|
670
|
+
const replaced = this.replaceSnapshot(file, value, reacquiredBytes);
|
|
671
|
+
return replaced.ok ? success(value.revision, value.revision, value) : replaced;
|
|
672
|
+
}
|
|
673
|
+
const parsed = this.parseBytes<TaskRecord>(selectedBytes, taskRecordSchema);
|
|
674
|
+
if (!parsed.ok) return parsed;
|
|
675
|
+
const value = {
|
|
676
|
+
...parsed.data,
|
|
677
|
+
revision: newRevision(),
|
|
678
|
+
updatedAt: now(),
|
|
679
|
+
status: parsed.data.status === "active" ? "paused" : parsed.data.status,
|
|
680
|
+
} as TaskRecord;
|
|
681
|
+
const replaced = this.replaceSnapshot(file, value, reacquiredBytes);
|
|
682
|
+
return replaced.ok ? success(value.revision, null, value) : replaced;
|
|
683
|
+
},
|
|
684
|
+
this.recoveryLockOptions(lock.data, evidence.data, recoveryGate),
|
|
685
|
+
);
|
|
686
|
+
return result;
|
|
687
|
+
} catch (error) {
|
|
688
|
+
return failure("recovery_required", `recovery protocol failed: ${String(error)}`);
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
private processEvidence(
|
|
693
|
+
input: RecoveryInput,
|
|
694
|
+
lock: MetadataLock | null,
|
|
695
|
+
writer: WorkspaceRecord["writer"],
|
|
696
|
+
): Result<ProcessEvidence> {
|
|
697
|
+
const lockCopy = lock ? this.deepFreeze(structuredClone(lock)) : null;
|
|
698
|
+
const writerCopy = writer ? this.deepFreeze(structuredClone(writer)) : null;
|
|
699
|
+
const lockDigest = lockCopy ? sha256(canonicalJson(lockCopy)) : null;
|
|
700
|
+
const writerDigest = writerCopy ? sha256(canonicalJson(writerCopy)) : null;
|
|
701
|
+
try {
|
|
702
|
+
const result = input.processEvidence(lockCopy, writerCopy);
|
|
703
|
+
if (lockCopy && sha256(canonicalJson(lockCopy)) !== lockDigest)
|
|
704
|
+
return failure("recovery_required", "process evidence mutated lock identity");
|
|
705
|
+
if (writerCopy && sha256(canonicalJson(writerCopy)) !== writerDigest)
|
|
706
|
+
return failure("recovery_required", "process evidence mutated writer identity");
|
|
707
|
+
if (!result.ok) return result;
|
|
708
|
+
const parsed = processEvidenceSchema.safeParse(result.data);
|
|
709
|
+
return parsed.success
|
|
710
|
+
? success(null, null, parsed.data)
|
|
711
|
+
: failure("recovery_required", "process evidence is invalid");
|
|
712
|
+
} catch (error) {
|
|
713
|
+
return failure("recovery_required", `process evidence failed: ${String(error)}`);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
private deepFreeze<T>(value: T): T {
|
|
718
|
+
if (value && typeof value === "object") {
|
|
719
|
+
for (const child of Object.values(value as Record<string, unknown>)) this.deepFreeze(child);
|
|
720
|
+
Object.freeze(value);
|
|
721
|
+
}
|
|
722
|
+
return value;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
private readLockSnapshot(): Result<LockSnapshot | null> {
|
|
726
|
+
try {
|
|
727
|
+
const stat = fs.lstatSync(this.lockPath);
|
|
728
|
+
if (!stat.isFile() || stat.isSymbolicLink())
|
|
729
|
+
return failure("recovery_required", "metadata lock is not a regular file", {
|
|
730
|
+
path: this.lockPath,
|
|
731
|
+
});
|
|
732
|
+
const raw = fs.readFileSync(this.lockPath, "utf8");
|
|
733
|
+
return success(null, null, { raw, data: parseMetadataLock(raw) });
|
|
734
|
+
} catch (error: any) {
|
|
735
|
+
if (error?.code === "ENOENT") return success(null, null, null);
|
|
736
|
+
return failure("recovery_required", `metadata lock is invalid: ${String(error)}`, {
|
|
737
|
+
path: this.lockPath,
|
|
738
|
+
});
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
private metadataLockOptions(): FileLockSyncAcquireOptions<MetadataLock> {
|
|
743
|
+
return {
|
|
744
|
+
lockPath: this.lockPath,
|
|
745
|
+
staleMs: Number.MAX_SAFE_INTEGER,
|
|
746
|
+
timeoutMs: 0,
|
|
747
|
+
retry: { retries: 0 },
|
|
748
|
+
staleRecovery: "fail-closed",
|
|
749
|
+
shouldReclaim: () => false,
|
|
750
|
+
parsePayload: parseMetadataLock,
|
|
751
|
+
payload: () => ({
|
|
752
|
+
pid: process.pid,
|
|
753
|
+
processStart: this.processStart(process.pid),
|
|
754
|
+
host: hostname(),
|
|
755
|
+
nonce: randomUUID(),
|
|
756
|
+
}),
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
private recoveryLockOptions(
|
|
761
|
+
observed: LockSnapshot | null,
|
|
762
|
+
evidence: ProcessEvidence,
|
|
763
|
+
gate: { reclaimed: boolean },
|
|
764
|
+
): FileLockSyncAcquireOptions<MetadataLock> {
|
|
765
|
+
const options = this.metadataLockOptions();
|
|
766
|
+
options.staleRecovery = "remove-if-unchanged";
|
|
767
|
+
options.shouldReclaim = ({ payload }) =>
|
|
768
|
+
Boolean(
|
|
769
|
+
observed &&
|
|
770
|
+
sameMetadataLock(payload, observed.data) &&
|
|
771
|
+
(evidence.state === "stopped" || evidence.state === "accounted_for"),
|
|
772
|
+
);
|
|
773
|
+
options.shouldRemoveStaleLock = ({ raw, payload }) => {
|
|
774
|
+
if (!observed || raw !== observed.raw || !sameMetadataLock(payload, observed.data))
|
|
775
|
+
return false;
|
|
776
|
+
gate.reclaimed = true;
|
|
777
|
+
return true;
|
|
778
|
+
};
|
|
779
|
+
return options;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
private markUncertain(workspace: WorkspaceRecord) {
|
|
783
|
+
if (!workspace.writer || workspace.writer.state === "uncertain") return;
|
|
784
|
+
const value = {
|
|
785
|
+
...workspace,
|
|
786
|
+
writer: { ...workspace.writer, state: "uncertain" as const },
|
|
787
|
+
revision: newRevision(),
|
|
788
|
+
};
|
|
789
|
+
this.replaceSnapshot(this.workspacePath, value, workspace);
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
private withLock<T>(
|
|
793
|
+
operation: (handle: FileLockSyncHandle) => Result<T>,
|
|
794
|
+
options: FileLockSyncAcquireOptions<MetadataLock> = this.metadataLockOptions(),
|
|
795
|
+
): Result<T> {
|
|
796
|
+
try {
|
|
797
|
+
this.initializeMutationStorage();
|
|
798
|
+
} catch (error) {
|
|
799
|
+
return failure("storage_error", `unable to initialize store: ${String(error)}`, {
|
|
800
|
+
path: this.workitDir,
|
|
801
|
+
});
|
|
802
|
+
}
|
|
803
|
+
let handle: FileLockSyncHandle | undefined;
|
|
804
|
+
let result: Result<T> = failure(
|
|
805
|
+
"storage_error",
|
|
806
|
+
"metadata lock operation did not produce a result",
|
|
807
|
+
);
|
|
808
|
+
try {
|
|
809
|
+
handle = acquireFileLockSync(this.workspacePath, options);
|
|
810
|
+
} catch (error) {
|
|
811
|
+
result = this.lockFailure(error);
|
|
812
|
+
}
|
|
813
|
+
if (handle) {
|
|
814
|
+
try {
|
|
815
|
+
if (!handle.verifyStillHeld())
|
|
816
|
+
result = failure("recovery_required", "metadata lock was compromised", {
|
|
817
|
+
path: this.lockPath,
|
|
818
|
+
});
|
|
819
|
+
else {
|
|
820
|
+
try {
|
|
821
|
+
result = operation(handle);
|
|
822
|
+
} catch (error) {
|
|
823
|
+
result = failure("storage_error", `mutation failed: ${String(error)}`);
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
} catch (error) {
|
|
827
|
+
result = this.lockFailure(error);
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
if (handle) {
|
|
831
|
+
try {
|
|
832
|
+
handle.release();
|
|
833
|
+
} catch (error) {
|
|
834
|
+
const releaseFailure = this.lockFailure(error);
|
|
835
|
+
const releaseError = releaseFailure.ok
|
|
836
|
+
? "metadata lock release failed"
|
|
837
|
+
: releaseFailure.error;
|
|
838
|
+
result = result.ok
|
|
839
|
+
? releaseFailure
|
|
840
|
+
: failure("recovery_required", `${result.error}; ${releaseError}`, result.details);
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
return result;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
private lockFailure(error: unknown): Result<never> {
|
|
847
|
+
const value = error as { code?: unknown; message?: unknown };
|
|
848
|
+
const code = typeof value?.code === "string" ? value.code : "";
|
|
849
|
+
const recovery =
|
|
850
|
+
code === "EEXIST" ||
|
|
851
|
+
code === "file_lock_timeout" ||
|
|
852
|
+
code === "file_lock_stale" ||
|
|
853
|
+
code === "metadata_lock_invalid" ||
|
|
854
|
+
code === "not-file" ||
|
|
855
|
+
/metadata lock|file lock|reclaim/i.test(String(value?.message ?? error));
|
|
856
|
+
return failure(
|
|
857
|
+
recovery ? "recovery_required" : "storage_error",
|
|
858
|
+
`metadata lock operation failed: ${String(error)}`,
|
|
859
|
+
{ path: this.lockPath },
|
|
860
|
+
);
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
private initializeMutationStorage() {
|
|
864
|
+
fs.mkdirSync(this.tasksDir, { recursive: true });
|
|
865
|
+
fs.mkdirSync(this.recoveryDir, { recursive: true });
|
|
866
|
+
fs.writeFileSync(this.gitignorePath, "*\n");
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
private replaceSnapshot(file: string, value: unknown, previous: unknown): Result<any> {
|
|
870
|
+
let temporary: string | undefined;
|
|
871
|
+
try {
|
|
872
|
+
if (Buffer.isBuffer(previous)) this.saveRecovery(file, previous);
|
|
873
|
+
else if (previous !== null && typeof previous === "object")
|
|
874
|
+
this.saveRecovery(file, jsonBytes(previous));
|
|
875
|
+
else if (typeof previous === "string") this.saveRecovery(file, previous);
|
|
876
|
+
temporary = `${file}.${process.pid}.${randomUUID()}.tmp`;
|
|
877
|
+
const fd = fs.openSync(temporary, "wx", 0o600);
|
|
878
|
+
try {
|
|
879
|
+
fs.writeSync(fd, jsonBytes(value));
|
|
880
|
+
fs.fsyncSync(fd);
|
|
881
|
+
} finally {
|
|
882
|
+
fs.closeSync(fd);
|
|
883
|
+
}
|
|
884
|
+
fs.renameSync(temporary, file);
|
|
885
|
+
temporary = undefined;
|
|
886
|
+
this.fsyncDirectory(path.dirname(file));
|
|
887
|
+
return success(null, null, value);
|
|
888
|
+
} catch (error) {
|
|
889
|
+
return failure("storage_error", `snapshot replacement failed: ${String(error)}`, {
|
|
890
|
+
path: file,
|
|
891
|
+
});
|
|
892
|
+
} finally {
|
|
893
|
+
if (temporary)
|
|
894
|
+
try {
|
|
895
|
+
fs.unlinkSync(temporary);
|
|
896
|
+
} catch {}
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
private saveRecovery(file: string, bytes: string | Buffer) {
|
|
901
|
+
const target = path.basename(file) === "workspace.json" ? "workspace" : "task";
|
|
902
|
+
const id = target === "task" ? path.basename(file, ".json") : "workspace";
|
|
903
|
+
const destination = path.join(this.recoveryDir, `${target}.${id}.${digestBytes(bytes)}.json`);
|
|
904
|
+
if (fs.existsSync(destination)) {
|
|
905
|
+
if (digestBytes(fs.readFileSync(destination)) === digestBytes(bytes)) return;
|
|
906
|
+
throw new Error("recovery copy already exists with different bytes");
|
|
907
|
+
}
|
|
908
|
+
let temporary: string | undefined;
|
|
909
|
+
try {
|
|
910
|
+
temporary = `${destination}.${process.pid}.${randomUUID()}.tmp`;
|
|
911
|
+
const fd = fs.openSync(temporary, "wx", 0o600);
|
|
912
|
+
try {
|
|
913
|
+
fs.writeSync(fd, bytes as any);
|
|
914
|
+
fs.fsyncSync(fd);
|
|
915
|
+
} finally {
|
|
916
|
+
fs.closeSync(fd);
|
|
917
|
+
}
|
|
918
|
+
fs.renameSync(temporary, destination);
|
|
919
|
+
temporary = undefined;
|
|
920
|
+
this.fsyncDirectory(this.recoveryDir);
|
|
921
|
+
} finally {
|
|
922
|
+
if (temporary)
|
|
923
|
+
try {
|
|
924
|
+
fs.unlinkSync(temporary);
|
|
925
|
+
} catch {}
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
private findRecovery(
|
|
930
|
+
target: "task" | "workspace",
|
|
931
|
+
taskId: Id | null,
|
|
932
|
+
digest: string,
|
|
933
|
+
workspace: WorkspaceRecord | null,
|
|
934
|
+
): Buffer | null {
|
|
935
|
+
if (!fs.existsSync(this.recoveryDir)) return null;
|
|
936
|
+
const workspaceId = workspace?.id ?? this.trustedWorkspaceId();
|
|
937
|
+
if (target === "workspace" && !workspaceId) return null;
|
|
938
|
+
const candidates = this.recoveryCandidates();
|
|
939
|
+
if (!candidates.ok) return null;
|
|
940
|
+
for (const candidate of candidates.data) {
|
|
941
|
+
if (candidate.target !== target || candidate.digest !== digest) continue;
|
|
942
|
+
try {
|
|
943
|
+
if (!fs.lstatSync(candidate.path).isFile()) continue;
|
|
944
|
+
const bytes = fs.readFileSync(candidate.path);
|
|
945
|
+
if (digestBytes(bytes) !== digest) continue;
|
|
946
|
+
if (target === "workspace") {
|
|
947
|
+
const parsed = this.parseBytes<WorkspaceRecord>(bytes, workspaceRecordSchema);
|
|
948
|
+
if (parsed.ok && parsed.data.id === workspaceId && parsed.data.root === this.root)
|
|
949
|
+
return bytes;
|
|
950
|
+
} else {
|
|
951
|
+
const parsed = this.parseBytes<TaskRecord>(bytes, taskRecordSchema);
|
|
952
|
+
if (parsed.ok && parsed.data.id === taskId && parsed.data.workspaceId === workspace?.id)
|
|
953
|
+
return bytes;
|
|
954
|
+
}
|
|
955
|
+
} catch {}
|
|
956
|
+
}
|
|
957
|
+
return null;
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
private fsyncDirectory(directory: string) {
|
|
961
|
+
try {
|
|
962
|
+
const fd = fs.openSync(directory, "r");
|
|
963
|
+
try {
|
|
964
|
+
fs.fsyncSync(fd);
|
|
965
|
+
} finally {
|
|
966
|
+
fs.closeSync(fd);
|
|
967
|
+
}
|
|
968
|
+
} catch (error) {
|
|
969
|
+
// Windows rejects fsync on a directory handle; the rename is the durable step there.
|
|
970
|
+
if (process.platform !== "win32") throw error;
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
private trustedWorkspaceId(): Id | null {
|
|
975
|
+
if (!fs.existsSync(this.tasksDir)) return null;
|
|
976
|
+
const ids = new Set<Id>();
|
|
977
|
+
try {
|
|
978
|
+
for (const name of fs.readdirSync(this.tasksDir)) {
|
|
979
|
+
if (!name.endsWith(".json") || !validId(name.slice(0, -5))) return null;
|
|
980
|
+
const parsed = this.parseBytes<TaskRecord>(
|
|
981
|
+
fs.readFileSync(path.join(this.tasksDir, name)),
|
|
982
|
+
taskRecordSchema,
|
|
983
|
+
);
|
|
984
|
+
if (!parsed.ok || parsed.data.id !== name.slice(0, -5)) return null;
|
|
985
|
+
ids.add(parsed.data.workspaceId);
|
|
986
|
+
}
|
|
987
|
+
} catch {
|
|
988
|
+
return null;
|
|
989
|
+
}
|
|
990
|
+
return ids.size === 1 ? [...ids][0] : null;
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
private snapshotBytes(file: string): Buffer | null {
|
|
994
|
+
try {
|
|
995
|
+
return fs.readFileSync(file);
|
|
996
|
+
} catch {
|
|
997
|
+
return null;
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
private readRecord<T>(file: string, schema: { safeParse(value: unknown): any }) {
|
|
1002
|
+
try {
|
|
1003
|
+
const bytes = fs.readFileSync(file, "utf8");
|
|
1004
|
+
return { exists: true, result: this.parseBytes<T>(bytes, schema) };
|
|
1005
|
+
} catch (error: any) {
|
|
1006
|
+
if (error?.code === "ENOENT")
|
|
1007
|
+
return { exists: false, result: success(null, null, null as T) };
|
|
1008
|
+
return {
|
|
1009
|
+
exists: true,
|
|
1010
|
+
result: failure("recovery_required", `snapshot cannot be read: ${String(error)}`, {
|
|
1011
|
+
path: file,
|
|
1012
|
+
}),
|
|
1013
|
+
};
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
private parseBytes<T>(
|
|
1018
|
+
bytes: string | Buffer,
|
|
1019
|
+
schema: { safeParse(value: unknown): any },
|
|
1020
|
+
): Result<T> {
|
|
1021
|
+
let value: unknown;
|
|
1022
|
+
try {
|
|
1023
|
+
value = JSON.parse(typeof bytes === "string" ? bytes : bytes.toString("utf8"));
|
|
1024
|
+
} catch {
|
|
1025
|
+
return failure("recovery_required", "snapshot JSON is corrupt");
|
|
1026
|
+
}
|
|
1027
|
+
if (isObject(value) && "schemaVersion" in value && value.schemaVersion !== SCHEMA_VERSION)
|
|
1028
|
+
return failure("unsupported_version", "unsupported snapshot schema version");
|
|
1029
|
+
const parsed = schema.safeParse(value);
|
|
1030
|
+
return parsed.success
|
|
1031
|
+
? success(null, null, parsed.data)
|
|
1032
|
+
: failure("recovery_required", "snapshot does not satisfy its schema");
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
private processStart(pid: number): string | null {
|
|
1036
|
+
try {
|
|
1037
|
+
return fs.readFileSync(`/proc/${pid}/stat`, "utf8").split(" ")[21] ?? null;
|
|
1038
|
+
} catch {
|
|
1039
|
+
return null;
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
private conflict(expected: Revision, actual: Revision): Result<never> {
|
|
1044
|
+
return failure("revision_conflict", "snapshot revision does not match", {
|
|
1045
|
+
expectedRevision: expected,
|
|
1046
|
+
actualRevision: actual,
|
|
1047
|
+
});
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
private taskPath(taskId: Id) {
|
|
1051
|
+
return path.join(this.tasksDir, `${taskId}.json`);
|
|
1052
|
+
}
|
|
1053
|
+
private get workitDir() {
|
|
1054
|
+
return path.join(this.root, ".workit");
|
|
1055
|
+
}
|
|
1056
|
+
private get tasksDir() {
|
|
1057
|
+
return path.join(this.workitDir, "tasks");
|
|
1058
|
+
}
|
|
1059
|
+
private get recoveryDir() {
|
|
1060
|
+
return path.join(this.workitDir, "recovery");
|
|
1061
|
+
}
|
|
1062
|
+
private get workspacePath() {
|
|
1063
|
+
return path.join(this.workitDir, "workspace.json");
|
|
1064
|
+
}
|
|
1065
|
+
private get lockPath() {
|
|
1066
|
+
return path.join(this.workitDir, "metadata.lock");
|
|
1067
|
+
}
|
|
1068
|
+
private get gitignorePath() {
|
|
1069
|
+
return path.join(this.workitDir, ".gitignore");
|
|
1070
|
+
}
|
|
1071
|
+
}
|