toolroll 0.9.56 → 0.9.58
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 +16 -0
- package/dist/action-ledger.js +0 -4
- package/dist/actor.d.ts +0 -10
- package/dist/actor.js +1 -18
- package/dist/api-tokens.d.ts +33 -2
- package/dist/api-tokens.js +39 -11
- package/dist/assignment-adapters.d.ts +5 -0
- package/dist/assignment-delivery.js +1 -1
- package/dist/assignment-status.js +1 -1
- package/dist/assignment.d.ts +24 -3
- package/dist/assignment.js +39 -12
- package/dist/browser-workspace.js +1 -1
- package/dist/chat-delivery.js +3 -3
- package/dist/chat-voice.js +1 -1
- package/dist/check-levels.d.ts +3 -0
- package/dist/check-levels.js +5 -0
- package/dist/cli-http.d.ts +7 -0
- package/dist/cli-http.js +30 -4
- package/dist/cli.js +2 -1
- package/dist/coordinator.d.ts +2 -1
- package/dist/coordinator.js +22 -31
- package/dist/desktop-update.d.ts +1 -1
- package/dist/dispose.js +0 -5
- package/dist/export.js +2 -2
- package/dist/lead-status.d.ts +1 -1
- package/dist/lead-status.js +2 -2
- package/dist/mcp-http.js +5 -5
- package/dist/operate.js +19 -9
- package/dist/password-bearer.d.ts +27 -0
- package/dist/password-bearer.js +59 -0
- package/dist/proof.d.ts +9 -0
- package/dist/proof.js +12 -0
- package/dist/release-coverage.d.ts +16 -0
- package/dist/release-coverage.js +92 -0
- package/dist/remote-command.js +0 -2
- package/dist/remote-exec.d.ts +19 -0
- package/dist/remote-exec.js +9 -8
- package/dist/request-budget.d.ts +1 -1
- package/dist/scope.js +2 -1
- package/dist/serve.js +15 -0
- package/dist/server/chat.js +8 -6
- package/dist/server/edge-refusal.d.ts +2 -0
- package/dist/server/edge-refusal.js +8 -6
- package/dist/server/guards.js +5 -4
- package/dist/server/http.d.ts +2 -0
- package/dist/server/remote.js +6 -13
- package/dist/server/render-results.d.ts +4 -1
- package/dist/server/render-results.js +23 -9
- package/dist/server/route-table.js +0 -3
- package/dist/server/tasks.js +28 -25
- package/dist/session-cli.d.ts +38 -9
- package/dist/session-cli.js +141 -154
- package/dist/session-contract.js +2 -2
- package/dist/session-remote.d.ts +19 -0
- package/dist/session-remote.js +86 -0
- package/dist/store.d.ts +31 -11
- package/dist/store.js +276 -95
- package/dist/task-outcome-cli.js +3 -1
- package/dist/task-status.d.ts +59 -7
- package/dist/task-status.js +132 -16
- package/dist/team-cli.d.ts +3 -0
- package/dist/team-cli.js +23 -3
- package/dist/team-runtime.js +1 -1
- package/dist/telegram-status.d.ts +11 -4
- package/dist/telegram-status.js +17 -10
- package/dist/telegram.js +4 -4
- package/dist/toolroll-update.js +3 -1
- package/dist/work-index.d.ts +6 -1
- package/dist/work-index.js +50 -8
- package/dist/workspace-ui.js +2 -1
- package/package.json +1 -1
- package/dist/session-http.d.ts +0 -24
- package/dist/session-http.js +0 -170
- package/dist/session-server.d.ts +0 -17
- package/dist/session-server.js +0 -39
package/dist/store.js
CHANGED
|
@@ -19,10 +19,10 @@ import { validateTaskText } from "./task-text.js";
|
|
|
19
19
|
import { flowDefinitionForStore, flowDefinitionFromStore } from "./flows.js";
|
|
20
20
|
import { cardOutputsForStore, cardOutputsFromStore } from "./contracts/stage-output.js";
|
|
21
21
|
import { chatControlHref, chatResultHref } from "./chat-controls.js";
|
|
22
|
-
import { actorLabel, currentActor
|
|
22
|
+
import { actorLabel, currentActor } from "./actor.js";
|
|
23
23
|
import { scanForSecrets } from "./evidence.js";
|
|
24
24
|
import { LIVE_ACTIVITY_KINDS } from "./live.js";
|
|
25
|
-
import { tokenPurpose, tokenRotationProblem } from "./api-tokens.js";
|
|
25
|
+
import { hashSecret, mintLeadToken, parseCredential, secretMatches, tokenPurpose, tokenRotationProblem } from "./api-tokens.js";
|
|
26
26
|
/**
|
|
27
27
|
* The database: a small task store, and the operational overlay beside it.
|
|
28
28
|
*
|
|
@@ -221,21 +221,11 @@ CREATE TABLE IF NOT EXISTS lead_config (
|
|
|
221
221
|
about_json TEXT NOT NULL DEFAULT '[]'
|
|
222
222
|
);
|
|
223
223
|
`;
|
|
224
|
-
/** Pings follow responsibility (no version bump: additive only).
|
|
225
|
-
* for
|
|
224
|
+
/** Pings follow responsibility (no version bump: additive only). A lead token (v118: a `credential` row of kind
|
|
225
|
+
* 'lead') is an agent acting for its owner, only its hash kept. `task_act` is who filed, approved, cancelled, completed or handed on a task
|
|
226
226
|
* (`lead` 1: the owner's lead; `person`/`why`: who the lead asked, and why). `notification_actor` is whose act made
|
|
227
227
|
* a fact. `task_replacement` is the task that replaced a cancelled one. `project_mute` is a person's muted projects. */
|
|
228
228
|
const LEAD_QUIET_SCHEMA = `
|
|
229
|
-
CREATE TABLE IF NOT EXISTS lead_credential (
|
|
230
|
-
id TEXT PRIMARY KEY,
|
|
231
|
-
owner TEXT NOT NULL,
|
|
232
|
-
secret_hash TEXT NOT NULL,
|
|
233
|
-
created_at TEXT NOT NULL,
|
|
234
|
-
created_by TEXT NOT NULL,
|
|
235
|
-
revoked_at TEXT,
|
|
236
|
-
revoked_by TEXT
|
|
237
|
-
);
|
|
238
|
-
CREATE INDEX IF NOT EXISTS lead_credential_owner ON lead_credential (owner);
|
|
239
229
|
CREATE TABLE IF NOT EXISTS task_act (
|
|
240
230
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
241
231
|
task_ref INTEGER NOT NULL,
|
|
@@ -566,7 +556,59 @@ const REGISTERED_AT_FILING = "again, as it was filed";
|
|
|
566
556
|
// the lead's subagents (teammate* → subagent*, their rows' teammate column → subagent), with every row, id and link kept.
|
|
567
557
|
// mate_turn (and chat_turn.mate_turn) keep their names this release: the update gate the previous build's updater lifts
|
|
568
558
|
// names them byte for byte (desktop-update-gate.ts), so they move once every updater reads either name.
|
|
569
|
-
|
|
559
|
+
// v118 (D7) keeps every machine credential in one table, `credential`: a person's API tokens (so_…), the lead's tokens
|
|
560
|
+
// (lt_…) and coordinators, each with its kind, read/act access, project limit, expiry, revocation and generation. Every
|
|
561
|
+
// row moves with its id, hash and dates; the references to coordinators and API tokens point at it.
|
|
562
|
+
export const SCHEMA_VERSION = 118;
|
|
563
|
+
/** v118: the tables merged into `credential`, in the order they move (a person's API tokens keep their ids first). */
|
|
564
|
+
export const V118_MERGED_TABLES = Object.freeze({ api_token: "person", coordinator_credential: "coordinator", lead_credential: "lead" });
|
|
565
|
+
/** The foreign keys that named a merged table, exactly as their DDL wrote them; each names `credential(id)` instead. */
|
|
566
|
+
const V118_REFERENCES = /REFERENCES (?:coordinator_credential\(cid\)|api_token\(id\))/g;
|
|
567
|
+
/** The columns that hold a coordinator's id: rewritten only when the merge had to give one a new canonical id. */
|
|
568
|
+
const V118_COORDINATOR_COLUMNS = [["task_ref", "coordinator_cid"], ["coordinator_event", "cid"], ["mcp_idempotency", "cid"], ["coordinator_proposal", "cid"]];
|
|
569
|
+
/**
|
|
570
|
+
* v118 (D7): one credential table for every machine credential. `id` is the canonical identity every reference holds;
|
|
571
|
+
* `public_id` is the id the secret presents (so_<id>_…, lt_<id>_…) or a coordinator's cid, unique per kind. They are
|
|
572
|
+
* equal unless the merge found two kinds sharing an id. `account` is the person a token acts as (a lead's owner; for
|
|
573
|
+
* a coordinator, who minted it); `projects_json` the project limit (a coordinator's projects); `generation` which
|
|
574
|
+
* issue it is (1, then one more per rotation, per lead token for the same owner, per coordinator of the same name).
|
|
575
|
+
* Lead tokens never expire; a coordinator made before v101 has no expiry.
|
|
576
|
+
*/
|
|
577
|
+
export const CREDENTIAL_TABLE = `
|
|
578
|
+
CREATE TABLE IF NOT EXISTS credential (
|
|
579
|
+
id TEXT PRIMARY KEY,
|
|
580
|
+
kind TEXT NOT NULL CHECK (kind IN ('person','lead','coordinator')),
|
|
581
|
+
public_id TEXT NOT NULL,
|
|
582
|
+
account TEXT NOT NULL,
|
|
583
|
+
name TEXT NOT NULL,
|
|
584
|
+
secret_hash TEXT NOT NULL,
|
|
585
|
+
access TEXT NOT NULL CHECK (access IN ('read','act')),
|
|
586
|
+
projects_json TEXT,
|
|
587
|
+
per_hour INTEGER,
|
|
588
|
+
purpose TEXT NOT NULL DEFAULT 'api' CHECK (purpose IN ('api','mcp')),
|
|
589
|
+
generation INTEGER NOT NULL DEFAULT 1 CHECK (generation >= 1),
|
|
590
|
+
created_at TEXT NOT NULL,
|
|
591
|
+
created_by TEXT NOT NULL,
|
|
592
|
+
expires_at TEXT,
|
|
593
|
+
last_used_at TEXT,
|
|
594
|
+
revoked_at TEXT,
|
|
595
|
+
revoked_by TEXT,
|
|
596
|
+
replaces TEXT,
|
|
597
|
+
replaced_by TEXT,
|
|
598
|
+
overlap_until TEXT,
|
|
599
|
+
UNIQUE (kind, public_id)
|
|
600
|
+
);
|
|
601
|
+
`;
|
|
602
|
+
/** Created once the merged tables (and their index of the same name) are gone. */
|
|
603
|
+
const CREDENTIAL_INDEXES = `
|
|
604
|
+
CREATE INDEX IF NOT EXISTS credential_account ON credential (account, kind);
|
|
605
|
+
CREATE INDEX IF NOT EXISTS credential_coordinator_secret ON credential (secret_hash) WHERE kind = 'coordinator';
|
|
606
|
+
CREATE UNIQUE INDEX IF NOT EXISTS coordinator_live_name ON credential (name) WHERE kind = 'coordinator' AND revoked_at IS NULL;
|
|
607
|
+
CREATE TRIGGER IF NOT EXISTS ledger_coordinator_minted AFTER INSERT ON credential WHEN NEW.kind = 'coordinator' BEGIN
|
|
608
|
+
INSERT INTO action_ledger(at,actor,repo,task_id,run_id,action,outcome,source,detail) VALUES (NEW.created_at, NEW.created_by, NULL, NULL, NULL,
|
|
609
|
+
'coordinator minted: ' || NEW.name, 'minted', 'access', 'projects: ' || NEW.projects_json);
|
|
610
|
+
END;
|
|
611
|
+
`;
|
|
570
612
|
/** v117: each table renamed, by its old name. Rows, ids, foreign keys and indexes go with it (renameForV117). */
|
|
571
613
|
export const V117_RENAMED_TABLES = Object.freeze({
|
|
572
614
|
mate_session: "lead_session", mate_thread: "lead_thread", mate_message: "lead_message", mate_proposal: "lead_proposal",
|
|
@@ -587,10 +629,10 @@ function presentTable(db, name) {
|
|
|
587
629
|
const old = V117_OLD_NAME[name];
|
|
588
630
|
return old !== undefined && !tableExists(db, name) && tableExists(db, old) ? old : name;
|
|
589
631
|
}
|
|
590
|
-
/** A row `PRAGMA foreign_key_check` reports, by today's table names: a row already pointing nowhere before v117
|
|
591
|
-
* same row after it, though RENAME now reports
|
|
632
|
+
/** A row `PRAGMA foreign_key_check` reports, by today's table names: a row already pointing nowhere before v117 (or
|
|
633
|
+
* v118) is the same row after it, though RENAME (or the credential merge) now reports what it points at by the new names. */
|
|
592
634
|
export function orphanKey(row) {
|
|
593
|
-
const named = (value) => typeof value === "string" ? V117_RENAMED_TABLES[value] ?? value : value;
|
|
635
|
+
const named = (value) => typeof value === "string" ? V117_RENAMED_TABLES[value] ?? (Object.hasOwn(V118_MERGED_TABLES, value) ? "credential" : value) : value;
|
|
594
636
|
return JSON.stringify({ table: named(row["table"]), rowid: row["rowid"], parent: named(row["parent"]), fkid: row["fkid"] });
|
|
595
637
|
}
|
|
596
638
|
/** Shapes compare by today's names: a pre-v117 table, index or reference names the same shape by its old one. */
|
|
@@ -636,9 +678,11 @@ const V115_DROPPED_REFERENCES = /\s+REFERENCES (?:contestant|contest|tournament_
|
|
|
636
678
|
* transaction that checks each count before the old tables are dropped; the
|
|
637
679
|
* rehearsal checks each moved table's rows arrived (HISTORY_RULES). v117 renames the mate's tables to the lead's and the
|
|
638
680
|
* teammates' to its subagents' (SQLite's RENAME, every row, id and link carried), renames their teammate column, and rewrites four internal kinds from 'teammate' to 'subagent' in place; the rehearsal checks
|
|
639
|
-
* each renamed table's rows arrived under its new name.
|
|
681
|
+
* each renamed table's rows arrived under its new name. v118 (D7) is checked the same way: every row of api_token,
|
|
682
|
+
* coordinator_credential and lead_credential moves into credential under its kind in one transaction that counts them
|
|
683
|
+
* before the old tables drop, and the references that named them name credential in place, their rows untouched.
|
|
640
684
|
*/
|
|
641
|
-
export const UPDATE_SAFE_MIGRATIONS = Object.freeze([108, 109, 110, 111, 112, 113, 114, 115, 116, 117]);
|
|
685
|
+
export const UPDATE_SAFE_MIGRATIONS = Object.freeze([108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118]);
|
|
642
686
|
/** Whether a database settled at `version` reaches this build's schema through update-safe migrations alone. */
|
|
643
687
|
export function updateSafeSchema(version) {
|
|
644
688
|
if (version === null || !Number.isSafeInteger(version) || version <= 0 || version > SCHEMA_VERSION)
|
|
@@ -673,22 +717,9 @@ CREATE TABLE IF NOT EXISTS scope_author (
|
|
|
673
717
|
PRIMARY KEY (task_id, digest, author)
|
|
674
718
|
);
|
|
675
719
|
`;
|
|
676
|
-
/** v101:
|
|
720
|
+
/** v101: the browser sessions that survive a restart (API tokens are `credential` rows since v118). Only hashes of
|
|
721
|
+
* their secrets are kept. */
|
|
677
722
|
const CREDENTIALS_SCHEMA = `
|
|
678
|
-
CREATE TABLE IF NOT EXISTS api_token (
|
|
679
|
-
id TEXT PRIMARY KEY,
|
|
680
|
-
account TEXT NOT NULL REFERENCES approver(name),
|
|
681
|
-
name TEXT NOT NULL,
|
|
682
|
-
secret_hash TEXT NOT NULL,
|
|
683
|
-
access TEXT NOT NULL CHECK (access IN ('read','act')),
|
|
684
|
-
created_at TEXT NOT NULL,
|
|
685
|
-
created_by TEXT NOT NULL,
|
|
686
|
-
expires_at TEXT NOT NULL,
|
|
687
|
-
last_used_at TEXT,
|
|
688
|
-
revoked_at TEXT,
|
|
689
|
-
revoked_by TEXT
|
|
690
|
-
);
|
|
691
|
-
CREATE INDEX IF NOT EXISTS api_token_account ON api_token (account);
|
|
692
723
|
CREATE TABLE IF NOT EXISTS web_session (
|
|
693
724
|
id_hash TEXT PRIMARY KEY,
|
|
694
725
|
account TEXT NOT NULL,
|
|
@@ -707,7 +738,7 @@ CREATE INDEX IF NOT EXISTS web_session_account ON web_session (account);
|
|
|
707
738
|
`;
|
|
708
739
|
/**
|
|
709
740
|
* v113: MCP sign-in (OAuth 2.1, mcp-oauth.ts). A registered client, a one-time code (its hash, a minute at most), and
|
|
710
|
-
* each grant: the ordinary API token it signs in with (revoked like any other), the account generation and exact
|
|
741
|
+
* each grant: the ordinary API token it signs in with (a person's `credential` row, revoked like any other), the account generation and exact
|
|
711
742
|
* projects the person chose, and every issued refresh-secret hash (any replay ends the whole family).
|
|
712
743
|
*/
|
|
713
744
|
const OAUTH_SCHEMA = `
|
|
@@ -734,7 +765,7 @@ CREATE TABLE IF NOT EXISTS oauth_code (
|
|
|
734
765
|
token TEXT
|
|
735
766
|
);
|
|
736
767
|
CREATE TABLE IF NOT EXISTS oauth_grant (
|
|
737
|
-
token TEXT PRIMARY KEY REFERENCES
|
|
768
|
+
token TEXT PRIMARY KEY REFERENCES credential(id),
|
|
738
769
|
client TEXT NOT NULL REFERENCES oauth_client(id),
|
|
739
770
|
account TEXT NOT NULL,
|
|
740
771
|
generation INTEGER NOT NULL,
|
|
@@ -1085,7 +1116,7 @@ CREATE TABLE IF NOT EXISTS task_ref (
|
|
|
1085
1116
|
-- AUTHORITATIVE linkage — written only by the branded coordinator door,
|
|
1086
1117
|
-- joined by exact cid, never parsed out of filed_via (which stays
|
|
1087
1118
|
-- display-only). NULL is every other filer.
|
|
1088
|
-
coordinator_cid TEXT REFERENCES
|
|
1119
|
+
coordinator_cid TEXT REFERENCES credential(id),
|
|
1089
1120
|
-- The deliverable (v34, scout tasks): 'branch' is every task until now;
|
|
1090
1121
|
-- 'report' dispatches a scout — a read-only session whose whole output
|
|
1091
1122
|
-- is one report artifact. Stamped at filing, never rewritten.
|
|
@@ -1093,29 +1124,15 @@ CREATE TABLE IF NOT EXISTS task_ref (
|
|
|
1093
1124
|
UNIQUE (backend, external_id)
|
|
1094
1125
|
);
|
|
1095
1126
|
|
|
1096
|
-
-- v31 (MCP gateway): the coordinator principal (DESIGN.md 9b)
|
|
1097
|
-
--
|
|
1098
|
-
--
|
|
1099
|
-
-- living, so revoke-and-remint never conflates generations in audit or
|
|
1100
|
-
-- cap accounting.
|
|
1101
|
-
CREATE TABLE IF NOT EXISTS coordinator_credential (
|
|
1102
|
-
cid TEXT PRIMARY KEY,
|
|
1103
|
-
name TEXT NOT NULL,
|
|
1104
|
-
credential_hash TEXT NOT NULL,
|
|
1105
|
-
repos TEXT NOT NULL,
|
|
1106
|
-
per_hour INTEGER NOT NULL,
|
|
1107
|
-
created_by TEXT NOT NULL,
|
|
1108
|
-
created_at TEXT NOT NULL,
|
|
1109
|
-
revoked_at TEXT
|
|
1110
|
-
);
|
|
1111
|
-
CREATE UNIQUE INDEX IF NOT EXISTS coordinator_live_name
|
|
1112
|
-
ON coordinator_credential (name) WHERE revoked_at IS NULL;
|
|
1127
|
+
-- v31 (MCP gateway): the coordinator principal (DESIGN.md 9b) is a row of
|
|
1128
|
+
-- credential (v118, kind 'coordinator'): its id is the IMMUTABLE identity,
|
|
1129
|
+
-- so revoke-and-remint never conflates generations in audit or cap accounting.
|
|
1113
1130
|
|
|
1114
1131
|
-- The coordinator audit (v31): filing, dismissal, and revocation rows,
|
|
1115
1132
|
-- inserted ATOMICALLY with their state change — never a separate write.
|
|
1116
1133
|
CREATE TABLE IF NOT EXISTS coordinator_event (
|
|
1117
1134
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1118
|
-
cid TEXT NOT NULL REFERENCES
|
|
1135
|
+
cid TEXT NOT NULL REFERENCES credential(id),
|
|
1119
1136
|
kind TEXT NOT NULL CHECK (kind IN ('filed','dismissed','revoked')),
|
|
1120
1137
|
task_id TEXT,
|
|
1121
1138
|
detail TEXT,
|
|
@@ -1127,7 +1144,7 @@ CREATE TABLE IF NOT EXISTS coordinator_event (
|
|
|
1127
1144
|
-- the original answer without a rate charge; a different digest refuses.
|
|
1128
1145
|
-- The global Store.replay is digest-blind and is NOT this.
|
|
1129
1146
|
CREATE TABLE IF NOT EXISTS mcp_idempotency (
|
|
1130
|
-
cid TEXT NOT NULL REFERENCES
|
|
1147
|
+
cid TEXT NOT NULL REFERENCES credential(id),
|
|
1131
1148
|
key TEXT NOT NULL,
|
|
1132
1149
|
request_digest TEXT NOT NULL,
|
|
1133
1150
|
task_id TEXT NOT NULL,
|
|
@@ -1789,7 +1806,7 @@ CREATE INDEX IF NOT EXISTS lead_proposal_thread ON lead_proposal (thread, state)
|
|
|
1789
1806
|
|
|
1790
1807
|
CREATE TABLE IF NOT EXISTS coordinator_proposal (
|
|
1791
1808
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1792
|
-
cid TEXT NOT NULL REFERENCES
|
|
1809
|
+
cid TEXT NOT NULL REFERENCES credential(id),
|
|
1793
1810
|
name TEXT NOT NULL,
|
|
1794
1811
|
repo TEXT NOT NULL,
|
|
1795
1812
|
kind TEXT NOT NULL CHECK (kind IN ('next','reserve','hold','unhold','scope','cancel','answer')),
|
|
@@ -3371,14 +3388,17 @@ function initializeStore(db, file) {
|
|
|
3371
3388
|
db.exec(TEAM_SCHEMA);
|
|
3372
3389
|
db.exec(SSO_SCHEMA);
|
|
3373
3390
|
db.exec(CREDENTIALS_SCHEMA);
|
|
3374
|
-
// v111: a token's project limit (null: every project its person may use) and its rotation.
|
|
3375
|
-
|
|
3376
|
-
|
|
3377
|
-
|
|
3378
|
-
|
|
3391
|
+
// v111: a token's project limit (null: every project its person may use) and its rotation. (Until v118 moves it.)
|
|
3392
|
+
if (tableExists(db, "api_token")) {
|
|
3393
|
+
addColumn(db, "api_token", "projects_json", "TEXT");
|
|
3394
|
+
addColumn(db, "api_token", "replaces", "TEXT");
|
|
3395
|
+
addColumn(db, "api_token", "replaced_by", "TEXT");
|
|
3396
|
+
addColumn(db, "api_token", "overlap_until", "TEXT");
|
|
3397
|
+
}
|
|
3379
3398
|
// v113: MCP sign-in (OAuth), and whether a token is an ordinary one or an MCP sign-in's.
|
|
3380
3399
|
db.exec(OAUTH_SCHEMA);
|
|
3381
|
-
|
|
3400
|
+
if (tableExists(db, "api_token"))
|
|
3401
|
+
addColumn(db, "api_token", "purpose", "TEXT NOT NULL DEFAULT 'api' CHECK (purpose IN ('api','mcp'))");
|
|
3382
3402
|
db.exec(APPROVAL_SCHEMA);
|
|
3383
3403
|
db.exec(LEDGER_CHAIN_SCHEMA);
|
|
3384
3404
|
db.exec(MONITORING_SCHEMA);
|
|
@@ -3531,6 +3551,8 @@ CREATE INDEX IF NOT EXISTS run_checkpoint_by_task ON run_checkpoint (task_ref, i
|
|
|
3531
3551
|
migrateToV115(db);
|
|
3532
3552
|
// v116: every chat app onto the shared chat tables, after v115 and every older step gave the old tables their last shape.
|
|
3533
3553
|
convertChatTables(db);
|
|
3554
|
+
// v118: every machine credential into the one credential table, after every older step gave the old tables their last shape.
|
|
3555
|
+
mergeCredentialsForV118(db);
|
|
3534
3556
|
// Keep the epoch until reclamation succeeds, including a retry after compaction already committed. A new file has
|
|
3535
3557
|
// nothing to reclaim.
|
|
3536
3558
|
if (!fresh)
|
|
@@ -4180,6 +4202,128 @@ function renameForV117(db) {
|
|
|
4180
4202
|
if (check.length !== 1 || check[0] !== "ok")
|
|
4181
4203
|
throw new Error(`the v117 renames left the database damaged (${check.slice(0, 3).join("; ")})`);
|
|
4182
4204
|
}
|
|
4205
|
+
/**
|
|
4206
|
+
* v118 (D7): the three credential tables become one. In one transaction every row moves with its id, hash, dates,
|
|
4207
|
+
* access, project limit, expiry and revocation; the counts are checked before the old tables drop; the foreign keys
|
|
4208
|
+
* that named them name `credential(id)` in place (rows untouched); and the coordinator ledger trigger follows. A
|
|
4209
|
+
* person's API token keeps its id. A coordinator or lead token whose id another kind already holds gets `<kind>:<id>`
|
|
4210
|
+
* as its canonical id (a coordinator's references follow it); its secret presents the same id as before either way.
|
|
4211
|
+
*/
|
|
4212
|
+
function mergeCredentialsForV118(db) {
|
|
4213
|
+
db.exec(CREDENTIAL_TABLE);
|
|
4214
|
+
const present = Object.keys(V118_MERGED_TABLES).filter(table => tableExists(db, table));
|
|
4215
|
+
const referring = db.prepare("SELECT name, sql FROM sqlite_master WHERE type = 'table' AND sql IS NOT NULL").all()
|
|
4216
|
+
.filter(row => !Object.hasOwn(V118_MERGED_TABLES, row.name) && new RegExp(V118_REFERENCES.source).test(row.sql));
|
|
4217
|
+
if (present.length === 0 && referring.length === 0) {
|
|
4218
|
+
db.exec(CREDENTIAL_INDEXES);
|
|
4219
|
+
return;
|
|
4220
|
+
}
|
|
4221
|
+
const orphans = () => new Set(db.prepare("PRAGMA foreign_key_check").all().map(orphanKey));
|
|
4222
|
+
db.exec("PRAGMA foreign_keys = OFF");
|
|
4223
|
+
try {
|
|
4224
|
+
const before = orphans();
|
|
4225
|
+
db.exec("BEGIN IMMEDIATE");
|
|
4226
|
+
try {
|
|
4227
|
+
const taken = new Set(db.prepare("SELECT id FROM credential").all().map(row => row.id));
|
|
4228
|
+
const already = Object.fromEntries(Object.values(V118_MERGED_TABLES).map(kind => [kind, Number(db.prepare("SELECT count(*) AS n FROM credential WHERE kind = ?").get(kind)["n"])]));
|
|
4229
|
+
const claim = (kind, legacy) => {
|
|
4230
|
+
let id = legacy;
|
|
4231
|
+
for (let n = 1; taken.has(id); n++)
|
|
4232
|
+
id = n === 1 ? `${kind}:${legacy}` : `${kind}:${legacy}:${n}`;
|
|
4233
|
+
taken.add(id);
|
|
4234
|
+
return id;
|
|
4235
|
+
};
|
|
4236
|
+
const insert = db.prepare(`INSERT INTO credential (id, kind, public_id, account, name, secret_hash, access, projects_json, per_hour, purpose, generation,
|
|
4237
|
+
created_at, created_by, expires_at, last_used_at, revoked_at, revoked_by, replaces, replaced_by, overlap_until) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
|
4238
|
+
const text = (value) => value == null ? null : String(value);
|
|
4239
|
+
if (tableExists(db, "api_token")) {
|
|
4240
|
+
const rows = db.prepare("SELECT * FROM api_token ORDER BY rowid").all();
|
|
4241
|
+
const byId = new Map(rows.map(row => [String(row["id"]), row]));
|
|
4242
|
+
// A rotation's replacement is one generation on from the token it replaced.
|
|
4243
|
+
const generation = (row, seen = new Set()) => {
|
|
4244
|
+
const from = text(row["replaces"]), earlier = from === null ? undefined : byId.get(from);
|
|
4245
|
+
if (earlier === undefined || seen.has(from))
|
|
4246
|
+
return 1;
|
|
4247
|
+
seen.add(from);
|
|
4248
|
+
return generation(earlier, seen) + 1;
|
|
4249
|
+
};
|
|
4250
|
+
for (const row of rows) {
|
|
4251
|
+
const id = String(row["id"]);
|
|
4252
|
+
// Persons move first, into an empty table: an API token's id is never renamed (its grants and budgets name it).
|
|
4253
|
+
if (claim("person", id) !== id)
|
|
4254
|
+
throw new Error(`API token ${id} collides with another credential`);
|
|
4255
|
+
insert.run(id, "person", id, String(row["account"]), String(row["name"]), String(row["secret_hash"]), row["access"] === "act" ? "act" : "read", text(row["projects_json"]), null, row["purpose"] === "mcp" ? "mcp" : "api", generation(row), String(row["created_at"]), String(row["created_by"]), text(row["expires_at"]), text(row["last_used_at"]), text(row["revoked_at"]), text(row["revoked_by"]), text(row["replaces"]), text(row["replaced_by"]), text(row["overlap_until"]));
|
|
4256
|
+
}
|
|
4257
|
+
}
|
|
4258
|
+
if (tableExists(db, "coordinator_credential")) {
|
|
4259
|
+
const issued = new Map();
|
|
4260
|
+
for (const row of db.prepare("SELECT * FROM coordinator_credential ORDER BY created_at, rowid").all()) {
|
|
4261
|
+
const cid = String(row["cid"]), id = claim("coordinator", cid), name = String(row["name"]);
|
|
4262
|
+
const generation = (issued.get(name) ?? 0) + 1;
|
|
4263
|
+
issued.set(name, generation);
|
|
4264
|
+
insert.run(id, "coordinator", cid, String(row["created_by"]), name, String(row["credential_hash"]), "act", String(row["repos"]), Number(row["per_hour"]), "api", generation, String(row["created_at"]), String(row["created_by"]), text(row["expires_at"]), null, text(row["revoked_at"]), null, null, null, null);
|
|
4265
|
+
if (id !== cid)
|
|
4266
|
+
for (const [table, column] of V118_COORDINATOR_COLUMNS) {
|
|
4267
|
+
if (tableExists(db, table) && hasColumn(db, table, column))
|
|
4268
|
+
db.prepare(`UPDATE "${table}" SET "${column}" = ? WHERE "${column}" = ?`).run(id, cid);
|
|
4269
|
+
}
|
|
4270
|
+
}
|
|
4271
|
+
}
|
|
4272
|
+
if (tableExists(db, "lead_credential")) {
|
|
4273
|
+
const issued = new Map();
|
|
4274
|
+
for (const row of db.prepare("SELECT * FROM lead_credential ORDER BY created_at, rowid").all()) {
|
|
4275
|
+
const legacy = String(row["id"]), owner = String(row["owner"]);
|
|
4276
|
+
const generation = (issued.get(owner) ?? 0) + 1;
|
|
4277
|
+
issued.set(owner, generation);
|
|
4278
|
+
insert.run(claim("lead", legacy), "lead", legacy, owner, "lead token", String(row["secret_hash"]), "act", null, null, "api", generation, String(row["created_at"]), String(row["created_by"]), null, null, text(row["revoked_at"]), text(row["revoked_by"]), null, null, null);
|
|
4279
|
+
}
|
|
4280
|
+
}
|
|
4281
|
+
for (const [table, kind] of Object.entries(V118_MERGED_TABLES)) {
|
|
4282
|
+
if (!tableExists(db, table))
|
|
4283
|
+
continue;
|
|
4284
|
+
const had = Number(db.prepare(`SELECT count(*) AS n FROM "${table}"`).get()["n"]);
|
|
4285
|
+
const moved = Number(db.prepare("SELECT count(*) AS n FROM credential WHERE kind = ?").get(kind)["n"]);
|
|
4286
|
+
if (had !== moved - already[kind])
|
|
4287
|
+
throw new Error(`${table} kept ${had} rows but ${moved} arrived in credential`);
|
|
4288
|
+
}
|
|
4289
|
+
// The references keep their rows and point at the one table (SQLite's documented procedure, as v115 does).
|
|
4290
|
+
// Node 24's SQLite opens in defensive mode, which forbids it; it is lifted for the rewrite only.
|
|
4291
|
+
if (referring.length > 0) {
|
|
4292
|
+
const version = Number(db.prepare("PRAGMA schema_version").get()?.["schema_version"]);
|
|
4293
|
+
const defensive = db.enableDefensive?.bind(db);
|
|
4294
|
+
defensive?.(false);
|
|
4295
|
+
db.exec("PRAGMA writable_schema = ON");
|
|
4296
|
+
try {
|
|
4297
|
+
const rewrite = db.prepare("UPDATE sqlite_master SET sql = ? WHERE type = 'table' AND name = ?");
|
|
4298
|
+
for (const row of referring)
|
|
4299
|
+
rewrite.run(row.sql.replace(V118_REFERENCES, "REFERENCES credential(id)"), row.name);
|
|
4300
|
+
db.exec(`PRAGMA schema_version = ${version + 1}`);
|
|
4301
|
+
}
|
|
4302
|
+
finally {
|
|
4303
|
+
db.exec("PRAGMA writable_schema = OFF");
|
|
4304
|
+
defensive?.(true);
|
|
4305
|
+
}
|
|
4306
|
+
}
|
|
4307
|
+
// Their indexes and the coordinator ledger trigger go with them; the one table gets its own.
|
|
4308
|
+
for (const table of present)
|
|
4309
|
+
db.exec(`DROP TABLE "${table}"`);
|
|
4310
|
+
db.exec(CREDENTIAL_INDEXES);
|
|
4311
|
+
if ([...orphans()].some(row => !before.has(row)))
|
|
4312
|
+
throw new Error("foreign keys did not survive the v118 credential merge");
|
|
4313
|
+
db.exec("COMMIT");
|
|
4314
|
+
}
|
|
4315
|
+
catch (error) {
|
|
4316
|
+
db.exec("ROLLBACK");
|
|
4317
|
+
throw error;
|
|
4318
|
+
}
|
|
4319
|
+
}
|
|
4320
|
+
finally {
|
|
4321
|
+
db.exec("PRAGMA foreign_keys = ON");
|
|
4322
|
+
}
|
|
4323
|
+
const check = db.prepare("PRAGMA integrity_check").all().map(row => String(row["integrity_check"]));
|
|
4324
|
+
if (check.length !== 1 || check[0] !== "ok")
|
|
4325
|
+
throw new Error(`the v118 credential merge left the database damaged (${check.slice(0, 3).join("; ")})`);
|
|
4326
|
+
}
|
|
4183
4327
|
function migrateToV115(db) {
|
|
4184
4328
|
const present = V115_DROPPED_TABLES.filter(table => tableExists(db, table));
|
|
4185
4329
|
const referring = db.prepare("SELECT name, sql FROM sqlite_master WHERE type = 'table' AND sql IS NOT NULL").all()
|
|
@@ -6358,11 +6502,11 @@ export class Store {
|
|
|
6358
6502
|
// with a revocation or an access change, not a password change.
|
|
6359
6503
|
this.db.prepare("DELETE FROM web_session WHERE account = ?").run(approver);
|
|
6360
6504
|
if (by !== "credential-rotation") {
|
|
6361
|
-
for (const row of this.db.prepare("SELECT id FROM
|
|
6505
|
+
for (const row of this.db.prepare("SELECT id FROM credential WHERE kind = 'person' AND account = ? AND revoked_at IS NULL").all(approver))
|
|
6362
6506
|
this.revokeApiToken(String(row["id"]), by, now, "ended with the account's standing");
|
|
6363
|
-
for (const row of this.db.prepare("SELECT cid, name FROM
|
|
6507
|
+
for (const row of this.db.prepare("SELECT id AS cid, name FROM credential WHERE kind = 'coordinator' AND created_by = ? AND revoked_at IS NULL").all(approver)) {
|
|
6364
6508
|
const cid = String(row["cid"]);
|
|
6365
|
-
this.db.prepare("UPDATE
|
|
6509
|
+
this.db.prepare("UPDATE credential SET revoked_at = ?, revoked_by = ? WHERE id = ? AND kind = 'coordinator'").run(stamp, by, cid);
|
|
6366
6510
|
this.db.prepare("INSERT INTO coordinator_event (cid, kind, detail, created_at) VALUES (?, 'revoked', ?, ?)").run(cid, `with ${approver}'s standing, by ${by}`, stamp);
|
|
6367
6511
|
this.sweepCoordinatorProposals(now, cid);
|
|
6368
6512
|
this.recordAction({ at: stamp, actor: by, repo: null, taskId: null, runId: null, action: `coordinator revoked: ${String(row["name"])}`, outcome: "revoked", source: "access", detail: `made by ${approver}` });
|
|
@@ -6977,8 +7121,8 @@ export class Store {
|
|
|
6977
7121
|
createApiToken(token, now) {
|
|
6978
7122
|
this.transact(() => {
|
|
6979
7123
|
const projects = token.projects ?? null;
|
|
6980
|
-
this.db.prepare("INSERT INTO
|
|
6981
|
-
.run(token.id, token.account, token.name, token.secretHash, token.access, now.toISOString(), token.by, token.expiresAt, projects === null ? null : JSON.stringify(projects), token.purpose ?? "api");
|
|
7124
|
+
this.db.prepare("INSERT INTO credential (id, kind, public_id, account, name, secret_hash, access, created_at, created_by, expires_at, projects_json, purpose) VALUES (?, 'person', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
|
|
7125
|
+
.run(token.id, token.id, token.account, token.name, token.secretHash, token.access, now.toISOString(), token.by, token.expiresAt, projects === null ? null : JSON.stringify(projects), token.purpose ?? "api");
|
|
6982
7126
|
this.recordAction({ at: now.toISOString(), actor: token.by, repo: null, taskId: null, runId: null, action: `API token created: ${token.name}`, outcome: token.access, source: "access",
|
|
6983
7127
|
detail: `for ${token.account}, until ${token.expiresAt.slice(0, 10)}${projects === null ? "" : ` · ${projects.length === 0 ? "no projects" : projects.join(", ")}`} · id ${token.id}` });
|
|
6984
7128
|
});
|
|
@@ -7000,9 +7144,11 @@ export class Store {
|
|
|
7000
7144
|
return { ok: false, reason: "replaced" };
|
|
7001
7145
|
const stamp = now.toISOString();
|
|
7002
7146
|
const until = new Date(Math.min(at + Math.max(0, overlapMs), Date.parse(old.expiresAt))).toISOString();
|
|
7003
|
-
|
|
7004
|
-
|
|
7005
|
-
|
|
7147
|
+
// The replacement is the token's next generation; it keeps every term, so rotating never widens or extends it.
|
|
7148
|
+
this.db.prepare(`INSERT INTO credential (id, kind, public_id, account, name, secret_hash, access, created_at, created_by, expires_at, projects_json, replaces, generation)
|
|
7149
|
+
VALUES (?, 'person', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, (SELECT generation + 1 FROM credential WHERE id = ?))`)
|
|
7150
|
+
.run(replacement.id, replacement.id, old.account, old.name, replacement.secretHash, old.access, stamp, by, old.expiresAt, old.projects === null ? null : JSON.stringify(old.projects), old.id, old.id);
|
|
7151
|
+
this.db.prepare("UPDATE credential SET replaced_by = ?, overlap_until = ? WHERE id = ? AND kind = 'person' AND replaced_by IS NULL").run(replacement.id, until, old.id);
|
|
7006
7152
|
this.recordAction({ at: stamp, actor: by, repo: null, taskId: null, runId: null, action: `API token rotated: ${old.name}`, outcome: old.access, source: "access",
|
|
7007
7153
|
detail: `${old.account}'s · id ${old.id} → ${replacement.id}, the old one stops ${until.slice(0, 16).replace("T", " ")} UTC` });
|
|
7008
7154
|
return { ok: true, row: this.apiTokenSecret(replacement.id).row };
|
|
@@ -7010,30 +7156,46 @@ export class Store {
|
|
|
7010
7156
|
}
|
|
7011
7157
|
/** v111: record the end of each replaced token whose overlap has passed (it already signs no one in). */
|
|
7012
7158
|
endRotatedApiTokens(now) {
|
|
7013
|
-
const due = this.db.prepare("SELECT id, overlap_until FROM
|
|
7159
|
+
const due = this.db.prepare("SELECT id, overlap_until FROM credential WHERE kind = 'person' AND revoked_at IS NULL AND overlap_until IS NOT NULL AND overlap_until <= ?").all(now.toISOString());
|
|
7014
7160
|
for (const row of due)
|
|
7015
7161
|
this.revokeApiToken(String(row["id"]), "system", new Date(String(row["overlap_until"])), "replaced; its overlap ended");
|
|
7016
7162
|
return due.length;
|
|
7017
7163
|
}
|
|
7018
|
-
/**
|
|
7164
|
+
/**
|
|
7165
|
+
* D7: the one check every machine credential takes. The credential a presented secret is (its kind and id), when
|
|
7166
|
+
* its kept hash matches in constant time; null otherwise. Whether it is live, and what its kind may do, stays with
|
|
7167
|
+
* each caller's own policy (a person's API token, a lead token, a coordinator).
|
|
7168
|
+
*/
|
|
7169
|
+
presentedCredential(presented) {
|
|
7170
|
+
const parsed = parseCredential(presented);
|
|
7171
|
+
if (parsed === null)
|
|
7172
|
+
return null;
|
|
7173
|
+
const row = parsed.kind === "coordinator"
|
|
7174
|
+
? this.db.prepare("SELECT id, secret_hash FROM credential WHERE kind = 'coordinator' AND secret_hash = ?").get(hashSecret(parsed.secret))
|
|
7175
|
+
: this.db.prepare("SELECT id, secret_hash FROM credential WHERE kind = ? AND public_id = ?").get(parsed.kind, parsed.id);
|
|
7176
|
+
if (row === undefined || !secretMatches(parsed.secret, String(row["secret_hash"])))
|
|
7177
|
+
return null;
|
|
7178
|
+
return { kind: parsed.kind, id: String(row["id"]) };
|
|
7179
|
+
}
|
|
7180
|
+
/** A person's API token by id with its kept hash (for the check), or null. */
|
|
7019
7181
|
apiTokenSecret(id) {
|
|
7020
|
-
const row = this.db.prepare("SELECT * FROM
|
|
7182
|
+
const row = this.db.prepare("SELECT * FROM credential WHERE id = ? AND kind = 'person'").get(id);
|
|
7021
7183
|
return row === undefined ? null : { row: readApiToken(row), secretHash: String(row["secret_hash"]) };
|
|
7022
7184
|
}
|
|
7023
7185
|
/** Tokens, newest first: one person's, or everyone's (null). */
|
|
7024
7186
|
apiTokens(account) {
|
|
7025
|
-
return (account === null ? this.db.prepare("SELECT * FROM
|
|
7187
|
+
return (account === null ? this.db.prepare("SELECT * FROM credential WHERE kind = 'person' ORDER BY created_at DESC").all() : this.db.prepare("SELECT * FROM credential WHERE kind = 'person' AND account = ? ORDER BY created_at DESC").all(account)).map(readApiToken);
|
|
7026
7188
|
}
|
|
7027
7189
|
/** Kept at most once a minute: a busy script doesn't write on every request. */
|
|
7028
7190
|
touchApiToken(id, now) {
|
|
7029
|
-
this.db.prepare("UPDATE
|
|
7191
|
+
this.db.prepare("UPDATE credential SET last_used_at = ? WHERE id = ? AND kind = 'person' AND (last_used_at IS NULL OR last_used_at < ?)").run(now.toISOString(), id, new Date(now.getTime() - 60_000).toISOString());
|
|
7030
7192
|
}
|
|
7031
7193
|
revokeApiToken(id, by, now, why = "revoked") {
|
|
7032
7194
|
return this.transact(() => {
|
|
7033
|
-
const row = this.db.prepare("SELECT name, account FROM
|
|
7195
|
+
const row = this.db.prepare("SELECT name, account FROM credential WHERE id = ? AND kind = 'person' AND revoked_at IS NULL").get(id);
|
|
7034
7196
|
if (row === undefined)
|
|
7035
7197
|
return false;
|
|
7036
|
-
this.db.prepare("UPDATE
|
|
7198
|
+
this.db.prepare("UPDATE credential SET revoked_at = ?, revoked_by = ? WHERE id = ? AND kind = 'person'").run(now.toISOString(), by, id);
|
|
7037
7199
|
this.recordAction({ at: now.toISOString(), actor: by, repo: null, taskId: null, runId: null, action: `API token revoked: ${String(row["name"])}`, outcome: "revoked", source: "access", detail: `${String(row["account"])}'s · ${why}` });
|
|
7038
7200
|
return true;
|
|
7039
7201
|
});
|
|
@@ -7146,7 +7308,7 @@ export class Store {
|
|
|
7146
7308
|
.run(next.refreshHash, next.accessExpiresAt, token, ask.refreshHash);
|
|
7147
7309
|
if (Number(moved.changes) !== 1)
|
|
7148
7310
|
return invalid;
|
|
7149
|
-
this.db.prepare("UPDATE
|
|
7311
|
+
this.db.prepare("UPDATE credential SET secret_hash = ? WHERE id = ? AND kind = 'person' AND revoked_at IS NULL").run(next.accessHash, token);
|
|
7150
7312
|
this.db.prepare("INSERT INTO oauth_refresh (token, hash) VALUES (?, ?)").run(token, next.refreshHash);
|
|
7151
7313
|
return { ok: true, access: kept.access };
|
|
7152
7314
|
});
|
|
@@ -8756,7 +8918,7 @@ export class Store {
|
|
|
8756
8918
|
* transaction can never commit half a seal). A brief FILE whose seal
|
|
8757
8919
|
* failed is an orphan on disk, not authority — no artifact row points at
|
|
8758
8920
|
* it. Two concurrent seals race on the comment consumption count, the
|
|
8759
|
-
*
|
|
8921
|
+
* family's next version id, or the repair chain's `source_run UNIQUE`, and
|
|
8760
8922
|
* exactly one wins; the loser refuses in words with zero rows.
|
|
8761
8923
|
*
|
|
8762
8924
|
* The child is filed UNAPPROVED, always: this method never stamps an
|
|
@@ -8848,8 +9010,9 @@ export class Store {
|
|
|
8848
9010
|
const badText = validateTaskText({ title: args.child.title, goal: args.child.repair });
|
|
8849
9011
|
if (badText !== null)
|
|
8850
9012
|
return refuse(badText.reason, badText.message);
|
|
9013
|
+
// Every road names its child the same way: the family root and the next version, `<root>-v2`, `-v3`.
|
|
8851
9014
|
const made = this.#insertConsoleTask({
|
|
8852
|
-
|
|
9015
|
+
id: this.#nextRevisionId(ancestry.chain.at(-1) ?? args.source.task),
|
|
8853
9016
|
title: args.child.title,
|
|
8854
9017
|
...(sourceRef.repo === null ? {} : { repo: sourceRef.repo }),
|
|
8855
9018
|
goal,
|
|
@@ -8926,6 +9089,20 @@ export class Store {
|
|
|
8926
9089
|
}
|
|
8927
9090
|
return { chain, problem: null };
|
|
8928
9091
|
}
|
|
9092
|
+
/** The next free `<root>-vN` id inside the caller's transaction: N counts the family's versions (the root is v1),
|
|
9093
|
+
* skipping any id already taken. The suffix survives truncation to the 64-character id bound. */
|
|
9094
|
+
#nextRevisionId(root) {
|
|
9095
|
+
const family = this.db.prepare(`WITH RECURSIVE fam(id, depth) AS (
|
|
9096
|
+
SELECT external_id, 0 FROM task_ref WHERE backend = ? AND external_id = ?
|
|
9097
|
+
UNION SELECT r.external_id, fam.depth + 1 FROM task_ref r JOIN fam ON r.revision_of = fam.id WHERE r.backend = ? AND fam.depth < 64)
|
|
9098
|
+
SELECT COUNT(DISTINCT id) AS n FROM fam`).get(BUILT_IN, root, BUILT_IN);
|
|
9099
|
+
const taken = (id) => this.db.prepare("SELECT 1 AS hit FROM task WHERE id = ? UNION ALL SELECT 1 FROM task_ref WHERE backend = ? AND external_id = ?").get(id, BUILT_IN, id) !== undefined;
|
|
9100
|
+
const named = (n) => `${root.slice(0, 64 - `-v${n}`.length)}-v${n}`;
|
|
9101
|
+
let n = Math.max(2, Number(family?.["n"] ?? 1) + 1);
|
|
9102
|
+
while (taken(named(n)))
|
|
9103
|
+
n++;
|
|
9104
|
+
return named(n);
|
|
9105
|
+
}
|
|
8929
9106
|
revisionAncestryOf(taskId) {
|
|
8930
9107
|
return this.revisionAncestryStatus(taskId).chain;
|
|
8931
9108
|
}
|
|
@@ -10897,10 +11074,10 @@ export class Store {
|
|
|
10897
11074
|
* before signing). */
|
|
10898
11075
|
coordinatorProvenanceOf(taskId) {
|
|
10899
11076
|
const row = this.db
|
|
10900
|
-
.prepare(`SELECT c.name AS name, c.
|
|
10901
|
-
(SELECT MIN(e.created_at) FROM coordinator_event e WHERE e.cid = c.
|
|
11077
|
+
.prepare(`SELECT c.name AS name, c.public_id AS cid,
|
|
11078
|
+
(SELECT MIN(e.created_at) FROM coordinator_event e WHERE e.cid = c.id AND e.task_id = task_ref.external_id AND e.kind = 'filed') AS filed_at
|
|
10902
11079
|
FROM task_ref
|
|
10903
|
-
JOIN
|
|
11080
|
+
JOIN credential c ON c.id = task_ref.coordinator_cid AND c.kind = 'coordinator'
|
|
10904
11081
|
WHERE task_ref.backend = ? AND task_ref.external_id = ?`)
|
|
10905
11082
|
.get(BUILT_IN, taskId);
|
|
10906
11083
|
if (row === undefined)
|
|
@@ -16677,9 +16854,13 @@ export class Store {
|
|
|
16677
16854
|
mintLeadCredential(owner, by, now) {
|
|
16678
16855
|
return this.transact(() => {
|
|
16679
16856
|
const stamp = now.toISOString();
|
|
16680
|
-
this.db.prepare("UPDATE
|
|
16681
|
-
|
|
16682
|
-
|
|
16857
|
+
this.db.prepare("UPDATE credential SET revoked_at = ?, revoked_by = ? WHERE kind = 'lead' AND account = ? AND revoked_at IS NULL").run(stamp, by, owner);
|
|
16858
|
+
let minted = mintLeadToken();
|
|
16859
|
+
// One id per credential across kinds; the canonical id is the one the token presents.
|
|
16860
|
+
while (this.db.prepare("SELECT 1 FROM credential WHERE id = ? OR (kind = 'lead' AND public_id = ?)").get(minted.id, minted.id) !== undefined)
|
|
16861
|
+
minted = mintLeadToken();
|
|
16862
|
+
this.db.prepare(`INSERT INTO credential (id, kind, public_id, account, name, secret_hash, access, created_at, created_by, generation)
|
|
16863
|
+
VALUES (?, 'lead', ?, ?, 'lead token', ?, 'act', ?, ?, 1 + (SELECT count(*) FROM credential WHERE kind = 'lead' AND account = ?))`).run(minted.id, minted.id, owner, minted.hash, stamp, by, owner);
|
|
16683
16864
|
this.recordAction({ at: stamp, actor: by, repo: null, taskId: null, runId: null, action: "lead token created", outcome: "recorded", source: "access", detail: `lead for ${owner}` });
|
|
16684
16865
|
return { id: minted.id, token: minted.token };
|
|
16685
16866
|
});
|
|
@@ -16687,7 +16868,7 @@ export class Store {
|
|
|
16687
16868
|
/** End every live lead token for one person. */
|
|
16688
16869
|
revokeLeadCredentials(owner, by, now) {
|
|
16689
16870
|
return this.transact(() => {
|
|
16690
|
-
const ended = Number(this.db.prepare("UPDATE
|
|
16871
|
+
const ended = Number(this.db.prepare("UPDATE credential SET revoked_at = ?, revoked_by = ? WHERE kind = 'lead' AND account = ? AND revoked_at IS NULL").run(now.toISOString(), by, owner).changes);
|
|
16691
16872
|
if (ended > 0)
|
|
16692
16873
|
this.recordAction({ at: now.toISOString(), actor: by, repo: null, taskId: null, runId: null, action: "lead token revoked", outcome: "recorded", source: "access", detail: `lead for ${owner}` });
|
|
16693
16874
|
return ended;
|
|
@@ -16695,14 +16876,14 @@ export class Store {
|
|
|
16695
16876
|
}
|
|
16696
16877
|
/** Whose lead a presented token is: a live token whose owner is still an approver, or null. */
|
|
16697
16878
|
leadFor(presented) {
|
|
16698
|
-
const
|
|
16699
|
-
if (
|
|
16879
|
+
const found = this.presentedCredential(presented);
|
|
16880
|
+
if (found?.kind !== "lead")
|
|
16700
16881
|
return null;
|
|
16701
|
-
const row = this.db.prepare("SELECT
|
|
16702
|
-
if (row === undefined
|
|
16882
|
+
const row = this.db.prepare("SELECT account, public_id FROM credential WHERE id = ? AND kind = 'lead' AND revoked_at IS NULL").get(found.id);
|
|
16883
|
+
if (row === undefined)
|
|
16703
16884
|
return null;
|
|
16704
|
-
const owner = String(row["
|
|
16705
|
-
return account === null || account.revokedAt !== null || account.role !== "approver" ? null : { owner, id:
|
|
16885
|
+
const owner = String(row["account"]), account = this.accountOf(owner);
|
|
16886
|
+
return account === null || account.revokedAt !== null || account.role !== "approver" ? null : { owner, id: String(row["public_id"]) };
|
|
16706
16887
|
}
|
|
16707
16888
|
/** Record who did this to a task, when someone is acting. The lead's acts are also in the ledger as "lead for <owner>". */
|
|
16708
16889
|
noteTaskAct(taskRef, act, now, handed, actor = currentActor()) {
|
package/dist/task-outcome-cli.js
CHANGED
|
@@ -82,7 +82,9 @@ export async function runTaskOutcomeCommand(action, positional, flags, context)
|
|
|
82
82
|
if (!changed.ok)
|
|
83
83
|
return emit(changed);
|
|
84
84
|
const assignment = 'body' in changed ? changed.body : changed.assignment;
|
|
85
|
-
|
|
85
|
+
// Checks deliberately Off: nothing to say about them here.
|
|
86
|
+
return emit({ ok: true, result: assignment }, [`${current.rootId} · Complete`, `Result: ${current.activeTaskId} · run ${current.receipt.runId} · ${current.receipt.head ?? 'no commit'}`,
|
|
87
|
+
...(current.readiness?.checksOff ? [] : [current.receipt.checks.detail])]);
|
|
86
88
|
}
|
|
87
89
|
const feedback = flags.get('feedback'), runFlag = flags.get('run'), sourceFlag = flags.get('source'), keyFlag = flags.get('key');
|
|
88
90
|
if (typeof feedback !== 'string' || !feedback.trim())
|