@brainervirus/workit-core 3.0.0 → 4.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/package.json +1 -1
- package/src/core/methods.ts +4 -1
- 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/ledger.ts +12 -18
- 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
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
// Where the task event store lives and which task a checkout's work belongs
|
|
2
|
+
// to (design §4.1, D3, D13).
|
|
3
|
+
//
|
|
4
|
+
// - The store directory is `<git common dir>/workit` in a git repository, so
|
|
5
|
+
// every worktree of the repo shares it and it survives worktree removal and
|
|
6
|
+
// `git clean`; outside git it is `<root>/.workit`.
|
|
7
|
+
// - The implicit task key is the branch name when HEAD is attached,
|
|
8
|
+
// `detached-<hash of the worktree>` on a detached HEAD, and
|
|
9
|
+
// `dir-<hash of the root>` outside git.
|
|
10
|
+
//
|
|
11
|
+
// Per-turn hooks resolve this on every event, so the common layouts (a `.git`
|
|
12
|
+
// directory, or a worktree's `.git` file with its `commondir`) are read from
|
|
13
|
+
// the filesystem. Anything else, or any GIT_* discovery override in the
|
|
14
|
+
// environment, asks git itself.
|
|
15
|
+
import { spawnSync } from "node:child_process";
|
|
16
|
+
import { createHash } from "node:crypto";
|
|
17
|
+
import fs from "node:fs";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
|
|
20
|
+
export type StoreLocation = {
|
|
21
|
+
/** The store directory. */
|
|
22
|
+
dir: string;
|
|
23
|
+
/** True in a git repository (shared by its worktrees). */
|
|
24
|
+
shared: boolean;
|
|
25
|
+
/** The worktree top level (null outside git, or in a bare repository). */
|
|
26
|
+
top: string | null;
|
|
27
|
+
/** The worktree's own git dir, when read from the filesystem. */
|
|
28
|
+
git?: { gitDir: string; top: string | null; reftable: boolean };
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type TaskKey = {
|
|
32
|
+
key: string;
|
|
33
|
+
kind: "branch" | "detached" | "dir";
|
|
34
|
+
/** The branch name when HEAD is attached. */
|
|
35
|
+
branch: string | null;
|
|
36
|
+
/** When a task was bound to this key (stored with the binding). */
|
|
37
|
+
boundAt?: string;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const NOT_A_REPO = /not a git repository \(or any (of the parent directories|parent up to)/u;
|
|
41
|
+
|
|
42
|
+
type GitRun = { ok: boolean; stdout: string; stderr: string; missing: boolean };
|
|
43
|
+
const gitRun = (cwd: string, args: string[]): GitRun => {
|
|
44
|
+
const run = spawnSync("git", args, {
|
|
45
|
+
cwd,
|
|
46
|
+
encoding: "utf8",
|
|
47
|
+
timeout: 20_000,
|
|
48
|
+
killSignal: "SIGKILL",
|
|
49
|
+
windowsHide: true,
|
|
50
|
+
env: {
|
|
51
|
+
...process.env,
|
|
52
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
53
|
+
GIT_OPTIONAL_LOCKS: "0",
|
|
54
|
+
LC_ALL: "C",
|
|
55
|
+
LANGUAGE: "C",
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
const error: NodeJS.ErrnoException | undefined = run.error;
|
|
59
|
+
return {
|
|
60
|
+
ok: run.status === 0,
|
|
61
|
+
stdout: run.stdout ?? "",
|
|
62
|
+
stderr: error ? String(error.message) : (run.stderr ?? ""),
|
|
63
|
+
missing: error?.code === "ENOENT",
|
|
64
|
+
};
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const shortHash = (value: string): string =>
|
|
68
|
+
createHash("sha256").update(value).digest("hex").slice(0, 12);
|
|
69
|
+
|
|
70
|
+
/** The canonical path (the native resolver also expands Windows 8.3 short names). */
|
|
71
|
+
const real = (file: string): string => {
|
|
72
|
+
try {
|
|
73
|
+
return fs.realpathSync.native(file);
|
|
74
|
+
} catch {
|
|
75
|
+
return file;
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/** Environment that changes how git discovers a repository. */
|
|
80
|
+
const DISCOVERY_ENV = [
|
|
81
|
+
"GIT_DIR",
|
|
82
|
+
"GIT_COMMON_DIR",
|
|
83
|
+
"GIT_WORK_TREE",
|
|
84
|
+
"GIT_CEILING_DIRECTORIES",
|
|
85
|
+
"GIT_DISCOVERY_ACROSS_FILESYSTEM",
|
|
86
|
+
];
|
|
87
|
+
|
|
88
|
+
type FastGit =
|
|
89
|
+
| { kind: "none" }
|
|
90
|
+
| { kind: "repo"; common: string; gitDir: string; top: string | null; reftable: boolean };
|
|
91
|
+
|
|
92
|
+
/** A directory that is itself a git dir (a bare repository, or inside `.git`). */
|
|
93
|
+
const isGitDir = (dir: string): boolean => {
|
|
94
|
+
try {
|
|
95
|
+
return (
|
|
96
|
+
fs.statSync(path.join(dir, "HEAD")).isFile() &&
|
|
97
|
+
fs.statSync(path.join(dir, "objects")).isDirectory() &&
|
|
98
|
+
fs.statSync(path.join(dir, "refs")).isDirectory()
|
|
99
|
+
);
|
|
100
|
+
} catch {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const reftableRefs = (common: string): boolean => {
|
|
106
|
+
try {
|
|
107
|
+
return /^\s*refstorage\s*=\s*reftable\s*$/imu.test(
|
|
108
|
+
fs.readFileSync(path.join(common, "config"), "utf8"),
|
|
109
|
+
);
|
|
110
|
+
} catch {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Find the repository the way git does for the plain layouts, without
|
|
117
|
+
* spawning git: from the canonical path (symlinks resolved first, as git
|
|
118
|
+
* does), walk up to the first `.git` or git dir (stopping at a filesystem
|
|
119
|
+
* boundary, like git), then follow a `.git` file and `commondir`. Null when
|
|
120
|
+
* the layout is anything else, so the caller asks git.
|
|
121
|
+
*/
|
|
122
|
+
function fastGit(root: string): FastGit | null {
|
|
123
|
+
if (DISCOVERY_ENV.some((name) => process.env[name])) return null;
|
|
124
|
+
let dir = real(root);
|
|
125
|
+
let device: number | null = null;
|
|
126
|
+
for (;;) {
|
|
127
|
+
let stat: fs.Stats;
|
|
128
|
+
try {
|
|
129
|
+
stat = fs.statSync(dir);
|
|
130
|
+
} catch {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
if (device !== null && stat.dev !== device) return { kind: "none" };
|
|
134
|
+
device = stat.dev;
|
|
135
|
+
const dotGit = path.join(dir, ".git");
|
|
136
|
+
let entry: fs.Stats | null = null;
|
|
137
|
+
try {
|
|
138
|
+
entry = fs.statSync(dotGit);
|
|
139
|
+
} catch {}
|
|
140
|
+
let gitDir: string | null = null;
|
|
141
|
+
let top: string | null = null;
|
|
142
|
+
if (entry) {
|
|
143
|
+
top = dir;
|
|
144
|
+
if (entry.isDirectory()) gitDir = dotGit;
|
|
145
|
+
else if (entry.isFile()) {
|
|
146
|
+
let text = "";
|
|
147
|
+
try {
|
|
148
|
+
text = fs.readFileSync(dotGit, "utf8");
|
|
149
|
+
} catch {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
const match = /^gitdir: (.+)$/mu.exec(text);
|
|
153
|
+
if (!match) return null;
|
|
154
|
+
gitDir = path.resolve(dir, match[1].trim());
|
|
155
|
+
} else return null;
|
|
156
|
+
if (!fs.existsSync(path.join(gitDir, "HEAD"))) return null;
|
|
157
|
+
} else if (isGitDir(dir)) gitDir = dir;
|
|
158
|
+
if (gitDir) {
|
|
159
|
+
let common = gitDir;
|
|
160
|
+
try {
|
|
161
|
+
common = path.resolve(
|
|
162
|
+
gitDir,
|
|
163
|
+
fs.readFileSync(path.join(gitDir, "commondir"), "utf8").trim(),
|
|
164
|
+
);
|
|
165
|
+
} catch {}
|
|
166
|
+
if (!fs.existsSync(path.join(common, "objects"))) return null;
|
|
167
|
+
return {
|
|
168
|
+
kind: "repo",
|
|
169
|
+
common: real(common),
|
|
170
|
+
gitDir: real(gitDir),
|
|
171
|
+
top: top === null ? null : real(top),
|
|
172
|
+
reftable: reftableRefs(common),
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
const parent = path.dirname(dir);
|
|
176
|
+
if (parent === dir) return { kind: "none" };
|
|
177
|
+
dir = parent;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* The store directory for a checkout root, or an error message when git
|
|
183
|
+
* cannot answer for a directory that is (or may be) inside a repository.
|
|
184
|
+
* `top` is the worktree's top level (null for a bare repository or a git
|
|
185
|
+
* dir); a checkout is identified by it, so every subdirectory of a worktree
|
|
186
|
+
* is the same checkout.
|
|
187
|
+
*/
|
|
188
|
+
export function resolveStore(root: string): StoreLocation | Error {
|
|
189
|
+
root = path.resolve(root);
|
|
190
|
+
// A root that is not (yet) a directory has no repository: keep the 2.x
|
|
191
|
+
// per-directory layout, so reads and writes fail or create as before.
|
|
192
|
+
let directory = false;
|
|
193
|
+
try {
|
|
194
|
+
directory = fs.statSync(root).isDirectory();
|
|
195
|
+
} catch {}
|
|
196
|
+
if (!directory) return { dir: path.join(root, ".workit"), shared: false, top: null };
|
|
197
|
+
const fast = fastGit(root);
|
|
198
|
+
if (fast?.kind === "none") return { dir: path.join(root, ".workit"), shared: false, top: null };
|
|
199
|
+
if (fast?.kind === "repo")
|
|
200
|
+
return {
|
|
201
|
+
dir: path.join(fast.common, "workit"),
|
|
202
|
+
shared: true,
|
|
203
|
+
top: fast.top,
|
|
204
|
+
git: { gitDir: fast.gitDir, top: fast.top, reftable: fast.reftable },
|
|
205
|
+
};
|
|
206
|
+
const run = gitRun(root, ["rev-parse", "--path-format=absolute", "--git-common-dir"]);
|
|
207
|
+
const common = run.stdout.trim();
|
|
208
|
+
if (run.ok && common) {
|
|
209
|
+
const top = gitRun(root, ["rev-parse", "--path-format=absolute", "--show-toplevel"]);
|
|
210
|
+
return {
|
|
211
|
+
dir: path.join(real(path.resolve(root, common)), "workit"),
|
|
212
|
+
shared: true,
|
|
213
|
+
top: top.ok && top.stdout.trim() ? real(top.stdout.trim()) : null,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
if (run.missing || (!run.ok && NOT_A_REPO.test(run.stderr)))
|
|
217
|
+
return { dir: path.join(root, ".workit"), shared: false, top: null };
|
|
218
|
+
return new Error(
|
|
219
|
+
`cannot locate the workit store: git rev-parse failed (${run.stderr.trim().split("\n")[0] || "no output"}); fix the repository (git status should work here)`,
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** The branch a rebase in progress will update (HEAD is detached meanwhile). */
|
|
224
|
+
const rebasingBranch = (gitDir: string): string | null => {
|
|
225
|
+
for (const state of ["rebase-merge", "rebase-apply"]) {
|
|
226
|
+
try {
|
|
227
|
+
const ref = fs.readFileSync(path.join(gitDir, state, "head-name"), "utf8").trim();
|
|
228
|
+
const match = /^refs\/heads\/(.+)$/u.exec(ref);
|
|
229
|
+
if (match) return match[1];
|
|
230
|
+
} catch {}
|
|
231
|
+
}
|
|
232
|
+
return null;
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
/** The implicit task key for `root` (see the module comment). */
|
|
236
|
+
export function resolveTaskKey(root: string, location: StoreLocation): TaskKey {
|
|
237
|
+
if (!location.shared) return { key: `dir-${shortHash(root)}`, kind: "dir", branch: null };
|
|
238
|
+
const branchKey = (name: string): TaskKey => ({ key: name, kind: "branch", branch: name });
|
|
239
|
+
// reftable keeps refs out of the filesystem; HEAD is a `refs/heads/.invalid` stub.
|
|
240
|
+
if (location.git && !location.git.reftable) {
|
|
241
|
+
let head = "";
|
|
242
|
+
try {
|
|
243
|
+
head = fs.readFileSync(path.join(location.git.gitDir, "HEAD"), "utf8").trim();
|
|
244
|
+
} catch {}
|
|
245
|
+
const ref = /^ref: refs\/heads\/(.+)$/u.exec(head);
|
|
246
|
+
if (ref && ref[1] !== ".invalid") return branchKey(ref[1]);
|
|
247
|
+
if (/^[0-9a-f]{40,64}$/u.test(head)) {
|
|
248
|
+
const rebasing = rebasingBranch(location.git.gitDir);
|
|
249
|
+
if (rebasing) return branchKey(rebasing);
|
|
250
|
+
return {
|
|
251
|
+
key: `detached-${shortHash(location.git.top ?? location.git.gitDir)}`,
|
|
252
|
+
kind: "detached",
|
|
253
|
+
branch: null,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
const branch = gitRun(root, ["symbolic-ref", "-q", "--short", "HEAD"]);
|
|
258
|
+
const name = branch.ok ? branch.stdout.trim() : "";
|
|
259
|
+
if (name && name !== ".invalid") return branchKey(name);
|
|
260
|
+
const gitDir = gitRun(root, ["rev-parse", "--path-format=absolute", "--git-dir"]);
|
|
261
|
+
if (gitDir.ok && gitDir.stdout.trim()) {
|
|
262
|
+
const rebasing = rebasingBranch(gitDir.stdout.trim());
|
|
263
|
+
if (rebasing) return branchKey(rebasing);
|
|
264
|
+
}
|
|
265
|
+
const top = gitRun(root, ["rev-parse", "--path-format=absolute", "--show-toplevel"]);
|
|
266
|
+
const worktree = top.ok && top.stdout.trim() ? top.stdout.trim() : root;
|
|
267
|
+
return { key: `detached-${shortHash(real(worktree))}`, kind: "detached", branch: null };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* The checkout a path belongs to: its worktree's top level in a git
|
|
272
|
+
* repository (so every subdirectory is the same checkout), else the path.
|
|
273
|
+
*/
|
|
274
|
+
export function checkoutRootOf(root: string, location?: StoreLocation | Error): string {
|
|
275
|
+
const resolved = path.resolve(root);
|
|
276
|
+
let canonical = resolved;
|
|
277
|
+
try {
|
|
278
|
+
canonical = fs.realpathSync(resolved);
|
|
279
|
+
} catch {
|
|
280
|
+
return resolved;
|
|
281
|
+
}
|
|
282
|
+
const found = location ?? resolveStore(canonical);
|
|
283
|
+
if (found instanceof Error || !found.shared || !found.top) return canonical;
|
|
284
|
+
// Keep the caller's spelling (e.g. a Windows short name) when it already is the top.
|
|
285
|
+
if (real(canonical) === found.top) return canonical;
|
|
286
|
+
try {
|
|
287
|
+
return fs.realpathSync(found.top);
|
|
288
|
+
} catch {
|
|
289
|
+
return canonical;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export type BranchRename = { from: string; to: string; /** Unix seconds. */ at: number };
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Branch renames recorded in the reflogs ("Branch: renamed refs/heads/a to
|
|
297
|
+
* refs/heads/b"): every branch log in the common dir, plus this worktree's
|
|
298
|
+
* HEAD log. Oldest first.
|
|
299
|
+
*/
|
|
300
|
+
export function branchRenames(location: StoreLocation): BranchRename[] {
|
|
301
|
+
if (!location.shared) return [];
|
|
302
|
+
const files: string[] = [];
|
|
303
|
+
const walk = (dir: string) => {
|
|
304
|
+
let entries: fs.Dirent[];
|
|
305
|
+
try {
|
|
306
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
307
|
+
} catch {
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
for (const entry of entries)
|
|
311
|
+
if (entry.isDirectory()) walk(path.join(dir, entry.name));
|
|
312
|
+
else files.push(path.join(dir, entry.name));
|
|
313
|
+
};
|
|
314
|
+
walk(path.join(path.dirname(location.dir), "logs", "refs", "heads"));
|
|
315
|
+
if (location.git) files.push(path.join(location.git.gitDir, "logs", "HEAD"));
|
|
316
|
+
const seen = new Set<string>();
|
|
317
|
+
const renames: BranchRename[] = [];
|
|
318
|
+
for (const file of files) {
|
|
319
|
+
let text = "";
|
|
320
|
+
try {
|
|
321
|
+
text = fs.readFileSync(file, "utf8");
|
|
322
|
+
} catch {
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
for (const match of text.matchAll(
|
|
326
|
+
/ (\d+) [+-]\d{4}\tBranch: renamed refs\/heads\/(\S+) to refs\/heads\/(\S+)/gu,
|
|
327
|
+
)) {
|
|
328
|
+
const rename = { from: match[2], to: match[3], at: Number(match[1]) };
|
|
329
|
+
const id = `${rename.at}\0${rename.from}\0${rename.to}`;
|
|
330
|
+
if (seen.has(id)) continue;
|
|
331
|
+
seen.add(id);
|
|
332
|
+
renames.push(rename);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return renames.toSorted((left, right) => left.at - right.at);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* The key a task bound to `key` at `boundAt` (ISO time) follows to: a branch
|
|
340
|
+
* renamed after the task was bound takes its task along, so a new branch
|
|
341
|
+
* created later under the old name does not inherit it.
|
|
342
|
+
*/
|
|
343
|
+
export function followRenames(key: string, boundAt: string, renames: BranchRename[]): string {
|
|
344
|
+
let current = key;
|
|
345
|
+
let since = Math.floor(Date.parse(boundAt) / 1000);
|
|
346
|
+
for (const rename of renames)
|
|
347
|
+
if (rename.from === current && rename.at >= since) {
|
|
348
|
+
current = rename.to;
|
|
349
|
+
since = rename.at;
|
|
350
|
+
}
|
|
351
|
+
return current;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* A filesystem-safe directory name for one checkout's records, from its
|
|
356
|
+
* canonical path, so every spelling of the same directory (symlinks, Windows
|
|
357
|
+
* short names, drive-letter case) maps to one checkout.
|
|
358
|
+
*/
|
|
359
|
+
export const checkoutSlug = (root: string): string => {
|
|
360
|
+
let canonical = real(root);
|
|
361
|
+
if (process.platform === "win32") canonical = canonical.toLowerCase();
|
|
362
|
+
const base =
|
|
363
|
+
path
|
|
364
|
+
.basename(canonical)
|
|
365
|
+
.replace(/[^A-Za-z0-9._-]/g, "_")
|
|
366
|
+
.slice(0, 40) || "root";
|
|
367
|
+
return `${base}-${shortHash(canonical)}`;
|
|
368
|
+
};
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// Pure reduction of a task's events into its state (design §4.1), plus the
|
|
2
|
+
// content-addressed blob encoding for stored candidates.
|
|
3
|
+
//
|
|
4
|
+
// Every event may carry, in `data`:
|
|
5
|
+
// record the whole task record (opening events and compaction checkpoints)
|
|
6
|
+
// ops a structural patch of the record (see patch.ts)
|
|
7
|
+
// key the implicit-task key the task is bound to (null unbinds)
|
|
8
|
+
// legacy where a migrated task came from
|
|
9
|
+
// The reducer applies whatever of these an event carries, so an event type
|
|
10
|
+
// this runtime does not know still replays correctly when it uses them. An
|
|
11
|
+
// unknown event that carries none of them is ignored, unless it is marked
|
|
12
|
+
// `critical`, in which case the reader fails closed.
|
|
13
|
+
import { createHash } from "node:crypto";
|
|
14
|
+
import { LogDamage, type StoreEvent } from "./event-log";
|
|
15
|
+
import { apply, isPatch, type PatchOp } from "./patch";
|
|
16
|
+
import type { TaskKey } from "./paths";
|
|
17
|
+
|
|
18
|
+
export type LegacyOrigin = { path: string; digest: string };
|
|
19
|
+
|
|
20
|
+
export type ReducedTask = {
|
|
21
|
+
record: Record<string, unknown>;
|
|
22
|
+
key: TaskKey | null;
|
|
23
|
+
legacy: LegacyOrigin | null;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** Event types that open (or restart, after compaction) a task's log. */
|
|
27
|
+
export const OPENING_TYPES: ReadonlySet<string> = new Set([
|
|
28
|
+
"task.opened",
|
|
29
|
+
"task.imported",
|
|
30
|
+
"migrated.from_v2",
|
|
31
|
+
"task.checkpoint",
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
/** Event types this runtime writes and reads. */
|
|
35
|
+
const KNOWN_TYPES: ReadonlySet<string> = new Set([
|
|
36
|
+
...OPENING_TYPES,
|
|
37
|
+
"task.bound",
|
|
38
|
+
"task.noted",
|
|
39
|
+
"task.revised",
|
|
40
|
+
"task.judged",
|
|
41
|
+
"task.paused",
|
|
42
|
+
"task.resumed",
|
|
43
|
+
"task.closed",
|
|
44
|
+
"task.updated",
|
|
45
|
+
"check.ran",
|
|
46
|
+
"evidence.recorded",
|
|
47
|
+
"finding.recorded",
|
|
48
|
+
"finding.updated",
|
|
49
|
+
"decision.recorded",
|
|
50
|
+
"decision.updated",
|
|
51
|
+
"worker.updated",
|
|
52
|
+
]);
|
|
53
|
+
|
|
54
|
+
const isObject = (value: unknown): value is Record<string, unknown> =>
|
|
55
|
+
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
56
|
+
|
|
57
|
+
const isKey = (value: unknown): value is TaskKey =>
|
|
58
|
+
isObject(value) &&
|
|
59
|
+
typeof value.key === "string" &&
|
|
60
|
+
(value.kind === "branch" || value.kind === "detached" || value.kind === "dir") &&
|
|
61
|
+
(value.branch === null || typeof value.branch === "string");
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// blobs: stored candidates are written once, by content digest
|
|
65
|
+
|
|
66
|
+
export type BlobStore = {
|
|
67
|
+
read(digest: string): unknown;
|
|
68
|
+
write(digest: string, value: unknown): void;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const BLOB_REF = "$blob";
|
|
72
|
+
const blobRef = (value: unknown): string | null =>
|
|
73
|
+
isObject(value) &&
|
|
74
|
+
Object.keys(value).length === 1 &&
|
|
75
|
+
typeof value[BLOB_REF] === "string" &&
|
|
76
|
+
/^[0-9a-f]{64}$/.test(value[BLOB_REF])
|
|
77
|
+
? value[BLOB_REF]
|
|
78
|
+
: null;
|
|
79
|
+
|
|
80
|
+
export const blobDigest = (value: unknown): string =>
|
|
81
|
+
createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
|
82
|
+
|
|
83
|
+
const toBlob = (value: unknown, blobs: BlobStore): unknown => {
|
|
84
|
+
if (!isObject(value)) return value;
|
|
85
|
+
const digest = blobDigest(value);
|
|
86
|
+
blobs.write(digest, value);
|
|
87
|
+
return { [BLOB_REF]: digest };
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/** Replace candidate bodies in a record with blob references. */
|
|
91
|
+
export const encodeRecord = (
|
|
92
|
+
record: Record<string, unknown>,
|
|
93
|
+
blobs: BlobStore,
|
|
94
|
+
): Record<string, unknown> =>
|
|
95
|
+
Array.isArray(record.candidates)
|
|
96
|
+
? { ...record, candidates: record.candidates.map((item) => toBlob(item, blobs)) }
|
|
97
|
+
: record;
|
|
98
|
+
|
|
99
|
+
/** Replace candidate bodies inside patch ops with blob references. */
|
|
100
|
+
export const encodeOps = (ops: PatchOp[], blobs: BlobStore): PatchOp[] =>
|
|
101
|
+
ops.map((op) => {
|
|
102
|
+
if (op.path[0] !== "candidates") return op;
|
|
103
|
+
if (op.op === "push" && op.path.length === 1)
|
|
104
|
+
return { ...op, values: op.values.map((item) => toBlob(item, blobs)) };
|
|
105
|
+
if (op.op === "set" && op.path.length === 1 && Array.isArray(op.value))
|
|
106
|
+
return { ...op, value: op.value.map((item) => toBlob(item, blobs)) };
|
|
107
|
+
if (op.op === "set" && op.path.length === 2) return { ...op, value: toBlob(op.value, blobs) };
|
|
108
|
+
return op;
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
/** Resolve blob references anywhere in `value`. */
|
|
112
|
+
const decode = (value: unknown, blobs: BlobStore): unknown => {
|
|
113
|
+
const digest = blobRef(value);
|
|
114
|
+
if (digest) return blobs.read(digest);
|
|
115
|
+
if (Array.isArray(value)) return value.map((item) => decode(item, blobs));
|
|
116
|
+
if (isObject(value)) {
|
|
117
|
+
const out: Record<string, unknown> = {};
|
|
118
|
+
for (const [key, item] of Object.entries(value)) out[key] = decode(item, blobs);
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
return value;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
// reduction
|
|
126
|
+
|
|
127
|
+
/** Apply one event to `state` (null before the opening event). */
|
|
128
|
+
export function reduce(
|
|
129
|
+
state: ReducedTask | null,
|
|
130
|
+
event: StoreEvent,
|
|
131
|
+
blobs: BlobStore,
|
|
132
|
+
): ReducedTask | null {
|
|
133
|
+
const data = event.data;
|
|
134
|
+
const known = KNOWN_TYPES.has(event.type);
|
|
135
|
+
const carries = "record" in data || "ops" in data || "key" in data;
|
|
136
|
+
if (!known && !carries) {
|
|
137
|
+
if (event.critical === true)
|
|
138
|
+
throw new LogDamage(
|
|
139
|
+
`event ${event.type} was written by a newer Workit and must be understood; upgrade Workit to read this task`,
|
|
140
|
+
true,
|
|
141
|
+
);
|
|
142
|
+
return state;
|
|
143
|
+
}
|
|
144
|
+
let next = state;
|
|
145
|
+
if ("record" in data) {
|
|
146
|
+
if (!isObject(data.record))
|
|
147
|
+
throw new LogDamage(`event ${event.seq} carries a malformed record`);
|
|
148
|
+
next = {
|
|
149
|
+
record: decode(data.record, blobs) as Record<string, unknown>,
|
|
150
|
+
key: null,
|
|
151
|
+
legacy: null,
|
|
152
|
+
};
|
|
153
|
+
} else if (next === null) {
|
|
154
|
+
throw new LogDamage(`the log does not start with an opening event (found ${event.type})`);
|
|
155
|
+
} else next = { ...next };
|
|
156
|
+
if ("ops" in data) {
|
|
157
|
+
if (!isPatch(data.ops)) throw new LogDamage(`event ${event.seq} carries a malformed patch`);
|
|
158
|
+
try {
|
|
159
|
+
next.record = apply(
|
|
160
|
+
next.record,
|
|
161
|
+
decode(data.ops, blobs) as PatchOp[],
|
|
162
|
+
) as ReducedTask["record"];
|
|
163
|
+
} catch (error) {
|
|
164
|
+
throw new LogDamage(`event ${event.seq} does not apply: ${String(error)}`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
if ("key" in data) next.key = isKey(data.key) ? data.key : null;
|
|
168
|
+
if ("legacy" in data && isObject(data.legacy))
|
|
169
|
+
next.legacy = {
|
|
170
|
+
path: String(data.legacy.path ?? ""),
|
|
171
|
+
digest: String(data.legacy.digest ?? ""),
|
|
172
|
+
};
|
|
173
|
+
return next;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** A readable event type for a mutation (informational; replay does not use it). */
|
|
177
|
+
export function eventType(before: Record<string, unknown>, after: Record<string, unknown>): string {
|
|
178
|
+
const count = (value: unknown) => (Array.isArray(value) ? value.length : 0);
|
|
179
|
+
if (before.status !== after.status)
|
|
180
|
+
return after.status === "closed"
|
|
181
|
+
? "task.closed"
|
|
182
|
+
: after.status === "paused"
|
|
183
|
+
? "task.paused"
|
|
184
|
+
: "task.resumed";
|
|
185
|
+
if (count(after.evidence) > count(before.evidence)) {
|
|
186
|
+
const last = (after.evidence as Array<{ data?: { observation?: unknown } }>).at(-1);
|
|
187
|
+
return last?.data?.observation ? "check.ran" : "evidence.recorded";
|
|
188
|
+
}
|
|
189
|
+
if (count(after.findings) > count(before.findings)) return "finding.recorded";
|
|
190
|
+
if (count(after.decisions) > count(before.decisions)) return "decision.recorded";
|
|
191
|
+
if (JSON.stringify(before.findings) !== JSON.stringify(after.findings)) return "finding.updated";
|
|
192
|
+
if (JSON.stringify(before.decisions) !== JSON.stringify(after.decisions))
|
|
193
|
+
return "decision.updated";
|
|
194
|
+
if (JSON.stringify(before.workers) !== JSON.stringify(after.workers)) return "worker.updated";
|
|
195
|
+
if (JSON.stringify(before.intent) !== JSON.stringify(after.intent)) return "task.revised";
|
|
196
|
+
if (
|
|
197
|
+
JSON.stringify(before.assessments) !== JSON.stringify(after.assessments) ||
|
|
198
|
+
JSON.stringify(before.policy) !== JSON.stringify(after.policy)
|
|
199
|
+
)
|
|
200
|
+
return "task.judged";
|
|
201
|
+
if (JSON.stringify(before.progress) !== JSON.stringify(after.progress)) return "task.noted";
|
|
202
|
+
return "task.updated";
|
|
203
|
+
}
|