toolroll 0.9.1 → 0.9.2
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 +28 -0
- package/dist/agent-fence.d.ts +11 -2
- package/dist/agent-fence.js +29 -3
- package/dist/browser/workspace.css +1 -1
- package/dist/browser/workspace.js +3 -3
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +3 -0
- package/dist/desktop-update.d.ts +1 -1
- package/dist/flow-insights.d.ts +9 -0
- package/dist/flow-insights.js +16 -0
- package/dist/flow-starters.d.ts +6 -1
- package/dist/flow-starters.js +46 -3
- package/dist/flow-triggers.d.ts +20 -1
- package/dist/flow-triggers.js +105 -6
- package/dist/flows-cli.d.ts +228 -0
- package/dist/flows-cli.js +403 -0
- package/dist/flows-ui.js +1 -1
- package/dist/flows.d.ts +2 -0
- package/dist/flows.js +4 -0
- package/dist/guides.js +23 -0
- package/dist/keys.js +4 -1
- package/dist/mate-contract.js +1 -1
- package/dist/mate-tools.js +4 -3
- package/dist/operate.d.ts +1 -1
- package/dist/operate.js +39 -4
- package/dist/plane-review.d.ts +39 -0
- package/dist/plane-review.js +233 -0
- package/dist/surface.js +4 -0
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -85,6 +85,8 @@ and any queue command + --help prints it too
|
|
|
85
85
|
toolroll heartbeat <lease> / release <lease> / reap
|
|
86
86
|
toolroll tick --runner <name> --token <t> --repo <path>
|
|
87
87
|
one unattended pass over the ready set
|
|
88
|
+
toolroll flows list | show <id> | create | edit | trigger | script | card | archive
|
|
89
|
+
set up and inspect flows (writes preview until --yes)
|
|
88
90
|
toolroll chat --as <you> --token <t>
|
|
89
91
|
talk to the mate: one conversation across every
|
|
90
92
|
project; it proposes, you confirm (--say "…" for one turn)
|
|
@@ -239,6 +241,7 @@ export const OPERATE_COMMANDS = new Set([
|
|
|
239
241
|
"publish",
|
|
240
242
|
"reconcile",
|
|
241
243
|
"routine",
|
|
244
|
+
"flows",
|
|
242
245
|
"config", "chat", "proposals", "mode", "people", "keys",
|
|
243
246
|
"setup",
|
|
244
247
|
"verify",
|
package/dist/desktop-update.d.ts
CHANGED
|
@@ -66,7 +66,6 @@ export declare function prepareDesktopUpdate(stateDir: string, installed: string
|
|
|
66
66
|
export declare function sqliteLock(file: string, busyTimeoutMs?: number): DatabaseSync | null;
|
|
67
67
|
export declare const installationLock: (j: UpdateJournal) => DatabaseSync | null;
|
|
68
68
|
export declare function desktopUpdateStatus(stateDir: string): {
|
|
69
|
-
version?: never;
|
|
70
69
|
active: boolean;
|
|
71
70
|
phase: string;
|
|
72
71
|
detail: string;
|
|
@@ -80,6 +79,7 @@ export declare function desktopUpdateStatus(stateDir: string): {
|
|
|
80
79
|
wasRunning?: never;
|
|
81
80
|
backupPath?: never;
|
|
82
81
|
updateId?: never;
|
|
82
|
+
version?: never;
|
|
83
83
|
buildId?: never;
|
|
84
84
|
workDir?: never;
|
|
85
85
|
} | {
|
package/dist/flow-insights.d.ts
CHANGED
|
@@ -88,6 +88,15 @@ export type FlowInsights = {
|
|
|
88
88
|
scripts: ScriptInsight[];
|
|
89
89
|
sorts: SortInsight[];
|
|
90
90
|
runs: StepRunInsight[];
|
|
91
|
+
/** A plane review's problems, the ones seen on the most days first: which causes keep coming back. */
|
|
92
|
+
recurring: RecurringProblem[];
|
|
93
|
+
};
|
|
94
|
+
export type RecurringProblem = {
|
|
95
|
+
problem: string;
|
|
96
|
+
title: string;
|
|
97
|
+
days: number;
|
|
98
|
+
lastSeen: string;
|
|
99
|
+
card: number | null;
|
|
91
100
|
};
|
|
92
101
|
/** One flow's insights over the last `days` days. */
|
|
93
102
|
export declare function flowInsights(store: Store, flow: FlowRow, now: Date, days?: number): FlowInsights;
|
package/dist/flow-insights.js
CHANGED
|
@@ -83,12 +83,28 @@ export function flowInsights(store, flow, now, days = 30) {
|
|
|
83
83
|
.map(one => ({ zone: one.zone, title: one.title, problems: one.failed + one.sentBack, of: Math.max(one.entered, one.failed + one.sentBack) })),
|
|
84
84
|
scripts: [...scripts].map(([script, one]) => ({ script, runs: one.runs, passed: one.passed, failed: one.failed, typicalSeconds: median(one.durations) === null ? null : Math.round(median(one.durations)), lastFailure: one.lastFailure })),
|
|
85
85
|
sorts,
|
|
86
|
+
recurring: recurringProblems(store, flow, since),
|
|
86
87
|
runs: runs.slice(0, 40).map(run => ({
|
|
87
88
|
card: run.card, cardTitle: run.cardTitle, entry: run.entry, zone: run.stage, zoneTitle: titleOf(run.stage), kind: run.kind, script: run.script, version: run.scriptVersion,
|
|
88
89
|
state: run.state, result: run.result, exitCode: run.exitCode, durationMs: run.durationMs, at: run.finishedAt ?? run.startedAt, hasLog: run.log !== null && run.log !== "",
|
|
89
90
|
})),
|
|
90
91
|
};
|
|
91
92
|
}
|
|
93
|
+
/** Each problem a plane review trigger of this flow noted in the window (one note per problem a day), and on how many days. */
|
|
94
|
+
function recurringProblems(store, flow, since) {
|
|
95
|
+
const rows = store.handle.prepare(`SELECT e.key, e.card, e.at, c.title FROM flow_trigger_event e JOIN flow_trigger t ON t.id = e.trigger LEFT JOIN flow_card c ON c.id = e.card
|
|
96
|
+
WHERE t.flow = ? AND json_extract(t.config_json, '$.kind') = 'plane-review' AND substr(e.key, 1, 6) = 'plane:' AND e.at >= ? ORDER BY e.rowid`).all(flow.id, since.toISOString());
|
|
97
|
+
const problems = new Map();
|
|
98
|
+
for (const row of rows) {
|
|
99
|
+
const match = /^plane:(.+):(\d{4}-\d{2}-\d{2})$/.exec(String(row["key"]));
|
|
100
|
+
if (match === null)
|
|
101
|
+
continue;
|
|
102
|
+
const had = problems.get(match[1]);
|
|
103
|
+
const card = row["card"] === null ? had?.card ?? null : Number(row["card"]);
|
|
104
|
+
problems.set(match[1], { problem: match[1], title: row["title"] === null ? had?.title ?? match[1] : String(row["title"]), days: (had?.days ?? 0) + 1, lastSeen: match[2], card });
|
|
105
|
+
}
|
|
106
|
+
return [...problems.values()].sort((a, b) => b.days - a.days || b.lastSeen.localeCompare(a.lastSeen)).slice(0, 20);
|
|
107
|
+
}
|
|
92
108
|
const BANDS = [[90, 101], [80, 90], [70, 80], [50, 70]];
|
|
93
109
|
function sortInsight(stage, runs, byCard) {
|
|
94
110
|
const sort = stage.sort;
|
package/dist/flow-starters.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type FlowStepInput } from "./flows.js";
|
|
2
2
|
import type { FlowRow, Store } from "./store.js";
|
|
3
|
-
export declare const STARTER_IDS: readonly ["ci-fix", "issue-task", "overnight"];
|
|
3
|
+
export declare const STARTER_IDS: readonly ["ci-fix", "issue-task", "overnight", "plane-review"];
|
|
4
4
|
export type StarterId = (typeof STARTER_IDS)[number];
|
|
5
5
|
export type StarterFlow = {
|
|
6
6
|
id: StarterId;
|
|
@@ -12,16 +12,21 @@ export type StarterFlow = {
|
|
|
12
12
|
does: (where: {
|
|
13
13
|
github: string | null;
|
|
14
14
|
branch: string;
|
|
15
|
+
own: boolean;
|
|
15
16
|
}) => string[];
|
|
16
17
|
/** What it never does. */
|
|
17
18
|
never: string;
|
|
18
19
|
github: boolean;
|
|
19
20
|
steps: FlowStepInput[];
|
|
21
|
+
/** The steps in Toolroll's own repository, when they differ: there, every fix lands in Toolroll. */
|
|
22
|
+
ownSteps?: FlowStepInput[];
|
|
20
23
|
trigger: (where: {
|
|
21
24
|
branch: string;
|
|
22
25
|
}) => Record<string, unknown>;
|
|
23
26
|
};
|
|
24
27
|
export declare const STARTER_FLOWS: readonly StarterFlow[];
|
|
28
|
+
/** Whether a project is Toolroll's own source. */
|
|
29
|
+
export declare function isToolrollRepo(repo: string): boolean;
|
|
25
30
|
export declare const starterOf: (id: string) => StarterFlow | null;
|
|
26
31
|
/** The flow a starter made in a project, while it is on. */
|
|
27
32
|
export declare function starterFlowOf(store: Store, starter: StarterFlow, repo: string): FlowRow | null;
|
package/dist/flow-starters.js
CHANGED
|
@@ -4,11 +4,13 @@
|
|
|
4
4
|
* and its trigger, exactly as drawing them by hand would; it is on while that flow is. Each says in plain words
|
|
5
5
|
* what it will do and what it never does: no starter merges or ships anything without a person.
|
|
6
6
|
*/
|
|
7
|
+
import { readFileSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
7
9
|
import { flowDefinitionOf } from "./flow-engine.js";
|
|
8
10
|
import { addFlowTriggerTo, githubRepoOf } from "./flow-triggers.js";
|
|
9
11
|
import { flowFromSteps, ISSUE_LABEL } from "./flows.js";
|
|
10
12
|
import { publishingOf } from "./pull-request-flow.js";
|
|
11
|
-
export const STARTER_IDS = ["ci-fix", "issue-task", "overnight"];
|
|
13
|
+
export const STARTER_IDS = ["ci-fix", "issue-task", "overnight", "plane-review"];
|
|
12
14
|
export const STARTER_FLOWS = [
|
|
13
15
|
{
|
|
14
16
|
id: "ci-fix",
|
|
@@ -51,7 +53,47 @@ export const STARTER_FLOWS = [
|
|
|
51
53
|
],
|
|
52
54
|
trigger: () => ({ kind: "button", label: "Queue for tonight", questions: ["What needs doing?", "Details"] }),
|
|
53
55
|
},
|
|
56
|
+
{
|
|
57
|
+
id: "plane-review",
|
|
58
|
+
name: "Morning plane review",
|
|
59
|
+
summary: "What went wrong in Toolroll yesterday becomes cards, researched and fixed.",
|
|
60
|
+
does: ({ own }) => [
|
|
61
|
+
"Every day at 07:30, looks at failed runs, waiting tasks, sign-ins, plan limits, chat, integrations and checks.",
|
|
62
|
+
"One card per problem; one that comes back joins its card. A clean day adds nothing.",
|
|
63
|
+
own ? "Finds the cause in Toolroll's code, builds a fix under your usual approvals, and opens a pull request."
|
|
64
|
+
: "Finds the cause. One in this project gets a fix under your usual approvals and a pull request; one in Toolroll's settings gets a note of what to change.",
|
|
65
|
+
"Anything that fails waits in “Needs a look”.",
|
|
66
|
+
],
|
|
67
|
+
never: "Never merges or ships anything without you. Each fix waits for your approval.",
|
|
68
|
+
github: false,
|
|
69
|
+
steps: planeReviewSteps(false),
|
|
70
|
+
ownSteps: planeReviewSteps(true),
|
|
71
|
+
trigger: () => ({ kind: "plane-review", at: "07:30" }),
|
|
72
|
+
},
|
|
54
73
|
];
|
|
74
|
+
/** The morning plane review's steps: find the cause, fix it, open a pull request; any failure waits for a person. */
|
|
75
|
+
function planeReviewSteps(own) {
|
|
76
|
+
const where = own
|
|
77
|
+
? "This project is Toolroll itself, so the fix belongs here. Find the root cause in this code: cite the files and lines, say why it happened, and say what change fixes it."
|
|
78
|
+
: "This project uses Toolroll; it isn't Toolroll's own code. Find the root cause. If it is in this project's code, cite the files and lines and say what change fixes it. If it is in Toolroll or its settings (a sign-in, a plan limit, a chat app, an integration, the worker), say exactly what to change in Toolroll's settings, and start the report with “No build needed”.";
|
|
79
|
+
return [
|
|
80
|
+
{ id: "find-cause", title: "Find the cause", kind: "report", ifFails: "needs-a-look",
|
|
81
|
+
instructions: `The morning plane review found a problem: {{card.title}}\n\n{{card.description}}\n\n${where}\n\nFeedback to address (if any): {{note}}` },
|
|
82
|
+
{ id: "fix", title: "Fix it", kind: "task", ifFails: "needs-a-look",
|
|
83
|
+
instructions: `Fix the root cause of this problem, with a regression check where one fits: {{card.title}}\n\nThe report:\n{{stage.find-cause}}${own ? "" : "\n\nIf the report says “No build needed”, the cause is outside this project: change nothing and say so."}\n\nChanges asked for (if any): {{note}}` },
|
|
84
|
+
{ id: "pull-request", title: "Pull request", kind: "pull-request", next: "done", ifFails: "needs-a-look" },
|
|
85
|
+
{ id: "needs-a-look", title: "Needs a look", kind: "inbox", next: "done" },
|
|
86
|
+
];
|
|
87
|
+
}
|
|
88
|
+
/** Whether a project is Toolroll's own source. */
|
|
89
|
+
export function isToolrollRepo(repo) {
|
|
90
|
+
try {
|
|
91
|
+
return JSON.parse(readFileSync(join(repo, "package.json"), "utf8")).name === "toolroll";
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
55
97
|
export const starterOf = (id) => STARTER_FLOWS.find(one => one.id === id) ?? null;
|
|
56
98
|
/** The flow a starter made in a project, while it is on. */
|
|
57
99
|
export function starterFlowOf(store, starter, repo) {
|
|
@@ -64,7 +106,7 @@ export function startersFor(store, repo) {
|
|
|
64
106
|
const branch = publishing.on ? publishing.base : "main";
|
|
65
107
|
return STARTER_FLOWS.map(starter => {
|
|
66
108
|
const flow = starterFlowOf(store, starter, repo);
|
|
67
|
-
return { id: starter.id, name: starter.name, summary: starter.summary, does: starter.does({ github, branch }), never: starter.never,
|
|
109
|
+
return { id: starter.id, name: starter.name, summary: starter.summary, does: starter.does({ github, branch, own: isToolrollRepo(repo) }), never: starter.never,
|
|
68
110
|
on: flow === null ? null : { flow: flow.id }, blocked: starter.github && github === null ? "This project isn't on GitHub, so this can't watch it." : null };
|
|
69
111
|
});
|
|
70
112
|
}
|
|
@@ -78,7 +120,8 @@ export function switchOnStarter(store, starter, repo, by, now, dir) {
|
|
|
78
120
|
const publishing = publishingOf(store, repo);
|
|
79
121
|
try {
|
|
80
122
|
const flow = store.transact(() => {
|
|
81
|
-
const
|
|
123
|
+
const steps = starter.ownSteps !== undefined && isToolrollRepo(repo) ? starter.ownSteps : starter.steps;
|
|
124
|
+
const id = store.createFlow({ repo, name: starter.name, definitionJson: JSON.stringify(flowFromSteps(steps, null)), by }, now);
|
|
82
125
|
const made = store.getFlow(id);
|
|
83
126
|
const trigger = addFlowTriggerTo(store, made, { ...starter.trigger({ branch: publishing.on ? publishing.base : "main" }), zone: flowDefinitionOf(made).start }, by, now, dir);
|
|
84
127
|
if (!trigger.ok)
|
package/dist/flow-triggers.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { type FlowAct } from "./flow-engine.js";
|
|
|
3
3
|
import type { FlowDefinition } from "./flows.js";
|
|
4
4
|
import { type MailReader } from "./mailbox.js";
|
|
5
5
|
import type { FlowCardSource, FlowRow, FlowTriggerRow, Store } from "./store.js";
|
|
6
|
-
export declare const FLOW_TRIGGER_KINDS: readonly ["button", "schedule", "github", "linear", "flow", "webhook", "email", "chat"];
|
|
6
|
+
export declare const FLOW_TRIGGER_KINDS: readonly ["button", "schedule", "github", "linear", "flow", "webhook", "email", "chat", "plane-review"];
|
|
7
7
|
export type FlowTriggerKind = (typeof FLOW_TRIGGER_KINDS)[number];
|
|
8
8
|
export type TriggerConfig = {
|
|
9
9
|
kind: "button";
|
|
@@ -64,6 +64,12 @@ export type TriggerConfig = {
|
|
|
64
64
|
chat: string;
|
|
65
65
|
binding: number;
|
|
66
66
|
zone: string | null;
|
|
67
|
+
}
|
|
68
|
+
/** `schedule`: always daily, in the routines' form ("daily:07:30@Europe/London"). */
|
|
69
|
+
| {
|
|
70
|
+
kind: "plane-review";
|
|
71
|
+
schedule: string;
|
|
72
|
+
zone: string | null;
|
|
67
73
|
};
|
|
68
74
|
export type ChatApp = "slack" | "discord" | "teams" | "telegram";
|
|
69
75
|
export declare const CHAT_APP_NAMES: Record<ChatApp, string>;
|
|
@@ -180,6 +186,19 @@ export declare function checkFlowTriggerNow(store: Store, trigger: FlowTriggerRo
|
|
|
180
186
|
ok: boolean;
|
|
181
187
|
said: string;
|
|
182
188
|
}>;
|
|
189
|
+
/**
|
|
190
|
+
* A plane review: the last 24 hours, one card per problem worth fixing. Each problem is noted once a day; a problem
|
|
191
|
+
* whose card is still open joins it (a note with the day's counts and evidence) instead of making another, and one
|
|
192
|
+
* whose card is finished starts a new card, since it came back. A clean day adds nothing and tells nobody.
|
|
193
|
+
* `scheduled` moves the trigger to its next morning; "Run now" leaves its times alone.
|
|
194
|
+
*/
|
|
195
|
+
export declare function firePlaneReview(store: Store, trigger: FlowTriggerRow, config: Extract<TriggerConfig, {
|
|
196
|
+
kind: "plane-review";
|
|
197
|
+
}>, now: Date, scheduled: boolean): Promise<{
|
|
198
|
+
added: number;
|
|
199
|
+
joined: number;
|
|
200
|
+
said: string;
|
|
201
|
+
}>;
|
|
183
202
|
/** v96: a schedule (a teammate's routine) makes its card now, once per press, and keeps its own times. */
|
|
184
203
|
export declare function runScheduleNow(store: Store, trigger: FlowTriggerRow, actor: string, now: Date): {
|
|
185
204
|
ok: true;
|
package/dist/flow-triggers.js
CHANGED
|
@@ -13,6 +13,11 @@
|
|
|
13
13
|
* read-only over IMAP; optionally only from some senders or with words in the subject.
|
|
14
14
|
* - chat — messages in a Slack, Discord or Teams channel (v89), connected from the
|
|
15
15
|
* channel itself (chat-inbox.ts), never from here.
|
|
16
|
+
* - plane-review — every day at a set time (07:30 in the computer's time zone unless it says),
|
|
17
|
+
* the plane's last 24 hours read straight from the store (plane-review.ts): one card
|
|
18
|
+
* per distinct problem worth fixing, and the same problem on a later day joins its
|
|
19
|
+
* card while that card is still open. A clean day makes nothing. Its row is kept as a
|
|
20
|
+
* 'schedule' (the table's kinds are fixed); its settings say what it is.
|
|
16
21
|
*
|
|
17
22
|
* GitHub and Linear are checked by the worker's pass — `gh` and one HTTPS
|
|
18
23
|
* request, never a model — or pushed to a secret webhook address when the
|
|
@@ -32,11 +37,11 @@ import { mailboxAccess, mailCursorOf, mailCursorText, readThroughImap } from "./
|
|
|
32
37
|
import { readFlowSecrets } from "./flow-secrets.js";
|
|
33
38
|
import { tmpdir } from "node:os";
|
|
34
39
|
import { envValue } from "./names.js";
|
|
35
|
-
export const FLOW_TRIGGER_KINDS = ["button", "schedule", "github", "linear", "flow", "webhook", "email", "chat"];
|
|
40
|
+
export const FLOW_TRIGGER_KINDS = ["button", "schedule", "github", "linear", "flow", "webhook", "email", "chat", "plane-review"];
|
|
36
41
|
export const CHAT_APP_NAMES = { slack: "Slack", discord: "Discord", teams: "Teams", telegram: "Telegram" };
|
|
37
42
|
/** What each kind is called on the canvas. */
|
|
38
43
|
export const FLOW_TRIGGER_WORDS = {
|
|
39
|
-
button: "Button", schedule: "Schedule", github: "GitHub", linear: "Linear", flow: "Another flow", webhook: "Webhook", email: "Email inbox", chat: "Chat channel",
|
|
44
|
+
button: "Button", schedule: "Schedule", github: "GitHub", linear: "Linear", flow: "Another flow", webhook: "Webhook", email: "Email inbox", chat: "Chat channel", "plane-review": "Plane review",
|
|
40
45
|
};
|
|
41
46
|
/** How often the worker checks GitHub and Linear, and how it backs off when they don't answer. */
|
|
42
47
|
export const POLL_EVERY_MS = 2 * 60_000;
|
|
@@ -116,7 +121,7 @@ export function validateTriggerConfig(raw, context) {
|
|
|
116
121
|
const input = raw;
|
|
117
122
|
const kind = input["kind"];
|
|
118
123
|
if (!FLOW_TRIGGER_KINDS.includes(kind))
|
|
119
|
-
throw new Error("Choose what starts cards: a button, a schedule, GitHub, Linear, another flow, a webhook, an email inbox or a
|
|
124
|
+
throw new Error("Choose what starts cards: a button, a schedule, GitHub, Linear, another flow, a webhook, an email inbox, a chat channel or a plane review.");
|
|
120
125
|
const named = words(input, "zone", 60, false);
|
|
121
126
|
const zoneStage = named === null ? null : context.definition.stages.find(one => one.id === named || one.title.toLowerCase() === named.toLowerCase());
|
|
122
127
|
if (named !== null && zoneStage === undefined)
|
|
@@ -199,6 +204,15 @@ export function validateTriggerConfig(raw, context) {
|
|
|
199
204
|
case "chat":
|
|
200
205
|
// Connecting a channel proves it's one the person is in: it happens in the channel, not here.
|
|
201
206
|
throw new Error(`Connect a chat channel from the channel itself: where Toolroll is in Slack, Discord, Teams or a Telegram group, send “flow ${context.flow.id}”.`);
|
|
207
|
+
case "plane-review": {
|
|
208
|
+
const at = words(input, "at", 5, false) ?? "07:30";
|
|
209
|
+
const clock = /^(\d{1,2}):(\d{2})$/.exec(at);
|
|
210
|
+
const zoneName = words(input, "timeZone", 60, false) ?? localTimeZone();
|
|
211
|
+
const schedule = clock === null ? null : scheduleFromWords(`daily ${clock[1]}:${clock[2]} ${zoneName}`);
|
|
212
|
+
if (schedule === null)
|
|
213
|
+
throw new Error("Say the time it reviews the day as HH:MM, like 07:30, and a time zone like Europe/London.");
|
|
214
|
+
return { kind: "plane-review", schedule, zone };
|
|
215
|
+
}
|
|
202
216
|
case "email": {
|
|
203
217
|
const folder = words(input, "folder", 100, false) ?? "INBOX";
|
|
204
218
|
const sender = words(input, "sender", 300, false);
|
|
@@ -208,6 +222,15 @@ export function validateTriggerConfig(raw, context) {
|
|
|
208
222
|
}
|
|
209
223
|
}
|
|
210
224
|
}
|
|
225
|
+
/** The computer's time zone, as the routines name one. */
|
|
226
|
+
function localTimeZone() {
|
|
227
|
+
try {
|
|
228
|
+
return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
|
|
229
|
+
}
|
|
230
|
+
catch {
|
|
231
|
+
return "UTC";
|
|
232
|
+
}
|
|
233
|
+
}
|
|
211
234
|
/** Addresses and domains from "priya@example.com, @shop.com, example.org". */
|
|
212
235
|
function sendersOf(text) {
|
|
213
236
|
return [...new Set(text.split(/[\s,;]+/).map(one => one.trim().toLowerCase().replace(/^@/, "")).filter(one => /^([a-z0-9._%+-]+@)?[a-z0-9-]+(\.[a-z0-9-]+)*\.[a-z]{2,}$/.test(one)))].slice(0, 20);
|
|
@@ -243,6 +266,10 @@ export function describeTrigger(config, store) {
|
|
|
243
266
|
}
|
|
244
267
|
case "webhook": return "Anything posted to its webhook address";
|
|
245
268
|
case "chat": return `Every new message in a ${CHAT_APP_NAMES[config.app]} channel; replies in a card's thread join its discussion`;
|
|
269
|
+
case "plane-review": {
|
|
270
|
+
const schedule = parseSchedule(config.schedule);
|
|
271
|
+
return `Every day at ${schedule !== null && schedule.kind !== "every" ? schedule.hhmm : config.schedule}: reviews the last 24 hours of runs, tasks, sign-ins, chat, integrations and checks, and makes a card for each problem worth fixing`;
|
|
272
|
+
}
|
|
246
273
|
case "email": return `Email arriving in ${config.folder === "INBOX" ? "the inbox" : config.folder}${config.sender === null ? "" : ` from ${config.sender}`}${config.subject === null ? "" : ` with “${config.subject}” in the subject`} (checked every 2 minutes)`;
|
|
247
274
|
}
|
|
248
275
|
}
|
|
@@ -263,6 +290,10 @@ export function triggerHeadline(config, store) {
|
|
|
263
290
|
}
|
|
264
291
|
case "webhook": return { name: "Webhook", detail: "Anything posted to its address" };
|
|
265
292
|
case "chat": return { name: `${CHAT_APP_NAMES[config.app]} channel`, detail: "Each message is a card" };
|
|
293
|
+
case "plane-review": {
|
|
294
|
+
const schedule = parseSchedule(config.schedule);
|
|
295
|
+
return { name: `Plane review at ${schedule !== null && schedule.kind !== "every" ? schedule.hhmm : "a set time"}`, detail: "Problems from the last 24 hours" };
|
|
296
|
+
}
|
|
266
297
|
case "email": return { name: config.folder === "INBOX" ? "New email" : `New email in ${config.folder}`, detail: config.sender !== null ? `From ${config.sender}` : config.subject !== null ? `Subject has “${config.subject}”` : "Email inbox" };
|
|
267
298
|
}
|
|
268
299
|
}
|
|
@@ -370,9 +401,10 @@ export function addFlowTriggerTo(store, flow, raw, actor, now, dir) {
|
|
|
370
401
|
if (takesDeliveries(config) && dir === null)
|
|
371
402
|
return { ok: false, message: "Webhook addresses are set up on the console." };
|
|
372
403
|
const token = takesDeliveries(config) ? randomBytes(24).toString("base64url") : null;
|
|
373
|
-
const schedule = config.kind === "schedule" ? parseSchedule(config.schedule) : null;
|
|
404
|
+
const schedule = config.kind === "schedule" || config.kind === "plane-review" ? parseSchedule(config.schedule) : null;
|
|
374
405
|
const id = store.addFlowTrigger({
|
|
375
|
-
|
|
406
|
+
// The table's kinds were fixed before plane reviews: one is kept as the daily schedule it is.
|
|
407
|
+
flow: flow.id, kind: config.kind === "plane-review" ? "schedule" : config.kind, configJson: JSON.stringify(config), hookHash: token === null ? null : hookHash(token),
|
|
376
408
|
// Only what happens from now on: never the backlog of issues, runs or finished cards.
|
|
377
409
|
cursor: config.kind === "flow" ? String(store.latestFlowEvent()) : config.kind === "github" || config.kind === "linear" ? now.toISOString() : null,
|
|
378
410
|
// Email: the first check notes where the mailbox stands (no cursor yet), so only mail after this becomes cards.
|
|
@@ -484,6 +516,8 @@ export async function runFlowTriggers(store, repo, now, io) {
|
|
|
484
516
|
}
|
|
485
517
|
else if (config.kind === "schedule" && trigger.nextAt !== null && Date.parse(trigger.nextAt) <= now.getTime())
|
|
486
518
|
pass.added += fireSchedule(store, trigger, config, now);
|
|
519
|
+
else if (config.kind === "plane-review" && trigger.nextAt !== null && Date.parse(trigger.nextAt) <= now.getTime())
|
|
520
|
+
pass.added += (await firePlaneReview(store, trigger, config, now, true)).added;
|
|
487
521
|
else if (config.kind === "flow")
|
|
488
522
|
pass.added += followFlow(store, trigger, config, now);
|
|
489
523
|
else if (polled(config) && (trigger.nextAt === null || Date.parse(trigger.nextAt) <= now.getTime())) {
|
|
@@ -508,8 +542,12 @@ export async function checkFlowTriggerNow(store, trigger, now, io) {
|
|
|
508
542
|
const fired = await fireScript(store, trigger, config, now, io, false);
|
|
509
543
|
return { ok: fired.ok, said: fired.said };
|
|
510
544
|
}
|
|
545
|
+
if (config?.kind === "plane-review") {
|
|
546
|
+
const fired = await firePlaneReview(store, trigger, config, now, false);
|
|
547
|
+
return { ok: true, said: fired.said };
|
|
548
|
+
}
|
|
511
549
|
if (config === null || !polled(config))
|
|
512
|
-
return { ok: false, said: "Only GitHub, Linear and email triggers that are checked,
|
|
550
|
+
return { ok: false, said: "Only GitHub, Linear and email triggers that are checked, schedules that run a script, and plane reviews can be run now." };
|
|
513
551
|
const checked = await checkTrigger(store, trigger, config, now, io);
|
|
514
552
|
return { ok: checked.ok, said: checked.said };
|
|
515
553
|
}
|
|
@@ -536,6 +574,67 @@ function fireSchedule(store, trigger, config, now) {
|
|
|
536
574
|
store.updateFlowTrigger(trigger.id, { nextAt: next, lastAt: now.toISOString(), lastOutcome: made.made === "added" ? "Added a card." : made.note ?? "Nothing new." }, now);
|
|
537
575
|
return made.made === "added" ? 1 : 0;
|
|
538
576
|
}
|
|
577
|
+
/** The problem a plane review's event key names, and the day: `plane:<problem>:<YYYY-MM-DD>`. */
|
|
578
|
+
const planeKey = (problem, day) => `plane:${problem}:${day}`;
|
|
579
|
+
/** The card a plane review last made or joined for one problem, if any. */
|
|
580
|
+
function planeCardOf(store, trigger, problem) {
|
|
581
|
+
const prefix = `plane:${problem}:`;
|
|
582
|
+
const row = store.handle.prepare("SELECT card FROM flow_trigger_event WHERE trigger = ? AND substr(key, 1, ?) = ? AND card IS NOT NULL ORDER BY rowid DESC LIMIT 1").get(trigger, prefix.length, prefix);
|
|
583
|
+
return row === undefined ? null : Number(row["card"]);
|
|
584
|
+
}
|
|
585
|
+
/**
|
|
586
|
+
* A plane review: the last 24 hours, one card per problem worth fixing. Each problem is noted once a day; a problem
|
|
587
|
+
* whose card is still open joins it (a note with the day's counts and evidence) instead of making another, and one
|
|
588
|
+
* whose card is finished starts a new card, since it came back. A clean day adds nothing and tells nobody.
|
|
589
|
+
* `scheduled` moves the trigger to its next morning; "Run now" leaves its times alone.
|
|
590
|
+
*/
|
|
591
|
+
export async function firePlaneReview(store, trigger, config, now, scheduled) {
|
|
592
|
+
const schedule = parseSchedule(config.schedule);
|
|
593
|
+
const next = scheduled && schedule !== null && trigger.nextAt !== null ? nextFireAt(schedule, trigger.nextAt, now) : trigger.nextAt;
|
|
594
|
+
const flow = store.getFlow(trigger.flow);
|
|
595
|
+
const done = (said, added = 0, joined = 0) => {
|
|
596
|
+
store.updateFlowTrigger(trigger.id, { nextAt: next, lastAt: now.toISOString(), lastOutcome: said }, now);
|
|
597
|
+
return { added, joined, said };
|
|
598
|
+
};
|
|
599
|
+
if (flow === null || flow.state !== "active")
|
|
600
|
+
return done("The flow isn't active.");
|
|
601
|
+
// Loaded when a review first runs: the reader reaches the integrations and task status, which reach back to these triggers.
|
|
602
|
+
const { problemCard, problemText, reviewPlane } = await import("./plane-review.js");
|
|
603
|
+
// Only the projects the flow's owner may see: a flow's cards are read by everyone on its project.
|
|
604
|
+
const owner = store.accountOf(flow.owner);
|
|
605
|
+
const problems = reviewPlane(store, now, repo => owner === null || (repo !== null && store.accountCanAccess(flow.owner, repo)) || (repo === null && owner.projects === null));
|
|
606
|
+
const timeZone = schedule !== null && schedule.kind !== "every" ? schedule.timezone ?? "UTC" : "UTC";
|
|
607
|
+
const day = new Intl.DateTimeFormat("en-CA", { year: "numeric", month: "2-digit", day: "2-digit", timeZone }).format(now);
|
|
608
|
+
const dayWords = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone }).format(now);
|
|
609
|
+
let added = 0, joined = 0;
|
|
610
|
+
const skipped = [];
|
|
611
|
+
for (const problem of problems) {
|
|
612
|
+
const key = planeKey(problem.key, day);
|
|
613
|
+
if (store.flowTriggerSaw(trigger.id, key))
|
|
614
|
+
continue;
|
|
615
|
+
const had = planeCardOf(store, trigger.id, problem.key);
|
|
616
|
+
const card = had === null ? null : store.getFlowCard(had);
|
|
617
|
+
if (card !== null && card.state === "active") {
|
|
618
|
+
store.transact(() => {
|
|
619
|
+
store.addFlowComment({ card: card.id, author: "Plane review", body: `Again — ${problemText(problem, dayWords)}`.slice(0, 4000), mentions: [] }, now);
|
|
620
|
+
store.recordFlowTriggerEvent(trigger.id, key, card.id, problem.summary, now);
|
|
621
|
+
});
|
|
622
|
+
joined++;
|
|
623
|
+
continue;
|
|
624
|
+
}
|
|
625
|
+
const text = problemCard(problem, dayWords);
|
|
626
|
+
const made = makeCard(store, trigger, config, { key, title: text.title, description: text.description, source: { kind: "plane-review", label: "Plane review", url: null } }, "Plane review", now);
|
|
627
|
+
if (made.made === "added")
|
|
628
|
+
added++;
|
|
629
|
+
else if (made.note !== null)
|
|
630
|
+
skipped.push(made.note);
|
|
631
|
+
}
|
|
632
|
+
if (problems.length === 0)
|
|
633
|
+
return done("A clean day: nothing to fix.");
|
|
634
|
+
const parts = [added > 0 ? `Added ${plural(added, "card")}.` : null, joined > 0 ? `${plural(joined, "problem")} came back and joined ${joined === 1 ? "its card" : "their cards"}.` : null,
|
|
635
|
+
skipped.length > 0 ? `Left out ${plural(skipped.length, "problem")}: ${skipped[0]}.` : null].filter(one => one !== null);
|
|
636
|
+
return done(parts.length === 0 ? "Nothing new since this morning's review." : parts.join(" "), added, joined);
|
|
637
|
+
}
|
|
539
638
|
/** v96: a schedule (a teammate's routine) makes its card now, once per press, and keeps its own times. */
|
|
540
639
|
export function runScheduleNow(store, trigger, actor, now) {
|
|
541
640
|
const config = triggerConfigOf(trigger);
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { type TriggerIo } from './flow-triggers.js';
|
|
2
|
+
import type { Store } from './store.js';
|
|
3
|
+
export declare const FLOWS_DESCRIPTORS: readonly [{
|
|
4
|
+
readonly action: 'list';
|
|
5
|
+
readonly synopsis: 'list flows: id, name, project, zones, triggers and cards waiting';
|
|
6
|
+
readonly mutation: 'none';
|
|
7
|
+
readonly flags: readonly [...{
|
|
8
|
+
name: string;
|
|
9
|
+
takesValue: boolean;
|
|
10
|
+
meaning: string;
|
|
11
|
+
}[], {
|
|
12
|
+
name: string;
|
|
13
|
+
takesValue: boolean;
|
|
14
|
+
meaning: string;
|
|
15
|
+
}];
|
|
16
|
+
}, {
|
|
17
|
+
readonly action: 'show';
|
|
18
|
+
readonly synopsis: 'one flow: zones with their next and failure paths, triggers and recent cards';
|
|
19
|
+
readonly mutation: 'none';
|
|
20
|
+
readonly positionals: readonly ['flow'];
|
|
21
|
+
readonly flags: {
|
|
22
|
+
name: string;
|
|
23
|
+
takesValue: boolean;
|
|
24
|
+
meaning: string;
|
|
25
|
+
}[];
|
|
26
|
+
}, {
|
|
27
|
+
readonly action: 'create';
|
|
28
|
+
readonly synopsis: 'create a flow from --template <id> (a template or starter flow) or --steps <file|->; previews until --yes';
|
|
29
|
+
readonly mutation: 'unkeyed';
|
|
30
|
+
readonly flags: readonly [...{
|
|
31
|
+
name: string;
|
|
32
|
+
takesValue: boolean;
|
|
33
|
+
meaning: string;
|
|
34
|
+
}[], {
|
|
35
|
+
name: string;
|
|
36
|
+
takesValue: boolean;
|
|
37
|
+
meaning: string;
|
|
38
|
+
}, {
|
|
39
|
+
name: string;
|
|
40
|
+
takesValue: boolean;
|
|
41
|
+
meaning: string;
|
|
42
|
+
}, {
|
|
43
|
+
readonly name: 'name';
|
|
44
|
+
readonly takesValue: true;
|
|
45
|
+
readonly meaning: "the flow's name (a template's label when left out)";
|
|
46
|
+
}, {
|
|
47
|
+
readonly name: 'template';
|
|
48
|
+
readonly takesValue: true;
|
|
49
|
+
readonly meaning: `one of ${string}`;
|
|
50
|
+
}, {
|
|
51
|
+
name: string;
|
|
52
|
+
takesValue: boolean;
|
|
53
|
+
meaning: string;
|
|
54
|
+
}];
|
|
55
|
+
}, {
|
|
56
|
+
readonly action: 'edit';
|
|
57
|
+
readonly synopsis: "replace a flow's steps (kept steps by id) and optionally rename it; previews until --yes";
|
|
58
|
+
readonly mutation: 'unkeyed';
|
|
59
|
+
readonly positionals: readonly ['flow'];
|
|
60
|
+
readonly flags: readonly [...{
|
|
61
|
+
name: string;
|
|
62
|
+
takesValue: boolean;
|
|
63
|
+
meaning: string;
|
|
64
|
+
}[], {
|
|
65
|
+
name: string;
|
|
66
|
+
takesValue: boolean;
|
|
67
|
+
meaning: string;
|
|
68
|
+
}, {
|
|
69
|
+
name: string;
|
|
70
|
+
takesValue: boolean;
|
|
71
|
+
meaning: string;
|
|
72
|
+
}, {
|
|
73
|
+
readonly name: 'name';
|
|
74
|
+
readonly takesValue: true;
|
|
75
|
+
readonly meaning: 'a new name';
|
|
76
|
+
}];
|
|
77
|
+
}, {
|
|
78
|
+
readonly action: 'trigger add';
|
|
79
|
+
readonly synopsis: 'add a trigger from JSON or a JSON file (any kind: button, schedule, github, linear, flow, webhook, email, chat); previews until --yes';
|
|
80
|
+
readonly mutation: 'unkeyed';
|
|
81
|
+
readonly positionals: readonly ['flow', 'trigger'];
|
|
82
|
+
readonly flags: readonly [...{
|
|
83
|
+
name: string;
|
|
84
|
+
takesValue: boolean;
|
|
85
|
+
meaning: string;
|
|
86
|
+
}[], {
|
|
87
|
+
name: string;
|
|
88
|
+
takesValue: boolean;
|
|
89
|
+
meaning: string;
|
|
90
|
+
}];
|
|
91
|
+
}, {
|
|
92
|
+
readonly action: 'trigger pause';
|
|
93
|
+
readonly synopsis: 'pause a trigger';
|
|
94
|
+
readonly mutation: 'identity-idempotent';
|
|
95
|
+
readonly positionals: readonly ['flow', 'trigger'];
|
|
96
|
+
readonly flags: {
|
|
97
|
+
name: string;
|
|
98
|
+
takesValue: boolean;
|
|
99
|
+
meaning: string;
|
|
100
|
+
}[];
|
|
101
|
+
}, {
|
|
102
|
+
readonly action: 'trigger resume';
|
|
103
|
+
readonly synopsis: 'turn a paused trigger on again';
|
|
104
|
+
readonly mutation: 'identity-idempotent';
|
|
105
|
+
readonly positionals: readonly ['flow', 'trigger'];
|
|
106
|
+
readonly flags: {
|
|
107
|
+
name: string;
|
|
108
|
+
takesValue: boolean;
|
|
109
|
+
meaning: string;
|
|
110
|
+
}[];
|
|
111
|
+
}, {
|
|
112
|
+
readonly action: 'trigger remove';
|
|
113
|
+
readonly synopsis: 'remove a trigger; cards it added stay';
|
|
114
|
+
readonly mutation: 'identity-idempotent';
|
|
115
|
+
readonly positionals: readonly ['flow', 'trigger'];
|
|
116
|
+
readonly flags: {
|
|
117
|
+
name: string;
|
|
118
|
+
takesValue: boolean;
|
|
119
|
+
meaning: string;
|
|
120
|
+
}[];
|
|
121
|
+
}, {
|
|
122
|
+
readonly action: 'trigger check';
|
|
123
|
+
readonly synopsis: 'run a checked trigger (or a schedule\'s script) now, like Check now';
|
|
124
|
+
readonly mutation: 'unkeyed';
|
|
125
|
+
readonly positionals: readonly ['flow', 'trigger'];
|
|
126
|
+
readonly flags: {
|
|
127
|
+
name: string;
|
|
128
|
+
takesValue: boolean;
|
|
129
|
+
meaning: string;
|
|
130
|
+
}[];
|
|
131
|
+
}, {
|
|
132
|
+
readonly action: 'script save';
|
|
133
|
+
readonly synopsis: "save a project script (a new version when it exists)";
|
|
134
|
+
readonly mutation: 'identity-idempotent';
|
|
135
|
+
readonly flags: readonly [...{
|
|
136
|
+
name: string;
|
|
137
|
+
takesValue: boolean;
|
|
138
|
+
meaning: string;
|
|
139
|
+
}[], {
|
|
140
|
+
name: string;
|
|
141
|
+
takesValue: boolean;
|
|
142
|
+
meaning: string;
|
|
143
|
+
}, {
|
|
144
|
+
readonly name: 'name';
|
|
145
|
+
readonly takesValue: true;
|
|
146
|
+
readonly meaning: 'lowercase letters, numbers and dashes';
|
|
147
|
+
}, {
|
|
148
|
+
readonly name: 'file';
|
|
149
|
+
readonly takesValue: true;
|
|
150
|
+
readonly meaning: 'run this path in the project';
|
|
151
|
+
}, {
|
|
152
|
+
readonly name: 'body';
|
|
153
|
+
readonly takesValue: true;
|
|
154
|
+
readonly meaning: 'a local file holding the script itself';
|
|
155
|
+
}, {
|
|
156
|
+
readonly name: 'about';
|
|
157
|
+
readonly takesValue: true;
|
|
158
|
+
readonly meaning: 'one line: what it checks or does';
|
|
159
|
+
}, {
|
|
160
|
+
readonly name: 'language';
|
|
161
|
+
readonly takesValue: true;
|
|
162
|
+
readonly meaning: 'shell (default), python or node';
|
|
163
|
+
}, {
|
|
164
|
+
readonly name: 'timeout-minutes';
|
|
165
|
+
readonly takesValue: true;
|
|
166
|
+
readonly meaning: '1 to 60 (default 15)';
|
|
167
|
+
}];
|
|
168
|
+
}, {
|
|
169
|
+
readonly action: 'card add';
|
|
170
|
+
readonly synopsis: 'add a card, in the first zone unless --zone names one';
|
|
171
|
+
readonly mutation: 'unkeyed';
|
|
172
|
+
readonly positionals: readonly ['flow'];
|
|
173
|
+
readonly flags: readonly [...{
|
|
174
|
+
name: string;
|
|
175
|
+
takesValue: boolean;
|
|
176
|
+
meaning: string;
|
|
177
|
+
}[], {
|
|
178
|
+
readonly name: 'title';
|
|
179
|
+
readonly takesValue: true;
|
|
180
|
+
readonly meaning: "the card's title";
|
|
181
|
+
}, {
|
|
182
|
+
readonly name: 'description';
|
|
183
|
+
readonly takesValue: true;
|
|
184
|
+
readonly meaning: 'its details';
|
|
185
|
+
}, {
|
|
186
|
+
readonly name: 'zone';
|
|
187
|
+
readonly takesValue: true;
|
|
188
|
+
readonly meaning: 'a zone id or name';
|
|
189
|
+
}];
|
|
190
|
+
}, {
|
|
191
|
+
readonly action: 'archive';
|
|
192
|
+
readonly synopsis: 'archive a flow: its cards stop moving and its triggers stop; previews until --yes';
|
|
193
|
+
readonly mutation: 'identity-idempotent';
|
|
194
|
+
readonly positionals: readonly ['flow'];
|
|
195
|
+
readonly flags: readonly [...{
|
|
196
|
+
name: string;
|
|
197
|
+
takesValue: boolean;
|
|
198
|
+
meaning: string;
|
|
199
|
+
}[], {
|
|
200
|
+
name: string;
|
|
201
|
+
takesValue: boolean;
|
|
202
|
+
meaning: string;
|
|
203
|
+
}];
|
|
204
|
+
}];
|
|
205
|
+
/** `--file` is a value here (a path in the project), where elsewhere it is a switch. */
|
|
206
|
+
export declare const FLOWS_VALUE_FLAGS: ReadonlySet<string>;
|
|
207
|
+
export type FlowsCliContext = {
|
|
208
|
+
store: Store;
|
|
209
|
+
write: (line: string) => void;
|
|
210
|
+
json: boolean;
|
|
211
|
+
clock: () => Date;
|
|
212
|
+
/** --as/--token, or the remembered login; null when neither. */
|
|
213
|
+
credentials: {
|
|
214
|
+
name: string;
|
|
215
|
+
token: string;
|
|
216
|
+
} | null;
|
|
217
|
+
/** Every project the installation knows (enrolled, opened or worked on). */
|
|
218
|
+
projects: readonly string[];
|
|
219
|
+
/** Beside the database: webhook secrets, the hooks address. */
|
|
220
|
+
configDir: string;
|
|
221
|
+
evidenceRoot: string;
|
|
222
|
+
triggerIo: TriggerIo;
|
|
223
|
+
/** Reads a file, or stdin for "-". */
|
|
224
|
+
readInput?: (path: string) => string;
|
|
225
|
+
};
|
|
226
|
+
type Flags = Map<string, string | true>;
|
|
227
|
+
export declare function runFlowsCommand(positional: readonly string[], flags: Flags, context: FlowsCliContext): Promise<number>;
|
|
228
|
+
export {};
|