@astrosheep/keiyaku 2.8.5 → 2.9.1
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 +35 -15
- package/build/.tsbuildinfo +1 -1
- package/build/agents/{call-terms_v2.js → call-terms.js} +12 -8
- package/build/agents/harness/control-types.js +1 -0
- package/build/agents/harness/execution-handle.js +23 -3
- package/build/agents/harness/index.js +6 -607
- package/build/agents/harness/outcome.js +141 -0
- package/build/agents/harness/projection.js +310 -0
- package/build/agents/harness/router.js +201 -0
- package/build/agents/harness/runtime.js +2 -1
- package/build/agents/{launch-snapshot_v2.js → launch-snapshot/model.js} +69 -74
- package/build/agents/launch-snapshot/resolve.js +72 -0
- package/build/agents/opencode-sdk.js +24 -3
- package/build/agents/providers/{claude-agent-sdk.js → claude-agent-sdk/adapter.js} +46 -368
- package/build/agents/providers/claude-agent-sdk/events.js +218 -0
- package/build/agents/providers/claude-agent-sdk/session.js +158 -0
- package/build/agents/providers/codex-app-server/adapter.js +347 -0
- package/build/agents/providers/codex-app-server/events.js +352 -0
- package/build/agents/providers/codex-app-server/session.js +266 -0
- package/build/agents/providers/codex-sdk.js +23 -5
- package/build/agents/providers/opencode-sdk/adapter.js +344 -0
- package/build/agents/providers/opencode-sdk/events.js +354 -0
- package/build/agents/providers/opencode-sdk/session.js +180 -0
- package/build/agents/providers/pi/adapter.js +269 -0
- package/build/agents/providers/pi/checkpoint.js +41 -0
- package/build/agents/providers/pi/events.js +282 -0
- package/build/agents/{selector_v2.js → selector.js} +8 -6
- package/build/cli/commands/akuma/akuma/handler.js +4 -0
- package/build/cli/commands/akuma/akuma/meta.js +16 -0
- package/build/cli/commands/akuma/catalog.js +8 -0
- package/build/cli/commands/akuma/list/handler.js +15 -0
- package/build/cli/commands/akuma/list/meta-ls.js +9 -0
- package/build/cli/commands/akuma/view/handler.js +34 -0
- package/build/cli/commands/akuma/view/meta.js +8 -0
- package/build/cli/commands/akuma.js +1 -1
- package/build/cli/commands/contract/amend/handler.js +10 -0
- package/build/cli/commands/contract/amend/meta.js +12 -0
- package/build/cli/commands/contract/arc/handler.js +9 -0
- package/build/cli/commands/contract/arc/meta.js +26 -0
- package/build/cli/commands/contract/bind/handler.js +23 -0
- package/build/cli/commands/contract/bind/meta.js +30 -0
- package/build/cli/commands/contract/catalog.js +16 -0
- package/build/cli/commands/contract/forfeit/handler.js +13 -0
- package/build/cli/commands/contract/forfeit/meta.js +12 -0
- package/build/cli/commands/contract/log/handler.js +12 -0
- package/build/cli/commands/contract/log/meta.js +12 -0
- package/build/cli/commands/contract/petition/handler.js +26 -0
- package/build/cli/commands/contract/petition/meta.js +23 -0
- package/build/cli/commands/contract/renew/handler.js +10 -0
- package/build/cli/commands/contract/renew/meta.js +17 -0
- package/build/cli/commands/projection/call/handler.js +42 -0
- package/build/cli/commands/projection/call/meta.js +9 -0
- package/build/cli/commands/projection/catalog.js +14 -0
- package/build/cli/commands/projection/kill/handler.js +37 -0
- package/build/cli/commands/projection/kill/meta.js +11 -0
- package/build/cli/commands/projection/revive/handler.js +56 -0
- package/build/cli/commands/projection/revive/meta.js +9 -0
- package/build/cli/commands/projection/status/handler.js +25 -0
- package/build/cli/commands/projection/status/meta.js +9 -0
- package/build/cli/commands/projection/tell/handler.js +36 -0
- package/build/cli/commands/projection/tell/meta.js +9 -0
- package/build/cli/commands/projection/wait/handler.js +23 -0
- package/build/cli/commands/projection/wait/meta.js +11 -0
- package/build/cli/commands/registry.js +100 -0
- package/build/cli/commands/shared.js +71 -0
- package/build/cli/commands/skills/catalog.js +6 -0
- package/build/cli/commands/skills/install/handler.js +10 -0
- package/build/cli/commands/skills/install/meta.js +9 -0
- package/build/cli/commands/skills/skills/handler.js +4 -0
- package/build/cli/commands/skills/skills/meta.js +10 -0
- package/build/cli/commands/system/catalog.js +8 -0
- package/build/cli/commands/system/completion/handler.js +10 -0
- package/build/cli/commands/system/completion/meta.js +9 -0
- package/build/cli/commands/system/dump-env/handler.js +3 -0
- package/build/cli/commands/system/dump-env/meta.js +10 -0
- package/build/cli/commands/system/guide/handler.js +4 -0
- package/build/cli/commands/system/guide/meta.js +9 -0
- package/build/cli/commands/task/add/handler.js +21 -0
- package/build/cli/commands/task/add/meta.js +8 -0
- package/build/cli/commands/task/catalog.js +22 -0
- package/build/cli/commands/task/doctor/handler.js +4 -0
- package/build/cli/commands/task/doctor/meta.js +8 -0
- package/build/cli/commands/task/done/handler.js +17 -0
- package/build/cli/commands/task/done/meta.js +8 -0
- package/build/cli/commands/task/drop/handler.js +6 -0
- package/build/cli/commands/task/drop/meta.js +8 -0
- package/build/cli/commands/task/ls/handler.js +13 -0
- package/build/cli/commands/task/ls/meta.js +8 -0
- package/build/cli/commands/task/shared.js +131 -0
- package/build/cli/commands/task/start/handler.js +6 -0
- package/build/cli/commands/task/start/meta.js +8 -0
- package/build/cli/commands/task/stop/handler.js +17 -0
- package/build/cli/commands/task/stop/meta.js +8 -0
- package/build/cli/commands/task/task/handler.js +5 -0
- package/build/cli/commands/task/task/meta.js +11 -0
- package/build/cli/commands/task/update/handler.js +18 -0
- package/build/cli/commands/task/update/meta.js +8 -0
- package/build/cli/commands/task/view/handler.js +14 -0
- package/build/cli/commands/task/view/meta.js +8 -0
- package/build/cli/completion.js +26 -27
- package/build/cli/flags.js +79 -2
- package/build/cli/help.js +27 -49
- package/build/cli/index.js +121 -374
- package/build/cli/output-stream-error-policy.js +17 -0
- package/build/cli/parse-flags.js +197 -0
- package/build/cli/parse-metadata.js +129 -0
- package/build/cli/parse-selectors.js +65 -0
- package/build/cli/parse.js +46 -313
- package/build/cli/projection-address.js +5 -0
- package/build/cli/render/arc.js +1 -1
- package/build/cli/render/errors.js +4 -3
- package/build/cli/render/format.js +14 -0
- package/build/cli/render/kanshi.js +106 -0
- package/build/cli/render/petition.js +24 -1
- package/build/cli/render/projection-activity.js +5 -1
- package/build/cli/render/shared.js +2 -2
- package/build/cli/render/status.js +79 -13
- package/build/cli/render/task.js +113 -0
- package/build/cli/render/wait.js +23 -3
- package/build/cli/types.js +1 -1
- package/build/config/{akuma-loader_v2.js → akuma-loader.js} +7 -4
- package/build/config/env.js +9 -4
- package/build/config/settings/disease.js +26 -0
- package/build/config/settings/knobs.js +8 -0
- package/build/config/settings/loader.js +244 -0
- package/build/config/settings/schema.js +276 -0
- package/build/core/addressing.js +1 -1
- package/build/core/amend.js +5 -8
- package/build/core/arc.js +7 -8
- package/build/core/bind-workspace.js +123 -0
- package/build/core/bind.js +75 -155
- package/build/core/call/call.js +229 -0
- package/build/core/call/context.js +251 -0
- package/build/core/call/execution.js +217 -0
- package/build/core/call/prompt.js +42 -0
- package/build/core/call/reference.js +24 -0
- package/build/core/call-persist.js +10 -3
- package/build/core/claim-delivery.js +247 -0
- package/build/core/claim.js +39 -277
- package/build/core/context.js +2 -1
- package/build/core/{render.js → contract-view.js} +12 -140
- package/build/core/draft.js +107 -9
- package/build/core/entry.js +9 -1
- package/build/core/execution-coordinate.js +4 -20
- package/build/core/execution-pact.js +16 -31
- package/build/core/forfeit.js +35 -22
- package/build/core/hints.js +2 -2
- package/build/core/log.js +2 -2
- package/build/core/path-coordinate.js +27 -0
- package/build/core/petition-claim-gates.js +8 -6
- package/build/core/petition-preview.js +97 -0
- package/build/core/petition-run.js +5 -7
- package/build/core/petition.js +39 -26
- package/build/core/projection/generation/database.js +306 -0
- package/build/core/projection/generation/identity.js +84 -0
- package/build/core/projection/generation/model.js +201 -0
- package/build/core/projection/generation/store.js +73 -0
- package/build/core/projection/generation/transitions.js +258 -0
- package/build/core/projection/heart.js +0 -307
- package/build/core/projection/tell/database.js +109 -0
- package/build/core/projection/tell/model.js +97 -0
- package/build/core/{projection-tells.js → projection/tell/store.js} +3 -158
- package/build/core/projection-activity.js +23 -2
- package/build/core/projection-coordinate.js +14 -2
- package/build/core/projection-core.js +2 -1
- package/build/core/projection-execution-observer.js +259 -0
- package/build/core/projection-generation-continuation.js +1 -1
- package/build/core/projection-generation-execution.js +90 -20
- package/build/core/projection-generation-launcher.js +1 -1
- package/build/core/projection-generation-runner.js +198 -4
- package/build/core/projection-identity.js +1 -1
- package/build/core/projection-kill.js +50 -17
- package/build/core/projection-life-observer.js +1 -1
- package/build/core/projection-life-protocol.js +10 -0
- package/build/core/projection-mint.js +1 -1
- package/build/core/projection-status.js +45 -32
- package/build/core/projection-wait.js +12 -2
- package/build/core/projection-wake.js +5 -2
- package/build/core/{queue_v2.js → queue.js} +2 -1
- package/build/core/registry.js +1 -1
- package/build/core/renew.js +9 -11
- package/build/core/response-artifact-catalog-sqlite.js +112 -0
- package/build/core/response-artifact-catalog.js +7 -0
- package/build/core/seal.js +44 -16
- package/build/core/status/board.js +289 -0
- package/build/core/status/drift.js +99 -0
- package/build/core/status/lifecycle.js +84 -0
- package/build/core/status/reconciliation.js +131 -0
- package/build/core/stored-agent-event.js +28 -4
- package/build/core/target-ref.js +10 -0
- package/build/core/task/board.js +175 -0
- package/build/core/task/commands.js +211 -0
- package/build/core/task/document.js +189 -0
- package/build/core/task/model.js +1 -0
- package/build/core/task/settlement-git.js +207 -0
- package/build/core/task/settlement-policy.js +82 -0
- package/build/core/task/source-board.js +84 -0
- package/build/core/task/source-model.js +1 -0
- package/build/core/task/validation.js +44 -0
- package/build/core/task-contract.js +237 -0
- package/build/core/task-doctor.js +14 -0
- package/build/core/task-git-actor.js +38 -0
- package/build/core/task-git-runtime.js +37 -0
- package/build/core/task-git-store.js +385 -0
- package/build/core/task-repository.js +1 -0
- package/build/core/task-store-repository.js +86 -0
- package/build/core/task-store.js +1 -0
- package/build/core/task-worktree.js +51 -0
- package/build/core/task.js +4 -0
- package/build/core/transcripts.js +138 -56
- package/build/core/validation.js +17 -0
- package/build/core/verdict.js +2 -2
- package/build/core/worktree-path.js +33 -8
- package/build/flow-error.js +5 -2
- package/build/generated/version.js +1 -1
- package/build/git/branches.js +33 -9
- package/build/git/commits.js +3 -0
- package/build/git/core.js +9 -0
- package/build/git/diff/parsers.js +0 -94
- package/build/index.js +2 -0
- package/build/keiyaku-constants.js +2 -0
- package/build/keiyaku.js +4 -3
- package/package.json +3 -2
- package/skills/keiyaku/SKILL.md +30 -13
- package/skills/keiyaku-akuma/SKILL.md +30 -57
- package/skills/keiyaku-task/SKILL.md +51 -0
- package/skills/keiyaku-workflow/SKILL.md +71 -0
- package/build/agents/harness/pump.js +0 -158
- package/build/agents/providers/codex-app-server.js +0 -864
- package/build/agents/providers/opencode-sdk.js +0 -363
- package/build/agents/providers/pi.js +0 -557
- package/build/config/settings.js +0 -533
- package/build/core/call.js +0 -686
- package/build/core/projection/generation.js +0 -412
- package/build/core/projection/identity.js +0 -81
- package/build/core/projection/mint.js +0 -174
- package/build/core/projection-generation-store.js +0 -707
- package/build/core/status.js +0 -539
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import * as fs from "node:fs";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { DatabaseSync } from "node:sqlite";
|
|
6
|
+
import { FlowError } from "../flow-error.js";
|
|
7
|
+
import { isGitExecutableUnavailable, NOT_GIT_REPOSITORY_PATTERNS } from "../git/core.js";
|
|
8
|
+
import { gitCommonRootPath, normalizeGitPath } from "./path-coordinate.js";
|
|
9
|
+
const TASK_ID = /^k-[0-9a-f]{12}$/;
|
|
10
|
+
const SQLITE_BUSY = 5;
|
|
11
|
+
const LOCK_FILE = "task-lock.sqlite";
|
|
12
|
+
const GIT_LOCK_FILE = "keiyaku-task-lock.sqlite";
|
|
13
|
+
export class TaskLockHeldError extends Error {
|
|
14
|
+
path;
|
|
15
|
+
code = "TASK_LOCK_HELD";
|
|
16
|
+
constructor(path, options) {
|
|
17
|
+
super(`task lock is already held: ${path}`, options);
|
|
18
|
+
this.path = path;
|
|
19
|
+
this.name = "TaskLockHeldError";
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function errorText(error) {
|
|
23
|
+
return error instanceof Error ? error.message : String(error);
|
|
24
|
+
}
|
|
25
|
+
function isSqliteBusy(error) {
|
|
26
|
+
return typeof error === "object"
|
|
27
|
+
&& error !== null
|
|
28
|
+
&& "errcode" in error
|
|
29
|
+
&& error.errcode === SQLITE_BUSY;
|
|
30
|
+
}
|
|
31
|
+
function defaultRunGit(cwd, args, input) {
|
|
32
|
+
return spawnSync("git", [...args], {
|
|
33
|
+
cwd,
|
|
34
|
+
input,
|
|
35
|
+
encoding: "utf8",
|
|
36
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: "0", LC_ALL: "C" },
|
|
37
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
function nearestDirectoryBoard(cwd) {
|
|
41
|
+
const original = path.resolve(cwd);
|
|
42
|
+
let candidate = original;
|
|
43
|
+
while (true) {
|
|
44
|
+
if (lstatIfPresent(path.join(candidate, ".keiyaku")))
|
|
45
|
+
return candidate;
|
|
46
|
+
const parent = path.dirname(candidate);
|
|
47
|
+
if (parent === candidate)
|
|
48
|
+
return original;
|
|
49
|
+
candidate = parent;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
export function taskBoardRootFromGitDir(rawGitDir, platform = process.platform) {
|
|
53
|
+
return gitCommonRootPath(rawGitDir, platform);
|
|
54
|
+
}
|
|
55
|
+
export async function locateTaskBoard(cwd, runGitProbe = defaultRunGit) {
|
|
56
|
+
const probe = runGitProbe(cwd, ["rev-parse", "--is-inside-work-tree", "--is-bare-repository"]);
|
|
57
|
+
if (probe.error && !isGitExecutableUnavailable(probe.error)) {
|
|
58
|
+
throw new FlowError("INTERNAL_STATE", `cannot inspect task repository: ${probe.error.message}`, { cause: probe.error });
|
|
59
|
+
}
|
|
60
|
+
if (probe.error) {
|
|
61
|
+
return { mode: "directory", root: nearestDirectoryBoard(cwd), capability: "unavailable" };
|
|
62
|
+
}
|
|
63
|
+
if (probe.status !== 0) {
|
|
64
|
+
if (NOT_GIT_REPOSITORY_PATTERNS.some((pattern) => probe.stderr.includes(pattern))) {
|
|
65
|
+
return { mode: "directory", root: nearestDirectoryBoard(cwd) };
|
|
66
|
+
}
|
|
67
|
+
const detail = probe.stderr.trim() || `exit ${String(probe.status)}`;
|
|
68
|
+
throw new FlowError("INTERNAL_STATE", `cannot inspect task repository: ${detail}`);
|
|
69
|
+
}
|
|
70
|
+
const [insideWorktree, bare] = probe.stdout.trim().split(/\r?\n/);
|
|
71
|
+
if (bare === "true" || insideWorktree !== "true") {
|
|
72
|
+
throw new FlowError("NOT_GIT_REPO", "bare repositories are not supported for task storage");
|
|
73
|
+
}
|
|
74
|
+
const facts = runGitProbe(cwd, ["rev-parse", "--path-format=absolute", "--show-toplevel", "--git-common-dir"]);
|
|
75
|
+
if (facts.error) {
|
|
76
|
+
throw new FlowError("INTERNAL_STATE", `git rev-parse failed: ${facts.error.message}`, { cause: facts.error });
|
|
77
|
+
}
|
|
78
|
+
if (facts.status !== 0) {
|
|
79
|
+
const detail = facts.stderr.trim() || `exit ${String(facts.status)}`;
|
|
80
|
+
throw new FlowError("INTERNAL_STATE", `git rev-parse failed: ${detail}`);
|
|
81
|
+
}
|
|
82
|
+
const [checkoutRoot, rawGitDir] = facts.stdout.trimEnd().split(/\r?\n/);
|
|
83
|
+
const gitDir = rawGitDir === undefined ? undefined : normalizeGitPath(rawGitDir);
|
|
84
|
+
const normalizedCheckoutRoot = checkoutRoot === undefined ? undefined : normalizeGitPath(checkoutRoot);
|
|
85
|
+
if (!normalizedCheckoutRoot || !gitDir || !path.isAbsolute(normalizedCheckoutRoot) || !path.isAbsolute(gitDir)) {
|
|
86
|
+
throw new FlowError("INTERNAL_STATE", "git returned incomplete task-board location facts");
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
mode: "git",
|
|
90
|
+
root: taskBoardRootFromGitDir(gitDir),
|
|
91
|
+
gitDir,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
function taskDirectory(board) {
|
|
95
|
+
return path.join(board.root, ".keiyaku", "tasks");
|
|
96
|
+
}
|
|
97
|
+
function assertKeiyakuDirectory(board) {
|
|
98
|
+
const keiyakuDirectory = path.join(board.root, ".keiyaku");
|
|
99
|
+
const stat = lstatIfPresent(keiyakuDirectory);
|
|
100
|
+
if (stat && (stat.isSymbolicLink() || !stat.isDirectory())) {
|
|
101
|
+
throw new FlowError("INTERNAL_STATE", `.keiyaku is not a regular directory: ${keiyakuDirectory}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
function taskLockPath(board) {
|
|
105
|
+
return board.mode === "git"
|
|
106
|
+
? path.join(board.gitDir, GIT_LOCK_FILE)
|
|
107
|
+
: path.join(board.root, ".keiyaku", LOCK_FILE);
|
|
108
|
+
}
|
|
109
|
+
function closeQuietly(database) {
|
|
110
|
+
try {
|
|
111
|
+
database.close();
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
// The original operation remains authoritative.
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
function acquireTaskLock(lockPath) {
|
|
118
|
+
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
|
|
119
|
+
let database;
|
|
120
|
+
try {
|
|
121
|
+
database = new DatabaseSync(lockPath);
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
throw new FlowError("INTERNAL_STATE", `cannot open task lock: ${errorText(error)}`, { cause: error });
|
|
125
|
+
}
|
|
126
|
+
try {
|
|
127
|
+
database.exec("PRAGMA busy_timeout=0");
|
|
128
|
+
database.exec("PRAGMA journal_mode=DELETE");
|
|
129
|
+
database.exec("CREATE TABLE IF NOT EXISTS lock_anchor (id INTEGER PRIMARY KEY CHECK (id = 1))");
|
|
130
|
+
database.exec("BEGIN EXCLUSIVE");
|
|
131
|
+
database.prepare("SELECT id FROM lock_anchor LIMIT 1").get();
|
|
132
|
+
return database;
|
|
133
|
+
}
|
|
134
|
+
catch (error) {
|
|
135
|
+
closeQuietly(database);
|
|
136
|
+
if (isSqliteBusy(error))
|
|
137
|
+
throw new TaskLockHeldError(lockPath, { cause: error });
|
|
138
|
+
throw new FlowError("INTERNAL_STATE", `cannot acquire task lock: ${errorText(error)}`, { cause: error });
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
export async function inspectTaskLock(cwd) {
|
|
142
|
+
const lockPath = taskLockPath(await locateTaskBoard(cwd));
|
|
143
|
+
if (!fs.existsSync(lockPath))
|
|
144
|
+
return { state: "free", path: lockPath };
|
|
145
|
+
let database;
|
|
146
|
+
try {
|
|
147
|
+
database = new DatabaseSync(lockPath, { readOnly: true });
|
|
148
|
+
}
|
|
149
|
+
catch (error) {
|
|
150
|
+
throw new FlowError("INTERNAL_STATE", `cannot inspect task lock: ${errorText(error)}`, { cause: error });
|
|
151
|
+
}
|
|
152
|
+
try {
|
|
153
|
+
database.exec("PRAGMA busy_timeout=0");
|
|
154
|
+
database.exec("BEGIN DEFERRED");
|
|
155
|
+
database.prepare("SELECT id FROM lock_anchor LIMIT 1").get();
|
|
156
|
+
database.exec("ROLLBACK");
|
|
157
|
+
return { state: "free", path: lockPath };
|
|
158
|
+
}
|
|
159
|
+
catch (error) {
|
|
160
|
+
if (isSqliteBusy(error))
|
|
161
|
+
return { state: "held", path: lockPath };
|
|
162
|
+
throw new FlowError("INTERNAL_STATE", `cannot inspect task lock: ${errorText(error)}`, { cause: error });
|
|
163
|
+
}
|
|
164
|
+
finally {
|
|
165
|
+
closeQuietly(database);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
export function atomicReplaceTaskFile(filePath, text, _deps) {
|
|
169
|
+
const directory = path.dirname(filePath);
|
|
170
|
+
fs.mkdirSync(directory, { recursive: true });
|
|
171
|
+
const temporary = path.join(directory, `.${path.basename(filePath)}.${process.pid}.${randomBytes(12).toString("hex")}.tmp`);
|
|
172
|
+
let descriptor;
|
|
173
|
+
try {
|
|
174
|
+
descriptor = fs.openSync(temporary, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL, 0o644);
|
|
175
|
+
fs.writeFileSync(descriptor, text, { encoding: "utf8" });
|
|
176
|
+
fs.fsyncSync(descriptor);
|
|
177
|
+
fs.closeSync(descriptor);
|
|
178
|
+
descriptor = undefined;
|
|
179
|
+
fs.renameSync(temporary, filePath);
|
|
180
|
+
fsyncDirectory(directory);
|
|
181
|
+
}
|
|
182
|
+
catch (error) {
|
|
183
|
+
if (descriptor !== undefined) {
|
|
184
|
+
try {
|
|
185
|
+
fs.closeSync(descriptor);
|
|
186
|
+
}
|
|
187
|
+
catch { /* preserve original failure */ }
|
|
188
|
+
}
|
|
189
|
+
try {
|
|
190
|
+
fs.unlinkSync(temporary);
|
|
191
|
+
}
|
|
192
|
+
catch { /* absent after rename or already cleaned */ }
|
|
193
|
+
throw error;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
function fsyncDirectory(directory) {
|
|
197
|
+
const descriptor = fs.openSync(directory, fs.constants.O_RDONLY);
|
|
198
|
+
try {
|
|
199
|
+
fs.fsyncSync(descriptor);
|
|
200
|
+
}
|
|
201
|
+
finally {
|
|
202
|
+
fs.closeSync(descriptor);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
function lstatIfPresent(target) {
|
|
206
|
+
try {
|
|
207
|
+
return fs.lstatSync(target);
|
|
208
|
+
}
|
|
209
|
+
catch (error) {
|
|
210
|
+
if (error.code === "ENOENT")
|
|
211
|
+
return undefined;
|
|
212
|
+
throw error;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
async function readTaskSources(directory) {
|
|
216
|
+
const directoryStat = lstatIfPresent(directory);
|
|
217
|
+
if (!directoryStat)
|
|
218
|
+
return [];
|
|
219
|
+
if (directoryStat.isSymbolicLink() || !directoryStat.isDirectory()) {
|
|
220
|
+
throw new FlowError("INTERNAL_STATE", `task directory is not a regular directory: ${directory}`);
|
|
221
|
+
}
|
|
222
|
+
const names = fs.readdirSync(directory)
|
|
223
|
+
.filter((name) => name.endsWith(".md"))
|
|
224
|
+
.sort((left, right) => Buffer.from(left).compare(Buffer.from(right)));
|
|
225
|
+
return names.map((name) => {
|
|
226
|
+
const sourcePath = path.join(directory, name);
|
|
227
|
+
const sourceStat = fs.lstatSync(sourcePath);
|
|
228
|
+
if (sourceStat.isSymbolicLink() || !sourceStat.isFile()) {
|
|
229
|
+
throw new FlowError("INTERNAL_STATE", `task source is not a regular file: ${sourcePath}`);
|
|
230
|
+
}
|
|
231
|
+
return {
|
|
232
|
+
path: sourcePath,
|
|
233
|
+
text: fs.readFileSync(sourcePath, "utf8"),
|
|
234
|
+
};
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
function validatePlan(plan) {
|
|
238
|
+
validateWrites(plan.writes);
|
|
239
|
+
}
|
|
240
|
+
function validateWrites(writes) {
|
|
241
|
+
if (writes.length === 0) {
|
|
242
|
+
throw new FlowError("INTERNAL_STATE", "task mutation must contain at least one write");
|
|
243
|
+
}
|
|
244
|
+
const targets = new Set();
|
|
245
|
+
for (const write of writes) {
|
|
246
|
+
const target = `${write.id}.md`;
|
|
247
|
+
if (!TASK_ID.test(write.id)) {
|
|
248
|
+
throw new FlowError("INTERNAL_STATE", `invalid task mutation id: ${write.id}`);
|
|
249
|
+
}
|
|
250
|
+
if (targets.has(target))
|
|
251
|
+
throw new FlowError("INTERNAL_STATE", `duplicate task mutation target: ${target}`);
|
|
252
|
+
targets.add(target);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
function mutationTarget(write) {
|
|
256
|
+
return `${write.id}.md`;
|
|
257
|
+
}
|
|
258
|
+
function capturePreviousFiles(directory, writes) {
|
|
259
|
+
return writes.map((write) => {
|
|
260
|
+
const destination = path.join(directory, mutationTarget(write));
|
|
261
|
+
const stat = lstatIfPresent(destination);
|
|
262
|
+
if (!stat)
|
|
263
|
+
return { path: destination };
|
|
264
|
+
if (stat.isSymbolicLink() || !stat.isFile()) {
|
|
265
|
+
throw new FlowError("INTERNAL_STATE", `task destination is not a regular file: ${destination}`);
|
|
266
|
+
}
|
|
267
|
+
return { path: destination, text: fs.readFileSync(destination, "utf8") };
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
function sameSources(left, right) {
|
|
271
|
+
return left.length === right.length && left.every((source, index) => {
|
|
272
|
+
const candidate = right[index];
|
|
273
|
+
return candidate !== undefined && source.path === candidate.path && source.text === candidate.text;
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
function removeTaskFile(filePath) {
|
|
277
|
+
try {
|
|
278
|
+
fs.unlinkSync(filePath);
|
|
279
|
+
fsyncDirectory(path.dirname(filePath));
|
|
280
|
+
}
|
|
281
|
+
catch (error) {
|
|
282
|
+
if (error.code !== "ENOENT")
|
|
283
|
+
throw error;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
function restoreFiles(previous, deps) {
|
|
287
|
+
for (const entry of previous) {
|
|
288
|
+
if (entry.text === undefined)
|
|
289
|
+
removeTaskFile(entry.path);
|
|
290
|
+
else
|
|
291
|
+
(deps.atomicReplace ?? atomicReplaceTaskFile)(entry.path, entry.text);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
function writeTaskFile(directory, write, deps) {
|
|
295
|
+
const destination = path.join(directory, mutationTarget(write));
|
|
296
|
+
(deps.atomicReplace ?? atomicReplaceTaskFile)(destination, write.text);
|
|
297
|
+
}
|
|
298
|
+
function releaseTaskLock(database, deps) {
|
|
299
|
+
try {
|
|
300
|
+
if (deps.releaseLock)
|
|
301
|
+
deps.releaseLock();
|
|
302
|
+
else
|
|
303
|
+
database.exec("COMMIT");
|
|
304
|
+
}
|
|
305
|
+
catch {
|
|
306
|
+
try {
|
|
307
|
+
database.exec("ROLLBACK");
|
|
308
|
+
}
|
|
309
|
+
catch { /* close releases the advisory lock */ }
|
|
310
|
+
}
|
|
311
|
+
finally {
|
|
312
|
+
closeQuietly(database);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
function assertTaskDirectorySafety(directory) {
|
|
316
|
+
const stat = lstatIfPresent(directory);
|
|
317
|
+
if (stat && (stat.isSymbolicLink() || !stat.isDirectory())) {
|
|
318
|
+
throw new FlowError("INTERNAL_STATE", `task directory is not a regular directory: ${directory}`);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
export async function openTaskStore(cwd, deps = {}) {
|
|
322
|
+
const board = await locateTaskBoard(cwd, deps.runGit);
|
|
323
|
+
const directory = taskDirectory(board);
|
|
324
|
+
return {
|
|
325
|
+
mode: board.mode,
|
|
326
|
+
read: async () => {
|
|
327
|
+
assertKeiyakuDirectory(board);
|
|
328
|
+
return readTaskSources(directory);
|
|
329
|
+
},
|
|
330
|
+
async mutate(apply) {
|
|
331
|
+
return withLockHeldTaskBoard(cwd, async (transaction) => {
|
|
332
|
+
const plan = await apply(transaction.sources);
|
|
333
|
+
validatePlan(plan);
|
|
334
|
+
await transaction.replace(plan.writes);
|
|
335
|
+
return plan.value;
|
|
336
|
+
}, deps, board);
|
|
337
|
+
},
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
export async function withLockHeldTaskBoard(cwd, apply, deps = {}, locatedBoard) {
|
|
341
|
+
const board = locatedBoard ?? await locateTaskBoard(cwd, deps.runGit);
|
|
342
|
+
const directory = taskDirectory(board);
|
|
343
|
+
assertKeiyakuDirectory(board);
|
|
344
|
+
const database = acquireTaskLock(taskLockPath(board));
|
|
345
|
+
try {
|
|
346
|
+
let sources = await readTaskSources(directory);
|
|
347
|
+
const transaction = {
|
|
348
|
+
location: board,
|
|
349
|
+
get sources() {
|
|
350
|
+
return sources;
|
|
351
|
+
},
|
|
352
|
+
async replace(writes) {
|
|
353
|
+
validateWrites(writes);
|
|
354
|
+
assertKeiyakuDirectory(board);
|
|
355
|
+
assertTaskDirectorySafety(directory);
|
|
356
|
+
const currentSources = await readTaskSources(directory);
|
|
357
|
+
if (!sameSources(sources, currentSources)) {
|
|
358
|
+
throw new FlowError("CONCURRENT_MODIFICATION", "task sources changed while applying the mutation");
|
|
359
|
+
}
|
|
360
|
+
assertKeiyakuDirectory(board);
|
|
361
|
+
assertTaskDirectorySafety(directory);
|
|
362
|
+
const previous = capturePreviousFiles(directory, writes);
|
|
363
|
+
let applied = 0;
|
|
364
|
+
try {
|
|
365
|
+
for (const write of writes) {
|
|
366
|
+
writeTaskFile(directory, write, deps);
|
|
367
|
+
applied += 1;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
catch (error) {
|
|
371
|
+
try {
|
|
372
|
+
restoreFiles(previous.slice(0, applied + 1), deps);
|
|
373
|
+
}
|
|
374
|
+
catch { /* the original replacement failure remains authoritative */ }
|
|
375
|
+
throw error;
|
|
376
|
+
}
|
|
377
|
+
sources = await readTaskSources(directory);
|
|
378
|
+
},
|
|
379
|
+
};
|
|
380
|
+
return await apply(transaction);
|
|
381
|
+
}
|
|
382
|
+
finally {
|
|
383
|
+
releaseTaskLock(database, deps);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { requireHealthy } from "./task/board.js";
|
|
2
|
+
import { parseTaskDocument, serializeTaskDocument } from "./task/document.js";
|
|
3
|
+
import { buildTaskSourceBoard } from "./task/source-board.js";
|
|
4
|
+
function taskBoardFromSourceBoard(sourceBoard) {
|
|
5
|
+
const tasks = sourceBoard.tasks.map((task) => ({
|
|
6
|
+
id: task.id,
|
|
7
|
+
title: task.title,
|
|
8
|
+
state: task.state,
|
|
9
|
+
pri: task.pri,
|
|
10
|
+
needs: [...task.needs],
|
|
11
|
+
parent: task.parent,
|
|
12
|
+
from: [...task.from],
|
|
13
|
+
createdAt: task.createdAt,
|
|
14
|
+
creator: task.creator,
|
|
15
|
+
...(task.startedAt === undefined ? {} : { startedAt: task.startedAt }),
|
|
16
|
+
...(task.contractId === undefined ? {} : { contractId: task.contractId }),
|
|
17
|
+
body: task.body,
|
|
18
|
+
blocks: [...task.blocks],
|
|
19
|
+
blocked: task.blocked,
|
|
20
|
+
releasedNeeds: [...task.releasedNeeds],
|
|
21
|
+
}));
|
|
22
|
+
return {
|
|
23
|
+
tasks,
|
|
24
|
+
diseases: sourceBoard.diseases.map((disease) => ({
|
|
25
|
+
code: disease.code,
|
|
26
|
+
message: disease.message,
|
|
27
|
+
...(disease.id === undefined ? {} : { id: disease.id }),
|
|
28
|
+
...(disease.relatedIds === undefined ? {} : { relatedIds: [...disease.relatedIds] }),
|
|
29
|
+
})),
|
|
30
|
+
byId: new Map(tasks.map((task) => [task.id, task])),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
export function prepareTaskDocumentReplacements(sources, replacements) {
|
|
34
|
+
const serialized = replacements.map((document) => ({
|
|
35
|
+
id: document.id,
|
|
36
|
+
text: serializeTaskDocument(document),
|
|
37
|
+
}));
|
|
38
|
+
const replacementIds = new Set(serialized.map((replacement) => replacement.id));
|
|
39
|
+
const paths = new Map();
|
|
40
|
+
const retained = sources.filter((source) => {
|
|
41
|
+
try {
|
|
42
|
+
const id = parseTaskDocument(source).id;
|
|
43
|
+
paths.set(id, source.path);
|
|
44
|
+
return !replacementIds.has(id);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
// Malformed sources remain quarantined and visible to the resulting board.
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
const replacementSources = serialized.map((replacement) => ({
|
|
52
|
+
path: paths.get(replacement.id) ?? `${replacement.id}.md`,
|
|
53
|
+
text: replacement.text,
|
|
54
|
+
}));
|
|
55
|
+
const resultingSources = [...retained, ...replacementSources];
|
|
56
|
+
const board = taskBoardFromSourceBoard(buildTaskSourceBoard(resultingSources));
|
|
57
|
+
requireHealthy(board);
|
|
58
|
+
return {
|
|
59
|
+
sources: resultingSources,
|
|
60
|
+
writes: serialized,
|
|
61
|
+
board,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
function mutationPlan(sources, mutation) {
|
|
65
|
+
const prepared = prepareTaskDocumentReplacements(sources, mutation.replacements);
|
|
66
|
+
return {
|
|
67
|
+
writes: prepared.writes,
|
|
68
|
+
value: mutation.select(prepared.board),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
export function taskRepositoryFromStore(store) {
|
|
72
|
+
return {
|
|
73
|
+
read: async () => taskBoardFromSourceBoard(buildTaskSourceBoard(await store.read())),
|
|
74
|
+
mutate: async (apply) => store.mutate(async (sources) => mutationPlan(sources, await apply(taskBoardFromSourceBoard(buildTaskSourceBoard(sources))))),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
export function taskRepositoryFromLockedBoard(transaction) {
|
|
78
|
+
return {
|
|
79
|
+
read: async () => taskBoardFromSourceBoard(buildTaskSourceBoard(transaction.sources)),
|
|
80
|
+
mutate: async (apply) => {
|
|
81
|
+
const plan = mutationPlan(transaction.sources, await apply(taskBoardFromSourceBoard(buildTaskSourceBoard(transaction.sources))));
|
|
82
|
+
await transaction.replace(plan.writes);
|
|
83
|
+
return plan.value;
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { FlowError } from "../flow-error.js";
|
|
2
|
+
function documentFacts(task) {
|
|
3
|
+
return JSON.stringify({
|
|
4
|
+
id: task.id,
|
|
5
|
+
title: task.title,
|
|
6
|
+
state: task.state,
|
|
7
|
+
pri: task.pri,
|
|
8
|
+
needs: [...task.needs],
|
|
9
|
+
parent: task.parent,
|
|
10
|
+
from: [...task.from],
|
|
11
|
+
createdAt: task.createdAt,
|
|
12
|
+
creator: task.creator,
|
|
13
|
+
...(task.startedAt === undefined ? {} : { startedAt: task.startedAt }),
|
|
14
|
+
...(task.contractId === undefined ? {} : { contractId: task.contractId }),
|
|
15
|
+
body: task.body,
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
function isNeedsAppend(prior, next) {
|
|
19
|
+
return next.needs.length > prior.needs.length
|
|
20
|
+
&& prior.needs.every((need, index) => next.needs[index] === need)
|
|
21
|
+
&& new Set(next.needs).size === next.needs.length
|
|
22
|
+
&& documentFacts(prior) === documentFacts({ ...next, needs: prior.needs });
|
|
23
|
+
}
|
|
24
|
+
export function validateTaskWorktreeMutation(before, replacements, after, contractId) {
|
|
25
|
+
const existing = new Map(before.tasks.map((task) => [task.id, task]));
|
|
26
|
+
const bound = before.tasks.filter((task) => task.contractId === contractId);
|
|
27
|
+
for (const replacement of replacements) {
|
|
28
|
+
const prior = existing.get(replacement.id);
|
|
29
|
+
if (!prior)
|
|
30
|
+
continue;
|
|
31
|
+
const next = after.byId.get(replacement.id);
|
|
32
|
+
if (bound.length === 1 && prior.id === bound[0].id && next && isNeedsAppend(prior, next))
|
|
33
|
+
continue;
|
|
34
|
+
throw new FlowError("UNSUPPORTED_POLICY", `task ${replacement.id} is not yours; this checkout is bound to contract ${contractId}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
export function withTaskWorktreePolicy(repository, contractId) {
|
|
38
|
+
return {
|
|
39
|
+
read: repository.read.bind(repository),
|
|
40
|
+
mutate: async (apply) => repository.mutate(async (before) => {
|
|
41
|
+
const mutation = await apply(before);
|
|
42
|
+
return {
|
|
43
|
+
replacements: mutation.replacements,
|
|
44
|
+
select(after) {
|
|
45
|
+
validateTaskWorktreeMutation(before, mutation.replacements, after, contractId);
|
|
46
|
+
return mutation.select(after);
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { parseTaskDocument, serializeTaskDocument, } from "./task/document.js";
|
|
2
|
+
export { resolveTaskReadId, } from "./task/board.js";
|
|
3
|
+
export { buildTaskBoard } from "./task/source-board.js";
|
|
4
|
+
export { readTaskBoard, createTask, updateTask, startTask, stopTask, doneTask, dropTask, selectSoleInProgress, rollbackOrphanTaskPointers, } from "./task/commands.js";
|