toolroll 0.8.2 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +49 -0
- package/dist/agent-onboard.d.ts +24 -1
- package/dist/agent-onboard.js +53 -1
- package/dist/backup-ui.js +4 -2
- package/dist/browser/workspace.css +2 -2
- package/dist/browser/workspace.js +33 -32
- package/dist/browser-workspace.d.ts +22 -2
- package/dist/chat-actions.d.ts +5 -0
- package/dist/chat-actions.js +43 -3
- package/dist/chat-delivery.js +51 -3
- package/dist/chat-quiet.d.ts +28 -0
- package/dist/chat-quiet.js +161 -0
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +4 -1
- package/dist/desktop-update.d.ts +1 -1
- package/dist/digest-times.d.ts +3 -0
- package/dist/digest-times.js +7 -0
- package/dist/dispose.js +2 -0
- package/dist/export.js +1 -1
- package/dist/flow-engine.js +13 -1
- package/dist/flow-pull-request.d.ts +11 -0
- package/dist/flow-pull-request.js +123 -0
- package/dist/flow-starters-ui.d.ts +20 -0
- package/dist/flow-starters-ui.js +35 -0
- package/dist/flow-starters.d.ts +55 -0
- package/dist/flow-starters.js +104 -0
- package/dist/flow-steps.js +14 -0
- package/dist/flows-ui.d.ts +1 -1
- package/dist/flows-ui.js +4 -3
- package/dist/flows.d.ts +30 -6
- package/dist/flows.js +110 -6
- package/dist/integrations-ui.d.ts +13 -0
- package/dist/integrations-ui.js +70 -0
- package/dist/integrations.d.ts +113 -0
- package/dist/integrations.js +526 -0
- package/dist/lead-context.d.ts +1 -1
- package/dist/lead-context.js +1 -1
- package/dist/mate-contract.js +1 -1
- package/dist/mate-tools.js +18 -5
- package/dist/models-ui.js +3 -2
- package/dist/monitoring-ui.js +2 -1
- package/dist/operate.d.ts +5 -2
- package/dist/operate.js +169 -4
- package/dist/policy-ui.js +2 -1
- package/dist/project-delete.js +1 -0
- package/dist/publish.d.ts +11 -0
- package/dist/publish.js +9 -2
- package/dist/pull-request-flow.d.ts +172 -0
- package/dist/pull-request-flow.js +475 -0
- package/dist/pull-request-schema.d.ts +5 -0
- package/dist/pull-request-schema.js +21 -0
- package/dist/pull-request-ui.d.ts +23 -0
- package/dist/pull-request-ui.js +55 -0
- package/dist/pulls.d.ts +5 -0
- package/dist/pulls.js +11 -0
- package/dist/serve.d.ts +6 -0
- package/dist/serve.js +371 -58
- package/dist/setup-guide.d.ts +2 -2
- package/dist/skills-ui.js +2 -1
- package/dist/store.d.ts +61 -0
- package/dist/store.js +144 -3
- package/dist/surface.js +3 -1
- package/dist/telegram-progress.d.ts +1 -0
- package/dist/telegram-progress.js +1 -1
- package/dist/telegram.js +95 -15
- package/dist/when-html.d.ts +8 -0
- package/dist/when-html.js +36 -0
- package/package.json +1 -1
package/dist/flows.js
CHANGED
|
@@ -30,14 +30,21 @@
|
|
|
30
30
|
* {{card.email}} is the first address the card mentions.
|
|
31
31
|
* - tool — (v87) calls one tool of one of the project's MCP servers
|
|
32
32
|
* (Settings → Tools), with arguments filled from the card.
|
|
33
|
+
* - pull-request — opens a pull request for the card's built result under
|
|
34
|
+
* the project's pull request setup (flow-pull-request.ts),
|
|
35
|
+
* waits for CI, and moves on when it passes or down the
|
|
36
|
+
* failure path, naming the failing check, when it fails. It
|
|
37
|
+
* can merge once checks pass, only after a person approved.
|
|
33
38
|
* - done — the end.
|
|
34
39
|
*
|
|
35
40
|
* The engine is deterministic and model-free: it runs in the worker's pass
|
|
36
41
|
* beside routines, and no card ever skips an approval the task itself needs.
|
|
37
42
|
*/
|
|
38
43
|
import { createHash } from "node:crypto";
|
|
39
|
-
export const FLOW_STAGE_KINDS = ["inbox", "task", "report", "approval", "check", "update", "notify", "sort", "draft", "request", "email", "tool", "wait", "teammate", "done"];
|
|
44
|
+
export const FLOW_STAGE_KINDS = ["inbox", "task", "report", "approval", "check", "pull-request", "update", "notify", "sort", "draft", "request", "email", "tool", "wait", "teammate", "done"];
|
|
40
45
|
export const FLOW_COLORS = ["slate", "blue", "violet", "amber", "green", "rose"];
|
|
46
|
+
/** How a Pull request zone merges once checks pass. */
|
|
47
|
+
export const FLOW_MERGE_METHODS = ["squash", "merge", "rebase"];
|
|
41
48
|
/** What each kind is called and does, in the words the canvas uses. */
|
|
42
49
|
export const FLOW_KIND_WORDS = {
|
|
43
50
|
inbox: { label: "Holding", about: "Cards wait here until someone moves them." },
|
|
@@ -45,6 +52,7 @@ export const FLOW_KIND_WORDS = {
|
|
|
45
52
|
report: { label: "Research", about: "An agent investigates and writes a report. No code changes." },
|
|
46
53
|
approval: { label: "Person decides", about: "Someone approves, or sends it back with a note." },
|
|
47
54
|
check: { label: "Run a script", about: "Runs one of the project's scripts (shell, Python or Node) with no AI. It gets the card; what it prints is passed on, and it can pick where the card goes next. If it fails, the card takes its failure path." },
|
|
55
|
+
"pull-request": { label: "Pull request", about: "Opens a pull request for the card's built result and waits for CI. Green moves it on; red takes the failure path with the failing check named. It can merge once checks pass, only after a person approved." },
|
|
48
56
|
update: { label: "Update where it came from", about: "Comments on the GitHub or Linear issue the card came from (and can close it), or answers in the chat thread it came from. Other cards pass straight through." },
|
|
49
57
|
notify: { label: "Message", about: "Posts a message to the project's chat, then moves on." },
|
|
50
58
|
request: { label: "Web request", about: "Calls an address on the web, like an API, with the card's details, and keeps what it answers. If it fails, the card takes its failure path." },
|
|
@@ -209,11 +217,42 @@ export function durationWords(minutes) {
|
|
|
209
217
|
/** A Wait zone's settings, checked. */
|
|
210
218
|
function validateWait(input, title) {
|
|
211
219
|
const raw = (input ?? {});
|
|
220
|
+
if (raw["for"] === "hours") {
|
|
221
|
+
const from = clockTime(raw["from"]), to = clockTime(raw["to"]);
|
|
222
|
+
if (from === null || to === null || from === to)
|
|
223
|
+
throw new Error(`Zone ${title}: say the hours it waits for, like 22:00 to 06:00.`);
|
|
224
|
+
const zone = typeof raw["timeZone"] === "string" && raw["timeZone"].trim() !== "" ? raw["timeZone"].trim() : null;
|
|
225
|
+
if (zone !== null && !knownTimeZone(zone))
|
|
226
|
+
throw new Error(`Zone ${title}: ${zone} isn't a time zone, like Europe/London.`);
|
|
227
|
+
return { for: "hours", minutes: 0, from, to, ...(zone === null ? {} : { timeZone: zone }) };
|
|
228
|
+
}
|
|
212
229
|
const minutes = durationMinutes(raw["minutes"]);
|
|
213
230
|
if (minutes === null)
|
|
214
231
|
throw new Error(`Zone ${title}: say how long it waits, from 1 minute to 30 days.`);
|
|
215
232
|
return { for: raw["for"] === "time" ? "time" : "reply", minutes };
|
|
216
233
|
}
|
|
234
|
+
/** "22:00", "9:30" as "HH:MM"; null when it isn't a time of day. */
|
|
235
|
+
export function clockTime(value) {
|
|
236
|
+
const match = typeof value === "string" ? /^\s*([01]?[0-9]|2[0-3]):([0-5][0-9])\s*$/.exec(value) : null;
|
|
237
|
+
return match === null ? null : `${match[1].padStart(2, "0")}:${match[2]}`;
|
|
238
|
+
}
|
|
239
|
+
function knownTimeZone(zone) {
|
|
240
|
+
try {
|
|
241
|
+
new Intl.DateTimeFormat("en-GB", { timeZone: zone });
|
|
242
|
+
return true;
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
/** Whether a time falls inside an "hours" wait (22:00–06:00 runs past midnight), in its time zone or this computer's. */
|
|
249
|
+
export function withinHours(wait, now) {
|
|
250
|
+
const parts = new Intl.DateTimeFormat("en-GB", { hour: "2-digit", minute: "2-digit", hourCycle: "h23", ...(wait.timeZone === undefined ? {} : { timeZone: wait.timeZone }) }).formatToParts(now);
|
|
251
|
+
const at = Number(parts.find(one => one.type === "hour")?.value ?? 0) * 60 + Number(parts.find(one => one.type === "minute")?.value ?? 0);
|
|
252
|
+
const minutesOf = (time) => { const [h = "0", m = "0"] = (time ?? "00:00").split(":"); return Number(h) * 60 + Number(m); };
|
|
253
|
+
const from = minutesOf(wait.from), to = minutesOf(wait.to);
|
|
254
|
+
return from < to ? at >= from && at < to : at >= from || at < to;
|
|
255
|
+
}
|
|
217
256
|
/** A zone's time limit, checked; null when it has none. Only Holding and "Person decides" zones move a card on. */
|
|
218
257
|
function validateLimit(input, kind, title) {
|
|
219
258
|
if (input === undefined || input === null)
|
|
@@ -270,6 +309,7 @@ export function validateFlowDefinition(input) {
|
|
|
270
309
|
...(kind === "email" ? { email: validateEmail(stage["email"], title) } : {}),
|
|
271
310
|
...(kind === "tool" ? { tool: validateTool(stage["tool"], title) } : {}),
|
|
272
311
|
...(kind === "wait" ? { wait: validateWait(stage["wait"], title) } : {}),
|
|
312
|
+
...(kind === "pull-request" && stage["merge"] !== undefined && stage["merge"] !== null && stage["merge"] !== false ? { merge: validateMerge(stage["merge"], title) } : {}),
|
|
273
313
|
...((kind === "approval" || kind === "teammate") && stage["teammate"] !== undefined && stage["teammate"] !== null && stage["teammate"] !== "" ? { teammate: validateTeammate(stage["teammate"], title) } : {}),
|
|
274
314
|
...(kind === "teammate" ? validateRoutes(stage, title) : {}),
|
|
275
315
|
...(kind === "teammate" && stage["reply"] === true ? { reply: true } : {}),
|
|
@@ -308,8 +348,36 @@ export function validateFlowDefinition(input) {
|
|
|
308
348
|
throw new Error(`Zone ${stage.title}: choose which teammate handles it.`);
|
|
309
349
|
}
|
|
310
350
|
const start = typeof raw.start === "string" && ids.has(raw.start) ? raw.start : stages[0].id;
|
|
351
|
+
// A zone that merges comes after a person's decision on every path to it: never merged without one.
|
|
352
|
+
const unapproved = reachableWithout(stages, start, one => one.kind === "approval");
|
|
353
|
+
for (const stage of stages) {
|
|
354
|
+
if (stage.merge !== undefined && unapproved.has(stage.id))
|
|
355
|
+
throw new Error(`Zone ${stage.title} merges, so a “Person decides” zone must come before it on every path.`);
|
|
356
|
+
}
|
|
311
357
|
return { version: 1, start, stages };
|
|
312
358
|
}
|
|
359
|
+
/** A Pull request zone's merge: true is squash, the default. */
|
|
360
|
+
function validateMerge(value, title) {
|
|
361
|
+
if (value === true)
|
|
362
|
+
return "squash";
|
|
363
|
+
if (typeof value === "string" && FLOW_MERGE_METHODS.includes(value))
|
|
364
|
+
return value;
|
|
365
|
+
throw new Error(`Zone ${title}: merge by squash, merge or rebase.`);
|
|
366
|
+
}
|
|
367
|
+
/** The zones a card can reach from `start` without passing through a zone `stop` picks (those zones aren't included). */
|
|
368
|
+
export function reachableWithout(stages, start, stop) {
|
|
369
|
+
const seen = new Set();
|
|
370
|
+
const queue = [start];
|
|
371
|
+
while (queue.length > 0) {
|
|
372
|
+
const id = queue.shift();
|
|
373
|
+
const stage = stages.find(one => one.id === id);
|
|
374
|
+
if (stage === undefined || seen.has(stage.id) || stop(stage))
|
|
375
|
+
continue;
|
|
376
|
+
seen.add(stage.id);
|
|
377
|
+
queue.push(...[stage.next, stage.onFail, stage.limit?.to ?? null, ...(stage.sort?.answers.map(one => one.to) ?? []), ...(stage.routes?.map(one => one.to) ?? [])].filter((one) => one !== null));
|
|
378
|
+
}
|
|
379
|
+
return seen;
|
|
380
|
+
}
|
|
313
381
|
/** A teammate's handle on a zone (v92): which teammate works it. Whether it exists is the project's to say, when a card arrives. */
|
|
314
382
|
function validateTeammate(value, title) {
|
|
315
383
|
if (typeof value !== "string" || !/^[a-z0-9][a-z0-9-]{0,31}$/.test(value))
|
|
@@ -387,13 +455,19 @@ function fillOne(key, stage, card) {
|
|
|
387
455
|
return stage === undefined ? "" : card.outputs[stage] ?? "";
|
|
388
456
|
}
|
|
389
457
|
}
|
|
390
|
-
const KIND_COLORS = { inbox: "slate", task: "blue", report: "violet", approval: "amber", check: "blue", update: "green", notify: "green", sort: "violet", draft: "violet", request: "blue", email: "green", tool: "blue", wait: "slate", teammate: "violet", done: "green" };
|
|
458
|
+
const KIND_COLORS = { inbox: "slate", task: "blue", report: "violet", approval: "amber", check: "blue", "pull-request": "blue", update: "green", notify: "green", sort: "violet", draft: "violet", request: "blue", email: "green", tool: "blue", wait: "slate", teammate: "violet", done: "green" };
|
|
391
459
|
/** A step id as the lead may write it (sort_by_hand, Sort-By-Hand) in the one form zones use. */
|
|
392
460
|
const idOf = (value) => value.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 32);
|
|
393
461
|
const slugOf = (title) => title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 28) || "zone";
|
|
394
462
|
const overlaps = (a, b) => a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h;
|
|
395
463
|
/** A wait step's settings: what it waits for and how long (3 days when neither the step nor the zone it keeps says). */
|
|
396
464
|
function waitFromStep(step, old, title) {
|
|
465
|
+
if ((step.waitFor ?? old?.for) === "hours") {
|
|
466
|
+
const from = clockTime(step.from ?? old?.from ?? "22:00"), to = clockTime(step.until ?? old?.to ?? "06:00");
|
|
467
|
+
if (from === null || to === null)
|
|
468
|
+
throw new Error(`Step ${title}: say the hours it waits for, like from 22:00 until 06:00.`);
|
|
469
|
+
return { for: "hours", minutes: 0, from, to, ...(old?.timeZone === undefined ? {} : { timeZone: old.timeZone }) };
|
|
470
|
+
}
|
|
397
471
|
const minutes = step.wait === undefined ? old?.minutes ?? 3 * 24 * 60 : durationMinutes(step.wait);
|
|
398
472
|
if (minutes === null)
|
|
399
473
|
throw new Error(`Step ${title}: say how long it waits, like "3 days" or "4 hours" (up to 30 days).`);
|
|
@@ -479,7 +553,9 @@ export function flowFromSteps(input, previous = null) {
|
|
|
479
553
|
: kind === "wait" && typeof step.ifNoReply === "string" && step.ifNoReply.trim() !== "" ? step.ifNoReply : step.ifFails;
|
|
480
554
|
const onFail = kind === "done" ? null
|
|
481
555
|
: typeof notSure === "string" && notSure.trim() !== "" ? find(notSure, title)
|
|
482
|
-
: keptFail ?? (kind === "approval" ? worker?.id ?? (drafts[0].id === id ? null : drafts[0].id)
|
|
556
|
+
: keptFail ?? (kind === "approval" ? worker?.id ?? (drafts[0].id === id ? null : drafts[0].id)
|
|
557
|
+
// Red CI goes back to the build before it, carrying the failing check.
|
|
558
|
+
: kind === "pull-request" ? [...earlier].reverse().find(one => one.kind === "task")?.id ?? null : null);
|
|
483
559
|
// "owner": whoever owns the flow when the card arrives.
|
|
484
560
|
const toOwner = kind === "approval" && (step.decider === undefined ? old?.toOwner === true : typeof step.decider === "string" && /^(owner|the owner|flow owner|the flow owner|the flow's owner)$/i.test(step.decider.trim()));
|
|
485
561
|
const approver = kind !== "approval" || toOwner ? null
|
|
@@ -512,6 +588,7 @@ export function flowFromSteps(input, previous = null) {
|
|
|
512
588
|
...(kind === "email" ? { email: { to: step.to ?? old?.email?.to ?? "{{card.email}}", subject: step.subject ?? old?.email?.subject ?? "Re: {{card.title}}", body: step.body ?? old?.email?.body ?? "" } } : {}),
|
|
513
589
|
...(kind === "tool" ? { tool: { server: step.server ?? old?.tool?.server ?? "", name: step.tool ?? old?.tool?.name ?? "", args: typeof step.args === "string" ? step.args : step.args !== undefined ? JSON.stringify(step.args) : old?.tool?.args ?? "{}" } } : {}),
|
|
514
590
|
...(kind === "wait" ? { wait: waitFromStep(step, old?.wait ?? null, title) } : {}),
|
|
591
|
+
...(kind === "pull-request" && (step.merge === undefined ? old?.merge !== undefined : step.merge !== false) ? { merge: step.merge === undefined || step.merge === true || step.merge === false ? old?.merge ?? "squash" : step.merge } : {}),
|
|
515
592
|
// v92: "nobody" (or "none") takes a teammate off an approval step.
|
|
516
593
|
...((kind === "approval" || kind === "teammate") && (step.teammate === undefined ? old?.teammate !== undefined : !/^(nobody|none|no one)$/i.test(step.teammate.trim())) ? { teammate: idOf(step.teammate ?? old.teammate) } : {}),
|
|
517
594
|
...(kind === "teammate" ? step.routes !== undefined ? step.routes.length === 0 ? {} : { routes: step.routes.map(one => ({ answer: String(one.answer ?? "").trim(), to: find(String(one.goesTo ?? ""), title) })) } : old?.routes === undefined ? {} : { routes: old.routes } : {}),
|
|
@@ -641,11 +718,16 @@ export function flowTerms(definition, previous) {
|
|
|
641
718
|
if (stage.sort.notes.length > 0)
|
|
642
719
|
lines.push(`Also notes: ${stage.sort.notes.map(one => one.kind === "score" ? `${one.question} (${one.levels?.join(" / ")})` : `${one.question} (yes or no)`).join("; ")}`);
|
|
643
720
|
}
|
|
721
|
+
else if (stage.kind === "pull-request") {
|
|
722
|
+
const approval = [...definition.stages.slice(0, index)].reverse().find(one => one.kind === "approval");
|
|
723
|
+
lines.push("Opens a pull request for the card's built result, then waits for CI.", `Checks pass → ${stage.merge === undefined ? to(stage.next) : `merges it (${stage.merge}) and deletes its branch, only when a person approved it${approval === undefined ? "" : ` at ${approval.title}`} since it was built. Then → ${to(stage.next)}`}`, `Checks fail → ${stage.onFail === null ? "waits there, naming the failing check." : `${to(stage.onFail)} The failing check is named on the card.`}`);
|
|
724
|
+
}
|
|
644
725
|
else if (stage.kind === "check")
|
|
645
726
|
lines.push(`Runs the project's script “${stage.script}” with no AI.`, `Passes → ${to(stage.next)}${stage.onFail === null ? " Fails → waits there." : ` Fails → ${to(stage.onFail)}`}`);
|
|
646
727
|
else if (stage.kind === "wait" && stage.wait !== undefined)
|
|
647
|
-
lines.push(stage.wait.for === "
|
|
648
|
-
:
|
|
728
|
+
lines.push(stage.wait.for === "hours" ? `Waits until it's between ${stage.wait.from} and ${stage.wait.to}${stage.wait.timeZone === undefined ? "" : ` (${stage.wait.timeZone})`}; cards that arrive then go straight on. Then → ${to(stage.next)}`
|
|
729
|
+
: stage.wait.for === "time" ? `Waits ${durationWords(stage.wait.minutes)}. Then → ${to(stage.next)}`
|
|
730
|
+
: `Waits up to ${durationWords(stage.wait.minutes)} for a reply to the card's email, from someone it was sent to. Reply → ${to(stage.next)} No reply → ${stage.onFail === null ? "stays there for a person." : to(stage.onFail)}`);
|
|
649
731
|
else if (stage.next !== null)
|
|
650
732
|
lines.push(`Then → ${to(stage.next)}${stage.onFail === null ? "" : ` If it fails → ${to(stage.onFail)}`}`);
|
|
651
733
|
if (stage.limit !== undefined)
|
|
@@ -676,6 +758,8 @@ export function flowTerms(definition, previous) {
|
|
|
676
758
|
terms.push("Sort steps send each card's title, details and earlier notes to Jev through your OpenRouter account.");
|
|
677
759
|
if (definition.stages.some(one => one.kind === "request" || one.kind === "email" || one.kind === "tool"))
|
|
678
760
|
terms.push("Web request, email and tool steps send what they're given outside this computer, with no one checking unless a decision comes before them.");
|
|
761
|
+
if (definition.stages.some(one => one.kind === "pull-request"))
|
|
762
|
+
terms.push(`Pull request steps push the card's branch to GitHub and open a pull request under this project's pull request setup.${definition.stages.some(one => one.merge !== undefined) ? " A merge happens only after a person approves the card; nothing merges without one." : " Nothing merges on its own."}`);
|
|
679
763
|
if (definition.stages.some(one => one.kind === "draft"))
|
|
680
764
|
terms.push("Draft steps send each card's text to Claude through the lead chat's sign-in. Nothing a draft writes is sent until a later step sends it.");
|
|
681
765
|
if (definition.stages.some(one => one.teammate !== undefined))
|
|
@@ -684,7 +768,10 @@ export function flowTerms(definition, previous) {
|
|
|
684
768
|
}
|
|
685
769
|
const zone = (x, y, color, h = 300) => ({ x, y, w: 260, h, color });
|
|
686
770
|
const stage = (id, title, kind, at, rest = {}) => ({ id, title, kind, zone: at, instructions: null, planning: kind === "task" ? "auto" : null, approver: null, message: null, close: kind === "update" ? true : null, script: null, sort: null, next: null, onFail: null, ...rest });
|
|
687
|
-
/**
|
|
771
|
+
/** The label an issue gets to become work: what the Issues to PRs template and the issue starter flow watch for. */
|
|
772
|
+
export const ISSUE_LABEL = "toolroll";
|
|
773
|
+
/** Ready-made flows: the coding flow is the whole business process around a change. A template's `trigger`
|
|
774
|
+
* (a trigger's settings, as flow-triggers.ts reads them) is added with it. */
|
|
688
775
|
export const FLOW_TEMPLATES = [
|
|
689
776
|
{
|
|
690
777
|
id: "coding",
|
|
@@ -710,6 +797,23 @@ export const FLOW_TEMPLATES = [
|
|
|
710
797
|
],
|
|
711
798
|
},
|
|
712
799
|
},
|
|
800
|
+
{
|
|
801
|
+
id: "issues-to-prs",
|
|
802
|
+
label: "Issues to PRs",
|
|
803
|
+
about: `GitHub issues labelled “${ISSUE_LABEL}” are built, a person approves, a pull request opens and waits for CI, then the issue gets a comment and is closed. Nothing merges on its own.`,
|
|
804
|
+
trigger: { kind: "github", watch: "issues", label: ISSUE_LABEL },
|
|
805
|
+
definition: {
|
|
806
|
+
version: 1,
|
|
807
|
+
start: "build",
|
|
808
|
+
stages: [
|
|
809
|
+
stage("build", "Build", "task", zone(0, 0, "blue"), { instructions: "{{card.title}}\n\n{{card.description}}\n\nChanges asked for (if any): {{note}}", next: "approve" }),
|
|
810
|
+
stage("approve", "Approve", "approval", zone(300, 0, "amber"), { toOwner: true, next: "pull-request", onFail: "build" }),
|
|
811
|
+
stage("pull-request", "Pull request", "pull-request", zone(600, 0, "blue"), { next: "update-issue", onFail: "build" }),
|
|
812
|
+
stage("update-issue", "Update the issue", "update", zone(900, 0, "green", 220), { message: "Done: {{card.title}}. {{stage.pull-request}}", next: "done" }),
|
|
813
|
+
stage("done", "Done", "done", zone(900, 280, "green", 220)),
|
|
814
|
+
],
|
|
815
|
+
},
|
|
816
|
+
},
|
|
713
817
|
{
|
|
714
818
|
id: "research",
|
|
715
819
|
label: "Research flow",
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Settings → Integrations: which integrations work. One row each: its name
|
|
3
|
+
* and state, the account it's connected as, when it last worked and last
|
|
4
|
+
* failed, what uses it, and one action. The list comes from saved checks; a
|
|
5
|
+
* render never waits on one (see integrations.ts).
|
|
6
|
+
*/
|
|
7
|
+
import { type Integration } from "./integrations.js";
|
|
8
|
+
export declare const INTEGRATIONS_CSS: string;
|
|
9
|
+
export declare function integrationsHtml(list: readonly Integration[], csrf: string, notice: {
|
|
10
|
+
said?: string | null;
|
|
11
|
+
problem?: string | null;
|
|
12
|
+
checking?: boolean;
|
|
13
|
+
}): string;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Settings → Integrations: which integrations work. One row each: its name
|
|
3
|
+
* and state, the account it's connected as, when it last worked and last
|
|
4
|
+
* failed, what uses it, and one action. The list comes from saved checks; a
|
|
5
|
+
* render never waits on one (see integrations.ts).
|
|
6
|
+
*/
|
|
7
|
+
import { STATE_WORDS } from "./integrations.js";
|
|
8
|
+
import { whenUtc } from "./when-html.js";
|
|
9
|
+
const e = (value) => String(value ?? "").replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
10
|
+
const when = (at) => whenUtc(at);
|
|
11
|
+
export const INTEGRATIONS_CSS = `.integrations{max-width:760px;min-width:0}.integrations h2{margin:24px 0 6px;font-size:.9375rem}` +
|
|
12
|
+
`.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
|
+
`.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-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
|
+
`.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
|
+
`.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}` +
|
|
18
|
+
`.integration-state{display:inline-flex;align-items:center;gap:6px;font-size:.75rem;font-weight:500;padding:0 8px;border-radius:5px;line-height:20px;white-space:nowrap}` +
|
|
19
|
+
`.integration-state i{width:6px;height:6px;border-radius:50%;background:currentColor}` +
|
|
20
|
+
`.integration-state--connected{color:var(--so-success);background:var(--so-success-soft)}.integration-state--broken{color:var(--so-danger);background:var(--so-danger-soft)}` +
|
|
21
|
+
`.integration-state--not-set-up,.integration-state--checking{color:var(--so-muted);background:var(--so-neutral-soft)}` +
|
|
22
|
+
// A phone: the action sits at the right of the name line, and the facts under it run on as one line.
|
|
23
|
+
`@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-body>p{margin:0}.integrations .integration-body>p.meta{display:inline}.integrations .integration-body>p.meta+p.meta::before{content:" · "}` +
|
|
26
|
+
`.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
|
+
const GROUPS = [
|
|
28
|
+
["chat", "Chat"], ["code", "Code and issues"], ["mail", "Email"], ["tools", "MCP tools"], ["monitoring", "Monitoring"], ["agents", "Agents"],
|
|
29
|
+
];
|
|
30
|
+
function action(one, csrf) {
|
|
31
|
+
const a = one.action;
|
|
32
|
+
if (a.kind === "test") {
|
|
33
|
+
return csrf === "" ? "" : `<form method="post" action="/settings/integrations/test" class="integration-action"><input type="hidden" name="csrf" value="${e(csrf)}">` +
|
|
34
|
+
`<input type="hidden" name="key" value="${e(one.key)}"><button type="submit">Send test</button></form>`;
|
|
35
|
+
}
|
|
36
|
+
// A command is run where Toolroll runs; a page is linked. Fix and Set up both say which.
|
|
37
|
+
// Fix is the one filled action on the page; Set up stays quiet so a list of unused services doesn't shout.
|
|
38
|
+
if (a.href !== null)
|
|
39
|
+
return `<p class="integration-action"><a class="button-link${a.kind === "setup" ? " integration-quiet" : ""}" href="${e(a.href)}">${a.label}</a></p>`;
|
|
40
|
+
return "";
|
|
41
|
+
}
|
|
42
|
+
function row(one, csrf) {
|
|
43
|
+
const badge = !one.checked && one.state === "connected"
|
|
44
|
+
? `<span class="integration-state integration-state--checking"><i aria-hidden="true"></i>Checking</span>`
|
|
45
|
+
: `<span class="integration-state integration-state--${one.state}"><i aria-hidden="true"></i>${STATE_WORDS[one.state]}</span>`;
|
|
46
|
+
const facts = [one.account, one.detail].filter(Boolean).map(e).join(" · ");
|
|
47
|
+
const command = one.action.kind !== "test" ? one.action.command : null;
|
|
48
|
+
const fix = one.action.kind === "fix" ? `<p class="integration-fix" role="status">${e(one.action.words)}</p>` : "";
|
|
49
|
+
const run = command === null || (one.action.kind === "fix" && one.action.words.includes(command)) ? "" : `<p class="meta">Run <code>${e(command)}</code> on the computer running Toolroll.</p>`;
|
|
50
|
+
const seen = [one.lastSuccessAt === null ? null : `Last success ${when(one.lastSuccessAt)}`, one.usedBy.length === 0 ? null : `Used by ${e(one.usedBy.join(", "))}`].filter(Boolean);
|
|
51
|
+
const history = one.state === "not-set-up" || seen.length === 0 ? "" : `<p class="meta">${seen.join(" · ")}</p>`;
|
|
52
|
+
const lastError = one.lastError === null || one.state === "not-set-up" ? "" : one.state === "broken" && one.action.kind === "fix" && one.action.words === one.lastError
|
|
53
|
+
? `<p class="meta">Failed ${when(one.lastErrorAt ?? "")}</p>`
|
|
54
|
+
: `<details><summary>Last error ${one.lastErrorAt === null ? "" : when(one.lastErrorAt)}</summary><p class="meta">${e(one.lastError)}</p></details>`;
|
|
55
|
+
return `<div class="integration" data-integration="${e(one.key)}" data-state="${one.state}">` +
|
|
56
|
+
`<p class="integration-head"><strong>${e(one.name)}</strong> ${badge}</p>` +
|
|
57
|
+
action(one, csrf) +
|
|
58
|
+
`<div class="integration-body">${facts === "" ? "" : `<p class="meta">${facts}</p>`}${fix}${run}${history}${lastError}</div></div>`;
|
|
59
|
+
}
|
|
60
|
+
export function integrationsHtml(list, csrf, notice) {
|
|
61
|
+
const note = notice.problem ? `<p class="problem" role="alert">${e(notice.problem)}</p>` : notice.said ? `<p role="status">${e(notice.said)}</p>` : "";
|
|
62
|
+
const broken = list.filter(one => one.state === "broken").length;
|
|
63
|
+
const summary = broken > 0 ? `<p><strong>${broken} need${broken === 1 ? "s" : ""} fixing.</strong></p>`
|
|
64
|
+
: notice.checking || list.some(one => !one.checked) ? `<p class="meta" role="status">Checking in the background. Refresh in a few seconds.</p>` : "";
|
|
65
|
+
const sections = GROUPS.map(([group, title]) => {
|
|
66
|
+
const rows = list.filter(one => one.group === group);
|
|
67
|
+
return rows.length === 0 ? "" : `<h2>${title}</h2>${rows.map(one => row(one, csrf)).join("")}`;
|
|
68
|
+
}).join("");
|
|
69
|
+
return `<section class="integrations">${note}${summary}${sections}</section>`;
|
|
70
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import type { Store } from "./store.js";
|
|
2
|
+
import type { Runner } from "./backend.js";
|
|
3
|
+
import type { ConnectionChecker } from "./provider-connection.js";
|
|
4
|
+
export type IntegrationState = "connected" | "not-set-up" | "broken";
|
|
5
|
+
export type IntegrationGroup = "chat" | "code" | "mail" | "tools" | "monitoring" | "agents";
|
|
6
|
+
/** Where a person goes (or what they run) to set something up or fix it. */
|
|
7
|
+
export type IntegrationPlace = {
|
|
8
|
+
href: string | null;
|
|
9
|
+
command: string | null;
|
|
10
|
+
};
|
|
11
|
+
export type IntegrationAction = ({
|
|
12
|
+
kind: "setup";
|
|
13
|
+
label: "Set up";
|
|
14
|
+
} & IntegrationPlace) | {
|
|
15
|
+
kind: "test";
|
|
16
|
+
label: "Send test";
|
|
17
|
+
} | ({
|
|
18
|
+
kind: "fix";
|
|
19
|
+
label: "Fix";
|
|
20
|
+
words: string;
|
|
21
|
+
} & IntegrationPlace);
|
|
22
|
+
export type Integration = {
|
|
23
|
+
key: string;
|
|
24
|
+
group: IntegrationGroup;
|
|
25
|
+
name: string;
|
|
26
|
+
state: IntegrationState;
|
|
27
|
+
/** The account or workspace name, never a credential. */
|
|
28
|
+
account: string | null;
|
|
29
|
+
/** One short fact, such as which projects can be pushed to. */
|
|
30
|
+
detail: string | null;
|
|
31
|
+
/** Whether a check has answered for this integration yet (always true for those with nothing to check). */
|
|
32
|
+
checked: boolean;
|
|
33
|
+
checkedAt: string | null;
|
|
34
|
+
lastSuccessAt: string | null;
|
|
35
|
+
lastError: string | null;
|
|
36
|
+
lastErrorAt: string | null;
|
|
37
|
+
usedBy: string[];
|
|
38
|
+
action: IntegrationAction;
|
|
39
|
+
};
|
|
40
|
+
export declare const STATE_WORDS: Record<IntegrationState, string>;
|
|
41
|
+
export type ProbeResult = {
|
|
42
|
+
outcome: "ok";
|
|
43
|
+
account?: string | null;
|
|
44
|
+
detail?: string | null;
|
|
45
|
+
} | {
|
|
46
|
+
outcome: "failed";
|
|
47
|
+
problem: string;
|
|
48
|
+
account?: string | null;
|
|
49
|
+
detail?: string | null;
|
|
50
|
+
fix?: Partial<IntegrationPlace>;
|
|
51
|
+
} | {
|
|
52
|
+
outcome: "absent";
|
|
53
|
+
problem: string;
|
|
54
|
+
setup?: Partial<IntegrationPlace>;
|
|
55
|
+
};
|
|
56
|
+
export type IntegrationIo = {
|
|
57
|
+
store: Store;
|
|
58
|
+
/** The folder beside the database where credential files live; null when there is none. */
|
|
59
|
+
dir: string | null;
|
|
60
|
+
telegramTokenFile: string | null;
|
|
61
|
+
env: Record<string, string | undefined>;
|
|
62
|
+
/** The projects whose MCP tools and GitHub push rights are listed. */
|
|
63
|
+
repos: readonly string[];
|
|
64
|
+
fetch?: typeof fetch;
|
|
65
|
+
/** How `gh` runs (tests replace it). */
|
|
66
|
+
gh?: Runner;
|
|
67
|
+
/** The same sign-in check Settings → AI providers makes. */
|
|
68
|
+
checkConnection?: ConnectionChecker;
|
|
69
|
+
/** Where project tool secrets live. */
|
|
70
|
+
toolHome?: string;
|
|
71
|
+
/** Whether a mail server answers at host:port (tests replace it). */
|
|
72
|
+
reach?: (host: string, port: number, secure: boolean) => Promise<void>;
|
|
73
|
+
clock?: () => Date;
|
|
74
|
+
};
|
|
75
|
+
/** Remove every known secret value and anything shaped like a token; keep it to one short line. */
|
|
76
|
+
export declare function scrubIntegrationText(text: string, secrets?: readonly (string | null | undefined)[]): string;
|
|
77
|
+
type CheckRow = {
|
|
78
|
+
key: string;
|
|
79
|
+
outcome: "ok" | "failed" | "absent";
|
|
80
|
+
account: string | null;
|
|
81
|
+
detail: string | null;
|
|
82
|
+
problem: string | null;
|
|
83
|
+
checkedAt: string;
|
|
84
|
+
okAt: string | null;
|
|
85
|
+
error: string | null;
|
|
86
|
+
errorAt: string | null;
|
|
87
|
+
};
|
|
88
|
+
export declare function readIntegrationChecks(store: Store): Map<string, CheckRow>;
|
|
89
|
+
export declare function recordIntegrationCheck(store: Store, key: string, result: ProbeResult, now: Date): void;
|
|
90
|
+
/** Whether something answers at host:port: a connection opened and closed, no login and no message. */
|
|
91
|
+
export declare function reachServer(host: string, port: number, secure: boolean): Promise<void>;
|
|
92
|
+
/** Every integration as it stands now, from files and saved history only: no network, no process, never slow. */
|
|
93
|
+
export declare function integrationsNow(io: IntegrationIo): Integration[];
|
|
94
|
+
/** Run the checks (all, or the named ones), record each answer, and return the list. */
|
|
95
|
+
export declare function checkIntegrations(io: IntegrationIo, keys?: readonly string[]): Promise<Integration[]>;
|
|
96
|
+
/** The one line `status` and `onboard` add when something is Broken; null when nothing is. */
|
|
97
|
+
export declare function integrationsBrokenLine(list: readonly Integration[]): string | null;
|
|
98
|
+
/** The CLI's lines: one per integration, then the fix under anything Broken. */
|
|
99
|
+
export declare function renderIntegrations(list: readonly Integration[]): string[];
|
|
100
|
+
export type IntegrationMonitor = {
|
|
101
|
+
/** The list now, without waiting; starts a background check when the last one is older than the cache. */
|
|
102
|
+
list(): Integration[];
|
|
103
|
+
/** Check now and wait (Send test, or the first answer in a test). */
|
|
104
|
+
check(keys?: readonly string[]): Promise<Integration[]>;
|
|
105
|
+
/** Whether a background check is running. */
|
|
106
|
+
readonly checking: boolean;
|
|
107
|
+
};
|
|
108
|
+
/** Checks in the background with a short cache, as the first-run suggestions are: a render never waits on a check. */
|
|
109
|
+
export declare function createIntegrationMonitor(io: () => IntegrationIo, options?: {
|
|
110
|
+
cacheMs?: number;
|
|
111
|
+
now?: () => number;
|
|
112
|
+
}): IntegrationMonitor;
|
|
113
|
+
export {};
|