@brainervirus/workit-core 2.9.0 → 4.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.
@@ -1,14 +1,39 @@
1
- import { createHash, randomUUID } from "node:crypto";
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
- export type { MetadataLock } from "./store-lock";
49
- /** Recovery copies kept per record (task or workspace); older copies are pruned. */
50
- export const RECOVERY_COPIES_PER_RECORD = 3;
51
- /** A temp file older than this was left by a crashed writer. */
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
- const RECOVERY_NAME = /^(task|workspace)\.([^.]+)\.([0-9a-f]{64})\.json$/;
125
+
54
126
  export type GarbageReport = {
55
127
  dryRun: boolean;
56
- recovery: { removed: number; removedBytes: number; kept: number };
128
+ compacted: { tasks: Id[]; eventsFolded: number; bytesBefore: number; bytesAfter: number };
129
+ blobs: { removed: number; removedBytes: number; kept: number };
57
130
  temporary: { removed: number };
58
- candidates: {
59
- removed: number;
60
- tasks: Id[];
61
- skippedActive: Id[];
62
- skippedClosed: Id[];
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 RecoveryInput = {
100
- expectedBytes: string;
101
- snapshotDigest: string;
102
- reason: string;
103
- authorityRefs: Ref[];
104
- expectedWorkspaceRevision: Revision;
105
- processEvidence: (
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
- const processEvidenceSchema = z
117
- .object({
118
- state: z.enum(["stopped", "accounted_for"]),
119
- pid: z.number().int().nonnegative(),
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
- /** Stat signature of the task file the entry was derived from. */
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 = 1;
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 = (task: TaskRecord, file: string): TaskIndexEntry => {
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
- /** Cheap change detector. Atomic replacement can reuse inodes and file
173
- * timestamps are often only jiffy-granular, so a signature alone may repeat
174
- * across rapid rewrites; see `racySignature`. */
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 .workit. */
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
- type LockSnapshot = { raw: string; data: MetadataLock };
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
- /** Roots whose metadata lock this process holds; waiting on them can only time out. */
345
+ /** Locks this process holds; waiting on them can only time out. */
296
346
  const heldInProcess = new Map<string, number>();
297
- const holdRoot = (root: string) => heldInProcess.set(root, (heldInProcess.get(root) ?? 0) + 1);
298
- const dropRoot = (root: string) => {
299
- const count = (heldInProcess.get(root) ?? 1) - 1;
300
- if (count > 0) heldInProcess.set(root, count);
301
- else heldInProcess.delete(root);
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
- /** Drop earlier copies of a repeated candidate ID; content is identical by ID. */
305
- const dedupeCandidates = (candidates: TaskRecord["candidates"]): TaskRecord["candidates"] => {
306
- const last = new Map(candidates.map((candidate, index) => [candidate.id, index]));
307
- return candidates.filter((candidate, index) => last.get(candidate.id) === index);
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
- this.root = fs.existsSync(root) ? fs.realpathSync(root) : path.resolve(root);
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
- readTask(taskId: Id): Result<TaskRecord> {
320
- if (!validId(taskId)) return failure("invalid_input", "task ID is invalid", { taskId });
321
- const result = this.readRecord<TaskRecord>(
322
- path.join(this.tasksDir, `${taskId}.json`),
323
- taskRecordSchema,
324
- );
325
- if (!result.exists) return failure("not_found", "task not found", { taskId });
326
- if (result.result.ok && result.result.data.id !== taskId)
327
- return failure("recovery_required", "task filename and record ID differ", { taskId });
328
- if (result.result.ok) {
329
- const workspace = this.readWorkspace();
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
- return result.result;
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
- listTasks(): Result<TaskRecord[]> {
338
- if (!fs.existsSync(this.tasksDir)) return success(null, null, []);
339
- let names: string[];
340
- try {
341
- names = fs.readdirSync(this.tasksDir).filter((name) => name.endsWith(".json"));
342
- } catch (error) {
343
- return failure("storage_error", `unable to list tasks: ${String(error)}`, {
344
- path: this.tasksDir,
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
- if (!workspace.data)
350
- return failure("recovery_required", "task workspace binding is invalid", {
351
- path: this.workspacePath,
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 name of names.toSorted()) {
355
- const item = this.readRecord<TaskRecord>(path.join(this.tasksDir, name), taskRecordSchema);
356
- if (!item.exists) continue;
357
- if (!item.result.ok) return item.result;
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
- * List tasks from `.workit/index.json` without parsing full records. Each
369
- * entry is checked against its task file's stat signature; missing, stale,
370
- * or corrupt entries are rebuilt from the full (validated) record and the
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
- listTaskIndex(): Result<TaskIndexEntry[]> {
374
- if (!fs.existsSync(this.tasksDir)) return success(null, null, []);
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.readdirSync(this.tasksDir).filter((name) => name.endsWith(".json"));
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: this.tasksDir,
499
+ path: tasksDir,
381
500
  });
382
501
  }
383
- const workspace = this.readWorkspace();
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 file = path.join(this.tasksDir, name);
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.slice(0, -5)];
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 item = this.readRecord<TaskRecord>(file, taskRecordSchema);
405
- if (!item.exists) continue;
406
- if (!item.result.ok) return item.result;
407
- if (!validId(name.slice(0, -5)) || item.result.data.id !== name.slice(0, -5))
408
- return failure("recovery_required", "task filename and record ID differ", { path: name });
409
- if (item.result.data.workspaceId !== workspace.data.id)
410
- return failure("recovery_required", "task workspace binding is invalid", { path: name });
411
- const entry = indexEntry(item.result.data, signature);
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(workspace.data.id, entries);
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 item = this.readRecord<WorkspaceRecord>(this.workspacePath, workspaceRecordSchema);
421
- if (!item.exists) return success(null, null, null);
422
- if (
423
- item.result.ok &&
424
- item.result.data.root !== this.root &&
425
- !sameDirectoryIdentity(item.result.data.root, this.root)
426
- )
427
- return failure("recovery_required", "workspace root binding is invalid", {
428
- path: this.workspacePath,
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
- return item.result;
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
- return this.withLock<TaskRecord>(() => {
447
- const current = this.readWorkspace();
448
- if (!current.ok) return current;
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
- return this.withLock<TaskRecord>(() => {
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: WorkspaceRecord = current.data
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
- const writtenWorkspace = this.replaceSnapshot(
629
- this.workspacePath,
630
- workspace,
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
- return this.withLock(() => {
670
- const current = this.readTask(taskId);
671
- if (!current.ok) return current;
672
- if (current.data.revision !== expected) return this.conflict(expected, current.data.revision);
673
- const previousBytes = this.snapshotBytes(this.taskPath(taskId));
674
- if (!previousBytes)
675
- return failure("storage_error", "task snapshot disappeared during mutation");
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
- changed = update(current.data, Object.freeze({ ...context }));
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 record = {
685
- ...changed.data,
686
- id: taskId,
687
- workspaceId: current.data.workspaceId,
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
- return this.withLock(() => {
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 record = {
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.replaceSnapshot(this.workspacePath, valid.data, previousBytes);
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
- return this.withLock(() => {
736
- const task = this.readTask(input.taskId);
737
- if (!task.ok) return task;
738
- const workspace = this.readWorkspace();
739
- if (!workspace.ok) return workspace;
740
- if (!workspace.data) return failure("not_found", "workspace not found");
741
- const expected = input.expectedRevision;
742
- if (!expected) return failure("invalid_input", "task revision is required");
743
- if (task.data.revision !== expected) return this.conflict(expected, task.data.revision);
744
- if (workspace.data.revision !== input.expectedWorkspaceRevision)
745
- return this.workspaceConflict(input.expectedWorkspaceRevision, workspace.data.revision);
746
- const previousWorkspaceBytes = this.snapshotBytes(this.workspacePath);
747
- const previousTaskBytes = this.snapshotBytes(this.taskPath(input.taskId));
748
- if (!previousWorkspaceBytes || !previousTaskBytes)
749
- return failure("storage_error", "snapshot disappeared during coupled mutation");
750
- const workspaceContext = { now: input.now ?? now(), revision: newRevision() };
751
- let changedWorkspace: Result<WorkspaceRecord>;
752
- try {
753
- changedWorkspace = input.workspace(workspace.data, Object.freeze({ ...workspaceContext }));
754
- } catch (error) {
755
- return failure("storage_error", `workspace mutation failed: ${String(error)}`);
756
- }
757
- if (!changedWorkspace.ok) return changedWorkspace;
758
- const nextWorkspace = workspaceRecordSchema.safeParse({
759
- ...changedWorkspace.data,
760
- id: workspace.data.id,
761
- root: this.root,
762
- revision: workspaceContext.revision,
763
- runtime: stampNew(workspace.data),
764
- });
765
- if (!nextWorkspace.success)
766
- return failure("invalid_input", "workspace mutation produced an invalid record");
767
- const reserved = this.replaceSnapshot(
768
- this.workspacePath,
769
- nextWorkspace.data,
770
- previousWorkspaceBytes,
771
- );
772
- if (!reserved.ok) return reserved;
773
- const taskContext = { now: input.now ?? now(), revision: newRevision() };
774
- let changedTask: Result<TaskRecord>;
775
- try {
776
- changedTask = input.task(task.data, Object.freeze({ ...taskContext }));
777
- } catch (error) {
778
- this.markUncertain(nextWorkspace.data);
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
- return success(null, null, candidates);
844
- } catch (error) {
845
- return failure("storage_error", `unable to list recovery: ${String(error)}`, {
846
- path: this.recoveryDir,
847
- });
848
- }
849
- }
850
-
851
- recoverTask(taskId: Id, input: RecoveryInput): Result<TaskRecord> {
852
- return this.recover("task", taskId, input);
853
- }
854
-
855
- recoverWorkspace(input: RecoveryInput): Result<WorkspaceRecord> {
856
- return this.recover("workspace", null, input);
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
- private recover(
860
- target: "task" | "workspace",
861
- taskId: Id | null,
862
- input: RecoveryInput,
863
- ): Result<any> {
864
- if (taskId !== null && !validId(taskId))
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
- !input.reason ||
868
- !Array.isArray(input.authorityRefs) ||
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 failure("invalid_input", "recovery authority is invalid");
874
- if (input.authorityRefs.some((ref) => !refSchema.safeParse(ref).success))
875
- return failure("invalid_input", "recovery references are invalid");
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
- const workspace = this.readWorkspace();
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("recovery_required", `recovery protocol failed: ${String(error)}`);
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 result = input.processEvidence(lockCopy, writerCopy);
998
- if (lockCopy && sha256(canonicalJson(lockCopy)) !== lockDigest)
999
- return failure("recovery_required", "process evidence mutated lock identity");
1000
- if (writerCopy && sha256(canonicalJson(writerCopy)) !== writerDigest)
1001
- return failure("recovery_required", "process evidence mutated writer identity");
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 failure("recovery_required", `process evidence failed: ${String(error)}`);
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
- return value;
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: this.lockPath,
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: this.lockPath,
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: this.lockPath,
951
+ path: lockPath,
1171
952
  });
1172
953
  }
1173
- dropRoot(this.root);
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: this.lockPath,
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
- private recoveryLockOptions(
1194
- observed: LockSnapshot | null,
1195
- evidence: ProcessEvidence,
1196
- gate: { reclaimed: boolean },
1197
- ): FileLockSyncAcquireOptions<MetadataLock> {
1198
- const options = this.metadataLockOptions();
1199
- options.timeoutMs = 0;
1200
- options.retry = { retries: 0 };
1201
- options.parsePayload = parseMetadataLock;
1202
- options.staleRecovery = "remove-if-unchanged";
1203
- options.shouldReclaim = ({ payload }) =>
1204
- Boolean(
1205
- observed &&
1206
- sameMetadataLock(payload, observed.data) &&
1207
- (evidence.state === "stopped" || evidence.state === "accounted_for"),
1208
- );
1209
- options.shouldRemoveStaleLock = ({ raw, payload }) => {
1210
- if (!observed || raw !== observed.raw || !sameMetadataLock(payload, observed.data))
1211
- return false;
1212
- gate.reclaimed = true;
1213
- return true;
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
- return options;
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
- private markUncertain(workspace: WorkspaceRecord) {
1219
- if (!workspace.writer || workspace.writer.state === "uncertain") return;
1220
- const value = {
1221
- ...workspace,
1222
- writer: { ...workspace.writer, state: "uncertain" as const },
1223
- revision: newRevision(),
1224
- runtime: stampNew(workspace),
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
- this.replaceSnapshot(this.workspacePath, value, workspace);
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
- private withLock<T>(
1230
- operation: (handle: FileLockSyncHandle) => Result<T>,
1231
- options: FileLockSyncAcquireOptions<MetadataLock> = this.metadataLockOptions(),
1232
- contention: "busy" | "recovery_required" = "busy",
1233
- ): Result<T> {
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
- this.initializeMutationStorage();
1236
- } catch (error) {
1237
- return failure("storage_error", `unable to initialize store: ${String(error)}`, {
1238
- path: this.workitDir,
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
- let handle: FileLockSyncHandle | undefined;
1242
- let result: Result<T> = failure(
1243
- "storage_error",
1244
- "metadata lock operation did not produce a result",
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
- handle = this.acquireMetadataLock(options);
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
- result = this.lockFailure(error, contention);
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
- if (handle) holdRoot(this.root);
1252
- if (handle) {
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
- if (!handle.verifyStillHeld())
1255
- result = failure("recovery_required", "metadata lock was compromised", {
1256
- path: this.lockPath,
1257
- });
1258
- else {
1259
- try {
1260
- result = operation(handle);
1261
- } catch (error) {
1262
- result = failure("storage_error", `mutation failed: ${String(error)}`);
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
- result = this.lockFailure(error);
1261
+ if (error instanceof LogDamage && error.upgrade) throw error;
1262
+ // Any other mismatch: fall back to a full replay.
1267
1263
  }
1268
1264
  }
1269
- if (handle) {
1270
- dropRoot(this.root);
1271
- try {
1272
- retryTransient(() => handle.release());
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
- return result;
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
- /** Whether the lock's directory accepts a new file right now. */
1287
- private lockDirWritable(): boolean {
1288
- const probe = `${this.lockPath}.${process.pid}.${randomUUID()}.probe`;
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
- fs.closeSync(fs.openSync(probe, "wx", 0o600));
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 false;
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
- fs.rmSync(probe, { force: true });
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
- /** A lock file (or an entry Windows is still tearing down) exists. */
1301
- private lockHolderPresent(): boolean {
1302
- try {
1303
- fs.lstatSync(this.lockPath);
1304
- return true;
1305
- } catch (error) {
1306
- return (error as { code?: unknown } | null)?.code !== "ENOENT";
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 lockFailure(
1311
- error: unknown,
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
- recovery ? "recovery_required" : "storage_error",
1362
- `metadata lock operation failed: ${String(error)}`,
1363
- { path: this.lockPath },
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 initializeMutationStorage() {
1368
- retryTransient(() => fs.mkdirSync(this.tasksDir, { recursive: true }));
1369
- retryTransient(() => fs.mkdirSync(this.recoveryDir, { recursive: true }));
1370
- // Rewriting an unchanged .gitignore on every call makes concurrent
1371
- // writers collide on it (Windows sharing violations); write it only when
1372
- // it is missing or different.
1373
- let current: string | null = null;
1374
- try {
1375
- current = retryTransient(() => fs.readFileSync(this.gitignorePath, "utf8"));
1376
- } catch {}
1377
- if (current !== "*\n") retryTransient(() => fs.writeFileSync(this.gitignorePath, "*\n"));
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
- private replaceSnapshot(file: string, value: unknown, previous: unknown): Result<any> {
1381
- let temporary: string | undefined;
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
- if (Buffer.isBuffer(previous)) this.saveRecovery(file, previous);
1384
- else if (previous !== null && typeof previous === "object")
1385
- this.saveRecovery(file, jsonBytes(previous));
1386
- else if (typeof previous === "string") this.saveRecovery(file, previous);
1387
- temporary = `${file}.${process.pid}.${randomUUID()}.tmp`;
1388
- const fd = fs.openSync(temporary, "wx", 0o600);
1389
- try {
1390
- fs.writeSync(fd, jsonBytes(value));
1391
- fs.fsyncSync(fd);
1392
- } finally {
1393
- fs.closeSync(fd);
1394
- }
1395
- retryTransient(() => fs.renameSync(temporary!, file));
1396
- temporary = undefined;
1397
- this.fsyncDirectory(path.dirname(file));
1398
- if (path.dirname(file) === this.tasksDir) this.indexTaskWrite(file, value as TaskRecord);
1399
- return success(null, null, value);
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", `snapshot replacement failed: ${String(error)}`, {
1402
- path: file,
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
- private readIndex(workspaceId: Id): Record<string, TaskIndexEntry> {
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 value = JSON.parse(fs.readFileSync(this.indexPath, "utf8")) as unknown;
1415
- if (
1416
- !isObject(value) ||
1417
- value.version !== INDEX_VERSION ||
1418
- value.workspaceId !== workspaceId ||
1419
- !isObject(value.tasks)
1420
- )
1421
- return {};
1422
- const tasks: Record<string, TaskIndexEntry> = {};
1423
- for (const [id, entry] of Object.entries(value.tasks))
1424
- if (
1425
- isObject(entry) &&
1426
- entry.id === id &&
1427
- typeof entry.file === "string" &&
1428
- typeof entry.status === "string" &&
1429
- typeof entry.updatedAt === "string" &&
1430
- Array.isArray(entry.sessions) &&
1431
- isObject(entry.progress) &&
1432
- isObject(entry.source)
1433
- )
1434
- tasks[id] = entry as TaskIndexEntry;
1435
- return tasks;
1436
- } catch {
1437
- return {};
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
- /** The index is a disposable cache: write it atomically, never fail the caller. */
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.writeFileSync(
1446
- temporary,
1447
- JSON.stringify({
1448
- version: INDEX_VERSION,
1449
- workspaceId,
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
- try {
1457
- fs.unlinkSync(temporary);
1458
- } catch {}
1521
+ return 0;
1459
1522
  }
1460
1523
  }
1461
1524
 
1462
- private indexTaskWrite(file: string, task: TaskRecord) {
1463
- try {
1464
- const signature = fileSignature(file);
1465
- if (signature === null) return;
1466
- const tasks = this.readIndex(task.workspaceId);
1467
- tasks[task.id] = indexEntry(task, signature);
1468
- this.writeIndex(task.workspaceId, Object.values(tasks));
1469
- } catch {
1470
- // A stale index entry is repaired by the next listTaskIndex().
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 saveRecovery(file: string, bytes: string | Buffer) {
1475
- const target = path.basename(file) === "workspace.json" ? "workspace" : "task";
1476
- const id = target === "task" ? path.basename(file, ".json") : "workspace";
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
- * Keep the newest RECOVERY_COPIES_PER_RECORD copies of one record (always
1511
- * including `keep`, the copy just written). Pruning is best-effort: a
1512
- * failure never fails the mutation that triggered it.
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
- private recoveryCopies(
1523
- prefix = "",
1524
- ): { name: string; path: string; group: string; mtimeNs: bigint }[] {
1525
- if (!fs.existsSync(this.recoveryDir)) return [];
1526
- const copies = [];
1527
- for (const name of fs.readdirSync(this.recoveryDir)) {
1528
- if (!name.startsWith(prefix)) continue;
1529
- const match = RECOVERY_NAME.exec(name);
1530
- if (!match) continue;
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
- return copies;
1539
- }
1540
-
1541
- /** Copies of one record beyond the cap, oldest first out; `keep` is never surplus. */
1542
- private surplusCopies<T extends { name: string; mtimeNs: bigint }>(copies: T[], keep?: string) {
1543
- const ordered = copies.toSorted((left, right) =>
1544
- left.name === keep
1545
- ? -1
1546
- : right.name === keep
1547
- ? 1
1548
- : left.mtimeNs === right.mtimeNs
1549
- ? left.name.localeCompare(right.name)
1550
- : left.mtimeNs > right.mtimeNs
1551
- ? -1
1552
- : 1,
1553
- );
1554
- return ordered.slice(RECOVERY_COPIES_PER_RECORD);
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
- * `workit gc`: prune recovery copies beyond the per-record cap, remove temp
1559
- * files left by crashed writers, and collapse duplicate stored candidates in
1560
- * paused tasks. Live records (tasks/*.json, workspace.json) are never
1561
- * deleted; candidate dedupe keeps every candidate ID and the latest position.
1562
- * Closed tasks are history and are never rewritten. A dry run is read-only:
1563
- * no lock, no directory creation, no .gitignore rewrite.
1564
- */
1565
- collectGarbage(options: { dryRun?: boolean } = {}): Result<GarbageReport> {
1566
- const dryRun = options.dryRun === true;
1567
- const report: GarbageReport = {
1568
- dryRun,
1569
- recovery: { removed: 0, removedBytes: 0, kept: 0 },
1570
- temporary: { removed: 0 },
1571
- candidates: { removed: 0, tasks: [], skippedActive: [], skippedClosed: [], failed: [] },
1572
- };
1573
- if (!fs.existsSync(this.workitDir)) return success(null, null, report);
1574
- const sweep = (): Result<null> => {
1575
- try {
1576
- const groups = new Map<string, ReturnType<TaskStore["recoveryCopies"]>>();
1577
- for (const copy of this.recoveryCopies())
1578
- groups.set(copy.group, [...(groups.get(copy.group) ?? []), copy]);
1579
- for (const copies of groups.values()) {
1580
- const surplus = this.surplusCopies(copies);
1581
- report.recovery.kept += copies.length - surplus.length;
1582
- for (const copy of surplus) {
1583
- report.recovery.removed += 1;
1584
- try {
1585
- report.recovery.removedBytes += fs.statSync(copy.path).size;
1586
- } catch {}
1587
- if (!dryRun) fs.rmSync(copy.path, { force: true });
1588
- }
1589
- }
1590
- const nowMs = Date.now();
1591
- for (const directory of [this.recoveryDir, this.tasksDir, this.workitDir]) {
1592
- if (!fs.existsSync(directory)) continue;
1593
- for (const name of fs.readdirSync(directory)) {
1594
- if (!name.endsWith(".tmp") && !name.endsWith(".probe")) continue;
1595
- const file = path.join(directory, name);
1596
- const stat = fs.lstatSync(file);
1597
- if (!stat.isFile() || nowMs - stat.mtimeMs <= STALE_TEMPORARY_MS) continue;
1598
- report.temporary.removed += 1;
1599
- if (!dryRun) fs.rmSync(file, { force: true });
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 success(null, null, report);
1649
+ return this.newTask(value, current.data, key);
1645
1650
  }
1646
1651
 
1647
- private findRecovery(
1648
- target: "task" | "workspace",
1649
- taskId: Id | null,
1650
- digest: string,
1651
- workspace: WorkspaceRecord | null,
1652
- ): Buffer | null {
1653
- if (!fs.existsSync(this.recoveryDir)) return null;
1654
- const workspaceId = workspace?.id ?? this.trustedWorkspaceId();
1655
- if (target === "workspace" && !workspaceId) return null;
1656
- const candidates = this.recoveryCandidates();
1657
- if (!candidates.ok) return null;
1658
- for (const candidate of candidates.data) {
1659
- if (candidate.target !== target || candidate.digest !== digest) continue;
1660
- try {
1661
- if (!fs.lstatSync(candidate.path).isFile()) continue;
1662
- const bytes = fs.readFileSync(candidate.path);
1663
- if (digestBytes(bytes) !== digest) continue;
1664
- if (target === "workspace") {
1665
- const parsed = this.parseBytes<WorkspaceRecord>(bytes, workspaceRecordSchema);
1666
- if (parsed.ok && parsed.data.id === workspaceId && parsed.data.root === this.root)
1667
- return bytes;
1668
- } else {
1669
- const parsed = this.parseBytes<TaskRecord>(bytes, taskRecordSchema);
1670
- if (parsed.ok && parsed.data.id === taskId && parsed.data.workspaceId === workspace?.id)
1671
- return bytes;
1672
- }
1673
- } catch {}
1674
- }
1675
- return null;
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
- private fsyncDirectory(directory: string) {
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
- const fd = fs.openSync(directory, "r");
1681
- try {
1682
- fs.fsyncSync(fd);
1683
- } finally {
1684
- fs.closeSync(fd);
1685
- }
1719
+ this.openTask(task, type, key);
1686
1720
  } catch (error) {
1687
- // Windows rejects fsync on a directory handle; the rename is the durable step there.
1688
- if (process.platform !== "win32") throw error;
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 trustedWorkspaceId(): Id | null {
1693
- if (!fs.existsSync(this.tasksDir)) return null;
1694
- const ids = new Set<Id>();
1739
+ private writeWorkspace(record: WorkspaceRecord): Result<null> {
1740
+ const file = this.paths().workspace;
1695
1741
  try {
1696
- for (const name of fs.readdirSync(this.tasksDir)) {
1697
- if (!name.endsWith(".json") || !validId(name.slice(0, -5))) return null;
1698
- const parsed = this.parseBytes<TaskRecord>(
1699
- fs.readFileSync(path.join(this.tasksDir, name)),
1700
- taskRecordSchema,
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 snapshotBytes(file: string): Buffer | null {
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
- return retryTransient(() => fs.readFileSync(file));
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 null;
1783
+ return {};
1716
1784
  }
1717
1785
  }
1718
1786
 
1719
- private readRecord<T>(file: string, schema: z.ZodType) {
1787
+ /** The index is a disposable cache: write it atomically, never fail the caller. */
1788
+ private writeIndex(entries: TaskIndexEntry[]) {
1720
1789
  try {
1721
- const bytes = retryTransient(() => fs.readFileSync(file, "utf8"));
1722
- return { exists: true, result: this.parseBytes<T>(bytes, schema) };
1723
- } catch (error: any) {
1724
- if (error?.code === "ENOENT")
1725
- return { exists: false, result: success(null, null, null as T) };
1726
- return {
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
- /** `rewrite` refuses a record that only parses after dropping unknown keys:
1736
- * recovery copies a snapshot back verbatim and must not lose its fields. */
1737
- private parseBytes<T>(
1738
- bytes: string | Buffer,
1739
- schema: z.ZodType,
1740
- mode: "read" | "rewrite" = "read",
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(typeof bytes === "string" ? bytes : bytes.toString("utf8"));
1832
+ value = JSON.parse(bytes);
1745
1833
  } catch {
1746
- return failure("recovery_required", "snapshot JSON is corrupt");
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", "unsupported snapshot schema version");
1845
+ return failure("unsupported_version", `unsupported ${label} schema version`);
1750
1846
  const parsed = parseStoredRecord(schema, value);
1751
- if (parsed.success && (mode === "read" || parsed.stripped.length === 0))
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((value as { runtime?: unknown }).runtime)
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
- `snapshot requires fields this Workit cannot read (${parsed.critical.join(", ")}); ${upgrade}`,
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
- `snapshot was written by workit ${writerVersion}; upgrade Workit before mutating this checkout`,
1858
+ `${label} record was written by workit ${writerVersion}; upgrade Workit before mutating this checkout`,
1772
1859
  );
1773
- return failure("recovery_required", "snapshot does not satisfy its schema");
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
- private taskPath(taskId: Id) {
1796
- return path.join(this.tasksDir, `${taskId}.json`);
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
- private get workitDir() {
1799
- return path.join(this.root, ".workit");
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
- private get tasksDir() {
1802
- return path.join(this.workitDir, "tasks");
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
- private get recoveryDir() {
1805
- return path.join(this.workitDir, "recovery");
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
- private get workspacePath() {
1808
- return path.join(this.workitDir, "workspace.json");
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
- private get indexPath() {
1811
- return path.join(this.workitDir, "index.json");
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
- private get lockPath() {
1814
- return path.join(this.workitDir, "metadata.lock");
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
- private get gitignorePath() {
1817
- return path.join(this.workitDir, ".gitignore");
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
  }