@astrosheep/keiyaku 2.9.6 → 2.9.8

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.
Files changed (87) hide show
  1. package/build/.tsbuildinfo +1 -1
  2. package/build/agents/harness/event-persistence.js +7 -5
  3. package/build/agents/harness/events.js +3 -2
  4. package/build/agents/harness/outcome.js +10 -0
  5. package/build/agents/providers/codex-app-server/adapter.js +6 -1
  6. package/build/agents/providers/codex-app-server/session.js +8 -7
  7. package/build/agents/selector.js +12 -1
  8. package/build/cli/commands/akuma/view/handler.js +3 -11
  9. package/build/cli/commands/contract/amend/handler.js +1 -1
  10. package/build/cli/commands/contract/amend/meta.js +4 -4
  11. package/build/cli/commands/contract/bind/handler.js +10 -4
  12. package/build/cli/commands/contract/bind/meta.js +6 -6
  13. package/build/cli/commands/contract/petition/handler.js +16 -3
  14. package/build/cli/commands/contract/petition/meta.js +4 -4
  15. package/build/cli/commands/metadata.js +3 -2
  16. package/build/cli/commands/projection/status/handler.js +5 -4
  17. package/build/cli/commands/projection/status/meta.js +2 -2
  18. package/build/cli/commands/projection/tell/handler.js +10 -2
  19. package/build/cli/commands/projection/tell/meta.js +3 -3
  20. package/build/cli/commands/task/add/meta.js +9 -1
  21. package/build/cli/commands/task/catalog.js +2 -0
  22. package/build/cli/commands/task/log/handler.js +12 -0
  23. package/build/cli/commands/task/log/meta.js +8 -0
  24. package/build/cli/commands/task/shared.js +2 -1
  25. package/build/cli/completion.js +8 -0
  26. package/build/cli/flags.js +8 -0
  27. package/build/cli/index.js +10 -6
  28. package/build/cli/parse-flags.js +6 -0
  29. package/build/cli/parse-metadata.js +1 -1
  30. package/build/cli/render/kanshi.js +2 -2
  31. package/build/cli/render/line-width.js +33 -0
  32. package/build/cli/render/path-prefix-compaction.js +131 -0
  33. package/build/cli/render/petition.js +4 -0
  34. package/build/cli/render/projection-activity.js +103 -21
  35. package/build/cli/render/shared.js +38 -15
  36. package/build/cli/render/status.js +8 -5
  37. package/build/cli/render/success-response.js +2 -0
  38. package/build/cli/render/tool-presentation.js +3 -3
  39. package/build/cli/render/wait.js +68 -48
  40. package/build/cli/subagent-guard.js +3 -0
  41. package/build/cli/types.js +1 -1
  42. package/build/config/settings/disease.js +4 -4
  43. package/build/config/settings/loader.js +44 -21
  44. package/build/core/addressing.js +40 -9
  45. package/build/core/amend.js +21 -5
  46. package/build/core/bind.js +111 -6
  47. package/build/core/call/context.js +19 -3
  48. package/build/core/call/execution.js +43 -20
  49. package/build/core/draft.js +1 -1
  50. package/build/core/ledger-batch.js +194 -0
  51. package/build/core/projection/generation/database.js +43 -2
  52. package/build/core/projection/generation/model.js +24 -3
  53. package/build/core/projection/generation/projection-generation-continuation.js +75 -10
  54. package/build/core/projection/generation/projection-generation-execution.js +46 -37
  55. package/build/core/projection/generation/projection-generation-launcher.js +148 -20
  56. package/build/core/projection/generation/projection-generation-process.js +3 -1
  57. package/build/core/projection/generation/projection-generation-runner.js +145 -66
  58. package/build/core/projection/generation/projection-generation-runtime.js +93 -11
  59. package/build/core/projection/generation/store.js +26 -1
  60. package/build/core/projection/generation/transitions.js +193 -14
  61. package/build/core/projection/index.js +5 -5
  62. package/build/core/projection/projection-core.js +1 -1
  63. package/build/core/projection/projection-kill.js +53 -10
  64. package/build/core/projection/projection-life-protocol.js +10 -0
  65. package/build/core/projection/projection-runner-lock.js +177 -37
  66. package/build/core/projection/projection-status.js +143 -55
  67. package/build/core/projection/projection-wait.js +53 -15
  68. package/build/core/projection/projection-wake.js +177 -46
  69. package/build/core/projection/tell/database.js +18 -0
  70. package/build/core/projection/tell/model.js +1 -0
  71. package/build/core/projection/tell/store.js +102 -55
  72. package/build/core/registry.js +82 -69
  73. package/build/core/scope.js +9 -9
  74. package/build/core/status/board.js +42 -4
  75. package/build/core/status/drift.js +21 -5
  76. package/build/core/status/ledger-batch.js +1 -158
  77. package/build/core/task/index.js +2 -2
  78. package/build/core/task/task-contract.js +18 -0
  79. package/build/core/task/task-git-runtime.js +8 -10
  80. package/build/core/task/task-git-store.js +55 -3
  81. package/build/core/worktree-path.js +39 -25
  82. package/build/flow-error.js +1 -1
  83. package/build/generated/version.js +2 -2
  84. package/build/git/refs.js +47 -1
  85. package/package.json +1 -1
  86. package/skills/keiyaku-akuma/SKILL.md +28 -0
  87. package/skills/keiyaku-workflow/SKILL.md +142 -18
