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.
Files changed (68) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/dist/agent-onboard.d.ts +24 -1
  3. package/dist/agent-onboard.js +53 -1
  4. package/dist/backup-ui.js +4 -2
  5. package/dist/browser/workspace.css +2 -2
  6. package/dist/browser/workspace.js +33 -32
  7. package/dist/browser-workspace.d.ts +22 -2
  8. package/dist/chat-actions.d.ts +5 -0
  9. package/dist/chat-actions.js +43 -3
  10. package/dist/chat-delivery.js +51 -3
  11. package/dist/chat-quiet.d.ts +28 -0
  12. package/dist/chat-quiet.js +161 -0
  13. package/dist/cli.d.ts +1 -1
  14. package/dist/cli.js +4 -1
  15. package/dist/desktop-update.d.ts +1 -1
  16. package/dist/digest-times.d.ts +3 -0
  17. package/dist/digest-times.js +7 -0
  18. package/dist/dispose.js +2 -0
  19. package/dist/export.js +1 -1
  20. package/dist/flow-engine.js +13 -1
  21. package/dist/flow-pull-request.d.ts +11 -0
  22. package/dist/flow-pull-request.js +123 -0
  23. package/dist/flow-starters-ui.d.ts +20 -0
  24. package/dist/flow-starters-ui.js +35 -0
  25. package/dist/flow-starters.d.ts +55 -0
  26. package/dist/flow-starters.js +104 -0
  27. package/dist/flow-steps.js +14 -0
  28. package/dist/flows-ui.d.ts +1 -1
  29. package/dist/flows-ui.js +4 -3
  30. package/dist/flows.d.ts +30 -6
  31. package/dist/flows.js +110 -6
  32. package/dist/integrations-ui.d.ts +13 -0
  33. package/dist/integrations-ui.js +70 -0
  34. package/dist/integrations.d.ts +113 -0
  35. package/dist/integrations.js +526 -0
  36. package/dist/lead-context.d.ts +1 -1
  37. package/dist/lead-context.js +1 -1
  38. package/dist/mate-contract.js +1 -1
  39. package/dist/mate-tools.js +18 -5
  40. package/dist/models-ui.js +3 -2
  41. package/dist/monitoring-ui.js +2 -1
  42. package/dist/operate.d.ts +5 -2
  43. package/dist/operate.js +169 -4
  44. package/dist/policy-ui.js +2 -1
  45. package/dist/project-delete.js +1 -0
  46. package/dist/publish.d.ts +11 -0
  47. package/dist/publish.js +9 -2
  48. package/dist/pull-request-flow.d.ts +172 -0
  49. package/dist/pull-request-flow.js +475 -0
  50. package/dist/pull-request-schema.d.ts +5 -0
  51. package/dist/pull-request-schema.js +21 -0
  52. package/dist/pull-request-ui.d.ts +23 -0
  53. package/dist/pull-request-ui.js +55 -0
  54. package/dist/pulls.d.ts +5 -0
  55. package/dist/pulls.js +11 -0
  56. package/dist/serve.d.ts +6 -0
  57. package/dist/serve.js +371 -58
  58. package/dist/setup-guide.d.ts +2 -2
  59. package/dist/skills-ui.js +2 -1
  60. package/dist/store.d.ts +61 -0
  61. package/dist/store.js +144 -3
  62. package/dist/surface.js +3 -1
  63. package/dist/telegram-progress.d.ts +1 -0
  64. package/dist/telegram-progress.js +1 -1
  65. package/dist/telegram.js +95 -15
  66. package/dist/when-html.d.ts +8 -0
  67. package/dist/when-html.js +36 -0
  68. 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) : null);
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 === "time" ? `Waits ${durationWords(stage.wait.minutes)}. Then → ${to(stage.next)}`
648
- : `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)}`);
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
- /** Ready-made flows: the coding flow is the whole business process around a change. */
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 => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[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 {};