Skip to content

Commit 03ae424

Browse files
authored
Merge pull request #135 from httpsphl/feat/routine-signals
feat: signals between bots start routines
2 parents f93ddc6 + 9eb5ccc commit 03ae424

42 files changed

Lines changed: 1115 additions & 144 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎app/src/dev/seedRoutines.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export function seedRoutines(fake: FakeBotloft, bots: { scout: BotId; analyst: B
2727
messageId: null,
2828
createdAt: fake.now - 2 * HOUR,
2929
finishedAt: fake.now - HOUR,
30+
signal: null,
3031
...run,
3132
};
3233
};
@@ -62,4 +63,14 @@ export function seedRoutines(fake: FakeBotloft, bots: { scout: BotId; analyst: B
6263
});
6364
backup.enabled = false;
6465
backup.nextRunAt = null;
66+
67+
// Spec 20.13: runs when Analyst says the numbers are in.
68+
const recheck = create(bots.scout, {
69+
name: "Recheck the sources",
70+
prompt: "The numbers changed: check that every claim in the report still has a source.",
71+
schedule: { kind: "signal", name: "numbers-updated" },
72+
});
73+
lastRun(recheck, {
74+
signal: { name: "numbers-updated", fromBotId: bots.analyst, note: null },
75+
});
6576
}

‎app/src/features/routines/MoreOptions.tsx‎

Lines changed: 36 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// "More options" in the routine form (spec 20.9): the time zone, what to
22
// do when the last run has not finished or the computer was off, and a
3-
// cron expression for those who want one.
3+
// cron expression for those who want one. A routine that waits for a
4+
// signal (spec 20.13) has no time: only the overlap rule is left.
45

