@brainervirus/workit-core 3.0.0 → 5.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 +1 -1
- package/package.json +1 -1
- package/skills/workit-bdd/SKILL.md +35 -29
- package/skills/workit-continue/SKILL.md +53 -0
- package/skills/workit-debug/SKILL.md +41 -36
- package/skills/workit-deslop/SKILL.md +35 -25
- package/skills/workit-fanout/SKILL.md +62 -0
- package/skills/workit-fanout/references/brief.md +56 -0
- package/skills/workit-implement/SKILL.md +48 -44
- package/skills/workit-review/SKILL.md +42 -51
- package/skills/workit-review/references/impact.md +24 -0
- package/skills/workit-shape/SKILL.md +71 -0
- package/skills/workit-shape/references/diagrams.md +17 -0
- package/skills/workit-shape/references/knowledge.md +58 -0
- package/skills/workit-shape/references/mockups.md +15 -0
- package/skills/workit-shape/references/slicing.md +42 -0
- package/skills/workit-ship/SKILL.md +52 -0
- package/skills/workit-test-audit/SKILL.md +10 -2
- package/skills/workit-verify-app/SKILL.md +63 -0
- package/skills/workit-verify-app/references/template.md +49 -0
- package/src/core/external-action-effects.ts +2 -2
- package/src/core/methods.ts +68 -95
- package/src/core/skill-manifests.ts +49 -25
- package/src/core/store-lock.ts +13 -2
- package/src/core/task-contract.ts +17 -18
- package/src/core/task-engine.ts +108 -58
- package/src/core/task-store.ts +1857 -1259
- package/src/core.ts +1 -6
- package/src/hooks/context.ts +57 -5
- package/src/hooks/handle.ts +29 -5
- package/src/hooks/hosts/codex.ts +1 -1
- package/src/ledger.ts +31 -28
- package/src/store/event-log.ts +217 -0
- package/src/store/notes.ts +29 -0
- package/src/store/patch.ts +135 -0
- package/src/store/paths.ts +368 -0
- package/src/store/reduce.ts +203 -0
- package/templates/plan-template.md +17 -18
- package/templates/spec-template.md +4 -3
- package/skills/workit-babysit/SKILL.md +0 -37
- package/skills/workit-behavioral-tdd/SKILL.md +0 -56
- package/skills/workit-blast-radius/SKILL.md +0 -26
- package/skills/workit-challenge/SKILL.md +0 -47
- package/skills/workit-diagram/SKILL.md +0 -27
- package/skills/workit-green-run/SKILL.md +0 -24
- package/skills/workit-handoff/SKILL.md +0 -37
- package/skills/workit-mockup/SKILL.md +0 -23
- package/skills/workit-plan/SKILL.md +0 -45
- package/skills/workit-steer/SKILL.md +0 -39
- package/templates/execution-contract.md +0 -40
package/src/core/task-store.ts
CHANGED
|
@@ -1,14 +1,39 @@
|
|
|
1
|
-
|
|
1
|
+
// The task store (design §4.1; D3, D13, D17).
|
|
2
|
+
//
|
|
3
|
+
// Layout under the store directory (`<git common dir>/workit`, or
|
|
4
|
+
// `<root>/.workit` outside git; see store/paths.ts):
|
|
5
|
+
//
|
|
6
|
+
// store.json format marker (a newer format fails closed)
|
|
7
|
+
// checkouts/<slug>/workspace.json this checkout's workspace record
|
|
8
|
+
// checkouts/<slug>/metadata.lock checkout lock: workspace writes, task
|
|
9
|
+
// creation and managed external actions
|
|
10
|
+
// tasks/<id>/events.jsonl the task's append-only event log
|
|
11
|
+
// tasks/<id>/snapshot.json rebuildable cache of the reduced state
|
|
12
|
+
// tasks/<id>/lock task lock: one append at a time
|
|
13
|
+
// task-index.json rebuildable listing cache for hooks
|
|
14
|
+
// blobs/candidates/<sha256>.json stored candidates, written once by content
|
|
15
|
+
// legacy/<slug>/v2/ backup of a migrated 2.x `.workit` store
|
|
16
|
+
//
|
|
17
|
+
// Each task mutation appends one event holding a structural patch of the
|
|
18
|
+
// record, so state grows with the change, never with full-file copies, and
|
|
19
|
+
// there is no recovery directory: the log is the history. `workit gc` folds
|
|
20
|
+
// old events into a checkpoint. A checkout's work belongs to an implicit task
|
|
21
|
+
// keyed by its branch (detached HEAD: by worktree; outside git: by directory);
|
|
22
|
+
// the first mutation creates it.
|
|
23
|
+
//
|
|
24
|
+
// A 2.x store in `<root>/.workit` is migrated on first use (idempotent, under
|
|
25
|
+
// the checkout lock, backup kept). The migrated `.workit/workspace.json` is
|
|
26
|
+
// replaced by a marker that 2.x readers reject with an upgrade message.
|
|
2
27
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
28
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
29
|
import * as fs from "node:fs";
|
|
4
30
|
import path from "node:path";
|
|
5
|
-
import * as z from "zod";
|
|
6
|
-
import { packageRoot } from "./package-root";
|
|
7
31
|
import {
|
|
8
32
|
acquireFileLockSync,
|
|
9
33
|
type FileLockSyncHandle,
|
|
10
34
|
type FileLockSyncAcquireOptions,
|
|
11
35
|
} from "@openclaw/fs-safe/file-lock";
|
|
36
|
+
import { packageRoot } from "./package-root";
|
|
12
37
|
import {
|
|
13
38
|
SCHEMA_VERSION,
|
|
14
39
|
canonicalJson,
|
|
@@ -18,14 +43,12 @@ import {
|
|
|
18
43
|
newRevision,
|
|
19
44
|
parseStoredRecord,
|
|
20
45
|
provenanceSchema,
|
|
21
|
-
refSchema,
|
|
22
46
|
sha256,
|
|
23
47
|
success,
|
|
24
48
|
taskRecordSchema,
|
|
25
49
|
workspaceRecordSchema,
|
|
26
50
|
type Id,
|
|
27
51
|
type Intent,
|
|
28
|
-
type Ref,
|
|
29
52
|
type Provenance,
|
|
30
53
|
type Result,
|
|
31
54
|
type Revision,
|
|
@@ -35,38 +58,91 @@ import {
|
|
|
35
58
|
} from "./task-contract";
|
|
36
59
|
import {
|
|
37
60
|
classifyLockOwner,
|
|
61
|
+
clearAbandonedReclaimGuard,
|
|
38
62
|
defaultLockTimeout,
|
|
39
63
|
localLockHost,
|
|
40
|
-
clearAbandonedReclaimGuard,
|
|
41
|
-
parseMetadataLock,
|
|
42
64
|
parseMetadataLockOrNull,
|
|
43
65
|
processStartOf,
|
|
44
|
-
sameMetadataLock,
|
|
45
66
|
type MetadataLock,
|
|
46
67
|
} from "./store-lock";
|
|
68
|
+
import {
|
|
69
|
+
EVENT_VERSION,
|
|
70
|
+
LogDamage,
|
|
71
|
+
appendEvent,
|
|
72
|
+
isTransientWindowsError,
|
|
73
|
+
readLog,
|
|
74
|
+
replaceFile,
|
|
75
|
+
retryTransient,
|
|
76
|
+
type EventActor,
|
|
77
|
+
type LogRead,
|
|
78
|
+
type StoreEvent,
|
|
79
|
+
} from "../store/event-log";
|
|
80
|
+
import { diff } from "../store/patch";
|
|
81
|
+
import {
|
|
82
|
+
checkoutRootOf,
|
|
83
|
+
checkoutSlug,
|
|
84
|
+
branchRenames,
|
|
85
|
+
followRenames,
|
|
86
|
+
resolveStore,
|
|
87
|
+
resolveTaskKey,
|
|
88
|
+
type StoreLocation,
|
|
89
|
+
type TaskKey,
|
|
90
|
+
} from "../store/paths";
|
|
91
|
+
import {
|
|
92
|
+
OPENING_TYPES,
|
|
93
|
+
encodeOps,
|
|
94
|
+
encodeRecord,
|
|
95
|
+
eventType,
|
|
96
|
+
reduce,
|
|
97
|
+
type BlobStore,
|
|
98
|
+
type LegacyOrigin,
|
|
99
|
+
type ReducedTask,
|
|
100
|
+
} from "../store/reduce";
|
|
101
|
+
|
|
102
|
+
import { migrationReporterInstalled, reportMigration } from "../store/notes";
|
|
103
|
+
|
|
104
|
+
/** Prefix of the `needs_input` error a read gets while a migration is pending. */
|
|
105
|
+
export const MIGRATION_PENDING = "workit migration pending";
|
|
106
|
+
|
|
107
|
+
export type { TaskKey } from "../store/paths";
|
|
47
108
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
109
|
+
/** The store format this runtime writes; a newer marker fails closed. */
|
|
110
|
+
export const STORE_FORMAT = 3;
|
|
111
|
+
/** The runtime version 2.x readers are told to upgrade to. */
|
|
112
|
+
const STORE_MIN_RUNTIME = "3.0.0";
|
|
113
|
+
/** A snapshot is written after this many events since the last one. */
|
|
114
|
+
export const SNAPSHOT_EVERY = 32;
|
|
115
|
+
/** `gc` compacts a log longer than this many events ... */
|
|
116
|
+
export const COMPACT_ABOVE = 200;
|
|
117
|
+
/** ... and keeps this many recent events after the checkpoint. */
|
|
118
|
+
export const COMPACT_KEEP = 50;
|
|
119
|
+
/** An append that leaves the log larger than this compacts it on the spot. */
|
|
120
|
+
export const AUTO_COMPACT_BYTES = 2 * 1024 * 1024;
|
|
121
|
+
/** readV2Workspace's answer for a 3.x marker at `.workit/workspace.json`. */
|
|
122
|
+
const TOMBSTONE = Symbol("tombstone");
|
|
123
|
+
/** A temp file or unreferenced blob older than this was left by a crashed writer. */
|
|
52
124
|
const STALE_TEMPORARY_MS = 60 * 60_000;
|
|
53
|
-
|
|
125
|
+
|
|
54
126
|
export type GarbageReport = {
|
|
55
127
|
dryRun: boolean;
|
|
56
|
-
|
|
128
|
+
compacted: { tasks: Id[]; eventsFolded: number; bytesBefore: number; bytesAfter: number };
|
|
129
|
+
blobs: { removed: number; removedBytes: number; kept: number };
|
|
57
130
|
temporary: { removed: number };
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
failed: Id[];
|
|
64
|
-
};
|
|
131
|
+
/** A 2.x `.workit/recovery` directory left by migration (deleted only with pruneRecovery). */
|
|
132
|
+
legacyRecovery: { path: string; files: number; bytes: number; removed: boolean } | null;
|
|
133
|
+
/** Logs another writer changed mid-compaction: left for the next run (benign). */
|
|
134
|
+
retried: Id[];
|
|
135
|
+
failed: Id[];
|
|
65
136
|
};
|
|
66
137
|
export type TaskStoreOptions = {
|
|
67
138
|
/** Total time a mutation retries a lock held by a live writer before `busy`
|
|
68
139
|
* (default: `defaultLockTimeout()`, short for in-process hosts). */
|
|
69
140
|
lockTimeoutMs?: number;
|
|
141
|
+
/** Who the events are attributed to (default: WORKIT_HOST/WORKIT_SESSION_ID/WORKIT_AGENT_ID). */
|
|
142
|
+
actor?: EventActor;
|
|
143
|
+
/** Migrate a pending 2.x store on reads too (default: only when a host
|
|
144
|
+
* installed a migration reporter, i.e. the CLI; writes always migrate). */
|
|
145
|
+
migrateOnRead?: boolean;
|
|
70
146
|
};
|
|
71
147
|
|
|
72
148
|
export type MutationContext = { now: Utc; revision: Revision };
|
|
@@ -96,36 +172,18 @@ export type ImportInput = {
|
|
|
96
172
|
workspaceId?: Id;
|
|
97
173
|
now?: Utc;
|
|
98
174
|
};
|
|
99
|
-
export type
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
lock: MetadataLock | null,
|
|
107
|
-
writer: WorkspaceRecord["writer"],
|
|
108
|
-
) => Result<ProcessEvidence>;
|
|
109
|
-
};
|
|
110
|
-
export type ProcessEvidence = {
|
|
111
|
-
state: "stopped" | "accounted_for";
|
|
112
|
-
pid: number;
|
|
113
|
-
processStart: string | null;
|
|
114
|
-
ownerDigest: string | null;
|
|
175
|
+
export type ImplicitInput = {
|
|
176
|
+
provenance: Provenance;
|
|
177
|
+
/** Objective for a task this call creates (default: names the branch). */
|
|
178
|
+
objective?: string;
|
|
179
|
+
now?: Utc;
|
|
180
|
+
/** False: only find the task, never create it. */
|
|
181
|
+
create?: boolean;
|
|
115
182
|
};
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
processStart: z.string().nullable(),
|
|
121
|
-
ownerDigest: z
|
|
122
|
-
.string()
|
|
123
|
-
.regex(/^[0-9a-f]{64}$/)
|
|
124
|
-
.nullable(),
|
|
125
|
-
})
|
|
126
|
-
.strict();
|
|
127
|
-
/** One task's listing facts, kept in `.workit/index.json` so per-turn host
|
|
128
|
-
* hooks can find a session's task without parsing every full record. */
|
|
183
|
+
export type ImplicitTask = { task: TaskRecord; created: boolean; key: TaskKey };
|
|
184
|
+
|
|
185
|
+
/** One task's listing facts, kept in `task-index.json` so per-turn host
|
|
186
|
+
* hooks can find a session's task without replaying every log. */
|
|
129
187
|
export type TaskIndexEntry = {
|
|
130
188
|
id: Id;
|
|
131
189
|
revision: Revision;
|
|
@@ -137,10 +195,22 @@ export type TaskIndexEntry = {
|
|
|
137
195
|
progress: { summary: string; nextAction: string | null };
|
|
138
196
|
/** Host sessions bound to the task: the intent session (workerId null) and worker sessions. */
|
|
139
197
|
sessions: { host: string; handle: string; workerId: Id | null }[];
|
|
140
|
-
/**
|
|
198
|
+
/** The checkout workspace the task belongs to. */
|
|
199
|
+
workspaceId: Id;
|
|
200
|
+
/** The implicit-task key (branch) the task is bound to, if any. */
|
|
201
|
+
key: string | null;
|
|
202
|
+
branch: string | null;
|
|
203
|
+
/** When the task was bound to `key`. */
|
|
204
|
+
boundAt: string | null;
|
|
205
|
+
/** Migrated from a 2.x store. */
|
|
206
|
+
legacy: boolean;
|
|
207
|
+
/** Stat signature of the task's event log the entry was derived from. */
|
|
141
208
|
file: string;
|
|
142
209
|
};
|
|
143
|
-
const INDEX_VERSION =
|
|
210
|
+
const INDEX_VERSION = 3;
|
|
211
|
+
|
|
212
|
+
const isObject = (value: unknown): value is Record<string, unknown> =>
|
|
213
|
+
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
144
214
|
const hostSession = (value: unknown): { host: string; handle: string } | null =>
|
|
145
215
|
isObject(value) &&
|
|
146
216
|
value.kind === "host" &&
|
|
@@ -148,7 +218,12 @@ const hostSession = (value: unknown): { host: string; handle: string } | null =>
|
|
|
148
218
|
typeof value.handle === "string"
|
|
149
219
|
? { host: value.host, handle: value.handle }
|
|
150
220
|
: null;
|
|
151
|
-
const indexEntry = (
|
|
221
|
+
const indexEntry = (
|
|
222
|
+
task: TaskRecord,
|
|
223
|
+
key: TaskKey | null,
|
|
224
|
+
legacy: boolean,
|
|
225
|
+
file: string,
|
|
226
|
+
): TaskIndexEntry => {
|
|
152
227
|
const sessions: TaskIndexEntry["sessions"] = [];
|
|
153
228
|
const intent = hostSession(task.intent.provenance.session);
|
|
154
229
|
if (intent) sessions.push({ ...intent, workerId: null });
|
|
@@ -166,12 +241,17 @@ const indexEntry = (task: TaskRecord, file: string): TaskIndexEntry => {
|
|
|
166
241
|
source: { host: task.intent.provenance.host, kind: task.intent.provenance.kind },
|
|
167
242
|
progress: { summary: task.progress.summary, nextAction: task.progress.nextAction },
|
|
168
243
|
sessions,
|
|
244
|
+
workspaceId: task.workspaceId,
|
|
245
|
+
key: key?.key ?? null,
|
|
246
|
+
branch: key?.branch ?? null,
|
|
247
|
+
boundAt: key?.boundAt ?? null,
|
|
248
|
+
legacy,
|
|
169
249
|
file,
|
|
170
250
|
};
|
|
171
251
|
};
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
*
|
|
252
|
+
|
|
253
|
+
/** Cheap change detector for a file. An append-only log changes size on
|
|
254
|
+
* every write; see `racySignature` for the timestamp-granularity caveat. */
|
|
175
255
|
export const fileSignature = (file: string): string | null => {
|
|
176
256
|
try {
|
|
177
257
|
const stat = fs.statSync(file, { bigint: true });
|
|
@@ -191,17 +271,13 @@ export const racySignature = (signature: string): boolean => {
|
|
|
191
271
|
return BigInt(Date.now()) * 1_000_000n - changed < RACY_WINDOW_NS;
|
|
192
272
|
};
|
|
193
273
|
|
|
194
|
-
export type RecoveryCandidate = {
|
|
195
|
-
target: "task" | "workspace";
|
|
196
|
-
path: string;
|
|
197
|
-
digest: string;
|
|
198
|
-
};
|
|
199
|
-
|
|
200
274
|
const now = (): Utc => new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
275
|
+
const oldestFirst = (left: TaskIndexEntry, right: TaskIndexEntry): number =>
|
|
276
|
+
left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id);
|
|
201
277
|
|
|
202
278
|
let cachedRuntimeVersion: string | null = null;
|
|
203
279
|
/** Version of the package that hosts this runtime; recorded with state so a
|
|
204
|
-
* bug report can identify the writer version without reading raw .
|
|
280
|
+
* bug report can identify the writer version without reading raw state. */
|
|
205
281
|
export const runtimeVersion = (): string => {
|
|
206
282
|
if (cachedRuntimeVersion === null) {
|
|
207
283
|
try {
|
|
@@ -235,14 +311,8 @@ const isNewerVersion = (candidate: string, current: string): boolean => {
|
|
|
235
311
|
}
|
|
236
312
|
return false;
|
|
237
313
|
};
|
|
238
|
-
const jsonBytes = (value: unknown): string => `${canonicalJson(value)}\n`;
|
|
239
|
-
const digestBytes = (value: string | Buffer): string =>
|
|
240
|
-
createHash("sha256").update(value).digest("hex");
|
|
241
|
-
const isObject = (value: unknown): value is Record<string, unknown> =>
|
|
242
|
-
typeof value === "object" && value !== null;
|
|
243
314
|
const validId = (value: string): boolean =>
|
|
244
315
|
/^[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);
|
|
245
|
-
const validDigest = (value: string): boolean => /^[0-9a-f]{64}$/.test(value);
|
|
246
316
|
export const sameDirectoryIdentity = (left: string, right: string): boolean => {
|
|
247
317
|
if (!path.isAbsolute(left) || !path.isAbsolute(right)) return false;
|
|
248
318
|
const normalizedLeft = path.resolve(left);
|
|
@@ -270,171 +340,331 @@ export const sameDirectoryIdentity = (left: string, right: string): boolean => {
|
|
|
270
340
|
return false;
|
|
271
341
|
}
|
|
272
342
|
};
|
|
273
|
-
|
|
274
|
-
const TRANSIENT_WINDOWS_CODES = new Set(["EPERM", "EACCES", "EBUSY"]);
|
|
275
|
-
/** Windows briefly refuses to replace or open a file that another process is
|
|
276
|
-
* reading or renaming at that instant. That is contention, not damage: retry
|
|
277
|
-
* for about a second before surfacing the error. */
|
|
278
|
-
const isTransientWindowsError = (error: unknown): boolean => {
|
|
279
|
-
if (process.platform !== "win32") return false;
|
|
280
|
-
const code = (error as { code?: unknown } | null)?.code;
|
|
281
|
-
return typeof code === "string" && TRANSIENT_WINDOWS_CODES.has(code);
|
|
282
|
-
};
|
|
283
|
-
const retryTransient = <T>(run: () => T): T => {
|
|
284
|
-
if (process.platform !== "win32") return run();
|
|
285
|
-
for (let attempt = 0; ; attempt += 1) {
|
|
286
|
-
try {
|
|
287
|
-
return run();
|
|
288
|
-
} catch (error) {
|
|
289
|
-
if (attempt >= 20 || !isTransientWindowsError(error)) throw error;
|
|
290
|
-
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5 * (attempt + 1));
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
};
|
|
343
|
+
|
|
294
344
|
const externalActionLockRoots = new AsyncLocalStorage<ReadonlySet<string>>();
|
|
295
|
-
/**
|
|
345
|
+
/** Locks this process holds; waiting on them can only time out. */
|
|
296
346
|
const heldInProcess = new Map<string, number>();
|
|
297
|
-
const
|
|
298
|
-
const
|
|
299
|
-
const count = (heldInProcess.get(
|
|
300
|
-
if (count > 0) heldInProcess.set(
|
|
301
|
-
else heldInProcess.delete(
|
|
347
|
+
const hold = (lock: string) => heldInProcess.set(lock, (heldInProcess.get(lock) ?? 0) + 1);
|
|
348
|
+
const drop = (lock: string) => {
|
|
349
|
+
const count = (heldInProcess.get(lock) ?? 1) - 1;
|
|
350
|
+
if (count > 0) heldInProcess.set(lock, count);
|
|
351
|
+
else heldInProcess.delete(lock);
|
|
302
352
|
};
|
|
303
353
|
|
|
304
|
-
|
|
305
|
-
const
|
|
306
|
-
|
|
307
|
-
|
|
354
|
+
const envActor = (): EventActor => {
|
|
355
|
+
const value = (key: string) => process.env[key]?.trim() || null;
|
|
356
|
+
return {
|
|
357
|
+
host: value("WORKIT_HOST") ?? "runtime",
|
|
358
|
+
session: value("WORKIT_SESSION_ID"),
|
|
359
|
+
agentId: value("WORKIT_AGENT_ID"),
|
|
360
|
+
};
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
/** The reduced, validated state of one task plus where its log stands. */
|
|
364
|
+
type Loaded = {
|
|
365
|
+
record: TaskRecord;
|
|
366
|
+
key: TaskKey | null;
|
|
367
|
+
legacy: LegacyOrigin | null;
|
|
368
|
+
seq: number;
|
|
369
|
+
lastId: string;
|
|
370
|
+
lastStart: number;
|
|
371
|
+
end: number;
|
|
372
|
+
size: number;
|
|
373
|
+
/** Seq the current snapshot was taken at (0: none). */
|
|
374
|
+
snapshotSeq: number;
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
type Snapshot = {
|
|
378
|
+
v: 1;
|
|
379
|
+
task: Id;
|
|
380
|
+
seq: number;
|
|
381
|
+
lastId: string;
|
|
382
|
+
lastStart: number;
|
|
383
|
+
end: number;
|
|
384
|
+
key: TaskKey | null;
|
|
385
|
+
legacy: LegacyOrigin | null;
|
|
386
|
+
record: unknown;
|
|
308
387
|
};
|
|
309
388
|
|
|
310
389
|
export class TaskStore {
|
|
390
|
+
/** The checkout this store serves: the worktree top level in a git
|
|
391
|
+
* repository (every subdirectory is the same checkout), else the
|
|
392
|
+
* directory. Git operations run here. */
|
|
311
393
|
readonly root: string;
|
|
394
|
+
/** The directory the store was opened from (a 2.x store may live there). */
|
|
395
|
+
private readonly origin: string;
|
|
312
396
|
private readonly lockTimeoutMs: number;
|
|
397
|
+
private readonly actor: EventActor;
|
|
398
|
+
private readonly migrateOnRead: boolean | undefined;
|
|
399
|
+
private located: StoreLocation | Error | undefined;
|
|
400
|
+
private migrationChecked = false;
|
|
401
|
+
/** Task logs past AUTO_COMPACT_BYTES, folded after the writing lock is released. */
|
|
402
|
+
private readonly compactDue = new Set<Id>();
|
|
313
403
|
|
|
314
404
|
constructor(root: string, options: TaskStoreOptions = {}) {
|
|
315
|
-
|
|
405
|
+
const resolved = path.resolve(root);
|
|
406
|
+
this.origin = fs.existsSync(resolved) ? fs.realpathSync(resolved) : resolved;
|
|
407
|
+
this.located = resolveStore(this.origin);
|
|
408
|
+
this.root = checkoutRootOf(this.origin, this.located);
|
|
316
409
|
this.lockTimeoutMs = options.lockTimeoutMs ?? defaultLockTimeout();
|
|
410
|
+
this.actor = options.actor ?? envActor();
|
|
411
|
+
this.migrateOnRead = options.migrateOnRead;
|
|
317
412
|
}
|
|
318
413
|
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
if (!workspace.ok) return workspace;
|
|
331
|
-
if (!workspace.data || result.result.data.workspaceId !== workspace.data.id)
|
|
332
|
-
return failure("recovery_required", "task workspace binding is invalid", { taskId });
|
|
414
|
+
/** Where this checkout's store lives. */
|
|
415
|
+
location(): Result<StoreLocation> {
|
|
416
|
+
// A directory that became a repository (`git init`) moves to the git store.
|
|
417
|
+
if (
|
|
418
|
+
this.located !== undefined &&
|
|
419
|
+
!(this.located instanceof Error) &&
|
|
420
|
+
!this.located.shared &&
|
|
421
|
+
fs.existsSync(path.join(this.root, ".git"))
|
|
422
|
+
) {
|
|
423
|
+
this.located = undefined;
|
|
424
|
+
this.migrationChecked = false;
|
|
333
425
|
}
|
|
334
|
-
|
|
426
|
+
if (this.located === undefined) this.located = resolveStore(this.root);
|
|
427
|
+
return this.located instanceof Error
|
|
428
|
+
? failure("storage_error", this.located.message, { path: this.root })
|
|
429
|
+
: success(null, null, this.located);
|
|
335
430
|
}
|
|
336
431
|
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
432
|
+
/** The implicit-task key of this checkout right now. */
|
|
433
|
+
currentKey(): Result<TaskKey> {
|
|
434
|
+
const location = this.location();
|
|
435
|
+
if (!location.ok) return location;
|
|
436
|
+
return success(null, null, resolveTaskKey(this.root, location.data));
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// -------------------------------------------------------------------------
|
|
440
|
+
// reads
|
|
441
|
+
|
|
442
|
+
readTask(taskId: Id): Result<TaskRecord> {
|
|
443
|
+
if (!validId(taskId)) return failure("invalid_input", "task ID is invalid", { taskId });
|
|
444
|
+
const ready = this.ready();
|
|
445
|
+
if (!ready.ok) return ready;
|
|
446
|
+
const loaded = this.load(taskId);
|
|
447
|
+
if (!loaded.ok) return loaded;
|
|
448
|
+
if (!loaded.data) return failure("not_found", "task not found", { taskId });
|
|
347
449
|
const workspace = this.readWorkspace();
|
|
348
450
|
if (!workspace.ok) return workspace;
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
451
|
+
const bound = this.bindingError(loaded.data.record, workspace.data);
|
|
452
|
+
return bound ?? success(null, null, loaded.data.record);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** This checkout's tasks (oldest id first). */
|
|
456
|
+
listTasks(): Result<TaskRecord[]> {
|
|
457
|
+
const listed = this.listTaskIndex();
|
|
458
|
+
if (!listed.ok) return listed;
|
|
353
459
|
const tasks: TaskRecord[] = [];
|
|
354
|
-
for (const
|
|
355
|
-
const
|
|
356
|
-
if (!
|
|
357
|
-
if (
|
|
358
|
-
if (!validId(name.slice(0, -5)) || item.result.data.id !== name.slice(0, -5))
|
|
359
|
-
return failure("recovery_required", "task filename and record ID differ", { path: name });
|
|
360
|
-
if (item.result.data.workspaceId !== workspace.data.id)
|
|
361
|
-
return failure("recovery_required", "task workspace binding is invalid", { path: name });
|
|
362
|
-
tasks.push(item.result.data);
|
|
460
|
+
for (const entry of listed.data.toSorted((left, right) => left.id.localeCompare(right.id))) {
|
|
461
|
+
const loaded = this.load(entry.id);
|
|
462
|
+
if (!loaded.ok) return loaded;
|
|
463
|
+
if (loaded.data) tasks.push(loaded.data.record);
|
|
363
464
|
}
|
|
364
465
|
return success(null, null, tasks);
|
|
365
466
|
}
|
|
366
467
|
|
|
468
|
+
/** Listing facts for this checkout's tasks, from the index cache. */
|
|
469
|
+
listTaskIndex(): Result<TaskIndexEntry[]> {
|
|
470
|
+
const all = this.listStoreIndex();
|
|
471
|
+
if (!all.ok) return all;
|
|
472
|
+
const workspace = this.readWorkspace();
|
|
473
|
+
if (!workspace.ok) return workspace;
|
|
474
|
+
if (!workspace.data) return success(null, null, []);
|
|
475
|
+
const id = workspace.data.id;
|
|
476
|
+
return success(
|
|
477
|
+
null,
|
|
478
|
+
null,
|
|
479
|
+
all.data.filter((entry) => entry.workspaceId === id),
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
|
|
367
483
|
/**
|
|
368
|
-
*
|
|
369
|
-
* entry is checked against its
|
|
370
|
-
*
|
|
371
|
-
* index is rewritten best-effort. Errors match `listTasks()`.
|
|
484
|
+
* Listing facts for every task in the store (all checkouts). Each cached
|
|
485
|
+
* entry is checked against its log's stat signature; changed or missing
|
|
486
|
+
* entries are rebuilt from the log and the index is rewritten best-effort.
|
|
372
487
|
*/
|
|
373
|
-
|
|
374
|
-
|
|
488
|
+
listStoreIndex(): Result<TaskIndexEntry[]> {
|
|
489
|
+
const ready = this.ready();
|
|
490
|
+
if (!ready.ok) return ready;
|
|
491
|
+
const tasksDir = this.paths().tasks;
|
|
375
492
|
let names: string[];
|
|
376
493
|
try {
|
|
377
|
-
names = fs.
|
|
494
|
+
names = fs.existsSync(tasksDir)
|
|
495
|
+
? fs.readdirSync(tasksDir).filter((name) => validId(name))
|
|
496
|
+
: [];
|
|
378
497
|
} catch (error) {
|
|
379
498
|
return failure("storage_error", `unable to list tasks: ${String(error)}`, {
|
|
380
|
-
path:
|
|
499
|
+
path: tasksDir,
|
|
381
500
|
});
|
|
382
501
|
}
|
|
383
|
-
const
|
|
384
|
-
if (!workspace.ok) return workspace;
|
|
385
|
-
if (!workspace.data)
|
|
386
|
-
return failure("recovery_required", "task workspace binding is invalid", {
|
|
387
|
-
path: this.workspacePath,
|
|
388
|
-
});
|
|
389
|
-
const stored = this.readIndex(workspace.data.id);
|
|
502
|
+
const stored = this.readIndex();
|
|
390
503
|
const entries: TaskIndexEntry[] = [];
|
|
391
504
|
let changed = Object.keys(stored).length !== names.length;
|
|
392
505
|
for (const name of names.toSorted()) {
|
|
393
|
-
const
|
|
394
|
-
const signature = fileSignature(file);
|
|
506
|
+
const signature = fileSignature(this.eventsPath(name));
|
|
395
507
|
if (signature === null) {
|
|
396
508
|
changed = true;
|
|
397
509
|
continue;
|
|
398
510
|
}
|
|
399
|
-
const cached = stored[name
|
|
511
|
+
const cached = stored[name];
|
|
400
512
|
if (cached && cached.file === signature && !racySignature(signature)) {
|
|
401
513
|
entries.push(cached);
|
|
402
514
|
continue;
|
|
403
515
|
}
|
|
404
|
-
const
|
|
405
|
-
if (!
|
|
406
|
-
if (!
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
516
|
+
const loaded = this.load(name);
|
|
517
|
+
if (!loaded.ok) return loaded;
|
|
518
|
+
if (!loaded.data) continue;
|
|
519
|
+
const entry = indexEntry(
|
|
520
|
+
loaded.data.record,
|
|
521
|
+
loaded.data.key,
|
|
522
|
+
loaded.data.legacy !== null,
|
|
523
|
+
signature,
|
|
524
|
+
);
|
|
412
525
|
if (!cached || canonicalJson(cached) !== canonicalJson(entry)) changed = true;
|
|
413
526
|
entries.push(entry);
|
|
414
527
|
}
|
|
415
|
-
if (changed) this.writeIndex(
|
|
528
|
+
if (changed) this.writeIndex(entries);
|
|
416
529
|
return success(null, null, entries);
|
|
417
530
|
}
|
|
418
531
|
|
|
419
532
|
readWorkspace(): Result<WorkspaceRecord | null> {
|
|
420
|
-
const
|
|
421
|
-
if (!
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
)
|
|
427
|
-
|
|
428
|
-
|
|
533
|
+
const ready = this.ready();
|
|
534
|
+
if (!ready.ok) return ready;
|
|
535
|
+
const file = this.paths().workspace;
|
|
536
|
+
let bytes: string;
|
|
537
|
+
try {
|
|
538
|
+
bytes = retryTransient(() => fs.readFileSync(file, "utf8"));
|
|
539
|
+
} catch (error) {
|
|
540
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return success(null, null, null);
|
|
541
|
+
return failure("recovery_required", `workspace record cannot be read: ${String(error)}`, {
|
|
542
|
+
path: file,
|
|
429
543
|
});
|
|
430
|
-
|
|
544
|
+
}
|
|
545
|
+
const parsed = this.parseRecord<WorkspaceRecord>(bytes, workspaceRecordSchema, "workspace");
|
|
546
|
+
if (!parsed.ok) return parsed;
|
|
547
|
+
if (parsed.data.root !== this.root && !sameDirectoryIdentity(parsed.data.root, this.root))
|
|
548
|
+
return failure("recovery_required", "workspace root binding is invalid", { path: file });
|
|
549
|
+
return parsed;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* The open task bound to this checkout's implicit key (branch, detached
|
|
554
|
+
* worktree, or directory), created when `create` is not false and there is
|
|
555
|
+
* none. A task bound to the key from another checkout of the same repo
|
|
556
|
+
* (e.g. a removed worktree) is moved to this checkout.
|
|
557
|
+
*/
|
|
558
|
+
implicitTask(input: ImplicitInput): Result<ImplicitTask | null> {
|
|
559
|
+
const ready = this.ready(input.create !== false);
|
|
560
|
+
if (!ready.ok) return ready;
|
|
561
|
+
const key = this.currentKey();
|
|
562
|
+
if (!key.ok) return key;
|
|
563
|
+
const found = this.openTaskForKey(key.data.key);
|
|
564
|
+
if (!found.ok) return found;
|
|
565
|
+
const workspace = this.readWorkspace();
|
|
566
|
+
if (!workspace.ok) return workspace;
|
|
567
|
+
if (
|
|
568
|
+
found.data &&
|
|
569
|
+
workspace.data &&
|
|
570
|
+
found.data.workspaceId === workspace.data.id &&
|
|
571
|
+
(found.data.key === key.data.key || input.create === false)
|
|
572
|
+
) {
|
|
573
|
+
const task = this.readTask(found.data.id);
|
|
574
|
+
return task.ok
|
|
575
|
+
? success(task.revision, null, { task: task.data, created: false, key: key.data })
|
|
576
|
+
: task;
|
|
577
|
+
}
|
|
578
|
+
if (input.create === false) {
|
|
579
|
+
// A lookup never binds or moves anything.
|
|
580
|
+
if (!found.data) return success(null, null, null);
|
|
581
|
+
const loaded = this.load(found.data.id);
|
|
582
|
+
if (!loaded.ok) return loaded;
|
|
583
|
+
return loaded.data
|
|
584
|
+
? success(null, null, { task: loaded.data.record, created: false, key: key.data })
|
|
585
|
+
: success(null, null, null);
|
|
586
|
+
}
|
|
587
|
+
return this.withLock<ImplicitTask | null>(this.paths().checkoutLock, () =>
|
|
588
|
+
this.withKeyLock(key.data.key, () => this.implicitLocked(input, key.data)),
|
|
589
|
+
);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/** Find, move or create the implicit task (under the checkout and key locks). */
|
|
593
|
+
private implicitLocked(input: ImplicitInput, key: TaskKey): Result<ImplicitTask | null> {
|
|
594
|
+
{
|
|
595
|
+
const again = this.openTaskForKey(key.key);
|
|
596
|
+
if (!again.ok) return again;
|
|
597
|
+
if (again.data) {
|
|
598
|
+
const moved = this.bindLocked(again.data.id, key, input.now);
|
|
599
|
+
return moved.ok
|
|
600
|
+
? success(moved.data.revision, null, { task: moved.data, created: false, key })
|
|
601
|
+
: moved;
|
|
602
|
+
}
|
|
603
|
+
const label =
|
|
604
|
+
key.kind === "branch"
|
|
605
|
+
? `branch ${key.branch}`
|
|
606
|
+
: key.kind === "detached"
|
|
607
|
+
? "a detached HEAD"
|
|
608
|
+
: path.basename(this.root) || this.root;
|
|
609
|
+
const intent: Intent = {
|
|
610
|
+
objective: input.objective?.trim() || `Work on ${label}`,
|
|
611
|
+
scope: { description: `the checkout (${label})`, paths: ["."], exclusions: [] },
|
|
612
|
+
authorityRefs: [],
|
|
613
|
+
};
|
|
614
|
+
const created = this.createLocked(
|
|
615
|
+
{ intent, provenance: input.provenance, expectedWorkspaceRevision: null, now: input.now },
|
|
616
|
+
false,
|
|
617
|
+
key,
|
|
618
|
+
false,
|
|
619
|
+
);
|
|
620
|
+
return created.ok
|
|
621
|
+
? success(created.data.revision, null, { task: created.data, created: true, key })
|
|
622
|
+
: created;
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/** Bind task `taskId` (any checkout, not closed) to this checkout's implicit key. */
|
|
627
|
+
adoptTask(taskId: Id): Result<TaskRecord> {
|
|
628
|
+
if (!validId(taskId)) return failure("invalid_input", "task ID is invalid", { taskId });
|
|
629
|
+
const ready = this.ready(true);
|
|
630
|
+
if (!ready.ok) return ready;
|
|
631
|
+
const key = this.currentKey();
|
|
632
|
+
if (!key.ok) return key;
|
|
633
|
+
return this.withLock(this.paths().checkoutLock, () =>
|
|
634
|
+
this.withKeyLock(key.data.key, () => {
|
|
635
|
+
const holder = this.openTaskForKey(key.data.key);
|
|
636
|
+
if (!holder.ok) return holder;
|
|
637
|
+
if (holder.data && holder.data.id !== taskId)
|
|
638
|
+
return failure(
|
|
639
|
+
"invalid_transition",
|
|
640
|
+
`${key.data.key} already has open task ${holder.data.id}; close it (\`workit task close\`) before adopting another`,
|
|
641
|
+
{ taskId: holder.data.id },
|
|
642
|
+
);
|
|
643
|
+
return this.bindLocked(taskId, key.data);
|
|
644
|
+
}),
|
|
645
|
+
);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* What `workit task status` reports about the current key: every open task
|
|
650
|
+
* bound to it (more than one is a duplicate; the oldest is the one used).
|
|
651
|
+
*/
|
|
652
|
+
keyReport(): Result<{ key: TaskKey; bound: TaskIndexEntry[] }> {
|
|
653
|
+
const key = this.currentKey();
|
|
654
|
+
if (!key.ok) return key;
|
|
655
|
+
const bound = this.openTasksForKey(key.data.key);
|
|
656
|
+
if (!bound.ok) return bound;
|
|
657
|
+
return success(null, null, { key: key.data, bound: bound.data });
|
|
431
658
|
}
|
|
432
659
|
|
|
660
|
+
// -------------------------------------------------------------------------
|
|
661
|
+
// writes
|
|
662
|
+
|
|
433
663
|
create(
|
|
434
664
|
input: CreateInput | Intent,
|
|
435
665
|
provenance?: Provenance,
|
|
436
666
|
expectedWorkspaceRevision?: Revision | null,
|
|
437
|
-
) {
|
|
667
|
+
): Result<TaskRecord> {
|
|
438
668
|
const value: CreateInput =
|
|
439
669
|
"intent" in input
|
|
440
670
|
? input
|
|
@@ -443,109 +673,15 @@ export class TaskStore {
|
|
|
443
673
|
provenance: provenance!,
|
|
444
674
|
expectedWorkspaceRevision: expectedWorkspaceRevision ?? null,
|
|
445
675
|
};
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
if (
|
|
450
|
-
current.data
|
|
451
|
-
? value.expectedWorkspaceRevision !== current.data.revision
|
|
452
|
-
: value.expectedWorkspaceRevision !== null
|
|
453
|
-
) {
|
|
454
|
-
return failure(
|
|
455
|
-
"revision_conflict",
|
|
456
|
-
"workspace revision does not match; omit expectedWorkspaceRevision to use the current record",
|
|
457
|
-
{
|
|
458
|
-
expectedWorkspaceRevision: value.expectedWorkspaceRevision,
|
|
459
|
-
actualWorkspaceRevision: current.data?.revision ?? null,
|
|
460
|
-
},
|
|
461
|
-
);
|
|
462
|
-
}
|
|
463
|
-
if (!value.provenance) return failure("invalid_input", "provenance is required");
|
|
464
|
-
if (
|
|
465
|
-
!intentSchema.safeParse(value.intent).success ||
|
|
466
|
-
!provenanceSchema.safeParse(value.provenance).success
|
|
467
|
-
)
|
|
468
|
-
return failure("invalid_input", "task intent or provenance is invalid");
|
|
469
|
-
const previousWorkspaceBytes = current.data ? this.snapshotBytes(this.workspacePath) : null;
|
|
470
|
-
if (current.data && !previousWorkspaceBytes)
|
|
471
|
-
return failure("storage_error", "workspace snapshot disappeared during creation");
|
|
472
|
-
const workspace: WorkspaceRecord = current.data
|
|
473
|
-
? {
|
|
474
|
-
...current.data,
|
|
475
|
-
revision: newRevision(),
|
|
476
|
-
root: this.root,
|
|
477
|
-
runtime: stampNew(current.data),
|
|
478
|
-
}
|
|
479
|
-
: {
|
|
480
|
-
schemaVersion: SCHEMA_VERSION,
|
|
481
|
-
id: newId(),
|
|
482
|
-
revision: newRevision(),
|
|
483
|
-
root: this.root,
|
|
484
|
-
runtime: { createdWith: runtimeVersion(), updatedWith: runtimeVersion() },
|
|
485
|
-
writer: null,
|
|
486
|
-
};
|
|
487
|
-
const timestamp = value.now ?? now();
|
|
488
|
-
const task: TaskRecord = {
|
|
489
|
-
schemaVersion: SCHEMA_VERSION,
|
|
490
|
-
id: newId(),
|
|
491
|
-
workspaceId: workspace.id,
|
|
492
|
-
revision: newRevision(),
|
|
493
|
-
createdAt: timestamp,
|
|
494
|
-
updatedAt: timestamp,
|
|
495
|
-
origin: null,
|
|
496
|
-
intent: {
|
|
497
|
-
id: newId(),
|
|
498
|
-
recordedAt: timestamp,
|
|
499
|
-
provenance: value.provenance,
|
|
500
|
-
data: value.intent,
|
|
501
|
-
},
|
|
502
|
-
constraints: [],
|
|
503
|
-
status: "active",
|
|
504
|
-
closure: null,
|
|
505
|
-
progress: { summary: "", nextAction: null, blockers: [] },
|
|
506
|
-
runtime: { createdWith: runtimeVersion(), updatedWith: runtimeVersion() },
|
|
507
|
-
assessments: [],
|
|
508
|
-
policy: null,
|
|
509
|
-
policyChanges: [],
|
|
510
|
-
candidates: [],
|
|
511
|
-
evidence: [],
|
|
512
|
-
decisions: [],
|
|
513
|
-
findings: [],
|
|
514
|
-
workers: [],
|
|
515
|
-
};
|
|
516
|
-
const writtenWorkspace = this.replaceSnapshot(
|
|
517
|
-
this.workspacePath,
|
|
518
|
-
workspace,
|
|
519
|
-
previousWorkspaceBytes,
|
|
520
|
-
);
|
|
521
|
-
if (!writtenWorkspace.ok) return writtenWorkspace;
|
|
522
|
-
const writtenTask = this.replaceSnapshot(this.taskPath(task.id), task, null);
|
|
523
|
-
if (!writtenTask.ok) {
|
|
524
|
-
if (current.data && previousWorkspaceBytes) {
|
|
525
|
-
const restored = this.replaceSnapshot(
|
|
526
|
-
this.workspacePath,
|
|
527
|
-
current.data,
|
|
528
|
-
this.snapshotBytes(this.workspacePath),
|
|
529
|
-
);
|
|
530
|
-
if (!restored.ok)
|
|
531
|
-
return failure(
|
|
532
|
-
"external_outcome_unknown",
|
|
533
|
-
"task creation failed and workspace restoration is uncertain",
|
|
534
|
-
{ operation: "create", outcome: "unknown" },
|
|
535
|
-
);
|
|
536
|
-
}
|
|
537
|
-
return failure(
|
|
538
|
-
"external_outcome_unknown",
|
|
539
|
-
"workspace created but task write is uncertain",
|
|
540
|
-
{ operation: "create", outcome: "unknown" },
|
|
541
|
-
);
|
|
542
|
-
}
|
|
543
|
-
return success(task.revision, workspace.revision, task);
|
|
544
|
-
});
|
|
676
|
+
const ready = this.ready(true);
|
|
677
|
+
if (!ready.ok) return ready;
|
|
678
|
+
return this.withLock(this.paths().checkoutLock, () => this.createLocked(value, true, null));
|
|
545
679
|
}
|
|
546
680
|
|
|
547
681
|
importTask(input: ImportInput): Result<TaskRecord> {
|
|
548
|
-
|
|
682
|
+
const ready = this.ready(true);
|
|
683
|
+
if (!ready.ok) return ready;
|
|
684
|
+
return this.withLock<TaskRecord>(this.paths().checkoutLock, () => {
|
|
549
685
|
const current = this.readWorkspace();
|
|
550
686
|
if (!current.ok) return current;
|
|
551
687
|
const origin = input.task.origin;
|
|
@@ -592,25 +728,8 @@ export class TaskStore {
|
|
|
592
728
|
actualWorkspaceRevision: current.data?.revision ?? null,
|
|
593
729
|
},
|
|
594
730
|
);
|
|
595
|
-
const previousWorkspaceBytes = current.data ? this.snapshotBytes(this.workspacePath) : null;
|
|
596
|
-
if (current.data && !previousWorkspaceBytes)
|
|
597
|
-
return failure("storage_error", "workspace snapshot disappeared during import");
|
|
598
731
|
const timestamp = input.now ?? now();
|
|
599
|
-
const workspace
|
|
600
|
-
? {
|
|
601
|
-
...current.data,
|
|
602
|
-
revision: newRevision(),
|
|
603
|
-
root: this.root,
|
|
604
|
-
runtime: stampNew(current.data),
|
|
605
|
-
}
|
|
606
|
-
: {
|
|
607
|
-
schemaVersion: SCHEMA_VERSION,
|
|
608
|
-
id: input.workspaceId ?? newId(),
|
|
609
|
-
revision: newRevision(),
|
|
610
|
-
root: this.root,
|
|
611
|
-
runtime: { createdWith: runtimeVersion(), updatedWith: runtimeVersion() },
|
|
612
|
-
writer: null,
|
|
613
|
-
};
|
|
732
|
+
const workspace = this.nextWorkspace(current.data, input.workspaceId);
|
|
614
733
|
const task = {
|
|
615
734
|
...input.task,
|
|
616
735
|
workspaceId: workspace.id,
|
|
@@ -625,38 +744,9 @@ export class TaskStore {
|
|
|
625
744
|
const validTask = taskRecordSchema.safeParse(task);
|
|
626
745
|
if (!validTask.success)
|
|
627
746
|
return failure("invalid_input", "imported task does not satisfy its schema");
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
previousWorkspaceBytes,
|
|
632
|
-
);
|
|
633
|
-
if (!writtenWorkspace.ok) return writtenWorkspace;
|
|
634
|
-
const writtenTask = this.replaceSnapshot(this.taskPath(task.id), validTask.data, null);
|
|
635
|
-
if (!writtenTask.ok) {
|
|
636
|
-
if (current.data && previousWorkspaceBytes) {
|
|
637
|
-
const restored = this.replaceSnapshot(
|
|
638
|
-
this.workspacePath,
|
|
639
|
-
current.data,
|
|
640
|
-
this.snapshotBytes(this.workspacePath),
|
|
641
|
-
);
|
|
642
|
-
if (!restored.ok)
|
|
643
|
-
return failure(
|
|
644
|
-
"external_outcome_unknown",
|
|
645
|
-
"import failed and workspace restoration is uncertain",
|
|
646
|
-
{ operation: "import", outcome: "unknown" },
|
|
647
|
-
);
|
|
648
|
-
}
|
|
649
|
-
return failure(
|
|
650
|
-
"external_outcome_unknown",
|
|
651
|
-
`workspace created but task import is uncertain: ${writtenTask.error}`,
|
|
652
|
-
{
|
|
653
|
-
operation: "import",
|
|
654
|
-
outcome: "unknown",
|
|
655
|
-
path: writtenTask.details.path,
|
|
656
|
-
},
|
|
657
|
-
);
|
|
658
|
-
}
|
|
659
|
-
return success(validTask.data.revision, workspace.revision, validTask.data);
|
|
747
|
+
return this.writeNewTask(current.data, workspace, validTask.data, "task.imported", null, {
|
|
748
|
+
operation: "import",
|
|
749
|
+
});
|
|
660
750
|
});
|
|
661
751
|
}
|
|
662
752
|
|
|
@@ -666,48 +756,41 @@ export class TaskStore {
|
|
|
666
756
|
update: TaskMutation,
|
|
667
757
|
timestamp?: Utc,
|
|
668
758
|
): Result<TaskRecord> {
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
const
|
|
674
|
-
if (!
|
|
675
|
-
|
|
759
|
+
if (!validId(taskId)) return failure("invalid_input", "task ID is invalid", { taskId });
|
|
760
|
+
const ready = this.ready(true);
|
|
761
|
+
if (!ready.ok) return ready;
|
|
762
|
+
const result = this.withLock(this.taskLockPath(taskId), () => {
|
|
763
|
+
const loaded = this.loadBound(taskId);
|
|
764
|
+
if (!loaded.ok) return loaded;
|
|
765
|
+
if (loaded.data.record.revision !== expected)
|
|
766
|
+
return this.conflict(expected, loaded.data.record.revision);
|
|
676
767
|
const context = { now: timestamp ?? now(), revision: newRevision() };
|
|
677
768
|
let changed: Result<TaskRecord>;
|
|
678
769
|
try {
|
|
679
|
-
|
|
770
|
+
// A copy: updates may edit in place, and the patch diffs against the original.
|
|
771
|
+
changed = update(structuredClone(loaded.data.record), Object.freeze({ ...context }));
|
|
680
772
|
} catch (error) {
|
|
681
773
|
return failure("storage_error", `task mutation failed: ${String(error)}`);
|
|
682
774
|
}
|
|
683
775
|
if (!changed.ok) return changed;
|
|
684
|
-
const
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
createdAt: current.data.createdAt,
|
|
689
|
-
revision: context.revision,
|
|
690
|
-
updatedAt: context.now,
|
|
691
|
-
runtime: stampNew(current.data),
|
|
692
|
-
};
|
|
693
|
-
const valid = taskRecordSchema.safeParse(record);
|
|
694
|
-
if (!valid.success)
|
|
695
|
-
return failure("invalid_input", "task mutation produced an invalid record");
|
|
696
|
-
const written = this.replaceSnapshot(this.taskPath(taskId), valid.data, previousBytes);
|
|
697
|
-
return written.ok ? success(valid.data.revision, null, valid.data) : written;
|
|
776
|
+
const valid = this.nextTask(loaded.data.record, changed.data, context);
|
|
777
|
+
if (!valid) return failure("invalid_input", "task mutation produced an invalid record");
|
|
778
|
+
const written = this.appendPatch(loaded.data, valid);
|
|
779
|
+
return written.ok ? success(valid.revision, null, valid) : written;
|
|
698
780
|
});
|
|
781
|
+
this.compactPending();
|
|
782
|
+
return result;
|
|
699
783
|
}
|
|
700
784
|
|
|
701
785
|
mutateWorkspace(expected: Revision, update: WorkspaceMutation): Result<WorkspaceRecord> {
|
|
702
|
-
|
|
786
|
+
const ready = this.ready(true);
|
|
787
|
+
if (!ready.ok) return ready;
|
|
788
|
+
return this.withLock(this.paths().checkoutLock, () => {
|
|
703
789
|
const current = this.readWorkspace();
|
|
704
790
|
if (!current.ok) return current;
|
|
705
791
|
if (!current.data) return failure("not_found", "workspace not found");
|
|
706
792
|
if (current.data.revision !== expected)
|
|
707
793
|
return this.workspaceConflict(expected, current.data.revision);
|
|
708
|
-
const previousBytes = this.snapshotBytes(this.workspacePath);
|
|
709
|
-
if (!previousBytes)
|
|
710
|
-
return failure("storage_error", "workspace snapshot disappeared during mutation");
|
|
711
794
|
const context = { now: now(), revision: newRevision() };
|
|
712
795
|
let changed: Result<WorkspaceRecord>;
|
|
713
796
|
try {
|
|
@@ -716,430 +799,128 @@ export class TaskStore {
|
|
|
716
799
|
return failure("storage_error", `workspace mutation failed: ${String(error)}`);
|
|
717
800
|
}
|
|
718
801
|
if (!changed.ok) return changed;
|
|
719
|
-
const
|
|
802
|
+
const valid = workspaceRecordSchema.safeParse({
|
|
720
803
|
...changed.data,
|
|
721
804
|
id: current.data.id,
|
|
722
805
|
root: this.root,
|
|
723
806
|
revision: context.revision,
|
|
724
807
|
runtime: stampNew(current.data),
|
|
725
|
-
};
|
|
726
|
-
const valid = workspaceRecordSchema.safeParse(record);
|
|
808
|
+
});
|
|
727
809
|
if (!valid.success)
|
|
728
810
|
return failure("invalid_input", "workspace mutation produced an invalid record");
|
|
729
|
-
const written = this.
|
|
811
|
+
const written = this.writeWorkspace(valid.data);
|
|
730
812
|
return written.ok ? success(valid.data.revision, valid.data.revision, valid.data) : written;
|
|
731
813
|
});
|
|
732
814
|
}
|
|
733
815
|
|
|
734
816
|
mutateTaskAndWorkspace(input: CoupledMutation): Result<CoupledSnapshot> {
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
return
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
changedWorkspace
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
return failure(
|
|
780
|
-
"external_outcome_unknown",
|
|
781
|
-
`workspace reserved but task mutation threw: ${String(error)}`,
|
|
782
|
-
{ operation: "coupled_mutation", outcome: "unknown" },
|
|
783
|
-
);
|
|
784
|
-
}
|
|
785
|
-
if (!changedTask.ok) {
|
|
786
|
-
this.markUncertain(nextWorkspace.data);
|
|
787
|
-
return failure(
|
|
788
|
-
"external_outcome_unknown",
|
|
789
|
-
"workspace reserved but task update is uncertain",
|
|
790
|
-
{ operation: "coupled_mutation", outcome: "unknown" },
|
|
791
|
-
);
|
|
792
|
-
}
|
|
793
|
-
const nextTask = taskRecordSchema.safeParse({
|
|
794
|
-
...changedTask.data,
|
|
795
|
-
id: input.taskId,
|
|
796
|
-
workspaceId: task.data.workspaceId,
|
|
797
|
-
createdAt: task.data.createdAt,
|
|
798
|
-
revision: taskContext.revision,
|
|
799
|
-
updatedAt: taskContext.now,
|
|
800
|
-
runtime: stampNew(task.data),
|
|
801
|
-
});
|
|
802
|
-
if (!nextTask.success) {
|
|
803
|
-
this.markUncertain(nextWorkspace.data);
|
|
804
|
-
return failure(
|
|
805
|
-
"external_outcome_unknown",
|
|
806
|
-
"workspace reserved but task update is uncertain",
|
|
807
|
-
{ operation: "coupled_mutation", outcome: "unknown" },
|
|
808
|
-
);
|
|
809
|
-
}
|
|
810
|
-
const written = this.replaceSnapshot(
|
|
811
|
-
this.taskPath(input.taskId),
|
|
812
|
-
nextTask.data,
|
|
813
|
-
previousTaskBytes,
|
|
814
|
-
);
|
|
815
|
-
if (!written.ok) {
|
|
816
|
-
this.markUncertain(nextWorkspace.data);
|
|
817
|
-
return failure(
|
|
818
|
-
"external_outcome_unknown",
|
|
819
|
-
"workspace reserved but task replacement is uncertain",
|
|
820
|
-
{ operation: "coupled_mutation", outcome: "unknown" },
|
|
821
|
-
);
|
|
822
|
-
}
|
|
823
|
-
return success(nextTask.data.revision, nextWorkspace.data.revision, {
|
|
824
|
-
task: nextTask.data,
|
|
825
|
-
workspace: nextWorkspace.data,
|
|
826
|
-
});
|
|
827
|
-
});
|
|
828
|
-
}
|
|
829
|
-
|
|
830
|
-
recoveryCandidates(): Result<RecoveryCandidate[]> {
|
|
831
|
-
if (!fs.existsSync(this.recoveryDir)) return success(null, null, []);
|
|
832
|
-
try {
|
|
833
|
-
const candidates: RecoveryCandidate[] = [];
|
|
834
|
-
for (const name of fs.readdirSync(this.recoveryDir)) {
|
|
835
|
-
const match = RECOVERY_NAME.exec(name);
|
|
836
|
-
if (match)
|
|
837
|
-
candidates.push({
|
|
838
|
-
target: match[1] as "task" | "workspace",
|
|
839
|
-
path: path.join(this.recoveryDir, name),
|
|
840
|
-
digest: match[3],
|
|
817
|
+
if (!validId(input.taskId))
|
|
818
|
+
return failure("invalid_input", "task ID is invalid", { taskId: input.taskId });
|
|
819
|
+
const ready = this.ready(true);
|
|
820
|
+
if (!ready.ok) return ready;
|
|
821
|
+
const result = this.withLock(this.paths().checkoutLock, () =>
|
|
822
|
+
this.withLock(this.taskLockPath(input.taskId), () => {
|
|
823
|
+
const task = this.loadBound(input.taskId);
|
|
824
|
+
if (!task.ok) return task;
|
|
825
|
+
const workspace = this.readWorkspace();
|
|
826
|
+
if (!workspace.ok) return workspace;
|
|
827
|
+
if (!workspace.data) return failure("not_found", "workspace not found");
|
|
828
|
+
const expected = input.expectedRevision;
|
|
829
|
+
if (!expected) return failure("invalid_input", "task revision is required");
|
|
830
|
+
if (task.data.record.revision !== expected)
|
|
831
|
+
return this.conflict(expected, task.data.record.revision);
|
|
832
|
+
if (workspace.data.revision !== input.expectedWorkspaceRevision)
|
|
833
|
+
return this.workspaceConflict(input.expectedWorkspaceRevision, workspace.data.revision);
|
|
834
|
+
const workspaceContext = { now: input.now ?? now(), revision: newRevision() };
|
|
835
|
+
let changedWorkspace: Result<WorkspaceRecord>;
|
|
836
|
+
try {
|
|
837
|
+
changedWorkspace = input.workspace(
|
|
838
|
+
workspace.data,
|
|
839
|
+
Object.freeze({ ...workspaceContext }),
|
|
840
|
+
);
|
|
841
|
+
} catch (error) {
|
|
842
|
+
return failure("storage_error", `workspace mutation failed: ${String(error)}`);
|
|
843
|
+
}
|
|
844
|
+
if (!changedWorkspace.ok) return changedWorkspace;
|
|
845
|
+
const nextWorkspace = workspaceRecordSchema.safeParse({
|
|
846
|
+
...changedWorkspace.data,
|
|
847
|
+
id: workspace.data.id,
|
|
848
|
+
root: this.root,
|
|
849
|
+
revision: workspaceContext.revision,
|
|
850
|
+
runtime: stampNew(workspace.data),
|
|
851
|
+
});
|
|
852
|
+
if (!nextWorkspace.success)
|
|
853
|
+
return failure("invalid_input", "workspace mutation produced an invalid record");
|
|
854
|
+
const reserved = this.writeWorkspace(nextWorkspace.data);
|
|
855
|
+
if (!reserved.ok) return reserved;
|
|
856
|
+
const uncertain = (message: string) => {
|
|
857
|
+
this.markUncertain(nextWorkspace.data);
|
|
858
|
+
return failure("external_outcome_unknown", message, {
|
|
859
|
+
operation: "coupled_mutation",
|
|
860
|
+
outcome: "unknown",
|
|
841
861
|
});
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
862
|
+
};
|
|
863
|
+
const taskContext = { now: input.now ?? now(), revision: newRevision() };
|
|
864
|
+
let changedTask: Result<TaskRecord>;
|
|
865
|
+
try {
|
|
866
|
+
changedTask = input.task(
|
|
867
|
+
structuredClone(task.data.record),
|
|
868
|
+
Object.freeze({ ...taskContext }),
|
|
869
|
+
);
|
|
870
|
+
} catch (error) {
|
|
871
|
+
return uncertain(`workspace reserved but task mutation threw: ${String(error)}`);
|
|
872
|
+
}
|
|
873
|
+
if (!changedTask.ok) return uncertain("workspace reserved but task update is uncertain");
|
|
874
|
+
const nextTask = this.nextTask(task.data.record, changedTask.data, taskContext);
|
|
875
|
+
if (!nextTask) return uncertain("workspace reserved but task update is uncertain");
|
|
876
|
+
const written = this.appendPatch(task.data, nextTask);
|
|
877
|
+
if (!written.ok) return uncertain("workspace reserved but task append is uncertain");
|
|
878
|
+
return success(nextTask.revision, nextWorkspace.data.revision, {
|
|
879
|
+
task: nextTask,
|
|
880
|
+
workspace: nextWorkspace.data,
|
|
881
|
+
});
|
|
882
|
+
}),
|
|
883
|
+
);
|
|
884
|
+
this.compactPending();
|
|
885
|
+
return result;
|
|
857
886
|
}
|
|
858
887
|
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
): Result<
|
|
864
|
-
|
|
865
|
-
return failure("invalid_input", "task ID is invalid", { taskId });
|
|
888
|
+
/** Hold this checkout's lock across a managed effect. */
|
|
889
|
+
async withExternalActionLock<T>(
|
|
890
|
+
operation: () => Promise<Result<T>>,
|
|
891
|
+
reentrant = false,
|
|
892
|
+
): Promise<Result<T>> {
|
|
893
|
+
const activeRoots = externalActionLockRoots.getStore();
|
|
866
894
|
if (
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
typeof input.processEvidence !== "function" ||
|
|
870
|
-
!validDigest(input.expectedBytes) ||
|
|
871
|
-
!validDigest(input.snapshotDigest)
|
|
895
|
+
activeRoots &&
|
|
896
|
+
[...activeRoots].some((root) => root === this.root || sameDirectoryIdentity(root, this.root))
|
|
872
897
|
)
|
|
873
|
-
return
|
|
874
|
-
|
|
875
|
-
|
|
898
|
+
return operation();
|
|
899
|
+
const ready = this.ready(true);
|
|
900
|
+
if (!ready.ok) return ready;
|
|
901
|
+
const lockPath = this.paths().checkoutLock;
|
|
876
902
|
try {
|
|
877
|
-
|
|
878
|
-
if (!workspace.ok && target === "task") return workspace;
|
|
879
|
-
const workspaceValue = workspace.ok ? workspace.data : null;
|
|
880
|
-
if (workspaceValue && workspaceValue.revision !== input.expectedWorkspaceRevision)
|
|
881
|
-
return this.conflict(input.expectedWorkspaceRevision, workspaceValue.revision);
|
|
882
|
-
const file = target === "workspace" ? this.workspacePath : this.taskPath(taskId!);
|
|
883
|
-
const currentBytes = this.snapshotBytes(file);
|
|
884
|
-
if (!currentBytes) return failure("not_found", "snapshot not found");
|
|
885
|
-
if (digestBytes(currentBytes) !== input.expectedBytes)
|
|
886
|
-
return failure("revision_conflict", "snapshot bytes do not match expected bytes");
|
|
887
|
-
let selectedBytes = currentBytes;
|
|
888
|
-
if (digestBytes(currentBytes) !== input.snapshotDigest) {
|
|
889
|
-
const candidate = this.findRecovery(target, taskId, input.snapshotDigest, workspaceValue);
|
|
890
|
-
if (!candidate)
|
|
891
|
-
return failure("recovery_required", "validated recovery snapshot not found");
|
|
892
|
-
selectedBytes = candidate;
|
|
893
|
-
}
|
|
894
|
-
const selectedRecord =
|
|
895
|
-
target === "workspace"
|
|
896
|
-
? this.parseBytes<WorkspaceRecord>(selectedBytes, workspaceRecordSchema, "rewrite")
|
|
897
|
-
: this.parseBytes<TaskRecord>(selectedBytes, taskRecordSchema, "rewrite");
|
|
898
|
-
if (!selectedRecord.ok) return selectedRecord;
|
|
899
|
-
if (target === "workspace") {
|
|
900
|
-
const parsed = selectedRecord as Result<WorkspaceRecord>;
|
|
901
|
-
if (!parsed.ok || parsed.data.root !== this.root)
|
|
902
|
-
return failure("recovery_required", "workspace recovery binding is invalid");
|
|
903
|
-
} else {
|
|
904
|
-
const parsed = selectedRecord as Result<TaskRecord>;
|
|
905
|
-
if (
|
|
906
|
-
!parsed.ok ||
|
|
907
|
-
parsed.data.id !== taskId ||
|
|
908
|
-
!workspaceValue ||
|
|
909
|
-
parsed.data.workspaceId !== workspaceValue.id
|
|
910
|
-
)
|
|
911
|
-
return failure("recovery_required", "task recovery binding is invalid");
|
|
912
|
-
}
|
|
913
|
-
const lock = this.readLockSnapshot();
|
|
914
|
-
if (!lock.ok) return lock;
|
|
915
|
-
const writer =
|
|
916
|
-
target === "workspace"
|
|
917
|
-
? (selectedRecord.data as WorkspaceRecord).writer
|
|
918
|
-
: (workspaceValue?.writer ?? null);
|
|
919
|
-
const evidence = this.processEvidence(input, lock.data?.data ?? null, writer);
|
|
920
|
-
if (!evidence.ok) return evidence;
|
|
921
|
-
if (
|
|
922
|
-
lock.data &&
|
|
923
|
-
(evidence.data.pid !== lock.data.data.pid ||
|
|
924
|
-
evidence.data.processStart !== lock.data.data.processStart)
|
|
925
|
-
)
|
|
926
|
-
return failure("recovery_required", "process evidence does not match metadata lock");
|
|
927
|
-
if (
|
|
928
|
-
writer &&
|
|
929
|
-
(evidence.data.state !== "accounted_for" ||
|
|
930
|
-
evidence.data.ownerDigest !== sha256(canonicalJson(writer)))
|
|
931
|
-
)
|
|
932
|
-
return failure("recovery_required", "workspace writer is not accounted for");
|
|
933
|
-
const recoveryGate = { reclaimed: false };
|
|
934
|
-
const result = this.withLock<any>(
|
|
935
|
-
(handle) => {
|
|
936
|
-
if (lock.data && !recoveryGate.reclaimed)
|
|
937
|
-
return failure("recovery_required", "metadata lock changed during recovery", {
|
|
938
|
-
path: this.lockPath,
|
|
939
|
-
});
|
|
940
|
-
if (!handle.verifyStillHeld())
|
|
941
|
-
return failure("recovery_required", "metadata lock was compromised", {
|
|
942
|
-
path: this.lockPath,
|
|
943
|
-
});
|
|
944
|
-
const reacquiredBytes = this.snapshotBytes(file);
|
|
945
|
-
if (!reacquiredBytes)
|
|
946
|
-
return failure("recovery_required", "snapshot disappeared during recovery");
|
|
947
|
-
if (digestBytes(reacquiredBytes) !== input.expectedBytes)
|
|
948
|
-
return failure("revision_conflict", "snapshot changed during recovery");
|
|
949
|
-
if (target === "task") {
|
|
950
|
-
const currentWorkspace = this.readWorkspace();
|
|
951
|
-
if (!currentWorkspace.ok || !currentWorkspace.data)
|
|
952
|
-
return failure("recovery_required", "workspace changed during recovery");
|
|
953
|
-
if (currentWorkspace.data.revision !== input.expectedWorkspaceRevision)
|
|
954
|
-
return this.conflict(input.expectedWorkspaceRevision, currentWorkspace.data.revision);
|
|
955
|
-
}
|
|
956
|
-
if (target === "workspace") {
|
|
957
|
-
const parsed = this.parseBytes<WorkspaceRecord>(
|
|
958
|
-
selectedBytes,
|
|
959
|
-
workspaceRecordSchema,
|
|
960
|
-
"rewrite",
|
|
961
|
-
);
|
|
962
|
-
if (!parsed.ok) return parsed;
|
|
963
|
-
const value = { ...parsed.data, revision: newRevision(), writer: null };
|
|
964
|
-
const replaced = this.replaceSnapshot(file, value, reacquiredBytes);
|
|
965
|
-
return replaced.ok ? success(value.revision, value.revision, value) : replaced;
|
|
966
|
-
}
|
|
967
|
-
const parsed = this.parseBytes<TaskRecord>(selectedBytes, taskRecordSchema, "rewrite");
|
|
968
|
-
if (!parsed.ok) return parsed;
|
|
969
|
-
const value = {
|
|
970
|
-
...parsed.data,
|
|
971
|
-
revision: newRevision(),
|
|
972
|
-
updatedAt: now(),
|
|
973
|
-
status: parsed.data.status === "active" ? "paused" : parsed.data.status,
|
|
974
|
-
} as TaskRecord;
|
|
975
|
-
const replaced = this.replaceSnapshot(file, value, reacquiredBytes);
|
|
976
|
-
return replaced.ok ? success(value.revision, null, value) : replaced;
|
|
977
|
-
},
|
|
978
|
-
this.recoveryLockOptions(lock.data, evidence.data, recoveryGate),
|
|
979
|
-
"recovery_required",
|
|
980
|
-
);
|
|
981
|
-
return result;
|
|
903
|
+
this.initializeStorage();
|
|
982
904
|
} catch (error) {
|
|
983
|
-
return failure("
|
|
905
|
+
return failure("storage_error", `unable to initialize store: ${String(error)}`, {
|
|
906
|
+
path: this.paths().dir,
|
|
907
|
+
});
|
|
984
908
|
}
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
private processEvidence(
|
|
988
|
-
input: RecoveryInput,
|
|
989
|
-
lock: MetadataLock | null,
|
|
990
|
-
writer: WorkspaceRecord["writer"],
|
|
991
|
-
): Result<ProcessEvidence> {
|
|
992
|
-
const lockCopy = lock ? this.deepFreeze(structuredClone(lock)) : null;
|
|
993
|
-
const writerCopy = writer ? this.deepFreeze(structuredClone(writer)) : null;
|
|
994
|
-
const lockDigest = lockCopy ? sha256(canonicalJson(lockCopy)) : null;
|
|
995
|
-
const writerDigest = writerCopy ? sha256(canonicalJson(writerCopy)) : null;
|
|
909
|
+
let handle: FileLockSyncHandle;
|
|
996
910
|
try {
|
|
997
|
-
const
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
if (!result.ok) return result;
|
|
1003
|
-
const parsed = processEvidenceSchema.safeParse(result.data);
|
|
1004
|
-
return parsed.success
|
|
1005
|
-
? success(null, null, parsed.data)
|
|
1006
|
-
: failure("recovery_required", "process evidence is invalid");
|
|
911
|
+
const options = this.lockOptions(lockPath);
|
|
912
|
+
handle = this.acquire(lockPath, {
|
|
913
|
+
...options,
|
|
914
|
+
payload: () => ({ ...options.payload(), externalAction: true }),
|
|
915
|
+
});
|
|
1007
916
|
} catch (error) {
|
|
1008
|
-
return
|
|
1009
|
-
}
|
|
1010
|
-
}
|
|
1011
|
-
|
|
1012
|
-
private deepFreeze<T>(value: T): T {
|
|
1013
|
-
if (value && typeof value === "object") {
|
|
1014
|
-
for (const child of Object.values(value as Record<string, unknown>)) this.deepFreeze(child);
|
|
1015
|
-
Object.freeze(value);
|
|
917
|
+
return this.lockFailure(lockPath, error);
|
|
1016
918
|
}
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
private readLockSnapshot(): Result<LockSnapshot | null> {
|
|
1021
|
-
try {
|
|
1022
|
-
const stat = fs.lstatSync(this.lockPath);
|
|
1023
|
-
if (!stat.isFile() || stat.isSymbolicLink())
|
|
1024
|
-
return failure("recovery_required", "metadata lock is not a regular file", {
|
|
1025
|
-
path: this.lockPath,
|
|
1026
|
-
});
|
|
1027
|
-
const raw = fs.readFileSync(this.lockPath, "utf8");
|
|
1028
|
-
return success(null, null, { raw, data: parseMetadataLock(raw) });
|
|
1029
|
-
} catch (error: any) {
|
|
1030
|
-
if (error?.code === "ENOENT") return success(null, null, null);
|
|
1031
|
-
return failure("recovery_required", `metadata lock is invalid: ${String(error)}`, {
|
|
1032
|
-
path: this.lockPath,
|
|
1033
|
-
});
|
|
1034
|
-
}
|
|
1035
|
-
}
|
|
1036
|
-
|
|
1037
|
-
/**
|
|
1038
|
-
* Mutation lock: a holder that is gone (dead pid, reused pid, or a foreign or
|
|
1039
|
-
* unreadable lock past its TTL) is reclaimed; a live holder is waited on
|
|
1040
|
-
* briefly and then reported as retryable `busy`.
|
|
1041
|
-
*/
|
|
1042
|
-
private metadataLockOptions(): FileLockSyncAcquireOptions<MetadataLock> {
|
|
1043
|
-
const inProcess = heldInProcess.has(this.root);
|
|
1044
|
-
return {
|
|
1045
|
-
lockPath: this.lockPath,
|
|
1046
|
-
staleMs: Number.MAX_SAFE_INTEGER,
|
|
1047
|
-
timeoutMs: inProcess ? 0 : this.lockTimeoutMs,
|
|
1048
|
-
retry: inProcess
|
|
1049
|
-
? { retries: 0 }
|
|
1050
|
-
: { minTimeout: 5, maxTimeout: 100, factor: 1.5, randomize: true },
|
|
1051
|
-
staleRecovery: "remove-if-unchanged",
|
|
1052
|
-
shouldReclaim: ({ payload, nowMs }) => {
|
|
1053
|
-
let ageMs: number | null = null;
|
|
1054
|
-
try {
|
|
1055
|
-
ageMs = nowMs - fs.lstatSync(this.lockPath).mtimeMs;
|
|
1056
|
-
} catch {}
|
|
1057
|
-
return classifyLockOwner(payload, ageMs).state === "stale";
|
|
1058
|
-
},
|
|
1059
|
-
// The library re-checks the bytes before removal, so a lock replaced
|
|
1060
|
-
// after classification is never deleted.
|
|
1061
|
-
shouldRemoveStaleLock: () => true,
|
|
1062
|
-
parsePayload: parseMetadataLockOrNull,
|
|
1063
|
-
payload: () => ({
|
|
1064
|
-
pid: process.pid,
|
|
1065
|
-
processStart: processStartOf(process.pid),
|
|
1066
|
-
host: localLockHost(),
|
|
1067
|
-
nonce: randomUUID(),
|
|
1068
|
-
}),
|
|
1069
|
-
};
|
|
1070
|
-
}
|
|
1071
|
-
|
|
1072
|
-
private acquireMetadataLock(
|
|
1073
|
-
options: FileLockSyncAcquireOptions<MetadataLock>,
|
|
1074
|
-
): FileLockSyncHandle {
|
|
1075
|
-
clearAbandonedReclaimGuard(this.lockPath);
|
|
1076
|
-
// One budget for the whole acquisition: a lost reclaim race retries with
|
|
1077
|
-
// the remaining time, never a fresh timeout.
|
|
1078
|
-
const deadline = Date.now() + (options.timeoutMs ?? 0);
|
|
1079
|
-
while (true) {
|
|
1080
|
-
try {
|
|
1081
|
-
return acquireFileLockSync(this.workspacePath, {
|
|
1082
|
-
...options,
|
|
1083
|
-
timeoutMs: Math.max(0, deadline - Date.now()),
|
|
1084
|
-
});
|
|
1085
|
-
} catch (error) {
|
|
1086
|
-
// Losing a reclaim race to another process, or Windows refusing the
|
|
1087
|
-
// lock file while another process opens or deletes it, is contention.
|
|
1088
|
-
const code = (error as { code?: unknown })?.code;
|
|
1089
|
-
if (
|
|
1090
|
-
(code !== "file_lock_stale" && !isTransientWindowsError(error)) ||
|
|
1091
|
-
Date.now() >= deadline
|
|
1092
|
-
)
|
|
1093
|
-
throw error;
|
|
1094
|
-
// Windows also denies the create while a just-released lock is still
|
|
1095
|
-
// pending delete, and that entry is already invisible to lstat. So a
|
|
1096
|
-
// denial with no visible holder is told apart by probing whether the
|
|
1097
|
-
// directory accepts a new file: if it does not, this is a permission
|
|
1098
|
-
// problem (read-only attribute, ACL) and fails fast.
|
|
1099
|
-
if (code !== "file_lock_stale" && !this.lockHolderPresent() && !this.lockDirWritable())
|
|
1100
|
-
throw error;
|
|
1101
|
-
if (code !== "file_lock_stale")
|
|
1102
|
-
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5);
|
|
1103
|
-
}
|
|
1104
|
-
}
|
|
1105
|
-
}
|
|
1106
|
-
|
|
1107
|
-
private externalActionLockOptions(): FileLockSyncAcquireOptions<MetadataLock> {
|
|
1108
|
-
const options = this.metadataLockOptions();
|
|
1109
|
-
return { ...options, payload: () => ({ ...options.payload(), externalAction: true }) };
|
|
1110
|
-
}
|
|
1111
|
-
|
|
1112
|
-
/** Hold the workspace metadata lock across a managed effect in this checkout. */
|
|
1113
|
-
async withExternalActionLock<T>(
|
|
1114
|
-
operation: () => Promise<Result<T>>,
|
|
1115
|
-
reentrant = false,
|
|
1116
|
-
): Promise<Result<T>> {
|
|
1117
|
-
const activeRoots = externalActionLockRoots.getStore();
|
|
1118
|
-
if (
|
|
1119
|
-
activeRoots &&
|
|
1120
|
-
[...activeRoots].some((root) => root === this.root || sameDirectoryIdentity(root, this.root))
|
|
1121
|
-
)
|
|
1122
|
-
return operation();
|
|
1123
|
-
try {
|
|
1124
|
-
this.initializeMutationStorage();
|
|
1125
|
-
} catch (error) {
|
|
1126
|
-
return failure("storage_error", `unable to initialize store: ${String(error)}`, {
|
|
1127
|
-
path: this.workitDir,
|
|
1128
|
-
});
|
|
1129
|
-
}
|
|
1130
|
-
let handle: FileLockSyncHandle;
|
|
1131
|
-
try {
|
|
1132
|
-
handle = this.acquireMetadataLock(this.externalActionLockOptions());
|
|
1133
|
-
} catch (error) {
|
|
1134
|
-
return this.lockFailure(error);
|
|
1135
|
-
}
|
|
1136
|
-
holdRoot(this.root);
|
|
1137
|
-
let result: Result<T>;
|
|
919
|
+
hold(lockPath);
|
|
920
|
+
let result: Result<T>;
|
|
1138
921
|
try {
|
|
1139
922
|
if (!handle.verifyStillHeld())
|
|
1140
|
-
result = failure("recovery_required", "metadata lock was compromised", {
|
|
1141
|
-
path: this.lockPath,
|
|
1142
|
-
});
|
|
923
|
+
result = failure("recovery_required", "metadata lock was compromised", { path: lockPath });
|
|
1143
924
|
else {
|
|
1144
925
|
try {
|
|
1145
926
|
const run = () => operation();
|
|
@@ -1155,26 +936,26 @@ export class TaskStore {
|
|
|
1155
936
|
if (!handle.verifyStillHeld())
|
|
1156
937
|
result = failure("external_outcome_unknown", "metadata lock was compromised", {
|
|
1157
938
|
outcome: "unknown",
|
|
1158
|
-
path:
|
|
939
|
+
path: lockPath,
|
|
1159
940
|
});
|
|
1160
941
|
} catch {
|
|
1161
942
|
result = failure("external_outcome_unknown", "metadata lock could not be verified", {
|
|
1162
943
|
outcome: "unknown",
|
|
1163
|
-
path:
|
|
944
|
+
path: lockPath,
|
|
1164
945
|
});
|
|
1165
946
|
}
|
|
1166
947
|
}
|
|
1167
948
|
} catch {
|
|
1168
949
|
result = failure("external_outcome_unknown", "external action lock operation failed", {
|
|
1169
950
|
outcome: "unknown",
|
|
1170
|
-
path:
|
|
951
|
+
path: lockPath,
|
|
1171
952
|
});
|
|
1172
953
|
}
|
|
1173
|
-
|
|
954
|
+
drop(lockPath);
|
|
1174
955
|
try {
|
|
1175
956
|
retryTransient(() => handle.release());
|
|
1176
957
|
} catch (error) {
|
|
1177
|
-
const released = this.lockFailure(error);
|
|
958
|
+
const released = this.lockFailure(lockPath, error);
|
|
1178
959
|
const releaseError = released.ok ? "metadata lock release failed" : released.error;
|
|
1179
960
|
return result.ok
|
|
1180
961
|
? failure(
|
|
@@ -1182,7 +963,7 @@ export class TaskStore {
|
|
|
1182
963
|
"external action metadata lock release is uncertain",
|
|
1183
964
|
{
|
|
1184
965
|
outcome: "unknown",
|
|
1185
|
-
path:
|
|
966
|
+
path: lockPath,
|
|
1186
967
|
},
|
|
1187
968
|
)
|
|
1188
969
|
: failure("recovery_required", `${result.error}; ${releaseError}`, result.details);
|
|
@@ -1190,597 +971,900 @@ export class TaskStore {
|
|
|
1190
971
|
return result;
|
|
1191
972
|
}
|
|
1192
973
|
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
options
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
974
|
+
/**
|
|
975
|
+
* `workit gc`: compact long task logs into a checkpoint plus their most
|
|
976
|
+
* recent events (the latest state is never lost), remove unreferenced
|
|
977
|
+
* candidate blobs and temp files left by crashed writers, and report a 2.x
|
|
978
|
+
* `.workit/recovery` directory left behind by migration (removed only with
|
|
979
|
+
* `pruneRecovery`). A dry run is read-only.
|
|
980
|
+
*/
|
|
981
|
+
collectGarbage(
|
|
982
|
+
options: { dryRun?: boolean; pruneRecovery?: boolean; compactAbove?: number } = {},
|
|
983
|
+
): Result<GarbageReport> {
|
|
984
|
+
const dryRun = options.dryRun === true;
|
|
985
|
+
const compactAbove = options.compactAbove ?? COMPACT_ABOVE;
|
|
986
|
+
const report: GarbageReport = {
|
|
987
|
+
dryRun,
|
|
988
|
+
compacted: { tasks: [], eventsFolded: 0, bytesBefore: 0, bytesAfter: 0 },
|
|
989
|
+
blobs: { removed: 0, removedBytes: 0, kept: 0 },
|
|
990
|
+
temporary: { removed: 0 },
|
|
991
|
+
legacyRecovery: null,
|
|
992
|
+
retried: [],
|
|
993
|
+
failed: [],
|
|
994
|
+
};
|
|
995
|
+
const ready = this.ready(!dryRun);
|
|
996
|
+
if (!ready.ok) return ready;
|
|
997
|
+
const dirs = this.paths();
|
|
998
|
+
const recovery = path.join(this.root, ".workit", "recovery");
|
|
999
|
+
if (fs.existsSync(recovery)) {
|
|
1000
|
+
let files = 0;
|
|
1001
|
+
let bytes = 0;
|
|
1002
|
+
try {
|
|
1003
|
+
for (const name of fs.readdirSync(recovery)) {
|
|
1004
|
+
const stat = fs.lstatSync(path.join(recovery, name));
|
|
1005
|
+
if (!stat.isFile()) continue;
|
|
1006
|
+
files += 1;
|
|
1007
|
+
bytes += stat.size;
|
|
1008
|
+
}
|
|
1009
|
+
} catch {}
|
|
1010
|
+
const remove = options.pruneRecovery === true && !dryRun;
|
|
1011
|
+
if (remove) fs.rmSync(recovery, { recursive: true, force: true });
|
|
1012
|
+
report.legacyRecovery = { path: recovery, files, bytes, removed: remove };
|
|
1013
|
+
}
|
|
1014
|
+
if (!fs.existsSync(dirs.tasks)) return success(null, null, report);
|
|
1015
|
+
const names = fs.readdirSync(dirs.tasks).filter((name) => validId(name));
|
|
1016
|
+
for (const name of names) {
|
|
1017
|
+
const file = this.eventsPath(name);
|
|
1018
|
+
let size = 0;
|
|
1019
|
+
try {
|
|
1020
|
+
size = fs.statSync(file).size;
|
|
1021
|
+
} catch {
|
|
1022
|
+
continue;
|
|
1023
|
+
}
|
|
1024
|
+
const lines = this.countEvents(file);
|
|
1025
|
+
if (lines > compactAbove) {
|
|
1026
|
+
if (dryRun) {
|
|
1027
|
+
report.compacted.tasks.push(name);
|
|
1028
|
+
report.compacted.eventsFolded += lines - COMPACT_KEEP;
|
|
1029
|
+
report.compacted.bytesBefore += size;
|
|
1030
|
+
} else {
|
|
1031
|
+
const compacted = this.compact(name);
|
|
1032
|
+
if (!compacted.ok)
|
|
1033
|
+
(compacted.code === "busy" ? report.retried : report.failed).push(name);
|
|
1034
|
+
else {
|
|
1035
|
+
report.compacted.tasks.push(name);
|
|
1036
|
+
report.compacted.eventsFolded += compacted.data.folded;
|
|
1037
|
+
report.compacted.bytesBefore += size;
|
|
1038
|
+
report.compacted.bytesAfter += compacted.data.bytes;
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
const nowMs = Date.now();
|
|
1044
|
+
// Blobs go only when unreferenced and older than the grace period, under
|
|
1045
|
+
// the store's gc lock (one sweep at a time).
|
|
1046
|
+
const sweep = (): Result<null> => {
|
|
1047
|
+
const references = new Set<string>();
|
|
1048
|
+
for (const name of names)
|
|
1049
|
+
try {
|
|
1050
|
+
for (const match of fs
|
|
1051
|
+
.readFileSync(this.eventsPath(name), "utf8")
|
|
1052
|
+
.matchAll(/"\$blob":"([0-9a-f]{64})"/g))
|
|
1053
|
+
references.add(match[1]);
|
|
1054
|
+
} catch {}
|
|
1055
|
+
if (fs.existsSync(dirs.blobs))
|
|
1056
|
+
for (const name of fs.readdirSync(dirs.blobs)) {
|
|
1057
|
+
const digest = name.replace(/\.json$/, "");
|
|
1058
|
+
const file = path.join(dirs.blobs, name);
|
|
1059
|
+
let stat: fs.Stats;
|
|
1060
|
+
try {
|
|
1061
|
+
stat = fs.lstatSync(file);
|
|
1062
|
+
} catch {
|
|
1063
|
+
continue;
|
|
1064
|
+
}
|
|
1065
|
+
if (references.has(digest) || nowMs - stat.mtimeMs <= STALE_TEMPORARY_MS) {
|
|
1066
|
+
report.blobs.kept += 1;
|
|
1067
|
+
continue;
|
|
1068
|
+
}
|
|
1069
|
+
report.blobs.removed += 1;
|
|
1070
|
+
report.blobs.removedBytes += stat.size;
|
|
1071
|
+
if (!dryRun) fs.rmSync(file, { force: true });
|
|
1072
|
+
}
|
|
1073
|
+
return success(null, null, null);
|
|
1214
1074
|
};
|
|
1215
|
-
|
|
1075
|
+
const swept = dryRun ? sweep() : this.withLock(path.join(dirs.dir, "gc.lock"), sweep);
|
|
1076
|
+
if (!swept.ok) return swept;
|
|
1077
|
+
const temporaryDirs = [
|
|
1078
|
+
dirs.dir,
|
|
1079
|
+
dirs.tasks,
|
|
1080
|
+
dirs.blobs,
|
|
1081
|
+
dirs.checkout,
|
|
1082
|
+
...names.map((name) => path.join(dirs.tasks, name)),
|
|
1083
|
+
];
|
|
1084
|
+
for (const directory of temporaryDirs) {
|
|
1085
|
+
if (!fs.existsSync(directory)) continue;
|
|
1086
|
+
for (const name of fs.readdirSync(directory)) {
|
|
1087
|
+
if (!name.endsWith(".tmp") && !name.endsWith(".probe")) continue;
|
|
1088
|
+
const file = path.join(directory, name);
|
|
1089
|
+
try {
|
|
1090
|
+
const stat = fs.lstatSync(file);
|
|
1091
|
+
if (!stat.isFile() || nowMs - stat.mtimeMs <= STALE_TEMPORARY_MS) continue;
|
|
1092
|
+
} catch {
|
|
1093
|
+
continue;
|
|
1094
|
+
}
|
|
1095
|
+
report.temporary.removed += 1;
|
|
1096
|
+
if (!dryRun) fs.rmSync(file, { force: true });
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
return success(null, null, report);
|
|
1216
1100
|
}
|
|
1217
1101
|
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1102
|
+
// -------------------------------------------------------------------------
|
|
1103
|
+
// internals: layout
|
|
1104
|
+
|
|
1105
|
+
private paths() {
|
|
1106
|
+
const location = this.location();
|
|
1107
|
+
// Callers go through ready(), which fails first when the store is unresolved.
|
|
1108
|
+
const dir = location.ok ? location.data.dir : path.join(this.root, ".workit");
|
|
1109
|
+
const checkout = path.join(dir, "checkouts", checkoutSlug(this.root));
|
|
1110
|
+
return {
|
|
1111
|
+
dir,
|
|
1112
|
+
shared: location.ok ? location.data.shared : false,
|
|
1113
|
+
marker: path.join(dir, "store.json"),
|
|
1114
|
+
checkout,
|
|
1115
|
+
workspace: path.join(checkout, "workspace.json"),
|
|
1116
|
+
checkoutLock: path.join(checkout, "metadata.lock"),
|
|
1117
|
+
tasks: path.join(dir, "tasks"),
|
|
1118
|
+
index: path.join(dir, "task-index.json"),
|
|
1119
|
+
blobs: path.join(dir, "blobs", "candidates"),
|
|
1120
|
+
legacy: path.join(dir, "legacy", checkoutSlug(this.root), "v2"),
|
|
1121
|
+
v2: path.join(this.root, ".workit"),
|
|
1225
1122
|
};
|
|
1226
|
-
|
|
1123
|
+
}
|
|
1124
|
+
private eventsPath(taskId: Id) {
|
|
1125
|
+
return path.join(this.paths().tasks, taskId, "events.jsonl");
|
|
1126
|
+
}
|
|
1127
|
+
private snapshotPath(taskId: Id) {
|
|
1128
|
+
return path.join(this.paths().tasks, taskId, "snapshot.json");
|
|
1129
|
+
}
|
|
1130
|
+
private taskLockPath(taskId: Id) {
|
|
1131
|
+
return path.join(this.paths().tasks, taskId, "lock");
|
|
1227
1132
|
}
|
|
1228
1133
|
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1134
|
+
/** The store resolves, its format is readable, and any 2.x store here is migrated. */
|
|
1135
|
+
/**
|
|
1136
|
+
* The store resolves and its format is readable. A pending migration (a
|
|
1137
|
+
* 2.x store, or a pre-git 3.x store) runs on writes, and on reads only for
|
|
1138
|
+
* a host that can tell the user (the CLI); other reads, e.g. per-turn
|
|
1139
|
+
* hooks, get a `needs_input` naming the command that migrates.
|
|
1140
|
+
*/
|
|
1141
|
+
private ready(write = false): Result<null> {
|
|
1142
|
+
const location = this.location();
|
|
1143
|
+
if (!location.ok) return location;
|
|
1144
|
+
if (this.migrationChecked) return success(null, null, null);
|
|
1145
|
+
const dirs = this.paths();
|
|
1234
1146
|
try {
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1147
|
+
const marker = JSON.parse(fs.readFileSync(dirs.marker, "utf8")) as { version?: unknown };
|
|
1148
|
+
if (typeof marker.version === "number" && marker.version > STORE_FORMAT)
|
|
1149
|
+
return failure(
|
|
1150
|
+
"recovery_required",
|
|
1151
|
+
`the workit store at ${dirs.dir} has format ${marker.version}; upgrade Workit to use it`,
|
|
1152
|
+
{ path: dirs.marker },
|
|
1153
|
+
);
|
|
1154
|
+
} catch {}
|
|
1155
|
+
// Reads made by the migration itself see the store as ready.
|
|
1156
|
+
this.migrationChecked = true;
|
|
1157
|
+
const settled = this.settleMigrations(write);
|
|
1158
|
+
if (!settled.ok) this.migrationChecked = false;
|
|
1159
|
+
return settled;
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
private settleMigrations(write: boolean): Result<null> {
|
|
1163
|
+
const moved = this.adoptLocalStore();
|
|
1164
|
+
if (!moved.ok) return moved;
|
|
1165
|
+
const pending = this.pendingMigrations();
|
|
1166
|
+
if (pending.length > 0 && !write && !(this.migrateOnRead ?? migrationReporterInstalled()))
|
|
1167
|
+
return failure(
|
|
1168
|
+
"needs_input",
|
|
1169
|
+
`${MIGRATION_PENDING}: the task store at ${pending[0]} has not been migrated yet; run \`workit task status\` to migrate it`,
|
|
1170
|
+
{ path: pending[0], guidance: "workit task status" },
|
|
1171
|
+
);
|
|
1172
|
+
for (const v2 of this.v2Dirs()) {
|
|
1173
|
+
const migrated = this.migrateV2(v2);
|
|
1174
|
+
if (!migrated.ok) return migrated;
|
|
1240
1175
|
}
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
);
|
|
1176
|
+
return success(null, null, null);
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
private blobStore(): BlobStore {
|
|
1180
|
+
const dir = this.paths().blobs;
|
|
1181
|
+
return {
|
|
1182
|
+
read: (digest) => {
|
|
1183
|
+
try {
|
|
1184
|
+
return JSON.parse(fs.readFileSync(path.join(dir, `${digest}.json`), "utf8"));
|
|
1185
|
+
} catch (error) {
|
|
1186
|
+
throw new LogDamage(
|
|
1187
|
+
`stored candidate ${digest} is missing or unreadable: ${String(error)}`,
|
|
1188
|
+
);
|
|
1189
|
+
}
|
|
1190
|
+
},
|
|
1191
|
+
write: (digest, value) => {
|
|
1192
|
+
const file = path.join(dir, `${digest}.json`);
|
|
1193
|
+
if (fs.existsSync(file)) {
|
|
1194
|
+
// A reused blob is fresh again: gc spares blobs younger than its
|
|
1195
|
+
// grace period, so one about to be referenced is never collected.
|
|
1196
|
+
const stamp = new Date();
|
|
1197
|
+
try {
|
|
1198
|
+
fs.utimesSync(file, stamp, stamp);
|
|
1199
|
+
return;
|
|
1200
|
+
} catch {}
|
|
1201
|
+
}
|
|
1202
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
1203
|
+
replaceFile(file, JSON.stringify(value), true);
|
|
1204
|
+
},
|
|
1205
|
+
};
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
// -------------------------------------------------------------------------
|
|
1209
|
+
// internals: task logs
|
|
1210
|
+
|
|
1211
|
+
/** Reduce a task's log (from its snapshot when that matches); null when absent. */
|
|
1212
|
+
private load(taskId: Id): Result<Loaded | null> {
|
|
1246
1213
|
try {
|
|
1247
|
-
|
|
1214
|
+
const loaded = this.loadOrThrow(taskId);
|
|
1215
|
+
if (!loaded) return success(null, null, null);
|
|
1216
|
+
const parsed = this.parseTask(loaded.state.record, taskId);
|
|
1217
|
+
if (!parsed.ok) return parsed;
|
|
1218
|
+
return success(null, null, {
|
|
1219
|
+
record: parsed.data,
|
|
1220
|
+
key: loaded.state.key,
|
|
1221
|
+
legacy: loaded.state.legacy,
|
|
1222
|
+
seq: loaded.seq,
|
|
1223
|
+
lastId: loaded.lastId,
|
|
1224
|
+
lastStart: loaded.lastStart,
|
|
1225
|
+
end: loaded.end,
|
|
1226
|
+
size: loaded.size,
|
|
1227
|
+
snapshotSeq: loaded.snapshotSeq,
|
|
1228
|
+
});
|
|
1248
1229
|
} catch (error) {
|
|
1249
|
-
|
|
1230
|
+
if (error instanceof LogDamage)
|
|
1231
|
+
return failure(
|
|
1232
|
+
"recovery_required",
|
|
1233
|
+
error.upgrade ? error.message : `task ${taskId} log is damaged: ${error.message}`,
|
|
1234
|
+
{ taskId, path: this.eventsPath(taskId) },
|
|
1235
|
+
);
|
|
1236
|
+
return failure("recovery_required", `task ${taskId} cannot be read: ${String(error)}`, {
|
|
1237
|
+
taskId,
|
|
1238
|
+
});
|
|
1250
1239
|
}
|
|
1251
|
-
|
|
1252
|
-
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
private loadOrThrow(taskId: Id) {
|
|
1243
|
+
const blobs = this.blobStore();
|
|
1244
|
+
const file = this.eventsPath(taskId);
|
|
1245
|
+
const snapshot = this.readSnapshot(taskId);
|
|
1246
|
+
if (snapshot) {
|
|
1253
1247
|
try {
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1248
|
+
const tail = readLog(file, snapshot.lastStart, snapshot.seq);
|
|
1249
|
+
const first = tail?.events[0];
|
|
1250
|
+
// The snapshot names the event it was taken after: same seq, same id.
|
|
1251
|
+
if (tail && first && first.id === snapshot.lastId) {
|
|
1252
|
+
let state: ReducedTask | null = {
|
|
1253
|
+
record: snapshot.record as Record<string, unknown>,
|
|
1254
|
+
key: snapshot.key,
|
|
1255
|
+
legacy: snapshot.legacy,
|
|
1256
|
+
};
|
|
1257
|
+
for (const event of tail.events.slice(1)) state = reduce(state, event, blobs);
|
|
1258
|
+
return this.loadedFrom(state!, tail, snapshot.seq);
|
|
1264
1259
|
}
|
|
1265
1260
|
} catch (error) {
|
|
1266
|
-
|
|
1261
|
+
if (error instanceof LogDamage && error.upgrade) throw error;
|
|
1262
|
+
// Any other mismatch: fall back to a full replay.
|
|
1267
1263
|
}
|
|
1268
1264
|
}
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
} catch (error) {
|
|
1274
|
-
const releaseFailure = this.lockFailure(error);
|
|
1275
|
-
const releaseError = releaseFailure.ok
|
|
1276
|
-
? "metadata lock release failed"
|
|
1277
|
-
: releaseFailure.error;
|
|
1278
|
-
result = result.ok
|
|
1279
|
-
? releaseFailure
|
|
1280
|
-
: failure("recovery_required", `${result.error}; ${releaseError}`, result.details);
|
|
1281
|
-
}
|
|
1265
|
+
const log = readLog(file, 0, null);
|
|
1266
|
+
if (!log || log.events.length === 0) {
|
|
1267
|
+
if (log && log.size > 0) throw new LogDamage("the log holds no complete event");
|
|
1268
|
+
return null;
|
|
1282
1269
|
}
|
|
1283
|
-
|
|
1270
|
+
if (!OPENING_TYPES.has(log.events[0].type))
|
|
1271
|
+
throw new LogDamage(`the log starts with ${log.events[0].type}, not an opening event`);
|
|
1272
|
+
let state: ReducedTask | null = null;
|
|
1273
|
+
for (const event of log.events) state = reduce(state, event, blobs);
|
|
1274
|
+
return this.loadedFrom(state!, log, 0);
|
|
1284
1275
|
}
|
|
1285
1276
|
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1277
|
+
private loadedFrom(state: ReducedTask, log: LogRead, snapshotSeq: number) {
|
|
1278
|
+
const last = log.events.at(-1)!;
|
|
1279
|
+
return {
|
|
1280
|
+
state,
|
|
1281
|
+
seq: last.seq,
|
|
1282
|
+
lastId: last.id,
|
|
1283
|
+
lastStart: log.lastStart,
|
|
1284
|
+
end: log.end,
|
|
1285
|
+
size: log.size,
|
|
1286
|
+
snapshotSeq,
|
|
1287
|
+
};
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
private readSnapshot(taskId: Id): Snapshot | null {
|
|
1289
1291
|
try {
|
|
1290
|
-
|
|
1292
|
+
const value = JSON.parse(fs.readFileSync(this.snapshotPath(taskId), "utf8")) as unknown;
|
|
1293
|
+
if (
|
|
1294
|
+
!isObject(value) ||
|
|
1295
|
+
value.v !== 1 ||
|
|
1296
|
+
value.task !== taskId ||
|
|
1297
|
+
typeof value.seq !== "number" ||
|
|
1298
|
+
typeof value.lastId !== "string" ||
|
|
1299
|
+
typeof value.lastStart !== "number" ||
|
|
1300
|
+
typeof value.end !== "number" ||
|
|
1301
|
+
!isObject(value.record)
|
|
1302
|
+
)
|
|
1303
|
+
return null;
|
|
1304
|
+
return value as Snapshot;
|
|
1291
1305
|
} catch {
|
|
1292
|
-
return
|
|
1306
|
+
return null;
|
|
1293
1307
|
}
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
private writeSnapshot(taskId: Id, loaded: Omit<Loaded, "size" | "snapshotSeq">) {
|
|
1311
|
+
const snapshot: Snapshot = {
|
|
1312
|
+
v: 1,
|
|
1313
|
+
task: taskId,
|
|
1314
|
+
seq: loaded.seq,
|
|
1315
|
+
lastId: loaded.lastId,
|
|
1316
|
+
lastStart: loaded.lastStart,
|
|
1317
|
+
end: loaded.end,
|
|
1318
|
+
key: loaded.key,
|
|
1319
|
+
legacy: loaded.legacy,
|
|
1320
|
+
record: loaded.record,
|
|
1321
|
+
};
|
|
1294
1322
|
try {
|
|
1295
|
-
|
|
1323
|
+
// A cache: a lost or torn snapshot only costs a replay.
|
|
1324
|
+
replaceFile(this.snapshotPath(taskId), JSON.stringify(snapshot), false);
|
|
1296
1325
|
} catch {}
|
|
1297
|
-
return true;
|
|
1298
1326
|
}
|
|
1299
1327
|
|
|
1300
|
-
/**
|
|
1301
|
-
private
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1328
|
+
/** The task when it exists and belongs to this checkout. */
|
|
1329
|
+
private loadBound(taskId: Id): Result<Loaded> {
|
|
1330
|
+
const loaded = this.load(taskId);
|
|
1331
|
+
if (!loaded.ok) return loaded;
|
|
1332
|
+
if (!loaded.data) return failure("not_found", "task not found", { taskId });
|
|
1333
|
+
const workspace = this.readWorkspace();
|
|
1334
|
+
if (!workspace.ok) return workspace;
|
|
1335
|
+
const bound = this.bindingError(loaded.data.record, workspace.data);
|
|
1336
|
+
return bound ?? success(null, null, loaded.data);
|
|
1308
1337
|
}
|
|
1309
1338
|
|
|
1310
|
-
private
|
|
1311
|
-
|
|
1312
|
-
contention: "busy" | "recovery_required" = "busy",
|
|
1313
|
-
): Result<never> {
|
|
1314
|
-
const value = error as { code?: unknown; message?: unknown };
|
|
1315
|
-
const code = typeof value?.code === "string" ? value.code : "";
|
|
1316
|
-
// A sharing-class denial is contention only when someone holds the lock.
|
|
1317
|
-
if (isTransientWindowsError(error) && !this.lockHolderPresent() && !this.lockDirWritable())
|
|
1318
|
-
return failure(
|
|
1319
|
-
"storage_error",
|
|
1320
|
-
`cannot create the workspace metadata lock (${code}); no other Workit call holds it`,
|
|
1321
|
-
{
|
|
1322
|
-
path: this.lockPath,
|
|
1323
|
-
guidance: `Check permissions and the read-only attribute on ${path.dirname(this.lockPath)}.`,
|
|
1324
|
-
},
|
|
1325
|
-
);
|
|
1326
|
-
if (
|
|
1327
|
-
code === "EEXIST" ||
|
|
1328
|
-
code === "file_lock_timeout" ||
|
|
1329
|
-
code === "file_lock_stale" ||
|
|
1330
|
-
isTransientWindowsError(error)
|
|
1331
|
-
) {
|
|
1332
|
-
let lock: MetadataLock | null = null;
|
|
1333
|
-
try {
|
|
1334
|
-
lock = parseMetadataLockOrNull(fs.readFileSync(this.lockPath, "utf8"));
|
|
1335
|
-
} catch {}
|
|
1336
|
-
if (lock?.externalAction)
|
|
1337
|
-
return failure("writer_conflict", "workspace is reserved by a managed external action", {
|
|
1338
|
-
outcome: "not_started",
|
|
1339
|
-
path: this.lockPath,
|
|
1340
|
-
});
|
|
1341
|
-
if (contention === "busy")
|
|
1342
|
-
return failure(
|
|
1343
|
-
"busy",
|
|
1344
|
-
`workspace metadata lock is held by another Workit call${lock ? ` (pid ${lock.pid} on ${lock.host})` : ""}; retry shortly`,
|
|
1345
|
-
{
|
|
1346
|
-
outcome: "not_started",
|
|
1347
|
-
path: this.lockPath,
|
|
1348
|
-
guidance:
|
|
1349
|
-
"Retry the same call. If it stays busy, run `workit doctor --fix-lock` to clear a lock left by a dead process.",
|
|
1350
|
-
},
|
|
1351
|
-
);
|
|
1352
|
-
}
|
|
1353
|
-
const recovery =
|
|
1354
|
-
code === "EEXIST" ||
|
|
1355
|
-
code === "file_lock_timeout" ||
|
|
1356
|
-
code === "file_lock_stale" ||
|
|
1357
|
-
code === "metadata_lock_invalid" ||
|
|
1358
|
-
code === "not-file" ||
|
|
1359
|
-
/metadata lock|file lock|reclaim/i.test(String(value?.message ?? error));
|
|
1339
|
+
private bindingError(task: TaskRecord, workspace: WorkspaceRecord | null): Result<never> | null {
|
|
1340
|
+
if (workspace && task.workspaceId === workspace.id) return null;
|
|
1360
1341
|
return failure(
|
|
1361
|
-
|
|
1362
|
-
`
|
|
1363
|
-
{
|
|
1342
|
+
"not_found",
|
|
1343
|
+
`task ${task.id} belongs to another checkout of this repository; run there, or move it here with \`workit task adopt ${task.id}\``,
|
|
1344
|
+
{ taskId: task.id },
|
|
1364
1345
|
);
|
|
1365
1346
|
}
|
|
1366
1347
|
|
|
1367
|
-
private
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
current
|
|
1376
|
-
|
|
1377
|
-
|
|
1348
|
+
private nextTask(
|
|
1349
|
+
current: TaskRecord,
|
|
1350
|
+
changed: TaskRecord,
|
|
1351
|
+
context: MutationContext,
|
|
1352
|
+
): TaskRecord | null {
|
|
1353
|
+
const valid = taskRecordSchema.safeParse({
|
|
1354
|
+
...changed,
|
|
1355
|
+
id: current.id,
|
|
1356
|
+
workspaceId: current.workspaceId,
|
|
1357
|
+
createdAt: current.createdAt,
|
|
1358
|
+
revision: context.revision,
|
|
1359
|
+
updatedAt: context.now,
|
|
1360
|
+
runtime: stampNew(current),
|
|
1361
|
+
});
|
|
1362
|
+
return valid.success ? valid.data : null;
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
private event(taskId: Id, seq: number, type: string, data: Record<string, unknown>): StoreEvent {
|
|
1366
|
+
return {
|
|
1367
|
+
v: EVENT_VERSION,
|
|
1368
|
+
seq,
|
|
1369
|
+
at: new Date().toISOString(),
|
|
1370
|
+
id: newId(),
|
|
1371
|
+
task: taskId,
|
|
1372
|
+
actor: this.actor,
|
|
1373
|
+
type,
|
|
1374
|
+
data,
|
|
1375
|
+
};
|
|
1378
1376
|
}
|
|
1379
1377
|
|
|
1380
|
-
|
|
1381
|
-
|
|
1378
|
+
/** Append the patch from `loaded` to `next` (under the task lock). */
|
|
1379
|
+
private appendPatch(
|
|
1380
|
+
loaded: Loaded,
|
|
1381
|
+
next: TaskRecord,
|
|
1382
|
+
extra: { key?: TaskKey | null; type?: string; legacy?: LegacyOrigin } = {},
|
|
1383
|
+
): Result<null> {
|
|
1384
|
+
const taskId = next.id;
|
|
1382
1385
|
try {
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1386
|
+
const blobs = this.blobStore();
|
|
1387
|
+
const before = loaded.record as unknown as Record<string, unknown>;
|
|
1388
|
+
const after = next as unknown as Record<string, unknown>;
|
|
1389
|
+
const ops = encodeOps(diff(before, after), blobs);
|
|
1390
|
+
const { type, ...data } = extra;
|
|
1391
|
+
if (data.key)
|
|
1392
|
+
data.key = { ...data.key, boundAt: data.key.boundAt ?? new Date().toISOString() };
|
|
1393
|
+
const event = this.event(
|
|
1394
|
+
taskId,
|
|
1395
|
+
loaded.seq + 1,
|
|
1396
|
+
type ?? ("key" in extra ? "task.bound" : eventType(before, after)),
|
|
1397
|
+
{ ops, ...data },
|
|
1398
|
+
);
|
|
1399
|
+
const start = appendEvent(this.eventsPath(taskId), event, loaded.end);
|
|
1400
|
+
const end = start + Buffer.byteLength(`${JSON.stringify(event)}\n`);
|
|
1401
|
+
const key = "key" in data ? (data.key ?? null) : loaded.key;
|
|
1402
|
+
const state = {
|
|
1403
|
+
record: next,
|
|
1404
|
+
key,
|
|
1405
|
+
legacy: extra.legacy ?? loaded.legacy,
|
|
1406
|
+
seq: event.seq,
|
|
1407
|
+
lastId: event.id,
|
|
1408
|
+
lastStart: start,
|
|
1409
|
+
end,
|
|
1410
|
+
};
|
|
1411
|
+
if (
|
|
1412
|
+
event.seq - loaded.snapshotSeq >= SNAPSHOT_EVERY ||
|
|
1413
|
+
next.status !== loaded.record.status ||
|
|
1414
|
+
loaded.snapshotSeq === 0
|
|
1415
|
+
)
|
|
1416
|
+
this.writeSnapshot(taskId, state);
|
|
1417
|
+
// Bounded without a gc run: a log past the watermark is folded once
|
|
1418
|
+
// this write releases its lock (see compactDue).
|
|
1419
|
+
if (end > AUTO_COMPACT_BYTES) this.compactDue.add(taskId);
|
|
1420
|
+
this.indexTask(next, key, (extra.legacy ?? loaded.legacy) !== null);
|
|
1421
|
+
return success(null, null, null);
|
|
1400
1422
|
} catch (error) {
|
|
1401
|
-
return failure("storage_error", `
|
|
1402
|
-
path:
|
|
1423
|
+
return failure("storage_error", `task append failed: ${String(error)}`, {
|
|
1424
|
+
path: this.eventsPath(taskId),
|
|
1403
1425
|
});
|
|
1404
|
-
} finally {
|
|
1405
|
-
if (temporary)
|
|
1406
|
-
try {
|
|
1407
|
-
fs.unlinkSync(temporary);
|
|
1408
|
-
} catch {}
|
|
1409
1426
|
}
|
|
1410
1427
|
}
|
|
1411
1428
|
|
|
1412
|
-
|
|
1429
|
+
/** Write a new task's opening event. */
|
|
1430
|
+
private openTask(
|
|
1431
|
+
record: TaskRecord,
|
|
1432
|
+
type: string,
|
|
1433
|
+
key: TaskKey | null,
|
|
1434
|
+
legacy: LegacyOrigin | null = null,
|
|
1435
|
+
) {
|
|
1436
|
+
const dir = path.join(this.paths().tasks, record.id);
|
|
1437
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
1438
|
+
const blobs = this.blobStore();
|
|
1439
|
+
const bound = key ? { ...key, boundAt: key.boundAt ?? new Date().toISOString() } : null;
|
|
1440
|
+
const event = this.event(record.id, 1, type, {
|
|
1441
|
+
record: encodeRecord(record, blobs),
|
|
1442
|
+
key: bound,
|
|
1443
|
+
...(legacy ? { legacy } : {}),
|
|
1444
|
+
});
|
|
1445
|
+
const file = this.eventsPath(record.id);
|
|
1446
|
+
const existing = readLog(file, 0, null);
|
|
1447
|
+
if (existing && existing.events.length > 0)
|
|
1448
|
+
throw new Error(`task ${record.id} already has a log`);
|
|
1449
|
+
appendEvent(file, event, 0);
|
|
1450
|
+
this.indexTask(record, bound, legacy !== null);
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
/** Fold the logs that writes marked past AUTO_COMPACT_BYTES (best effort). */
|
|
1454
|
+
private compactPending() {
|
|
1455
|
+
for (const taskId of this.compactDue) {
|
|
1456
|
+
this.compactDue.delete(taskId);
|
|
1457
|
+
this.compact(taskId);
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
/**
|
|
1462
|
+
* Compact a task log to a checkpoint + its last COMPACT_KEEP events. The
|
|
1463
|
+
* checkpoint is built without the lock; the task lock is held only to
|
|
1464
|
+
* check that the log still holds the events it was built from, carry over
|
|
1465
|
+
* events appended meanwhile, and swap the file.
|
|
1466
|
+
*/
|
|
1467
|
+
private compact(taskId: Id): Result<{ folded: number; bytes: number }> {
|
|
1413
1468
|
try {
|
|
1414
|
-
const
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
const
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1469
|
+
const file = this.eventsPath(taskId);
|
|
1470
|
+
const log = readLog(file, 0, null);
|
|
1471
|
+
if (!log || log.events.length <= COMPACT_KEEP + 1)
|
|
1472
|
+
return success(null, null, { folded: 0, bytes: log?.size ?? 0 });
|
|
1473
|
+
const blobs = this.blobStore();
|
|
1474
|
+
const cut = log.events.length - COMPACT_KEEP;
|
|
1475
|
+
let state: ReducedTask | null = null;
|
|
1476
|
+
for (const event of log.events.slice(0, cut)) state = reduce(state, event, blobs);
|
|
1477
|
+
const folded = log.events[cut - 1];
|
|
1478
|
+
const checkpoint: StoreEvent = {
|
|
1479
|
+
...this.event(taskId, folded.seq, "task.checkpoint", {
|
|
1480
|
+
record: encodeRecord(state!.record, blobs),
|
|
1481
|
+
key: state!.key,
|
|
1482
|
+
...(state!.legacy ? { legacy: state!.legacy } : {}),
|
|
1483
|
+
folded: { from: log.events[0].seq, to: folded.seq },
|
|
1484
|
+
}),
|
|
1485
|
+
};
|
|
1486
|
+
const prefix = `${[checkpoint, ...log.events.slice(cut)].map((event) => JSON.stringify(event)).join("\n")}\n`;
|
|
1487
|
+
const last = log.events.at(-1)!;
|
|
1488
|
+
return this.withLock(this.taskLockPath(taskId), () => {
|
|
1489
|
+
// The log must still hold the events read above (another compaction
|
|
1490
|
+
// or a rewrite means start over next time).
|
|
1491
|
+
let latest: LogRead | null = null;
|
|
1492
|
+
try {
|
|
1493
|
+
latest = readLog(file, log.lastStart, last.seq);
|
|
1494
|
+
} catch {}
|
|
1495
|
+
if (!latest || latest.events[0]?.id !== last.id)
|
|
1496
|
+
return failure("busy", "the task log changed during compaction; retry", { taskId });
|
|
1497
|
+
const appended = fs.readFileSync(file).subarray(log.end, latest.end);
|
|
1498
|
+
const bytes = Buffer.concat([Buffer.from(prefix, "utf8"), appended]);
|
|
1499
|
+
replaceFile(file, bytes.toString("utf8"), true);
|
|
1500
|
+
// Re-anchor the snapshot on the rewritten log.
|
|
1501
|
+
try {
|
|
1502
|
+
fs.rmSync(this.snapshotPath(taskId), { force: true });
|
|
1503
|
+
} catch {}
|
|
1504
|
+
const reloaded = this.load(taskId);
|
|
1505
|
+
if (reloaded.ok && reloaded.data) this.writeSnapshot(taskId, reloaded.data);
|
|
1506
|
+
return success(null, null, { folded: cut, bytes: bytes.length });
|
|
1507
|
+
});
|
|
1508
|
+
} catch (error) {
|
|
1509
|
+
return failure("storage_error", `compaction failed: ${String(error)}`, { taskId });
|
|
1438
1510
|
}
|
|
1439
1511
|
}
|
|
1440
1512
|
|
|
1441
|
-
|
|
1442
|
-
private writeIndex(workspaceId: Id, entries: TaskIndexEntry[]) {
|
|
1443
|
-
const temporary = `${this.indexPath}.${process.pid}.${randomUUID()}.tmp`;
|
|
1513
|
+
private countEvents(file: string): number {
|
|
1444
1514
|
try {
|
|
1445
|
-
fs.
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
tasks: Object.fromEntries(entries.map((entry) => [entry.id, entry])),
|
|
1451
|
-
}),
|
|
1452
|
-
{ mode: 0o600, flag: "wx" },
|
|
1453
|
-
);
|
|
1454
|
-
retryTransient(() => fs.renameSync(temporary, this.indexPath));
|
|
1515
|
+
const bytes = fs.readFileSync(file);
|
|
1516
|
+
let count = 0;
|
|
1517
|
+
for (let index = bytes.indexOf(0x0a); index >= 0; index = bytes.indexOf(0x0a, index + 1))
|
|
1518
|
+
count += 1;
|
|
1519
|
+
return count;
|
|
1455
1520
|
} catch {
|
|
1456
|
-
|
|
1457
|
-
fs.unlinkSync(temporary);
|
|
1458
|
-
} catch {}
|
|
1521
|
+
return 0;
|
|
1459
1522
|
}
|
|
1460
1523
|
}
|
|
1461
1524
|
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1525
|
+
/**
|
|
1526
|
+
* Open tasks bound to `key` anywhere in the store, oldest first (with
|
|
1527
|
+
* duplicates, e.g. from a 2.x-era race, the oldest is the one used). A
|
|
1528
|
+
* task follows its branch through `git branch -m`: it counts for the new
|
|
1529
|
+
* name, and a branch created later under the old name starts fresh.
|
|
1530
|
+
*/
|
|
1531
|
+
private openTasksForKey(key: string): Result<TaskIndexEntry[]> {
|
|
1532
|
+
const all = this.listStoreIndex();
|
|
1533
|
+
if (!all.ok) return all;
|
|
1534
|
+
const location = this.location();
|
|
1535
|
+
const renames = location.ok ? branchRenames(location.data) : [];
|
|
1536
|
+
return success(
|
|
1537
|
+
null,
|
|
1538
|
+
null,
|
|
1539
|
+
all.data
|
|
1540
|
+
.filter((entry) => entry.key !== null && entry.status !== "closed")
|
|
1541
|
+
.filter(
|
|
1542
|
+
(entry) =>
|
|
1543
|
+
(renames.length
|
|
1544
|
+
? followRenames(entry.key!, entry.boundAt ?? entry.createdAt, renames)
|
|
1545
|
+
: entry.key) === key,
|
|
1546
|
+
)
|
|
1547
|
+
.toSorted(oldestFirst),
|
|
1548
|
+
);
|
|
1472
1549
|
}
|
|
1473
1550
|
|
|
1474
|
-
private
|
|
1475
|
-
const
|
|
1476
|
-
|
|
1477
|
-
const destination = path.join(this.recoveryDir, `${target}.${id}.${digestBytes(bytes)}.json`);
|
|
1478
|
-
if (fs.existsSync(destination)) {
|
|
1479
|
-
if (digestBytes(retryTransient(() => fs.readFileSync(destination))) !== digestBytes(bytes))
|
|
1480
|
-
throw new Error("recovery copy already exists with different bytes");
|
|
1481
|
-
// Re-saved bytes are the newest copy again for pruning purposes.
|
|
1482
|
-
const stamp = new Date();
|
|
1483
|
-
retryTransient(() => fs.utimesSync(destination, stamp, stamp));
|
|
1484
|
-
this.pruneRecovery(`${target}.${id}.`, destination);
|
|
1485
|
-
return;
|
|
1486
|
-
}
|
|
1487
|
-
let temporary: string | undefined;
|
|
1488
|
-
try {
|
|
1489
|
-
temporary = `${destination}.${process.pid}.${randomUUID()}.tmp`;
|
|
1490
|
-
const fd = fs.openSync(temporary, "wx", 0o600);
|
|
1491
|
-
try {
|
|
1492
|
-
fs.writeSync(fd, bytes as any);
|
|
1493
|
-
fs.fsyncSync(fd);
|
|
1494
|
-
} finally {
|
|
1495
|
-
fs.closeSync(fd);
|
|
1496
|
-
}
|
|
1497
|
-
retryTransient(() => fs.renameSync(temporary!, destination));
|
|
1498
|
-
temporary = undefined;
|
|
1499
|
-
this.fsyncDirectory(this.recoveryDir);
|
|
1500
|
-
this.pruneRecovery(`${target}.${id}.`, destination);
|
|
1501
|
-
} finally {
|
|
1502
|
-
if (temporary)
|
|
1503
|
-
try {
|
|
1504
|
-
fs.unlinkSync(temporary);
|
|
1505
|
-
} catch {}
|
|
1506
|
-
}
|
|
1551
|
+
private openTaskForKey(key: string): Result<TaskIndexEntry | null> {
|
|
1552
|
+
const bound = this.openTasksForKey(key);
|
|
1553
|
+
return bound.ok ? success(null, null, bound.data[0] ?? null) : bound;
|
|
1507
1554
|
}
|
|
1508
1555
|
|
|
1509
|
-
/**
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
*/
|
|
1514
|
-
private pruneRecovery(prefix: string, keep: string) {
|
|
1515
|
-
try {
|
|
1516
|
-
const copies = this.recoveryCopies(prefix);
|
|
1517
|
-
const surplus = this.surplusCopies(copies, path.basename(keep));
|
|
1518
|
-
for (const copy of surplus) fs.rmSync(copy.path, { force: true });
|
|
1519
|
-
} catch {}
|
|
1556
|
+
/** Serialize implicit-task binding for one key across the whole store. */
|
|
1557
|
+
private withKeyLock<T>(key: string, operation: () => Result<T>): Result<T> {
|
|
1558
|
+
const digest = createHash("sha256").update(key).digest("hex").slice(0, 32);
|
|
1559
|
+
return this.withLock(path.join(this.paths().dir, "keys", `${digest}.lock`), operation);
|
|
1520
1560
|
}
|
|
1521
1561
|
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
if (!
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
const
|
|
1530
|
-
if (!
|
|
1531
|
-
const file = path.join(this.recoveryDir, name);
|
|
1532
|
-
try {
|
|
1533
|
-
const stat = fs.lstatSync(file, { bigint: true });
|
|
1534
|
-
if (!stat.isFile()) continue;
|
|
1535
|
-
copies.push({ name, path: file, group: `${match[1]}.${match[2]}`, mtimeNs: stat.mtimeNs });
|
|
1536
|
-
} catch {}
|
|
1562
|
+
/** Bind `taskId` to `key` and this checkout (under the checkout lock). */
|
|
1563
|
+
private bindLocked(taskId: Id, key: TaskKey, timestamp?: Utc): Result<TaskRecord> {
|
|
1564
|
+
const current = this.readWorkspace();
|
|
1565
|
+
if (!current.ok) return current;
|
|
1566
|
+
let workspace = current.data;
|
|
1567
|
+
if (!workspace) {
|
|
1568
|
+
workspace = this.nextWorkspace(null);
|
|
1569
|
+
const written = this.writeWorkspace(workspace);
|
|
1570
|
+
if (!written.ok) return written;
|
|
1537
1571
|
}
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1572
|
+
const target = workspace;
|
|
1573
|
+
return this.withLock(this.taskLockPath(taskId), () => {
|
|
1574
|
+
const loaded = this.load(taskId);
|
|
1575
|
+
if (!loaded.ok) return loaded;
|
|
1576
|
+
if (!loaded.data) return failure("not_found", "task not found", { taskId });
|
|
1577
|
+
if (loaded.data.record.status === "closed")
|
|
1578
|
+
return failure("invalid_transition", "a closed task cannot be adopted", { taskId });
|
|
1579
|
+
if (loaded.data.record.workspaceId === target.id && loaded.data.key?.key === key.key)
|
|
1580
|
+
return success(loaded.data.record.revision, null, loaded.data.record);
|
|
1581
|
+
const next = taskRecordSchema.safeParse({
|
|
1582
|
+
...loaded.data.record,
|
|
1583
|
+
workspaceId: target.id,
|
|
1584
|
+
revision: newRevision(),
|
|
1585
|
+
updatedAt: timestamp ?? now(),
|
|
1586
|
+
runtime: stampNew(loaded.data.record),
|
|
1587
|
+
});
|
|
1588
|
+
if (!next.success) return failure("invalid_input", "rebound task is invalid");
|
|
1589
|
+
const written = this.appendPatch(loaded.data, next.data, { key });
|
|
1590
|
+
return written.ok ? success(next.data.revision, null, next.data) : written;
|
|
1591
|
+
});
|
|
1555
1592
|
}
|
|
1556
1593
|
|
|
1557
|
-
/**
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
}
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1594
|
+
/** Release a task's implicit key (under the checkout lock). */
|
|
1595
|
+
private unbindLocked(taskId: Id): Result<null> {
|
|
1596
|
+
return this.withLock(this.taskLockPath(taskId), () => {
|
|
1597
|
+
const loaded = this.load(taskId);
|
|
1598
|
+
if (!loaded.ok) return loaded;
|
|
1599
|
+
if (!loaded.data || loaded.data.key === null) return success(null, null, null);
|
|
1600
|
+
return this.appendPatch(loaded.data, loaded.data.record, { key: null });
|
|
1601
|
+
});
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
/** Create a task (under the checkout lock), bound to `key`, or with
|
|
1605
|
+
* `bindCurrent` to the checkout's current key. */
|
|
1606
|
+
private createLocked(
|
|
1607
|
+
value: CreateInput,
|
|
1608
|
+
bindCurrent: boolean,
|
|
1609
|
+
key: TaskKey | null,
|
|
1610
|
+
checkRevision = true,
|
|
1611
|
+
): Result<TaskRecord> {
|
|
1612
|
+
const current = this.readWorkspace();
|
|
1613
|
+
if (!current.ok) return current;
|
|
1614
|
+
if (
|
|
1615
|
+
checkRevision &&
|
|
1616
|
+
(current.data
|
|
1617
|
+
? value.expectedWorkspaceRevision !== current.data.revision
|
|
1618
|
+
: value.expectedWorkspaceRevision !== null)
|
|
1619
|
+
)
|
|
1620
|
+
return failure(
|
|
1621
|
+
"revision_conflict",
|
|
1622
|
+
"workspace revision does not match; omit expectedWorkspaceRevision to use the current record",
|
|
1623
|
+
{
|
|
1624
|
+
expectedWorkspaceRevision: value.expectedWorkspaceRevision,
|
|
1625
|
+
actualWorkspaceRevision: current.data?.revision ?? null,
|
|
1626
|
+
},
|
|
1627
|
+
);
|
|
1628
|
+
if (!value.provenance) return failure("invalid_input", "provenance is required");
|
|
1629
|
+
if (
|
|
1630
|
+
!intentSchema.safeParse(value.intent).success ||
|
|
1631
|
+
!provenanceSchema.safeParse(value.provenance).success
|
|
1632
|
+
)
|
|
1633
|
+
return failure("invalid_input", "task intent or provenance is invalid");
|
|
1634
|
+
// An explicitly started task takes the branch over: the task that held
|
|
1635
|
+
// its key stays open, unbound (listed by `workit task status --all`).
|
|
1636
|
+
if (bindCurrent) {
|
|
1637
|
+
const currentKey = this.currentKey();
|
|
1638
|
+
if (currentKey.ok)
|
|
1639
|
+
return this.withKeyLock(currentKey.data.key, () => {
|
|
1640
|
+
const holder = this.openTaskForKey(currentKey.data.key);
|
|
1641
|
+
if (!holder.ok) return holder;
|
|
1642
|
+
if (holder.data) {
|
|
1643
|
+
const released = this.unbindLocked(holder.data.id);
|
|
1644
|
+
if (!released.ok) return released;
|
|
1600
1645
|
}
|
|
1601
|
-
|
|
1602
|
-
return success(null, null, null);
|
|
1603
|
-
} catch (error) {
|
|
1604
|
-
return failure("storage_error", `garbage collection failed: ${String(error)}`, {
|
|
1605
|
-
path: this.recoveryDir,
|
|
1646
|
+
return this.newTask(value, current.data, currentKey.data);
|
|
1606
1647
|
});
|
|
1607
|
-
}
|
|
1608
|
-
};
|
|
1609
|
-
// withLock initializes storage (mkdir, .gitignore); a dry run must not.
|
|
1610
|
-
const pruned = dryRun ? sweep() : this.withLock<null>(sweep);
|
|
1611
|
-
if (!pruned.ok) return pruned;
|
|
1612
|
-
const tasks = this.listTasks();
|
|
1613
|
-
if (!tasks.ok) return tasks;
|
|
1614
|
-
for (const task of tasks.data) {
|
|
1615
|
-
const deduped = dedupeCandidates(task.candidates);
|
|
1616
|
-
const removed = task.candidates.length - deduped.length;
|
|
1617
|
-
if (removed === 0) continue;
|
|
1618
|
-
// An active task belongs to a live session; rewriting it would bump the
|
|
1619
|
-
// revision under that session's feet.
|
|
1620
|
-
if (task.status === "active") {
|
|
1621
|
-
report.candidates.skippedActive.push(task.id);
|
|
1622
|
-
continue;
|
|
1623
|
-
}
|
|
1624
|
-
// Closed records are immutable history (docs/workit-v1/contracts.md).
|
|
1625
|
-
if (task.status === "closed") {
|
|
1626
|
-
report.candidates.skippedClosed.push(task.id);
|
|
1627
|
-
continue;
|
|
1628
|
-
}
|
|
1629
|
-
if (!dryRun) {
|
|
1630
|
-
const written = this.mutateTask(task.id, task.revision, (current) =>
|
|
1631
|
-
success(current.revision, null, {
|
|
1632
|
-
...current,
|
|
1633
|
-
candidates: dedupeCandidates(current.candidates),
|
|
1634
|
-
}),
|
|
1635
|
-
);
|
|
1636
|
-
if (!written.ok) {
|
|
1637
|
-
report.candidates.failed.push(task.id);
|
|
1638
|
-
continue;
|
|
1639
|
-
}
|
|
1640
|
-
}
|
|
1641
|
-
report.candidates.removed += removed;
|
|
1642
|
-
report.candidates.tasks.push(task.id);
|
|
1643
1648
|
}
|
|
1644
|
-
return
|
|
1649
|
+
return this.newTask(value, current.data, key);
|
|
1645
1650
|
}
|
|
1646
1651
|
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
):
|
|
1653
|
-
|
|
1654
|
-
const
|
|
1655
|
-
|
|
1656
|
-
const
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1652
|
+
/** Write a new task, bound to `binding` (under the checkout lock). */
|
|
1653
|
+
private newTask(
|
|
1654
|
+
value: CreateInput,
|
|
1655
|
+
previous: WorkspaceRecord | null,
|
|
1656
|
+
binding: TaskKey | null,
|
|
1657
|
+
): Result<TaskRecord> {
|
|
1658
|
+
const current = { data: previous };
|
|
1659
|
+
const workspace = this.nextWorkspace(current.data);
|
|
1660
|
+
const timestamp = value.now ?? now();
|
|
1661
|
+
const task: TaskRecord = {
|
|
1662
|
+
schemaVersion: SCHEMA_VERSION,
|
|
1663
|
+
id: newId(),
|
|
1664
|
+
workspaceId: workspace.id,
|
|
1665
|
+
revision: newRevision(),
|
|
1666
|
+
createdAt: timestamp,
|
|
1667
|
+
updatedAt: timestamp,
|
|
1668
|
+
origin: null,
|
|
1669
|
+
intent: {
|
|
1670
|
+
id: newId(),
|
|
1671
|
+
recordedAt: timestamp,
|
|
1672
|
+
provenance: value.provenance,
|
|
1673
|
+
data: value.intent,
|
|
1674
|
+
},
|
|
1675
|
+
constraints: [],
|
|
1676
|
+
status: "active",
|
|
1677
|
+
closure: null,
|
|
1678
|
+
progress: { summary: "", nextAction: null, blockers: [] },
|
|
1679
|
+
runtime: { createdWith: runtimeVersion(), updatedWith: runtimeVersion() },
|
|
1680
|
+
assessments: [],
|
|
1681
|
+
policy: null,
|
|
1682
|
+
policyChanges: [],
|
|
1683
|
+
candidates: [],
|
|
1684
|
+
evidence: [],
|
|
1685
|
+
decisions: [],
|
|
1686
|
+
findings: [],
|
|
1687
|
+
workers: [],
|
|
1688
|
+
};
|
|
1689
|
+
return this.writeNewTask(current.data, workspace, task, "task.opened", binding, {
|
|
1690
|
+
operation: "create",
|
|
1691
|
+
});
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
private nextWorkspace(current: WorkspaceRecord | null, id?: Id): WorkspaceRecord {
|
|
1695
|
+
return current
|
|
1696
|
+
? { ...current, revision: newRevision(), root: this.root, runtime: stampNew(current) }
|
|
1697
|
+
: {
|
|
1698
|
+
schemaVersion: SCHEMA_VERSION,
|
|
1699
|
+
id: id ?? newId(),
|
|
1700
|
+
revision: newRevision(),
|
|
1701
|
+
root: this.root,
|
|
1702
|
+
runtime: { createdWith: runtimeVersion(), updatedWith: runtimeVersion() },
|
|
1703
|
+
writer: null,
|
|
1704
|
+
};
|
|
1676
1705
|
}
|
|
1677
1706
|
|
|
1678
|
-
|
|
1707
|
+
/** Write the workspace, then the task's opening event; undo the workspace on failure. */
|
|
1708
|
+
private writeNewTask(
|
|
1709
|
+
previous: WorkspaceRecord | null,
|
|
1710
|
+
workspace: WorkspaceRecord,
|
|
1711
|
+
task: TaskRecord,
|
|
1712
|
+
type: string,
|
|
1713
|
+
key: TaskKey | null,
|
|
1714
|
+
details: { operation: string },
|
|
1715
|
+
): Result<TaskRecord> {
|
|
1716
|
+
const written = this.writeWorkspace(workspace);
|
|
1717
|
+
if (!written.ok) return written;
|
|
1679
1718
|
try {
|
|
1680
|
-
|
|
1681
|
-
try {
|
|
1682
|
-
fs.fsyncSync(fd);
|
|
1683
|
-
} finally {
|
|
1684
|
-
fs.closeSync(fd);
|
|
1685
|
-
}
|
|
1719
|
+
this.openTask(task, type, key);
|
|
1686
1720
|
} catch (error) {
|
|
1687
|
-
|
|
1688
|
-
|
|
1721
|
+
if (previous) {
|
|
1722
|
+
const restored = this.writeWorkspace(previous);
|
|
1723
|
+
if (!restored.ok)
|
|
1724
|
+
return failure(
|
|
1725
|
+
"external_outcome_unknown",
|
|
1726
|
+
`task ${details.operation} failed and workspace restoration is uncertain`,
|
|
1727
|
+
{ ...details, outcome: "unknown" },
|
|
1728
|
+
);
|
|
1729
|
+
}
|
|
1730
|
+
return failure(
|
|
1731
|
+
"external_outcome_unknown",
|
|
1732
|
+
`workspace written but the task log is uncertain: ${String(error)}`,
|
|
1733
|
+
{ ...details, outcome: "unknown" },
|
|
1734
|
+
);
|
|
1689
1735
|
}
|
|
1736
|
+
return success(task.revision, workspace.revision, task);
|
|
1690
1737
|
}
|
|
1691
1738
|
|
|
1692
|
-
private
|
|
1693
|
-
|
|
1694
|
-
const ids = new Set<Id>();
|
|
1739
|
+
private writeWorkspace(record: WorkspaceRecord): Result<null> {
|
|
1740
|
+
const file = this.paths().workspace;
|
|
1695
1741
|
try {
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
);
|
|
1702
|
-
if (!parsed.ok || parsed.data.id !== name.slice(0, -5)) return null;
|
|
1703
|
-
ids.add(parsed.data.workspaceId);
|
|
1704
|
-
}
|
|
1705
|
-
} catch {
|
|
1706
|
-
return null;
|
|
1742
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
1743
|
+
replaceFile(file, `${canonicalJson(record)}\n`, true);
|
|
1744
|
+
return success(null, null, null);
|
|
1745
|
+
} catch (error) {
|
|
1746
|
+
return failure("storage_error", `workspace write failed: ${String(error)}`, { path: file });
|
|
1707
1747
|
}
|
|
1708
|
-
return ids.size === 1 ? [...ids][0] : null;
|
|
1709
1748
|
}
|
|
1710
1749
|
|
|
1711
|
-
private
|
|
1750
|
+
private markUncertain(workspace: WorkspaceRecord) {
|
|
1751
|
+
if (!workspace.writer || workspace.writer.state === "uncertain") return;
|
|
1752
|
+
this.writeWorkspace({
|
|
1753
|
+
...workspace,
|
|
1754
|
+
writer: { ...workspace.writer, state: "uncertain" as const },
|
|
1755
|
+
revision: newRevision(),
|
|
1756
|
+
runtime: stampNew(workspace),
|
|
1757
|
+
});
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
// -------------------------------------------------------------------------
|
|
1761
|
+
// internals: index cache
|
|
1762
|
+
|
|
1763
|
+
private readIndex(): Record<string, TaskIndexEntry> {
|
|
1712
1764
|
try {
|
|
1713
|
-
|
|
1765
|
+
const value = JSON.parse(fs.readFileSync(this.paths().index, "utf8")) as unknown;
|
|
1766
|
+
if (!isObject(value) || value.version !== INDEX_VERSION || !isObject(value.tasks)) return {};
|
|
1767
|
+
const tasks: Record<string, TaskIndexEntry> = {};
|
|
1768
|
+
for (const [id, entry] of Object.entries(value.tasks))
|
|
1769
|
+
if (
|
|
1770
|
+
isObject(entry) &&
|
|
1771
|
+
entry.id === id &&
|
|
1772
|
+
typeof entry.file === "string" &&
|
|
1773
|
+
typeof entry.status === "string" &&
|
|
1774
|
+
typeof entry.updatedAt === "string" &&
|
|
1775
|
+
typeof entry.workspaceId === "string" &&
|
|
1776
|
+
Array.isArray(entry.sessions) &&
|
|
1777
|
+
isObject(entry.progress) &&
|
|
1778
|
+
isObject(entry.source)
|
|
1779
|
+
)
|
|
1780
|
+
tasks[id] = entry as TaskIndexEntry;
|
|
1781
|
+
return tasks;
|
|
1714
1782
|
} catch {
|
|
1715
|
-
return
|
|
1783
|
+
return {};
|
|
1716
1784
|
}
|
|
1717
1785
|
}
|
|
1718
1786
|
|
|
1719
|
-
|
|
1787
|
+
/** The index is a disposable cache: write it atomically, never fail the caller. */
|
|
1788
|
+
private writeIndex(entries: TaskIndexEntry[]) {
|
|
1720
1789
|
try {
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
exists: true,
|
|
1728
|
-
result: failure("recovery_required", `snapshot cannot be read: ${String(error)}`, {
|
|
1729
|
-
path: file,
|
|
1790
|
+
fs.mkdirSync(this.paths().tasks, { recursive: true });
|
|
1791
|
+
replaceFile(
|
|
1792
|
+
this.paths().index,
|
|
1793
|
+
JSON.stringify({
|
|
1794
|
+
version: INDEX_VERSION,
|
|
1795
|
+
tasks: Object.fromEntries(entries.map((entry) => [entry.id, entry])),
|
|
1730
1796
|
}),
|
|
1731
|
-
|
|
1797
|
+
false,
|
|
1798
|
+
);
|
|
1799
|
+
} catch {}
|
|
1800
|
+
}
|
|
1801
|
+
|
|
1802
|
+
private indexTask(task: TaskRecord, key: TaskKey | null, legacy: boolean) {
|
|
1803
|
+
try {
|
|
1804
|
+
const signature = fileSignature(this.eventsPath(task.id));
|
|
1805
|
+
if (signature === null) return;
|
|
1806
|
+
const tasks = this.readIndex();
|
|
1807
|
+
tasks[task.id] = indexEntry(task, key, legacy, signature);
|
|
1808
|
+
this.writeIndex(Object.values(tasks));
|
|
1809
|
+
} catch {
|
|
1810
|
+
// A stale index entry is repaired by the next listing.
|
|
1732
1811
|
}
|
|
1733
1812
|
}
|
|
1734
1813
|
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1814
|
+
// -------------------------------------------------------------------------
|
|
1815
|
+
// internals: record parsing
|
|
1816
|
+
|
|
1817
|
+
private parseTask(record: Record<string, unknown>, taskId: Id): Result<TaskRecord> {
|
|
1818
|
+
const parsed = this.parseValue<TaskRecord>(record, taskRecordSchema, "task");
|
|
1819
|
+
if (!parsed.ok) return { ...parsed, details: { ...parsed.details, taskId } };
|
|
1820
|
+
if (parsed.data.id !== taskId)
|
|
1821
|
+
return failure("recovery_required", "task log and record ID differ", { taskId });
|
|
1822
|
+
return parsed;
|
|
1823
|
+
}
|
|
1824
|
+
|
|
1825
|
+
private parseRecord<T>(
|
|
1826
|
+
bytes: string,
|
|
1827
|
+
schema: typeof taskRecordSchema | typeof workspaceRecordSchema,
|
|
1828
|
+
label: string,
|
|
1741
1829
|
): Result<T> {
|
|
1742
1830
|
let value: unknown;
|
|
1743
1831
|
try {
|
|
1744
|
-
value = JSON.parse(
|
|
1832
|
+
value = JSON.parse(bytes);
|
|
1745
1833
|
} catch {
|
|
1746
|
-
return failure("recovery_required",
|
|
1834
|
+
return failure("recovery_required", `${label} record JSON is corrupt`);
|
|
1747
1835
|
}
|
|
1836
|
+
return this.parseValue<T>(value, schema, label);
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
private parseValue<T>(
|
|
1840
|
+
value: unknown,
|
|
1841
|
+
schema: typeof taskRecordSchema | typeof workspaceRecordSchema,
|
|
1842
|
+
label: string,
|
|
1843
|
+
): Result<T> {
|
|
1748
1844
|
if (isObject(value) && "schemaVersion" in value && value.schemaVersion !== SCHEMA_VERSION)
|
|
1749
|
-
return failure("unsupported_version",
|
|
1845
|
+
return failure("unsupported_version", `unsupported ${label} schema version`);
|
|
1750
1846
|
const parsed = parseStoredRecord(schema, value);
|
|
1751
|
-
if (parsed.success
|
|
1752
|
-
return success(null, null, parsed.data as T);
|
|
1847
|
+
if (parsed.success) return success(null, null, parsed.data as T);
|
|
1753
1848
|
const writerVersion =
|
|
1754
|
-
isObject(value) && isObject(
|
|
1755
|
-
? (value as { runtime: { updatedWith?: unknown } }).runtime.updatedWith
|
|
1756
|
-
: null;
|
|
1757
|
-
const upgrade = `upgrade Workit before ${parsed.success ? "recovering" : "mutating"} this checkout`;
|
|
1758
|
-
if (parsed.success)
|
|
1759
|
-
return failure(
|
|
1760
|
-
"recovery_required",
|
|
1761
|
-
`snapshot carries fields this Workit cannot preserve (${parsed.stripped.join(", ")}); ${upgrade}`,
|
|
1762
|
-
);
|
|
1849
|
+
isObject(value) && isObject(value.runtime) ? value.runtime.updatedWith : null;
|
|
1763
1850
|
if (parsed.critical.length > 0)
|
|
1764
1851
|
return failure(
|
|
1765
1852
|
"recovery_required",
|
|
1766
|
-
|
|
1853
|
+
`${label} record requires fields this Workit cannot read (${parsed.critical.join(", ")}); upgrade Workit before mutating this checkout`,
|
|
1767
1854
|
);
|
|
1768
1855
|
if (typeof writerVersion === "string" && isNewerVersion(writerVersion, runtimeVersion()))
|
|
1769
1856
|
return failure(
|
|
1770
1857
|
"recovery_required",
|
|
1771
|
-
|
|
1858
|
+
`${label} record was written by workit ${writerVersion}; upgrade Workit before mutating this checkout`,
|
|
1772
1859
|
);
|
|
1773
|
-
return failure("recovery_required",
|
|
1860
|
+
return failure("recovery_required", `${label} record does not satisfy its schema`);
|
|
1774
1861
|
}
|
|
1775
1862
|
|
|
1776
1863
|
private conflict(expected: Revision, actual: Revision): Result<never> {
|
|
1777
1864
|
return failure(
|
|
1778
1865
|
"revision_conflict",
|
|
1779
1866
|
"snapshot revision does not match; omit expectedRevision to use the current record",
|
|
1780
|
-
{
|
|
1781
|
-
expectedRevision: expected,
|
|
1782
|
-
actualRevision: actual,
|
|
1783
|
-
},
|
|
1867
|
+
{ expectedRevision: expected, actualRevision: actual },
|
|
1784
1868
|
);
|
|
1785
1869
|
}
|
|
1786
1870
|
|
|
@@ -1792,28 +1876,542 @@ export class TaskStore {
|
|
|
1792
1876
|
);
|
|
1793
1877
|
}
|
|
1794
1878
|
|
|
1795
|
-
|
|
1796
|
-
|
|
1879
|
+
// -------------------------------------------------------------------------
|
|
1880
|
+
// internals: 2.x migration
|
|
1881
|
+
|
|
1882
|
+
/** 2.x stores this checkout may have: `<top>/.workit`, and `<dir>/.workit`
|
|
1883
|
+
* when the store was opened from a subdirectory. */
|
|
1884
|
+
private v2Dirs(): string[] {
|
|
1885
|
+
return [...new Set([this.root, this.origin])].map((dir) => path.join(dir, ".workit"));
|
|
1797
1886
|
}
|
|
1798
|
-
|
|
1799
|
-
|
|
1887
|
+
|
|
1888
|
+
/** 2.x stores waiting to migrate into this checkout. (A pre-git 3.x
|
|
1889
|
+
* store is this runtime's own data and moves on any access.) */
|
|
1890
|
+
private pendingMigrations(): string[] {
|
|
1891
|
+
return this.v2Dirs().filter((dir) => {
|
|
1892
|
+
const raw = this.readV2Workspace(path.join(dir, "workspace.json"));
|
|
1893
|
+
return raw !== null && raw !== TOMBSTONE;
|
|
1894
|
+
});
|
|
1800
1895
|
}
|
|
1801
|
-
|
|
1802
|
-
|
|
1896
|
+
|
|
1897
|
+
/**
|
|
1898
|
+
* Migrate the 2.x store in `v2` (tasks/*.json + workspace.json) into this
|
|
1899
|
+
* store, under the checkout lock and the 2.x store's own metadata lock (S1
|
|
1900
|
+
* identity and reclaim rules), so a live 2.x writer is never migrated
|
|
1901
|
+
* under: its lock makes this `busy` and nothing changes.
|
|
1902
|
+
* 1. back up the 2.x files to legacy/<checkout>/v2/ (refreshed each run);
|
|
1903
|
+
* 2. each task file is migrated by content: a new task becomes
|
|
1904
|
+
* tasks/<id>/events.jsonl with one `migrated.from_v2` event holding the
|
|
1905
|
+
* whole record and the file's digest; a task already migrated from
|
|
1906
|
+
* other bytes (a 2.x write after an interrupted run) gets the
|
|
1907
|
+
* difference appended as another `migrated.from_v2` event;
|
|
1908
|
+
* 3. the tasks join this checkout's workspace (the existing 3.x record,
|
|
1909
|
+
* else the 2.x one, so nothing migrated is hidden);
|
|
1910
|
+
* 4. each task file is replaced by a marker only while its digest still
|
|
1911
|
+
* matches what was migrated (compare-and-swap under the 2.x lock);
|
|
1912
|
+
* 5. `.workit/workspace.json` becomes the marker last, so an interrupted
|
|
1913
|
+
* run is finished by the next one.
|
|
1914
|
+
* `.workit/recovery` is left in place (`workit gc` reports it).
|
|
1915
|
+
*/
|
|
1916
|
+
private migrateV2(v2: string): Result<null> {
|
|
1917
|
+
const dirs = this.paths();
|
|
1918
|
+
const v2Workspace = path.join(v2, "workspace.json");
|
|
1919
|
+
const v2Tasks = path.join(v2, "tasks");
|
|
1920
|
+
const legacy = path.join(dirs.dir, "legacy", checkoutSlug(path.dirname(v2)), "v2");
|
|
1921
|
+
const raw = this.readV2Workspace(v2Workspace);
|
|
1922
|
+
if (raw === null || raw === TOMBSTONE) return success(null, null, null);
|
|
1923
|
+
return this.withLock(dirs.checkoutLock, () =>
|
|
1924
|
+
this.withLock(path.join(v2, "metadata.lock"), () => {
|
|
1925
|
+
const workspace = this.readV2Workspace(v2Workspace);
|
|
1926
|
+
if (workspace === null || workspace === TOMBSTONE) return success(null, null, null);
|
|
1927
|
+
const parsedWorkspace = this.parseRecord<WorkspaceRecord>(
|
|
1928
|
+
workspace.bytes,
|
|
1929
|
+
workspaceRecordSchema,
|
|
1930
|
+
"2.x workspace",
|
|
1931
|
+
);
|
|
1932
|
+
if (!parsedWorkspace.ok)
|
|
1933
|
+
return failure(
|
|
1934
|
+
"recovery_required",
|
|
1935
|
+
`cannot migrate the 2.x store at ${v2}: ${parsedWorkspace.error}; move it aside to start fresh`,
|
|
1936
|
+
{ path: v2Workspace },
|
|
1937
|
+
);
|
|
1938
|
+
let files: string[] = [];
|
|
1939
|
+
try {
|
|
1940
|
+
files = fs
|
|
1941
|
+
.readdirSync(v2Tasks)
|
|
1942
|
+
.filter((name) => name.endsWith(".json") && validId(name.slice(0, -5)))
|
|
1943
|
+
.filter((name) => !this.isStub(path.join(v2Tasks, name)));
|
|
1944
|
+
} catch {}
|
|
1945
|
+
const current = this.readWorkspace();
|
|
1946
|
+
if (!current.ok) return current;
|
|
1947
|
+
let workspaceMoved = false;
|
|
1948
|
+
let target = current.data;
|
|
1949
|
+
if (!target) {
|
|
1950
|
+
target = { ...parsedWorkspace.data, root: this.root };
|
|
1951
|
+
const written = this.writeWorkspace(target);
|
|
1952
|
+
if (!written.ok) return written;
|
|
1953
|
+
workspaceMoved = true;
|
|
1954
|
+
}
|
|
1955
|
+
let migrated = 0;
|
|
1956
|
+
let skipped = 0;
|
|
1957
|
+
const done = new Map<string, string>();
|
|
1958
|
+
try {
|
|
1959
|
+
fs.mkdirSync(path.join(legacy, "tasks"), { recursive: true });
|
|
1960
|
+
for (const name of ["workspace.json", "index.json"])
|
|
1961
|
+
if (fs.existsSync(path.join(v2, name)))
|
|
1962
|
+
fs.copyFileSync(path.join(v2, name), path.join(legacy, name));
|
|
1963
|
+
for (const name of files) {
|
|
1964
|
+
const id = name.slice(0, -5);
|
|
1965
|
+
const file = path.join(v2Tasks, name);
|
|
1966
|
+
const bytes = fs.readFileSync(file, "utf8");
|
|
1967
|
+
const digest = sha256(bytes);
|
|
1968
|
+
fs.writeFileSync(path.join(legacy, "tasks", name), bytes);
|
|
1969
|
+
const parsed = this.parseRecord<TaskRecord>(bytes, taskRecordSchema, "2.x task");
|
|
1970
|
+
if (!parsed.ok || parsed.data.id !== id)
|
|
1971
|
+
return failure(
|
|
1972
|
+
"recovery_required",
|
|
1973
|
+
`cannot migrate 2.x task ${id}: ${parsed.ok ? "file name and record ID differ" : parsed.error}; move ${file} aside to continue`,
|
|
1974
|
+
{ path: file },
|
|
1975
|
+
);
|
|
1976
|
+
const record = { ...parsed.data, workspaceId: target.id };
|
|
1977
|
+
const origin = { path: path.join(legacy, "tasks", name), digest };
|
|
1978
|
+
if (!fs.existsSync(this.eventsPath(id))) {
|
|
1979
|
+
this.openTask(record, "migrated.from_v2", null, origin);
|
|
1980
|
+
migrated += 1;
|
|
1981
|
+
} else {
|
|
1982
|
+
const resynced = this.withLock(this.taskLockPath(id), () => {
|
|
1983
|
+
const loaded = this.load(id);
|
|
1984
|
+
if (!loaded.ok) return loaded;
|
|
1985
|
+
if (!loaded.data) return failure("not_found", "task not found", { taskId: id });
|
|
1986
|
+
if (loaded.data.legacy?.digest === digest) return success(null, null, false);
|
|
1987
|
+
const appended = this.appendPatch(loaded.data, record, {
|
|
1988
|
+
type: "migrated.from_v2",
|
|
1989
|
+
legacy: origin,
|
|
1990
|
+
});
|
|
1991
|
+
return appended.ok ? success(null, null, true) : appended;
|
|
1992
|
+
});
|
|
1993
|
+
if (!resynced.ok) return resynced;
|
|
1994
|
+
if (resynced.data) migrated += 1;
|
|
1995
|
+
else skipped += 1;
|
|
1996
|
+
}
|
|
1997
|
+
done.set(name, digest);
|
|
1998
|
+
}
|
|
1999
|
+
} catch (error) {
|
|
2000
|
+
return failure(
|
|
2001
|
+
"storage_error",
|
|
2002
|
+
`cannot migrate the 2.x store at ${v2}: ${String(error)}`,
|
|
2003
|
+
{
|
|
2004
|
+
path: v2,
|
|
2005
|
+
},
|
|
2006
|
+
);
|
|
2007
|
+
}
|
|
2008
|
+
try {
|
|
2009
|
+
// Compare-and-swap: replace a 2.x task file by a marker (so a 2.x
|
|
2010
|
+
// reader asked for it by id fails closed) only if it still holds
|
|
2011
|
+
// the bytes just migrated; otherwise leave the store unfinished so
|
|
2012
|
+
// the next run migrates the change.
|
|
2013
|
+
let complete = true;
|
|
2014
|
+
for (const [name, digest] of done) {
|
|
2015
|
+
const file = path.join(v2Tasks, name);
|
|
2016
|
+
if (sha256(fs.readFileSync(file, "utf8")) !== digest) {
|
|
2017
|
+
complete = false;
|
|
2018
|
+
continue;
|
|
2019
|
+
}
|
|
2020
|
+
replaceFile(file, this.stubFor(name.slice(0, -5)), false);
|
|
2021
|
+
}
|
|
2022
|
+
if (!complete)
|
|
2023
|
+
return failure("busy", `the 2.x store at ${v2} changed during migration; retry`, {
|
|
2024
|
+
path: v2,
|
|
2025
|
+
});
|
|
2026
|
+
fs.rmSync(path.join(v2, "index.json"), { force: true });
|
|
2027
|
+
this.writeTombstone(v2, target.id);
|
|
2028
|
+
} catch (error) {
|
|
2029
|
+
return failure("storage_error", `cannot finish the 2.x migration: ${String(error)}`, {
|
|
2030
|
+
path: v2,
|
|
2031
|
+
});
|
|
2032
|
+
}
|
|
2033
|
+
reportMigration({
|
|
2034
|
+
from: v2,
|
|
2035
|
+
to: dirs.dir,
|
|
2036
|
+
backup: legacy,
|
|
2037
|
+
tasks: migrated,
|
|
2038
|
+
skipped,
|
|
2039
|
+
workspace: workspaceMoved,
|
|
2040
|
+
});
|
|
2041
|
+
return success(null, null, null);
|
|
2042
|
+
}),
|
|
2043
|
+
);
|
|
2044
|
+
}
|
|
2045
|
+
|
|
2046
|
+
/**
|
|
2047
|
+
* A 3.x store kept in `<root>/.workit` while the directory was not a git
|
|
2048
|
+
* repository moves into the git store once it is one: task logs, checkout
|
|
2049
|
+
* records, blobs and (when the git store has none) the ledger. Entries the
|
|
2050
|
+
* git store already has are left in place. The local marker is renamed so
|
|
2051
|
+
* this runs once.
|
|
2052
|
+
*/
|
|
2053
|
+
private adoptLocalStore(): Result<null> {
|
|
2054
|
+
const dirs = this.paths();
|
|
2055
|
+
const marker = path.join(dirs.v2, "store.json");
|
|
2056
|
+
if (!dirs.shared || !fs.existsSync(marker)) return success(null, null, null);
|
|
2057
|
+
return this.withLock(dirs.checkoutLock, () => {
|
|
2058
|
+
if (!fs.existsSync(marker)) return success(null, null, null);
|
|
2059
|
+
let tasks = 0;
|
|
2060
|
+
try {
|
|
2061
|
+
// Move what the git store lacks; merge directories both have.
|
|
2062
|
+
const merge = (from: string, to: string) => {
|
|
2063
|
+
if (!fs.existsSync(to)) {
|
|
2064
|
+
fs.mkdirSync(path.dirname(to), { recursive: true });
|
|
2065
|
+
try {
|
|
2066
|
+
fs.renameSync(from, to);
|
|
2067
|
+
} catch {
|
|
2068
|
+
fs.cpSync(from, to, { recursive: true, errorOnExist: true, force: false });
|
|
2069
|
+
fs.rmSync(from, { recursive: true, force: true });
|
|
2070
|
+
}
|
|
2071
|
+
return;
|
|
2072
|
+
}
|
|
2073
|
+
if (fs.lstatSync(from).isDirectory() && fs.lstatSync(to).isDirectory())
|
|
2074
|
+
for (const name of fs.readdirSync(from))
|
|
2075
|
+
merge(path.join(from, name), path.join(to, name));
|
|
2076
|
+
};
|
|
2077
|
+
for (const sub of ["tasks", "checkouts", "blobs", "legacy"]) {
|
|
2078
|
+
const from = path.join(dirs.v2, sub);
|
|
2079
|
+
if (!fs.existsSync(from)) continue;
|
|
2080
|
+
if (sub === "tasks")
|
|
2081
|
+
for (const name of fs.readdirSync(from))
|
|
2082
|
+
if (validId(name) && !fs.existsSync(path.join(dirs.dir, sub, name))) tasks += 1;
|
|
2083
|
+
merge(from, path.join(dirs.dir, sub));
|
|
2084
|
+
}
|
|
2085
|
+
const ledger = path.join(dirs.v2, "ledger");
|
|
2086
|
+
if (fs.existsSync(ledger) && !fs.existsSync(path.join(dirs.dir, "ledger")))
|
|
2087
|
+
merge(ledger, path.join(dirs.dir, "ledger"));
|
|
2088
|
+
fs.rmSync(path.join(dirs.v2, "task-index.json"), { force: true });
|
|
2089
|
+
fs.renameSync(marker, path.join(dirs.v2, "store.moved.json"));
|
|
2090
|
+
} catch (error) {
|
|
2091
|
+
return failure(
|
|
2092
|
+
"storage_error",
|
|
2093
|
+
`cannot move ${dirs.v2} into ${dirs.dir}: ${String(error)}`,
|
|
2094
|
+
{
|
|
2095
|
+
path: dirs.v2,
|
|
2096
|
+
},
|
|
2097
|
+
);
|
|
2098
|
+
}
|
|
2099
|
+
reportMigration({
|
|
2100
|
+
from: dirs.v2,
|
|
2101
|
+
to: dirs.dir,
|
|
2102
|
+
backup: dirs.v2,
|
|
2103
|
+
tasks,
|
|
2104
|
+
skipped: 0,
|
|
2105
|
+
workspace: false,
|
|
2106
|
+
});
|
|
2107
|
+
return success(null, null, null);
|
|
2108
|
+
});
|
|
2109
|
+
}
|
|
2110
|
+
|
|
2111
|
+
/** The 2.x workspace bytes, TOMBSTONE for a 3.x marker, or null when absent. */
|
|
2112
|
+
private readV2Workspace(file: string): { bytes: string } | typeof TOMBSTONE | null {
|
|
2113
|
+
let bytes: string;
|
|
2114
|
+
try {
|
|
2115
|
+
bytes = fs.readFileSync(file, "utf8");
|
|
2116
|
+
} catch {
|
|
2117
|
+
return null;
|
|
2118
|
+
}
|
|
2119
|
+
try {
|
|
2120
|
+
const value = JSON.parse(bytes) as unknown;
|
|
2121
|
+
if (isObject(value) && isObject(value.store) && value.store.format === "workit-store")
|
|
2122
|
+
return TOMBSTONE;
|
|
2123
|
+
} catch {}
|
|
2124
|
+
return { bytes };
|
|
2125
|
+
}
|
|
2126
|
+
|
|
2127
|
+
/**
|
|
2128
|
+
* `<root>/.workit/workspace.json` as a marker 2.x readers fail closed on:
|
|
2129
|
+
* its `store` field is declared critical, and runtimes that predate the
|
|
2130
|
+
* critical rule reject it as written by a newer Workit.
|
|
2131
|
+
*/
|
|
2132
|
+
private writeTombstone(v2: string, workspaceId: Id | null) {
|
|
2133
|
+
const dirs = this.paths();
|
|
2134
|
+
const version = isNewerVersion(runtimeVersion(), STORE_MIN_RUNTIME)
|
|
2135
|
+
? runtimeVersion()
|
|
2136
|
+
: STORE_MIN_RUNTIME;
|
|
2137
|
+
const marker = {
|
|
2138
|
+
schemaVersion: SCHEMA_VERSION,
|
|
2139
|
+
id: workspaceId ?? newId(),
|
|
2140
|
+
revision: newRevision(),
|
|
2141
|
+
root: path.dirname(v2),
|
|
2142
|
+
runtime: { createdWith: version, updatedWith: version },
|
|
2143
|
+
writer: null,
|
|
2144
|
+
store: {
|
|
2145
|
+
format: "workit-store",
|
|
2146
|
+
version: STORE_FORMAT,
|
|
2147
|
+
path: dirs.dir,
|
|
2148
|
+
note: `workit ${STORE_MIN_RUNTIME}+ keeps task state in ${dirs.dir}; upgrade Workit`,
|
|
2149
|
+
},
|
|
2150
|
+
critical: ["store"],
|
|
2151
|
+
};
|
|
2152
|
+
fs.mkdirSync(v2, { recursive: true });
|
|
2153
|
+
// Keep the marker directory out of version control.
|
|
2154
|
+
const ignore = path.join(v2, ".gitignore");
|
|
2155
|
+
if (!fs.existsSync(ignore)) fs.writeFileSync(ignore, "*\n");
|
|
2156
|
+
replaceFile(path.join(v2, "workspace.json"), `${JSON.stringify(marker, null, 2)}\n`, true);
|
|
2157
|
+
}
|
|
2158
|
+
|
|
2159
|
+
/** The marker that replaces a migrated 2.x task file. */
|
|
2160
|
+
private stubFor(taskId: Id): string {
|
|
2161
|
+
const version = isNewerVersion(runtimeVersion(), STORE_MIN_RUNTIME)
|
|
2162
|
+
? runtimeVersion()
|
|
2163
|
+
: STORE_MIN_RUNTIME;
|
|
2164
|
+
return `${JSON.stringify({
|
|
2165
|
+
schemaVersion: SCHEMA_VERSION,
|
|
2166
|
+
id: taskId,
|
|
2167
|
+
runtime: { createdWith: version, updatedWith: version },
|
|
2168
|
+
store: { format: "workit-store", version: STORE_FORMAT, path: this.paths().dir },
|
|
2169
|
+
critical: ["store"],
|
|
2170
|
+
})}\n`;
|
|
2171
|
+
}
|
|
2172
|
+
|
|
2173
|
+
private isStub(file: string): boolean {
|
|
2174
|
+
try {
|
|
2175
|
+
const value = JSON.parse(fs.readFileSync(file, "utf8")) as unknown;
|
|
2176
|
+
return isObject(value) && isObject(value.store) && value.store.format === "workit-store";
|
|
2177
|
+
} catch {
|
|
2178
|
+
return false;
|
|
2179
|
+
}
|
|
2180
|
+
}
|
|
2181
|
+
|
|
2182
|
+
/**
|
|
2183
|
+
* Every checkout this runtime writes for carries the marker at
|
|
2184
|
+
* `<top>/.workit/workspace.json`, so a 2.x runtime never starts a second,
|
|
2185
|
+
* divergent store there (it fails closed with the upgrade message).
|
|
2186
|
+
*/
|
|
2187
|
+
private writeTombstoneIfMissing() {
|
|
2188
|
+
try {
|
|
2189
|
+
const v2 = path.join(this.root, ".workit");
|
|
2190
|
+
if (fs.existsSync(path.join(v2, "workspace.json"))) return;
|
|
2191
|
+
let id: Id | null = null;
|
|
2192
|
+
try {
|
|
2193
|
+
id =
|
|
2194
|
+
(JSON.parse(fs.readFileSync(this.paths().workspace, "utf8")) as { id?: Id }).id ?? null;
|
|
2195
|
+
} catch {}
|
|
2196
|
+
this.writeTombstone(v2, id);
|
|
2197
|
+
} catch {}
|
|
2198
|
+
}
|
|
2199
|
+
|
|
2200
|
+
// -------------------------------------------------------------------------
|
|
2201
|
+
// internals: locks
|
|
2202
|
+
|
|
2203
|
+
private initializeStorage() {
|
|
2204
|
+
const dirs = this.paths();
|
|
2205
|
+
retryTransient(() => fs.mkdirSync(dirs.checkout, { recursive: true }));
|
|
2206
|
+
retryTransient(() => fs.mkdirSync(dirs.tasks, { recursive: true }));
|
|
2207
|
+
if (!fs.existsSync(dirs.marker))
|
|
2208
|
+
replaceFile(
|
|
2209
|
+
dirs.marker,
|
|
2210
|
+
`${JSON.stringify({ format: "workit-store", version: STORE_FORMAT, createdWith: runtimeVersion() })}\n`,
|
|
2211
|
+
false,
|
|
2212
|
+
);
|
|
2213
|
+
if (!dirs.shared) {
|
|
2214
|
+
// Outside git the store sits in the checkout: keep it out of any VCS.
|
|
2215
|
+
const ignore = path.join(dirs.dir, ".gitignore");
|
|
2216
|
+
let current: string | null = null;
|
|
2217
|
+
try {
|
|
2218
|
+
current = fs.readFileSync(ignore, "utf8");
|
|
2219
|
+
} catch {}
|
|
2220
|
+
if (current !== "*\n") retryTransient(() => fs.writeFileSync(ignore, "*\n"));
|
|
2221
|
+
}
|
|
2222
|
+
this.writeTombstoneIfMissing();
|
|
2223
|
+
}
|
|
2224
|
+
|
|
2225
|
+
/**
|
|
2226
|
+
* Lock options: a holder that is gone (dead pid, reused pid, or a foreign
|
|
2227
|
+
* or unreadable lock past its TTL) is reclaimed; a live holder is waited
|
|
2228
|
+
* on briefly and then reported as retryable `busy`.
|
|
2229
|
+
*/
|
|
2230
|
+
private lockOptions(lockPath: string): FileLockSyncAcquireOptions<MetadataLock> & {
|
|
2231
|
+
payload: () => MetadataLock;
|
|
2232
|
+
} {
|
|
2233
|
+
const inProcess = heldInProcess.has(lockPath);
|
|
2234
|
+
return {
|
|
2235
|
+
lockPath,
|
|
2236
|
+
staleMs: Number.MAX_SAFE_INTEGER,
|
|
2237
|
+
timeoutMs: inProcess ? 0 : this.lockTimeoutMs,
|
|
2238
|
+
retry: inProcess
|
|
2239
|
+
? { retries: 0 }
|
|
2240
|
+
: { minTimeout: 5, maxTimeout: 100, factor: 1.5, randomize: true },
|
|
2241
|
+
staleRecovery: "remove-if-unchanged",
|
|
2242
|
+
shouldReclaim: ({ payload, nowMs }) => {
|
|
2243
|
+
let ageMs: number | null = null;
|
|
2244
|
+
try {
|
|
2245
|
+
ageMs = nowMs - fs.lstatSync(lockPath).mtimeMs;
|
|
2246
|
+
} catch {}
|
|
2247
|
+
return classifyLockOwner(payload, ageMs).state === "stale";
|
|
2248
|
+
},
|
|
2249
|
+
// The library re-checks the bytes before removal, so a lock replaced
|
|
2250
|
+
// after classification is never deleted.
|
|
2251
|
+
shouldRemoveStaleLock: () => true,
|
|
2252
|
+
parsePayload: parseMetadataLockOrNull,
|
|
2253
|
+
payload: () => ({
|
|
2254
|
+
pid: process.pid,
|
|
2255
|
+
processStart: processStartOf(process.pid),
|
|
2256
|
+
host: localLockHost(),
|
|
2257
|
+
nonce: randomUUID(),
|
|
2258
|
+
}),
|
|
2259
|
+
};
|
|
1803
2260
|
}
|
|
1804
|
-
|
|
1805
|
-
|
|
2261
|
+
|
|
2262
|
+
private acquire(
|
|
2263
|
+
lockPath: string,
|
|
2264
|
+
options: FileLockSyncAcquireOptions<MetadataLock>,
|
|
2265
|
+
): FileLockSyncHandle {
|
|
2266
|
+
clearAbandonedReclaimGuard(lockPath);
|
|
2267
|
+
// One budget for the whole acquisition: a lost reclaim race retries with
|
|
2268
|
+
// the remaining time, never a fresh timeout.
|
|
2269
|
+
const deadline = Date.now() + (options.timeoutMs ?? 0);
|
|
2270
|
+
while (true) {
|
|
2271
|
+
try {
|
|
2272
|
+
return acquireFileLockSync(lockPath, {
|
|
2273
|
+
...options,
|
|
2274
|
+
timeoutMs: Math.max(0, deadline - Date.now()),
|
|
2275
|
+
});
|
|
2276
|
+
} catch (error) {
|
|
2277
|
+
const code = (error as { code?: unknown })?.code;
|
|
2278
|
+
if (
|
|
2279
|
+
(code !== "file_lock_stale" && !isTransientWindowsError(error)) ||
|
|
2280
|
+
Date.now() >= deadline
|
|
2281
|
+
)
|
|
2282
|
+
throw error;
|
|
2283
|
+
// A Windows denial with no visible holder and a directory that
|
|
2284
|
+
// refuses new files is a permission problem: fail fast.
|
|
2285
|
+
if (
|
|
2286
|
+
code !== "file_lock_stale" &&
|
|
2287
|
+
!this.lockHolderPresent(lockPath) &&
|
|
2288
|
+
!this.lockDirWritable(lockPath)
|
|
2289
|
+
)
|
|
2290
|
+
throw error;
|
|
2291
|
+
if (code !== "file_lock_stale")
|
|
2292
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5);
|
|
2293
|
+
}
|
|
2294
|
+
}
|
|
1806
2295
|
}
|
|
1807
|
-
|
|
1808
|
-
|
|
2296
|
+
|
|
2297
|
+
private withLock<T>(lockPath: string, operation: () => Result<T>): Result<T> {
|
|
2298
|
+
try {
|
|
2299
|
+
this.initializeStorage();
|
|
2300
|
+
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
|
|
2301
|
+
} catch (error) {
|
|
2302
|
+
return failure("storage_error", `unable to initialize store: ${String(error)}`, {
|
|
2303
|
+
path: this.paths().dir,
|
|
2304
|
+
});
|
|
2305
|
+
}
|
|
2306
|
+
let handle: FileLockSyncHandle;
|
|
2307
|
+
try {
|
|
2308
|
+
handle = this.acquire(lockPath, this.lockOptions(lockPath));
|
|
2309
|
+
} catch (error) {
|
|
2310
|
+
return this.lockFailure(lockPath, error);
|
|
2311
|
+
}
|
|
2312
|
+
hold(lockPath);
|
|
2313
|
+
let result: Result<T>;
|
|
2314
|
+
try {
|
|
2315
|
+
if (!handle.verifyStillHeld())
|
|
2316
|
+
result = failure("recovery_required", "metadata lock was compromised", { path: lockPath });
|
|
2317
|
+
else {
|
|
2318
|
+
try {
|
|
2319
|
+
result = operation();
|
|
2320
|
+
} catch (error) {
|
|
2321
|
+
result = failure("storage_error", `mutation failed: ${String(error)}`);
|
|
2322
|
+
}
|
|
2323
|
+
}
|
|
2324
|
+
} catch (error) {
|
|
2325
|
+
result = this.lockFailure(lockPath, error);
|
|
2326
|
+
}
|
|
2327
|
+
drop(lockPath);
|
|
2328
|
+
try {
|
|
2329
|
+
retryTransient(() => handle.release());
|
|
2330
|
+
} catch (error) {
|
|
2331
|
+
const released = this.lockFailure(lockPath, error);
|
|
2332
|
+
const releaseError = released.ok ? "metadata lock release failed" : released.error;
|
|
2333
|
+
result = result.ok
|
|
2334
|
+
? released
|
|
2335
|
+
: failure("recovery_required", `${result.error}; ${releaseError}`, result.details);
|
|
2336
|
+
}
|
|
2337
|
+
return result;
|
|
1809
2338
|
}
|
|
1810
|
-
|
|
1811
|
-
|
|
2339
|
+
|
|
2340
|
+
/** Whether the lock's directory accepts a new file right now. */
|
|
2341
|
+
private lockDirWritable(lockPath: string): boolean {
|
|
2342
|
+
const probe = `${lockPath}.${process.pid}.${randomUUID()}.probe`;
|
|
2343
|
+
try {
|
|
2344
|
+
fs.closeSync(fs.openSync(probe, "wx", 0o600));
|
|
2345
|
+
} catch {
|
|
2346
|
+
return false;
|
|
2347
|
+
}
|
|
2348
|
+
try {
|
|
2349
|
+
fs.rmSync(probe, { force: true });
|
|
2350
|
+
} catch {}
|
|
2351
|
+
return true;
|
|
1812
2352
|
}
|
|
1813
|
-
|
|
1814
|
-
|
|
2353
|
+
|
|
2354
|
+
/** A lock file (or an entry Windows is still tearing down) exists. */
|
|
2355
|
+
private lockHolderPresent(lockPath: string): boolean {
|
|
2356
|
+
try {
|
|
2357
|
+
fs.lstatSync(lockPath);
|
|
2358
|
+
return true;
|
|
2359
|
+
} catch (error) {
|
|
2360
|
+
return (error as { code?: unknown } | null)?.code !== "ENOENT";
|
|
2361
|
+
}
|
|
1815
2362
|
}
|
|
1816
|
-
|
|
1817
|
-
|
|
2363
|
+
|
|
2364
|
+
private lockFailure(lockPath: string, error: unknown): Result<never> {
|
|
2365
|
+
const value = error as { code?: unknown; message?: unknown };
|
|
2366
|
+
const code = typeof value?.code === "string" ? value.code : "";
|
|
2367
|
+
// A sharing-class denial is contention only when someone holds the lock.
|
|
2368
|
+
if (
|
|
2369
|
+
isTransientWindowsError(error) &&
|
|
2370
|
+
!this.lockHolderPresent(lockPath) &&
|
|
2371
|
+
!this.lockDirWritable(lockPath)
|
|
2372
|
+
)
|
|
2373
|
+
return failure(
|
|
2374
|
+
"storage_error",
|
|
2375
|
+
`cannot create the workit lock (${code}); no other Workit call holds it`,
|
|
2376
|
+
{
|
|
2377
|
+
path: lockPath,
|
|
2378
|
+
guidance: `Check permissions and the read-only attribute on ${path.dirname(lockPath)}.`,
|
|
2379
|
+
},
|
|
2380
|
+
);
|
|
2381
|
+
if (
|
|
2382
|
+
code === "EEXIST" ||
|
|
2383
|
+
code === "file_lock_timeout" ||
|
|
2384
|
+
code === "file_lock_stale" ||
|
|
2385
|
+
isTransientWindowsError(error)
|
|
2386
|
+
) {
|
|
2387
|
+
let lock: MetadataLock | null = null;
|
|
2388
|
+
try {
|
|
2389
|
+
lock = parseMetadataLockOrNull(fs.readFileSync(lockPath, "utf8"));
|
|
2390
|
+
} catch {}
|
|
2391
|
+
if (lock?.externalAction)
|
|
2392
|
+
return failure("writer_conflict", "workspace is reserved by a managed external action", {
|
|
2393
|
+
outcome: "not_started",
|
|
2394
|
+
path: lockPath,
|
|
2395
|
+
});
|
|
2396
|
+
return failure(
|
|
2397
|
+
"busy",
|
|
2398
|
+
`workit lock is held by another Workit call${lock ? ` (pid ${lock.pid} on ${lock.host})` : ""}; retry shortly`,
|
|
2399
|
+
{
|
|
2400
|
+
outcome: "not_started",
|
|
2401
|
+
path: lockPath,
|
|
2402
|
+
guidance:
|
|
2403
|
+
"Retry the same call. If it stays busy, run `workit doctor --fix-lock` to clear a lock left by a dead process.",
|
|
2404
|
+
},
|
|
2405
|
+
);
|
|
2406
|
+
}
|
|
2407
|
+
const recovery =
|
|
2408
|
+
code === "metadata_lock_invalid" ||
|
|
2409
|
+
code === "not-file" ||
|
|
2410
|
+
/metadata lock|file lock|reclaim/i.test(String(value?.message ?? error));
|
|
2411
|
+
return failure(
|
|
2412
|
+
recovery ? "recovery_required" : "storage_error",
|
|
2413
|
+
`lock operation failed: ${String(error)}`,
|
|
2414
|
+
{ path: lockPath },
|
|
2415
|
+
);
|
|
1818
2416
|
}
|
|
1819
2417
|
}
|