@brainervirus/workit-core 3.0.0 → 5.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/README.md +1 -1
- package/package.json +1 -1
- package/skills/workit-bdd/SKILL.md +35 -29
- package/skills/workit-continue/SKILL.md +53 -0
- package/skills/workit-debug/SKILL.md +41 -36
- package/skills/workit-deslop/SKILL.md +35 -25
- package/skills/workit-fanout/SKILL.md +62 -0
- package/skills/workit-fanout/references/brief.md +56 -0
- package/skills/workit-implement/SKILL.md +48 -44
- package/skills/workit-review/SKILL.md +42 -51
- 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 +58 -0
- package/skills/workit-shape/references/mockups.md +15 -0
- package/skills/workit-shape/references/slicing.md +42 -0
- package/skills/workit-ship/SKILL.md +52 -0
- package/skills/workit-test-audit/SKILL.md +10 -2
- package/skills/workit-verify-app/SKILL.md +63 -0
- package/skills/workit-verify-app/references/template.md +49 -0
- package/src/core/external-action-effects.ts +2 -2
- package/src/core/methods.ts +68 -95
- package/src/core/skill-manifests.ts +49 -25
- package/src/core/store-lock.ts +13 -2
- package/src/core/task-contract.ts +17 -18
- package/src/core/task-engine.ts +108 -58
- package/src/core/task-store.ts +1857 -1259
- package/src/core.ts +1 -6
- package/src/hooks/context.ts +57 -5
- package/src/hooks/handle.ts +29 -5
- package/src/hooks/hosts/codex.ts +1 -1
- package/src/ledger.ts +31 -28
- 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/templates/plan-template.md +17 -18
- package/templates/spec-template.md +4 -3
- package/skills/workit-babysit/SKILL.md +0 -37
- package/skills/workit-behavioral-tdd/SKILL.md +0 -56
- package/skills/workit-blast-radius/SKILL.md +0 -26
- package/skills/workit-challenge/SKILL.md +0 -47
- package/skills/workit-diagram/SKILL.md +0 -27
- package/skills/workit-green-run/SKILL.md +0 -24
- package/skills/workit-handoff/SKILL.md +0 -37
- package/skills/workit-mockup/SKILL.md +0 -23
- package/skills/workit-plan/SKILL.md +0 -45
- package/skills/workit-steer/SKILL.md +0 -39
- package/templates/execution-contract.md +0 -40
package/src/core.ts
CHANGED
|
@@ -61,12 +61,7 @@ export type {
|
|
|
61
61
|
Result as ContractResult,
|
|
62
62
|
} from "./core/task-contract";
|
|
63
63
|
export { TaskStore, runtimeVersion } from "./core/task-store";
|
|
64
|
-
export type {
|
|
65
|
-
MetadataLock,
|
|
66
|
-
ProcessEvidence,
|
|
67
|
-
RecoveryInput,
|
|
68
|
-
TaskIndexEntry,
|
|
69
|
-
} from "./core/task-store";
|
|
64
|
+
export type { TaskIndexEntry } from "./core/task-store";
|
|
70
65
|
export { compactTaskContext, reconcileResume } from "./core/task-context";
|
|
71
66
|
export type {
|
|
72
67
|
CompactTaskContext,
|
package/src/hooks/context.ts
CHANGED
|
@@ -6,10 +6,24 @@ import { canonicalJson } from "../core/task-contract";
|
|
|
6
6
|
import { isObservedCheck, type Freshness } from "../core/task-evaluation";
|
|
7
7
|
import { worktreeSignal } from "../git/rev";
|
|
8
8
|
import { WorkitCore, type OperationContext } from "../core/task-engine";
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
fileSignature,
|
|
11
|
+
MIGRATION_PENDING,
|
|
12
|
+
racySignature,
|
|
13
|
+
TaskStore,
|
|
14
|
+
type TaskIndexEntry,
|
|
15
|
+
} from "../core/task-store";
|
|
10
16
|
import { capabilitiesFor, type HostDescriptor } from "./descriptor";
|
|
11
17
|
import type { HookInput } from "./protocol";
|
|
12
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Shown on every per-turn path while the checkout's 2.x store waits for the
|
|
21
|
+
* CLI to migrate it (hooks never migrate), instead of silently no context.
|
|
22
|
+
*/
|
|
23
|
+
export const MIGRATION_PENDING_NOTE = "workit migration pending — run `workit task status`";
|
|
24
|
+
const migrationPending = (result: { ok: boolean; code?: string; error?: string }): boolean =>
|
|
25
|
+
!result.ok && result.code === "needs_input" && (result.error ?? "").startsWith(MIGRATION_PENDING);
|
|
26
|
+
|
|
13
27
|
/** A native host session, e.g. `{ host: "opencode", handle: sessionID }`. */
|
|
14
28
|
export type SessionHandle = { host: string; handle: string };
|
|
15
29
|
|
|
@@ -50,6 +64,34 @@ export const currentTaskEntry = (
|
|
|
50
64
|
return active.length === 1 ? active[0] : null;
|
|
51
65
|
};
|
|
52
66
|
|
|
67
|
+
/**
|
|
68
|
+
* The open task bound to this checkout's branch (its implicit task, D3),
|
|
69
|
+
* unless a host conversation other than this one is bound to it: someone
|
|
70
|
+
* else's conversation is only ever offered, never injected. Sessions of the
|
|
71
|
+
* CLI (`workit_cli`, which creates implicit tasks) are tooling, not
|
|
72
|
+
* conversations.
|
|
73
|
+
*/
|
|
74
|
+
const implicitTaskEntry = (
|
|
75
|
+
store: TaskStore,
|
|
76
|
+
entries: TaskIndexEntry[],
|
|
77
|
+
session: SessionHandle,
|
|
78
|
+
): TaskIndexEntry | null => {
|
|
79
|
+
const open = entries.filter(
|
|
80
|
+
(entry) =>
|
|
81
|
+
entry.key !== null &&
|
|
82
|
+
entry.status !== "closed" &&
|
|
83
|
+
!entry.sessions.some(
|
|
84
|
+
(item) =>
|
|
85
|
+
item.host !== "workit_cli" &&
|
|
86
|
+
!(item.host === session.host && item.handle === session.handle),
|
|
87
|
+
),
|
|
88
|
+
);
|
|
89
|
+
if (open.length === 0) return null;
|
|
90
|
+
const key = store.currentKey();
|
|
91
|
+
if (!key.ok) return null;
|
|
92
|
+
return open.filter((entry) => entry.key === key.data.key).toSorted(newestFirst)[0] ?? null;
|
|
93
|
+
};
|
|
94
|
+
|
|
53
95
|
/**
|
|
54
96
|
* History offer for open tasks not bound to `session` (and not `excludeTaskId`,
|
|
55
97
|
* the task already shown), built from the task index. Task text is quoted and
|
|
@@ -109,7 +151,9 @@ const recentSignal = (root: string): string | null => {
|
|
|
109
151
|
const cache = new Map<string, CachedContext>();
|
|
110
152
|
|
|
111
153
|
/**
|
|
112
|
-
* Compact context for `session`'s current task
|
|
154
|
+
* Compact context for `session`'s current task (the task bound to the
|
|
155
|
+
* session, else the implicit task of the checkout's branch), for per-turn
|
|
156
|
+
* host injection.
|
|
113
157
|
*
|
|
114
158
|
* Reads the task index (no full-record parse for unrelated tasks), never
|
|
115
159
|
* captures a candidate, and reuses the previous result while the task and
|
|
@@ -123,8 +167,10 @@ export function sessionCompactContext(
|
|
|
123
167
|
selection: HostDescriptor["context"]["task"] = "session-bound",
|
|
124
168
|
): string | null {
|
|
125
169
|
const listed = store.listTaskIndex();
|
|
126
|
-
if (!listed.ok) return null;
|
|
127
|
-
const entry =
|
|
170
|
+
if (!listed.ok) return migrationPending(listed) ? MIGRATION_PENDING_NOTE : null;
|
|
171
|
+
const entry =
|
|
172
|
+
currentTaskEntry(listed.data, session, selection) ??
|
|
173
|
+
implicitTaskEntry(store, listed.data, session);
|
|
128
174
|
return entry ? entryCompactContext(store, entry, session, context) : null;
|
|
129
175
|
}
|
|
130
176
|
|
|
@@ -208,8 +254,13 @@ export const sessionContextText = (
|
|
|
208
254
|
try {
|
|
209
255
|
const store = new TaskStore(input.cwd);
|
|
210
256
|
const listed = store.listTaskIndex();
|
|
257
|
+
// Hooks never migrate (hot path, possibly a live 2.x writer): say how.
|
|
258
|
+
if (migrationPending(listed))
|
|
259
|
+
return `<workit-contract>\n${invariantBootstrap()}\n${MIGRATION_PENDING_NOTE}${options.addendum ? `\n${options.addendum}` : ""}\n</workit-contract>`;
|
|
211
260
|
if (!listed.ok) throw new Error(listed.error);
|
|
212
|
-
const entry =
|
|
261
|
+
const entry =
|
|
262
|
+
currentTaskEntry(listed.data, session, descriptor.context.task) ??
|
|
263
|
+
implicitTaskEntry(store, listed.data, session);
|
|
213
264
|
const text = entry
|
|
214
265
|
? entryCompactContext(store, entry, session, hookOperationContext(input, descriptor))
|
|
215
266
|
: null;
|
|
@@ -230,6 +281,7 @@ export const turnContextText = (input: HookInput, descriptor: HostDescriptor): s
|
|
|
230
281
|
hookOperationContext(input, descriptor),
|
|
231
282
|
descriptor.context.task,
|
|
232
283
|
);
|
|
284
|
+
if (text === MIGRATION_PENDING_NOTE) return text;
|
|
233
285
|
return text ? `<workit-task-context>${text}</workit-task-context>` : null;
|
|
234
286
|
} catch {
|
|
235
287
|
return null;
|
package/src/hooks/handle.ts
CHANGED
|
@@ -17,14 +17,35 @@ const offered = new Set<string>();
|
|
|
17
17
|
* `workit:implementer` is ours: a bare or other-plugin `implementer` is not. */
|
|
18
18
|
const CLAUDE_WORKTREE_IMPLEMENTER = "workit:implementer";
|
|
19
19
|
|
|
20
|
+
/** The plugin's read-only judges: each records verdicts as its own session. */
|
|
21
|
+
const CLAUDE_JUDGES: ReadonlySet<string> = new Set(["workit:verifier", "workit:reviewer"]);
|
|
22
|
+
|
|
23
|
+
/** `<lead session>:<agent id>`, safe for the ledger's session grammar. */
|
|
24
|
+
const subagentSession = (session: string | null, agentId: string): string =>
|
|
25
|
+
[session, agentId]
|
|
26
|
+
.filter((part): part is string => Boolean(part))
|
|
27
|
+
.join(":")
|
|
28
|
+
.replace(/[^A-Za-z0-9_.:@/+-]/g, "-")
|
|
29
|
+
.slice(0, 128);
|
|
30
|
+
|
|
20
31
|
const subagentStartText = (
|
|
21
32
|
host: HookInput["host"],
|
|
33
|
+
session: string | null,
|
|
22
34
|
descriptor: HostDescriptor,
|
|
23
35
|
event: Extract<HookInput["event"], { kind: "subagent.start" }>,
|
|
24
|
-
): string =>
|
|
25
|
-
host === "claude_code" && event.agentType === CLAUDE_WORKTREE_IMPLEMENTER
|
|
26
|
-
|
|
27
|
-
|
|
36
|
+
): string => {
|
|
37
|
+
if (host === "claude_code" && event.agentType === CLAUDE_WORKTREE_IMPLEMENTER)
|
|
38
|
+
return `Workit observed ${descriptor.label} subagent ${event.agentId} (${event.agentType}) working in its own git worktree: it may edit and commit there, within its brief's scope. Before the first commit, switch to a policy-compliant branch with \`workit git branch <branch> --base <base>\` (e.g. feature/<slug>); branch policy hooks still deny protected or non-compliant branches. Never record a verdict on your own work. Never push, open a PR, or merge unless the brief asks for it.`;
|
|
39
|
+
const readOnly = `Workit observed ${descriptor.label} subagent ${event.agentId} (${event.agentType}) as read-only/agent-guided; writer delegation is unavailable.`;
|
|
40
|
+
if (
|
|
41
|
+
host === "claude_code" &&
|
|
42
|
+
event.agentType &&
|
|
43
|
+
CLAUDE_JUDGES.has(event.agentType) &&
|
|
44
|
+
event.agentId
|
|
45
|
+
)
|
|
46
|
+
return `${readOnly} Its own Workit session is ${subagentSession(session, event.agentId)}: record verdicts with \`workit ledger verdict <result> --session ${subagentSession(session, event.agentId)} ...\` so the ledger tells it apart from the author.`;
|
|
47
|
+
return readOnly;
|
|
48
|
+
};
|
|
28
49
|
|
|
29
50
|
export function handleHook(input: HookInput, deps: HookDeps): HookDecision {
|
|
30
51
|
const { descriptor } = deps;
|
|
@@ -50,7 +71,10 @@ export function handleHook(input: HookInput, deps: HookDeps): HookDecision {
|
|
|
50
71
|
case "shell.pre":
|
|
51
72
|
return usable(descriptor.shellPolicy.deny) ? shellPolicy(input.cwd, event.command) : NONE;
|
|
52
73
|
case "subagent.start":
|
|
53
|
-
return {
|
|
74
|
+
return {
|
|
75
|
+
kind: "context",
|
|
76
|
+
text: subagentStartText(input.host, input.session.id ?? null, descriptor, event),
|
|
77
|
+
};
|
|
54
78
|
case "compact.pre":
|
|
55
79
|
return {
|
|
56
80
|
kind: "notice",
|
package/src/hooks/hosts/codex.ts
CHANGED
|
@@ -294,5 +294,5 @@ export const codexAdapter: HostAdapter = {
|
|
|
294
294
|
render,
|
|
295
295
|
// Without a session id there is no actor to bind a writer to.
|
|
296
296
|
addendum: (input) =>
|
|
297
|
-
`<workit-codex-mutations>Codex MCP is read-only: unattested callers cannot mutate. Run the workit CLI
|
|
297
|
+
`<workit-codex-mutations>Codex MCP is read-only: unattested callers cannot mutate. Run workit verbs with the workit CLI on the shell (node_modules/.bin/workit, or npx -y @brainervirus/workit-cli): check, git branch|commit|push, pr, ci, stack, ledger, handoff; task-family mutations take --json --confirm${input.session.id ? `; bind the writer to this session with node_modules/.bin/workit writer acquire --task <id> --revision <rev> --actor ${input.session.id} --confirm` : ""}. A verifier or reviewer records workit ledger verdict under a session the lead assigns (WORKIT_SESSION_ID=<lead>-v<n>), never the author's. Merge and release need a workspace grant; a question answer is not host permission.</workit-codex-mutations>`,
|
|
298
298
|
};
|
package/src/ledger.ts
CHANGED
|
@@ -40,6 +40,7 @@ import { createHash, randomBytes } from "node:crypto";
|
|
|
40
40
|
import fs from "node:fs";
|
|
41
41
|
import path from "node:path";
|
|
42
42
|
import { GIT_TIMEOUTS, currentBranch, headSha, patchId, worktreeTree } from "./git/rev";
|
|
43
|
+
import { resolveStore } from "./store/paths";
|
|
43
44
|
|
|
44
45
|
export const LEDGER_VERSION = 1;
|
|
45
46
|
/** The largest row (including its newline) that one append may carry. */
|
|
@@ -254,29 +255,22 @@ export function diffHash(cwd: string, base: string, head: string): string | null
|
|
|
254
255
|
|
|
255
256
|
export type StoreRoot = { root: string; shared: boolean };
|
|
256
257
|
|
|
257
|
-
const NOT_A_REPO = /not a git repository \(or any (of the parent directories|parent up to)/u;
|
|
258
|
-
|
|
259
258
|
/**
|
|
260
259
|
* Where workit state lives for `cwd`: `<git common dir>/workit` (shared by all
|
|
261
260
|
* worktrees of the repo), or `<cwd>/.workit` only when git reports that `cwd`
|
|
262
|
-
* is not inside a repository
|
|
263
|
-
* refused (dubious-ownership) repository is `unavailable`.
|
|
261
|
+
* is not inside a repository (or git is not installed). A broken repository
|
|
262
|
+
* or a refused (dubious-ownership) repository is `unavailable`. The task
|
|
263
|
+
* store resolves the same way (store/paths.ts).
|
|
264
264
|
*/
|
|
265
265
|
export function storeRoot(cwd: string): LedgerResult<StoreRoot> {
|
|
266
|
-
const
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
return { ok: true, value: { root: path.join(path.resolve(cwd), ".workit"), shared: false } };
|
|
275
|
-
return err(
|
|
276
|
-
"unavailable",
|
|
277
|
-
`cannot locate the workit store: git rev-parse failed (${run.stderr.trim().split("\n")[0] || "no output"})`,
|
|
278
|
-
"fix the repository (git status should work here) or run from a non-git directory",
|
|
279
|
-
);
|
|
266
|
+
const location = resolveStore(path.resolve(cwd));
|
|
267
|
+
if (location instanceof Error)
|
|
268
|
+
return err(
|
|
269
|
+
"unavailable",
|
|
270
|
+
location.message.replace(/; fix the repository.*$/u, ""),
|
|
271
|
+
"fix the repository (git status should work here) or run from a non-git directory",
|
|
272
|
+
);
|
|
273
|
+
return { ok: true, value: { root: location.dir, shared: location.shared } };
|
|
280
274
|
}
|
|
281
275
|
|
|
282
276
|
export function ledgerPath(cwd: string): LedgerResult<string> {
|
|
@@ -1077,6 +1071,12 @@ export function recordVerdict(
|
|
|
1077
1071
|
surface?: string | null;
|
|
1078
1072
|
self?: boolean;
|
|
1079
1073
|
evidenceRefs?: string[];
|
|
1074
|
+
/**
|
|
1075
|
+
* A role id derived from another session (`--as`): the session it was
|
|
1076
|
+
* derived from. `null` means it was derived from no session, which makes
|
|
1077
|
+
* the verdict self. An author's derived id is refused like the author.
|
|
1078
|
+
*/
|
|
1079
|
+
derivedFrom?: string | null;
|
|
1080
1080
|
},
|
|
1081
1081
|
): LedgerResult<VerdictRow> {
|
|
1082
1082
|
if (!(VERDICT_RESULTS as readonly string[]).includes(input.result))
|
|
@@ -1111,19 +1111,22 @@ export function recordVerdict(
|
|
|
1111
1111
|
);
|
|
1112
1112
|
const session = context.actor.session;
|
|
1113
1113
|
const selfReason: SelfReason | null =
|
|
1114
|
-
input.self === true ? "flag" : session ? null : "no_session";
|
|
1114
|
+
input.self === true ? "flag" : session && input.derivedFrom !== null ? null : "no_session";
|
|
1115
1115
|
const self = selfReason !== null;
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
)
|
|
1122
|
-
)
|
|
1116
|
+
const authors = authorSessions(context.cwd, ledger.value.rows, branch, {
|
|
1117
|
+
base: key.base,
|
|
1118
|
+
head: key.head,
|
|
1119
|
+
});
|
|
1120
|
+
const authoring = [session, input.derivedFrom].find(
|
|
1121
|
+
(candidate): candidate is string => typeof candidate === "string" && authors.has(candidate),
|
|
1122
|
+
);
|
|
1123
|
+
if (!self && authoring)
|
|
1123
1124
|
return err(
|
|
1124
1125
|
"blocked",
|
|
1125
|
-
`author_verdict: session ${
|
|
1126
|
-
|
|
1126
|
+
`author_verdict: session ${authoring} authored ${branch}; a verdict must come from a different session`,
|
|
1127
|
+
input.derivedFrom === undefined
|
|
1128
|
+
? 'have a non-author session record the verdict, or pass --self (a self verdict is never accepted by merge:"verified")'
|
|
1129
|
+
: "run the verifier as a separate session: the lead spawns it with its own WORKIT_SESSION_ID (Claude Code: subagents get one from the SubagentStart hook)",
|
|
1127
1130
|
);
|
|
1128
1131
|
const link = checkSupersede(context, "verdict", self);
|
|
1129
1132
|
if (!link.ok) return link;
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// The append-only event log of one task: `events.jsonl`, one JSON event per
|
|
2
|
+
// line, plus `snapshot.json`, a rebuildable cache of the reduced state
|
|
3
|
+
// (design §4.1).
|
|
4
|
+
//
|
|
5
|
+
// - A writer holds the task lock, appends one complete line with O_APPEND in
|
|
6
|
+
// a single write, and fsyncs. A crash can therefore only leave a torn last
|
|
7
|
+
// line (no trailing newline); readers ignore it and the next locked append
|
|
8
|
+
// truncates it away first. Any other unreadable line is real damage.
|
|
9
|
+
// - Readers are lenient (D17): unknown event keys and unknown event types are
|
|
10
|
+
// ignored, unless the event says `critical: true`, in which case a reader
|
|
11
|
+
// that does not know the type fails closed with an upgrade message.
|
|
12
|
+
// - The snapshot names the event it was taken after (seq, id, byte range). A
|
|
13
|
+
// snapshot that does not match the log is ignored and the log is replayed.
|
|
14
|
+
import { randomUUID } from "node:crypto";
|
|
15
|
+
import fs from "node:fs";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
|
|
18
|
+
export const EVENT_VERSION = 1;
|
|
19
|
+
|
|
20
|
+
export type EventActor = { host: string; session: string | null; agentId: string | null };
|
|
21
|
+
|
|
22
|
+
export type StoreEvent = {
|
|
23
|
+
v: number;
|
|
24
|
+
seq: number;
|
|
25
|
+
at: string;
|
|
26
|
+
id: string;
|
|
27
|
+
task: string;
|
|
28
|
+
actor: EventActor | null;
|
|
29
|
+
type: string;
|
|
30
|
+
data: Record<string, unknown>;
|
|
31
|
+
critical?: boolean;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export type LogRead = {
|
|
35
|
+
events: StoreEvent[];
|
|
36
|
+
/** Byte offset just past the last complete event line. */
|
|
37
|
+
end: number;
|
|
38
|
+
/** Byte offset where the last complete event line starts. */
|
|
39
|
+
lastStart: number;
|
|
40
|
+
/** File size; larger than `end` when a torn line trails the log. */
|
|
41
|
+
size: number;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export class LogDamage extends Error {
|
|
45
|
+
constructor(
|
|
46
|
+
message: string,
|
|
47
|
+
readonly upgrade: boolean = false,
|
|
48
|
+
) {
|
|
49
|
+
super(message);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const isObject = (value: unknown): value is Record<string, unknown> =>
|
|
54
|
+
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
55
|
+
|
|
56
|
+
const parseEvent = (line: string, file: string, offset: number): StoreEvent => {
|
|
57
|
+
let value: unknown;
|
|
58
|
+
try {
|
|
59
|
+
value = JSON.parse(line);
|
|
60
|
+
} catch {
|
|
61
|
+
throw new LogDamage(`${file}: unreadable event at byte ${offset}`);
|
|
62
|
+
}
|
|
63
|
+
if (!isObject(value) || typeof value.v !== "number")
|
|
64
|
+
throw new LogDamage(`${file}: malformed event at byte ${offset}`);
|
|
65
|
+
if (value.v > EVENT_VERSION)
|
|
66
|
+
throw new LogDamage(
|
|
67
|
+
`${file}: event format v${value.v} was written by a newer Workit; upgrade Workit to read this task`,
|
|
68
|
+
true,
|
|
69
|
+
);
|
|
70
|
+
if (
|
|
71
|
+
typeof value.seq !== "number" ||
|
|
72
|
+
!Number.isInteger(value.seq) ||
|
|
73
|
+
typeof value.type !== "string" ||
|
|
74
|
+
typeof value.id !== "string" ||
|
|
75
|
+
typeof value.task !== "string" ||
|
|
76
|
+
!isObject(value.data)
|
|
77
|
+
)
|
|
78
|
+
throw new LogDamage(`${file}: malformed event at byte ${offset}`);
|
|
79
|
+
return value as StoreEvent;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Read the events of `file` starting at byte `from` (a line start). A torn
|
|
84
|
+
* trailing line is excluded; `expectSeq` is the seq the first event must have.
|
|
85
|
+
* Returns null when the file does not exist.
|
|
86
|
+
*/
|
|
87
|
+
export function readLog(file: string, from = 0, expectSeq: number | null = null): LogRead | null {
|
|
88
|
+
let fd: number;
|
|
89
|
+
try {
|
|
90
|
+
fd = fs.openSync(file, "r");
|
|
91
|
+
} catch (error) {
|
|
92
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
93
|
+
throw error;
|
|
94
|
+
}
|
|
95
|
+
let bytes: Buffer;
|
|
96
|
+
let size: number;
|
|
97
|
+
try {
|
|
98
|
+
size = fs.fstatSync(fd).size;
|
|
99
|
+
if (from > size) throw new LogDamage(`${file}: shorter than its snapshot`);
|
|
100
|
+
bytes = Buffer.alloc(size - from);
|
|
101
|
+
let read = 0;
|
|
102
|
+
while (read < bytes.length) {
|
|
103
|
+
const got = fs.readSync(fd, bytes, read, bytes.length - read, from + read);
|
|
104
|
+
if (got === 0) break;
|
|
105
|
+
read += got;
|
|
106
|
+
}
|
|
107
|
+
bytes = bytes.subarray(0, read);
|
|
108
|
+
size = from + read;
|
|
109
|
+
} finally {
|
|
110
|
+
fs.closeSync(fd);
|
|
111
|
+
}
|
|
112
|
+
const events: StoreEvent[] = [];
|
|
113
|
+
let start = 0;
|
|
114
|
+
let lastStart = from;
|
|
115
|
+
let seq = expectSeq;
|
|
116
|
+
for (;;) {
|
|
117
|
+
const newline = bytes.indexOf(0x0a, start);
|
|
118
|
+
if (newline < 0) break;
|
|
119
|
+
const line = bytes.subarray(start, newline).toString("utf8");
|
|
120
|
+
if (line.trim() !== "") {
|
|
121
|
+
const event = parseEvent(line, file, from + start);
|
|
122
|
+
if (seq !== null && event.seq !== seq)
|
|
123
|
+
throw new LogDamage(`${file}: event seq ${event.seq} where ${seq} was expected`);
|
|
124
|
+
seq = event.seq + 1;
|
|
125
|
+
events.push(event);
|
|
126
|
+
lastStart = from + start;
|
|
127
|
+
}
|
|
128
|
+
start = newline + 1;
|
|
129
|
+
}
|
|
130
|
+
return { events, end: from + start, lastStart, size };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Durably write `dir`'s entries (no-op where directories cannot be fsynced). */
|
|
134
|
+
export const fsyncDirectory = (dir: string): void => {
|
|
135
|
+
try {
|
|
136
|
+
const fd = fs.openSync(dir, "r");
|
|
137
|
+
try {
|
|
138
|
+
fs.fsyncSync(fd);
|
|
139
|
+
} finally {
|
|
140
|
+
fs.closeSync(fd);
|
|
141
|
+
}
|
|
142
|
+
} catch (error) {
|
|
143
|
+
if (process.platform !== "win32") throw error;
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Append one event line. `validEnd` is where the last complete line ends: a
|
|
149
|
+
* torn tail beyond it is truncated first. Returns the byte offset the line
|
|
150
|
+
* starts at.
|
|
151
|
+
*/
|
|
152
|
+
export function appendEvent(file: string, event: StoreEvent, validEnd: number): number {
|
|
153
|
+
const line = Buffer.from(`${JSON.stringify(event)}\n`, "utf8");
|
|
154
|
+
const created = !fs.existsSync(file);
|
|
155
|
+
// Cut a torn tail first, through a writable handle: an append-only handle
|
|
156
|
+
// cannot truncate on Windows.
|
|
157
|
+
if (!created && fs.statSync(file).size !== validEnd) {
|
|
158
|
+
const cut = fs.openSync(file, "r+");
|
|
159
|
+
try {
|
|
160
|
+
fs.ftruncateSync(cut, validEnd);
|
|
161
|
+
fs.fsyncSync(cut);
|
|
162
|
+
} finally {
|
|
163
|
+
fs.closeSync(cut);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
const fd = fs.openSync(file, "a", 0o600);
|
|
167
|
+
try {
|
|
168
|
+
let written = 0;
|
|
169
|
+
while (written < line.length) written += fs.writeSync(fd, line, written, line.length - written);
|
|
170
|
+
fs.fsyncSync(fd);
|
|
171
|
+
} finally {
|
|
172
|
+
fs.closeSync(fd);
|
|
173
|
+
}
|
|
174
|
+
if (created) fsyncDirectory(path.dirname(file));
|
|
175
|
+
return validEnd;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Replace `file` with `bytes` atomically (temp file + rename). */
|
|
179
|
+
export function replaceFile(file: string, bytes: string, durable: boolean): void {
|
|
180
|
+
const temporary = `${file}.${process.pid}.${randomUUID()}.tmp`;
|
|
181
|
+
try {
|
|
182
|
+
const fd = fs.openSync(temporary, "wx", 0o600);
|
|
183
|
+
try {
|
|
184
|
+
fs.writeSync(fd, bytes);
|
|
185
|
+
if (durable) fs.fsyncSync(fd);
|
|
186
|
+
} finally {
|
|
187
|
+
fs.closeSync(fd);
|
|
188
|
+
}
|
|
189
|
+
renameWithRetry(temporary, file);
|
|
190
|
+
if (durable) fsyncDirectory(path.dirname(file));
|
|
191
|
+
} catch (error) {
|
|
192
|
+
try {
|
|
193
|
+
fs.unlinkSync(temporary);
|
|
194
|
+
} catch {}
|
|
195
|
+
throw error;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const TRANSIENT_WINDOWS_CODES = new Set(["EPERM", "EACCES", "EBUSY"]);
|
|
200
|
+
/** Windows briefly refuses to replace a file another process is reading. */
|
|
201
|
+
export const isTransientWindowsError = (error: unknown): boolean => {
|
|
202
|
+
if (process.platform !== "win32") return false;
|
|
203
|
+
const code = (error as { code?: unknown } | null)?.code;
|
|
204
|
+
return typeof code === "string" && TRANSIENT_WINDOWS_CODES.has(code);
|
|
205
|
+
};
|
|
206
|
+
export const retryTransient = <T>(run: () => T): T => {
|
|
207
|
+
if (process.platform !== "win32") return run();
|
|
208
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
209
|
+
try {
|
|
210
|
+
return run();
|
|
211
|
+
} catch (error) {
|
|
212
|
+
if (attempt >= 20 || !isTransientWindowsError(error)) throw error;
|
|
213
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5 * (attempt + 1));
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
const renameWithRetry = (from: string, to: string) => retryTransient(() => fs.renameSync(from, to));
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// One-time notes from the store to the user. A host installs a reporter on
|
|
2
|
+
// the global `Symbol.for("workit.migrationReporter")` slot (the CLI router
|
|
3
|
+
// does; it must not import workit-core on its cold path). Without one, the
|
|
4
|
+
// note is silent: hooks and in-process hosts own their output channels.
|
|
5
|
+
|
|
6
|
+
export type MigrationReport = {
|
|
7
|
+
/** The 2.x store that was migrated. */
|
|
8
|
+
from: string;
|
|
9
|
+
/** The store it now lives in. */
|
|
10
|
+
to: string;
|
|
11
|
+
/** Where the 2.x files were backed up. */
|
|
12
|
+
backup: string;
|
|
13
|
+
tasks: number;
|
|
14
|
+
skipped: number;
|
|
15
|
+
workspace: boolean;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const SLOT = Symbol.for("workit.migrationReporter");
|
|
19
|
+
|
|
20
|
+
export const reportMigration = (report: MigrationReport): void => {
|
|
21
|
+
try {
|
|
22
|
+
const reporter = (globalThis as Record<symbol, unknown>)[SLOT];
|
|
23
|
+
if (typeof reporter === "function") reporter(report);
|
|
24
|
+
} catch {}
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** Whether a host installed a reporter (it can tell the user about a migration). */
|
|
28
|
+
export const migrationReporterInstalled = (): boolean =>
|
|
29
|
+
typeof (globalThis as Record<symbol, unknown>)[SLOT] === "function";
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// Structural JSON diff for the task event log (design §4.1). One mutation
|
|
2
|
+
// becomes the smallest set of operations that turns the previous record into
|
|
3
|
+
// the next one, so the log grows with the change, never with the record:
|
|
4
|
+
//
|
|
5
|
+
// set replace the value at `path`
|
|
6
|
+
// del remove the key at `path`
|
|
7
|
+
// push append `values` to the array at `path`
|
|
8
|
+
// cut truncate the array at `path` to `length`
|
|
9
|
+
//
|
|
10
|
+
// Arrays are compared element by element, so appending an entry (evidence,
|
|
11
|
+
// findings, decisions) is one `push`, and editing one entry in place is a
|
|
12
|
+
// diff inside that entry. Paths are arrays of keys and indices.
|
|
13
|
+
|
|
14
|
+
export type PatchPath = (string | number)[];
|
|
15
|
+
export type PatchOp =
|
|
16
|
+
| { op: "set"; path: PatchPath; value: unknown }
|
|
17
|
+
| { op: "del"; path: PatchPath }
|
|
18
|
+
| { op: "push"; path: PatchPath; values: unknown[] }
|
|
19
|
+
| { op: "cut"; path: PatchPath; length: number };
|
|
20
|
+
|
|
21
|
+
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
|
|
22
|
+
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
23
|
+
|
|
24
|
+
const equal = (left: unknown, right: unknown): boolean => {
|
|
25
|
+
if (left === right) return true;
|
|
26
|
+
if (typeof left !== "object" || typeof right !== "object" || left === null || right === null)
|
|
27
|
+
return false;
|
|
28
|
+
if (Array.isArray(left) !== Array.isArray(right)) return false;
|
|
29
|
+
if (Array.isArray(left)) {
|
|
30
|
+
const other = right as unknown[];
|
|
31
|
+
return left.length === other.length && left.every((item, index) => equal(item, other[index]));
|
|
32
|
+
}
|
|
33
|
+
const leftKeys = Object.keys(left).filter(
|
|
34
|
+
(key) => (left as Record<string, unknown>)[key] !== undefined,
|
|
35
|
+
);
|
|
36
|
+
const rightKeys = Object.keys(right).filter(
|
|
37
|
+
(key) => (right as Record<string, unknown>)[key] !== undefined,
|
|
38
|
+
);
|
|
39
|
+
return (
|
|
40
|
+
leftKeys.length === rightKeys.length &&
|
|
41
|
+
leftKeys.every((key) =>
|
|
42
|
+
equal((left as Record<string, unknown>)[key], (right as Record<string, unknown>)[key]),
|
|
43
|
+
)
|
|
44
|
+
);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** Operations that turn `before` into `after` (JSON values only). */
|
|
48
|
+
export function diff(before: unknown, after: unknown, path: PatchPath = []): PatchOp[] {
|
|
49
|
+
if (equal(before, after)) return [];
|
|
50
|
+
if (isPlainObject(before) && isPlainObject(after)) {
|
|
51
|
+
const ops: PatchOp[] = [];
|
|
52
|
+
for (const key of Object.keys(before))
|
|
53
|
+
if (before[key] !== undefined && after[key] === undefined)
|
|
54
|
+
ops.push({ op: "del", path: [...path, key] });
|
|
55
|
+
for (const key of Object.keys(after)) {
|
|
56
|
+
if (after[key] === undefined) continue;
|
|
57
|
+
if (before[key] === undefined)
|
|
58
|
+
ops.push({ op: "set", path: [...path, key], value: after[key] });
|
|
59
|
+
else ops.push(...diff(before[key], after[key], [...path, key]));
|
|
60
|
+
}
|
|
61
|
+
return ops;
|
|
62
|
+
}
|
|
63
|
+
if (Array.isArray(before) && Array.isArray(after)) {
|
|
64
|
+
const ops: PatchOp[] = [];
|
|
65
|
+
if (after.length < before.length) ops.push({ op: "cut", path, length: after.length });
|
|
66
|
+
const shared = Math.min(before.length, after.length);
|
|
67
|
+
// A rewrite of most of the array is cheaper as one value.
|
|
68
|
+
let changed = 0;
|
|
69
|
+
for (let index = 0; index < shared; index += 1)
|
|
70
|
+
if (!equal(before[index], after[index])) changed += 1;
|
|
71
|
+
if (shared > 0 && changed > shared / 2 && changed > 4)
|
|
72
|
+
return [{ op: "set", path, value: after }];
|
|
73
|
+
for (let index = 0; index < shared; index += 1)
|
|
74
|
+
ops.push(...diff(before[index], after[index], [...path, index]));
|
|
75
|
+
if (after.length > before.length)
|
|
76
|
+
ops.push({ op: "push", path, values: after.slice(before.length) });
|
|
77
|
+
return ops;
|
|
78
|
+
}
|
|
79
|
+
return [{ op: "set", path, value: after }];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const clone = <T>(value: T): T => structuredClone(value);
|
|
83
|
+
|
|
84
|
+
/** Apply `ops` to `target` in place; throws on a path that does not exist. */
|
|
85
|
+
export function apply(target: unknown, ops: readonly PatchOp[]): unknown {
|
|
86
|
+
let root = target;
|
|
87
|
+
for (const op of ops) {
|
|
88
|
+
if (op.path.length === 0) {
|
|
89
|
+
if (op.op === "set") root = clone(op.value);
|
|
90
|
+
else if (op.op === "push" && Array.isArray(root)) root.push(...clone(op.values));
|
|
91
|
+
else if (op.op === "cut" && Array.isArray(root)) root.length = op.length;
|
|
92
|
+
else throw new Error(`patch: ${op.op} at the root is invalid`);
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
let parent: unknown = root;
|
|
96
|
+
for (const key of op.path.slice(0, -1)) {
|
|
97
|
+
if (typeof parent !== "object" || parent === null)
|
|
98
|
+
throw new Error(`patch: missing path ${op.path.join(".")}`);
|
|
99
|
+
parent = (parent as Record<string | number, unknown>)[key];
|
|
100
|
+
}
|
|
101
|
+
if (typeof parent !== "object" || parent === null)
|
|
102
|
+
throw new Error(`patch: missing path ${op.path.join(".")}`);
|
|
103
|
+
const key = op.path.at(-1)!;
|
|
104
|
+
const container = parent as Record<string | number, unknown>;
|
|
105
|
+
if (op.op === "set") container[key] = clone(op.value);
|
|
106
|
+
else if (op.op === "del") delete container[key];
|
|
107
|
+
else {
|
|
108
|
+
const array = container[key];
|
|
109
|
+
if (!Array.isArray(array)) throw new Error(`patch: ${op.path.join(".")} is not an array`);
|
|
110
|
+
if (op.op === "push") array.push(...clone(op.values));
|
|
111
|
+
else array.length = op.length;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return root;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const isPath = (value: unknown): value is PatchPath =>
|
|
118
|
+
Array.isArray(value) &&
|
|
119
|
+
value.every(
|
|
120
|
+
(key) =>
|
|
121
|
+
typeof key === "string" || (typeof key === "number" && Number.isInteger(key) && key >= 0),
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
/** Structural check of untrusted ops (from disk). */
|
|
125
|
+
export const isPatch = (value: unknown): value is PatchOp[] =>
|
|
126
|
+
Array.isArray(value) &&
|
|
127
|
+
value.every((op) => {
|
|
128
|
+
if (!isPlainObject(op) || !isPath(op.path)) return false;
|
|
129
|
+
if (op.op === "set") return "value" in op;
|
|
130
|
+
if (op.op === "del") return op.path.length > 0;
|
|
131
|
+
if (op.op === "push") return Array.isArray(op.values);
|
|
132
|
+
if (op.op === "cut")
|
|
133
|
+
return typeof op.length === "number" && Number.isInteger(op.length) && op.length >= 0;
|
|
134
|
+
return false;
|
|
135
|
+
});
|