56
import { ChevronRight } from "lucide-react";
67
import { useT } from "../../i18n";
@@ -18,19 +19,22 @@ export function MoreOptions({
1819
}) {
1920
const d = useT().routines.dialog;
2021
const zones = allZones();
22+
const timed = form.frequency !== "signal" || form.useCron;
2123
return (
2224
<details className="group rounded-xl border border-line">
2325
<summary className="flex cursor-default items-center gap-1.5 px-3 py-2 font-medium text-ink-soft text-sm hover:text-ink">
2426
<ChevronRight aria-hidden size={14} className="transition-transform group-open:rotate-90" />
2527
{d.more}
2628
</summary>
2729
<div className="flex flex-col gap-4 border-line border-t px-3 py-3">
28-
<SelectField
29-
label={d.timezone}
30-
value={form.timezone}
31-
onChange={(event) => change({ timezone: event.target.value })}
32-
options={zones.map((zone) => ({ value: zone, label: zoneLabel(zone) }))}
33-
/>
30+
{timed && (
31+
<SelectField
32+
label={d.timezone}
33+
value={form.timezone}
34+
onChange={(event) => change({ timezone: event.target.value })}
35+
options={zones.map((zone) => ({ value: zone, label: zoneLabel(zone) }))}
36+
/>
37+
)}
3438
<div className="flex flex-col gap-1.5">
3539
<p className="font-medium text-ink-soft text-sm">{d.overlap}</p>
3640
<Choices
@@ -43,27 +47,31 @@ export function MoreOptions({
4347
onChange={(overlap) => change({ overlap })}
4448
/>
4549
</div>
46-
<div className="flex flex-col gap-1.5">
47-
<p className="font-medium text-ink-soft text-sm">{d.missed}</p>
48-
<Choices
49-
label={d.missed}
50-
value={form.missed}
51-
options={[
52-
{ value: "run_once", label: d.missedRunOnce },
53-
{ value: "skip", label: d.missedSkip },
54-
]}
55-
onChange={(missed) => change({ missed })}
56-
/>
57-
<p className="text-muted text-xs">{d.wakeNote}</p>
58-
</div>
59-
<label className="flex items-center gap-2 text-sm">
60-
<input
61-
type="checkbox"
62-
checked={form.useCron}
63-
onChange={(event) => change({ useCron: event.target.checked })}
64-
/>
65-
{d.advanced}
66-
</label>
50+
{timed && (
51+
<div className="flex flex-col gap-1.5">
52+
<p className="font-medium text-ink-soft text-sm">{d.missed}</p>
53+
<Choices
54+
label={d.missed}
55+
value={form.missed}
56+
options={[
57+
{ value: "run_once", label: d.missedRunOnce },
58+
{ value: "skip", label: d.missedSkip },
59+
]}
60+
onChange={(missed) => change({ missed })}
61+
/>
62+
<p className="text-muted text-xs">{d.wakeNote}</p>
63+
</div>
64+
)}
65+
{timed && (
66+
<label className="flex items-center gap-2 text-sm">
67+
<input
68+
type="checkbox"
69+
checked={form.useCron}
70+
onChange={(event) => change({ useCron: event.target.checked })}
71+
/>
72+
{d.advanced}
73+
</label>
74+
)}
6775
{form.useCron && (
6876
<TextField
6977
label={d.cron}

‎app/src/features/routines/RoutineDialog.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ export function RoutineDialog({ bot, routine, onClose }: Props) {
8989
placeholder={d.promptPlaceholder}
9090
required
9191
/>
92-
<ScheduleFields form={form} change={change} />
92+
<ScheduleFields form={form} change={change} bot={bot.name} />
9393
<MoreOptions form={form} change={change} />
9494
{error && (
9595
<p role="alert" className="text-danger text-sm">

‎app/src/features/routines/RoutineRequestCard.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ export function RoutineRequestCard({ approval, bot }: { approval: ApprovalItem;
132132
rows={4}
133133
onChange={(event) => change({ prompt: event.target.value })}
134134
/>
135-
<ScheduleFields form={form} change={change} />
135+
<ScheduleFields form={form} change={change} bot={runner.name} />
136136
<MoreOptions form={form} change={change} />
137137
<p className="text-muted text-xs">{r.explain(runner.name)}</p>
138138
{error && (

‎app/src/features/routines/RoutineRow.tsx‎

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -73,9 +73,13 @@ export function RoutineRow({
7373
{describeSchedule(routine.schedule, r.when)}
7474
<span className="text-muted">
7575
{" · "}
76-
{routine.enabled && routine.nextRunAt !== null
77-
? r.next(nextText(routine.nextRunAt, routine.timezone, r))
78-
: r.off}
76+
{!routine.enabled
77+
? r.off
78+
: routine.schedule.kind === "signal"
79+
? r.waitsSignal
80+
: routine.nextRunAt !== null
81+
? r.next(nextText(routine.nextRunAt, routine.timezone, r))
82+
: r.off}
7983
</span>
8084
</p>
8185
{routine.lastRun && <LastRun run={routine.lastRun} words={r.last} />}
@@ -112,26 +116,34 @@ export function RoutineRow({
112116

113117
function LastRun({ run, words }: { run: RoutineRun; words: Messages["routines"]["last"] }) {
114118
const line = "mt-1 flex items-center gap-1.5 text-xs";
119+
// Who sent the signal it ran for (spec 20.13), while that bot is around.
120+
const sender = useApp((state) =>
121+
run.signal?.fromBotId ? (state.bots[run.signal.fromBotId]?.name ?? null) : null,
122+
);
123+
const from = sender && <span className="text-muted">· {words.signalFrom(sender)}</span>;
115124
switch (run.status) {
116125
case "queued":
117126
return (
118127
<p className={`${line} text-work`}>
119128
<LoaderCircle aria-hidden size={12} className="animate-spin" />
120129
{words.queued}
130+
{from}
121131
</p>
122132
);
123133
case "done":
124134
return (
125135
<p className={`${line} text-ok`}>
126136
<CircleCheck aria-hidden size={12} />
127137
{words.done}
138+
{from}
128139
</p>
129140
);
130141
case "failed":
131142
return (
132143
<p className={`${line} text-danger`}>
133144
<CircleAlert aria-hidden size={12} />
134145
{words.failed}
146+
{from}
135147
</p>
136148
);
137149
case "skipped": {
@@ -140,11 +152,14 @@ function LastRun({ run, words }: { run: RoutineRun; words: Messages["routines"][
140152
? words.missed(run.skippedCount)
141153
: run.reason === "bot_paused"
142154
? words.bot_paused
143-
: words.overlap;
155+
: run.reason === "too_soon"
156+
? words.too_soon
157+
: words.overlap;
144158
return (
145159
<p className={`${line} text-muted`}>
146160
<SkipForward aria-hidden size={12} />
147161
{text}
162+
{from}
148163
</p>
149164
);
150165
}

‎app/src/features/routines/ScheduleFields.tsx‎

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,18 +13,23 @@ const control =
1313
export function ScheduleFields({
1414
form,
1515
change,
16+
bot,
1617
}: {
1718
form: RoutineForm;
1819
change(patch: Partial<RoutineForm>): void;
20+
/** The bot's name, for the signal's hint. */
21+
bot: string;
1922
}) {
2023
const d = useT().routines.dialog;
2124
const timeId = useId();
2225
const everyId = useId();
26+
const signalId = useId();
2327
const frequencies: { value: Frequency; label: string }[] = [
2428
{ value: "daily", label: d.frequency.daily },
2529
{ value: "weekdays", label: d.frequency.weekdays },
2630
{ value: "days", label: d.frequency.days },
2731
{ value: "interval", label: d.frequency.interval },
32+
{ value: "signal", label: d.frequency.signal },
2833
];
2934

3035
const toggleDay = (day: number) =>
@@ -65,7 +70,24 @@ export function ScheduleFields({
6570
})}
6671
</fieldset>
6772
)}
68-
{form.frequency === "interval" ? (
73+
{form.frequency === "signal" ? (
74+
<div className="flex flex-col gap-1.5">
75+
<div className="flex items-center gap-2">
76+
<label htmlFor={signalId} className="text-ink-soft text-sm">
77+
{d.signal}
78+
</label>
79+
<input
80+
id={signalId}
81+
required
82+
value={form.signal}
83+
placeholder={d.signalPlaceholder}
84+
onChange={(event) => change({ signal: event.target.value })}
85+
className={`${control} w-56`}
86+
/>
87+
</div>
88+
<p className="text-muted text-xs">{d.signalHint(bot)}</p>
89+
</div>
90+
) : form.frequency === "interval" ? (
6991
<div className="flex items-center gap-2">
7092
<label htmlFor={everyId} className="text-ink-soft text-sm">
7193
{d.every}

‎app/src/features/routines/describe.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ export function describeSchedule(schedule: Schedule, words: Words["when"]): stri
5151
: words.everyMinutes(schedule.minutes);
5252
case "cron":
5353
return words.cron(schedule.expr);
54+
case "signal":
55+
return words.signal(schedule.name);
5456
}
5557
}
5658

‎app/src/features/routines/form.ts‎

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
// The routine form's fields and the schedule they stand for (spec 20.9):
2-
// every day, weekdays, chosen days or every N minutes or hours, or a cron
3-
// expression under "More options".
2+
// every day, weekdays, chosen days, every N minutes or hours, or when a
3+
// bot signals (spec 20.13), or a cron expression under "More options".
44

55
import type { Missed, Overlap, Routine, Schedule } from "../../lib/protocol.gen";
66
import { systemZone } from "./describe";
77

8-
export type Frequency = "daily" | "weekdays" | "days" | "interval";
8+
export type Frequency = "daily" | "weekdays" | "days" | "interval" | "signal";
99
export type Unit = "minutes" | "hours";
1010

1111
export interface RoutineForm {
@@ -23,6 +23,8 @@ export interface RoutineForm {
2323
missed: Missed;
2424
useCron: boolean;
2525
cron: string;
26+
/** The signal the routine waits for. */
27+
signal: string;
2628
}
2729

2830
const EVERY_DAY = [1, 2, 3, 4, 5, 6, 7];
@@ -42,6 +44,7 @@ export function blankForm(): RoutineForm {
4244
missed: "run_once",
4345
useCron: false,
4446
cron: "",
47+
signal: "",
4548
};
4649
}
4750

@@ -86,6 +89,10 @@ export function formOf(routine: RoutineFields): RoutineForm {
8689
form.useCron = true;
8790
form.cron = schedule.expr;
8891
break;
92+
case "signal":
93+
form.frequency = "signal";
94+
form.signal = schedule.name;
95+
break;
8996
}
9097
return form;
9198
}
@@ -107,5 +114,7 @@ export function scheduleOf(form: RoutineForm): Schedule {
107114
kind: "interval",
108115
minutes: Math.round(form.every) * (form.unit === "hours" ? 60 : 1),
109116
};
117+
case "signal":
118+
return { kind: "signal", name: form.signal.trim() };
110119
}
111120
}

‎app/src/features/routines/routines.test.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,7 @@ describe("failed runs", () => {
218218
messageId: null,
219219
createdAt: Date.now(),
220220
finishedAt: Date.now() + 1000,
221+
signal: null,
221222
},
222223
});
223224
});

0 commit comments

Comments
 (0)