toolroll 0.9.51 → 0.9.53
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 +20 -0
- package/README.md +55 -80
- package/dist/agentconfig.d.ts +11 -36
- package/dist/agentconfig.js +3 -62
- package/dist/approval-policy.d.ts +1 -1
- package/dist/approval-policy.js +1 -1
- package/dist/backup.d.ts +8 -0
- package/dist/backup.js +10 -1
- package/dist/board.d.ts +3 -19
- package/dist/board.js +4 -42
- package/dist/browser/workspace.css +1 -1
- package/dist/browser/workspace.js +18 -18
- package/dist/browser-workspace.d.ts +3 -1
- package/dist/builder.d.ts +9 -40
- package/dist/builder.js +50 -257
- package/dist/chat-actions.js +3 -14
- package/dist/chat-controls.d.ts +0 -4
- package/dist/chat-controls.js +0 -1
- package/dist/chat-decide.js +1 -3
- package/dist/chat-delivery.d.ts +0 -2
- package/dist/chat-delivery.js +0 -1
- package/dist/chat-quiet.js +2 -2
- package/dist/chat-task-actions.d.ts +1 -1
- package/dist/chat-task-actions.js +0 -2
- package/dist/claim.d.ts +79 -244
- package/dist/claim.js +688 -1023
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +10 -2
- package/dist/contracts/cli.d.ts +0 -13
- package/dist/contracts/cli.js +0 -2
- package/dist/contracts/console-api.d.ts +5 -10
- package/dist/contracts/console-api.js +4 -10
- package/dist/contracts/flow.d.ts +50 -0
- package/dist/contracts/flow.js +26 -1
- package/dist/contracts/lead-tools.d.ts +0 -13
- package/dist/contracts/lead-tools.js +3 -3
- package/dist/contracts/store-json.d.ts +1 -25
- package/dist/contracts/store-json.js +3 -13
- package/dist/converse.d.ts +1 -11
- package/dist/converse.js +6 -38
- package/dist/coordinator.d.ts +0 -1
- package/dist/coordinator.js +3 -5
- package/dist/database-reclaim.d.ts +4 -0
- package/dist/database-reclaim.js +13 -0
- package/dist/demo.d.ts +1 -1
- package/dist/demo.js +17 -27
- package/dist/desktop-update-gate.js +0 -1
- package/dist/desktop-update.d.ts +1 -1
- package/dist/desktop-update.js +1 -3
- package/dist/dispatch.d.ts +1 -1
- package/dist/dispatch.js +1 -1
- package/dist/dispose.d.ts +2 -3
- package/dist/dispose.js +17 -92
- package/dist/envelope.js +0 -3
- package/dist/exec.d.ts +0 -64
- package/dist/exec.js +3 -356
- package/dist/exhaustion.d.ts +22 -76
- package/dist/exhaustion.js +14 -140
- package/dist/export.js +4 -4
- package/dist/flow-engine.js +1 -1
- package/dist/flow-schedule.d.ts +154 -0
- package/dist/flow-schedule.js +321 -0
- package/dist/flow-share.js +1 -1
- package/dist/flow-triggers.d.ts +6 -1
- package/dist/flow-triggers.js +65 -10
- package/dist/flows-cli.js +8 -4
- package/dist/flows-ui.js +2 -0
- package/dist/flows.js +1 -1
- package/dist/guides.js +9 -29
- package/dist/invoke.d.ts +1 -24
- package/dist/invoke.js +17 -259
- package/dist/lead-status.js +2 -4
- package/dist/lead-voice.js +1 -1
- package/dist/live-bus.d.ts +3 -3
- package/dist/live-bus.js +3 -3
- package/dist/mate-doors.d.ts +1 -2
- package/dist/mate-doors.js +12 -25
- package/dist/mate-tools.d.ts +2 -2
- package/dist/mate-tools.js +10 -22
- package/dist/modes.d.ts +6 -7
- package/dist/modes.js +7 -13
- package/dist/monitoring.js +32 -8
- package/dist/notification-delivery.d.ts +19 -0
- package/dist/notification-delivery.js +37 -0
- package/dist/operate-remote-arguments.js +2 -6
- package/dist/operate-remote.js +6 -7
- package/dist/operate.d.ts +0 -6
- package/dist/operate.js +283 -2378
- package/dist/phase-routing.d.ts +13 -23
- package/dist/phase-routing.js +13 -35
- package/dist/plan-auto.js +2 -2
- package/dist/planner-source.js +2 -1
- package/dist/policy.d.ts +1 -4
- package/dist/policy.js +0 -8
- package/dist/process-recovery-eligibility.js +2 -3
- package/dist/process-recovery-settlement.js +1 -0
- package/dist/project-delete.js +3 -15
- package/dist/proposal.d.ts +4 -24
- package/dist/proposal.js +3 -88
- package/dist/provider-auth.d.ts +3 -3
- package/dist/provider-auth.js +4 -6
- package/dist/provider.d.ts +11 -25
- package/dist/provider.js +4 -36
- package/dist/push.js +1 -1
- package/dist/recipe-ui.js +6 -6
- package/dist/recipes.d.ts +3 -3
- package/dist/recipes.js +27 -23
- package/dist/retention.js +6 -5
- package/dist/run-closeout.d.ts +1 -1
- package/dist/run-closeout.js +1 -4
- package/dist/scope.d.ts +11 -65
- package/dist/scope.js +17 -82
- package/dist/serve.js +16 -25
- package/dist/server/chat.js +7 -30
- package/dist/server/flows.js +11 -10
- package/dist/server/guards.js +0 -1
- package/dist/server/pages-assets.js +1 -1
- package/dist/server/pages.js +0 -2
- package/dist/server/people-tokens.js +0 -4
- package/dist/server/route-table.d.ts +1 -1
- package/dist/server/route-table.js +2 -17
- package/dist/server/settings.js +4 -9
- package/dist/server/shared.d.ts +29 -212
- package/dist/server/shared.js +73 -780
- package/dist/server/task-views.js +9 -76
- package/dist/server/tasks.d.ts +1 -50
- package/dist/server/tasks.js +36 -946
- package/dist/setup-guide.d.ts +2 -2
- package/dist/slack-chat.d.ts +0 -2
- package/dist/slack-chat.js +0 -1
- package/dist/store.d.ts +142 -1382
- package/dist/store.js +900 -4083
- package/dist/surface.js +10 -25
- package/dist/task-composer.d.ts +1 -1
- package/dist/task-composer.js +1 -1
- package/dist/task-control.d.ts +3 -10
- package/dist/task-control.js +2 -7
- package/dist/teammate-desk.js +1 -1
- package/dist/teammates-ui.js +1 -1
- package/dist/telegram-mate.d.ts +0 -3
- package/dist/telegram-mate.js +0 -1
- package/dist/templates.d.ts +11 -7
- package/dist/templates.js +12 -9
- package/dist/toolroll-update.d.ts +24 -3
- package/dist/toolroll-update.js +159 -16
- package/dist/webhooks.d.ts +10 -22
- package/dist/webhooks.js +43 -81
- package/dist/work-index.js +3 -7
- package/dist/workspace-revision.d.ts +28 -1
- package/dist/workspace-revision.js +276 -49
- package/dist/workspace-ui.js +1 -1
- package/package.json +1 -3
- package/scripts/recovery-canary.mjs +3 -3
- package/dist/contest.d.ts +0 -318
- package/dist/contest.js +0 -753
- package/dist/held.d.ts +0 -150
- package/dist/held.js +0 -799
- package/dist/liveness.d.ts +0 -30
- package/dist/liveness.js +0 -42
- package/dist/pricing.d.ts +0 -45
- package/dist/pricing.js +0 -77
- package/dist/routine.d.ts +0 -228
- package/dist/routine.js +0 -764
- package/dist/supervisor.mjs +0 -297
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schedules (v115: flow-owned; routines became scheduled flows).
|
|
3
|
+
*
|
|
4
|
+
* A schedule trigger fires on one of four readable cadences. Its two failure modes are subtle enough to deserve
|
|
5
|
+
* names: advancing one interval after downtime leaves the next firing in the past — a catch-up burst; advancing to
|
|
6
|
+
* `now + interval` drifts the cadence forever. The rule is ALIGNED advancement: the next occurrence strictly after
|
|
7
|
+
* now, anchored to the occurrence that just fired (for `every`) or to the clock itself (for calendar schedules).
|
|
8
|
+
* Missed slots while nothing was running fire ONCE — the overdue occurrence — and are never backfilled.
|
|
9
|
+
*/
|
|
10
|
+
import { createHash } from "node:crypto";
|
|
11
|
+
import { fileTaskProposal } from "./proposal.js";
|
|
12
|
+
import { reportsCost } from "./provider.js";
|
|
13
|
+
import { legOf, routeDigestOf, routeFromJson, routeProblems } from "./phase-routing.js";
|
|
14
|
+
import { canonicalAcceptance, digestOf, parseAcceptanceCriteria, profileDigestOf, profileFromJson } from "./scope.js";
|
|
15
|
+
import { hasDisguisedText, hasForbiddenControls } from "./decision.js";
|
|
16
|
+
import { BUILT_IN, parseCapabilityKey } from "./store.js";
|
|
17
|
+
export const WEEKDAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
|
18
|
+
export function validTimezone(value) {
|
|
19
|
+
if (value.trim() !== value)
|
|
20
|
+
return false;
|
|
21
|
+
if (!/^[A-Za-z0-9_+/-]{1,80}$/.test(value))
|
|
22
|
+
return false;
|
|
23
|
+
try {
|
|
24
|
+
new Intl.DateTimeFormat("en", { timeZone: value }).format(0);
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** Bounds a person would pick on purpose: 5 minutes to 7 days. */
|
|
32
|
+
export const MIN_EVERY_MINUTES = 5;
|
|
33
|
+
export const MAX_EVERY_MINUTES = 7 * 24 * 60;
|
|
34
|
+
/**
|
|
35
|
+
* Existing daily schedules remain UTC. Calendar schedules may name an IANA
|
|
36
|
+
* timezone, whose wall-clock time stays fixed across daylight saving changes.
|
|
37
|
+
* No cron expressions in v1 — a schedule the operator cannot read at a
|
|
38
|
+
* glance is a schedule they cannot honestly approve.
|
|
39
|
+
*/
|
|
40
|
+
export function parseSchedule(text) {
|
|
41
|
+
if (text.trim() !== text)
|
|
42
|
+
return null;
|
|
43
|
+
const every = /^every:([0-9]{1,5})$/.exec(text);
|
|
44
|
+
if (every !== null) {
|
|
45
|
+
const minutes = Number(every[1]);
|
|
46
|
+
if (minutes < MIN_EVERY_MINUTES || minutes > MAX_EVERY_MINUTES)
|
|
47
|
+
return null;
|
|
48
|
+
return { kind: "every", minutes };
|
|
49
|
+
}
|
|
50
|
+
const calendar = /^(daily|weekdays|weekly:[0-6]):([01][0-9]|2[0-3]):([0-5][0-9])(?:@([A-Za-z0-9_+/-]{1,80}))?$/.exec(text);
|
|
51
|
+
if (calendar !== null) {
|
|
52
|
+
const timezone = calendar[4];
|
|
53
|
+
if (timezone !== undefined && !validTimezone(timezone))
|
|
54
|
+
return null;
|
|
55
|
+
const time = { hhmm: `${calendar[2]}:${calendar[3]}`, ...(timezone === undefined ? {} : { timezone }) };
|
|
56
|
+
return calendar[1] === "daily" ? { kind: "daily", ...time } : calendar[1] === "weekdays" ? { kind: "weekdays", ...time } : { kind: "weekly", day: Number(calendar[1].slice(-1)), ...time };
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
export function scheduleText(schedule) {
|
|
61
|
+
return schedule.kind === "every" ? `every:${schedule.minutes}` : `${schedule.kind === "weekly" ? `weekly:${schedule.day}` : schedule.kind}:${schedule.hhmm}${schedule.timezone === undefined ? "" : `@${schedule.timezone}`}`;
|
|
62
|
+
}
|
|
63
|
+
/** The schedule, in words an operator agrees to. */
|
|
64
|
+
export function describeSchedule(schedule) {
|
|
65
|
+
if (schedule.kind !== "every")
|
|
66
|
+
return `${schedule.kind === "daily" ? "daily" : schedule.kind === "weekdays" ? "weekdays" : `every ${WEEKDAYS[schedule.day]}`} at ${schedule.hhmm} ${schedule.timezone ?? "UTC"}`;
|
|
67
|
+
const { minutes } = schedule;
|
|
68
|
+
if (minutes % (24 * 60) === 0)
|
|
69
|
+
return `every ${minutes / (24 * 60)} day(s)`;
|
|
70
|
+
if (minutes % 60 === 0)
|
|
71
|
+
return `every ${minutes / 60} hour(s)`;
|
|
72
|
+
return `every ${minutes} minutes`;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* The first occurrence after approval: the schedule starts counting from the
|
|
76
|
+
* yes, because "approved at 14:07, every 60 minutes" firing instantly would
|
|
77
|
+
* spend before the approver's hand left the keyboard.
|
|
78
|
+
*/
|
|
79
|
+
export function firstFireAt(schedule, now) {
|
|
80
|
+
if (schedule.kind === "every") {
|
|
81
|
+
return new Date(now.getTime() + schedule.minutes * 60_000).toISOString();
|
|
82
|
+
}
|
|
83
|
+
return nextCalendar(schedule, now);
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Aligned advancement: the smallest cadence occurrence STRICTLY after now.
|
|
87
|
+
* `anchor` is the occurrence that just fired (or was just skipped) — the
|
|
88
|
+
* cadence grid grows from it, so a pass that ran late does not tilt every
|
|
89
|
+
* later firing by its lateness.
|
|
90
|
+
*/
|
|
91
|
+
export function nextFireAt(schedule, anchorIso, now) {
|
|
92
|
+
if (schedule.kind !== "every")
|
|
93
|
+
return nextCalendar(schedule, now);
|
|
94
|
+
const interval = schedule.minutes * 60_000;
|
|
95
|
+
const anchor = new Date(anchorIso).getTime();
|
|
96
|
+
const elapsed = now.getTime() - anchor;
|
|
97
|
+
// Strictly after now: an occurrence landing exactly on now already fired.
|
|
98
|
+
const steps = Math.max(1, Math.floor(elapsed / interval) + 1);
|
|
99
|
+
return new Date(anchor + steps * interval).toISOString();
|
|
100
|
+
}
|
|
101
|
+
function nextCalendar(schedule, now) {
|
|
102
|
+
const format = new Intl.DateTimeFormat("en-US", { timeZone: schedule.timezone ?? "UTC", year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hourCycle: "h23" });
|
|
103
|
+
// Represent wall-clock components as a UTC number solely for arithmetic.
|
|
104
|
+
const wall = (instant) => {
|
|
105
|
+
const parts = Object.fromEntries(format.formatToParts(instant).map(part => [part.type, part.value]));
|
|
106
|
+
return Date.UTC(Number(parts.year), Number(parts.month) - 1, Number(parts.day), Number(parts.hour), Number(parts.minute));
|
|
107
|
+
};
|
|
108
|
+
const localNow = new Date(wall(now.getTime()));
|
|
109
|
+
const [hour, minute] = schedule.hhmm.split(":").map(Number);
|
|
110
|
+
const today = Date.UTC(localNow.getUTCFullYear(), localNow.getUTCMonth(), localNow.getUTCDate(), hour, minute);
|
|
111
|
+
for (let days = 0; days <= 15; days++) {
|
|
112
|
+
const target = today + days * 86_400_000;
|
|
113
|
+
if (schedule.kind === "weekly" && new Date(target).getUTCDay() !== schedule.day)
|
|
114
|
+
continue;
|
|
115
|
+
if (schedule.kind === "weekdays" && (new Date(target).getUTCDay() === 0 || new Date(target).getUTCDay() === 6))
|
|
116
|
+
continue;
|
|
117
|
+
// Nearby dates expose both offsets around a daylight-saving transition.
|
|
118
|
+
// A missing clock time skips its slot. An ambiguous time uses the first
|
|
119
|
+
// occurrence only, even if the scheduler advances during the repeated hour.
|
|
120
|
+
const offsets = [...new Set([-36, 0, 36].map(hours => { const sample = target + hours * 3_600_000; return wall(sample) - sample; }))];
|
|
121
|
+
const matches = offsets.map(offset => target - offset).filter(instant => wall(instant) === target);
|
|
122
|
+
if (matches.length > 0) {
|
|
123
|
+
const first = Math.min(...matches);
|
|
124
|
+
if (first > now.getTime())
|
|
125
|
+
return new Date(first).toISOString();
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
throw new Error("No calendar occurrence within the next fifteen days");
|
|
129
|
+
}
|
|
130
|
+
// ------------------------------------------------------------------ standing orders
|
|
131
|
+
/** The rolling window a standing order's cost ceiling counts over: seven days, and every surface says so. */
|
|
132
|
+
export const BUDGET_WINDOW_MS = 7 * 24 * 60 * 60_000;
|
|
133
|
+
/** A firing's task id: the order's stem stamped with its slot (`nightly-deps-20261008-0700`). */
|
|
134
|
+
export function instanceId(stem, slotIso) {
|
|
135
|
+
return `${stem}-${slotIso.replace(/[-:]/g, "").slice(0, 13).replace("T", "-")}`;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* The exact standing order an approver agreed to, as 128 bits — byte for byte what a routine's digest was, so a
|
|
139
|
+
* routine's approval moved onto a scheduled flow (v115) still verifies: the project, the terms, the schedule, the
|
|
140
|
+
* ceiling, and the agents the approval froze. Editing any of them (or moving the flow) strands the approval.
|
|
141
|
+
*/
|
|
142
|
+
export function standingDigestOf(repo, schedule, order, acceptance, profile, route) {
|
|
143
|
+
return createHash("sha256").update(JSON.stringify({
|
|
144
|
+
repo,
|
|
145
|
+
goal: order.goal.trim(),
|
|
146
|
+
outOfScope: order.outOfScope?.trim() ?? null,
|
|
147
|
+
touches: [...order.touches].sort(),
|
|
148
|
+
...(acceptance.length === 0 ? {} : { acceptance: canonicalAcceptance(acceptance) }),
|
|
149
|
+
requirements: [...order.requirements].sort(),
|
|
150
|
+
schedule,
|
|
151
|
+
singleFlight: order.singleFlight,
|
|
152
|
+
costCeilingUsd: order.costCeilingUsd,
|
|
153
|
+
...(order.budgetPerRunMicrousd == null ? {} : { budgetPerRun: order.budgetPerRunMicrousd }),
|
|
154
|
+
profileDigest: profileDigestOf(profile),
|
|
155
|
+
route: routeDigestOf(route),
|
|
156
|
+
}), "utf8").digest("hex").slice(0, 32);
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Whether a standing order's carried approval still stands, read whole before anything is written: the frozen agents
|
|
160
|
+
* read back, re-hash with the order's terms, this schedule and project to the digest the approver signed, state no
|
|
161
|
+
* problem, and build and repair on the approved profile's own agents. `problem` is null when there is no approval.
|
|
162
|
+
*/
|
|
163
|
+
export function standingApproval(order, repo, schedule) {
|
|
164
|
+
const read = parseAcceptanceCriteria(order.acceptance);
|
|
165
|
+
const acceptance = read.problems.length === 0 && read.criteria.length > 0 ? read.criteria : null;
|
|
166
|
+
if (order.approval === null)
|
|
167
|
+
return { live: false, problem: null, acceptance };
|
|
168
|
+
const refused = (problem) => ({ live: false, problem, acceptance });
|
|
169
|
+
if (acceptance === null)
|
|
170
|
+
return refused("its success checks can't be read");
|
|
171
|
+
const profile = profileFromJson(order.approval.profileJson), route = routeFromJson(order.approval.routeJson);
|
|
172
|
+
if (profile === null || route === null)
|
|
173
|
+
return refused("the agents its approval froze can't be read back");
|
|
174
|
+
if (standingDigestOf(repo, schedule, order, acceptance, profile, route) !== order.approval.digest)
|
|
175
|
+
return refused("it changed after it was approved");
|
|
176
|
+
const problems = routeProblems(route);
|
|
177
|
+
if (problems.length > 0)
|
|
178
|
+
return refused(problems.join("; "));
|
|
179
|
+
const build = legOf(route, "build"), repair = legOf(route, "repair");
|
|
180
|
+
const repairModel = profile.repairModel === "inherit" ? profile.model : profile.repairModel;
|
|
181
|
+
if (build.provider !== profile.provider || build.model !== profile.model || repair.provider !== profile.provider || repair.model !== repairModel) {
|
|
182
|
+
return refused("its approved agents disagree with its approved route");
|
|
183
|
+
}
|
|
184
|
+
return { live: true, by: order.approval.by, profile, route, acceptance };
|
|
185
|
+
}
|
|
186
|
+
/** A firing that must not stand: thrown inside the transaction so nothing it wrote survives. */
|
|
187
|
+
export class StandingOrderRefused extends Error {
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* File one firing of a standing order (v115, what firing a routine did), inside the caller's transaction. It skips,
|
|
191
|
+
* saying why, while the last task is unfinished or the rolling 7-day ceiling is reached (an unmeasured paid run fails
|
|
192
|
+
* closed). Otherwise it files the order's terms unchanged: approved by the carried approval when that still verifies
|
|
193
|
+
* — on exactly the agents it froze, sealed like any automatic approval under the project's rules — or else as an
|
|
194
|
+
* ordinary proposal that waits for the project's approval rules like any other.
|
|
195
|
+
*/
|
|
196
|
+
export function fileStandingOrder(store, input, now) {
|
|
197
|
+
const { order, repo } = input;
|
|
198
|
+
const blocker = store.standingOrderBlocker(input.trigger, order.routine, now);
|
|
199
|
+
if (blocker !== null)
|
|
200
|
+
return { made: "skipped", note: `Skipped: the last one (${blocker.taskId}, ${blocker.state}) hasn't finished.` };
|
|
201
|
+
const approval = standingApproval(order, repo, input.schedule);
|
|
202
|
+
if (approval.acceptance === null)
|
|
203
|
+
return { made: "skipped", note: "Skipped: its success checks can't be read. Set the flow up again." };
|
|
204
|
+
if (order.costCeilingUsd !== null) {
|
|
205
|
+
const ceiling = `$${order.costCeilingUsd.toFixed(2)}`;
|
|
206
|
+
if (approval.live && !reportsCost(legOf(approval.route, "build").provider)) {
|
|
207
|
+
return { made: "skipped", note: `Skipped: ${legOf(approval.route, "build").provider} doesn't report what it spends, so the ${ceiling} weekly limit can't be kept.` };
|
|
208
|
+
}
|
|
209
|
+
const spend = store.standingOrderSpend(input.trigger, order.routine, new Date(now.getTime() - BUDGET_WINDOW_MS).toISOString());
|
|
210
|
+
if (spend.unmeasuredRuns > 0)
|
|
211
|
+
return { made: "skipped", note: `Skipped: ${spend.unmeasuredRuns} run(s) in the last 7 days recorded no cost, so the ${ceiling} weekly limit can't be kept.` };
|
|
212
|
+
if (spend.costUsd >= order.costCeilingUsd)
|
|
213
|
+
return { made: "skipped", note: `Skipped: $${spend.costUsd.toFixed(2)} of the ${ceiling} weekly limit is spent.` };
|
|
214
|
+
}
|
|
215
|
+
let taskId = instanceId(order.stem, input.slot);
|
|
216
|
+
for (let n = 2; store.getTask(taskId) !== null; n++)
|
|
217
|
+
taskId = `${instanceId(order.stem, input.slot)}-${n}`;
|
|
218
|
+
const filedBy = order.filedBy === null ? { name: null, kind: "automation" } : { name: order.filedBy, kind: "person" };
|
|
219
|
+
if (!approval.live) {
|
|
220
|
+
const filed = fileTaskProposal(store, {
|
|
221
|
+
id: taskId, title: input.title, repo, goal: order.goal, outOfScope: order.outOfScope, touches: [...order.touches], acceptance: approval.acceptance,
|
|
222
|
+
budgetMicrousd: order.budgetPerRunMicrousd, filedVia: `flow:${input.flowId}`, filedBy, planning: "skip", admittedRepos: [repo],
|
|
223
|
+
}, now);
|
|
224
|
+
if (!filed.ok)
|
|
225
|
+
return { made: "skipped", note: `Couldn't file it: ${filed.message}.` };
|
|
226
|
+
const ref = store.lookupRef(filed.id);
|
|
227
|
+
if (ref !== null && order.requirements.length > 0)
|
|
228
|
+
store.setRequirements(ref.id, order.requirements);
|
|
229
|
+
return { made: "filed", taskId: filed.id, approved: false, note: approval.problem === null ? null : `Its earlier approval no longer applies (${approval.problem}), so this one waits for approval.` };
|
|
230
|
+
}
|
|
231
|
+
// The approved road: the frozen agents are the pin, the terms are the scope byte for byte, and the seal must read
|
|
232
|
+
// back as the frozen route — or nothing of this firing stands.
|
|
233
|
+
store.createTask({ id: taskId, title: input.title, filedBy }, now);
|
|
234
|
+
const ref = store.refFor(BUILT_IN, taskId, "ours");
|
|
235
|
+
store.placeTask(ref.id, repo);
|
|
236
|
+
store.pinTaskAgent(ref.id, approval.profile.provider, approval.profile.model);
|
|
237
|
+
if (order.requirements.length > 0)
|
|
238
|
+
store.setRequirements(ref.id, order.requirements);
|
|
239
|
+
const draft = { goal: order.goal, outOfScope: order.outOfScope, touches: [...order.touches], acceptance: approval.acceptance, ...(order.budgetPerRunMicrousd == null ? {} : { budgetMicrousd: order.budgetPerRunMicrousd }) };
|
|
240
|
+
const digest = digestOf(draft, approval.profile, approval.route);
|
|
241
|
+
store.saveScope({ taskId, ...draft, proposedAt: now.toISOString(), digest, budgetMicrousd: order.budgetPerRunMicrousd ?? null, approvedAt: order.approval.at, approvedBy: approval.by, approvedDigest: digest }, {}, { profile: approval.profile, route: approval.route });
|
|
242
|
+
const sealed = store.sealScopeApproval(taskId, approval.by ?? "schedule", now, {}, undefined, "automation");
|
|
243
|
+
const sealedRoute = sealed ? store.sealedRouteOf(taskId) : null;
|
|
244
|
+
if (!sealed || sealedRoute === null || !sealedRoute.ok || routeDigestOf(sealedRoute.route) !== routeDigestOf(approval.route)) {
|
|
245
|
+
throw new StandingOrderRefused(!sealed ? "the approval couldn't be sealed" : sealedRoute !== null && !sealedRoute.ok ? sealedRoute.detail : "the sealed agents aren't the approved ones");
|
|
246
|
+
}
|
|
247
|
+
return { made: "filed", taskId, approved: true, note: null };
|
|
248
|
+
}
|
|
249
|
+
/** A standing order in the words a person reads before turning its schedule on. */
|
|
250
|
+
export function describeStandingOrder(order, repo, schedule) {
|
|
251
|
+
const approval = standingApproval(order, repo, schedule);
|
|
252
|
+
return [
|
|
253
|
+
`Builds: ${order.goal}`,
|
|
254
|
+
...(order.outOfScope === null ? [] : [`Not this: ${order.outOfScope}`]),
|
|
255
|
+
...(order.touches.length === 0 ? [] : [`Only touches: ${order.touches.join(", ")}`]),
|
|
256
|
+
...(order.requirements.length === 0 ? [] : [`Needs: ${order.requirements.join(", ")}`]),
|
|
257
|
+
...(order.budgetPerRunMicrousd === null ? [] : [`Each run: up to $${(order.budgetPerRunMicrousd / 1_000_000).toFixed(2)}`]),
|
|
258
|
+
...(order.costCeilingUsd === null ? [] : [`Every 7 days: up to $${order.costCeilingUsd.toFixed(2)}`]),
|
|
259
|
+
"One at a time: it skips while the last one is unfinished.",
|
|
260
|
+
approval.live ? `Approved by ${approval.by ?? "an approver"}: each run builds without asking, on the agents approved then.` : approval.problem === null ? "Each run waits for approval under the project's rules." : `Each run waits for approval: its earlier approval no longer applies (${approval.problem}).`,
|
|
261
|
+
];
|
|
262
|
+
}
|
|
263
|
+
/** Every problem with a standing order's terms and schedule at once, as `field: problem` lines (none when it can be set up). */
|
|
264
|
+
export function standingTermsProblems(terms, schedule) {
|
|
265
|
+
const problems = [];
|
|
266
|
+
// What an approver reads must be what it appears to be (no control, invisible or direction-override text), bounded in bytes too.
|
|
267
|
+
const unfit = (text, chars, bytes) => text.length > chars || Buffer.byteLength(text, "utf8") > bytes || hasForbiddenControls(text) || hasDisguisedText(text);
|
|
268
|
+
if (terms.goal.trim() === "" || unfit(terms.goal, 2_000, 8_000))
|
|
269
|
+
problems.push("goal: required, at most 2000 characters, no control or hidden characters");
|
|
270
|
+
if (terms.outOfScope !== null && unfit(terms.outOfScope, 2_000, 8_000))
|
|
271
|
+
problems.push("outOfScope: at most 2000 characters, no control or hidden characters");
|
|
272
|
+
if (terms.touches.length > 50 || terms.touches.some(one => one.trim() === "" || unfit(one, 200, 800)))
|
|
273
|
+
problems.push("touches: at most 50 paths, each non-empty and under 200 characters");
|
|
274
|
+
if (terms.requirements.length > 20 || terms.requirements.some(one => parseCapabilityKey(one) === null || unfit(one, 400, 400)))
|
|
275
|
+
problems.push("requirements: capability keys, `kind:name`, at most 20");
|
|
276
|
+
// Each firing copies its success checks forward unchanged and never asks again, so there must be at least one.
|
|
277
|
+
const read = parseAcceptanceCriteria(terms.acceptance);
|
|
278
|
+
if (read.problems.length > 0)
|
|
279
|
+
problems.push(`acceptance: ${read.problems.map(one => one.message).join("; ")}`);
|
|
280
|
+
else if (read.criteria.length === 0)
|
|
281
|
+
problems.push("acceptance: a repeating task needs at least one success check");
|
|
282
|
+
if (parseSchedule(schedule) === null)
|
|
283
|
+
problems.push(`schedule: \`every:<minutes>\` (${MIN_EVERY_MINUTES}–${MAX_EVERY_MINUTES}), \`daily:<HH:MM>\`, \`weekdays:<HH:MM>\` or \`weekly:<0–6>:<HH:MM>\`, with an optional @IANA/timezone`);
|
|
284
|
+
if (terms.costCeilingUsd !== null && (!Number.isFinite(terms.costCeilingUsd) || terms.costCeilingUsd <= 0))
|
|
285
|
+
problems.push("costCeilingUsd: a positive dollar amount, or none for no weekly limit");
|
|
286
|
+
if (terms.budgetPerRunMicrousd !== null && (!Number.isSafeInteger(terms.budgetPerRunMicrousd) || terms.budgetPerRunMicrousd <= 0))
|
|
287
|
+
problems.push("budgetPerRunMicrousd: a positive whole micro-dollar amount, or none");
|
|
288
|
+
return problems;
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* A scheduled flow a person set up (a template or a recipe; v115, what filing a routine was): a Build step that follows
|
|
292
|
+
* the task each firing files, then Done, and a schedule trigger carrying the terms as its standing order — with no
|
|
293
|
+
* approval, so each firing waits for approval under the project's rules. The schedule starts PAUSED with no time:
|
|
294
|
+
* nothing repeats until a person turns it on, and it starts counting then. Its tasks are filed as `filedBy`'s (default
|
|
295
|
+
* `by`; null files them as automation's). Inside the caller's transaction, if any.
|
|
296
|
+
*/
|
|
297
|
+
export function createScheduledFlow(store, input, now) {
|
|
298
|
+
const problems = standingTermsProblems(input.terms, input.schedule);
|
|
299
|
+
if (!/^[a-z0-9][a-z0-9-]{0,40}$/.test(input.stem))
|
|
300
|
+
problems.unshift("name: lowercase letters, digits and dashes, at most 41 — it starts each task's id");
|
|
301
|
+
if (input.name.trim() === "" || input.name.length > 80 || hasForbiddenControls(input.name))
|
|
302
|
+
problems.unshift("name: required, at most 80 characters");
|
|
303
|
+
if (problems.length > 0)
|
|
304
|
+
return { ok: false, message: problems.join("; ") };
|
|
305
|
+
const { terms } = input;
|
|
306
|
+
const order = {
|
|
307
|
+
stem: input.stem, goal: terms.goal, outOfScope: terms.outOfScope, touches: [...terms.touches], requirements: [...terms.requirements], acceptance: [...terms.acceptance],
|
|
308
|
+
budgetPerRunMicrousd: terms.budgetPerRunMicrousd, costCeilingUsd: terms.costCeilingUsd, singleFlight: true, filedBy: input.filedBy === undefined ? input.by : input.filedBy, routine: null, approval: null,
|
|
309
|
+
};
|
|
310
|
+
const zone = (x, color) => ({ x, y: 0, w: 260, h: 300, color });
|
|
311
|
+
const definition = { version: 1, start: "build", stages: [
|
|
312
|
+
{ id: "build", title: "Build", kind: "task", zone: zone(0, "blue"), instructions: terms.goal, planning: "skip", approver: null, message: null, close: null, script: null, sort: null, next: "done", onFail: null },
|
|
313
|
+
{ id: "done", title: "Done", kind: "done", zone: zone(420, "green"), instructions: null, planning: null, approver: null, message: null, close: null, script: null, sort: null, next: null, onFail: null },
|
|
314
|
+
] };
|
|
315
|
+
return store.transact(() => {
|
|
316
|
+
const flow = store.createFlow({ repo: input.repo, name: input.name, definitionJson: JSON.stringify(definition), by: input.by }, now);
|
|
317
|
+
const trigger = store.addFlowTrigger({ flow, kind: "schedule", configJson: JSON.stringify({ kind: "schedule", schedule: input.schedule, title: input.name, description: null, zone: "build", order }), hookHash: null, cursor: null, nextAt: null, by: input.by }, now);
|
|
318
|
+
store.updateFlowTrigger(trigger, { state: "paused" }, now);
|
|
319
|
+
return { ok: true, flow, trigger };
|
|
320
|
+
});
|
|
321
|
+
}
|
package/dist/flow-share.js
CHANGED
|
@@ -25,7 +25,7 @@ import { durationMinutes, durationWords, FlowContractError, flowTerms, inStepWor
|
|
|
25
25
|
import { stageReferenceProblems } from "./contracts/stage-output.js";
|
|
26
26
|
import { readVersioned } from "./contracts/contract.js";
|
|
27
27
|
import { FLOW_ALIASES, FLOW_FILE_FORMAT, FLOW_FILE_VERSION, flowFileSchema, PARAMETER_ID } from "./contracts/flow.js";
|
|
28
|
-
import { parseSchedule } from "./
|
|
28
|
+
import { parseSchedule } from "./flow-schedule.js";
|
|
29
29
|
export { FLOW_FILE_FORMAT, FLOW_FILE_VERSION };
|
|
30
30
|
/** The largest flow file Toolroll reads or fetches. */
|
|
31
31
|
export const FLOW_FILE_MAX_BYTES = 256 * 1024;
|
package/dist/flow-triggers.d.ts
CHANGED
|
@@ -20,7 +20,7 @@ export declare const CARDS_PER_CHECK = 10;
|
|
|
20
20
|
* say, named by its path: no hidden characters and nothing that looks like a key, anywhere in the settings.
|
|
21
21
|
*/
|
|
22
22
|
export declare function readTriggerSettings(raw: unknown): ContractResult<TriggerInput>;
|
|
23
|
-
/** A schedule said in words ("every 2 hours", "daily 09:00 Europe/London", "monday 09:00") or in
|
|
23
|
+
/** A schedule said in words ("every 2 hours", "daily 09:00 Europe/London", "monday 09:00") or in its stored form ("daily:09:00"). */
|
|
24
24
|
export declare function scheduleFromWords(text: string): string | null;
|
|
25
25
|
/** A GitHub repository as owner/name, read from a project's origin remote when it is on GitHub. */
|
|
26
26
|
export declare function githubRepoOf(path: string): string | null;
|
|
@@ -92,6 +92,11 @@ export type TriggerMade = {
|
|
|
92
92
|
export declare function addFlowTriggerTo(store: Store, flow: FlowRow, raw: unknown, actor: string, now: Date, dir: string | null): TriggerMade;
|
|
93
93
|
/** A new secret address for a webhook trigger (the old one stops working at once). */
|
|
94
94
|
export declare function renewFlowHook(store: Store, trigger: FlowTriggerRow, now: Date, dir: string): TriggerMade;
|
|
95
|
+
/**
|
|
96
|
+
* Pause a trigger, or turn it on again. A schedule that never had a time (a routine moved to a flow while it waited
|
|
97
|
+
* for approval, v115) starts counting from now, as a new schedule does; one that was due while it was off fires once.
|
|
98
|
+
*/
|
|
99
|
+
export declare function setFlowTriggerOn(store: Store, trigger: FlowTriggerRow, on: boolean, now: Date): void;
|
|
95
100
|
export declare function removeFlowTrigger(store: Store, trigger: FlowTriggerRow, now: Date, dir: string | null): void;
|
|
96
101
|
export type Incoming = {
|
|
97
102
|
key: string;
|
package/dist/flow-triggers.js
CHANGED
|
@@ -4,7 +4,7 @@ import { adapterPolicy } from "./server/route-policy.js";
|
|
|
4
4
|
* them by hand.
|
|
5
5
|
*
|
|
6
6
|
* - button — a named button with questions; pressing it makes a card from the answers.
|
|
7
|
-
* - schedule — a card on a schedule (
|
|
7
|
+
* - schedule — a card on a schedule (every N minutes, daily, weekdays, weekly), or a standing order's task;
|
|
8
8
|
* v90: or a project script run on the schedule, each item it prints a card.
|
|
9
9
|
* - github — new issues (optionally with a label), new pull requests, or failed checks on a branch.
|
|
10
10
|
* - linear — Linear issues in a team, moving into a state, and/or with a label.
|
|
@@ -37,7 +37,7 @@ import { flowCardText, flowDefinitionOf } from "./flow-engine.js";
|
|
|
37
37
|
import { FlowContractError } from "./flows.js";
|
|
38
38
|
import { parseContract } from "./contracts/contract.js";
|
|
39
39
|
import { FLOW_ALIASES, FLOW_TRIGGER_KINDS, SECRETS_MAX, triggerConfigSchema, triggerInputSchema } from "./contracts/flow.js";
|
|
40
|
-
import { describeSchedule, firstFireAt, nextFireAt, parseSchedule, WEEKDAYS } from "./
|
|
40
|
+
import { describeSchedule, fileStandingOrder, firstFireAt, nextFireAt, parseSchedule, StandingOrderRefused, WEEKDAYS } from "./flow-schedule.js";
|
|
41
41
|
import { takeReply } from "./flow-replies.js";
|
|
42
42
|
import { mailboxAccess, mailCursorOf, mailCursorText, readThroughImap } from "./mailbox.js";
|
|
43
43
|
import { readFlowSecrets } from "./flow-secrets.js";
|
|
@@ -84,7 +84,7 @@ export function readTriggerSettings(raw) {
|
|
|
84
84
|
}
|
|
85
85
|
return read;
|
|
86
86
|
}
|
|
87
|
-
/** A schedule said in words ("every 2 hours", "daily 09:00 Europe/London", "monday 09:00") or in
|
|
87
|
+
/** A schedule said in words ("every 2 hours", "daily 09:00 Europe/London", "monday 09:00") or in its stored form ("daily:09:00"). */
|
|
88
88
|
export function scheduleFromWords(text) {
|
|
89
89
|
const raw = text.trim().replace(/\s+/g, " ");
|
|
90
90
|
if (parseSchedule(raw) !== null)
|
|
@@ -223,7 +223,7 @@ export function validateTriggerConfig(raw, context) {
|
|
|
223
223
|
}
|
|
224
224
|
}
|
|
225
225
|
}
|
|
226
|
-
/** The computer's time zone, as
|
|
226
|
+
/** The computer's time zone, as a schedule names one. */
|
|
227
227
|
function localTimeZone() {
|
|
228
228
|
try {
|
|
229
229
|
return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
|
|
@@ -253,6 +253,9 @@ export function describeTrigger(config, store) {
|
|
|
253
253
|
case "schedule": {
|
|
254
254
|
const schedule = parseSchedule(config.schedule);
|
|
255
255
|
const when = schedule === null ? config.schedule : describeSchedule(schedule);
|
|
256
|
+
// Its limits and terms are said once, in "What each run files" (describeStandingOrder).
|
|
257
|
+
if (config.order !== undefined)
|
|
258
|
+
return `${when.charAt(0).toUpperCase()}${when.slice(1)}: files the task “${config.title}”`;
|
|
256
259
|
return config.script !== undefined ? `${when.charAt(0).toUpperCase()}${when.slice(1)}: runs the ${config.script} script, and each item it prints becomes a card` : `${when.charAt(0).toUpperCase()}${when.slice(1)}: “${config.title}”`;
|
|
257
260
|
}
|
|
258
261
|
case "github":
|
|
@@ -281,7 +284,7 @@ export function triggerHeadline(config, store) {
|
|
|
281
284
|
case "schedule": {
|
|
282
285
|
const schedule = parseSchedule(config.schedule);
|
|
283
286
|
const when = schedule === null ? config.schedule : schedule.kind === "every" ? describeSchedule(schedule) : `${schedule.kind === "daily" ? "daily" : schedule.kind === "weekdays" ? "weekdays" : `${WEEKDAYS[schedule.day]}s`} at ${schedule.hhmm}`;
|
|
284
|
-
return { name: `${when.charAt(0).toUpperCase()}${when.slice(1)}`, detail: config.script !== undefined ? `Runs ${config.script}` : `“${config.title}”` };
|
|
287
|
+
return { name: `${when.charAt(0).toUpperCase()}${when.slice(1)}`, detail: config.script !== undefined ? `Runs ${config.script}` : config.order !== undefined ? `Files “${config.title}”` : `“${config.title}”` };
|
|
285
288
|
}
|
|
286
289
|
case "github": return { name: config.watch === "checks" ? `Failed checks on ${config.branch}` : config.watch === "pulls" ? `New pull requests${config.label === null ? "" : ` labeled ${config.label}`}` : config.label === null ? "New issues" : `Issues labeled ${config.label}`, detail: `GitHub · ${config.repo}` };
|
|
287
290
|
case "linear": return { name: `Linear${config.team === null ? "" : ` ${config.team}`}${config.state === null ? "" : ` → ${config.state}`}`, detail: config.label === null ? "Linear issues" : `Labeled ${config.label}` };
|
|
@@ -449,6 +452,15 @@ export function renewFlowHook(store, trigger, now, dir) {
|
|
|
449
452
|
const base = readHooksBase(dir);
|
|
450
453
|
return { ok: true, id: trigger.id, said: "New address made; the old one no longer works. Copy it now; it isn't shown again.", reveal: { path: `${HOOK_PATH}${token}`, address: base === null ? null : `${base}${HOOK_PATH}${token}`, secret } };
|
|
451
454
|
}
|
|
455
|
+
/**
|
|
456
|
+
* Pause a trigger, or turn it on again. A schedule that never had a time (a routine moved to a flow while it waited
|
|
457
|
+
* for approval, v115) starts counting from now, as a new schedule does; one that was due while it was off fires once.
|
|
458
|
+
*/
|
|
459
|
+
export function setFlowTriggerOn(store, trigger, on, now) {
|
|
460
|
+
const config = on && trigger.nextAt === null ? triggerConfigOf(trigger) : null;
|
|
461
|
+
const schedule = config !== null && (config.kind === "schedule" || config.kind === "plane-review") ? parseSchedule(config.schedule) : null;
|
|
462
|
+
store.updateFlowTrigger(trigger.id, { state: on ? "active" : "paused", ...(schedule === null ? {} : { nextAt: firstFireAt(schedule, now) }) }, now);
|
|
463
|
+
}
|
|
452
464
|
export function removeFlowTrigger(store, trigger, now, dir) {
|
|
453
465
|
store.updateFlowTrigger(trigger.id, { state: "removed", hookHash: null }, now);
|
|
454
466
|
if (dir !== null)
|
|
@@ -497,6 +509,46 @@ function makeCard(store, trigger, config, item, by, now) {
|
|
|
497
509
|
return { made: "added", note: null, card };
|
|
498
510
|
});
|
|
499
511
|
}
|
|
512
|
+
/**
|
|
513
|
+
* v115: a schedule with a standing order (what a routine was) files its task and the card that follows it, together or
|
|
514
|
+
* not at all; a slot it skips says why on the trigger. The card starts in the schedule's build step, which follows the
|
|
515
|
+
* task it was filed with.
|
|
516
|
+
*/
|
|
517
|
+
function fileOrderCard(store, trigger, config, order, item, slot, now) {
|
|
518
|
+
const flow = store.getFlow(trigger.flow);
|
|
519
|
+
const definition = flow === null ? null : flowDefinitionOf(flow);
|
|
520
|
+
if (flow === null || definition === null || flow.state !== "active")
|
|
521
|
+
return { made: "skipped", note: "the flow isn't active", card: null };
|
|
522
|
+
const zone = startZone(config, definition);
|
|
523
|
+
if (definition.stages.find(one => one.id === zone)?.kind !== "task")
|
|
524
|
+
return { made: "skipped", note: "Skipped: the schedule's first step isn't a build step.", card: null };
|
|
525
|
+
if (store.flowTriggerSaw(trigger.id, item.key))
|
|
526
|
+
return { made: "seen", note: null, card: null };
|
|
527
|
+
const text = flowCardText(item.title, item.description);
|
|
528
|
+
if ("problem" in text)
|
|
529
|
+
return { made: "skipped", note: `Skipped: ${text.problem}`, card: null };
|
|
530
|
+
try {
|
|
531
|
+
return store.transact(() => {
|
|
532
|
+
if (store.flowTriggerSaw(trigger.id, item.key))
|
|
533
|
+
return { made: "seen", note: null, card: null };
|
|
534
|
+
const fired = fileStandingOrder(store, { flowId: flow.id, repo: flow.repo, trigger: trigger.id, schedule: config.schedule, order, title: text.title, slot }, now);
|
|
535
|
+
if (fired.made === "skipped") {
|
|
536
|
+
store.recordFlowTriggerEvent(trigger.id, item.key, null, fired.note, now);
|
|
537
|
+
return { made: "skipped", note: fired.note, card: null };
|
|
538
|
+
}
|
|
539
|
+
const card = store.addFlowCard({ flow: flow.id, title: text.title, description: text.description, stage: zone, by: "Schedule", source: item.source }, now);
|
|
540
|
+
store.updateFlowCard(card, { task: fired.taskId, primaryTask: fired.taskId, waiting: "Filed as a task" }, now);
|
|
541
|
+
// The firing keeps its task: one-at-a-time and the weekly limit count it however the card moves later.
|
|
542
|
+
store.recordFlowTriggerEvent(trigger.id, item.key, card, null, now, fired.taskId);
|
|
543
|
+
return { made: "added", note: fired.note ?? (fired.approved ? `Filed ${fired.taskId}, approved.` : `Filed ${fired.taskId}; it waits for approval.`), card };
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
catch (error) {
|
|
547
|
+
if (!(error instanceof StandingOrderRefused))
|
|
548
|
+
throw error;
|
|
549
|
+
return { made: "skipped", note: `Skipped: ${error.message}. Nothing was filed.`, card: null };
|
|
550
|
+
}
|
|
551
|
+
}
|
|
500
552
|
const plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
|
|
501
553
|
function outcomeWords(results, more) {
|
|
502
554
|
const added = results.filter(one => one.made === "added").length;
|
|
@@ -589,10 +641,11 @@ function fireSchedule(store, trigger, config, now) {
|
|
|
589
641
|
const slot = trigger.nextAt;
|
|
590
642
|
const next = nextFireAt(schedule, slot, now);
|
|
591
643
|
// One card at a time: while the last one still waits where it started, the slot is skipped, not piled up.
|
|
592
|
-
const last = store.lastFlowTriggerCard(trigger.id);
|
|
644
|
+
const last = config.order === undefined ? store.lastFlowTriggerCard(trigger.id) : null;
|
|
593
645
|
const card = last === null ? null : store.getFlowCard(last);
|
|
594
646
|
const flow = store.getFlow(trigger.flow);
|
|
595
647
|
const definition = flow === null ? null : flowDefinitionOf(flow);
|
|
648
|
+
// A standing order says itself when it waits (its last task unfinished); other schedules wait on their last card.
|
|
596
649
|
if (card !== null && definition !== null && card.state === "active" && card.stage === startZone(config, definition) && card.entry === 1) {
|
|
597
650
|
store.recordFlowTriggerEvent(trigger.id, `slot:${slot}`, null, "the last one hadn't been picked up", now);
|
|
598
651
|
store.updateFlowTrigger(trigger.id, { nextAt: next, lastAt: now.toISOString(), lastOutcome: "Skipped: the last card hasn't been picked up yet." }, now);
|
|
@@ -601,8 +654,9 @@ function fireSchedule(store, trigger, config, now) {
|
|
|
601
654
|
const day = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone: schedule.kind === "every" ? "UTC" : schedule.timezone ?? "UTC" }).format(new Date(slot));
|
|
602
655
|
const fill = (text) => text.replace(/\{\{\s*date\s*\}\}/g, day);
|
|
603
656
|
const title = /\{\{\s*date\s*\}\}/.test(config.title) ? fill(config.title) : `${config.title} — ${day}`;
|
|
604
|
-
const
|
|
605
|
-
|
|
657
|
+
const item = { key: `slot:${slot}`, title, description: config.description === null ? null : fill(config.description), source: { kind: "schedule", label: "Schedule", url: null } };
|
|
658
|
+
const made = config.order === undefined ? makeCard(store, trigger, config, item, "Schedule", now) : fileOrderCard(store, trigger, config, config.order, item, slot, now);
|
|
659
|
+
store.updateFlowTrigger(trigger.id, { nextAt: next, lastAt: now.toISOString(), lastOutcome: made.made === "added" ? made.note ?? "Added a card." : made.note ?? "Nothing new." }, now);
|
|
606
660
|
return made.made === "added" ? 1 : 0;
|
|
607
661
|
}
|
|
608
662
|
/** The problem a plane review's event key names, and the day: `plane:<problem>:<YYYY-MM-DD>`. */
|
|
@@ -683,10 +737,11 @@ export function runScheduleNow(store, trigger, actor, now) {
|
|
|
683
737
|
return { ok: false, said: "Only a schedule without a script runs this way." };
|
|
684
738
|
if (trigger.state !== "active")
|
|
685
739
|
return { ok: false, said: "It's paused." };
|
|
686
|
-
const
|
|
740
|
+
const item = { key: `now:${now.toISOString()}`, title: config.title.replace(/\{\{\s*date\s*\}\}/g, "today"), description: config.description, source: { kind: "schedule", label: `Run now by ${actor}`, url: null } };
|
|
741
|
+
const made = config.order === undefined ? makeCard(store, trigger, config, item, "Schedule", now) : fileOrderCard(store, trigger, config, config.order, item, now.toISOString(), now);
|
|
687
742
|
if (made.made !== "added" || made.card === null)
|
|
688
743
|
return { ok: false, said: made.note ?? "Nothing was added." };
|
|
689
|
-
store.updateFlowTrigger(trigger.id, { lastAt: now.toISOString(), lastOutcome: "Added a card (run now)
|
|
744
|
+
store.updateFlowTrigger(trigger.id, { lastAt: now.toISOString(), lastOutcome: `${made.note ?? "Added a card."} (run now)` }, now);
|
|
690
745
|
return { ok: true, card: made.card };
|
|
691
746
|
}
|
|
692
747
|
/** A schedule's script (v90): it runs in a clean folder inside the agents' fence; each item it prints is a card, once. */
|
package/dist/flows-cli.js
CHANGED
|
@@ -14,8 +14,9 @@ import { addCardToFlow, advanceFlows, crossProjectProblem, decideFlowCard, flowD
|
|
|
14
14
|
import { chooseFlowCard } from './flow-send.js';
|
|
15
15
|
import { saveScript } from './flow-scripts.js';
|
|
16
16
|
import { exportFlow, fetchFlowFile, FLOW_FILE_MAX_BYTES, FlowFileError, importFlow, parseFlowFile, planFlowImport } from './flow-share.js';
|
|
17
|
+
import { describeStandingOrder } from './flow-schedule.js';
|
|
17
18
|
import { STARTER_FLOWS, starterFlowOf, starterOf, startersFor, starterTerms, switchOnStarter } from './flow-starters.js';
|
|
18
|
-
import { addFlowTriggerTo, checkFlowTriggerNow, describeTrigger, removeFlowTrigger, triggerConfigOf, validateTriggerConfig } from './flow-triggers.js';
|
|
19
|
+
import { addFlowTriggerTo, checkFlowTriggerNow, describeTrigger, removeFlowTrigger, setFlowTriggerOn, triggerConfigOf, validateTriggerConfig } from './flow-triggers.js';
|
|
19
20
|
import { deciderOf, FLOW_KIND_WORDS, FLOW_TEMPLATES, flowDigest, flowFromSteps, flowTerms, stepsFor } from './flows.js';
|
|
20
21
|
import { verifyApproverByPassword } from './principal.js';
|
|
21
22
|
import { projectName } from './project.js';
|
|
@@ -491,7 +492,7 @@ export async function runFlowsCommand(positional, flags, context) {
|
|
|
491
492
|
if (action === 'trigger remove')
|
|
492
493
|
removeFlowTrigger(store, trigger, now, context.configDir);
|
|
493
494
|
else
|
|
494
|
-
store
|
|
495
|
+
setFlowTriggerOn(store, trigger, action !== 'trigger pause', now);
|
|
495
496
|
record(flow.repo, 'accepted', `flow #${flow.id} trigger #${trigger.id}`);
|
|
496
497
|
settle(flow.repo);
|
|
497
498
|
const said = action === 'trigger remove' ? 'Trigger removed.' : action === 'trigger pause' ? 'Trigger paused.' : 'Trigger on again.';
|
|
@@ -532,7 +533,8 @@ function describeFlow(store, flow) {
|
|
|
532
533
|
triggers: store.flowTriggers(flow.id).map(trigger => {
|
|
533
534
|
const config = triggerConfigOf(trigger);
|
|
534
535
|
return { id: trigger.id, kind: trigger.kind, state: trigger.state, words: config === null ? 'This trigger can\'t be read.' : describeTrigger(config, store),
|
|
535
|
-
zone: config?.zone ?? definition?.start ?? null, nextAt: trigger.nextAt, lastAt: trigger.lastAt, lastOutcome: trigger.lastOutcome, failing: trigger.failures > 0
|
|
536
|
+
zone: config?.zone ?? definition?.start ?? null, nextAt: trigger.nextAt, lastAt: trigger.lastAt, lastOutcome: trigger.lastOutcome, failing: trigger.failures > 0,
|
|
537
|
+
...(config?.kind === 'schedule' && config.order !== undefined ? { order: describeStandingOrder(config.order, flow.repo, config.schedule) } : {}) };
|
|
536
538
|
}),
|
|
537
539
|
cards: store.flowCards(flow.id, true).slice(0, 10).map(card => cardOf(definition, card)),
|
|
538
540
|
};
|
|
@@ -552,7 +554,9 @@ function showLines(flow) {
|
|
|
552
554
|
...(zone.ifFails === null ? [] : [` ${zone.kind === 'approval' ? 'sent back' : zone.kind === 'sort' ? 'not sure' : zone.kind === 'wait' ? 'no reply' : zone.kind === 'choose' ? 'a reply' : 'if it fails'} → ${title(zone.ifFails)}`]),
|
|
553
555
|
]),
|
|
554
556
|
'', 'Triggers',
|
|
555
|
-
...(flow.triggers.length === 0 ? [' none — add one with toolroll flows trigger add'] : flow.triggers.
|
|
557
|
+
...(flow.triggers.length === 0 ? [' none — add one with toolroll flows trigger add'] : flow.triggers.flatMap(one => [` #${one.id} ${one.state === 'active' ? '' : `(${one.state}) `}${one.words} → ${title(one.zone)}${one.lastOutcome === null ? '' : ` · last: ${one.lastOutcome}`}`,
|
|
558
|
+
...(one.order === undefined ? [] : one.order.map(line => ` ${line}`)),
|
|
559
|
+
...(one.order !== undefined && one.state === 'paused' ? [` Off: toolroll flows trigger resume ${flow.id} ${one.id} turns it on.`] : [])])),
|
|
556
560
|
'', 'Recent cards',
|
|
557
561
|
...(flow.cards.length === 0 ? [' none'] : flow.cards.map(one => ` #${one.id} ${one.title} · ${one.state === 'active' ? one.zoneTitle : one.state}${one.waiting === null ? '' : ` · ${one.waiting}`}`)),
|
|
558
562
|
];
|
package/dist/flows-ui.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { draftFor, flowDefinitionOf } from "./flow-engine.js";
|
|
2
|
+
import { describeStandingOrder } from "./flow-schedule.js";
|
|
2
3
|
import { describeTrigger, triggerHeadline, FLOW_TRIGGER_KINDS, FLOW_TRIGGER_WORDS, githubRepoOf, HOOK_PATH, hookReady, planeStatusLink, readHooksBase, readLinearKey, takesDeliveries, triggerConfigOf } from "./flow-triggers.js";
|
|
3
4
|
import { deciderOf, FLOW_COLORS, FLOW_KIND_WORDS, FLOW_STAGE_KINDS } from "./flows.js";
|
|
4
5
|
import { flowInsights, troubleWords } from "./flow-insights.js";
|
|
@@ -190,6 +191,7 @@ export function flowView(store, flow, viewer, selectedCard, setup = { dir: null,
|
|
|
190
191
|
hook: config !== null && takesDeliveries(config) ? { ready: hookReady(trigger, setup.dir), needsSecret: config.kind === "linear" } : null,
|
|
191
192
|
checkable: ((config?.kind === "github" || config?.kind === "linear") && config.delivery === "poll") || config?.kind === "email" || (config?.kind === "schedule" && config.script !== undefined) || config?.kind === "plane-review",
|
|
192
193
|
shared: config?.kind === "button" && trigger.hookHash !== null,
|
|
194
|
+
...(config?.kind === "schedule" && config.order !== undefined ? { order: describeStandingOrder(config.order, flow.repo, config.schedule) } : {}),
|
|
193
195
|
};
|
|
194
196
|
});
|
|
195
197
|
return {
|
package/dist/flows.js
CHANGED
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
* - done — the end.
|
|
46
46
|
*
|
|
47
47
|
* The engine is deterministic and model-free: it runs in the worker's pass
|
|
48
|
-
* beside
|
|
48
|
+
* beside builds, and no card ever skips an approval the task itself needs.
|
|
49
49
|
*/
|
|
50
50
|
import { TEXT_LIMITS } from "./text-limits.js";
|
|
51
51
|
import { createHash } from "node:crypto";
|