toolroll 0.9.5 → 0.9.7
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 +49 -0
- package/dist/actor.d.ts +21 -0
- package/dist/actor.js +48 -0
- package/dist/assignment-adapters.d.ts +1 -0
- package/dist/assignment-presentation.d.ts +13 -2
- package/dist/assignment-presentation.js +22 -9
- package/dist/assignment-ui.d.ts +10 -15
- package/dist/assignment-ui.js +19 -18
- package/dist/assignment.d.ts +9 -0
- package/dist/assignment.js +62 -13
- package/dist/browser/workspace.css +2 -2
- package/dist/browser/workspace.js +29 -24
- package/dist/browser-workspace.d.ts +120 -12
- package/dist/builder.d.ts +1 -0
- package/dist/builder.js +33 -13
- package/dist/chat-acceptance.js +2 -2
- package/dist/chat-actions.js +2 -1
- package/dist/chat-delivery.js +2 -0
- package/dist/chat-quiet.js +30 -19
- package/dist/chat-task-actions.d.ts +13 -0
- package/dist/chat-task-actions.js +19 -1
- package/dist/check-levels-ui.d.ts +21 -0
- package/dist/check-levels-ui.js +70 -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 +4 -0
- package/dist/demo-chat.d.ts +0 -5
- package/dist/demo-chat.js +14 -3
- package/dist/dispatch.js +7 -2
- package/dist/dispose.d.ts +8 -0
- package/dist/dispose.js +7 -1
- 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/lead-context.js +6 -2
- package/dist/lead-status.d.ts +7 -0
- package/dist/lead-status.js +11 -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/operate.d.ts +4 -1
- package/dist/operate.js +601 -85
- 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/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/scope.js +11 -0
- package/dist/serve.d.ts +211 -2
- package/dist/serve.js +1070 -204
- package/dist/store.d.ts +76 -3
- package/dist/store.js +311 -22
- package/dist/surface.js +31 -8
- 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 +186 -0
- package/dist/task-status.js +335 -0
- package/dist/team-cli.js +3 -0
- package/dist/telegram-progress.js +62 -100
- package/dist/telegram-status.js +21 -8
- package/dist/verification-evidence.js +21 -1
- package/dist/webhooks.js +5 -0
- package/dist/work-index.js +60 -11
- package/dist/work-summary.d.ts +0 -4
- package/dist/work-summary.js +2 -0
- package/dist/workspace-ui.d.ts +10 -0
- package/dist/workspace-ui.js +33 -2
- package/package.json +1 -1
package/dist/store.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type Actor } from "./actor.js";
|
|
1
2
|
import { type CriterionMatrixRow, type CriterionJudgementWord } from "./proof.js";
|
|
2
3
|
import { type ExecutionProfile, type ChainEntry, type UnattendedPermissionMode, type AcceptanceCriterion } from "./scope.js";
|
|
3
4
|
import { type PhaseRoute, type PublicationAuthority, type RouteStamp, type ReadinessLookup, type ReadinessObservation, type RiskLevel, type RouteOverride } from "./phase-routing.js";
|
|
@@ -19,6 +20,15 @@ import { type RetentionKind, type RetentionPeriods } from "./retention.js";
|
|
|
19
20
|
import { type CheckoutCleanup } from "./storage.js";
|
|
20
21
|
import { type LedgerChainReport } from "./ledger-chain.js";
|
|
21
22
|
import { type ApprovalGate, type ApprovalRules, type ApproverKind, type Filer } from "./approval-policy.js";
|
|
23
|
+
export type TaskAct = "filed" | "approved" | "cancelled" | "completed" | "asked";
|
|
24
|
+
export type TaskActRow = {
|
|
25
|
+
act: TaskAct;
|
|
26
|
+
account: string;
|
|
27
|
+
lead: boolean;
|
|
28
|
+
person: string | null;
|
|
29
|
+
why: string | null;
|
|
30
|
+
at: string;
|
|
31
|
+
};
|
|
22
32
|
/** Settings → Integrations (no version bump): each integration's last check. `outcome` is ok, failed, or absent (the check found
|
|
23
33
|
* nothing set up, such as no `gh` sign-in). `ok_at` and `error`/`error_at` keep the last success and the last
|
|
24
34
|
* failure across checks, so a row can say both. `account` is a display name only, never a credential. */
|
|
@@ -676,6 +686,8 @@ export type FlowScriptRow = {
|
|
|
676
686
|
savedAt: string;
|
|
677
687
|
language: "shell" | "python" | "node";
|
|
678
688
|
file: string | null;
|
|
689
|
+
/** Why it can't run yet ("imported": it came in with a flow file and waits for a person's approval); null when it runs. */
|
|
690
|
+
held: string | null;
|
|
679
691
|
};
|
|
680
692
|
/** Where a card came from when a trigger made it: what to call it and where to look (a GitHub issue, a Linear issue, another flow's card). */
|
|
681
693
|
/** Where a card came from. `mail` (v89): the email it came from, so a reply to its sender stays in the thread.
|
|
@@ -1843,9 +1855,9 @@ export declare class Store {
|
|
|
1843
1855
|
} | {
|
|
1844
1856
|
kind: "machine";
|
|
1845
1857
|
code: "mirror-latched" | "disowned-completion";
|
|
1846
|
-
}, now: Date, admittedFrom: readonly TaskState[] | null): CancellationTransition;
|
|
1858
|
+
}, now: Date, admittedFrom: readonly TaskState[] | null, replacedBy?: string): CancellationTransition;
|
|
1847
1859
|
private applyCancellationLocked;
|
|
1848
|
-
setTaskState(id: string, state: TaskState, now: Date, mutation?: Mutation, cancellationReason?: string): {
|
|
1860
|
+
setTaskState(id: string, state: TaskState, now: Date, mutation?: Mutation, cancellationReason?: string, replacedBy?: string): {
|
|
1849
1861
|
ok: true;
|
|
1850
1862
|
} | {
|
|
1851
1863
|
ok: false;
|
|
@@ -6446,7 +6458,10 @@ export declare class Store {
|
|
|
6446
6458
|
by: string;
|
|
6447
6459
|
language?: "shell" | "python" | "node";
|
|
6448
6460
|
file?: string | null;
|
|
6461
|
+
held?: string | null;
|
|
6449
6462
|
}, now: Date): number;
|
|
6463
|
+
/** A person approves a held script (one an imported flow brought): from now on it runs. False when it wasn't held. */
|
|
6464
|
+
approveFlowScript(repo: string, name: string): boolean;
|
|
6450
6465
|
removeFlowScript(repo: string, name: string): boolean;
|
|
6451
6466
|
/** Claim a card's visit to a step zone. False when another pass holds it or it already finished. */
|
|
6452
6467
|
claimFlowStep(step: {
|
|
@@ -6746,7 +6761,8 @@ export declare class Store {
|
|
|
6746
6761
|
getMateTurn(id: number): MateTurn | null;
|
|
6747
6762
|
liveMateTurnFor(approver: string): MateTurn | null;
|
|
6748
6763
|
mateStepsOf(mateTurn: number): ChatTurn[];
|
|
6749
|
-
/** Remember a project was opened. Upsert keeps added_at; recency always moves.
|
|
6764
|
+
/** Remember a project was opened. Upsert keeps added_at; recency always moves.
|
|
6765
|
+
* A project added for the first time starts on Quick checks. */
|
|
6750
6766
|
upsertProject(path: string, name: string, now: Date): void;
|
|
6751
6767
|
/** Most recently opened first — the opener page's order. */
|
|
6752
6768
|
listProjects(): {
|
|
@@ -7147,6 +7163,12 @@ export declare class Store {
|
|
|
7147
7163
|
* conversations never freeze the runner's queue.
|
|
7148
7164
|
*/
|
|
7149
7165
|
liveClaimCount(runner: string, now: Date): number;
|
|
7166
|
+
/** The same live claims as liveClaimCount, by the project their task is placed in. */
|
|
7167
|
+
liveClaimCountsByRepo(runner: string, now: Date): Map<string, number>;
|
|
7168
|
+
/** Every builder's live attempts, by project: what `status` shows against each project's limit. */
|
|
7169
|
+
runningBuildsByRepo(now: Date): Map<string, number>;
|
|
7170
|
+
/** A project's build concurrency changed (project-concurrency.ts): the ledger keeps before → after. */
|
|
7171
|
+
recordProjectConcurrency(by: string, repo: string, before: number, after: number, now: Date): void;
|
|
7150
7172
|
/** Forward only, and only under the live generation. A stale poller moves nothing. */
|
|
7151
7173
|
/** v98: an update Telegram pushed, kept until the bridge applies it; false when it's already kept or applied. */
|
|
7152
7174
|
queueTelegramUpdate(botId: string, updateId: number, payload: string, now: Date): boolean;
|
|
@@ -7339,6 +7361,55 @@ export declare class Store {
|
|
|
7339
7361
|
}[];
|
|
7340
7362
|
/** Claim one local day's digest for one person: true once, false if it was already considered. */
|
|
7341
7363
|
claimEveningDigestDay(account: string, day: string): boolean;
|
|
7364
|
+
/** A lead token for one person, shown once. A new one ends their earlier ones; the ledger names it "lead for <owner>". */
|
|
7365
|
+
mintLeadCredential(owner: string, by: string, now: Date): {
|
|
7366
|
+
id: string;
|
|
7367
|
+
token: string;
|
|
7368
|
+
};
|
|
7369
|
+
/** End every live lead token for one person. */
|
|
7370
|
+
revokeLeadCredentials(owner: string, by: string, now: Date): number;
|
|
7371
|
+
/** Whose lead a presented token is: a live token whose owner is still an approver, or null. */
|
|
7372
|
+
leadFor(presented: string): {
|
|
7373
|
+
owner: string;
|
|
7374
|
+
id: string;
|
|
7375
|
+
} | null;
|
|
7376
|
+
/** Record who did this to a task, when someone is acting. The lead's acts are also in the ledger as "lead for <owner>". */
|
|
7377
|
+
noteTaskAct(taskRef: number, act: TaskAct, now: Date, handed?: {
|
|
7378
|
+
person: string;
|
|
7379
|
+
why: string;
|
|
7380
|
+
}, actor?: Actor | null): void;
|
|
7381
|
+
/** Every recorded act on a task, oldest first. */
|
|
7382
|
+
taskActs(taskRef: number): TaskActRow[];
|
|
7383
|
+
/** 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). */
|
|
7384
|
+
leadWorkOf(taskRef: number): string | null;
|
|
7385
|
+
/** The lead hands a task to a person: it becomes theirs, and they get one message saying why. */
|
|
7386
|
+
askPerson(taskId: string, person: string, why: string, now: Date): {
|
|
7387
|
+
ok: true;
|
|
7388
|
+
} | {
|
|
7389
|
+
ok: false;
|
|
7390
|
+
reason: "unknown-task" | "unknown-person" | "unplaced";
|
|
7391
|
+
};
|
|
7392
|
+
/** The task that replaced a cancelled one, or null. */
|
|
7393
|
+
replacementOf(taskId: string): string | null;
|
|
7394
|
+
/** Every replaced task in one read, for lists: task id → its successor. */
|
|
7395
|
+
replacements(): Map<string, string>;
|
|
7396
|
+
/** Mute or unmute one project's pings for one person. The console and digest keep everything. */
|
|
7397
|
+
setProjectMuted(account: string, repo: string, muted: boolean, now: Date): void;
|
|
7398
|
+
mutedProjects(account: string): string[];
|
|
7399
|
+
/** Whose act made this fact, if anyone's. */
|
|
7400
|
+
notificationActor(id: number): Actor | null;
|
|
7401
|
+
/**
|
|
7402
|
+
* Whether this fact may message this person (every chat, phone and browser asks here). Pings follow
|
|
7403
|
+
* responsibility: a security alert always does; nobody is messaged about their own act (or their lead's);
|
|
7404
|
+
* the lead's work messages nobody unless it is handed to a person or fails with nothing left for the lead
|
|
7405
|
+
* to try; and a person's muted project stays quiet. A fact addressed to one person is theirs. The console
|
|
7406
|
+
* and the evening digest keep every fact either way.
|
|
7407
|
+
*/
|
|
7408
|
+
pingAllowed(row: Pick<Notification, "id" | "kind" | "recipient" | "project" | "taskRef">, account: string): boolean;
|
|
7409
|
+
/** The lead's work, which messages nobody: unless it is a security alert or a failure with nothing left for the lead. */
|
|
7410
|
+
leadQuiet(row: Pick<Notification, "id" | "kind" | "taskRef">, actor?: Actor | null): boolean;
|
|
7411
|
+
/** A failure with nothing left for the lead to do: its attempts are spent, or the task has failed for good. */
|
|
7412
|
+
private failedForGood;
|
|
7342
7413
|
/** The card a destination keeps for this task. A task with none joins a card whose first task was filed within
|
|
7343
7414
|
* a minute of it (up to eight tasks), or starts its own. */
|
|
7344
7415
|
chatCardFor(destination: string, taskRef: number, filedAt: string, now: Date): ChatCard;
|
|
@@ -7487,6 +7558,8 @@ export declare class Store {
|
|
|
7487
7558
|
export declare const TELEGRAM_SKIPPED_RECEIPT = "skipped:before-pairing";
|
|
7488
7559
|
/** v83: a notification for another person, settled for this destination without sending. */
|
|
7489
7560
|
export declare const TELEGRAM_SKIPPED_ELSEWHERE = "skipped:for-another-person";
|
|
7561
|
+
/** A fact this person is not messaged about: the lead's work, their own act, or a project they muted. */
|
|
7562
|
+
export declare const TELEGRAM_SKIPPED_QUIET = "skipped:quiet";
|
|
7490
7563
|
/**
|
|
7491
7564
|
* Why the store itself holds a Telegram delivery back: authority,
|
|
7492
7565
|
* provenance, ordering, a switched-off channel, or a wait it imposed. A
|
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";
|
|
@@ -164,6 +166,54 @@ CREATE TABLE IF NOT EXISTS chat_card_task (
|
|
|
164
166
|
);
|
|
165
167
|
CREATE INDEX IF NOT EXISTS chat_card_task_card ON chat_card_task(card);
|
|
166
168
|
`;
|
|
169
|
+
/** Pings follow responsibility (no version bump: additive only). `lead_credential` is a lead token: an agent acting
|
|
170
|
+
* for `owner`, only its hash kept. `task_act` is who filed, approved, cancelled, completed or handed on a task
|
|
171
|
+
* (`lead` 1: the owner's lead; `person`/`why`: who the lead asked, and why). `notification_actor` is whose act made
|
|
172
|
+
* a fact. `task_replacement` is the task that replaced a cancelled one. `project_mute` is a person's muted projects. */
|
|
173
|
+
const LEAD_QUIET_SCHEMA = `
|
|
174
|
+
CREATE TABLE IF NOT EXISTS lead_credential (
|
|
175
|
+
id TEXT PRIMARY KEY,
|
|
176
|
+
owner TEXT NOT NULL,
|
|
177
|
+
secret_hash TEXT NOT NULL,
|
|
178
|
+
created_at TEXT NOT NULL,
|
|
179
|
+
created_by TEXT NOT NULL,
|
|
180
|
+
revoked_at TEXT,
|
|
181
|
+
revoked_by TEXT
|
|
182
|
+
);
|
|
183
|
+
CREATE INDEX IF NOT EXISTS lead_credential_owner ON lead_credential (owner);
|
|
184
|
+
CREATE TABLE IF NOT EXISTS task_act (
|
|
185
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
186
|
+
task_ref INTEGER NOT NULL,
|
|
187
|
+
act TEXT NOT NULL CHECK (act IN ('filed', 'approved', 'cancelled', 'completed', 'asked')),
|
|
188
|
+
account TEXT NOT NULL,
|
|
189
|
+
lead INTEGER NOT NULL DEFAULT 0 CHECK (lead IN (0, 1)),
|
|
190
|
+
person TEXT,
|
|
191
|
+
why TEXT,
|
|
192
|
+
at TEXT NOT NULL
|
|
193
|
+
);
|
|
194
|
+
CREATE INDEX IF NOT EXISTS task_act_task ON task_act (task_ref, id);
|
|
195
|
+
CREATE TABLE IF NOT EXISTS notification_actor (
|
|
196
|
+
notification INTEGER PRIMARY KEY REFERENCES notification(id) ON DELETE CASCADE,
|
|
197
|
+
account TEXT NOT NULL,
|
|
198
|
+
lead INTEGER NOT NULL DEFAULT 0 CHECK (lead IN (0, 1))
|
|
199
|
+
);
|
|
200
|
+
CREATE TABLE IF NOT EXISTS task_replacement (
|
|
201
|
+
task_ref INTEGER PRIMARY KEY,
|
|
202
|
+
successor TEXT NOT NULL,
|
|
203
|
+
by TEXT,
|
|
204
|
+
at TEXT NOT NULL
|
|
205
|
+
);
|
|
206
|
+
CREATE TABLE IF NOT EXISTS project_mute (
|
|
207
|
+
account TEXT NOT NULL,
|
|
208
|
+
repo TEXT NOT NULL,
|
|
209
|
+
at TEXT NOT NULL,
|
|
210
|
+
PRIMARY KEY (account, repo)
|
|
211
|
+
);
|
|
212
|
+
`;
|
|
213
|
+
/** What still pings on the lead's own work: a security alert. */
|
|
214
|
+
const SECURITY_KINDS = new Set(["secret-detected", "security-release"]);
|
|
215
|
+
/** A failure that leaves nothing for the lead to try: the attempts are spent. */
|
|
216
|
+
const SPENT_KINDS = new Set(["attempts-exhausted", "plan-attempts-exhausted"]);
|
|
167
217
|
/** Settings → Integrations (no version bump): each integration's last check. `outcome` is ok, failed, or absent (the check found
|
|
168
218
|
* nothing set up, such as no `gh` sign-in). `ok_at` and `error`/`error_at` keep the last success and the last
|
|
169
219
|
* failure across checks, so a row can say both. `account` is a display name only, never a credential. */
|
|
@@ -450,7 +500,8 @@ function readFlowRow(row) {
|
|
|
450
500
|
function readFlowScriptRow(row) {
|
|
451
501
|
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
502
|
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"])
|
|
503
|
+
language: row["language"] === "python" || row["language"] === "node" ? row["language"] : "shell", file: row["file"] === null || row["file"] === undefined ? null : String(row["file"]),
|
|
504
|
+
held: row["held"] === null || row["held"] === undefined ? null : String(row["held"]) };
|
|
454
505
|
}
|
|
455
506
|
function readFlowStepRunRow(row) {
|
|
456
507
|
const optional = (key) => row[key] === null || row[key] === undefined ? null : String(row[key]);
|
|
@@ -3757,6 +3808,7 @@ function initializeStore(db, file) {
|
|
|
3757
3808
|
db.exec(POLICY_SCHEMA);
|
|
3758
3809
|
db.exec(RUN_CHECK_SCHEMA);
|
|
3759
3810
|
db.exec(QUIET_CHAT_SCHEMA);
|
|
3811
|
+
db.exec(LEAD_QUIET_SCHEMA);
|
|
3760
3812
|
mergeCheckTables(db);
|
|
3761
3813
|
addColumn(db, "monitoring_status", "target", "TEXT");
|
|
3762
3814
|
// v105: how a teammate's turn was billed (this computer's Claude sign-in, as last seen).
|
|
@@ -3778,6 +3830,8 @@ function initializeStore(db, file) {
|
|
|
3778
3830
|
// v90: scripts in Python and Node, and scripts that run a file in the project.
|
|
3779
3831
|
addColumn(db, "flow_script", "language", "TEXT NOT NULL DEFAULT 'shell'");
|
|
3780
3832
|
addColumn(db, "flow_script", "file", "TEXT");
|
|
3833
|
+
// A script that came in with an imported flow waits for a person to approve it before it runs (no version bump: additive only).
|
|
3834
|
+
addColumn(db, "flow_script", "held", "TEXT");
|
|
3781
3835
|
addColumn(db, "notification", "recipient", "TEXT");
|
|
3782
3836
|
addColumn(db, "approver", "projects_json", "TEXT");
|
|
3783
3837
|
addColumn(db, "invite", "projects_json", "TEXT");
|
|
@@ -6942,6 +6996,8 @@ export class Store {
|
|
|
6942
6996
|
const earlier = this.db.prepare("SELECT 1 AS hit FROM task_ref WHERE external_id = ?").get(spec.id) !== undefined;
|
|
6943
6997
|
// Created here, so it is ours — the one place that is true by construction.
|
|
6944
6998
|
const ref = this.refFor(BUILT_IN, spec.id, "ours");
|
|
6999
|
+
// Who filed it, when someone is acting: the lead's work pings nobody.
|
|
7000
|
+
this.noteTaskAct(ref.id, "filed", now);
|
|
6945
7001
|
if (earlier)
|
|
6946
7002
|
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
7003
|
// v102: who filed it, set once with the filing (separation of duties reads it; nothing changes it).
|
|
@@ -7006,10 +7062,10 @@ export class Store {
|
|
|
7006
7062
|
* they admit from (`null` = any). It returns raw changes; callers keep
|
|
7007
7063
|
* their own refusal words and wake bumps.
|
|
7008
7064
|
*/
|
|
7009
|
-
applyCancellation(taskId, reason, now, admittedFrom) {
|
|
7010
|
-
return this.transact(() => this.applyCancellationLocked(taskId, reason, now, admittedFrom));
|
|
7065
|
+
applyCancellation(taskId, reason, now, admittedFrom, replacedBy) {
|
|
7066
|
+
return this.transact(() => this.applyCancellationLocked(taskId, reason, now, admittedFrom, replacedBy));
|
|
7011
7067
|
}
|
|
7012
|
-
applyCancellationLocked(taskId, reason, now, admittedFrom) {
|
|
7068
|
+
applyCancellationLocked(taskId, reason, now, admittedFrom, replacedBy) {
|
|
7013
7069
|
const ref = this.db
|
|
7014
7070
|
.prepare("SELECT id, coordinator_cid FROM task_ref WHERE backend = ? AND external_id = ?")
|
|
7015
7071
|
.get(BUILT_IN, taskId);
|
|
@@ -7035,6 +7091,14 @@ export class Store {
|
|
|
7035
7091
|
.run(now.toISOString(), taskId, ...(admittedFrom ?? []));
|
|
7036
7092
|
if (Number(changes) === 0)
|
|
7037
7093
|
return { changed: false };
|
|
7094
|
+
// Replaced: the successor is part of the cancellation, so every surface says "Replaced by <id>".
|
|
7095
|
+
if (ref !== undefined && replacedBy !== undefined) {
|
|
7096
|
+
this.db.prepare(`INSERT INTO task_replacement (task_ref, successor, by, at) VALUES (?, ?, ?, ?)
|
|
7097
|
+
ON CONFLICT (task_ref) DO UPDATE SET successor = excluded.successor, by = excluded.by, at = excluded.at`)
|
|
7098
|
+
.run(Number(ref["id"]), replacedBy, currentActor() === null ? null : actorLabel(currentActor()), now.toISOString());
|
|
7099
|
+
}
|
|
7100
|
+
if (ref !== undefined && prior?.["state"] !== "cancelled")
|
|
7101
|
+
this.noteTaskAct(Number(ref["id"]), "cancelled", now);
|
|
7038
7102
|
// A cancelled task's pending steering settles superseded in the SAME
|
|
7039
7103
|
// transaction, whichever road cancelled it — a note silently waiting
|
|
7040
7104
|
// for a build that can no longer happen is wrong on every road, not
|
|
@@ -7058,18 +7122,20 @@ export class Store {
|
|
|
7058
7122
|
if (ref !== undefined && prior?.["state"] !== "cancelled") {
|
|
7059
7123
|
this.noteLifecycle({
|
|
7060
7124
|
taskRef: Number(ref["id"]), kind: "task-cancelled", identity: `t${Number(ref["id"])}`,
|
|
7061
|
-
subject: "Cancelled"
|
|
7062
|
-
body:
|
|
7063
|
-
? `${
|
|
7064
|
-
: reason.
|
|
7065
|
-
?
|
|
7066
|
-
:
|
|
7067
|
-
|
|
7125
|
+
subject: replacedBy === undefined ? "Cancelled" : `Replaced by ${replacedBy}`,
|
|
7126
|
+
body: replacedBy !== undefined
|
|
7127
|
+
? `${replacedBy} replaces it. Nothing more runs for it.`
|
|
7128
|
+
: reason.kind === "operator"
|
|
7129
|
+
? `${cancellationWords(reason.text)} Nothing more runs for it.`
|
|
7130
|
+
: reason.code === "mirror-latched"
|
|
7131
|
+
? "The tracker closed it. Nothing more runs for it."
|
|
7132
|
+
: "The tracker closed it while it was being built, so the finished work was not accepted.",
|
|
7133
|
+
link: chatControlHref("task", replacedBy ?? taskId),
|
|
7068
7134
|
}, now);
|
|
7069
7135
|
}
|
|
7070
7136
|
return { changed: true };
|
|
7071
7137
|
}
|
|
7072
|
-
setTaskState(id, state, now, mutation = {}, cancellationReason) {
|
|
7138
|
+
setTaskState(id, state, now, mutation = {}, cancellationReason, replacedBy) {
|
|
7073
7139
|
return this.once(mutation, "setTaskState", () => {
|
|
7074
7140
|
// The terminal guard lives IN the primitive so every present and
|
|
7075
7141
|
// future caller is covered (external dispatch, finding 23): a
|
|
@@ -7085,7 +7151,7 @@ export class Store {
|
|
|
7085
7151
|
}
|
|
7086
7152
|
return this.transact(() => {
|
|
7087
7153
|
if (state === "cancelled") {
|
|
7088
|
-
const done = this.applyCancellation(id, { kind: "operator", text: cancellationReason ?? null }, now, null);
|
|
7154
|
+
const done = this.applyCancellation(id, { kind: "operator", text: cancellationReason ?? null }, now, null, replacedBy);
|
|
7089
7155
|
if (done.reason !== undefined)
|
|
7090
7156
|
return { ok: false, reason: done.reason };
|
|
7091
7157
|
if (!done.changed)
|
|
@@ -7099,6 +7165,11 @@ export class Store {
|
|
|
7099
7165
|
.run(state, now.toISOString(), id);
|
|
7100
7166
|
if (Number(changes) === 0)
|
|
7101
7167
|
return { ok: false, reason: "unknown-task" };
|
|
7168
|
+
if (state === "done" && prior?.["state"] !== "done") {
|
|
7169
|
+
const done = this.lookupRef(id);
|
|
7170
|
+
if (done !== null)
|
|
7171
|
+
this.noteTaskAct(done.id, "completed", now);
|
|
7172
|
+
}
|
|
7102
7173
|
// The return-to-queue fact (Telegram task updates): a running
|
|
7103
7174
|
// task its worker released unfinished, or a closed one reopened
|
|
7104
7175
|
// by the state verb. Marking done or failed by hand rides the
|
|
@@ -8475,6 +8546,8 @@ export class Store {
|
|
|
8475
8546
|
// (setup review): the approval is what was stale, and it is new.
|
|
8476
8547
|
const ref = this.db.prepare("SELECT id FROM task_ref WHERE backend = ? AND external_id = ?").get(BUILT_IN, taskId);
|
|
8477
8548
|
if (ref !== undefined) {
|
|
8549
|
+
if (basis === undefined)
|
|
8550
|
+
this.noteTaskAct(Number(ref["id"]), "approved", now);
|
|
8478
8551
|
this.db.prepare("DELETE FROM hold WHERE owner_kind = 'backoff' AND owner_id = ?").run(`stale:${Number(ref["id"])}`);
|
|
8479
8552
|
this.db
|
|
8480
8553
|
.prepare("UPDATE notification SET resolved_at = ? WHERE dedupe_key LIKE ? AND resolved_at IS NULL")
|
|
@@ -8488,7 +8561,7 @@ export class Store {
|
|
|
8488
8561
|
taskRef: Number(ref["id"]), kind: "scope-approved", identity: `t${Number(ref["id"])}`,
|
|
8489
8562
|
subject: "Scope approved",
|
|
8490
8563
|
body: basis === undefined
|
|
8491
|
-
? `Approved by ${lifecycleWords(by, 40)}. A connected worker can take it next.`
|
|
8564
|
+
? `Approved by ${currentActor()?.lead === true ? "the lead" : lifecycleWords(by, 40)}. A connected worker can take it next.`
|
|
8492
8565
|
: "Approved automatically under the operating mode. A connected worker can take it next.",
|
|
8493
8566
|
link: chatControlHref("task", taskId),
|
|
8494
8567
|
}, now);
|
|
@@ -14928,10 +15001,15 @@ export class Store {
|
|
|
14928
15001
|
: source !== undefined && "project" in source && source.project !== "" ? "project"
|
|
14929
15002
|
: source !== undefined && "installation" in source ? "installation" : "unknown";
|
|
14930
15003
|
const project = scope === "task" ? String(ref["repo"]) : scope === "project" && source !== undefined && "project" in source ? source.project : null;
|
|
14931
|
-
const { changes } = this.db
|
|
15004
|
+
const { changes, lastInsertRowid } = this.db
|
|
14932
15005
|
.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
15006
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
14934
15007
|
.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);
|
|
15008
|
+
// Whose act made this fact: pings follow it (see pingAllowed).
|
|
15009
|
+
const actor = currentActor();
|
|
15010
|
+
if (Number(changes) > 0 && actor !== null) {
|
|
15011
|
+
this.db.prepare("INSERT OR IGNORE INTO notification_actor (notification, account, lead) VALUES (?, ?, ?)").run(Number(lastInsertRowid), actor.account, actor.lead ? 1 : 0);
|
|
15012
|
+
}
|
|
14935
15013
|
return Number(changes) > 0;
|
|
14936
15014
|
}
|
|
14937
15015
|
/**
|
|
@@ -19161,11 +19239,16 @@ export class Store {
|
|
|
19161
19239
|
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
19240
|
if (current !== null)
|
|
19163
19241
|
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);
|
|
19242
|
+
this.db.prepare(`INSERT INTO flow_script (repo, name, about, body, timeout_minutes, version, digest, saved_by, saved_at, state, language, file, held)
|
|
19243
|
+
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
19244
|
return version;
|
|
19167
19245
|
});
|
|
19168
19246
|
}
|
|
19247
|
+
/** A person approves a held script (one an imported flow brought): from now on it runs. False when it wasn't held. */
|
|
19248
|
+
approveFlowScript(repo, name) {
|
|
19249
|
+
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);
|
|
19250
|
+
return Number(changes) === 1;
|
|
19251
|
+
}
|
|
19169
19252
|
removeFlowScript(repo, name) {
|
|
19170
19253
|
const { changes } = this.db.prepare("UPDATE flow_script SET state = 'removed' WHERE repo = ? AND name = ? AND state = 'active'").run(repo, name);
|
|
19171
19254
|
return Number(changes) === 1;
|
|
@@ -19819,12 +19902,18 @@ export class Store {
|
|
|
19819
19902
|
return this.db.prepare("SELECT * FROM chat_turn WHERE mate_turn = ? ORDER BY id ASC").all(mateTurn).map(readChatTurn);
|
|
19820
19903
|
}
|
|
19821
19904
|
// ---- projects ------------------------------------------------------------
|
|
19822
|
-
/** Remember a project was opened. Upsert keeps added_at; recency always moves.
|
|
19905
|
+
/** Remember a project was opened. Upsert keeps added_at; recency always moves.
|
|
19906
|
+
* A project added for the first time starts on Quick checks. */
|
|
19823
19907
|
upsertProject(path, name, now) {
|
|
19824
|
-
this.
|
|
19825
|
-
.prepare(
|
|
19826
|
-
|
|
19827
|
-
|
|
19908
|
+
this.transact(() => {
|
|
19909
|
+
const added = this.db.prepare("SELECT 1 FROM project WHERE path = ?").get(path) === undefined;
|
|
19910
|
+
this.db
|
|
19911
|
+
.prepare(`INSERT INTO project (path, name, added_at, last_opened_at) VALUES (?, ?, ?, ?)
|
|
19912
|
+
ON CONFLICT (path) DO UPDATE SET name = excluded.name, last_opened_at = excluded.last_opened_at`)
|
|
19913
|
+
.run(path, name, now.toISOString(), now.toISOString());
|
|
19914
|
+
if (added)
|
|
19915
|
+
stampNewProjectLevel(this, path, now);
|
|
19916
|
+
});
|
|
19828
19917
|
}
|
|
19829
19918
|
/** Most recently opened first — the opener page's order. */
|
|
19830
19919
|
listProjects() {
|
|
@@ -21182,6 +21271,48 @@ export class Store {
|
|
|
21182
21271
|
.get(runner, now.toISOString());
|
|
21183
21272
|
return Number(row?.["n"] ?? 0);
|
|
21184
21273
|
}
|
|
21274
|
+
/** The same live claims as liveClaimCount, by the project their task is placed in. */
|
|
21275
|
+
liveClaimCountsByRepo(runner, now) {
|
|
21276
|
+
const rows = this.db
|
|
21277
|
+
.prepare(`SELECT task_ref.repo AS repo, COUNT(*) AS n FROM claim
|
|
21278
|
+
JOIN task_ref ON task_ref.id = claim.task_ref
|
|
21279
|
+
WHERE claim.runner = ? AND claim.released_at IS NULL AND claim.expires_at > ?
|
|
21280
|
+
AND claim.lease_generation = (
|
|
21281
|
+
SELECT MAX(newest.lease_generation) FROM claim AS newest
|
|
21282
|
+
WHERE newest.task_ref = claim.task_ref
|
|
21283
|
+
)
|
|
21284
|
+
AND claim.lease_id NOT IN (SELECT lease_id FROM held_session WHERE ended_at IS NULL)
|
|
21285
|
+
GROUP BY task_ref.repo`)
|
|
21286
|
+
.all(runner, now.toISOString());
|
|
21287
|
+
const counts = new Map();
|
|
21288
|
+
for (const row of rows)
|
|
21289
|
+
if (row["repo"] != null)
|
|
21290
|
+
counts.set(String(row["repo"]), Number(row["n"]));
|
|
21291
|
+
return counts;
|
|
21292
|
+
}
|
|
21293
|
+
/** Every builder's live attempts, by project: what `status` shows against each project's limit. */
|
|
21294
|
+
runningBuildsByRepo(now) {
|
|
21295
|
+
const rows = this.db
|
|
21296
|
+
.prepare(`SELECT task_ref.repo AS repo, COUNT(*) AS n FROM claim
|
|
21297
|
+
JOIN task_ref ON task_ref.id = claim.task_ref
|
|
21298
|
+
WHERE claim.released_at IS NULL AND claim.expires_at > ?
|
|
21299
|
+
AND claim.lease_generation = (
|
|
21300
|
+
SELECT MAX(newest.lease_generation) FROM claim AS newest
|
|
21301
|
+
WHERE newest.task_ref = claim.task_ref
|
|
21302
|
+
)
|
|
21303
|
+
AND claim.lease_id NOT IN (SELECT lease_id FROM held_session WHERE ended_at IS NULL)
|
|
21304
|
+
GROUP BY task_ref.repo`)
|
|
21305
|
+
.all(now.toISOString());
|
|
21306
|
+
const counts = new Map();
|
|
21307
|
+
for (const row of rows)
|
|
21308
|
+
if (row["repo"] != null)
|
|
21309
|
+
counts.set(String(row["repo"]), Number(row["n"]));
|
|
21310
|
+
return counts;
|
|
21311
|
+
}
|
|
21312
|
+
/** A project's build concurrency changed (project-concurrency.ts): the ledger keeps before → after. */
|
|
21313
|
+
recordProjectConcurrency(by, repo, before, after, now) {
|
|
21314
|
+
this.recordPolicy(by, repo, "builds at once", String(before), String(after), now);
|
|
21315
|
+
}
|
|
21185
21316
|
/** Forward only, and only under the live generation. A stale poller moves nothing. */
|
|
21186
21317
|
/** v98: an update Telegram pushed, kept until the bridge applies it; false when it's already kept or applied. */
|
|
21187
21318
|
queueTelegramUpdate(botId, updateId, payload, now) {
|
|
@@ -21509,6 +21640,14 @@ export class Store {
|
|
|
21509
21640
|
this.db.prepare(`UPDATE notification_delivery SET receipt = '${TELEGRAM_SKIPPED_ELSEWHERE}'
|
|
21510
21641
|
WHERE destination = ? AND delivered_at IS NULL AND receipt IS NULL
|
|
21511
21642
|
AND notification IN (SELECT id FROM notification WHERE recipient IS NOT NULL AND recipient <> ?)`).run(destination, binding.approver);
|
|
21643
|
+
// Pings follow responsibility (the lead's work, this person's own act, a muted project): settled here, unsent.
|
|
21644
|
+
for (const raw of this.db.prepare(`SELECT n.* FROM notification n JOIN notification_delivery d ON d.notification = n.id AND d.destination = ?
|
|
21645
|
+
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())) {
|
|
21646
|
+
const row = readNotification(raw);
|
|
21647
|
+
if (!this.pingAllowed(row, binding.approver)) {
|
|
21648
|
+
this.db.prepare(`UPDATE notification_delivery SET receipt = '${TELEGRAM_SKIPPED_QUIET}' WHERE notification = ? AND destination = ?`).run(row.id, destination);
|
|
21649
|
+
}
|
|
21650
|
+
}
|
|
21512
21651
|
if (this.telegramRetryAt(binding.botId) > now.toISOString())
|
|
21513
21652
|
return [];
|
|
21514
21653
|
const rows = this.db.prepare(`SELECT n.*, d.claim_generation FROM notification n
|
|
@@ -21722,6 +21861,143 @@ export class Store {
|
|
|
21722
21861
|
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
21862
|
.run(day, account, day).changes) === 1;
|
|
21724
21863
|
}
|
|
21864
|
+
// ---- pings follow responsibility: the lead, who acted, replacements and muted projects ----
|
|
21865
|
+
/** A lead token for one person, shown once. A new one ends their earlier ones; the ledger names it "lead for <owner>". */
|
|
21866
|
+
mintLeadCredential(owner, by, now) {
|
|
21867
|
+
return this.transact(() => {
|
|
21868
|
+
const stamp = now.toISOString();
|
|
21869
|
+
this.db.prepare("UPDATE lead_credential SET revoked_at = ?, revoked_by = ? WHERE owner = ? AND revoked_at IS NULL").run(stamp, by, owner);
|
|
21870
|
+
const minted = mintLeadToken();
|
|
21871
|
+
this.db.prepare("INSERT INTO lead_credential (id, owner, secret_hash, created_at, created_by) VALUES (?, ?, ?, ?, ?)").run(minted.id, owner, minted.hash, stamp, by);
|
|
21872
|
+
this.recordAction({ at: stamp, actor: by, repo: null, taskId: null, runId: null, action: "lead token created", outcome: "recorded", source: "access", detail: `lead for ${owner}` });
|
|
21873
|
+
return { id: minted.id, token: minted.token };
|
|
21874
|
+
});
|
|
21875
|
+
}
|
|
21876
|
+
/** End every live lead token for one person. */
|
|
21877
|
+
revokeLeadCredentials(owner, by, now) {
|
|
21878
|
+
return this.transact(() => {
|
|
21879
|
+
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);
|
|
21880
|
+
if (ended > 0)
|
|
21881
|
+
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}` });
|
|
21882
|
+
return ended;
|
|
21883
|
+
});
|
|
21884
|
+
}
|
|
21885
|
+
/** Whose lead a presented token is: a live token whose owner is still an approver, or null. */
|
|
21886
|
+
leadFor(presented) {
|
|
21887
|
+
const parsed = parseLeadToken(presented);
|
|
21888
|
+
if (parsed === null)
|
|
21889
|
+
return null;
|
|
21890
|
+
const row = this.db.prepare("SELECT owner, secret_hash FROM lead_credential WHERE id = ? AND revoked_at IS NULL").get(parsed.id);
|
|
21891
|
+
if (row === undefined || !leadSecretMatches(parsed.secret, String(row["secret_hash"])))
|
|
21892
|
+
return null;
|
|
21893
|
+
const owner = String(row["owner"]), account = this.accountOf(owner);
|
|
21894
|
+
return account === null || account.revokedAt !== null || account.role !== "approver" ? null : { owner, id: parsed.id };
|
|
21895
|
+
}
|
|
21896
|
+
/** Record who did this to a task, when someone is acting. The lead's acts are also in the ledger as "lead for <owner>". */
|
|
21897
|
+
noteTaskAct(taskRef, act, now, handed, actor = currentActor()) {
|
|
21898
|
+
if (actor === null)
|
|
21899
|
+
return;
|
|
21900
|
+
this.db.prepare("INSERT INTO task_act (task_ref, act, account, lead, person, why, at) VALUES (?, ?, ?, ?, ?, ?, ?)")
|
|
21901
|
+
.run(taskRef, act, actor.account, actor.lead ? 1 : 0, handed?.person ?? null, handed?.why ?? null, now.toISOString());
|
|
21902
|
+
if (!actor.lead)
|
|
21903
|
+
return;
|
|
21904
|
+
const ref = this.db.prepare("SELECT repo, external_id FROM task_ref WHERE id = ?").get(taskRef);
|
|
21905
|
+
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,
|
|
21906
|
+
action: act === "asked" ? "task handed to a person" : `task ${act}`, outcome: "recorded", source: "work", detail: handed === undefined ? null : `${handed.person}: ${handed.why}` });
|
|
21907
|
+
}
|
|
21908
|
+
/** Every recorded act on a task, oldest first. */
|
|
21909
|
+
taskActs(taskRef) {
|
|
21910
|
+
return this.db.prepare("SELECT act, account, lead, person, why, at FROM task_act WHERE task_ref = ? ORDER BY id").all(taskRef).map(row => ({
|
|
21911
|
+
act: String(row["act"]), account: String(row["account"]), lead: Number(row["lead"]) === 1,
|
|
21912
|
+
person: row["person"] == null ? null : String(row["person"]), why: row["why"] == null ? null : String(row["why"]), at: String(row["at"])
|
|
21913
|
+
}));
|
|
21914
|
+
}
|
|
21915
|
+
/** 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). */
|
|
21916
|
+
leadWorkOf(taskRef) {
|
|
21917
|
+
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);
|
|
21918
|
+
return row === undefined || Number(row["lead"]) !== 1 || row["act"] === "asked" ? null : String(row["account"]);
|
|
21919
|
+
}
|
|
21920
|
+
/** The lead hands a task to a person: it becomes theirs, and they get one message saying why. */
|
|
21921
|
+
askPerson(taskId, person, why, now) {
|
|
21922
|
+
return this.transact(() => {
|
|
21923
|
+
const ref = this.lookupRef(taskId);
|
|
21924
|
+
if (ref === null)
|
|
21925
|
+
return { ok: false, reason: "unknown-task" };
|
|
21926
|
+
if (ref.repo === null)
|
|
21927
|
+
return { ok: false, reason: "unplaced" };
|
|
21928
|
+
const account = this.accountOf(person);
|
|
21929
|
+
if (account === null || account.revokedAt !== null || !this.accountCanAccess(person, ref.repo))
|
|
21930
|
+
return { ok: false, reason: "unknown-person" };
|
|
21931
|
+
this.noteTaskAct(ref.id, "asked", now, { person, why });
|
|
21932
|
+
const asks = this.taskActs(ref.id).filter(one => one.act === "asked").length;
|
|
21933
|
+
const actor = currentActor();
|
|
21934
|
+
this.enqueueNotification({ dedupeKey: `ask:${ref.id}:${asks}`, kind: "task-ask", pushClass: "attention", recipient: person,
|
|
21935
|
+
subject: `${actor?.lead ? "The lead" : actor?.account ?? "Someone"} needs you: ${lifecycleWords(this.getTask(taskId)?.title ?? taskId, 80)}`,
|
|
21936
|
+
body: lifecycleWords(why, 400), link: chatControlHref("task", taskId), source: { taskRef: ref.id } }, now);
|
|
21937
|
+
return { ok: true };
|
|
21938
|
+
});
|
|
21939
|
+
}
|
|
21940
|
+
/** The task that replaced a cancelled one, or null. */
|
|
21941
|
+
replacementOf(taskId) {
|
|
21942
|
+
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);
|
|
21943
|
+
return row === undefined ? null : String(row["successor"]);
|
|
21944
|
+
}
|
|
21945
|
+
/** Every replaced task in one read, for lists: task id → its successor. */
|
|
21946
|
+
replacements() {
|
|
21947
|
+
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()
|
|
21948
|
+
.map(row => [String(row["external_id"]), String(row["successor"])]));
|
|
21949
|
+
}
|
|
21950
|
+
/** Mute or unmute one project's pings for one person. The console and digest keep everything. */
|
|
21951
|
+
setProjectMuted(account, repo, muted, now) {
|
|
21952
|
+
if (muted)
|
|
21953
|
+
this.db.prepare("INSERT OR IGNORE INTO project_mute (account, repo, at) VALUES (?, ?, ?)").run(account, repo, now.toISOString());
|
|
21954
|
+
else
|
|
21955
|
+
this.db.prepare("DELETE FROM project_mute WHERE account = ? AND repo = ?").run(account, repo);
|
|
21956
|
+
}
|
|
21957
|
+
mutedProjects(account) {
|
|
21958
|
+
return this.db.prepare("SELECT repo FROM project_mute WHERE account = ? ORDER BY repo").all(account).map(row => String(row["repo"]));
|
|
21959
|
+
}
|
|
21960
|
+
/** Whose act made this fact, if anyone's. */
|
|
21961
|
+
notificationActor(id) {
|
|
21962
|
+
const row = this.db.prepare("SELECT account, lead FROM notification_actor WHERE notification = ?").get(id);
|
|
21963
|
+
return row === undefined ? null : { account: String(row["account"]), lead: Number(row["lead"]) === 1 };
|
|
21964
|
+
}
|
|
21965
|
+
/**
|
|
21966
|
+
* Whether this fact may message this person (every chat, phone and browser asks here). Pings follow
|
|
21967
|
+
* responsibility: a security alert always does; nobody is messaged about their own act (or their lead's);
|
|
21968
|
+
* the lead's work messages nobody unless it is handed to a person or fails with nothing left for the lead
|
|
21969
|
+
* to try; and a person's muted project stays quiet. A fact addressed to one person is theirs. The console
|
|
21970
|
+
* and the evening digest keep every fact either way.
|
|
21971
|
+
*/
|
|
21972
|
+
pingAllowed(row, account) {
|
|
21973
|
+
if (SECURITY_KINDS.has(row.kind) || row.kind === "evening-digest")
|
|
21974
|
+
return true;
|
|
21975
|
+
if (row.recipient !== null && row.kind === "task-ask")
|
|
21976
|
+
return true;
|
|
21977
|
+
const actor = this.notificationActor(row.id);
|
|
21978
|
+
if (actor !== null && actor.account === account)
|
|
21979
|
+
return false;
|
|
21980
|
+
if (row.recipient !== null)
|
|
21981
|
+
return true;
|
|
21982
|
+
if (this.leadQuiet(row, actor))
|
|
21983
|
+
return false;
|
|
21984
|
+
return row.project === null || this.db.prepare("SELECT 1 AS hit FROM project_mute WHERE account = ? AND repo = ?").get(account, row.project) === undefined;
|
|
21985
|
+
}
|
|
21986
|
+
/** The lead's work, which messages nobody: unless it is a security alert or a failure with nothing left for the lead. */
|
|
21987
|
+
leadQuiet(row, actor = this.notificationActor(row.id)) {
|
|
21988
|
+
if (SECURITY_KINDS.has(row.kind))
|
|
21989
|
+
return false;
|
|
21990
|
+
const lead = actor?.lead === true || (row.taskRef !== null && this.leadWorkOf(row.taskRef) !== null);
|
|
21991
|
+
return lead && !this.failedForGood(row);
|
|
21992
|
+
}
|
|
21993
|
+
/** A failure with nothing left for the lead to do: its attempts are spent, or the task has failed for good. */
|
|
21994
|
+
failedForGood(row) {
|
|
21995
|
+
if (SPENT_KINDS.has(row.kind))
|
|
21996
|
+
return true;
|
|
21997
|
+
if (!/fail|exhausted/.test(row.kind) || row.taskRef === null)
|
|
21998
|
+
return false;
|
|
21999
|
+
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;
|
|
22000
|
+
}
|
|
21725
22001
|
/** The card a destination keeps for this task. A task with none joins a card whose first task was filed within
|
|
21726
22002
|
* a minute of it (up to eight tasks), or starts its own. */
|
|
21727
22003
|
chatCardFor(destination, taskRef, filedAt, now) {
|
|
@@ -21927,6 +22203,17 @@ export class Store {
|
|
|
21927
22203
|
AND push_subscription.retired_at IS NULL
|
|
21928
22204
|
AND notification.id > push_subscription.starts_after_notification`)
|
|
21929
22205
|
.run(now.toISOString());
|
|
22206
|
+
// Pings follow responsibility: a pair this person must not be alerted about retires before it is ever sent.
|
|
22207
|
+
if (Number(changes) > 0) {
|
|
22208
|
+
const fresh = this.db.prepare(`SELECT p.id AS pair, s.approver AS approver, n.* FROM push_delivery p
|
|
22209
|
+
JOIN push_subscription s ON s.id = p.subscription JOIN notification n ON n.id = p.notification
|
|
22210
|
+
WHERE p.state = 'pending' AND p.attempts = 0 AND p.created_at = ?`).all(now.toISOString());
|
|
22211
|
+
for (const raw of fresh) {
|
|
22212
|
+
if (!this.pingAllowed(readNotification(raw), String(raw["approver"]))) {
|
|
22213
|
+
this.db.prepare("UPDATE push_delivery SET state = 'retired', last_error = 'quiet' WHERE id = ? AND state = 'pending'").run(Number(raw["pair"]));
|
|
22214
|
+
}
|
|
22215
|
+
}
|
|
22216
|
+
}
|
|
21930
22217
|
return Number(changes);
|
|
21931
22218
|
}
|
|
21932
22219
|
/**
|
|
@@ -22247,6 +22534,8 @@ function readHold(row) {
|
|
|
22247
22534
|
export const TELEGRAM_SKIPPED_RECEIPT = "skipped:before-pairing";
|
|
22248
22535
|
/** v83: a notification for another person, settled for this destination without sending. */
|
|
22249
22536
|
export const TELEGRAM_SKIPPED_ELSEWHERE = "skipped:for-another-person";
|
|
22537
|
+
/** A fact this person is not messaged about: the lead's work, their own act, or a project they muted. */
|
|
22538
|
+
export const TELEGRAM_SKIPPED_QUIET = "skipped:quiet";
|
|
22250
22539
|
/** A destination receipt (`d` = notification_delivery) that still owes a
|
|
22251
22540
|
* send: not delivered, and not explicitly skipped as pre-pairing history.
|
|
22252
22541
|
* Every reader of Telegram receipts that means "undelivered" uses this, so
|