toolroll 0.9.92 → 0.9.93
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 +19 -0
- package/dist/assignment-status.d.ts +1 -1
- package/dist/assignment-ui.js +2 -2
- package/dist/assignment.js +11 -3
- package/dist/browser/workspace.css +1 -1
- package/dist/browser/workspace.js +30 -30
- package/dist/browser-crew.js +2 -2
- package/dist/browser-workspace.d.ts +3 -5
- package/dist/builder.js +21 -2
- package/dist/check-facts.d.ts +124 -0
- package/dist/check-facts.js +272 -0
- package/dist/check-report.d.ts +5 -0
- package/dist/check-report.js +4 -3
- package/dist/cli.js +5 -0
- package/dist/contracts/console-api.d.ts +2 -2
- package/dist/contracts/console-api.js +2 -2
- package/dist/contracts/release-check.d.ts +25 -1
- package/dist/contracts/release-check.js +19 -0
- package/dist/desktop-update.d.ts +2 -2
- package/dist/desktop-update.js +13 -9
- package/dist/eval-hooks.d.ts +55 -0
- package/dist/eval-hooks.js +227 -4
- package/dist/flaky.d.ts +17 -0
- package/dist/flaky.js +9 -0
- package/dist/needs-you.d.ts +7 -7
- package/dist/needs-you.js +12 -10
- package/dist/operate.js +138 -18
- package/dist/project-delete.js +5 -1
- package/dist/project-knowledge.d.ts +6 -6
- package/dist/push.d.ts +10 -3
- package/dist/push.js +22 -6
- package/dist/release-adapters.js +11 -1
- package/dist/release-approval.d.ts +191 -0
- package/dist/release-approval.js +382 -0
- package/dist/release-auto.js +13 -4
- package/dist/release-card.d.ts +73 -4
- package/dist/release-card.js +168 -23
- package/dist/release-cli.js +9 -1
- package/dist/release.d.ts +30 -0
- package/dist/release.js +42 -3
- package/dist/result-acts.d.ts +3 -3
- package/dist/result-acts.js +1 -1
- package/dist/server/guards.js +1 -0
- package/dist/server/pages-assets.js +1 -1
- package/dist/server/render-release.d.ts +17 -0
- package/dist/server/render-release.js +84 -0
- package/dist/server/render-results.d.ts +0 -2
- package/dist/server/render-results.js +4 -6
- package/dist/server/render-tasks.js +3 -3
- package/dist/server/route-table.d.ts +1 -1
- package/dist/server/route-table.js +7 -1
- package/dist/server/tasks.d.ts +15 -0
- package/dist/server/tasks.js +102 -20
- package/dist/setup-guide.d.ts +2 -2
- package/dist/stop-proof.d.ts +67 -0
- package/dist/stop-proof.js +293 -0
- package/dist/store.d.ts +70 -31
- package/dist/store.js +335 -95
- package/dist/surface.js +1 -1
- package/dist/task-outcome-cli.js +19 -0
- package/dist/toolroll-update-ui.js +2 -2
- package/dist/toolroll-update.js +1 -0
- package/dist/work-index.js +7 -9
- package/dist/work-summary.d.ts +2 -2
- package/package.json +1 -1
package/dist/operate.js
CHANGED
|
@@ -33,6 +33,7 @@ import { personProof } from "./person-proof.js";
|
|
|
33
33
|
import { RemoteMains } from "./remote-main.js";
|
|
34
34
|
import { checkoutPlan, cleanCheckouts, discardCheckout, finishedCheckouts, slimKeptCheckouts, whyWords } from "./checkout-cleanup.js";
|
|
35
35
|
import { closeOutCheckouts, closeOutRuns, stoppedWords } from "./run-closeout.js";
|
|
36
|
+
import { proveFinishedRunsStopped, proveRunStopped } from "./stop-proof.js";
|
|
36
37
|
import { MIN_DAYS, RETENTION_KINDS, countWords, dailyRetention, isRetentionKind, lastSweepAt, parsePeriod, periodLabel, periodWords, retentionPlan, sweepWords } from "./retention.js";
|
|
37
38
|
import { checkPolicy, parseList, policyParts } from "./policy.js";
|
|
38
39
|
import { billingOf, budgetHoldWords, budgetLabel, budgetStates, monthNamed, monthOf, spendItems, subagentNames, usd as spendUsd } from "./spend.js";
|
|
@@ -63,6 +64,8 @@ import { sendSubagentSummaries } from "./subagent-admin.js";
|
|
|
63
64
|
import { runRequestedUndos, sendSubagentWeeklies } from "./subagent-week.js";
|
|
64
65
|
import { refreshConnections } from "./mcp-connect.js";
|
|
65
66
|
import { releaseCheckPurposeFor, releaseTermsFor } from "./release-carry.js";
|
|
67
|
+
import { approveReleaseCard, journalMatchesPlane, releaseApprovalCard, releaseCardLines, releaseTaskOf, waitingJournals } from "./release-approval.js";
|
|
68
|
+
import { journalFile } from "./release.js";
|
|
66
69
|
import { refreshTrustedMains, settleReleaseChecks } from "./nightly-check.js";
|
|
67
70
|
import { githubNightlyRepos, readGithubNightlies } from "./nightly-github.js";
|
|
68
71
|
import { exec as releaseExec, githubReads } from "./release-adapters.js";
|
|
@@ -1215,6 +1218,9 @@ async function dispatch(command, positional, flags, context) {
|
|
|
1215
1218
|
switch (command) {
|
|
1216
1219
|
case "status":
|
|
1217
1220
|
return statusCommand(positional, flags, context);
|
|
1221
|
+
// `toolroll release approve` (cli.ts routes only this verb here; every other `release` form is release-cli.ts).
|
|
1222
|
+
case "release":
|
|
1223
|
+
return releaseApproveCommand(positional, flags, context);
|
|
1218
1224
|
case "evals":
|
|
1219
1225
|
return evalsCommand(positional, flags, context);
|
|
1220
1226
|
case "ready":
|
|
@@ -3576,6 +3582,9 @@ async function reconcileCommand(flags, context) {
|
|
|
3576
3582
|
// A witness reserved for a spawn that never made a process (a crash or a
|
|
3577
3583
|
// thrown spawn) settles once its run's process groups are proven gone.
|
|
3578
3584
|
store.settleUnspawnedWitnesses(clock());
|
|
3585
|
+
// What no record proves stopped, Toolroll proves itself from this computer's processes (stop-proof.ts): nobody is
|
|
3586
|
+
// asked to confirm a build stopped, so a release waiting on its check moves on by itself.
|
|
3587
|
+
await proveFinishedRunsStopped(store, clock);
|
|
3579
3588
|
store.settleQuiescentStops(clock());
|
|
3580
3589
|
// An update whose updater ended while letting work finish must not leave new work paused.
|
|
3581
3590
|
try {
|
|
@@ -10790,43 +10799,47 @@ async function resumeTaskCommand(positional, flags, context) {
|
|
|
10790
10799
|
]);
|
|
10791
10800
|
}
|
|
10792
10801
|
/**
|
|
10793
|
-
* `toolroll run settle <id> --
|
|
10794
|
-
*
|
|
10795
|
-
*
|
|
10796
|
-
*
|
|
10797
|
-
*
|
|
10802
|
+
* `toolroll run settle <id> --as <you> --token <t>`: have Toolroll check again whether a finished run stopped. Nobody
|
|
10803
|
+
* vouches that a build stopped (a person can't see whether a process exited): this runs Toolroll's own look at this
|
|
10804
|
+
* computer's processes (stop-proof.ts), the one the worker makes on its own, and settles the run when none of them is
|
|
10805
|
+
* the run's. A leftover proven the run's is stopped; anything else is left alone. `--why` is still accepted from older
|
|
10806
|
+
* scripts and ignored.
|
|
10798
10807
|
*/
|
|
10799
10808
|
async function runCommand(positional, flags, context) {
|
|
10800
10809
|
const { store, write, json, clock } = context;
|
|
10801
10810
|
const [action, id, ...extra] = positional;
|
|
10802
10811
|
const command = "run settle";
|
|
10803
|
-
const usage = "`toolroll run settle <run-id
|
|
10812
|
+
const usage = "`toolroll run settle <run-id>`";
|
|
10804
10813
|
if (action !== "settle")
|
|
10805
10814
|
return fail(write, json, "run", "usage", usage, EXIT.usage);
|
|
10806
10815
|
const runId = Number(id ?? "");
|
|
10807
|
-
const why = text(flags, "why")?.trim();
|
|
10808
10816
|
if (id === undefined || extra.length > 0 || !/^[1-9]\d*$/.test(id) || !Number.isSafeInteger(runId))
|
|
10809
10817
|
return fail(write, json, command, "usage", usage, EXIT.usage);
|
|
10810
|
-
if (why === undefined || why === "" || why.length > 300 || /[\x00-\x1f\x7f]/.test(why))
|
|
10811
|
-
return fail(write, json, command, "usage", "say why in one line with --why (at most 300 characters) — it is kept in the ledger", EXIT.usage);
|
|
10812
10818
|
const run = store.getRun(runId);
|
|
10813
10819
|
if (run === null)
|
|
10814
10820
|
return fail(write, json, command, "unknown-run", `no run #${runId}`, EXIT.refused);
|
|
10815
10821
|
const acting = await askCredentials(flags, context);
|
|
10816
10822
|
if (acting === null)
|
|
10817
|
-
return fail(write, json, command, "usage", "
|
|
10823
|
+
return fail(write, json, command, "usage", "checking a run again takes `--as <you> --token <t>` (or your sign-in from `toolroll up`)", EXIT.usage);
|
|
10818
10824
|
const authenticated = authenticateApprover(store, acting.name, acting.token);
|
|
10819
10825
|
const repo = store.refById(run.taskRef)?.repo ?? null;
|
|
10820
10826
|
if (!authenticated.ok || !store.accountCanAccess(acting.name, repo))
|
|
10821
10827
|
return fail(write, json, command, "not-an-approver", "that is not an approver for this run's project, or the token does not match", EXIT.refused);
|
|
10822
|
-
|
|
10823
|
-
|
|
10824
|
-
|
|
10825
|
-
|
|
10826
|
-
|
|
10827
|
-
|
|
10828
|
-
|
|
10829
|
-
|
|
10828
|
+
if (run.outcome === null)
|
|
10829
|
+
return fail(write, json, command, "still-running", `run #${runId} is still open`, EXIT.refused, { run: runId });
|
|
10830
|
+
const proved = await proveRunStopped(store, runId, clock);
|
|
10831
|
+
const stopped = store.stopQuiescenceFact(runId);
|
|
10832
|
+
if (stopped === null) {
|
|
10833
|
+
return succeed(write, json, command, { run: runId, outcome: proved.outcome === "skipped" ? "already-stopped" : "stopped", stoppedProcesses: proved.stopped ?? 0 }, () => [
|
|
10834
|
+
proved.outcome === "skipped" ? `Run #${runId} had already stopped; nothing changed.`
|
|
10835
|
+
: `Run #${runId} stopped: Toolroll checked this computer's processes and none of them is the run's${proved.stopped ? ` (it stopped ${proved.stopped} the run left running)` : ""}. The check is in the ledger.`,
|
|
10836
|
+
]);
|
|
10837
|
+
}
|
|
10838
|
+
const reason = proved.outcome === "blocked" ? "unproven" : stopped.kind === "alive" ? "alive" : "waiting";
|
|
10839
|
+
const words = proved.outcome === "blocked" ? `Run #${proved.run} ${proved.words}.`
|
|
10840
|
+
: stopped.kind === "alive" ? `Something of run #${stopped.run} is still running; Toolroll stops what is proven the run's on its own.`
|
|
10841
|
+
: `Toolroll is still making sure run #${stopped.run} stopped. This clears on its own.`;
|
|
10842
|
+
return fail(write, json, command, reason, words, EXIT.refused, { run: runId });
|
|
10830
10843
|
}
|
|
10831
10844
|
// ---- shared ---------------------------------------------------------------
|
|
10832
10845
|
/**
|
|
@@ -13466,3 +13479,110 @@ function memoryWriteVerb(positional) {
|
|
|
13466
13479
|
return MEMORY_WRITE_VERBS.has(positional[0] ?? "");
|
|
13467
13480
|
}
|
|
13468
13481
|
const MEMORY_WRITE_REFUSAL = "Project memory is changed by a person with their own sign-in, not the lead. The lead proposes decisions in chat for you to confirm.";
|
|
13482
|
+
/**
|
|
13483
|
+
* `toolroll release approve [<branch>] [--repo <path>]` (docs/plans/trusted-release-reports.md, slice A, A8): approve
|
|
13484
|
+
* the release waiting for a person from this terminal, without copying a task id or a digest. The release journals are
|
|
13485
|
+
* discovery only, read strictly; the plane's sealed release identity decides which release this is. The card prints
|
|
13486
|
+
* control-free; the person is asked through `askCredentials` (or its remembered login, under its existing conditions);
|
|
13487
|
+
* the card is built again AFTER that prompt and anything changed refuses; then the shared card wrapper hands the
|
|
13488
|
+
* snapshot's scope digest to the unchanged approve(). `--json` never prompts.
|
|
13489
|
+
*/
|
|
13490
|
+
async function releaseApproveCommand(positional, flags, context) {
|
|
13491
|
+
const { store, write, json } = context;
|
|
13492
|
+
const command = "release approve";
|
|
13493
|
+
const usage = "`toolroll release approve [<branch>] [--repo <path>]` (with --json: --as <you> --token <password> --digest <card digest>)";
|
|
13494
|
+
const branchFlag = text(flags, "branch");
|
|
13495
|
+
if (positional[0] !== "approve" || positional.length > 2 || (branchFlag !== undefined && positional.length > 1))
|
|
13496
|
+
return fail(write, json, command, "usage", usage, EXIT.usage);
|
|
13497
|
+
const branch = branchFlag ?? positional[1];
|
|
13498
|
+
if (branch !== undefined && (!/^[A-Za-z0-9._/-]{1,255}$/.test(branch) || branch.startsWith("-") || branch.includes("..")))
|
|
13499
|
+
return fail(write, json, command, "usage", `${branch} is not a branch name this command takes.`, EXIT.usage);
|
|
13500
|
+
const repoFlag = text(flags, "repo");
|
|
13501
|
+
const repo = repoFlag === undefined ? null : resolve(repoFlag);
|
|
13502
|
+
const stateDir = dirname(context.databaseFile);
|
|
13503
|
+
const { journals, unreadable } = waitingJournals(stateDir, name => journalFile(stateDir, name));
|
|
13504
|
+
// The exact branch asked for, never another that shares its file name (feature/foo and feature_foo).
|
|
13505
|
+
const named = branch === undefined ? journals : journals.filter(one => one.branch === branch);
|
|
13506
|
+
if (branch !== undefined && named.length === 0 && unreadable.includes(basename(journalFile(stateDir, branch)))) {
|
|
13507
|
+
return fail(write, json, command, "unreadable", `${branch}'s release journal can't be read as one; nothing was approved. Run \`toolroll release ${branch}\` again.`, EXIT.refused);
|
|
13508
|
+
}
|
|
13509
|
+
const matches = [], refusals = [];
|
|
13510
|
+
for (const one of named) {
|
|
13511
|
+
if (releaseTaskOf(store, one.taskId) !== null && approvalOf(store.getScope(one.taskId)).approved)
|
|
13512
|
+
continue;
|
|
13513
|
+
const proved = journalMatchesPlane(store, one, repo);
|
|
13514
|
+
if (proved.ok)
|
|
13515
|
+
matches.push(one);
|
|
13516
|
+
else
|
|
13517
|
+
refusals.push(proved.why);
|
|
13518
|
+
}
|
|
13519
|
+
if (matches.length === 0) {
|
|
13520
|
+
const why = refusals.length > 0 ? `${refusals[0]}; nothing was approved` : branch === undefined ? "No release is waiting for your approval" : `No release of ${branch} is waiting for your approval`;
|
|
13521
|
+
return fail(write, json, command, refusals.length > 0 ? "mismatch" : "none", `${why}.`, EXIT.refused);
|
|
13522
|
+
}
|
|
13523
|
+
if (matches.length > 1) {
|
|
13524
|
+
return fail(write, json, command, "ambiguous", `More than one release is waiting: ${matches.map(one => `${one.branch} (${one.version})`).join(", ")}. Name the branch: \`toolroll release approve <branch>\`.`, EXIT.refused, { releases: matches.map(one => ({ branch: one.branch, version: one.version, task: one.taskId })) });
|
|
13525
|
+
}
|
|
13526
|
+
const chosen = matches[0];
|
|
13527
|
+
const built = releaseApprovalCard(store, chosen.taskId);
|
|
13528
|
+
if (!built.ok)
|
|
13529
|
+
return fail(write, json, command, "none", built.why, EXIT.refused);
|
|
13530
|
+
const shown = built.card;
|
|
13531
|
+
const cardData = { task: shown.taskId, version: shown.seal.identity.packageVersion, branch: shown.seal.identity.branch, candidate: shown.seal.identity.approvedSha, digest: shown.digest, ci: shown.ci?.state ?? null };
|
|
13532
|
+
if (shown.approved !== null)
|
|
13533
|
+
return fail(write, json, command, "approved", `Release ${cardData.version} is already approved by ${shown.approved.by}.`, EXIT.refused, { release: cardData });
|
|
13534
|
+
if (shown.refusal !== null) {
|
|
13535
|
+
if (!json)
|
|
13536
|
+
for (const line of releaseCardLines(shown))
|
|
13537
|
+
write(line);
|
|
13538
|
+
return fail(write, json, command, "refused", `Can't approve now: ${shown.refusal}`, EXIT.refused, { release: cardData });
|
|
13539
|
+
}
|
|
13540
|
+
let name, token;
|
|
13541
|
+
if (json) {
|
|
13542
|
+
const given = credentialsFrom(flags, context);
|
|
13543
|
+
if (given.name === undefined || given.token === undefined || given.name === "" || given.token === "")
|
|
13544
|
+
return fail(write, json, command, "credentials", "Approving a release needs who is agreeing: --as <you> --token <password>. Nothing was approved.", EXIT.usage, { release: cardData });
|
|
13545
|
+
if (text(flags, "digest") !== shown.digest)
|
|
13546
|
+
return fail(write, json, command, "unconfirmed", "Name the card you read: --digest <card digest>. Nothing was approved.", EXIT.refused, { release: cardData });
|
|
13547
|
+
name = given.name;
|
|
13548
|
+
token = given.token;
|
|
13549
|
+
}
|
|
13550
|
+
else {
|
|
13551
|
+
for (const line of releaseCardLines(shown))
|
|
13552
|
+
write(line);
|
|
13553
|
+
write("");
|
|
13554
|
+
if (interactive()) {
|
|
13555
|
+
if (!await confirm(`Approve release ${cardData.version}?`)) {
|
|
13556
|
+
write("Nothing approved.");
|
|
13557
|
+
return EXIT.refused;
|
|
13558
|
+
}
|
|
13559
|
+
}
|
|
13560
|
+
else if (!flags.has("yes") || text(flags, "digest") !== shown.digest) {
|
|
13561
|
+
write(`Nothing has been approved. Agree to exactly this card with: toolroll release approve ${chosen.branch} --yes --digest ${shown.digest} --as <you> --token <your password>`);
|
|
13562
|
+
return EXIT.refused;
|
|
13563
|
+
}
|
|
13564
|
+
const acting = await askCredentials(flags, context);
|
|
13565
|
+
if (acting === null)
|
|
13566
|
+
return fail(write, json, command, "credentials", "Approving a release needs who is agreeing. Nothing was approved.", EXIT.usage);
|
|
13567
|
+
name = acting.name;
|
|
13568
|
+
token = acting.token;
|
|
13569
|
+
}
|
|
13570
|
+
// After the prompt: the card again, from the plane. A change refuses; it is never approved in place of what was shown.
|
|
13571
|
+
const again = releaseApprovalCard(store, chosen.taskId);
|
|
13572
|
+
if (!again.ok || again.card.digest !== shown.digest)
|
|
13573
|
+
return fail(write, json, command, "changed", "This release changed while you were being asked; nothing was approved. Run the command again to read it.", EXIT.refused);
|
|
13574
|
+
const now = context.clock();
|
|
13575
|
+
const answer = approveReleaseCard(store, { taskId: chosen.taskId, cardDigest: shown.digest, approveNow: scopeDigest => approve(store, chosen.taskId, name, now, scopeDigest, token, mutationFrom(flags, now)) });
|
|
13576
|
+
if (!answer.ok) {
|
|
13577
|
+
if (answer.reason !== "approve")
|
|
13578
|
+
return fail(write, json, command, answer.reason, `${answer.message} Nothing was approved.`, EXIT.refused);
|
|
13579
|
+
const refused = answer.result;
|
|
13580
|
+
if (refused.reason === "policy")
|
|
13581
|
+
return fail(write, json, command, "policy", `${chosen.taskId}: ${refused.message}`, EXIT.refused);
|
|
13582
|
+
return fail(write, json, command, refused.reason, describeApproveFailure(refused.reason, chosen.taskId), EXIT.refused);
|
|
13583
|
+
}
|
|
13584
|
+
return succeed(write, json, command, { release: cardData }, () => [
|
|
13585
|
+
`Approved release ${cardData.version} (${cardData.candidate.slice(0, 7)}). The release check runs next; it deploys, merges and publishes once GitHub CI and the check pass.`,
|
|
13586
|
+
...(shown.stopped === null ? [] : [`The release isn't running: run \`toolroll release ${chosen.branch}\` to go on.`]),
|
|
13587
|
+
]);
|
|
13588
|
+
}
|
package/dist/project-delete.js
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
import { existsSync, lstatSync, rmSync } from "node:fs";
|
|
16
16
|
import { isAbsolute, relative, resolve } from "node:path";
|
|
17
17
|
import { teardownGit, refusal } from "./teardown-git.js";
|
|
18
|
-
import { BUILT_IN, forgetProjectModels } from "./store.js";
|
|
18
|
+
import { BUILT_IN, forgetProjectModels, V149_REPO_TABLES } from "./store.js";
|
|
19
19
|
import { BRANCH_PREFIXES, isOwnBranch } from "./names.js";
|
|
20
20
|
/** The tables whose history is the audit record itself: never touched. */
|
|
21
21
|
const KEPT = new Set(["action_ledger", "ledger_seal", "ledger_checkpoint"]);
|
|
@@ -318,6 +318,10 @@ function deleteRows(store, repo, d, now) {
|
|
|
318
318
|
// Settings, knowledge and the project itself.
|
|
319
319
|
del("operating_mode_event", "mode IN (SELECT id FROM operating_mode WHERE repo = ?)", repo);
|
|
320
320
|
del("decision_change", "decision IN (SELECT id FROM project_decision WHERE repo = ?)", repo);
|
|
321
|
+
// Its learning-loops rows (v149), by repo; a check's results, a note's tallies and a merge of its decisions go with
|
|
322
|
+
// the rows they hang off (the deleted runs, notes and decisions), below.
|
|
323
|
+
for (const table of V149_REPO_TABLES)
|
|
324
|
+
del(table, "repo = ?", repo);
|
|
321
325
|
del("memory_sighting", "gap IN (SELECT id FROM memory_gap WHERE repo = ?) OR session IN (SELECT id FROM memory_session WHERE repo = ?)", repo, repo);
|
|
322
326
|
for (const [table, column] of SETTINGS)
|
|
323
327
|
del(table, `${column} = ?`, repo);
|
|
@@ -22,18 +22,18 @@ export type KnowledgeView = ReturnType<typeof knowledgeView>;
|
|
|
22
22
|
export declare function knowledgeVersion(store: Store, repo: string, actor: string, revision: number): Knowledge;
|
|
23
23
|
/** Chat reads only an admitted project. Keep the full source in the project UI. */
|
|
24
24
|
export declare function conversationKnowledge(store: Store, repo: string, actor: string, reference?: string, decision?: number): {
|
|
25
|
-
|
|
25
|
+
content?: never;
|
|
26
26
|
revision: number;
|
|
27
27
|
stale: boolean;
|
|
28
28
|
notice: string;
|
|
29
29
|
decision?: never;
|
|
30
30
|
title?: never;
|
|
31
|
-
|
|
31
|
+
history?: never;
|
|
32
32
|
instructions?: never;
|
|
33
33
|
references?: never;
|
|
34
34
|
decisions?: never;
|
|
35
35
|
} | {
|
|
36
|
-
|
|
36
|
+
content?: never;
|
|
37
37
|
stale?: never;
|
|
38
38
|
decision: {
|
|
39
39
|
repo: string;
|
|
@@ -51,27 +51,27 @@ export declare function conversationKnowledge(store: Store, repo: string, actor:
|
|
|
51
51
|
};
|
|
52
52
|
notice: string;
|
|
53
53
|
title?: never;
|
|
54
|
-
content?: never;
|
|
55
54
|
revision?: never;
|
|
55
|
+
history?: never;
|
|
56
56
|
instructions?: never;
|
|
57
57
|
references?: never;
|
|
58
58
|
decisions?: never;
|
|
59
59
|
} | {
|
|
60
|
-
history?: never;
|
|
61
60
|
stale?: never;
|
|
62
61
|
decision?: never;
|
|
63
62
|
revision: number;
|
|
64
63
|
title: string;
|
|
65
64
|
content: string;
|
|
66
65
|
notice: string;
|
|
66
|
+
history?: never;
|
|
67
67
|
instructions?: never;
|
|
68
68
|
references?: never;
|
|
69
69
|
decisions?: never;
|
|
70
70
|
} | {
|
|
71
|
+
content?: never;
|
|
71
72
|
stale?: never;
|
|
72
73
|
decision?: never;
|
|
73
74
|
title?: never;
|
|
74
|
-
content?: never;
|
|
75
75
|
revision: number;
|
|
76
76
|
history: {
|
|
77
77
|
revision: number;
|
package/dist/push.d.ts
CHANGED
|
@@ -61,14 +61,21 @@ export declare function validatePushEndpoint(raw: string): {
|
|
|
61
61
|
ok: false;
|
|
62
62
|
problem: string;
|
|
63
63
|
};
|
|
64
|
+
/** A push's class: a stored class, or `release`, which is never stored: it is derived at send time from the plane's own
|
|
65
|
+
* state (release-approval.ts releasePushOf), never from a title, a task id or a link. */
|
|
66
|
+
export type PushClass = NonNullable<Notification["pushClass"]> | "release";
|
|
64
67
|
/** Fixed words, except for one validated machine-only suite/count line. */
|
|
65
|
-
export declare const PUSH_WORDS: Record<
|
|
68
|
+
export declare const PUSH_WORDS: Record<PushClass, {
|
|
66
69
|
title: string;
|
|
67
70
|
body: string;
|
|
68
71
|
}>;
|
|
69
72
|
/** Static words and machine-minted numbers only — re-proved at send time. */
|
|
70
|
-
export declare function safePushLink(pushClass:
|
|
71
|
-
|
|
73
|
+
export declare function safePushLink(pushClass: PushClass, link: string | null): string;
|
|
74
|
+
/** `release`: the class and link the plane derived for this row at send time (releasePushOf); absent or null, the
|
|
75
|
+
* row's stored class and link, revalidated. */
|
|
76
|
+
export declare function buildPushPayload(notification: Notification, waiting?: number | null, release?: {
|
|
77
|
+
link: string;
|
|
78
|
+
} | null): string;
|
|
72
79
|
export type PushFetch = (url: string, init: Record<string, unknown>) => Promise<{
|
|
73
80
|
status: number;
|
|
74
81
|
headers: {
|
package/dist/push.js
CHANGED
|
@@ -20,6 +20,7 @@ import { closeSync, constants as fsConstants, fsyncSync, openSync, readFileSync,
|
|
|
20
20
|
import { join } from "node:path";
|
|
21
21
|
import { createCipheriv, createECDH, createPrivateKey, hkdfSync, randomBytes, createHash, sign as cryptoSign } from "node:crypto";
|
|
22
22
|
import { isCheckProgressLine } from "./check-progress.js";
|
|
23
|
+
import { RELEASE_PUSH_WORDS, releasePushOf, releaseReadyObsolete } from "./release-approval.js";
|
|
23
24
|
export const VAPID_FILE = "vapid-keys.json";
|
|
24
25
|
/** RFC 8291 §4: rs 4096 leaves room for the record's delimiter and tag. */
|
|
25
26
|
const RECORD_SIZE = 4096;
|
|
@@ -193,7 +194,6 @@ export function validatePushEndpoint(raw) {
|
|
|
193
194
|
return { ok: false, problem: "that host is not a known push service — extending the list is a code change, deliberately" };
|
|
194
195
|
return { ok: true, url };
|
|
195
196
|
}
|
|
196
|
-
// ---- the payload ------------------------------------------------------------
|
|
197
197
|
/** Fixed words, except for one validated machine-only suite/count line. */
|
|
198
198
|
export const PUSH_WORDS = {
|
|
199
199
|
decision: { title: "Toolroll", body: "a decision needs you" },
|
|
@@ -201,28 +201,37 @@ export const PUSH_WORDS = {
|
|
|
201
201
|
merge: { title: "Toolroll", body: "a pull request needs a person" },
|
|
202
202
|
attention: { title: "Toolroll", body: "the plane needs attention" },
|
|
203
203
|
progress: { title: "Toolroll", body: "checks in progress" },
|
|
204
|
+
release: { title: "Toolroll", body: RELEASE_PUSH_WORDS },
|
|
204
205
|
};
|
|
205
|
-
|
|
206
|
+
// `/approve/<n>`: a release's opaque locator (a task ref number: no version, commit or branch), resolved by the console.
|
|
207
|
+
const RELEASE_LINK = /^\/approve\/[0-9]{1,15}$/;
|
|
208
|
+
const LINK_SHAPES = [/^\/next$/, /^\/review$/, /^\/system$/, /^\/routines$/, /^\/routines\/[0-9]{1,15}$/, /^\/d\/[0-9]{1,15}$/, /^\/r\/[0-9]{1,15}$/, RELEASE_LINK];
|
|
206
209
|
const CLASS_FALLBACK = {
|
|
207
210
|
decision: "/next",
|
|
208
211
|
pick: "/next",
|
|
209
212
|
merge: "/review",
|
|
210
213
|
attention: "/next",
|
|
211
214
|
progress: "/next",
|
|
215
|
+
release: "/next",
|
|
212
216
|
};
|
|
213
217
|
/** Static words and machine-minted numbers only — re-proved at send time. */
|
|
214
218
|
export function safePushLink(pushClass, link) {
|
|
219
|
+
// The release locator rides only the derived `release` class: a stored link of that shape on any other row is not one.
|
|
220
|
+
if (link !== null && RELEASE_LINK.test(link))
|
|
221
|
+
return pushClass === "release" ? link : CLASS_FALLBACK[pushClass];
|
|
215
222
|
if (link !== null && LINK_SHAPES.some(shape => shape.test(link)))
|
|
216
223
|
return link;
|
|
217
224
|
return CLASS_FALLBACK[pushClass];
|
|
218
225
|
}
|
|
219
|
-
|
|
220
|
-
|
|
226
|
+
/** `release`: the class and link the plane derived for this row at send time (releasePushOf); absent or null, the
|
|
227
|
+
* row's stored class and link, revalidated. */
|
|
228
|
+
export function buildPushPayload(notification, waiting = null, release = null) {
|
|
229
|
+
const pushClass = release !== null ? "release" : notification.pushClass;
|
|
221
230
|
const words = PUSH_WORDS[pushClass];
|
|
222
231
|
return JSON.stringify({
|
|
223
232
|
title: words.title,
|
|
224
233
|
body: pushClass === "progress" && isCheckProgressLine(notification.body) ? notification.body : words.body,
|
|
225
|
-
url: safePushLink(pushClass, notification.link),
|
|
234
|
+
url: safePushLink(pushClass, release !== null ? release.link : notification.link),
|
|
226
235
|
// The opaque tag collapses a crash-duplicate on the lock screen.
|
|
227
236
|
tag: pushClass === "progress" && notification.run !== null ? `so-run-${notification.run}` : `so-${notification.id}`,
|
|
228
237
|
// The app-icon badge (Phase 2E, v2 S4): the server-computed
|
|
@@ -268,6 +277,13 @@ export async function pushPass(store, options) {
|
|
|
268
277
|
if (fenced === null)
|
|
269
278
|
continue; // resolved, retired, rotated, or lost — nothing to send
|
|
270
279
|
const subscription = fenced.subscriptionRow;
|
|
280
|
+
// A release's "ready for your approval" that no longer stands (approved, superseded, put away) is resolved, never
|
|
281
|
+
// sent; one that stands is sent as `release`, its class and link derived from the plane's state now.
|
|
282
|
+
if (releaseReadyObsolete(store, fenced.notificationRow)) {
|
|
283
|
+
store.handle.prepare("UPDATE notification SET resolved_at = ? WHERE id = ? AND resolved_at IS NULL").run(clock().toISOString(), fenced.notificationRow.id);
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
const release = releasePushOf(store, fenced.notificationRow);
|
|
271
287
|
const endpoint = validatePushEndpoint(subscription.endpoint);
|
|
272
288
|
if (!endpoint.ok) {
|
|
273
289
|
store.retirePushSubscription(subscription.id, "invalid-endpoint", clock());
|
|
@@ -280,7 +296,7 @@ export async function pushPass(store, options) {
|
|
|
280
296
|
// The badge count rides every push: the same inbox classifier the
|
|
281
297
|
// console's own sidebar badge reads, unscoped (a phone has no
|
|
282
298
|
// project pane), computed at send time.
|
|
283
|
-
buildPushPayload(fenced.notificationRow, store.countInboxScoped(null, clock(), 100, null).count));
|
|
299
|
+
buildPushPayload(fenced.notificationRow, store.countInboxScoped(null, clock(), 100, null).count, release));
|
|
284
300
|
}
|
|
285
301
|
catch {
|
|
286
302
|
// Off-curve or malformed subscriber material: the device, not us.
|
package/dist/release-adapters.js
CHANGED
|
@@ -31,6 +31,8 @@ import { randomBytes, randomUUID } from "node:crypto";
|
|
|
31
31
|
import { deploymentGate } from "./deploy-gate.js";
|
|
32
32
|
import { failedBeforeTests } from "./github-jobs.js";
|
|
33
33
|
import { mainHistoryOf, nightlyBoundary } from "./nightly-github.js";
|
|
34
|
+
import { noteReleaseReady, noteReleaseStopped, recordReleaseCi } from "./release-approval.js";
|
|
35
|
+
import { recordVerifiedRelease } from "./release-card.js";
|
|
34
36
|
export { failedBeforeTests } from "./github-jobs.js";
|
|
35
37
|
import { extractCommit } from "./commit-extract.js";
|
|
36
38
|
import { teardownGit, refusal as gitRefusal } from "./teardown-git.js";
|
|
@@ -529,7 +531,7 @@ export function releaseAdapters(options) {
|
|
|
529
531
|
},
|
|
530
532
|
async complete(taskId, receipt) {
|
|
531
533
|
const answer = await toolroll("task", "complete", taskId, "--digest", receipt);
|
|
532
|
-
return answer["ok"] === true ? { ok: true } : { ok: false, message: refused(answer) };
|
|
534
|
+
return answer["ok"] === true ? { ok: true } : { ok: false, message: refused(answer), ...(typeof answer["reason"] === "string" ? { reason: answer["reason"] } : {}) };
|
|
533
535
|
},
|
|
534
536
|
async busy() {
|
|
535
537
|
const db = new (sqlite().DatabaseSync)(database, { readOnly: true });
|
|
@@ -971,6 +973,14 @@ nightlyReads) {
|
|
|
971
973
|
} };
|
|
972
974
|
return {
|
|
973
975
|
...(auto === undefined ? {} : { auto: autoReleaseAdapter(withStore, now, auto) }),
|
|
976
|
+
// One-tap release approval (trusted-release-reports.md, slice A): ids and the coordinator's own readings only; the
|
|
977
|
+
// plane decides from its own records whether anything is written.
|
|
978
|
+
ready: {
|
|
979
|
+
async asked(taskId) { withStore(store => { noteReleaseReady(store, { taskId, now: now() }); }); },
|
|
980
|
+
async ci(input) { withStore(store => { recordReleaseCi(store, { ...input, now: now() }); }); },
|
|
981
|
+
async stopped(input) { withStore(store => { noteReleaseStopped(store, { ...input, now: now() }); }); },
|
|
982
|
+
async verified(input) { withStore(store => { recordVerifiedRelease(store, { ...input, now: now() }); }); },
|
|
983
|
+
},
|
|
974
984
|
// After a failed release check, and before a pass is taken: the plane's own classification (check-failure.ts).
|
|
975
985
|
...(auto === undefined ? {} : {
|
|
976
986
|
async recheck(taskId) {
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { type PullRequestReader, type ReleaseCard, type ReleaseSeal, type TermLine } from "./release-card.js";
|
|
2
|
+
import { type ApproveResult, type Scope } from "./scope.js";
|
|
3
|
+
import { type Notification, type Store } from "./store.js";
|
|
4
|
+
/** The ledger act holding the coordinator's latest reading of the pull request's CI on the exact candidate. */
|
|
5
|
+
export declare const RELEASE_CI_SEEN_ACTION = "release CI observed";
|
|
6
|
+
/** One check's newest attempt, as the coordinator's own gh read it. A re-run is a new check run id. */
|
|
7
|
+
export type ReleaseCiRun = {
|
|
8
|
+
id: number | null;
|
|
9
|
+
name: string;
|
|
10
|
+
status: string;
|
|
11
|
+
conclusion: string | null;
|
|
12
|
+
};
|
|
13
|
+
export type ReleaseCiSnapshot = {
|
|
14
|
+
candidate: string;
|
|
15
|
+
at: string;
|
|
16
|
+
state: "passed" | "running" | "failed";
|
|
17
|
+
failed: string[];
|
|
18
|
+
runs: ReleaseCiRun[];
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* Record what the coordinator just read of the pull request's CI on `candidate` (its verdict and every check's newest
|
|
22
|
+
* attempt), when it differs from the last reading recorded: the sheet and the CLI show it, and the card digest binds
|
|
23
|
+
* it. The coordinator is Toolroll's own runtime reading GitHub through the plane's recorded repository.
|
|
24
|
+
*/
|
|
25
|
+
export declare function recordReleaseCi(store: Store, input: {
|
|
26
|
+
taskId: string;
|
|
27
|
+
candidate: string;
|
|
28
|
+
state: ReleaseCiSnapshot["state"];
|
|
29
|
+
failed: readonly string[];
|
|
30
|
+
runs: readonly {
|
|
31
|
+
id?: number | null;
|
|
32
|
+
name: string;
|
|
33
|
+
status: string;
|
|
34
|
+
conclusion: string | null;
|
|
35
|
+
}[];
|
|
36
|
+
now: Date;
|
|
37
|
+
}): boolean;
|
|
38
|
+
/** The coordinator's latest CI reading for this task on exactly `candidate`, or null (not read yet, or another commit). */
|
|
39
|
+
export declare function releaseCiOf(store: Store, taskId: string, candidate: string): ReleaseCiSnapshot | null;
|
|
40
|
+
/** What of a CI reading the card binds: its state and each check's attempt, whether it finished, and how. Queued and
|
|
41
|
+
* in progress read alike (neither is finished); the time it was read is not bound. */
|
|
42
|
+
export declare function ciBinding(ci: ReleaseCiSnapshot | null): unknown;
|
|
43
|
+
/** GitHub CI in one line, as the sheet and the CLI say it. */
|
|
44
|
+
export declare function ciWords(ci: ReleaseCiSnapshot | null, candidate: string): string;
|
|
45
|
+
export type ReleaseApprovalCard = {
|
|
46
|
+
taskId: string;
|
|
47
|
+
taskRef: number;
|
|
48
|
+
repo: string;
|
|
49
|
+
scope: Scope;
|
|
50
|
+
seal: ReleaseSeal;
|
|
51
|
+
/** The chat release card (release-card.ts): the change list and its own digest. */
|
|
52
|
+
card: ReleaseCard;
|
|
53
|
+
/** Every signed term, one line each (release-card.ts approvalTermsOf). */
|
|
54
|
+
terms: TermLine[];
|
|
55
|
+
ci: ReleaseCiSnapshot | null;
|
|
56
|
+
/** Why it can't be approved now, in words; null when it can (or already is). */
|
|
57
|
+
refusal: string | null;
|
|
58
|
+
approved: {
|
|
59
|
+
by: string;
|
|
60
|
+
at: string;
|
|
61
|
+
} | null;
|
|
62
|
+
/** The release coordinator stopped (its last step failed): approving needs `toolroll release` run again. */
|
|
63
|
+
stopped: {
|
|
64
|
+
step: string;
|
|
65
|
+
message: string;
|
|
66
|
+
} | null;
|
|
67
|
+
/** Binds everything above that a person is shown and can change under them. */
|
|
68
|
+
digest: string;
|
|
69
|
+
};
|
|
70
|
+
/** A release task from the plane's own state: a sealed form (terms or a release check's purpose) whose repository is the
|
|
71
|
+
* task's project and whose approved commit is the scope's candidate. Never from a title or a task id. */
|
|
72
|
+
export declare function releaseTaskOf(store: Store, taskId: string): {
|
|
73
|
+
ref: {
|
|
74
|
+
id: number;
|
|
75
|
+
repo: string;
|
|
76
|
+
};
|
|
77
|
+
scope: Scope;
|
|
78
|
+
seal: ReleaseSeal;
|
|
79
|
+
} | null;
|
|
80
|
+
/** The authoritative card for a release task waiting for its person, or why there is none. Reads only. */
|
|
81
|
+
export declare function releaseApprovalCard(store: Store, taskId: string, options?: {
|
|
82
|
+
read?: PullRequestReader;
|
|
83
|
+
}): {
|
|
84
|
+
ok: true;
|
|
85
|
+
card: ReleaseApprovalCard;
|
|
86
|
+
} | {
|
|
87
|
+
ok: false;
|
|
88
|
+
why: string;
|
|
89
|
+
};
|
|
90
|
+
/** What approving does, in the sheet's and the CLI's words: the consequence, the check's access, and whether later
|
|
91
|
+
* commits carry. */
|
|
92
|
+
export declare function releaseConsequence(card: ReleaseApprovalCard): string[];
|
|
93
|
+
export type ReleaseCardRefusal = {
|
|
94
|
+
ok: false;
|
|
95
|
+
reason: "not-release" | "changed" | "approved" | "refused";
|
|
96
|
+
message: string;
|
|
97
|
+
} | {
|
|
98
|
+
ok: false;
|
|
99
|
+
reason: "approve";
|
|
100
|
+
result: Exclude<ApproveResult, {
|
|
101
|
+
ok: true;
|
|
102
|
+
}>;
|
|
103
|
+
};
|
|
104
|
+
/**
|
|
105
|
+
* The one card-approval wrapper (Codex round 2, finding 2): rebuild the authoritative card and compare the digest the
|
|
106
|
+
* person saw; then, in one transaction, re-read the store's parts (the scope digest, the CI reading, the refusal state)
|
|
107
|
+
* and refuse on any change; then hand THAT snapshot's scope digest to `approveNow`, the caller's unchanged
|
|
108
|
+
* transactional `approve()`, which proves the person and seals. Nothing here proves anyone.
|
|
109
|
+
*/
|
|
110
|
+
export declare function approveReleaseCard(store: Store, input: {
|
|
111
|
+
taskId: string;
|
|
112
|
+
cardDigest: string;
|
|
113
|
+
read?: PullRequestReader;
|
|
114
|
+
approveNow: (scopeDigest: string) => ApproveResult;
|
|
115
|
+
}): {
|
|
116
|
+
ok: true;
|
|
117
|
+
scope: Scope;
|
|
118
|
+
} | ReleaseCardRefusal;
|
|
119
|
+
export declare const RELEASE_READY_KIND = "release-ready";
|
|
120
|
+
export declare const RELEASE_STOPPED_KIND = "release-stopped";
|
|
121
|
+
/** The one push phrase for a release waiting on its person (push.ts PUSH_WORDS.release). */
|
|
122
|
+
export declare const RELEASE_PUSH_WORDS = "A release is ready for your approval";
|
|
123
|
+
/** The sheet's own address for a task (the in-app link; a push carries the opaque `/approve/<task ref>` instead). */
|
|
124
|
+
export declare const releaseSheetHref: (taskId: string) => string;
|
|
125
|
+
/**
|
|
126
|
+
* The coordinator asked a person (on entering the approval step, a fresh filing or a resumed one): one notification per
|
|
127
|
+
* task and stable scope digest, so a crash between filing and asking is healed by the next run and a repeat says nothing
|
|
128
|
+
* new. Every older `release-ready` row of the project (another task or digest) is resolved first. Nothing when the task
|
|
129
|
+
* isn't a release waiting for a person.
|
|
130
|
+
*/
|
|
131
|
+
export declare function noteReleaseReady(store: Store, input: {
|
|
132
|
+
taskId: string;
|
|
133
|
+
now: Date;
|
|
134
|
+
}): boolean;
|
|
135
|
+
/** Resolve a task's `release-ready` rows (it was approved, or the release stopped): a push not sent yet never is. */
|
|
136
|
+
export declare function retireReleaseReady(store: Store, taskRef: number, now: Date): number;
|
|
137
|
+
/**
|
|
138
|
+
* The push class `release` and its link, derived at send time from the plane's own state, never from a title, a task id,
|
|
139
|
+
* the stored link or the stored class: the row is a `release-ready` row for exactly its task ref and a scope digest, and
|
|
140
|
+
* that task is a release scope (terms or a release check's purpose, its candidate the approved commit), not put away,
|
|
141
|
+
* not approved, at that same digest. Null otherwise.
|
|
142
|
+
*/
|
|
143
|
+
export declare function releasePushOf(store: Store, notification: Pick<Notification, "kind" | "dedupeKey" | "taskRef" | "resolvedAt">): {
|
|
144
|
+
link: string;
|
|
145
|
+
} | null;
|
|
146
|
+
/** A `release-ready` row that no longer stands (the push pass resolves it instead of sending it). */
|
|
147
|
+
export declare function releaseReadyObsolete(store: Store, notification: Pick<Notification, "kind" | "dedupeKey" | "taskRef" | "resolvedAt">): boolean;
|
|
148
|
+
/**
|
|
149
|
+
* A person's release stopped (CI failed, the check failed, the deploy, the merge, the tag or publication failed, or it
|
|
150
|
+
* stopped waiting): one attention notification per task, step and reason, with the stop's words in the app (the push
|
|
151
|
+
* says only the fixed attention phrase). Its `release-ready` row is resolved, except while it only stopped waiting
|
|
152
|
+
* for the approval (the sheet still approves it then).
|
|
153
|
+
*/
|
|
154
|
+
export declare function noteReleaseStopped(store: Store, input: {
|
|
155
|
+
taskId: string;
|
|
156
|
+
step: string;
|
|
157
|
+
reason: string;
|
|
158
|
+
message: string;
|
|
159
|
+
now: Date;
|
|
160
|
+
}): boolean;
|
|
161
|
+
/** The card as the CLI prints it, every line control-free (src/terminal-text.ts). */
|
|
162
|
+
export declare function releaseCardLines(card: ReleaseApprovalCard): string[];
|
|
163
|
+
/** One release journal as discovery reads it: strictly, every field it relies on in its exact shape. */
|
|
164
|
+
export type WaitingJournal = {
|
|
165
|
+
file: string;
|
|
166
|
+
branch: string;
|
|
167
|
+
checkout: string;
|
|
168
|
+
taskId: string;
|
|
169
|
+
scopeDigest: string;
|
|
170
|
+
candidate: string;
|
|
171
|
+
version: string;
|
|
172
|
+
pr: number;
|
|
173
|
+
};
|
|
174
|
+
/** A journal's own fields, strictly; null when any is missing or not its exact shape. */
|
|
175
|
+
export declare function readWaitingJournal(file: string, raw: unknown, expectedFile: (branch: string) => string): WaitingJournal | null;
|
|
176
|
+
/**
|
|
177
|
+
* Whether a journal (discovery only) names exactly the release the plane sealed: the task is a release scope whose
|
|
178
|
+
* sealed identity has this exact branch, version, pull request and candidate at this scope digest, and the journal's
|
|
179
|
+
* checkout, the task's project and the sealed repository are one real folder (`repo` too, when given). Why not, in words.
|
|
180
|
+
*/
|
|
181
|
+
export declare function journalMatchesPlane(store: Store, journal: WaitingJournal, repo: string | null): {
|
|
182
|
+
ok: true;
|
|
183
|
+
} | {
|
|
184
|
+
ok: false;
|
|
185
|
+
why: string;
|
|
186
|
+
};
|
|
187
|
+
/** Every release journal in the state folder, read strictly; unreadable ones are counted, never guessed at. */
|
|
188
|
+
export declare function waitingJournals(stateDir: string, expectedFile: (branch: string) => string): {
|
|
189
|
+
journals: WaitingJournal[];
|
|
190
|
+
unreadable: string[];
|
|
191
|
+
};
|