toolroll 0.9.42 → 0.9.44
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 +37 -0
- package/dist/action-ledger.d.ts +5 -2
- package/dist/action-ledger.js +13 -3
- package/dist/chat-actions.js +1 -1
- package/dist/chat-approval.d.ts +45 -0
- package/dist/chat-approval.js +81 -0
- package/dist/chat-channel.js +5 -0
- package/dist/chat-decide.d.ts +18 -6
- package/dist/chat-decide.js +54 -20
- package/dist/chat-flow.js +2 -2
- package/dist/cli-http.d.ts +64 -0
- package/dist/cli-http.js +143 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +8 -1
- package/dist/contracts/cli.d.ts +13 -0
- package/dist/contracts/cli.js +7 -0
- package/dist/contracts/console-api.d.ts +1 -0
- package/dist/contracts/console-api.js +1 -0
- package/dist/contracts/gateway-tools.d.ts +82 -0
- package/dist/contracts/gateway-tools.js +33 -0
- package/dist/contracts/lead-tools.d.ts +11 -0
- package/dist/contracts/lead-tools.js +2 -0
- package/dist/desktop-update.d.ts +3 -1
- package/dist/desktop-update.js +7 -6
- package/dist/flow-engine.d.ts +2 -0
- package/dist/flow-engine.js +10 -2
- package/dist/flows-cli.d.ts +28 -0
- package/dist/flows-cli.js +43 -1
- package/dist/guides.js +2 -0
- package/dist/mate-contract.js +1 -1
- package/dist/mate-tools.js +43 -1
- package/dist/mcp-core.d.ts +110 -0
- package/dist/mcp-core.js +311 -0
- package/dist/mcp-http.d.ts +26 -0
- package/dist/mcp-http.js +151 -0
- package/dist/mcp-person.d.ts +31 -0
- package/dist/mcp-person.js +96 -0
- package/dist/mcp.d.ts +1 -8
- package/dist/mcp.js +21 -322
- package/dist/operate-remote-arguments.d.ts +36 -0
- package/dist/operate-remote-arguments.js +185 -0
- package/dist/operate-remote.d.ts +86 -0
- package/dist/operate-remote.js +125 -0
- package/dist/operate.d.ts +21 -1
- package/dist/operate.js +229 -15
- package/dist/process-recovery-eligibility.d.ts +1 -1
- package/dist/remote-command.d.ts +45 -0
- package/dist/remote-command.js +124 -0
- package/dist/remote-exec.d.ts +18 -0
- package/dist/remote-exec.js +241 -0
- package/dist/remote-run.d.ts +16 -0
- package/dist/remote-run.js +13 -0
- package/dist/scope.d.ts +4 -2
- package/dist/scope.js +10 -0
- package/dist/serve.d.ts +7 -0
- package/dist/serve.js +75 -2
- package/dist/store.d.ts +23 -2
- package/dist/store.js +76 -9
- package/dist/surface.d.ts +10 -3
- package/dist/surface.js +71 -3
- package/dist/team-cli.d.ts +19 -0
- package/dist/team-cli.js +20 -3
- package/dist/teammate-work.js +1 -1
- package/dist/telegram-flow.d.ts +41 -1
- package/dist/telegram-flow.js +96 -12
- package/dist/telegram.js +27 -3
- package/dist/toolroll-update.js +4 -1
- package/package.json +1 -1
package/dist/store.js
CHANGED
|
@@ -64,7 +64,7 @@ import { authenticate as runnerAuthenticate } from "./runner.js";
|
|
|
64
64
|
import { stampNewProjectLevel } from "./check-levels.js";
|
|
65
65
|
import { parseCheckProgressSnapshot } from "./check-progress.js";
|
|
66
66
|
import { normalizeProjectAccess, projectAccessAllows, readProjectAccess } from "./project-access.js";
|
|
67
|
-
import { LEDGER_SCHEMA, LEDGER_TABLE, LEDGER_V54_COLUMNS, LEDGER_V54_TABLE, installLedgerTriggers } from "./action-ledger.js";
|
|
67
|
+
import { LEDGER_SCHEMA, LEDGER_TABLE, LEDGER_V54_COLUMNS, LEDGER_V54_TABLE, LEDGER_V99_TABLE, installLedgerTriggers } from "./action-ledger.js";
|
|
68
68
|
import { PLAN_AUTO_SCHEMA } from "./plan-auto.js";
|
|
69
69
|
import { RECIPE_SCHEMA } from "./recipes.js";
|
|
70
70
|
import { POLICY_SCHEMA, agentRefusal, approvalRefusal, attendedRefusal, policyParts, readPolicy, underCeiling, sessionCeilingRefusal } from "./policy.js";
|
|
@@ -354,6 +354,40 @@ CREATE TABLE IF NOT EXISTS telegram_flow_choice (
|
|
|
354
354
|
);
|
|
355
355
|
CREATE INDEX IF NOT EXISTS telegram_flow_choice_visit ON telegram_flow_choice (card, entry);
|
|
356
356
|
`;
|
|
357
|
+
/** An owner's lasting choice to approve plans and merges from their paired chat (chat-approval.ts), for all their
|
|
358
|
+
* projects (scope '*') or one (its path), with the limits they agreed to. Off unless a row says on; a project's own row
|
|
359
|
+
* outranks the all-projects one. `generation` is the account's at the change: a reset password ends it. */
|
|
360
|
+
export const CHAT_APPROVAL_SCHEMA = `
|
|
361
|
+
CREATE TABLE IF NOT EXISTS chat_approval_setting (
|
|
362
|
+
approver TEXT NOT NULL,
|
|
363
|
+
scope TEXT NOT NULL,
|
|
364
|
+
enabled INTEGER NOT NULL CHECK (enabled IN (0,1)),
|
|
365
|
+
full_access INTEGER NOT NULL DEFAULT 0 CHECK (full_access IN (0,1)),
|
|
366
|
+
attempt_cap_microusd INTEGER CHECK (attempt_cap_microusd IS NULL OR attempt_cap_microusd >= 0),
|
|
367
|
+
generation INTEGER NOT NULL,
|
|
368
|
+
digest TEXT NOT NULL,
|
|
369
|
+
updated_by TEXT NOT NULL,
|
|
370
|
+
updated_at TEXT NOT NULL,
|
|
371
|
+
PRIMARY KEY (approver, scope)
|
|
372
|
+
);
|
|
373
|
+
`;
|
|
374
|
+
/** The second tap on a flow decision in Telegram (telegram-flow.ts): Approve arms Yes and Cancel, bound to the binding,
|
|
375
|
+
* chat, message and exact visit of the card; Yes decides it once. */
|
|
376
|
+
export const TELEGRAM_FLOW_CONFIRM_SCHEMA = `
|
|
377
|
+
CREATE TABLE IF NOT EXISTS telegram_flow_confirm (
|
|
378
|
+
token TEXT PRIMARY KEY,
|
|
379
|
+
binding INTEGER NOT NULL,
|
|
380
|
+
chat_id TEXT NOT NULL,
|
|
381
|
+
message_id TEXT NOT NULL,
|
|
382
|
+
card INTEGER NOT NULL,
|
|
383
|
+
entry INTEGER NOT NULL,
|
|
384
|
+
phase TEXT NOT NULL CHECK (phase IN ('yes','cancel')),
|
|
385
|
+
created_at TEXT NOT NULL,
|
|
386
|
+
expires_at TEXT NOT NULL,
|
|
387
|
+
consumed_at TEXT
|
|
388
|
+
);
|
|
389
|
+
CREATE INDEX IF NOT EXISTS telegram_flow_confirm_visit ON telegram_flow_confirm (card, entry);
|
|
390
|
+
`;
|
|
357
391
|
export const CHAT_DECIDE_SCHEMA = `
|
|
358
392
|
CREATE TABLE IF NOT EXISTS chat_decide_action (
|
|
359
393
|
token TEXT PRIMARY KEY,
|
|
@@ -529,7 +563,29 @@ const REGISTERED_AT_FILING = "again, as it was filed";
|
|
|
529
563
|
// v107 keeps a check's progress and result in one record (run_check absorbs check_progress).
|
|
530
564
|
// v108 keeps sign-in pauses: one incident per provider whose sign-in stopped working (provider_auth_pause).
|
|
531
565
|
// v109 remembers which sign-in pause parked a task (task_ref.auth_wait_pause), so reviews and other runs outside the tick wait for it too.
|
|
532
|
-
|
|
566
|
+
// v110 lets the action ledger record commands a person ran on this server with their API token (source 'api', or 'mcp' from their agent).
|
|
567
|
+
export const SCHEMA_VERSION = 110;
|
|
568
|
+
/**
|
|
569
|
+
* The migrations `toolroll update` may carry a database through in place: each
|
|
570
|
+
* one only adds (a table, a column, an accepted value) and leaves every saved row
|
|
571
|
+
* as it was, so the updater's rehearsal on a copy (same history, integrity ok)
|
|
572
|
+
* proves it and the verified backup undoes it. Any migration not listed here
|
|
573
|
+
* needs the separate verified migration procedure. The desktop update never
|
|
574
|
+
* migrates and does not read this list.
|
|
575
|
+
*
|
|
576
|
+
* v110 rebuilds action_ledger only to widen its source check ('api', 'mcp'):
|
|
577
|
+
* every row, id and hash-chain link is copied unchanged.
|
|
578
|
+
*/
|
|
579
|
+
export const UPDATE_SAFE_MIGRATIONS = Object.freeze([110]);
|
|
580
|
+
/** Whether a database settled at `version` reaches this build's schema through update-safe migrations alone. */
|
|
581
|
+
export function updateSafeSchema(version) {
|
|
582
|
+
if (version === null || !Number.isSafeInteger(version) || version <= 0 || version > SCHEMA_VERSION)
|
|
583
|
+
return false;
|
|
584
|
+
for (let next = version + 1; next <= SCHEMA_VERSION; next++)
|
|
585
|
+
if (!UPDATE_SAFE_MIGRATIONS.includes(next))
|
|
586
|
+
return false;
|
|
587
|
+
return true;
|
|
588
|
+
}
|
|
533
589
|
/** v102: a project's approval rules, and each person's approval of an exact scope (two are needed for protected work). */
|
|
534
590
|
const APPROVAL_SCHEMA = `
|
|
535
591
|
CREATE TABLE IF NOT EXISTS approval_policy (
|
|
@@ -3964,6 +4020,9 @@ function initializeStore(db, file) {
|
|
|
3964
4020
|
db.exec(MATE_ASK_SCHEMA);
|
|
3965
4021
|
// Decisions in the chat app (no version bump: additive only): result, plan and merge buttons, and Request changes.
|
|
3966
4022
|
db.exec(CHAT_DECIDE_SCHEMA);
|
|
4023
|
+
// An owner's lasting chat-approval setting (no version bump: additive only; absent means off).
|
|
4024
|
+
db.exec(CHAT_APPROVAL_SCHEMA);
|
|
4025
|
+
db.exec(TELEGRAM_FLOW_CONFIRM_SCHEMA);
|
|
3967
4026
|
// A flow's "Send to me" and "Person chooses" zones (no version bump: additive only).
|
|
3968
4027
|
db.exec(FLOW_SEND_SCHEMA);
|
|
3969
4028
|
db.exec(SPEND_SCHEMA);
|
|
@@ -5175,8 +5234,8 @@ function migrate(db, origin) {
|
|
|
5175
5234
|
tool_call INTEGER REFERENCES teammate_call(id),
|
|
5176
5235
|
suggestion INTEGER REFERENCES teammate_suggestion(id)
|
|
5177
5236
|
)`, ["id", "teammate", "card", "entry", "question", "options_json", "asked_of", "state", "choice", "answer", "answered_by", "answered_via", "answered_at", "created_at", "tool_call", "suggestion"]);
|
|
5178
|
-
// v99: the action ledger admits sign-in and policy events, each with a short detail.
|
|
5179
|
-
|
|
5237
|
+
// v99: the action ledger admits sign-in and policy events, each with a short detail; v110: and remote commands ('api', 'mcp').
|
|
5238
|
+
rebuildLedger(db);
|
|
5180
5239
|
// v101: a coordinator credential's expiry (null: made before expiry existed, until renewed).
|
|
5181
5240
|
addColumn(db, "coordinator_credential", "expires_at", "TEXT");
|
|
5182
5241
|
// v102: who filed a task (a person, or the person a coordinator acts for; null when no person is known) and what kind of filer.
|
|
@@ -5497,19 +5556,24 @@ function readWebSession(row) {
|
|
|
5497
5556
|
}
|
|
5498
5557
|
/**
|
|
5499
5558
|
* v99: the ledger's source admits 'sign-in' and 'policy', and each row may
|
|
5500
|
-
* carry a short detail.
|
|
5559
|
+
* carry a short detail. v110: it admits 'api' and 'mcp' too, from either
|
|
5560
|
+
* earlier shape; rows, ids and the counter are carried whole, so the v103
|
|
5561
|
+
* seals over them still hold. The work triggers on other tables write into the
|
|
5501
5562
|
* ledger, and a rename refuses while a trigger points at a table that's gone,
|
|
5502
5563
|
* so every trigger naming it is dropped first; openStore puts them back
|
|
5503
5564
|
* (LEDGER_SCHEMA and installLedgerTriggers run after migration).
|
|
5504
5565
|
*/
|
|
5505
|
-
function
|
|
5566
|
+
function rebuildLedger(db) {
|
|
5506
5567
|
const row = db.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'action_ledger'").get();
|
|
5507
5568
|
if (row === undefined || canonicalDdl(String(row["sql"])) === canonicalDdl(LEDGER_TABLE("action_ledger")))
|
|
5508
5569
|
return;
|
|
5509
5570
|
for (const trigger of db.prepare("SELECT name FROM sqlite_master WHERE type = 'trigger' AND sql LIKE '%action_ledger%'").all()) {
|
|
5510
5571
|
db.exec(`DROP TRIGGER IF EXISTS "${String(trigger["name"]).replace(/"/g, '""')}"`);
|
|
5511
5572
|
}
|
|
5512
|
-
|
|
5573
|
+
if (canonicalDdl(String(row["sql"])) === canonicalDdl(LEDGER_V54_TABLE("action_ledger")))
|
|
5574
|
+
rebuildExact(db, "action_ledger", LEDGER_V54_TABLE, LEDGER_TABLE, LEDGER_V54_COLUMNS);
|
|
5575
|
+
else
|
|
5576
|
+
rebuildExact(db, "action_ledger", LEDGER_V99_TABLE, LEDGER_TABLE, [...LEDGER_V54_COLUMNS, "detail"]);
|
|
5513
5577
|
}
|
|
5514
5578
|
/** v34: artifact.kind admits 'report'. A table already rebuilt to the v38
|
|
5515
5579
|
* shape (migrate() runs unconditionally on every open, including a fresh
|
|
@@ -8797,7 +8861,7 @@ export class Store {
|
|
|
8797
8861
|
// (setup review): the approval is what was stale, and it is new.
|
|
8798
8862
|
const ref = this.db.prepare("SELECT id FROM task_ref WHERE backend = ? AND external_id = ?").get(BUILT_IN, taskId);
|
|
8799
8863
|
if (ref !== undefined) {
|
|
8800
|
-
if (basis === undefined)
|
|
8864
|
+
if (basis === undefined || basis.kind === "chat")
|
|
8801
8865
|
this.noteTaskAct(Number(ref["id"]), "approved", now);
|
|
8802
8866
|
this.db.prepare("DELETE FROM hold WHERE owner_kind = 'backoff' AND owner_id = ?").run(`stale:${Number(ref["id"])}`);
|
|
8803
8867
|
this.db
|
|
@@ -8813,7 +8877,9 @@ export class Store {
|
|
|
8813
8877
|
subject: "Scope approved",
|
|
8814
8878
|
body: basis === undefined
|
|
8815
8879
|
? `Approved by ${currentActor()?.lead === true ? "the lead" : lifecycleWords(by, 40)}. A connected worker can take it next.`
|
|
8816
|
-
:
|
|
8880
|
+
: basis.kind === "chat"
|
|
8881
|
+
? `Approved by ${lifecycleWords(by, 40)} in chat. A connected worker can take it next.`
|
|
8882
|
+
: "Approved automatically under the operating mode. A connected worker can take it next.",
|
|
8817
8883
|
link: chatControlHref("task", taskId),
|
|
8818
8884
|
}, now);
|
|
8819
8885
|
}
|
|
@@ -18513,6 +18579,7 @@ export class Store {
|
|
|
18513
18579
|
const stamp = now.toISOString();
|
|
18514
18580
|
this.db.prepare("UPDATE telegram_flow_action SET consumed_at = ? WHERE card = ? AND entry = ? AND consumed_at IS NULL").run(stamp, card, entry);
|
|
18515
18581
|
this.db.prepare("UPDATE telegram_flow_prompt SET consumed_at = ? WHERE card = ? AND entry = ? AND consumed_at IS NULL").run(stamp, card, entry);
|
|
18582
|
+
this.db.prepare("UPDATE telegram_flow_confirm SET consumed_at = ? WHERE card = ? AND entry = ? AND consumed_at IS NULL").run(stamp, card, entry);
|
|
18516
18583
|
}
|
|
18517
18584
|
recordTelegramFlowPrompt(prompt, now, hours = 24) {
|
|
18518
18585
|
this.db.prepare("INSERT OR REPLACE INTO telegram_flow_prompt (chat_id, message_id, binding, card, entry, mode, created_at, expires_at, consumed_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL)")
|
package/dist/surface.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { type CommandFlag, type CommandRow } from './contracts/cli.js';
|
|
1
|
+
import { type CommandFlag, type CommandRow, type RemotePolicy } from './contracts/cli.js';
|
|
2
2
|
/** A guide row and its flags are derived from their schemas in src/contracts/cli.ts. */
|
|
3
|
-
export type { CommandFlag, CommandRow };
|
|
3
|
+
export type { CommandFlag, CommandRow, RemotePolicy };
|
|
4
4
|
/** The guide's limits, stated machine-readably in every dump. */
|
|
5
5
|
export declare const SURFACE_NOTES: {
|
|
6
6
|
readonly authority: "documentation is not permission; session descriptors also drive parser and request validation; legacy entries remain advisory";
|
|
@@ -9,7 +9,14 @@ export declare const SURFACE_NOTES: {
|
|
|
9
9
|
readonly database: "local database commands wait up to 15 seconds for another writer; an exhausted wait returns database-busy and names the selected file";
|
|
10
10
|
};
|
|
11
11
|
export declare const SURFACE_SCHEMA_VERSION = 1;
|
|
12
|
-
/** Every
|
|
12
|
+
/** Every row's remote policy, by invocation. Exported for the contract test, which holds it to the guide exactly. */
|
|
13
|
+
export declare const REMOTE_POLICY: ReadonlyMap<string, RemotePolicy>;
|
|
14
|
+
/** How many times each invocation was classified — the contract test refuses a row stated twice. */
|
|
15
|
+
export declare const REMOTE_POLICY_COUNT: number;
|
|
16
|
+
/** Every declared command, in guide order, paired with the schema of its `--json` answer (src/contracts/cli.ts).
|
|
17
|
+
* An unclassified row is "no": nothing runs remotely by omission. */
|
|
13
18
|
export declare const COMMAND_ENTRIES: readonly import("./contracts/cli.js").CommandEntry[];
|
|
19
|
+
/** The invocations the guide states, for the contract test (a classification naming none of them is stale). */
|
|
20
|
+
export declare const GUIDE_INVOCATIONS: readonly string[];
|
|
14
21
|
/** The guide `contract --commands` dumps: each declared command's row, projected from COMMAND_ENTRIES. */
|
|
15
22
|
export declare const COMMAND_GUIDE: readonly CommandRow[];
|
package/dist/surface.js
CHANGED
|
@@ -125,7 +125,7 @@ const GUIDE_ROWS = [
|
|
|
125
125
|
...MEMORY_DESCRIPTORS.map(spec => ({ invocation: `memory ${spec.action}`, synopsis: spec.synopsis, audience: "agent", agentMayInvoke: true, mutation: spec.mutation, flags: spec.flags, ...(spec.takesQuery ? { positionals: [{ name: "query", required: true, meaning: "search text, a decision id, or the decision sentence" }] } : {}) })),
|
|
126
126
|
// Flow writes carry an approver's credential: an agent runs them only as the person asked, after showing the preview.
|
|
127
127
|
...FLOWS_DESCRIPTORS.map(spec => ({ invocation: `flows ${spec.action}`, synopsis: spec.synopsis, audience: "agent", agentMayInvoke: true, mutation: spec.mutation, flags: spec.flags,
|
|
128
|
-
...("positionals" in spec ? { positionals: spec.positionals.map(name => ({ name, required: true, meaning: name === "flow" ? "a flow id from flows list" : "a trigger id from flows show, or for trigger add its settings as JSON, a JSON file, or -" })) } : {}) })),
|
|
128
|
+
...("positionals" in spec ? { positionals: spec.positionals.map(name => ({ name, required: true, meaning: name === "flow" ? "a flow id from flows list" : name === "card" ? "a card id from flows show" : "a trigger id from flows show, or for trigger add its settings as JSON, a JSON file, or -" })) } : {}) })),
|
|
129
129
|
...MODELS_DESCRIPTORS.map(spec => ({ invocation: `models ${spec.action}`, synopsis: spec.synopsis, audience: "agent", agentMayInvoke: true, mutation: spec.mutation, flags: spec.flags, ...(spec.takesQuery ? { positionals: [{ name: "target", required: true, meaning: "the CLI to update, or on/off" }] } : {}) })),
|
|
130
130
|
// ---- the queue (agent surface) ----
|
|
131
131
|
{ invocation: "status", synopsis: "running work, queued reasons, results to review, the latest release check and plan windows", audience: "agent", agentMayInvoke: true, mutation: "none", flags: [jsonFlag, dbFlag] },
|
|
@@ -384,6 +384,9 @@ const GUIDE_ROWS = [
|
|
|
384
384
|
operatorRead("review show", "whether a project's finished builds get one automatic review"),
|
|
385
385
|
operator("review on", "one automatic review of each finished build whose check passes; only HIGH findings send it back, once"),
|
|
386
386
|
operator("review off", "finished builds go straight to the person, unreviewed"),
|
|
387
|
+
operatorRead("chat-approval show", "whether your own taps in your paired chat approve plans and merges: all projects and each project's own setting"),
|
|
388
|
+
operator("chat-approval on", "let your paired chat approve plans and merges in all your projects or one (--repo); lasting, with limits; previews until --yes"),
|
|
389
|
+
operator("chat-approval off", "stop approving from chat for all your projects or one (--repo); one step"),
|
|
387
390
|
operator("mode revoke", "revoke a repository's mode — one click, every act it covered falls back to its own ceremony"),
|
|
388
391
|
operator("config set", "set phase or spend configuration"),
|
|
389
392
|
operator("config clear", "clear phase or spend configuration"),
|
|
@@ -413,7 +416,72 @@ const GUIDE_ROWS = [
|
|
|
413
416
|
operator("onboard", "add this repository, report signed-in agents, install or --remove the operator skill (--yes), offer the starter flows (--starter <ids>), and print the handoff"),
|
|
414
417
|
operator("daemon", "install the loop under the OS service manager"),
|
|
415
418
|
];
|
|
416
|
-
/**
|
|
417
|
-
|
|
419
|
+
/**
|
|
420
|
+
* Whether each row may run on a central server for a person signed in with their own API token (`runOperateAs` in
|
|
421
|
+
* operate.ts), stated for every row by name. "no" is this machine's own infrastructure, credentials, files and the
|
|
422
|
+
* commands that only make sense beside a checkout (or that the local CLI answers itself); "step-up" is approvals,
|
|
423
|
+
* people and policy, which stay in the console and chat; "yes" runs as the person, within their token's scope and
|
|
424
|
+
* project access. A row missing here fails the contract test, and runs nowhere remotely meanwhile.
|
|
425
|
+
*/
|
|
426
|
+
const REMOTE_NO = [
|
|
427
|
+
// Answered by the local CLI, or a client of a central server itself.
|
|
428
|
+
"", "pulls", "graph", "repos", "repos add", "repos remove", "repos add-from-github", "contract", "skills list", "skills get", "skills install",
|
|
429
|
+
"link", "unlink", "update", "demo", "chat", "connect", "lead list", "lead create", "lead update", "lead member", "lead transfer",
|
|
430
|
+
"conversation list", "conversation create", "conversation show", "conversation member", "conversation edit", "conversation withdraw",
|
|
431
|
+
"conversation read", "conversation follow", "conversation stop",
|
|
432
|
+
"session capabilities", "session list", "session show", "session changes", "session start", "session send", "session stop", "session resume", "session recover",
|
|
433
|
+
// This machine's checkout, saved profile, credentials and catch-up.
|
|
434
|
+
"project show", "project use", "brief", "lead token", "lead say",
|
|
435
|
+
"assignment show", "assignment updates", "assignment claim", "assignment check", "assignment brief", "assignment inbox", "assignment ack",
|
|
436
|
+
// Workers, the loop and the services that run here.
|
|
437
|
+
"claim", "heartbeat", "release", "reap", "tick", "build", "reconcile",
|
|
438
|
+
"runner register", "runner retire", "runner bind", "runner capacity", "coordinator mint", "coordinator revoke", "mcp", "outbox deliver", "peek",
|
|
439
|
+
"serve", "watch", "up", "onboard", "daemon", "bridge", "setup show", "setup clear",
|
|
440
|
+
// Provider keys and model tools on this machine.
|
|
441
|
+
"providers", "keys status", "keys set", "keys clear", "keys verify", "keys auth", "models check", "models update", "models watch",
|
|
442
|
+
// Publication and release.
|
|
443
|
+
"publish", "publish status", "publish merge", "publish refire", "publish unblock", "publish rearm", "publish grant", "publish revoke",
|
|
444
|
+
// A repository's authority grants on this machine.
|
|
445
|
+
"enroll", "revoke",
|
|
446
|
+
// Flow steps that fetch addresses or run checks and scripts here.
|
|
447
|
+
"flows import", "flows trigger check", "flows script save",
|
|
448
|
+
// Reads this machine owner's own Claude and Codex session files (~/.claude, ~/.codex).
|
|
449
|
+
"memory propose",
|
|
450
|
+
];
|
|
451
|
+
const REMOTE_STEP_UP = [
|
|
452
|
+
"proposals", "decide", "task regate", "task approve", "task accept", "task merge", "run settle", "routine approve", "knowledge apply", "memory apply",
|
|
453
|
+
"flows card approve", "flows card send-back", "flows script approve",
|
|
454
|
+
"approver list", "approver add", "people list", "people invite", "people projects", "people revoke",
|
|
455
|
+
"mode show", "mode set", "mode revoke", "chat-approval on", "review on", "review off", "config set", "config clear",
|
|
456
|
+
"verify set", "verify clear", "verify level", "project checks", "project concurrency", "intake grant", "intake clear", "webhook primary",
|
|
457
|
+
];
|
|
458
|
+
const REMOTE_YES = [
|
|
459
|
+
"status", "integrations", "ready", "gaps", "grants", "sync",
|
|
460
|
+
"task add", "task ask", "task checks", "task add-tests", "task list", "task show", "check-progress", "task wait", "task complete", "task revise",
|
|
461
|
+
"task state", "task block", "task unblock", "task next", "task steer", "task assign", "task scope", "task plan", "task hold", "task unhold", "task require",
|
|
462
|
+
"task requeue", "task review", "task repair", "task route", "task reopen", "task stop", "task resume",
|
|
463
|
+
"runner list", "coordinator list", "cap list", "cap add", "outbox list", "incident list", "incident resolve",
|
|
464
|
+
"routine list", "routine show", "routine add", "routine refresh", "routine pause", "routine resume", "routine run-now",
|
|
465
|
+
"config show", "verify show", "intake show", "intake run", "intake preview", "intake pr-comments", "template list", "template show",
|
|
466
|
+
"contest show", "contest exclude", "webhook status", "webhook test", "review show", "chat-approval show", "chat-approval off",
|
|
467
|
+
"knowledge search", "knowledge impact", "knowledge refresh",
|
|
468
|
+
"memory search", "memory decisions", "memory show", "memory decide", "memory retire", "memory review", "memory status",
|
|
469
|
+
"flows list", "flows show", "flows export", "flows create", "flows edit", "flows trigger add", "flows trigger pause", "flows trigger resume",
|
|
470
|
+
"flows trigger remove", "flows card add", "flows archive",
|
|
471
|
+
"models status", "models list",
|
|
472
|
+
];
|
|
473
|
+
/** Every row's remote policy, by invocation. Exported for the contract test, which holds it to the guide exactly. */
|
|
474
|
+
export const REMOTE_POLICY = new Map([
|
|
475
|
+
...REMOTE_NO.map(invocation => [invocation, "no"]),
|
|
476
|
+
...REMOTE_STEP_UP.map(invocation => [invocation, "step-up"]),
|
|
477
|
+
...REMOTE_YES.map(invocation => [invocation, "yes"]),
|
|
478
|
+
]);
|
|
479
|
+
/** How many times each invocation was classified — the contract test refuses a row stated twice. */
|
|
480
|
+
export const REMOTE_POLICY_COUNT = REMOTE_NO.length + REMOTE_STEP_UP.length + REMOTE_YES.length;
|
|
481
|
+
/** Every declared command, in guide order, paired with the schema of its `--json` answer (src/contracts/cli.ts).
|
|
482
|
+
* An unclassified row is "no": nothing runs remotely by omission. */
|
|
483
|
+
export const COMMAND_ENTRIES = commandEntries(GUIDE_ROWS.map(row => ({ ...row, remote: REMOTE_POLICY.get(row.invocation) ?? "no" })));
|
|
484
|
+
/** The invocations the guide states, for the contract test (a classification naming none of them is stale). */
|
|
485
|
+
export const GUIDE_INVOCATIONS = GUIDE_ROWS.map(row => row.invocation);
|
|
418
486
|
/** The guide `contract --commands` dumps: each declared command's row, projected from COMMAND_ENTRIES. */
|
|
419
487
|
export const COMMAND_GUIDE = COMMAND_ENTRIES.map(entry => entry.guide);
|
package/dist/team-cli.d.ts
CHANGED
|
@@ -11,5 +11,24 @@ export type TeamCliOptions = {
|
|
|
11
11
|
sleep?: (milliseconds: number, signal: AbortSignal) => Promise<void>;
|
|
12
12
|
};
|
|
13
13
|
export declare const TEAM_CLI_ACTIONS: readonly ['connect', 'lead list', 'lead create', 'lead update', 'lead member', 'lead transfer', 'conversation list', 'conversation create', 'conversation show', 'conversation member', 'conversation edit', 'conversation withdraw', 'conversation read', 'conversation follow', 'conversation stop'];
|
|
14
|
+
export declare class UsageError extends Error {
|
|
15
|
+
}
|
|
16
|
+
type Profile = {
|
|
17
|
+
origin: string;
|
|
18
|
+
account: string;
|
|
19
|
+
token: string;
|
|
20
|
+
};
|
|
21
|
+
/** An API token (so_…) is sent bare; an older saved sign-in keeps its account:secret form. */
|
|
22
|
+
export declare const bearerOf: (profile: Profile) => string;
|
|
23
|
+
/** The saved central profile remote commands use: the named one (or the active one), and whether it holds an API token.
|
|
24
|
+
* null when nothing is saved. Reads only the private profile file — never a local login or the database. */
|
|
25
|
+
export declare function centralProfile(options: TeamCliOptions, name?: string): {
|
|
26
|
+
name: string;
|
|
27
|
+
origin: string;
|
|
28
|
+
account: string;
|
|
29
|
+
token: string;
|
|
30
|
+
apiToken: boolean;
|
|
31
|
+
} | null;
|
|
14
32
|
/** null means preserve the existing local command path. Nothing here opens a database. */
|
|
15
33
|
export declare function maybeRunTeamCommand(argv: readonly string[], write: (line: string) => void, options?: TeamCliOptions): Promise<number | null>;
|
|
34
|
+
export {};
|
package/dist/team-cli.js
CHANGED
|
@@ -8,9 +8,12 @@ import { envelopeJson } from './envelope.js';
|
|
|
8
8
|
import { sessionServiceOrigin } from './session-cli.js';
|
|
9
9
|
import { databasePath } from './store.js';
|
|
10
10
|
import { configBase, namedPath } from './names.js';
|
|
11
|
+
import { parseApiToken } from './api-tokens.js';
|
|
11
12
|
export const TEAM_CLI_ACTIONS = ['connect', 'lead list', 'lead create', 'lead update', 'lead member', 'lead transfer', 'conversation list', 'conversation create', 'conversation show', 'conversation member', 'conversation edit', 'conversation withdraw', 'conversation read', 'conversation follow', 'conversation stop'];
|
|
12
13
|
const HELP = `toolroll connect <HTTPS-origin> --as <account> --token-stdin
|
|
13
|
-
|
|
14
|
+
Pipe your API token (so_…, from Settings → API tokens) or use --token-file <private-file>;
|
|
15
|
+
--local-login uses your existing local sign-in for chat only.
|
|
16
|
+
With an API token saved, other commands run on that server as you (--local runs here).
|
|
14
17
|
Optional: --profile <name> (saved as the active profile).
|
|
15
18
|
toolroll lead list|create|update|member|transfer [--profile <name>] [--json]
|
|
16
19
|
create: --name <name> --instructions <text> --project <server-path> (repeatable)
|
|
@@ -36,7 +39,7 @@ toolroll brief --lead <id> --conversation <id> [--json]
|
|
|
36
39
|
Saved profiles select the central service for chat and brief. Use --local for the
|
|
37
40
|
existing local commands. Credentials stay in a private file, never in URLs or arguments.
|
|
38
41
|
Reads and attachment do not start model work. Server permissions remain authoritative.`;
|
|
39
|
-
class UsageError extends Error {
|
|
42
|
+
export class UsageError extends Error {
|
|
40
43
|
}
|
|
41
44
|
const value = (flags, name) => typeof flags[name] === 'string' ? flags[name] : '';
|
|
42
45
|
const required = (flags, name) => { const result = value(flags, name); if (!result)
|
|
@@ -128,6 +131,20 @@ function saveProfile(options, name, profile) {
|
|
|
128
131
|
unlinkSync(temporary);
|
|
129
132
|
}
|
|
130
133
|
}
|
|
134
|
+
/** An API token (so_…) is sent bare; an older saved sign-in keeps its account:secret form. */
|
|
135
|
+
export const bearerOf = (profile) => parseApiToken(profile.token) !== null ? `Bearer ${profile.token}` : `Bearer ${profile.account}:${profile.token}`;
|
|
136
|
+
/** The saved central profile remote commands use: the named one (or the active one), and whether it holds an API token.
|
|
137
|
+
* null when nothing is saved. Reads only the private profile file — never a local login or the database. */
|
|
138
|
+
export function centralProfile(options, name) {
|
|
139
|
+
const saved = profiles(options);
|
|
140
|
+
if (saved === null)
|
|
141
|
+
return null;
|
|
142
|
+
const chosen = name ?? saved.active;
|
|
143
|
+
if (!/^[A-Za-z0-9_-]{1,64}$/.test(chosen) || !Object.hasOwn(saved.profiles, chosen))
|
|
144
|
+
throw new UsageError(`No saved connection named ${chosen}. Use connect with your API token first.`);
|
|
145
|
+
const profile = saved.profiles[chosen];
|
|
146
|
+
return { name: chosen, ...profile, apiToken: parseApiToken(profile.token) !== null };
|
|
147
|
+
}
|
|
131
148
|
async function stdinSecret() {
|
|
132
149
|
if (process.stdin.isTTY)
|
|
133
150
|
throw new UsageError('Pipe your sign-in secret into --token-stdin, or use --token-file.');
|
|
@@ -229,7 +246,7 @@ async function perform(profile, request, options) {
|
|
|
229
246
|
const reading = ['list', 'show'].includes(request.operation);
|
|
230
247
|
const unconfirmed = () => failure(reading ? 'service-unavailable' : 'delivery-unconfirmed', reading ? 'The service response could not be read.' : 'The response could not be confirmed. Inspect saved messages before continuing; this request was not retried.', { operation: request.operation, ...(request.args.conversationId ? { conversationId: request.args.conversationId } : {}), ...(request.args.requestId ? { requestId: request.args.requestId } : {}) });
|
|
231
248
|
try {
|
|
232
|
-
const response = await (options.fetch ?? fetch)(`${profile.origin}/api/team`, { method: 'POST', redirect: 'manual', credentials: 'omit', signal: options.signal ? AbortSignal.any([options.signal, AbortSignal.timeout(30_000)]) : AbortSignal.timeout(30_000), headers: { authorization:
|
|
249
|
+
const response = await (options.fetch ?? fetch)(`${profile.origin}/api/team`, { method: 'POST', redirect: 'manual', credentials: 'omit', signal: options.signal ? AbortSignal.any([options.signal, AbortSignal.timeout(30_000)]) : AbortSignal.timeout(30_000), headers: { authorization: bearerOf(profile), 'content-type': 'application/json', accept: 'application/json' }, body: JSON.stringify(request) });
|
|
233
250
|
if (response.status >= 300 && response.status < 400) {
|
|
234
251
|
await response.body?.cancel();
|
|
235
252
|
return unconfirmed();
|
package/dist/teammate-work.js
CHANGED
|
@@ -206,7 +206,7 @@ function carryOut(store, flow, definition, stage, card, mate, context, answer, n
|
|
|
206
206
|
if (answer.action === "approve" || answer.action === "send_back") {
|
|
207
207
|
const draft = draftFor(definition, stage);
|
|
208
208
|
const decided = decideFlowCard(store, {
|
|
209
|
-
card: card.id, decision: answer.action === "approve" ? "approve" : "send-back", note: answer.action === "approve" ? answer.reason || null : note(answer.note), actor, repos, teammate: mate.handle, entry: card.entry,
|
|
209
|
+
card: card.id, decision: answer.action === "approve" ? "approve" : "send-back", note: answer.action === "approve" ? answer.reason || null : note(answer.note), actor, repos, teammate: mate.handle, entry: card.entry, where: "a teammate",
|
|
210
210
|
...(answer.action === "approve" && draft !== null && answer.text !== "" ? { draft: wholeDraft(answer.text) } : {}),
|
|
211
211
|
...(io.evidenceRoot === undefined ? {} : { evidenceRoot: io.evidenceRoot }),
|
|
212
212
|
}, now);
|
package/dist/telegram-flow.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { type FlowChoiceVisit, type FlowSendContent } from "./flow-send.js";
|
|
|
2
2
|
import { type FlowStage } from "./flows.js";
|
|
3
3
|
import { type InlineButton } from "./telegram-mate.js";
|
|
4
4
|
import type { FlowCardRow, FlowRow, Store, TelegramBinding, TelegramFlowAction, TelegramFlowChoice, TelegramFlowPrompt } from "./store.js";
|
|
5
|
+
/** A flow decision's notice: one per visit, and again (`:ask:<ms>`) when the owner asks the lead to approve it. */
|
|
5
6
|
export declare const FLOW_DECIDE_KEY: RegExp;
|
|
6
7
|
type Waiting = {
|
|
7
8
|
card: FlowCardRow;
|
|
@@ -41,21 +42,60 @@ export declare function applyFlowChoiceTap(store: Store, binding: TelegramBindin
|
|
|
41
42
|
export type FlowTapEffect = {
|
|
42
43
|
kind: "ack";
|
|
43
44
|
text: string;
|
|
44
|
-
}
|
|
45
|
+
}
|
|
46
|
+
/** The message repainted; `keyboard` (absent: none) replaces its buttons, and links are paths under the console. */
|
|
47
|
+
| {
|
|
45
48
|
kind: "edit";
|
|
46
49
|
text: string;
|
|
50
|
+
keyboard?: FlowKeyboard;
|
|
51
|
+
place?: string[];
|
|
47
52
|
} | {
|
|
48
53
|
kind: "prompt";
|
|
49
54
|
text: string;
|
|
50
55
|
placeholder: string;
|
|
51
56
|
prompt: Omit<TelegramFlowPrompt, "messageId">;
|
|
52
57
|
};
|
|
58
|
+
/** A repainted message's buttons: a token, or a link to a console path. `place` names fresh flow buttons to stamp on the message. */
|
|
59
|
+
export type FlowKeyboard = Array<Array<{
|
|
60
|
+
label: string;
|
|
61
|
+
token: string;
|
|
62
|
+
} | {
|
|
63
|
+
label: string;
|
|
64
|
+
link: {
|
|
65
|
+
label: string;
|
|
66
|
+
path: string;
|
|
67
|
+
};
|
|
68
|
+
}>>;
|
|
69
|
+
/** Where a card is now, for a message whose buttons no longer act: named and linked only when it is in one of the
|
|
70
|
+
* viewer's projects, and linked only while it still waits on someone. Never a live button. */
|
|
71
|
+
export declare function flowWhereNow(store: Store, cardId: number, repos: readonly string[] | null): {
|
|
72
|
+
said: string;
|
|
73
|
+
keyboard: FlowKeyboard;
|
|
74
|
+
};
|
|
53
75
|
/** A tapped flow button, applied inside the update's transaction. */
|
|
54
76
|
export declare function applyFlowTap(store: Store, binding: TelegramBinding, action: TelegramFlowAction, message: {
|
|
55
77
|
chatId: string;
|
|
56
78
|
messageId: string;
|
|
57
79
|
text: string;
|
|
58
80
|
}, repos: readonly string[] | null, now: Date): FlowTapEffect[];
|
|
81
|
+
export type FlowConfirm = {
|
|
82
|
+
token: string;
|
|
83
|
+
binding: number;
|
|
84
|
+
chatId: string;
|
|
85
|
+
messageId: string;
|
|
86
|
+
card: number;
|
|
87
|
+
entry: number;
|
|
88
|
+
phase: "yes" | "cancel";
|
|
89
|
+
expiresAt: string;
|
|
90
|
+
consumedAt: string | null;
|
|
91
|
+
};
|
|
92
|
+
/** A Yes or Cancel this module armed, or null when the token isn't one. */
|
|
93
|
+
export declare function flowConfirmOf(store: Store, token: string): FlowConfirm | null;
|
|
94
|
+
/** The second tap, inside the update's transaction: Yes decides the card through the console's door, once; Cancel puts
|
|
95
|
+
* the card's own buttons back. The caller has proved the binding, chat and message are the ones it was armed on. */
|
|
96
|
+
export declare function applyFlowConfirm(store: Store, binding: TelegramBinding, confirm: FlowConfirm, message: {
|
|
97
|
+
text: string;
|
|
98
|
+
}, repos: readonly string[] | null, now: Date): FlowTapEffect[];
|
|
59
99
|
export type FlowReplyEffect = {
|
|
60
100
|
kind: "say";
|
|
61
101
|
text: string;
|
package/dist/telegram-flow.js
CHANGED
|
@@ -22,12 +22,13 @@
|
|
|
22
22
|
import { randomBytes } from "node:crypto";
|
|
23
23
|
import { LIMITS } from "./decision.js";
|
|
24
24
|
import { keptDraft } from "./flow-draft.js";
|
|
25
|
-
import { decideFlowCard, draftFor, flowDefinitionOf } from "./flow-engine.js";
|
|
26
|
-
import { chooseFlowCard, flowChoiceAt, flowSendPaths, readFlowSend } from "./flow-send.js";
|
|
25
|
+
import { decideFlowCard, draftFor, flowCardHref, flowDefinitionOf } from "./flow-engine.js";
|
|
26
|
+
import { chooseFlowCard, flowChoiceAt, flowPersonOf, flowSendPaths, readFlowSend } from "./flow-send.js";
|
|
27
27
|
import { deciderOf, FLOW_END, replyTarget } from "./flows.js";
|
|
28
28
|
import { phoneLinkButton } from "./telegram-mate.js";
|
|
29
29
|
import { telegramButton } from "./contracts/telegram-callback.js";
|
|
30
|
-
|
|
30
|
+
/** A flow decision's notice: one per visit, and again (`:ask:<ms>`) when the owner asks the lead to approve it. */
|
|
31
|
+
export const FLOW_DECIDE_KEY = /^flow-decide:([1-9][0-9]{0,14}):([1-9][0-9]{0,9})(?::ask:[0-9]{1,15})?$/;
|
|
31
32
|
const DRAFT_LIMIT = 4000;
|
|
32
33
|
/** The card, if it is still at that visit, waiting at a decision. */
|
|
33
34
|
export function flowDecisionAt(store, cardId, entry) {
|
|
@@ -126,24 +127,66 @@ export function applyFlowChoiceTap(store, binding, choice, message, repos, now)
|
|
|
126
127
|
const said = chosen.said.startsWith(`${choice.label}.`) ? chosen.said : `You chose “${choice.label}”. ${chosen.said}`;
|
|
127
128
|
return [{ kind: "ack", text: choice.label.slice(0, 190) }, { kind: "edit", text: `${message.text}\n\n✅ ${said}`.slice(0, 4000) }];
|
|
128
129
|
}
|
|
130
|
+
/** A card's own fresh buttons, as a repaint carries them. */
|
|
131
|
+
function freshButtons(store, binding, waiting, now) {
|
|
132
|
+
const minted = flowButtons(store, binding, waiting, now);
|
|
133
|
+
return { keyboard: minted.keyboard.map(row => row.flatMap(one => "callback_data" in one && typeof one.callback_data === "string" ? [{ label: one.text, token: one.callback_data }] : [])), place: minted.tokens };
|
|
134
|
+
}
|
|
135
|
+
const CONFIRM_TTL_MS = 10 * 60_000;
|
|
136
|
+
const FLOW_QUESTION = /\n\nApprove “[^\n]*”\? [^\n]*$/;
|
|
137
|
+
/** Where a card is now, for a message whose buttons no longer act: named and linked only when it is in one of the
|
|
138
|
+
* viewer's projects, and linked only while it still waits on someone. Never a live button. */
|
|
139
|
+
export function flowWhereNow(store, cardId, repos) {
|
|
140
|
+
const card = store.getFlowCard(cardId);
|
|
141
|
+
const flow = card === null ? null : store.getFlow(card.flow);
|
|
142
|
+
if (card === null || flow === null || repos === null || !repos.includes(flow.repo))
|
|
143
|
+
return { said: "This card has moved on since.", keyboard: [] };
|
|
144
|
+
if (card.state !== "active")
|
|
145
|
+
return { said: card.state === "done" ? "This card is done." : "This card was closed.", keyboard: [] };
|
|
146
|
+
const definition = flowDefinitionOf(flow);
|
|
147
|
+
const stage = definition?.stages.find(one => one.id === card.stage);
|
|
148
|
+
const where = stage?.title ?? card.stage;
|
|
149
|
+
const open = { label: "Open it in Flows", path: flowCardHref(flow.id, card.id) };
|
|
150
|
+
if (stage?.kind === "approval" || stage?.kind === "choose") {
|
|
151
|
+
const person = stage.kind === "approval" ? deciderOf(stage, flow) ?? "an approver" : flowPersonOf(card, flow);
|
|
152
|
+
return { said: `It's waiting for ${person} in ${where} (${flow.name}).`, keyboard: [[{ label: open.label, link: open }]] };
|
|
153
|
+
}
|
|
154
|
+
if (card.waiting !== null)
|
|
155
|
+
return { said: `It's in ${where} now (${flow.name}): ${card.waiting.replace(/\.$/, "")}.`, keyboard: [[{ label: open.label, link: open }]] };
|
|
156
|
+
return { said: `It moved on to ${where} (${flow.name}).`, keyboard: [] };
|
|
157
|
+
}
|
|
129
158
|
/** A tapped flow button, applied inside the update's transaction. */
|
|
130
159
|
export function applyFlowTap(store, binding, action, message, repos, now) {
|
|
131
|
-
if (action.consumedAt !== null || action.expiresAt <= now.toISOString())
|
|
132
|
-
|
|
160
|
+
if (action.consumedAt !== null || action.expiresAt <= now.toISOString()) {
|
|
161
|
+
// Spent or too old: say where the card is now, with a way to it while it still waits. The old button never acts again.
|
|
162
|
+
const now_ = flowWhereNow(store, action.card, repos);
|
|
163
|
+
return [{ kind: "ack", text: "These buttons were already used, or are too old." }, { kind: "edit", text: `${message.text.replace(FLOW_QUESTION, "")}\n\nThese buttons were already used, or are too old. ${now_.said}`.slice(0, 4000), keyboard: now_.keyboard }];
|
|
164
|
+
}
|
|
133
165
|
const waiting = flowDecisionAt(store, action.card, action.entry);
|
|
134
|
-
if (waiting === null)
|
|
135
|
-
|
|
166
|
+
if (waiting === null) {
|
|
167
|
+
const now_ = flowWhereNow(store, action.card, repos);
|
|
168
|
+
store.retireTelegramFlowVisit(action.card, action.entry, now);
|
|
169
|
+
return [{ kind: "ack", text: "That card has moved on since; nothing was changed." }, { kind: "edit", text: `${message.text}\n\nNothing was changed. ${now_.said}`.slice(0, 4000), keyboard: now_.keyboard }];
|
|
170
|
+
}
|
|
136
171
|
const decider = deciderOf(waiting.stage, waiting.flow);
|
|
137
172
|
if (decider !== null && decider !== binding.approver)
|
|
138
173
|
return [{ kind: "ack", text: `Only ${decider} decides here.` }];
|
|
139
174
|
if (action.action === "approve") {
|
|
175
|
+
// The first tap asks; only the Yes decides, bound to exactly this visit of this card, on this message.
|
|
140
176
|
if (repos === null)
|
|
141
177
|
return [{ kind: "ack", text: "Couldn't check your projects just now. Try again in a moment." }];
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
return [{ kind: "ack", text: decided.message.slice(0, 190) }];
|
|
178
|
+
if (!repos.includes(waiting.flow.repo))
|
|
179
|
+
return [{ kind: "ack", text: "That card isn't in one of your projects now." }];
|
|
145
180
|
store.retireTelegramFlowVisit(action.card, action.entry, now);
|
|
146
|
-
|
|
181
|
+
const mint = (phase) => {
|
|
182
|
+
const token = randomBytes(16).toString("hex");
|
|
183
|
+
store.handle.prepare("INSERT INTO telegram_flow_confirm (token, binding, chat_id, message_id, card, entry, phase, created_at, expires_at) VALUES (?,?,?,?,?,?,?,?,?)")
|
|
184
|
+
.run(token, binding.id, binding.chatId, message.messageId, action.card, action.entry, phase, now.toISOString(), new Date(now.getTime() + CONFIRM_TTL_MS).toISOString());
|
|
185
|
+
return token;
|
|
186
|
+
};
|
|
187
|
+
const where = waiting.stage.next === null ? "The card is then done." : `It moves to ${titleOf(waiting, waiting.stage.next)}.`;
|
|
188
|
+
return [{ kind: "ack", text: "Confirm below." }, { kind: "edit", text: `${message.text}\n\nApprove “${waiting.card.title}”? ${where}`.slice(0, 4000),
|
|
189
|
+
keyboard: [[{ label: "Yes", token: mint("yes") }, { label: "Cancel", token: mint("cancel") }]] }];
|
|
147
190
|
}
|
|
148
191
|
const prompt = { chatId: binding.chatId, binding: binding.id, card: action.card, entry: action.entry, mode: action.action };
|
|
149
192
|
if (action.action === "edit") {
|
|
@@ -154,6 +197,47 @@ export function applyFlowTap(store, binding, action, message, repos, now) {
|
|
|
154
197
|
return [{ kind: "ack", text: "What should change?" }, { kind: "prompt", prompt, placeholder: "What should change",
|
|
155
198
|
text: `What should change on “${waiting.card.title}”? Reply to this message and it goes back to ${back} with your note.` }];
|
|
156
199
|
}
|
|
200
|
+
/** A Yes or Cancel this module armed, or null when the token isn't one. */
|
|
201
|
+
export function flowConfirmOf(store, token) {
|
|
202
|
+
if (!/^[0-9a-f]{32}$/.test(token))
|
|
203
|
+
return null;
|
|
204
|
+
const row = store.handle.prepare("SELECT * FROM telegram_flow_confirm WHERE token = ?").get(token);
|
|
205
|
+
if (row === undefined)
|
|
206
|
+
return null;
|
|
207
|
+
return { token: String(row["token"]), binding: Number(row["binding"]), chatId: String(row["chat_id"]), messageId: String(row["message_id"]), card: Number(row["card"]), entry: Number(row["entry"]),
|
|
208
|
+
phase: String(row["phase"]) === "cancel" ? "cancel" : "yes", expiresAt: String(row["expires_at"]), consumedAt: row["consumed_at"] == null ? null : String(row["consumed_at"]) };
|
|
209
|
+
}
|
|
210
|
+
/** The second tap, inside the update's transaction: Yes decides the card through the console's door, once; Cancel puts
|
|
211
|
+
* the card's own buttons back. The caller has proved the binding, chat and message are the ones it was armed on. */
|
|
212
|
+
export function applyFlowConfirm(store, binding, confirm, message, repos, now) {
|
|
213
|
+
const body = message.text.replace(FLOW_QUESTION, "");
|
|
214
|
+
const spend = () => store.handle.prepare("UPDATE telegram_flow_confirm SET consumed_at = ? WHERE card = ? AND entry = ? AND message_id = ? AND consumed_at IS NULL").run(now.toISOString(), confirm.card, confirm.entry, confirm.messageId);
|
|
215
|
+
if (confirm.consumedAt !== null || confirm.expiresAt <= now.toISOString()) {
|
|
216
|
+
spend();
|
|
217
|
+
const now_ = flowWhereNow(store, confirm.card, repos);
|
|
218
|
+
const waiting = flowDecisionAt(store, confirm.card, confirm.entry);
|
|
219
|
+
// Too old, but the same visit still waits: fresh buttons, never the old Yes.
|
|
220
|
+
if (waiting !== null && repos !== null && repos.includes(waiting.flow.repo)) {
|
|
221
|
+
return [{ kind: "ack", text: "That Yes expired. Nothing was done." }, { kind: "edit", text: body, ...freshButtons(store, binding, waiting, now) }];
|
|
222
|
+
}
|
|
223
|
+
return [{ kind: "ack", text: "That Yes expired. Nothing was done." }, { kind: "edit", text: `${body}\n\nNothing was done. ${now_.said}`.slice(0, 4000), keyboard: now_.keyboard }];
|
|
224
|
+
}
|
|
225
|
+
spend();
|
|
226
|
+
const waiting = flowDecisionAt(store, confirm.card, confirm.entry);
|
|
227
|
+
if (waiting === null || repos === null) {
|
|
228
|
+
const now_ = flowWhereNow(store, confirm.card, repos);
|
|
229
|
+
return [{ kind: "ack", text: repos === null ? "Couldn't check your projects just now. Try again in a moment." : "That card has moved on since; nothing was changed." },
|
|
230
|
+
{ kind: "edit", text: `${body}\n\nNothing was changed. ${now_.said}`.slice(0, 4000), keyboard: now_.keyboard }];
|
|
231
|
+
}
|
|
232
|
+
if (confirm.phase === "cancel") {
|
|
233
|
+
return [{ kind: "ack", text: "Cancelled." }, { kind: "edit", text: body, ...freshButtons(store, binding, waiting, now) }];
|
|
234
|
+
}
|
|
235
|
+
const decided = decideFlowCard(store, { card: waiting.card.id, decision: "approve", note: null, actor: binding.approver, repos, entry: confirm.entry, where: "Telegram" }, now);
|
|
236
|
+
if (!decided.ok)
|
|
237
|
+
return [{ kind: "ack", text: "Not done." }, { kind: "edit", text: `${body}\n\n✗ Not done: ${decided.message}`.slice(0, 4000), keyboard: flowWhereNow(store, confirm.card, repos).keyboard }];
|
|
238
|
+
store.retireTelegramFlowVisit(confirm.card, confirm.entry, now);
|
|
239
|
+
return [{ kind: "ack", text: "Approved" }, { kind: "edit", text: `${body}\n\n✅ ${decided.said}`.slice(0, 4000) }];
|
|
240
|
+
}
|
|
157
241
|
const titleOf = (waiting, id) => flowDefinitionOf(waiting.flow)?.stages.find(one => one.id === id)?.title ?? id;
|
|
158
242
|
/** A reply to an Edit or Send back prompt, applied inside the update's transaction. */
|
|
159
243
|
export function applyFlowReply(store, binding, prompt, text, repos, now) {
|
|
@@ -200,7 +284,7 @@ export function applyFlowReply(store, binding, prompt, text, repos, now) {
|
|
|
200
284
|
return [{ kind: "say", text: `That's ${said.length.toLocaleString("en-US")} characters. Keep the note to ${LIMITS.note.toLocaleString("en-US")}, or send it back in Toolroll.` }];
|
|
201
285
|
if (repos === null)
|
|
202
286
|
return [{ kind: "say", text: "Couldn't check your projects just now. Reply again in a moment." }];
|
|
203
|
-
const decided = decideFlowCard(store, { card: waiting.card.id, decision: "send-back", note: said, actor: binding.approver, repos, entry: prompt.entry }, now);
|
|
287
|
+
const decided = decideFlowCard(store, { card: waiting.card.id, decision: "send-back", note: said, actor: binding.approver, repos, entry: prompt.entry, where: "Telegram" }, now);
|
|
204
288
|
if (!decided.ok)
|
|
205
289
|
return [{ kind: "say", text: decided.message }];
|
|
206
290
|
store.retireTelegramFlowVisit(prompt.card, prompt.entry, now);
|