toolroll 0.9.58 → 0.9.60
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 +18 -0
- package/README.md +8 -6
- package/dist/action-ledger.js +10 -1
- package/dist/assignment-adapters.d.ts +1 -1
- package/dist/assignment-adapters.js +1 -1
- package/dist/assignment-delivery.d.ts +16 -0
- package/dist/assignment-delivery.js +54 -28
- package/dist/assignment-status.d.ts +13 -4
- package/dist/assignment-status.js +70 -39
- package/dist/assignment.d.ts +6 -2
- package/dist/assignment.js +13 -8
- package/dist/bin.js +11 -2
- package/dist/brief-fence.d.ts +9 -0
- package/dist/brief-fence.js +22 -0
- package/dist/browser/workspace.css +2 -2
- package/dist/browser/workspace.js +19 -19
- package/dist/builder.js +78 -27
- package/dist/chat-channel.d.ts +1 -0
- package/dist/chat-channel.js +4 -1
- package/dist/chat-delivery.js +2 -0
- package/dist/cli-fast.d.ts +8 -0
- package/dist/cli-fast.js +18 -0
- package/dist/cli-help.d.ts +5 -0
- package/dist/cli-help.js +91 -0
- package/dist/cli.d.ts +6 -5
- package/dist/cli.js +17 -96
- package/dist/contract-changes.d.ts +52 -0
- package/dist/contract-changes.js +61 -0
- 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/design/tokens.css +234 -0
- package/dist/discord.js +2 -1
- package/dist/dispatch.js +1 -1
- package/dist/evidence.d.ts +22 -0
- package/dist/evidence.js +76 -17
- package/dist/flow-actions.js +2 -1
- package/dist/guides.js +14 -11
- package/dist/lead-cli.js +4 -1
- package/dist/lead-contract.js +1 -1
- package/dist/lead-doors.js +8 -2
- package/dist/lead-follow.d.ts +2 -0
- package/dist/lead-follow.js +138 -88
- package/dist/lead-tools.js +11 -3
- package/dist/mailbox.js +3 -2
- package/dist/mcp-core.js +1 -1
- package/dist/needs-you.js +3 -1
- package/dist/operate.js +38 -17
- package/dist/password-bearer.d.ts +4 -4
- package/dist/password-bearer.js +2 -2
- package/dist/planner-source.d.ts +23 -53
- package/dist/planner-source.js +43 -68
- package/dist/provider.d.ts +8 -0
- package/dist/provider.js +12 -1
- package/dist/release-gate.d.ts +2 -3
- package/dist/release-gate.js +44 -16
- package/dist/scope.js +14 -0
- package/dist/serve.js +3 -2
- package/dist/server/chrome.d.ts +6 -3
- package/dist/server/chrome.js +8 -4
- package/dist/server/console.css +9 -64
- package/dist/server/render-chat.js +12 -2
- package/dist/server/render-tasks.d.ts +2 -0
- package/dist/server/render-tasks.js +30 -22
- package/dist/server/task-views.js +1 -0
- package/dist/server/tasks.js +3 -1
- package/dist/slack-socket.d.ts +12 -0
- package/dist/slack-socket.js +29 -0
- package/dist/slack.d.ts +0 -7
- package/dist/slack.js +2 -24
- package/dist/steer-mailbox.d.ts +39 -0
- package/dist/steer-mailbox.js +207 -0
- package/dist/steer-status.d.ts +19 -0
- package/dist/steer-status.js +30 -0
- package/dist/store.d.ts +85 -8
- package/dist/store.js +203 -15
- package/dist/surface.js +2 -2
- package/dist/sweep-gate.d.ts +10 -0
- package/dist/sweep-gate.js +18 -0
- package/dist/task-status.js +3 -1
- package/dist/team-updates.js +3 -3
- package/dist/telegram-lead.js +6 -0
- package/dist/telegram.js +9 -7
- package/dist/toolroll-update.js +20 -1
- package/package.json +21 -21
package/dist/store.js
CHANGED
|
@@ -559,7 +559,12 @@ const REGISTERED_AT_FILING = "again, as it was filed";
|
|
|
559
559
|
// v118 (D7) keeps every machine credential in one table, `credential`: a person's API tokens (so_…), the lead's tokens
|
|
560
560
|
// (lt_…) and coordinators, each with its kind, read/act access, project limit, expiry, revocation and generation. Every
|
|
561
561
|
// row moves with its id, hash and dates; the references to coordinators and API tokens point at it.
|
|
562
|
-
|
|
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
|
+
// v120 lets a running build read steering: a note the pulse put in the build's steering folder (task_steer.mailed_run,
|
|
566
|
+
// mailed_at) and the moment the agent moved it to handled/ (acked_at).
|
|
567
|
+
export const SCHEMA_VERSION = 120;
|
|
563
568
|
/** v118: the tables merged into `credential`, in the order they move (a person's API tokens keep their ids first). */
|
|
564
569
|
export const V118_MERGED_TABLES = Object.freeze({ api_token: "person", coordinator_credential: "coordinator", lead_credential: "lead" });
|
|
565
570
|
/** The foreign keys that named a merged table, exactly as their DDL wrote them; each names `credential(id)` instead. */
|
|
@@ -680,9 +685,31 @@ const V115_DROPPED_REFERENCES = /\s+REFERENCES (?:contestant|contest|tournament_
|
|
|
680
685
|
* 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
|
|
681
686
|
* each renamed table's rows arrived under its new name. v118 (D7) is checked the same way: every row of api_token,
|
|
682
687
|
* 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.
|
|
688
|
+
* before the old tables drop, and the references that named them name credential in place, their rows untouched. v119 only adds four columns
|
|
689
|
+
* (planning_generation on task_ref, run and plan_revision, defaulting to 0, and decision.superseded_reason, null);
|
|
690
|
+
* the rehearsal checks every saved row reads the declared default (HISTORY_RULES' added columns). v120 only adds
|
|
691
|
+
* three nullable task_steer columns (the running build's steering folder), checked the same way.
|
|
684
692
|
*/
|
|
685
|
-
export const UPDATE_SAFE_MIGRATIONS = Object.freeze([108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118]);
|
|
693
|
+
export const UPDATE_SAFE_MIGRATIONS = Object.freeze([108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120]);
|
|
694
|
+
/** v119: the columns it adds, by table, with the value every row saved before it reads (toolroll-update's HISTORY_RULES). */
|
|
695
|
+
export const V119_ADDED_COLUMNS = Object.freeze({
|
|
696
|
+
task_ref: { planning_generation: { definition: "INTEGER NOT NULL DEFAULT 0", saved: "0" } },
|
|
697
|
+
run: { planning_generation: { definition: "INTEGER NOT NULL DEFAULT 0", saved: "0" } },
|
|
698
|
+
plan_revision: { planning_generation: { definition: "INTEGER NOT NULL DEFAULT 0", saved: "0" } },
|
|
699
|
+
decision: { superseded_reason: { definition: "TEXT CHECK (superseded_reason IN ('scope-changed','cancelled'))", saved: "NULL" } },
|
|
700
|
+
});
|
|
701
|
+
/** v120: the columns it adds, by table, with the value every row saved before it reads. */
|
|
702
|
+
export const V120_ADDED_COLUMNS = Object.freeze({
|
|
703
|
+
task_steer: {
|
|
704
|
+
mailed_run: { definition: "INTEGER REFERENCES run(id)", saved: "NULL" },
|
|
705
|
+
mailed_at: { definition: "TEXT", saved: "NULL" },
|
|
706
|
+
acked_at: { definition: "TEXT", saved: "NULL" },
|
|
707
|
+
},
|
|
708
|
+
run: {
|
|
709
|
+
steer_opened_at: { definition: "TEXT", saved: "NULL" },
|
|
710
|
+
steer_closed_at: { definition: "TEXT", saved: "NULL" },
|
|
711
|
+
},
|
|
712
|
+
});
|
|
686
713
|
/** Whether a database settled at `version` reaches this build's schema through update-safe migrations alone. */
|
|
687
714
|
export function updateSafeSchema(version) {
|
|
688
715
|
if (version === null || !Number.isSafeInteger(version) || version <= 0 || version > SCHEMA_VERSION)
|
|
@@ -2878,6 +2905,9 @@ CREATE TABLE IF NOT EXISTS review_request (
|
|
|
2878
2905
|
-- rode toward; delivered_at settles only on the stream's own receipt (the
|
|
2879
2906
|
-- agent's first words, or a successful completion). At-least-once, honestly
|
|
2880
2907
|
-- labeled. Superseded means the task ended with the note still pending.
|
|
2908
|
+
-- v120 adds mailed_run, mailed_at and acked_at (V120_ADDED_COLUMNS): a note
|
|
2909
|
+
-- filed while a build runs is written into its steering folder, and is read
|
|
2910
|
+
-- (and delivered) only when that agent moves it to handled/.
|
|
2881
2911
|
CREATE TABLE IF NOT EXISTS task_steer (
|
|
2882
2912
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
2883
2913
|
task_ref INTEGER NOT NULL REFERENCES task_ref(id) ON DELETE CASCADE,
|
|
@@ -3474,6 +3504,16 @@ function initializeStore(db, file) {
|
|
|
3474
3504
|
// v115: the task each standing-order firing filed, kept on the firing itself (a card's task changes as it moves).
|
|
3475
3505
|
addColumn(db, "flow_trigger_event", "task", "TEXT");
|
|
3476
3506
|
db.exec(PLAN_AUTO_SCHEMA);
|
|
3507
|
+
// v119: planning generations, and why a question closed unanswered (additive only; saved rows read the defaults).
|
|
3508
|
+
for (const [table, columns] of Object.entries(V119_ADDED_COLUMNS)) {
|
|
3509
|
+
for (const [column, { definition }] of Object.entries(columns))
|
|
3510
|
+
addColumn(db, table, column, definition);
|
|
3511
|
+
}
|
|
3512
|
+
// v120: a running build's steering folder (additive only; saved notes read as never mailed).
|
|
3513
|
+
for (const [table, columns] of Object.entries(V120_ADDED_COLUMNS)) {
|
|
3514
|
+
for (const [column, { definition }] of Object.entries(columns))
|
|
3515
|
+
addColumn(db, table, column, definition);
|
|
3516
|
+
}
|
|
3477
3517
|
installLedgerTriggers(db);
|
|
3478
3518
|
// Attention/history indexes come AFTER migration: on a database whose
|
|
3479
3519
|
// constrained tables still carry a pre-rebuild shape, creating a partial
|
|
@@ -4796,6 +4836,10 @@ export class Store {
|
|
|
4796
4836
|
// just the state verb's.
|
|
4797
4837
|
if (ref !== undefined)
|
|
4798
4838
|
this.supersedeSteerNotes(Number(ref["id"]), now);
|
|
4839
|
+
// Its open questions close the same way (v119), typed 'cancelled', with their holds and pages: a question about
|
|
4840
|
+
// work that will never run must leave every decision queue. Answered ones stay exactly as they were.
|
|
4841
|
+
if (ref !== undefined)
|
|
4842
|
+
this.closeQuestionsLocked(Number(ref["id"]), "cancelled", now);
|
|
4799
4843
|
// The reason is part of the transition, not decoration: a
|
|
4800
4844
|
// coordinator-filed task's dismissal writes its durable event IN THIS
|
|
4801
4845
|
// transaction — operator text and typed machine codes alike (MCP spec
|
|
@@ -9942,6 +9986,66 @@ export class Store {
|
|
|
9942
9986
|
return { ok: true };
|
|
9943
9987
|
});
|
|
9944
9988
|
}
|
|
9989
|
+
/**
|
|
9990
|
+
* A rewritten scope starts planning again (v119), in one transaction: the task's planning generation advances, a
|
|
9991
|
+
* requested or drafted plan becomes requested with no strikes, and what the old goal produced leaves the active
|
|
9992
|
+
* planning context — its open questions close (typed 'scope-changed', never a fake answer, their holds and pages
|
|
9993
|
+
* with them) and a revision still waiting for a person is rejected with its hold. The old runs, plans, revisions,
|
|
9994
|
+
* answers and ledger rows all stay: generation-bound reads simply stop offering them to the next planner. Only a
|
|
9995
|
+
* task already planning restarts; false (nothing changed) for any other.
|
|
9996
|
+
*/
|
|
9997
|
+
restartPlanning(taskRef, now) {
|
|
9998
|
+
return this.transact(() => {
|
|
9999
|
+
const moved = this.db
|
|
10000
|
+
.prepare("UPDATE task_ref SET planning_generation = planning_generation + 1, plan = 'requested', plan_strikes = 0 WHERE id = ? AND plan IN ('requested','drafted')")
|
|
10001
|
+
.run(taskRef);
|
|
10002
|
+
if (Number(moved.changes) === 0)
|
|
10003
|
+
return false;
|
|
10004
|
+
const ref = this.db.prepare("SELECT external_id, repo, planning_generation FROM task_ref WHERE id = ?").get(taskRef);
|
|
10005
|
+
const generation = Number(ref["planning_generation"]);
|
|
10006
|
+
this.closeQuestionsLocked(taskRef, "scope-changed", now, generation);
|
|
10007
|
+
const blocked = this.db
|
|
10008
|
+
.prepare("SELECT id FROM plan_revision WHERE task_ref = ? AND status = 'blocked' AND planning_generation < ?")
|
|
10009
|
+
.all(taskRef, generation)
|
|
10010
|
+
.map(row => Number(row["id"]));
|
|
10011
|
+
for (const id of blocked) {
|
|
10012
|
+
this.resolvePlanRevision(id, "rejected", "toolroll", now);
|
|
10013
|
+
this.releaseOwnedHold("revision", String(id));
|
|
10014
|
+
}
|
|
10015
|
+
this.recordAction({
|
|
10016
|
+
at: now.toISOString(), actor: currentActor() === null ? "system" : actorLabel(currentActor()), repo: ref["repo"] === null ? null : String(ref["repo"]),
|
|
10017
|
+
taskId: String(ref["external_id"]), runId: null, action: "planning restarted", outcome: "scope changed", source: "work",
|
|
10018
|
+
});
|
|
10019
|
+
this.bumpWake();
|
|
10020
|
+
return true;
|
|
10021
|
+
});
|
|
10022
|
+
}
|
|
10023
|
+
/**
|
|
10024
|
+
* Close a task's open and expired questions without answering them (v119): typed with why, no choice, their
|
|
10025
|
+
* holds lifted and their pages resolved, so they leave every decision queue. Answered questions are never touched.
|
|
10026
|
+
* `belowGeneration` limits it to questions asked in earlier planning generations. Caller holds the transaction.
|
|
10027
|
+
*/
|
|
10028
|
+
closeQuestionsLocked(taskRef, reason, now, belowGeneration) {
|
|
10029
|
+
const ids = this.db
|
|
10030
|
+
.prepare(`SELECT decision.id FROM decision JOIN run ON run.id = decision.run
|
|
10031
|
+
WHERE run.task_ref = ? AND decision.state IN ('open','expired') AND (? IS NULL OR run.planning_generation < ?)
|
|
10032
|
+
ORDER BY decision.id`)
|
|
10033
|
+
.all(taskRef, belowGeneration ?? null, belowGeneration ?? null)
|
|
10034
|
+
.map(row => Number(row["id"]));
|
|
10035
|
+
const close = this.db.prepare("UPDATE decision SET state = 'answered', answered_at = ?, answered_by = 'toolroll', superseded_reason = ? WHERE id = ? AND state IN ('open','expired')");
|
|
10036
|
+
for (const id of ids) {
|
|
10037
|
+
close.run(now.toISOString(), reason, id);
|
|
10038
|
+
this.releaseOwnedHold("decision", String(id));
|
|
10039
|
+
this.resolveEpisode(`decision:${id}`, now);
|
|
10040
|
+
}
|
|
10041
|
+
return ids;
|
|
10042
|
+
}
|
|
10043
|
+
/** The number the next plan revision of a task takes: one past every revision it ever had, in any planning
|
|
10044
|
+
* generation, so a restarted plan's revisions never collide with the history they follow. */
|
|
10045
|
+
nextPlanRevisionNumber(taskRef) {
|
|
10046
|
+
const row = this.db.prepare("SELECT coalesce(max(revision), 0) AS n FROM plan_revision WHERE task_ref = ?").get(taskRef);
|
|
10047
|
+
return Number(row?.["n"] ?? 0) + 1;
|
|
10048
|
+
}
|
|
9945
10049
|
/** Move the planning state; null clears it. Caller owns the transaction story. */
|
|
9946
10050
|
setPlanState(taskRef, state) {
|
|
9947
10051
|
this.db.prepare("UPDATE task_ref SET plan = ? WHERE id = ?").run(state, taskRef);
|
|
@@ -11653,8 +11757,8 @@ export class Store {
|
|
|
11653
11757
|
})();
|
|
11654
11758
|
const inserted = this.db
|
|
11655
11759
|
.prepare(`INSERT INTO run (task_ref, lease_id, runner, branch, worktree, model, role, provider, parent_run, session_id, quality_mode,
|
|
11656
|
-
started_at, review_attempt)
|
|
11657
|
-
VALUES (
|
|
11760
|
+
started_at, review_attempt, planning_generation)
|
|
11761
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, (SELECT planning_generation FROM task_ref WHERE id = ?1))`)
|
|
11658
11762
|
.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(),
|
|
11659
11763
|
// The root reviewer's ordinal (v50) rides the insert: a root and
|
|
11660
11764
|
// its attempt number are one fact, and the ordinal index refuses a
|
|
@@ -12599,12 +12703,14 @@ export class Store {
|
|
|
12599
12703
|
* reads in the order the questions arrived. "unanswered" is open + expired:
|
|
12600
12704
|
* expiry makes a decision louder, never gone.
|
|
12601
12705
|
*/
|
|
12602
|
-
/** The newest planner run's plan document for a task, when one exists
|
|
12706
|
+
/** The newest planner run's plan document for a task, when one exists in its current planning generation (v119:
|
|
12707
|
+
* a plan drafted for a scope since rewritten is history, read by its artifact id). */
|
|
12603
12708
|
latestPlanArtifact(taskRef) {
|
|
12604
12709
|
const row = this.db
|
|
12605
12710
|
.prepare(`SELECT artifact.* FROM artifact
|
|
12606
12711
|
JOIN run ON run.id = artifact.run
|
|
12607
12712
|
WHERE run.task_ref = ? AND run.role = 'planner' AND artifact.kind = 'plan'
|
|
12713
|
+
AND run.planning_generation = (SELECT planning_generation FROM task_ref WHERE id = run.task_ref)
|
|
12608
12714
|
ORDER BY artifact.id DESC LIMIT 1`)
|
|
12609
12715
|
.get(taskRef);
|
|
12610
12716
|
return row === undefined ? null : readArtifact(row);
|
|
@@ -12619,6 +12725,7 @@ export class Store {
|
|
|
12619
12725
|
JOIN run ON run.id = artifact.run
|
|
12620
12726
|
WHERE run.task_ref = ? AND run.role = 'planner' AND artifact.kind = 'plan-contract'
|
|
12621
12727
|
AND artifact.key LIKE '%/plan-contract.json'
|
|
12728
|
+
AND run.planning_generation = (SELECT planning_generation FROM task_ref WHERE id = run.task_ref)
|
|
12622
12729
|
ORDER BY artifact.id DESC LIMIT 1`)
|
|
12623
12730
|
.get(taskRef);
|
|
12624
12731
|
return row === undefined ? null : readArtifact(row);
|
|
@@ -12646,16 +12753,17 @@ export class Store {
|
|
|
12646
12753
|
const inserted = this.db
|
|
12647
12754
|
.prepare(`INSERT INTO plan_revision
|
|
12648
12755
|
(task_ref, revision, artifact, parent_hash, reason, evidence_link, author, origin_run,
|
|
12649
|
-
kind, authority_kind, authority_digest, changed_fields, status, created_at)
|
|
12650
|
-
VALUES (
|
|
12756
|
+
kind, authority_kind, authority_digest, changed_fields, status, created_at, planning_generation)
|
|
12757
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, (SELECT planning_generation FROM task_ref WHERE id = ?1))`)
|
|
12651
12758
|
.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());
|
|
12652
12759
|
return Number(inserted.lastInsertRowid);
|
|
12653
12760
|
}
|
|
12654
|
-
/** Every revision for a task
|
|
12655
|
-
* reader can show without a second table
|
|
12761
|
+
/** Every revision for a task in its current planning generation, newest
|
|
12762
|
+
* first — the immutable history a reader can show without a second table
|
|
12763
|
+
* (v119: an earlier generation's rows stay, read by id). */
|
|
12656
12764
|
listPlanRevisions(taskRef) {
|
|
12657
12765
|
return this.db
|
|
12658
|
-
.prepare("SELECT * FROM plan_revision WHERE task_ref = ? ORDER BY revision DESC")
|
|
12766
|
+
.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")
|
|
12659
12767
|
.all(taskRef)
|
|
12660
12768
|
.map(readPlanRevision);
|
|
12661
12769
|
}
|
|
@@ -12666,7 +12774,7 @@ export class Store {
|
|
|
12666
12774
|
* `latestPlanArtifact`'s synthetic revision 1 in both cases. */
|
|
12667
12775
|
currentPlanRevision(taskRef) {
|
|
12668
12776
|
const row = this.db
|
|
12669
|
-
.prepare("SELECT * FROM plan_revision WHERE task_ref = ? AND status = 'applied' ORDER BY revision DESC LIMIT 1")
|
|
12777
|
+
.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")
|
|
12670
12778
|
.get(taskRef);
|
|
12671
12779
|
return row === undefined ? null : readPlanRevision(row);
|
|
12672
12780
|
}
|
|
@@ -12674,7 +12782,7 @@ export class Store {
|
|
|
12674
12782
|
* 'blocked' awaiting an operator's decision — what a reader shows
|
|
12675
12783
|
* alongside the current one to render a pending proposal at all. */
|
|
12676
12784
|
latestPlanRevision(taskRef) {
|
|
12677
|
-
const row = this.db.prepare("SELECT * FROM plan_revision WHERE task_ref = ? ORDER BY revision DESC LIMIT 1").get(taskRef);
|
|
12785
|
+
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);
|
|
12678
12786
|
return row === undefined ? null : readPlanRevision(row);
|
|
12679
12787
|
}
|
|
12680
12788
|
getPlanRevision(id) {
|
|
@@ -12762,7 +12870,8 @@ export class Store {
|
|
|
12762
12870
|
.get(taskRef);
|
|
12763
12871
|
return row === undefined ? null : readArtifact(row);
|
|
12764
12872
|
}
|
|
12765
|
-
/** Answered questions for one task, newest first — the planner's memory.
|
|
12873
|
+
/** Answered questions for one task, newest first — the planner's memory. Only real answers from its current
|
|
12874
|
+
* planning generation (v119): a question closed unanswered, or asked about a scope since rewritten, is history. */
|
|
12766
12875
|
answeredDecisionsFor(taskId, limit = 5) {
|
|
12767
12876
|
return this.db
|
|
12768
12877
|
.prepare(`SELECT decision.question, decision.choice, decision.note
|
|
@@ -12770,7 +12879,8 @@ export class Store {
|
|
|
12770
12879
|
JOIN run ON run.id = decision.run
|
|
12771
12880
|
JOIN task_ref ON task_ref.id = run.task_ref
|
|
12772
12881
|
WHERE task_ref.backend = ? AND task_ref.external_id = ?
|
|
12773
|
-
AND decision.answered_at IS NOT NULL
|
|
12882
|
+
AND decision.answered_at IS NOT NULL AND decision.superseded_reason IS NULL
|
|
12883
|
+
AND run.planning_generation = task_ref.planning_generation
|
|
12774
12884
|
ORDER BY decision.answered_at DESC, decision.id DESC LIMIT ?`)
|
|
12775
12885
|
.all(BUILT_IN, taskId, Math.max(1, Math.min(Math.floor(limit), 20)))
|
|
12776
12886
|
.map(row => ({
|
|
@@ -12869,6 +12979,9 @@ export class Store {
|
|
|
12869
12979
|
JOIN task_ref ON task_ref.id = run.task_ref
|
|
12870
12980
|
WHERE run.task_ref = ? AND decision.state = 'answered'
|
|
12871
12981
|
AND decision.contestant IS NULL
|
|
12982
|
+
-- v119: a question closed unanswered carries no answer, and one asked about a rewritten scope is history.
|
|
12983
|
+
AND decision.superseded_reason IS NULL
|
|
12984
|
+
AND run.planning_generation = task_ref.planning_generation
|
|
12872
12985
|
AND NOT EXISTS (
|
|
12873
12986
|
SELECT 1 FROM run_decision
|
|
12874
12987
|
JOIN run AS delivered ON delivered.id = run_decision.run
|
|
@@ -12977,6 +13090,72 @@ export class Store {
|
|
|
12977
13090
|
.run(now.toISOString(), runId);
|
|
12978
13091
|
return Number(changes);
|
|
12979
13092
|
}
|
|
13093
|
+
/**
|
|
13094
|
+
* The task's build whose agent is running right now and reads its steering
|
|
13095
|
+
* folder (v120): the current live lease's open builder run, in its
|
|
13096
|
+
* agent-running phase, whose steering folder is open. Null otherwise — a
|
|
13097
|
+
* note then waits for the next attempt. Display state, never evidence.
|
|
13098
|
+
*/
|
|
13099
|
+
steeringLiveBuild(taskRef, now) {
|
|
13100
|
+
const live = this.currentLiveLease(taskRef, now);
|
|
13101
|
+
if (live === null)
|
|
13102
|
+
return null;
|
|
13103
|
+
const row = this.db
|
|
13104
|
+
.prepare(`SELECT id FROM run WHERE task_ref = ? AND lease_id = ? AND role = 'builder'
|
|
13105
|
+
AND outcome IS NULL AND phase = 'agent-running' AND steer_opened_at IS NOT NULL AND steer_closed_at IS NULL
|
|
13106
|
+
ORDER BY id DESC LIMIT 1`)
|
|
13107
|
+
.get(taskRef, live);
|
|
13108
|
+
return row === undefined ? null : Number(row["id"]);
|
|
13109
|
+
}
|
|
13110
|
+
/** This run made its steering folder (v120): its agent can read a note filed now. */
|
|
13111
|
+
markSteerFolderOpened(runId, now) {
|
|
13112
|
+
this.db.prepare("UPDATE run SET steer_opened_at = ? WHERE id = ? AND outcome IS NULL AND steer_opened_at IS NULL").run(now.toISOString(), runId);
|
|
13113
|
+
}
|
|
13114
|
+
/** This run's steering folder is gone (v120): a note filed now reaches the next attempt. */
|
|
13115
|
+
markSteerFolderClosed(runId, now) {
|
|
13116
|
+
this.db.prepare("UPDATE run SET steer_closed_at = ? WHERE id = ? AND steer_opened_at IS NOT NULL AND steer_closed_at IS NULL").run(now.toISOString(), runId);
|
|
13117
|
+
}
|
|
13118
|
+
/**
|
|
13119
|
+
* The notes a RUNNING build may still read (v120): verified, pending, not
|
|
13120
|
+
* already quoted in this run's brief, and not already in its folder. The
|
|
13121
|
+
* pulse mails exactly these, in filing order.
|
|
13122
|
+
*/
|
|
13123
|
+
mailableSteerNotes(taskRef, runId) {
|
|
13124
|
+
return this.db
|
|
13125
|
+
.prepare(`SELECT * FROM task_steer
|
|
13126
|
+
WHERE task_ref = ? AND authorship_state = 'verified'
|
|
13127
|
+
AND delivered_at IS NULL AND superseded_at IS NULL
|
|
13128
|
+
AND (attached_run IS NULL OR attached_run <> ?)
|
|
13129
|
+
AND (mailed_run IS NULL OR mailed_run <> ?)
|
|
13130
|
+
ORDER BY id`)
|
|
13131
|
+
.all(taskRef, runId, runId)
|
|
13132
|
+
.map(readSteerNote);
|
|
13133
|
+
}
|
|
13134
|
+
/** The pulse wrote this note into the run's steering folder. Mailing is not
|
|
13135
|
+
* reading: delivery settles only on the agent's own acknowledgement. */
|
|
13136
|
+
markSteerMailed(noteId, runId, now) {
|
|
13137
|
+
const { changes } = this.db
|
|
13138
|
+
.prepare(`UPDATE task_steer SET mailed_run = ?, mailed_at = ?
|
|
13139
|
+
WHERE id = ? AND delivered_at IS NULL AND superseded_at IS NULL`)
|
|
13140
|
+
.run(runId, now.toISOString(), noteId);
|
|
13141
|
+
return Number(changes) > 0;
|
|
13142
|
+
}
|
|
13143
|
+
/**
|
|
13144
|
+
* The agent moved this note to handled/ (v120): it was read, by THIS run.
|
|
13145
|
+
* Acknowledgement is the only road from mailed to delivered — a hook firing
|
|
13146
|
+
* or a file written proves nothing. First acknowledgement wins; a note the
|
|
13147
|
+
* task already ended, or mailed to another run since, is left alone.
|
|
13148
|
+
*/
|
|
13149
|
+
ackSteerNote(noteId, runId, now) {
|
|
13150
|
+
const stamp = now.toISOString();
|
|
13151
|
+
const { changes } = this.db
|
|
13152
|
+
.prepare(`UPDATE task_steer SET acked_at = ?, delivered_at = ?, attached_run = ?, attached_at = COALESCE(mailed_at, ?)
|
|
13153
|
+
WHERE id = ? AND mailed_run = ? AND acked_at IS NULL AND delivered_at IS NULL AND superseded_at IS NULL`)
|
|
13154
|
+
.run(stamp, stamp, runId, stamp, noteId, runId);
|
|
13155
|
+
if (Number(changes) > 0)
|
|
13156
|
+
this.bumpWake();
|
|
13157
|
+
return Number(changes) > 0;
|
|
13158
|
+
}
|
|
12980
13159
|
/** A finished task's pending notes settle superseded — shown, typed, done. */
|
|
12981
13160
|
supersedeSteerNotes(taskRef, now) {
|
|
12982
13161
|
const { changes } = this.db
|
|
@@ -13022,6 +13201,9 @@ export class Store {
|
|
|
13022
13201
|
const existing = this.getDecision(answer.id);
|
|
13023
13202
|
if (existing === null)
|
|
13024
13203
|
return { ok: false, reason: "unknown-decision" };
|
|
13204
|
+
// A question closed without an answer (v119) stays closed: its task's scope changed or the task was cancelled.
|
|
13205
|
+
if (existing.supersededReason !== null)
|
|
13206
|
+
return { ok: false, reason: "closed" };
|
|
13025
13207
|
if (!existing.options.some(option => option.id === answer.choice)) {
|
|
13026
13208
|
return { ok: false, reason: "bad-option" };
|
|
13027
13209
|
}
|
|
@@ -17443,6 +17625,7 @@ function readRun(row) {
|
|
|
17443
17625
|
taskRef: Number(row["task_ref"]),
|
|
17444
17626
|
leaseId: String(row["lease_id"]),
|
|
17445
17627
|
runner: String(row["runner"]),
|
|
17628
|
+
planningGeneration: Number(row["planning_generation"] ?? 0),
|
|
17446
17629
|
watchIncarnation: row["watch_incarnation"] == null ? null : String(row["watch_incarnation"]),
|
|
17447
17630
|
reviewAttempt: wholeNumber(row["review_attempt"]),
|
|
17448
17631
|
role: String(row["role"] ?? "builder"),
|
|
@@ -17555,6 +17738,9 @@ function readSteerNote(row) {
|
|
|
17555
17738
|
supersededAt: row["superseded_at"] === null ? null : String(row["superseded_at"]),
|
|
17556
17739
|
authorshipState: row["authorship_state"] === "verified" ? "verified" : "unverified-legacy",
|
|
17557
17740
|
supersededReason: row["superseded_reason"] === null || row["superseded_reason"] === undefined ? null : String(row["superseded_reason"]),
|
|
17741
|
+
mailedRun: row["mailed_run"] === null || row["mailed_run"] === undefined ? null : Number(row["mailed_run"]),
|
|
17742
|
+
mailedAt: row["mailed_at"] === null || row["mailed_at"] === undefined ? null : String(row["mailed_at"]),
|
|
17743
|
+
ackedAt: row["acked_at"] === null || row["acked_at"] === undefined ? null : String(row["acked_at"]),
|
|
17558
17744
|
};
|
|
17559
17745
|
}
|
|
17560
17746
|
function readDecision(row) {
|
|
@@ -17575,6 +17761,7 @@ function readDecision(row) {
|
|
|
17575
17761
|
answeredVia: row["answered_via"] === null ? null : String(row["answered_via"]),
|
|
17576
17762
|
choice: row["choice"] === null ? null : String(row["choice"]),
|
|
17577
17763
|
note: row["note"] === null ? null : String(row["note"]),
|
|
17764
|
+
supersededReason: row["superseded_reason"] === "scope-changed" || row["superseded_reason"] === "cancelled" ? row["superseded_reason"] : null,
|
|
17578
17765
|
};
|
|
17579
17766
|
}
|
|
17580
17767
|
function readArtifact(row) {
|
|
@@ -17762,6 +17949,7 @@ function readTaskRef(row) {
|
|
|
17762
17949
|
? null
|
|
17763
17950
|
: String(row["plan"]),
|
|
17764
17951
|
planStrikes: Number(row["plan_strikes"] ?? 0),
|
|
17952
|
+
planningGeneration: Number(row["planning_generation"] ?? 0),
|
|
17765
17953
|
deliverable: row["deliverable"] === "report" ? "report" : "branch",
|
|
17766
17954
|
strikes: Number(row["strikes"] ?? 0),
|
|
17767
17955
|
agentProvider: row["agent_provider"] === null || row["agent_provider"] === undefined
|
package/dist/surface.js
CHANGED
|
@@ -201,7 +201,7 @@ const GUIDE_ROWS = [
|
|
|
201
201
|
...["show", "updates", "claim", "check", "brief", "inbox", "ack"].map(action => ({
|
|
202
202
|
invocation: `assignment ${action}`,
|
|
203
203
|
synopsis: action === "show" ? "read the root, current work, owner and exact receipt"
|
|
204
|
-
: action === "updates" ? "read
|
|
204
|
+
: action === "updates" ? "read the history of handoffs sent to people after a cursor; delivers nothing — receive updates with assignment inbox"
|
|
205
205
|
: action === "claim" ? "record lead ownership; with the lead token the task reads \"<the lead's name> is on it\" and leaves Needs you until done, handed over, or 2 hours idle; grants no approval or execution authority"
|
|
206
206
|
: action === "brief" ? "catch up from current assignments and project knowledge in the local database"
|
|
207
207
|
: action === "inbox" ? "receive or replay a saved batch of lead status updates"
|
|
@@ -242,7 +242,7 @@ const GUIDE_ROWS = [
|
|
|
242
242
|
positionals: [{ name: "id", required: true, meaning: "the task" }],
|
|
243
243
|
flags: [jsonFlag, dbFlag, keyFlag, { name: "undo", takesValue: false, meaning: "back to filing order" }],
|
|
244
244
|
notableReasons: ["unknown-task", "claimed"] },
|
|
245
|
-
operator("task steer", "one credentialed note
|
|
245
|
+
operator("task steer", "one credentialed note for the task's agent — a running build reads it when it next checks for notes, otherwise the next attempt does; steering speaks with the operator's voice"),
|
|
246
246
|
{ invocation: "task assign", synopsis: "reserve a task for one worker, or return it to the shared queue", audience: "agent", agentMayInvoke: true, mutation: "keyed",
|
|
247
247
|
positionals: [{ name: "id", required: true, meaning: "the task" }],
|
|
248
248
|
flags: [jsonFlag, dbFlag, keyFlag,
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Assignment sweeps re-project every owned root on each pass. A pass is
|
|
2
|
+
* skipped when nothing has moved since the last full cycle began: the store's
|
|
3
|
+
* wake sequence is unchanged and that cycle started under a minute ago. The
|
|
4
|
+
* minute covers changes that don't bump wake (time-based lease expiry, some
|
|
5
|
+
* ledger-only writes). A cycle in progress (its cursor past 0) always goes on,
|
|
6
|
+
* and a restarted process sweeps at once. */
|
|
7
|
+
import type { Store } from "./store.js";
|
|
8
|
+
export declare const SWEEP_FALLBACK_MS = 60000;
|
|
9
|
+
/** Runs `sweep` unless the cycle under `cursorKey` would see what its last start saw. Returns whether it ran. */
|
|
10
|
+
export declare function sweepIfMoved(store: Store, cursorKey: string, now: Date, sweep: () => void): boolean;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export const SWEEP_FALLBACK_MS = 60_000;
|
|
2
|
+
const cycles = new WeakMap();
|
|
3
|
+
/** Runs `sweep` unless the cycle under `cursorKey` would see what its last start saw. Returns whether it ran. */
|
|
4
|
+
export function sweepIfMoved(store, cursorKey, now, sweep) {
|
|
5
|
+
const seq = store.wakeSeq();
|
|
6
|
+
const startsCycle = store.serviceCursor(cursorKey) === 0;
|
|
7
|
+
const seen = cycles.get(store) ?? new Map();
|
|
8
|
+
cycles.set(store, seen);
|
|
9
|
+
const last = seen.get(cursorKey);
|
|
10
|
+
const elapsed = last === undefined ? Infinity : now.getTime() - last.at;
|
|
11
|
+
if (startsCycle && last?.seq === seq && elapsed >= 0 && elapsed < SWEEP_FALLBACK_MS)
|
|
12
|
+
return false;
|
|
13
|
+
sweep();
|
|
14
|
+
// Recorded only once the pass returns: a pass that threw is retried next time.
|
|
15
|
+
if (startsCycle)
|
|
16
|
+
seen.set(cursorKey, { seq, at: now.getTime() });
|
|
17
|
+
return true;
|
|
18
|
+
}
|
package/dist/task-status.js
CHANGED
|
@@ -261,7 +261,9 @@ export const taskStatusOf = statusOf;
|
|
|
261
261
|
* wait on a person stay Needs you; everything else waits in the queue. */
|
|
262
262
|
const CODE_STAGE = {
|
|
263
263
|
queued: ["queued"], ready: ["queued"], "worker-at-capacity": ["queued"], "retry-scheduled": ["queued"], updating: ["queued"],
|
|
264
|
-
"waiting-dependency": ["queued"], "scouting-ready": ["queued"], "planning-ready": ["queued"], "provider-quota": ["queued"],
|
|
264
|
+
"waiting-dependency": ["queued"], "scouting-ready": ["queued"], "planning-ready": ["queued"], "provider-quota": ["queued"],
|
|
265
|
+
// A request the planner cannot be given (too long to hand back, say) waits on a person to shorten it.
|
|
266
|
+
"planner-source": ["needs-you", "fix-request"],
|
|
265
267
|
running: ["building"], reviewing: ["reviewing"], "review-pending": ["building"],
|
|
266
268
|
"signed-out": ["needs-you", "sign-in"],
|
|
267
269
|
"needs-approval": ["needs-you", "approval"],
|
package/dist/team-updates.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { TeamLeads } from './team-leads.js';
|
|
2
|
-
import { ASSIGNMENT_STATUS_ACTION, parseAssignmentStatusEvent } from './assignment-status.js';
|
|
2
|
+
import { ASSIGNMENT_STATUS_ACTION, LEAD_WAKE_STATES, parseAssignmentStatusEvent } from './assignment-status.js';
|
|
3
3
|
export const teamUpdateCursor = (conversation) => `team-updates:${conversation}`;
|
|
4
4
|
export function startTeamUpdates(store, conversation, now) {
|
|
5
5
|
// Enabling updates starts here. Past events remain available in task history.
|
|
@@ -33,10 +33,10 @@ export function deliverTeamUpdates(store, domain, authorized, now) {
|
|
|
33
33
|
for (const row of rows) {
|
|
34
34
|
const event = parseAssignmentStatusEvent(String(row['outcome']));
|
|
35
35
|
// Keep an unreadable row pending instead of silently losing delivery.
|
|
36
|
-
if (!event || event.assignment.rootId !== row['task_id'] || event.assignment.owner
|
|
36
|
+
if (!event || event.assignment.rootId !== row['task_id'] || event.assignment.owner?.kind !== 'lead' || event.assignment.owner.id !== current.lead.id)
|
|
37
37
|
break;
|
|
38
38
|
through = Number(row['id']);
|
|
39
|
-
if (!
|
|
39
|
+
if (!LEAD_WAKE_STATES.includes(event.assignment.state))
|
|
40
40
|
continue;
|
|
41
41
|
changes.push(`${event.assignment.rootId}: ${event.assignment.title.slice(0, 100)} — ${event.assignment.state}. ${event.assignment.detail.slice(0, 180)}`);
|
|
42
42
|
if (changes.length === 4)
|
package/dist/telegram-lead.js
CHANGED
|
@@ -204,6 +204,12 @@ export function applyProposalTap(store, binding, token, message, repos, options,
|
|
|
204
204
|
const who = verified.who;
|
|
205
205
|
const preview = proposalPreview(store, proposal, actionRepos, "telegram");
|
|
206
206
|
const origin = options.phoneOrigin?.() ?? null;
|
|
207
|
+
if (preview.closed) {
|
|
208
|
+
store.consumeTelegramProposalActions(proposal.id, now);
|
|
209
|
+
ack("This question is closed.");
|
|
210
|
+
edit(preview.text, []);
|
|
211
|
+
return { effects, confirmed: false, ignored: true };
|
|
212
|
+
}
|
|
207
213
|
if (action.phase === "dismiss") {
|
|
208
214
|
if (!store.consumeTelegramProposalAction(token, now)) {
|
|
209
215
|
ack("that button was already used");
|
package/dist/telegram.js
CHANGED
|
@@ -25,7 +25,7 @@ import { resultImageFileName, resultTaskLabel, verifyResultImage } from "./chat-
|
|
|
25
25
|
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
26
26
|
import { chmodSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
27
27
|
import { join } from "node:path";
|
|
28
|
-
import { validateNote } from "./decision.js";
|
|
28
|
+
import { closedQuestionWords, validateNote } from "./decision.js";
|
|
29
29
|
import { isShotsKind, resultShotsFor } from "./result-shots.js";
|
|
30
30
|
import { isLifecycleNotification, isTelegramProgressNotification, RESULT_SHOTS_KIND, TELEGRAM_HOLD_REASONS } from "./store.js";
|
|
31
31
|
import { telegramProgressCard } from "./telegram-progress.js";
|
|
@@ -1998,7 +1998,7 @@ function applyNote(context, update, effects) {
|
|
|
1998
1998
|
return;
|
|
1999
1999
|
}
|
|
2000
2000
|
if (decision.state === "answered") {
|
|
2001
|
-
say(`already answered: ${decision.choice ?? "?"} — this note did not travel`);
|
|
2001
|
+
say(`${decision.supersededReason !== null ? closedQuestionWords(decision.supersededReason) : `already answered: ${decision.choice ?? "?"}`} — this note did not travel`);
|
|
2002
2002
|
report.ignored++;
|
|
2003
2003
|
return;
|
|
2004
2004
|
}
|
|
@@ -2344,8 +2344,8 @@ function applyCallback(context, update, effects) {
|
|
|
2344
2344
|
}
|
|
2345
2345
|
if (decision.state === "answered") {
|
|
2346
2346
|
store.consumeTelegramAction(token, clock());
|
|
2347
|
-
ack(`already answered: ${decision.choice ?? "?"}`);
|
|
2348
|
-
editText(answeredText(store, decision));
|
|
2347
|
+
ack(decision.supersededReason !== null ? closedQuestionWords(decision.supersededReason) : `already answered: ${decision.choice ?? "?"}`);
|
|
2348
|
+
editText(answeredText(store, decision), []);
|
|
2349
2349
|
return;
|
|
2350
2350
|
}
|
|
2351
2351
|
if (action.phase === "choose") {
|
|
@@ -2451,13 +2451,13 @@ function answerNow(context, decision, choice, binding, ack, editText) {
|
|
|
2451
2451
|
editText(answeredText(store, answered.decision));
|
|
2452
2452
|
return;
|
|
2453
2453
|
}
|
|
2454
|
-
if (answered.reason === "already-answered") {
|
|
2454
|
+
if (answered.reason === "already-answered" || answered.reason === "closed") {
|
|
2455
2455
|
if (draft !== null)
|
|
2456
2456
|
store.setNoteDraftState(draft.id, "discarded");
|
|
2457
2457
|
const settled = store.getDecision(decision.id);
|
|
2458
|
-
ack(`already answered: ${settled?.choice ?? "?"}${draft === null ? "" : " — your note did NOT travel"}`);
|
|
2458
|
+
ack(`${settled?.supersededReason != null ? closedQuestionWords(settled.supersededReason) : `already answered: ${settled?.choice ?? "?"}`}${draft === null ? "" : " — your note did NOT travel"}`);
|
|
2459
2459
|
if (settled !== null)
|
|
2460
|
-
editText(answeredText(store, settled));
|
|
2460
|
+
editText(answeredText(store, settled), []);
|
|
2461
2461
|
return;
|
|
2462
2462
|
}
|
|
2463
2463
|
ack(`could not answer: ${answered.reason}`);
|
|
@@ -2479,6 +2479,8 @@ function armedDecisionText(option, note) {
|
|
|
2479
2479
|
].join("\n");
|
|
2480
2480
|
}
|
|
2481
2481
|
function answeredText(store, decision) {
|
|
2482
|
+
if (decision.supersededReason !== null)
|
|
2483
|
+
return [closedQuestionWords(decision.supersededReason), taskOf(store, decision)].join("\n");
|
|
2482
2484
|
const chosen = decision.options.find(one => one.id === decision.choice)?.label ?? decision.choice ?? "?";
|
|
2483
2485
|
return [
|
|
2484
2486
|
`✓ Answered: ${chosen}`,
|
package/dist/toolroll-update.js
CHANGED
|
@@ -35,7 +35,7 @@ import { processMayBeAlive } from "./process-liveness.js";
|
|
|
35
35
|
import { assertCodingUpdateStopped, backupCodingCatalog, codingCatalogExists, releaseStaleCodingOwner, removeCodingUpdateGate } from "./coding-update.js";
|
|
36
36
|
import { installLaunchdService, launchdPlist, stopLaunchdService, writeFileDurably } from "./daemon.js";
|
|
37
37
|
import { NAME } from "./names.js";
|
|
38
|
-
import { orphanKey, updateSafeSchema, V117_RENAMED_COLUMNS, V117_RENAMED_TABLES, V118_MERGED_TABLES } from "./store.js";
|
|
38
|
+
import { orphanKey, updateSafeSchema, V117_RENAMED_COLUMNS, V117_RENAMED_TABLES, V118_MERGED_TABLES, V119_ADDED_COLUMNS, V120_ADDED_COLUMNS } from "./store.js";
|
|
39
39
|
import { isNewer, REGISTRY } from "./releases.js";
|
|
40
40
|
import { markNeverIndex } from "./never-index.js";
|
|
41
41
|
import { readRuntimeUpdateJournal, RUNTIME_PHASES, RUNTIME_UPDATE_STEPS, stagedStartedAt, updaterStartingOf } from "./contracts/update-journal.js";
|
|
@@ -363,6 +363,16 @@ export const CHAT_MOVES = {
|
|
|
363
363
|
...Object.fromEntries(Object.entries(TELEGRAM_MOVES).map(([table, move]) => [table, { ...move, where: `provider = 'telegram' AND (${move.where})` }])),
|
|
364
364
|
...Object.fromEntries(["slack", "discord", "teams"].flatMap(app => APP_TABLES.map(suffix => [`${app}_${suffix}`, { into: `chat_${suffix}`, where: `provider = '${app}'` }]))),
|
|
365
365
|
};
|
|
366
|
+
/** Each table's added columns across versions, merged: a table two versions both extend keeps every column. */
|
|
367
|
+
function addedColumns(...versions) {
|
|
368
|
+
const rules = {};
|
|
369
|
+
for (const version of versions) {
|
|
370
|
+
for (const [table, columns] of Object.entries(version)) {
|
|
371
|
+
rules[table] = { added: { ...rules[table]?.added, ...Object.fromEntries(Object.entries(columns).map(([column, { saved }]) => [column, saved])) } };
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
return rules;
|
|
375
|
+
}
|
|
366
376
|
const HISTORY_RULES = {
|
|
367
377
|
...Object.fromEntries(Object.entries(CHAT_MOVES).map(([table, moved]) => [table, { moved }])),
|
|
368
378
|
action_ledger: { appendOnly: true }, ledger_seal: { appendOnly: true }, ledger_checkpoint: { appendOnly: true },
|
|
@@ -384,6 +394,11 @@ const HISTORY_RULES = {
|
|
|
384
394
|
...Object.fromEntries(V117_RENAMED_COLUMNS.filter(([table]) => !Object.values(V117_RENAMED_TABLES).includes(table)).map(([table, from, to]) => [table, { renamed: { [from]: to } }])),
|
|
385
395
|
// v118 (D7): every API token, coordinator and lead token arrives in the one credential table under its kind.
|
|
386
396
|
...Object.fromEntries(Object.entries(V118_MERGED_TABLES).map(([table, kind]) => [table, { moved: { into: "credential", where: `kind = '${kind}'` } }])),
|
|
397
|
+
// v119: planning generations and a question's closure reason are new columns; every row saved before them reads
|
|
398
|
+
// the declared value (generation 0, no closure reason).
|
|
399
|
+
// v120: a steering note's mailing and acknowledgement, and a run's steering folder, are new columns; every row
|
|
400
|
+
// saved before them reads none.
|
|
401
|
+
...addedColumns(V119_ADDED_COLUMNS, V120_ADDED_COLUMNS),
|
|
387
402
|
};
|
|
388
403
|
/** The tables a migration may remove whole (v115's removed features). */
|
|
389
404
|
export const RETIRED_TABLES = Object.freeze(Object.keys(HISTORY_RULES).filter(name => HISTORY_RULES[name].retired));
|
|
@@ -537,6 +552,10 @@ export function changedHistory(db, before) {
|
|
|
537
552
|
if (t.columns.some(c => !kept(c) && (!rule?.dropped?.includes(c) || (t.nonNull?.[c] !== 0 && !accounted(rule, t)))))
|
|
538
553
|
return true;
|
|
539
554
|
const columns = t.columns.filter(c => present.has(c));
|
|
555
|
+
// A declared new column: every row saved before it reads its declared value.
|
|
556
|
+
const added = Object.entries(rule?.added ?? {}).filter(([c]) => !t.columns.includes(c) && present.has(c));
|
|
557
|
+
if (t.rowid && added.some(([c, saved]) => Number(db.prepare("SELECT count(*) AS n FROM " + quote(t.name) + " WHERE rowid <= ? AND " + quote(c) + " IS NOT " + saved).get(t.last)["n"]) > 0))
|
|
558
|
+
return true;
|
|
540
559
|
if (!t.rowid) {
|
|
541
560
|
const after = rowsHash(db, t.name, columns);
|
|
542
561
|
return after.count !== t.count || after.hash !== t.hash;
|