@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.
- package/build/.tsbuildinfo +1 -1
- package/build/agents/call-terms_v2.js +6 -2
- package/build/agents/harness/index.js +5 -1
- package/build/agents/harness/pump.js +22 -5
- package/build/agents/launch-snapshot_v2.js +14 -9
- package/build/agents/selector_v2.js +15 -3
- package/build/cli/commands/akuma.js +21 -3
- package/build/cli/completion.js +4 -1
- package/build/cli/flags.js +10 -1
- package/build/cli/help.js +5 -3
- package/build/cli/index.js +67 -14
- package/build/cli/parse.js +65 -24
- package/build/cli/projection-address.js +97 -10
- package/build/cli/render/address.js +12 -3
- package/build/cli/render/call.js +10 -5
- package/build/cli/render/shared.js +2 -2
- package/build/cli/render/status.js +19 -4
- package/build/cli/render/wait.js +3 -3
- package/build/config/provider-policy-ownership.js +39 -0
- package/build/config/settings.js +1 -11
- package/build/core/addressing.js +82 -38
- package/build/core/call.js +33 -5
- package/build/core/projection/mint.js +23 -3
- package/build/core/projection-alias.js +123 -0
- package/build/core/projection-coordinate.js +23 -7
- package/build/core/projection-core.js +8 -47
- package/build/core/projection-kill.js +26 -0
- package/build/core/projection-mint.js +24 -3
- package/build/core/projection-runner-lock.js +1 -1
- package/build/core/projection-status.js +111 -12
- package/build/core/projection-wake.js +3 -0
- package/build/core/response-artifact-id.js +5 -0
- package/build/core/status.js +1 -1
- package/build/core/transcripts.js +42 -15
- package/build/generated/version.js +1 -1
- package/package.json +4 -4
package/build/core/addressing.js
CHANGED
|
@@ -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
|
-
|
|
166
|
-
|
|
167
|
-
const
|
|
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
|
-
|
|
173
|
-
|
|
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 (
|
|
176
|
-
const candidate = operable[0];
|
|
228
|
+
if (matches.length === 1) {
|
|
177
229
|
return buildResolvedAddress({
|
|
178
230
|
repo,
|
|
179
231
|
workdir,
|
|
180
|
-
candidate,
|
|
181
|
-
matchedBy:
|
|
182
|
-
asTyped:
|
|
183
|
-
source
|
|
184
|
-
inference:
|
|
232
|
+
candidate: matches[0],
|
|
233
|
+
matchedBy: matches[0].spelling,
|
|
234
|
+
asTyped: attempt.spelling.asTyped,
|
|
235
|
+
source,
|
|
236
|
+
inference: [],
|
|
185
237
|
});
|
|
186
238
|
}
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
|
192
|
-
const
|
|
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
|
-
|
|
198
|
-
|
|
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 (
|
|
253
|
+
if (operable.length === 1) {
|
|
254
|
+
const candidate = operable[0];
|
|
210
255
|
return buildResolvedAddress({
|
|
211
256
|
repo,
|
|
212
257
|
workdir,
|
|
213
|
-
candidate
|
|
214
|
-
matchedBy:
|
|
215
|
-
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
|
-
|
|
221
|
-
|
|
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
|
}
|
package/build/core/call.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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 (
|
|
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}
|
|
66
|
+
const id = `${slug}/${hex}`;
|
|
52
67
|
const dir = projectionDir(input.cwd, id);
|
|
53
|
-
const stagingDir = path.join(
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|