@brainervirus/workit-core 2.1.5 → 2.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brainervirus/workit-core",
3
- "version": "2.1.5",
3
+ "version": "2.2.0",
4
4
  "private": false,
5
5
  "description": "Workit shared core — task, policy, evidence, review, decision, worker, and writer state for agentic coding workflows",
6
6
  "keywords": [
@@ -36,6 +36,7 @@
36
36
  "type": "module",
37
37
  "main": "./src/core.ts",
38
38
  "exports": {
39
+ "./hooks": "./src/hooks/index.ts",
39
40
  "./src/*.ts": "./src/*.ts",
40
41
  "./src/*": "./src/*.ts",
41
42
  "./package.json": "./package.json"
@@ -19,6 +19,7 @@ import {
19
19
  import os from "node:os";
20
20
  import path from "node:path";
21
21
  import { SUPPORT_MATRIX } from "./support-matrix";
22
+ import { inspectMetadataLock } from "./store-lock";
22
23
  import { bundleHashOfFile, isEphemeralCachePath } from "./runtime-identity";
23
24
  import { EVENT } from "./boundary";
24
25
  import { getDiagnosticLogger, isConfigObject } from "./config";
@@ -61,6 +62,7 @@ export type DoctorCheckId =
61
62
  | "duplicate_registration"
62
63
  | "malformed_config"
63
64
  | "workspace_mismatch"
65
+ | "workspace_lock"
64
66
  | "credential_metadata"
65
67
  | "github_identity"
66
68
  | "gitlab_identity"
@@ -110,6 +112,8 @@ export type DoctorOptions = {
110
112
  /** Checkout containing packages/ (monorepo or share clone). */
111
113
  dev?: string;
112
114
  cwd?: string;
115
+ /** Workit store root for the lock check (default: WORKFLOW_WORKSPACE_ROOT, then cwd). */
116
+ workspaceRoot?: string;
113
117
  opencodeConfig?: string;
114
118
  /** OpenCode npm `@latest` package cache root (test seam). */
115
119
  opencodePackageCacheDir?: string;
@@ -129,6 +133,7 @@ type Resolved = {
129
133
  configDir: string;
130
134
  stateDir: string;
131
135
  cwd: string;
136
+ workspaceRoot: string;
132
137
  dev: string | null;
133
138
  opencodeConfig: string;
134
139
  opencodePackageCacheDir: string;
@@ -179,6 +184,7 @@ const resolve = (options: DoctorOptions): Resolved => {
179
184
  configDir,
180
185
  stateDir,
181
186
  cwd,
187
+ workspaceRoot: options.workspaceRoot ?? env.WORKFLOW_WORKSPACE_ROOT ?? cwd,
182
188
  dev,
183
189
  opencodeConfig:
184
190
  options.opencodeConfig ?? path.join(home, ".config", "opencode", "opencode.json"),
@@ -1693,6 +1699,44 @@ const checkManagedContentConflict = (res: Resolved): DoctorCheck => {
1693
1699
  };
1694
1700
  };
1695
1701
 
1702
+ // The checkout's `.workit/metadata.lock`. Writes reclaim a stale lock by
1703
+ // themselves, so a stale lock is a warning with an explicit cleanup command.
1704
+ const BLOCKING_LOCK_WARN_MS = 30_000;
1705
+ const checkWorkspaceLock = (res: Resolved): DoctorCheck => {
1706
+ const lock = inspectMetadataLock(res.workspaceRoot);
1707
+ const fix = "workit doctor --fix-lock";
1708
+ if (lock.guard === "abandoned")
1709
+ return {
1710
+ id: "workspace_lock",
1711
+ status: "warn",
1712
+ detail: `abandoned lock reclaim guard at ${lock.path}.reclaim`,
1713
+ fix,
1714
+ };
1715
+ if (lock.state === "absent")
1716
+ return { id: "workspace_lock", status: "pass", detail: "no metadata lock held" };
1717
+ if (lock.state === "stale")
1718
+ return {
1719
+ id: "workspace_lock",
1720
+ status: "warn",
1721
+ detail: `stale metadata lock at ${lock.path}: ${lock.reason}`,
1722
+ fix,
1723
+ };
1724
+ // An unverifiable owner (other host, pid namespace, or an older Workit's
1725
+ // lock) that has blocked writes this long needs an explicit decision.
1726
+ if (lock.state === "unknown" && (lock.ageMs ?? 0) > BLOCKING_LOCK_WARN_MS)
1727
+ return {
1728
+ id: "workspace_lock",
1729
+ status: "warn",
1730
+ detail: `metadata lock at ${lock.path} has blocked writes for ${Math.round((lock.ageMs ?? 0) / 1000)}s and its owner cannot be verified: ${lock.reason}`,
1731
+ fix: "workit doctor --fix-lock --force --yes",
1732
+ };
1733
+ return {
1734
+ id: "workspace_lock",
1735
+ status: "pass",
1736
+ detail: `metadata lock ${lock.reason} (writes retry, then report busy)`,
1737
+ };
1738
+ };
1739
+
1696
1740
  const RUN_CHECKS: Array<(res: Resolved) => DoctorCheck> = [
1697
1741
  checkRuntime,
1698
1742
  checkVersions,
@@ -1705,6 +1749,7 @@ const RUN_CHECKS: Array<(res: Resolved) => DoctorCheck> = [
1705
1749
  checkDuplicateRegistration,
1706
1750
  checkMalformedConfig,
1707
1751
  checkWorkspaceMismatch,
1752
+ checkWorkspaceLock,
1708
1753
  checkCredentialMetadata,
1709
1754
  checkGithubIdentity,
1710
1755
  checkGitLabIdentity,
@@ -110,6 +110,11 @@ Start a record once for an explicit tracked objective; assess or reassess only
110
110
  when policy selection or changed evidence/constraints requires it. Omitted
111
111
  expectedRevision and expectedWorkspaceRevision use current values; explicit
112
112
  values are still concurrency-checked, so never copy revisions between calls.
113
+ A busy result means another live Workit call holds the checkout lock: retry the
114
+ same call; it is not a recovery condition. A lock left by a dead process is
115
+ reclaimed on the next write, and \`workit doctor --fix-lock\` clears it on demand.
116
+ A revision_conflict on a call that omitted expectedRevision is contention too:
117
+ re-read the record and retry the call.
113
118
  A solo edit does not need writer acquisition; use it when concurrent checkout
114
119
  writers need coordination. Record only observed facts and checks. Evidence can
115
120
  become stale when its bound candidate changes; reconcile findings against the
@@ -0,0 +1,301 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import { hostname } from "node:os";
4
+ import path from "node:path";
5
+ import * as z from "zod";
6
+ import { canonicalJson } from "./task-contract";
7
+
8
+ /**
9
+ * Ownership rules for a checkout's `.workit/metadata.lock`.
10
+ *
11
+ * The lock is a short mutex around one store mutation (or one managed effect).
12
+ * A lock whose owner is gone is reclaimed automatically; a lock whose owner is
13
+ * alive is contention, which callers report as retryable `busy`.
14
+ */
15
+
16
+ export type MetadataLock = {
17
+ pid: number;
18
+ processStart: string | null;
19
+ host: string;
20
+ nonce: string;
21
+ externalAction?: true;
22
+ };
23
+
24
+ const metadataLockSchema = z
25
+ .object({
26
+ pid: z.number().int().nonnegative().safe(),
27
+ processStart: z.string().nullable(),
28
+ host: z.string().min(1),
29
+ nonce: z.string().min(1),
30
+ externalAction: z.literal(true).optional(),
31
+ })
32
+ .strict();
33
+
34
+ /** A lock from another host cannot be checked for liveness; trust it this long. */
35
+ export const FOREIGN_LOCK_TTL_MS = 10 * 60_000;
36
+ /** An empty or unparseable lock is a writer mid-create; after this it is debris. */
37
+ export const UNREADABLE_LOCK_TTL_MS = 30_000;
38
+ /** A reclaim guard lives for microseconds; one older than this was abandoned. */
39
+ export const RECLAIM_GUARD_TTL_MS = 30_000;
40
+ /**
41
+ * Total time a mutation waits for a live holder before returning `busy`. The
42
+ * wait blocks the calling thread, so in-process hosts (OpenCode, MCP, Pi) keep
43
+ * the short default; the CLI raises it for its own process.
44
+ */
45
+ let defaultLockTimeoutMs = 250;
46
+ export const defaultLockTimeout = (): number => defaultLockTimeoutMs;
47
+ export const setDefaultLockTimeout = (ms: number): void => {
48
+ defaultLockTimeoutMs = ms;
49
+ };
50
+
51
+ export const parseMetadataLock = (raw: string): MetadataLock => {
52
+ let value: unknown;
53
+ try {
54
+ value = JSON.parse(raw);
55
+ } catch {
56
+ throw Object.assign(new Error("metadata lock is invalid"), { code: "metadata_lock_invalid" });
57
+ }
58
+ const parsed = metadataLockSchema.safeParse(value);
59
+ if (!parsed.success)
60
+ throw Object.assign(new Error("metadata lock is invalid"), { code: "metadata_lock_invalid" });
61
+ return parsed.data;
62
+ };
63
+
64
+ /** Lenient variant for the acquire loop: an unreadable lock is classified by age. */
65
+ export const parseMetadataLockOrNull = (raw: string): MetadataLock | null => {
66
+ try {
67
+ return parseMetadataLock(raw);
68
+ } catch {
69
+ return null;
70
+ }
71
+ };
72
+
73
+ export const sameMetadataLock = (left: unknown, right: MetadataLock): boolean => {
74
+ const parsed = metadataLockSchema.safeParse(left);
75
+ return parsed.success && canonicalJson(parsed.data) === canonicalJson(right);
76
+ };
77
+
78
+ /** Field 22 (starttime) of /proc/<pid>/stat; parsed after the last ")" so a comm with spaces cannot shift it. */
79
+ export const parseProcStatStart = (stat: string): string | null => {
80
+ const close = stat.lastIndexOf(")");
81
+ if (close < 0) return null;
82
+ // After ")": field 3 (state) is index 0, so field 22 is index 19.
83
+ return (
84
+ stat
85
+ .slice(close + 1)
86
+ .trim()
87
+ .split(/\s+/)[19] ?? null
88
+ );
89
+ };
90
+
91
+ export const processStartOf = (pid: number): string | null => {
92
+ if (process.platform === "linux") {
93
+ try {
94
+ return parseProcStatStart(fs.readFileSync(`/proc/${pid}/stat`, "utf8"));
95
+ } catch {
96
+ return null;
97
+ }
98
+ }
99
+ if (process.platform === "darwin" || process.platform === "freebsd") {
100
+ try {
101
+ const run = spawnSync("ps", ["-o", "lstart=", "-p", String(pid)], {
102
+ encoding: "utf8",
103
+ timeout: 1_000,
104
+ });
105
+ const value = run.status === 0 ? run.stdout.trim() : "";
106
+ return value || null;
107
+ } catch {
108
+ return null;
109
+ }
110
+ }
111
+ return null;
112
+ };
113
+
114
+ const readTrimmed = (file: string): string | null => {
115
+ try {
116
+ return fs.readFileSync(file, "utf8").trim() || null;
117
+ } catch {
118
+ return null;
119
+ }
120
+ };
121
+
122
+ /**
123
+ * Identity of the pid space this process lives in: hostname plus, on Linux,
124
+ * the pid-namespace inode and boot id. Containers that share the hostname
125
+ * (`--network host`) but not the pid namespace get a different identity, so
126
+ * their pids are never checked against this process table. Folded into the
127
+ * existing `host` string so older Workit versions still parse the lock.
128
+ */
129
+ let cachedLockHost: string | null = null;
130
+ export const localLockHost = (): string => {
131
+ if (cachedLockHost !== null) return cachedLockHost;
132
+ let pidns: string | null = null;
133
+ try {
134
+ pidns = /\[(\d+)\]/.exec(fs.readlinkSync("/proc/self/ns/pid"))?.[1] ?? null;
135
+ } catch {}
136
+ const boot = readTrimmed("/proc/sys/kernel/random/boot_id");
137
+ cachedLockHost = pidns || boot ? `${hostname()}#${pidns ?? "?"}:${boot ?? "?"}` : hostname();
138
+ return cachedLockHost;
139
+ };
140
+
141
+ const pidAlive = (pid: number): boolean => {
142
+ if (!Number.isSafeInteger(pid) || pid <= 0) return false;
143
+ try {
144
+ process.kill(pid, 0);
145
+ return true;
146
+ } catch (error) {
147
+ // EPERM: the process exists but belongs to another user.
148
+ return (error as { code?: unknown }).code === "EPERM";
149
+ }
150
+ };
151
+
152
+ export type LockOwnerState = {
153
+ /** live: wait. stale: reclaim. unknown: wait (cannot prove the owner is gone). */
154
+ state: "live" | "stale" | "unknown";
155
+ reason: string;
156
+ };
157
+
158
+ export const classifyLockOwner = (
159
+ payload: unknown,
160
+ ageMs: number | null,
161
+ localHost: string = localLockHost(),
162
+ ): LockOwnerState => {
163
+ const lock = metadataLockSchema.safeParse(payload);
164
+ if (!lock.success)
165
+ return ageMs !== null && ageMs > UNREADABLE_LOCK_TTL_MS
166
+ ? { state: "stale", reason: "unreadable lock left behind" }
167
+ : { state: "unknown", reason: "lock is being written" };
168
+ const { pid, processStart, host } = lock.data;
169
+ // Same host and pid namespace but another boot: the machine rebooted since
170
+ // the lock was taken, so its owner cannot still be running.
171
+ const [lockName, lockSpace] = host.split("#");
172
+ const [localName, localSpace] = localHost.split("#");
173
+ if (lockSpace && localSpace && lockName === localName) {
174
+ const [lockNs, lockBoot] = lockSpace.split(":");
175
+ const [localNs, localBoot] = localSpace.split(":");
176
+ if (lockNs === localNs && lockNs !== "?" && lockBoot !== "?" && lockBoot !== localBoot)
177
+ return { state: "stale", reason: "lock was taken before this machine rebooted" };
178
+ }
179
+ // Another host, another pid namespace, or a lock written by an older Workit
180
+ // without namespace identity: its pid cannot be checked here.
181
+ if (host !== localHost)
182
+ return ageMs !== null && ageMs > FOREIGN_LOCK_TTL_MS
183
+ ? { state: "stale", reason: `lock from ${host} is older than its TTL` }
184
+ : { state: "unknown", reason: `lock is held from ${host}; its pid cannot be checked here` };
185
+ if (!pidAlive(pid)) return { state: "stale", reason: `pid ${pid} is not running` };
186
+ const currentStart = processStartOf(pid);
187
+ if (processStart !== null && currentStart !== null && processStart !== currentStart)
188
+ return { state: "stale", reason: `pid ${pid} now belongs to a different process` };
189
+ return { state: "live", reason: `held by running pid ${pid}` };
190
+ };
191
+
192
+ const ageOf = (file: string, nowMs: number): number | null => {
193
+ try {
194
+ return nowMs - fs.lstatSync(file).mtimeMs;
195
+ } catch {
196
+ return null;
197
+ }
198
+ };
199
+
200
+ export const lockPathFor = (root: string) => path.join(root, ".workit", "metadata.lock");
201
+
202
+ /** Remove a reclaim guard abandoned by a crashed reclaimer. Returns true when removed. */
203
+ export const clearAbandonedReclaimGuard = (lockPath: string, nowMs = Date.now()): boolean => {
204
+ const guard = `${lockPath}.reclaim`;
205
+ const age = ageOf(guard, nowMs);
206
+ if (age === null || age <= RECLAIM_GUARD_TTL_MS) return false;
207
+ try {
208
+ fs.rmdirSync(guard);
209
+ return true;
210
+ } catch {
211
+ return false;
212
+ }
213
+ };
214
+
215
+ export type MetadataLockStatus = {
216
+ path: string;
217
+ present: boolean;
218
+ owner: MetadataLock | null;
219
+ state: LockOwnerState["state"] | "absent";
220
+ reason: string;
221
+ guard: "absent" | "fresh" | "abandoned";
222
+ /** Exact lock bytes that were classified (for compare-before-remove). */
223
+ raw?: string;
224
+ /** Age of the lock file in milliseconds, when present. */
225
+ ageMs?: number | null;
226
+ };
227
+
228
+ /** Read-only inspection of a checkout's metadata lock (doctor surface). */
229
+ export const inspectMetadataLock = (root: string, nowMs = Date.now()): MetadataLockStatus => {
230
+ const lockPath = lockPathFor(root);
231
+ const guardAge = ageOf(`${lockPath}.reclaim`, nowMs);
232
+ const guard =
233
+ guardAge === null ? "absent" : guardAge > RECLAIM_GUARD_TTL_MS ? "abandoned" : "fresh";
234
+ let raw: string;
235
+ try {
236
+ raw = fs.readFileSync(lockPath, "utf8");
237
+ } catch {
238
+ return {
239
+ path: lockPath,
240
+ present: false,
241
+ owner: null,
242
+ state: "absent",
243
+ reason: "no lock",
244
+ guard,
245
+ };
246
+ }
247
+ const owner = parseMetadataLockOrNull(raw);
248
+ const ageMs = ageOf(lockPath, nowMs);
249
+ const verdict = classifyLockOwner(owner, ageMs);
250
+ return { path: lockPath, present: true, owner, ...verdict, guard, raw, ageMs };
251
+ };
252
+
253
+ export type ClearLockOutcome = MetadataLockStatus & {
254
+ cleared: boolean;
255
+ guardCleared: boolean;
256
+ /** Why the lock was kept, when it was present and not cleared. */
257
+ skipped?: string;
258
+ };
259
+
260
+ /**
261
+ * Clear a stale metadata lock (or, with `force`, any lock) and an abandoned
262
+ * reclaim guard. Removal holds the same `.reclaim` guard that writers take
263
+ * before reclaiming, so no writer can replace the lock between the final
264
+ * byte check and the unlink; a fresh guard means a reclaim is already in
265
+ * progress and the lock is left alone.
266
+ */
267
+ export const clearStaleMetadataLock = (
268
+ root: string,
269
+ options: { force?: boolean; nowMs?: number } = {},
270
+ ): ClearLockOutcome => {
271
+ const nowMs = options.nowMs ?? Date.now();
272
+ const status = inspectMetadataLock(root, nowMs);
273
+ const guardCleared = status.guard === "abandoned" && clearAbandonedReclaimGuard(status.path);
274
+ const outcome = { ...status, cleared: false, guardCleared };
275
+ if (!status.present) return outcome;
276
+ if (status.state !== "stale" && !options.force) return { ...outcome, skipped: status.reason };
277
+ const guard = `${status.path}.reclaim`;
278
+ try {
279
+ fs.mkdirSync(guard);
280
+ } catch {
281
+ return { ...outcome, skipped: "a reclaim is in progress" };
282
+ }
283
+ try {
284
+ const before = fs.readFileSync(status.path, "utf8");
285
+ if (before !== status.raw) return { ...outcome, skipped: "the lock changed" };
286
+ if (!options.force) {
287
+ const verdict = classifyLockOwner(parseMetadataLockOrNull(before), ageOf(status.path, nowMs));
288
+ if (verdict.state !== "stale") return { ...outcome, skipped: verdict.reason };
289
+ }
290
+ if (fs.readFileSync(status.path, "utf8") !== before)
291
+ return { ...outcome, skipped: "the lock changed" };
292
+ fs.rmSync(status.path);
293
+ return { ...outcome, cleared: true };
294
+ } catch (error) {
295
+ return { ...outcome, skipped: `could not clear: ${String(error)}` };
296
+ } finally {
297
+ try {
298
+ fs.rmdirSync(guard);
299
+ } catch {}
300
+ }
301
+ };
@@ -84,6 +84,7 @@ export const hostSchema = z.enum([
84
84
  "codex_desktop",
85
85
  "pi",
86
86
  "workit_cli",
87
+ "claude_code",
87
88
  ]);
88
89
  export type Host = z.infer<typeof hostSchema>;
89
90
  export const assuranceSchema = z.enum(["enforced", "agent_guided", "unavailable"]);
@@ -746,9 +747,111 @@ export const taskRecordSchema = z
746
747
  actionProgress: actionProgressListSchema.optional(),
747
748
  findings: z.array(entrySchema(findingSchema)),
748
749
  workers: z.array(entrySchema(workerSchema)),
750
+ /** Paths a reader must understand; see parseStoredRecord. */
751
+ critical: z.array(nonEmpty).optional(),
749
752
  })
750
753
  .strict();
751
754
  export type TaskRecord = z.infer<typeof taskRecordSchema>;
755
+
756
+ type Strip = { path: PropertyKey[]; keys: string[] };
757
+ const stripCount = (strips: Strip[]) => strips.reduce((sum, strip) => sum + strip.keys.length, 0);
758
+ /** Unknown-key issues only, flattened; null when any issue is a real schema
759
+ * violation. For a union, the branch that strips the fewest keys wins, so a
760
+ * key one branch knows is never dropped in favor of a narrower branch. */
761
+ const strippable = (
762
+ issues: readonly z.core.$ZodIssue[],
763
+ prefix: PropertyKey[] = [],
764
+ ): Strip[] | null => {
765
+ const strips: Strip[] = [];
766
+ for (const issue of issues) {
767
+ if (issue.code === "unrecognized_keys") {
768
+ strips.push({ path: [...prefix, ...issue.path], keys: issue.keys });
769
+ continue;
770
+ }
771
+ if (issue.code !== "invalid_union") return null;
772
+ const branch = issue.errors
773
+ .map((errors) => strippable(errors, [...prefix, ...issue.path]))
774
+ .filter((found): found is Strip[] => found !== null && found.length > 0)
775
+ .reduce<Strip[] | null>(
776
+ (best, found) => (best === null || stripCount(found) < stripCount(best) ? found : best),
777
+ null,
778
+ );
779
+ if (!branch) return null;
780
+ strips.push(...branch);
781
+ }
782
+ return strips;
783
+ };
784
+
785
+ /** A dotted record path with array indices as `*`, e.g. `evidence.*.data.observer`. */
786
+ const recordPath = (path: PropertyKey[]): string =>
787
+ path.map((key) => (typeof key === "number" ? "*" : String(key))).join(".");
788
+ const overlaps = (left: string, right: string) =>
789
+ left === right || left.startsWith(`${right}.`) || right.startsWith(`${left}.`);
790
+
791
+ export type StoredRecordParse<T> =
792
+ | { success: true; data: T; stripped: string[] }
793
+ | { success: false; error: z.ZodError; critical: string[] };
794
+
795
+ /**
796
+ * Reader tolerance for stored records (D17). A record written by a newer
797
+ * runtime may carry keys this reader does not know; exactly the keys zod
798
+ * reports as unrecognized are dropped and the value is parsed again, and
799
+ * every other violation still fails. Writes keep parsing strictly.
800
+ *
801
+ * The rule for new record fields: a field must be safe for an older reader
802
+ * to ignore (and to lose when that reader rewrites the record), or the writer
803
+ * must list its path in the record's top-level `critical` array. A reader
804
+ * that would strip a critical path fails closed instead (`critical` names the
805
+ * paths), so it neither acts on nor rewrites a record it cannot represent.
806
+ */
807
+ export const parseStoredRecord = <S extends z.ZodType>(
808
+ schema: S,
809
+ value: unknown,
810
+ ): StoredRecordParse<z.output<S>> => {
811
+ let parsed = schema.safeParse(value);
812
+ if (parsed.success) return { success: true, data: parsed.data, stripped: [] };
813
+ const first = { success: false as const, error: parsed.error, critical: [] as string[] };
814
+ const declared =
815
+ typeof value === "object" &&
816
+ value !== null &&
817
+ Array.isArray((value as { critical?: unknown }).critical)
818
+ ? (value as { critical: unknown[] }).critical
819
+ .filter((item): item is string => typeof item === "string")
820
+ // An array index in a declaration means any element, like `*`.
821
+ .map((item) =>
822
+ item
823
+ .split(".")
824
+ .map((key) => (/^\d+$/.test(key) ? "*" : key))
825
+ .join("."),
826
+ )
827
+ : [];
828
+ const stripped: string[] = [];
829
+ let current: unknown = structuredClone(value);
830
+ for (let round = 0; round < 8 && !parsed.success; round++) {
831
+ const strips = strippable(parsed.error.issues);
832
+ if (strips === null || strips.length === 0) return first;
833
+ for (const strip of strips) {
834
+ let target: unknown = current;
835
+ for (const key of strip.path)
836
+ target =
837
+ typeof target === "object" && target !== null
838
+ ? (target as Record<PropertyKey, unknown>)[key]
839
+ : undefined;
840
+ if (typeof target !== "object" || target === null) return first;
841
+ for (const key of strip.keys) {
842
+ stripped.push(recordPath([...strip.path, key]));
843
+ delete (target as Record<string, unknown>)[key];
844
+ }
845
+ }
846
+ parsed = schema.safeParse(current);
847
+ }
848
+ if (!parsed.success) return first;
849
+ const critical = [
850
+ ...new Set(stripped.filter((item) => declared.some((path) => overlaps(item, path)))),
851
+ ];
852
+ if (critical.length > 0) return { ...first, critical };
853
+ return { success: true, data: parsed.data, stripped: [...new Set(stripped)] };
854
+ };
752
855
  export const workspaceRecordSchema = z
753
856
  .object({
754
857
  schemaVersion: z.literal(1),
@@ -760,6 +863,8 @@ export const workspaceRecordSchema = z
760
863
  .object({ state: z.enum(["held", "uncertain"]), owner: ownerSchema, acquiredAt: utc })
761
864
  .strict()
762
865
  .nullable(),
866
+ /** Paths a reader must understand; see parseStoredRecord. */
867
+ critical: z.array(nonEmpty).optional(),
763
868
  })
764
869
  .strict();
765
870
  export type WorkspaceRecord = z.infer<typeof workspaceRecordSchema>;
@@ -1026,6 +1131,19 @@ export const operationSchemas = {
1026
1131
  state: z.discriminatedUnion("action", Object.values(stateOperations) as any),
1027
1132
  } as const;
1028
1133
  export type OperationRequest = z.infer<(typeof operationSchemas)[OperationFamily]>;
1134
+
1135
+ /**
1136
+ * Schemas advertised to hosts. `state.recover` needs host-supplied native
1137
+ * recovery authority (`OperationContext.nativeRecovery`), which no shipped
1138
+ * host provides, so advertising it only sends agents into a guaranteed
1139
+ * permission_denied. parseOperation still accepts it for embedders that do
1140
+ * supply that authority.
1141
+ */
1142
+ const { recover: _unadvertisedRecover, ...advertisedStateOperations } = stateOperations;
1143
+ export const advertisedOperationSchemas = {
1144
+ ...operationSchemas,
1145
+ state: z.discriminatedUnion("action", Object.values(advertisedStateOperations) as any),
1146
+ } as const;
1029
1147
  export type TaskStartRequest = z.infer<typeof taskOperations.start>;
1030
1148
 
1031
1149
  const compiledOperationSchemas = Object.fromEntries(
@@ -1048,6 +1166,8 @@ export type ErrorCode =
1048
1166
  | "capability_unavailable"
1049
1167
  | "requirements_unsatisfied"
1050
1168
  | "writer_conflict"
1169
+ /** Retryable: another live Workit call holds the checkout's metadata lock. */
1170
+ | "busy"
1051
1171
  | "recovery_required"
1052
1172
  | "storage_error"
1053
1173
  | "external_outcome_unknown";
@@ -1127,7 +1247,7 @@ export function parseOperation(family: OperationFamily, input: unknown): Result<
1127
1247
  }
1128
1248
 
1129
1249
  export function operationJsonSchema(family: OperationFamily): z.core.JSONSchema.BaseSchema {
1130
- return z.toJSONSchema(operationSchemas[family], { target: "draft-2020-12" });
1250
+ return z.toJSONSchema(advertisedOperationSchemas[family], { target: "draft-2020-12" });
1131
1251
  }
1132
1252
 
1133
1253
  /**