toolroll 0.9.71 → 0.9.72
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 +79 -0
- package/dist/agent-fence.d.ts +5 -2
- package/dist/agent-fence.js +5 -2
- package/dist/approval-authority.d.ts +20 -0
- package/dist/approval-authority.js +67 -0
- package/dist/assignment.js +26 -4
- package/dist/browser/workspace.css +1 -1
- package/dist/builder.js +48 -6
- package/dist/chat-decide.js +17 -2
- package/dist/check-access.js +2 -2
- package/dist/check-report.d.ts +109 -0
- package/dist/check-report.js +204 -0
- package/dist/claim.js +1 -1
- package/dist/commit-extract.d.ts +94 -0
- package/dist/commit-extract.js +436 -0
- package/dist/contracts/console-api.d.ts +1 -1
- package/dist/contracts/console-api.js +1 -0
- package/dist/contracts/flow.d.ts +16 -0
- package/dist/contracts/flow.js +3 -0
- package/dist/contracts/release-check.d.ts +335 -0
- package/dist/contracts/release-check.js +159 -0
- package/dist/contracts/release-terms.d.ts +60 -0
- package/dist/contracts/release-terms.js +70 -0
- package/dist/contracts/verification-receipt.d.ts +42 -8
- package/dist/contracts/verification-receipt.js +24 -7
- package/dist/contracts/verification-view.d.ts +1 -0
- package/dist/deploy-gate.d.ts +17 -0
- package/dist/deploy-gate.js +68 -0
- package/dist/dispatch.js +3 -10
- package/dist/dispose.js +4 -1
- package/dist/evidence-pack.d.ts +1 -1
- package/dist/evidence-pack.js +20 -2
- package/dist/evidence.d.ts +1 -0
- package/dist/evidence.js +2 -1
- package/dist/flow-engine.js +5 -1
- package/dist/flow-pull-request.d.ts +5 -2
- package/dist/flow-pull-request.js +14 -5
- package/dist/flow-schedule.js +25 -6
- package/dist/flows-ui.js +7 -1
- package/dist/invoke.d.ts +17 -6
- package/dist/invoke.js +48 -18
- package/dist/lead-status.js +5 -2
- package/dist/live-terminal.d.ts +10 -6
- package/dist/live-terminal.js +55 -14
- package/dist/nightly-check.d.ts +104 -0
- package/dist/nightly-check.js +362 -0
- package/dist/operate.js +39 -2
- package/dist/person-proof.d.ts +2 -0
- package/dist/person-proof.js +14 -0
- package/dist/plan-auto.js +4 -4
- package/dist/planner-source.d.ts +1 -1
- package/dist/project-delete-ui.js +1 -1
- package/dist/proof.js +2 -0
- package/dist/publish.js +156 -144
- package/dist/pull-request-flow.d.ts +2 -0
- package/dist/pull-request-flow.js +24 -2
- package/dist/release-adapters.d.ts +8 -0
- package/dist/release-adapters.js +212 -15
- package/dist/release-authority.d.ts +112 -0
- package/dist/release-authority.js +126 -0
- package/dist/release-carry.d.ts +233 -0
- package/dist/release-carry.js +645 -0
- package/dist/release-cli.d.ts +3 -0
- package/dist/release-cli.js +124 -5
- package/dist/release-coverage.js +3 -0
- package/dist/release-recheck.d.ts +146 -0
- package/dist/release-recheck.js +657 -0
- package/dist/release-view.d.ts +34 -0
- package/dist/release-view.js +27 -0
- package/dist/release.d.ts +131 -1
- package/dist/release.js +562 -244
- package/dist/remote-main.d.ts +2 -0
- package/dist/remote-main.js +1 -1
- package/dist/result-completion.d.ts +6 -0
- package/dist/result-completion.js +15 -1
- package/dist/result-follow-ups.js +29 -1
- package/dist/sandbox-runtime.d.ts +16 -5
- package/dist/sandbox-runtime.js +19 -6
- package/dist/scope.d.ts +115 -5
- package/dist/scope.js +219 -7
- package/dist/server/render-tasks.d.ts +8 -0
- package/dist/server/render-tasks.js +50 -9
- package/dist/server/route-table.js +1 -0
- package/dist/server/task-views.js +4 -0
- package/dist/server/tasks.js +12 -0
- package/dist/store.d.ts +227 -5
- package/dist/store.js +727 -37
- package/dist/task-outcome-cli.js +7 -3
- package/dist/toolroll-update.js +2 -2
- package/dist/verification-evidence.d.ts +25 -1
- package/dist/verification-evidence.js +57 -2
- package/dist/work-index.js +3 -6
- package/package.json +1 -1
package/dist/store.js
CHANGED
|
@@ -57,9 +57,10 @@ import { hashSecret, mintLeadToken, parseCredential, secretMatches, tokenPurpose
|
|
|
57
57
|
* are deferred until after M4, and a column that exists to serve a feature
|
|
58
58
|
* nobody has designed yet is a column that will be wrong when they do.
|
|
59
59
|
*/
|
|
60
|
-
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
60
|
+
import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
61
61
|
import { existsSync, mkdirSync, statSync, unlinkSync } from "node:fs";
|
|
62
62
|
import { createRequire } from "node:module";
|
|
63
|
+
import { execFileSync } from "node:child_process";
|
|
63
64
|
import { dirname } from "node:path";
|
|
64
65
|
import { hostname } from "node:os";
|
|
65
66
|
import { ownedProcessCount, runOwnerTag } from "./exec.js";
|
|
@@ -70,7 +71,8 @@ import { containerEmptiness } from "./container-state.js";
|
|
|
70
71
|
import { hasDisguisedText, hasForbiddenControls, validateNote } from "./decision.js";
|
|
71
72
|
import { parseReviewContext, reviewContextCustodyProblem } from "./review-context.js";
|
|
72
73
|
import { foldReview, manualReviewOnly } from "./proof.js";
|
|
73
|
-
import {
|
|
74
|
+
import { liveReleaseOf } from "./scope.js";
|
|
75
|
+
import { APPROVAL_BASES, approvalOf, machineActing, personProof, proofDetailOf, quarantinedAuthor, sealOriginOf, digestOf, canonicalProfileJson, chainFromJson, profileDigestOf, profileFromJson, scopeAuthorityOf, routeParityProblem, parseAcceptanceCriteria, exactAcceptance, exactStringList, exactSafeIntegerOrNull } from "./scope.js";
|
|
74
76
|
import { resolveScopeProfile, resolveRouteCandidates, exactPinOf, routeOfTask, agentChoicesFor } from "./agentconfig.js";
|
|
75
77
|
import { canonicalOverridesJson, canonicalRouteJson, isRiskLevel, isTaskSize, headroomFrom, makesNoPlan, legOf, overridesFromJson, phaseOfRole, recommendRoute, routeDigestOf, routeFromJson, routeProblems, routeStampProblem, ROUTE_ERA, PROVIDER_ID_LIST, } from "./phase-routing.js";
|
|
76
78
|
import { readAuthModeStrict } from "./keys.js";
|
|
@@ -80,6 +82,8 @@ import {} from "./provider.js";
|
|
|
80
82
|
import { authenticate as runnerAuthenticate } from "./runner.js";
|
|
81
83
|
import { stampNewProjectLevel } from "./check-levels.js";
|
|
82
84
|
import { parseCheckProgressSnapshot } from "./check-progress.js";
|
|
85
|
+
import { authorityStandsSql } from "./approval-authority.js";
|
|
86
|
+
import { releasePolicyDigest, storedReleaseTerms, readReleaseTerms, readReleasePolicy } from "./contracts/release-terms.js";
|
|
83
87
|
import { accountReachesSql, normalizeProjectAccess, projectAccessAllows, readProjectAccess } from "./project-access.js";
|
|
84
88
|
import { LEDGER_COLUMNS, LEDGER_SCHEMA, LEDGER_TABLE, LEDGER_V99_TABLE, installLedgerTriggers } from "./action-ledger.js";
|
|
85
89
|
import { PLAN_AUTO_SCHEMA } from "./plan-auto.js";
|
|
@@ -725,7 +729,15 @@ const REGISTERED_AT_FILING = "again, as it was filed";
|
|
|
725
729
|
// v133 adds, and changes no saved row: a project's sandbox settings (project_sandbox; project-sandbox.ts,
|
|
726
730
|
// docs/plans/sandbox-parity.md C9) — what its builds may not read, the macOS services and protected files they may — and
|
|
727
731
|
// the fence policy a flow was made under (flow.fence_policy; saved flows read NULL: v1, the home configuration denied).
|
|
728
|
-
|
|
732
|
+
// v134 adds, and changes no saved row: who gave each yes (docs/plans/approval-policy.md) — a protected-work vote's proof
|
|
733
|
+
// (scope_approval_vote.proof) and a flow decision's authority (flow_event.authority), each NULL on saved rows: no proof
|
|
734
|
+
// on record. Saved approvals a machine credential gave are read for what they are (approval-authority.ts), not rewritten.
|
|
735
|
+
// v135 adds, and changes no saved row: one approval per release (docs/plans/release-once.md) — a release's authority
|
|
736
|
+
// record, its carried commits and its deploy and merge operations (release_authority, release_carry,
|
|
737
|
+
// release_operation), each release check attempt's intended mode, sealed before it runs (release_check_attempt), the
|
|
738
|
+
// nightly full check on main and its open failures (nightly_check, main_check_failure), and a scope's release terms
|
|
739
|
+
// (task_scope.release_terms_json; saved rows read NULL: no release terms).
|
|
740
|
+
export const SCHEMA_VERSION = 135;
|
|
729
741
|
/** v118: the tables merged into `credential`, in the order they move (a person's API tokens keep their ids first). */
|
|
730
742
|
export const V118_MERGED_TABLES = Object.freeze({ api_token: "person", coordinator_credential: "coordinator", lead_credential: "lead" });
|
|
731
743
|
/** The foreign keys that named a merged table, exactly as their DDL wrote them; each names `credential(id)` instead. */
|
|
@@ -858,7 +870,7 @@ const V115_DROPPED_REFERENCES = /\s+REFERENCES (?:contestant|contest|tournament_
|
|
|
858
870
|
* unchanged), and adds the nullable column in V125_ADDED_COLUMNS, checked the same way. v126 only adds a new table
|
|
859
871
|
* (task_source) and the columns in V126_ADDED_COLUMNS (null or 0), checked the same way.
|
|
860
872
|
*/
|
|
861
|
-
export const UPDATE_SAFE_MIGRATIONS = Object.freeze([108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 133]);
|
|
873
|
+
export const UPDATE_SAFE_MIGRATIONS = Object.freeze([108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 133, 134, 135]);
|
|
862
874
|
/** v119: the columns it adds, by table, with the value every row saved before it reads (toolroll-update's HISTORY_RULES). */
|
|
863
875
|
export const V119_ADDED_COLUMNS = Object.freeze({
|
|
864
876
|
task_ref: { planning_generation: { definition: "INTEGER NOT NULL DEFAULT 0", saved: "0" } },
|
|
@@ -969,6 +981,138 @@ export const V129_ADDED_COLUMNS = Object.freeze({
|
|
|
969
981
|
cache_write_tokens: { definition: "INTEGER CHECK (cache_write_tokens IS NULL OR cache_write_tokens >= 0)", saved: "NULL" },
|
|
970
982
|
},
|
|
971
983
|
});
|
|
984
|
+
/** v134 (approval policy): how each protected-work vote was proved, and who decided a flow card at a decision zone.
|
|
985
|
+
* Saved rows read NULL: no proof or authority on record, which never counts as a person. */
|
|
986
|
+
export const V134_ADDED_COLUMNS = Object.freeze({
|
|
987
|
+
scope_approval_vote: { proof: { definition: "TEXT", saved: "NULL" } },
|
|
988
|
+
flow_event: { authority: { definition: "TEXT", saved: "NULL" } },
|
|
989
|
+
});
|
|
990
|
+
/** v135 (docs/plans/release-once.md): a scope's release terms, part of its digest when present. Saved rows read NULL. */
|
|
991
|
+
export const V135_ADDED_COLUMNS = Object.freeze({
|
|
992
|
+
task_scope: { release_terms_json: { definition: "TEXT", saved: "NULL" } },
|
|
993
|
+
});
|
|
994
|
+
/**
|
|
995
|
+
* v135: one approval per release (docs/plans/release-once.md). `release_authority` is the single source of truth for a
|
|
996
|
+
* release a person approved: one row per generation, changed only by compare-and-swap on `rev`, with a lease whose
|
|
997
|
+
* epoch goes up on every takeover. `release_carry` records each later commit carried under it, `release_operation` each
|
|
998
|
+
* deploy or merge that can't be undone. `release_check_attempt` seals a check's intended mode, inventory and context
|
|
999
|
+
* before it runs. `main_check_failure` keeps one row per item the nightly full check on main found failing, until a
|
|
1000
|
+
* qualifying full pass clears that exact item; `nightly_check` records each nightly firing.
|
|
1001
|
+
*/
|
|
1002
|
+
export const RELEASE_ONCE_SCHEMA = `
|
|
1003
|
+
CREATE TABLE IF NOT EXISTS release_authority (
|
|
1004
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1005
|
+
repo TEXT NOT NULL,
|
|
1006
|
+
generation INTEGER NOT NULL,
|
|
1007
|
+
previous INTEGER REFERENCES release_authority(id),
|
|
1008
|
+
version TEXT NOT NULL,
|
|
1009
|
+
pr INTEGER NOT NULL,
|
|
1010
|
+
branch TEXT NOT NULL,
|
|
1011
|
+
base TEXT NOT NULL,
|
|
1012
|
+
anchor_task TEXT NOT NULL,
|
|
1013
|
+
anchor_digest TEXT NOT NULL,
|
|
1014
|
+
approved_sha TEXT NOT NULL,
|
|
1015
|
+
approved_tree TEXT NOT NULL,
|
|
1016
|
+
artifact_digest TEXT,
|
|
1017
|
+
tip_sha TEXT NOT NULL,
|
|
1018
|
+
tip_task TEXT NOT NULL,
|
|
1019
|
+
policy_json TEXT NOT NULL,
|
|
1020
|
+
policy_digest TEXT NOT NULL,
|
|
1021
|
+
verify_command INTEGER NOT NULL,
|
|
1022
|
+
verify_digest TEXT NOT NULL,
|
|
1023
|
+
access_digest TEXT NOT NULL,
|
|
1024
|
+
state TEXT NOT NULL CHECK (state IN ('open','deploying','merge-reserved','merged','revoked','expired','superseded')),
|
|
1025
|
+
rev INTEGER NOT NULL DEFAULT 0,
|
|
1026
|
+
lease_owner TEXT,
|
|
1027
|
+
lease_epoch INTEGER NOT NULL DEFAULT 0,
|
|
1028
|
+
lease_expires TEXT,
|
|
1029
|
+
expires_at TEXT NOT NULL,
|
|
1030
|
+
created_at TEXT NOT NULL,
|
|
1031
|
+
ended_at TEXT,
|
|
1032
|
+
ended_by TEXT,
|
|
1033
|
+
UNIQUE (repo, version, pr, generation)
|
|
1034
|
+
);
|
|
1035
|
+
CREATE UNIQUE INDEX IF NOT EXISTS release_authority_live ON release_authority (repo, version, pr)
|
|
1036
|
+
WHERE state IN ('open','deploying','merge-reserved');
|
|
1037
|
+
CREATE INDEX IF NOT EXISTS release_authority_anchor ON release_authority (anchor_task);
|
|
1038
|
+
CREATE TABLE IF NOT EXISTS release_carry (
|
|
1039
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1040
|
+
authority INTEGER NOT NULL REFERENCES release_authority(id),
|
|
1041
|
+
task_id TEXT NOT NULL UNIQUE,
|
|
1042
|
+
scope_digest TEXT NOT NULL,
|
|
1043
|
+
request_id TEXT NOT NULL,
|
|
1044
|
+
from_sha TEXT NOT NULL,
|
|
1045
|
+
to_sha TEXT NOT NULL,
|
|
1046
|
+
to_tree TEXT NOT NULL,
|
|
1047
|
+
artifact_digest TEXT NOT NULL,
|
|
1048
|
+
commits_json TEXT NOT NULL,
|
|
1049
|
+
changed_lines INTEGER NOT NULL,
|
|
1050
|
+
files INTEGER NOT NULL,
|
|
1051
|
+
churn INTEGER NOT NULL,
|
|
1052
|
+
runtime TEXT NOT NULL,
|
|
1053
|
+
proved_at TEXT NOT NULL,
|
|
1054
|
+
UNIQUE (authority, to_sha)
|
|
1055
|
+
);
|
|
1056
|
+
CREATE TABLE IF NOT EXISTS release_operation (
|
|
1057
|
+
id TEXT PRIMARY KEY,
|
|
1058
|
+
authority INTEGER NOT NULL REFERENCES release_authority(id),
|
|
1059
|
+
kind TEXT NOT NULL CHECK (kind IN ('deploy','merge')),
|
|
1060
|
+
task_id TEXT NOT NULL,
|
|
1061
|
+
candidate TEXT NOT NULL,
|
|
1062
|
+
epoch INTEGER NOT NULL,
|
|
1063
|
+
state TEXT NOT NULL CHECK (state IN ('reserved','running','succeeded','failed','unknown')),
|
|
1064
|
+
external_ref TEXT,
|
|
1065
|
+
-- The sha256 of a secret only the reserving coordinator holds: a deploy run adopting this operation proves it.
|
|
1066
|
+
secret_hash TEXT,
|
|
1067
|
+
-- 1: reserved by a person's own deploy run with no coordinator holding the release (its epoch is the one it found).
|
|
1068
|
+
direct INTEGER NOT NULL DEFAULT 0,
|
|
1069
|
+
-- The process that reserved, then issued, the call: its pid, its start time and when it was recorded (real time).
|
|
1070
|
+
-- Recovery settles a started operation only once that process is proven gone (review round 2).
|
|
1071
|
+
issuer_pid INTEGER,
|
|
1072
|
+
issuer_started TEXT,
|
|
1073
|
+
issued_at TEXT,
|
|
1074
|
+
-- A person's direct deploy: the staging folder its deployment journals in.
|
|
1075
|
+
stage TEXT,
|
|
1076
|
+
reserved_at TEXT NOT NULL,
|
|
1077
|
+
finished_at TEXT,
|
|
1078
|
+
detail TEXT
|
|
1079
|
+
);
|
|
1080
|
+
CREATE INDEX IF NOT EXISTS release_operation_open ON release_operation (authority, state);
|
|
1081
|
+
CREATE TABLE IF NOT EXISTS release_check_attempt (
|
|
1082
|
+
run INTEGER PRIMARY KEY REFERENCES run(id),
|
|
1083
|
+
authority INTEGER REFERENCES release_authority(id),
|
|
1084
|
+
mode TEXT NOT NULL CHECK (mode IN ('full','targeted')),
|
|
1085
|
+
base_run INTEGER REFERENCES run(id),
|
|
1086
|
+
plan_digest TEXT,
|
|
1087
|
+
snapshot_digest TEXT,
|
|
1088
|
+
inventory_digest TEXT NOT NULL,
|
|
1089
|
+
context_digest TEXT NOT NULL,
|
|
1090
|
+
nonce_hash TEXT NOT NULL,
|
|
1091
|
+
created_at TEXT NOT NULL,
|
|
1092
|
+
settled_at TEXT
|
|
1093
|
+
);
|
|
1094
|
+
CREATE TABLE IF NOT EXISTS main_check_failure (
|
|
1095
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1096
|
+
repo TEXT NOT NULL,
|
|
1097
|
+
sha TEXT NOT NULL,
|
|
1098
|
+
run INTEGER NOT NULL,
|
|
1099
|
+
item TEXT NOT NULL,
|
|
1100
|
+
opened_at TEXT NOT NULL,
|
|
1101
|
+
cleared_by_run INTEGER,
|
|
1102
|
+
cleared_at TEXT,
|
|
1103
|
+
UNIQUE (repo, run, item)
|
|
1104
|
+
);
|
|
1105
|
+
CREATE INDEX IF NOT EXISTS main_check_failure_open ON main_check_failure (repo) WHERE cleared_at IS NULL;
|
|
1106
|
+
CREATE TABLE IF NOT EXISTS nightly_check (
|
|
1107
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1108
|
+
repo TEXT NOT NULL,
|
|
1109
|
+
sha TEXT NOT NULL,
|
|
1110
|
+
task TEXT,
|
|
1111
|
+
run INTEGER,
|
|
1112
|
+
outcome TEXT,
|
|
1113
|
+
at TEXT NOT NULL
|
|
1114
|
+
);
|
|
1115
|
+
`;
|
|
972
1116
|
/** v130: a project's network (an approver's setting, egress.ts ProjectNetwork as JSON) and the installation's choice
|
|
973
1117
|
* whether an agent may run where no OS confinement exists (one row; 0 until an instance operator turns it on). */
|
|
974
1118
|
export const CONFINEMENT_SCHEMA = `
|
|
@@ -4064,6 +4208,17 @@ CREATE INDEX IF NOT EXISTS run_checkpoint_by_task ON run_checkpoint (task_ref, i
|
|
|
4064
4208
|
convertChatTables(db);
|
|
4065
4209
|
// v118: every machine credential into the one credential table, after every older step gave the old tables their last shape.
|
|
4066
4210
|
mergeCredentialsForV118(db);
|
|
4211
|
+
// v134: a vote's proof and a flow decision's authority (V134_ADDED_COLUMNS: saved rows read NULL, no proof on record).
|
|
4212
|
+
for (const [table, columns] of Object.entries(V134_ADDED_COLUMNS)) {
|
|
4213
|
+
for (const [column, { definition }] of Object.entries(columns))
|
|
4214
|
+
addColumn(db, table, column, definition);
|
|
4215
|
+
}
|
|
4216
|
+
// v135: one approval per release (new tables only) and a scope's release terms (V135_ADDED_COLUMNS: saved rows NULL).
|
|
4217
|
+
db.exec(RELEASE_ONCE_SCHEMA);
|
|
4218
|
+
for (const [table, columns] of Object.entries(V135_ADDED_COLUMNS)) {
|
|
4219
|
+
for (const [column, { definition }] of Object.entries(columns))
|
|
4220
|
+
addColumn(db, table, column, definition);
|
|
4221
|
+
}
|
|
4067
4222
|
// Keep the epoch until reclamation succeeds, including a retry after compaction already committed. A new file has
|
|
4068
4223
|
// nothing to reclaim.
|
|
4069
4224
|
if (!fresh)
|
|
@@ -6492,6 +6647,8 @@ export class Store {
|
|
|
6492
6647
|
// ---- scope --------------------------------------------------------------
|
|
6493
6648
|
saveScope(scope, mutation = {}, options = {}) {
|
|
6494
6649
|
this.once(mutation, "saveScope", () => this.transact(() => {
|
|
6650
|
+
// The text's current author mark, read before this write (approval policy §1.4).
|
|
6651
|
+
const prior = this.db.prepare("SELECT proposed_via, release_terms_json FROM task_scope WHERE task_id = ?").get(scope.taskId);
|
|
6495
6652
|
// THE filing invariant (foundations findings 5/13/19): every scope
|
|
6496
6653
|
// row leaves this method either RESOLVED (working profile stamped,
|
|
6497
6654
|
// digest recomputed to bind it, version 2) or UNRESOLVED with its
|
|
@@ -6652,6 +6809,9 @@ export class Store {
|
|
|
6652
6809
|
const lowered = (one) => { const verdict = underCeiling(orgPolicy, one); return verdict.ok ? verdict.profile : one; };
|
|
6653
6810
|
if (profile !== null)
|
|
6654
6811
|
profile = lowered(profile);
|
|
6812
|
+
// v135: release terms the caller names, else the ones this scope already carries (a refile keeps them bound).
|
|
6813
|
+
const priorTerms = storedReleaseTerms(prior?.["release_terms_json"]);
|
|
6814
|
+
const releaseTerms = options.releaseTerms !== undefined ? options.releaseTerms : priorTerms.ok ? priorTerms.terms : null;
|
|
6655
6815
|
const digestInput = {
|
|
6656
6816
|
goal: scope.goal,
|
|
6657
6817
|
outOfScope: scope.outOfScope,
|
|
@@ -6660,6 +6820,7 @@ export class Store {
|
|
|
6660
6820
|
acceptance: scope.acceptance,
|
|
6661
6821
|
qualityMode,
|
|
6662
6822
|
candidate: scope.candidate ?? null,
|
|
6823
|
+
releaseTerms,
|
|
6663
6824
|
};
|
|
6664
6825
|
// EVERY route filed since v47 binds into the digest — routine-shaped
|
|
6665
6826
|
// ones included — so approval freezes exactly which agent runs each
|
|
@@ -6689,8 +6850,8 @@ export class Store {
|
|
|
6689
6850
|
.prepare(`INSERT INTO task_scope
|
|
6690
6851
|
(task_id, goal, out_of_scope, touches, budget_microusd, proposed_at, digest, approved_at, approved_by, approved_digest,
|
|
6691
6852
|
profile_json, profile_state, unresolved_reason, digest_version, profile_provenance, proposed_chain_json, acceptance_json, quality_mode,
|
|
6692
|
-
risk_level, proposed_route_json, route_era, candidate)
|
|
6693
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
6853
|
+
risk_level, proposed_route_json, route_era, candidate, release_terms_json)
|
|
6854
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
6694
6855
|
ON CONFLICT (task_id) DO UPDATE SET
|
|
6695
6856
|
goal = excluded.goal, out_of_scope = excluded.out_of_scope,
|
|
6696
6857
|
touches = excluded.touches, budget_microusd = excluded.budget_microusd,
|
|
@@ -6702,7 +6863,7 @@ export class Store {
|
|
|
6702
6863
|
profile_provenance = excluded.profile_provenance, proposed_chain_json = excluded.proposed_chain_json,
|
|
6703
6864
|
acceptance_json = excluded.acceptance_json, quality_mode = excluded.quality_mode,
|
|
6704
6865
|
risk_level = excluded.risk_level, proposed_route_json = excluded.proposed_route_json, route_era = excluded.route_era,
|
|
6705
|
-
candidate = excluded.candidate`)
|
|
6866
|
+
candidate = excluded.candidate, release_terms_json = excluded.release_terms_json`)
|
|
6706
6867
|
.run(scope.taskId, scope.goal, scope.outOfScope, JSON.stringify(scope.touches), scope.budgetMicrousd ?? null, scope.proposedAt, boundDigest, scope.approvedAt, scope.approvedBy, scope.approvedDigest, profile === null ? null : canonicalProfileJson(profile), profile === null ? "unresolved" : "resolved", unresolvedReason, profile === null ? 1 : 2, provenance,
|
|
6707
6868
|
// Fallback chains (v30-v114) are gone: no filing names one.
|
|
6708
6869
|
null, scope.acceptance.length === 0 ? null : JSON.stringify(scope.acceptance), qualityMode,
|
|
@@ -6710,10 +6871,13 @@ export class Store {
|
|
|
6710
6871
|
// routine risk, the value its signed terms always carried.
|
|
6711
6872
|
"routine", route === null ? null : canonicalRouteJson(route),
|
|
6712
6873
|
// The durable era: every row this method writes is a routed row.
|
|
6713
|
-
ROUTE_ERA, scope.candidate ?? null);
|
|
6874
|
+
ROUTE_ERA, scope.candidate ?? null, releaseTerms === null ? null : serializeReleaseTerms(releaseTerms));
|
|
6714
6875
|
// Who wrote THIS text (mate arc, ruling 2): set per write, so a human
|
|
6715
|
-
// rewrite clears the lead's mark and a mate rewrite sets it
|
|
6716
|
-
|
|
6876
|
+
// rewrite clears the lead's mark and a mate rewrite sets it — but only a
|
|
6877
|
+
// person's write clears one (approval policy §1.4): the lead (its token)
|
|
6878
|
+
// or a remote API run keeps any mark already there and never erases a
|
|
6879
|
+
// quarantine by refiling, and the lead's own text is marked `lead`.
|
|
6880
|
+
this.db.prepare("UPDATE task_scope SET proposed_via = ? WHERE task_id = ?").run(provenanceAfter(prior?.["proposed_via"], options.proposedVia ?? null), scope.taskId);
|
|
6717
6881
|
return null;
|
|
6718
6882
|
}));
|
|
6719
6883
|
}
|
|
@@ -6744,6 +6908,14 @@ export class Store {
|
|
|
6744
6908
|
* scope vanished mid-ceremony. */
|
|
6745
6909
|
sealScopeApproval(taskId, by, now, mutation = {}, basis, approverKind) {
|
|
6746
6910
|
return (this.once(mutation, "sealScopeApproval", () => {
|
|
6911
|
+
// ONLY A PERSON'S SEAL HAS NO BASIS (approval policy, phase 1): with none given this is the person's
|
|
6912
|
+
// ceremony (scope.ts approve), so a machine — the lead's token, a remote API run — never mints one, whatever
|
|
6913
|
+
// road it came by.
|
|
6914
|
+
if (basis === undefined && machineActing())
|
|
6915
|
+
return false;
|
|
6916
|
+
// `carried` is written by the carry transaction alone (carryReleaseApproval), never through a seal.
|
|
6917
|
+
if (basis?.kind === "carried")
|
|
6918
|
+
return false;
|
|
6747
6919
|
// THE COORDINATOR QUARANTINE, enforced in the primitive (MCP spec
|
|
6748
6920
|
// v6, round-3 f1): mode coverage never admits a coordinator-filed
|
|
6749
6921
|
// task — console, CLI `task scope`, and direct callers alike hit
|
|
@@ -6812,6 +6984,15 @@ export class Store {
|
|
|
6812
6984
|
return false;
|
|
6813
6985
|
if (routeProblems(authority.route).length > 0)
|
|
6814
6986
|
return false;
|
|
6987
|
+
// RELEASE TERMS (release-once.md): only a person's own yes (the ceremony or a paired-chat tap) seals them, and
|
|
6988
|
+
// that yes opens the release's authority record in this same transaction — or the seal doesn't stand.
|
|
6989
|
+
const terms = storedReleaseTerms(working["release_terms_json"]);
|
|
6990
|
+
if (!terms.ok)
|
|
6991
|
+
return false;
|
|
6992
|
+
if (terms.terms !== null && basis !== undefined && basis.kind !== "chat")
|
|
6993
|
+
return false;
|
|
6994
|
+
if (terms.terms !== null && releaseSealProblem(this, terms.terms) !== null)
|
|
6995
|
+
return false;
|
|
6815
6996
|
const changed = this.db
|
|
6816
6997
|
.prepare(`UPDATE task_scope
|
|
6817
6998
|
SET approved_at = ?, approved_by = ?, approved_digest = digest,
|
|
@@ -6827,6 +7008,10 @@ export class Store {
|
|
|
6827
7008
|
approved_route_json = proposed_route_json
|
|
6828
7009
|
WHERE task_id = ?`)
|
|
6829
7010
|
.run(now.toISOString(), by, basis === undefined ? "password" : basis.kind, basis === undefined ? null : basis.modeDigest, taskId);
|
|
7011
|
+
if (changed.changes > 0 && terms.ok && terms.terms !== null) {
|
|
7012
|
+
if (openReleaseAuthority(this, { taskId, digest: String(working["digest"]), terms: terms.terms, by, now }) === null)
|
|
7013
|
+
throw new ReleaseSealRefused();
|
|
7014
|
+
}
|
|
6830
7015
|
if (changed.changes > 0) {
|
|
6831
7016
|
// The project's tools this yes saw (v80): a later addition needs a later yes.
|
|
6832
7017
|
this.sealProjectTools(taskId, String(working["digest"]), now);
|
|
@@ -6849,12 +7034,14 @@ export class Store {
|
|
|
6849
7034
|
taskRef: Number(ref["id"]), kind: "scope-approved", identity: `t${Number(ref["id"])}`,
|
|
6850
7035
|
subject: "Scope approved",
|
|
6851
7036
|
body: basis === undefined
|
|
6852
|
-
? `Approved by ${
|
|
7037
|
+
? `Approved by ${lifecycleWords(by, 40)}. A connected worker can take it next.`
|
|
6853
7038
|
: basis.kind === "chat"
|
|
6854
7039
|
? `Approved by ${lifecycleWords(by, 40)} in chat. A connected worker can take it next.`
|
|
6855
7040
|
: basis.kind === "grant"
|
|
6856
7041
|
? "Approved automatically: its flow runs on its own. A connected worker can take it next."
|
|
6857
|
-
:
|
|
7042
|
+
: basis.kind === "schedule"
|
|
7043
|
+
? "Approved by its standing order. A connected worker can take it next."
|
|
7044
|
+
: "Approved automatically under the operating mode. A connected worker can take it next.",
|
|
6858
7045
|
link: chatControlHref("task", taskId),
|
|
6859
7046
|
}, now);
|
|
6860
7047
|
}
|
|
@@ -6874,7 +7061,7 @@ export class Store {
|
|
|
6874
7061
|
}
|
|
6875
7062
|
getScope(taskId) {
|
|
6876
7063
|
const row = this.db.prepare("SELECT * FROM task_scope WHERE task_id = ?").get(taskId);
|
|
6877
|
-
return row === undefined ? null : readScope(row);
|
|
7064
|
+
return row === undefined ? null : sealedAs(this, readScope(row));
|
|
6878
7065
|
}
|
|
6879
7066
|
/**
|
|
6880
7067
|
* The mode-approval belt, as ONE question every consumer asks the same
|
|
@@ -6885,20 +7072,23 @@ export class Store {
|
|
|
6885
7072
|
* scopes answer true: this belt only ever REMOVES mode authority.
|
|
6886
7073
|
*/
|
|
6887
7074
|
modeApprovalLive(taskRef, now) {
|
|
6888
|
-
//
|
|
7075
|
+
// Approval policy §1.5: an allowlist over the seal that exists. No seal claims no authority (a pre-approval
|
|
7076
|
+
// planner, a scope-free check — the role gates decide those); a seal stands only on an authority that still does
|
|
7077
|
+
// (approval-authority.ts), so a lead seal, a null basis or a word this code doesn't write is dead here.
|
|
6889
7078
|
const dead = this.db
|
|
6890
7079
|
.prepare(`SELECT 1 AS dead FROM task_scope
|
|
6891
7080
|
JOIN task_ref ON task_ref.id = ? AND task_ref.backend = 'built-in' AND task_scope.task_id = task_ref.external_id
|
|
6892
7081
|
WHERE task_scope.approved_at IS NOT NULL
|
|
6893
|
-
AND (
|
|
6894
|
-
|
|
6895
|
-
|
|
6896
|
-
|
|
6897
|
-
|
|
6898
|
-
|
|
6899
|
-
|
|
6900
|
-
|
|
6901
|
-
|
|
7082
|
+
AND NOT ${authorityStandsSql("task_scope", "task_ref.repo", "?")}`)
|
|
7083
|
+
.get(taskRef, now.toISOString(), now.toISOString(), now.toISOString());
|
|
7084
|
+
if (dead !== undefined)
|
|
7085
|
+
return false;
|
|
7086
|
+
// A release task (release-once.md): the whole live-release predicate, beyond what the SQL can say.
|
|
7087
|
+
const external = this.db.prepare("SELECT external_id FROM task_ref WHERE id = ? AND backend = 'built-in'").get(taskRef);
|
|
7088
|
+
if (external === undefined)
|
|
7089
|
+
return true;
|
|
7090
|
+
const live = liveReleaseOf(this, String(external["external_id"]), "dispatch", now);
|
|
7091
|
+
return !live.applies || live.ok;
|
|
6902
7092
|
}
|
|
6903
7093
|
// ---- approvers ----------------------------------------------------------
|
|
6904
7094
|
saveApprover(name, credentialHash, now, mutation = {}) {
|
|
@@ -7359,7 +7549,8 @@ export class Store {
|
|
|
7359
7549
|
// Only people who can still approve here count: a vote from an account since revoked, demoted or moved off the project doesn't.
|
|
7360
7550
|
const standing = (name) => { const account = this.accountOf(name); return account !== null && account.revokedAt === null && account.role === "approver" && this.accountCanAccess(name, repo); };
|
|
7361
7551
|
const digest = scope === undefined ? "" : String(scope["digest"]);
|
|
7362
|
-
|
|
7552
|
+
// Only a person's vote counts (approval policy §1.3): a machine's, or one with no recorded proof, is history only.
|
|
7553
|
+
const voters = new Set(this.db.prepare("SELECT approver, proof FROM scope_approval_vote WHERE task_id = ? AND digest = ?").all(taskId, digest).filter(row => personVote(this.db, taskId, digest, row)).map(row => String(row["approver"])).filter(standing));
|
|
7363
7554
|
const already = voters.has(by);
|
|
7364
7555
|
voters.add(by);
|
|
7365
7556
|
for (const name of requesters)
|
|
@@ -7380,10 +7571,10 @@ export class Store {
|
|
|
7380
7571
|
const hits = changed === null ? null : protectedChanges(rules, changed);
|
|
7381
7572
|
if (hits !== null && hits.length === 0)
|
|
7382
7573
|
return null;
|
|
7383
|
-
const scope = this.db.prepare("SELECT approved_by, approved_digest FROM task_scope WHERE task_id = ?").get(taskId);
|
|
7574
|
+
const scope = this.db.prepare("SELECT approved_by, approved_digest, approved_at, approval_basis FROM task_scope WHERE task_id = ?").get(taskId);
|
|
7384
7575
|
const approvedDigest = scope?.["approved_digest"] == null ? "" : String(scope["approved_digest"]);
|
|
7385
7576
|
const standing = (name) => { const account = this.accountOf(name); return account !== null && account.revokedAt === null && account.role === "approver" && this.accountCanAccess(name, repo); };
|
|
7386
|
-
const voters = new Set(this.db.prepare("SELECT approver FROM scope_approval_vote WHERE task_id = ? AND digest = ?").all(taskId, approvedDigest).map(row => String(row["approver"])).filter(standing));
|
|
7577
|
+
const voters = new Set(this.db.prepare("SELECT approver, proof FROM scope_approval_vote WHERE task_id = ? AND digest = ?").all(taskId, approvedDigest).filter(row => personVote(this.db, taskId, approvedDigest, row)).map(row => String(row["approver"])).filter(standing));
|
|
7387
7578
|
// Whoever asked for the work never counts as the second pair of eyes on it, whatever the requester rule says.
|
|
7388
7579
|
const requesters = this.requestersOf(taskId);
|
|
7389
7580
|
for (const name of requesters)
|
|
@@ -7394,13 +7585,35 @@ export class Store {
|
|
|
7394
7585
|
const what = hits === null ? "files this project protects (its changed files couldn't be read)" : `protected files (${hits.slice(0, 3).join(", ")}${hits.length > 3 ? ` and ${hits.length - 3} more` : ""})`;
|
|
7395
7586
|
if (completer === null)
|
|
7396
7587
|
return `This result changed ${what}, so a person other than its approver marks it complete.`;
|
|
7397
|
-
|
|
7398
|
-
|
|
7399
|
-
|
|
7588
|
+
// Two DISTINCT people across the votes, the seal and the completion (approval policy §1.7a): the seal's approver
|
|
7589
|
+
// counts only when a person's proof stands behind that seal (never a lead or other machine credential under their
|
|
7590
|
+
// name, and never a seal with no proof on record: fail closed, as personApprovalOf), a
|
|
7591
|
+
// requester never counts, and someone who already voted is not a second person by also completing.
|
|
7592
|
+
const people = new Set(voters);
|
|
7593
|
+
const basis = scope?.["approval_basis"];
|
|
7594
|
+
// (The approver is the first pair of eyes and may be who asked; the second may not.)
|
|
7595
|
+
if (approver !== null && (basis === "password" || basis === "chat") && scope?.["approved_at"] != null
|
|
7596
|
+
&& sealOriginOf(this, taskId, approver, approvedDigest, String(scope["approved_at"])).origin === "person")
|
|
7597
|
+
people.add(approver);
|
|
7598
|
+
const before = people.size;
|
|
7599
|
+
if (!requesters.has(completer) && standing(completer))
|
|
7600
|
+
people.add(completer);
|
|
7601
|
+
// No seal on record at all (nothing to pair with): the completer, if they can still approve here, is the person.
|
|
7602
|
+
if (approver === null && people.size > before && voters.size === 0)
|
|
7603
|
+
return null;
|
|
7604
|
+
// Two distinct people across the votes, the person-made seal and the completer. Each person counts once, so one
|
|
7605
|
+
// person voting and completing is still one; a voter who completes a seal another person made is two.
|
|
7606
|
+
if (people.size >= 2)
|
|
7607
|
+
return null;
|
|
7608
|
+
return `This result changed ${what}, and ${approver ?? "one person"} approved its scope alone. Someone else has to mark it complete.`;
|
|
7400
7609
|
}
|
|
7401
7610
|
/** One person's yes to these exact bytes (protected work counts two). */
|
|
7402
|
-
recordApprovalVote(taskId, digest, by, now) {
|
|
7403
|
-
|
|
7611
|
+
recordApprovalVote(taskId, digest, by, now, proof) {
|
|
7612
|
+
// How this yes was proved rides with it (approval policy §1.3): the counters count only a person's.
|
|
7613
|
+
// A person's yes replaces a row a machine credential left for the same person and bytes (or one with no proof).
|
|
7614
|
+
const fresh = this.db.prepare(`INSERT INTO scope_approval_vote (task_id, digest, approver, at, proof) VALUES (?,?,?,?,?)
|
|
7615
|
+
ON CONFLICT (task_id, digest, approver) DO UPDATE SET at = excluded.at, proof = excluded.proof
|
|
7616
|
+
WHERE COALESCE(scope_approval_vote.proof, '') NOT IN ('password-now','sso-now','fresh-password','fresh-sso','session','cli-login','chat','passkey')`).run(taskId, digest, by, now.toISOString(), proof);
|
|
7404
7617
|
if (fresh.changes > 0) {
|
|
7405
7618
|
const repo = this.db.prepare("SELECT repo FROM task_ref WHERE backend = ? AND external_id = ?").get(BUILT_IN, taskId)?.["repo"];
|
|
7406
7619
|
this.recordAction({ at: now.toISOString(), actor: by, repo: repo == null ? null : String(repo), taskId, runId: null, action: "approval recorded", outcome: "recorded", source: "work", detail: `digest ${digest.slice(0, 12)}` });
|
|
@@ -11370,7 +11583,7 @@ export class Store {
|
|
|
11370
11583
|
if (route === null) {
|
|
11371
11584
|
return { ok: false, reason: "unreadable", detail: scope.approvedRouteJson == null ? "the approval sealed no agent route — re-file the scope and approve it again" : "the approval's sealed agent route cannot be read — re-file the scope and approve it again" };
|
|
11372
11585
|
}
|
|
11373
|
-
const fields = { goal: scope.goal, outOfScope: scope.outOfScope, touches: scope.touches, budgetMicrousd: scope.budgetMicrousd, acceptance: scope.acceptance, candidate: scope.candidate ?? null, qualityMode: scope.qualityMode ?? "default" };
|
|
11586
|
+
const fields = { goal: scope.goal, outOfScope: scope.outOfScope, touches: scope.touches, budgetMicrousd: scope.budgetMicrousd, acceptance: scope.acceptance, candidate: scope.candidate ?? null, qualityMode: scope.qualityMode ?? "default", releaseTerms: scope.releaseTerms ?? null };
|
|
11374
11587
|
const chain = scope.approvalKind === "chain" ? chainFromJson(scope.approvedChainJson ?? null) : null;
|
|
11375
11588
|
const target = chain !== null ? { chain } : (scope.approvedProfile ?? null);
|
|
11376
11589
|
if (target === null)
|
|
@@ -14775,6 +14988,12 @@ export class Store {
|
|
|
14775
14988
|
* an existing row's posture is the reconciler's job, never creation's. */
|
|
14776
14989
|
createMergeIntent(intent, now) {
|
|
14777
14990
|
this.transact(() => {
|
|
14991
|
+
// The nightly check of main is a check only (release-once.md, part 3): nothing merges it.
|
|
14992
|
+
if (nightlyPublication(this, intent.publication))
|
|
14993
|
+
throw new Error(NIGHTLY_CHECK_ONLY);
|
|
14994
|
+
// A release's result merges only through its coordinator (release-once.md; security review): no intent for it.
|
|
14995
|
+
if (releasePublication(this, intent.publication))
|
|
14996
|
+
throw new Error(RELEASE_MERGES_ONLY);
|
|
14778
14997
|
const mode = this.activeMode(intent.repo, now);
|
|
14779
14998
|
let state = "pending";
|
|
14780
14999
|
let basis = "grant";
|
|
@@ -14881,8 +15100,18 @@ export class Store {
|
|
|
14881
15100
|
fireMergeIntent(id, generation, proof, now) {
|
|
14882
15101
|
return this.transact(() => {
|
|
14883
15102
|
const row = this.db
|
|
14884
|
-
.prepare("SELECT state, generation, head_sha, grant_terms_hash, authority_basis, mode_digest FROM merge_intent WHERE id = ?")
|
|
15103
|
+
.prepare("SELECT state, generation, head_sha, grant_terms_hash, authority_basis, mode_digest, publication FROM merge_intent WHERE id = ?")
|
|
14885
15104
|
.get(id);
|
|
15105
|
+
// The shared merge authority (release-once.md, part 3): a nightly check of main never merges, by any road.
|
|
15106
|
+
if (row !== undefined && nightlyPublication(this, Number(row["publication"]))) {
|
|
15107
|
+
this.settleMergeIntent(id, generation, "refused", now, { error: NIGHTLY_CHECK_ONLY, from: "claimed" });
|
|
15108
|
+
return { ok: false, outcome: "superseded", why: NIGHTLY_CHECK_ONLY };
|
|
15109
|
+
}
|
|
15110
|
+
// Nor does a release's result, whatever intent was planted for it: only `toolroll release` merges it.
|
|
15111
|
+
if (row !== undefined && releasePublication(this, Number(row["publication"]))) {
|
|
15112
|
+
this.settleMergeIntent(id, generation, "refused", now, { error: RELEASE_MERGES_ONLY, from: "claimed" });
|
|
15113
|
+
return { ok: false, outcome: "superseded", why: RELEASE_MERGES_ONLY };
|
|
15114
|
+
}
|
|
14886
15115
|
if (row === undefined || String(row["state"]) !== "claimed" || Number(row["generation"]) !== generation) {
|
|
14887
15116
|
return { ok: false, outcome: "raced", why: "the claim moved" };
|
|
14888
15117
|
}
|
|
@@ -15002,6 +15231,12 @@ export class Store {
|
|
|
15002
15231
|
* reach a PR" are one write or neither.
|
|
15003
15232
|
*/
|
|
15004
15233
|
createPublicationIntent(intent, now) {
|
|
15234
|
+
// The nightly check of main is a check only: it is never published, so no pull request or merge can follow.
|
|
15235
|
+
if (nightlyTaskRef(this, intent.taskRef))
|
|
15236
|
+
throw new Error(NIGHTLY_CHECK_ONLY);
|
|
15237
|
+
// Nor is a release's task: `toolroll release` merges its own pull request, so no publication (and no merge sweep) has it.
|
|
15238
|
+
if (releaseTaskRef(this, intent.taskRef))
|
|
15239
|
+
throw new Error(RELEASE_MERGES_ONLY);
|
|
15005
15240
|
const inserted = this.db
|
|
15006
15241
|
.prepare(`INSERT INTO publication
|
|
15007
15242
|
(run, task_ref, github_repo, remote, base, head, head_sha, body_hash, draft, state, created_at, updated_at)
|
|
@@ -15339,7 +15574,7 @@ export class Store {
|
|
|
15339
15574
|
const stamp = now.toISOString();
|
|
15340
15575
|
this.db.prepare("UPDATE flow_card SET stage = ?, entry = entry + 1, task = ?, waiting = NULL, note = COALESCE(?, note), updated_at = ? WHERE id = ?")
|
|
15341
15576
|
.run(move.to, move.task ?? null, move.note ?? null, stamp, id);
|
|
15342
|
-
this.db.prepare("INSERT INTO flow_event (card, from_stage, to_stage, outcome, actor, note, at) VALUES (?, ?, ?, ?, ?, ?, ?)").run(id, card.stage, move.to, move.outcome, move.actor, move.historyNote ?? move.note ?? null, stamp);
|
|
15577
|
+
this.db.prepare("INSERT INTO flow_event (card, from_stage, to_stage, outcome, actor, note, at, authority) VALUES (?, ?, ?, ?, ?, ?, ?, ?)").run(id, card.stage, move.to, move.outcome, move.actor, move.historyNote ?? move.note ?? null, stamp, move.authority ?? null);
|
|
15343
15578
|
return true;
|
|
15344
15579
|
});
|
|
15345
15580
|
}
|
|
@@ -15367,6 +15602,8 @@ export class Store {
|
|
|
15367
15602
|
return this.db.prepare("SELECT * FROM flow_event WHERE card = ? ORDER BY id").all(card).map(row => ({
|
|
15368
15603
|
fromStage: row["from_stage"] === null ? null : String(row["from_stage"]), toStage: String(row["to_stage"]), outcome: String(row["outcome"]),
|
|
15369
15604
|
actor: String(row["actor"]), note: row["note"] === null ? null : String(row["note"]), at: String(row["at"]),
|
|
15605
|
+
// A word this code doesn't write (or a move from before v134) is no recorded authority.
|
|
15606
|
+
authority: FLOW_DECISION_AUTHORITIES.find(one => one === row["authority"]) ?? null,
|
|
15370
15607
|
}));
|
|
15371
15608
|
}
|
|
15372
15609
|
/** When a card came into the zone it is in: its latest history line (it is written with every move). */
|
|
@@ -19861,7 +20098,7 @@ export function scopeTermsProblem(row) {
|
|
|
19861
20098
|
// null for a person) is corruption, never a value that slips past the
|
|
19862
20099
|
// quarantine because it is not literally 'mate'.
|
|
19863
20100
|
const via = row["proposed_via"];
|
|
19864
|
-
if (via !== null && via !== undefined && via !== "mate" && via !== "coordinator" && via !== "scout")
|
|
20101
|
+
if (via !== null && via !== undefined && via !== "mate" && via !== "coordinator" && via !== "scout" && via !== "lead")
|
|
19865
20102
|
return "the proposed-via marker is not one this code writes";
|
|
19866
20103
|
const era = row["route_era"];
|
|
19867
20104
|
if (era !== null && era !== undefined && (typeof era !== "number" || !Number.isSafeInteger(era) || era < 1 || era > ROUTE_ERA))
|
|
@@ -19870,6 +20107,12 @@ export function scopeTermsProblem(row) {
|
|
|
19870
20107
|
if (typeof row[column] !== "string")
|
|
19871
20108
|
return `${column.replace("_", " ")} is not stored as text`;
|
|
19872
20109
|
}
|
|
20110
|
+
// v135: release terms read back exactly, or the row states why (a reader that didn't select them skips this).
|
|
20111
|
+
if ("release_terms_json" in row) {
|
|
20112
|
+
const terms = storedReleaseTerms(row["release_terms_json"]);
|
|
20113
|
+
if (!terms.ok)
|
|
20114
|
+
return terms.problem;
|
|
20115
|
+
}
|
|
19873
20116
|
for (const column of ["approved_at", "approved_by", "approved_digest", "profile_json", "approved_profile_json", "proposed_chain_json", "approved_chain_json", "proposed_route_json", "approved_route_json", "unresolved_reason"]) {
|
|
19874
20117
|
if (row[column] !== null && row[column] !== undefined && typeof row[column] !== "string")
|
|
19875
20118
|
return `${column.replace(/_/g, " ")} is not stored as text`;
|
|
@@ -19886,9 +20129,10 @@ function readScope(row) {
|
|
|
19886
20129
|
proposedAt: String(row["proposed_at"]),
|
|
19887
20130
|
digest: String(row["digest"]),
|
|
19888
20131
|
budgetMicrousd: row["budget_microusd"] === null || row["budget_microusd"] === undefined ? null : Number(row["budget_microusd"]),
|
|
20132
|
+
// Only the authorities this code writes; any other stored word reads as `unknown`, which no reader treats as a yes.
|
|
19889
20133
|
approvalBasis: row["approval_basis"] === null || row["approval_basis"] === undefined
|
|
19890
20134
|
? null
|
|
19891
|
-
:
|
|
20135
|
+
: APPROVAL_BASES.find(one => one === row["approval_basis"]) ?? "unknown",
|
|
19892
20136
|
modeDigest: row["mode_digest"] === null || row["mode_digest"] === undefined ? null : String(row["mode_digest"]),
|
|
19893
20137
|
approvedAt: row["approved_at"] === null ? null : String(row["approved_at"]),
|
|
19894
20138
|
approvedBy: row["approved_by"] === null ? null : String(row["approved_by"]),
|
|
@@ -19913,7 +20157,8 @@ function readScope(row) {
|
|
|
19913
20157
|
approvedRouteJson: row["approved_route_json"] === null || row["approved_route_json"] === undefined ? null : String(row["approved_route_json"]),
|
|
19914
20158
|
routeEra: row["route_era"] === null || row["route_era"] === undefined ? null : Number(row["route_era"]),
|
|
19915
20159
|
candidate: typeof row["candidate"] === "string" && row["candidate"] !== "" ? row["candidate"] : null,
|
|
19916
|
-
|
|
20160
|
+
releaseTerms: (() => { const read = storedReleaseTerms(row["release_terms_json"]); return read.ok ? read.terms : null; })(),
|
|
20161
|
+
proposedVia: row["proposed_via"] === "mate" || row["proposed_via"] === "coordinator" || row["proposed_via"] === "scout" || row["proposed_via"] === "lead" ? row["proposed_via"] : null,
|
|
19917
20162
|
};
|
|
19918
20163
|
}
|
|
19919
20164
|
function readRunner(row) {
|
|
@@ -20050,3 +20295,448 @@ function readAcpCheck(row) {
|
|
|
20050
20295
|
settledAt: text("settled_at"),
|
|
20051
20296
|
};
|
|
20052
20297
|
}
|
|
20298
|
+
/**
|
|
20299
|
+
* Who wrote a scope's text after this write (docs/plans/approval-policy.md §1.4), from trusted context only. A person's
|
|
20300
|
+
* write sets what the caller says (null for their own words), as always. A machine's write — the lead's token or a
|
|
20301
|
+
* remote API run — never clears or downgrades a mark: a quarantined author (a chat proposal, a scout, a coordinator)
|
|
20302
|
+
* stays, and otherwise the lead's write is marked `lead` (a remote run's keeps whatever was there).
|
|
20303
|
+
*/
|
|
20304
|
+
export function provenanceAfter(prior, requested) {
|
|
20305
|
+
if (!machineActing())
|
|
20306
|
+
return requested;
|
|
20307
|
+
const was = prior === "mate" || prior === "coordinator" || prior === "scout" || prior === "lead" ? prior : null;
|
|
20308
|
+
if (quarantinedAuthor(was))
|
|
20309
|
+
return was;
|
|
20310
|
+
if (quarantinedAuthor(requested))
|
|
20311
|
+
return requested;
|
|
20312
|
+
return currentActor()?.lead === true ? "lead" : (requested ?? was);
|
|
20313
|
+
}
|
|
20314
|
+
export const FLOW_DECISION_AUTHORITIES = ["person", "subagent", "lead", "remote"];
|
|
20315
|
+
/** Whether a protected-work vote is a person's (approval policy §1.3): the proof stored with it, or for a vote from
|
|
20316
|
+
* before votes kept one, the approver's own proof row for those bytes. None on record is no person. */
|
|
20317
|
+
function personVote(db, taskId, digest, row) {
|
|
20318
|
+
if (typeof row["proof"] === "string")
|
|
20319
|
+
return personProof(row["proof"]);
|
|
20320
|
+
// The approver's newest proof row for these exact bytes, parsed (either form); none is no person.
|
|
20321
|
+
const proof = db.prepare("SELECT outcome, detail FROM action_ledger WHERE task_id = ? AND action = 'approval proved' AND actor = ? ORDER BY id DESC LIMIT 50")
|
|
20322
|
+
.all(taskId, String(row["approver"])).find(one => proofDetailOf(one["detail"])?.digest === digest);
|
|
20323
|
+
return personProof(proof === undefined ? null : String(proof["outcome"]));
|
|
20324
|
+
}
|
|
20325
|
+
/**
|
|
20326
|
+
* A scope's approval read for what its seal really was (approval policy §1.5): a seal that says `password` or `chat`
|
|
20327
|
+
* but that a machine credential made reads as `lead` (the lead's token) or `unknown` (any other machine credential),
|
|
20328
|
+
* and a standing order's old firing as `schedule`. Nothing is rewritten; every reader of getScope sees the truth.
|
|
20329
|
+
*/
|
|
20330
|
+
function sealedAs(store, scope) {
|
|
20331
|
+
if (scope.approvedAt === null || scope.approvedBy === null || scope.approvedDigest === null)
|
|
20332
|
+
return scope;
|
|
20333
|
+
if (scope.approvalBasis !== "password" && scope.approvalBasis !== "chat")
|
|
20334
|
+
return scope;
|
|
20335
|
+
const origin = sealOriginOf(store, scope.taskId, scope.approvedBy, scope.approvedDigest, scope.approvedAt);
|
|
20336
|
+
if (origin.origin === "machine")
|
|
20337
|
+
return { ...scope, approvalBasis: origin.lead ? "lead" : "unknown" };
|
|
20338
|
+
if (origin.origin === "automation")
|
|
20339
|
+
return { ...scope, approvalBasis: "schedule" };
|
|
20340
|
+
return scope;
|
|
20341
|
+
}
|
|
20342
|
+
function readCheckAttempt(row) {
|
|
20343
|
+
const text = (key) => row[key] == null ? null : String(row[key]);
|
|
20344
|
+
return {
|
|
20345
|
+
run: Number(row["run"]), authority: row["authority"] == null ? null : Number(row["authority"]),
|
|
20346
|
+
mode: row["mode"] === "targeted" ? "targeted" : "full", baseRun: row["base_run"] == null ? null : Number(row["base_run"]),
|
|
20347
|
+
planDigest: text("plan_digest"), snapshotDigest: text("snapshot_digest"), inventoryDigest: String(row["inventory_digest"]),
|
|
20348
|
+
contextDigest: String(row["context_digest"]), nonceHash: String(row["nonce_hash"]), createdAt: String(row["created_at"]), settledAt: text("settled_at"),
|
|
20349
|
+
};
|
|
20350
|
+
}
|
|
20351
|
+
/** Seal a check attempt before its command is spawned. Once written it never changes (bar its settled mark). */
|
|
20352
|
+
export function recordCheckAttempt(store, attempt, now) {
|
|
20353
|
+
store.handle.prepare(`INSERT INTO release_check_attempt (run, authority, mode, base_run, plan_digest, snapshot_digest, inventory_digest, context_digest, nonce_hash, created_at)
|
|
20354
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(attempt.run, attempt.authority, attempt.mode, attempt.baseRun, attempt.planDigest, attempt.snapshotDigest, attempt.inventoryDigest, attempt.contextDigest, attempt.nonceHash, now.toISOString());
|
|
20355
|
+
}
|
|
20356
|
+
/** A run's sealed check attempt, or null. */
|
|
20357
|
+
export function checkAttemptOf(store, run) {
|
|
20358
|
+
const row = store.handle.prepare("SELECT * FROM release_check_attempt WHERE run = ?").get(run);
|
|
20359
|
+
return row === undefined ? null : readCheckAttempt(row);
|
|
20360
|
+
}
|
|
20361
|
+
/** Release terms as this code writes them: the contract's own reading of them, serialized. */
|
|
20362
|
+
export function serializeReleaseTerms(terms) {
|
|
20363
|
+
const read = readReleaseTerms(terms);
|
|
20364
|
+
if (!read.ok)
|
|
20365
|
+
throw new Error(`The release terms don't match their contract: ${read.issues[0]?.line ?? "invalid"}`);
|
|
20366
|
+
return JSON.stringify(read.value);
|
|
20367
|
+
}
|
|
20368
|
+
// ---- one approval per release: the authority record (release-once.md, part 1) --------------------------------------
|
|
20369
|
+
export { LIVE_RELEASE_STATES, belongsToRelease, newestReleaseGeneration, outstandingReleaseOperations, releaseAuthority, releaseAuthorityOfTask, releaseCarries, releaseCarryTo, releaseOperation, releaseOperations, releaseSealProblem, } from "./release-authority.js";
|
|
20370
|
+
import { LIVE_RELEASE_STATES, belongsToRelease, newestReleaseGeneration, outstandingReleaseOperations, releaseAuthority, releaseCarryTo, releaseOperation, releaseSealProblem, } from "./release-authority.js";
|
|
20371
|
+
/**
|
|
20372
|
+
* Open a release's authority for a person's seal of its terms (inside the seal's transaction): the next generation,
|
|
20373
|
+
* superseding the live one. False when it can't (releaseSealProblem), which refuses the seal.
|
|
20374
|
+
*/
|
|
20375
|
+
export function openReleaseAuthority(store, input) {
|
|
20376
|
+
const { terms, now } = input;
|
|
20377
|
+
if (releaseSealProblem(store, terms) !== null)
|
|
20378
|
+
return null;
|
|
20379
|
+
const live = newestReleaseGeneration(store, terms.repo, terms.packageVersion, terms.pr);
|
|
20380
|
+
if (live !== null && LIVE_RELEASE_STATES.includes(live.state)) {
|
|
20381
|
+
store.handle.prepare("UPDATE release_authority SET state = 'superseded', ended_at = ?, ended_by = ?, rev = rev + 1 WHERE id = ? AND rev = ?")
|
|
20382
|
+
.run(now.toISOString(), input.by, live.id, live.rev);
|
|
20383
|
+
}
|
|
20384
|
+
const policyJson = JSON.stringify(terms.policy);
|
|
20385
|
+
const result = store.handle.prepare(`INSERT INTO release_authority (repo, generation, previous, version, pr, branch, base, anchor_task, anchor_digest, approved_sha, approved_tree,
|
|
20386
|
+
artifact_digest, tip_sha, tip_task, policy_json, policy_digest, verify_command, verify_digest, access_digest, state, rev, lease_epoch, expires_at, created_at)
|
|
20387
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, 'open', 0, 0, ?, ?)`).run(terms.repo, (live?.generation ?? 0) + 1, live?.id ?? null, terms.packageVersion, terms.pr, terms.branch, terms.base, input.taskId, input.digest, terms.approvedSha, terms.approvedTree, terms.approvedSha, input.taskId, policyJson, releasePolicyDigest(terms.policy), terms.grant.id, terms.grant.digest, terms.access, terms.expiresAt, now.toISOString());
|
|
20388
|
+
const id = Number(result.lastInsertRowid);
|
|
20389
|
+
store.recordAction({ at: now.toISOString(), actor: input.by, repo: terms.repo, taskId: input.taskId, runId: null, action: "release approved", outcome: "open", source: "work",
|
|
20390
|
+
detail: `release ${id} · generation ${(live?.generation ?? 0) + 1} · ${terms.packageVersion} · pull request #${terms.pr} · ${terms.approvedSha}` });
|
|
20391
|
+
return id;
|
|
20392
|
+
}
|
|
20393
|
+
/** Seal the anchor's artifact digest once (the person's seal authorized building that approved commit). */
|
|
20394
|
+
export function sealAnchorArtifact(store, id, digest, now) {
|
|
20395
|
+
return store.transact(() => {
|
|
20396
|
+
const changed = store.handle.prepare("UPDATE release_authority SET artifact_digest = ?, rev = rev + 1 WHERE id = ? AND artifact_digest IS NULL AND state = 'open'").run(digest, id);
|
|
20397
|
+
if (Number(changed.changes) === 1)
|
|
20398
|
+
store.recordAction({ at: now.toISOString(), actor: "toolroll", repo: releaseAuthority(store, id)?.repo ?? null, taskId: releaseAuthority(store, id)?.anchorTask ?? null, runId: null,
|
|
20399
|
+
action: "release artifact sealed", outcome: "ok", source: "work", detail: `release ${id} · ${digest}` });
|
|
20400
|
+
return Number(changed.changes) === 1;
|
|
20401
|
+
});
|
|
20402
|
+
}
|
|
20403
|
+
/** Take (or keep) a release's lease. A takeover by another owner raises the epoch, which fences every operation the
|
|
20404
|
+
* old owner reserved. */
|
|
20405
|
+
export function takeReleaseLease(store, id, owner, now, ttlMs = 10 * 60_000) {
|
|
20406
|
+
return store.transact(() => {
|
|
20407
|
+
const authority = releaseAuthority(store, id);
|
|
20408
|
+
if (authority === null)
|
|
20409
|
+
return { ok: false, why: `No release ${id}.` };
|
|
20410
|
+
const held = authority.leaseOwner !== null && authority.leaseOwner !== owner && authority.leaseExpires !== null && authority.leaseExpires > now.toISOString();
|
|
20411
|
+
if (held)
|
|
20412
|
+
return { ok: false, why: `Release ${id} is being run by ${authority.leaseOwner} until ${authority.leaseExpires}.` };
|
|
20413
|
+
// A person's own deploy still at work holds the release as a lease would: nothing takes it over (review round 2).
|
|
20414
|
+
const direct = outstandingReleaseOperations(store, id).find(op => op.direct && !releaseIssuerGone(op));
|
|
20415
|
+
if (direct !== undefined)
|
|
20416
|
+
return { ok: false, why: `A deploy of this release (operation ${direct.id}, process ${direct.issuer?.pid ?? "unknown"}) is still running.` };
|
|
20417
|
+
const epoch = authority.leaseOwner === owner && authority.leaseExpires !== null && authority.leaseExpires > now.toISOString() ? authority.leaseEpoch : authority.leaseEpoch + 1;
|
|
20418
|
+
const changed = store.handle.prepare("UPDATE release_authority SET lease_owner = ?, lease_epoch = ?, lease_expires = ?, rev = rev + 1 WHERE id = ? AND rev = ?")
|
|
20419
|
+
.run(owner, epoch, new Date(now.getTime() + ttlMs).toISOString(), id, authority.rev);
|
|
20420
|
+
return Number(changed.changes) === 1 ? { ok: true, epoch } : { ok: false, why: "The release changed while taking its lease; try again." };
|
|
20421
|
+
});
|
|
20422
|
+
}
|
|
20423
|
+
/** Renew a lease this owner still holds at this epoch. */
|
|
20424
|
+
export function renewReleaseLease(store, id, owner, epoch, now, ttlMs = 10 * 60_000) {
|
|
20425
|
+
const changed = store.handle.prepare("UPDATE release_authority SET lease_expires = ? WHERE id = ? AND lease_owner = ? AND lease_epoch = ? AND lease_expires > ?")
|
|
20426
|
+
.run(new Date(now.getTime() + ttlMs).toISOString(), id, owner, epoch, now.toISOString());
|
|
20427
|
+
return Number(changed.changes) === 1;
|
|
20428
|
+
}
|
|
20429
|
+
/**
|
|
20430
|
+
* Reserve a deploy or a merge of the release tip (the freeze happens here, in the store): a deploy moves `open` to
|
|
20431
|
+
* `deploying` (or, already deploying, reserves a new attempt of the same frozen candidate); a merge moves `deploying`
|
|
20432
|
+
* to `merge-reserved`. `epoch` null is a direct deploy by a person: it takes the current epoch, and only while no
|
|
20433
|
+
* coordinator holds a live lease. Refused while any earlier operation is unsettled.
|
|
20434
|
+
*/
|
|
20435
|
+
export function reserveRelease(store, input) {
|
|
20436
|
+
return store.transact(() => {
|
|
20437
|
+
const a = releaseAuthority(store, input.authority);
|
|
20438
|
+
if (a === null)
|
|
20439
|
+
return { ok: false, why: `No release ${input.authority}.` };
|
|
20440
|
+
const at = input.now.toISOString();
|
|
20441
|
+
// Expiry stops every live state; an operation already reserved settles only through reconciliation.
|
|
20442
|
+
if (a.expiresAt <= at)
|
|
20443
|
+
return { ok: false, why: "The release approval expired." };
|
|
20444
|
+
if (a.tipTask !== input.taskId || a.tipSha !== input.candidate)
|
|
20445
|
+
return { ok: false, why: `Task ${input.taskId} is not the release's current commit.` };
|
|
20446
|
+
const leased = a.leaseOwner !== null && a.leaseExpires !== null && a.leaseExpires > at;
|
|
20447
|
+
if (input.epoch === null ? leased : input.epoch !== a.leaseEpoch || !leased)
|
|
20448
|
+
return { ok: false, why: input.epoch === null ? `A release coordinator (${a.leaseOwner}) holds this release; let it deploy.` : "This coordinator no longer holds the release's lease." };
|
|
20449
|
+
const outstanding = outstandingReleaseOperations(store, a.id);
|
|
20450
|
+
if (outstanding.length > 0)
|
|
20451
|
+
return { ok: false, why: `An earlier ${outstanding[0].kind} (${outstanding[0].id}) hasn't been settled yet.` };
|
|
20452
|
+
// The whole live-release predicate, inside this transaction, at this moment (review round 1): the approver's
|
|
20453
|
+
// standing and access, the grant and the sandbox, the generation and the tip, and open main-check debt.
|
|
20454
|
+
const live = liveReleaseOf(store, input.taskId, input.kind, input.now);
|
|
20455
|
+
if (!live.applies || !live.ok)
|
|
20456
|
+
return { ok: false, why: `The release can't ${input.kind}: ${live.applies && !live.ok ? live.why : "the task isn't part of it"}.` };
|
|
20457
|
+
const next = input.kind === "deploy"
|
|
20458
|
+
? a.state === "open" || a.state === "deploying" ? "deploying" : null
|
|
20459
|
+
: a.state === "deploying" || a.state === "merge-reserved" ? "merge-reserved" : null;
|
|
20460
|
+
if (next === null)
|
|
20461
|
+
return { ok: false, why: `The release is ${a.state}, so it can't ${input.kind}.` };
|
|
20462
|
+
const changed = store.handle.prepare("UPDATE release_authority SET state = ?, rev = rev + 1 WHERE id = ? AND rev = ?").run(next, a.id, a.rev);
|
|
20463
|
+
if (Number(changed.changes) !== 1)
|
|
20464
|
+
return { ok: false, why: "The release changed while reserving; try again." };
|
|
20465
|
+
const issuer = input.issuer ?? currentReleaseIssuer();
|
|
20466
|
+
store.handle.prepare("INSERT INTO release_operation (id, authority, kind, task_id, candidate, epoch, state, secret_hash, direct, issuer_pid, issuer_started, issued_at, stage, reserved_at) VALUES (?, ?, ?, ?, ?, ?, 'reserved', ?, ?, ?, ?, ?, ?, ?)")
|
|
20467
|
+
.run(input.operationId, a.id, input.kind, input.taskId, input.candidate, a.leaseEpoch, input.secret === undefined ? null : createHash("sha256").update(input.secret).digest("hex"), input.epoch === null ? 1 : 0, issuer.pid, issuer.started, issuer.at, input.stage ?? null, at);
|
|
20468
|
+
store.recordAction({ at, actor: "toolroll", repo: a.repo, taskId: input.taskId, runId: null, action: `release ${input.kind} reserved`, outcome: next, source: "work", detail: `release ${a.id} · operation ${input.operationId} · epoch ${a.leaseEpoch}` });
|
|
20469
|
+
return { ok: true, operation: releaseOperation(store, input.operationId) };
|
|
20470
|
+
});
|
|
20471
|
+
}
|
|
20472
|
+
/** The external call's own fence, taken immediately before the call is spawned: `reserved` → `running` (issuing) only
|
|
20473
|
+
* at the epoch that reserved it, while that epoch still holds the lease (a direct deploy's epoch is checked as
|
|
20474
|
+
* reserved) and the release stands, recording the process that issues it. False: the call must not start. */
|
|
20475
|
+
export function runReleaseOperation(store, id, epoch, now, issuer = currentReleaseIssuer()) {
|
|
20476
|
+
return store.transact(() => {
|
|
20477
|
+
const op = releaseOperation(store, id);
|
|
20478
|
+
const a = op === null ? null : releaseAuthority(store, op.authority);
|
|
20479
|
+
if (op === null || a === null || op.state !== "reserved" || op.epoch !== epoch || a.leaseEpoch !== epoch)
|
|
20480
|
+
return false;
|
|
20481
|
+
// Past the expiry nothing new starts; the reserved operation is settled by reconciliation instead.
|
|
20482
|
+
if (a.expiresAt <= now.toISOString())
|
|
20483
|
+
return false;
|
|
20484
|
+
// The lease that reserved it still holds (a person's direct deploy: still no coordinator holding the release), and
|
|
20485
|
+
// the live-release predicate still accepts it, at this moment (review round 1).
|
|
20486
|
+
const at = now.toISOString();
|
|
20487
|
+
const leased = a.leaseOwner !== null && a.leaseExpires !== null && a.leaseExpires > at;
|
|
20488
|
+
const direct = Number(store.handle.prepare("SELECT direct FROM release_operation WHERE id = ?").get(id)?.["direct"] ?? 0) === 1;
|
|
20489
|
+
if (direct ? leased : !leased)
|
|
20490
|
+
return false;
|
|
20491
|
+
const live = liveReleaseOf(store, op.taskId, op.kind, now);
|
|
20492
|
+
if (!live.applies || !live.ok)
|
|
20493
|
+
return false;
|
|
20494
|
+
const changed = store.handle.prepare("UPDATE release_operation SET state = 'running', issuer_pid = ?, issuer_started = ?, issued_at = ? WHERE id = ? AND state = 'reserved' AND epoch = ?")
|
|
20495
|
+
.run(issuer.pid, issuer.started, issuer.at, id, epoch);
|
|
20496
|
+
if (Number(changed.changes) === 1)
|
|
20497
|
+
store.recordAction({ at: now.toISOString(), actor: "toolroll", repo: a.repo, taskId: op.taskId, runId: null, action: `release ${op.kind} started`, outcome: "running", source: "work", detail: `operation ${id}` });
|
|
20498
|
+
return Number(changed.changes) === 1;
|
|
20499
|
+
});
|
|
20500
|
+
}
|
|
20501
|
+
/** Settle an operation: `succeeded`, `failed` or `unknown` (reconciled later). A merge that succeeded ends the release
|
|
20502
|
+
* as `merged`; one that failed puts it back to `deploying`. */
|
|
20503
|
+
export function finishReleaseOperation(store, id, outcome, facts, now, epoch) {
|
|
20504
|
+
return store.transact(() => {
|
|
20505
|
+
const op = releaseOperation(store, id);
|
|
20506
|
+
if (op === null || op.state === "succeeded" || op.state === "failed")
|
|
20507
|
+
return false;
|
|
20508
|
+
// Fenced by the epoch that holds the release now (review round 1): a coordinator that lost the lease settles nothing.
|
|
20509
|
+
if (releaseAuthority(store, op.authority)?.leaseEpoch !== epoch)
|
|
20510
|
+
return false;
|
|
20511
|
+
store.handle.prepare("UPDATE release_operation SET state = ?, external_ref = COALESCE(?, external_ref), detail = ?, finished_at = ? WHERE id = ?")
|
|
20512
|
+
.run(outcome, facts.externalRef ?? null, facts.detail ?? null, outcome === "unknown" ? null : now.toISOString(), id);
|
|
20513
|
+
const a = releaseAuthority(store, op.authority);
|
|
20514
|
+
if (op.kind === "merge" && outcome === "succeeded")
|
|
20515
|
+
store.handle.prepare("UPDATE release_authority SET state = 'merged', ended_at = ?, ended_by = 'toolroll', rev = rev + 1 WHERE id = ?").run(now.toISOString(), a.id);
|
|
20516
|
+
if (op.kind === "merge" && outcome === "failed" && a.state === "merge-reserved")
|
|
20517
|
+
store.handle.prepare("UPDATE release_authority SET state = 'deploying', rev = rev + 1 WHERE id = ?").run(a.id);
|
|
20518
|
+
store.recordAction({ at: now.toISOString(), actor: "toolroll", repo: a.repo, taskId: op.taskId, runId: null, action: `release ${op.kind} ${outcome}`, outcome, source: "work", detail: `operation ${id}${facts.detail ? ` · ${facts.detail}` : ""}` });
|
|
20519
|
+
return true;
|
|
20520
|
+
});
|
|
20521
|
+
}
|
|
20522
|
+
/** Stop carrying later commits: any active approver, any time before the merge starts. */
|
|
20523
|
+
export function revokeRelease(store, id, by, now) {
|
|
20524
|
+
return store.transact(() => {
|
|
20525
|
+
const a = releaseAuthority(store, id);
|
|
20526
|
+
if (a === null)
|
|
20527
|
+
return { ok: false, why: `No release ${id}.` };
|
|
20528
|
+
if (a.state === "merge-reserved" || a.state === "merged")
|
|
20529
|
+
return { ok: false, why: "The merge already started, so the release can't be stopped now." };
|
|
20530
|
+
if (!LIVE_RELEASE_STATES.includes(a.state))
|
|
20531
|
+
return { ok: false, why: `The release is already ${a.state}.` };
|
|
20532
|
+
store.handle.prepare("UPDATE release_authority SET state = 'revoked', ended_at = ?, ended_by = ?, rev = rev + 1 WHERE id = ? AND rev = ?").run(now.toISOString(), by, id, a.rev);
|
|
20533
|
+
store.recordAction({ at: now.toISOString(), actor: by, repo: a.repo, taskId: a.anchorTask, runId: null, action: "release revoked", outcome: "revoked", source: "work", detail: `release ${id}` });
|
|
20534
|
+
return { ok: true };
|
|
20535
|
+
});
|
|
20536
|
+
}
|
|
20537
|
+
/**
|
|
20538
|
+
* THE carry transaction (release-carry.ts proveCarry step 4), one BEGIN IMMEDIATE: every mutable fact is proved again
|
|
20539
|
+
* — the authority's `rev` and tip, its state and expiry and the whole live-release predicate (liveReleaseOf), the
|
|
20540
|
+
* anchor's sealed artifact digest equal to the candidate's, and whatever `recheck` re-reads (the policy, grant and
|
|
20541
|
+
* check access as they are now) — then it writes the carried task and its scope (the anchor's approved terms, profile,
|
|
20542
|
+
* route and acceptance verbatim, with candidate `to`), its seal (basis `carried`, reference `<authority>:<carry>`), the
|
|
20543
|
+
* carry row, the new tip with `rev + 1`, and the ledger row. A carry already written for (authority, `to`) is returned
|
|
20544
|
+
* as it is.
|
|
20545
|
+
*/
|
|
20546
|
+
export function carryReleaseApproval(store, w, now, recheck) {
|
|
20547
|
+
return store.transact(() => {
|
|
20548
|
+
const a = releaseAuthority(store, w.authority);
|
|
20549
|
+
if (a === null)
|
|
20550
|
+
return { ok: false, why: `No release ${w.authority}.` };
|
|
20551
|
+
const already = releaseCarryTo(store, a.id, w.to);
|
|
20552
|
+
if (already !== null)
|
|
20553
|
+
return { ok: true, taskId: already.taskId, carry: already };
|
|
20554
|
+
if (a.rev !== w.expectRev)
|
|
20555
|
+
return { ok: false, why: "The release changed since this carry was checked; check it again." };
|
|
20556
|
+
if (a.tipSha !== w.expectTip)
|
|
20557
|
+
return { ok: false, why: "The release's current commit moved since this carry was checked." };
|
|
20558
|
+
const live = liveReleaseOf(store, a.tipTask, "dispatch", now);
|
|
20559
|
+
if (!live.applies || !live.ok)
|
|
20560
|
+
return { ok: false, why: live.applies && !live.ok ? `The release approval doesn't stand: ${live.why}.` : "The release's current commit isn't part of it." };
|
|
20561
|
+
if (a.artifactDigest === null || a.artifactDigest !== w.artifactDigest)
|
|
20562
|
+
return { ok: false, why: "The production build differs from the approved one." };
|
|
20563
|
+
const problem = recheck();
|
|
20564
|
+
if (problem !== null)
|
|
20565
|
+
return { ok: false, why: problem };
|
|
20566
|
+
if (store.getTask(w.taskId) !== null)
|
|
20567
|
+
return { ok: false, why: `A task ${w.taskId} already exists.` };
|
|
20568
|
+
const anchorRow = store.handle.prepare("SELECT * FROM task_scope WHERE task_id = ?").get(a.anchorTask);
|
|
20569
|
+
if (anchorRow === undefined)
|
|
20570
|
+
return { ok: false, why: "The approved release task has no scope." };
|
|
20571
|
+
const anchor = readScope(anchorRow);
|
|
20572
|
+
const route = routeFromJson(anchor.approvedRouteJson ?? null);
|
|
20573
|
+
if (anchor.approvedProfile == null || route === null || anchor.releaseTerms == null)
|
|
20574
|
+
return { ok: false, why: "The approved release scope can't be read exactly." };
|
|
20575
|
+
const fields = { goal: anchor.goal, outOfScope: anchor.outOfScope, touches: anchor.touches, budgetMicrousd: anchor.budgetMicrousd, acceptance: anchor.acceptance,
|
|
20576
|
+
qualityMode: anchor.qualityMode ?? "default", candidate: w.to, releaseTerms: anchor.releaseTerms };
|
|
20577
|
+
const digest = digestOf(fields, anchor.approvedProfile, route);
|
|
20578
|
+
store.createTask({ id: w.taskId, title: w.title }, now);
|
|
20579
|
+
const ref = store.refFor(BUILT_IN, w.taskId, "ours");
|
|
20580
|
+
if (store.placeTask(ref.id, a.repo, {}, now) !== true)
|
|
20581
|
+
return { ok: false, why: "The carried task couldn't be placed in the project." };
|
|
20582
|
+
// Its checks, as the anchor's: the Full release check.
|
|
20583
|
+
store.recordAction({ at: now.toISOString(), actor: "toolroll", repo: a.repo, taskId: w.taskId, runId: null, action: "task checks chosen", outcome: "full", source: "request", detail: "Checks for this task: Full" });
|
|
20584
|
+
const carryId = Number(store.handle.prepare(`INSERT INTO release_carry (authority, task_id, scope_digest, request_id, from_sha, to_sha, to_tree, artifact_digest, commits_json, changed_lines, files, churn, runtime, proved_at)
|
|
20585
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(a.id, w.taskId, digest, w.requestId, a.approvedSha, w.to, w.toTree, w.artifactDigest, JSON.stringify(w.commits), w.changedLines, w.files, w.churn, w.runtime, now.toISOString()).lastInsertRowid);
|
|
20586
|
+
// The anchor's approved row, copied; only the candidate, the digest and the seal differ. Nobody but this
|
|
20587
|
+
// transaction writes basis `carried` (sealScopeApproval refuses it from every caller).
|
|
20588
|
+
store.handle.prepare(`INSERT INTO task_scope (task_id, goal, out_of_scope, touches, budget_microusd, proposed_at, digest, approved_at, approved_by, approved_digest,
|
|
20589
|
+
profile_json, profile_state, unresolved_reason, approved_profile_json, digest_version, profile_provenance, proposed_chain_json, approved_chain_json, acceptance_json,
|
|
20590
|
+
quality_mode, risk_level, approval_basis, mode_digest, approval_kind, proposed_route_json, approved_route_json, route_era, candidate, proposed_via, release_terms_json)
|
|
20591
|
+
SELECT ?, goal, out_of_scope, touches, budget_microusd, ?, ?, ?, approved_by, ?,
|
|
20592
|
+
approved_profile_json, 'resolved', NULL, approved_profile_json, digest_version, profile_provenance, NULL, NULL, acceptance_json,
|
|
20593
|
+
quality_mode, risk_level, 'carried', ?, 'profile', approved_route_json, approved_route_json, route_era, ?, proposed_via, release_terms_json
|
|
20594
|
+
FROM task_scope WHERE task_id = ?`).run(w.taskId, now.toISOString(), digest, now.toISOString(), digest, `${a.id}:${carryId}`, w.to, a.anchorTask);
|
|
20595
|
+
const moved = store.handle.prepare("UPDATE release_authority SET tip_sha = ?, tip_task = ?, rev = rev + 1 WHERE id = ? AND rev = ?").run(w.to, w.taskId, a.id, a.rev);
|
|
20596
|
+
if (Number(moved.changes) !== 1)
|
|
20597
|
+
throw new Error("The release changed under its own transaction.");
|
|
20598
|
+
store.recordAction({ at: now.toISOString(), actor: "toolroll", repo: a.repo, taskId: w.taskId, runId: null, action: "release carried", outcome: "carried", source: "work",
|
|
20599
|
+
detail: `release ${a.id} · carry ${carryId} · ${a.tipSha} → ${w.to} · ${w.files} files · ${w.changedLines} lines · request ${w.requestId}` });
|
|
20600
|
+
// One quiet task notification, no push.
|
|
20601
|
+
store.enqueueNotification({ dedupeKey: `release-carried:${a.id}:${carryId}`, kind: "release-carried", subject: `Carried to ${w.to.slice(0, 7)}`,
|
|
20602
|
+
body: `Release ${a.version} now ships ${w.to.slice(0, 7)} (${w.files} file${w.files === 1 ? "" : "s"}, ${w.changedLines} line${w.changedLines === 1 ? "" : "s"}) under ${anchor.approvedBy ?? "the"} release approval.`,
|
|
20603
|
+
link: chatControlHref("task", w.taskId), source: { taskRef: ref.id } }, now);
|
|
20604
|
+
return { ok: true, taskId: w.taskId, carry: releaseCarryTo(store, a.id, w.to) };
|
|
20605
|
+
});
|
|
20606
|
+
}
|
|
20607
|
+
/** Thrown inside a seal whose release authority can't open: the whole seal rolls back. */
|
|
20608
|
+
export class ReleaseSealRefused extends Error {
|
|
20609
|
+
}
|
|
20610
|
+
/**
|
|
20611
|
+
* Whether a deploy run may adopt operation `id` (deploy-browser.mjs --operation): it is this release's running deploy of
|
|
20612
|
+
* this candidate, reserved at the epoch that still holds a live lease, and the run proves the secret only the
|
|
20613
|
+
* reserving coordinator holds (handed over on stdin, never in argv).
|
|
20614
|
+
*/
|
|
20615
|
+
export function adoptReleaseOperation(store, input) {
|
|
20616
|
+
const op = releaseOperation(store, input.id);
|
|
20617
|
+
const a = releaseAuthority(store, input.authority);
|
|
20618
|
+
if (op === null || a === null || op.authority !== a.id || op.kind !== "deploy" || op.candidate !== input.candidate || op.state !== "running")
|
|
20619
|
+
return { ok: false, why: `Operation ${input.id} isn't this release's running deploy.` };
|
|
20620
|
+
if (op.epoch !== a.leaseEpoch || a.leaseOwner === null || a.leaseExpires === null || a.leaseExpires <= input.now.toISOString())
|
|
20621
|
+
return { ok: false, why: "The coordinator that reserved this deploy no longer holds the release." };
|
|
20622
|
+
const row = store.handle.prepare("SELECT secret_hash FROM release_operation WHERE id = ?").get(input.id);
|
|
20623
|
+
const expected = row?.["secret_hash"];
|
|
20624
|
+
const given = createHash("sha256").update(input.secret).digest("hex");
|
|
20625
|
+
if (typeof expected !== "string" || expected.length !== given.length || !timingSafeEqual(Buffer.from(expected), Buffer.from(given)))
|
|
20626
|
+
return { ok: false, why: "This run can't prove it is the coordinator's deploy." };
|
|
20627
|
+
return { ok: true };
|
|
20628
|
+
}
|
|
20629
|
+
/** The nightly check of main is a check only (release-once.md, part 3). */
|
|
20630
|
+
export const NIGHTLY_CHECK_ONLY = "The nightly check of main is only a check; it is never published or merged.";
|
|
20631
|
+
/** Whether a task reference is a nightly check of main. */
|
|
20632
|
+
export function nightlyTaskRef(store, taskRef) {
|
|
20633
|
+
return store.handle.prepare("SELECT 1 AS hit FROM nightly_check n JOIN task_ref r ON r.external_id = n.task AND r.backend = 'built-in' WHERE r.id = ?").get(taskRef) !== undefined;
|
|
20634
|
+
}
|
|
20635
|
+
/** Whether a publication belongs to a nightly check of main. */
|
|
20636
|
+
export function nightlyPublication(store, publication) {
|
|
20637
|
+
const row = store.handle.prepare("SELECT task_ref FROM publication WHERE id = ?").get(publication);
|
|
20638
|
+
return row !== undefined && nightlyTaskRef(store, Number(row["task_ref"]));
|
|
20639
|
+
}
|
|
20640
|
+
/** A release's result merges only through its coordinator: every other merge road refuses it. */
|
|
20641
|
+
export const RELEASE_MERGES_ONLY = "This result belongs to a release; it merges only through `toolroll release`.";
|
|
20642
|
+
/** Whether a task reference is a release's anchor or a carried release task. */
|
|
20643
|
+
export function releaseTaskRef(store, taskRef) {
|
|
20644
|
+
const row = store.handle.prepare("SELECT external_id FROM task_ref WHERE id = ? AND backend = 'built-in'").get(taskRef);
|
|
20645
|
+
return row !== undefined && belongsToRelease(store, String(row["external_id"]));
|
|
20646
|
+
}
|
|
20647
|
+
/** Whether a publication belongs to a release's task. */
|
|
20648
|
+
export function releasePublication(store, publication) {
|
|
20649
|
+
const row = store.handle.prepare("SELECT task_ref FROM publication WHERE id = ?").get(publication);
|
|
20650
|
+
return row !== undefined && releaseTaskRef(store, Number(row["task_ref"]));
|
|
20651
|
+
}
|
|
20652
|
+
/** The ledger act that records a trusted read of a project's recorded remote main (remote-main.ts). */
|
|
20653
|
+
export const TRUSTED_MAIN_ACTION = "remote main read";
|
|
20654
|
+
/** The identity of a project's recorded remote: its URL and the ledger record that last set it (0: set on admission
|
|
20655
|
+
* with no record). A read of main stands only for the remote identity it was read from (review round 2). */
|
|
20656
|
+
export function trustedRemoteIdentity(store, repo) {
|
|
20657
|
+
const url = store.projectRemote(repo);
|
|
20658
|
+
if (url === null)
|
|
20659
|
+
return null;
|
|
20660
|
+
const row = store.handle.prepare("SELECT id FROM action_ledger WHERE repo = ? AND action = 'project remote recorded' ORDER BY id DESC LIMIT 1").get(repo);
|
|
20661
|
+
return { url, record: row === undefined ? 0 : Number(row["id"]) };
|
|
20662
|
+
}
|
|
20663
|
+
/** Record the commit the project's recorded remote main was at, as Toolroll's own process fetched it just now from
|
|
20664
|
+
* `url`, bound to the remote's identity at this moment. Refused (nothing recorded) when `url` isn't the recorded one. */
|
|
20665
|
+
export function recordTrustedMain(store, repo, sha, url, now) {
|
|
20666
|
+
const identity = trustedRemoteIdentity(store, repo);
|
|
20667
|
+
if (identity === null || identity.url !== url || !/^[0-9a-f]{40}$/.test(sha))
|
|
20668
|
+
return false;
|
|
20669
|
+
store.recordAction({ at: now.toISOString(), actor: "toolroll", repo, taskId: null, runId: null, action: TRUSTED_MAIN_ACTION, outcome: sha, source: "work", detail: JSON.stringify({ v: 2, sha, url, record: identity.record }) });
|
|
20670
|
+
return true;
|
|
20671
|
+
}
|
|
20672
|
+
/** A failed read of the recorded remote: the last read no longer stands. */
|
|
20673
|
+
export function forgetTrustedMain(store, repo, why, now) {
|
|
20674
|
+
store.recordAction({ at: now.toISOString(), actor: "toolroll", repo, taskId: null, runId: null, action: TRUSTED_MAIN_ACTION, outcome: "unread", source: "work", detail: JSON.stringify({ v: 2, why: why.slice(0, 300) }) });
|
|
20675
|
+
}
|
|
20676
|
+
/**
|
|
20677
|
+
* A pull request check failed after the deploy reservation (Alex, 2026-10-25): the candidate froze at deploy, so the
|
|
20678
|
+
* release stops and asks a person again — even for the same commit, a new person-approved generation continues it.
|
|
20679
|
+
*/
|
|
20680
|
+
export function requireFreshReleaseApproval(store, id, reason, epoch, now) {
|
|
20681
|
+
return store.transact(() => {
|
|
20682
|
+
const a = releaseAuthority(store, id);
|
|
20683
|
+
if (a === null)
|
|
20684
|
+
return false;
|
|
20685
|
+
// Already ended (revoked, expired, superseded, merged): no approval of it stands to carry the commit.
|
|
20686
|
+
if (!LIVE_RELEASE_STATES.includes(a.state))
|
|
20687
|
+
return a.state !== "merged";
|
|
20688
|
+
if (a.leaseEpoch !== epoch || a.state === "merge-reserved")
|
|
20689
|
+
return false;
|
|
20690
|
+
const changed = store.handle.prepare("UPDATE release_authority SET state = 'revoked', ended_at = ?, ended_by = ?, rev = rev + 1 WHERE id = ? AND rev = ?").run(now.toISOString(), `toolroll: ${reason}; a person approves it again`, id, a.rev);
|
|
20691
|
+
if (Number(changed.changes) !== 1)
|
|
20692
|
+
return false;
|
|
20693
|
+
store.recordAction({ at: now.toISOString(), actor: "toolroll", repo: a.repo, taskId: a.tipTask, runId: null, action: "release revoked", outcome: "needs-approval", source: "work", detail: `release ${id}: ${reason}` });
|
|
20694
|
+
return true;
|
|
20695
|
+
});
|
|
20696
|
+
}
|
|
20697
|
+
/** This process, as a release operation's issuer. Real time, whatever clock a caller passes for the rest. */
|
|
20698
|
+
export function currentReleaseIssuer() {
|
|
20699
|
+
const now = Date.now();
|
|
20700
|
+
return { pid: process.pid, started: new Date(now - Math.round(process.uptime() * 1000)).toISOString(), at: new Date(now).toISOString() };
|
|
20701
|
+
}
|
|
20702
|
+
/**
|
|
20703
|
+
* Whether an operation's issuer is proven gone (review round 2): no process has its pid, or the process that has it
|
|
20704
|
+
* started after the issuer was recorded (so it is another one). Anything unknown — no issuer recorded, a start time
|
|
20705
|
+
* that can't be read — is not proof, and the operation stays for a person.
|
|
20706
|
+
*/
|
|
20707
|
+
export function releaseIssuerGone(op) {
|
|
20708
|
+
const issuer = op.issuer;
|
|
20709
|
+
if (issuer === null || !Number.isSafeInteger(issuer.pid) || issuer.pid <= 0)
|
|
20710
|
+
return false;
|
|
20711
|
+
try {
|
|
20712
|
+
process.kill(issuer.pid, 0);
|
|
20713
|
+
}
|
|
20714
|
+
catch (error) {
|
|
20715
|
+
const code = error.code;
|
|
20716
|
+
if (code === "ESRCH")
|
|
20717
|
+
return true;
|
|
20718
|
+
if (code !== "EPERM")
|
|
20719
|
+
return false;
|
|
20720
|
+
}
|
|
20721
|
+
const started = processStartedAt(issuer.pid);
|
|
20722
|
+
const recorded = Date.parse(issuer.at);
|
|
20723
|
+
// ps shows whole seconds (a lower bound): a start a full second after the issuer was recorded is another process.
|
|
20724
|
+
return started !== null && Number.isFinite(recorded) && started > recorded + 1000;
|
|
20725
|
+
}
|
|
20726
|
+
/** A live process's start time from ps (whole seconds, UTC), or null when it can't be read. */
|
|
20727
|
+
function processStartedAt(pid) {
|
|
20728
|
+
if (process.platform === "win32")
|
|
20729
|
+
return null;
|
|
20730
|
+
try {
|
|
20731
|
+
const raw = execFileSync("ps", ["-p", String(pid), "-o", "lstart="], { encoding: "utf8", env: { ...process.env, LC_ALL: "C", TZ: "UTC" }, stdio: ["ignore", "pipe", "ignore"], maxBuffer: 1024, timeout: 1_000, killSignal: "SIGKILL" })
|
|
20732
|
+
.trim().replace(/ +/g, " ");
|
|
20733
|
+
const match = /^(?:Sun|Mon|Tue|Wed|Thu|Fri|Sat) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) (\d{1,2}) (\d{2}:\d{2}:\d{2}) (\d{4})$/.exec(raw);
|
|
20734
|
+
if (match === null)
|
|
20735
|
+
return null;
|
|
20736
|
+
const at = Date.parse(`${match[2].padStart(2, "0")} ${match[1]} ${match[4]} ${match[3]} GMT`);
|
|
20737
|
+
return Number.isFinite(at) ? at : null;
|
|
20738
|
+
}
|
|
20739
|
+
catch {
|
|
20740
|
+
return null;
|
|
20741
|
+
}
|
|
20742
|
+
}
|