@astrosheep/keiyaku 2.8.0 → 2.8.2

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 (36) hide show
  1. package/build/.tsbuildinfo +1 -1
  2. package/build/agents/call-terms_v2.js +6 -2
  3. package/build/agents/harness/index.js +5 -1
  4. package/build/agents/harness/pump.js +22 -5
  5. package/build/agents/launch-snapshot_v2.js +14 -9
  6. package/build/agents/selector_v2.js +15 -3
  7. package/build/cli/commands/akuma.js +21 -3
  8. package/build/cli/completion.js +4 -1
  9. package/build/cli/flags.js +10 -1
  10. package/build/cli/help.js +5 -3
  11. package/build/cli/index.js +67 -14
  12. package/build/cli/parse.js +65 -24
  13. package/build/cli/projection-address.js +97 -10
  14. package/build/cli/render/address.js +12 -3
  15. package/build/cli/render/call.js +10 -5
  16. package/build/cli/render/shared.js +2 -2
  17. package/build/cli/render/status.js +19 -4
  18. package/build/cli/render/wait.js +3 -3
  19. package/build/config/provider-policy-ownership.js +39 -0
  20. package/build/config/settings.js +1 -11
  21. package/build/core/addressing.js +82 -38
  22. package/build/core/call.js +33 -5
  23. package/build/core/projection/mint.js +23 -3
  24. package/build/core/projection-alias.js +123 -0
  25. package/build/core/projection-coordinate.js +23 -7
  26. package/build/core/projection-core.js +8 -47
  27. package/build/core/projection-kill.js +26 -0
  28. package/build/core/projection-mint.js +24 -3
  29. package/build/core/projection-runner-lock.js +1 -1
  30. package/build/core/projection-status.js +111 -12
  31. package/build/core/projection-wake.js +3 -0
  32. package/build/core/response-artifact-id.js +5 -0
  33. package/build/core/status.js +1 -1
  34. package/build/core/transcripts.js +42 -15
  35. package/build/generated/version.js +1 -1
  36. package/package.json +4 -4
@@ -1,6 +1,7 @@
1
1
  import * as path from "node:path";
2
2
  import { FlowError } from "../flow-error.js";
3
3
  import { listContractIds, readLedger } from "./ledger.js";
4
+ import { isResponseArtifactId } from "./response-artifact-id.js";
4
5
  import { stableRepoRoot } from "./worktree-path.js";
5
6
  import { deriveContractState, isTerminalState, } from "./status.js";
6
7
  function normalizeAddressQuery(raw) {
@@ -66,10 +67,39 @@ export function buildResolutionAttempt(input) {
66
67
  workdir: path.resolve(input.workdir),
67
68
  expected: "commission",
68
69
  ...(input.spelling ? { spelling: input.spelling } : {}),
70
+ ...(input.actual !== undefined ? { actual: input.actual } : {}),
69
71
  candidates: [...(input.candidates ?? [])],
70
72
  inference: [...(input.inference ?? [])],
71
73
  };
72
74
  }
