toolroll 0.9.86 → 0.9.88
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 +86 -0
- package/dist/acp-check.d.ts +9 -0
- package/dist/acp-check.js +40 -28
- package/dist/agents-board.d.ts +12 -0
- package/dist/agents-board.js +26 -0
- package/dist/approval-rules-ui.d.ts +4 -1
- package/dist/approval-rules-ui.js +31 -15
- package/dist/assignment-ui.js +1 -1
- package/dist/backup-ui.d.ts +5 -4
- package/dist/backup-ui.js +65 -32
- package/dist/browser/terminal.css +1 -1
- package/dist/browser/terminal.js +57 -54
- package/dist/browser/workspace.css +2 -2
- package/dist/browser/workspace.js +35 -70
- package/dist/browser-crew.js +1 -1
- package/dist/browser-workspace.d.ts +329 -183
- package/dist/browser-workspace.js +10 -9
- package/dist/chat-controls.d.ts +1 -1
- package/dist/chat-controls.js +1 -1
- package/dist/chat-settings-page.d.ts +29 -9
- package/dist/chat-settings-page.js +59 -28
- package/dist/check-levels-ui.d.ts +2 -1
- package/dist/check-levels-ui.js +67 -58
- package/dist/contracts/cli.d.ts +43 -0
- package/dist/contracts/cli.js +4 -0
- package/dist/contracts/console-api.d.ts +1 -1
- package/dist/contracts/console-api.js +1 -1
- package/dist/credentials-ui.d.ts +5 -3
- package/dist/credentials-ui.js +58 -31
- package/dist/design/kit.css +722 -0
- package/dist/design/kit.d.ts +208 -0
- package/dist/design/kit.js +177 -0
- package/dist/design/tokens.css +31 -1
- package/dist/discord-settings.js +8 -4
- package/dist/export-ui.d.ts +6 -4
- package/dist/export-ui.js +18 -8
- package/dist/flow-changed.d.ts +14 -0
- package/dist/flow-changed.js +53 -0
- package/dist/flow-gallery-ui.d.ts +41 -4
- package/dist/flow-gallery-ui.js +95 -63
- package/dist/flow-starters-ui.d.ts +3 -9
- package/dist/flow-starters-ui.js +12 -23
- package/dist/flow-unattended-words.d.ts +20 -0
- package/dist/flow-unattended-words.js +24 -0
- package/dist/flow-unattended.d.ts +9 -5
- package/dist/flow-unattended.js +45 -23
- package/dist/flows-cli.js +7 -3
- package/dist/flows-ui.d.ts +12 -6
- package/dist/flows-ui.js +141 -30
- package/dist/integrations-ui.d.ts +6 -4
- package/dist/integrations-ui.js +34 -40
- package/dist/integrations.d.ts +2 -2
- package/dist/integrations.js +4 -4
- package/dist/kits-ui.d.ts +8 -10
- package/dist/kits-ui.js +29 -33
- package/dist/kits.js +1 -1
- package/dist/knowledge-ui.js +76 -19
- package/dist/ledger-view.d.ts +2 -0
- package/dist/ledger-view.js +41 -38
- package/dist/models-ui.d.ts +8 -3
- package/dist/models-ui.js +82 -59
- package/dist/monitoring-ui.d.ts +4 -2
- package/dist/monitoring-ui.js +44 -13
- package/dist/needs-you.d.ts +1 -1
- package/dist/needs-you.js +8 -0
- package/dist/operate-remote-arguments.js +1 -0
- package/dist/operate-remote.js +1 -1
- package/dist/operate.d.ts +1 -1
- package/dist/operate.js +38 -4
- package/dist/passwordless.d.ts +1 -1
- package/dist/passwordless.js +1 -1
- package/dist/people-audit-ui.d.ts +2 -1
- package/dist/people-audit-ui.js +25 -23
- package/dist/policy-ui.d.ts +3 -2
- package/dist/policy-ui.js +27 -21
- package/dist/policy.d.ts +3 -0
- package/dist/policy.js +14 -1
- package/dist/project-delete-ui.d.ts +4 -2
- package/dist/project-delete-ui.js +135 -126
- package/dist/project-knowledge.d.ts +10 -10
- package/dist/publish.d.ts +11 -0
- package/dist/publish.js +16 -3
- package/dist/pull-request-ui.d.ts +2 -1
- package/dist/pull-request-ui.js +39 -28
- package/dist/recipe-ui.d.ts +4 -2
- package/dist/recipe-ui.js +14 -4
- package/dist/release-adapters.d.ts +3 -0
- package/dist/release-adapters.js +22 -2
- package/dist/release-cli.js +81 -9
- package/dist/release.d.ts +23 -0
- package/dist/release.js +46 -4
- package/dist/releases.js +1 -1
- package/dist/request-budget-ui.d.ts +4 -2
- package/dist/request-budget-ui.js +30 -19
- package/dist/retention-ui.d.ts +4 -3
- package/dist/retention-ui.js +15 -12
- package/dist/scope.d.ts +3 -0
- package/dist/scope.js +6 -0
- package/dist/serve.d.ts +0 -1
- package/dist/serve.js +51 -7
- package/dist/server/agents-computers.d.ts +9 -0
- package/dist/server/agents-computers.js +67 -0
- package/dist/server/chat.js +16 -16
- package/dist/server/chrome.d.ts +1 -1
- package/dist/server/chrome.js +19 -9
- package/dist/server/console.css +24 -12
- package/dist/server/flows.js +64 -36
- package/dist/server/home.d.ts +35 -0
- package/dist/server/home.js +104 -0
- package/dist/server/http.d.ts +3 -0
- package/dist/server/http.js +5 -0
- package/dist/server/kit-standalone.d.ts +22 -0
- package/dist/server/kit-standalone.js +70 -0
- package/dist/server/kit.d.ts +419 -0
- package/dist/server/kit.js +369 -0
- package/dist/server/pages.d.ts +2 -1
- package/dist/server/pages.js +213 -174
- package/dist/server/people-tokens.js +26 -85
- package/dist/server/render-history.d.ts +51 -0
- package/dist/server/render-history.js +228 -0
- package/dist/server/render-new-task.d.ts +29 -0
- package/dist/server/render-new-task.js +96 -0
- package/dist/server/render-next.d.ts +63 -0
- package/dist/server/render-next.js +109 -0
- package/dist/server/render-pages.d.ts +22 -190
- package/dist/server/render-pages.js +123 -575
- package/dist/server/render-people.d.ts +24 -3
- package/dist/server/render-people.js +93 -72
- package/dist/server/render-projects.d.ts +103 -0
- package/dist/server/render-projects.js +247 -0
- package/dist/server/render-results.d.ts +5 -24
- package/dist/server/render-results.js +48 -185
- package/dist/server/render-settings.d.ts +5 -74
- package/dist/server/render-settings.js +102 -493
- package/dist/server/render-tasks.d.ts +35 -5
- package/dist/server/render-tasks.js +114 -85
- package/dist/server/route-table.js +20 -5
- package/dist/server/settings.js +276 -255
- package/dist/server/task-views.d.ts +8 -0
- package/dist/server/task-views.js +85 -2
- package/dist/server/tasks.d.ts +8 -0
- package/dist/server/tasks.js +95 -40
- package/dist/server/terminals.js +1 -1
- package/dist/server/welcome.d.ts +22 -0
- package/dist/server/welcome.js +69 -0
- package/dist/settings-groups-ui.d.ts +279 -0
- package/dist/settings-groups-ui.js +615 -0
- package/dist/settings-kit.d.ts +42 -6
- package/dist/settings-kit.js +69 -7
- package/dist/setup-guide.d.ts +2 -2
- package/dist/skills-ui.js +59 -7
- package/dist/slack-settings.js +12 -5
- package/dist/spend-ui.d.ts +45 -26
- package/dist/spend-ui.js +124 -56
- package/dist/sso-ui.d.ts +5 -2
- package/dist/sso-ui.js +63 -31
- package/dist/storage-ui.d.ts +3 -2
- package/dist/storage-ui.js +56 -50
- package/dist/store.d.ts +112 -3
- package/dist/store.js +292 -20
- package/dist/surface.js +5 -1
- package/dist/teams-settings.d.ts +2 -1
- package/dist/teams-settings.js +24 -41
- package/dist/telegram-settings.d.ts +0 -4
- package/dist/telegram-settings.js +52 -25
- package/dist/toolroll-update-ui.d.ts +8 -4
- package/dist/toolroll-update-ui.js +55 -45
- package/dist/tools-ui.d.ts +5 -3
- package/dist/tools-ui.js +107 -42
- package/dist/work-index.js +11 -7
- package/dist/workspace-ui.d.ts +1 -1
- package/dist/workspace-ui.js +7 -4
- package/package.json +1 -1
package/dist/store.js
CHANGED
|
@@ -96,6 +96,7 @@ import { LEDGER_COLUMNS, LEDGER_SCHEMA, LEDGER_TABLE, LEDGER_V99_TABLE, installL
|
|
|
96
96
|
import { PLAN_AUTO_SCHEMA } from "./plan-auto.js";
|
|
97
97
|
import { CONSOLE_INSTANCE_SCHEMA } from "./console-instance.js";
|
|
98
98
|
import { endUnattendedGrants, FLOW_UNATTENDED_SCHEMA, grantStandsSql } from "./flow-unattended.js";
|
|
99
|
+
import { flowChanged } from "./flow-changed.js";
|
|
99
100
|
import { FLOW_BROWSER_SCHEMA } from "./flow-browser.js";
|
|
100
101
|
import { RECIPE_SCHEMA } from "./recipes.js";
|
|
101
102
|
import { POLICY_SCHEMA, agentRefusal, approvalRefusal, policyParts, readPolicy, underCeiling, sessionCeilingRefusal } from "./policy.js";
|
|
@@ -1101,7 +1102,9 @@ const REGISTERED_AT_FILING = "again, as it was filed";
|
|
|
1101
1102
|
// version; no row is every saved task: not archived), the completed workflow runs on a watched branch the checks
|
|
1102
1103
|
// trigger saw (branch_check_run), and the reservations that hold work back from being put away (merge_in_flight,
|
|
1103
1104
|
// deploy_in_flight, flow_step_task; V145_SCHEMA, new tables only).
|
|
1104
|
-
|
|
1105
|
+
// v146 adds, and changes no saved row: each pull request check's own timing as GitHub last reported it for the head the
|
|
1106
|
+
// plane observed (ci_check), and each release step's start, end and outcome (release_step; V146_SCHEMA, new tables only).
|
|
1107
|
+
export const SCHEMA_VERSION = 146;
|
|
1105
1108
|
/** v118: the tables merged into `credential`, in the order they move (a person's API tokens keep their ids first). */
|
|
1106
1109
|
export const V118_MERGED_TABLES = Object.freeze({ api_token: "person", coordinator_credential: "coordinator", lead_credential: "lead" });
|
|
1107
1110
|
/** The foreign keys that named a merged table, exactly as their DDL wrote them; each names `credential(id)` instead. */
|
|
@@ -1234,7 +1237,7 @@ const V115_DROPPED_REFERENCES = /\s+REFERENCES (?:contestant|contest|tournament_
|
|
|
1234
1237
|
* unchanged), and adds the nullable column in V125_ADDED_COLUMNS, checked the same way. v126 only adds a new table
|
|
1235
1238
|
* (task_source) and the columns in V126_ADDED_COLUMNS (null or 0), checked the same way.
|
|
1236
1239
|
*/
|
|
1237
|
-
export const UPDATE_SAFE_MIGRATIONS = Object.freeze([108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140, 141, 142, 143, 144, 145]);
|
|
1240
|
+
export const UPDATE_SAFE_MIGRATIONS = Object.freeze([108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140, 141, 142, 143, 144, 145, 146]);
|
|
1238
1241
|
/** v119: the columns it adds, by table, with the value every row saved before it reads (toolroll-update's HISTORY_RULES). */
|
|
1239
1242
|
export const V119_ADDED_COLUMNS = Object.freeze({
|
|
1240
1243
|
task_ref: { planning_generation: { definition: "INTEGER NOT NULL DEFAULT 0", saved: "0" } },
|
|
@@ -1423,6 +1426,43 @@ CREATE TABLE IF NOT EXISTS flow_step_task (
|
|
|
1423
1426
|
PRIMARY KEY (card, entry, task_id)
|
|
1424
1427
|
);
|
|
1425
1428
|
`;
|
|
1429
|
+
/** v146: `ci_check` is one pull request check (a GitHub check run or commit status) as the plane's winning observation of
|
|
1430
|
+
* that head read it: written only with the observation that won its generation, replacing that head's rows wholesale.
|
|
1431
|
+
* `release_step` is one step of a release (release.ts RELEASE_STEPS) as `toolroll release` moved through it: when it
|
|
1432
|
+
* started, when it ended and how; recording it never changes the release. Both are read-only history for the Ship and
|
|
1433
|
+
* Release views and their CLI twins. */
|
|
1434
|
+
export const V146_SCHEMA = `
|
|
1435
|
+
CREATE TABLE IF NOT EXISTS ci_check (
|
|
1436
|
+
github_repo TEXT NOT NULL,
|
|
1437
|
+
pr_number INTEGER NOT NULL,
|
|
1438
|
+
head_sha TEXT NOT NULL,
|
|
1439
|
+
-- A check's identity: its kind (check run or commit status), its workflow and its name, so two workflows' checks
|
|
1440
|
+
-- with the same name stay two rows (a failing one never hides behind a passing namesake).
|
|
1441
|
+
ident TEXT NOT NULL,
|
|
1442
|
+
name TEXT NOT NULL,
|
|
1443
|
+
workflow TEXT,
|
|
1444
|
+
state TEXT NOT NULL CHECK (state IN ('queued','running','passed','failed','skipped','cancelled','neutral')),
|
|
1445
|
+
started_at TEXT,
|
|
1446
|
+
completed_at TEXT,
|
|
1447
|
+
url TEXT,
|
|
1448
|
+
observed_at TEXT NOT NULL,
|
|
1449
|
+
PRIMARY KEY (github_repo, pr_number, head_sha, ident)
|
|
1450
|
+
);
|
|
1451
|
+
CREATE TABLE IF NOT EXISTS release_step (
|
|
1452
|
+
task_id TEXT NOT NULL,
|
|
1453
|
+
tag TEXT NOT NULL,
|
|
1454
|
+
version TEXT NOT NULL,
|
|
1455
|
+
pr INTEGER,
|
|
1456
|
+
step TEXT NOT NULL,
|
|
1457
|
+
state TEXT NOT NULL CHECK (state IN ('running','done','failed','waiting')),
|
|
1458
|
+
started_at TEXT NOT NULL,
|
|
1459
|
+
ended_at TEXT,
|
|
1460
|
+
detail TEXT,
|
|
1461
|
+
updated_at TEXT NOT NULL,
|
|
1462
|
+
PRIMARY KEY (task_id, tag, step)
|
|
1463
|
+
);
|
|
1464
|
+
CREATE INDEX IF NOT EXISTS release_step_by_task ON release_step (task_id, updated_at);
|
|
1465
|
+
`;
|
|
1426
1466
|
/**
|
|
1427
1467
|
* v135: one approval per release (docs/plans/release-once.md). `release_authority` is the single source of truth for a
|
|
1428
1468
|
* release a person approved: one row per generation, changed only by compare-and-swap on `rev`, with a lease whose
|
|
@@ -4684,6 +4724,8 @@ CREATE INDEX IF NOT EXISTS run_checkpoint_by_task ON run_checkpoint (task_ref, i
|
|
|
4684
4724
|
db.exec(FLOW_BROWSER_SCHEMA);
|
|
4685
4725
|
// v145: put-away tasks, green checks per branch and the reservations that hold work back (new tables only).
|
|
4686
4726
|
db.exec(V145_SCHEMA);
|
|
4727
|
+
// v146: pull request checks' own timing and release steps (new tables only).
|
|
4728
|
+
db.exec(V146_SCHEMA);
|
|
4687
4729
|
// Keep the epoch until reclamation succeeds, including a retry after compaction already committed. A new file has
|
|
4688
4730
|
// nothing to reclaim.
|
|
4689
4731
|
if (!fresh)
|
|
@@ -16127,9 +16169,12 @@ export class Store {
|
|
|
16127
16169
|
}
|
|
16128
16170
|
/** Save a new drawing of the flow only over the revision the editor saw (two editors never overwrite each other silently). */
|
|
16129
16171
|
saveFlow(id, change, now) {
|
|
16130
|
-
|
|
16131
|
-
|
|
16132
|
-
|
|
16172
|
+
// The one road for a changed flow (flow-changed.ts): what a person approved for the flow as it was ends with it.
|
|
16173
|
+
return flowChanged(this, { flow: id }, change.by, now, () => {
|
|
16174
|
+
const { changes } = this.db.prepare("UPDATE flow SET name = ?, definition_json = ?, revision = revision + 1, updated_by = ?, updated_at = ? WHERE id = ? AND revision = ? AND state = 'active'")
|
|
16175
|
+
.run(change.name, flowDefinitionForStore(change.definitionJson), change.by, now.toISOString(), id, change.sawRevision);
|
|
16176
|
+
return Number(changes) === 1;
|
|
16177
|
+
});
|
|
16133
16178
|
}
|
|
16134
16179
|
archiveFlow(id, by, now) {
|
|
16135
16180
|
const { changes } = this.db.prepare("UPDATE flow SET state = 'archived', updated_by = ?, updated_at = ? WHERE id = ? AND state = 'active'").run(by, now.toISOString(), id);
|
|
@@ -16552,26 +16597,35 @@ export class Store {
|
|
|
16552
16597
|
return this.transact(() => {
|
|
16553
16598
|
const current = this.flowScript(script.repo, script.name);
|
|
16554
16599
|
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;
|
|
16555
|
-
|
|
16556
|
-
|
|
16557
|
-
|
|
16558
|
-
|
|
16559
|
-
|
|
16560
|
-
|
|
16561
|
-
|
|
16600
|
+
// A new version ends what was approved for the flows that run this script (flow-changed.ts): "runs on its own"
|
|
16601
|
+
// grants that signed its code, and browser use that sealed it.
|
|
16602
|
+
return flowChanged(this, { script: { repo: script.repo, name: script.name } }, script.by, now, () => {
|
|
16603
|
+
if (current !== null)
|
|
16604
|
+
this.db.prepare("UPDATE flow_script SET state = 'removed' WHERE id = ?").run(current.id);
|
|
16605
|
+
this.db.prepare(`INSERT INTO flow_script (repo, name, about, body, timeout_minutes, version, digest, saved_by, saved_at, state, language, file, held)
|
|
16606
|
+
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);
|
|
16607
|
+
return version;
|
|
16608
|
+
}, () => true);
|
|
16562
16609
|
});
|
|
16563
16610
|
}
|
|
16564
|
-
/** A person approves a held script (one an imported flow brought): from now on it runs.
|
|
16565
|
-
|
|
16566
|
-
|
|
16567
|
-
|
|
16611
|
+
/** A person approves a held script (one an imported flow brought): from now on it runs. The approval covers exactly
|
|
16612
|
+
* the version and content the person read (`seen`): the check and the release of the hold are one statement in one
|
|
16613
|
+
* transaction, so a script removed and brought back under the same name is never approved unseen. */
|
|
16614
|
+
approveFlowScript(repo, name, seen) {
|
|
16615
|
+
return this.transact(() => {
|
|
16616
|
+
const current = this.flowScript(repo, name);
|
|
16617
|
+
if (current === null || current.held === null)
|
|
16618
|
+
return "not-held";
|
|
16619
|
+
if (current.version !== seen.version || current.digest !== seen.digest)
|
|
16620
|
+
return "changed";
|
|
16621
|
+
const { changes } = this.db.prepare("UPDATE flow_script SET held = NULL WHERE id = ? AND version = ? AND digest = ? AND state = 'active' AND held IS NOT NULL").run(current.id, seen.version, seen.digest);
|
|
16622
|
+
return Number(changes) === 1 ? "approved" : "changed";
|
|
16623
|
+
});
|
|
16568
16624
|
}
|
|
16569
16625
|
removeFlowScript(repo, name, by = "flow", now = new Date()) {
|
|
16570
|
-
|
|
16626
|
+
// A grant that signed this script's code covers nothing without it: it ends, as does browser use that sealed it (flow-changed.ts).
|
|
16627
|
+
return flowChanged(this, { script: { repo, name } }, by, now, () => {
|
|
16571
16628
|
const { changes } = this.db.prepare("UPDATE flow_script SET state = 'removed' WHERE repo = ? AND name = ? AND state = 'active'").run(repo, name);
|
|
16572
|
-
// A grant that signed this script's code covers nothing without it: it ends (flow-unattended.ts).
|
|
16573
|
-
if (Number(changes) === 1)
|
|
16574
|
-
endUnattendedGrants(this, { script: { repo, name } }, by, "script changed", now);
|
|
16575
16629
|
return Number(changes) === 1;
|
|
16576
16630
|
});
|
|
16577
16631
|
}
|
|
@@ -21871,3 +21925,221 @@ export function restoreTaskFamily(store, taskId, by, now, guard) {
|
|
|
21871
21925
|
return { ok: true, refs };
|
|
21872
21926
|
});
|
|
21873
21927
|
}
|
|
21928
|
+
/**
|
|
21929
|
+
* What a LIVE invite grants, for its own join page (Wave E, "Your access"): the role, who invited, the projects and
|
|
21930
|
+
* when it expires. Read without spending an attempt and under exactly inviteIsLive's predicate, so every dead shape
|
|
21931
|
+
* reads as null and still gets the one indistinguishable page (D6). Only a holder of the live token sees it.
|
|
21932
|
+
*/
|
|
21933
|
+
export function liveInviteTerms(store, tokenValue, now) {
|
|
21934
|
+
const row = store.handle
|
|
21935
|
+
.prepare("SELECT role, minted_by, expires_at, projects_json FROM invite WHERE token_hash = ? AND revoked_at IS NULL AND consumed_at IS NULL AND expires_at > ? AND attempts < 10")
|
|
21936
|
+
.get(createHash("sha256").update(tokenValue, "utf8").digest("hex"), now.toISOString());
|
|
21937
|
+
if (row === undefined)
|
|
21938
|
+
return null;
|
|
21939
|
+
return { role: String(row["role"]) === "approver" ? "approver" : "viewer", mintedBy: String(row["minted_by"]), expiresAt: String(row["expires_at"]), projects: readProjectAccess(row["projects_json"]) };
|
|
21940
|
+
}
|
|
21941
|
+
/** At most this many checks are kept for one head; GitHub's rollup is far smaller in practice. */
|
|
21942
|
+
export const CI_CHECKS_MAX = 200;
|
|
21943
|
+
const CI_CHECK_TEXT_MAX = 200;
|
|
21944
|
+
const CI_CHECK_URL_MAX = 2048;
|
|
21945
|
+
const CI_FAILED_CONCLUSIONS = new Set(["FAILURE", "ERROR", "TIMED_OUT", "ACTION_REQUIRED", "STARTUP_FAILURE"]);
|
|
21946
|
+
const CI_PENDING = new Set(["QUEUED", "WAITING", "PENDING", "REQUESTED"]);
|
|
21947
|
+
const ISO_INSTANT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2})$/;
|
|
21948
|
+
/** A timestamp GitHub gave, as an exact ISO instant, or null (GitHub's zero time "0001-01-01T00:00:00Z" is no time). */
|
|
21949
|
+
function ciInstant(value) {
|
|
21950
|
+
if (typeof value !== "string" || value.length > 40 || !ISO_INSTANT.test(value))
|
|
21951
|
+
return null;
|
|
21952
|
+
const at = Date.parse(value);
|
|
21953
|
+
if (!Number.isFinite(at) || at < Date.UTC(2000, 0, 1))
|
|
21954
|
+
return null;
|
|
21955
|
+
return new Date(at).toISOString();
|
|
21956
|
+
}
|
|
21957
|
+
/** The same ordering in SQL, for a column holding a check state. */
|
|
21958
|
+
const CI_SEVERITY_SQL = (column) => `CASE ${column} ${Object.entries({ failed: 6, cancelled: 5, running: 4, queued: 3, neutral: 2, skipped: 1, passed: 0 }).map(([state, n]) => `WHEN '${state}' THEN ${n}`).join(" ")} ELSE 0 END`;
|
|
21959
|
+
/** How bad a check state is: when one check identity appears twice, the worse stands. */
|
|
21960
|
+
const CI_SEVERITY = { failed: 6, cancelled: 5, running: 4, queued: 3, neutral: 2, skipped: 1, passed: 0 };
|
|
21961
|
+
/** A check's identity: its kind, its workflow and its name. */
|
|
21962
|
+
function ciCheckIdent(kind, row) {
|
|
21963
|
+
// Unit separators, never NUL (SQLite text ends at one): names and workflows had every control character removed.
|
|
21964
|
+
return `${kind}\u001f${row.workflow ?? ""}\u001f${row.name}`;
|
|
21965
|
+
}
|
|
21966
|
+
/** Short, printable text, or null. Every control (C0, DEL, C1) and format character (bidi overrides and isolates,
|
|
21967
|
+
* zero-width marks) is removed, so a check name can't move the terminal's cursor or reorder what `task ship` prints.
|
|
21968
|
+
* Check names never need a zero-width joiner, so emoji sequences lose theirs: that is the accepted cost. */
|
|
21969
|
+
function ciText(value) {
|
|
21970
|
+
if (typeof value !== "string")
|
|
21971
|
+
return null;
|
|
21972
|
+
const text = value.replace(/[\p{Cc}\p{Cf}]/gu, "").trim();
|
|
21973
|
+
return text === "" || text.length > CI_CHECK_TEXT_MAX ? null : text;
|
|
21974
|
+
}
|
|
21975
|
+
/** Only an https link to GitHub itself is kept; anything else reads null. */
|
|
21976
|
+
function ciUrl(value) {
|
|
21977
|
+
if (typeof value !== "string" || value.length > CI_CHECK_URL_MAX)
|
|
21978
|
+
return null;
|
|
21979
|
+
try {
|
|
21980
|
+
const url = new URL(value);
|
|
21981
|
+
return url.protocol === "https:" && url.username === "" && url.password === "" && url.port === "" && url.hostname === "github.com" ? url.toString() : null;
|
|
21982
|
+
}
|
|
21983
|
+
catch {
|
|
21984
|
+
return null;
|
|
21985
|
+
}
|
|
21986
|
+
}
|
|
21987
|
+
/**
|
|
21988
|
+
* GitHub's statusCheckRollup (gh pr view --json statusCheckRollup) as check rows: CheckRun items (name, workflowName,
|
|
21989
|
+
* status, conclusion, startedAt, completedAt, detailsUrl) and StatusContext items (context, state, startedAt, targetUrl).
|
|
21990
|
+
* Strict: an item that is not one of those shapes, has no usable name, or names a state it doesn't know is dropped; one
|
|
21991
|
+
* row per identity (kind, workflow and name), and when one identity appears twice (a re-run) the worse state stands;
|
|
21992
|
+
* at most CI_CHECKS_MAX.
|
|
21993
|
+
*/
|
|
21994
|
+
export function parseCiRollup(rollup) {
|
|
21995
|
+
if (!Array.isArray(rollup))
|
|
21996
|
+
return [];
|
|
21997
|
+
const rows = [];
|
|
21998
|
+
const seen = new Map();
|
|
21999
|
+
for (const item of rollup.slice(0, CI_CHECKS_MAX * 2)) {
|
|
22000
|
+
if (rows.length >= CI_CHECKS_MAX)
|
|
22001
|
+
break;
|
|
22002
|
+
if (typeof item !== "object" || item === null || Array.isArray(item))
|
|
22003
|
+
continue;
|
|
22004
|
+
const raw = item;
|
|
22005
|
+
const kind = raw["__typename"];
|
|
22006
|
+
let row = null;
|
|
22007
|
+
if (kind === "CheckRun" || (kind === undefined && typeof raw["status"] === "string" && raw["context"] === undefined)) {
|
|
22008
|
+
const name = ciText(raw["name"]);
|
|
22009
|
+
const status = typeof raw["status"] === "string" ? raw["status"].toUpperCase() : "";
|
|
22010
|
+
const conclusion = typeof raw["conclusion"] === "string" ? raw["conclusion"].toUpperCase() : "";
|
|
22011
|
+
const state = status === "IN_PROGRESS" ? "running"
|
|
22012
|
+
: CI_PENDING.has(status) ? "queued"
|
|
22013
|
+
: status !== "COMPLETED" ? null
|
|
22014
|
+
: conclusion === "SUCCESS" ? "passed"
|
|
22015
|
+
: CI_FAILED_CONCLUSIONS.has(conclusion) ? "failed"
|
|
22016
|
+
: conclusion === "CANCELLED" ? "cancelled"
|
|
22017
|
+
: conclusion === "SKIPPED" ? "skipped"
|
|
22018
|
+
: conclusion === "NEUTRAL" || conclusion === "STALE" ? "neutral"
|
|
22019
|
+
: null;
|
|
22020
|
+
if (name === null || state === null)
|
|
22021
|
+
continue;
|
|
22022
|
+
const completedAt = state === "running" || state === "queued" ? null : ciInstant(raw["completedAt"]);
|
|
22023
|
+
row = { name, workflow: ciText(raw["workflowName"]), state, startedAt: state === "queued" ? null : ciInstant(raw["startedAt"]), completedAt, url: ciUrl(raw["detailsUrl"]), kind: "run" };
|
|
22024
|
+
}
|
|
22025
|
+
else if (kind === "StatusContext" || (kind === undefined && typeof raw["context"] === "string")) {
|
|
22026
|
+
const name = ciText(raw["context"]);
|
|
22027
|
+
const value = typeof raw["state"] === "string" ? raw["state"].toUpperCase() : "";
|
|
22028
|
+
const state = value === "SUCCESS" ? "passed" : value === "FAILURE" || value === "ERROR" ? "failed"
|
|
22029
|
+
: value === "PENDING" || value === "EXPECTED" ? "running" : null;
|
|
22030
|
+
if (name === null || state === null)
|
|
22031
|
+
continue;
|
|
22032
|
+
row = { name, workflow: null, state, startedAt: ciInstant(raw["startedAt"] ?? raw["createdAt"]), completedAt: null, url: ciUrl(raw["targetUrl"]), kind: "status" };
|
|
22033
|
+
}
|
|
22034
|
+
if (row === null)
|
|
22035
|
+
continue;
|
|
22036
|
+
// A completion before its start is GitHub's clock disagreeing with itself: keep the state, drop the end.
|
|
22037
|
+
if (row.startedAt !== null && row.completedAt !== null && row.completedAt < row.startedAt)
|
|
22038
|
+
row = { ...row, completedAt: null };
|
|
22039
|
+
const ident = ciCheckIdent(row.kind ?? "run", row);
|
|
22040
|
+
const at = seen.get(ident);
|
|
22041
|
+
if (at !== undefined) {
|
|
22042
|
+
if (CI_SEVERITY[row.state] > CI_SEVERITY[rows[at].state])
|
|
22043
|
+
rows[at] = row;
|
|
22044
|
+
continue;
|
|
22045
|
+
}
|
|
22046
|
+
seen.set(ident, rows.length);
|
|
22047
|
+
rows.push(row);
|
|
22048
|
+
}
|
|
22049
|
+
return rows;
|
|
22050
|
+
}
|
|
22051
|
+
/**
|
|
22052
|
+
* Replace one head's checks wholesale (v146). Call ONLY with an observation that won its generation (publish.ts settles
|
|
22053
|
+
* and records in one transaction): a losing, stale answer never writes here.
|
|
22054
|
+
*/
|
|
22055
|
+
export function recordCiChecks(store, githubRepo, prNumber, headSha, rows, now) {
|
|
22056
|
+
store.transact(() => {
|
|
22057
|
+
// One head per pull request is kept: an older head's rows go with the new observation.
|
|
22058
|
+
store.handle.prepare("DELETE FROM ci_check WHERE github_repo = ? AND pr_number = ?").run(githubRepo, prNumber);
|
|
22059
|
+
const insert = store.handle.prepare(`INSERT INTO ci_check (github_repo, pr_number, head_sha, ident, name, workflow, state, started_at, completed_at, url, observed_at)
|
|
22060
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
22061
|
+
ON CONFLICT (github_repo, pr_number, head_sha, ident) DO UPDATE SET state = excluded.state, started_at = excluded.started_at,
|
|
22062
|
+
completed_at = excluded.completed_at, url = excluded.url WHERE ${CI_SEVERITY_SQL("excluded.state")} > ${CI_SEVERITY_SQL("ci_check.state")}`);
|
|
22063
|
+
const at = now.toISOString();
|
|
22064
|
+
// Rows a caller built itself carry no kind: a row with a workflow is a check run, one without may be a status; the
|
|
22065
|
+
// identity keeps the two apart either way.
|
|
22066
|
+
for (const row of rows.slice(0, CI_CHECKS_MAX))
|
|
22067
|
+
insert.run(githubRepo, prNumber, headSha, ciCheckIdent(row.kind ?? (row.workflow === null ? "status" : "run"), row), row.name, row.workflow, row.state, row.startedAt, row.completedAt, row.url, at);
|
|
22068
|
+
});
|
|
22069
|
+
}
|
|
22070
|
+
/** A pull request's checks for the head the plane's last winning observation recorded, or null when none was. */
|
|
22071
|
+
export function ciChecksFor(store, githubRepo, prNumber) {
|
|
22072
|
+
const observation = store.handle.prepare("SELECT head_sha, state, observed_at FROM ci_observation WHERE github_repo = ? AND pr_number = ?").get(githubRepo, prNumber);
|
|
22073
|
+
if (observation === undefined || String(observation["head_sha"]) === "" || String(observation["observed_at"]) === "")
|
|
22074
|
+
return null;
|
|
22075
|
+
const headSha = String(observation["head_sha"]);
|
|
22076
|
+
const rows = store.handle.prepare(`SELECT ident, name, workflow, state, started_at, completed_at, url, observed_at FROM ci_check
|
|
22077
|
+
WHERE github_repo = ? AND pr_number = ? AND head_sha = ? ORDER BY COALESCE(started_at, '9999'), name`).all(githubRepo, prNumber, headSha);
|
|
22078
|
+
const text = (value) => value === null || value === undefined ? null : String(value);
|
|
22079
|
+
return {
|
|
22080
|
+
githubRepo, prNumber, headSha, observedAt: String(observation["observed_at"]),
|
|
22081
|
+
observed: ["passing", "failing", "running"].find(one => one === String(observation["state"])) ?? "none",
|
|
22082
|
+
checks: rows.map(row => ({ name: String(row["name"]), workflow: text(row["workflow"]), state: String(row["state"]),
|
|
22083
|
+
startedAt: text(row["started_at"]), completedAt: text(row["completed_at"]), url: text(row["url"]),
|
|
22084
|
+
kind: String(row["ident"]).startsWith("status\u001f") ? "status" : "run" })),
|
|
22085
|
+
};
|
|
22086
|
+
}
|
|
22087
|
+
/** A task's pull request checks: its family's newest publication that has a pull request number, and that pull
|
|
22088
|
+
* request's checks (ciChecksFor). Null: no pull request, or none observed yet. */
|
|
22089
|
+
export function shipChecksForTask(store, taskId) {
|
|
22090
|
+
const refs = taskFamilyRefs(store, taskId).map(one => one.id);
|
|
22091
|
+
if (refs.length === 0)
|
|
22092
|
+
return null;
|
|
22093
|
+
const row = store.handle.prepare(`SELECT github_repo, pr_number FROM publication WHERE task_ref IN (SELECT value FROM json_each(?))
|
|
22094
|
+
AND pr_number IS NOT NULL ORDER BY id DESC LIMIT 1`).get(JSON.stringify(refs));
|
|
22095
|
+
return row === undefined ? null : ciChecksFor(store, String(row["github_repo"]), Number(row["pr_number"]));
|
|
22096
|
+
}
|
|
22097
|
+
const RELEASE_STEP_NAMES = new Set(["gate", "approval", "check", "complete", "quiet", "deploy", "link", "ci", "merge", "tree", "tag", "publish", "homebrew"]);
|
|
22098
|
+
/**
|
|
22099
|
+
* Record a release step (v146): `running` or `waiting` opens it (its first start is kept), `done` or `failed` ends it.
|
|
22100
|
+
* A step that runs again after a stop starts its row again. A failed write throws here; the release's adapter swallows
|
|
22101
|
+
* it, so recording never changes a release.
|
|
22102
|
+
*/
|
|
22103
|
+
export function recordReleaseStep(store, entry) {
|
|
22104
|
+
if (!RELEASE_STEP_NAMES.has(entry.step) || entry.taskId === "" || entry.tag === "")
|
|
22105
|
+
return;
|
|
22106
|
+
const at = entry.at.toISOString();
|
|
22107
|
+
// A stop message can carry a remote tool's words: no control or format character reaches `release status`.
|
|
22108
|
+
const detail = entry.detail == null ? null : entry.detail.replace(/[\p{Cc}\p{Cf}]+/gu, " ").trim().slice(0, 1000);
|
|
22109
|
+
const ended = entry.state === "done" || entry.state === "failed";
|
|
22110
|
+
store.handle.prepare(`INSERT INTO release_step (task_id, tag, version, pr, step, state, started_at, ended_at, detail, updated_at)
|
|
22111
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
22112
|
+
ON CONFLICT (task_id, tag, step) DO UPDATE SET
|
|
22113
|
+
state = excluded.state, version = excluded.version, pr = excluded.pr, updated_at = excluded.updated_at,
|
|
22114
|
+
started_at = CASE WHEN release_step.ended_at IS NOT NULL AND excluded.ended_at IS NULL THEN excluded.started_at ELSE release_step.started_at END,
|
|
22115
|
+
ended_at = excluded.ended_at,
|
|
22116
|
+
detail = CASE WHEN excluded.detail IS NOT NULL OR excluded.ended_at IS NULL THEN excluded.detail ELSE release_step.detail END`)
|
|
22117
|
+
.run(entry.taskId, entry.tag, entry.version, entry.pr, entry.step, entry.state, at, ended ? at : null, detail, at);
|
|
22118
|
+
}
|
|
22119
|
+
/** A release's steps, for the release task: the newest tag recorded for it, its steps in the order they ran, and the
|
|
22120
|
+
* step it stopped at when the last thing recorded was a failure. Null: no step recorded. */
|
|
22121
|
+
export function releaseProgressFor(store, taskId) {
|
|
22122
|
+
const newest = store.handle.prepare("SELECT tag FROM release_step WHERE task_id = ? ORDER BY updated_at DESC, rowid DESC LIMIT 1").get(taskId);
|
|
22123
|
+
if (newest === undefined)
|
|
22124
|
+
return null;
|
|
22125
|
+
const tag = String(newest["tag"]);
|
|
22126
|
+
const rows = store.handle.prepare("SELECT * FROM release_step WHERE task_id = ? AND tag = ? ORDER BY started_at, rowid").all(taskId, tag);
|
|
22127
|
+
const steps = rows.map(row => ({ step: String(row["step"]), state: String(row["state"]), startedAt: String(row["started_at"]),
|
|
22128
|
+
endedAt: row["ended_at"] === null ? null : String(row["ended_at"]), detail: row["detail"] === null ? null : String(row["detail"]) }));
|
|
22129
|
+
const last = [...rows].sort((a, b) => String(a["updated_at"]).localeCompare(String(b["updated_at"]))).at(-1);
|
|
22130
|
+
const stopped = String(last["state"]) === "failed" ? { step: String(last["step"]), message: last["detail"] === null ? "" : String(last["detail"]), at: String(last["updated_at"]) } : null;
|
|
22131
|
+
const pr = last["pr"] === null ? null : Number(last["pr"]);
|
|
22132
|
+
return { tag, version: String(last["version"]), pr, startedAt: steps[0].startedAt, updatedAt: String(last["updated_at"]), steps, stopped };
|
|
22133
|
+
}
|
|
22134
|
+
/** The release task whose steps were recorded most recently (v146), or null when none was. */
|
|
22135
|
+
export function latestReleaseStepTask(store) {
|
|
22136
|
+
const row = store.handle.prepare("SELECT task_id FROM release_step ORDER BY updated_at DESC, rowid DESC LIMIT 1").get();
|
|
22137
|
+
return row === undefined ? null : String(row["task_id"]);
|
|
22138
|
+
}
|
|
22139
|
+
/** Seconds a step or check took (to its end, or to `now` while it runs); null without a start. */
|
|
22140
|
+
export function elapsedSeconds(startedAt, endedAt, now) {
|
|
22141
|
+
if (startedAt === null)
|
|
22142
|
+
return null;
|
|
22143
|
+
const start = Date.parse(startedAt), end = endedAt === null ? now.getTime() : Date.parse(endedAt);
|
|
22144
|
+
return Number.isFinite(start) && Number.isFinite(end) ? Math.max(0, Math.round((end - start) / 1000)) : null;
|
|
22145
|
+
}
|
package/dist/surface.js
CHANGED
|
@@ -165,6 +165,10 @@ const GUIDE_ROWS = [
|
|
|
165
165
|
positionals: [{ name: "task", required: true, meaning: "any version of the task" }],
|
|
166
166
|
flags: [jsonFlag, dbFlag, { name: "level", takesValue: true, meaning: "quick (default) or full" }],
|
|
167
167
|
notableReasons: ["no-result", "no-check", "checks-failed"] },
|
|
168
|
+
{ invocation: "task ship", synopsis: "the task's pull request checks as last observed for its head: each check's state and how long it took",
|
|
169
|
+
audience: "agent", agentMayInvoke: true, mutation: "none",
|
|
170
|
+
positionals: [{ name: "task", required: true, meaning: "any version of the task" }], flags: [jsonFlag, dbFlag],
|
|
171
|
+
notableReasons: ["unknown-task", "no-pull-request"] },
|
|
168
172
|
{ invocation: "task add-tests", synopsis: "file a small task to write tests for the task's latest change; it waits for approval like any task",
|
|
169
173
|
audience: "agent", agentMayInvoke: true, mutation: "identity-idempotent",
|
|
170
174
|
positionals: [{ name: "task", required: true, meaning: "any version of the task" }], flags: [jsonFlag, dbFlag],
|
|
@@ -473,7 +477,7 @@ const REMOTE_STEP_UP = [
|
|
|
473
477
|
];
|
|
474
478
|
const REMOTE_YES = [
|
|
475
479
|
"status", "integrations", "ready", "gaps", "grants", "sync", "audit",
|
|
476
|
-
"task add", "task ask", "task checks", "task add-tests", "task list", "task show", "check-progress", "task wait", "task complete", "task revise",
|
|
480
|
+
"task add", "task ask", "task checks", "task ship", "task add-tests", "task list", "task show", "check-progress", "task wait", "task complete", "task revise",
|
|
477
481
|
"task state", "task block", "task unblock", "task next", "task steer", "task pause", "task continue", "task assign", "task scope", "task plan", "task hold", "task unhold", "task require",
|
|
478
482
|
"task requeue", "task review", "task repair", "task route", "task reopen", "task stop", "task resume",
|
|
479
483
|
"runner list", "coordinator list", "cap list", "cap add", "outbox list", "incident list", "incident resolve",
|
package/dist/teams-settings.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/** Settings → Teams: connect the Entra app once (operator), then each
|
|
2
2
|
* teammate pairs their own Teams account. The messaging endpoint the bot
|
|
3
3
|
* registration must point at is shown here; it has to be reachable from the
|
|
4
|
-
* internet, which a tailnet-only address is not.
|
|
4
|
+
* internet, which a tailnet-only address is not. One page with Slack and
|
|
5
|
+
* Discord (chat-settings-page.ts). */
|
|
5
6
|
import type { Store } from "./store.js";
|
|
6
7
|
import { type Html } from "./html.js";
|
|
7
8
|
export declare function teamsSettingsHtml(store: Store, dir: string, options?: {
|
package/dist/teams-settings.js
CHANGED
|
@@ -1,46 +1,29 @@
|
|
|
1
|
-
import { html, joinHtml
|
|
2
|
-
import { ChatState } from "./chat-delivery-state.js";
|
|
1
|
+
import { html, joinHtml } from "./html.js";
|
|
3
2
|
import { loadTeamsCredentials } from "./teams-api.js";
|
|
4
|
-
import { loadPrimary } from "./webhooks.js";
|
|
5
3
|
import { TEAMS_MESSAGES_PATH } from "./teams.js";
|
|
4
|
+
import { formRow, input } from "./server/kit.js";
|
|
5
|
+
import { chatSecretRow, chatSettingsHtml } from "./chat-settings-page.js";
|
|
6
6
|
export function teamsSettingsHtml(store, dir, options = {}) {
|
|
7
|
-
const credentials = loadTeamsCredentials(dir), state = new ChatState(store, "teams");
|
|
8
|
-
const bindings = credentials ? state.bindings(credentials.installation).filter(one => state.live(one)) : [];
|
|
9
|
-
const binding = options.who === undefined ? null : bindings.find(one => one.approver === options.who) ?? null;
|
|
10
|
-
const others = bindings.length - (binding === null ? 0 : 1);
|
|
11
|
-
const runtime = credentials ? state.runtime(credentials.installation) : null;
|
|
12
|
-
const now = options.now ?? new Date();
|
|
13
|
-
const live = runtime && runtime.leaseUntil !== null && runtime.leaseUntil > now.toISOString() && runtime.connected;
|
|
14
|
-
const password = html `<label>Your Toolroll password<input style="min-height:44px" type="password" name="password" autocomplete="current-password" required></label>`;
|
|
15
|
-
const post = (action, content) => postForm(`/settings/teams/${action}`, content, { attrs: { class: "card" } });
|
|
16
7
|
const endpoint = options.publicUrl ? `${options.publicUrl.replace(/\/$/, "")}${TEAMS_MESSAGES_PATH}` : null;
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
parts.push(html `<p>Your Teams account is paired.${others ? ` ${others} teammate${others === 1 ? " is" : "s are"} paired too.` : ""}</p>`, html `<p>Try “What needs my attention?” or send <code>status</code>. In a channel, mention the bot with <code>team</code> to follow a conversation.</p>`, html `<p><a href="/chat">Open saved chat</a></p>`, loadPrimary(process.env, dir) === "teams" ? html `<p>Task updates are sent here.</p>` : post("alerts", html `<button type="submit">Send task updates here</button>`), post("unpair", html `${password}<p class="meta">Unpairing ends every open button in your chat. Teammates are unaffected.</p><button type="submit">Unpair my account</button>`));
|
|
39
|
-
}
|
|
40
|
-
else {
|
|
41
|
-
parts.push(post("pair", html `<h2>Pair your account</h2><p>Your paired Teams account can read your connected projects and confirm proposed changes. Password approvals still open in Toolroll.${others ? ` ${others} teammate${others === 1 ? " is" : "s are"} already paired.` : ""}</p>${password}<button type="submit">Create pairing code</button>`));
|
|
42
|
-
}
|
|
43
|
-
parts.push(html `<details><summary>Connection settings</summary>${post("disconnect", html `${password}<button type="submit">Disconnect Teams</button>`)}</details>`);
|
|
44
|
-
}
|
|
45
|
-
return html `<section style="max-width:42rem;overflow-wrap:anywhere"><p><a href="/settings">Settings</a></p><h1>Teams</h1>${options.problem ? html `<p class="problem" role="alert">${options.problem}</p>` : ""}${parts}</section>`;
|
|
8
|
+
const text = (id, name, label) => formRow(label, null, input({ id, name, attrs: { autocomplete: "off", spellcheck: "false", required: true, "aria-label": label } }));
|
|
9
|
+
const { code, problem, now, who } = options;
|
|
10
|
+
return chatSettingsHtml(store, dir, { ...(code === undefined ? {} : { code }), ...(problem === undefined ? {} : { problem }), ...(now === undefined ? {} : { now }), ...(who === undefined ? {} : { who }) }, {
|
|
11
|
+
provider: "teams",
|
|
12
|
+
label: "Teams",
|
|
13
|
+
credentials: loadTeamsCredentials(dir),
|
|
14
|
+
intro: "Manage projects and review results in a Teams chat, and let a channel follow a team conversation.",
|
|
15
|
+
steps: html `<ol class="k-note"><li>In <a class="k-link" href="https://portal.azure.com" target="_blank" rel="noopener noreferrer">Azure</a>, create an <em>Azure Bot</em> with a new single-tenant app registration; note the app (client) id and the directory (tenant) id, and create a client secret.</li>\
|
|
16
|
+
<li>Set its messaging endpoint to <code class="k-mono">${endpoint === null ? "https://your-public-address" + TEAMS_MESSAGES_PATH : endpoint}</code>. Microsoft must reach it from the internet: a tailnet-only address needs Tailscale Funnel (or another public tunnel) on that path.</li>\
|
|
17
|
+
<li>Enable the Microsoft Teams channel on the bot, then add the app to Teams (personal scope; team scope for channels).</li></ol>`,
|
|
18
|
+
fields: joinHtml([
|
|
19
|
+
text("teams-app-id", "app-id", "App (client) id"),
|
|
20
|
+
text("teams-tenant", "tenant", "Directory (tenant) id"),
|
|
21
|
+
chatSecretRow({ id: "teams-secret", name: "secret", label: "Client secret" }),
|
|
22
|
+
]),
|
|
23
|
+
fieldsNote: "The secret stays on this installation. Next, pair your own Teams account.",
|
|
24
|
+
pairWhere: "to the Toolroll bot in a personal Teams chat",
|
|
25
|
+
tryLine: html `Try “What needs my attention?” or send <code class="k-mono">status</code>. In a channel, mention the bot with <code class="k-mono">team</code> to follow a conversation.`,
|
|
26
|
+
liveWords: ["Signed in", "Waiting for sign-in"],
|
|
27
|
+
...(endpoint === null ? {} : { connection: html `<p class="k-note">Messaging endpoint, reachable from the internet: <code class="k-mono">${endpoint}</code></p>` }),
|
|
28
|
+
});
|
|
46
29
|
}
|
|
@@ -1,7 +1,3 @@
|
|
|
1
|
-
/** The person's own Telegram pairing, from the console: one private chat per
|
|
2
|
-
* teammate with the installation's bot (v72). The bot token itself stays on
|
|
3
|
-
* the settings page; this card only mints or revokes the signed-in person's
|
|
4
|
-
* pairing, never anyone else's. */
|
|
5
1
|
import type { Store } from "./store.js";
|
|
6
2
|
import { type Html } from "./html.js";
|
|
7
3
|
/** How long a written reply may wait to be sent before it shows here with a Retry. */
|
|
@@ -1,36 +1,63 @@
|
|
|
1
|
+
/** The person's own Telegram pairing, from the console: one private chat per
|
|
2
|
+
* teammate with the installation's bot (v72). The bot token itself stays on
|
|
3
|
+
* Chat apps and alerts; this page only mints or revokes the signed-in person's
|
|
4
|
+
* pairing, never anyone else's. Laid out as three steps: token, pair, check. */
|
|
5
|
+
import { settingsSubPage } from "./settings-groups-ui.js";
|
|
1
6
|
import { html, joinHtml, postForm } from "./html.js";
|
|
7
|
+
import { acts, btn, codeLine, note, notice, settingsRecord, settingsSection } from "./server/kit.js";
|
|
8
|
+
import { kitSectionForm } from "./settings-kit.js";
|
|
2
9
|
/** How long a written reply may wait to be sent before it shows here with a Retry. */
|
|
3
10
|
export const UNSENT_REPLY_MS = 2 * 60_000;
|
|
4
11
|
export function telegramSettingsHtml(store, botId, who, options = {}) {
|
|
5
|
-
const
|
|
6
|
-
const
|
|
7
|
-
let content;
|
|
12
|
+
const problem = options.problem === undefined ? "" : notice(options.problem);
|
|
13
|
+
const group = note(html `Each teammate pairs their own private chat. To connect a group, a conversation manager sends <code class="k-mono">/team</code> there and chooses a conversation. Everyone in the group can read its replies.`);
|
|
8
14
|
if (botId === null) {
|
|
9
|
-
|
|
15
|
+
return settingsSubPage("alerts", "Telegram", joinHtml([
|
|
16
|
+
problem,
|
|
17
|
+
settingsSection({ id: "telegram-bot", title: "1. Bot token", state: " · Not saved",
|
|
18
|
+
lead: "No Telegram bot is connected yet. An approver saves the bot token first.",
|
|
19
|
+
body: acts(btn("Open Chat apps and alerts", { sm: true, href: "/settings/alerts#telegram-token" })) }),
|
|
20
|
+
settingsSection({ id: "telegram-pair", title: "2. Pair your phone", body: note("Once the bot token is saved, pair your phone here.") }),
|
|
21
|
+
group,
|
|
22
|
+
]));
|
|
23
|
+
}
|
|
24
|
+
const bindings = store.liveTelegramBindings(botId);
|
|
25
|
+
const mine = bindings.filter(binding => binding.approver === who);
|
|
26
|
+
const others = bindings.length - mine.length;
|
|
27
|
+
const paired = mine.length > 0;
|
|
28
|
+
const teammates = others === 0 ? "No teammates are paired yet." : `${others} teammate${others === 1 ? " is" : "s are"} paired.`;
|
|
29
|
+
const bot = settingsSection({ id: "telegram-bot", title: "1. Bot token", state: " · Saved",
|
|
30
|
+
body: note(html `Saved on <a class="k-link" href="/settings/alerts#telegram-token">Chat apps and alerts</a>.`) });
|
|
31
|
+
let pair;
|
|
32
|
+
let check;
|
|
33
|
+
if (options.code !== undefined) {
|
|
34
|
+
// The code shows once, on this page only: selectable text, never in a link or an address.
|
|
35
|
+
pair = settingsSection({ id: "telegram-pair", title: "2. Pair your phone", state: " · Code ready",
|
|
36
|
+
lead: "Send this to the bot in a private Telegram chat within 10 minutes:",
|
|
37
|
+
body: joinHtml([codeLine(`/pair ${options.code}`), note("The code works once. That chat then answers as you, so treat it like your sign-in.")]) });
|
|
38
|
+
check = settingsSection({ id: "telegram-check", title: "3. Check it works",
|
|
39
|
+
lead: "Once you've sent it, this page says your phone is paired.",
|
|
40
|
+
body: acts(btn("Check connection", { sm: true, href: "/settings/telegram" })) });
|
|
10
41
|
}
|
|
11
42
|
else {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
43
|
+
pair = settingsSection({ id: "telegram-pair", title: "2. Pair your phone", state: paired ? " · Paired" : " · Not paired",
|
|
44
|
+
lead: `${paired ? "Your phone is paired." : "Your phone is not paired."} ${teammates}`,
|
|
45
|
+
body: paired
|
|
46
|
+
? kitSectionForm({ proof: "telegram-unpair", action: "/settings/telegram/unpair", id: "telegram-unpair-form", save: { label: "Unpair my phone", variant: "danger" },
|
|
47
|
+
confirm: "Unpair your phone from Telegram", body: note("This phone will lose access. Its existing Telegram buttons will stop working.") })
|
|
48
|
+
: kitSectionForm({ proof: "telegram-pair", action: "/settings/telegram/pair", id: "telegram-pair-form", native: true, save: { label: "Pair my phone" },
|
|
49
|
+
confirm: "Pair your phone with Telegram", body: note("You get a one-time code to send to the bot.") }) });
|
|
16
50
|
// A reply the assistant wrote that has not reached this person's phone for two minutes.
|
|
17
51
|
const unsent = store.unsentTelegramReplies(botId, who, new Date((options.now ?? new Date()).getTime() - UNSENT_REPLY_MS));
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
}
|
|
28
|
-
else {
|
|
29
|
-
parts.push(post("unpair", html `${password}<p class="meta">This phone will lose access. Its existing Telegram buttons will stop working.</p><button type="submit" class="danger">Unpair my phone</button>`));
|
|
30
|
-
}
|
|
31
|
-
content = joinHtml(parts);
|
|
52
|
+
const oldest = unsent[0];
|
|
53
|
+
check = settingsSection({ id: "telegram-check", title: "3. Check it works",
|
|
54
|
+
lead: paired ? html `In your private chat with the bot, send <code class="k-mono">/status</code>. It answers without an AI model.` : "Pair your phone first.",
|
|
55
|
+
body: oldest === undefined ? "" : settingsRecord({
|
|
56
|
+
title: unsent.length === 1 ? html `A reply hasn't reached your phone` : html `${unsent.length} replies haven't reached your phone`,
|
|
57
|
+
sub: `Trying since ${oldest.since.slice(11, 16)} UTC${oldest.error === null ? "" : `: ${oldest.error}`}`,
|
|
58
|
+
action: postForm("/settings/telegram/retry", btn("Retry", { sm: true, type: "submit" })),
|
|
59
|
+
attrs: { role: "status", "data-unsent-replies": true },
|
|
60
|
+
}) });
|
|
32
61
|
}
|
|
33
|
-
|
|
34
|
-
content = html `<p role="status">${options.problem}</p>${content}`;
|
|
35
|
-
return html `<h1>Telegram</h1>${content}<p class="meta">Each teammate pairs their own private chat. To connect a group, a conversation manager sends <code>/team</code> there and chooses a conversation. Everyone in the group can read its replies.</p>`;
|
|
62
|
+
return settingsSubPage("alerts", "Telegram", joinHtml([problem, bot, pair, check, group]));
|
|
36
63
|
}
|