@brainervirus/workit-core 2.9.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/README.md +2 -1
- package/package.json +1 -1
- package/scripts/bundled-deps.json +1 -7
- package/scripts/install-codex-plugin.sh +1 -2
- package/scripts/install-cursor-plugin.sh +3 -4
- package/scripts/install-opencode-plugin.sh +2 -3
- package/scripts/install-pi-package.sh +2 -3
- package/scripts/sync-runtime.sh +0 -13
- package/src/core/init.ts +1 -1
- package/src/core/methods.ts +4 -1
- package/src/core/store-lock.ts +13 -2
- package/src/core/support-matrix.ts +4 -4
- 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/workspace-branch-policy.ts +123 -0
- package/src/core.ts +1 -6
- package/src/hooks/context.ts +57 -5
- package/src/hooks/hosts/opencode.ts +2 -2
- 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
- package/scripts/doctor-check.ts +0 -37
- package/src/core/config-conversion.ts +0 -180
- package/src/core/cutover.ts +0 -1789
- package/src/core/detect-hosts.ts +0 -115
- package/src/core/doctor.ts +0 -1882
- package/src/core/host-install.ts +0 -617
- package/src/core/legacy-ownership.ts +0 -47
- package/src/core/registration.ts +0 -306
- package/src/core/setup-state.ts +0 -61
- package/src/core/setup.ts +0 -1582
- package/src/core/uninstall.ts +0 -544
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { isDeepStrictEqual } from "node:util";
|
|
4
|
+
import { detectBranchPolicy } from "./branch-policy";
|
|
5
|
+
import { configDir } from "./config";
|
|
6
|
+
import {
|
|
7
|
+
readWorkspacesResult,
|
|
8
|
+
resolveWorkspaceFrom,
|
|
9
|
+
validateWorkspaceGlob,
|
|
10
|
+
validateWorkspacesDocument,
|
|
11
|
+
workspacesRevision,
|
|
12
|
+
type WorkspaceConfig,
|
|
13
|
+
} from "./workspaces";
|
|
14
|
+
|
|
15
|
+
const readFileSafe = (p: string): string | null => {
|
|
16
|
+
try {
|
|
17
|
+
return readFileSync(p, "utf8");
|
|
18
|
+
} catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
// The one proposal→write path for a workspace branch policy, shared by the
|
|
24
|
+
// OpenCode `workit_init_apply` branch_policy action (init.ts) and the CLI
|
|
25
|
+
// wizard, so both produce byte-identical workspaces.json writes.
|
|
26
|
+
export function applyWorkspaceBranchPolicy(opts: {
|
|
27
|
+
workspace_root: string;
|
|
28
|
+
env?: NodeJS.ProcessEnv;
|
|
29
|
+
}): Record<string, any> {
|
|
30
|
+
const { workspace_root, env = process.env } = opts;
|
|
31
|
+
const dir = path.join(env.WORKFLOW_TOOLKIT_CONFIG ?? configDir());
|
|
32
|
+
const {
|
|
33
|
+
status,
|
|
34
|
+
path: wsPath,
|
|
35
|
+
entries,
|
|
36
|
+
document,
|
|
37
|
+
revision,
|
|
38
|
+
error: workspacesError,
|
|
39
|
+
} = readWorkspacesResult(dir);
|
|
40
|
+
if (status === "malformed" || status === "invalid")
|
|
41
|
+
return { ok: false, error: workspacesError ?? `invalid workspaces.json: ${wsPath}` };
|
|
42
|
+
const detection = detectBranchPolicy(workspace_root);
|
|
43
|
+
const name = String(env.WORKFLOW_BP_NAME ?? path.basename(workspace_root));
|
|
44
|
+
const integration = (env.WORKFLOW_BP_INTEGRATION ?? detection.integration) as "pr" | "merge";
|
|
45
|
+
const policy = {
|
|
46
|
+
preset: detection.preset,
|
|
47
|
+
developBranch: env.WORKFLOW_BP_DEVELOP ?? detection.developBranch ?? undefined,
|
|
48
|
+
prefixes: detection.prefixes,
|
|
49
|
+
allowed: detection.allowed,
|
|
50
|
+
protected: detection.protected,
|
|
51
|
+
integration,
|
|
52
|
+
};
|
|
53
|
+
const glob = `${workspace_root.replace(/[\\/]+$/, "")}/**`;
|
|
54
|
+
if (!validateWorkspaceGlob(glob).ok)
|
|
55
|
+
return { ok: false, error: `invalid workspace glob: ${glob}` };
|
|
56
|
+
let existing: WorkspaceConfig | null;
|
|
57
|
+
try {
|
|
58
|
+
existing = resolveWorkspaceFrom(workspace_root, dir);
|
|
59
|
+
} catch (error) {
|
|
60
|
+
const selectedName = env.WORKFLOW_BP_NAME?.trim();
|
|
61
|
+
if (!selectedName)
|
|
62
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
63
|
+
try {
|
|
64
|
+
existing = resolveWorkspaceFrom(workspace_root, dir, selectedName);
|
|
65
|
+
} catch (selectionError) {
|
|
66
|
+
return {
|
|
67
|
+
ok: false,
|
|
68
|
+
error: selectionError instanceof Error ? selectionError.message : String(selectionError),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (existing?.branchPolicy && isDeepStrictEqual(existing.branchPolicy, policy)) {
|
|
73
|
+
return {
|
|
74
|
+
ok: true,
|
|
75
|
+
status: "already-configured",
|
|
76
|
+
workspace: existing,
|
|
77
|
+
policy,
|
|
78
|
+
config_path: wsPath,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
const next = existing
|
|
82
|
+
? entries.map((w) => (w.name === existing?.name ? { ...w, branchPolicy: policy } : w))
|
|
83
|
+
: [...entries, { name, glob, branchPolicy: policy }];
|
|
84
|
+
if (!existing && entries.some((entry) => entry.name === name))
|
|
85
|
+
return { ok: false, error: `workspace name ${JSON.stringify(name)} is already configured` };
|
|
86
|
+
const proposed = validateWorkspacesDocument({ ...document, workspaces: next }, wsPath);
|
|
87
|
+
if (proposed.status !== "valid") return { ok: false, error: proposed.error };
|
|
88
|
+
if (workspacesRevision(readFileSafe(wsPath)) !== revision)
|
|
89
|
+
return {
|
|
90
|
+
ok: false,
|
|
91
|
+
error: `workspaces.json changed while applying the branch policy: ${wsPath}`,
|
|
92
|
+
};
|
|
93
|
+
mkdirSync(path.dirname(wsPath), { recursive: true });
|
|
94
|
+
const tmp = `${wsPath}.${process.pid}.tmp`;
|
|
95
|
+
try {
|
|
96
|
+
writeFileSync(tmp, JSON.stringify({ ...document, workspaces: next }, null, 2) + "\n", "utf8");
|
|
97
|
+
if (workspacesRevision(readFileSafe(wsPath)) !== revision) {
|
|
98
|
+
rmSync(tmp, { force: true });
|
|
99
|
+
return {
|
|
100
|
+
ok: false,
|
|
101
|
+
error: `workspaces.json changed while applying the branch policy: ${wsPath}`,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
renameSync(tmp, wsPath);
|
|
105
|
+
} catch (error) {
|
|
106
|
+
try {
|
|
107
|
+
rmSync(tmp, { force: true });
|
|
108
|
+
} catch {}
|
|
109
|
+
return {
|
|
110
|
+
ok: false,
|
|
111
|
+
error: `failed to write ${wsPath}: ${error instanceof Error ? error.message : String(error)}`,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
ok: true,
|
|
116
|
+
status: existing ? "updated" : "configured",
|
|
117
|
+
workspace: existing
|
|
118
|
+
? { ...existing, branchPolicy: policy }
|
|
119
|
+
: { name, glob, branchPolicy: policy },
|
|
120
|
+
policy,
|
|
121
|
+
config_path: wsPath,
|
|
122
|
+
};
|
|
123
|
+
}
|
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;
|
|
@@ -4,8 +4,8 @@ import type { HostDescriptor } from "../descriptor";
|
|
|
4
4
|
export const OPENCODE_DESCRIPTOR: HostDescriptor = {
|
|
5
5
|
host: "opencode",
|
|
6
6
|
label: "OpenCode",
|
|
7
|
-
verifiedAgainst: "@opencode
|
|
8
|
-
docs: ["https://opencode.ai/docs/plugins/"],
|
|
7
|
+
verifiedAgainst: "@opencode/plugin 2.0.18",
|
|
8
|
+
docs: ["https://opencode.ai/v2/docs/build/plugins/"],
|
|
9
9
|
transport: "in-process-plugin",
|
|
10
10
|
events: {
|
|
11
11
|
// No separate start event: context is injected on every agent-loop call.
|
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> {
|
|
@@ -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";
|