@@ -3,12 +3,12 @@ import { assertSettingsKnobUsable } from "../config/settings/disease.js";
3
3
  import { loadKeiyakuSettings } from "../config/settings/loader.js";
4
4
  import { FlowError } from "../flow-error.js";
5
5
  import { activeLedgerRef, appendEntry, appendWithRetry, readLedger } from "./ledger.js";
6
- import { describeMissingBindSections, materializeBindDraft, mergeBindDraft, missingBindDraftSections, parseBindDraft, parsePartialBindDraft, renderMissingBindSectionsTemplate, } from "./draft.js";
7
- import { assertValidContractId, buildContractId } from "./ids.js";
8
- import { registerScope, releaseScope } from "./registry.js";
6
+ import { describeMissingBindSections, materializeBindDraft, mergeBindDraft, missingBindDraftSections, parseBindDraft, parsePartialBindDraft, renderBindDraft, renderMissingBindSectionsTemplate, } from "./draft.js";
7
+ import { assertValidContractId, buildContractId, isCommissionId } from "./ids.js";
8
+ import { previewScopeRegistration, registerScope, releaseScope } from "./registry.js";
9
9
  import { resolveDefaultBranch } from "./status/drift.js";
10
10
  import { normalizeTargetRef } from "./target-ref.js";
11
- import { reconcileTaskForfeit, settleTaskBoardForBind } from "./task/index.js";
11
+ import { previewTaskBoardForBind, reconcileTaskForfeit, settleTaskBoardForBind } from "./task/index.js";
12
12
  import { bootstrapEntryDataFromObservation, isBootstrapSuccess, runWorktreeBootstrap, } from "./worktree-bootstrap.js";
13
13
  import { contractWorktreePathFromBind, stableRepoRoot } from "./worktree-path.js";
14
14
  import { allocateWorktreePlace, cleanupCreatedBindResources, createContractBranch, defaultCreateWorktree, materializeContractView, resolveTreeRoot, } from "./bind-workspace.js";
@@ -138,6 +138,99 @@ export function composeTaskBindDraft(input) {
138
138
  }
139
139
  return materializeBindDraft(merged);
140
140
  }
