toolroll 0.9.57 → 0.9.59
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 +15 -0
- package/dist/action-ledger.js +10 -5
- 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-delivery.js +1 -1
- package/dist/assignment-status.js +1 -1
- package/dist/assignment.js +3 -2
- package/dist/browser/workspace.js +1 -1
- package/dist/builder.js +4 -2
- package/dist/chat-channel.d.ts +1 -0
- package/dist/chat-channel.js +3 -0
- package/dist/chat-delivery.js +2 -0
- package/dist/cli-http.d.ts +7 -0
- package/dist/cli-http.js +30 -4
- package/dist/cli.js +2 -1
- package/dist/contract-changes.d.ts +52 -0
- package/dist/contract-changes.js +61 -0
- package/dist/coordinator.d.ts +2 -1
- package/dist/coordinator.js +22 -31
- package/dist/decision.d.ts +4 -0
- package/dist/decision.js +8 -0
- package/dist/deploy-lock.d.ts +8 -0
- package/dist/deploy-lock.js +45 -0
- package/dist/desktop-update.d.ts +1 -1
- package/dist/dispatch.js +1 -1
- package/dist/export.js +2 -2
- package/dist/lead-cli.js +3 -0
- package/dist/lead-doors.js +3 -0
- package/dist/lead-tools.js +2 -1
- package/dist/mcp-http.js +5 -5
- package/dist/needs-you.js +3 -1
- package/dist/operate.js +23 -11
- package/dist/password-bearer.d.ts +27 -0
- package/dist/password-bearer.js +59 -0
- package/dist/planner-source.d.ts +23 -53
- package/dist/planner-source.js +43 -68
- package/dist/release-gate.d.ts +2 -3
- package/dist/release-gate.js +44 -16
- 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 +16 -1
- package/dist/serve.js +15 -0
- 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-chat.js +10 -0
- package/dist/server/render-tasks.js +19 -9
- package/dist/server/route-table.js +0 -3
- package/dist/server/tasks.js +3 -1
- 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 +72 -12
- package/dist/store.js +367 -105
- package/dist/task-status.js +3 -1
- package/dist/team-cli.d.ts +3 -0
- package/dist/team-cli.js +23 -3
- package/dist/telegram-lead.js +6 -0
- package/dist/telegram.js +9 -7
- package/dist/toolroll-update.js +10 -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,62 @@ 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
|
+
// v119 keeps planning generations: a rewritten scope starts a clean planning context (task_ref, run and plan_revision
|
|
563
|
+
// carry planning_generation, 0 for all earlier history), and a question closed without an answer says why
|
|
564
|
+
// (decision.superseded_reason: its task's scope changed, or the task was cancelled).
|
|
565
|
+
export const SCHEMA_VERSION = 119;
|
|
566
|
+
/** v118: the tables merged into `credential`, in the order they move (a person's API tokens keep their ids first). */
|
|
567
|
+
export const V118_MERGED_TABLES = Object.freeze({ api_token: "person", coordinator_credential: "coordinator", lead_credential: "lead" });
|
|
568
|
+
/** The foreign keys that named a merged table, exactly as their DDL wrote them; each names `credential(id)` instead. */
|
|
569
|
+
const V118_REFERENCES = /REFERENCES (?:coordinator_credential\(cid\)|api_token\(id\))/g;
|
|
570
|
+
/** The columns that hold a coordinator's id: rewritten only when the merge had to give one a new canonical id. */
|
|
571
|
+
const V118_COORDINATOR_COLUMNS = [["task_ref", "coordinator_cid"], ["coordinator_event", "cid"], ["mcp_idempotency", "cid"], ["coordinator_proposal", "cid"]];
|
|
572
|
+
/**
|
|
573
|
+
* v118 (D7): one credential table for every machine credential. `id` is the canonical identity every reference holds;
|
|
574
|
+
* `public_id` is the id the secret presents (so_<id>_…, lt_<id>_…) or a coordinator's cid, unique per kind. They are
|
|
575
|
+
* equal unless the merge found two kinds sharing an id. `account` is the person a token acts as (a lead's owner; for
|
|
576
|
+
* a coordinator, who minted it); `projects_json` the project limit (a coordinator's projects); `generation` which
|
|
577
|
+
* issue it is (1, then one more per rotation, per lead token for the same owner, per coordinator of the same name).
|
|
578
|
+
* Lead tokens never expire; a coordinator made before v101 has no expiry.
|
|
579
|
+
*/
|
|
580
|
+
export const CREDENTIAL_TABLE = `
|
|
581
|
+
CREATE TABLE IF NOT EXISTS credential (
|
|
582
|
+
id TEXT PRIMARY KEY,
|
|
583
|
+
kind TEXT NOT NULL CHECK (kind IN ('person','lead','coordinator')),
|
|
584
|
+
public_id TEXT NOT NULL,
|
|
585
|
+
account TEXT NOT NULL,
|
|
586
|
+
name TEXT NOT NULL,
|
|
587
|
+
secret_hash TEXT NOT NULL,
|
|
588
|
+
access TEXT NOT NULL CHECK (access IN ('read','act')),
|
|
589
|
+
projects_json TEXT,
|
|
590
|
+
per_hour INTEGER,
|
|
591
|
+
purpose TEXT NOT NULL DEFAULT 'api' CHECK (purpose IN ('api','mcp')),
|
|
592
|
+
generation INTEGER NOT NULL DEFAULT 1 CHECK (generation >= 1),
|
|
593
|
+
created_at TEXT NOT NULL,
|
|
594
|
+
created_by TEXT NOT NULL,
|
|
595
|
+
expires_at TEXT,
|
|
596
|
+
last_used_at TEXT,
|
|
597
|
+
revoked_at TEXT,
|
|
598
|
+
revoked_by TEXT,
|
|
599
|
+
replaces TEXT,
|
|
600
|
+
replaced_by TEXT,
|
|
601
|
+
overlap_until TEXT,
|
|
602
|
+
UNIQUE (kind, public_id)
|
|
603
|
+
);
|
|
604
|
+
`;
|
|
605
|
+
/** Created once the merged tables (and their index of the same name) are gone. */
|
|
606
|
+
const CREDENTIAL_INDEXES = `
|
|
607
|
+
CREATE INDEX IF NOT EXISTS credential_account ON credential (account, kind);
|
|
608
|
+
CREATE INDEX IF NOT EXISTS credential_coordinator_secret ON credential (secret_hash) WHERE kind = 'coordinator';
|
|
609
|
+
CREATE UNIQUE INDEX IF NOT EXISTS coordinator_live_name ON credential (name) WHERE kind = 'coordinator' AND revoked_at IS NULL;
|
|
610
|
+
CREATE TRIGGER IF NOT EXISTS ledger_coordinator_minted AFTER INSERT ON credential WHEN NEW.kind = 'coordinator' BEGIN
|
|
611
|
+
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,
|
|
612
|
+
'coordinator minted: ' || NEW.name, 'minted', 'access', 'projects: ' || NEW.projects_json);
|
|
613
|
+
END;
|
|
614
|
+
`;
|
|
570
615
|
/** v117: each table renamed, by its old name. Rows, ids, foreign keys and indexes go with it (renameForV117). */
|
|
571
616
|
export const V117_RENAMED_TABLES = Object.freeze({
|
|
572
617
|
mate_session: "lead_session", mate_thread: "lead_thread", mate_message: "lead_message", mate_proposal: "lead_proposal",
|
|
@@ -587,10 +632,10 @@ function presentTable(db, name) {
|
|
|
587
632
|
const old = V117_OLD_NAME[name];
|
|
588
633
|
return old !== undefined && !tableExists(db, name) && tableExists(db, old) ? old : name;
|
|
589
634
|
}
|
|
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
|
|
635
|
+
/** A row `PRAGMA foreign_key_check` reports, by today's table names: a row already pointing nowhere before v117 (or
|
|
636
|
+
* v118) is the same row after it, though RENAME (or the credential merge) now reports what it points at by the new names. */
|
|
592
637
|
export function orphanKey(row) {
|
|
593
|
-
const named = (value) => typeof value === "string" ? V117_RENAMED_TABLES[value] ?? value : value;
|
|
638
|
+
const named = (value) => typeof value === "string" ? V117_RENAMED_TABLES[value] ?? (Object.hasOwn(V118_MERGED_TABLES, value) ? "credential" : value) : value;
|
|
594
639
|
return JSON.stringify({ table: named(row["table"]), rowid: row["rowid"], parent: named(row["parent"]), fkid: row["fkid"] });
|
|
595
640
|
}
|
|
596
641
|
/** Shapes compare by today's names: a pre-v117 table, index or reference names the same shape by its old one. */
|
|
@@ -636,9 +681,20 @@ const V115_DROPPED_REFERENCES = /\s+REFERENCES (?:contestant|contest|tournament_
|
|
|
636
681
|
* transaction that checks each count before the old tables are dropped; the
|
|
637
682
|
* rehearsal checks each moved table's rows arrived (HISTORY_RULES). v117 renames the mate's tables to the lead's and the
|
|
638
683
|
* 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.
|
|
684
|
+
* each renamed table's rows arrived under its new name. v118 (D7) is checked the same way: every row of api_token,
|
|
685
|
+
* coordinator_credential and lead_credential moves into credential under its kind in one transaction that counts them
|
|
686
|
+
* before the old tables drop, and the references that named them name credential in place, their rows untouched. v119 only adds four columns
|
|
687
|
+
* (planning_generation on task_ref, run and plan_revision, defaulting to 0, and decision.superseded_reason, null);
|
|
688
|
+
* the rehearsal checks every saved row reads the declared default (HISTORY_RULES' added columns).
|
|
640
689
|
*/
|
|
641
|
-
export const UPDATE_SAFE_MIGRATIONS = Object.freeze([108, 109, 110, 111, 112, 113, 114, 115, 116, 117]);
|
|
690
|
+
export const UPDATE_SAFE_MIGRATIONS = Object.freeze([108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119]);
|
|
691
|
+
/** v119: the columns it adds, by table, with the value every row saved before it reads (toolroll-update's HISTORY_RULES). */
|
|
692
|
+
export const V119_ADDED_COLUMNS = Object.freeze({
|
|
693
|
+
task_ref: { planning_generation: { definition: "INTEGER NOT NULL DEFAULT 0", saved: "0" } },
|
|
694
|
+
run: { planning_generation: { definition: "INTEGER NOT NULL DEFAULT 0", saved: "0" } },
|
|
695
|
+
plan_revision: { planning_generation: { definition: "INTEGER NOT NULL DEFAULT 0", saved: "0" } },
|
|
696
|
+
decision: { superseded_reason: { definition: "TEXT CHECK (superseded_reason IN ('scope-changed','cancelled'))", saved: "NULL" } },
|
|
697
|
+
});
|
|
642
698
|
/** Whether a database settled at `version` reaches this build's schema through update-safe migrations alone. */
|
|
643
699
|
export function updateSafeSchema(version) {
|
|
644
700
|
if (version === null || !Number.isSafeInteger(version) || version <= 0 || version > SCHEMA_VERSION)
|
|
@@ -673,22 +729,9 @@ CREATE TABLE IF NOT EXISTS scope_author (
|
|
|
673
729
|
PRIMARY KEY (task_id, digest, author)
|
|
674
730
|
);
|
|
675
731
|
`;
|
|
676
|
-
/** v101:
|
|
732
|
+
/** v101: the browser sessions that survive a restart (API tokens are `credential` rows since v118). Only hashes of
|
|
733
|
+
* their secrets are kept. */
|
|
677
734
|
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
735
|
CREATE TABLE IF NOT EXISTS web_session (
|
|
693
736
|
id_hash TEXT PRIMARY KEY,
|
|
694
737
|
account TEXT NOT NULL,
|
|
@@ -707,7 +750,7 @@ CREATE INDEX IF NOT EXISTS web_session_account ON web_session (account);
|
|
|
707
750
|
`;
|
|
708
751
|
/**
|
|
709
752
|
* 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
|
|
753
|
+
* 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
754
|
* projects the person chose, and every issued refresh-secret hash (any replay ends the whole family).
|
|
712
755
|
*/
|
|
713
756
|
const OAUTH_SCHEMA = `
|
|
@@ -734,7 +777,7 @@ CREATE TABLE IF NOT EXISTS oauth_code (
|
|
|
734
777
|
token TEXT
|
|
735
778
|
);
|
|
736
779
|
CREATE TABLE IF NOT EXISTS oauth_grant (
|
|
737
|
-
token TEXT PRIMARY KEY REFERENCES
|
|
780
|
+
token TEXT PRIMARY KEY REFERENCES credential(id),
|
|
738
781
|
client TEXT NOT NULL REFERENCES oauth_client(id),
|
|
739
782
|
account TEXT NOT NULL,
|
|
740
783
|
generation INTEGER NOT NULL,
|
|
@@ -1085,7 +1128,7 @@ CREATE TABLE IF NOT EXISTS task_ref (
|
|
|
1085
1128
|
-- AUTHORITATIVE linkage — written only by the branded coordinator door,
|
|
1086
1129
|
-- joined by exact cid, never parsed out of filed_via (which stays
|
|
1087
1130
|
-- display-only). NULL is every other filer.
|
|
1088
|
-
coordinator_cid TEXT REFERENCES
|
|
1131
|
+
coordinator_cid TEXT REFERENCES credential(id),
|
|
1089
1132
|
-- The deliverable (v34, scout tasks): 'branch' is every task until now;
|
|
1090
1133
|
-- 'report' dispatches a scout — a read-only session whose whole output
|
|
1091
1134
|
-- is one report artifact. Stamped at filing, never rewritten.
|
|
@@ -1093,29 +1136,15 @@ CREATE TABLE IF NOT EXISTS task_ref (
|
|
|
1093
1136
|
UNIQUE (backend, external_id)
|
|
1094
1137
|
);
|
|
1095
1138
|
|
|
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;
|
|
1139
|
+
-- v31 (MCP gateway): the coordinator principal (DESIGN.md 9b) is a row of
|
|
1140
|
+
-- credential (v118, kind 'coordinator'): its id is the IMMUTABLE identity,
|
|
1141
|
+
-- so revoke-and-remint never conflates generations in audit or cap accounting.
|
|
1113
1142
|
|
|
1114
1143
|
-- The coordinator audit (v31): filing, dismissal, and revocation rows,
|
|
1115
1144
|
-- inserted ATOMICALLY with their state change — never a separate write.
|
|
1116
1145
|
CREATE TABLE IF NOT EXISTS coordinator_event (
|
|
1117
1146
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1118
|
-
cid TEXT NOT NULL REFERENCES
|
|
1147
|
+
cid TEXT NOT NULL REFERENCES credential(id),
|
|
1119
1148
|
kind TEXT NOT NULL CHECK (kind IN ('filed','dismissed','revoked')),
|
|
1120
1149
|
task_id TEXT,
|
|
1121
1150
|
detail TEXT,
|
|
@@ -1127,7 +1156,7 @@ CREATE TABLE IF NOT EXISTS coordinator_event (
|
|
|
1127
1156
|
-- the original answer without a rate charge; a different digest refuses.
|
|
1128
1157
|
-- The global Store.replay is digest-blind and is NOT this.
|
|
1129
1158
|
CREATE TABLE IF NOT EXISTS mcp_idempotency (
|
|
1130
|
-
cid TEXT NOT NULL REFERENCES
|
|
1159
|
+
cid TEXT NOT NULL REFERENCES credential(id),
|
|
1131
1160
|
key TEXT NOT NULL,
|
|
1132
1161
|
request_digest TEXT NOT NULL,
|
|
1133
1162
|
task_id TEXT NOT NULL,
|
|
@@ -1789,7 +1818,7 @@ CREATE INDEX IF NOT EXISTS lead_proposal_thread ON lead_proposal (thread, state)
|
|
|
1789
1818
|
|
|
1790
1819
|
CREATE TABLE IF NOT EXISTS coordinator_proposal (
|
|
1791
1820
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1792
|
-
cid TEXT NOT NULL REFERENCES
|
|
1821
|
+
cid TEXT NOT NULL REFERENCES credential(id),
|
|
1793
1822
|
name TEXT NOT NULL,
|
|
1794
1823
|
repo TEXT NOT NULL,
|
|
1795
1824
|
kind TEXT NOT NULL CHECK (kind IN ('next','reserve','hold','unhold','scope','cancel','answer')),
|
|
@@ -3371,14 +3400,17 @@ function initializeStore(db, file) {
|
|
|
3371
3400
|
db.exec(TEAM_SCHEMA);
|
|
3372
3401
|
db.exec(SSO_SCHEMA);
|
|
3373
3402
|
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
|
-
|
|
3403
|
+
// v111: a token's project limit (null: every project its person may use) and its rotation. (Until v118 moves it.)
|
|
3404
|
+
if (tableExists(db, "api_token")) {
|
|
3405
|
+
addColumn(db, "api_token", "projects_json", "TEXT");
|
|
3406
|
+
addColumn(db, "api_token", "replaces", "TEXT");
|
|
3407
|
+
addColumn(db, "api_token", "replaced_by", "TEXT");
|
|
3408
|
+
addColumn(db, "api_token", "overlap_until", "TEXT");
|
|
3409
|
+
}
|
|
3379
3410
|
// v113: MCP sign-in (OAuth), and whether a token is an ordinary one or an MCP sign-in's.
|
|
3380
3411
|
db.exec(OAUTH_SCHEMA);
|
|
3381
|
-
|
|
3412
|
+
if (tableExists(db, "api_token"))
|
|
3413
|
+
addColumn(db, "api_token", "purpose", "TEXT NOT NULL DEFAULT 'api' CHECK (purpose IN ('api','mcp'))");
|
|
3382
3414
|
db.exec(APPROVAL_SCHEMA);
|
|
3383
3415
|
db.exec(LEDGER_CHAIN_SCHEMA);
|
|
3384
3416
|
db.exec(MONITORING_SCHEMA);
|
|
@@ -3454,6 +3486,11 @@ function initializeStore(db, file) {
|
|
|
3454
3486
|
// v115: the task each standing-order firing filed, kept on the firing itself (a card's task changes as it moves).
|
|
3455
3487
|
addColumn(db, "flow_trigger_event", "task", "TEXT");
|
|
3456
3488
|
db.exec(PLAN_AUTO_SCHEMA);
|
|
3489
|
+
// v119: planning generations, and why a question closed unanswered (additive only; saved rows read the defaults).
|
|
3490
|
+
for (const [table, columns] of Object.entries(V119_ADDED_COLUMNS)) {
|
|
3491
|
+
for (const [column, { definition }] of Object.entries(columns))
|
|
3492
|
+
addColumn(db, table, column, definition);
|
|
3493
|
+
}
|
|
3457
3494
|
installLedgerTriggers(db);
|
|
3458
3495
|
// Attention/history indexes come AFTER migration: on a database whose
|
|
3459
3496
|
// constrained tables still carry a pre-rebuild shape, creating a partial
|
|
@@ -3531,6 +3568,8 @@ CREATE INDEX IF NOT EXISTS run_checkpoint_by_task ON run_checkpoint (task_ref, i
|
|
|
3531
3568
|
migrateToV115(db);
|
|
3532
3569
|
// v116: every chat app onto the shared chat tables, after v115 and every older step gave the old tables their last shape.
|
|
3533
3570
|
convertChatTables(db);
|
|
3571
|
+
// v118: every machine credential into the one credential table, after every older step gave the old tables their last shape.
|
|
3572
|
+
mergeCredentialsForV118(db);
|
|
3534
3573
|
// Keep the epoch until reclamation succeeds, including a retry after compaction already committed. A new file has
|
|
3535
3574
|
// nothing to reclaim.
|
|
3536
3575
|
if (!fresh)
|
|
@@ -4180,6 +4219,128 @@ function renameForV117(db) {
|
|
|
4180
4219
|
if (check.length !== 1 || check[0] !== "ok")
|
|
4181
4220
|
throw new Error(`the v117 renames left the database damaged (${check.slice(0, 3).join("; ")})`);
|
|
4182
4221
|
}
|
|
4222
|
+
/**
|
|
4223
|
+
* v118 (D7): the three credential tables become one. In one transaction every row moves with its id, hash, dates,
|
|
4224
|
+
* access, project limit, expiry and revocation; the counts are checked before the old tables drop; the foreign keys
|
|
4225
|
+
* that named them name `credential(id)` in place (rows untouched); and the coordinator ledger trigger follows. A
|
|
4226
|
+
* person's API token keeps its id. A coordinator or lead token whose id another kind already holds gets `<kind>:<id>`
|
|
4227
|
+
* as its canonical id (a coordinator's references follow it); its secret presents the same id as before either way.
|
|
4228
|
+
*/
|
|
4229
|
+
function mergeCredentialsForV118(db) {
|
|
4230
|
+
db.exec(CREDENTIAL_TABLE);
|
|
4231
|
+
const present = Object.keys(V118_MERGED_TABLES).filter(table => tableExists(db, table));
|
|
4232
|
+
const referring = db.prepare("SELECT name, sql FROM sqlite_master WHERE type = 'table' AND sql IS NOT NULL").all()
|
|
4233
|
+
.filter(row => !Object.hasOwn(V118_MERGED_TABLES, row.name) && new RegExp(V118_REFERENCES.source).test(row.sql));
|
|
4234
|
+
if (present.length === 0 && referring.length === 0) {
|
|
4235
|
+
db.exec(CREDENTIAL_INDEXES);
|
|
4236
|
+
return;
|
|
4237
|
+
}
|
|
4238
|
+
const orphans = () => new Set(db.prepare("PRAGMA foreign_key_check").all().map(orphanKey));
|
|
4239
|
+
db.exec("PRAGMA foreign_keys = OFF");
|
|
4240
|
+
try {
|
|
4241
|
+
const before = orphans();
|
|
4242
|
+
db.exec("BEGIN IMMEDIATE");
|
|
4243
|
+
try {
|
|
4244
|
+
const taken = new Set(db.prepare("SELECT id FROM credential").all().map(row => row.id));
|
|
4245
|
+
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"])]));
|
|
4246
|
+
const claim = (kind, legacy) => {
|
|
4247
|
+
let id = legacy;
|
|
4248
|
+
for (let n = 1; taken.has(id); n++)
|
|
4249
|
+
id = n === 1 ? `${kind}:${legacy}` : `${kind}:${legacy}:${n}`;
|
|
4250
|
+
taken.add(id);
|
|
4251
|
+
return id;
|
|
4252
|
+
};
|
|
4253
|
+
const insert = db.prepare(`INSERT INTO credential (id, kind, public_id, account, name, secret_hash, access, projects_json, per_hour, purpose, generation,
|
|
4254
|
+
created_at, created_by, expires_at, last_used_at, revoked_at, revoked_by, replaces, replaced_by, overlap_until) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
|
4255
|
+
const text = (value) => value == null ? null : String(value);
|
|
4256
|
+
if (tableExists(db, "api_token")) {
|
|
4257
|
+
const rows = db.prepare("SELECT * FROM api_token ORDER BY rowid").all();
|
|
4258
|
+
const byId = new Map(rows.map(row => [String(row["id"]), row]));
|
|
4259
|
+
// A rotation's replacement is one generation on from the token it replaced.
|
|
4260
|
+
const generation = (row, seen = new Set()) => {
|
|
4261
|
+
const from = text(row["replaces"]), earlier = from === null ? undefined : byId.get(from);
|
|
4262
|
+
if (earlier === undefined || seen.has(from))
|
|
4263
|
+
return 1;
|
|
4264
|
+
seen.add(from);
|
|
4265
|
+
return generation(earlier, seen) + 1;
|
|
4266
|
+
};
|
|
4267
|
+
for (const row of rows) {
|
|
4268
|
+
const id = String(row["id"]);
|
|
4269
|
+
// Persons move first, into an empty table: an API token's id is never renamed (its grants and budgets name it).
|
|
4270
|
+
if (claim("person", id) !== id)
|
|
4271
|
+
throw new Error(`API token ${id} collides with another credential`);
|
|
4272
|
+
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"]));
|
|
4273
|
+
}
|
|
4274
|
+
}
|
|
4275
|
+
if (tableExists(db, "coordinator_credential")) {
|
|
4276
|
+
const issued = new Map();
|
|
4277
|
+
for (const row of db.prepare("SELECT * FROM coordinator_credential ORDER BY created_at, rowid").all()) {
|
|
4278
|
+
const cid = String(row["cid"]), id = claim("coordinator", cid), name = String(row["name"]);
|
|
4279
|
+
const generation = (issued.get(name) ?? 0) + 1;
|
|
4280
|
+
issued.set(name, generation);
|
|
4281
|
+
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);
|
|
4282
|
+
if (id !== cid)
|
|
4283
|
+
for (const [table, column] of V118_COORDINATOR_COLUMNS) {
|
|
4284
|
+
if (tableExists(db, table) && hasColumn(db, table, column))
|
|
4285
|
+
db.prepare(`UPDATE "${table}" SET "${column}" = ? WHERE "${column}" = ?`).run(id, cid);
|
|
4286
|
+
}
|
|
4287
|
+
}
|
|
4288
|
+
}
|
|
4289
|
+
if (tableExists(db, "lead_credential")) {
|
|
4290
|
+
const issued = new Map();
|
|
4291
|
+
for (const row of db.prepare("SELECT * FROM lead_credential ORDER BY created_at, rowid").all()) {
|
|
4292
|
+
const legacy = String(row["id"]), owner = String(row["owner"]);
|
|
4293
|
+
const generation = (issued.get(owner) ?? 0) + 1;
|
|
4294
|
+
issued.set(owner, generation);
|
|
4295
|
+
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);
|
|
4296
|
+
}
|
|
4297
|
+
}
|
|
4298
|
+
for (const [table, kind] of Object.entries(V118_MERGED_TABLES)) {
|
|
4299
|
+
if (!tableExists(db, table))
|
|
4300
|
+
continue;
|
|
4301
|
+
const had = Number(db.prepare(`SELECT count(*) AS n FROM "${table}"`).get()["n"]);
|
|
4302
|
+
const moved = Number(db.prepare("SELECT count(*) AS n FROM credential WHERE kind = ?").get(kind)["n"]);
|
|
4303
|
+
if (had !== moved - already[kind])
|
|
4304
|
+
throw new Error(`${table} kept ${had} rows but ${moved} arrived in credential`);
|
|
4305
|
+
}
|
|
4306
|
+
// The references keep their rows and point at the one table (SQLite's documented procedure, as v115 does).
|
|
4307
|
+
// Node 24's SQLite opens in defensive mode, which forbids it; it is lifted for the rewrite only.
|
|
4308
|
+
if (referring.length > 0) {
|
|
4309
|
+
const version = Number(db.prepare("PRAGMA schema_version").get()?.["schema_version"]);
|
|
4310
|
+
const defensive = db.enableDefensive?.bind(db);
|
|
4311
|
+
defensive?.(false);
|
|
4312
|
+
db.exec("PRAGMA writable_schema = ON");
|
|
4313
|
+
try {
|
|
4314
|
+
const rewrite = db.prepare("UPDATE sqlite_master SET sql = ? WHERE type = 'table' AND name = ?");
|
|
4315
|
+
for (const row of referring)
|
|
4316
|
+
rewrite.run(row.sql.replace(V118_REFERENCES, "REFERENCES credential(id)"), row.name);
|
|
4317
|
+
db.exec(`PRAGMA schema_version = ${version + 1}`);
|
|
4318
|
+
}
|
|
4319
|
+
finally {
|
|
4320
|
+
db.exec("PRAGMA writable_schema = OFF");
|
|
4321
|
+
defensive?.(true);
|
|
4322
|
+
}
|
|
4323
|
+
}
|
|
4324
|
+
// Their indexes and the coordinator ledger trigger go with them; the one table gets its own.
|
|
4325
|
+
for (const table of present)
|
|
4326
|
+
db.exec(`DROP TABLE "${table}"`);
|
|
4327
|
+
db.exec(CREDENTIAL_INDEXES);
|
|
4328
|
+
if ([...orphans()].some(row => !before.has(row)))
|
|
4329
|
+
throw new Error("foreign keys did not survive the v118 credential merge");
|
|
4330
|
+
db.exec("COMMIT");
|
|
4331
|
+
}
|
|
4332
|
+
catch (error) {
|
|
4333
|
+
db.exec("ROLLBACK");
|
|
4334
|
+
throw error;
|
|
4335
|
+
}
|
|
4336
|
+
}
|
|
4337
|
+
finally {
|
|
4338
|
+
db.exec("PRAGMA foreign_keys = ON");
|
|
4339
|
+
}
|
|
4340
|
+
const check = db.prepare("PRAGMA integrity_check").all().map(row => String(row["integrity_check"]));
|
|
4341
|
+
if (check.length !== 1 || check[0] !== "ok")
|
|
4342
|
+
throw new Error(`the v118 credential merge left the database damaged (${check.slice(0, 3).join("; ")})`);
|
|
4343
|
+
}
|
|
4183
4344
|
function migrateToV115(db) {
|
|
4184
4345
|
const present = V115_DROPPED_TABLES.filter(table => tableExists(db, table));
|
|
4185
4346
|
const referring = db.prepare("SELECT name, sql FROM sqlite_master WHERE type = 'table' AND sql IS NOT NULL").all()
|
|
@@ -4652,6 +4813,10 @@ export class Store {
|
|
|
4652
4813
|
// just the state verb's.
|
|
4653
4814
|
if (ref !== undefined)
|
|
4654
4815
|
this.supersedeSteerNotes(Number(ref["id"]), now);
|
|
4816
|
+
// Its open questions close the same way (v119), typed 'cancelled', with their holds and pages: a question about
|
|
4817
|
+
// work that will never run must leave every decision queue. Answered ones stay exactly as they were.
|
|
4818
|
+
if (ref !== undefined)
|
|
4819
|
+
this.closeQuestionsLocked(Number(ref["id"]), "cancelled", now);
|
|
4655
4820
|
// The reason is part of the transition, not decoration: a
|
|
4656
4821
|
// coordinator-filed task's dismissal writes its durable event IN THIS
|
|
4657
4822
|
// transaction — operator text and typed machine codes alike (MCP spec
|
|
@@ -6358,11 +6523,11 @@ export class Store {
|
|
|
6358
6523
|
// with a revocation or an access change, not a password change.
|
|
6359
6524
|
this.db.prepare("DELETE FROM web_session WHERE account = ?").run(approver);
|
|
6360
6525
|
if (by !== "credential-rotation") {
|
|
6361
|
-
for (const row of this.db.prepare("SELECT id FROM
|
|
6526
|
+
for (const row of this.db.prepare("SELECT id FROM credential WHERE kind = 'person' AND account = ? AND revoked_at IS NULL").all(approver))
|
|
6362
6527
|
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
|
|
6528
|
+
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
6529
|
const cid = String(row["cid"]);
|
|
6365
|
-
this.db.prepare("UPDATE
|
|
6530
|
+
this.db.prepare("UPDATE credential SET revoked_at = ?, revoked_by = ? WHERE id = ? AND kind = 'coordinator'").run(stamp, by, cid);
|
|
6366
6531
|
this.db.prepare("INSERT INTO coordinator_event (cid, kind, detail, created_at) VALUES (?, 'revoked', ?, ?)").run(cid, `with ${approver}'s standing, by ${by}`, stamp);
|
|
6367
6532
|
this.sweepCoordinatorProposals(now, cid);
|
|
6368
6533
|
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 +7142,8 @@ export class Store {
|
|
|
6977
7142
|
createApiToken(token, now) {
|
|
6978
7143
|
this.transact(() => {
|
|
6979
7144
|
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");
|
|
7145
|
+
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', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
|
|
7146
|
+
.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
7147
|
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
7148
|
detail: `for ${token.account}, until ${token.expiresAt.slice(0, 10)}${projects === null ? "" : ` · ${projects.length === 0 ? "no projects" : projects.join(", ")}`} · id ${token.id}` });
|
|
6984
7149
|
});
|
|
@@ -7000,9 +7165,11 @@ export class Store {
|
|
|
7000
7165
|
return { ok: false, reason: "replaced" };
|
|
7001
7166
|
const stamp = now.toISOString();
|
|
7002
7167
|
const until = new Date(Math.min(at + Math.max(0, overlapMs), Date.parse(old.expiresAt))).toISOString();
|
|
7003
|
-
|
|
7004
|
-
|
|
7005
|
-
|
|
7168
|
+
// The replacement is the token's next generation; it keeps every term, so rotating never widens or extends it.
|
|
7169
|
+
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)
|
|
7170
|
+
VALUES (?, 'person', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, (SELECT generation + 1 FROM credential WHERE id = ?))`)
|
|
7171
|
+
.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);
|
|
7172
|
+
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
7173
|
this.recordAction({ at: stamp, actor: by, repo: null, taskId: null, runId: null, action: `API token rotated: ${old.name}`, outcome: old.access, source: "access",
|
|
7007
7174
|
detail: `${old.account}'s · id ${old.id} → ${replacement.id}, the old one stops ${until.slice(0, 16).replace("T", " ")} UTC` });
|
|
7008
7175
|
return { ok: true, row: this.apiTokenSecret(replacement.id).row };
|
|
@@ -7010,30 +7177,46 @@ export class Store {
|
|
|
7010
7177
|
}
|
|
7011
7178
|
/** v111: record the end of each replaced token whose overlap has passed (it already signs no one in). */
|
|
7012
7179
|
endRotatedApiTokens(now) {
|
|
7013
|
-
const due = this.db.prepare("SELECT id, overlap_until FROM
|
|
7180
|
+
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
7181
|
for (const row of due)
|
|
7015
7182
|
this.revokeApiToken(String(row["id"]), "system", new Date(String(row["overlap_until"])), "replaced; its overlap ended");
|
|
7016
7183
|
return due.length;
|
|
7017
7184
|
}
|
|
7018
|
-
/**
|
|
7185
|
+
/**
|
|
7186
|
+
* D7: the one check every machine credential takes. The credential a presented secret is (its kind and id), when
|
|
7187
|
+
* its kept hash matches in constant time; null otherwise. Whether it is live, and what its kind may do, stays with
|
|
7188
|
+
* each caller's own policy (a person's API token, a lead token, a coordinator).
|
|
7189
|
+
*/
|
|
7190
|
+
presentedCredential(presented) {
|
|
7191
|
+
const parsed = parseCredential(presented);
|
|
7192
|
+
if (parsed === null)
|
|
7193
|
+
return null;
|
|
7194
|
+
const row = parsed.kind === "coordinator"
|
|
7195
|
+
? this.db.prepare("SELECT id, secret_hash FROM credential WHERE kind = 'coordinator' AND secret_hash = ?").get(hashSecret(parsed.secret))
|
|
7196
|
+
: this.db.prepare("SELECT id, secret_hash FROM credential WHERE kind = ? AND public_id = ?").get(parsed.kind, parsed.id);
|
|
7197
|
+
if (row === undefined || !secretMatches(parsed.secret, String(row["secret_hash"])))
|
|
7198
|
+
return null;
|
|
7199
|
+
return { kind: parsed.kind, id: String(row["id"]) };
|
|
7200
|
+
}
|
|
7201
|
+
/** A person's API token by id with its kept hash (for the check), or null. */
|
|
7019
7202
|
apiTokenSecret(id) {
|
|
7020
|
-
const row = this.db.prepare("SELECT * FROM
|
|
7203
|
+
const row = this.db.prepare("SELECT * FROM credential WHERE id = ? AND kind = 'person'").get(id);
|
|
7021
7204
|
return row === undefined ? null : { row: readApiToken(row), secretHash: String(row["secret_hash"]) };
|
|
7022
7205
|
}
|
|
7023
7206
|
/** Tokens, newest first: one person's, or everyone's (null). */
|
|
7024
7207
|
apiTokens(account) {
|
|
7025
|
-
return (account === null ? this.db.prepare("SELECT * FROM
|
|
7208
|
+
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
7209
|
}
|
|
7027
7210
|
/** Kept at most once a minute: a busy script doesn't write on every request. */
|
|
7028
7211
|
touchApiToken(id, now) {
|
|
7029
|
-
this.db.prepare("UPDATE
|
|
7212
|
+
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
7213
|
}
|
|
7031
7214
|
revokeApiToken(id, by, now, why = "revoked") {
|
|
7032
7215
|
return this.transact(() => {
|
|
7033
|
-
const row = this.db.prepare("SELECT name, account FROM
|
|
7216
|
+
const row = this.db.prepare("SELECT name, account FROM credential WHERE id = ? AND kind = 'person' AND revoked_at IS NULL").get(id);
|
|
7034
7217
|
if (row === undefined)
|
|
7035
7218
|
return false;
|
|
7036
|
-
this.db.prepare("UPDATE
|
|
7219
|
+
this.db.prepare("UPDATE credential SET revoked_at = ?, revoked_by = ? WHERE id = ? AND kind = 'person'").run(now.toISOString(), by, id);
|
|
7037
7220
|
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
7221
|
return true;
|
|
7039
7222
|
});
|
|
@@ -7146,7 +7329,7 @@ export class Store {
|
|
|
7146
7329
|
.run(next.refreshHash, next.accessExpiresAt, token, ask.refreshHash);
|
|
7147
7330
|
if (Number(moved.changes) !== 1)
|
|
7148
7331
|
return invalid;
|
|
7149
|
-
this.db.prepare("UPDATE
|
|
7332
|
+
this.db.prepare("UPDATE credential SET secret_hash = ? WHERE id = ? AND kind = 'person' AND revoked_at IS NULL").run(next.accessHash, token);
|
|
7150
7333
|
this.db.prepare("INSERT INTO oauth_refresh (token, hash) VALUES (?, ?)").run(token, next.refreshHash);
|
|
7151
7334
|
return { ok: true, access: kept.access };
|
|
7152
7335
|
});
|
|
@@ -9780,6 +9963,66 @@ export class Store {
|
|
|
9780
9963
|
return { ok: true };
|
|
9781
9964
|
});
|
|
9782
9965
|
}
|
|
9966
|
+
/**
|
|
9967
|
+
* A rewritten scope starts planning again (v119), in one transaction: the task's planning generation advances, a
|
|
9968
|
+
* requested or drafted plan becomes requested with no strikes, and what the old goal produced leaves the active
|
|
9969
|
+
* planning context — its open questions close (typed 'scope-changed', never a fake answer, their holds and pages
|
|
9970
|
+
* with them) and a revision still waiting for a person is rejected with its hold. The old runs, plans, revisions,
|
|
9971
|
+
* answers and ledger rows all stay: generation-bound reads simply stop offering them to the next planner. Only a
|
|
9972
|
+
* task already planning restarts; false (nothing changed) for any other.
|
|
9973
|
+
*/
|
|
9974
|
+
restartPlanning(taskRef, now) {
|
|
9975
|
+
return this.transact(() => {
|
|
9976
|
+
const moved = this.db
|
|
9977
|
+
.prepare("UPDATE task_ref SET planning_generation = planning_generation + 1, plan = 'requested', plan_strikes = 0 WHERE id = ? AND plan IN ('requested','drafted')")
|
|
9978
|
+
.run(taskRef);
|
|
9979
|
+
if (Number(moved.changes) === 0)
|
|
9980
|
+
return false;
|
|
9981
|
+
const ref = this.db.prepare("SELECT external_id, repo, planning_generation FROM task_ref WHERE id = ?").get(taskRef);
|
|
9982
|
+
const generation = Number(ref["planning_generation"]);
|
|
9983
|
+
this.closeQuestionsLocked(taskRef, "scope-changed", now, generation);
|
|
9984
|
+
const blocked = this.db
|
|
9985
|
+
.prepare("SELECT id FROM plan_revision WHERE task_ref = ? AND status = 'blocked' AND planning_generation < ?")
|
|
9986
|
+
.all(taskRef, generation)
|
|
9987
|
+
.map(row => Number(row["id"]));
|
|
9988
|
+
for (const id of blocked) {
|
|
9989
|
+
this.resolvePlanRevision(id, "rejected", "toolroll", now);
|
|
9990
|
+
this.releaseOwnedHold("revision", String(id));
|
|
9991
|
+
}
|
|
9992
|
+
this.recordAction({
|
|
9993
|
+
at: now.toISOString(), actor: currentActor() === null ? "system" : actorLabel(currentActor()), repo: ref["repo"] === null ? null : String(ref["repo"]),
|
|
9994
|
+
taskId: String(ref["external_id"]), runId: null, action: "planning restarted", outcome: "scope changed", source: "work",
|
|
9995
|
+
});
|
|
9996
|
+
this.bumpWake();
|
|
9997
|
+
return true;
|
|
9998
|
+
});
|
|
9999
|
+
}
|
|
10000
|
+
/**
|
|
10001
|
+
* Close a task's open and expired questions without answering them (v119): typed with why, no choice, their
|
|
10002
|
+
* holds lifted and their pages resolved, so they leave every decision queue. Answered questions are never touched.
|
|
10003
|
+
* `belowGeneration` limits it to questions asked in earlier planning generations. Caller holds the transaction.
|
|
10004
|
+
*/
|
|
10005
|
+
closeQuestionsLocked(taskRef, reason, now, belowGeneration) {
|
|
10006
|
+
const ids = this.db
|
|
10007
|
+
.prepare(`SELECT decision.id FROM decision JOIN run ON run.id = decision.run
|
|
10008
|
+
WHERE run.task_ref = ? AND decision.state IN ('open','expired') AND (? IS NULL OR run.planning_generation < ?)
|
|
10009
|
+
ORDER BY decision.id`)
|
|
10010
|
+
.all(taskRef, belowGeneration ?? null, belowGeneration ?? null)
|
|
10011
|
+
.map(row => Number(row["id"]));
|
|
10012
|
+
const close = this.db.prepare("UPDATE decision SET state = 'answered', answered_at = ?, answered_by = 'toolroll', superseded_reason = ? WHERE id = ? AND state IN ('open','expired')");
|
|
10013
|
+
for (const id of ids) {
|
|
10014
|
+
close.run(now.toISOString(), reason, id);
|
|
10015
|
+
this.releaseOwnedHold("decision", String(id));
|
|
10016
|
+
this.resolveEpisode(`decision:${id}`, now);
|
|
10017
|
+
}
|
|
10018
|
+
return ids;
|
|
10019
|
+
}
|
|
10020
|
+
/** The number the next plan revision of a task takes: one past every revision it ever had, in any planning
|
|
10021
|
+
* generation, so a restarted plan's revisions never collide with the history they follow. */
|
|
10022
|
+
nextPlanRevisionNumber(taskRef) {
|
|
10023
|
+
const row = this.db.prepare("SELECT coalesce(max(revision), 0) AS n FROM plan_revision WHERE task_ref = ?").get(taskRef);
|
|
10024
|
+
return Number(row?.["n"] ?? 0) + 1;
|
|
10025
|
+
}
|
|
9783
10026
|
/** Move the planning state; null clears it. Caller owns the transaction story. */
|
|
9784
10027
|
setPlanState(taskRef, state) {
|
|
9785
10028
|
this.db.prepare("UPDATE task_ref SET plan = ? WHERE id = ?").run(state, taskRef);
|
|
@@ -10912,10 +11155,10 @@ export class Store {
|
|
|
10912
11155
|
* before signing). */
|
|
10913
11156
|
coordinatorProvenanceOf(taskId) {
|
|
10914
11157
|
const row = this.db
|
|
10915
|
-
.prepare(`SELECT c.name AS name, c.
|
|
10916
|
-
(SELECT MIN(e.created_at) FROM coordinator_event e WHERE e.cid = c.
|
|
11158
|
+
.prepare(`SELECT c.name AS name, c.public_id AS cid,
|
|
11159
|
+
(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
|
|
10917
11160
|
FROM task_ref
|
|
10918
|
-
JOIN
|
|
11161
|
+
JOIN credential c ON c.id = task_ref.coordinator_cid AND c.kind = 'coordinator'
|
|
10919
11162
|
WHERE task_ref.backend = ? AND task_ref.external_id = ?`)
|
|
10920
11163
|
.get(BUILT_IN, taskId);
|
|
10921
11164
|
if (row === undefined)
|
|
@@ -11491,8 +11734,8 @@ export class Store {
|
|
|
11491
11734
|
})();
|
|
11492
11735
|
const inserted = this.db
|
|
11493
11736
|
.prepare(`INSERT INTO run (task_ref, lease_id, runner, branch, worktree, model, role, provider, parent_run, session_id, quality_mode,
|
|
11494
|
-
started_at, review_attempt)
|
|
11495
|
-
VALUES (
|
|
11737
|
+
started_at, review_attempt, planning_generation)
|
|
11738
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, (SELECT planning_generation FROM task_ref WHERE id = ?1))`)
|
|
11496
11739
|
.run(run.taskRef, run.leaseId, run.runner, run.branch ?? null, run.worktree ?? null, model, role, provider ?? "claude", run.parentRun ?? null, run.sessionId ?? null, qualityMode, run.now.toISOString(),
|
|
11497
11740
|
// The root reviewer's ordinal (v50) rides the insert: a root and
|
|
11498
11741
|
// its attempt number are one fact, and the ordinal index refuses a
|
|
@@ -12437,12 +12680,14 @@ export class Store {
|
|
|
12437
12680
|
* reads in the order the questions arrived. "unanswered" is open + expired:
|
|
12438
12681
|
* expiry makes a decision louder, never gone.
|
|
12439
12682
|
*/
|
|
12440
|
-
/** The newest planner run's plan document for a task, when one exists
|
|
12683
|
+
/** The newest planner run's plan document for a task, when one exists in its current planning generation (v119:
|
|
12684
|
+
* a plan drafted for a scope since rewritten is history, read by its artifact id). */
|
|
12441
12685
|
latestPlanArtifact(taskRef) {
|
|
12442
12686
|
const row = this.db
|
|
12443
12687
|
.prepare(`SELECT artifact.* FROM artifact
|
|
12444
12688
|
JOIN run ON run.id = artifact.run
|
|
12445
12689
|
WHERE run.task_ref = ? AND run.role = 'planner' AND artifact.kind = 'plan'
|
|
12690
|
+
AND run.planning_generation = (SELECT planning_generation FROM task_ref WHERE id = run.task_ref)
|
|
12446
12691
|
ORDER BY artifact.id DESC LIMIT 1`)
|
|
12447
12692
|
.get(taskRef);
|
|
12448
12693
|
return row === undefined ? null : readArtifact(row);
|
|
@@ -12457,6 +12702,7 @@ export class Store {
|
|
|
12457
12702
|
JOIN run ON run.id = artifact.run
|
|
12458
12703
|
WHERE run.task_ref = ? AND run.role = 'planner' AND artifact.kind = 'plan-contract'
|
|
12459
12704
|
AND artifact.key LIKE '%/plan-contract.json'
|
|
12705
|
+
AND run.planning_generation = (SELECT planning_generation FROM task_ref WHERE id = run.task_ref)
|
|
12460
12706
|
ORDER BY artifact.id DESC LIMIT 1`)
|
|
12461
12707
|
.get(taskRef);
|
|
12462
12708
|
return row === undefined ? null : readArtifact(row);
|
|
@@ -12484,16 +12730,17 @@ export class Store {
|
|
|
12484
12730
|
const inserted = this.db
|
|
12485
12731
|
.prepare(`INSERT INTO plan_revision
|
|
12486
12732
|
(task_ref, revision, artifact, parent_hash, reason, evidence_link, author, origin_run,
|
|
12487
|
-
kind, authority_kind, authority_digest, changed_fields, status, created_at)
|
|
12488
|
-
VALUES (
|
|
12733
|
+
kind, authority_kind, authority_digest, changed_fields, status, created_at, planning_generation)
|
|
12734
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, (SELECT planning_generation FROM task_ref WHERE id = ?1))`)
|
|
12489
12735
|
.run(revision.taskRef, revision.revision, revision.artifact, revision.parentHash, revision.reason, revision.evidenceLink, revision.author, revision.originRun, revision.kind, revision.authorityKind, revision.authorityDigest, JSON.stringify(revision.changedFields), revision.status, now.toISOString());
|
|
12490
12736
|
return Number(inserted.lastInsertRowid);
|
|
12491
12737
|
}
|
|
12492
|
-
/** Every revision for a task
|
|
12493
|
-
* reader can show without a second table
|
|
12738
|
+
/** Every revision for a task in its current planning generation, newest
|
|
12739
|
+
* first — the immutable history a reader can show without a second table
|
|
12740
|
+
* (v119: an earlier generation's rows stay, read by id). */
|
|
12494
12741
|
listPlanRevisions(taskRef) {
|
|
12495
12742
|
return this.db
|
|
12496
|
-
.prepare("SELECT * FROM plan_revision WHERE task_ref = ? ORDER BY revision DESC")
|
|
12743
|
+
.prepare("SELECT * FROM plan_revision WHERE task_ref = ? AND planning_generation = (SELECT planning_generation FROM task_ref WHERE id = plan_revision.task_ref) ORDER BY revision DESC")
|
|
12497
12744
|
.all(taskRef)
|
|
12498
12745
|
.map(readPlanRevision);
|
|
12499
12746
|
}
|
|
@@ -12504,7 +12751,7 @@ export class Store {
|
|
|
12504
12751
|
* `latestPlanArtifact`'s synthetic revision 1 in both cases. */
|
|
12505
12752
|
currentPlanRevision(taskRef) {
|
|
12506
12753
|
const row = this.db
|
|
12507
|
-
.prepare("SELECT * FROM plan_revision WHERE task_ref = ? AND status = 'applied' ORDER BY revision DESC LIMIT 1")
|
|
12754
|
+
.prepare("SELECT * FROM plan_revision WHERE task_ref = ? AND planning_generation = (SELECT planning_generation FROM task_ref WHERE id = plan_revision.task_ref) AND status = 'applied' ORDER BY revision DESC LIMIT 1")
|
|
12508
12755
|
.get(taskRef);
|
|
12509
12756
|
return row === undefined ? null : readPlanRevision(row);
|
|
12510
12757
|
}
|
|
@@ -12512,7 +12759,7 @@ export class Store {
|
|
|
12512
12759
|
* 'blocked' awaiting an operator's decision — what a reader shows
|
|
12513
12760
|
* alongside the current one to render a pending proposal at all. */
|
|
12514
12761
|
latestPlanRevision(taskRef) {
|
|
12515
|
-
const row = this.db.prepare("SELECT * FROM plan_revision WHERE task_ref = ? ORDER BY revision DESC LIMIT 1").get(taskRef);
|
|
12762
|
+
const row = this.db.prepare("SELECT * FROM plan_revision WHERE task_ref = ? AND planning_generation = (SELECT planning_generation FROM task_ref WHERE id = plan_revision.task_ref) ORDER BY revision DESC LIMIT 1").get(taskRef);
|
|
12516
12763
|
return row === undefined ? null : readPlanRevision(row);
|
|
12517
12764
|
}
|
|
12518
12765
|
getPlanRevision(id) {
|
|
@@ -12600,7 +12847,8 @@ export class Store {
|
|
|
12600
12847
|
.get(taskRef);
|
|
12601
12848
|
return row === undefined ? null : readArtifact(row);
|
|
12602
12849
|
}
|
|
12603
|
-
/** Answered questions for one task, newest first — the planner's memory.
|
|
12850
|
+
/** Answered questions for one task, newest first — the planner's memory. Only real answers from its current
|
|
12851
|
+
* planning generation (v119): a question closed unanswered, or asked about a scope since rewritten, is history. */
|
|
12604
12852
|
answeredDecisionsFor(taskId, limit = 5) {
|
|
12605
12853
|
return this.db
|
|
12606
12854
|
.prepare(`SELECT decision.question, decision.choice, decision.note
|
|
@@ -12608,7 +12856,8 @@ export class Store {
|
|
|
12608
12856
|
JOIN run ON run.id = decision.run
|
|
12609
12857
|
JOIN task_ref ON task_ref.id = run.task_ref
|
|
12610
12858
|
WHERE task_ref.backend = ? AND task_ref.external_id = ?
|
|
12611
|
-
AND decision.answered_at IS NOT NULL
|
|
12859
|
+
AND decision.answered_at IS NOT NULL AND decision.superseded_reason IS NULL
|
|
12860
|
+
AND run.planning_generation = task_ref.planning_generation
|
|
12612
12861
|
ORDER BY decision.answered_at DESC, decision.id DESC LIMIT ?`)
|
|
12613
12862
|
.all(BUILT_IN, taskId, Math.max(1, Math.min(Math.floor(limit), 20)))
|
|
12614
12863
|
.map(row => ({
|
|
@@ -12707,6 +12956,9 @@ export class Store {
|
|
|
12707
12956
|
JOIN task_ref ON task_ref.id = run.task_ref
|
|
12708
12957
|
WHERE run.task_ref = ? AND decision.state = 'answered'
|
|
12709
12958
|
AND decision.contestant IS NULL
|
|
12959
|
+
-- v119: a question closed unanswered carries no answer, and one asked about a rewritten scope is history.
|
|
12960
|
+
AND decision.superseded_reason IS NULL
|
|
12961
|
+
AND run.planning_generation = task_ref.planning_generation
|
|
12710
12962
|
AND NOT EXISTS (
|
|
12711
12963
|
SELECT 1 FROM run_decision
|
|
12712
12964
|
JOIN run AS delivered ON delivered.id = run_decision.run
|
|
@@ -12860,6 +13112,9 @@ export class Store {
|
|
|
12860
13112
|
const existing = this.getDecision(answer.id);
|
|
12861
13113
|
if (existing === null)
|
|
12862
13114
|
return { ok: false, reason: "unknown-decision" };
|
|
13115
|
+
// A question closed without an answer (v119) stays closed: its task's scope changed or the task was cancelled.
|
|
13116
|
+
if (existing.supersededReason !== null)
|
|
13117
|
+
return { ok: false, reason: "closed" };
|
|
12863
13118
|
if (!existing.options.some(option => option.id === answer.choice)) {
|
|
12864
13119
|
return { ok: false, reason: "bad-option" };
|
|
12865
13120
|
}
|
|
@@ -16692,9 +16947,13 @@ export class Store {
|
|
|
16692
16947
|
mintLeadCredential(owner, by, now) {
|
|
16693
16948
|
return this.transact(() => {
|
|
16694
16949
|
const stamp = now.toISOString();
|
|
16695
|
-
this.db.prepare("UPDATE
|
|
16696
|
-
|
|
16697
|
-
|
|
16950
|
+
this.db.prepare("UPDATE credential SET revoked_at = ?, revoked_by = ? WHERE kind = 'lead' AND account = ? AND revoked_at IS NULL").run(stamp, by, owner);
|
|
16951
|
+
let minted = mintLeadToken();
|
|
16952
|
+
// One id per credential across kinds; the canonical id is the one the token presents.
|
|
16953
|
+
while (this.db.prepare("SELECT 1 FROM credential WHERE id = ? OR (kind = 'lead' AND public_id = ?)").get(minted.id, minted.id) !== undefined)
|
|
16954
|
+
minted = mintLeadToken();
|
|
16955
|
+
this.db.prepare(`INSERT INTO credential (id, kind, public_id, account, name, secret_hash, access, created_at, created_by, generation)
|
|
16956
|
+
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);
|
|
16698
16957
|
this.recordAction({ at: stamp, actor: by, repo: null, taskId: null, runId: null, action: "lead token created", outcome: "recorded", source: "access", detail: `lead for ${owner}` });
|
|
16699
16958
|
return { id: minted.id, token: minted.token };
|
|
16700
16959
|
});
|
|
@@ -16702,7 +16961,7 @@ export class Store {
|
|
|
16702
16961
|
/** End every live lead token for one person. */
|
|
16703
16962
|
revokeLeadCredentials(owner, by, now) {
|
|
16704
16963
|
return this.transact(() => {
|
|
16705
|
-
const ended = Number(this.db.prepare("UPDATE
|
|
16964
|
+
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);
|
|
16706
16965
|
if (ended > 0)
|
|
16707
16966
|
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}` });
|
|
16708
16967
|
return ended;
|
|
@@ -16710,14 +16969,14 @@ export class Store {
|
|
|
16710
16969
|
}
|
|
16711
16970
|
/** Whose lead a presented token is: a live token whose owner is still an approver, or null. */
|
|
16712
16971
|
leadFor(presented) {
|
|
16713
|
-
const
|
|
16714
|
-
if (
|
|
16972
|
+
const found = this.presentedCredential(presented);
|
|
16973
|
+
if (found?.kind !== "lead")
|
|
16715
16974
|
return null;
|
|
16716
|
-
const row = this.db.prepare("SELECT
|
|
16717
|
-
if (row === undefined
|
|
16975
|
+
const row = this.db.prepare("SELECT account, public_id FROM credential WHERE id = ? AND kind = 'lead' AND revoked_at IS NULL").get(found.id);
|
|
16976
|
+
if (row === undefined)
|
|
16718
16977
|
return null;
|
|
16719
|
-
const owner = String(row["
|
|
16720
|
-
return account === null || account.revokedAt !== null || account.role !== "approver" ? null : { owner, id:
|
|
16978
|
+
const owner = String(row["account"]), account = this.accountOf(owner);
|
|
16979
|
+
return account === null || account.revokedAt !== null || account.role !== "approver" ? null : { owner, id: String(row["public_id"]) };
|
|
16721
16980
|
}
|
|
16722
16981
|
/** Record who did this to a task, when someone is acting. The lead's acts are also in the ledger as "lead for <owner>". */
|
|
16723
16982
|
noteTaskAct(taskRef, act, now, handed, actor = currentActor()) {
|
|
@@ -17277,6 +17536,7 @@ function readRun(row) {
|
|
|
17277
17536
|
taskRef: Number(row["task_ref"]),
|
|
17278
17537
|
leaseId: String(row["lease_id"]),
|
|
17279
17538
|
runner: String(row["runner"]),
|
|
17539
|
+
planningGeneration: Number(row["planning_generation"] ?? 0),
|
|
17280
17540
|
watchIncarnation: row["watch_incarnation"] == null ? null : String(row["watch_incarnation"]),
|
|
17281
17541
|
reviewAttempt: wholeNumber(row["review_attempt"]),
|
|
17282
17542
|
role: String(row["role"] ?? "builder"),
|
|
@@ -17409,6 +17669,7 @@ function readDecision(row) {
|
|
|
17409
17669
|
answeredVia: row["answered_via"] === null ? null : String(row["answered_via"]),
|
|
17410
17670
|
choice: row["choice"] === null ? null : String(row["choice"]),
|
|
17411
17671
|
note: row["note"] === null ? null : String(row["note"]),
|
|
17672
|
+
supersededReason: row["superseded_reason"] === "scope-changed" || row["superseded_reason"] === "cancelled" ? row["superseded_reason"] : null,
|
|
17412
17673
|
};
|
|
17413
17674
|
}
|
|
17414
17675
|
function readArtifact(row) {
|
|
@@ -17596,6 +17857,7 @@ function readTaskRef(row) {
|
|
|
17596
17857
|
? null
|
|
17597
17858
|
: String(row["plan"]),
|
|
17598
17859
|
planStrikes: Number(row["plan_strikes"] ?? 0),
|
|
17860
|
+
planningGeneration: Number(row["planning_generation"] ?? 0),
|
|
17599
17861
|
deliverable: row["deliverable"] === "report" ? "report" : "branch",
|
|
17600
17862
|
strikes: Number(row["strikes"] ?? 0),
|
|
17601
17863
|
agentProvider: row["agent_provider"] === null || row["agent_provider"] === undefined
|