toolroll 0.9.72 → 0.9.74
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/CHANGELOG.md +108 -0
- package/THIRD_PARTY_NOTICES.md +40 -0
- package/dist/agent-fence.d.ts +5 -0
- package/dist/agent-fence.js +42 -7
- package/dist/agent-turns.js +5 -0
- package/dist/agents-live.d.ts +31 -0
- package/dist/agents-live.js +43 -0
- package/dist/browser/THIRD_PARTY_NOTICES.txt +40 -0
- package/dist/browser/workspace.css +2 -2
- package/dist/browser/workspace.js +31 -30
- package/dist/browser-workspace.d.ts +6 -0
- package/dist/builder.js +1 -1
- package/dist/chat-actions.js +13 -8
- package/dist/chat-continuity.js +10 -0
- package/dist/chat-decide.d.ts +47 -1
- package/dist/chat-decide.js +170 -4
- package/dist/chat-quiet.js +3 -3
- package/dist/check-report.js +2 -1
- package/dist/coding-provider.js +4 -2
- package/dist/commit-extract.d.ts +17 -0
- package/dist/commit-extract.js +82 -4
- package/dist/contracts/console-api.d.ts +3 -3
- package/dist/contracts/console-api.js +4 -4
- package/dist/contracts/release-terms.d.ts +7 -1
- package/dist/contracts/release-terms.js +6 -2
- package/dist/deploy-gate.js +5 -2
- package/dist/desktop-update.d.ts +1 -1
- package/dist/dispose.js +35 -2
- package/dist/exec.d.ts +1 -1
- package/dist/exec.js +20 -19
- package/dist/first-run.d.ts +17 -1
- package/dist/first-run.js +38 -0
- package/dist/flow-gallery-ui.js +4 -2
- package/dist/flow-gallery.d.ts +3 -0
- package/dist/flow-gallery.js +6 -2
- package/dist/flow-triggers.d.ts +8 -0
- package/dist/flow-triggers.js +15 -0
- package/dist/flows-cli.js +13 -3
- package/dist/guides.js +10 -1
- package/dist/invoke.d.ts +13 -0
- package/dist/invoke.js +21 -12
- package/dist/kits-ui.js +4 -2
- package/dist/kits.d.ts +6 -4
- package/dist/kits.js +9 -11
- package/dist/knowledge-ui.d.ts +4 -1
- package/dist/knowledge-ui.js +10 -5
- package/dist/lead-progress.d.ts +4 -1
- package/dist/lead-progress.js +3 -1
- package/dist/lead.js +4 -0
- package/dist/live-bus.d.ts +2 -0
- package/dist/live-bus.js +1 -0
- package/dist/live-children-read.d.ts +52 -0
- package/dist/live-children-read.js +333 -0
- package/dist/live-children.d.ts +73 -0
- package/dist/live-children.js +318 -0
- package/dist/live-detail.d.ts +4 -3
- package/dist/live-detail.js +9 -5
- package/dist/live-input.js +7 -8
- package/dist/live-main-read.d.ts +19 -0
- package/dist/live-main-read.js +91 -0
- package/dist/live-open.d.ts +28 -0
- package/dist/live-open.js +125 -0
- package/dist/live-records.d.ts +10 -1
- package/dist/live-records.js +16 -5
- package/dist/live.d.ts +54 -22
- package/dist/live.js +218 -69
- package/dist/mcp-connect.d.ts +10 -0
- package/dist/mcp-connect.js +2 -0
- package/dist/operate.js +3 -2
- package/dist/peek-cli.d.ts +10 -3
- package/dist/peek-cli.js +17 -8
- package/dist/planner.js +1 -1
- package/dist/project-tools.js +3 -1
- package/dist/protected-storage.d.ts +67 -0
- package/dist/protected-storage.js +398 -0
- package/dist/provider-limits.d.ts +3 -1
- package/dist/provider-limits.js +5 -2
- package/dist/provider.js +6 -12
- package/dist/release-adapters.d.ts +8 -1
- package/dist/release-adapters.js +84 -14
- package/dist/release-authority.d.ts +6 -0
- package/dist/release-authority.js +9 -0
- package/dist/release-card.d.ts +76 -0
- package/dist/release-card.js +252 -0
- package/dist/release-carry.d.ts +3 -0
- package/dist/release-carry.js +23 -4
- package/dist/release-cli.js +36 -3
- package/dist/release-git.d.ts +43 -0
- package/dist/release-git.js +106 -0
- package/dist/release.d.ts +11 -2
- package/dist/release.js +82 -6
- package/dist/repository-context-ui.js +2 -1
- package/dist/run-efficiency.d.ts +3 -3
- package/dist/run-efficiency.js +20 -8
- package/dist/scope.d.ts +7 -0
- package/dist/scope.js +22 -5
- package/dist/scout.d.ts +3 -0
- package/dist/scout.js +5 -3
- package/dist/serve.js +34 -23
- package/dist/server/agents-live-lead.d.ts +47 -0
- package/dist/server/agents-live-lead.js +116 -0
- package/dist/server/agents-live-room.d.ts +23 -0
- package/dist/server/agents-live-room.js +88 -0
- package/dist/server/chat.js +84 -30
- package/dist/server/chrome.js +2 -2
- package/dist/server/console.css +68 -40
- package/dist/server/flows.js +40 -10
- package/dist/server/live.d.ts +4 -0
- package/dist/server/live.js +42 -2
- package/dist/server/pages.js +81 -1
- package/dist/server/render-chat.d.ts +15 -1
- package/dist/server/render-chat.js +14 -7
- package/dist/server/render-pages.d.ts +8 -4
- package/dist/server/render-pages.js +55 -50
- package/dist/server/render-settings.js +1 -1
- package/dist/server/render-tasks.js +10 -3
- package/dist/server/route-table.js +5 -1
- package/dist/server/runtime.d.ts +1 -1
- package/dist/server/settings.js +29 -8
- package/dist/server/task-views.js +1 -1
- package/dist/server/tasks.js +59 -41
- package/dist/setup-guide.d.ts +2 -2
- package/dist/store.d.ts +34 -1
- package/dist/store.js +81 -2
- package/dist/stream-scope.d.ts +18 -0
- package/dist/stream-scope.js +28 -0
- package/dist/subagent-memory.d.ts +1 -0
- package/dist/subagent-memory.js +1 -1
- package/dist/subagent-tools.d.ts +22 -6
- package/dist/subagent-tools.js +44 -8
- package/dist/subagent-work.d.ts +2 -0
- package/dist/subagent-work.js +5 -2
- package/dist/subagents-ui.js +6 -4
- package/dist/subscription-chat.js +5 -3
- package/dist/telegram.d.ts +6 -1
- package/dist/telegram.js +58 -6
- package/package.json +1 -1
package/dist/release-adapters.js
CHANGED
|
@@ -24,6 +24,9 @@ import { proveCarry } from "./release-carry.js";
|
|
|
24
24
|
import { randomBytes, randomUUID } from "node:crypto";
|
|
25
25
|
import { deploymentGate } from "./deploy-gate.js";
|
|
26
26
|
import { extractCommit } from "./commit-extract.js";
|
|
27
|
+
import { checkoutIncludes } from "./teardown-git.js";
|
|
28
|
+
import { RELEASE_REMOTE, releaseConfigProblem, releaseGitArgs, releaseGitEnv, releaseRemote, releaseTools } from "./release-git.js";
|
|
29
|
+
export { RELEASE_PATH, RELEASE_REMOTE, releaseConfigProblem, releaseGitArgs, releaseGitEnv, releaseRemote, releaseTools } from "./release-git.js";
|
|
27
30
|
/** A child process, its output kept whole; a missing program or a time limit is exit 127 or 124, never a throw. */
|
|
28
31
|
export const exec = (command, args, options = {}) => new Promise(done => {
|
|
29
32
|
const child = execFile(command, [...args], { cwd: options.cwd, env: options.env ?? process.env, timeout: options.timeoutMs ?? 120_000, maxBuffer: 64 * 1024 * 1024, encoding: "utf8" }, (error, stdout, stderr) => {
|
|
@@ -112,6 +115,9 @@ export function gateStateOf(answer, accounts = []) {
|
|
|
112
115
|
const failed = newest !== undefined && newest["finishedAt"] != null && newest["outcome"] !== "built" && newest["outcome"] !== "no-change" && Number(newest["id"]) !== resultRun
|
|
113
116
|
? `run ${String(newest["id"])} ended ${String(newest["outcome"] ?? "without an outcome")}` : null;
|
|
114
117
|
const worktree = runs.find(run => run["id"] === resultRun)?.["worktree"];
|
|
118
|
+
// Queued again or still building past the result shown (a regate): that result is the one before the rerun.
|
|
119
|
+
const state = object(answer["task"])["state"];
|
|
120
|
+
const pending = resultRun !== null && (state === "queued" || state === "running" || (newest !== undefined && newest["finishedAt"] == null && Number(newest["id"]) > resultRun));
|
|
115
121
|
return {
|
|
116
122
|
exists: true,
|
|
117
123
|
candidate: textOr(scope["candidate"]),
|
|
@@ -128,6 +134,7 @@ export function gateStateOf(answer, accounts = []) {
|
|
|
128
134
|
: checks["status"] === "passed" && checks["level"] !== "full" ? "not-full" : String(checks["status"] ?? "not-run"),
|
|
129
135
|
},
|
|
130
136
|
failed,
|
|
137
|
+
pending,
|
|
131
138
|
completion: textOr(completion["digest"]) === null ? null : { actor: String(completion["actor"]), at: String(completion["at"]), digest: String(completion["digest"]) },
|
|
132
139
|
};
|
|
133
140
|
}
|
|
@@ -162,6 +169,48 @@ export function stageGate(stage, owner) {
|
|
|
162
169
|
const id = textOr(journal["id"]), phase = textOr(journal["phase"]);
|
|
163
170
|
return id !== null && phase !== null && BEFORE_SWAP.includes(phase) && owner() === id ? { id, phase } : null;
|
|
164
171
|
}
|
|
172
|
+
/** The recorded repository for a gate checkout, from the plane's database; throws in plain words when there is none. */
|
|
173
|
+
export function recordedReleaseRepo(database, checkout) {
|
|
174
|
+
const real = (path) => { try {
|
|
175
|
+
return realpathSync(path);
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
return null;
|
|
179
|
+
} };
|
|
180
|
+
const at = real(checkout);
|
|
181
|
+
if (at === null)
|
|
182
|
+
throw new Error(`${checkout} isn't there.`);
|
|
183
|
+
if (!existsSync(database))
|
|
184
|
+
throw new Error("The plane's database isn't here, so the release's repository can't be read from its records.");
|
|
185
|
+
const db = new (sqlite().DatabaseSync)(database, { readOnly: true });
|
|
186
|
+
let project = null, remote = null;
|
|
187
|
+
try {
|
|
188
|
+
const store = new Store(db);
|
|
189
|
+
project = store.listProjects().map(one => one.path).find(path => real(path) === at) ?? null;
|
|
190
|
+
remote = project === null ? null : store.projectRemote(project);
|
|
191
|
+
}
|
|
192
|
+
finally {
|
|
193
|
+
db.close();
|
|
194
|
+
}
|
|
195
|
+
if (project === null)
|
|
196
|
+
throw new Error(`${checkout} isn't one of the projects Toolroll records, so it can't be released from here.`);
|
|
197
|
+
if (remote === null)
|
|
198
|
+
throw new Error(`${project} has no recorded remote. Record it with \`toolroll repos add ${project} --remote <url>\` first.`);
|
|
199
|
+
const parsed = releaseRemote(remote);
|
|
200
|
+
if (parsed === null)
|
|
201
|
+
throw new Error(`${project}'s recorded remote (${remote}) isn't a GitHub repository over https or ssh.`);
|
|
202
|
+
const named = join(at, ".git");
|
|
203
|
+
let gitDir;
|
|
204
|
+
try {
|
|
205
|
+
if (!lstatSync(named).isDirectory())
|
|
206
|
+
throw new Error("not a folder");
|
|
207
|
+
gitDir = realpathSync(named);
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
throw new Error(`${project}'s Git directory isn't a plain folder, so the release won't read it.`);
|
|
211
|
+
}
|
|
212
|
+
return { checkout: at, gitDir, remote, ...parsed };
|
|
213
|
+
}
|
|
165
214
|
/** How many times, and how far apart, a merged branch's deletion is looked for after the push lost a race. */
|
|
166
215
|
export const BRANCH_GONE_LOOKS = 5;
|
|
167
216
|
export const BRANCH_GONE_PAUSE_MS = 2_000;
|
|
@@ -171,9 +220,11 @@ export function releaseAdapters(options) {
|
|
|
171
220
|
const { checkout } = options;
|
|
172
221
|
const tap = options.tap ?? "ap9000/homebrew-toolroll";
|
|
173
222
|
const formula = options.formula ?? "Formula/toolroll.rb";
|
|
174
|
-
const git = (...args) => run("git", ["-C", checkout, ...args]);
|
|
175
223
|
const pause = options.sleep ?? ((ms) => new Promise(done => setTimeout(done, ms)));
|
|
176
|
-
const
|
|
224
|
+
const tools = options.tools ?? releaseTools();
|
|
225
|
+
const gitEnv = releaseGitEnv();
|
|
226
|
+
// Never a PATH lookup or the checkout's Git config: gh by its absolute path, from a neutral folder, naming the repo.
|
|
227
|
+
const gh = (...args) => run(tools.gh, args, { cwd: "/", env: gitEnv });
|
|
177
228
|
const ghJson = async (what, ...args) => JSON.parse(await must(gh(...args), what));
|
|
178
229
|
const toolroll = async (...args) => {
|
|
179
230
|
const result = await run("toolroll", [...args, "--json"], { env: cleanEnv(), timeoutMs: 120_000 });
|
|
@@ -186,6 +237,29 @@ export function releaseAdapters(options) {
|
|
|
186
237
|
};
|
|
187
238
|
const refused = (answer) => `${String(answer["message"] ?? answer["reason"] ?? "refused")}`;
|
|
188
239
|
const database = options.database ?? databasePath(process.env, homedir());
|
|
240
|
+
let recorded = options.releaseRepo ?? null;
|
|
241
|
+
/** The recorded repository, read once (chat-management.md §0a.2). */
|
|
242
|
+
const releaseRepo = () => recorded ??= recordedReleaseRepo(database, checkout);
|
|
243
|
+
/**
|
|
244
|
+
* The coordinator's one way to run Git (§0a.2): on the recorded Git directory, hardened, with an allowlisted
|
|
245
|
+
* environment and the absolute Git. Before every command the checkout's own config is read with includes off and
|
|
246
|
+
* as plain files; one that includes a file, names a command or redirects a remote stops the release, naming it.
|
|
247
|
+
*/
|
|
248
|
+
const git = async (...args) => {
|
|
249
|
+
const repo = releaseRepo();
|
|
250
|
+
const stop = (why) => new Error(`Toolroll won't run Git in ${repo.checkout} for a release: ${why}. Remove it from the project's Git config, then rerun.`);
|
|
251
|
+
if (options.releaseRepo === undefined && checkoutIncludes(repo.checkout))
|
|
252
|
+
throw stop("its Git config includes another file, or couldn't be read as a plain file");
|
|
253
|
+
const listing = await run(tools.git, [`--git-dir=${repo.gitDir}`, "--no-optional-locks", "config", "--list", "--no-includes", "--show-scope", "-z"], { cwd: "/", env: gitEnv });
|
|
254
|
+
if (listing.code !== 0)
|
|
255
|
+
throw stop("its Git config couldn't be read");
|
|
256
|
+
const setting = releaseConfigProblem(listing.stdout);
|
|
257
|
+
if (setting !== null)
|
|
258
|
+
throw stop(`its Git config sets ${setting}, which a release doesn't run with (it could run a command, or change where Git connects or what it sends)`);
|
|
259
|
+
return run(tools.git, [...releaseGitArgs(repo, tools), ...args], { cwd: repo.checkout, env: gitEnv });
|
|
260
|
+
};
|
|
261
|
+
/** The remote every fetch, look and push names: the recorded URL, defined only on the command line. */
|
|
262
|
+
const remoteUrl = () => RELEASE_REMOTE;
|
|
189
263
|
const liveOwner = () => {
|
|
190
264
|
if (!existsSync(database))
|
|
191
265
|
return null;
|
|
@@ -293,16 +367,12 @@ export function releaseAdapters(options) {
|
|
|
293
367
|
sleep: options.sleep ?? sleep,
|
|
294
368
|
say: options.say ?? (line => process.stdout.write(`${line}\n`)),
|
|
295
369
|
repo: {
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
if (slug === null)
|
|
299
|
-
throw new Error(`${checkout}'s origin is not a GitHub repository`);
|
|
300
|
-
return slug;
|
|
301
|
-
},
|
|
370
|
+
// The recorded owner/name, never the checkout's origin.
|
|
371
|
+
async github() { return releaseRepo().slug; },
|
|
302
372
|
async sync(branch) {
|
|
303
|
-
await must(git("fetch", "--quiet", "
|
|
373
|
+
await must(git("fetch", "--quiet", "--no-tags", remoteUrl(), "+refs/heads/main:refs/remotes/origin/main"), "git fetch main");
|
|
304
374
|
// A merged branch may be gone from GitHub; its commit is already here.
|
|
305
|
-
await git("fetch", "--quiet", "
|
|
375
|
+
await git("fetch", "--quiet", "--no-tags", remoteUrl(), `+refs/heads/${branch}:refs/remotes/origin/${branch}`);
|
|
306
376
|
const current = (await git("symbolic-ref", "--quiet", "--short", "HEAD")).stdout.trim();
|
|
307
377
|
if (current === "main") {
|
|
308
378
|
await must(git("merge", "--ff-only", "--quiet", "origin/main"), "git merge --ff-only origin/main");
|
|
@@ -316,17 +386,17 @@ export function releaseAdapters(options) {
|
|
|
316
386
|
throw new Error(`${checkout}'s local main has commits origin/main doesn't; the gate diffs from it, so fix it first`);
|
|
317
387
|
},
|
|
318
388
|
async remoteHead(branch) {
|
|
319
|
-
const out = await must(git("ls-remote",
|
|
389
|
+
const out = await must(git("ls-remote", remoteUrl(), `refs/heads/${branch}`), "git ls-remote");
|
|
320
390
|
return out.trim().split(/\s+/)[0] || null;
|
|
321
391
|
},
|
|
322
392
|
async deleteBranch(branch, head) {
|
|
323
393
|
const ref = `refs/heads/${branch}`;
|
|
324
|
-
const current = (await must(git("ls-remote",
|
|
394
|
+
const current = (await must(git("ls-remote", remoteUrl(), ref), "git ls-remote")).trim().split(/\s+/)[0] || null;
|
|
325
395
|
if (current === null)
|
|
326
396
|
return;
|
|
327
397
|
if (current !== head)
|
|
328
398
|
throw new Error(`${branch} moved after merging; its new head was not deleted.`);
|
|
329
|
-
const pushed = await git("push", `--force-with-lease=${ref}:${head}`,
|
|
399
|
+
const pushed = await git("push", "--no-verify", `--force-with-lease=${ref}:${head}`, remoteUrl(), `:${ref}`);
|
|
330
400
|
if (pushed.code === 0)
|
|
331
401
|
return;
|
|
332
402
|
// GitHub may delete a merged branch itself between the look and the push; gone is what was wanted. Its refs can
|
|
@@ -334,7 +404,7 @@ export function releaseAdapters(options) {
|
|
|
334
404
|
for (let look = 0; look < BRANCH_GONE_LOOKS; look++) {
|
|
335
405
|
if (look > 0)
|
|
336
406
|
await pause(BRANCH_GONE_PAUSE_MS);
|
|
337
|
-
const after = (await must(git("ls-remote",
|
|
407
|
+
const after = (await must(git("ls-remote", remoteUrl(), ref), "git ls-remote")).trim().split(/\s+/)[0] || null;
|
|
338
408
|
if (after === null)
|
|
339
409
|
return;
|
|
340
410
|
}
|
|
@@ -98,6 +98,12 @@ export declare function releaseAuthorityOfTask(store: Store, taskId: string): {
|
|
|
98
98
|
carry: ReleaseCarry | null;
|
|
99
99
|
} | null;
|
|
100
100
|
/** Whether a task belongs to any release authority (anchor or carried), from the records alone. */
|
|
101
|
+
/** "Carry release approvals when checks aren't sandboxed": a project setting, newest choice wins (a ledger row each);
|
|
102
|
+
* off by default. Turning it on is a person's own proven act (release-cli.ts); turning it off is anyone's. */
|
|
103
|
+
export declare const UNSANDBOXED_CARRY_ACTION = "release unsandboxed carrying changed";
|
|
104
|
+
export declare function carriesUnsandboxed(store: Store, repo: string): boolean;
|
|
105
|
+
/** How far a check reaches, narrowest first. */
|
|
106
|
+
export declare const CHECK_ACCESS_WIDTH: Readonly<Record<string, number>>;
|
|
101
107
|
export declare function belongsToRelease(store: Store, taskId: string): boolean;
|
|
102
108
|
export declare function releaseCarries(store: Store, authority: number): ReleaseCarry[];
|
|
103
109
|
export declare function releaseCarryTo(store: Store, authority: number, toSha: string): ReleaseCarry | null;
|
|
@@ -86,6 +86,15 @@ export function releaseAuthorityOfTask(store, taskId) {
|
|
|
86
86
|
return row === undefined ? null : { authority: readReleaseAuthority(row), carry: null };
|
|
87
87
|
}
|
|
88
88
|
/** Whether a task belongs to any release authority (anchor or carried), from the records alone. */
|
|
89
|
+
/** "Carry release approvals when checks aren't sandboxed": a project setting, newest choice wins (a ledger row each);
|
|
90
|
+
* off by default. Turning it on is a person's own proven act (release-cli.ts); turning it off is anyone's. */
|
|
91
|
+
export const UNSANDBOXED_CARRY_ACTION = "release unsandboxed carrying changed";
|
|
92
|
+
export function carriesUnsandboxed(store, repo) {
|
|
93
|
+
const row = store.handle.prepare("SELECT outcome FROM action_ledger WHERE repo = ? AND action = ? ORDER BY id DESC LIMIT 1").get(repo, UNSANDBOXED_CARRY_ACTION);
|
|
94
|
+
return row !== undefined && row["outcome"] === "on";
|
|
95
|
+
}
|
|
96
|
+
/** How far a check reaches, narrowest first. */
|
|
97
|
+
export const CHECK_ACCESS_WIDTH = Object.freeze({ sandboxed: 0, "sign-ins": 1, unsandboxed: 2 });
|
|
89
98
|
export function belongsToRelease(store, taskId) {
|
|
90
99
|
return store.handle.prepare("SELECT 1 AS hit FROM release_authority WHERE anchor_task = ? UNION ALL SELECT 1 FROM release_carry WHERE task_id = ? LIMIT 1").get(taskId, taskId) !== undefined;
|
|
91
100
|
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { type ReleaseTerms } from "./contracts/release-terms.js";
|
|
2
|
+
import type { Scope } from "./scope.js";
|
|
3
|
+
import type { Store } from "./store.js";
|
|
4
|
+
/** One signed term as the card says it. `key` names the scope field or the release term's path. */
|
|
5
|
+
export type TermLine = {
|
|
6
|
+
key: string;
|
|
7
|
+
label: string;
|
|
8
|
+
value: string;
|
|
9
|
+
};
|
|
10
|
+
/** The scope's own terms the card names, in order. A contract test holds `goal`, `outOfScope`, `touches` and
|
|
11
|
+
* `acceptance` (contracts/scope.ts) and the scope's other signed fields to this list; the agent profile and the phase
|
|
12
|
+
* route follow as one line per signed setting (`profile.…`, `route.…`). */
|
|
13
|
+
export declare const SCOPE_TERM_LABELS: {
|
|
14
|
+
readonly title: "Title";
|
|
15
|
+
readonly project: "Project";
|
|
16
|
+
readonly filedBy: "Filed by";
|
|
17
|
+
readonly goal: "Goal";
|
|
18
|
+
readonly outOfScope: "Not in scope";
|
|
19
|
+
readonly touches: "Paths it may touch";
|
|
20
|
+
readonly acceptance: "Done when";
|
|
21
|
+
readonly qualityMode: "Evidence standard";
|
|
22
|
+
readonly agents: "Agents and model";
|
|
23
|
+
readonly permission: "Agents may";
|
|
24
|
+
readonly checks: "Checks";
|
|
25
|
+
readonly checkAccess: "Check access";
|
|
26
|
+
readonly budgetMicrousd: "Budget per attempt";
|
|
27
|
+
readonly candidate: "Candidate commit";
|
|
28
|
+
};
|
|
29
|
+
/** Every field of the release terms, by its path in the terms (contracts/release-terms.ts), against the schema's
|
|
30
|
+
* own leaves: the contract test checks every one has a label. */
|
|
31
|
+
export declare const RELEASE_TERM_LABELS: Readonly<Record<string, string>>;
|
|
32
|
+
/** The leaf paths of a zod object schema (nested objects joined with dots). */
|
|
33
|
+
export declare function schemaLeaves(schema: unknown, prefix?: string): string[];
|
|
34
|
+
/** Every signed term of `scope`, its release terms included, one line each. Reads only. */
|
|
35
|
+
export declare function approvalTermsOf(store: Store, scope: Scope, words: {
|
|
36
|
+
profile: (profile: Scope["profile"]) => string | null;
|
|
37
|
+
}): TermLine[];
|
|
38
|
+
export declare const termsText: (lines: readonly TermLine[]) => string;
|
|
39
|
+
export type ChangeList = {
|
|
40
|
+
/** The merge commit of the last verified release, or null when none is recorded. */
|
|
41
|
+
baseline: string | null;
|
|
42
|
+
/** Newest first; null when it couldn't be read. */
|
|
43
|
+
commits: {
|
|
44
|
+
sha: string;
|
|
45
|
+
subject: string;
|
|
46
|
+
}[] | null;
|
|
47
|
+
source: "recorded" | "pull-request" | "unread";
|
|
48
|
+
digest: string;
|
|
49
|
+
};
|
|
50
|
+
/** The plane's own record of the last verified release of `repo`: the merge commit GitHub answered with, recorded once
|
|
51
|
+
* the release verified the merged tree (the newest merged authority whose merge operation succeeded). Never a tag. */
|
|
52
|
+
export declare function releaseBaseline(store: Store, repo: string): string | null;
|
|
53
|
+
/** The commits after `from` up to `to`, as verified Git objects only, from the project's recorded Git directory: the
|
|
54
|
+
* release coordinator's checkout config allowlist first, then commit-extract's verified range (every commit hashed here,
|
|
55
|
+
* a shallow boundary, grafts or replacement refs refused, and the range proved to stop only in the last release's own
|
|
56
|
+
* history). Null when they can't be read, so a card never shows a shortened list. */
|
|
57
|
+
export declare function commitsBetween(repo: string, from: string, to: string): {
|
|
58
|
+
sha: string;
|
|
59
|
+
subject: string;
|
|
60
|
+
}[] | null;
|
|
61
|
+
/** What changed since the last verified release, for a release's terms, from Toolroll's records only. */
|
|
62
|
+
export declare function releaseChangeList(store: Store, terms: ReleaseTerms): ChangeList;
|
|
63
|
+
export type ReleaseCard = {
|
|
64
|
+
/** Binds the scope digest, the plan form's digest, the baseline and the change list. */
|
|
65
|
+
digest: string;
|
|
66
|
+
short: string;
|
|
67
|
+
full: string;
|
|
68
|
+
fileName: string;
|
|
69
|
+
changes: ChangeList;
|
|
70
|
+
};
|
|
71
|
+
/** The card digest: everything the person is shown that can change under them. */
|
|
72
|
+
export declare function cardDigestOf(scopeDigest: string, formDigest: string, changes: Pick<ChangeList, "baseline" | "digest">): string;
|
|
73
|
+
/** The release card for a deploy gate, against the live records now. */
|
|
74
|
+
export declare function releaseCardOf(store: Store, scope: Scope, formDigest: string, words: {
|
|
75
|
+
profile: (profile: Scope["profile"]) => string | null;
|
|
76
|
+
}): ReleaseCard;
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The release card (docs/plans/chat-management.md §0a.5): how a deploy gate is approved from chat until the
|
|
3
|
+
* authenticator ships. Alex's decision (2026-10-25): chat keeps approving releases by tap, so the tap is made to mean
|
|
4
|
+
* exactly what was shown.
|
|
5
|
+
*
|
|
6
|
+
* - `approvalTermsOf`: every signed term of the scope (its agent profile and phase route setting by setting) and every
|
|
7
|
+
* field of its release terms, one line each, "none" for an empty one and "any file in the project" for unnamed paths
|
|
8
|
+
* (a contract test changes each input of the approval digest in turn and holds the text to change with it).
|
|
9
|
+
* - `releaseChangeList`: what changed since the last verified release, from the plane's own record of it (the merge
|
|
10
|
+
* commit of the newest merged release whose merge operation succeeded), read as verified Git objects from the
|
|
11
|
+
* project's recorded Git directory: never a tag or a ref, so moving one changes nothing, and history rewritten around
|
|
12
|
+
* the objects (a shallow boundary, grafts, replacement refs) refuses the list rather than shortening it.
|
|
13
|
+
* With no earlier release on record, the pull request's own commits from GitHub, for the recorded owner/name.
|
|
14
|
+
* - `releaseCardOf`: the short card, the full terms (sent as a file where the chat app limits a message), and
|
|
15
|
+
* `cardDigest`, which binds the scope digest, the plan form's digest, the baseline and the change list: Continue and
|
|
16
|
+
* Approve carry it, and anything different at the tap refuses.
|
|
17
|
+
*/
|
|
18
|
+
import { spawnSync } from "node:child_process";
|
|
19
|
+
import { createHash } from "node:crypto";
|
|
20
|
+
import { lstatSync, realpathSync } from "node:fs";
|
|
21
|
+
import { join } from "node:path";
|
|
22
|
+
import { checkAccessWords } from "./check-access.js";
|
|
23
|
+
import { CHECK_LEVEL_WORDS, effectiveCheckLevel } from "./check-levels.js";
|
|
24
|
+
import { verifiedRange } from "./commit-extract.js";
|
|
25
|
+
import { releaseTermsSchema } from "./contracts/release-terms.js";
|
|
26
|
+
import { routeFromJson } from "./phase-routing.js";
|
|
27
|
+
import { projectName } from "./project.js";
|
|
28
|
+
import { RELEASE_PATH, releaseConfigProblem, releaseGitEnv, releaseRemote, releaseTools } from "./release-git.js";
|
|
29
|
+
const NONE = "none";
|
|
30
|
+
/** A budget in dollars from integer micro-dollars, in integer arithmetic: cents when it is whole cents, else every digit. */
|
|
31
|
+
const money = (micro) => {
|
|
32
|
+
const exact = BigInt(micro), whole = exact / 1000000n, rest = (exact < 0n ? -exact : exact) % 1000000n;
|
|
33
|
+
const fraction = rest.toString().padStart(6, "0");
|
|
34
|
+
return `${exact < 0n && whole === 0n ? "-" : ""}$${whole}.${rest % 10000n === 0n ? fraction.slice(0, 2) : fraction}`;
|
|
35
|
+
};
|
|
36
|
+
/** Words the card itself says in place of a value: a signed string spelled the same is quoted, never mistaken for one. */
|
|
37
|
+
const RESERVED = new Set([NONE, "yes", "no", "null", "empty", "not set", "any file in the project", "the whole project", "unreadable"]);
|
|
38
|
+
/** Text that reads the same plain as quoted: ASCII letters, digits and ordinary punctuation only (no lookalike letters
|
|
39
|
+
* from other scripts, no direction marks), never a comma, semicolon, parenthesis, quote, backslash, line break or
|
|
40
|
+
* control character, nor a trailing space. */
|
|
41
|
+
const PLAIN = /^[A-Za-z0-9~/.#$@][A-Za-z0-9 ._/@~+#=!?&%$*:<>|^-]*$/;
|
|
42
|
+
/** JSON-quoted with everything outside printable ASCII \u-escaped (JSON.stringify leaves non-ASCII as it is). */
|
|
43
|
+
const quoted = (value) => JSON.stringify(value).replace(/[^\x20-\x7e]/g, one => `\\u${one.charCodeAt(0).toString(16).padStart(4, "0")}`);
|
|
44
|
+
/** One signed string, unambiguous: as it is when plain, else quoted; an empty, number-like or colon-bearing string, or
|
|
45
|
+
* one of the card's own words, is quoted too. */
|
|
46
|
+
const text = (value) => PLAIN.test(value) && !value.endsWith(" ") && !RESERVED.has(value.toLowerCase()) && !/^-?\d+(\.\d+)?$/.test(value) ? value : quoted(value);
|
|
47
|
+
/** One signed value as the card says it, every shape distinct: "none" for null, "not set" for absent, "empty" for an
|
|
48
|
+
* empty list, `""` for an empty string; a list's entries each said the same way, so a comma never joins two into one. */
|
|
49
|
+
const said = (value) => {
|
|
50
|
+
if (value === undefined)
|
|
51
|
+
return "not set";
|
|
52
|
+
if (value === null)
|
|
53
|
+
return NONE;
|
|
54
|
+
if (typeof value === "boolean")
|
|
55
|
+
return value ? "yes" : "no";
|
|
56
|
+
if (typeof value === "number")
|
|
57
|
+
return String(value);
|
|
58
|
+
if (Array.isArray(value))
|
|
59
|
+
return value.length === 0 ? "empty" : value.map(one => typeof one === "string" ? text(one) : one === null ? "null" : said(one)).join(", ");
|
|
60
|
+
return text(String(value));
|
|
61
|
+
};
|
|
62
|
+
/** The scope's own terms the card names, in order. A contract test holds `goal`, `outOfScope`, `touches` and
|
|
63
|
+
* `acceptance` (contracts/scope.ts) and the scope's other signed fields to this list; the agent profile and the phase
|
|
64
|
+
* route follow as one line per signed setting (`profile.…`, `route.…`). */
|
|
65
|
+
export const SCOPE_TERM_LABELS = {
|
|
66
|
+
title: "Title", project: "Project", filedBy: "Filed by", goal: "Goal", outOfScope: "Not in scope", touches: "Paths it may touch",
|
|
67
|
+
acceptance: "Done when", qualityMode: "Evidence standard", agents: "Agents and model", permission: "Agents may", checks: "Checks", checkAccess: "Check access",
|
|
68
|
+
budgetMicrousd: "Budget per attempt", candidate: "Candidate commit",
|
|
69
|
+
};
|
|
70
|
+
/** Every leaf of a signed value, at its path (`a.b`, `legs.0.model`), as the card says it. */
|
|
71
|
+
function leavesOf(value, path) {
|
|
72
|
+
if (Array.isArray(value))
|
|
73
|
+
return value.every(one => one === null || typeof one !== "object") ? [{ path, value: said(value) }]
|
|
74
|
+
: value.flatMap((one, index) => leavesOf(one, `${path}.${index}`));
|
|
75
|
+
if (value !== null && typeof value === "object") {
|
|
76
|
+
const keys = Object.keys(value).sort();
|
|
77
|
+
return keys.length === 0 ? [{ path, value: "no settings" }] : keys.flatMap(key => leavesOf(value[key], `${path}.${key}`));
|
|
78
|
+
}
|
|
79
|
+
return [{ path, value: said(value) }];
|
|
80
|
+
}
|
|
81
|
+
/** Every field of the release terms, by its path in the terms (contracts/release-terms.ts), against the schema's
|
|
82
|
+
* own leaves: the contract test checks every one has a label. */
|
|
83
|
+
export const RELEASE_TERM_LABELS = {
|
|
84
|
+
version: "Release terms version", repo: "Repository", packageVersion: "Package version", branch: "Branch", pr: "Pull request", base: "Base",
|
|
85
|
+
approvedSha: "Approved commit", approvedTree: "Approved tree",
|
|
86
|
+
"policy.version": "Carry policy version", "policy.rules": "Carry rules", "policy.carry": "Carry later commits",
|
|
87
|
+
"policy.protectProject": "Protected: the whole project", "policy.protectedPaths": "Protected paths",
|
|
88
|
+
"policy.limits.lines": "Carry limit: lines", "policy.limits.files": "Carry limit: files", "policy.limits.churn": "Carry limit: churn",
|
|
89
|
+
"policy.unsandboxedCarry": "Carry when checks aren't sandboxed",
|
|
90
|
+
"grant.id": "Release check grant", "grant.digest": "Release check grant digest", access: "Release check access", expiresAt: "Expires",
|
|
91
|
+
};
|
|
92
|
+
/** The leaf paths of a zod object schema (nested objects joined with dots). */
|
|
93
|
+
export function schemaLeaves(schema, prefix = "") {
|
|
94
|
+
const shape = schema.shape;
|
|
95
|
+
if (shape === undefined || shape === null)
|
|
96
|
+
return [prefix];
|
|
97
|
+
return Object.entries(shape).flatMap(([key, child]) => schemaLeaves(child, prefix === "" ? key : `${prefix}.${key}`));
|
|
98
|
+
}
|
|
99
|
+
const valueAt = (terms, path) => path.split(".").reduce((at, key) => at !== null && typeof at === "object" ? at[key] : undefined, terms);
|
|
100
|
+
/** Every signed term of `scope`, its release terms included, one line each. Reads only. */
|
|
101
|
+
export function approvalTermsOf(store, scope, words) {
|
|
102
|
+
const ref = store.lookupRef(scope.taskId);
|
|
103
|
+
const repo = ref?.repo ?? null;
|
|
104
|
+
const filer = store.taskFiler(scope.taskId);
|
|
105
|
+
const profile = scope.profile ?? null;
|
|
106
|
+
const scopeValues = {
|
|
107
|
+
title: said(store.getTask(scope.taskId)?.title),
|
|
108
|
+
project: repo === null ? NONE : `${text(projectName(repo))} (${text(repo)})`,
|
|
109
|
+
filedBy: filer === null ? NONE : filer.name === null ? filer.kind : `${text(filer.name)}${filer.kind === "person" ? "" : ` (${filer.kind})`}`,
|
|
110
|
+
goal: said(scope.goal),
|
|
111
|
+
// null (nothing excluded) and an empty exclusion are signed apart, and read apart.
|
|
112
|
+
outOfScope: said(scope.outOfScope),
|
|
113
|
+
// No paths named is no limit: the work may touch any file in the project.
|
|
114
|
+
touches: scope.touches.length === 0 ? "any file in the project" : said(scope.touches),
|
|
115
|
+
// Each criterion's fields quoted, always: a colon or a parenthesis in one never moves the boundary to the next.
|
|
116
|
+
acceptance: scope.acceptance.length === 0 ? NONE : scope.acceptance.map(one => `${quoted(one.id)}: ${quoted(one.statement)} (evidence: ${said(one.evidence)})`).join("; "),
|
|
117
|
+
qualityMode: scope.qualityMode === "strict" ? "strict" : "standard",
|
|
118
|
+
agents: profile === null ? NONE : `${profile.provider} · ${text(profile.model)}`,
|
|
119
|
+
permission: said(words.profile(profile)),
|
|
120
|
+
checks: CHECK_LEVEL_WORDS[effectiveCheckLevel(store, repo, scope.taskId).level],
|
|
121
|
+
checkAccess: checkAccessWords(profile?.checkAccess ?? "sandboxed"),
|
|
122
|
+
budgetMicrousd: scope.budgetMicrousd === null ? NONE : money(scope.budgetMicrousd),
|
|
123
|
+
candidate: said(scope.candidate),
|
|
124
|
+
};
|
|
125
|
+
const lines = Object.keys(SCOPE_TERM_LABELS).map(key => ({ key, label: SCOPE_TERM_LABELS[key], value: scopeValues[key] }));
|
|
126
|
+
// Every signed setting of the agent profile (what it may read, reach and write, its limits) and the phase route the
|
|
127
|
+
// digest binds, one line each: the summary lines above never stand in for them.
|
|
128
|
+
if (profile !== null)
|
|
129
|
+
for (const leaf of leavesOf(profile, "profile"))
|
|
130
|
+
lines.push({ key: leaf.path, label: `Agent setting ${leaf.path.slice("profile.".length)}`, value: leaf.value });
|
|
131
|
+
if (scope.proposedRouteJson != null) {
|
|
132
|
+
const route = routeFromJson(scope.proposedRouteJson);
|
|
133
|
+
// Unreadable (it then never seals): its stored text, exactly, so two different ones never read alike.
|
|
134
|
+
if (route === null)
|
|
135
|
+
lines.push({ key: "route", label: "Phase route (unreadable)", value: quoted(scope.proposedRouteJson) });
|
|
136
|
+
else
|
|
137
|
+
for (const leaf of leavesOf(route, "route"))
|
|
138
|
+
lines.push({ key: leaf.path, label: `Phase route ${leaf.path.slice("route.".length)}`, value: leaf.value });
|
|
139
|
+
}
|
|
140
|
+
const terms = scope.releaseTerms ?? null;
|
|
141
|
+
for (const path of schemaLeaves(releaseTermsSchema)) {
|
|
142
|
+
const label = RELEASE_TERM_LABELS[path] ?? path;
|
|
143
|
+
const value = terms === null ? NONE : path === "pr" ? `#${terms.pr}`
|
|
144
|
+
// The whole project is protected, and the signed list is said too (it is part of what was signed).
|
|
145
|
+
: path === "policy.protectedPaths" && terms.policy.protectProject ? `the whole project (listed: ${said(terms.policy.protectedPaths)})`
|
|
146
|
+
// Absent (sealed before the setting existed) is off, said apart from a signed "no".
|
|
147
|
+
: path === "policy.unsandboxedCarry" && terms.policy.unsandboxedCarry === undefined ? "no (not set when sealed)" : said(valueAt(terms, path));
|
|
148
|
+
lines.push({ key: `release.${path}`, label, value });
|
|
149
|
+
}
|
|
150
|
+
return lines;
|
|
151
|
+
}
|
|
152
|
+
export const termsText = (lines) => lines.map(line => `${line.label}: ${line.value}`).join("\n");
|
|
153
|
+
const SHA = /^[0-9a-f]{40}$/;
|
|
154
|
+
const sha256 = (text) => createHash("sha256").update(text).digest("hex");
|
|
155
|
+
/** The plane's own record of the last verified release of `repo`: the merge commit GitHub answered with, recorded once
|
|
156
|
+
* the release verified the merged tree (the newest merged authority whose merge operation succeeded). Never a tag. */
|
|
157
|
+
export function releaseBaseline(store, repo) {
|
|
158
|
+
const row = store.handle.prepare(`SELECT o.external_ref FROM release_authority a JOIN release_operation o ON o.authority = a.id
|
|
159
|
+
WHERE a.repo = ? AND a.state = 'merged' AND o.kind = 'merge' AND o.state = 'succeeded' AND o.external_ref IS NOT NULL ORDER BY a.id DESC, o.finished_at DESC LIMIT 1`).get(repo);
|
|
160
|
+
const ref = row === undefined ? null : String(row["external_ref"]);
|
|
161
|
+
return ref !== null && SHA.test(ref) ? ref : null;
|
|
162
|
+
}
|
|
163
|
+
/** The commits after `from` up to `to`, as verified Git objects only, from the project's recorded Git directory: the
|
|
164
|
+
* release coordinator's checkout config allowlist first, then commit-extract's verified range (every commit hashed here,
|
|
165
|
+
* a shallow boundary, grafts or replacement refs refused, and the range proved to stop only in the last release's own
|
|
166
|
+
* history). Null when they can't be read, so a card never shows a shortened list. */
|
|
167
|
+
export function commitsBetween(repo, from, to) {
|
|
168
|
+
if (!SHA.test(from) || !SHA.test(to))
|
|
169
|
+
return null;
|
|
170
|
+
let gitDir;
|
|
171
|
+
try {
|
|
172
|
+
const named = join(realpathSync(repo), ".git");
|
|
173
|
+
if (!lstatSync(named).isDirectory())
|
|
174
|
+
return null;
|
|
175
|
+
gitDir = realpathSync(named);
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
const tools = releaseTools();
|
|
181
|
+
const env = { ...releaseGitEnv(), PATH: RELEASE_PATH };
|
|
182
|
+
const listing = spawnSync(tools.git, [`--git-dir=${gitDir}`, "--no-optional-locks", "config", "--list", "--no-includes", "--show-scope", "-z"], { cwd: "/", env, encoding: "utf8", timeout: 15_000 });
|
|
183
|
+
if (listing.status !== 0 || releaseConfigProblem(listing.stdout) !== null)
|
|
184
|
+
return null;
|
|
185
|
+
const range = verifiedRange(gitDir, from, to, { git: tools.git, env });
|
|
186
|
+
if (!range.ok)
|
|
187
|
+
return null;
|
|
188
|
+
return range.value.map(one => ({ sha: one.id, subject: one.message.split("\n")[0].trim() }));
|
|
189
|
+
}
|
|
190
|
+
/** A pull request's own commits from GitHub, for the recorded owner/name. Null when GitHub can't be read. */
|
|
191
|
+
function pullRequestCommits(slug, pr) {
|
|
192
|
+
const tools = releaseTools();
|
|
193
|
+
const read = spawnSync(tools.gh, ["api", `repos/${slug}/pulls/${pr}/commits?per_page=100`, "--jq", '.[] | .sha + "\\t" + (.commit.message | split("\\n")[0])'], { cwd: "/", env: releaseGitEnv(), encoding: "utf8", timeout: 15_000 });
|
|
194
|
+
if (read.status !== 0)
|
|
195
|
+
return null;
|
|
196
|
+
return read.stdout.split("\n").filter(line => line !== "").map(line => { const tab = line.indexOf("\t"); return { sha: line.slice(0, tab), subject: line.slice(tab + 1) }; })
|
|
197
|
+
.filter(one => SHA.test(one.sha)).reverse();
|
|
198
|
+
}
|
|
199
|
+
/** What changed since the last verified release, for a release's terms, from Toolroll's records only. */
|
|
200
|
+
export function releaseChangeList(store, terms) {
|
|
201
|
+
const baseline = releaseBaseline(store, terms.repo);
|
|
202
|
+
let commits;
|
|
203
|
+
let source;
|
|
204
|
+
if (baseline !== null) {
|
|
205
|
+
commits = commitsBetween(terms.repo, baseline, terms.approvedSha);
|
|
206
|
+
source = commits === null ? "unread" : "recorded";
|
|
207
|
+
}
|
|
208
|
+
else {
|
|
209
|
+
const remote = store.projectRemote(terms.repo);
|
|
210
|
+
const parsed = remote === null ? null : releaseRemote(remote);
|
|
211
|
+
commits = parsed === null ? null : pullRequestCommits(parsed.slug, terms.pr);
|
|
212
|
+
source = commits === null ? "unread" : "pull-request";
|
|
213
|
+
}
|
|
214
|
+
return { baseline, commits, source, digest: sha256(`release-changes:${JSON.stringify({ baseline, source, commits })}`) };
|
|
215
|
+
}
|
|
216
|
+
/** The card digest: everything the person is shown that can change under them. */
|
|
217
|
+
export function cardDigestOf(scopeDigest, formDigest, changes) {
|
|
218
|
+
return sha256(`release-card:v1:${JSON.stringify([scopeDigest, formDigest, changes.baseline, changes.digest])}`);
|
|
219
|
+
}
|
|
220
|
+
function changeWords(changes, terms, cap) {
|
|
221
|
+
const head = changes.baseline === null ? `No earlier verified release is recorded. ${changes.source === "pull-request" ? `Pull request #${terms?.pr ?? "?"}'s commits:` : ""}`.trim()
|
|
222
|
+
: `Changes since the last verified release (${changes.baseline.slice(0, 7)}):`;
|
|
223
|
+
if (changes.commits === null)
|
|
224
|
+
return [head, changes.baseline === null ? `The pull request's commits couldn't be read; see #${terms?.pr ?? "?"} on GitHub.` : "The change list couldn't be read."];
|
|
225
|
+
if (changes.commits.length === 0)
|
|
226
|
+
return [head, "No commits."];
|
|
227
|
+
return [head, ...changes.commits.slice(0, cap).map(one => `• ${one.sha.slice(0, 7)} ${one.subject.slice(0, 100)}`), ...(changes.commits.length > cap ? [`• and ${changes.commits.length - cap} more`] : [])];
|
|
228
|
+
}
|
|
229
|
+
/** The release card for a deploy gate, against the live records now. */
|
|
230
|
+
export function releaseCardOf(store, scope, formDigest, words) {
|
|
231
|
+
const terms = scope.releaseTerms ?? null;
|
|
232
|
+
const lines = approvalTermsOf(store, scope, words);
|
|
233
|
+
const value = (key) => lines.find(line => line.key === key)?.value ?? NONE;
|
|
234
|
+
const changes = terms === null
|
|
235
|
+
? { baseline: null, commits: null, source: "unread", digest: sha256("release-changes:none") }
|
|
236
|
+
: releaseChangeList(store, terms);
|
|
237
|
+
const version = terms?.packageVersion ?? null;
|
|
238
|
+
const fileName = `release-${version ?? scope.taskId.replace(/[^A-Za-z0-9._-]/g, "-")}-terms.txt`;
|
|
239
|
+
const short = [
|
|
240
|
+
version === null ? `Deploy gate: ${value("title")}` : `Release ${version}: ${value("title")}`,
|
|
241
|
+
terms === null ? `Candidate ${said(scope.candidate).slice(0, 12)} · ${value("project")}` : `${projectName(terms.repo)} · ${terms.branch} · pull request #${terms.pr} · commit ${terms.approvedSha.slice(0, 7)}`,
|
|
242
|
+
terms === null ? `Checks: ${value("checks")} · check access: ${value("checkAccess")}` : `Carry later commits: ${terms.policy.carry ? "yes" : "no"} · checks: ${value("checks")} · expires ${terms.expiresAt}`,
|
|
243
|
+
// Option A's disclosure, on the card as on the approval sheet: never approved from chat without it.
|
|
244
|
+
...(terms !== null && terms.policy.carry && terms.access !== "sandboxed" ? ["This project's checks don't run in the sandbox: later test-only commits will run outside the sandbox without being shown to you first."] : []),
|
|
245
|
+
"",
|
|
246
|
+
...changeWords(changes, terms, 5),
|
|
247
|
+
"",
|
|
248
|
+
`Every term is in ${fileName}.`,
|
|
249
|
+
].join("\n");
|
|
250
|
+
const full = [`${version === null ? "Deploy gate" : `Release ${version}`}: every term`, "", termsText(lines), "", ...changeWords(changes, terms, 200)].join("\n");
|
|
251
|
+
return { digest: cardDigestOf(scope.digest, formDigest, changes), short, full, fileName, changes };
|
|
252
|
+
}
|
package/dist/release-carry.d.ts
CHANGED
|
@@ -27,6 +27,9 @@ export declare const CARRY_LIMITS: Readonly<{
|
|
|
27
27
|
/** "Ask for every release commit": a project setting, newest choice wins (a ledger row each); off by default. */
|
|
28
28
|
export declare const ASK_EVERY_COMMIT_ACTION = "release carrying changed";
|
|
29
29
|
export declare function asksEveryReleaseCommit(store: Store, repo: string): boolean;
|
|
30
|
+
/** Set "Carry release approvals when checks aren't sandboxed". `by` is who set it; turning it on is only ever called
|
|
31
|
+
* after a person's own proof (release-cli.ts). */
|
|
32
|
+
export declare function setUnsandboxedCarry(store: Store, repo: string, on: boolean, by: string, now: Date): void;
|
|
30
33
|
export declare function setAskEveryReleaseCommit(store: Store, repo: string, ask: boolean, by: string, now: Date): void;
|
|
31
34
|
/** The effective policy now: the runtime rules, the project's protected paths and the limits. */
|
|
32
35
|
export declare function effectivePolicy(store: Store, repo: string, options?: {
|
package/dist/release-carry.js
CHANGED
|
@@ -32,6 +32,7 @@ import { CARRY_RULES_VERSION, readReleasePolicy, releasePolicyDigest } from "./c
|
|
|
32
32
|
import { run } from "./exec.js";
|
|
33
33
|
import { proxyEnv, startEgressProxy } from "./egress.js";
|
|
34
34
|
import { liveReleaseOf } from "./scope.js";
|
|
35
|
+
import { UNSANDBOXED_CARRY_ACTION, carriesUnsandboxed } from "./release-authority.js";
|
|
35
36
|
import { carryReleaseApproval, releaseAuthority, releaseCarryTo, sealAnchorArtifact, } from "./store.js";
|
|
36
37
|
// ------------------------------------------------------------------ the rules
|
|
37
38
|
/** The journey scripts: tests, by Alex's decision (they carry). */
|
|
@@ -70,6 +71,12 @@ export function asksEveryReleaseCommit(store, repo) {
|
|
|
70
71
|
const row = store.handle.prepare("SELECT outcome FROM action_ledger WHERE repo = ? AND action = ? ORDER BY id DESC LIMIT 1").get(repo, ASK_EVERY_COMMIT_ACTION);
|
|
71
72
|
return row !== undefined && row["outcome"] === "ask";
|
|
72
73
|
}
|
|
74
|
+
/** Set "Carry release approvals when checks aren't sandboxed". `by` is who set it; turning it on is only ever called
|
|
75
|
+
* after a person's own proof (release-cli.ts). */
|
|
76
|
+
export function setUnsandboxedCarry(store, repo, on, by, now) {
|
|
77
|
+
store.recordAction({ at: now.toISOString(), actor: by, repo, taskId: null, runId: null, action: UNSANDBOXED_CARRY_ACTION, outcome: on ? "on" : "off", source: "request",
|
|
78
|
+
detail: on ? "Carry release approvals when checks aren't sandboxed: later test-only commits run outside the sandbox without being shown first" : "Release approvals carry only when checks are sandboxed" });
|
|
79
|
+
}
|
|
73
80
|
export function setAskEveryReleaseCommit(store, repo, ask, by, now) {
|
|
74
81
|
store.recordAction({ at: now.toISOString(), actor: by, repo, taskId: null, runId: null, action: ASK_EVERY_COMMIT_ACTION, outcome: ask ? "ask" : "carry", source: "request",
|
|
75
82
|
detail: ask ? "Ask for every release commit" : "Later test-only commits ship under the release approval" });
|
|
@@ -80,6 +87,7 @@ export function effectivePolicy(store, repo, options = {}) {
|
|
|
80
87
|
return {
|
|
81
88
|
version: 1, rules: CARRY_RULES_VERSION, carry: options.noCarry !== true && !asksEveryReleaseCommit(store, repo),
|
|
82
89
|
protectProject: rules.protectProject, protectedPaths: [...new Set(rules.protectedPaths)].sort(), limits: { ...CARRY_LIMITS },
|
|
90
|
+
unsandboxedCarry: carriesUnsandboxed(store, repo),
|
|
83
91
|
};
|
|
84
92
|
}
|
|
85
93
|
/** The stricter of two policies: what either protects asks again, the lower limit wins, either one's "ask" asks. Null
|
|
@@ -91,6 +99,8 @@ export function stricterOf(a, b) {
|
|
|
91
99
|
version: 1, rules: CARRY_RULES_VERSION, carry: a.carry && b.carry, protectProject: a.protectProject || b.protectProject,
|
|
92
100
|
protectedPaths: [...new Set([...a.protectedPaths, ...b.protectedPaths])].sort(),
|
|
93
101
|
limits: { lines: Math.min(a.limits.lines, b.limits.lines), files: Math.min(a.limits.files, b.limits.files), churn: Math.min(a.limits.churn, b.limits.churn) },
|
|
102
|
+
// Carrying when checks aren't sandboxed only while both allow it: turning it on later never widens an approval.
|
|
103
|
+
unsandboxedCarry: a.unsandboxedCarry === true && b.unsandboxedCarry === true,
|
|
94
104
|
};
|
|
95
105
|
}
|
|
96
106
|
/**
|
|
@@ -141,12 +151,17 @@ export function releaseTermsFor(store, input) {
|
|
|
141
151
|
const grant = store.liveVerifyCommand(input.repo);
|
|
142
152
|
if (grant === null)
|
|
143
153
|
return { ok: false, why: "the project has no approved release check" };
|
|
144
|
-
|
|
145
|
-
|
|
154
|
+
const access = store.projectCheckAccess(input.repo);
|
|
155
|
+
const policy = effectivePolicy(store, input.repo, { noCarry: input.noCarry ?? false });
|
|
156
|
+
// Checks outside the sandbox carry only with the project's "Carry release approvals when checks aren't sandboxed" on,
|
|
157
|
+
// sealed here: turning it on after this approval never carries for it.
|
|
158
|
+
if (access !== "sandboxed" && policy.unsandboxedCarry !== true) {
|
|
159
|
+
return { ok: false, why: "the project's checks run outside the sandbox, so later commits always ask again (unless “Carry release approvals when checks aren't sandboxed” is on)" };
|
|
160
|
+
}
|
|
146
161
|
return { ok: true, terms: {
|
|
147
162
|
version: 1, repo: input.repo, packageVersion: input.version, branch: input.branch, pr: input.pr, base: "main",
|
|
148
|
-
approvedSha: input.candidate, approvedTree: commit.value.tree, policy
|
|
149
|
-
grant: { id: grant.id, digest: grant.digest }, access
|
|
163
|
+
approvedSha: input.candidate, approvedTree: commit.value.tree, policy,
|
|
164
|
+
grant: { id: grant.id, digest: grant.digest }, access,
|
|
150
165
|
expiresAt: new Date(input.now.getTime() + 3 * 24 * 60 * 60_000).toISOString(),
|
|
151
166
|
} };
|
|
152
167
|
}
|
|
@@ -170,6 +185,10 @@ export function preflightCarry(store, input) {
|
|
|
170
185
|
return refuse("authority", "This Toolroll doesn't know the release's carry rules; update Toolroll first.");
|
|
171
186
|
if (!policy.carry)
|
|
172
187
|
return refuse("rules", "This project asks for every release commit.");
|
|
188
|
+
// Checks outside the sandbox (at the approval or now): only when the sealed setting and the current one both allow it.
|
|
189
|
+
if ((a.accessDigest !== "sandboxed" || store.projectCheckAccess(a.repo) !== "sandboxed") && policy.unsandboxedCarry !== true) {
|
|
190
|
+
return refuse("rules", "This project's checks run outside the sandbox, and carrying release approvals then wasn't on when this release was approved, or is off now.");
|
|
191
|
+
}
|
|
173
192
|
if (!/^[0-9a-f]{40}([0-9a-f]{24})?$/.test(input.to))
|
|
174
193
|
return refuse("ancestry", `${input.to} isn't a full commit id.`);
|
|
175
194
|
if (input.to === a.tipSha)
|