@brainervirus/workit-core 2.1.4 → 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 +3 -2
- package/scripts/analyze-release-scope.ts +3 -3
- package/scripts/doctor-check.ts +1 -2
- package/scripts/sync-release-manifests.ts +4 -4
- package/src/core/authority.ts +8 -8
- package/src/core/auto-approval.ts +6 -6
- package/src/core/branch.ts +4 -4
- package/src/core/config-conversion.ts +1 -1
- package/src/core/config.ts +3 -3
- package/src/core/cutover.ts +6 -6
- package/src/core/doctor.ts +49 -3
- package/src/core/external-action-effects.ts +15 -15
- package/src/core/external-action.ts +16 -19
- package/src/core/logger.ts +2 -2
- package/src/core/methods.ts +5 -0
- package/src/core/policy-resolver.ts +9 -6
- package/src/core/registration.ts +3 -5
- package/src/core/repo-context.ts +1 -1
- package/src/core/setup.ts +2 -2
- package/src/core/skill-manifests.ts +1 -1
- package/src/core/store-lock.ts +301 -0
- package/src/core/support-matrix.ts +1 -1
- package/src/core/task-context.ts +3 -3
- package/src/core/task-contract.ts +129 -9
- package/src/core/task-engine.ts +159 -159
- package/src/core/task-evaluation.ts +3 -3
- package/src/core/task-store.ts +407 -89
- package/src/core/tracker-issues.ts +1 -1
- package/src/core/uninstall.ts +3 -3
- package/src/core/workers.ts +3 -3
- package/src/core/workspaces.ts +4 -6
- package/src/core/youtrack-tools.ts +1 -1
- package/src/core.ts +1 -1
- package/src/git/rev.ts +736 -0
- package/src/{core/session-context.ts → hooks/context.ts} +97 -10
- package/src/hooks/descriptor.ts +113 -0
- package/src/hooks/handle.ts +111 -0
- package/src/hooks/hosts/claude-code.ts +281 -0
- package/src/hooks/hosts/codex.ts +298 -0
- package/src/hooks/hosts/cursor.ts +321 -0
- package/src/hooks/hosts/fields.ts +55 -0
- package/src/hooks/hosts/opencode.ts +79 -0
- package/src/hooks/hosts/pi.ts +80 -0
- package/src/hooks/index.ts +43 -0
- package/src/hooks/policy.ts +14 -0
- package/src/hooks/protocol.ts +89 -0
- package/src/hooks/run.ts +52 -0
|
@@ -76,10 +76,13 @@ const compareCodeUnits = (left: string, right: string): number => {
|
|
|
76
76
|
return left.length - right.length;
|
|
77
77
|
};
|
|
78
78
|
|
|
79
|
-
const stableUnique = (values: string[]): string[] =>
|
|
79
|
+
const stableUnique = (values: string[]): string[] =>
|
|
80
|
+
[...new Set(values)].toSorted(compareCodeUnits);
|
|
80
81
|
|
|
81
82
|
const stableList = <T>(values: T[]): T[] =>
|
|
82
|
-
[...values].
|
|
83
|
+
[...values].toSorted((left, right) =>
|
|
84
|
+
compareCodeUnits(canonicalJson(left), canonicalJson(right)),
|
|
85
|
+
);
|
|
83
86
|
|
|
84
87
|
const normalizedScope = (scope: Scope): Scope => ({
|
|
85
88
|
description: scope.description,
|
|
@@ -463,10 +466,10 @@ export function diffPolicy(
|
|
|
463
466
|
if (!policySchema.safeParse(previous).success && previous !== null)
|
|
464
467
|
throw new TypeError("previous policy is invalid");
|
|
465
468
|
if (!policySchema.safeParse(next).success) throw new TypeError("next policy is invalid");
|
|
466
|
-
const oldIds = new Set(previous?.requirements.map((
|
|
467
|
-
const newIds = new Set(next.requirements.map((
|
|
468
|
-
const added = [...newIds].filter((id) => !oldIds.has(id)).
|
|
469
|
-
const retired = [...oldIds].filter((id) => !newIds.has(id)).
|
|
469
|
+
const oldIds = new Set(previous?.requirements.map((item) => item.id) ?? []);
|
|
470
|
+
const newIds = new Set(next.requirements.map((item) => item.id));
|
|
471
|
+
const added = [...newIds].filter((id) => !oldIds.has(id)).toSorted(compareCodeUnits);
|
|
472
|
+
const retired = [...oldIds].filter((id) => !newIds.has(id)).toSorted(compareCodeUnits);
|
|
470
473
|
if (added.length === 0 && retired.length === 0) return null;
|
|
471
474
|
const normalizedReason = reason.trim();
|
|
472
475
|
const changeReason = retired.length
|
package/src/core/registration.ts
CHANGED
|
@@ -138,9 +138,7 @@ export function mergeCursorMcp(
|
|
|
138
138
|
server: Record<string, unknown>,
|
|
139
139
|
): MergeResult<Record<string, unknown>> {
|
|
140
140
|
const base = isRecord(mcp) ? { ...mcp } : {};
|
|
141
|
-
const servers = isRecord(base.mcpServers)
|
|
142
|
-
? { ...(base.mcpServers as Record<string, unknown>) }
|
|
143
|
-
: {};
|
|
141
|
+
const servers = isRecord(base.mcpServers) ? { ...base.mcpServers } : {};
|
|
144
142
|
delete servers["workflow-toolkit"]; // legacy duplicate registration
|
|
145
143
|
servers[serverName] = server;
|
|
146
144
|
const changed = JSON.stringify(servers) !== JSON.stringify(base.mcpServers) ? ["mcpServers"] : [];
|
|
@@ -157,7 +155,7 @@ export function mergeCursorHooks(
|
|
|
157
155
|
sessionStartEntry: Record<string, unknown>,
|
|
158
156
|
): MergeResult<Record<string, unknown>> {
|
|
159
157
|
const base: Record<string, unknown> = isRecord(hooks) ? { ...hooks } : { version: 1 };
|
|
160
|
-
const hooksMap = isRecord(base.hooks) ? { ...
|
|
158
|
+
const hooksMap = isRecord(base.hooks) ? { ...base.hooks } : {};
|
|
161
159
|
const changed: string[] = [];
|
|
162
160
|
const list = Array.isArray(hooksMap.sessionStart) ? hooksMap.sessionStart : [];
|
|
163
161
|
const same =
|
|
@@ -223,7 +221,7 @@ const canonicalHookEntry = (
|
|
|
223
221
|
*/
|
|
224
222
|
export function cursorHookDrift(installed: unknown): string[] {
|
|
225
223
|
if (!isRecord(installed) || !isRecord(installed.hooks)) return ["hooks file is not a hook map"];
|
|
226
|
-
const hooks = installed.hooks
|
|
224
|
+
const hooks = installed.hooks;
|
|
227
225
|
const drift: string[] = [];
|
|
228
226
|
for (const event of ["preToolUse", "beforeShellExecution"] as const) {
|
|
229
227
|
const list = hooks[event];
|
package/src/core/repo-context.ts
CHANGED
|
@@ -258,7 +258,7 @@ export const documentationFiles = (cwd: string): string[] => {
|
|
|
258
258
|
}
|
|
259
259
|
};
|
|
260
260
|
walk(cwd, 1);
|
|
261
|
-
return matches.
|
|
261
|
+
return matches.toSorted().slice(0, 200);
|
|
262
262
|
};
|
|
263
263
|
|
|
264
264
|
const readTrimmed = (file: string, maxLines: number): string => {
|
package/src/core/setup.ts
CHANGED
|
@@ -506,13 +506,13 @@ type ResolvedApply = {
|
|
|
506
506
|
// the same options (defaulting both from env) — tests pass `home` explicitly.
|
|
507
507
|
const resolveSetupPaths = (
|
|
508
508
|
options: ApplySetupOptions,
|
|
509
|
-
|
|
509
|
+
resolvedConfigDir: string,
|
|
510
510
|
): Omit<ResolvedApply, "dev"> => {
|
|
511
511
|
const env = options.env ?? process.env;
|
|
512
512
|
const home = options.home ?? env.HOME ?? os.homedir();
|
|
513
513
|
return {
|
|
514
514
|
home,
|
|
515
|
-
configDir,
|
|
515
|
+
configDir: resolvedConfigDir,
|
|
516
516
|
cwd: options.cwd ?? process.cwd(),
|
|
517
517
|
env,
|
|
518
518
|
opencodeConfig:
|
|
@@ -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
|
+
};
|
|
@@ -10,7 +10,7 @@ export const SUPPORT_MATRIX = {
|
|
|
10
10
|
// The object-form dual entry (server() + setup()) only loads from
|
|
11
11
|
// 1.18.29+; the supported floor is 1.18.30 so no host can pass doctor while
|
|
12
12
|
// being unable to load the entry shape.
|
|
13
|
-
opencode: { minimum: "1.18.30", current: "1.18.
|
|
13
|
+
opencode: { minimum: "1.18.30", current: "1.18.34" },
|
|
14
14
|
// Codex is a qualification host, not a runtime dependency: the CLI version
|
|
15
15
|
// below is the one live qualification evidence covers. The doctor warns when
|
|
16
16
|
// an installed CLI drifts ahead so a fresh install never silently outruns
|
package/src/core/task-context.ts
CHANGED
|
@@ -38,7 +38,7 @@ export function reconcileResume(
|
|
|
38
38
|
if (observations.length > 0)
|
|
39
39
|
return failure("permission_denied", "worker observations require native host verification");
|
|
40
40
|
const candidate = captureCandidate(view.workspace.root, view.task.intent.data.scope, []);
|
|
41
|
-
if (!candidate.ok) return candidate
|
|
41
|
+
if (!candidate.ok) return candidate;
|
|
42
42
|
const staleEvidenceIds = evaluateEvidence(view.task, candidate.data)
|
|
43
43
|
.filter((entry) => entry.status === "stale")
|
|
44
44
|
.map((entry) => entry.evidenceId);
|
|
@@ -127,7 +127,7 @@ const compactReference = (ref: Ref): CompactReference => {
|
|
|
127
127
|
export function compactTaskContext(view: TaskView): string {
|
|
128
128
|
const decisions: CompactDecision[] = view.task.decisions
|
|
129
129
|
.slice()
|
|
130
|
-
.
|
|
130
|
+
.toSorted((left, right) => {
|
|
131
131
|
const leftAt = compactTimestamp(left.recordedAt);
|
|
132
132
|
const rightAt = compactTimestamp(right.recordedAt);
|
|
133
133
|
if (leftAt !== rightAt) return leftAt > rightAt ? -1 : 1;
|
|
@@ -162,7 +162,7 @@ export function compactTaskContext(view: TaskView): string {
|
|
|
162
162
|
...view.task.progress.blockers.map((entry) => entry.reason),
|
|
163
163
|
]),
|
|
164
164
|
)
|
|
165
|
-
.
|
|
165
|
+
.toSorted()
|
|
166
166
|
.slice(0, COMPACT_MAX_ITEMS)
|
|
167
167
|
.map((gap) => compactText(gap, COMPACT_TEXT_BYTES)!);
|
|
168
168
|
const context: CompactTaskContext = {
|
|
@@ -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";
|
|
@@ -1118,7 +1238,7 @@ export function parseOperation(family: OperationFamily, input: unknown): Result<
|
|
|
1118
1238
|
return failure("unsupported_version", "unsupported schema version", { operation: family });
|
|
1119
1239
|
const compiled = compiledOperationSchemas[family].safeParse(input);
|
|
1120
1240
|
const parsed = compiled.success ? operationSchemas[family].safeParse(input) : compiled;
|
|
1121
|
-
if (parsed.success) return success(null, null, parsed.data
|
|
1241
|
+
if (parsed.success) return success(null, null, parsed.data);
|
|
1122
1242
|
const fields = parsed.error.issues.map((issue) => ({
|
|
1123
1243
|
path: issue.code === "unrecognized_keys" ? issue.keys.join(".") : issue.path.join("."),
|
|
1124
1244
|
reason: issue.message,
|
|
@@ -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(
|
|
1250
|
+
return z.toJSONSchema(advertisedOperationSchemas[family], { target: "draft-2020-12" });
|
|
1131
1251
|
}
|
|
1132
1252
|
|
|
1133
1253
|
/**
|
|
@@ -1293,7 +1413,7 @@ function canonical(value: unknown, seen: Set<object>): JsonValue {
|
|
|
1293
1413
|
throw new TypeError("invalid Unicode");
|
|
1294
1414
|
result = Object.fromEntries(
|
|
1295
1415
|
Object.keys(value)
|
|
1296
|
-
.
|
|
1416
|
+
.toSorted()
|
|
1297
1417
|
.map((key) => [key, canonical((value as Record<string, unknown>)[key], seen)]),
|
|
1298
1418
|
);
|
|
1299
1419
|
}
|
|
@@ -1339,13 +1459,13 @@ export function decisionDigest(input: Omit<Decision, "digest"> | Decision): Dige
|
|
|
1339
1459
|
export function candidateDigest(input: Candidate): Digest {
|
|
1340
1460
|
const normalizedScope = {
|
|
1341
1461
|
description: input.scope.description,
|
|
1342
|
-
paths: [...input.scope.paths].
|
|
1343
|
-
exclusions: [...input.scope.exclusions].
|
|
1462
|
+
paths: [...input.scope.paths].toSorted(compareCodeUnits),
|
|
1463
|
+
exclusions: [...input.scope.exclusions].toSorted(compareCodeUnits),
|
|
1344
1464
|
};
|
|
1345
1465
|
return sha256({
|
|
1346
1466
|
scope: normalizedScope,
|
|
1347
1467
|
completeness: input.completeness,
|
|
1348
|
-
files: [...input.files].
|
|
1468
|
+
files: [...input.files].toSorted(
|
|
1349
1469
|
(left, right) =>
|
|
1350
1470
|
compareCodeUnits(left.path, right.path) ||
|
|
1351
1471
|
compareCodeUnits(left.kind, right.kind) ||
|
|
@@ -1354,7 +1474,7 @@ export function candidateDigest(input: Candidate): Digest {
|
|
|
1354
1474
|
),
|
|
1355
1475
|
environment: input.environment
|
|
1356
1476
|
.map(({ name, value }) => ({ name, value }))
|
|
1357
|
-
.
|
|
1477
|
+
.toSorted(
|
|
1358
1478
|
(left, right) =>
|
|
1359
1479
|
compareCodeUnits(left.name, right.name) || compareNullableText(left.value, right.value),
|
|
1360
1480
|
),
|
|
@@ -1372,8 +1492,8 @@ export function requirementId(input: {
|
|
|
1372
1492
|
...input,
|
|
1373
1493
|
scope: {
|
|
1374
1494
|
description: input.scope.description,
|
|
1375
|
-
paths: [...input.scope.paths].
|
|
1376
|
-
exclusions: [...input.scope.exclusions].
|
|
1495
|
+
paths: [...input.scope.paths].toSorted(compareCodeUnits),
|
|
1496
|
+
exclusions: [...input.scope.exclusions].toSorted(compareCodeUnits),
|
|
1377
1497
|
},
|
|
1378
1498
|
});
|
|
1379
1499
|
}
|