141
+ async function validateBindAfter(cwd, after) {
142
+ const unique = new Set();
143
+ for (const contractId of after) {
144
+ if (unique.has(contractId)) {
145
+ throw new FlowError("INVALID_TARGET", `contract ${contractId} selected more than once by --after`);
146
+ }
147
+ unique.add(contractId);
148
+ if (!isCommissionId(contractId)) {
149
+ throw new FlowError("INVALID_TARGET", `after contract ${contractId} is not a valid contract id`);
150
+ }
151
+ if (!await readLedger(cwd, contractId)) {
152
+ throw new FlowError("INVALID_TARGET", `after contract ${contractId} does not exist`);
153
+ }
154
+ }
155
+ }
156
+ /**
157
+ * The common read-only bind admission candidate. Mutation uses this candidate
158
+ * before task settlement; preview stops after it has observed all admission
159
+ * facts and rendered the canonical draft.
160
+ */
161
+ async function admitBindCandidate(input, draft) {
162
+ const id = buildContractId({ name: draft.name, nowMs: input.nowMs, randomBytes: input.randomBytes });
163
+ await assertValidContractId(input.cwd, id);
164
+ await validateBindAfter(input.cwd, input.after);
165
+ const provisionalEntry = makeBindEntry({
166
+ id,
167
+ at: new Date(input.nowMs).toISOString(),
168
+ actor: input.actor,
169
+ draft,
170
+ // Preview never writes the task-board commit. The current target head is
171
+ // sufficient for its read-only provisional entry and worktree coordinate.
172
+ base: input.base,
173
+ target: input.target,
174
+ after: input.after,
175
+ workspace: input.workspace,
176
+ place: input.place,
177
+ bootstrap: input.bootstrapPlan,
178
+ });
179
+ const tree = await resolveTreeRoot(input.cwd, input.workspace, id, provisionalEntry);
180
+ const scopeOverlaps = await previewScopeRegistration(input.cwd, {
181
+ contractId: id,
182
+ scope: draft.scope,
183
+ tree,
184
+ exclusive: input.exclusive,
185
+ });
186
+ return {
187
+ id,
188
+ draft,
189
+ canonicalDraft: renderBindDraft(draft),
190
+ target: input.target,
191
+ place: input.workspace === "worktree" ? input.place ?? null : null,
192
+ workspace: input.workspace,
193
+ after: input.after,
194
+ scopeOverlaps,
195
+ };
196
+ }
197
+ export async function previewBindContract(input) {
198
+ const isTaskBound = input.taskIds !== undefined && input.taskIds.length > 0;
199
+ const ordinaryDraft = !isTaskBound ? parseBindDraft(input.draft) : undefined;
200
+ const workspace = input.workspace ?? "here";
201
+ const defaultBranch = await resolveDefaultBranch(input.cwd);
202
+ if (!defaultBranch) {
203
+ throw new FlowError("INTERNAL_STATE", "cannot bind: no default branch");
204
+ }
205
+ const target = normalizeTargetRef(defaultBranch.branch);
206
+ const loadedSettings = await loadKeiyakuSettings(input.cwd);
207
+ let bootstrapPlan;
208
+ if (workspace === "worktree") {
209
+ assertSettingsKnobUsable(loadedSettings, "worktreeBootstrap");
210
+ bootstrapPlan = resolvedBootstrapPlan(loadedSettings.knobs?.worktreeBootstrap);
211
+ }
212
+ const randomBytes = input.randomBytes?.(0) ?? randomBytesDefault();
213
+ const place = workspace === "worktree" ? await allocateWorktreePlace(input.cwd, randomBytes) : undefined;
214
+ const taskAdmission = isTaskBound
215
+ ? await previewTaskBoardForBind({ cwd: input.cwd, taskIds: input.taskIds })
216
+ : { tasks: [] };
217
+ const draft = isTaskBound
218
+ ? composeTaskBindDraft({ task: taskAdmission.tasks[0], draft: input.draft })
219
+ : ordinaryDraft;
220
+ return await admitBindCandidate({
221
+ cwd: input.cwd,
222
+ actor: input.actor,
223
+ nowMs: input.nowMs,
224
+ after: input.after ?? [],
225
+ exclusive: input.exclusive,
226
+ workspace,
227
+ randomBytes,
228
+ base: defaultBranch.head,
229
+ target,
230
+ bootstrapPlan,
231
+ place,
232
+ }, draft);
233
+ }
141
234
  export async function bindContract(input) {
142
235
  const isTaskBound = input.taskIds !== undefined && input.taskIds.length > 0;
143
236
  const ordinaryDraft = !isTaskBound ? parseBindDraft(input.draft) : undefined;
@@ -161,8 +254,20 @@ export async function bindContract(input) {
161
254
  let settlement;
162
255
  try {
163
256
  const prepare = async (draft) => {
164
- const id = buildContractId({ name: draft.name, nowMs: input.nowMs, randomBytes });
165
- await assertValidContractId(input.cwd, id);
257
+ const candidate = await admitBindCandidate({
258
+ cwd: input.cwd,
259
+ actor: input.actor,
260
+ nowMs: input.nowMs,
261
+ after: input.after ?? [],
262
+ exclusive: input.exclusive,
263
+ workspace,
264
+ randomBytes,
265
+ base: defaultBranch.head,
266
+ target,
267
+ bootstrapPlan,
268
+ place,
269
+ }, draft);
270
+ const id = candidate.id;
166
271
  const ref = activeLedgerRef(id);
167
272
  return {
168
273
  contractId: id,
@@ -2,7 +2,7 @@ import * as fs from "fs/promises";
2
2
  import { FlowError } from "../../flow-error.js";
3
3
  import { assertGitObservation, observeGitRepository } from "../../git/branches.js";
4
4
  import { createGit, wrapGitError } from "../../git/core.js";
5
- import { AddressResolutionError, buildResolutionAttempt, buildUncommissionedAddress, buildUncommissionedRepositoryAddress, resolveContractAddress, withResolvedAddress, } from "../addressing.js";
5
+ import { AddressResolutionError, buildResolutionAttempt, buildUncommissionedAddress, buildUncommissionedRepositoryAddress, resolveContractAddress, resolveCallWorktreeAddress, withResolvedAddress, } from "../addressing.js";
6
6
  import { normalizeFilesystemCoordinate } from "../../fs/path-coordinate.js";
7
7
  import { findOpenArcView } from "../arc.js";
8
8
  import { readLedger } from "../ledger.js";
@@ -139,7 +139,24 @@ export async function readCallKeiyakuContext(executionCwd, input) {
139
139
  },
140
140
  }));
141
141
  }
142
- if (input.bare || !input.contractId) {
142
+ if (input.bare) {
143
+ return {
144
+ activeKeiyaku: false,
145
+ effectiveCwd: executionCwd,
146
+ historyCwd: ledgerCwd,
147
+ profileCwd: ledgerCwd,
148
+ projectionCwd: ledgerCwd,
149
+ address: buildUncommissionedRepositoryAddress({
150
+ stableRoot: ledgerCwd,
151
+ workdir: executionCwd,
152
+ binding: "bare",
153
+ }),
154
+ };
155
+ }
156
+ const address = input.contractId
157
+ ? await resolveContractAddress(ledgerCwd, input.contractId, input.contractAddressSource)
158
+ : await resolveCallWorktreeAddress(ledgerCwd, executionCwd);
159
+ if (!address) {
143
160
  return {
144
161
  activeKeiyaku: false,
145
162
  effectiveCwd: executionCwd,
@@ -153,7 +170,6 @@ export async function readCallKeiyakuContext(executionCwd, input) {
153
170
  }),
154
171
  };
155
172
  }
156
- const address = await resolveContractAddress(ledgerCwd, input.contractId, input.contractAddressSource);
157
173
  return await withResolvedAddress(address, async () => {
158
174
  const contractId = address.binding.commissionId;
159
175
  const ledger = await readLedger(ledgerCwd, contractId);
@@ -7,7 +7,7 @@ import { assertProjectionAlias, moveProjectionAlias } from "../projection/index.
7
7
  import { projectionDir } from "../projection/index.js";
8
8
  import { createProjectionExecutionId } from "../projection/index.js";
9
9
  import { PROJECTION_ADOPTION_TIMEOUT_MS, startProjectionGeneration } from "../projection/index.js";
10
- import { openProjectionGenerationStore, openProjectionGenerationStoreReadOnly, } from "../projection/index.js";
10
+ import { openProjectionGenerationStore, openProjectionGenerationStoreReadOnly, acquireProjectionRunnerLock, } from "../projection/index.js";
11
11
  import { observeProjectionLife } from "../projection/index.js";
12
12
  import { mintProjection, mintRepositoryProjection } from "../projection/index.js";
13
13
  import { waitForProjection } from "../projection/index.js";
@@ -60,13 +60,29 @@ export function outcomeFromGeneration(current) {
60
60
  return restoreOutcome(record);
61
61
  }
62
62
  export async function executeSubagent(agentName, prompt, cwd, options) {
63
- const { projection: projectionCoordinate, launchSnapshot } = await buildCallConfig(agentName, prompt, cwd, options);
63
+ const { projection: projectionCoordinate, launchSnapshot, launchFacts } = await buildCallConfig(agentName, prompt, cwd, options);
64
64
  if (!projectionCoordinate) {
65
65
  throw new FlowError("INTERNAL_STATE", "durable execution requires a projection coordinate");
66
66
  }
67
- const launched = await startProjectionGeneration(projectionCoordinate.dir, projectionCoordinate.executionId);
68
- if (launched.status === "failed") {
69
- throw new FlowError("SUBAGENT_EXEC_ERROR", launched.detail);
67
+ const launchOwnership = acquireProjectionRunnerLock(projectionCoordinate.dir);
68
+ const launched = await startProjectionGeneration(projectionCoordinate.dir, projectionCoordinate.executionId, {
69
+ commitLaunch: () => {
70
+ const store = openProjectionGenerationStore(projectionCoordinate.dir);
71
+ try {
72
+ return store.launchIfSettled({ executionId: projectionCoordinate.executionId, facts: launchFacts }).status === "committed";
73
+ }
74
+ finally {
75
+ store.close();
76
+ }
77
+ },
78
+ launchOwnership,
79
+ });
80
+ launchOwnership.close();
81
+ if (launched.status !== "adopted") {
82
+ const detail = launched.status === "pending"
83
+ ? "runner retained startup ownership without adopting before the bounded launch wait"
84
+ : launched.detail;
85
+ throw new FlowError("SUBAGENT_EXEC_ERROR", detail);
70
86
  }
71
87
  const joined = await waitForProjection({
72
88
  projectionDirectory: projectionCoordinate.dir,
@@ -111,13 +127,28 @@ export async function executeSubagent(agentName, prompt, cwd, options) {
111
127
  throw new FlowError("INTERNAL_STATE", "Unhandled subagent outcome state.");
112
128
  }
113
129
  export async function launchSubagentGeneration(agentName, prompt, cwd, options) {
114
- const { projection, launchSnapshot } = await buildCallConfig(agentName, prompt, cwd, options);
130
+ const { projection, launchSnapshot, launchFacts } = await buildCallConfig(agentName, prompt, cwd, options);
115
131
  if (!projection) {
116
132
  throw new FlowError("INTERNAL_STATE", "durable launch requires a projection coordinate");
117
133
  }
118
- const launched = await startProjectionGeneration(projection.dir, projection.executionId);
119
- if (launched.status === "failed") {
120
- throw new FlowError("SUBAGENT_EXEC_ERROR", launched.detail);
134
+ const launchOwnership = acquireProjectionRunnerLock(projection.dir);
135
+ const launched = await startProjectionGeneration(projection.dir, projection.executionId, {
136
+ commitLaunch: () => {
137
+ const store = openProjectionGenerationStore(projection.dir);
138
+ try {
139
+ return store.launchIfSettled({ executionId: projection.executionId, facts: launchFacts }).status === "committed";
140
+ }
141
+ finally {
142
+ store.close();
143
+ }
144
+ },
145
+ launchOwnership,
146
+ });
147
+ launchOwnership.close();
148
+ if (launched.status !== "adopted") {
149
+ throw new FlowError("SUBAGENT_EXEC_ERROR", launched.status === "pending"
150
+ ? "runner retained startup ownership without adopting before the bounded launch wait"
151
+ : launched.detail);
121
152
  }
122
153
  return { projection, launchSnapshot };
123
154
  }
@@ -173,16 +204,6 @@ export async function buildCallConfig(agentName, prompt, cwd, options) {
173
204
  } : {}),
174
205
  },
175
206
  }));
176
- const store = openProjectionGenerationStore(minted.dir);
177
- try {
178
- const launch = store.launchIfSettled({ executionId, facts: launchFacts });
179
- if (launch.status !== "committed") {
180
- throw new FlowError("INTERNAL_STATE", `initial generation launch rejected: ${launch.reason}`);
181
- }
182
- }
183
- finally {
184
- store.close();
185
- }
186
207
  const previousAliasTarget = options.alias === undefined
187
208
  ? undefined
188
209
  : await moveProjectionAlias(options.projectionCwd ?? cwd, assertProjectionAlias(options.alias), minted.id);
@@ -202,6 +223,7 @@ export async function buildCallConfig(agentName, prompt, cwd, options) {
202
223
  id: minted.id,
203
224
  dir: minted.dir,
204
225
  executionId,
226
+ launchFacts,
205
227
  ...(options.alias ? { alias: options.alias } : {}),
206
228
  ...(previousAliasTargetStillRunning ? { previousAliasTarget: previousAliasTargetStillRunning } : {}),
207
229
  };
@@ -209,9 +231,10 @@ export async function buildCallConfig(agentName, prompt, cwd, options) {
209
231
  // Every public launch has one durable projection object. Repository-free
210
232
  // authority changes the storage owner, not whether the object exists; mint
211
233
  // failure is therefore a launch failure rather than an in-process fallback.
212
- const projectionCoordinate = await publishProjection();
234
+ const { launchFacts, ...projectionCoordinate } = await publishProjection();
213
235
  return {
214
236
  projection: projectionCoordinate,
215
237
  launchSnapshot,
238
+ launchFacts,
216
239
  };
217
240
  }
@@ -204,7 +204,7 @@ export function renderMissingBindSectionsTemplate(missing) {
204
204
  continue;
205
205
  }
206
206
  if (section === "scope") {
207
- lines.push("## Scope", "```", "<pattern>", "<another-pattern>", "```");
207
+ lines.push("## Scope", "~~~", "<pattern>", "<another-pattern>", "~~~");
208
208
  continue;
209
209
  }
210
210
  lines.push("## Checks", "- <verification>");
@@ -0,0 +1,194 @@
1
+ import { wrapGitError, runGitProcess } from "../git/core.js";
2
+ import { GitStreamingBatchFramingError, GitStreamingBatchResponseEndedError, openGitStreamingBatch, } from "../git/streaming-batch.js";
3
+ import { parseLedgerEntryCommitMessage } from "./entry.js";
4
+ import { ACTIVE_LEDGER_REF_PREFIX, activeLedgerRef, } from "./ledger.js";
5
+ const MAX_LEDGER_COMMIT_BYTES = 1024 * 1024;
6
+ async function runLedgerGit(cwd, args) {
7
+ const result = await runGitProcess(cwd, args);
8
+ if (result.error || result.status !== 0) {
9
+ throw wrapGitError(args.join(" "), {
10
+ message: result.error?.message
11
+ ?? `git exited with status ${result.status ?? "unknown"}`,
12
+ stderr: result.stderr,
13
+ stdout: result.stdout,
14
+ }, cwd);
15
+ }
16
+ return Buffer.from(result.stdout, "utf8");
17
+ }
18
+ async function queryBatchObject(batch, expression) {
19
+ const record = await batch.request(expression, {
20
+ maxContentBytes: MAX_LEDGER_COMMIT_BYTES,
21
+ });
22
+ if (record.kind === "missing")
23
+ return null;
24
+ if (record.kind === "ambiguous") {
25
+ throw new Error(`[git cat-file ${expression}] object is ambiguous`);
26
+ }
27
+ return {
28
+ oid: record.record.oid,
29
+ type: record.record.type,
30
+ content: record.record.content,
31
+ };
32
+ }
33
+ function parseCommitObject(object) {
34
+ if (object.type !== "commit") {
35
+ throw new Error(`git cat-file expected commit ${object.oid}, received ${object.type}`);
36
+ }
37
+ const content = object.content.toString("utf8");
38
+ const messageStart = content.indexOf("\n\n");
39
+ if (messageStart < 0)
40
+ throw new Error(`git commit ${object.oid} has no message separator`);
41
+ const parents = [];
42
+ for (const line of content.slice(0, messageStart).split("\n")) {
43
+ if (!line.startsWith("parent "))
44
+ continue;
45
+ const parent = line.slice("parent ".length);
46
+ if (!/^[0-9a-f]{40,64}$/.test(parent)) {
47
+ throw new Error(`git commit ${object.oid} has an invalid parent header`);
48
+ }
49
+ parents.push(parent);
50
+ }
51
+ return { oid: object.oid, parents, message: content.slice(messageStart + 2) };
52
+ }
53
+ function parseLedgerRefs(output) {
54
+ return output
55
+ .toString("utf8")
56
+ .split(/\r?\n/)
57
+ .filter((ref) => ref.startsWith(ACTIVE_LEDGER_REF_PREFIX))
58
+ .map((ref) => ({ contractId: ref.slice(ACTIVE_LEDGER_REF_PREFIX.length), ref }));
59
+ }
60
+ async function readLedgerFromBatch(batch, ref, commits) {
61
+ const headObject = await queryBatchObject(batch, `${ref.ref}^{commit}`);
62
+ if (!headObject)
63
+ return null;
64
+ const head = parseCommitObject(headObject);
65
+ commits.set(head.oid, Promise.resolve(head));
66
+ const ordered = [];
67
+ const visited = new Set();
68
+ const visiting = new Set();
69
+ const visit = async (oid) => {
70
+ if (visited.has(oid))
71
+ return;
72
+ if (visiting.has(oid))
73
+ throw new Error(`git ledger ancestry contains a cycle at ${oid}`);
74
+ visiting.add(oid);
75
+ let pending = commits.get(oid);
76
+ if (!pending) {
77
+ pending = queryBatchObject(batch, oid).then((object) => {
78
+ if (!object)
79
+ throw new Error(`[git cat-file ${oid}] object is missing`);
80
+ return parseCommitObject(object);
81
+ });
82
+ commits.set(oid, pending);
83
+ }
84
+ const commit = await pending;
85
+ for (const parent of commit.parents)
86
+ await visit(parent);
87
+ visiting.delete(oid);
88
+ visited.add(oid);
89
+ ordered.push(commit);
90
+ };
91
+ await visit(head.oid);
92
+ return {
93
+ contractId: ref.contractId,
94
+ ref: ref.ref,
95
+ head: head.oid,
96
+ entries: ordered.map((commit) => parseLedgerEntryCommitMessage(commit.message)),
97
+ };
98
+ }
99
+ async function closeBatch(cwd, batch) {
100
+ const close = await batch.close();
101
+ if (!close.aborted && (close.timedOut || close.signal || close.code !== 0)) {
102
+ throw wrapGitError("cat-file --batch", {
103
+ message: close.timedOut
104
+ ? "git timed out"
105
+ : close.signal
106
+ ? `git exited from signal ${close.signal}`
107
+ : `git exited with status ${close.code ?? "unknown"}`,
108
+ stderr: close.stderr.toString("utf8"),
109
+ }, cwd);
110
+ }
111
+ }
112
+ async function readLedgerRefs(cwd, refs) {
113
+ if (refs.length === 0)
114
+ return [];
115
+ const batch = openGitStreamingBatch(cwd);
116
+ const commits = new Map();
117
+ const results = [];
118
+ let readError;
119
+ try {
120
+ for (let index = 0; index < refs.length; index += 1) {
121
+ const ref = refs[index];
122
+ try {
123
+ const ledger = await readLedgerFromBatch(batch, ref, commits);
124
+ if (ledger)
125
+ results.push(ledger);
126
+ else
127
+ results.push({ id: ref.contractId, error: new Error(`ledger ref disappeared: ${ref.ref}`) });
128
+ }
129
+ catch (error) {
130
+ const diagnostic = error instanceof Error ? error : new Error(String(error));
131
+ results.push({ id: ref.contractId, error: diagnostic });
132
+ if (error instanceof GitStreamingBatchFramingError
133
+ || error instanceof GitStreamingBatchResponseEndedError) {
134
+ const isolationReason = error instanceof GitStreamingBatchResponseEndedError
135
+ ? "response ended"
136
+ : "malformed frame";
137
+ for (const pending of refs.slice(index + 1)) {
138
+ results.push({
139
+ id: pending.contractId,
140
+ error: new Error(`git cat-file batch terminated after ${isolationReason}: ${diagnostic.message}`),
141
+ });
142
+ }
143
+ break;
144
+ }
145
+ }
146
+ }
147
+ }
148
+ catch (error) {
149
+ readError = error;
150
+ }
151
+ try {
152
+ await closeBatch(cwd, batch);
153
+ }
154
+ catch (error) {
155
+ if (!readError)
156
+ readError = error;
157
+ }
158
+ if (readError)
159
+ throw readError;
160
+ return results;
161
+ }
162
+ /** Read every ledger with two Git processes regardless of ledger or entry count. */
163
+ export async function readAllLedgerSnapshots(cwd) {
164
+ const refOutput = await runLedgerGit(cwd, [
165
+ "for-each-ref",
166
+ "--sort=refname",
167
+ "--format=%(refname)",
168
+ ACTIVE_LEDGER_REF_PREFIX,
169
+ ]);
170
+ return readLedgerRefs(cwd, parseLedgerRefs(refOutput));
171
+ }
172
+ /** Read one exact ledger with one Git process and without enumerating other refs. */
173
+ export async function readLedgerSnapshot(cwd, contractId) {
174
+ const ref = activeLedgerRef(contractId);
175
+ const batch = openGitStreamingBatch(cwd);
176
+ let ledger = null;
177
+ let readError;
178
+ try {
179
+ ledger = await readLedgerFromBatch(batch, { contractId, ref }, new Map());
180
+ }
181
+ catch (error) {
182
+ readError = error;
183
+ }
184
+ try {
185
+ await closeBatch(cwd, batch);
186
+ }
187
+ catch (error) {
188
+ if (!readError)
189
+ readError = error;
190
+ }
191
+ if (readError)
192
+ throw readError;
193
+ return ledger;
194
+ }
@@ -4,7 +4,7 @@ import { DatabaseSync } from "node:sqlite";
4
4
  import { configureHeartBusyTimeout } from "../heart.js";
5
5
  import { ProjectionGenerationStoreError, parseStoredRow, validateHistory, } from "./model.js";
6
6
  export const PROJECTION_GENERATION_STORE_FILE = "heart";
7
- export const PROJECTION_GENERATION_SCHEMA_VERSION = 2;
7
+ export const PROJECTION_GENERATION_SCHEMA_VERSION = 3;
8
8
  const SCHEMA_TABLE_SQL = `
9
9
  CREATE TABLE generation_schema (
10
10
  singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
@@ -14,7 +14,7 @@ const SCHEMA_TABLE_SQL = `
14
14
  const RECORD_TABLE_SQL = `
15
15
  CREATE TABLE generation_records (
16
16
  seq INTEGER PRIMARY KEY CHECK (seq > 0),
17
- kind TEXT NOT NULL CHECK (kind IN ('launch', 'adoption', 'kill-intent', 'verdict')),
17
+ kind TEXT NOT NULL CHECK (kind IN ('launch', 'adoption', 'kill-intent', 'interrupt-intent', 'verdict')),
18
18
  execution_id TEXT NOT NULL CHECK (length(trim(execution_id)) > 0),
19
19
  facts_json TEXT NOT NULL CHECK (json_valid(facts_json))
20
20
  ) STRICT
@@ -250,6 +250,25 @@ export function openReadOnlyDatabase(projectionDirectory) {
250
250
  throw projectionGenerationStoreReadError(databasePath, error);
251
251
  }
252
252
  }
253
+ /** Read the validated heart through one consistent SQLite snapshot. */
254
+ export function withGenerationReadSnapshot(projectionDirectory, read) {
255
+ const { database } = openReadOnlyDatabase(projectionDirectory);
256
+ try {
257
+ database.exec("BEGIN");
258
+ try {
259
+ const result = read(database);
260
+ database.exec("COMMIT");
261
+ return result;
262
+ }
263
+ catch (error) {
264
+ rollbackQuietly(database);
265
+ throw error;
266
+ }
267
+ }
268
+ finally {
269
+ closeDatabase(database);
270
+ }
271
+ }
253
272
  export function assertDatabaseOpen(database) {
254
273
  if (closedDatabases.has(database))
255
274
  throw new ProjectionGenerationStoreError("projection generation store is closed");
@@ -276,6 +295,8 @@ export function writeTransition(database, readOnly, run) {
276
295
  if (readOnly) {
277
296
  throw new ProjectionGenerationStoreError("projection generation store is read-only");
278
297
  }
298
+ if (database.isTransaction)
299
+ return run();
279
300
  database.exec("BEGIN IMMEDIATE");
280
301
  try {
281
302
  const result = run();
@@ -287,6 +308,26 @@ export function writeTransition(database, readOnly, run) {
287
308
  throw error;
288
309
  }
289
310
  }
311
+ export function beginGenerationWriteTransaction(database, readOnly) {
312
+ assertDatabaseOpen(database);
313
+ if (readOnly)
314
+ throw new ProjectionGenerationStoreError("projection generation store is read-only");
315
+ if (database.isTransaction)
316
+ throw new ProjectionGenerationStoreError("projection generation write transaction is already open");
317
+ database.exec("BEGIN IMMEDIATE");
318
+ }
319
+ export function commitGenerationWriteTransaction(database) {
320
+ assertDatabaseOpen(database);
321
+ if (!database.isTransaction)
322
+ throw new ProjectionGenerationStoreError("projection generation write transaction is not open");
323
+ database.exec("COMMIT");
324
+ }
325
+ export function rollbackGenerationWriteTransaction(database) {
326
+ assertDatabaseOpen(database);
327
+ if (!database.isTransaction)
328
+ return;
329
+ rollbackQuietly(database);
330
+ }
290
331
  export function closeDatabase(database) {
291
332
  if (closedDatabases.has(database))
292
333
  return;
@@ -133,6 +133,16 @@ export function validateKillIntentFacts(value, location) {
133
133
  throw new ProjectionGenerationStoreError(`${location}.state is reserved for verdict records`);
134
134
  return facts;
135
135
  }
136
+ export function validateInterruptIntentFacts(value, location) {
137
+ const facts = cloneFacts(value, location);
138
+ facts.interruptedAt = assertIsoTimestamp(facts.interruptedAt, `${location}.interruptedAt`);
139
+ if ("operatorAction" in facts) {
140
+ throw new ProjectionGenerationStoreError(`${location}.operatorAction is not part of interrupt-intent`);
141
+ }
142
+ if ("state" in facts)
143
+ throw new ProjectionGenerationStoreError(`${location}.state is reserved for verdict records`);
144
+ return facts;
145
+ }
136
146
  export function validateVerdictFacts(value, location) {
137
147
  const facts = cloneFacts(value, location);
138
148
  if (typeof facts.state !== "string" || !VERDICTS.has(facts.state)) {
@@ -182,6 +192,7 @@ export function parseStoredRow(row) {
182
192
  if (row.kind !== "launch"
183
193
  && row.kind !== "adoption"
184
194
  && row.kind !== "kill-intent"
195
+ && row.kind !== "interrupt-intent"
185
196
  && row.kind !== "verdict") {
186
197
  throw new ProjectionGenerationStoreError(`generation row ${seq} has unknown kind '${row.kind}'`);
187
198
  }
@@ -200,7 +211,9 @@ export function parseStoredRow(row) {
200
211
  ? validateAdoptionFacts(decoded, location)
201
212
  : row.kind === "kill-intent"
202
213
  ? validateKillIntentFacts(decoded, location)
203
- : validateVerdictFacts(decoded, location);
214
+ : row.kind === "interrupt-intent"
215
+ ? validateInterruptIntentFacts(decoded, location)
216
+ : validateVerdictFacts(decoded, location);
204
217
  return { seq, kind: row.kind, executionId, facts };
205
218
  }
206
219
  export function validateHistory(records) {
@@ -237,13 +250,21 @@ export function validateHistory(records) {
237
250
  continue;
238
251
  }
239
252
  if (record.kind === "kill-intent") {
240
- if (current.killIntent)
241
- throw new ProjectionGenerationStoreError(`generation row ${record.seq} duplicates kill-intent`);
253
+ if (current.killIntent || current.interruptIntent)
254
+ throw new ProjectionGenerationStoreError(`generation row ${record.seq} duplicates generation stop intent`);
242
255
  if (current.verdict)
243
256
  throw new ProjectionGenerationStoreError(`generation row ${record.seq} records kill-intent after verdict`);
244
257
  current.killIntent = record;
245
258
  continue;
246
259
  }
260
+ if (record.kind === "interrupt-intent") {
261
+ if (current.killIntent || current.interruptIntent)
262
+ throw new ProjectionGenerationStoreError(`generation row ${record.seq} duplicates generation stop intent`);
263
+ if (current.verdict)
264
+ throw new ProjectionGenerationStoreError(`generation row ${record.seq} records interrupt-intent after verdict`);
265
+ current.interruptIntent = record;
266
+ continue;
267
+ }
247
268
  if (current.verdict)
248
269
  throw new ProjectionGenerationStoreError(`generation row ${record.seq} duplicates verdict`);
249
270
  current.verdict = record;