pi-roundtable 0.7.10 → 0.7.11
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 +14 -0
- package/docs/plugins.md +22 -1
- package/package.json +1 -1
- package/src/core/discord/schedule-commands.ts +3 -1
- package/src/core/i18n/schedules.ts +4 -0
- package/src/core/modules/schedules/prechecks.ts +135 -2
- package/src/core/modules/schedules/schedule-store.ts +39 -4
- package/src/core/modules/schedules/schedule-tools.ts +109 -18
- package/src/core/modules/schedules/scheduler.ts +97 -17
- package/src/core/modules/schedules/schedules.ts +4 -2
- package/src/core/shared/schedule-tools.ts +23 -0
- package/src/core/testing/prechecks.ts +44 -0
- package/src/index.ts +7 -1
- package/src/testing.ts +7 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,20 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [0.7.11] - 2026-10-03
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- Precheck scripts: agents may write a schedule's precheck themselves. `schedule_create` and `schedule_update` take `precheck_script`, a JavaScript module of at most `PRECHECK_SCRIPT_CHARS` (8,000) characters with a default export, parsed but never run when it is set; a schedule has a `precheck` or a `precheck_script`, and setting one removes the other. The core never runs a script: a `PrecheckScriptRunner` registered with `PRECHECKS.useScriptRunner` (`scriptRunner` reads it) runs it with a `PrecheckScriptContext` (the schedule, `firedAt`, `signal`, the host's `timeZone`, and `today`), and its `describe` (given a `PrecheckScope`) tells the model what a script may call; `schedule_list` shows it and a schedule's script. Without a runner, the tools neither take nor mention scripts, and a stored script wakes the turn with `### Precheck failed: script`. `Schedule`, `NewSchedule`, and `ScheduleChange` gain `precheckScript`; the `schedules` table gains a nullable `precheck_script` column through the new `schedules-precheck-script` migration. `scheduleToolSpecs` takes `precheckScripts`.
|
|
13
|
+
- `pi-roundtable/testing`: `fakeScriptRunner` (`fakeScriptRunner(answer, options?)`), with the types `FakeScriptAnswer` and `FakeScriptRunner`.
|
|
14
|
+
- `PrecheckScope` carries `tier`: the script's creator's tier when it runs, the asker's when `schedule_list` describes it, so a runner may grant lower tiers less. A script may export its default as `export { check as default }`. The store itself keeps a schedule's `precheck` and `precheckScript` apart. `schedule_list` waits at most 10 seconds for the runner's `describe`.
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
|
|
18
|
+
- Stopping the scheduler aborts running prechecks (their `signal` fires) and waits up to 15 seconds for them to settle, so a sandboxed script's container is removed before the host exits; a precheck that ends after the stop starts no turn and records `skipped: the host stopped during its precheck`.
|
|
19
|
+
- Behavior change: `PrecheckRegistry` gains `useScriptRunner` and `scriptRunner`, so a host that provides `PRECHECKS` with a registry of its own must add them.
|
|
20
|
+
- pi-roundtable-sandbox: `precheckScriptRunner` runs each script in a sealed container; see its changelog.
|
|
21
|
+
|
|
8
22
|
## [0.7.10] - 2026-10-03
|
|
9
23
|
|
|
10
24
|
### Added
|
package/docs/plugins.md
CHANGED
|
@@ -167,7 +167,7 @@ The built-in plugins provide these, from the main entry:
|
|
|
167
167
|
| `AGENTS` | `AgentServer` | `agent-server` | The `team` (`AgentTeam`), the read-only `directory` (`AgentDirectory`), the `runtime` every agent turn runs on, `approvals` (whether the owner's reply approves held actions), and `avatars` (`AvatarStudio`) |
|
|
168
168
|
| `SKILLS` | `SkillRegistry` | `skills` (an addon) | What agents carry: `carried`, `carriedNames`, `describeCarried`, `catalog`, `list`, `linkedFrom`, `checkRegistered`, `link`, `attach` |
|
|
169
169
|
| `SCHEDULES` | `ScheduleStore` | `schedule-store` | The stored schedules: `create`, `get`, `forChannel`, `all`, `update`, `remove`, `due`, `claim`, `recordStatus` |
|
|
170
|
-
| `PRECHECKS` | `PrecheckRegistry` | `prechecks` | The host's named [prechecks](#prechecks-wake-a-schedule-only-when-it-has-work): `register`, `get`, `list` |
|
|
170
|
+
| `PRECHECKS` | `PrecheckRegistry` | `prechecks` | The host's named [prechecks](#prechecks-wake-a-schedule-only-when-it-has-work): `register`, `get`, `list`; and the runner of agents' precheck scripts: `useScriptRunner`, `scriptRunner` |
|
|
171
171
|
| `MEMORY` | `MemoryStore` | `memory` (an addon) | `forSpeaker(id)` gives that speaker's `SpeakerMemory`: `list`, `forPrompt`, `add`, `search`, `update`, `removeById`, `remove`; `MEMORY_KINDS` is `core`, `note`, `event` |
|
|
172
172
|
| `BACKGROUND_TURNS` | `BackgroundTurns` | `modules` | Turns nobody wrote: `runScheduled`, `runDelegated`, `runErrorReport` |
|
|
173
173
|
| `DELEGATION` | `Delegator` | `modules` | `start(request)` a background task, `runningChannels()`, `idle()` |
|
|
@@ -800,6 +800,19 @@ export function recoveryPrecheck(read: () => Promise<RecoveryReading>) {
|
|
|
800
800
|
```
|
|
801
801
|
<!-- /example -->
|
|
802
802
|
|
|
803
|
+
##### Precheck scripts: prechecks the agent writes
|
|
804
|
+
|
|
805
|
+
A host can also let agents write a schedule's precheck themselves, so a new check needs no change to the host.
|
|
806
|
+
The core stores the script with the schedule and decides with it exactly as with a named precheck, but it never runs a script itself: a `PrecheckScriptRunner` does, registered once with `services.get(PRECHECKS).useScriptRunner(runner)`.
|
|
807
|
+
pi-roundtable-sandbox's `precheckScriptRunner` runs each script in a sealed container whose only way out is the MCP tools the host grants for that schedule; see its README.
|
|
808
|
+
|
|
809
|
+
- `schedule_create` and `schedule_update` take `precheck_script`, a JavaScript module of at most `PRECHECK_SCRIPT_CHARS` (8,000) characters with a default export; it is parsed, never run, when it is set. A schedule has a `precheck` or a `precheck_script`; setting one removes the other, and `null` removes either.
|
|
810
|
+
- The runner's `run(script, context)` gets the `PrecheckScriptContext`: the schedule, `firedAt`, `signal`, the host's `timeZone`, and `today`, the date there. Its answer is checked like a named precheck's, and its finding is named `script`.
|
|
811
|
+
- `describe({ channel, target, tier })` (a `PrecheckScope`) tells the model how to write one and what it may call there; `schedule_list` shows it, waiting at most 10 seconds. `tier` is the asker's there and the script's creator's when it runs, so a runner may grant lower tiers less.
|
|
812
|
+
- When the host stops, the scheduler aborts running scripts and waits up to 15 seconds for the runner to clean up; a precheck that ends then starts no turn.
|
|
813
|
+
- Without a runner, the tools neither take nor mention `precheck_script`, a script is refused with the registered names, and a schedule that already has one wakes with `### Precheck failed: script`, never a silent skip.
|
|
814
|
+
|
|
815
|
+
|
|
803
816
|
### `migrations` and `context.database()`: tables of your own
|
|
804
817
|
|
|
805
818
|
Declare `migrations` on the plugin object.
|
|
@@ -2135,6 +2148,7 @@ Call `useTestLocale()` after a test changes the process-wide locale or time zone
|
|
|
2135
2148
|
Use `partial<Port>({ ... })` to stand in for a port your code takes as an argument.
|
|
2136
2149
|
It provides the members you give it and throws an error naming any missing member you read, so the test needs no `as unknown as Port` cast.
|
|
2137
2150
|
`fakePrecheck(name, answer, { description?, timeoutMs? })` is a precheck that answers `answer` (a `PrecheckResult`, an `Error` it throws, or a function of its context) and records each context in `calls`; `fakePrechecks(...prechecks)` is a real in-memory `PrecheckRegistry` with them registered, which a test gives a plugin as `servicePair(PRECHECKS, registry)` and reads back with `registry.get(name)`.
|
|
2151
|
+
`fakeScriptRunner(answer, { describe?, timeoutMs? })` is a `PrecheckScriptRunner` that runs nothing and answers `answer` (a result, an `Error` it throws, or a function of the script and its context), recording each call in `calls`; give it to `registry.useScriptRunner`.
|
|
2138
2152
|
`fakeDiscord({ ownerId?, rootCommand? })` is the `DISCORD` service for a plugin that adds slash commands: give it as `services: [discord.service]`, read what the plugin added with `discord.added()`, and compose the tree Discord would get with `discord.compose()`.
|
|
2139
2153
|
Only `commands` and `guard` are given; a plugin that reads another member of `DISCORD` in a test gives its own with `servicePair(DISCORD, { ... })`.
|
|
2140
2154
|
|
|
@@ -2507,6 +2521,10 @@ Import from the entries listed below; source area files are internal.
|
|
|
2507
2521
|
| `PrecheckFinding` | `pi-roundtable` | type |
|
|
2508
2522
|
| `PrecheckRegistry` | `pi-roundtable` | type |
|
|
2509
2523
|
| `PrecheckResult` | `pi-roundtable` | type |
|
|
2524
|
+
| `PrecheckScope` | `pi-roundtable` | type |
|
|
2525
|
+
| `PrecheckScriptContext` | `pi-roundtable` | type |
|
|
2526
|
+
| `PrecheckScriptRunner` | `pi-roundtable` | type |
|
|
2527
|
+
| `PRECHECK_SCRIPT_CHARS` | `pi-roundtable` | value |
|
|
2510
2528
|
| `SKILLS` | `pi-roundtable` | value |
|
|
2511
2529
|
| `Schedule` | `pi-roundtable` | type |
|
|
2512
2530
|
| `ScheduleChange` | `pi-roundtable` | type |
|
|
@@ -2585,6 +2603,9 @@ Import from the entries listed below; source area files are internal.
|
|
|
2585
2603
|
| `fakePrechecks` | `pi-roundtable/testing` | value |
|
|
2586
2604
|
| `FakePrecheck` | `pi-roundtable/testing` | type |
|
|
2587
2605
|
| `FakePrecheckAnswer` | `pi-roundtable/testing` | type |
|
|
2606
|
+
| `fakeScriptRunner` | `pi-roundtable/testing` | value |
|
|
2607
|
+
| `FakeScriptAnswer` | `pi-roundtable/testing` | type |
|
|
2608
|
+
| `FakeScriptRunner` | `pi-roundtable/testing` | type |
|
|
2588
2609
|
| `fakeThreads` | `pi-roundtable/testing` | value |
|
|
2589
2610
|
| `openTestStore` | `pi-roundtable/testing` | value |
|
|
2590
2611
|
| `servicePair` | `pi-roundtable/testing` | value |
|
package/package.json
CHANGED
|
@@ -78,7 +78,9 @@ function section(schedule: Schedule, label: TargetLabel): string {
|
|
|
78
78
|
text.scheduleSetBy(plain(schedule.createdByName), last),
|
|
79
79
|
...(schedule.precheck
|
|
80
80
|
? [text.schedulePrecheck(plain(schedule.precheck))]
|
|
81
|
-
:
|
|
81
|
+
: schedule.precheckScript
|
|
82
|
+
? [text.schedulePrecheckScript(schedule.precheckScript.length)]
|
|
83
|
+
: []),
|
|
82
84
|
`-# ${plain(prompt)}`,
|
|
83
85
|
].join("\n");
|
|
84
86
|
}
|
|
@@ -49,6 +49,8 @@ export function schedulesEn(ctx: CatalogContext) {
|
|
|
49
49
|
scheduleSetBy: (name: string, lastRun: string) =>
|
|
50
50
|
`Set by ${name}${lastRun}`,
|
|
51
51
|
schedulePrecheck: (name: string) => `Checked first by precheck ${name}`,
|
|
52
|
+
schedulePrecheckScript: (chars: number) =>
|
|
53
|
+
`Checked first by its own precheck script (${chars} characters)`,
|
|
52
54
|
schedulePrecheckNote: (id: number, title: string, note: string) =>
|
|
53
55
|
`-# Schedule #${id} ${title}, skipped by its precheck: ${note.replace(/\n+/g, "\n-# ")}`,
|
|
54
56
|
scheduleChoice: (id: number, title: string, recurrence: string) =>
|
|
@@ -91,6 +93,8 @@ export function schedulesZhTW(
|
|
|
91
93
|
scheduleSetBy: (name: string, lastRun: string) =>
|
|
92
94
|
`由 ${name} 設定${lastRun}`,
|
|
93
95
|
schedulePrecheck: (name: string) => `先由預檢 ${name} 判斷`,
|
|
96
|
+
schedulePrecheckScript: (chars: number) =>
|
|
97
|
+
`先由自訂的預檢腳本判斷(${chars} 字元)`,
|
|
94
98
|
schedulePrecheckNote: (id: number, title: string, note: string) =>
|
|
95
99
|
`-# 排程 #${id} ${title} 經預檢略過:${note.replace(/\n+/g, "\n-# ")}`,
|
|
96
100
|
scheduleChoice: (id: number, title: string, recurrence: string) =>
|
|
@@ -1,9 +1,19 @@
|
|
|
1
|
+
import { parse } from "@babel/parser";
|
|
2
|
+
import type { ChannelKey } from "../../domain/conversation.ts";
|
|
3
|
+
import { ScheduleError } from "../../domain/errors.ts";
|
|
1
4
|
import { PluginError } from "../../errors.ts";
|
|
5
|
+
import type { Tier } from "../../speakers.ts";
|
|
2
6
|
import type { Schedule } from "./schedule-store.ts";
|
|
3
7
|
|
|
4
8
|
/** How long a precheck may run before it counts as failed, unless it sets its own `timeoutMs`. */
|
|
5
9
|
export const PRECHECK_TIMEOUT_MS = 60_000;
|
|
6
10
|
|
|
11
|
+
/** The longest precheck script a schedule may carry, in characters. */
|
|
12
|
+
export const PRECHECK_SCRIPT_CHARS = 8_000;
|
|
13
|
+
|
|
14
|
+
/** What a finding names a schedule's own script by, in its heading and status. */
|
|
15
|
+
export const SCRIPT_PRECHECK = "script";
|
|
16
|
+
|
|
7
17
|
/** A precheck name: lower-case letters, digits, `.`, `_`, and `-`, starting with a letter or digit. */
|
|
8
18
|
const NAME = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
|
9
19
|
|
|
@@ -25,7 +35,7 @@ export interface PrecheckContext {
|
|
|
25
35
|
/** The due schedule, already moved to its next run (or deleted, when it runs once). */
|
|
26
36
|
schedule: Schedule;
|
|
27
37
|
firedAt: Date;
|
|
28
|
-
/** Aborted when the precheck runs out of time; its answer is then ignored. */
|
|
38
|
+
/** Aborted when the precheck runs out of time or the host stops; its answer is then ignored. */
|
|
29
39
|
signal: AbortSignal;
|
|
30
40
|
}
|
|
31
41
|
|
|
@@ -45,13 +55,57 @@ export interface Precheck {
|
|
|
45
55
|
run(context: PrecheckContext): PrecheckResult | Promise<PrecheckResult>;
|
|
46
56
|
}
|
|
47
57
|
|
|
48
|
-
/**
|
|
58
|
+
/** Whose schedule a script is for: the runner decides what such a script may reach from it. */
|
|
59
|
+
export interface PrecheckScope {
|
|
60
|
+
channel: ChannelKey;
|
|
61
|
+
/** The schedule's background target, such as the owner's. */
|
|
62
|
+
target: string;
|
|
63
|
+
/**
|
|
64
|
+
* The tier of whoever set the script: its creator's when it runs, the asker's when schedule_list
|
|
65
|
+
* describes it; absent when the asker's is unknown. A runner may grant lower tiers less.
|
|
66
|
+
*/
|
|
67
|
+
tier?: Tier;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** What a schedule's precheck script runs with when its schedule falls due. */
|
|
71
|
+
export interface PrecheckScriptContext extends PrecheckContext {
|
|
72
|
+
/** The host's IANA time zone, such as `Asia/Taipei`. */
|
|
73
|
+
timeZone: string;
|
|
74
|
+
/** The date in that zone when it fired, `YYYY-MM-DD`, so a script never reads a UTC date by mistake. */
|
|
75
|
+
today: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Runs the precheck scripts agents write for their schedules, isolated from the host: the core
|
|
80
|
+
* stores, checks, and schedules them but never runs one itself. Provided by a sandbox, such as
|
|
81
|
+
* pi-roundtable-sandbox's `precheckScriptRunner`, through `PRECHECKS.useScriptRunner`.
|
|
82
|
+
*/
|
|
83
|
+
export interface PrecheckScriptRunner {
|
|
84
|
+
/** Runs one script; its answer is checked like a host precheck's, and a throw wakes the turn with the error. */
|
|
85
|
+
run(
|
|
86
|
+
script: string,
|
|
87
|
+
context: PrecheckScriptContext,
|
|
88
|
+
): PrecheckResult | Promise<PrecheckResult>;
|
|
89
|
+
/** How long a script may run; default 60 seconds. A timeout counts as a throw. */
|
|
90
|
+
timeoutMs?: number;
|
|
91
|
+
/**
|
|
92
|
+
* How to write a script for a schedule in this scope, and what it may call there, shown to the
|
|
93
|
+
* model by schedule_list.
|
|
94
|
+
*/
|
|
95
|
+
describe(scope: PrecheckScope): string | Promise<string>;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The host's prechecks, by name, and the runner of agents' precheck scripts. Provided as `PRECHECKS` by the `prechecks` plugin. */
|
|
49
99
|
export interface PrecheckRegistry {
|
|
50
100
|
/** Adds a precheck; throws PluginError for a bad or repeated name, an empty description, or a bad timeout. */
|
|
51
101
|
register(precheck: Precheck): void;
|
|
52
102
|
get(name: string): Precheck | undefined;
|
|
53
103
|
/** Every registered precheck, by name. */
|
|
54
104
|
list(): readonly Precheck[];
|
|
105
|
+
/** Lets agents attach scripts of their own, run by this runner; throws PluginError when one is set already. */
|
|
106
|
+
useScriptRunner(runner: PrecheckScriptRunner): void;
|
|
107
|
+
/** The runner of precheck scripts; undefined when no sandbox provides one, and then no script is accepted. */
|
|
108
|
+
scriptRunner(): PrecheckScriptRunner | undefined;
|
|
55
109
|
}
|
|
56
110
|
|
|
57
111
|
/** What came of a precheck that let the turn run, carried into the turn's text. */
|
|
@@ -71,7 +125,30 @@ export type PrecheckOutcome =
|
|
|
71
125
|
*/
|
|
72
126
|
export function memoryPrecheckRegistry(): PrecheckRegistry {
|
|
73
127
|
const prechecks = new Map<string, Precheck>();
|
|
128
|
+
let runner: PrecheckScriptRunner | undefined;
|
|
74
129
|
return {
|
|
130
|
+
useScriptRunner(given) {
|
|
131
|
+
if (runner)
|
|
132
|
+
throw new PluginError(
|
|
133
|
+
"a precheck script runner is set already; register one sandbox to run scripts.",
|
|
134
|
+
);
|
|
135
|
+
if (
|
|
136
|
+
typeof given?.run !== "function" ||
|
|
137
|
+
typeof given.describe !== "function"
|
|
138
|
+
)
|
|
139
|
+
throw new PluginError(
|
|
140
|
+
"a precheck script runner needs run and describe functions.",
|
|
141
|
+
);
|
|
142
|
+
if (
|
|
143
|
+
given.timeoutMs !== undefined &&
|
|
144
|
+
!(Number.isInteger(given.timeoutMs) && given.timeoutMs > 0)
|
|
145
|
+
)
|
|
146
|
+
throw new PluginError(
|
|
147
|
+
`a precheck script runner's timeoutMs must be a positive whole number of milliseconds; got ${String(given.timeoutMs)}.`,
|
|
148
|
+
);
|
|
149
|
+
runner = given;
|
|
150
|
+
},
|
|
151
|
+
scriptRunner: () => runner,
|
|
75
152
|
register(precheck) {
|
|
76
153
|
const { name, description, timeoutMs, run } = precheck ?? {};
|
|
77
154
|
if (typeof name !== "string" || !NAME.test(name))
|
|
@@ -103,6 +180,47 @@ export function memoryPrecheckRegistry(): PrecheckRegistry {
|
|
|
103
180
|
};
|
|
104
181
|
}
|
|
105
182
|
|
|
183
|
+
/**
|
|
184
|
+
* Checks a precheck script without running it: a JavaScript module within the size limit whose
|
|
185
|
+
* default export is what the runner calls. Throws ScheduleError with what to fix.
|
|
186
|
+
*/
|
|
187
|
+
export function checkPrecheckScript(script: unknown): string {
|
|
188
|
+
if (typeof script !== "string" || !script.trim())
|
|
189
|
+
throw new ScheduleError(
|
|
190
|
+
"precheck_script must be a JavaScript module with a default export",
|
|
191
|
+
);
|
|
192
|
+
if (script.length > PRECHECK_SCRIPT_CHARS)
|
|
193
|
+
throw new ScheduleError(
|
|
194
|
+
`precheck_script is ${script.length} characters; keep it within ${PRECHECK_SCRIPT_CHARS}`,
|
|
195
|
+
);
|
|
196
|
+
let program: ReturnType<typeof parse>["program"];
|
|
197
|
+
try {
|
|
198
|
+
program = parse(script, {
|
|
199
|
+
sourceType: "module",
|
|
200
|
+
errorRecovery: false,
|
|
201
|
+
}).program;
|
|
202
|
+
} catch (error) {
|
|
203
|
+
throw new ScheduleError(
|
|
204
|
+
`precheck_script does not parse as a JavaScript module: ${errorText(error)}`,
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
const exportsDefault = program.body.some(
|
|
208
|
+
(node) =>
|
|
209
|
+
node.type === "ExportDefaultDeclaration" ||
|
|
210
|
+
(node.type === "ExportNamedDeclaration" &&
|
|
211
|
+
node.specifiers.some((specifier) =>
|
|
212
|
+
specifier.exported.type === "Identifier"
|
|
213
|
+
? specifier.exported.name === "default"
|
|
214
|
+
: specifier.exported.value === "default",
|
|
215
|
+
)),
|
|
216
|
+
);
|
|
217
|
+
if (!exportsDefault)
|
|
218
|
+
throw new ScheduleError(
|
|
219
|
+
"precheck_script needs a default export: export default async (context) => ({ wake: false, note }) or ({ wake: true, context })",
|
|
220
|
+
);
|
|
221
|
+
return script;
|
|
222
|
+
}
|
|
223
|
+
|
|
106
224
|
function errorText(error: unknown): string {
|
|
107
225
|
return error instanceof Error ? error.message || error.name : String(error);
|
|
108
226
|
}
|
|
@@ -128,10 +246,17 @@ function decided(result: unknown): PrecheckOutcome {
|
|
|
128
246
|
export async function runPrecheck(
|
|
129
247
|
precheck: Precheck,
|
|
130
248
|
context: Omit<PrecheckContext, "signal">,
|
|
249
|
+
options: {
|
|
250
|
+
/** Aborts the run early, such as when the host stops; it then fails with `stopped`. */
|
|
251
|
+
signal?: AbortSignal;
|
|
252
|
+
/** Receives the run itself, which may outlast the answer after a timeout, so a caller can await its cleanup. */
|
|
253
|
+
settled?: (run: Promise<unknown>) => void;
|
|
254
|
+
} = {},
|
|
131
255
|
): Promise<PrecheckOutcome> {
|
|
132
256
|
const timeoutMs = precheck.timeoutMs ?? PRECHECK_TIMEOUT_MS;
|
|
133
257
|
const abort = new AbortController();
|
|
134
258
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
259
|
+
let stop: (() => void) | undefined;
|
|
135
260
|
const timeout = new Promise<PrecheckOutcome>((resolve) => {
|
|
136
261
|
timer = setTimeout(() => {
|
|
137
262
|
abort.abort();
|
|
@@ -140,6 +265,12 @@ export async function runPrecheck(
|
|
|
140
265
|
error: `it did not answer within ${timeoutMs / 1000} seconds`,
|
|
141
266
|
});
|
|
142
267
|
}, timeoutMs);
|
|
268
|
+
stop = () => {
|
|
269
|
+
abort.abort();
|
|
270
|
+
resolve({ kind: "failed", error: "stopped: the host is shutting down" });
|
|
271
|
+
};
|
|
272
|
+
if (options.signal?.aborted) stop();
|
|
273
|
+
else options.signal?.addEventListener("abort", stop, { once: true });
|
|
143
274
|
});
|
|
144
275
|
const answer = (async (): Promise<PrecheckOutcome> => {
|
|
145
276
|
try {
|
|
@@ -148,9 +279,11 @@ export async function runPrecheck(
|
|
|
148
279
|
return { kind: "failed", error: `it threw: ${errorText(error)}` };
|
|
149
280
|
}
|
|
150
281
|
})();
|
|
282
|
+
options.settled?.(answer);
|
|
151
283
|
try {
|
|
152
284
|
return await Promise.race([answer, timeout]);
|
|
153
285
|
} finally {
|
|
154
286
|
clearTimeout(timer);
|
|
287
|
+
if (stop) options.signal?.removeEventListener("abort", stop);
|
|
155
288
|
}
|
|
156
289
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { SQL } from "bun";
|
|
2
2
|
import type { Migration } from "../../db/migrations.ts";
|
|
3
3
|
import type { ChannelKey } from "../../domain/conversation.ts";
|
|
4
|
+
import { ScheduleError } from "../../domain/errors.ts";
|
|
4
5
|
import type { ScheduleStore } from "../../services.ts";
|
|
5
6
|
import type { Tier } from "../../speakers.ts";
|
|
6
7
|
import type { Recurrence } from "./recurrence.ts";
|
|
@@ -21,6 +22,8 @@ export interface Schedule {
|
|
|
21
22
|
createdAt: Date;
|
|
22
23
|
/** The name of the precheck the host runs before its turn; absent, the turn always runs. */
|
|
23
24
|
precheck?: string;
|
|
25
|
+
/** The precheck script an agent wrote for it, run in the host's sandbox; a schedule has a name or a script, not both. */
|
|
26
|
+
precheckScript?: string;
|
|
24
27
|
lastRun?: Date;
|
|
25
28
|
lastStatus?: string;
|
|
26
29
|
}
|
|
@@ -37,6 +40,8 @@ export interface NewSchedule {
|
|
|
37
40
|
createdTier: Tier;
|
|
38
41
|
/** A registered precheck's name, run before each turn. */
|
|
39
42
|
precheck?: string;
|
|
43
|
+
/** A precheck script, run in the host's sandbox before each turn; not with `precheck`. */
|
|
44
|
+
precheckScript?: string;
|
|
40
45
|
}
|
|
41
46
|
|
|
42
47
|
export interface ScheduleChange {
|
|
@@ -46,6 +51,8 @@ export interface ScheduleChange {
|
|
|
46
51
|
nextRun?: Date;
|
|
47
52
|
/** A registered precheck's name to run before each turn; null removes the schedule's precheck. */
|
|
48
53
|
precheck?: string | null;
|
|
54
|
+
/** A precheck script to run before each turn; null removes it. */
|
|
55
|
+
precheckScript?: string | null;
|
|
49
56
|
}
|
|
50
57
|
|
|
51
58
|
interface Row {
|
|
@@ -62,6 +69,7 @@ interface Row {
|
|
|
62
69
|
created_tier: Tier;
|
|
63
70
|
created_at: Date;
|
|
64
71
|
precheck: string | null;
|
|
72
|
+
precheck_script: string | null;
|
|
65
73
|
last_run: Date | null;
|
|
66
74
|
last_status: string | null;
|
|
67
75
|
}
|
|
@@ -81,6 +89,7 @@ function toSchedule(row: Row): Schedule {
|
|
|
81
89
|
createdTier: row.created_tier,
|
|
82
90
|
createdAt: row.created_at,
|
|
83
91
|
...(row.precheck ? { precheck: row.precheck } : {}),
|
|
92
|
+
...(row.precheck_script ? { precheckScript: row.precheck_script } : {}),
|
|
84
93
|
...(row.last_run ? { lastRun: row.last_run } : {}),
|
|
85
94
|
...(row.last_status ? { lastStatus: row.last_status } : {}),
|
|
86
95
|
};
|
|
@@ -120,7 +129,7 @@ export class PgScheduleStore implements ScheduleStore {
|
|
|
120
129
|
},
|
|
121
130
|
};
|
|
122
131
|
|
|
123
|
-
/** The table, then the precheck each schedule may name; schedules made before prechecks have none. */
|
|
132
|
+
/** The table, then the precheck each schedule may name or carry; schedules made before prechecks have none. */
|
|
124
133
|
static migrations(): Migration[] {
|
|
125
134
|
return [
|
|
126
135
|
PgScheduleStore.migration,
|
|
@@ -130,6 +139,12 @@ export class PgScheduleStore implements ScheduleStore {
|
|
|
130
139
|
await sql`ALTER TABLE schedules ADD COLUMN IF NOT EXISTS precheck text`;
|
|
131
140
|
},
|
|
132
141
|
},
|
|
142
|
+
{
|
|
143
|
+
name: "schedules-precheck-script",
|
|
144
|
+
up: async (sql) => {
|
|
145
|
+
await sql`ALTER TABLE schedules ADD COLUMN IF NOT EXISTS precheck_script text`;
|
|
146
|
+
},
|
|
147
|
+
},
|
|
133
148
|
];
|
|
134
149
|
}
|
|
135
150
|
|
|
@@ -139,13 +154,17 @@ export class PgScheduleStore implements ScheduleStore {
|
|
|
139
154
|
}
|
|
140
155
|
|
|
141
156
|
async create(schedule: NewSchedule): Promise<Schedule> {
|
|
157
|
+
if (schedule.precheck && schedule.precheckScript)
|
|
158
|
+
throw new ScheduleError(
|
|
159
|
+
"a schedule has a precheck or a precheck script, not both",
|
|
160
|
+
);
|
|
142
161
|
const rows: Row[] = await this.#sql`
|
|
143
162
|
INSERT INTO schedules (channel_key, mode, title, prompt, recurrence, next_run,
|
|
144
|
-
created_by_id, created_by_name, created_tier, precheck)
|
|
163
|
+
created_by_id, created_by_name, created_tier, precheck, precheck_script)
|
|
145
164
|
VALUES (${schedule.channel}, ${schedule.target}, ${schedule.title}, ${schedule.prompt},
|
|
146
165
|
${JSON.stringify(schedule.recurrence)}, ${schedule.nextRun},
|
|
147
166
|
${schedule.createdById}, ${schedule.createdByName}, ${schedule.createdTier},
|
|
148
|
-
${schedule.precheck ?? null})
|
|
167
|
+
${schedule.precheck ?? null}, ${schedule.precheckScript ?? null})
|
|
149
168
|
RETURNING *`;
|
|
150
169
|
const [row] = rows;
|
|
151
170
|
if (!row) throw new Error("the schedule insert returned no row");
|
|
@@ -178,13 +197,29 @@ export class PgScheduleStore implements ScheduleStore {
|
|
|
178
197
|
): Promise<Schedule | undefined> {
|
|
179
198
|
const current = await this.get(id);
|
|
180
199
|
if (!current || current.channel !== channel) return undefined;
|
|
200
|
+
if (change.precheck && change.precheckScript)
|
|
201
|
+
throw new ScheduleError(
|
|
202
|
+
"a schedule has a precheck or a precheck script, not both",
|
|
203
|
+
);
|
|
204
|
+
// Setting one removes the other, so the scheduler never has to pick.
|
|
205
|
+
const precheck = change.precheckScript
|
|
206
|
+
? null
|
|
207
|
+
: change.precheck === undefined
|
|
208
|
+
? (current.precheck ?? null)
|
|
209
|
+
: change.precheck;
|
|
210
|
+
const precheckScript = change.precheck
|
|
211
|
+
? null
|
|
212
|
+
: change.precheckScript === undefined
|
|
213
|
+
? (current.precheckScript ?? null)
|
|
214
|
+
: change.precheckScript;
|
|
181
215
|
const rows: Row[] = await this.#sql`
|
|
182
216
|
UPDATE schedules SET
|
|
183
217
|
title = ${change.title ?? current.title},
|
|
184
218
|
prompt = ${change.prompt ?? current.prompt},
|
|
185
219
|
recurrence = ${JSON.stringify(change.recurrence ?? current.recurrence)},
|
|
186
220
|
next_run = ${change.nextRun ?? current.nextRun},
|
|
187
|
-
precheck = ${
|
|
221
|
+
precheck = ${precheck},
|
|
222
|
+
precheck_script = ${precheckScript}
|
|
188
223
|
WHERE id = ${id}
|
|
189
224
|
RETURNING *`;
|
|
190
225
|
return rows[0] ? toSchedule(rows[0]) : undefined;
|
|
@@ -6,7 +6,12 @@ import type { ScheduleStore } from "../../services.ts";
|
|
|
6
6
|
import type { ScheduleToolName } from "../../shared/schedule-tools.ts";
|
|
7
7
|
import { type Tier, tierAtLeast } from "../../speakers.ts";
|
|
8
8
|
import { timeZone, zonedStamp } from "../../time.ts";
|
|
9
|
-
import
|
|
9
|
+
import {
|
|
10
|
+
checkPrecheckScript,
|
|
11
|
+
PRECHECK_SCRIPT_CHARS,
|
|
12
|
+
type PrecheckFinding,
|
|
13
|
+
type PrecheckRegistry,
|
|
14
|
+
} from "./prechecks.ts";
|
|
10
15
|
import {
|
|
11
16
|
describeRecurrence,
|
|
12
17
|
nextRun,
|
|
@@ -18,6 +23,8 @@ import type { Schedule } from "./schedule-store.ts";
|
|
|
18
23
|
|
|
19
24
|
const TITLE_CHARS = 80;
|
|
20
25
|
const LIST_PROMPT_PREVIEW = 200;
|
|
26
|
+
/** How long schedule_list waits for the script runner to say what a script may call. */
|
|
27
|
+
const DESCRIBE_TIMEOUT_MS = 10_000;
|
|
21
28
|
const DAY_MS = 86_400_000;
|
|
22
29
|
|
|
23
30
|
export interface ScheduleToolContext {
|
|
@@ -31,8 +38,12 @@ export interface ScheduleToolContext {
|
|
|
31
38
|
/** Who asked, recorded on created schedules; a scheduled run speaks for its creator. */
|
|
32
39
|
author: { id: string; name: string; tier?: Tier };
|
|
33
40
|
now: Date;
|
|
34
|
-
/**
|
|
35
|
-
|
|
41
|
+
/**
|
|
42
|
+
* The host's prechecks a schedule may name, and the runner of the scripts it may carry instead;
|
|
43
|
+
* without them, none can be attached.
|
|
44
|
+
*/
|
|
45
|
+
prechecks?: Pick<PrecheckRegistry, "get" | "list"> &
|
|
46
|
+
Partial<Pick<PrecheckRegistry, "scriptRunner">>;
|
|
36
47
|
}
|
|
37
48
|
|
|
38
49
|
/** The limits of the context's target; a target without them may not schedule. */
|
|
@@ -84,11 +95,57 @@ function precheckName(ctx: ScheduleToolContext, value: unknown): string {
|
|
|
84
95
|
return name;
|
|
85
96
|
}
|
|
86
97
|
|
|
87
|
-
/**
|
|
88
|
-
function
|
|
98
|
+
/** A precheck script the host's sandbox runs; refused when the host has no runner for scripts. */
|
|
99
|
+
function precheckScript(ctx: ScheduleToolContext, value: unknown): string {
|
|
100
|
+
if (!ctx.prechecks?.scriptRunner?.()) {
|
|
101
|
+
const names = (ctx.prechecks?.list() ?? []).map((p) => p.name);
|
|
102
|
+
throw new ScheduleError(
|
|
103
|
+
`this host runs no precheck scripts; ${names.length ? `attach a registered precheck instead: ${names.join(", ")}` : "it registers no prechecks either"}`,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
return checkPrecheckScript(value);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The prechecks a schedule may name or write, for schedule_list; empty when the host offers neither. */
|
|
110
|
+
async function precheckCatalog(ctx: ScheduleToolContext): Promise<string> {
|
|
89
111
|
const all = ctx.prechecks?.list() ?? [];
|
|
90
|
-
|
|
91
|
-
|
|
112
|
+
const runner = ctx.prechecks?.scriptRunner?.();
|
|
113
|
+
const named =
|
|
114
|
+
all.length === 0
|
|
115
|
+
? ""
|
|
116
|
+
: `\n\nPrechecks you can attach with precheck on schedule_create or schedule_update; the host runs one before each turn and wakes you only when it finds something:\n${all.map((p) => `- ${p.name}: ${p.description.replace(/\n/g, " ")}`).join("\n")}`;
|
|
117
|
+
if (!runner) return named;
|
|
118
|
+
// The list still answers when the host cannot say, or is slow to say, what a script may reach here.
|
|
119
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
120
|
+
const late = new Promise<string>((resolve) => {
|
|
121
|
+
timer = setTimeout(
|
|
122
|
+
() => resolve("it did not answer in time"),
|
|
123
|
+
DESCRIBE_TIMEOUT_MS,
|
|
124
|
+
);
|
|
125
|
+
});
|
|
126
|
+
const described = Promise.resolve()
|
|
127
|
+
.then(() =>
|
|
128
|
+
runner.describe({
|
|
129
|
+
channel: ctx.channel,
|
|
130
|
+
target: ctx.target.name,
|
|
131
|
+
...(ctx.author.tier ? { tier: ctx.author.tier } : {}),
|
|
132
|
+
}),
|
|
133
|
+
)
|
|
134
|
+
.then(
|
|
135
|
+
(text) => ({ text }),
|
|
136
|
+
(error: unknown) => ({
|
|
137
|
+
error: error instanceof Error ? error.message : String(error),
|
|
138
|
+
}),
|
|
139
|
+
);
|
|
140
|
+
const answer = await Promise.race([
|
|
141
|
+
described,
|
|
142
|
+
late.then((error) => ({ error })),
|
|
143
|
+
]).finally(() => clearTimeout(timer));
|
|
144
|
+
const guide =
|
|
145
|
+
"text" in answer
|
|
146
|
+
? answer.text
|
|
147
|
+
: `The host could not say what a script may call here (${answer.error}); a script set now may fail when it runs.`;
|
|
148
|
+
return `${named}\n\nYou can write a precheck of your own instead, with precheck_script on schedule_create or schedule_update (null removes it): a JavaScript module, at most ${PRECHECK_SCRIPT_CHARS} characters, that the host runs in a sandbox before each turn. A schedule has a precheck or a precheck_script, not both.\n${guide}`;
|
|
92
149
|
}
|
|
93
150
|
|
|
94
151
|
function hasTiming(input: Input): boolean {
|
|
@@ -121,7 +178,11 @@ function line(schedule: Schedule): string {
|
|
|
121
178
|
schedule.prompt.length > LIST_PROMPT_PREVIEW
|
|
122
179
|
? `${schedule.prompt.slice(0, LIST_PROMPT_PREVIEW)}…`
|
|
123
180
|
: schedule.prompt;
|
|
124
|
-
const precheck = schedule.precheck
|
|
181
|
+
const precheck = schedule.precheck
|
|
182
|
+
? `; precheck ${schedule.precheck}`
|
|
183
|
+
: schedule.precheckScript
|
|
184
|
+
? `; precheck script (${schedule.precheckScript.length} characters)`
|
|
185
|
+
: "";
|
|
125
186
|
const last = schedule.lastRun
|
|
126
187
|
? `; last run ${zonedStamp(schedule.lastRun)} (${schedule.lastStatus ?? "?"})`
|
|
127
188
|
: "";
|
|
@@ -159,10 +220,16 @@ export async function callScheduleTool(
|
|
|
159
220
|
const title = text(input, "title", TITLE_CHARS);
|
|
160
221
|
const prompt = text(input, "prompt", limits.promptChars);
|
|
161
222
|
const [recurrence, next] = timing(ctx, input);
|
|
162
|
-
const
|
|
163
|
-
input
|
|
164
|
-
|
|
165
|
-
|
|
223
|
+
const given = (name: string) =>
|
|
224
|
+
input[name] !== undefined && input[name] !== null;
|
|
225
|
+
if (given("precheck") && given("precheck_script"))
|
|
226
|
+
throw new ScheduleError("give precheck or precheck_script, not both");
|
|
227
|
+
const precheck = given("precheck")
|
|
228
|
+
? precheckName(ctx, input.precheck)
|
|
229
|
+
: undefined;
|
|
230
|
+
const script = given("precheck_script")
|
|
231
|
+
? precheckScript(ctx, input.precheck_script)
|
|
232
|
+
: undefined;
|
|
166
233
|
const existing = await ctx.store.forChannel(ctx.channel);
|
|
167
234
|
if (existing.length >= limits.perChannel)
|
|
168
235
|
throw new ScheduleError(
|
|
@@ -179,21 +246,29 @@ export async function callScheduleTool(
|
|
|
179
246
|
createdByName: ctx.author.name,
|
|
180
247
|
createdTier: ctx.author.tier ?? "owner",
|
|
181
248
|
...(precheck ? { precheck } : {}),
|
|
249
|
+
...(script ? { precheckScript: script } : {}),
|
|
182
250
|
});
|
|
183
|
-
const checked = precheck
|
|
251
|
+
const checked = precheck
|
|
252
|
+
? `; precheck ${precheck} runs first`
|
|
253
|
+
: script
|
|
254
|
+
? "; its precheck script runs first"
|
|
255
|
+
: "";
|
|
184
256
|
return `Scheduled #${created.id} "${title}": ${describeRecurrence(recurrence)}, first run ${zonedStamp(next)} ${messages().zoneTime(timeZone())}${checked}.`;
|
|
185
257
|
}
|
|
186
258
|
case "schedule_list": {
|
|
187
259
|
if (input.id !== undefined) {
|
|
188
260
|
const schedule = await own(ctx, input);
|
|
189
|
-
|
|
261
|
+
const script = schedule.precheckScript
|
|
262
|
+
? `\n\nPrecheck script:\n${schedule.precheckScript}`
|
|
263
|
+
: "";
|
|
264
|
+
return `${line(schedule).split("\n")[0]}\n\nPrompt:\n${schedule.prompt}${script}`;
|
|
190
265
|
}
|
|
191
266
|
const all = await ctx.store.forChannel(ctx.channel);
|
|
192
267
|
const listed =
|
|
193
268
|
all.length === 0
|
|
194
269
|
? "This channel has no schedules."
|
|
195
270
|
: `It is ${zonedStamp(ctx.now)} in ${messages().zoneName(timeZone())}.\n${all.map(line).join("\n")}`;
|
|
196
|
-
return `${listed}${precheckCatalog(ctx)}`;
|
|
271
|
+
return `${listed}${await precheckCatalog(ctx)}`;
|
|
197
272
|
}
|
|
198
273
|
case "schedule_update": {
|
|
199
274
|
const schedule = changeable(ctx, await own(ctx, input));
|
|
@@ -207,19 +282,35 @@ export async function callScheduleTool(
|
|
|
207
282
|
change.recurrence = recurrence;
|
|
208
283
|
change.nextRun = next;
|
|
209
284
|
}
|
|
210
|
-
// null removes
|
|
285
|
+
// null removes one; a name must be registered, and a script replaces a name and back.
|
|
286
|
+
if (
|
|
287
|
+
input.precheck !== undefined &&
|
|
288
|
+
input.precheck !== null &&
|
|
289
|
+
input.precheck_script !== undefined &&
|
|
290
|
+
input.precheck_script !== null
|
|
291
|
+
)
|
|
292
|
+
throw new ScheduleError("give precheck or precheck_script, not both");
|
|
211
293
|
if (input.precheck !== undefined)
|
|
212
294
|
change.precheck =
|
|
213
295
|
input.precheck === null ? null : precheckName(ctx, input.precheck);
|
|
296
|
+
if (input.precheck_script !== undefined)
|
|
297
|
+
change.precheckScript =
|
|
298
|
+
input.precheck_script === null
|
|
299
|
+
? null
|
|
300
|
+
: precheckScript(ctx, input.precheck_script);
|
|
301
|
+
if (change.precheck) change.precheckScript = null;
|
|
302
|
+
if (change.precheckScript) change.precheck = null;
|
|
214
303
|
if (Object.keys(change).length === 0)
|
|
215
304
|
throw new ScheduleError(
|
|
216
|
-
"give a title, prompt, timing, or
|
|
305
|
+
"give a title, prompt, timing, precheck, or precheck_script to change",
|
|
217
306
|
);
|
|
218
307
|
const updated = await ctx.store.update(ctx.channel, schedule.id, change);
|
|
219
308
|
if (!updated) throw new ScheduleError(`schedule #${schedule.id} is gone`);
|
|
220
309
|
const checked = updated.precheck
|
|
221
310
|
? `; precheck ${updated.precheck} runs first`
|
|
222
|
-
:
|
|
311
|
+
: updated.precheckScript
|
|
312
|
+
? "; its precheck script runs first"
|
|
313
|
+
: "";
|
|
223
314
|
return `Updated #${updated.id} "${updated.title}": ${describeRecurrence(updated.recurrence)}, next run ${zonedStamp(updated.nextRun)}${checked}.`;
|
|
224
315
|
}
|
|
225
316
|
case "schedule_cancel": {
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import type { ScheduledOutcome } from "../../contract/channels.ts";
|
|
2
2
|
import type { Logger } from "../../log.ts";
|
|
3
3
|
import type { ScheduleStore } from "../../services.ts";
|
|
4
|
+
import { timeZone, zonedStamp } from "../../time.ts";
|
|
4
5
|
import {
|
|
6
|
+
type Precheck,
|
|
5
7
|
type PrecheckFinding,
|
|
6
8
|
type PrecheckOutcome,
|
|
7
9
|
type PrecheckRegistry,
|
|
8
10
|
runPrecheck,
|
|
11
|
+
SCRIPT_PRECHECK,
|
|
9
12
|
} from "./prechecks.ts";
|
|
10
13
|
import { nextRun } from "./recurrence.ts";
|
|
11
14
|
import type { Schedule } from "./schedule-store.ts";
|
|
@@ -21,14 +24,18 @@ export interface ScheduledRunner {
|
|
|
21
24
|
): Promise<ScheduledOutcome>;
|
|
22
25
|
}
|
|
23
26
|
|
|
27
|
+
/** How long stop waits for running prechecks to clean up after aborting them. */
|
|
28
|
+
export const PRECHECK_STOP_MS = 15_000;
|
|
29
|
+
|
|
24
30
|
/** A run found this late, for example after the service was down, is skipped instead of run. */
|
|
25
31
|
export const LATE_LIMIT_MS = 12 * 3_600_000;
|
|
26
32
|
|
|
27
33
|
export interface SchedulerOptions {
|
|
28
34
|
store: Pick<ScheduleStore, "due" | "claim" | "recordStatus">;
|
|
29
35
|
runner: ScheduledRunner;
|
|
30
|
-
/** The host's prechecks; without them, a schedule that
|
|
31
|
-
prechecks?: Pick<PrecheckRegistry, "get"
|
|
36
|
+
/** The host's prechecks and script runner; without them, a schedule that has one runs with that as its error. */
|
|
37
|
+
prechecks?: Pick<PrecheckRegistry, "get"> &
|
|
38
|
+
Partial<Pick<PrecheckRegistry, "scriptRunner">>;
|
|
32
39
|
/** Posts a skipping precheck's note in the schedule's channel as the bot's own message, which starts no turn. */
|
|
33
40
|
notify?: (schedule: Schedule, note: string) => Promise<void>;
|
|
34
41
|
logger: Logger;
|
|
@@ -67,6 +74,9 @@ function precheckPrefix(finding: PrecheckFinding | undefined): string {
|
|
|
67
74
|
export class Scheduler {
|
|
68
75
|
readonly #options: SchedulerOptions;
|
|
69
76
|
readonly #running = new Set<Promise<void>>();
|
|
77
|
+
/** Prechecks in flight, including their cleanup after a timeout, which stop waits for. */
|
|
78
|
+
readonly #prechecks = new Set<Promise<unknown>>();
|
|
79
|
+
readonly #stopping = new AbortController();
|
|
70
80
|
#timer: ReturnType<typeof setInterval> | undefined;
|
|
71
81
|
#ticking = false;
|
|
72
82
|
|
|
@@ -82,13 +92,33 @@ export class Scheduler {
|
|
|
82
92
|
void this.tick();
|
|
83
93
|
}
|
|
84
94
|
|
|
85
|
-
|
|
95
|
+
/**
|
|
96
|
+
* Stops checking, aborts running prechecks, and waits up to PRECHECK_STOP_MS for them to clean
|
|
97
|
+
* up, so a sandboxed script never outlives the host. A turn already running is left to the
|
|
98
|
+
* host's drain; no precheck that ends now starts one.
|
|
99
|
+
*/
|
|
100
|
+
async stop(): Promise<void> {
|
|
86
101
|
clearInterval(this.#timer);
|
|
102
|
+
this.#stopping.abort();
|
|
103
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
104
|
+
const limit = new Promise<"late">((resolve) => {
|
|
105
|
+
timer = setTimeout(() => resolve("late"), PRECHECK_STOP_MS);
|
|
106
|
+
});
|
|
107
|
+
const settled = Promise.allSettled([...this.#prechecks]);
|
|
108
|
+
try {
|
|
109
|
+
if ((await Promise.race([settled, limit])) === "late")
|
|
110
|
+
this.#options.logger.warn(
|
|
111
|
+
{ prechecks: this.#prechecks.size },
|
|
112
|
+
"prechecks still cleaning up when the scheduler stopped",
|
|
113
|
+
);
|
|
114
|
+
} finally {
|
|
115
|
+
clearTimeout(timer);
|
|
116
|
+
}
|
|
87
117
|
}
|
|
88
118
|
|
|
89
119
|
/** Claims every due schedule and starts its run; runs continue after tick resolves. */
|
|
90
120
|
async tick(): Promise<void> {
|
|
91
|
-
if (this.#ticking) return;
|
|
121
|
+
if (this.#ticking || this.#stopping.signal.aborted) return;
|
|
92
122
|
this.#ticking = true;
|
|
93
123
|
const { store, logger } = this.#options;
|
|
94
124
|
try {
|
|
@@ -132,20 +162,26 @@ export class Scheduler {
|
|
|
132
162
|
async #fire(schedule: Schedule, firedAt: Date): Promise<void> {
|
|
133
163
|
const { runner, logger } = this.#options;
|
|
134
164
|
let finding: PrecheckFinding | undefined;
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
)
|
|
165
|
+
const name =
|
|
166
|
+
schedule.precheck ??
|
|
167
|
+
(schedule.precheckScript ? SCRIPT_PRECHECK : undefined);
|
|
168
|
+
if (name) {
|
|
169
|
+
const decision = await this.#precheck(schedule, name, firedAt);
|
|
170
|
+
if (this.#stopping.signal.aborted) {
|
|
171
|
+
await this.#record(
|
|
172
|
+
schedule,
|
|
173
|
+
"skipped: the host stopped during its precheck",
|
|
174
|
+
);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
141
177
|
if (decision.kind === "skip") {
|
|
142
178
|
await this.#skip(schedule, decision.note);
|
|
143
179
|
return;
|
|
144
180
|
}
|
|
145
181
|
finding =
|
|
146
182
|
decision.kind === "wake"
|
|
147
|
-
? { precheck:
|
|
148
|
-
: { precheck:
|
|
183
|
+
? { precheck: name, context: decision.context }
|
|
184
|
+
: { precheck: name, error: decision.error };
|
|
149
185
|
}
|
|
150
186
|
logger.info(
|
|
151
187
|
{
|
|
@@ -163,19 +199,63 @@ export class Scheduler {
|
|
|
163
199
|
);
|
|
164
200
|
}
|
|
165
201
|
|
|
202
|
+
/**
|
|
203
|
+
* The schedule's precheck: a registered one by name, or its script through the host's runner.
|
|
204
|
+
* A script never runs in this process; without a runner it fails, and the turn still runs.
|
|
205
|
+
*/
|
|
206
|
+
#resolve(schedule: Schedule, firedAt: Date): Precheck | string {
|
|
207
|
+
const { prechecks } = this.#options;
|
|
208
|
+
if (schedule.precheck)
|
|
209
|
+
return (
|
|
210
|
+
prechecks?.get(schedule.precheck) ??
|
|
211
|
+
"no precheck of that name is registered"
|
|
212
|
+
);
|
|
213
|
+
const script = schedule.precheckScript ?? "";
|
|
214
|
+
const runner = prechecks?.scriptRunner?.();
|
|
215
|
+
if (!runner)
|
|
216
|
+
return "this host has no precheck script runner, so the script did not run";
|
|
217
|
+
const zone = timeZone();
|
|
218
|
+
return {
|
|
219
|
+
name: SCRIPT_PRECHECK,
|
|
220
|
+
description: "the schedule's own precheck script",
|
|
221
|
+
...(runner.timeoutMs === undefined
|
|
222
|
+
? {}
|
|
223
|
+
: { timeoutMs: runner.timeoutMs }),
|
|
224
|
+
run: (context) =>
|
|
225
|
+
runner.run(script, {
|
|
226
|
+
...context,
|
|
227
|
+
timeZone: zone,
|
|
228
|
+
today: zonedStamp(firedAt).slice(0, 10),
|
|
229
|
+
}),
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
166
233
|
/** Runs the schedule's precheck; a missing one fails like a throw, so the turn still runs. */
|
|
167
234
|
async #precheck(
|
|
168
235
|
schedule: Schedule,
|
|
169
236
|
name: string,
|
|
170
237
|
firedAt: Date,
|
|
171
238
|
): Promise<PrecheckOutcome> {
|
|
172
|
-
const {
|
|
239
|
+
const { logger } = this.#options;
|
|
173
240
|
let decision: PrecheckOutcome;
|
|
174
241
|
try {
|
|
175
|
-
const precheck =
|
|
176
|
-
decision =
|
|
177
|
-
|
|
178
|
-
|
|
242
|
+
const precheck = this.#resolve(schedule, firedAt);
|
|
243
|
+
decision =
|
|
244
|
+
typeof precheck === "string"
|
|
245
|
+
? { kind: "failed", error: precheck }
|
|
246
|
+
: await runPrecheck(
|
|
247
|
+
precheck,
|
|
248
|
+
{ schedule, firedAt },
|
|
249
|
+
{
|
|
250
|
+
signal: this.#stopping.signal,
|
|
251
|
+
settled: (run) => {
|
|
252
|
+
const tracked = run
|
|
253
|
+
.catch(() => undefined)
|
|
254
|
+
.finally(() => this.#prechecks.delete(tracked));
|
|
255
|
+
this.#prechecks.add(tracked);
|
|
256
|
+
},
|
|
257
|
+
},
|
|
258
|
+
);
|
|
179
259
|
} catch (error) {
|
|
180
260
|
decision = {
|
|
181
261
|
kind: "failed",
|
|
@@ -27,8 +27,9 @@ export interface OwnerSchedules {
|
|
|
27
27
|
* messages for a conversation no chat surface carries, where a run could not be posted.
|
|
28
28
|
*/
|
|
29
29
|
channelFor: (channel: ChannelKey) => Promise<ChannelKey>;
|
|
30
|
-
/** The host's prechecks a schedule may name; without them, none can be attached. */
|
|
31
|
-
prechecks?: Pick<PrecheckRegistry, "get" | "list"
|
|
30
|
+
/** The host's prechecks a schedule may name, and the runner of scripts it may carry; without them, none can be attached. */
|
|
31
|
+
prechecks?: Pick<PrecheckRegistry, "get" | "list"> &
|
|
32
|
+
Partial<Pick<PrecheckRegistry, "scriptRunner">>;
|
|
32
33
|
}
|
|
33
34
|
|
|
34
35
|
/** Finds another agent's channel, for reading its schedules; throws ScheduleError when there is none. */
|
|
@@ -65,6 +66,7 @@ export function schedulesExtension(
|
|
|
65
66
|
const defs = scheduleToolSpecs({
|
|
66
67
|
locale: activeLocale(),
|
|
67
68
|
timeZone: timeZone(),
|
|
69
|
+
precheckScripts: prechecks?.scriptRunner?.() !== undefined,
|
|
68
70
|
}).map((base) => {
|
|
69
71
|
const spec = agents ? withAgentOption(base) : base;
|
|
70
72
|
return {
|
|
@@ -74,12 +74,33 @@ export interface ScheduleToolWording {
|
|
|
74
74
|
locale: Locale;
|
|
75
75
|
/** An IANA time zone, such as `Asia/Taipei`. */
|
|
76
76
|
timeZone: string;
|
|
77
|
+
/**
|
|
78
|
+
* Whether the host runs agents' precheck scripts, so schedule_create and schedule_update take
|
|
79
|
+
* `precheck_script`; without it they neither take nor mention one.
|
|
80
|
+
*/
|
|
81
|
+
precheckScripts?: boolean;
|
|
77
82
|
}
|
|
78
83
|
|
|
79
84
|
export function scheduleToolSpecs(
|
|
80
85
|
wording: ScheduleToolWording,
|
|
81
86
|
): readonly ScheduleToolSpec[] {
|
|
82
87
|
const zone = catalogFor(wording.locale).zoneTime(wording.timeZone);
|
|
88
|
+
const script = (cleared: boolean): Record<string, TSchema> =>
|
|
89
|
+
wording.precheckScripts
|
|
90
|
+
? {
|
|
91
|
+
precheck_script: Type.Optional(
|
|
92
|
+
cleared
|
|
93
|
+
? Type.Union([Type.String(), Type.Null()], {
|
|
94
|
+
description:
|
|
95
|
+
"A JavaScript module the host runs in a sandbox before each turn, as schedule_list explains; it replaces precheck. null removes it.",
|
|
96
|
+
})
|
|
97
|
+
: Type.String({
|
|
98
|
+
description:
|
|
99
|
+
"A JavaScript module the host runs in a sandbox before each turn, as schedule_list explains; instead of precheck.",
|
|
100
|
+
}),
|
|
101
|
+
),
|
|
102
|
+
}
|
|
103
|
+
: {};
|
|
83
104
|
return [
|
|
84
105
|
{
|
|
85
106
|
name: "schedule_create",
|
|
@@ -97,6 +118,7 @@ export function scheduleToolSpecs(
|
|
|
97
118
|
"The name of a precheck the host runs first, from schedule_list; you are woken only when it finds something.",
|
|
98
119
|
}),
|
|
99
120
|
),
|
|
121
|
+
...script(false),
|
|
100
122
|
}),
|
|
101
123
|
},
|
|
102
124
|
{
|
|
@@ -125,6 +147,7 @@ export function scheduleToolSpecs(
|
|
|
125
147
|
"A precheck's name from schedule_list to run first, or null to remove the schedule's precheck.",
|
|
126
148
|
}),
|
|
127
149
|
),
|
|
150
|
+
...script(true),
|
|
128
151
|
}),
|
|
129
152
|
},
|
|
130
153
|
{
|
|
@@ -4,6 +4,9 @@ import {
|
|
|
4
4
|
type PrecheckContext,
|
|
5
5
|
type PrecheckRegistry,
|
|
6
6
|
type PrecheckResult,
|
|
7
|
+
type PrecheckScope,
|
|
8
|
+
type PrecheckScriptContext,
|
|
9
|
+
type PrecheckScriptRunner,
|
|
7
10
|
} from "../modules/schedules/prechecks.ts";
|
|
8
11
|
|
|
9
12
|
/** What a fake precheck answers: a result, an Error it throws, or a function of its context. */
|
|
@@ -43,6 +46,47 @@ export function fakePrecheck(
|
|
|
43
46
|
};
|
|
44
47
|
}
|
|
45
48
|
|
|
49
|
+
/** What a fake script runner answers: a result, an Error it throws, or a function of the script and its context. */
|
|
50
|
+
export type FakeScriptAnswer =
|
|
51
|
+
| PrecheckResult
|
|
52
|
+
| Error
|
|
53
|
+
| ((
|
|
54
|
+
script: string,
|
|
55
|
+
context: PrecheckScriptContext,
|
|
56
|
+
) => PrecheckResult | Promise<PrecheckResult>);
|
|
57
|
+
|
|
58
|
+
/** A precheck script runner for tests, which records every script it was asked to run. */
|
|
59
|
+
export interface FakeScriptRunner extends PrecheckScriptRunner {
|
|
60
|
+
readonly calls: { script: string; context: PrecheckScriptContext }[];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* A script runner that runs nothing: it answers as the test says, for
|
|
65
|
+
* `registry.useScriptRunner(fakeScriptRunner({ wake: false }))`. `describe` defaults to a line
|
|
66
|
+
* naming it a test runner.
|
|
67
|
+
*/
|
|
68
|
+
export function fakeScriptRunner(
|
|
69
|
+
answer: FakeScriptAnswer,
|
|
70
|
+
options: {
|
|
71
|
+
describe?: (scope: PrecheckScope) => string;
|
|
72
|
+
timeoutMs?: number;
|
|
73
|
+
} = {},
|
|
74
|
+
): FakeScriptRunner {
|
|
75
|
+
const calls: FakeScriptRunner["calls"] = [];
|
|
76
|
+
return {
|
|
77
|
+
calls,
|
|
78
|
+
...(options.timeoutMs === undefined
|
|
79
|
+
? {}
|
|
80
|
+
: { timeoutMs: options.timeoutMs }),
|
|
81
|
+
describe: options.describe ?? (() => "A test script runner."),
|
|
82
|
+
run: async (script, context) => {
|
|
83
|
+
calls.push({ script, context });
|
|
84
|
+
if (answer instanceof Error) throw answer;
|
|
85
|
+
return typeof answer === "function" ? answer(script, context) : answer;
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
46
90
|
/**
|
|
47
91
|
* A real in-memory precheck registry with the given prechecks registered, for a test that builds
|
|
48
92
|
* a scheduler or schedule tools itself. It refuses what the host's refuses.
|
package/src/index.ts
CHANGED
|
@@ -132,8 +132,14 @@ export type {
|
|
|
132
132
|
PrecheckFinding,
|
|
133
133
|
PrecheckRegistry,
|
|
134
134
|
PrecheckResult,
|
|
135
|
+
PrecheckScope,
|
|
136
|
+
PrecheckScriptContext,
|
|
137
|
+
PrecheckScriptRunner,
|
|
138
|
+
} from "./core/modules/schedules/prechecks.ts";
|
|
139
|
+
export {
|
|
140
|
+
PRECHECK_SCRIPT_CHARS,
|
|
141
|
+
PRECHECK_TIMEOUT_MS,
|
|
135
142
|
} from "./core/modules/schedules/prechecks.ts";
|
|
136
|
-
export { PRECHECK_TIMEOUT_MS } from "./core/modules/schedules/prechecks.ts";
|
|
137
143
|
export type {
|
|
138
144
|
Recurrence,
|
|
139
145
|
Weekday,
|
package/src/testing.ts
CHANGED
|
@@ -86,8 +86,14 @@ export { partial } from "./core/testing/partial.ts";
|
|
|
86
86
|
export type {
|
|
87
87
|
FakePrecheck,
|
|
88
88
|
FakePrecheckAnswer,
|
|
89
|
+
FakeScriptAnswer,
|
|
90
|
+
FakeScriptRunner,
|
|
91
|
+
} from "./core/testing/prechecks.ts";
|
|
92
|
+
export {
|
|
93
|
+
fakePrecheck,
|
|
94
|
+
fakePrechecks,
|
|
95
|
+
fakeScriptRunner,
|
|
89
96
|
} from "./core/testing/prechecks.ts";
|
|
90
|
-
export { fakePrecheck, fakePrechecks } from "./core/testing/prechecks.ts";
|
|
91
97
|
export type {
|
|
92
98
|
RecordedLog,
|
|
93
99
|
RecordingLogger,
|