@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.
- package/LICENSE +21 -0
- package/README.md +22 -43
- package/package.json +56 -6
- package/schemas/config.json +74 -0
- package/schemas/vcs.json +79 -0
- package/schemas/workspaces.json +1029 -0
- package/schemas/youtrack.json +84 -0
- package/scripts/analyze-release-scope.ts +215 -0
- package/scripts/bundled-deps.json +131 -0
- package/scripts/fixtures/sample-plan.md +15 -0
- package/scripts/fixtures/sample-sdd/progress.md +2 -0
- package/scripts/install-codex-plugin.sh +28 -0
- package/scripts/install-cursor-plugin.sh +146 -0
- package/scripts/install-opencode-plugin.sh +79 -0
- package/scripts/install-pi-package.sh +56 -0
- package/scripts/lib/config-dir.sh +28 -0
- package/scripts/publish-changed-packages.ts +395 -0
- package/scripts/rewrite-workspace-deps.ts +53 -0
- package/scripts/run-cursor-mcp.sh +6 -0
- package/scripts/sync-release-manifests.ts +82 -0
- package/scripts/sync-runtime.sh +142 -0
- package/scripts/validate-cursor-marketplace.ts +171 -0
- package/scripts/verify-bundle-sources.ts +134 -0
- package/scripts/write-schemas.ts +16 -0
- package/skills/workit-architecture/SKILL.md +72 -0
- package/skills/workit-architecture/agents/openai.yaml +3 -0
- package/skills/workit-architecture/references/instruction-files.md +52 -0
- package/skills/workit-architecture/references/vocabulary.md +53 -0
- package/skills/workit-bdd/SKILL.md +53 -0
- package/skills/workit-continue/SKILL.md +54 -0
- package/skills/workit-debug/SKILL.md +45 -0
- package/skills/workit-deslop/SKILL.md +58 -0
- package/skills/workit-fanout/SKILL.md +71 -0
- package/skills/workit-fanout/references/brief.md +141 -0
- package/skills/workit-fanout/references/orchestration.md +169 -0
- package/skills/workit-implement/SKILL.md +71 -0
- package/skills/workit-retro/SKILL.md +72 -0
- package/skills/workit-retro/agents/openai.yaml +3 -0
- package/skills/workit-retro/references/sources.md +35 -0
- package/skills/workit-retro/references/steering.md +39 -0
- package/skills/workit-review/SKILL.md +66 -0
- package/skills/workit-review/references/impact.md +24 -0
- package/skills/workit-shape/SKILL.md +71 -0
- package/skills/workit-shape/references/diagrams.md +17 -0
- package/skills/workit-shape/references/knowledge.md +61 -0
- package/skills/workit-shape/references/mockups.md +15 -0
- package/skills/workit-shape/references/slicing.md +44 -0
- package/skills/workit-ship/SKILL.md +71 -0
- package/skills/workit-ship/references/changelog.md +44 -0
- package/skills/workit-ship/references/pr-body.md +32 -0
- package/skills/workit-ship/references/release.md +39 -0
- package/skills/workit-test-audit/SKILL.md +54 -0
- package/skills/workit-verify-app/SKILL.md +68 -0
- package/skills/workit-verify-app/references/template.md +51 -0
- package/src/autonomy.ts +910 -0
- package/src/check-config.ts +613 -0
- package/src/checks.ts +534 -0
- package/src/ci-waiter.ts +159 -0
- package/src/core/boundary.ts +55 -0
- package/src/core/branch-policy.ts +112 -0
- package/src/core/branch.ts +1192 -0
- package/src/core/changelog-gate.ts +349 -0
- package/src/core/changelog-merge.ts +214 -0
- package/src/core/changelog.ts +392 -0
- package/src/core/cleanup.ts +638 -0
- package/src/core/cli-locate.ts +173 -0
- package/src/core/commit-flavors.ts +65 -0
- package/src/core/config-schemas.ts +653 -0
- package/src/core/config.ts +351 -0
- package/src/core/context-read.ts +144 -0
- package/src/core/docs-gate.ts +69 -0
- package/src/core/finding-evidence.ts +89 -0
- package/src/core/finding-queue.ts +36 -0
- package/src/core/git.ts +73 -0
- package/src/core/gitignore.ts +52 -0
- package/src/core/heavy-eta.ts +170 -0
- package/src/core/heavy-lease.ts +290 -0
- package/src/core/heavy-rules.ts +373 -0
- package/src/core/heavy-scope.ts +131 -0
- package/src/core/heavy-words.ts +83 -0
- package/src/core/heavy.ts +535 -0
- package/src/core/hygiene.ts +149 -0
- package/src/core/init.ts +218 -0
- package/src/core/live-session.ts +333 -0
- package/src/core/logger.ts +310 -0
- package/src/core/methods.ts +185 -0
- package/src/core/model-fallback.ts +208 -0
- package/src/core/node-resolve.mjs +249 -0
- package/src/core/operation-input.ts +632 -0
- package/src/core/package-root.ts +28 -0
- package/src/core/policy/attributes.ts +97 -0
- package/src/core/policy/bookkeeping.ts +119 -0
- package/src/core/policy/carry.ts +122 -0
- package/src/core/policy/check-evidence.ts +115 -0
- package/src/core/policy/current-judgment.ts +20 -0
- package/src/core/policy/derive.ts +387 -0
- package/src/core/policy/judgment.ts +246 -0
- package/src/core/policy/lane.ts +562 -0
- package/src/core/policy/pr-lane.ts +32 -0
- package/src/core/policy/substance.ts +365 -0
- package/src/core/policy-resolver.ts +94 -0
- package/src/core/pr-create.ts +448 -0
- package/src/core/pr-template.ts +162 -0
- package/src/core/release-notes.ts +197 -0
- package/src/core/release-tracks.ts +779 -0
- package/src/core/remotes.ts +436 -0
- package/src/core/repo-context.ts +542 -0
- package/src/core/repo-paths.ts +29 -0
- package/src/core/resources.ts +1299 -0
- package/src/core/role-models.ts +603 -0
- package/src/core/route-intent.ts +305 -0
- package/src/core/rules.ts +65 -0
- package/src/core/runtime-identity.ts +63 -0
- package/src/core/safe-write.ts +87 -0
- package/src/core/scope-glob.ts +56 -0
- package/src/core/scripts.ts +6 -0
- package/src/core/skill-manifests.ts +173 -0
- package/src/core/store-lock.ts +374 -0
- package/src/core/support-matrix.ts +24 -0
- package/src/core/task-context.ts +247 -0
- package/src/core/task-contract.ts +1279 -0
- package/src/core/task-delivery.ts +109 -0
- package/src/core/task-engine.ts +2248 -0
- package/src/core/task-evaluation.ts +1147 -0
- package/src/core/task-store.ts +2649 -0
- package/src/core/task-view.ts +180 -0
- package/src/core/templates.ts +22 -0
- package/src/core/tracker-issues.ts +174 -0
- package/src/core/vcs-config.ts +540 -0
- package/src/core/workers.ts +62 -0
- package/src/core/workit-paths.mjs +242 -0
- package/src/core/workspace-branch-policy.ts +123 -0
- package/src/core/workspaces.ts +1745 -0
- package/src/core/youtrack-backend.ts +288 -0
- package/src/core/youtrack-tools.ts +260 -0
- package/src/core/youtrack-writes.ts +73 -0
- package/src/core/youtrack-ytrack.ts +217 -0
- package/src/core/youtrack.ts +454 -0
- package/src/core/ytrack-skill.ts +297 -0
- package/src/core.ts +171 -0
- package/src/deslop/lex.ts +271 -0
- package/src/deslop/scan.ts +232 -0
- package/src/duration.ts +27 -0
- package/src/fanout-brief.ts +486 -0
- package/src/fanout-check.ts +455 -0
- package/src/fanout-landed.ts +370 -0
- package/src/fanout-stack.ts +108 -0
- package/src/fanout-status.ts +362 -0
- package/src/fanout-worktree.ts +893 -0
- package/src/fanout.ts +1473 -0
- package/src/forge/exec.ts +385 -0
- package/src/forge/github.ts +831 -0
- package/src/forge/gitlab.ts +906 -0
- package/src/forge/issue-forge.ts +209 -0
- package/src/forge/issue-link.ts +468 -0
- package/src/forge/issue-ops.ts +314 -0
- package/src/forge/merge-gate-base-red.ts +72 -0
- package/src/forge/pr-body.ts +100 -0
- package/src/forge/pr-edit.ts +292 -0
- package/src/forge/pr-ops.ts +1109 -0
- package/src/forge/pr-status.ts +10 -0
- package/src/forge/redact.ts +195 -0
- package/src/forge/report.ts +994 -0
- package/src/forge/reruns.ts +132 -0
- package/src/forge/resolve.ts +509 -0
- package/src/forge/types.ts +242 -0
- package/src/forge/verify.ts +398 -0
- package/src/git/default-base.ts +130 -0
- package/src/git/env-redirect.ts +28 -0
- package/src/git/facts.ts +122 -0
- package/src/git/ops.ts +1823 -0
- package/src/git/refs-read.ts +332 -0
- package/src/git/release.ts +1020 -0
- package/src/git/rev.ts +1175 -0
- package/src/git/session-hook.ts +597 -0
- package/src/git/stage.ts +63 -0
- package/src/hooks/agent-model.ts +149 -0
- package/src/hooks/budget-signal.ts +284 -0
- package/src/hooks/context.ts +461 -0
- package/src/hooks/cwd-normalize.ts +79 -0
- package/src/hooks/deadline.ts +228 -0
- package/src/hooks/delivery-verbs.ts +182 -0
- package/src/hooks/descriptor.ts +124 -0
- package/src/hooks/handle.ts +433 -0
- package/src/hooks/heavy-gate.ts +99 -0
- package/src/hooks/hook-runs.ts +130 -0
- package/src/hooks/hosts/claude-code.ts +380 -0
- package/src/hooks/hosts/codex.ts +372 -0
- package/src/hooks/hosts/cursor.ts +419 -0
- package/src/hooks/hosts/fields.ts +98 -0
- package/src/hooks/hosts/opencode.ts +79 -0
- package/src/hooks/hosts/pi.ts +78 -0
- package/src/hooks/index.ts +60 -0
- package/src/hooks/model-limit.ts +378 -0
- package/src/hooks/model-switch.ts +35 -0
- package/src/hooks/policy.ts +29 -0
- package/src/hooks/process.ts +48 -0
- package/src/hooks/protocol.ts +135 -0
- package/src/hooks/raw-git.ts +2289 -0
- package/src/hooks/repeat-failure.ts +115 -0
- package/src/hooks/run.ts +54 -0
- package/src/hooks/session-marker.ts +151 -0
- package/src/hooks/shell-words.ts +532 -0
- package/src/hooks/skill-nudge.ts +295 -0
- package/src/hooks/spawn-gate.ts +72 -0
- package/src/hooks/standing-signal.ts +99 -0
- package/src/hooks/standing.ts +225 -0
- package/src/hooks/steering.ts +74 -0
- package/src/hooks/stop.ts +408 -0
- package/src/hooks/worktree-confine.ts +415 -0
- package/src/hooks/write-gate.ts +1174 -0
- package/src/host-session.ts +459 -0
- package/src/knowledge.ts +368 -0
- package/src/ledger.ts +2913 -0
- package/src/stack.ts +2570 -0
- package/src/store/event-log.ts +217 -0
- package/src/store/notes.ts +29 -0
- package/src/store/patch.ts +135 -0
- package/src/store/paths.ts +368 -0
- package/src/store/reduce.ts +203 -0
- package/src/test-audit-mocks.ts +309 -0
- package/src/verdict-cover.ts +151 -0
- package/src/worktree-deps.ts +142 -0
- package/templates/headers.md +3 -0
- package/templates/hygiene/.editorconfig +8 -0
- package/templates/hygiene/.gitattributes +3 -0
- package/templates/hygiene/CHANGELOG.md +14 -0
- package/templates/hygiene/CONTRIBUTING.md +3 -0
- package/templates/hygiene/LICENSE +21 -0
- package/templates/hygiene/README.md +3 -0
- package/templates/issue-update.md +5 -0
- package/templates/plan-template.md +26 -0
- package/templates/spec-template.md +64 -0
- 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
|
+
}
|