toolroll 0.9.86 → 0.9.87
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 +59 -0
- package/dist/agents-board.d.ts +12 -0
- package/dist/agents-board.js +26 -0
- package/dist/approval-rules-ui.d.ts +4 -1
- package/dist/approval-rules-ui.js +31 -15
- package/dist/assignment-ui.js +1 -1
- package/dist/backup-ui.d.ts +5 -4
- package/dist/backup-ui.js +65 -32
- package/dist/browser/terminal.css +1 -1
- package/dist/browser/terminal.js +57 -54
- package/dist/browser/workspace.css +2 -2
- package/dist/browser/workspace.js +35 -70
- package/dist/browser-crew.js +1 -1
- package/dist/browser-workspace.d.ts +329 -183
- package/dist/browser-workspace.js +10 -9
- package/dist/chat-controls.d.ts +1 -1
- package/dist/chat-controls.js +1 -1
- package/dist/chat-settings-page.d.ts +29 -9
- package/dist/chat-settings-page.js +59 -28
- package/dist/check-levels-ui.d.ts +2 -1
- package/dist/check-levels-ui.js +67 -58
- package/dist/contracts/cli.d.ts +43 -0
- package/dist/contracts/cli.js +4 -0
- package/dist/contracts/console-api.d.ts +1 -1
- package/dist/contracts/console-api.js +1 -1
- package/dist/credentials-ui.d.ts +5 -3
- package/dist/credentials-ui.js +58 -31
- package/dist/design/kit.css +685 -0
- package/dist/design/kit.d.ts +208 -0
- package/dist/design/kit.js +177 -0
- package/dist/design/tokens.css +19 -1
- package/dist/discord-settings.js +8 -4
- package/dist/export-ui.d.ts +6 -4
- package/dist/export-ui.js +18 -8
- package/dist/flow-changed.d.ts +14 -0
- package/dist/flow-changed.js +53 -0
- package/dist/flow-gallery-ui.d.ts +41 -4
- package/dist/flow-gallery-ui.js +95 -63
- package/dist/flow-starters-ui.d.ts +3 -9
- package/dist/flow-starters-ui.js +12 -23
- package/dist/flow-unattended-words.d.ts +20 -0
- package/dist/flow-unattended-words.js +24 -0
- package/dist/flow-unattended.d.ts +9 -5
- package/dist/flow-unattended.js +45 -23
- package/dist/flows-cli.js +7 -3
- package/dist/flows-ui.d.ts +12 -6
- package/dist/flows-ui.js +141 -30
- package/dist/integrations-ui.d.ts +6 -4
- package/dist/integrations-ui.js +34 -40
- package/dist/integrations.d.ts +2 -2
- package/dist/integrations.js +4 -4
- package/dist/kits-ui.d.ts +8 -10
- package/dist/kits-ui.js +29 -33
- package/dist/kits.js +1 -1
- package/dist/knowledge-ui.js +76 -19
- package/dist/ledger-view.d.ts +2 -0
- package/dist/ledger-view.js +41 -38
- package/dist/models-ui.d.ts +8 -3
- package/dist/models-ui.js +82 -59
- package/dist/monitoring-ui.d.ts +4 -2
- package/dist/monitoring-ui.js +44 -13
- package/dist/needs-you.d.ts +1 -1
- package/dist/needs-you.js +8 -0
- package/dist/operate-remote-arguments.js +1 -0
- package/dist/operate-remote.js +1 -1
- package/dist/operate.d.ts +1 -1
- package/dist/operate.js +38 -4
- package/dist/passwordless.d.ts +1 -1
- package/dist/passwordless.js +1 -1
- package/dist/people-audit-ui.d.ts +2 -1
- package/dist/people-audit-ui.js +25 -23
- package/dist/policy-ui.d.ts +3 -2
- package/dist/policy-ui.js +27 -21
- package/dist/policy.d.ts +3 -0
- package/dist/policy.js +14 -1
- package/dist/project-delete-ui.d.ts +4 -2
- package/dist/project-delete-ui.js +135 -126
- package/dist/project-knowledge.d.ts +10 -10
- package/dist/publish.d.ts +11 -0
- package/dist/publish.js +16 -3
- package/dist/pull-request-ui.d.ts +2 -1
- package/dist/pull-request-ui.js +39 -28
- package/dist/recipe-ui.d.ts +4 -2
- package/dist/recipe-ui.js +14 -4
- package/dist/release-adapters.d.ts +3 -0
- package/dist/release-adapters.js +22 -2
- package/dist/release-cli.js +81 -9
- package/dist/release.d.ts +21 -0
- package/dist/release.js +40 -4
- package/dist/releases.js +1 -1
- package/dist/request-budget-ui.d.ts +4 -2
- package/dist/request-budget-ui.js +30 -19
- package/dist/retention-ui.d.ts +4 -3
- package/dist/retention-ui.js +15 -12
- package/dist/scope.d.ts +3 -0
- package/dist/scope.js +6 -0
- package/dist/serve.d.ts +0 -1
- package/dist/serve.js +51 -7
- package/dist/server/agents-computers.d.ts +9 -0
- package/dist/server/agents-computers.js +67 -0
- package/dist/server/chat.js +16 -16
- package/dist/server/chrome.d.ts +1 -1
- package/dist/server/chrome.js +19 -9
- package/dist/server/console.css +2 -2
- package/dist/server/flows.js +64 -36
- package/dist/server/home.d.ts +35 -0
- package/dist/server/home.js +104 -0
- package/dist/server/http.d.ts +3 -0
- package/dist/server/http.js +5 -0
- package/dist/server/kit-standalone.d.ts +22 -0
- package/dist/server/kit-standalone.js +69 -0
- package/dist/server/kit.d.ts +405 -0
- package/dist/server/kit.js +361 -0
- package/dist/server/pages.d.ts +2 -1
- package/dist/server/pages.js +213 -174
- package/dist/server/people-tokens.js +26 -85
- package/dist/server/render-history.d.ts +51 -0
- package/dist/server/render-history.js +228 -0
- package/dist/server/render-new-task.d.ts +29 -0
- package/dist/server/render-new-task.js +96 -0
- package/dist/server/render-next.d.ts +63 -0
- package/dist/server/render-next.js +109 -0
- package/dist/server/render-pages.d.ts +22 -190
- package/dist/server/render-pages.js +123 -575
- package/dist/server/render-people.d.ts +24 -3
- package/dist/server/render-people.js +93 -72
- package/dist/server/render-projects.d.ts +103 -0
- package/dist/server/render-projects.js +247 -0
- package/dist/server/render-results.d.ts +5 -24
- package/dist/server/render-results.js +48 -185
- package/dist/server/render-settings.d.ts +5 -74
- package/dist/server/render-settings.js +102 -493
- package/dist/server/render-tasks.d.ts +35 -5
- package/dist/server/render-tasks.js +114 -85
- package/dist/server/route-table.js +20 -5
- package/dist/server/settings.js +276 -255
- package/dist/server/task-views.d.ts +8 -0
- package/dist/server/task-views.js +85 -2
- package/dist/server/tasks.d.ts +8 -0
- package/dist/server/tasks.js +95 -40
- package/dist/server/terminals.js +1 -1
- package/dist/server/welcome.d.ts +22 -0
- package/dist/server/welcome.js +69 -0
- package/dist/settings-groups-ui.d.ts +279 -0
- package/dist/settings-groups-ui.js +615 -0
- package/dist/settings-kit.d.ts +42 -6
- package/dist/settings-kit.js +69 -7
- package/dist/setup-guide.d.ts +2 -2
- package/dist/skills-ui.js +59 -7
- package/dist/slack-settings.js +12 -5
- package/dist/spend-ui.d.ts +45 -26
- package/dist/spend-ui.js +124 -56
- package/dist/sso-ui.d.ts +5 -2
- package/dist/sso-ui.js +63 -31
- package/dist/storage-ui.d.ts +3 -2
- package/dist/storage-ui.js +56 -50
- package/dist/store.d.ts +112 -3
- package/dist/store.js +292 -20
- package/dist/surface.js +5 -1
- package/dist/teams-settings.d.ts +2 -1
- package/dist/teams-settings.js +24 -41
- package/dist/telegram-settings.d.ts +0 -4
- package/dist/telegram-settings.js +52 -25
- package/dist/toolroll-update-ui.d.ts +8 -4
- package/dist/toolroll-update-ui.js +55 -45
- package/dist/tools-ui.d.ts +5 -3
- package/dist/tools-ui.js +107 -42
- package/dist/work-index.js +11 -7
- package/dist/workspace-ui.d.ts +1 -1
- package/dist/workspace-ui.js +7 -4
- package/package.json +1 -1
package/dist/flow-unattended.js
CHANGED
|
@@ -31,7 +31,7 @@ import { lstat, readFile, realpath, stat } from "node:fs/promises";
|
|
|
31
31
|
import { createRequire } from "node:module";
|
|
32
32
|
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
33
33
|
import { isProtectedWork } from "./approval-policy.js";
|
|
34
|
-
import { dollarWords as dollars, scheduleWords, UNATTENDED_RULE_WORDS, unattendedCoverWords, unattendedEndWords, unattendedHeadWords, unattendedLimitWords, untilWords } from "./flow-unattended-words.js";
|
|
34
|
+
import { dollarWords as dollars, scheduleWords, unattendedBudgetOf, unattendedRunsOf, UNATTENDED_RULE_WORDS, unattendedCoverWords, unattendedEndWords, unattendedHeadWords, unattendedLimitWords, untilWords } from "./flow-unattended-words.js";
|
|
35
35
|
import { MODE_MAX_DAYS } from "./modes.js";
|
|
36
36
|
import { accountReachesSql } from "./project-access.js";
|
|
37
37
|
import { canonicalJson } from "./canonical-json.js";
|
|
@@ -70,10 +70,8 @@ export const FLOW_ACCEPTANCE = {
|
|
|
70
70
|
report: [{ id: "c1", statement: "The report answers the request, citing where in the repository each point comes from", how: null, evidence: ["manual-review"] }],
|
|
71
71
|
task: [{ id: "c1", statement: "The requested change is made and the project's existing checks still pass", how: null, evidence: ["manual-review"] }],
|
|
72
72
|
};
|
|
73
|
-
/** The
|
|
74
|
-
export
|
|
75
|
-
/** The largest per-attempt budget a grant may name: $100. */
|
|
76
|
-
export const UNATTENDED_MAX_BUDGET_MICROUSD = 100_000_000;
|
|
73
|
+
/** The limits a grant may name, and their one parser, live with its words (the flow page validates with them too). */
|
|
74
|
+
export { UNATTENDED_MAX_BUDGET_MICROUSD, UNATTENDED_MAX_RUNS } from "./flow-unattended-words.js";
|
|
77
75
|
export const unattendedTermsJson = (terms) => canonicalJson(terms);
|
|
78
76
|
/** sha256 over a domain-separated canonical encoding, 32 hex like the mode digest. */
|
|
79
77
|
export function unattendedDigestOf(terms) {
|
|
@@ -213,10 +211,11 @@ function closeGrant(store, row, by, reason, now) {
|
|
|
213
211
|
export function endUnattendedGrants(store, filter, by, reason, now) {
|
|
214
212
|
const rows = "flow" in filter ? store.handle.prepare("SELECT * FROM flow_unattended_grant WHERE flow = ? AND revoked_at IS NULL").all(filter.flow)
|
|
215
213
|
: "signer" in filter ? store.handle.prepare("SELECT * FROM flow_unattended_grant WHERE signed_by = ? AND revoked_at IS NULL").all(filter.signer)
|
|
216
|
-
// A script saved or removed: the grants in its project that signed a schedule running it
|
|
214
|
+
// A script saved or removed: the grants in its project that signed a schedule running it, or whose flow has a step
|
|
215
|
+
// that runs it (pre-authorized work would run the new code unseen); an unreadable one too, to be safe.
|
|
217
216
|
: "script" in filter ? store.handle.prepare("SELECT * FROM flow_unattended_grant WHERE repo = ? AND revoked_at IS NULL").all(filter.script.repo).filter(row => {
|
|
218
217
|
const terms = unattendedTermsFromJson(String(row["terms_json"]));
|
|
219
|
-
return terms === null || terms.triggers.some(one => one.script?.name === filter.script.name);
|
|
218
|
+
return terms === null || terms.triggers.some(one => one.script?.name === filter.script.name) || flowStepRunsScript(store, Number(row["flow"]), filter.script.name);
|
|
220
219
|
})
|
|
221
220
|
// A project's rules reach the flows in it and the flows whose build zones work in it.
|
|
222
221
|
: store.handle.prepare(`SELECT ug.* FROM flow_unattended_grant ug JOIN flow f ON f.id = ug.flow WHERE ug.revoked_at IS NULL
|
|
@@ -605,22 +604,13 @@ export function parseUnattendedAsk(input) {
|
|
|
605
604
|
const days = Number(text(input.days));
|
|
606
605
|
if (!Number.isInteger(days) || days < 1 || days > MODE_MAX_DAYS)
|
|
607
606
|
return { ok: false, message: `Choose 1 to ${MODE_MAX_DAYS} days: it always ends.` };
|
|
608
|
-
const
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
}
|
|
616
|
-
const runsText = text(input.runs);
|
|
617
|
-
let dailyRunCap = null;
|
|
618
|
-
if (runsText !== "") {
|
|
619
|
-
const value = Number(runsText);
|
|
620
|
-
if (!Number.isInteger(value) || value < 1 || value > UNATTENDED_MAX_RUNS)
|
|
621
|
-
return { ok: false, message: `Give runs a day as 1 to ${UNATTENDED_MAX_RUNS}, or leave it empty.` };
|
|
622
|
-
dailyRunCap = value;
|
|
623
|
-
}
|
|
607
|
+
const budget = unattendedBudgetOf(text(input.budget));
|
|
608
|
+
if (!budget.ok)
|
|
609
|
+
return budget;
|
|
610
|
+
const runs = unattendedRunsOf(text(input.runs));
|
|
611
|
+
if (!runs.ok)
|
|
612
|
+
return runs;
|
|
613
|
+
const budgetMicrousd = budget.value, dailyRunCap = runs.value;
|
|
624
614
|
const includeText = Array.isArray(input.include) ? input.include.map(text).join(",") : text(input.include);
|
|
625
615
|
const include = includeText === "" ? [] : includeText.split(/[\s,]+/).filter(one => one !== "").map(Number);
|
|
626
616
|
if (include.some(id => !Number.isSafeInteger(id) || id < 1))
|
|
@@ -772,5 +762,37 @@ export function unattendedViewOf(store, flow, viewer, now) {
|
|
|
772
762
|
terms: { head: unattendedHeadWords(viewer.name, FLOW_ACCEPTANCE), rules: [...UNATTENDED_RULE_WORDS] },
|
|
773
763
|
schedules: candidates.map(one => ({ id: one.id, words: one.words, optional: one.optional })),
|
|
774
764
|
schedulesDigest: candidatesDigestOf(candidates),
|
|
765
|
+
ended: live === null ? unattendedEndedOf(store, flow.id, now) : null,
|
|
775
766
|
};
|
|
776
767
|
}
|
|
768
|
+
/** Why the flow's latest grant ended early, in words, when a change ended it while it would still have run. */
|
|
769
|
+
const ENDED_WORDS = {
|
|
770
|
+
"flow changed": "the flow changed", "script changed": "a script it runs changed", "schedule changed": "a schedule changed",
|
|
771
|
+
"project protected": "the project's approval rules changed", "access changed": "its signer's access changed",
|
|
772
|
+
};
|
|
773
|
+
function unattendedEndedOf(store, flowId, now) {
|
|
774
|
+
const row = store.handle.prepare("SELECT revoked_at, revoke_reason, absolute_expiry FROM flow_unattended_grant WHERE flow = ? ORDER BY id DESC LIMIT 1").get(flowId);
|
|
775
|
+
if (row === undefined || row["revoked_at"] == null)
|
|
776
|
+
return null;
|
|
777
|
+
const why = ENDED_WORDS[String(row["revoke_reason"])];
|
|
778
|
+
return why === undefined || Date.parse(String(row["absolute_expiry"])) <= now.getTime() ? null : { why, at: String(row["revoked_at"]) };
|
|
779
|
+
}
|
|
780
|
+
/** Whether one of the flow's "Run a script" steps runs this script. A drawing that isn't readable and well formed counts,
|
|
781
|
+
* to be safe. */
|
|
782
|
+
function flowStepRunsScript(store, flowId, name) {
|
|
783
|
+
const flow = store.getFlow(flowId);
|
|
784
|
+
if (flow === null)
|
|
785
|
+
return false;
|
|
786
|
+
let drawn;
|
|
787
|
+
try {
|
|
788
|
+
drawn = JSON.parse(flow.definitionJson);
|
|
789
|
+
}
|
|
790
|
+
catch {
|
|
791
|
+
return true;
|
|
792
|
+
}
|
|
793
|
+
// Anything but a well-formed drawing (an object whose stages are a list of objects) fails closed: the grant ends.
|
|
794
|
+
const stages = drawn !== null && typeof drawn === "object" && !Array.isArray(drawn) ? drawn.stages : undefined;
|
|
795
|
+
if (!Array.isArray(stages) || !stages.every(stage => stage !== null && typeof stage === "object" && !Array.isArray(stage)))
|
|
796
|
+
return true;
|
|
797
|
+
return stages.some(stage => stage.kind === "check" && stage.script === name);
|
|
798
|
+
}
|
package/dist/flows-cli.js
CHANGED
|
@@ -364,8 +364,10 @@ export async function runFlowsCommand(positional, flags, context) {
|
|
|
364
364
|
return refuse(project, 'unknown-script', `There's no script called ${name} in ${projectName(project)}.`);
|
|
365
365
|
if (script.held === null)
|
|
366
366
|
return ok({ applied: false, repo: project, said: `${script.name} already runs.` }, [`${script.name} already runs.`]);
|
|
367
|
-
|
|
368
|
-
|
|
367
|
+
// Bound to the version just read: a script swapped under the same name meanwhile is refused, never approved unseen.
|
|
368
|
+
if (store.approveFlowScript(project, script.name, { version: script.version, digest: script.digest }) !== 'approved')
|
|
369
|
+
return refuse(project, 'changed', `${script.name} changed while this ran. Read it again (toolroll flows show), then approve it. Nothing was approved.`);
|
|
370
|
+
record(project, 'accepted', `script ${script.name} v${script.version} approved`);
|
|
369
371
|
settle(project);
|
|
370
372
|
return ok({ applied: true, repo: project, said: `Approved ${script.name}. Zones that run it go on.` }, [`Approved ${script.name}. Zones that run it go on.`]);
|
|
371
373
|
}
|
|
@@ -631,6 +633,8 @@ function describeFlow(store, flow, now) {
|
|
|
631
633
|
}),
|
|
632
634
|
cards: store.flowCards(flow.id, true).slice(0, 10).map(card => cardOf(definition, card)),
|
|
633
635
|
unattended: unattendedJson(store, flow, now),
|
|
636
|
+
// The latest grant a change ended early (the flow, a script, a schedule): why and when, as the flow page says it.
|
|
637
|
+
...(() => { const ended = unattendedViewOf(store, flow, { name: flow.owner, approver: true }, now).ended; return ended === null ? {} : { unattendedEnded: ended }; })(),
|
|
634
638
|
// Browser use, only for a flow with a zone that starts one: the same status line the flow page shows.
|
|
635
639
|
...(() => { const view = browserViewOf(store, flow); return view === null ? {} : { browser: { status: view.status, words: view.words, signedBy: view.signedBy, signedAt: view.signedAt } }; })(),
|
|
636
640
|
};
|
|
@@ -647,7 +651,7 @@ function showLines(flow) {
|
|
|
647
651
|
return [
|
|
648
652
|
`#${flow.id} ${flow.name} · ${flow.project} · owner ${flow.owner}`,
|
|
649
653
|
flow.unattended === null
|
|
650
|
-
? `Runs on its own: no · turn on: toolroll flows unattended ${flow.id} on --days N`
|
|
654
|
+
? `Runs on its own: no${'unattendedEnded' in flow && flow.unattendedEnded !== undefined ? ` (it ended ${untilWords(flow.unattendedEnded.at)}: ${flow.unattendedEnded.why})` : ''} · turn on: toolroll flows unattended ${flow.id} on --days N`
|
|
651
655
|
: `Runs on its own until ${untilWords(flow.unattended.until)} · revoke: toolroll flows unattended ${flow.id} off`,
|
|
652
656
|
...(flow.unattended === null ? [] : [` Covers: ${flow.unattended.covers.join('; ')}`]),
|
|
653
657
|
...(flow.browser === undefined ? [] : [`${flow.browser.words} · ${flow.browser.status === 'allowed' ? `turn off: toolroll flows browser ${flow.id} off` : `allow: toolroll flows browser ${flow.id} on`}`]),
|
package/dist/flows-ui.d.ts
CHANGED
|
@@ -2,12 +2,18 @@ import type { BrowserFlowView } from "./browser-workspace.js";
|
|
|
2
2
|
import type { FlowRow, Store } from "./store.js";
|
|
3
3
|
import { type FlowFile, type FlowImportPlan } from "./flow-share.js";
|
|
4
4
|
import { type Html } from "./html.js";
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
5
|
+
/** The flow page's fallback rules, plus the Flows list and the import preview (kit pieces; only layout here). */
|
|
6
|
+
export declare const FLOWS_CSS: string;
|
|
7
|
+
/**
|
|
8
|
+
* Flows: the top bar (a project filter, More, New flow), then each flow as a quiet row under Needs you, Running,
|
|
9
|
+
* Turned on and Paused. Words come only from what the store knows now: cards waiting on a decision, cards in
|
|
10
|
+
* progress, and the triggers (on, paused, when they last looked and what they found).
|
|
11
|
+
*/
|
|
12
|
+
export declare function flowsListHtml(store: Store, flows: readonly FlowRow[], projects: readonly string[], canCreate: boolean, problem: string | null, filter?: string | null, now?: Date): Html;
|
|
13
|
+
/** Reads the chosen flow file into the form (the server reads it again, whole, before anything is made), and sends a
|
|
14
|
+
* filter select's form when it changes. */
|
|
15
|
+
export declare const FLOW_IMPORT_SCRIPT = "(function(){document.querySelectorAll('select[data-k-autosubmit]').forEach(function(s){s.addEventListener('change',function(){if(s.form)s.form.requestSubmit();});});var form=document.querySelector('[data-flow-import]');if(!form)return;var input=form.querySelector('[data-flow-file]'),text=form.querySelector('[data-flow-document]'),note=form.querySelector('[data-flow-file-note]'),named=form.querySelector('[data-flow-file-name]'),address=form.querySelector('[name=url]');function say(words){note.textContent=words;note.hidden=words==='';}var reading=null;input.addEventListener('change',function(){text.value='';say('');var file=input.files&&input.files[0];if(named)named.textContent=file?file.name:'No file chosen';if(!file){reading=null;return;}if(file.size>262144){say('That file is too big: a flow file is at most 256 KB.');input.value='';if(named)named.textContent='No file chosen';reading=null;return;}reading=file.text().then(function(value){text.value=value;});});form.addEventListener('submit',function(event){if(reading!==null&&text.value===''){event.preventDefault();reading.then(function(){form.requestSubmit();});return;}if(text.value===''&&address.value.trim()===''){event.preventDefault();say('Choose a flow file or give its address.');}});})();";
|
|
16
|
+
/** An import's preview, a reading page: the plan in plain words, what it asks for, and one Import button after the terms. */
|
|
11
17
|
export declare function flowImportHtml(input: {
|
|
12
18
|
plan: FlowImportPlan | null;
|
|
13
19
|
file: FlowFile;
|
package/dist/flows-ui.js
CHANGED
|
@@ -27,6 +27,8 @@ import { unattendedViewOf } from "./flow-unattended.js";
|
|
|
27
27
|
import { browserViewOf } from "./flow-browser.js";
|
|
28
28
|
import { readFlowSecrets } from "./flow-secrets.js";
|
|
29
29
|
import { html, joinHtml, postForm } from "./html.js";
|
|
30
|
+
import { acts, btn, chip, count, disclose, doc, docHeader, dock, empty, field, group, infoLine, input as input_, note, notice, overlay, overlayTrigger, pageHeader, phoneHeader, row, segLinks, select, status } from "./server/kit.js";
|
|
31
|
+
import { agoWords } from "./lead-voice.js";
|
|
30
32
|
const projectName = (repo) => repo.split(/[\\/]/).filter(Boolean).pop() ?? repo;
|
|
31
33
|
/** v126: what happens to deliveries turned away (refused with 503), as far as each sender is known to say: nothing
|
|
32
34
|
* here claims a loss that a sender's own retries may still make good. */
|
|
@@ -62,40 +64,149 @@ function hookView(trigger, config, dir) {
|
|
|
62
64
|
return { ready, needsSecret: sender === "sentry" || sender === "vercel", sender, makesSecret: sender === "standard" || sender === "github-style", warning, readerNote: [readerNote, repeats].filter(one => one !== null).join(" ") || null, dropped,
|
|
63
65
|
scope: sender === "sentry" || sender === "vercel" ? { project: config.project ?? null, team: config.team ?? null } : null };
|
|
64
66
|
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
67
|
+
/** The flow page's fallback rules, plus the Flows list and the import preview (kit pieces; only layout here). */
|
|
68
|
+
export const FLOWS_CSS = `.flows{max-width:880px;min-width:0}.flows .card{padding:16px 18px;margin:12px 0}.flows form{display:grid;gap:10px;margin:0}.flows label{display:grid;gap:6px}.flows input,.flows select{box-sizing:border-box;width:100%;max-width:100%}.flows button{justify-self:start;min-height:44px}.flows .flow-fallback ol{padding-left:20px}.flows summary{cursor:pointer;min-height:44px;display:flex;align-items:center;font-weight:600}.flows button.primary{background:var(--primary);color:var(--primary-foreground);border-color:var(--primary)}.flows fieldset{border:0;padding:0;margin:0;display:grid;gap:10px;min-width:0}.flows legend{font-weight:600;padding:0;margin-bottom:6px}.flows textarea[hidden]{display:none}.flows button.secondary{background:transparent;color:var(--foreground);border:1px solid var(--border)}@media(max-width:600px){.flows input,.flows select{font-size:16px}}@media(max-width:760px){.flows .card{padding:10px 12px;margin:8px 0;position:relative}}` +
|
|
69
|
+
// The list: the phone's project bar under the phone header; notices and the example in the page's gutter.
|
|
70
|
+
`.fl-pbar{display:flex;gap:8px;align-items:center;padding:10px 16px;border-bottom:1px solid var(--so-line)}.fl-pbar form{flex:1;display:flex;gap:8px;margin:0;min-width:0}.fl-pbar .k-selw{flex:1;min-width:0}` +
|
|
71
|
+
`.fl-say{padding:12px 24px 0}.fl-try{display:grid;gap:10px;justify-items:start;padding:0 24px 24px}.fl-try form{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin:0}` +
|
|
72
|
+
// The import drawer: the file chooser is a labelled outline button over the native input.
|
|
73
|
+
`.fl-imp{display:grid;gap:18px;margin:0}.fl-file{display:flex;align-items:center;gap:10px;min-width:0}.fl-file input[type=file]{position:absolute;width:1px;height:1px;opacity:0}.fl-file label{cursor:pointer}.fl-file label:focus-within{outline:2px solid var(--so-ring);outline-offset:2px}.fl-file .k-note{min-width:0;overflow-wrap:anywhere}` +
|
|
74
|
+
`.fl-imp textarea[hidden]{display:none}.fl-terms{list-style:none;padding:0;margin:0;display:grid;gap:8px;font-size:13px;line-height:1.5}.fl-terms li{white-space:pre-line;overflow-wrap:anywhere}` +
|
|
75
|
+
`.fl-terms pre{white-space:pre-wrap;overflow-wrap:anywhere;margin:6px 0 0}.fl-pv-form{display:grid;gap:16px;margin:0}.fl-pv-form fieldset{border:0;padding:0;margin:0;display:grid;gap:14px;min-width:0}.fl-pv-form legend{font:600 13px/1.3 var(--so-font-sans);padding:0;margin-bottom:10px}` +
|
|
76
|
+
`@media(max-width:760px){.fl-say{padding:12px 16px 0}.fl-try{padding:0 16px 24px}.fl-file label{min-height:44px}}`;
|
|
77
|
+
/** "in 12 min", "in 5 h", "in 3 days": when a trigger next looks. */
|
|
78
|
+
function nextWords(at, now) {
|
|
79
|
+
const minutes = Math.max(0, Math.round((Date.parse(at) - now.getTime()) / 60_000));
|
|
80
|
+
if (!Number.isFinite(minutes) || minutes < 1)
|
|
81
|
+
return "now";
|
|
82
|
+
if (minutes < 60)
|
|
83
|
+
return `in ${minutes} min`;
|
|
84
|
+
const hours = Math.round(minutes / 60);
|
|
85
|
+
return hours < 24 ? `in ${hours} h` : `in ${Math.round(hours / 24)} day${Math.round(hours / 24) === 1 ? "" : "s"}`;
|
|
86
|
+
}
|
|
87
|
+
/** What starts a flow, in a few words: its active triggers, else the paused ones, else that cards are added by hand. */
|
|
88
|
+
function triggerWords(store, triggers) {
|
|
89
|
+
const shown = triggers.filter(one => one.state === "active");
|
|
90
|
+
const pick = shown.length > 0 ? shown : triggers.filter(one => one.state === "paused");
|
|
91
|
+
const words = pick.flatMap(one => {
|
|
92
|
+
const config = triggerConfigOf(one);
|
|
93
|
+
if (config === null)
|
|
94
|
+
return [];
|
|
95
|
+
return [config.kind === "button" ? `When someone presses ${config.label}` : triggerHeadline(config, store).name];
|
|
75
96
|
});
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
const
|
|
86
|
-
|
|
97
|
+
if (words.length === 0)
|
|
98
|
+
return "Cards added by hand";
|
|
99
|
+
return words.length === 1 ? words[0] : `${words[0]} and ${words.length - 1} more`;
|
|
100
|
+
}
|
|
101
|
+
const FLOW_LIST_GROUPS = [["needs", "Needs you"], ["running", "Running"], ["on", "Turned on"], ["paused", "Paused"]];
|
|
102
|
+
/** One flow as a quiet row: what the store knows now (cards waiting on a decision, cards in progress, its triggers). */
|
|
103
|
+
function flowListRow(store, flow, now, options) {
|
|
104
|
+
const cards = store.flowCards(flow.id, false);
|
|
105
|
+
const definition = flowDefinitionOf(flow);
|
|
106
|
+
const kindOf = (card) => definition?.stages.find(one => one.id === card.stage)?.kind ?? "";
|
|
107
|
+
const waiting = cards.filter(card => ["approval", "choose"].includes(kindOf(card)));
|
|
108
|
+
const triggers = store.flowTriggers(flow.id).filter(one => one.state !== "removed");
|
|
109
|
+
const active = triggers.filter(one => one.state === "active");
|
|
110
|
+
const button = active.flatMap(one => { const config = triggerConfigOf(one); return config?.kind === "button" ? [{ id: one.id, label: config.label }] : []; })[0];
|
|
111
|
+
const trouble = troubleWords(flowInsights(store, flow, now, 7)) ?? nightlyHealthOf(store, flow);
|
|
112
|
+
const starts = triggerWords(store, triggers);
|
|
113
|
+
const next = active.map(one => one.nextAt).filter((one) => one !== null).sort()[0];
|
|
114
|
+
const meta = html `${options.showProject ? html `<span>${projectName(flow.repo)}</span>` : ""}${next !== undefined && active.length > 0 && waiting.length === 0 ? html `<span class="k-num">Next ${nextWords(next, now)}</span>` : ""}`;
|
|
115
|
+
const start = button !== undefined && options.canAct ? btn(button.label, { sm: true, href: `/flows/${flow.id}?start=${button.id}` }) : html ``;
|
|
116
|
+
const plural = (n, one) => `${n} ${one}${n === 1 ? "" : "s"}`;
|
|
117
|
+
const tail = trouble === null ? "" : ` · ${trouble}`;
|
|
118
|
+
if (waiting.length > 0) {
|
|
119
|
+
const first = waiting[0];
|
|
120
|
+
const ask = kindOf(first) === "choose" ? chip("Choose", "ask") : chip("Approve", "ask");
|
|
121
|
+
const said = waiting.length === 1 ? `${first.title} waits for your decision` : `${plural(waiting.length, "card")} wait for your decision`;
|
|
122
|
+
return { group: "needs", row: row({ href: `/flows/${flow.id}`, chip: ask, title: flow.name, desc: `${starts} · ${said}${tail}`, meta, wait: true,
|
|
123
|
+
action: btn("Review", { sm: true, href: `/flows/${flow.id}?card=${first.id}`, attrs: { "aria-label": `Review ${first.title}` } }), attrs: { "data-flow": flow.id } }) };
|
|
124
|
+
}
|
|
125
|
+
if (cards.length > 0) {
|
|
126
|
+
return { group: "running", row: row({ href: `/flows/${flow.id}`, chip: status("running"), title: flow.name, desc: `${starts} · ${plural(cards.length, "card")} in progress${tail}`, meta, action: start, attrs: { "data-flow": flow.id } }) };
|
|
127
|
+
}
|
|
128
|
+
if (active.length > 0) {
|
|
129
|
+
const failing = active.find(one => one.failures > 0);
|
|
130
|
+
const last = active.filter(one => one.lastAt !== null).sort((a, b) => (b.lastAt ?? "").localeCompare(a.lastAt ?? ""))[0];
|
|
131
|
+
const chipNow = failing !== undefined || trouble !== null ? status("failed") : last === undefined ? chip("Not run yet", "", { dot: false }) : status("finished");
|
|
132
|
+
const lastWords = failing !== undefined ? `last check failed ${agoWords(failing.lastAt ?? failing.updatedAt, now)}${failing.lastOutcome ? `: ${failing.lastOutcome}` : ""}`
|
|
133
|
+
: last === undefined ? "not run yet" : `last ran ${agoWords(last.lastAt, now)}${last.lastOutcome ? `: ${last.lastOutcome}` : ""}`;
|
|
134
|
+
return { group: "on", row: row({ href: `/flows/${flow.id}`, chip: chipNow, title: flow.name, desc: `${starts} · ${lastWords}${tail}`, meta, action: start, attrs: { "data-flow": flow.id } }) };
|
|
135
|
+
}
|
|
136
|
+
const paused = triggers.length > 0;
|
|
137
|
+
return { group: "paused", row: row({ href: `/flows/${flow.id}`, chip: chip(paused ? "Paused" : "Off", "", { dot: false }), title: flow.name,
|
|
138
|
+
desc: paused ? `${starts} · paused${tail}` : `${starts} · nothing starts it on its own${tail}`, meta, attrs: { "data-flow": flow.id } }) };
|
|
139
|
+
}
|
|
140
|
+
/** The project field of a list form: a select when there's a choice. */
|
|
141
|
+
const projectSelect = (id, projects, current) => projects.length === 1
|
|
142
|
+
? html `<input type="hidden" name="repo" value="${projects[0]}">`
|
|
143
|
+
: field({ id, label: "Project", control: select({ id, name: "repo", items: projects.map(repo => [repo, projectName(repo)]), current: current ?? projects[0], wide: true }) });
|
|
144
|
+
/** The import form: a flow file chosen here or fetched from a gist, previewed in plain words before anything is made. */
|
|
145
|
+
function importFormHtml(projects, current) {
|
|
146
|
+
return postForm("/flows/import", html `${note("A flow file from another Toolroll, a teammate or a gist. Nothing runs until you import it, and any script it brings waits for your approval.")}\
|
|
147
|
+
${projectSelect("fl-imp-project", projects, current)}\
|
|
148
|
+
<div class="k-field"><span class="k-label">File</span><div class="fl-file"><label class="k-btn k-btn-outline k-btn-sm"><input type="file" accept=".json,application/json" data-flow-file><span class="k-face">Choose a file</span></label><span class="k-note" data-flow-file-name>No file chosen</span></div></div>\
|
|
149
|
+
<textarea name="document" hidden data-flow-document></textarea>\
|
|
150
|
+
${field({ id: "fl-imp-url", label: "Or its address", control: input_({ id: "fl-imp-url", name: "url", type: "url", attrs: { inputmode: "url", placeholder: "https://gist.github.com/…" } }) })}\
|
|
151
|
+
<p class="k-notice" role="alert" data-flow-file-note hidden></p>${acts(btn("Preview import", { type: "submit" }))}`, { attrs: { class: "fl-imp", "data-flow-import": true } });
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Flows: the top bar (a project filter, More, New flow), then each flow as a quiet row under Needs you, Running,
|
|
155
|
+
* Turned on and Paused. Words come only from what the store knows now: cards waiting on a decision, cards in
|
|
156
|
+
* progress, and the triggers (on, paused, when they last looked and what they found).
|
|
157
|
+
*/
|
|
158
|
+
export function flowsListHtml(store, flows, projects, canCreate, problem, filter = null, now = new Date()) {
|
|
159
|
+
const scope = filter !== null && projects.includes(filter) ? filter : null;
|
|
160
|
+
const shown = scope === null ? flows : flows.filter(one => one.repo === scope);
|
|
161
|
+
const making = canCreate && projects.length > 0;
|
|
162
|
+
const href = (repo) => repo === null ? "/flows" : `/flows?project=${encodeURIComponent(repo)}`;
|
|
163
|
+
const seg = projects.length > 1 ? segLinks("Project", scope ?? "all", [{ key: "all", label: "All", href: "/flows" }, ...projects.map(repo => ({ key: repo, label: projectName(repo), href: href(repo) }))]) : html ``;
|
|
164
|
+
const more = (id, phone) => overlayTrigger("More", "fl-more", { variant: phone ? "outline" : "ghost", sm: !phone, iconOnly: "more", ...(id === "" ? {} : { attrs: { id } }) });
|
|
165
|
+
const create = making ? btn("New flow", { variant: "primary", sm: true, lead: "plus", href: "/flows/new" }) : html ``;
|
|
166
|
+
const header = pageHeader("Flows", { right: html `${seg}${more("", false)}${create}` });
|
|
167
|
+
// On a phone the filter is a select (the form posts nothing: it's an address), beside More.
|
|
168
|
+
const phoneBar = html `<div class="fl-pbar k-phone-only">${projects.length > 1 ? html `<form method="get" action="/flows">${select({ name: "project", label: "Project", items: [["", "All projects"], ...projects.map(repo => [repo, projectName(repo)])], current: scope ?? "", wide: true, attrs: { "data-k-autosubmit": "" } })}<noscript>${btn("Show", { sm: true, type: "submit" })}</noscript></form>` : html `<span class="k-grow"></span>`}${more("", true)}</div>`;
|
|
169
|
+
const menu = overlay({ id: "fl-more", kind: "menu", title: "More", body: html `${making ? html `<button type="button" data-k-open="fl-import" aria-haspopup="dialog" aria-controls="fl-import">Import a flow file…</button>` : ""}<a href="/flows/new?group=kits">Starter kits</a><a href="/settings/lead/subagents">Subagents</a>` });
|
|
170
|
+
const importDrawer = making ? overlay({ id: "fl-import", kind: "drawer", title: "Import a flow file", body: importFormHtml(projects, scope) }) : html ``;
|
|
171
|
+
const say = problem !== null ? html `<div class="fl-say">${notice(problem)}</div>` : html ``;
|
|
172
|
+
let body;
|
|
173
|
+
if (flows.length === 0) {
|
|
174
|
+
const example = making ? html `<div class="fl-try">${postForm("/flows/example", html `<span class="k-note">Or see one work: Claude drafts a reply to a sample customer question for you to approve.</span>${projects.length === 1 ? html `<input type="hidden" name="repo" value="${projects[0]}">` : select({ name: "repo", label: "Project", items: projects.map(repo => [repo, projectName(repo)]), sm: true })}${btn("Try an example", { sm: true, type: "submit" })}`)}</div>` : html ``;
|
|
175
|
+
const words = { title: "No flows yet.", text: "A flow repeats work for you: run your tests every night, turn labelled issues into pull requests, draft replies to support mail." };
|
|
176
|
+
body = html `${empty(making ? { ...words, action: acts(btn("Browse templates", { sm: true, href: "/flows/new" }), overlayTrigger("Import a flow file", "fl-import", { variant: "ghost", sm: true })) } : words)}${example}`;
|
|
177
|
+
}
|
|
178
|
+
else if (shown.length === 0) {
|
|
179
|
+
body = empty({ title: `No flows in ${projectName(scope)}.`, action: btn("Show all projects", { variant: "ghost", sm: true, href: "/flows" }) });
|
|
180
|
+
}
|
|
181
|
+
else {
|
|
182
|
+
const rows = shown.map(flow => flowListRow(store, flow, now, { canAct: canCreate, showProject: scope === null && projects.length > 1 }));
|
|
183
|
+
body = html `<div class="fl-rows" aria-label="Flows">${FLOW_LIST_GROUPS.map(([key, label]) => {
|
|
184
|
+
const mine = rows.filter(one => one.group === key);
|
|
185
|
+
return mine.length === 0 ? "" : html `${group(label, count(mine.length))}${mine.map(one => one.row)}`;
|
|
186
|
+
})}</div>`;
|
|
187
|
+
}
|
|
188
|
+
return html `<div class="fl-page" data-kit-page>${header}${phoneBar}${say}${body}${menu}${importDrawer}${making ? dock(btn("New flow", { variant: "primary", lead: "plus", href: "/flows/new" })) : ""}</div>`;
|
|
87
189
|
}
|
|
88
|
-
/** Reads the chosen flow file into the form (the server reads it again, whole, before anything is made)
|
|
89
|
-
|
|
90
|
-
|
|
190
|
+
/** Reads the chosen flow file into the form (the server reads it again, whole, before anything is made), and sends a
|
|
191
|
+
* filter select's form when it changes. */
|
|
192
|
+
export const FLOW_IMPORT_SCRIPT = `(function(){document.querySelectorAll('select[data-k-autosubmit]').forEach(function(s){s.addEventListener('change',function(){if(s.form)s.form.requestSubmit();});});var form=document.querySelector('[data-flow-import]');if(!form)return;var input=form.querySelector('[data-flow-file]'),text=form.querySelector('[data-flow-document]'),note=form.querySelector('[data-flow-file-note]'),named=form.querySelector('[data-flow-file-name]'),address=form.querySelector('[name=url]');function say(words){note.textContent=words;note.hidden=words==='';}var reading=null;input.addEventListener('change',function(){text.value='';say('');var file=input.files&&input.files[0];if(named)named.textContent=file?file.name:'No file chosen';if(!file){reading=null;return;}if(file.size>${FLOW_FILE_MAX_BYTES}){say('That file is too big: a flow file is at most ${FLOW_FILE_MAX_BYTES / 1024} KB.');input.value='';if(named)named.textContent='No file chosen';reading=null;return;}reading=file.text().then(function(value){text.value=value;});});form.addEventListener('submit',function(event){if(reading!==null&&text.value===''){event.preventDefault();reading.then(function(){form.requestSubmit();});return;}if(text.value===''&&address.value.trim()===''){event.preventDefault();say('Choose a flow file or give its address.');}});})();`;
|
|
193
|
+
/** An import's preview, a reading page: the plan in plain words, what it asks for, and one Import button after the terms. */
|
|
91
194
|
export function flowImportHtml(input) {
|
|
92
195
|
const { plan, file } = input;
|
|
93
|
-
const fields = file.parameters.map(one =>
|
|
94
|
-
|
|
196
|
+
const fields = file.parameters.map((one, index) => field({ id: `fl-param-${index}`, label: one.optional === true ? `${one.about} (optional)` : one.about,
|
|
197
|
+
control: input_({ id: `fl-param-${index}`, name: `param.${one.id}`, value: input.values[one.id] ?? plan?.values[one.id] ?? one.default ?? "", attrs: { maxlength: 200, autocomplete: "off" } }) }));
|
|
198
|
+
const list = (items) => items.length === 0 ? "" : html `<ul class="fl-terms">${items.map(term => html `<li>${term}</li>`)}</ul>`;
|
|
199
|
+
const section = (title, body) => html `<section class="k-sec"><h3>${title}</h3>${body}</section>`;
|
|
95
200
|
// The steps, what they mean, the triggers and the scripts; each script's code one tap away, as on the Scripts panel.
|
|
96
|
-
const terms = plan === null ? "" : html `${plan.parts.about !== null && html `<p
|
|
97
|
-
|
|
98
|
-
|
|
201
|
+
const terms = plan === null ? "" : html `${plan.parts.about !== null && html `<p class="k-note">${plan.parts.about}</p>`}\
|
|
202
|
+
${section("Steps", html `${infoLine("Its instructions come from the file, not from you: read what each step is asked before you import.")}${list(plan.parts.steps)}`)}\
|
|
203
|
+
${plan.parts.triggers.length > 0 && section("Triggers", list(plan.parts.triggers))}\
|
|
204
|
+
${plan.parts.scripts.length + plan.parts.kept.length > 0 && section("Scripts", html `<ul class="fl-terms">${plan.scripts.add.map((one, index) => html `<li>${plan.parts.scripts[index]}${one.body !== undefined && disclose("Show the script", html `<pre class="k-mono">${one.body}</pre>`)}</li>`)}${plan.parts.kept.map(one => html `<li>${one}</li>`)}</ul>`)}\
|
|
205
|
+
${section("Good to know", list([...plan.parts.notes, ...(plan.parts.needs === null ? [] : [plan.parts.needs])]))}`;
|
|
206
|
+
const importButton = plan !== null ? btn("Import flow", { variant: "primary", type: "submit", docked: true, attrs: { name: "confirm", value: "yes" } }) : html ``;
|
|
207
|
+
const form = postForm("/flows/import", html `<textarea name="document" hidden>${input.document}</textarea>${fields.length > 0 && html `<fieldset><legend>Fill in</legend>${fields}</fieldset>`}\
|
|
208
|
+
${acts(importButton, fields.length > 0 ? btn(plan === null ? "Preview" : "Update preview", { variant: plan === null ? "primary" : "outline", type: "submit", attrs: { name: "confirm", value: "" } }) : "", btn("Cancel", { variant: "ghost", href: "/flows" }))}`, { attrs: { class: "fl-pv-form", id: "fl-pv-form" }, hidden: { repo: input.repo, previewed: input.previewed } });
|
|
209
|
+
return html `<div class="fl-page" data-kit-page>${phoneHeader(`Import ${file.name}`, { back: "/flows" })}${doc(docHeader({ title: `Import ${file.name}`, back: ["/flows", "Flows"], meta: `Into ${projectName(input.repo)}. Nothing is made until you import it.`, same: true }), input.problem !== null ? notice(input.problem) : "", terms, form)}${plan !== null ? dock(btn("Import flow", { variant: "primary", type: "submit", attrs: { form: "fl-pv-form", name: "confirm", value: "yes" } })) : ""}</div>`;
|
|
99
210
|
}
|
|
100
211
|
const historyText = (event, title, sorts = new Set()) => {
|
|
101
212
|
const who = event.actor === "flow" ? "" : ` by ${event.actor}`;
|
|
@@ -260,7 +371,7 @@ export function flowView(store, flow, viewer, selectedCard, setup = { dir: null,
|
|
|
260
371
|
requestSecrets: flowSecretNames(setup.dir, flow.repo),
|
|
261
372
|
tools: projectToolsOf(store, flow.repo).map(tool => ({ name: tool.name, about: tool.spec.about, functions: tool.lastTest?.ok === true ? tool.lastTest.tools : [],
|
|
262
373
|
ready: toolStanding(tool, secretsSetFor(flow.repo, tool.spec, setup.toolHome)).ready })),
|
|
263
|
-
scripts: store.flowScripts(flow.repo).map(script => ({ name: script.name, about: script.about, body: script.body, timeoutMinutes: script.timeoutMinutes, version: script.version, savedBy: script.savedBy, savedAt: script.savedAt,
|
|
374
|
+
scripts: store.flowScripts(flow.repo).map(script => ({ name: script.name, about: script.about, body: script.body, timeoutMinutes: script.timeoutMinutes, version: script.version, digest: script.digest, savedBy: script.savedBy, savedAt: script.savedAt,
|
|
264
375
|
language: script.language, file: script.file, held: script.held !== null,
|
|
265
376
|
usedHere: stages.filter(stage => stage.kind === "check" && stage.script === script.name).map(stage => stage.title) })),
|
|
266
377
|
start: definition?.start ?? stages[0]?.id ?? "",
|
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Settings → Integrations: which integrations work. One
|
|
2
|
+
* Settings → Integrations: which integrations work. One record each: its name
|
|
3
3
|
* and state, the account it's connected as, when it last worked and last
|
|
4
4
|
* failed, what uses it, and one action. The list comes from saved checks; a
|
|
5
|
-
* render never waits on one (see integrations.ts).
|
|
5
|
+
* render never waits on one (see integrations.ts). Built from the revamp kit;
|
|
6
|
+
* the Settings frame adds the title.
|
|
6
7
|
*/
|
|
7
8
|
import { type Html } from "./html.js";
|
|
8
9
|
import { type Integration, type SecretsKept } from "./integrations.js";
|
|
9
|
-
export
|
|
10
|
-
export declare
|
|
10
|
+
/** The page is kit pieces now; nothing of the old look is left. Kept as an export for the shared stylesheet. */
|
|
11
|
+
export declare const INTEGRATIONS_CSS = "";
|
|
12
|
+
export declare function integrationsHtml(list: readonly Integration[], csrf: string, said: {
|
|
11
13
|
said?: string | null;
|
|
12
14
|
problem?: string | null;
|
|
13
15
|
checking?: boolean;
|
package/dist/integrations-ui.js
CHANGED
|
@@ -1,46 +1,35 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Settings → Integrations: which integrations work. One
|
|
2
|
+
* Settings → Integrations: which integrations work. One record each: its name
|
|
3
3
|
* and state, the account it's connected as, when it last worked and last
|
|
4
4
|
* failed, what uses it, and one action. The list comes from saved checks; a
|
|
5
|
-
* render never waits on one (see integrations.ts).
|
|
5
|
+
* render never waits on one (see integrations.ts). Built from the revamp kit;
|
|
6
|
+
* the Settings frame adds the title.
|
|
6
7
|
*/
|
|
7
8
|
import { html, joinHtml, postForm } from "./html.js";
|
|
8
9
|
import { STATE_WORDS, secretsNote } from "./integrations.js";
|
|
9
10
|
import { whenUtc } from "./when-html.js";
|
|
10
11
|
import { brandMarkHtml } from "./brand-mark.js";
|
|
12
|
+
import { btn, chip, disclose, infoLine, note, notice, settingsRecord, settingsSection } from "./server/kit.js";
|
|
11
13
|
const when = (at) => whenUtc(at);
|
|
12
|
-
|
|
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}` +
|
|
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}` +
|
|
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}` +
|
|
17
|
-
`.integrations .integration .integration-action button{width:auto}.integrations .integration .integration-action a.integration-quiet,.integrations .integration .integration-action button.integration-quiet{background:var(--so-paper);color:var(--so-ink);border:1px solid var(--so-input-line)}` +
|
|
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}` +
|
|
19
|
-
`.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}` +
|
|
20
|
-
`.integration-state i{width:6px;height:6px;border-radius:50%;background:currentColor}` +
|
|
21
|
-
`.integration-state--connected{color:var(--so-success);background:var(--so-success-soft)}.integration-state--broken{color:var(--so-danger);background:var(--so-danger-soft)}` +
|
|
22
|
-
`.integration-state--not-set-up,.integration-state--checking{color:var(--so-muted);background:var(--so-neutral-soft)}` +
|
|
23
|
-
// A phone: the action sits at the right of the name line, and the facts under it run on as one line.
|
|
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}` +
|
|
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}` +
|
|
26
|
-
`.integrations .integration-body>p{margin:0}.integrations .integration-body>p.meta{display:inline}.integrations .integration-body>p.meta+p.meta::before{content:" · "}` +
|
|
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}}`;
|
|
14
|
+
/** The page is kit pieces now; nothing of the old look is left. Kept as an export for the shared stylesheet. */
|
|
15
|
+
export const INTEGRATIONS_CSS = "";
|
|
28
16
|
const GROUPS = [
|
|
29
17
|
["chat", "Chat"], ["code", "Code and issues"], ["mail", "Email"], ["tools", "MCP tools"], ["monitoring", "Monitoring"], ["agents", "Agents"],
|
|
30
18
|
];
|
|
31
19
|
function action(one, csrf) {
|
|
32
20
|
const a = one.action;
|
|
21
|
+
// One harmless read-only check of this integration, now (the handler sends nothing).
|
|
33
22
|
if (a.kind === "test") {
|
|
34
|
-
return csrf === "" ? "" : postForm("/settings/integrations/test",
|
|
23
|
+
return csrf === "" ? "" : postForm("/settings/integrations/test", btn("Check connection", { sm: true, type: "submit" }), { hidden: { key: one.key } });
|
|
35
24
|
}
|
|
36
25
|
// A project's youiyoux requests: one switch for that project.
|
|
37
26
|
if (a.kind === "switch") {
|
|
38
|
-
return csrf === "" ? "" : postForm("/settings/integrations/youiyoux",
|
|
27
|
+
return csrf === "" ? "" : postForm("/settings/integrations/youiyoux", btn(a.label, { sm: true, type: "submit" }), { hidden: { repo: a.repo, on: a.on ? "1" : "0" } });
|
|
39
28
|
}
|
|
40
29
|
// A command is run where Toolroll runs; a page is linked. Fix and Set up both say which.
|
|
41
|
-
// Fix is the
|
|
30
|
+
// Fix is the louder of the two; Set up stays quiet so a list of unused services doesn't shout.
|
|
42
31
|
if (a.href !== null)
|
|
43
|
-
return
|
|
32
|
+
return btn(a.label, { sm: true, href: a.href, variant: a.kind === "setup" ? "ghost" : "outline" });
|
|
44
33
|
return "";
|
|
45
34
|
}
|
|
46
35
|
/** Which mark a row shows: email is Gmail's only when the account is a Gmail address; a custom tool is a letter. */
|
|
@@ -49,33 +38,38 @@ function markFor(one) {
|
|
|
49
38
|
return null;
|
|
50
39
|
return one.key === "email" && /@(gmail|googlemail)\.com$/i.test(one.account ?? "") ? "gmail" : one.key;
|
|
51
40
|
}
|
|
41
|
+
/** Its state as one of the kit's status chips: working, broken, not set up, or still being checked. */
|
|
42
|
+
function stateChip(one) {
|
|
43
|
+
if (!one.checked && one.state === "connected")
|
|
44
|
+
return chip("Checking", "live");
|
|
45
|
+
return chip(STATE_WORDS[one.state], one.state === "connected" ? "done" : one.state === "broken" ? "failed" : "");
|
|
46
|
+
}
|
|
52
47
|
function row(one, csrf) {
|
|
53
|
-
const badge = !one.checked && one.state === "connected"
|
|
54
|
-
? html `<span class="integration-state integration-state--checking"><i aria-hidden="true"></i>Checking</span>`
|
|
55
|
-
: html `<span class="integration-state integration-state--${one.state}"><i aria-hidden="true"></i>${STATE_WORDS[one.state]}</span>`;
|
|
56
48
|
const facts = [one.account, one.detail, one.credentials].filter(Boolean).join(" · ");
|
|
57
49
|
const command = one.action.kind === "setup" || one.action.kind === "fix" ? one.action.command : null;
|
|
58
50
|
const words = one.action.kind === "fix" || one.action.kind === "switch" ? one.action.words : null;
|
|
59
|
-
const fix = words !== null ?
|
|
60
|
-
const run = command === null || (one.action.kind === "fix" && one.action.words.includes(command)) ? "" : html
|
|
51
|
+
const fix = words !== null ? notice(words) : "";
|
|
52
|
+
const run = command === null || (one.action.kind === "fix" && one.action.words.includes(command)) ? "" : note(html `Run <code class="k-mono">${command}</code> on the computer running Toolroll.`);
|
|
61
53
|
const seen = [one.lastSuccessAt === null ? null : html `Last success ${when(one.lastSuccessAt)}`, one.usedBy.length === 0 ? null : html `Used by ${one.usedBy.join(", ")}`].filter(Boolean);
|
|
62
|
-
const history = one.state === "not-set-up" || seen.length === 0 ? "" :
|
|
54
|
+
const history = one.state === "not-set-up" || seen.length === 0 ? "" : note(joinHtml(seen, " · "));
|
|
63
55
|
const lastError = one.lastError === null || one.state === "not-set-up" ? "" : one.state === "broken" && words === one.lastError
|
|
64
|
-
? html
|
|
65
|
-
:
|
|
66
|
-
|
|
56
|
+
? note(html `Failed ${when(one.lastErrorAt ?? "")}`)
|
|
57
|
+
: disclose(`Last error${one.lastErrorAt === null ? "" : ` ${one.lastErrorAt.slice(0, 16).replace("T", " ")} UTC`}`, note(one.lastError));
|
|
58
|
+
const more = joinHtml([fix, run, history, lastError]);
|
|
59
|
+
return settingsRecord({ title: html `${brandMarkHtml(markFor(one), one.name, one.state === "connected")}<span>${one.name}</span> ${stateChip(one)}`,
|
|
60
|
+
sub: facts, action: action(one, csrf), attrs: { "data-integration": one.key, "data-state": one.state }, more });
|
|
67
61
|
}
|
|
68
|
-
export function integrationsHtml(list, csrf,
|
|
69
|
-
const
|
|
62
|
+
export function integrationsHtml(list, csrf, said) {
|
|
63
|
+
const answer = said.problem ? notice(said.problem) : said.said ? infoLine(said.said) : "";
|
|
70
64
|
const broken = list.filter(one => one.state === "broken").length;
|
|
71
|
-
const summary = broken > 0 ? html `<p><
|
|
72
|
-
:
|
|
65
|
+
const summary = broken > 0 ? html `<p class="k-note"><b>${broken} need${broken === 1 ? "s" : ""} fixing.</b></p>`
|
|
66
|
+
: said.checking || list.some(one => !one.checked) ? html `<p class="k-note" role="status">Checking in the background. Refresh in a few seconds.</p>` : "";
|
|
67
|
+
// No keychain here, or Toolroll's folder for secrets can't be used: said once, plainly.
|
|
68
|
+
const kept = secretsNote(list, said.secrets);
|
|
69
|
+
const secrets = kept === null ? "" : kept.problem ? notice(kept.words) : html `<p class="k-note" role="note">${kept.words}</p>`;
|
|
73
70
|
const sections = GROUPS.map(([group, title]) => {
|
|
74
71
|
const rows = list.filter(one => one.group === group);
|
|
75
|
-
return rows.length === 0 ? "" :
|
|
72
|
+
return rows.length === 0 ? "" : settingsSection({ id: `integrations-${group}`, title, body: joinHtml(rows.map(one => row(one, csrf))) });
|
|
76
73
|
});
|
|
77
|
-
|
|
78
|
-
const said = secretsNote(list, notice.secrets);
|
|
79
|
-
const kept = said === null ? "" : said.problem ? html `<p class="problem integration-secrets" role="alert">${said.words}</p>` : html `<p class="meta integration-secrets" role="note">${said.words}</p>`;
|
|
80
|
-
return html `<section class="integrations">${note}${summary}${kept}${sections}</section>`;
|
|
74
|
+
return joinHtml([answer, summary, secrets, ...sections]);
|
|
81
75
|
}
|
package/dist/integrations.d.ts
CHANGED
|
@@ -13,7 +13,7 @@ export type IntegrationAction = ({
|
|
|
13
13
|
label: "Set up";
|
|
14
14
|
} & IntegrationPlace) | {
|
|
15
15
|
kind: "test";
|
|
16
|
-
label: "
|
|
16
|
+
label: "Check connection";
|
|
17
17
|
} | ({
|
|
18
18
|
kind: "fix";
|
|
19
19
|
label: "Fix";
|
|
@@ -124,7 +124,7 @@ export declare function renderIntegrations(list: readonly Integration[], secrets
|
|
|
124
124
|
export type IntegrationMonitor = {
|
|
125
125
|
/** The list now, from the last answers saved. It only reads: a page calls it on every render (docs/plans/get-side-effects.md). */
|
|
126
126
|
list(): Integration[];
|
|
127
|
-
/** Check now and wait (
|
|
127
|
+
/** Check now and wait (Check connection, Check now, or the first answer in a test). */
|
|
128
128
|
check(keys?: readonly string[]): Promise<Integration[]>;
|
|
129
129
|
/** The background pass: start a check when the last one is older than the cache. Never called from a page. */
|
|
130
130
|
refreshIfDue(): void;
|