balladeer 0.0.4 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +200 -5
- package/README.md +154 -68
- package/dist/agent.d.ts +126 -0
- package/dist/agent.js +209 -0
- package/dist/cli.d.ts +34 -0
- package/dist/cli.js +392 -0
- package/dist/client.d.ts +44 -0
- package/dist/client.js +114 -0
- package/dist/commands/affected.d.ts +22 -0
- package/dist/commands/affected.js +122 -0
- package/dist/commands/check-seals.d.ts +37 -0
- package/dist/commands/check-seals.js +289 -0
- package/dist/commands/discover.d.ts +68 -0
- package/dist/commands/discover.js +395 -0
- package/dist/commands/explain.d.ts +35 -0
- package/dist/commands/explain.js +90 -0
- package/dist/commands/invite.d.ts +24 -0
- package/dist/commands/invite.js +197 -0
- package/dist/commands/mcp.d.ts +65 -0
- package/dist/commands/mcp.js +202 -0
- package/dist/commands/propose.d.ts +59 -0
- package/dist/commands/propose.js +262 -0
- package/dist/commands/repositories.d.ts +18 -0
- package/dist/commands/repositories.js +185 -0
- package/dist/commands/setup.d.ts +75 -0
- package/dist/commands/setup.js +1471 -0
- package/dist/commands/status.d.ts +35 -0
- package/dist/commands/status.js +482 -0
- package/dist/commands/touch-map.d.ts +42 -0
- package/dist/commands/touch-map.js +251 -0
- package/dist/commands/whoami.d.ts +8 -0
- package/dist/commands/whoami.js +79 -0
- package/dist/conventions.d.ts +69 -0
- package/dist/conventions.js +175 -0
- package/dist/copy.d.ts +148 -0
- package/dist/copy.js +459 -0
- package/dist/currency.d.ts +31 -0
- package/dist/currency.js +72 -0
- package/dist/gh.d.ts +80 -0
- package/dist/gh.js +188 -0
- package/dist/git.d.ts +76 -0
- package/dist/git.js +203 -0
- package/dist/markers.d.ts +76 -0
- package/dist/markers.js +125 -0
- package/dist/mcp-config.d.ts +99 -0
- package/dist/mcp-config.js +230 -0
- package/dist/release.d.ts +55 -0
- package/dist/release.js +67 -0
- package/dist/repository.d.ts +8 -0
- package/dist/repository.js +32 -0
- package/dist/seals.d.ts +48 -0
- package/dist/seals.js +112 -0
- package/dist/store.d.ts +98 -0
- package/dist/store.js +225 -0
- package/dist/touch-map.d.ts +241 -0
- package/dist/touch-map.js +487 -0
- package/dist/wire.d.ts +588 -0
- package/dist/wire.js +20 -0
- package/package.json +19 -10
- package/bin/balladeer.js +0 -136
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export type StatusOptions = Readonly<{
|
|
2
|
+
controlPlane: string;
|
|
3
|
+
json: boolean;
|
|
4
|
+
/**
|
|
5
|
+
* One promise, by id, instead of the whole picture.
|
|
6
|
+
*
|
|
7
|
+
* It is the second half of the line a person copies out of a broken promise:
|
|
8
|
+
* "Run balladeer status <id> first." An agent that starts here reads what the
|
|
9
|
+
* failing run actually reported and repairs the behavior; one that starts from
|
|
10
|
+
* the claim alone rewrites the test until it goes green.
|
|
11
|
+
*/
|
|
12
|
+
promiseId?: string;
|
|
13
|
+
/** The repository whose connection to use, named as `owner/name`. */
|
|
14
|
+
repo?: string;
|
|
15
|
+
/** The repository whose connection to use, named by id, as `mcp` takes it. */
|
|
16
|
+
repository?: string;
|
|
17
|
+
environment: NodeJS.ProcessEnv;
|
|
18
|
+
cwd: string;
|
|
19
|
+
write: (text: string) => void;
|
|
20
|
+
}>;
|
|
21
|
+
/**
|
|
22
|
+
* What Balladeer looks like right now, read from the server and never from
|
|
23
|
+
* anything this machine remembers.
|
|
24
|
+
*
|
|
25
|
+
* It reads two different things over two different credentials, because they are
|
|
26
|
+
* two different questions. This repository's own state comes over its agent
|
|
27
|
+
* connection, which setup issued and which does not expire, so an agent asked
|
|
28
|
+
* "are we set up here" gets an answer months after pairing. The whole workspace,
|
|
29
|
+
* every repository in it, is a person's view and stays on the delegated setup
|
|
30
|
+
* session; when no live session is stored this says so rather than reporting a
|
|
31
|
+
* workspace it could not read.
|
|
32
|
+
*
|
|
33
|
+
* It changes nothing.
|
|
34
|
+
*/
|
|
35
|
+
export declare function runStatus(options: StatusOptions): Promise<number>;
|
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
import { callAgentTool, noAgentCredentialSentence, selectAgent, structuredString, } from "../agent.js";
|
|
2
|
+
import { ClientTooOldError, RefusalError, TransportError, request } from "../client.js";
|
|
3
|
+
import { repositoryRoot } from "../git.js";
|
|
4
|
+
import { MARKER_OBSERVATION_LIMITS, observeMissingMarkers, staleMarkerRow, } from "../markers.js";
|
|
5
|
+
import { commandLine } from "../release.js";
|
|
6
|
+
import { repositoryHint } from "../repository.js";
|
|
7
|
+
import { SEALED_PATHS_SHOWN, sealedPromises } from "../seals.js";
|
|
8
|
+
import { StoreError, findSession, readCredentials } from "../store.js";
|
|
9
|
+
import {} from "../wire.js";
|
|
10
|
+
/**
|
|
11
|
+
* The bounded code for "this machine holds no credential for that repository".
|
|
12
|
+
* It is its own code rather than the session's: an agent branching on
|
|
13
|
+
* `session_expired` pairs again and still holds nothing.
|
|
14
|
+
*/
|
|
15
|
+
const NO_CREDENTIAL_HERE = "no_agent_credential_on_this_machine";
|
|
16
|
+
function noSessionSentence(controlPlane) {
|
|
17
|
+
return `Every repository in the workspace is read over a setup session, and none is stored for ${controlPlane}. Run \`${commandLine(null, "setup")}\` to read the whole workspace.`;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* What Balladeer looks like right now, read from the server and never from
|
|
21
|
+
* anything this machine remembers.
|
|
22
|
+
*
|
|
23
|
+
* It reads two different things over two different credentials, because they are
|
|
24
|
+
* two different questions. This repository's own state comes over its agent
|
|
25
|
+
* connection, which setup issued and which does not expire, so an agent asked
|
|
26
|
+
* "are we set up here" gets an answer months after pairing. The whole workspace,
|
|
27
|
+
* every repository in it, is a person's view and stays on the delegated setup
|
|
28
|
+
* session; when no live session is stored this says so rather than reporting a
|
|
29
|
+
* workspace it could not read.
|
|
30
|
+
*
|
|
31
|
+
* It changes nothing.
|
|
32
|
+
*/
|
|
33
|
+
export async function runStatus(options) {
|
|
34
|
+
const code = await reportBalladeer(options);
|
|
35
|
+
// Last, and on every path out of the report above, including the ones that
|
|
36
|
+
// could read nothing at all. Which directories are sealed is a fact about
|
|
37
|
+
// this checkout rather than about any credential, and a session that has just
|
|
38
|
+
// been told it holds none is exactly the session about to edit something.
|
|
39
|
+
await reportSealedPaths(options, (step) => {
|
|
40
|
+
if (options.json)
|
|
41
|
+
options.write(`${JSON.stringify(step)}\n`);
|
|
42
|
+
}, (text) => {
|
|
43
|
+
if (!options.json)
|
|
44
|
+
options.write(`${text}\n`);
|
|
45
|
+
});
|
|
46
|
+
return code;
|
|
47
|
+
}
|
|
48
|
+
async function reportBalladeer(options) {
|
|
49
|
+
const emit = (step) => {
|
|
50
|
+
if (options.json)
|
|
51
|
+
options.write(`${JSON.stringify(step)}\n`);
|
|
52
|
+
};
|
|
53
|
+
const say = (text) => {
|
|
54
|
+
if (!options.json)
|
|
55
|
+
options.write(`${text}\n`);
|
|
56
|
+
};
|
|
57
|
+
const fail = (reason, message, exitCode) => {
|
|
58
|
+
if (options.json)
|
|
59
|
+
emit({ step: "error", reason, message, changed: false, exitCode });
|
|
60
|
+
else
|
|
61
|
+
options.write(`${message}\n`);
|
|
62
|
+
return exitCode;
|
|
63
|
+
};
|
|
64
|
+
let credentials;
|
|
65
|
+
try {
|
|
66
|
+
credentials = readCredentials(options.environment);
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
return fail(error instanceof StoreError ? error.code : "credential_store_unusable", error instanceof StoreError ? error.message : String(error), 4);
|
|
70
|
+
}
|
|
71
|
+
const here = options.repo ?? repositoryHint(options.cwd);
|
|
72
|
+
const selection = selectAgent(credentials.agents, options.controlPlane, options.repository, here);
|
|
73
|
+
const session = findSession(credentials, options.controlPlane);
|
|
74
|
+
// The credential this machine holds is what this repository is read over, so
|
|
75
|
+
// its absence is the first thing said, and it is said in its own words. What
|
|
76
|
+
// the founder got instead was the workspace half's refusal, "Balladeer refused
|
|
77
|
+
// to report this workspace (session_expired). Run setup to pair again", which
|
|
78
|
+
// named the wrong credential, the wrong remedy and the wrong machine: the
|
|
79
|
+
// repository was connected, in a browser, and this machine had never been
|
|
80
|
+
// issued anything.
|
|
81
|
+
if (selection.kind === "refused" && selection.missingFor !== undefined) {
|
|
82
|
+
const message = noAgentCredentialSentence(selection.missingFor);
|
|
83
|
+
if (options.json) {
|
|
84
|
+
emit({ step: "error", reason: NO_CREDENTIAL_HERE, message, changed: false, exitCode: 4 });
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
options.write(`${message}\n`);
|
|
88
|
+
}
|
|
89
|
+
if (session === undefined) {
|
|
90
|
+
sayWorkspaceUnavailable(emit, say, "no_stored_setup_session", noSessionSentence(options.controlPlane));
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
// A different question, over a different credential, so it is still
|
|
94
|
+
// answered underneath. Its outcome never becomes this run's exit code:
|
|
95
|
+
// this repository went unreported whatever the workspace read did.
|
|
96
|
+
await reportWorkspace(options, session, here, emit, say, true);
|
|
97
|
+
}
|
|
98
|
+
return 4;
|
|
99
|
+
}
|
|
100
|
+
if (selection.kind === "refused" && session === undefined) {
|
|
101
|
+
// Nothing stored can answer anything, so this names the form that always
|
|
102
|
+
// works: the one this copy was run as.
|
|
103
|
+
return fail("nothing_stored", `${selection.reason} No Balladeer setup session is stored for ${options.controlPlane} either. Run: ${commandLine(null, "setup")}`, 4);
|
|
104
|
+
}
|
|
105
|
+
// One promise, by id: a different question, answered over the same connection
|
|
106
|
+
// and on its own. Somebody who pasted the repair line wants what the failing
|
|
107
|
+
// run reported, not a repository-wide report they have to read past to find
|
|
108
|
+
// it, and the workspace-wide half needs a setup session this person may not
|
|
109
|
+
// have any more.
|
|
110
|
+
if (options.promiseId !== undefined) {
|
|
111
|
+
if (selection.kind !== "agent") {
|
|
112
|
+
return fail("nothing_stored", `${selection.reason} A promise is read over this repository's own agent connection, so nothing here could answer for ${options.promiseId}.`, 4);
|
|
113
|
+
}
|
|
114
|
+
return reportOnePromise(options, options.promiseId, selection.agent, emit, say);
|
|
115
|
+
}
|
|
116
|
+
let reported = false;
|
|
117
|
+
if (selection.kind === "agent") {
|
|
118
|
+
reported = await reportThisRepository(options, selection.agent, emit, say);
|
|
119
|
+
}
|
|
120
|
+
if (session === undefined) {
|
|
121
|
+
sayWorkspaceUnavailable(emit, say, "no_stored_setup_session", noSessionSentence(options.controlPlane));
|
|
122
|
+
return reported ? 0 : 4;
|
|
123
|
+
}
|
|
124
|
+
return reportWorkspace(options, session, here, emit, say, reported);
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* This repository, over its own agent connection.
|
|
128
|
+
*
|
|
129
|
+
* `get_promise_setup` is the tool the setup probe already uses, and it answers
|
|
130
|
+
* for the one repository the connection is bound to. An answer naming a
|
|
131
|
+
* different repository is a failure rather than a curiosity: it would mean this
|
|
132
|
+
* machine reported one repository's state under another's name.
|
|
133
|
+
*/
|
|
134
|
+
async function reportThisRepository(options, agent, emit, say) {
|
|
135
|
+
const call = await callAgentTool(agent, "get_promise_setup", {});
|
|
136
|
+
if (call.kind !== "result") {
|
|
137
|
+
say(`This repository could not be read over its Balladeer agent connection: ${reason(call)}`);
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
const repositoryId = structuredString(call.structured, "repositoryId");
|
|
141
|
+
const repository = structuredString(call.structured, "repository");
|
|
142
|
+
if (repositoryId === undefined || repositoryId !== agent.repositoryId) {
|
|
143
|
+
say("That connection answered for a different repository, so nothing about this one was reported. Issue the connection again from the Balladeer setup page, and do not use it.");
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
const structured = call.structured;
|
|
147
|
+
const enrolled = structured.enrolled === true;
|
|
148
|
+
const memberCount = Array.isArray(structured.members) ? structured.members.length : 0;
|
|
149
|
+
emit({
|
|
150
|
+
step: "connection",
|
|
151
|
+
status: "agent",
|
|
152
|
+
repositoryId,
|
|
153
|
+
...(repository === undefined ? {} : { repository }),
|
|
154
|
+
enrolled,
|
|
155
|
+
memberCount,
|
|
156
|
+
});
|
|
157
|
+
say(`${repository ?? repositoryId}, read over this repository's Balladeer agent connection.`);
|
|
158
|
+
say(" Agent: connected. This connection has no expiry; a person revokes it in Balladeer.");
|
|
159
|
+
say(` Attestor release: ${enrolled ? "enrolled, so CI has a runner to pin" : "not enrolled, so there is no runner to pin yet"}.`);
|
|
160
|
+
say(` People who may be named as a promise's owner: ${memberCount}.`);
|
|
161
|
+
await reportStaleMarkers(options, agent, emit, say);
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* The promises this repository keeps that name a path it no longer has.
|
|
166
|
+
*
|
|
167
|
+
* This is the one fact about a catalog that only the machine holding the
|
|
168
|
+
* checkout can establish. Balladeer stores the markers a person approved and
|
|
169
|
+
* has no way to look at the tree, so a rename that leaves a promise
|
|
170
|
+
* unretrievable is invisible everywhere except here. It is read over the same
|
|
171
|
+
* connection as the rest of this half, it changes nothing, and its failure is
|
|
172
|
+
* silence: a status run whose catalog could not be read still reported the
|
|
173
|
+
* repository.
|
|
174
|
+
*/
|
|
175
|
+
async function reportStaleMarkers(options, agent, emit, say) {
|
|
176
|
+
const root = await repositoryRoot(options.cwd);
|
|
177
|
+
if (root === undefined)
|
|
178
|
+
return;
|
|
179
|
+
// One page, deliberately. The index is bounded and so is this: a status run
|
|
180
|
+
// is not the place to walk a thousand-promise catalog, and the row says when
|
|
181
|
+
// it stopped rather than reporting a clean tree it did not finish reading.
|
|
182
|
+
const call = await callAgentTool(agent, "list_promises", { limit: 100 });
|
|
183
|
+
if (call.kind !== "result")
|
|
184
|
+
return;
|
|
185
|
+
const structured = call.structured;
|
|
186
|
+
if (!Array.isArray(structured?.promises))
|
|
187
|
+
return;
|
|
188
|
+
const subjects = [];
|
|
189
|
+
for (const entry of structured.promises) {
|
|
190
|
+
if (typeof entry !== "object" || entry === null)
|
|
191
|
+
continue;
|
|
192
|
+
const row = entry;
|
|
193
|
+
if (typeof row.promiseId !== "string")
|
|
194
|
+
continue;
|
|
195
|
+
subjects.push({
|
|
196
|
+
promiseId: row.promiseId,
|
|
197
|
+
title: typeof row.title === "string" ? row.title : row.promiseId,
|
|
198
|
+
surfaces: Array.isArray(row.surfaces)
|
|
199
|
+
? row.surfaces.filter((value) => typeof value === "string")
|
|
200
|
+
: [],
|
|
201
|
+
labels: Array.isArray(row.labels)
|
|
202
|
+
? row.labels.filter((value) => typeof value === "string")
|
|
203
|
+
: [],
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
if (subjects.length === 0)
|
|
207
|
+
return;
|
|
208
|
+
const observed = observeMissingMarkers(root, subjects, MARKER_OBSERVATION_LIMITS);
|
|
209
|
+
const total = typeof structured.total === "number" ? structured.total : subjects.length;
|
|
210
|
+
emit({
|
|
211
|
+
step: "attention",
|
|
212
|
+
reason: "scope_marker_missing",
|
|
213
|
+
observed: observed.observations.length,
|
|
214
|
+
considered: subjects.length,
|
|
215
|
+
total,
|
|
216
|
+
stoppedEarly: observed.stoppedEarly,
|
|
217
|
+
promises: observed.observations.map((observation) => ({
|
|
218
|
+
promiseId: observation.promiseId,
|
|
219
|
+
missing: [...observation.missing],
|
|
220
|
+
})),
|
|
221
|
+
});
|
|
222
|
+
const row = staleMarkerRow(observed);
|
|
223
|
+
if (row !== undefined)
|
|
224
|
+
say(row);
|
|
225
|
+
if (subjects.length < total) {
|
|
226
|
+
say(` This looked at ${subjects.length} of ${total} promises, so there may be more paths to check.`);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* One promise, read by id over this repository's agent connection.
|
|
231
|
+
*
|
|
232
|
+
* What it prints on a promise that is not holding is exactly what a repair
|
|
233
|
+
* starts from, and every line of it came off a stored row: the commit the
|
|
234
|
+
* failing run checked, the bounded reason it could not report green, and the
|
|
235
|
+
* cases in the meaning its owner agreed to that the run was checking. The note
|
|
236
|
+
* about what those cases are is printed with them rather than left in a
|
|
237
|
+
* document, because the one thing worse than not naming them is naming them in
|
|
238
|
+
* a way that reads as a verdict on each one.
|
|
239
|
+
*
|
|
240
|
+
* A promise that is holding says so in one line. It is not an error and does
|
|
241
|
+
* not exit non-zero: somebody who ran this after a fix wants to hear that the
|
|
242
|
+
* behavior is back, and a red exit would tell their agent to keep going.
|
|
243
|
+
*/
|
|
244
|
+
async function reportOnePromise(options, promiseId, agent, emit, say) {
|
|
245
|
+
const call = await callAgentTool(agent, "get_promise", { promiseId });
|
|
246
|
+
if (call.kind !== "result") {
|
|
247
|
+
const message = `${promiseId} could not be read over this repository's Balladeer agent connection: ${reason(call)}`;
|
|
248
|
+
emit({ step: "promise", status: "unreadable", promiseId, message });
|
|
249
|
+
say(message);
|
|
250
|
+
return 5;
|
|
251
|
+
}
|
|
252
|
+
const structured = call.structured;
|
|
253
|
+
if (structured.found === false) {
|
|
254
|
+
const message = structuredString(call.structured, "reason") ??
|
|
255
|
+
`No current approved promise with the id ${promiseId} is available to this repository.`;
|
|
256
|
+
emit({ step: "promise", status: "unreadable", promiseId, message });
|
|
257
|
+
say(message);
|
|
258
|
+
return 4;
|
|
259
|
+
}
|
|
260
|
+
const title = structuredString(call.structured, "title");
|
|
261
|
+
const posture = structuredString(call.structured, "posture");
|
|
262
|
+
const ownerName = structuredString(call.structured, "ownerName");
|
|
263
|
+
const promiseUrl = structuredString(call.structured, "promiseUrl");
|
|
264
|
+
const threat = structured.threat;
|
|
265
|
+
const named = {
|
|
266
|
+
promiseId,
|
|
267
|
+
...(title === undefined ? {} : { title }),
|
|
268
|
+
...(posture === undefined ? {} : { protectionPosture: posture }),
|
|
269
|
+
...(ownerName === undefined ? {} : { ownerName }),
|
|
270
|
+
...(promiseUrl === undefined ? {} : { promiseUrl }),
|
|
271
|
+
};
|
|
272
|
+
if (threat === undefined) {
|
|
273
|
+
emit({ step: "promise", status: "holding", ...named });
|
|
274
|
+
say(`${title ?? promiseId} (${promiseId})`);
|
|
275
|
+
if (posture !== undefined)
|
|
276
|
+
say(` Balladeer reports this promise as ${posture}.`);
|
|
277
|
+
say(" Nothing is reported broken here, so there is nothing to repair.");
|
|
278
|
+
if (promiseUrl !== undefined)
|
|
279
|
+
say(` Promise page: ${promiseUrl}`);
|
|
280
|
+
return 0;
|
|
281
|
+
}
|
|
282
|
+
const kind = threat.kind === "refuted" ? "refuted" : "unknown";
|
|
283
|
+
const sourceSha = typeof threat.sourceSha === "string" ? threat.sourceSha : undefined;
|
|
284
|
+
const reasonCode = typeof threat.reasonCode === "string" ? threat.reasonCode : "unknown";
|
|
285
|
+
const caseIds = Array.isArray(threat.refutedCaseIds)
|
|
286
|
+
? threat.refutedCaseIds.filter((value) => typeof value === "string")
|
|
287
|
+
: [];
|
|
288
|
+
const caseNote = typeof threat.refutedCaseNote === "string" ? threat.refutedCaseNote : undefined;
|
|
289
|
+
const observedAt = typeof threat.observedAt === "string" ? threat.observedAt : undefined;
|
|
290
|
+
emit({
|
|
291
|
+
step: "promise",
|
|
292
|
+
status: "not_holding",
|
|
293
|
+
...named,
|
|
294
|
+
kind,
|
|
295
|
+
...(sourceSha === undefined ? {} : { sourceSha }),
|
|
296
|
+
reasonCode,
|
|
297
|
+
refutedCaseIds: caseIds,
|
|
298
|
+
...(caseNote === undefined ? {} : { refutedCaseNote: caseNote }),
|
|
299
|
+
...(observedAt === undefined ? {} : { observedAt }),
|
|
300
|
+
});
|
|
301
|
+
say(`${title ?? promiseId} (${promiseId})`);
|
|
302
|
+
say(kind === "refuted"
|
|
303
|
+
? " A verifier ran and the behavior no longer holds."
|
|
304
|
+
: " The check could not reach a verdict, so this behavior is unmeasured rather than broken.");
|
|
305
|
+
say(` Commit the failing run checked: ${sourceSha ?? "not recorded for this run"}`);
|
|
306
|
+
say(` Reason: ${reasonCode}`);
|
|
307
|
+
say(caseIds.length === 0
|
|
308
|
+
? " Cases named by this run: none."
|
|
309
|
+
: ` Cases the run was checking: ${caseIds.join(", ")}`);
|
|
310
|
+
if (caseNote !== undefined)
|
|
311
|
+
say(` ${caseNote}`);
|
|
312
|
+
if (observedAt !== undefined)
|
|
313
|
+
say(` Observed: ${observedAt}`);
|
|
314
|
+
if (ownerName !== undefined)
|
|
315
|
+
say(` Owner: ${ownerName}`);
|
|
316
|
+
if (promiseUrl !== undefined)
|
|
317
|
+
say(` Promise page: ${promiseUrl}`);
|
|
318
|
+
return 0;
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Which directories in this checkout are sealed, read off the customer's own
|
|
322
|
+
* sealed packages rather than asked of anybody.
|
|
323
|
+
*
|
|
324
|
+
* It is here because this is the command an agent runs before it plans, and a
|
|
325
|
+
* coder who learns after the fact that a rename swept a promise's directory has
|
|
326
|
+
* cost its owner an afternoon of qualifying it again. Offline, bounded, and
|
|
327
|
+
* silent in a repository that seals nothing: a line about zero sealed
|
|
328
|
+
* directories is noise in the twelve repositories that have none yet.
|
|
329
|
+
*/
|
|
330
|
+
async function reportSealedPaths(options, emit, say) {
|
|
331
|
+
// The sealed directories are named from the repository root, so the answer
|
|
332
|
+
// must not depend on which directory somebody happened to run this from.
|
|
333
|
+
const sealed = sealedPromises((await repositoryRoot(options.cwd)) ?? options.cwd);
|
|
334
|
+
if (sealed.length === 0)
|
|
335
|
+
return;
|
|
336
|
+
const shown = sealed.slice(0, SEALED_PATHS_SHOWN);
|
|
337
|
+
emit({
|
|
338
|
+
step: "sealed_paths",
|
|
339
|
+
total: sealed.length,
|
|
340
|
+
shown: shown.map((promise) => ({
|
|
341
|
+
promiseId: promise.promiseId,
|
|
342
|
+
sealedPath: promise.sealedPath,
|
|
343
|
+
...(promise.title === undefined ? {} : { title: promise.title }),
|
|
344
|
+
})),
|
|
345
|
+
});
|
|
346
|
+
say("");
|
|
347
|
+
say(sealed.length === 1
|
|
348
|
+
? " One promise seals a directory in this checkout. Editing anything in it breaks the seal:"
|
|
349
|
+
: ` ${sealed.length} promises seal a directory in this checkout. Editing anything in one breaks its seal:`);
|
|
350
|
+
for (const promise of shown)
|
|
351
|
+
say(` ${promise.sealedPath}${promise.title === undefined ? "" : ` ${promise.title}`}`);
|
|
352
|
+
if (sealed.length > shown.length)
|
|
353
|
+
say(` and ${sealed.length - shown.length} more, not listed here.`);
|
|
354
|
+
say(" Run `balladeer check-seals` before you push to find out whether your change broke one.");
|
|
355
|
+
}
|
|
356
|
+
function reason(call) {
|
|
357
|
+
switch (call.kind) {
|
|
358
|
+
case "endpoint_refused":
|
|
359
|
+
return call.reason;
|
|
360
|
+
case "unreachable":
|
|
361
|
+
return "Balladeer could not be reached from this machine.";
|
|
362
|
+
case "client_too_old":
|
|
363
|
+
return `this copy of the command is too old for that Balladeer. Update it with: ${call.update}`;
|
|
364
|
+
case "unauthorized":
|
|
365
|
+
return "the connection was revoked, or the repository it was bound to is no longer enrolled.";
|
|
366
|
+
case "tool_refusal":
|
|
367
|
+
return call.text;
|
|
368
|
+
case "http":
|
|
369
|
+
return `Balladeer answered ${call.status}.`;
|
|
370
|
+
default:
|
|
371
|
+
return "Balladeer's answer was not a tool result.";
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* The workspace-wide half, named as missing rather than reported as empty.
|
|
376
|
+
*
|
|
377
|
+
* A run that reported this repository and then said nothing about the workspace
|
|
378
|
+
* would read as a workspace with one repository in it.
|
|
379
|
+
*/
|
|
380
|
+
function sayWorkspaceUnavailable(emit, say, reasonCode, sentence) {
|
|
381
|
+
emit({ step: "workspace", status: "unavailable", reason: reasonCode, message: sentence });
|
|
382
|
+
say("");
|
|
383
|
+
say(sentence);
|
|
384
|
+
}
|
|
385
|
+
/** Every repository in the workspace, which only a setup session can read. */
|
|
386
|
+
async function reportWorkspace(options, session, here, emit, say, alreadyReported) {
|
|
387
|
+
let state;
|
|
388
|
+
try {
|
|
389
|
+
state = await request(options.controlPlane, {
|
|
390
|
+
method: "GET",
|
|
391
|
+
path: "/api/setup/v1/state",
|
|
392
|
+
bearer: session.token,
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
catch (error) {
|
|
396
|
+
const code = error instanceof ClientTooOldError
|
|
397
|
+
? "client_too_old"
|
|
398
|
+
: error instanceof TransportError
|
|
399
|
+
? "control_plane_unreachable"
|
|
400
|
+
: error instanceof RefusalError
|
|
401
|
+
? error.code
|
|
402
|
+
: "setup_state_unreadable";
|
|
403
|
+
if (alreadyReported) {
|
|
404
|
+
// This repository has already been reported over its own connection, so
|
|
405
|
+
// the workspace-wide half is a view this run could not read rather than a
|
|
406
|
+
// command that failed.
|
|
407
|
+
sayWorkspaceUnavailable(emit, say, code, `Every repository in the workspace is read over a setup session, and the stored one could not be used (${code}). Run \`${commandLine(null, "setup")}\` to read the whole workspace.`);
|
|
408
|
+
return 0;
|
|
409
|
+
}
|
|
410
|
+
if (error instanceof ClientTooOldError) {
|
|
411
|
+
return failOutside(options, emit, code, error.message, 3);
|
|
412
|
+
}
|
|
413
|
+
if (error instanceof TransportError) {
|
|
414
|
+
return failOutside(options, emit, code, `Balladeer could not be reached at ${options.controlPlane}: ${error.message}. Nothing was changed.`, 5);
|
|
415
|
+
}
|
|
416
|
+
if (error instanceof RefusalError) {
|
|
417
|
+
return failOutside(options, emit, code, `Balladeer refused to report this workspace (${code}). Run \`${commandLine(null, "setup")}\` to pair again. Nothing was changed.`, 5);
|
|
418
|
+
}
|
|
419
|
+
return failOutside(options, emit, code, "Balladeer could not report this workspace.", 5);
|
|
420
|
+
}
|
|
421
|
+
const active = state.repositories.filter((repository) => repository.status === "active");
|
|
422
|
+
emit({
|
|
423
|
+
step: "receipt",
|
|
424
|
+
setup: {
|
|
425
|
+
complete: active.some((item) => item.agentConfigured && item.ciConfigured),
|
|
426
|
+
sentence: `${active.length} repositor${active.length === 1 ? "y" : "ies"} enrolled in ${state.workspace.name}.`,
|
|
427
|
+
},
|
|
428
|
+
firstPromise: { complete: false, sentence: "See each repository below." },
|
|
429
|
+
protection: { complete: false, sentence: "See each repository below." },
|
|
430
|
+
connection: {
|
|
431
|
+
complete: active.some((item) => item.agentConfigured),
|
|
432
|
+
sentence: "Proposing and reporting run over each repository's agent connection, which does not expire.",
|
|
433
|
+
},
|
|
434
|
+
repositories: active.map((repository) => ({
|
|
435
|
+
repository: repository.displayName,
|
|
436
|
+
agent: repository.agentConfigured ? "agent connected" : "no agent connection",
|
|
437
|
+
ci: repository.ciConfigured
|
|
438
|
+
? `CI connected, ${repository.ciValidatedRunCount} authenticated run${repository.ciValidatedRunCount === 1 ? "" : "s"}`
|
|
439
|
+
: repository.ciIdentityRecorded
|
|
440
|
+
? "CI identity recorded, waiting for the first authenticated run"
|
|
441
|
+
: "CI not recorded",
|
|
442
|
+
})),
|
|
443
|
+
});
|
|
444
|
+
if (options.json)
|
|
445
|
+
return 0;
|
|
446
|
+
const setupCommand = commandLine(state.client.publishedVersion, "setup");
|
|
447
|
+
say("");
|
|
448
|
+
say(`Workspace "${state.workspace.name}" (${state.workspace.slug}).`);
|
|
449
|
+
if (active.length === 0) {
|
|
450
|
+
say(`No repository is enrolled yet. Run \`${setupCommand}\` inside one.`);
|
|
451
|
+
return 0;
|
|
452
|
+
}
|
|
453
|
+
for (const repository of active) {
|
|
454
|
+
const mark = repository.displayName.toLowerCase() === here.toLowerCase() ? " (this one)" : "";
|
|
455
|
+
say(`\n${repository.displayName}${mark}`);
|
|
456
|
+
say(` Default branch: ${repository.defaultBranch}`);
|
|
457
|
+
say(` Agent: ${repository.agentConfigured ? "connected" : "not connected"}`);
|
|
458
|
+
say(` CI: ${repository.ciConfigured
|
|
459
|
+
? `connected, ${repository.ciValidatedRunCount} authenticated run${repository.ciValidatedRunCount === 1 ? "" : "s"}, first observed ${repository.ciFirstValidatedAt ?? "at an earlier run"}`
|
|
460
|
+
: repository.ciIdentityRecorded
|
|
461
|
+
? "identity recorded, waiting for the first authenticated run"
|
|
462
|
+
: "not recorded"}`);
|
|
463
|
+
// `candidateCount` counts only what is genuinely waiting for a person, so a
|
|
464
|
+
// promise somebody already agreed to is never also listed as a proposal
|
|
465
|
+
// awaiting them, and a rejected proposal is listed nowhere at all.
|
|
466
|
+
say(` Promises: ${repository.promiseCount} agreed, ${repository.candidateCount} proposed and awaiting a person.`);
|
|
467
|
+
if (repository.firstPromiseId !== null) {
|
|
468
|
+
say(` First promise: ${options.controlPlane}/promises/${repository.firstPromiseId}`);
|
|
469
|
+
}
|
|
470
|
+
if (repository.latestCandidateId !== null) {
|
|
471
|
+
say(` Newest proposal: ${options.controlPlane}/candidates/${repository.latestCandidateId}`);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
return 0;
|
|
475
|
+
}
|
|
476
|
+
function failOutside(options, emit, reasonCode, message, exitCode) {
|
|
477
|
+
if (options.json)
|
|
478
|
+
emit({ step: "error", reason: reasonCode, message, changed: false, exitCode });
|
|
479
|
+
else
|
|
480
|
+
options.write(`${message}\n`);
|
|
481
|
+
return exitCode;
|
|
482
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { type TouchMap } from "../touch-map.js";
|
|
2
|
+
export type TouchMapOptions = Readonly<{
|
|
3
|
+
cwd: string;
|
|
4
|
+
json: boolean;
|
|
5
|
+
environment: NodeJS.ProcessEnv;
|
|
6
|
+
write: (text: string) => void;
|
|
7
|
+
/** The clock, so a test can pin the timestamp the document carries. */
|
|
8
|
+
now?: () => Date;
|
|
9
|
+
}>;
|
|
10
|
+
/**
|
|
11
|
+
* Where the promises are, which is not always the git root.
|
|
12
|
+
*
|
|
13
|
+
* A repository keeps its promises at its top level, so the git root is the
|
|
14
|
+
* answer almost every time. It is not the answer when somebody is standing in a
|
|
15
|
+
* sub-tree that carries its own `.continuity/` directory, which is how this
|
|
16
|
+
* repository's own dogfood packages are laid out, and running there and mapping
|
|
17
|
+
* the outer tree instead would answer a question nobody asked.
|
|
18
|
+
*/
|
|
19
|
+
export declare function promiseRoot(cwd: string): Promise<string | undefined>;
|
|
20
|
+
/**
|
|
21
|
+
* The command that turns "which files does this verifier read" from a guess
|
|
22
|
+
* into a measurement.
|
|
23
|
+
*
|
|
24
|
+
* It runs every sealed promise in this checkout under V8's coverage recorder
|
|
25
|
+
* and writes what each one executed to `.continuity/touch-map.json`. The file
|
|
26
|
+
* stays on the customer's disk. Nothing here posts it, and the map is a list of
|
|
27
|
+
* a customer's own file names, which is exactly the thing the boundary copy
|
|
28
|
+
* promises Balladeer never receives.
|
|
29
|
+
*/
|
|
30
|
+
export declare function runTouchMap(options: TouchMapOptions): Promise<number>;
|
|
31
|
+
/**
|
|
32
|
+
* Write the map, and leave the file alone when nothing about it changed.
|
|
33
|
+
*
|
|
34
|
+
* The timestamp is the only field that moves on every run, so comparing the
|
|
35
|
+
* document without it is what makes a second run a no-op. A command that
|
|
36
|
+
* rewrote a byte of a committed file every time it was asked a question would
|
|
37
|
+
* put a diff in front of somebody who changed nothing.
|
|
38
|
+
*/
|
|
39
|
+
export declare function writeTouchMap(root: string, map: TouchMap): Readonly<{
|
|
40
|
+
changed: boolean;
|
|
41
|
+
bytes: number;
|
|
42
|
+
}>;
|