75
+ export function buildObjectResolutionAttempt(input) {
76
+ return {
77
+ repo: input.repo,
78
+ workdir: path.resolve(input.workdir),
79
+ expected: input.expected,
80
+ supplied: input.supplied,
81
+ ...(input.actual !== undefined ? { actual: input.actual } : {}),
82
+ ...(input.scope ? { scope: input.scope } : {}),
83
+ candidates: [...(input.candidates ?? [])],
84
+ inference: [...(input.inference ?? [])],
85
+ };
86
+ }
87
+ export function buildArtifactResolutionAttempt(input) {
88
+ return {
89
+ ...buildObjectResolutionAttempt({
90
+ ...input,
91
+ expected: "artifact",
92
+ }),
93
+ expected: "artifact",
94
+ };
95
+ }
96
+ function commissionSelectorAsTyped(query, source) {
97
+ if (source === "at-address")
98
+ return `@${query}`;
99
+ if (source === "explicit-flag")
100
+ return `--contract ${query}`;
101
+ return query;
102
+ }
73
103
  export class AddressResolutionError extends FlowError {
74
104
  attempt;
75
105
  constructor(code, message, attempt, options) {
@@ -162,63 +192,77 @@ export async function resolveUncommissionedRepositoryAddress(input) {
162
192
  export async function resolveContractAddress(cwd, raw, source = raw?.startsWith("@") ? "at-address" : "explicit-flag") {
163
193
  const workdir = path.resolve(cwd);
164
194
  const repo = await resolvedRepository(cwd);
165
- const candidates = await commissionAddressCandidates(repo.stableRoot);
166
- if (raw === undefined) {
167
- const operable = candidates.filter((candidate) => candidate.active);
195
+ if (raw !== undefined) {
196
+ const query = normalizeAddressQuery(raw);
197
+ const spelling = { asTyped: commissionSelectorAsTyped(query, source), source };
198
+ // Lexical artifact fence: no ledger enumeration for rsp_<ULID>.
199
+ if (isResponseArtifactId(query)) {
200
+ throw new AddressResolutionError("EMPTY_PARAM", `artifact ${query} is a response artifact; expected a commission address`, {
201
+ repo,
202
+ workdir,
203
+ expected: "commission",
204
+ spelling,
205
+ candidates: [],
206
+ inference: [],
207
+ actual: "artifact",
208
+ });
209
+ }
210
+ const candidates = await commissionAddressCandidates(repo.stableRoot);
211
+ const matches = matchCommissionAddress(query, candidates);
168
212
  const attempt = {
169
213
  repo,
170
214
  workdir,
171
215
  expected: "commission",
172
- candidates: attemptCandidates(operable),
173
- inference: ["consulted active commission ledgers"],
216
+ spelling,
217
+ candidates: matches.map((candidate) => ({
218
+ commissionId: candidate.contractId,
219
+ matchedBy: candidate.spelling,
220
+ place: candidate.bind.data.place ?? null,
221
+ name: candidate.bind.data.name,
222
+ objective: candidate.bind.data.objective,
223
+ lifecycle: candidate.lifecycle,
224
+ target: { branch: candidate.bind.data.target },
225
+ })),
226
+ inference: [],
174
227
  };
175
- if (operable.length === 1) {
176
- const candidate = operable[0];
228
+ if (matches.length === 1) {
177
229
  return buildResolvedAddress({
178
230
  repo,
179
231
  workdir,
180
- candidate,
181
- matchedBy: "id",
182
- asTyped: `@${candidate.contractId}`,
183
- source: "sole-active",
184
- inference: attempt.inference,
232
+ candidate: matches[0],
233
+ matchedBy: matches[0].spelling,
234
+ asTyped: attempt.spelling.asTyped,
235
+ source,
236
+ inference: [],
185
237
  });
186
238
  }
187
- throw new AddressResolutionError(operable.length === 0 ? "EMPTY_PARAM" : "CONTRACT_AMBIGUOUS", operable.length === 0
188
- ? "no active commission found; pass @place, @slug, full @commission-id, or --contract <addr>"
189
- : "multiple active commissions found; pass @place, @slug, full @commission-id, or --contract <addr>", attempt);
239
+ if (matches.length === 0) {
240
+ throw new AddressResolutionError("EMPTY_PARAM", `unknown contract address @${query}`, attempt);
241
+ }
242
+ throw new AddressResolutionError("CONTRACT_AMBIGUOUS", `contract address @${query} is ambiguous: ${attempt.candidates.map((candidate) => formatAmbiguousCandidate(query, candidate)).join("; ")}; use a full @commission-id`, attempt);
190
243
  }
191
- const query = normalizeAddressQuery(raw);
192
- const matches = matchCommissionAddress(query, candidates);
244
+ const candidates = await commissionAddressCandidates(repo.stableRoot);
245
+ const operable = candidates.filter((candidate) => candidate.active);
193
246
  const attempt = {
194
247
  repo,
195
248
  workdir,
196
249
  expected: "commission",
197
- spelling: { asTyped: source === "at-address" ? `@${query}` : query, source },
198
- candidates: matches.map((candidate) => ({
199
- commissionId: candidate.contractId,
200
- matchedBy: candidate.spelling,
201
- place: candidate.bind.data.place ?? null,
202
- name: candidate.bind.data.name,
203
- objective: candidate.bind.data.objective,
204
- lifecycle: candidate.lifecycle,
205
- target: { branch: candidate.bind.data.target },
206
- })),
207
- inference: [],
250
+ candidates: attemptCandidates(operable),
251
+ inference: ["consulted active commission ledgers"],
208
252
  };
209
- if (matches.length === 1) {
253
+ if (operable.length === 1) {
254
+ const candidate = operable[0];
210
255
  return buildResolvedAddress({
211
256
  repo,
212
257
  workdir,
213
- candidate: matches[0],
214
- matchedBy: matches[0].spelling,
215
- asTyped: attempt.spelling.asTyped,
216
- source,
217
- inference: [],
258
+ candidate,
259
+ matchedBy: "id",
260
+ asTyped: `@${candidate.contractId}`,
261
+ source: "sole-active",
262
+ inference: attempt.inference,
218
263
  });
219
264
  }
220
- if (matches.length === 0) {
221
- throw new AddressResolutionError("EMPTY_PARAM", `unknown contract address @${query}`, attempt);
222
- }
223
- throw new AddressResolutionError("CONTRACT_AMBIGUOUS", `contract address @${query} is ambiguous: ${attempt.candidates.map((candidate) => formatAmbiguousCandidate(query, candidate)).join("; ")}; use a full @commission-id`, attempt);
265
+ throw new AddressResolutionError(operable.length === 0 ? "EMPTY_PARAM" : "CONTRACT_AMBIGUOUS", operable.length === 0
266
+ ? "no active commission found; pass @place, @slug, full @commission-id, or --contract <addr>"
267
+ : "multiple active commissions found; pass @place, @slug, full @commission-id, or --contract <addr>", attempt);
224
268
  }
@@ -11,6 +11,8 @@ import { buildCallTermsV2 } from "../agents/call-terms_v2.js";
11
11
  import { applySnapshotEffort, resolveAkumaLaunchSnapshot, } from "../agents/launch-snapshot_v2.js";
12
12
  import { restoreOutcome } from "../agents/harness/index.js";
13
13
  import { mintProjection, mintRepositoryProjection } from "./projection-mint.js";
14
+ import { assertProjectionAlias, moveProjectionAlias } from "./projection-alias.js";
15
+ import { projectionDir } from "./projection-core.js";
14
16
  import { waitForProjection } from "./projection-wait.js";
15
17
  import { requireResumableArtifact } from "./transcripts.js";
16
18
  import { appendDebugBlock } from "../telemetry/debug-log.js";
@@ -125,7 +127,7 @@ async function readCallKeiyakuContext(executionCwd, input) {
125
127
  if (input.bare && input.contractId) {
126
128
  const asTyped = input.contractAddressSource === "at-address"
127
129
  ? `@${input.contractId}`
128
- : input.contractId;
130
+ : `--contract ${input.contractId}`;
129
131
  throw new AddressResolutionError("EMPTY_PARAM", `commission address ${asTyped} cannot be combined with --bare`, buildResolutionAttempt({
130
132
  repo: { kind: "user" },
131
133
  workdir: executionCwd,
@@ -138,7 +140,7 @@ async function readCallKeiyakuContext(executionCwd, input) {
138
140
  if (input.contractId) {
139
141
  const asTyped = input.contractAddressSource === "at-address"
140
142
  ? `@${input.contractId}`
141
- : input.contractId;
143
+ : `--contract ${input.contractId}`;
142
144
  throw new AddressResolutionError("EMPTY_PARAM", `${asTyped} is a commission address, but ${ledgerCandidate} has no repository ledger; contract addressing requires a ledger`, buildResolutionAttempt({
143
145
  repo: { kind: "user" },
144
146
  workdir: executionCwd,
@@ -190,7 +192,7 @@ async function readCallKeiyakuContext(executionCwd, input) {
190
192
  });
191
193
  const asTyped = input.contractAddressSource === "at-address"
192
194
  ? `@${input.contractId}`
193
- : input.contractId;
195
+ : `--contract ${input.contractId}`;
194
196
  throw new AddressResolutionError("EMPTY_PARAM", `commission address ${asTyped} cannot be combined with --bare`, buildResolutionAttempt({
195
197
  repo: repositoryAddress.repo,
196
198
  workdir: repositoryAddress.workdir,
@@ -240,6 +242,7 @@ async function readCallKeiyakuContext(executionCwd, input) {
240
242
  catch (error) {
241
243
  if (error instanceof AddressResolutionError
242
244
  && error.code === "EMPTY_PARAM"
245
+ && error.attempt.expected === "commission"
243
246
  && error.attempt.candidates.length === 0) {
244
247
  return {
245
248
  activeKeiyaku: false,
@@ -414,7 +417,7 @@ async function buildCallConfig(agentName, prompt, cwd, options) {
414
417
  idleTimeoutMs: options.idleTimeoutMs,
415
418
  });
416
419
  // Mint projection directory for supervisor pump.
417
- const slug = launchSnapshot.akuma.name.replace(/[^a-zA-Z0-9_-]/g, "-").toLowerCase();
420
+ const slug = launchSnapshot.akuma.name;
418
421
  const publishProjection = async () => {
419
422
  const mintInput = {
420
423
  cwd: options.projectionCwd ?? cwd,
@@ -462,7 +465,29 @@ async function buildCallConfig(agentName, prompt, cwd, options) {
462
465
  finally {
463
466
  store.close();
464
467
  }
465
- return { id: minted.id, dir: minted.dir, executionId };
468
+ const previousAliasTarget = options.alias === undefined
469
+ ? undefined
470
+ : moveProjectionAlias(options.projectionCwd ?? cwd, assertProjectionAlias(options.alias), minted.id);
471
+ const previousAliasTargetStillRunning = previousAliasTarget === undefined
472
+ ? undefined
473
+ : (() => {
474
+ try {
475
+ const phase = observeProjectionLife(projectionDir(options.projectionCwd ?? cwd, previousAliasTarget), { nowMs: Date.now(), startupTimeoutMs: PROJECTION_ADOPTION_TIMEOUT_MS }).phase;
476
+ return phase === "minting" || phase === "active" || phase === "quiescent"
477
+ ? previousAliasTarget
478
+ : undefined;
479
+ }
480
+ catch {
481
+ return undefined;
482
+ }
483
+ })();
484
+ return {
485
+ id: minted.id,
486
+ dir: minted.dir,
487
+ executionId,
488
+ ...(options.alias ? { alias: options.alias } : {}),
489
+ ...(previousAliasTargetStillRunning ? { previousAliasTarget: previousAliasTargetStillRunning } : {}),
490
+ };
466
491
  };
467
492
  // Every public launch has one durable projection object. Repository-free
468
493
  // authority changes the storage owner, not whether the object exists; mint
@@ -565,6 +590,7 @@ export async function runCall(input) {
565
590
  binding: keiyakuContext.address.binding,
566
591
  title,
567
592
  context: input.context,
593
+ ...(input.mode === "call" && input.alias !== undefined ? { alias: assertProjectionAlias(input.alias) } : {}),
568
594
  };
569
595
  if ((input.mode ?? "call") === "call") {
570
596
  const launched = await launchSubagentGeneration(launchSnapshot.akuma.name, prompt, executionCwd, executionOptions);
@@ -574,6 +600,8 @@ export async function runCall(input) {
574
600
  nowMs: Date.now(),
575
601
  startupTimeoutMs: PROJECTION_ADOPTION_TIMEOUT_MS,
576
602
  }).phase,
603
+ ...(launched.projection.alias ? { alias: launched.projection.alias } : {}),
604
+ ...(launched.projection.previousAliasTarget ? { previousAliasTarget: launched.projection.previousAliasTarget } : {}),
577
605
  };
578
606
  input.onLaunch?.({ address: keiyakuContext.address, projection: adoptedProjection });
579
607
  if (input.detach) {
@@ -20,11 +20,21 @@ function assertMintSlug(slug) {
20
20
  if (!normalized) {
21
21
  throw new ProjectionStateError("projection slug cannot be empty");
22
22
  }
23
- if (normalized.includes("/") || normalized.includes("\0")) {
23
+ if (!/^[a-zA-Z0-9]+(?:[.-][a-zA-Z0-9]+)*$/.test(normalized)) {
24
24
  throw new ProjectionStateError(`invalid projection slug: ${slug}`);
25
25
  }
26
26
  return normalized;
27
27
  }
28
+ function removeEmptyAkumaRoot(dir) {
29
+ try {
30
+ fs.rmdirSync(dir);
31
+ }
32
+ catch (error) {
33
+ if (isErrnoException(error) && ["ENOENT", "ENOTEMPTY", "EEXIST"].includes(error.code ?? ""))
34
+ return;
35
+ throw error;
36
+ }
37
+ }
28
38
  /**
29
39
  * Validate mint inputs that can fail, then reserve one private staging directory.
30
40
  * Dot-prefixed staging is never a published projection address.
@@ -43,14 +53,19 @@ function reserveProjectionDirectory(input) {
43
53
  }
44
54
  const root = projectionRoot(input.cwd);
45
55
  fs.mkdirSync(root, { recursive: true });
56
+ const akumaRoot = path.join(root, slug);
57
+ const createdAkumaRoot = !fs.existsSync(akumaRoot);
58
+ fs.mkdirSync(akumaRoot, { recursive: true });
46
59
  for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
47
60
  const hex = (input.randomHex?.(attempt) ?? randomHex8()).toLowerCase();
48
61
  if (!/^[0-9a-f]{8}$/.test(hex)) {
62
+ if (createdAkumaRoot)
63
+ removeEmptyAkumaRoot(akumaRoot);
49
64
  throw new ProjectionStateError(`projection id random segment must be 8 hex chars, got ${hex}`);
50
65
  }
51
- const id = `${slug}-${hex}`;
66
+ const id = `${slug}/${hex}`;
52
67
  const dir = projectionDir(input.cwd, id);
53
- const stagingDir = path.join(root, `.mint-${id}`);
68
+ const stagingDir = path.join(akumaRoot, `.mint-${hex}`);
54
69
  if (fs.existsSync(dir))
55
70
  continue;
56
71
  try {
@@ -60,6 +75,8 @@ function reserveProjectionDirectory(input) {
60
75
  if (isErrnoException(error) && error.code === "EEXIST") {
61
76
  continue;
62
77
  }
78
+ if (createdAkumaRoot)
79
+ removeEmptyAkumaRoot(akumaRoot);
63
80
  throw error;
64
81
  }
65
82
  if (fs.existsSync(dir)) {
@@ -68,6 +85,8 @@ function reserveProjectionDirectory(input) {
68
85
  }
69
86
  return { id, dir, stagingDir };
70
87
  }
88
+ if (createdAkumaRoot)
89
+ removeEmptyAkumaRoot(akumaRoot);
71
90
  throw new ProjectionMintCollisionError(`exhausted ${maxAttempts} projection id attempts under ${PROJECTION_ROOT_SEGMENTS.join("/")} (PROJECTION_MINT_COLLISION)`);
72
91
  }
73
92
  /** Build the identity fields shared by both authority variants. */
@@ -107,6 +126,7 @@ function publishReservedProjection(pact, reserved) {
107
126
  if (!published) {
108
127
  // Failure removes only this mint's staging.
109
128
  fs.rmSync(reserved.stagingDir, { recursive: true, force: true });
129
+ removeEmptyAkumaRoot(path.dirname(reserved.stagingDir));
110
130
  }
111
131
  }
112
132
  }
@@ -0,0 +1,123 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { isErrnoException } from "../errno.js";
4
+ import { ProjectionStateError, randomHex8 } from "./atomic-publish.js";
5
+ import { projectionCoordinateRoot } from "./projection-coordinate.js";
6
+ import { isProjectionId } from "./projection-core.js";
7
+ export const PROJECTION_ALIAS_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
8
+ const MAX_ALIAS_REF_BYTES = 1024;
9
+ export function isProjectionAlias(value) {
10
+ return PROJECTION_ALIAS_PATTERN.test(value);
11
+ }
12
+ export function assertProjectionAlias(value) {
13
+ if (!isProjectionAlias(value)) {
14
+ throw new ProjectionStateError("projection alias must match ^[a-z0-9][a-z0-9-]{0,63}$");
15
+ }
16
+ return value;
17
+ }
18
+ export function projectionAliasRoot(cwd) {
19
+ return path.join(projectionCoordinateRoot(cwd), ".keiyaku", "projection-alias");
20
+ }
21
+ export function projectionAliasPath(cwd, alias) {
22
+ assertProjectionAlias(alias);
23
+ return path.join(projectionAliasRoot(cwd), alias);
24
+ }
25
+ function invalidAliasRef(filePath) {
26
+ return new ProjectionStateError(`invalid projection alias ref: ${filePath}`);
27
+ }
28
+ function readBoundedRegularAliasRef(filePath) {
29
+ let before;
30
+ try {
31
+ before = fs.lstatSync(filePath);
32
+ }
33
+ catch (error) {
34
+ if (isErrnoException(error) && error.code === "ENOENT")
35
+ return undefined;
36
+ throw error;
37
+ }
38
+ if (!before.isFile() || before.size > MAX_ALIAS_REF_BYTES)
39
+ throw invalidAliasRef(filePath);
40
+ let fd;
41
+ try {
42
+ fd = fs.openSync(filePath, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
43
+ }
44
+ catch (error) {
45
+ if (isErrnoException(error) && error.code === "ELOOP")
46
+ throw invalidAliasRef(filePath);
47
+ throw error;
48
+ }
49
+ try {
50
+ const opened = fs.fstatSync(fd);
51
+ if (!opened.isFile() || opened.size > MAX_ALIAS_REF_BYTES)
52
+ throw invalidAliasRef(filePath);
53
+ const bytes = Buffer.alloc(opened.size);
54
+ const count = fs.readSync(fd, bytes, 0, bytes.length, 0);
55
+ if (count !== bytes.length)
56
+ throw invalidAliasRef(filePath);
57
+ return bytes.toString("utf8");
58
+ }
59
+ finally {
60
+ fs.closeSync(fd);
61
+ }
62
+ }
63
+ /** Undefined means no ref; malformed refs fail closed with their exact sidecar path. */
64
+ export function readProjectionAlias(cwd, alias) {
65
+ const filePath = projectionAliasPath(cwd, alias);
66
+ const content = readBoundedRegularAliasRef(filePath);
67
+ if (content === undefined)
68
+ return undefined;
69
+ if (!content.endsWith("\n") || content.indexOf("\n") !== content.length - 1)
70
+ throw invalidAliasRef(filePath);
71
+ const id = content.slice(0, -1);
72
+ if (!isProjectionId(id))
73
+ throw invalidAliasRef(filePath);
74
+ return id;
75
+ }
76
+ /** Atomically replace one alias ref. Aliases intentionally have no multi-ref transaction. */
77
+ export function moveProjectionAlias(cwd, alias, id) {
78
+ assertProjectionAlias(alias);
79
+ if (!isProjectionId(id))
80
+ throw new ProjectionStateError(`invalid projection id: ${id}`);
81
+ const previous = readProjectionAlias(cwd, alias);
82
+ const root = projectionAliasRoot(cwd);
83
+ fs.mkdirSync(root, { recursive: true });
84
+ const target = projectionAliasPath(cwd, alias);
85
+ let temp;
86
+ try {
87
+ for (let attempt = 0; attempt < 16; attempt += 1) {
88
+ const candidate = path.join(root, `.tmp.${randomHex8()}`);
89
+ try {
90
+ const fd = fs.openSync(candidate, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL, 0o600);
91
+ temp = candidate;
92
+ try {
93
+ fs.writeFileSync(fd, `${id}\n`, "utf8");
94
+ fs.fsyncSync(fd);
95
+ }
96
+ finally {
97
+ fs.closeSync(fd);
98
+ }
99
+ break;
100
+ }
101
+ catch (error) {
102
+ if (isErrnoException(error) && error.code === "EEXIST")
103
+ continue;
104
+ throw error;
105
+ }
106
+ }
107
+ if (!temp)
108
+ throw new ProjectionStateError(`failed to create projection alias temp: ${target}`);
109
+ fs.renameSync(temp, target);
110
+ const directoryFd = fs.openSync(root, fs.constants.O_RDONLY);
111
+ try {
112
+ fs.fsyncSync(directoryFd);
113
+ }
114
+ finally {
115
+ fs.closeSync(directoryFd);
116
+ }
117
+ }
118
+ finally {
119
+ if (temp)
120
+ fs.rmSync(temp, { force: true });
121
+ }
122
+ return previous;
123
+ }
@@ -2,13 +2,13 @@ import { spawnSync } from "node:child_process";
2
2
  import * as path from "node:path";
3
3
  import { ProjectionStateError } from "./atomic-publish.js";
4
4
  /**
5
- * Select the filesystem authority for repository projection state.
5
+ * Resolve projection storage root and authority from one Git probe.
6
6
  *
7
7
  * A non-bare repository uses the parent of its absolute common Git directory,
8
8
  * so every linked worktree shares one coordinate. Repo-free callers retain the
9
9
  * existing cwd-local authority until its replacement is legislated.
10
10
  */
11
- export function projectionCoordinateRoot(cwd) {
11
+ export function resolveProjectionCoordinate(cwd) {
12
12
  const result = spawnSync("git", ["rev-parse", "--is-bare-repository", "--path-format=absolute", "--git-common-dir"], {
13
13
  cwd,
14
14
  encoding: "utf8",
@@ -24,12 +24,28 @@ export function projectionCoordinateRoot(cwd) {
24
24
  }
25
25
  if (result.status !== 0) {
26
26
  const detail = result.stderr.trim();
27
- if (detail.includes("not a git repository"))
28
- return cwd;
27
+ if (detail.includes("not a git repository")) {
28
+ return { physicalRoot: cwd, authority: { kind: "user" } };
29
+ }
29
30
  throw new ProjectionStateError(`cannot resolve projection repository coordinate${detail ? `: ${detail}` : ` (git exited ${String(result.status)})`}`);
30
31
  }
31
32
  const [bare, commonDir] = result.stdout.trim().split(/\r?\n/);
32
- if (bare !== "false" || !commonDir || !path.isAbsolute(commonDir))
33
- return cwd;
34
- return path.dirname(commonDir);
33
+ if (bare !== "false" || !commonDir || !path.isAbsolute(commonDir)) {
34
+ return { physicalRoot: cwd, authority: { kind: "user" } };
35
+ }
36
+ const physicalRoot = path.dirname(commonDir);
37
+ return {
38
+ physicalRoot,
39
+ authority: { kind: "repository", stableRoot: physicalRoot },
40
+ };
41
+ }
42
+ /**
43
+ * Select the filesystem authority for repository projection state.
44
+ *
45
+ * A non-bare repository uses the parent of its absolute common Git directory,
46
+ * so every linked worktree shares one coordinate. Repo-free callers retain the
47
+ * existing cwd-local authority until its replacement is legislated.
48
+ */
49
+ export function projectionCoordinateRoot(cwd) {
50
+ return resolveProjectionCoordinate(cwd).physicalRoot;
35
51
  }
@@ -3,16 +3,22 @@ import * as path from "node:path";
3
3
  import { isErrnoException } from "../errno.js";
4
4
  import { projectionCoordinateRoot } from "./projection-coordinate.js";
5
5
  import { ProjectionStateError, } from "./atomic-publish.js";
6
- import { readProjectionIdentity } from "./projection-identity.js";
7
6
  export { atomicPublishFile, ProjectionStateError, randomHex8 } from "./atomic-publish.js";
8
7
  export { claimAllInbox, claimFencedTells, claimTellToInflight, demoteSubmittedTellsForReplay, isTellCausallyConsumed, listTellIds, markTellConsumed, markTellSubmitted, readTellOriginal, readTellSubmitted, snapshotPendingTells, writeInboxTell, } from "./projection-tells.js";
9
8
  /** Relative segments for `.keiyaku/projection`. */
10
9
  export const PROJECTION_ROOT_SEGMENTS = [".keiyaku", "projection"];
10
+ export const PROJECTION_ID_PATTERN = /^[a-zA-Z0-9]+(?:[.-][a-zA-Z0-9]+)*\/[0-9a-f]{8}$/;
11
+ export function isProjectionId(value) {
12
+ return PROJECTION_ID_PATTERN.test(value);
13
+ }
11
14
  export function projectionRoot(cwd) {
12
15
  return path.join(projectionCoordinateRoot(cwd), ...PROJECTION_ROOT_SEGMENTS);
13
16
  }
14
17
  export function projectionDir(cwd, id) {
15
- return path.join(projectionRoot(cwd), id);
18
+ if (!isProjectionId(id))
19
+ throw new ProjectionStateError(`invalid projection id: ${id}`);
20
+ const [akuma, hex] = id.split("/");
21
+ return path.join(projectionRoot(cwd), akuma, hex);
16
22
  }
17
23
  export function encodeProjectionJson(value) {
18
24
  return `${JSON.stringify(value)}\n`;
@@ -32,51 +38,6 @@ export function isPublishedProjection(dir) {
32
38
  return false;
33
39
  }
34
40
  }
35
- /** readdir-based address resolution. Atomic mint rename makes every visible non-dot directory published. */
36
- export function resolveProjectionPrefix(cwd, prefix, commissionId) {
37
- const needle = prefix.trim();
38
- if (!needle) {
39
- return { status: "none" };
40
- }
41
- const root = projectionRoot(cwd);
42
- let entries;
43
- try {
44
- entries = fs.readdirSync(root);
45
- }
46
- catch (error) {
47
- if (isErrnoException(error) && error.code === "ENOENT") {
48
- return { status: "none" };
49
- }
50
- throw error;
51
- }
52
- const publishedIds = entries
53
- .filter((id) => {
54
- if (id.startsWith("."))
55
- return false;
56
- const directory = path.join(root, id);
57
- return isPublishedProjection(directory);
58
- })
59
- .sort();
60
- const addressed = publishedIds.filter((id) => {
61
- if (id === needle || id.startsWith(needle))
62
- return true;
63
- const suffix = /-([0-9a-f]{8})$/.exec(id)?.[1];
64
- return suffix === needle;
65
- });
66
- const candidates = commissionId === undefined
67
- ? addressed
68
- : addressed.filter((id) => {
69
- const identity = readProjectionIdentity(path.join(root, id));
70
- return identity.binding.kind === "commission" && identity.binding.commissionId === commissionId;
71
- });
72
- if (candidates.length === 1) {
73
- return { status: "resolved", id: candidates[0] };
74
- }
75
- if (candidates.length === 0) {
76
- return { status: "none" };
77
- }
78
- return { status: "ambiguous", candidates };
79
- }
80
41
  const PROJECTION_EXECUTION_ID_PATTERN = /^[0-9A-HJKMNP-TV-Z]{26}$/;
81
42
  function assertProjectionExecutionId(executionId) {
82
43
  if (!PROJECTION_EXECUTION_ID_PATTERN.test(executionId)) {
@@ -0,0 +1,26 @@
1
+ import { openProjectionGenerationStore } from "./projection-generation-store.js";
2
+ export function killProjectionGeneration(projectionDirectory, nowMs = Date.now()) {
3
+ const store = openProjectionGenerationStore(projectionDirectory);
4
+ try {
5
+ const current = store.readCurrentGeneration();
6
+ if (!current)
7
+ return { status: "already-resting" };
8
+ if (current.verdict) {
9
+ return { status: "already-resting", executionId: current.launch.executionId };
10
+ }
11
+ const transition = store.verdictIfOpen({
12
+ executionId: current.launch.executionId,
13
+ verdict: "completed",
14
+ facts: {
15
+ operatorAction: "kill",
16
+ killedAt: new Date(nowMs).toISOString(),
17
+ },
18
+ });
19
+ return transition.status === "committed"
20
+ ? { status: "killed", executionId: current.launch.executionId }
21
+ : { status: "already-resting", executionId: current.launch.executionId };
22
+ }
23
+ finally {
24
+ store.close();
25
+ }
26
+ }