@workit-dev/core 0.0.1 → 1.0.0

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