toolroll 0.9.25 → 0.9.27
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 +25 -0
- package/THIRD_PARTY_NOTICES.md +41 -0
- package/dist/brand-icons.d.ts +168 -0
- package/dist/brand-icons.js +32 -0
- package/dist/brand-mark.d.ts +27 -0
- package/dist/brand-mark.js +49 -0
- package/dist/browser/THIRD_PARTY_NOTICES.txt +41 -0
- package/dist/browser/workspace.css +1 -1
- package/dist/browser/workspace.js +21 -23
- package/dist/browser-workspace.d.ts +17 -0
- package/dist/chat-delivery.js +3 -3
- package/dist/chat-flow.d.ts +4 -2
- package/dist/chat-flow.js +26 -4
- package/dist/flow-cadence.d.ts +42 -0
- package/dist/flow-cadence.js +78 -0
- package/dist/flow-items.d.ts +46 -0
- package/dist/flow-items.js +152 -0
- package/dist/flow-send.d.ts +5 -0
- package/dist/flow-send.js +30 -4
- package/dist/flows-ui.js +5 -1
- package/dist/integrations-ui.js +10 -3
- package/dist/integrations.d.ts +2 -0
- package/dist/integrations.js +2 -2
- package/dist/mate-tools.js +1 -1
- package/dist/mcp-connect.d.ts +22 -3
- package/dist/mcp-connect.js +48 -12
- package/dist/operate.d.ts +3 -1
- package/dist/operate.js +95 -27
- package/dist/project-tools.d.ts +15 -0
- package/dist/project-tools.js +14 -1
- package/dist/result-shots.d.ts +5 -1
- package/dist/result-shots.js +33 -3
- package/dist/scout.d.ts +3 -2
- package/dist/scout.js +11 -9
- package/dist/serve.d.ts +2 -0
- package/dist/serve.js +85 -28
- package/dist/setup-guide.d.ts +2 -2
- package/dist/telegram.js +37 -6
- package/dist/tools-ui.d.ts +12 -3
- package/dist/tools-ui.js +37 -14
- package/package.json +3 -1
package/dist/integrations-ui.js
CHANGED
|
@@ -6,12 +6,13 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { STATE_WORDS } from "./integrations.js";
|
|
8
8
|
import { whenUtc } from "./when-html.js";
|
|
9
|
+
import { brandMarkHtml } from "./brand-mark.js";
|
|
9
10
|
const e = (value) => String(value ?? "").replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
10
11
|
const when = (at) => whenUtc(at);
|
|
11
12
|
export const INTEGRATIONS_CSS = `.integrations{max-width:760px;min-width:0}.integrations h2{margin:24px 0 6px;font-size:.9375rem}` +
|
|
12
13
|
`.integrations .integration{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:4px 16px;align-items:start;padding:12px 0;border-top:1px solid var(--so-line);min-width:0}` +
|
|
13
14
|
`.integrations .integration>:not(.integration-action),.integrations .integration-body>*{grid-column:1}.integrations .integration-body{display:contents}.integrations .integration:first-of-type{border-top:0}.integrations .integration-head{margin:0;display:flex;flex-wrap:wrap;gap:6px 10px;align-items:center;min-width:0}` +
|
|
14
|
-
`.integrations .integration-head strong{overflow-wrap:anywhere}.integrations .integration p{margin:2px 0 0;min-width:0;overflow-wrap:anywhere}` +
|
|
15
|
+
`.integrations .integration .integration-body>*{margin-left:calc(var(--brand-mark) + 10px)}.integrations .integration-head strong{overflow-wrap:anywhere}.integrations .integration p{margin:2px 0 0;min-width:0;overflow-wrap:anywhere}` +
|
|
15
16
|
`.integrations .integration-action{grid-column:2;grid-row:1 / span 3;align-self:center;margin:0}.integrations .integration-action button,.integrations .integration-action .button-link{min-height:44px;white-space:nowrap}` +
|
|
16
17
|
`.integrations .integration .integration-action button{width:auto}.integrations .integration .integration-action a.integration-quiet{background:var(--so-paper);color:var(--so-ink);border:1px solid var(--so-input-line)}` +
|
|
17
18
|
`.integrations .integration-fix{color:var(--so-danger)}.integrations code{overflow-wrap:anywhere}.integrations details{margin-top:4px;font-size:.8125rem}.integrations summary{cursor:pointer;padding:4px 0}` +
|
|
@@ -21,7 +22,7 @@ export const INTEGRATIONS_CSS = `.integrations{max-width:760px;min-width:0}.inte
|
|
|
21
22
|
`.integration-state--not-set-up,.integration-state--checking{color:var(--so-muted);background:var(--so-neutral-soft)}` +
|
|
22
23
|
// A phone: the action sits at the right of the name line, and the facts under it run on as one line.
|
|
23
24
|
`@media (max-width:760px){.integrations h2{margin:16px 0 0}.integrations .integration{gap:0 12px;padding:6px 0 8px}.integrations .integration-head{min-height:44px}` +
|
|
24
|
-
`.integrations .integration-action{grid-row:1;align-self:center}.integrations .integration-body{display:block;grid-column:1 / -1;line-height:1.35}` +
|
|
25
|
+
`.integrations .integration-action{grid-row:1;align-self:center}.integrations .integration-body{display:block;grid-column:1 / -1;line-height:1.35;padding-left:calc(var(--brand-mark) + 10px)}.integrations .integration .integration-body>*{margin-left:0}` +
|
|
25
26
|
`.integrations .integration-body>p{margin:0}.integrations .integration-body>p.meta{display:inline}.integrations .integration-body>p.meta+p.meta::before{content:" · "}` +
|
|
26
27
|
`.integrations .integration-body>:not(.meta){margin-top:4px}.integrations .integration-body>:not(.meta)+p.meta{display:block;margin-top:2px}.integrations details{margin-top:2px}}`;
|
|
27
28
|
const GROUPS = [
|
|
@@ -39,6 +40,12 @@ function action(one, csrf) {
|
|
|
39
40
|
return `<p class="integration-action"><a class="button-link${a.kind === "setup" ? " integration-quiet" : ""}" href="${e(a.href)}">${a.label}</a></p>`;
|
|
40
41
|
return "";
|
|
41
42
|
}
|
|
43
|
+
/** Which mark a row shows: email is Gmail's only when the account is a Gmail address; a custom tool is a letter. */
|
|
44
|
+
function markFor(one) {
|
|
45
|
+
if (one.custom)
|
|
46
|
+
return null;
|
|
47
|
+
return one.key === "email" && /@(gmail|googlemail)\.com$/i.test(one.account ?? "") ? "gmail" : one.key;
|
|
48
|
+
}
|
|
42
49
|
function row(one, csrf) {
|
|
43
50
|
const badge = !one.checked && one.state === "connected"
|
|
44
51
|
? `<span class="integration-state integration-state--checking"><i aria-hidden="true"></i>Checking</span>`
|
|
@@ -53,7 +60,7 @@ function row(one, csrf) {
|
|
|
53
60
|
? `<p class="meta">Failed ${when(one.lastErrorAt ?? "")}</p>`
|
|
54
61
|
: `<details><summary>Last error ${one.lastErrorAt === null ? "" : when(one.lastErrorAt)}</summary><p class="meta">${e(one.lastError)}</p></details>`;
|
|
55
62
|
return `<div class="integration" data-integration="${e(one.key)}" data-state="${one.state}">` +
|
|
56
|
-
`<p class="integration-head"
|
|
63
|
+
`<p class="integration-head">${brandMarkHtml(markFor(one), one.name, one.state === "connected")}<strong>${e(one.name)}</strong> ${badge}</p>` +
|
|
57
64
|
action(one, csrf) +
|
|
58
65
|
`<div class="integration-body">${facts === "" ? "" : `<p class="meta">${facts}</p>`}${fix}${run}${history}${lastError}</div></div>`;
|
|
59
66
|
}
|
package/dist/integrations.d.ts
CHANGED
|
@@ -36,6 +36,8 @@ export type Integration = {
|
|
|
36
36
|
lastErrorAt: string | null;
|
|
37
37
|
usedBy: string[];
|
|
38
38
|
action: IntegrationAction;
|
|
39
|
+
/** A project's own MCP tool: it shows a letter, never a logo matched by name. */
|
|
40
|
+
custom?: true;
|
|
39
41
|
};
|
|
40
42
|
export declare const STATE_WORDS: Record<IntegrationState, string>;
|
|
41
43
|
export type ProbeResult = {
|
package/dist/integrations.js
CHANGED
|
@@ -383,7 +383,7 @@ function targets(io) {
|
|
|
383
383
|
const href = `/settings/tools?repo=${encodeURIComponent(repo)}#tool-${encodeURIComponent(tool.name)}`;
|
|
384
384
|
const test = tool.lastTest;
|
|
385
385
|
list.push({
|
|
386
|
-
key: `mcp:${repo}:${tool.name}`, group: "tools", name: tool.name, configured: true, account: basename(repo),
|
|
386
|
+
key: `mcp:${repo}:${tool.name}`, group: "tools", name: tool.name, configured: true, account: basename(repo), ...(tool.source === "custom" ? { custom: true } : {}),
|
|
387
387
|
setup: place(href), fix: place(href), usedBy: [`Tasks in ${basename(repo)}`],
|
|
388
388
|
activity: test === null ? NO_ACTIVITY : test.ok ? { okAt: test.at, error: null, errorAt: null, failing: false } : { okAt: null, error: test.problem, errorAt: test.at, failing: false },
|
|
389
389
|
probe: async (check) => {
|
|
@@ -432,7 +432,7 @@ function targets(io) {
|
|
|
432
432
|
}
|
|
433
433
|
// ---------------------------------------------------------------- the list
|
|
434
434
|
function combine(target, row) {
|
|
435
|
-
const base = { key: target.key, group: target.group, name: target.name, usedBy: target.usedBy };
|
|
435
|
+
const base = { key: target.key, group: target.group, name: target.name, usedBy: target.usedBy, ...(target.custom ? { custom: true } : {}) };
|
|
436
436
|
const checked = target.probe === null || row !== undefined;
|
|
437
437
|
const lastSuccessAt = later(row?.okAt ?? null, target.activity.okAt);
|
|
438
438
|
const checkError = row?.error ?? null, checkErrorAt = row?.errorAt ?? null;
|
package/dist/mate-tools.js
CHANGED
|
@@ -761,7 +761,7 @@ export const MATE_TOOLS = [
|
|
|
761
761
|
},
|
|
762
762
|
{
|
|
763
763
|
name: "get_project_tools",
|
|
764
|
-
description: "Read a project's tools (the MCP servers its builds get, and only those), the services that connect by signing in, the common tools list and servers found on this computer. A service in connectBySigningIn (Stripe, Notion, Linear, Sentry, Jira…) connects in one click: the person signs in on the service's own page, so give them its connect link and never propose tool_add for it. Add/remove others with propose_action tool_add/tool_remove; secrets are set only on the Tools page.",
|
|
764
|
+
description: "Read a project's tools (the MCP servers its builds get, and only those), the services that connect by signing in, the common tools list and servers found on this computer. A service in connectBySigningIn (Stripe, Notion, Linear, Sentry, Jira…) connects in one click: the person signs in on the service's own page, so give them its connect link (for this repo; another project has its own) and never propose tool_add for it. When saying a service is connected, name the project it is connected to (its list_repos name). Add/remove others with propose_action tool_add/tool_remove; secrets are set only on the Tools page.",
|
|
765
765
|
inputSchema: schema({ repo: REPO_ARG }, ["repo"]),
|
|
766
766
|
handle: (ctx, args) => {
|
|
767
767
|
const repo = repoPathOf(ctx.who, args["repo"]);
|
package/dist/mcp-connect.d.ts
CHANGED
|
@@ -12,7 +12,17 @@ export type OneClick = {
|
|
|
12
12
|
about: string;
|
|
13
13
|
reads?: readonly string[];
|
|
14
14
|
};
|
|
15
|
-
/**
|
|
15
|
+
/**
|
|
16
|
+
* Services that connect this way: their servers speak streamable HTTP and
|
|
17
|
+
* advertise registering clients on the spot. Advertising isn't accepting:
|
|
18
|
+
* Figma's server advertises it, but its registration answers 403 to any app
|
|
19
|
+
* Figma hasn't approved, so Figma connects through its desktop app instead
|
|
20
|
+
* (LOCAL_APPS). A service is listed only when its registration accepts
|
|
21
|
+
* Toolroll; mcp-connect.test.ts replays each one's answer (no live calls).
|
|
22
|
+
*
|
|
23
|
+
* Signed in end to end, live: Mobbin. The rest register in replayed checks
|
|
24
|
+
* only, until someone signs in to each for real.
|
|
25
|
+
*/
|
|
16
26
|
export declare const ONE_CLICK: readonly OneClick[];
|
|
17
27
|
/**
|
|
18
28
|
* The list, with one service standing in on this computer when the person
|
|
@@ -22,12 +32,19 @@ export declare const ONE_CLICK: readonly OneClick[];
|
|
|
22
32
|
*/
|
|
23
33
|
export declare function oneClickServices(environment?: NodeJS.ProcessEnv): typeof ONE_CLICK;
|
|
24
34
|
export declare const oneClickOf: (id: string) => OneClick | null;
|
|
25
|
-
/**
|
|
35
|
+
/** An app on this computer that Connect adds without a sign-in (LOCAL_APPS): its tool, name and one line of how. */
|
|
36
|
+
export declare const localConnectOf: (id: string) => {
|
|
37
|
+
id: string;
|
|
38
|
+
label: string;
|
|
39
|
+
about: string;
|
|
40
|
+
} | null;
|
|
41
|
+
/** Where each service stands in a project: connected, open to connect, or its name taken by a tool set up another way. `local` connects with no sign-in. */
|
|
26
42
|
export declare function connectionsOf(store: Store, repo: string): {
|
|
27
43
|
id: string;
|
|
28
44
|
label: string;
|
|
29
45
|
about: string;
|
|
30
46
|
state: "connected" | "open" | "taken";
|
|
47
|
+
local?: true;
|
|
31
48
|
}[];
|
|
32
49
|
/**
|
|
33
50
|
* Whether an action only reads: its name has a reading verb, and every other
|
|
@@ -39,7 +56,8 @@ export declare function readsOnly(action: string, subjects: readonly string[]):
|
|
|
39
56
|
* What a research step may use of a project's connected services: each
|
|
40
57
|
* signed-in service's read-only actions, as its last test listed them —
|
|
41
58
|
* by server (`reads`, for Codex's `enabled_tools`) and named the way Claude
|
|
42
|
-
* allows them (`mcp__<service>__<action>`).
|
|
59
|
+
* allows them (`mcp__<service>__<action>`). `local` is the loopback host of
|
|
60
|
+
* each app on this computer it reads (its proxy lets only those through). A service not connected, without
|
|
43
61
|
* read-only actions, or left out of this run (`launched`) gives nothing.
|
|
44
62
|
*/
|
|
45
63
|
export type ResearchTools = {
|
|
@@ -49,6 +67,7 @@ export type ResearchTools = {
|
|
|
49
67
|
}[];
|
|
50
68
|
allowed: string[];
|
|
51
69
|
reads: Record<string, string[]>;
|
|
70
|
+
local: string[];
|
|
52
71
|
};
|
|
53
72
|
export declare function researchToolsOf(tools: readonly ProjectTool[], launched?: ReadonlySet<string> | null): ResearchTools;
|
|
54
73
|
/** Where a service sends the person back after they sign in. */
|
package/dist/mcp-connect.js
CHANGED
|
@@ -13,9 +13,20 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import { createHash, randomBytes } from "node:crypto";
|
|
15
15
|
import { homedir } from "node:os";
|
|
16
|
-
import {
|
|
16
|
+
import { basename } from "node:path";
|
|
17
|
+
import { LOCAL_APPS, addToolTo, catalogTool, localAppOf, projectToolsOf, readToolSecrets, setToolSecrets, testToolOf } from "./project-tools.js";
|
|
17
18
|
import { envValue } from "./names.js";
|
|
18
|
-
/**
|
|
19
|
+
/**
|
|
20
|
+
* Services that connect this way: their servers speak streamable HTTP and
|
|
21
|
+
* advertise registering clients on the spot. Advertising isn't accepting:
|
|
22
|
+
* Figma's server advertises it, but its registration answers 403 to any app
|
|
23
|
+
* Figma hasn't approved, so Figma connects through its desktop app instead
|
|
24
|
+
* (LOCAL_APPS). A service is listed only when its registration accepts
|
|
25
|
+
* Toolroll; mcp-connect.test.ts replays each one's answer (no live calls).
|
|
26
|
+
*
|
|
27
|
+
* Signed in end to end, live: Mobbin. The rest register in replayed checks
|
|
28
|
+
* only, until someone signs in to each for real.
|
|
29
|
+
*/
|
|
19
30
|
export const ONE_CLICK = [
|
|
20
31
|
{ id: "stripe", label: "Stripe", url: "https://mcp.stripe.com/", about: "Payments, customers, refunds and invoices." },
|
|
21
32
|
{ id: "notion", label: "Notion", url: "https://mcp.notion.com/mcp", about: "Pages and databases: search, read and write." },
|
|
@@ -36,8 +47,6 @@ export const ONE_CLICK = [
|
|
|
36
47
|
// Mobbin's sign-in is its Supabase auth server, on another origin: its protected-resource metadata names it.
|
|
37
48
|
{ id: "mobbin", label: "Mobbin", url: "https://api.mobbin.com/mcp", about: "Real app screens and flows to learn from.",
|
|
38
49
|
reads: ["screen", "flow", "app", "site", "element", "pattern", "ui", "ios", "android", "web"] },
|
|
39
|
-
{ id: "figma", label: "Figma", url: "https://mcp.figma.com/mcp", about: "Design files and frames.",
|
|
40
|
-
reads: ["file", "frame", "node", "image", "screenshot", "design", "context", "metadata", "variable", "defs", "component", "style", "code", "connect", "map", "figjam"] },
|
|
41
50
|
{ id: "posthog", label: "PostHog", url: "https://mcp.posthog.com/mcp", about: "Product analytics, funnels and events.",
|
|
42
51
|
reads: ["insight", "run", "event", "definition", "property", "properties"] },
|
|
43
52
|
{ id: "betterstack", label: "Better Stack", url: "https://mcp.betterstack.com", about: "Uptime checks and incidents.",
|
|
@@ -65,13 +74,28 @@ export function oneClickServices(environment = process.env) {
|
|
|
65
74
|
return ONE_CLICK.some(one => one.id === id) ? ONE_CLICK.map(one => one.id === id ? standIn : one) : [...ONE_CLICK, standIn];
|
|
66
75
|
}
|
|
67
76
|
export const oneClickOf = (id) => oneClickServices().find(one => one.id === id) ?? null;
|
|
68
|
-
/**
|
|
77
|
+
/** What to use when a service won't let Toolroll sign in by itself. */
|
|
78
|
+
const INSTEAD = { figma: "Connect Figma (desktop app) instead." };
|
|
79
|
+
/** An app on this computer that Connect adds without a sign-in (LOCAL_APPS): its tool, name and one line of how. */
|
|
80
|
+
export const localConnectOf = (id) => {
|
|
81
|
+
const app = LOCAL_APPS.find(one => one.tool === id), tool = catalogTool(id);
|
|
82
|
+
return app === undefined || tool === null ? null : { id, label: tool.label, about: app.connect };
|
|
83
|
+
};
|
|
84
|
+
/** Where each service stands in a project: connected, open to connect, or its name taken by a tool set up another way. `local` connects with no sign-in. */
|
|
69
85
|
export function connectionsOf(store, repo) {
|
|
70
86
|
const tools = projectToolsOf(store, repo);
|
|
71
|
-
|
|
87
|
+
const signIn = oneClickServices().map(service => {
|
|
72
88
|
const had = tools.find(one => one.name === service.id);
|
|
73
89
|
return { id: service.id, label: service.label, about: service.about, state: had === undefined ? "open" : had.spec.url === service.url && had.spec.bearer === ACCESS ? "connected" : "taken" };
|
|
74
90
|
});
|
|
91
|
+
const local = LOCAL_APPS.flatMap(app => {
|
|
92
|
+
const one = localConnectOf(app.tool);
|
|
93
|
+
if (one === null)
|
|
94
|
+
return [];
|
|
95
|
+
const had = tools.find(tool => tool.name === one.id);
|
|
96
|
+
return [{ ...one, state: had === undefined ? "open" : localAppOf(had.spec) !== null ? "connected" : "taken", local: true }];
|
|
97
|
+
});
|
|
98
|
+
return [...signIn, ...local];
|
|
75
99
|
}
|
|
76
100
|
/** The verbs that only read. An action named by anything else is withheld from research. */
|
|
77
101
|
const READING = new Set(["list", "get", "search", "find", "read", "query", "describe", "fetch", "view", "export-image"]);
|
|
@@ -91,10 +115,15 @@ export function readsOnly(action, subjects) {
|
|
|
91
115
|
return words.some(word => READING.has(word)) && words.every(word => READING.has(word) || QUALIFIERS.has(word) || subject(word));
|
|
92
116
|
}
|
|
93
117
|
export function researchToolsOf(tools, launched = null) {
|
|
94
|
-
const research = { services: [], allowed: [], reads: {} };
|
|
95
|
-
|
|
118
|
+
const research = { services: [], allowed: [], reads: {}, local: [] };
|
|
119
|
+
// Signed-in services, then apps on this computer (each exactly as listed: its own address, no key).
|
|
120
|
+
const readable = [
|
|
121
|
+
...oneClickServices().map(service => ({ id: service.id, label: service.label, reads: service.reads, is: (spec) => spec.url === service.url && spec.bearer === ACCESS })),
|
|
122
|
+
...LOCAL_APPS.map(app => ({ id: app.tool, label: catalogTool(app.tool)?.label ?? app.tool, reads: app.reads, is: (spec) => localAppOf(spec) === app })),
|
|
123
|
+
];
|
|
124
|
+
for (const service of readable) {
|
|
96
125
|
const tool = tools.find(one => one.name === service.id);
|
|
97
|
-
if (service.reads === undefined || tool === undefined ||
|
|
126
|
+
if (service.reads === undefined || tool === undefined || !service.is(tool.spec) || (launched !== null && !launched.has(tool.name)))
|
|
98
127
|
continue;
|
|
99
128
|
const reads = (tool.lastTest?.ok ? tool.lastTest.tools : []).filter(action => readsOnly(action, service.reads));
|
|
100
129
|
if (reads.length === 0)
|
|
@@ -102,6 +131,8 @@ export function researchToolsOf(tools, launched = null) {
|
|
|
102
131
|
research.services.push({ id: service.id, label: service.label });
|
|
103
132
|
research.allowed.push(...reads.map(action => `mcp__${service.id}__${action}`));
|
|
104
133
|
research.reads[service.id] = reads;
|
|
134
|
+
if (localAppOf(tool.spec) !== null)
|
|
135
|
+
research.local.push(new URL(tool.spec.url).host);
|
|
105
136
|
}
|
|
106
137
|
return research;
|
|
107
138
|
}
|
|
@@ -181,6 +212,9 @@ export async function startConnect(input, fetcher = fetch, now = Date.now()) {
|
|
|
181
212
|
try {
|
|
182
213
|
const registered = await fetcher(server.register, { method: "POST", headers: { "content-type": "application/json", accept: "application/json" }, signal: AbortSignal.timeout(10_000),
|
|
183
214
|
body: JSON.stringify({ client_name: "Toolroll", redirect_uris: [redirect], grant_types: ["authorization_code", "refresh_token"], response_types: ["code"], token_endpoint_auth_method: "none" }) });
|
|
215
|
+
// Figma's answer (Oct 2026): it registers only apps it has approved. Said before reading a body that may not be JSON.
|
|
216
|
+
if (registered.status === 401 || registered.status === 403)
|
|
217
|
+
return { ok: false, said: `${service.label} doesn't let other apps sign in this way yet.${INSTEAD[service.id] === undefined ? "" : ` ${INSTEAD[service.id]}`}` };
|
|
184
218
|
const body = await registered.json();
|
|
185
219
|
if (!registered.ok || typeof body.client_id !== "string")
|
|
186
220
|
return { ok: false, said: `${service.label} didn't let Toolroll register (HTTP ${registered.status}).` };
|
|
@@ -251,11 +285,13 @@ export async function finishConnect(store, visit, code, now, options = {}) {
|
|
|
251
285
|
}
|
|
252
286
|
setToolSecrets(visit.repo, spec.name, { [ACCESS]: tokens.access, [CLIENT]: visit.clientId, [TOKEN_URL]: visit.token, [RESOURCE]: visit.resource,
|
|
253
287
|
...(tokens.refresh === null ? {} : { [REFRESH]: tokens.refresh }), ...(tokens.expires === null ? {} : { [EXPIRES]: tokens.expires }), ...(visit.clientSecret === null ? {} : { [CLIENT_SECRET]: visit.clientSecret }) }, home);
|
|
288
|
+
// The success line names the project it went to: the page and chat may be looking at another.
|
|
289
|
+
const to = basename(visit.repo);
|
|
254
290
|
if (options.test === false)
|
|
255
|
-
return { ok: true, said: `${service.label} is connected.` };
|
|
291
|
+
return { ok: true, said: `${service.label} is connected to ${to}.` };
|
|
256
292
|
const tested = await testToolOf(store, visit.repo, spec.name, now, { home, ...(options.omitEnv === undefined ? {} : { omitEnv: options.omitEnv }) });
|
|
257
|
-
return tested?.ok ? { ok: true, said: `${service.label} is connected: ${tested.tools.length} action${tested.tools.length === 1 ? "" : "s"}. Let a teammate use it from its page.` }
|
|
258
|
-
: { ok: true, said: `${service.label} is signed in, but its test didn't pass yet: ${tested?.problem ?? "no answer"}.` };
|
|
293
|
+
return tested?.ok ? { ok: true, said: `${service.label} is connected to ${to}: ${tested.tools.length} action${tested.tools.length === 1 ? "" : "s"}. Let a teammate use it from its page.` }
|
|
294
|
+
: { ok: true, said: `${service.label} is signed in for ${to}, but its test didn't pass yet: ${tested?.problem ?? "no answer"}.` };
|
|
259
295
|
}
|
|
260
296
|
/** Keep connected services signed in: any token expiring within ten minutes is refreshed (the worker's pass, and before a teammate's call). */
|
|
261
297
|
export async function refreshConnections(store, repos, now, options = {}) {
|
package/dist/operate.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type IntegrationIo } from "./integrations.js";
|
|
2
2
|
import { type OnboardIo } from "./agent-onboard.js";
|
|
3
3
|
import { type TriggerIo } from "./flow-triggers.js";
|
|
4
|
-
import {
|
|
4
|
+
import type { StepIo } from "./flow-steps.js";
|
|
5
5
|
import type { FetchLike } from "./flow-share.js";
|
|
6
6
|
import { type Store } from "./store.js";
|
|
7
7
|
import { type DispatchAdapter } from "./sync.js";
|
|
@@ -61,6 +61,8 @@ export type OperateOptions = {
|
|
|
61
61
|
flowTriggerIo?: Partial<TriggerIo>;
|
|
62
62
|
/** Injected by tests: how flow check and update steps run commands and reach GitHub and Linear. */
|
|
63
63
|
flowStepIo?: Partial<StepIo>;
|
|
64
|
+
/** Injected by tests: how often a worker's flow passes run beside its builds (default FLOW_EVERY_MS). */
|
|
65
|
+
flowEveryMs?: number;
|
|
64
66
|
/** Injected by tests: how `flows import` fetches a flow file's address. */
|
|
65
67
|
flowFetch?: FetchLike;
|
|
66
68
|
/** Injected by tests: how `integrations` checks reach services (fetch, gh, sign-in checks, mail servers). */
|
package/dist/operate.js
CHANGED
|
@@ -31,13 +31,11 @@ import { startCodexLimits } from "./codex-limits.js";
|
|
|
31
31
|
import { packageVersion, startMonitoring, targetOf } from "./monitoring.js";
|
|
32
32
|
import { origin, readMonitoring } from "./monitoring-settings.js";
|
|
33
33
|
import { logEvent } from "./log.js";
|
|
34
|
-
import {
|
|
35
|
-
import { readHooksBase
|
|
36
|
-
import { watchFlowReplies } from "./flow-replies.js";
|
|
34
|
+
import { FLOW_EVERY_MS, flowHousekeeping, moveCards, moveCardsAfter } from "./flow-cadence.js";
|
|
35
|
+
import { readHooksBase } from "./flow-triggers.js";
|
|
37
36
|
import { sendTeammateSummaries } from "./teammate-admin.js";
|
|
38
37
|
import { runRequestedUndos, sendTeammateWeeklies } from "./teammate-week.js";
|
|
39
38
|
import { refreshConnections } from "./mcp-connect.js";
|
|
40
|
-
import { runFlowSteps } from "./flow-steps.js";
|
|
41
39
|
import { followDiscord } from "./discord.js";
|
|
42
40
|
import { followTeams } from "./teams.js";
|
|
43
41
|
import { loadDiscordCredentials } from "./discord-api.js";
|
|
@@ -835,6 +833,7 @@ export async function runOperate(command, argv, write, options = {}) {
|
|
|
835
833
|
...(options.dispatchAdapter === undefined ? {} : { dispatchAdapter: options.dispatchAdapter }),
|
|
836
834
|
...(options.flowTriggerIo === undefined ? {} : { flowTriggerIo: options.flowTriggerIo }),
|
|
837
835
|
...(options.flowStepIo === undefined ? {} : { flowStepIo: options.flowStepIo }),
|
|
836
|
+
...(options.flowEveryMs === undefined ? {} : { flowEveryMs: options.flowEveryMs }),
|
|
838
837
|
...(options.flowFetch === undefined ? {} : { flowFetch: options.flowFetch }),
|
|
839
838
|
...(options.integrationIo === undefined ? {} : { integrationIo: options.integrationIo }),
|
|
840
839
|
...(options.shouldStop === undefined ? {} : { shouldStop: options.shouldStop }),
|
|
@@ -2007,6 +2006,24 @@ async function buildCommand(positional, flags, context) {
|
|
|
2007
2006
|
* run opens and no attempt is spent, reported as its own skip reason. */
|
|
2008
2007
|
class StaleAuthorization extends Error {
|
|
2009
2008
|
}
|
|
2009
|
+
/** How a worker's flow pass reaches outside: the tests' seams, or the real commands, network and folders. */
|
|
2010
|
+
function flowIoOf(context, pool, git, base) {
|
|
2011
|
+
const dir = dirname(context.databaseFile);
|
|
2012
|
+
return {
|
|
2013
|
+
triggers: { gh: context.flowTriggerIo?.gh ?? run, fetch: context.flowTriggerIo?.fetch ?? fetch, dir: context.flowTriggerIo?.dir ?? dir,
|
|
2014
|
+
// A schedule's script (v90) runs like a code step: the same runner, in a clean folder beside the step copies.
|
|
2015
|
+
shell: context.flowTriggerIo?.shell ?? context.flowStepIo?.shell ?? run, scratch: context.flowTriggerIo?.scratch ?? context.flowStepIo?.scratch ?? join(pool, "flow-checks"),
|
|
2016
|
+
...(context.flowTriggerIo?.mail === undefined ? {} : { mail: context.flowTriggerIo.mail }) },
|
|
2017
|
+
// Replies to cards' emails (v91): read about once a minute, and only while a card is in an email conversation.
|
|
2018
|
+
replies: { fetch: context.flowTriggerIo?.fetch ?? fetch, dir: context.flowTriggerIo?.dir ?? dir,
|
|
2019
|
+
...(context.flowTriggerIo?.mail === undefined ? {} : { mail: context.flowTriggerIo.mail }) },
|
|
2020
|
+
// Check and update steps run outside a model: an approved command in a fresh copy of the card's work, or a comment on the issue it came from.
|
|
2021
|
+
steps: { gh: context.flowStepIo?.gh ?? run, git: context.flowStepIo?.git ?? git, shell: context.flowStepIo?.shell ?? run, fetch: context.flowStepIo?.fetch ?? fetch,
|
|
2022
|
+
dir: context.flowStepIo?.dir ?? dir, scratch: context.flowStepIo?.scratch ?? join(pool, "flow-checks"), base,
|
|
2023
|
+
...(context.evidenceRoot === undefined ? {} : { evidenceRoot: context.evidenceRoot }) },
|
|
2024
|
+
...(context.evidenceRoot === undefined ? {} : { evidenceRoot: context.evidenceRoot }),
|
|
2025
|
+
};
|
|
2026
|
+
}
|
|
2010
2027
|
/**
|
|
2011
2028
|
* One scheduling pass: the M1 loop, without the human typing each step.
|
|
2012
2029
|
*
|
|
@@ -2130,26 +2147,13 @@ async function tickCommand(flags, context) {
|
|
|
2130
2147
|
// Triggers first: a schedule, GitHub, Linear or another flow may start
|
|
2131
2148
|
// cards, which then move in the same pass. Checking an outside service is
|
|
2132
2149
|
// `gh` or one HTTPS request, never a model, and only when it is due.
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
// before its next step runs.
|
|
2141
|
-
const replyPass = buildsOnly || context.shouldStop?.() === true || context.shouldPauseAdmission?.() === true ? { read: 0, taken: 0, problem: null }
|
|
2142
|
-
: await watchFlowReplies(store, clock(), { fetch: context.flowTriggerIo?.fetch ?? fetch, dir: context.flowTriggerIo?.dir ?? dirname(context.databaseFile),
|
|
2143
|
-
...(context.flowTriggerIo?.mail === undefined ? {} : { mail: context.flowTriggerIo.mail }) });
|
|
2144
|
-
// Check and update steps run outside a model: an approved command in a
|
|
2145
|
-
// fresh copy of the card's work, or a comment on the issue it came from.
|
|
2146
|
-
const stepPass = buildsOnly || context.shouldStop?.() === true || context.shouldPauseAdmission?.() === true ? { ran: 0, problems: [] }
|
|
2147
|
-
: await runFlowSteps(store, repo, clock(), {
|
|
2148
|
-
gh: context.flowStepIo?.gh ?? run, git: context.flowStepIo?.git ?? git, shell: context.flowStepIo?.shell ?? run, fetch: context.flowStepIo?.fetch ?? fetch,
|
|
2149
|
-
dir: context.flowStepIo?.dir ?? dirname(context.databaseFile), scratch: context.flowStepIo?.scratch ?? join(pool, "flow-checks"), base,
|
|
2150
|
-
...(context.evidenceRoot === undefined ? {} : { evidenceRoot: context.evidenceRoot }),
|
|
2151
|
-
});
|
|
2152
|
-
const flowPass = buildsOnly ? { moved: 0, filed: [], problems: [] } : advanceFlows(store, repo, clock(), context.evidenceRoot === undefined ? {} : { evidenceRoot: context.evidenceRoot });
|
|
2150
|
+
// Each part is one at a time per project, so the worker's own flow cadence (runWatchLoop) and this pass never
|
|
2151
|
+
// run the same part twice at once.
|
|
2152
|
+
const housekeeping = buildsOnly ? null : await flowHousekeeping(store, repo, clock, flowIoOf(context, pool, git, base), () => context.shouldStop?.() === true || context.shouldPauseAdmission?.() === true);
|
|
2153
|
+
const triggerPass = housekeeping?.triggers ?? { added: 0, checked: 0, problems: [] };
|
|
2154
|
+
const replyPass = housekeeping?.replies ?? { read: 0, taken: 0, problem: null };
|
|
2155
|
+
const stepPass = housekeeping?.steps ?? { ran: 0, problems: [] };
|
|
2156
|
+
const flowPass = housekeeping?.flows ?? { moved: 0, filed: [], problems: [] };
|
|
2153
2157
|
// Each AI teammate's daily summary to its manager (v92), once, after 5 pm; its weekly report (v97), Monday mornings.
|
|
2154
2158
|
if (!buildsOnly)
|
|
2155
2159
|
try {
|
|
@@ -2177,9 +2181,19 @@ async function tickCommand(flags, context) {
|
|
|
2177
2181
|
flowPass.problems.push(`tool sign-ins: ${error instanceof Error ? error.message : "could not renew"}`);
|
|
2178
2182
|
}
|
|
2179
2183
|
}
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2184
|
+
// A build, research or check finishing below moves its card at once (moveCardsAfter), not a pass later: by then
|
|
2185
|
+
// other builds may hold the worker for minutes.
|
|
2186
|
+
let settled = 0;
|
|
2187
|
+
const settleFinished = () => {
|
|
2188
|
+
const finished = dispatched.slice(settled).filter(one => one.outcome !== "skipped" && one.outcome !== "contest").map(one => one.id);
|
|
2189
|
+
settled = dispatched.length;
|
|
2190
|
+
if (finished.length === 0)
|
|
2191
|
+
return;
|
|
2192
|
+
const after = moveCardsAfter(store, repo, finished, clock(), context.evidenceRoot);
|
|
2193
|
+
flowPass.moved += after.moved;
|
|
2194
|
+
flowPass.filed.push(...after.filed);
|
|
2195
|
+
flowPass.problems.push(...after.problems);
|
|
2196
|
+
};
|
|
2183
2197
|
// Tournament housekeeping before the ordinary pass (stage 4): interrupted
|
|
2184
2198
|
// races recover by CAS, and an ANSWERED question re-admits its parked
|
|
2185
2199
|
// agent — fresh claim, fresh slot, remaining budget only, the SAME
|
|
@@ -2544,6 +2558,7 @@ async function tickCommand(flags, context) {
|
|
|
2544
2558
|
const attestedThisPass = new Map();
|
|
2545
2559
|
let untakenTrial = null;
|
|
2546
2560
|
for (const ref of ready) {
|
|
2561
|
+
settleFinished();
|
|
2547
2562
|
untakenTrial?.giveBack();
|
|
2548
2563
|
untakenTrial = null;
|
|
2549
2564
|
// The build budget governs UNATTENDED admissions (round-1 finding 4):
|
|
@@ -3913,6 +3928,7 @@ async function tickCommand(flags, context) {
|
|
|
3913
3928
|
// only claim, worktree, and rail, and its run then re-proves the
|
|
3914
3929
|
// chain-entry dispatch proof inside build() before any money moves.
|
|
3915
3930
|
for (const pending of buildsOnly ? [] : store.pendingChainAdmissions(repo)) {
|
|
3931
|
+
settleFinished();
|
|
3916
3932
|
if (context.shouldStop?.() === true || context.shouldPauseAdmission?.() === true)
|
|
3917
3933
|
break;
|
|
3918
3934
|
if (built >= max)
|
|
@@ -4323,6 +4339,10 @@ async function tickCommand(flags, context) {
|
|
|
4323
4339
|
// Existing worker pass owns durable lead handoffs; reads never create events.
|
|
4324
4340
|
if (!buildsOnly)
|
|
4325
4341
|
syncAssignmentHandoffs(store, clock(), auth.runner.repos, context.evidenceRoot);
|
|
4342
|
+
settleFinished();
|
|
4343
|
+
const replied = replyPass.taken > 0 || replyPass.problem !== null;
|
|
4344
|
+
const flows = flowPass.moved + flowPass.filed.length + flowPass.problems.length + triggerPass.added + triggerPass.problems.length + stepPass.ran + stepPass.problems.length === 0 && !replied ? {} : { flows: { ...flowPass,
|
|
4345
|
+
...(triggerPass.added + triggerPass.problems.length === 0 ? {} : { triggers: triggerPass }), ...(stepPass.ran + stepPass.problems.length === 0 ? {} : { steps: stepPass }), ...(replied ? { replies: replyPass } : {}) } };
|
|
4326
4346
|
const summary = () => {
|
|
4327
4347
|
const lines = [`Considered ${considered}, built ${built}, parked ${parked}, broke ${broke}.`];
|
|
4328
4348
|
for (const entry of routines) {
|
|
@@ -7544,6 +7564,52 @@ async function runWatchLoop(args) {
|
|
|
7544
7564
|
const ran = await runWaitingChecks(store, context.evidenceRoot, { now: context.clock, shouldStop: stopping });
|
|
7545
7565
|
if (ran > 0)
|
|
7546
7566
|
progress(`watch: ran ${ran} follow-up check(s)`);
|
|
7567
|
+
// A finished check may send a build's card down its failure path: now, not on the next beat.
|
|
7568
|
+
if (ran > 0)
|
|
7569
|
+
moveCards(store, repo, context.clock(), context.evidenceRoot);
|
|
7570
|
+
},
|
|
7571
|
+
});
|
|
7572
|
+
// Flows on their own cadence (flow-cadence.ts), not between builds: while builds hold every slot of this
|
|
7573
|
+
// project, a card whose step finished still moves within one beat. Moves run apart from triggers, replies
|
|
7574
|
+
// and steps, so a long check script doesn't hold them either. Neither starts a build: a card entering a
|
|
7575
|
+
// build zone files its task, which waits for a free slot like any other.
|
|
7576
|
+
const flowEveryMs = context.flowEveryMs ?? FLOW_EVERY_MS;
|
|
7577
|
+
const flowPool = text(flags, "pool") ?? join(dirname(databasePath(process.env, homedir())), "worktrees");
|
|
7578
|
+
const flowGit = context.gitRunner ?? run;
|
|
7579
|
+
const flowsDid = (moved, filed, did) => {
|
|
7580
|
+
if (moved + filed === 0)
|
|
7581
|
+
return;
|
|
7582
|
+
store.bumpWake();
|
|
7583
|
+
progress(`watch: ${did}`);
|
|
7584
|
+
};
|
|
7585
|
+
const flowMoves = startMaintenance({
|
|
7586
|
+
intervalMs: flowEveryMs,
|
|
7587
|
+
shouldStop: stopping,
|
|
7588
|
+
onError: error => progress(`watch: flow cards couldn't move — ${describe(error)}; they try again shortly`),
|
|
7589
|
+
run: async () => {
|
|
7590
|
+
if (paused())
|
|
7591
|
+
return;
|
|
7592
|
+
const moved = moveCards(store, repo, context.clock(), context.evidenceRoot);
|
|
7593
|
+
flowsDid(moved.moved, moved.filed.length, `moved ${moved.moved} flow card(s), filed ${moved.filed.length} task(s)`);
|
|
7594
|
+
},
|
|
7595
|
+
});
|
|
7596
|
+
const flowSteps = startMaintenance({
|
|
7597
|
+
intervalMs: flowEveryMs,
|
|
7598
|
+
shouldStop: stopping,
|
|
7599
|
+
onError: error => progress(`watch: a flow pass failed — ${describe(error)}; it runs again shortly`),
|
|
7600
|
+
run: async () => {
|
|
7601
|
+
if (paused())
|
|
7602
|
+
return;
|
|
7603
|
+
let base = text(flags, "base");
|
|
7604
|
+
if (base === undefined) {
|
|
7605
|
+
const head = await flowGit("git", ["symbolic-ref", "--short", "-q", "HEAD"], { cwd: repo });
|
|
7606
|
+
// A detached HEAD: the tick says so; steps wait for a branch rather than guessing one.
|
|
7607
|
+
if (head.code !== 0 || head.stdout.trim() === "")
|
|
7608
|
+
return;
|
|
7609
|
+
base = head.stdout.trim();
|
|
7610
|
+
}
|
|
7611
|
+
const pass = await flowHousekeeping(store, repo, context.clock, flowIoOf(context, flowPool, flowGit, base), admissionStopped);
|
|
7612
|
+
flowsDid(pass.flows.moved + pass.triggers.added + pass.steps.ran + pass.replies.taken, pass.flows.filed.length, `flow pass — ${pass.triggers.added} card(s) added, ${pass.replies.taken} reply(ies) taken, ${pass.steps.ran} step(s) run, ${pass.flows.moved} moved`);
|
|
7547
7613
|
},
|
|
7548
7614
|
});
|
|
7549
7615
|
const modelWatch = startMaintenance({
|
|
@@ -7780,6 +7846,8 @@ async function runWatchLoop(args) {
|
|
|
7780
7846
|
await Promise.all(lanes);
|
|
7781
7847
|
await maintenance.stop();
|
|
7782
7848
|
await followUpChecks.stop();
|
|
7849
|
+
await flowMoves.stop();
|
|
7850
|
+
await flowSteps.stop();
|
|
7783
7851
|
await modelWatch.stop();
|
|
7784
7852
|
clearInterval(heartbeat);
|
|
7785
7853
|
followController.abort();
|
package/dist/project-tools.d.ts
CHANGED
|
@@ -48,6 +48,21 @@ export type ResolvedTool = {
|
|
|
48
48
|
export declare const TOOL_CATALOG: readonly (ToolSpec & {
|
|
49
49
|
label: string;
|
|
50
50
|
})[];
|
|
51
|
+
/**
|
|
52
|
+
* Apps that serve their own MCP on this computer: Connect adds them like a
|
|
53
|
+
* common tool (`connect` is its one line of how), a test that can't reach one
|
|
54
|
+
* says why (`offline`), and research may read what `reads` names (see
|
|
55
|
+
* readsOnly in mcp-connect.ts). The only loopback address research reaches.
|
|
56
|
+
*/
|
|
57
|
+
export type LocalApp = {
|
|
58
|
+
tool: string;
|
|
59
|
+
connect: string;
|
|
60
|
+
offline: string;
|
|
61
|
+
reads: readonly string[];
|
|
62
|
+
};
|
|
63
|
+
export declare const LOCAL_APPS: readonly LocalApp[];
|
|
64
|
+
/** The local app a project's tool is, when it is one exactly as listed (same name and address). */
|
|
65
|
+
export declare function localAppOf(spec: Pick<ToolSpec, "name" | "url">): LocalApp | null;
|
|
51
66
|
export declare function catalogTool(id: string): (ToolSpec & {
|
|
52
67
|
label: string;
|
|
53
68
|
}) | null;
|
package/dist/project-tools.js
CHANGED
|
@@ -51,7 +51,20 @@ export const TOOL_CATALOG = [
|
|
|
51
51
|
about: "Reads Sentry issues and errors." },
|
|
52
52
|
{ label: "Supabase (read-only)", name: "supabase", transport: "stdio", command: "npx", args: ["-y", "@supabase/mcp-server-supabase@0.13.0", "--read-only"], url: null, secrets: [{ name: "SUPABASE_ACCESS_TOKEN", optional: false }], bearer: null, headerSecrets: {},
|
|
53
53
|
about: "Reads Supabase tables and schema, without changing anything." },
|
|
54
|
+
// Figma's Dev Mode server, run by its desktop app on this computer and signed in as the app is: no key.
|
|
55
|
+
{ label: "Figma (desktop app)", name: "figma-desktop", transport: "http", command: null, args: [], url: "http://127.0.0.1:3845/mcp", secrets: [], bearer: null, headerSecrets: {},
|
|
56
|
+
about: "Code, images, variables and metadata from what you select in the Figma desktop app." },
|
|
54
57
|
];
|
|
58
|
+
export const LOCAL_APPS = [
|
|
59
|
+
{ tool: "figma-desktop", connect: "Open the Figma desktop app, turn on the Dev Mode MCP server in Preferences, then Connect.",
|
|
60
|
+
offline: "The Figma desktop app isn't running, or its Dev Mode MCP server is off. Open Figma and turn the server on in Preferences.",
|
|
61
|
+
reads: ["code", "image", "screenshot", "variable", "defs", "metadata", "design", "context", "connect", "map", "figjam"] },
|
|
62
|
+
];
|
|
63
|
+
/** The local app a project's tool is, when it is one exactly as listed (same name and address). */
|
|
64
|
+
export function localAppOf(spec) {
|
|
65
|
+
const app = LOCAL_APPS.find(one => one.tool === spec.name);
|
|
66
|
+
return app !== undefined && spec.url !== null && spec.url === catalogTool(app.tool)?.url ? app : null;
|
|
67
|
+
}
|
|
55
68
|
export function catalogTool(id) {
|
|
56
69
|
return TOOL_CATALOG.find(one => one.name === id) ?? null;
|
|
57
70
|
}
|
|
@@ -513,7 +526,7 @@ async function exchangeHttp(spec, values, timeoutMs, request) {
|
|
|
513
526
|
return { ok: true, result: answered.message.result };
|
|
514
527
|
}
|
|
515
528
|
catch (error) {
|
|
516
|
-
return { ok: false, problem: error instanceof Error && error.name === "TimeoutError" ? `It did not answer within ${Math.round(timeoutMs / 1000)} seconds.` : "It could not be reached." };
|
|
529
|
+
return { ok: false, problem: error instanceof Error && error.name === "TimeoutError" ? `It did not answer within ${Math.round(timeoutMs / 1000)} seconds.` : localAppOf(spec)?.offline ?? "It could not be reached." };
|
|
517
530
|
}
|
|
518
531
|
}
|
|
519
532
|
/** One line of a server's own words, never a secret. */
|
package/dist/result-shots.d.ts
CHANGED
|
@@ -15,6 +15,8 @@ export type ResultShot = {
|
|
|
15
15
|
caption: string;
|
|
16
16
|
/** Within Telegram's photo limits, so it shows an inline preview. */
|
|
17
17
|
photo: boolean;
|
|
18
|
+
/** Captioned with its item's number (flow-items.ts): an album shows it on this photo, not only on the first. */
|
|
19
|
+
numbered?: true;
|
|
18
20
|
};
|
|
19
21
|
export type ResultShotsPlan =
|
|
20
22
|
/** Nothing to send and nothing to say: off, pruned by retention, none saved, or all already sent. */
|
|
@@ -38,7 +40,9 @@ export type ResultShotsPlan =
|
|
|
38
40
|
* Every byte returned was verified this instant; a channel uploads them with
|
|
39
41
|
* no wait in between, or asks again.
|
|
40
42
|
*/
|
|
41
|
-
export declare function resultShotsFor(store: Store, evidenceRoot: string | undefined, repos: readonly string[], row: Pick<Notification, "kind" | "recipient" | "taskId" | "taskRef" | "run"
|
|
43
|
+
export declare function resultShotsFor(store: Store, evidenceRoot: string | undefined, repos: readonly string[], row: Pick<Notification, "kind" | "recipient" | "taskId" | "taskRef" | "run"> & {
|
|
44
|
+
dedupeKey?: string;
|
|
45
|
+
}, sent?: ReadonlySet<number>): ResultShotsPlan;
|
|
42
46
|
/** A row that carries a result's screenshots: one a person asked for with results, or one a flow sends them. */
|
|
43
47
|
export declare function isShotsKind(kind: string): boolean;
|
|
44
48
|
/** A screenshot whose result message is not posted yet waits for that message's own next try (at least a second). */
|
package/dist/result-shots.js
CHANGED
|
@@ -19,6 +19,7 @@ import { imageDimensions, readVerifiedProofForRun, readVerifiedReport, RETENTION
|
|
|
19
19
|
import { resultImageFileName, verifyResultImage } from "./chat-evidence.js";
|
|
20
20
|
import { publicChatText } from "./chat-display.js";
|
|
21
21
|
import { chatTitle } from "./chat-voice.js";
|
|
22
|
+
import { itemCaption, readFlowItems } from "./flow-items.js";
|
|
22
23
|
import { FLOW_SHOTS_KIND, RESULT_SHOTS_KIND } from "./store.js";
|
|
23
24
|
/** "Up to 4": the most screenshots one result ever sends. */
|
|
24
25
|
export const RESULT_SHOTS_MAX = 4;
|
|
@@ -58,6 +59,28 @@ function reportCaptions(store, root, taskRef, run) {
|
|
|
58
59
|
}
|
|
59
60
|
return captions;
|
|
60
61
|
}
|
|
62
|
+
/** A flow visit's item screenshots from this run, in item order, each with its numbered caption (flow-items.ts). */
|
|
63
|
+
function itemCaptions(store, dedupeKey, run) {
|
|
64
|
+
const captions = new Map();
|
|
65
|
+
const visit = /^flow-shots:([1-9][0-9]{0,14}):([1-9][0-9]{0,9})$/.exec(dedupeKey ?? "");
|
|
66
|
+
const kept = visit === null ? null : store.flowSend(Number(visit[1]), Number(visit[2]));
|
|
67
|
+
if (kept === null)
|
|
68
|
+
return captions;
|
|
69
|
+
let content;
|
|
70
|
+
try {
|
|
71
|
+
content = JSON.parse(kept.contentJson);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return captions;
|
|
75
|
+
}
|
|
76
|
+
if (content.shots?.run !== run)
|
|
77
|
+
return captions;
|
|
78
|
+
readFlowItems(content.items).forEach((item, index) => {
|
|
79
|
+
if (item.shot !== null && !captions.has(item.shot))
|
|
80
|
+
captions.set(item.shot, publicChatText(itemCaption(index + 1, item), 200).trim());
|
|
81
|
+
});
|
|
82
|
+
return captions;
|
|
83
|
+
}
|
|
61
84
|
function isPhoto(bytes, format) {
|
|
62
85
|
const size = imageDimensions(bytes, format);
|
|
63
86
|
if (size === null || size.width === 0 || size.height === 0)
|
|
@@ -92,7 +115,13 @@ export function resultShotsFor(store, evidenceRoot, repos, row, sent = new Set()
|
|
|
92
115
|
const latest = store.runsFor(row.taskRef).find(one => one.finishedAt !== null && ["builder", "repair", "scout"].includes(one.role));
|
|
93
116
|
if (family?.current.id !== row.taskId || latest?.id !== row.run)
|
|
94
117
|
return line("a newer result replaced this one");
|
|
95
|
-
|
|
118
|
+
// After research, a flow's message numbers the report's items (flow-items.ts): their screenshots go first, in that
|
|
119
|
+
// order, each captioned with its item's number, so "2." here is item 2 in the text.
|
|
120
|
+
const items = row.kind === FLOW_SHOTS_KIND ? itemCaptions(store, row.dedupeKey, row.run) : new Map();
|
|
121
|
+
const order = [...items.keys()];
|
|
122
|
+
const rank = (id) => { const at = order.indexOf(id); return at < 0 ? order.length : at; };
|
|
123
|
+
const chosen = store.artifactsFor(row.run).filter(one => one.kind === "screenshot")
|
|
124
|
+
.map((one, at) => ({ one, at })).sort((a, b) => rank(a.one.id) - rank(b.one.id) || a.at - b.at).map(({ one }) => one).slice(0, limit);
|
|
96
125
|
if (chosen.length === 0)
|
|
97
126
|
return { kind: "none", why: "none" };
|
|
98
127
|
const captions = ownCaptions(store, evidenceRoot, row.run);
|
|
@@ -107,11 +136,12 @@ export function resultShotsFor(store, evidenceRoot, repos, row, sent = new Set()
|
|
|
107
136
|
return line(verified.problem);
|
|
108
137
|
}
|
|
109
138
|
const own = captions.get(SCREENSHOT_CAPTURE.exec(artifact.capture)?.[1] ?? "") ?? reported.get(artifact.id) ?? null;
|
|
139
|
+
const item = items.get(artifact.id);
|
|
110
140
|
shots.push({
|
|
111
141
|
artifact: artifact.id, sha256: artifact.sha256, bytes: verified.bytes, format: verified.format,
|
|
112
142
|
fileName: resultImageFileName(row.taskId, row.run, artifact.id, verified.format),
|
|
113
|
-
caption: index === 0 ? (own === null ? title : `${title} · ${own}`) : own ?? `${title} · screenshot ${index + 1}`,
|
|
114
|
-
photo: isPhoto(verified.bytes, verified.format),
|
|
143
|
+
caption: item !== undefined ? item : index === 0 ? (own === null ? title : `${title} · ${own}`) : own ?? `${title} · screenshot ${index + 1}`,
|
|
144
|
+
photo: isPhoto(verified.bytes, verified.format), ...(item === undefined ? {} : { numbered: true }),
|
|
115
145
|
});
|
|
116
146
|
}
|
|
117
147
|
const remaining = shots.filter(one => !sent.has(one.artifact));
|