toolroll 0.9.6 → 0.9.8
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 +64 -1
- package/dist/actor.d.ts +21 -0
- package/dist/actor.js +48 -0
- package/dist/assignment-adapters.d.ts +2 -0
- package/dist/assignment-adapters.js +2 -0
- package/dist/assignment-ui.js +3 -1
- package/dist/assignment.d.ts +15 -0
- package/dist/assignment.js +80 -16
- package/dist/browser/workspace.css +2 -2
- package/dist/browser/workspace.js +28 -24
- package/dist/browser-workspace.d.ts +117 -2
- package/dist/build-review.d.ts +38 -0
- package/dist/build-review.js +341 -0
- package/dist/builder.d.ts +1 -0
- package/dist/builder.js +33 -13
- package/dist/chat-actions.js +2 -1
- package/dist/chat-delivery.js +2 -0
- package/dist/chat-quiet.js +11 -1
- package/dist/chat-task-actions.d.ts +13 -0
- package/dist/chat-task-actions.js +19 -1
- package/dist/check-levels-ui.d.ts +26 -0
- package/dist/check-levels-ui.js +84 -0
- package/dist/check-levels.d.ts +67 -0
- package/dist/check-levels.js +171 -0
- package/dist/claim.d.ts +22 -0
- package/dist/claim.js +43 -0
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +6 -0
- package/dist/demo-chat.js +4 -1
- package/dist/dispatch.js +9 -3
- package/dist/dispose.d.ts +12 -3
- package/dist/dispose.js +19 -5
- package/dist/exec.d.ts +5 -0
- package/dist/exec.js +30 -4
- package/dist/export.js +3 -2
- package/dist/first-run.d.ts +34 -0
- package/dist/first-run.js +37 -1
- package/dist/flow-gallery-ui.d.ts +30 -0
- package/dist/flow-gallery-ui.js +85 -0
- package/dist/flow-gallery.d.ts +111 -0
- package/dist/flow-gallery.js +445 -0
- package/dist/flow-share.d.ts +102 -0
- package/dist/flow-share.js +614 -0
- package/dist/flow-starters.d.ts +2 -0
- package/dist/flow-starters.js +1 -1
- package/dist/flow-steps.js +7 -0
- package/dist/flow-triggers.js +2 -0
- package/dist/flows-cli.d.ts +56 -0
- package/dist/flows-cli.js +109 -3
- package/dist/flows-ui.d.ts +15 -1
- package/dist/flows-ui.js +31 -6
- package/dist/guides.js +27 -2
- package/dist/invoke.js +5 -0
- package/dist/lead-context.js +6 -2
- package/dist/mate-doors.d.ts +0 -15
- package/dist/mate-doors.js +5 -1
- package/dist/mate-tools.js +9 -4
- package/dist/models-ui.js +1 -1
- package/dist/modes.d.ts +3 -1
- package/dist/modes.js +3 -1
- package/dist/operate.d.ts +4 -1
- package/dist/operate.js +711 -98
- package/dist/process-custody.js +8 -0
- package/dist/project-concurrency.d.ts +62 -0
- package/dist/project-concurrency.js +116 -0
- package/dist/project-delete-ui.d.ts +7 -0
- package/dist/project-delete-ui.js +19 -2
- package/dist/project-delete.js +4 -0
- package/dist/provider.js +16 -0
- package/dist/pull-request-flow.d.ts +9 -0
- package/dist/pull-request-flow.js +21 -3
- package/dist/result-follow-ups.d.ts +101 -0
- package/dist/result-follow-ups.js +333 -0
- package/dist/retention-ui.d.ts +3 -1
- package/dist/retention-ui.js +4 -4
- package/dist/retention.d.ts +13 -6
- package/dist/retention.js +50 -12
- package/dist/review-switch.d.ts +41 -0
- package/dist/review-switch.js +88 -0
- package/dist/reviewer.d.ts +24 -2
- package/dist/reviewer.js +49 -1
- package/dist/scope.js +11 -0
- package/dist/serve.d.ts +211 -2
- package/dist/serve.js +1038 -145
- package/dist/store.d.ts +112 -4
- package/dist/store.js +447 -27
- package/dist/surface.js +36 -9
- package/dist/tailnet.d.ts +11 -0
- package/dist/tailnet.js +47 -0
- package/dist/task-outcome-cli.js +2 -2
- package/dist/task-status.d.ts +13 -3
- package/dist/task-status.js +38 -16
- package/dist/team-cli.js +3 -0
- package/dist/telegram-status.js +2 -2
- package/dist/verification-evidence.js +21 -1
- package/dist/webhooks.js +5 -0
- package/dist/work-index.js +39 -6
- package/dist/work-summary.d.ts +0 -4
- package/dist/work-summary.js +4 -1
- package/dist/workspace-ui.d.ts +6 -1
- package/dist/workspace-ui.js +14 -11
- package/package.json +1 -1
package/dist/store.js
CHANGED
|
@@ -8,6 +8,7 @@ import { MEMORY_SCHEMA } from "./project-memory.js";
|
|
|
8
8
|
import { MODELS_SCHEMA } from "./model-catalog.js";
|
|
9
9
|
import { validateTaskText } from "./task-text.js";
|
|
10
10
|
import { chatControlHref, chatResultHref } from "./chat-controls.js";
|
|
11
|
+
import { actorLabel, currentActor, leadSecretMatches, mintLeadToken, parseLeadToken } from "./actor.js";
|
|
11
12
|
import { scanForSecrets } from "./evidence.js";
|
|
12
13
|
/**
|
|
13
14
|
* The database: a small task store, and the operational overlay beside it.
|
|
@@ -58,6 +59,7 @@ import { modeDigestOf, modeTermsFromJson } from "./modes.js";
|
|
|
58
59
|
import { readVerifiedArtifact } from "./evidence.js";
|
|
59
60
|
import { isProviderId } from "./provider.js";
|
|
60
61
|
import { authenticate as runnerAuthenticate } from "./runner.js";
|
|
62
|
+
import { stampNewProjectLevel } from "./check-levels.js";
|
|
61
63
|
import { parseCheckProgressSnapshot } from "./check-progress.js";
|
|
62
64
|
import { normalizeProjectAccess, projectAccessAllows, readProjectAccess } from "./project-access.js";
|
|
63
65
|
import { LEDGER_SCHEMA, LEDGER_TABLE, LEDGER_V54_COLUMNS, LEDGER_V54_TABLE, installLedgerTriggers } from "./action-ledger.js";
|
|
@@ -65,8 +67,9 @@ import { PLAN_AUTO_SCHEMA } from "./plan-auto.js";
|
|
|
65
67
|
import { RECIPE_SCHEMA } from "./recipes.js";
|
|
66
68
|
import { POLICY_SCHEMA, agentRefusal, approvalRefusal, attendedRefusal, policyParts, readPolicy, underCeiling, sessionCeilingRefusal } from "./policy.js";
|
|
67
69
|
import { PROVIDER_AUTH_SCHEMA } from "./provider-auth.js";
|
|
70
|
+
import { REVIEW_SCHEMA, reviewSwitchWords } from "./review-switch.js";
|
|
68
71
|
import { SPEND_SCHEMA, billingOf, budgetStates, canPrice, claudeMachineBilling, countsToward, filersOf, monthOf, priceWork, seenBilling as seenBillingOf, spendItems, teammateFilers, usd } from "./spend.js";
|
|
69
|
-
import {
|
|
72
|
+
import { DEFAULT_PERIODS, RETENTION_SCHEMA, periodWords, widenRetentionSchema } from "./retention.js";
|
|
70
73
|
import { CHECKOUT_CLEANUP_SCHEMA, DEFAULT_CLEANUP, cleanupWords } from "./storage.js";
|
|
71
74
|
import { IN_RANGE, LEDGER_CHAIN_SCHEMA, safeWhole, sealLedger, verifyLedgerChain } from "./ledger-chain.js";
|
|
72
75
|
import { NO_RULES, filerFor, isProtectedWork, protectedChanges, rulesWords } from "./approval-policy.js";
|
|
@@ -164,6 +167,54 @@ CREATE TABLE IF NOT EXISTS chat_card_task (
|
|
|
164
167
|
);
|
|
165
168
|
CREATE INDEX IF NOT EXISTS chat_card_task_card ON chat_card_task(card);
|
|
166
169
|
`;
|
|
170
|
+
/** Pings follow responsibility (no version bump: additive only). `lead_credential` is a lead token: an agent acting
|
|
171
|
+
* for `owner`, only its hash kept. `task_act` is who filed, approved, cancelled, completed or handed on a task
|
|
172
|
+
* (`lead` 1: the owner's lead; `person`/`why`: who the lead asked, and why). `notification_actor` is whose act made
|
|
173
|
+
* a fact. `task_replacement` is the task that replaced a cancelled one. `project_mute` is a person's muted projects. */
|
|
174
|
+
const LEAD_QUIET_SCHEMA = `
|
|
175
|
+
CREATE TABLE IF NOT EXISTS lead_credential (
|
|
176
|
+
id TEXT PRIMARY KEY,
|
|
177
|
+
owner TEXT NOT NULL,
|
|
178
|
+
secret_hash TEXT NOT NULL,
|
|
179
|
+
created_at TEXT NOT NULL,
|
|
180
|
+
created_by TEXT NOT NULL,
|
|
181
|
+
revoked_at TEXT,
|
|
182
|
+
revoked_by TEXT
|
|
183
|
+
);
|
|
184
|
+
CREATE INDEX IF NOT EXISTS lead_credential_owner ON lead_credential (owner);
|
|
185
|
+
CREATE TABLE IF NOT EXISTS task_act (
|
|
186
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
187
|
+
task_ref INTEGER NOT NULL,
|
|
188
|
+
act TEXT NOT NULL CHECK (act IN ('filed', 'approved', 'cancelled', 'completed', 'asked')),
|
|
189
|
+
account TEXT NOT NULL,
|
|
190
|
+
lead INTEGER NOT NULL DEFAULT 0 CHECK (lead IN (0, 1)),
|
|
191
|
+
person TEXT,
|
|
192
|
+
why TEXT,
|
|
193
|
+
at TEXT NOT NULL
|
|
194
|
+
);
|
|
195
|
+
CREATE INDEX IF NOT EXISTS task_act_task ON task_act (task_ref, id);
|
|
196
|
+
CREATE TABLE IF NOT EXISTS notification_actor (
|
|
197
|
+
notification INTEGER PRIMARY KEY REFERENCES notification(id) ON DELETE CASCADE,
|
|
198
|
+
account TEXT NOT NULL,
|
|
199
|
+
lead INTEGER NOT NULL DEFAULT 0 CHECK (lead IN (0, 1))
|
|
200
|
+
);
|
|
201
|
+
CREATE TABLE IF NOT EXISTS task_replacement (
|
|
202
|
+
task_ref INTEGER PRIMARY KEY,
|
|
203
|
+
successor TEXT NOT NULL,
|
|
204
|
+
by TEXT,
|
|
205
|
+
at TEXT NOT NULL
|
|
206
|
+
);
|
|
207
|
+
CREATE TABLE IF NOT EXISTS project_mute (
|
|
208
|
+
account TEXT NOT NULL,
|
|
209
|
+
repo TEXT NOT NULL,
|
|
210
|
+
at TEXT NOT NULL,
|
|
211
|
+
PRIMARY KEY (account, repo)
|
|
212
|
+
);
|
|
213
|
+
`;
|
|
214
|
+
/** What still pings on the lead's own work: a security alert. */
|
|
215
|
+
const SECURITY_KINDS = new Set(["secret-detected", "security-release"]);
|
|
216
|
+
/** A failure that leaves nothing for the lead to try: the attempts are spent. */
|
|
217
|
+
const SPENT_KINDS = new Set(["attempts-exhausted", "plan-attempts-exhausted"]);
|
|
167
218
|
/** Settings → Integrations (no version bump): each integration's last check. `outcome` is ok, failed, or absent (the check found
|
|
168
219
|
* nothing set up, such as no `gh` sign-in). `ok_at` and `error`/`error_at` keep the last success and the last
|
|
169
220
|
* failure across checks, so a row can say both. `account` is a display name only, never a credential. */
|
|
@@ -450,7 +501,8 @@ function readFlowRow(row) {
|
|
|
450
501
|
function readFlowScriptRow(row) {
|
|
451
502
|
return { id: Number(row["id"]), repo: String(row["repo"]), name: String(row["name"]), about: String(row["about"]), body: String(row["body"]), timeoutMinutes: Number(row["timeout_minutes"]),
|
|
452
503
|
version: Number(row["version"]), digest: String(row["digest"]), savedBy: String(row["saved_by"]), savedAt: String(row["saved_at"]),
|
|
453
|
-
language: row["language"] === "python" || row["language"] === "node" ? row["language"] : "shell", file: row["file"] === null || row["file"] === undefined ? null : String(row["file"])
|
|
504
|
+
language: row["language"] === "python" || row["language"] === "node" ? row["language"] : "shell", file: row["file"] === null || row["file"] === undefined ? null : String(row["file"]),
|
|
505
|
+
held: row["held"] === null || row["held"] === undefined ? null : String(row["held"]) };
|
|
454
506
|
}
|
|
455
507
|
function readFlowStepRunRow(row) {
|
|
456
508
|
const optional = (key) => row[key] === null || row[key] === undefined ? null : String(row[key]);
|
|
@@ -3750,13 +3802,17 @@ function initializeStore(db, file) {
|
|
|
3750
3802
|
db.exec(PROVIDER_AUTH_SCHEMA);
|
|
3751
3803
|
// The sign-in pause the dispatch gate last left this task waiting on (null: none), for the work index.
|
|
3752
3804
|
addColumn(db, "task_ref", "auth_wait_pause", "INTEGER REFERENCES provider_auth_pause(id)");
|
|
3805
|
+
db.exec(REVIEW_SCHEMA);
|
|
3753
3806
|
db.exec(RETENTION_SCHEMA);
|
|
3807
|
+
// 1-day evidence: an older file's retention_setting only allowed 7 days or more (no version bump: its rows carry over).
|
|
3808
|
+
widenRetentionSchema(db);
|
|
3754
3809
|
db.exec(CHECKOUT_CLEANUP_SCHEMA);
|
|
3755
3810
|
db.exec(BACKUP_SCHEMA);
|
|
3756
3811
|
// Sprint 8: the organisation policy (one row, or none: nothing restricted).
|
|
3757
3812
|
db.exec(POLICY_SCHEMA);
|
|
3758
3813
|
db.exec(RUN_CHECK_SCHEMA);
|
|
3759
3814
|
db.exec(QUIET_CHAT_SCHEMA);
|
|
3815
|
+
db.exec(LEAD_QUIET_SCHEMA);
|
|
3760
3816
|
mergeCheckTables(db);
|
|
3761
3817
|
addColumn(db, "monitoring_status", "target", "TEXT");
|
|
3762
3818
|
// v105: how a teammate's turn was billed (this computer's Claude sign-in, as last seen).
|
|
@@ -3778,6 +3834,8 @@ function initializeStore(db, file) {
|
|
|
3778
3834
|
// v90: scripts in Python and Node, and scripts that run a file in the project.
|
|
3779
3835
|
addColumn(db, "flow_script", "language", "TEXT NOT NULL DEFAULT 'shell'");
|
|
3780
3836
|
addColumn(db, "flow_script", "file", "TEXT");
|
|
3837
|
+
// A script that came in with an imported flow waits for a person to approve it before it runs (no version bump: additive only).
|
|
3838
|
+
addColumn(db, "flow_script", "held", "TEXT");
|
|
3781
3839
|
addColumn(db, "notification", "recipient", "TEXT");
|
|
3782
3840
|
addColumn(db, "approver", "projects_json", "TEXT");
|
|
3783
3841
|
addColumn(db, "invite", "projects_json", "TEXT");
|
|
@@ -6942,6 +7000,8 @@ export class Store {
|
|
|
6942
7000
|
const earlier = this.db.prepare("SELECT 1 AS hit FROM task_ref WHERE external_id = ?").get(spec.id) !== undefined;
|
|
6943
7001
|
// Created here, so it is ours — the one place that is true by construction.
|
|
6944
7002
|
const ref = this.refFor(BUILT_IN, spec.id, "ours");
|
|
7003
|
+
// Who filed it, when someone is acting: the lead's work pings nobody.
|
|
7004
|
+
this.noteTaskAct(ref.id, "filed", now);
|
|
6945
7005
|
if (earlier)
|
|
6946
7006
|
this.recordAction({ at: stamp, actor: "system", repo: ref.repo, taskId: spec.id, runId: null, action: "task registered", outcome: "recorded", source: "work", detail: REGISTERED_AT_FILING });
|
|
6947
7007
|
// v102: who filed it, set once with the filing (separation of duties reads it; nothing changes it).
|
|
@@ -7006,10 +7066,10 @@ export class Store {
|
|
|
7006
7066
|
* they admit from (`null` = any). It returns raw changes; callers keep
|
|
7007
7067
|
* their own refusal words and wake bumps.
|
|
7008
7068
|
*/
|
|
7009
|
-
applyCancellation(taskId, reason, now, admittedFrom) {
|
|
7010
|
-
return this.transact(() => this.applyCancellationLocked(taskId, reason, now, admittedFrom));
|
|
7069
|
+
applyCancellation(taskId, reason, now, admittedFrom, replacedBy) {
|
|
7070
|
+
return this.transact(() => this.applyCancellationLocked(taskId, reason, now, admittedFrom, replacedBy));
|
|
7011
7071
|
}
|
|
7012
|
-
applyCancellationLocked(taskId, reason, now, admittedFrom) {
|
|
7072
|
+
applyCancellationLocked(taskId, reason, now, admittedFrom, replacedBy) {
|
|
7013
7073
|
const ref = this.db
|
|
7014
7074
|
.prepare("SELECT id, coordinator_cid FROM task_ref WHERE backend = ? AND external_id = ?")
|
|
7015
7075
|
.get(BUILT_IN, taskId);
|
|
@@ -7035,6 +7095,14 @@ export class Store {
|
|
|
7035
7095
|
.run(now.toISOString(), taskId, ...(admittedFrom ?? []));
|
|
7036
7096
|
if (Number(changes) === 0)
|
|
7037
7097
|
return { changed: false };
|
|
7098
|
+
// Replaced: the successor is part of the cancellation, so every surface says "Replaced by <id>".
|
|
7099
|
+
if (ref !== undefined && replacedBy !== undefined) {
|
|
7100
|
+
this.db.prepare(`INSERT INTO task_replacement (task_ref, successor, by, at) VALUES (?, ?, ?, ?)
|
|
7101
|
+
ON CONFLICT (task_ref) DO UPDATE SET successor = excluded.successor, by = excluded.by, at = excluded.at`)
|
|
7102
|
+
.run(Number(ref["id"]), replacedBy, currentActor() === null ? null : actorLabel(currentActor()), now.toISOString());
|
|
7103
|
+
}
|
|
7104
|
+
if (ref !== undefined && prior?.["state"] !== "cancelled")
|
|
7105
|
+
this.noteTaskAct(Number(ref["id"]), "cancelled", now);
|
|
7038
7106
|
// A cancelled task's pending steering settles superseded in the SAME
|
|
7039
7107
|
// transaction, whichever road cancelled it — a note silently waiting
|
|
7040
7108
|
// for a build that can no longer happen is wrong on every road, not
|
|
@@ -7058,18 +7126,20 @@ export class Store {
|
|
|
7058
7126
|
if (ref !== undefined && prior?.["state"] !== "cancelled") {
|
|
7059
7127
|
this.noteLifecycle({
|
|
7060
7128
|
taskRef: Number(ref["id"]), kind: "task-cancelled", identity: `t${Number(ref["id"])}`,
|
|
7061
|
-
subject: "Cancelled"
|
|
7062
|
-
body:
|
|
7063
|
-
? `${
|
|
7064
|
-
: reason.
|
|
7065
|
-
?
|
|
7066
|
-
:
|
|
7067
|
-
|
|
7129
|
+
subject: replacedBy === undefined ? "Cancelled" : `Replaced by ${replacedBy}`,
|
|
7130
|
+
body: replacedBy !== undefined
|
|
7131
|
+
? `${replacedBy} replaces it. Nothing more runs for it.`
|
|
7132
|
+
: reason.kind === "operator"
|
|
7133
|
+
? `${cancellationWords(reason.text)} Nothing more runs for it.`
|
|
7134
|
+
: reason.code === "mirror-latched"
|
|
7135
|
+
? "The tracker closed it. Nothing more runs for it."
|
|
7136
|
+
: "The tracker closed it while it was being built, so the finished work was not accepted.",
|
|
7137
|
+
link: chatControlHref("task", replacedBy ?? taskId),
|
|
7068
7138
|
}, now);
|
|
7069
7139
|
}
|
|
7070
7140
|
return { changed: true };
|
|
7071
7141
|
}
|
|
7072
|
-
setTaskState(id, state, now, mutation = {}, cancellationReason) {
|
|
7142
|
+
setTaskState(id, state, now, mutation = {}, cancellationReason, replacedBy) {
|
|
7073
7143
|
return this.once(mutation, "setTaskState", () => {
|
|
7074
7144
|
// The terminal guard lives IN the primitive so every present and
|
|
7075
7145
|
// future caller is covered (external dispatch, finding 23): a
|
|
@@ -7085,7 +7155,7 @@ export class Store {
|
|
|
7085
7155
|
}
|
|
7086
7156
|
return this.transact(() => {
|
|
7087
7157
|
if (state === "cancelled") {
|
|
7088
|
-
const done = this.applyCancellation(id, { kind: "operator", text: cancellationReason ?? null }, now, null);
|
|
7158
|
+
const done = this.applyCancellation(id, { kind: "operator", text: cancellationReason ?? null }, now, null, replacedBy);
|
|
7089
7159
|
if (done.reason !== undefined)
|
|
7090
7160
|
return { ok: false, reason: done.reason };
|
|
7091
7161
|
if (!done.changed)
|
|
@@ -7099,6 +7169,11 @@ export class Store {
|
|
|
7099
7169
|
.run(state, now.toISOString(), id);
|
|
7100
7170
|
if (Number(changes) === 0)
|
|
7101
7171
|
return { ok: false, reason: "unknown-task" };
|
|
7172
|
+
if (state === "done" && prior?.["state"] !== "done") {
|
|
7173
|
+
const done = this.lookupRef(id);
|
|
7174
|
+
if (done !== null)
|
|
7175
|
+
this.noteTaskAct(done.id, "completed", now);
|
|
7176
|
+
}
|
|
7102
7177
|
// The return-to-queue fact (Telegram task updates): a running
|
|
7103
7178
|
// task its worker released unfinished, or a closed one reopened
|
|
7104
7179
|
// by the state verb. Marking done or failed by hand rides the
|
|
@@ -8475,6 +8550,8 @@ export class Store {
|
|
|
8475
8550
|
// (setup review): the approval is what was stale, and it is new.
|
|
8476
8551
|
const ref = this.db.prepare("SELECT id FROM task_ref WHERE backend = ? AND external_id = ?").get(BUILT_IN, taskId);
|
|
8477
8552
|
if (ref !== undefined) {
|
|
8553
|
+
if (basis === undefined)
|
|
8554
|
+
this.noteTaskAct(Number(ref["id"]), "approved", now);
|
|
8478
8555
|
this.db.prepare("DELETE FROM hold WHERE owner_kind = 'backoff' AND owner_id = ?").run(`stale:${Number(ref["id"])}`);
|
|
8479
8556
|
this.db
|
|
8480
8557
|
.prepare("UPDATE notification SET resolved_at = ? WHERE dedupe_key LIKE ? AND resolved_at IS NULL")
|
|
@@ -8488,7 +8565,7 @@ export class Store {
|
|
|
8488
8565
|
taskRef: Number(ref["id"]), kind: "scope-approved", identity: `t${Number(ref["id"])}`,
|
|
8489
8566
|
subject: "Scope approved",
|
|
8490
8567
|
body: basis === undefined
|
|
8491
|
-
? `Approved by ${lifecycleWords(by, 40)}. A connected worker can take it next.`
|
|
8568
|
+
? `Approved by ${currentActor()?.lead === true ? "the lead" : lifecycleWords(by, 40)}. A connected worker can take it next.`
|
|
8492
8569
|
: "Approved automatically under the operating mode. A connected worker can take it next.",
|
|
8493
8570
|
link: chatControlHref("task", taskId),
|
|
8494
8571
|
}, now);
|
|
@@ -9260,6 +9337,27 @@ export class Store {
|
|
|
9260
9337
|
return;
|
|
9261
9338
|
this.recordAction({ at: now.toISOString(), actor: by, repo, taskId: null, runId: null, action, outcome, source: "policy", detail: `${before} → ${after}` });
|
|
9262
9339
|
}
|
|
9340
|
+
/** The project's automatic review switch: its explicit setting, else on
|
|
9341
|
+
* while a hands-off mode is active on it, else off. */
|
|
9342
|
+
reviewSwitch(repo, now) {
|
|
9343
|
+
const row = this.db.prepare("SELECT enabled, changed_by, changed_at FROM review_switch WHERE repo = ?").get(repo);
|
|
9344
|
+
if (row !== undefined)
|
|
9345
|
+
return { on: Number(row["enabled"]) === 1, source: "project", changedBy: String(row["changed_by"]), changedAt: String(row["changed_at"]) };
|
|
9346
|
+
const handsOff = this.activeMode(repo, now)?.name === "hands-off";
|
|
9347
|
+
return { on: handsOff, source: handsOff ? "hands-off" : "default", changedBy: null, changedAt: null };
|
|
9348
|
+
}
|
|
9349
|
+
/** Turn a project's automatic review on or off; the policy log keeps before → after. */
|
|
9350
|
+
setReviewSwitch(repo, on, by, now) {
|
|
9351
|
+
return this.transact(() => {
|
|
9352
|
+
const before = this.reviewSwitch(repo, now);
|
|
9353
|
+
this.db.prepare(`INSERT INTO review_switch (repo, enabled, changed_by, changed_at) VALUES (?, ?, ?, ?)
|
|
9354
|
+
ON CONFLICT(repo) DO UPDATE SET enabled = excluded.enabled, changed_by = excluded.changed_by, changed_at = excluded.changed_at`)
|
|
9355
|
+
.run(repo, on ? 1 : 0, by, now.toISOString());
|
|
9356
|
+
const after = this.reviewSwitch(repo, now);
|
|
9357
|
+
this.recordPolicy(by, repo, "automatic review", reviewSwitchWords(before), reviewSwitchWords(after), now);
|
|
9358
|
+
return { before, after };
|
|
9359
|
+
});
|
|
9360
|
+
}
|
|
9263
9361
|
/** A project deleted (project-delete.ts): who, and what it held → how it went. Returns the entry's id. */
|
|
9264
9362
|
recordProjectDeleted(by, repo, held, how, now) {
|
|
9265
9363
|
this.recordPolicy(by, repo, "project deleted", held, how, now, "deleted");
|
|
@@ -14928,10 +15026,15 @@ export class Store {
|
|
|
14928
15026
|
: source !== undefined && "project" in source && source.project !== "" ? "project"
|
|
14929
15027
|
: source !== undefined && "installation" in source ? "installation" : "unknown";
|
|
14930
15028
|
const project = scope === "task" ? String(ref["repo"]) : scope === "project" && source !== undefined && "project" in source ? source.project : null;
|
|
14931
|
-
const { changes } = this.db
|
|
15029
|
+
const { changes, lastInsertRowid } = this.db
|
|
14932
15030
|
.prepare(`INSERT OR IGNORE INTO notification (dedupe_key, kind, subject, body, created_at, push_class, link, provenance_scope, project, task_ref, task_id, source_run, recipient)
|
|
14933
15031
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
14934
15032
|
.run(notification.dedupeKey, notification.kind, notification.subject, notification.body, now.toISOString(), notification.pushClass ?? null, notification.link ?? null, scope, project, ref === undefined ? null : taskRef, ref === undefined ? null : String(ref["external_id"]), run?.id ?? null, notification.recipient ?? null);
|
|
15033
|
+
// Whose act made this fact: pings follow it (see pingAllowed).
|
|
15034
|
+
const actor = currentActor();
|
|
15035
|
+
if (Number(changes) > 0 && actor !== null) {
|
|
15036
|
+
this.db.prepare("INSERT OR IGNORE INTO notification_actor (notification, account, lead) VALUES (?, ?, ?)").run(Number(lastInsertRowid), actor.account, actor.lead ? 1 : 0);
|
|
15037
|
+
}
|
|
14935
15038
|
return Number(changes) > 0;
|
|
14936
15039
|
}
|
|
14937
15040
|
/**
|
|
@@ -16275,13 +16378,17 @@ export class Store {
|
|
|
16275
16378
|
});
|
|
16276
16379
|
}
|
|
16277
16380
|
// ---- Retention (v105) -----------------------------------------------------
|
|
16278
|
-
/** How long each kind of data is kept; null is forever (
|
|
16381
|
+
/** How long each kind of data is kept; null is forever. A kind nobody chose keeps its default (evidence 28 days, the rest forever). */
|
|
16279
16382
|
retentionPeriods() {
|
|
16280
|
-
|
|
16383
|
+
return { ...DEFAULT_PERIODS, ...this.retentionChosen() };
|
|
16384
|
+
}
|
|
16385
|
+
/** The periods someone chose: only kinds with a saved row. */
|
|
16386
|
+
retentionChosen() {
|
|
16387
|
+
const chosen = {};
|
|
16281
16388
|
for (const row of this.db.prepare("SELECT kind, days FROM retention_setting").all()) {
|
|
16282
|
-
|
|
16389
|
+
chosen[String(row["kind"])] = row["days"] === null ? null : Number(row["days"]);
|
|
16283
16390
|
}
|
|
16284
|
-
return
|
|
16391
|
+
return chosen;
|
|
16285
16392
|
}
|
|
16286
16393
|
/** Set how long one kind is kept (null: forever); the ledger keeps before → after. */
|
|
16287
16394
|
setRetentionPeriod(kind, days, by, now) {
|
|
@@ -19161,11 +19268,16 @@ export class Store {
|
|
|
19161
19268
|
const version = (current?.version ?? Number(this.db.prepare("SELECT COALESCE(MAX(version), 0) AS v FROM flow_script WHERE repo = ? AND name = ?").get(script.repo, script.name)?.["v"] ?? 0)) + 1;
|
|
19162
19269
|
if (current !== null)
|
|
19163
19270
|
this.db.prepare("UPDATE flow_script SET state = 'removed' WHERE id = ?").run(current.id);
|
|
19164
|
-
this.db.prepare(`INSERT INTO flow_script (repo, name, about, body, timeout_minutes, version, digest, saved_by, saved_at, state, language, file)
|
|
19165
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?)`).run(script.repo, script.name, script.about, script.body, script.timeoutMinutes, version, script.digest, script.by, now.toISOString(), script.language ?? "shell", script.file ?? null);
|
|
19271
|
+
this.db.prepare(`INSERT INTO flow_script (repo, name, about, body, timeout_minutes, version, digest, saved_by, saved_at, state, language, file, held)
|
|
19272
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?)`).run(script.repo, script.name, script.about, script.body, script.timeoutMinutes, version, script.digest, script.by, now.toISOString(), script.language ?? "shell", script.file ?? null, script.held ?? null);
|
|
19166
19273
|
return version;
|
|
19167
19274
|
});
|
|
19168
19275
|
}
|
|
19276
|
+
/** A person approves a held script (one an imported flow brought): from now on it runs. False when it wasn't held. */
|
|
19277
|
+
approveFlowScript(repo, name) {
|
|
19278
|
+
const { changes } = this.db.prepare("UPDATE flow_script SET held = NULL WHERE repo = ? AND name = ? AND state = 'active' AND held IS NOT NULL").run(repo, name);
|
|
19279
|
+
return Number(changes) === 1;
|
|
19280
|
+
}
|
|
19169
19281
|
removeFlowScript(repo, name) {
|
|
19170
19282
|
const { changes } = this.db.prepare("UPDATE flow_script SET state = 'removed' WHERE repo = ? AND name = ? AND state = 'active'").run(repo, name);
|
|
19171
19283
|
return Number(changes) === 1;
|
|
@@ -19819,12 +19931,18 @@ export class Store {
|
|
|
19819
19931
|
return this.db.prepare("SELECT * FROM chat_turn WHERE mate_turn = ? ORDER BY id ASC").all(mateTurn).map(readChatTurn);
|
|
19820
19932
|
}
|
|
19821
19933
|
// ---- projects ------------------------------------------------------------
|
|
19822
|
-
/** Remember a project was opened. Upsert keeps added_at; recency always moves.
|
|
19934
|
+
/** Remember a project was opened. Upsert keeps added_at; recency always moves.
|
|
19935
|
+
* A project added for the first time starts on Quick checks. */
|
|
19823
19936
|
upsertProject(path, name, now) {
|
|
19824
|
-
this.
|
|
19825
|
-
.prepare(
|
|
19826
|
-
|
|
19827
|
-
|
|
19937
|
+
this.transact(() => {
|
|
19938
|
+
const added = this.db.prepare("SELECT 1 FROM project WHERE path = ?").get(path) === undefined;
|
|
19939
|
+
this.db
|
|
19940
|
+
.prepare(`INSERT INTO project (path, name, added_at, last_opened_at) VALUES (?, ?, ?, ?)
|
|
19941
|
+
ON CONFLICT (path) DO UPDATE SET name = excluded.name, last_opened_at = excluded.last_opened_at`)
|
|
19942
|
+
.run(path, name, now.toISOString(), now.toISOString());
|
|
19943
|
+
if (added)
|
|
19944
|
+
stampNewProjectLevel(this, path, now);
|
|
19945
|
+
});
|
|
19828
19946
|
}
|
|
19829
19947
|
/** Most recently opened first — the opener page's order. */
|
|
19830
19948
|
listProjects() {
|
|
@@ -20971,6 +21089,108 @@ export class Store {
|
|
|
20971
21089
|
this.db.prepare("UPDATE run_process SET exited_at = ? WHERE id = ? AND pid IS NULL AND ((containment IS NULL AND container IS NULL) OR container_empty_at IS NOT NULL)")
|
|
20972
21090
|
.run(now.toISOString(), witness);
|
|
20973
21091
|
}
|
|
21092
|
+
/** The reconcile road for a pid-less witness a crash or a thrown spawn
|
|
21093
|
+
* left behind: once its run has finished, no live child is tracked for it
|
|
21094
|
+
* here, every other witness of the run has exited and none of the run's
|
|
21095
|
+
* process groups is alive, it settles as never started, with a ledger
|
|
21096
|
+
* entry. A sole pid-less witness has no group to prove gone and stays for
|
|
21097
|
+
* `run settle`. Native witnesses keep their OS-only road. */
|
|
21098
|
+
settleUnspawnedWitnesses(now) {
|
|
21099
|
+
const candidates = this.db.prepare(`SELECT DISTINCT p.run FROM run_process p JOIN run r ON r.id = p.run
|
|
21100
|
+
WHERE p.exited_at IS NULL AND p.pid IS NULL AND p.containment IS NULL AND p.container IS NULL
|
|
21101
|
+
AND p.host = ? AND r.outcome IS NOT NULL AND r.finished_at IS NOT NULL ORDER BY p.run`).all(hostname());
|
|
21102
|
+
let settled = 0;
|
|
21103
|
+
for (const candidate of candidates) {
|
|
21104
|
+
const runId = Number(candidate["run"]);
|
|
21105
|
+
this.recordRunProcessExits(runId, now);
|
|
21106
|
+
settled += this.transact(() => {
|
|
21107
|
+
const run = this.getRun(runId);
|
|
21108
|
+
if (run === null || run.outcome === null)
|
|
21109
|
+
return 0;
|
|
21110
|
+
if (ownedProcessCount(runOwnerTag(this, runId)) > 0)
|
|
21111
|
+
return 0;
|
|
21112
|
+
const held = this.heldSessionOf(runId);
|
|
21113
|
+
if (held !== null && held.endedAt === null)
|
|
21114
|
+
return 0;
|
|
21115
|
+
const rows = this.db.prepare("SELECT * FROM run_process WHERE run = ? ORDER BY id").all(runId);
|
|
21116
|
+
const pidless = rows.filter(row => row["exited_at"] === null && row["pid"] === null && row["containment"] === null && row["container"] === null && row["host"] === hostname());
|
|
21117
|
+
const others = rows.filter(row => !pidless.includes(row));
|
|
21118
|
+
if (pidless.length === 0 || others.length === 0)
|
|
21119
|
+
return 0;
|
|
21120
|
+
if (others.some(row => row["exited_at"] === null))
|
|
21121
|
+
return 0;
|
|
21122
|
+
// Exit was recorded once; the group's members are asked again now.
|
|
21123
|
+
const groups = others.filter(row => row["pid"] !== null && row["host"] === hostname());
|
|
21124
|
+
if (groups.length === 0 || groups.some(row => processMayBeAlive(Number(row["pid"]), row["process_group"] === 1, { observedAt: row["observed_at"], finishedAt: run.finishedAt })))
|
|
21125
|
+
return 0;
|
|
21126
|
+
const ref = this.refById(run.taskRef);
|
|
21127
|
+
let count = 0;
|
|
21128
|
+
for (const row of pidless) {
|
|
21129
|
+
const changed = this.db.prepare(`UPDATE run_process SET exited_at = ?
|
|
21130
|
+
WHERE id = ? AND run = ? AND pid IS NULL AND exited_at IS NULL AND containment IS NULL AND container IS NULL`)
|
|
21131
|
+
.run(now.toISOString(), row["id"], runId);
|
|
21132
|
+
if (Number(changed.changes) !== 1)
|
|
21133
|
+
continue;
|
|
21134
|
+
count++;
|
|
21135
|
+
this.recordAction({ at: now.toISOString(), actor: "system", repo: ref?.repo ?? null, taskId: ref?.externalId ?? null, runId,
|
|
21136
|
+
action: "process witness settled", outcome: "never started", source: "work",
|
|
21137
|
+
detail: `witness ${String(row["id"])}: no pid; the run finished, every other witness exited and none of its process groups is alive` });
|
|
21138
|
+
}
|
|
21139
|
+
return count;
|
|
21140
|
+
});
|
|
21141
|
+
}
|
|
21142
|
+
return settled;
|
|
21143
|
+
}
|
|
21144
|
+
/** `run settle` (an approver's last resort): every witness of a finished
|
|
21145
|
+
* run that cannot be proven either way is recorded settled under the
|
|
21146
|
+
* approver's reason, in the ledger. Refuses while anything of the run is
|
|
21147
|
+
* alive or could be checked only from another host. Never signals. */
|
|
21148
|
+
settleRunWitnessesByApprover(args, now) {
|
|
21149
|
+
return this.transact(() => {
|
|
21150
|
+
const run = this.getRun(args.runId);
|
|
21151
|
+
if (run === null)
|
|
21152
|
+
return { ok: false, reason: "unknown-run", detail: `no run #${args.runId}` };
|
|
21153
|
+
if (run.outcome === null)
|
|
21154
|
+
return { ok: false, reason: "still-running", detail: `run #${args.runId} is still open` };
|
|
21155
|
+
const alive = (detail) => ({ ok: false, reason: "alive", detail });
|
|
21156
|
+
if (ownedProcessCount(runOwnerTag(this, args.runId)) > 0)
|
|
21157
|
+
return alive(`run #${args.runId} still has an owned subprocess`);
|
|
21158
|
+
const held = this.heldSessionOf(args.runId);
|
|
21159
|
+
if (held !== null && held.endedAt === null)
|
|
21160
|
+
return alive(`run #${args.runId}'s held supervisor has not finished shutdown`);
|
|
21161
|
+
const rows = this.db.prepare("SELECT * FROM run_process WHERE run = ? ORDER BY id").all(args.runId);
|
|
21162
|
+
const open = rows.filter(row => row["exited_at"] === null);
|
|
21163
|
+
for (const row of rows) {
|
|
21164
|
+
if (row["pid"] === null)
|
|
21165
|
+
continue;
|
|
21166
|
+
if (row["host"] !== hostname()) {
|
|
21167
|
+
if (row["exited_at"] === null)
|
|
21168
|
+
return alive(`run #${args.runId}'s process ${String(row["pid"])} belongs to ${String(row["host"])}; settle it there`);
|
|
21169
|
+
continue;
|
|
21170
|
+
}
|
|
21171
|
+
if (processMayBeAlive(Number(row["pid"]), row["process_group"] === 1, { observedAt: row["observed_at"], finishedAt: run.finishedAt }))
|
|
21172
|
+
return alive(`run #${args.runId}'s process ${String(row["pid"])} is still running`);
|
|
21173
|
+
}
|
|
21174
|
+
for (const row of open) {
|
|
21175
|
+
if (row["pid"] === null && row["host"] !== hostname())
|
|
21176
|
+
return alive(`run #${args.runId} has a witness from ${String(row["host"])}; settle it there`);
|
|
21177
|
+
if (row["containment"] != null && row["container"] != null && row["container_empty_at"] == null &&
|
|
21178
|
+
containerEmptiness(String(row["containment"]), String(row["container"]), process.platform, row["container_identity"] == null ? null : String(row["container_identity"])) === "populated") {
|
|
21179
|
+
return alive(`run #${args.runId}'s ${String(row["containment"])} object ${String(row["container"])} still has members`);
|
|
21180
|
+
}
|
|
21181
|
+
}
|
|
21182
|
+
if (open.length === 0)
|
|
21183
|
+
return { ok: true, witnesses: [], repeated: true };
|
|
21184
|
+
const ids = open.map(row => Number(row["id"]));
|
|
21185
|
+
for (const id of ids)
|
|
21186
|
+
this.db.prepare("UPDATE run_process SET exited_at = ? WHERE id = ? AND run = ? AND exited_at IS NULL").run(now.toISOString(), id, args.runId);
|
|
21187
|
+
const ref = this.refById(run.taskRef);
|
|
21188
|
+
this.recordAction({ at: now.toISOString(), actor: args.by, repo: ref?.repo ?? null, taskId: ref?.externalId ?? null, runId: args.runId,
|
|
21189
|
+
action: "process witness settled by approver", outcome: `witness ${ids.join(", ")}`, source: "request", detail: args.why });
|
|
21190
|
+
this.settleQuiescentStops(now);
|
|
21191
|
+
return { ok: true, witnesses: ids, repeated: false };
|
|
21192
|
+
});
|
|
21193
|
+
}
|
|
20974
21194
|
recordRunProcess(runId, pid, now, group = true, witness) {
|
|
20975
21195
|
if (!Number.isSafeInteger(pid) || pid <= 0)
|
|
20976
21196
|
throw new Error("a process witness needs a valid spawned PID");
|
|
@@ -21182,6 +21402,48 @@ export class Store {
|
|
|
21182
21402
|
.get(runner, now.toISOString());
|
|
21183
21403
|
return Number(row?.["n"] ?? 0);
|
|
21184
21404
|
}
|
|
21405
|
+
/** The same live claims as liveClaimCount, by the project their task is placed in. */
|
|
21406
|
+
liveClaimCountsByRepo(runner, now) {
|
|
21407
|
+
const rows = this.db
|
|
21408
|
+
.prepare(`SELECT task_ref.repo AS repo, COUNT(*) AS n FROM claim
|
|
21409
|
+
JOIN task_ref ON task_ref.id = claim.task_ref
|
|
21410
|
+
WHERE claim.runner = ? AND claim.released_at IS NULL AND claim.expires_at > ?
|
|
21411
|
+
AND claim.lease_generation = (
|
|
21412
|
+
SELECT MAX(newest.lease_generation) FROM claim AS newest
|
|
21413
|
+
WHERE newest.task_ref = claim.task_ref
|
|
21414
|
+
)
|
|
21415
|
+
AND claim.lease_id NOT IN (SELECT lease_id FROM held_session WHERE ended_at IS NULL)
|
|
21416
|
+
GROUP BY task_ref.repo`)
|
|
21417
|
+
.all(runner, now.toISOString());
|
|
21418
|
+
const counts = new Map();
|
|
21419
|
+
for (const row of rows)
|
|
21420
|
+
if (row["repo"] != null)
|
|
21421
|
+
counts.set(String(row["repo"]), Number(row["n"]));
|
|
21422
|
+
return counts;
|
|
21423
|
+
}
|
|
21424
|
+
/** Every builder's live attempts, by project: what `status` shows against each project's limit. */
|
|
21425
|
+
runningBuildsByRepo(now) {
|
|
21426
|
+
const rows = this.db
|
|
21427
|
+
.prepare(`SELECT task_ref.repo AS repo, COUNT(*) AS n FROM claim
|
|
21428
|
+
JOIN task_ref ON task_ref.id = claim.task_ref
|
|
21429
|
+
WHERE claim.released_at IS NULL AND claim.expires_at > ?
|
|
21430
|
+
AND claim.lease_generation = (
|
|
21431
|
+
SELECT MAX(newest.lease_generation) FROM claim AS newest
|
|
21432
|
+
WHERE newest.task_ref = claim.task_ref
|
|
21433
|
+
)
|
|
21434
|
+
AND claim.lease_id NOT IN (SELECT lease_id FROM held_session WHERE ended_at IS NULL)
|
|
21435
|
+
GROUP BY task_ref.repo`)
|
|
21436
|
+
.all(now.toISOString());
|
|
21437
|
+
const counts = new Map();
|
|
21438
|
+
for (const row of rows)
|
|
21439
|
+
if (row["repo"] != null)
|
|
21440
|
+
counts.set(String(row["repo"]), Number(row["n"]));
|
|
21441
|
+
return counts;
|
|
21442
|
+
}
|
|
21443
|
+
/** A project's build concurrency changed (project-concurrency.ts): the ledger keeps before → after. */
|
|
21444
|
+
recordProjectConcurrency(by, repo, before, after, now) {
|
|
21445
|
+
this.recordPolicy(by, repo, "builds at once", String(before), String(after), now);
|
|
21446
|
+
}
|
|
21185
21447
|
/** Forward only, and only under the live generation. A stale poller moves nothing. */
|
|
21186
21448
|
/** v98: an update Telegram pushed, kept until the bridge applies it; false when it's already kept or applied. */
|
|
21187
21449
|
queueTelegramUpdate(botId, updateId, payload, now) {
|
|
@@ -21509,6 +21771,14 @@ export class Store {
|
|
|
21509
21771
|
this.db.prepare(`UPDATE notification_delivery SET receipt = '${TELEGRAM_SKIPPED_ELSEWHERE}'
|
|
21510
21772
|
WHERE destination = ? AND delivered_at IS NULL AND receipt IS NULL
|
|
21511
21773
|
AND notification IN (SELECT id FROM notification WHERE recipient IS NOT NULL AND recipient <> ?)`).run(destination, binding.approver);
|
|
21774
|
+
// Pings follow responsibility (the lead's work, this person's own act, a muted project): settled here, unsent.
|
|
21775
|
+
for (const raw of this.db.prepare(`SELECT n.* FROM notification n JOIN notification_delivery d ON d.notification = n.id AND d.destination = ?
|
|
21776
|
+
WHERE n.resolved_at IS NULL AND d.delivered_at IS NULL AND d.receipt IS NULL AND (d.claim_owner IS NULL OR d.claim_expires_at <= ?)`).all(destination, now.toISOString())) {
|
|
21777
|
+
const row = readNotification(raw);
|
|
21778
|
+
if (!this.pingAllowed(row, binding.approver)) {
|
|
21779
|
+
this.db.prepare(`UPDATE notification_delivery SET receipt = '${TELEGRAM_SKIPPED_QUIET}' WHERE notification = ? AND destination = ?`).run(row.id, destination);
|
|
21780
|
+
}
|
|
21781
|
+
}
|
|
21512
21782
|
if (this.telegramRetryAt(binding.botId) > now.toISOString())
|
|
21513
21783
|
return [];
|
|
21514
21784
|
const rows = this.db.prepare(`SELECT n.*, d.claim_generation FROM notification n
|
|
@@ -21722,6 +21992,143 @@ export class Store {
|
|
|
21722
21992
|
return Number(this.db.prepare("UPDATE notification_preference SET digest_on = ? WHERE account = ? AND digest_at IS NOT NULL AND (digest_on IS NULL OR digest_on < ?)")
|
|
21723
21993
|
.run(day, account, day).changes) === 1;
|
|
21724
21994
|
}
|
|
21995
|
+
// ---- pings follow responsibility: the lead, who acted, replacements and muted projects ----
|
|
21996
|
+
/** A lead token for one person, shown once. A new one ends their earlier ones; the ledger names it "lead for <owner>". */
|
|
21997
|
+
mintLeadCredential(owner, by, now) {
|
|
21998
|
+
return this.transact(() => {
|
|
21999
|
+
const stamp = now.toISOString();
|
|
22000
|
+
this.db.prepare("UPDATE lead_credential SET revoked_at = ?, revoked_by = ? WHERE owner = ? AND revoked_at IS NULL").run(stamp, by, owner);
|
|
22001
|
+
const minted = mintLeadToken();
|
|
22002
|
+
this.db.prepare("INSERT INTO lead_credential (id, owner, secret_hash, created_at, created_by) VALUES (?, ?, ?, ?, ?)").run(minted.id, owner, minted.hash, stamp, by);
|
|
22003
|
+
this.recordAction({ at: stamp, actor: by, repo: null, taskId: null, runId: null, action: "lead token created", outcome: "recorded", source: "access", detail: `lead for ${owner}` });
|
|
22004
|
+
return { id: minted.id, token: minted.token };
|
|
22005
|
+
});
|
|
22006
|
+
}
|
|
22007
|
+
/** End every live lead token for one person. */
|
|
22008
|
+
revokeLeadCredentials(owner, by, now) {
|
|
22009
|
+
return this.transact(() => {
|
|
22010
|
+
const ended = Number(this.db.prepare("UPDATE lead_credential SET revoked_at = ?, revoked_by = ? WHERE owner = ? AND revoked_at IS NULL").run(now.toISOString(), by, owner).changes);
|
|
22011
|
+
if (ended > 0)
|
|
22012
|
+
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}` });
|
|
22013
|
+
return ended;
|
|
22014
|
+
});
|
|
22015
|
+
}
|
|
22016
|
+
/** Whose lead a presented token is: a live token whose owner is still an approver, or null. */
|
|
22017
|
+
leadFor(presented) {
|
|
22018
|
+
const parsed = parseLeadToken(presented);
|
|
22019
|
+
if (parsed === null)
|
|
22020
|
+
return null;
|
|
22021
|
+
const row = this.db.prepare("SELECT owner, secret_hash FROM lead_credential WHERE id = ? AND revoked_at IS NULL").get(parsed.id);
|
|
22022
|
+
if (row === undefined || !leadSecretMatches(parsed.secret, String(row["secret_hash"])))
|
|
22023
|
+
return null;
|
|
22024
|
+
const owner = String(row["owner"]), account = this.accountOf(owner);
|
|
22025
|
+
return account === null || account.revokedAt !== null || account.role !== "approver" ? null : { owner, id: parsed.id };
|
|
22026
|
+
}
|
|
22027
|
+
/** Record who did this to a task, when someone is acting. The lead's acts are also in the ledger as "lead for <owner>". */
|
|
22028
|
+
noteTaskAct(taskRef, act, now, handed, actor = currentActor()) {
|
|
22029
|
+
if (actor === null)
|
|
22030
|
+
return;
|
|
22031
|
+
this.db.prepare("INSERT INTO task_act (task_ref, act, account, lead, person, why, at) VALUES (?, ?, ?, ?, ?, ?, ?)")
|
|
22032
|
+
.run(taskRef, act, actor.account, actor.lead ? 1 : 0, handed?.person ?? null, handed?.why ?? null, now.toISOString());
|
|
22033
|
+
if (!actor.lead)
|
|
22034
|
+
return;
|
|
22035
|
+
const ref = this.db.prepare("SELECT repo, external_id FROM task_ref WHERE id = ?").get(taskRef);
|
|
22036
|
+
this.recordAction({ at: now.toISOString(), actor: actorLabel(actor), repo: ref?.["repo"] == null ? null : String(ref["repo"]), taskId: ref === undefined ? null : String(ref["external_id"]), runId: null,
|
|
22037
|
+
action: act === "asked" ? "task handed to a person" : `task ${act}`, outcome: "recorded", source: "work", detail: handed === undefined ? null : `${handed.person}: ${handed.why}` });
|
|
22038
|
+
}
|
|
22039
|
+
/** Every recorded act on a task, oldest first. */
|
|
22040
|
+
taskActs(taskRef) {
|
|
22041
|
+
return this.db.prepare("SELECT act, account, lead, person, why, at FROM task_act WHERE task_ref = ? ORDER BY id").all(taskRef).map(row => ({
|
|
22042
|
+
act: String(row["act"]), account: String(row["account"]), lead: Number(row["lead"]) === 1,
|
|
22043
|
+
person: row["person"] == null ? null : String(row["person"]), why: row["why"] == null ? null : String(row["why"]), at: String(row["at"])
|
|
22044
|
+
}));
|
|
22045
|
+
}
|
|
22046
|
+
/** Whose lead's work this task is: the last to file, approve or hand it on was that person's lead (null: a person's). */
|
|
22047
|
+
leadWorkOf(taskRef) {
|
|
22048
|
+
const row = this.db.prepare("SELECT account, lead, act FROM task_act WHERE task_ref = ? AND act IN ('filed', 'approved', 'asked') ORDER BY id DESC LIMIT 1").get(taskRef);
|
|
22049
|
+
return row === undefined || Number(row["lead"]) !== 1 || row["act"] === "asked" ? null : String(row["account"]);
|
|
22050
|
+
}
|
|
22051
|
+
/** The lead hands a task to a person: it becomes theirs, and they get one message saying why. */
|
|
22052
|
+
askPerson(taskId, person, why, now) {
|
|
22053
|
+
return this.transact(() => {
|
|
22054
|
+
const ref = this.lookupRef(taskId);
|
|
22055
|
+
if (ref === null)
|
|
22056
|
+
return { ok: false, reason: "unknown-task" };
|
|
22057
|
+
if (ref.repo === null)
|
|
22058
|
+
return { ok: false, reason: "unplaced" };
|
|
22059
|
+
const account = this.accountOf(person);
|
|
22060
|
+
if (account === null || account.revokedAt !== null || !this.accountCanAccess(person, ref.repo))
|
|
22061
|
+
return { ok: false, reason: "unknown-person" };
|
|
22062
|
+
this.noteTaskAct(ref.id, "asked", now, { person, why });
|
|
22063
|
+
const asks = this.taskActs(ref.id).filter(one => one.act === "asked").length;
|
|
22064
|
+
const actor = currentActor();
|
|
22065
|
+
this.enqueueNotification({ dedupeKey: `ask:${ref.id}:${asks}`, kind: "task-ask", pushClass: "attention", recipient: person,
|
|
22066
|
+
subject: `${actor?.lead ? "The lead" : actor?.account ?? "Someone"} needs you: ${lifecycleWords(this.getTask(taskId)?.title ?? taskId, 80)}`,
|
|
22067
|
+
body: lifecycleWords(why, 400), link: chatControlHref("task", taskId), source: { taskRef: ref.id } }, now);
|
|
22068
|
+
return { ok: true };
|
|
22069
|
+
});
|
|
22070
|
+
}
|
|
22071
|
+
/** The task that replaced a cancelled one, or null. */
|
|
22072
|
+
replacementOf(taskId) {
|
|
22073
|
+
const row = this.db.prepare("SELECT r.successor FROM task_replacement r JOIN task_ref t ON t.id = r.task_ref WHERE t.backend = ? AND t.external_id = ?").get(BUILT_IN, taskId);
|
|
22074
|
+
return row === undefined ? null : String(row["successor"]);
|
|
22075
|
+
}
|
|
22076
|
+
/** Every replaced task in one read, for lists: task id → its successor. */
|
|
22077
|
+
replacements() {
|
|
22078
|
+
return new Map(this.db.prepare("SELECT t.external_id, r.successor FROM task_replacement r JOIN task_ref t ON t.id = r.task_ref").all()
|
|
22079
|
+
.map(row => [String(row["external_id"]), String(row["successor"])]));
|
|
22080
|
+
}
|
|
22081
|
+
/** Mute or unmute one project's pings for one person. The console and digest keep everything. */
|
|
22082
|
+
setProjectMuted(account, repo, muted, now) {
|
|
22083
|
+
if (muted)
|
|
22084
|
+
this.db.prepare("INSERT OR IGNORE INTO project_mute (account, repo, at) VALUES (?, ?, ?)").run(account, repo, now.toISOString());
|
|
22085
|
+
else
|
|
22086
|
+
this.db.prepare("DELETE FROM project_mute WHERE account = ? AND repo = ?").run(account, repo);
|
|
22087
|
+
}
|
|
22088
|
+
mutedProjects(account) {
|
|
22089
|
+
return this.db.prepare("SELECT repo FROM project_mute WHERE account = ? ORDER BY repo").all(account).map(row => String(row["repo"]));
|
|
22090
|
+
}
|
|
22091
|
+
/** Whose act made this fact, if anyone's. */
|
|
22092
|
+
notificationActor(id) {
|
|
22093
|
+
const row = this.db.prepare("SELECT account, lead FROM notification_actor WHERE notification = ?").get(id);
|
|
22094
|
+
return row === undefined ? null : { account: String(row["account"]), lead: Number(row["lead"]) === 1 };
|
|
22095
|
+
}
|
|
22096
|
+
/**
|
|
22097
|
+
* Whether this fact may message this person (every chat, phone and browser asks here). Pings follow
|
|
22098
|
+
* responsibility: a security alert always does; nobody is messaged about their own act (or their lead's);
|
|
22099
|
+
* the lead's work messages nobody unless it is handed to a person or fails with nothing left for the lead
|
|
22100
|
+
* to try; and a person's muted project stays quiet. A fact addressed to one person is theirs. The console
|
|
22101
|
+
* and the evening digest keep every fact either way.
|
|
22102
|
+
*/
|
|
22103
|
+
pingAllowed(row, account) {
|
|
22104
|
+
if (SECURITY_KINDS.has(row.kind) || row.kind === "evening-digest")
|
|
22105
|
+
return true;
|
|
22106
|
+
if (row.recipient !== null && row.kind === "task-ask")
|
|
22107
|
+
return true;
|
|
22108
|
+
const actor = this.notificationActor(row.id);
|
|
22109
|
+
if (actor !== null && actor.account === account)
|
|
22110
|
+
return false;
|
|
22111
|
+
if (row.recipient !== null)
|
|
22112
|
+
return true;
|
|
22113
|
+
if (this.leadQuiet(row, actor))
|
|
22114
|
+
return false;
|
|
22115
|
+
return row.project === null || this.db.prepare("SELECT 1 AS hit FROM project_mute WHERE account = ? AND repo = ?").get(account, row.project) === undefined;
|
|
22116
|
+
}
|
|
22117
|
+
/** The lead's work, which messages nobody: unless it is a security alert or a failure with nothing left for the lead. */
|
|
22118
|
+
leadQuiet(row, actor = this.notificationActor(row.id)) {
|
|
22119
|
+
if (SECURITY_KINDS.has(row.kind))
|
|
22120
|
+
return false;
|
|
22121
|
+
const lead = actor?.lead === true || (row.taskRef !== null && this.leadWorkOf(row.taskRef) !== null);
|
|
22122
|
+
return lead && !this.failedForGood(row);
|
|
22123
|
+
}
|
|
22124
|
+
/** A failure with nothing left for the lead to do: its attempts are spent, or the task has failed for good. */
|
|
22125
|
+
failedForGood(row) {
|
|
22126
|
+
if (SPENT_KINDS.has(row.kind))
|
|
22127
|
+
return true;
|
|
22128
|
+
if (!/fail|exhausted/.test(row.kind) || row.taskRef === null)
|
|
22129
|
+
return false;
|
|
22130
|
+
return this.db.prepare("SELECT 1 AS hit FROM task t JOIN task_ref r ON r.external_id = t.id AND r.backend = ? WHERE r.id = ? AND t.state = 'failed'").get(BUILT_IN, row.taskRef) !== undefined;
|
|
22131
|
+
}
|
|
21725
22132
|
/** The card a destination keeps for this task. A task with none joins a card whose first task was filed within
|
|
21726
22133
|
* a minute of it (up to eight tasks), or starts its own. */
|
|
21727
22134
|
chatCardFor(destination, taskRef, filedAt, now) {
|
|
@@ -21927,6 +22334,17 @@ export class Store {
|
|
|
21927
22334
|
AND push_subscription.retired_at IS NULL
|
|
21928
22335
|
AND notification.id > push_subscription.starts_after_notification`)
|
|
21929
22336
|
.run(now.toISOString());
|
|
22337
|
+
// Pings follow responsibility: a pair this person must not be alerted about retires before it is ever sent.
|
|
22338
|
+
if (Number(changes) > 0) {
|
|
22339
|
+
const fresh = this.db.prepare(`SELECT p.id AS pair, s.approver AS approver, n.* FROM push_delivery p
|
|
22340
|
+
JOIN push_subscription s ON s.id = p.subscription JOIN notification n ON n.id = p.notification
|
|
22341
|
+
WHERE p.state = 'pending' AND p.attempts = 0 AND p.created_at = ?`).all(now.toISOString());
|
|
22342
|
+
for (const raw of fresh) {
|
|
22343
|
+
if (!this.pingAllowed(readNotification(raw), String(raw["approver"]))) {
|
|
22344
|
+
this.db.prepare("UPDATE push_delivery SET state = 'retired', last_error = 'quiet' WHERE id = ? AND state = 'pending'").run(Number(raw["pair"]));
|
|
22345
|
+
}
|
|
22346
|
+
}
|
|
22347
|
+
}
|
|
21930
22348
|
return Number(changes);
|
|
21931
22349
|
}
|
|
21932
22350
|
/**
|
|
@@ -22247,6 +22665,8 @@ function readHold(row) {
|
|
|
22247
22665
|
export const TELEGRAM_SKIPPED_RECEIPT = "skipped:before-pairing";
|
|
22248
22666
|
/** v83: a notification for another person, settled for this destination without sending. */
|
|
22249
22667
|
export const TELEGRAM_SKIPPED_ELSEWHERE = "skipped:for-another-person";
|
|
22668
|
+
/** A fact this person is not messaged about: the lead's work, their own act, or a project they muted. */
|
|
22669
|
+
export const TELEGRAM_SKIPPED_QUIET = "skipped:quiet";
|
|
22250
22670
|
/** A destination receipt (`d` = notification_delivery) that still owes a
|
|
22251
22671
|
* send: not delivered, and not explicitly skipped as pre-pairing history.
|
|
22252
22672
|
* Every reader of Telegram receipts that means "undelivered" uses this, so
|