pi-roundtable 0.6.1 → 0.7.0
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/docs/plugins.md +108 -7
- package/examples/reply-files.test.ts +34 -0
- package/examples/reply-files.ts +27 -0
- package/package.json +1 -1
- package/src/cli/cli.ts +1 -1
- package/src/cli/templates.ts +5 -1
- package/src/core/agents/team-turns.ts +22 -12
- package/src/core/contract/surface.ts +2 -0
- package/src/core/define.ts +5 -0
- package/src/core/discord/discord-surface.ts +1 -0
- package/src/core/domain/conversation.ts +8 -2
- package/src/core/reply-files.ts +107 -0
- package/src/core/routing/conversation-turns.ts +20 -14
- package/src/core/routing/surface-port.ts +8 -2
- package/src/core/runtime/pi-agent-runtime.ts +2 -1
- package/src/core/runtime/turn-answer.ts +2 -1
- package/src/index.ts +6 -0
- package/src/testing.ts +34 -4
- package/templates/official/release-notice/plugin.test.ts.tmpl +248 -0
- package/templates/official/release-notice/plugin.ts +194 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,26 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [0.7.0] - 2026-10-02
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- Turn reply attachments: `ToolTurn.attachFile(file)` queues raw bytes with the agent's reply instead of posting as the bot ahead of it.
|
|
13
|
+
The main entry exports `ReplyFile` (`{ name, data: Uint8Array }`), `attachReplyFile` (`(file): void`) for raw session and Pi package tools, `ReplyFileError`, and frozen `REPLY_FILE_LIMITS` (10 files, 10 MiB each, 50 MiB total per turn).
|
|
14
|
+
Successful `TurnResult` may carry `files`; a successful textless Pi answer with files is posted, while stopped or failed turns discard them.
|
|
15
|
+
Discord sends files through the same agent webhook identity as the text.
|
|
16
|
+
Transient tasks and calls outside a conversation turn refuse attachments.
|
|
17
|
+
- `testPlugin` records files attached by `runTool` as `files: { channel, file }[]`; inject a file-capable surface for attachment tests.
|
|
18
|
+
The guide includes the tested `examples/reply-files.ts` plugin.
|
|
19
|
+
- `roundtable add plugin release-notice` copies a third official plugin. After the agent server is up it posts once in the coordinator's channel when the running release differs from the one last announced, with the commits the release added since the release last announced, read from a `release.json` that the deploy writes (`{ "sha": "...", "commits": ["subject", ...] }`); it also names the channels whose work the previous shutdown cut short, recorded from the `shutdown` event. It remembers the announced `sha` only after the post succeeds. `createReleaseNotice` takes `releaseFile`, `dataDir`, and `announce` to change where the release is read, where the state is kept, and where the post goes.
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
- Chat surfaces explicitly opt in with `ChatSurface.supportsFiles: true` and must deliver every reply file or reject.
|
|
24
|
+
Existing custom surfaces that handle files must add this flag; absent or false refuses attachment calls and direct `SurfacePort.sendReply` file sends instead of silently losing them.
|
|
25
|
+
Replacement-runtime result files pass the same capability and size checks; custom turn reply callbacks receive the files and own delivery.
|
|
26
|
+
- `release-notice` joins `codex-images` and `dice` as a reserved name for `add plugin`, and the usage text lists all three.
|
|
27
|
+
|
|
8
28
|
## [0.6.1] - 2026-10-02
|
|
9
29
|
|
|
10
30
|
### Fixed
|
package/docs/plugins.md
CHANGED
|
@@ -65,7 +65,7 @@ Use the kit's building blocks for a plugin that runs Pi itself, such as a coding
|
|
|
65
65
|
|
|
66
66
|
`roundtable add plugin <name>` creates `plugins/<name>.ts` and its test from a small template and lists it in `roundtable.config.ts`.
|
|
67
67
|
The name is lowercase words joined by dashes, such as `my-notes`.
|
|
68
|
-
|
|
68
|
+
Three names are reserved for the [official plugins](#official-plugins): `codex-images`, `dice`, and `release-notice` copy a ready-made plugin into the project.
|
|
69
69
|
|
|
70
70
|
`roundtable add package <spec>` adds a Pi package from npm: it runs `bun add <spec>`, loads the package's extensions to find the tools they register, and writes `plugins/<name>.ts` and its test, named after the package without its scope, as [`piPackages`](#pipackages-pi-extensions-every-session-loads) describes.
|
|
71
71
|
|
|
@@ -338,7 +338,7 @@ A key the contract does not have stops the start and names the closest one.
|
|
|
338
338
|
### `tools`: what agents can call
|
|
339
339
|
|
|
340
340
|
`defineTool` takes a name (lowercase words joined by underscores), a description the model reads to decide when to call it, a Typebox parameter schema, the lowest tier that may call it, and the function.
|
|
341
|
-
`run` receives the arguments, already typed, and the turn: the speaker, the channel, the agent,
|
|
341
|
+
`run` receives the arguments, already typed, and the turn: the speaker, the channel, the agent, an abort signal, and `attachFile`.
|
|
342
342
|
It returns the text the model reads.
|
|
343
343
|
Throw `ToolRefusal` for a call the model should correct; any other error fails the call.
|
|
344
344
|
|
|
@@ -377,6 +377,72 @@ export const notes = definePlugin({
|
|
|
377
377
|
|
|
378
378
|
The tool names `bash`, `read`, `edit`, and `write` belong to the agents already.
|
|
379
379
|
|
|
380
|
+
#### Attach files to the agent's reply
|
|
381
|
+
|
|
382
|
+
Call `turn.attachFile(file)` from a tool's `run` to queue a file for the turn's reply, and still return the text the model reads.
|
|
383
|
+
A `ReplyFile` is `{ name: string; data: Uint8Array }`: raw bytes, including images, not a path or base64 string.
|
|
384
|
+
The core copies the bytes when the call succeeds, so the tool may reuse its buffer afterwards.
|
|
385
|
+
The tool does not send a message itself; the successful turn returns `TurnResult.files` and the reply path hands them to the surface as `OutboundReply.files`.
|
|
386
|
+
On Discord, files follow the text as one file per message to avoid image grids, all through the same agent webhook name and avatar as the text.
|
|
387
|
+
Other surfaces may send the text and files in one message or a message group.
|
|
388
|
+
|
|
389
|
+
<!-- example: examples/reply-files.ts -->
|
|
390
|
+
```ts
|
|
391
|
+
import { definePlugin, defineTool } from "pi-roundtable";
|
|
392
|
+
import { Type } from "typebox";
|
|
393
|
+
|
|
394
|
+
/** A small PNG keeps this example runnable without an image provider. */
|
|
395
|
+
export const imageReply = definePlugin({
|
|
396
|
+
name: "image-reply",
|
|
397
|
+
setup: () => ({
|
|
398
|
+
tools: [
|
|
399
|
+
defineTool({
|
|
400
|
+
name: "reply_image",
|
|
401
|
+
description: "Attach a sample image to your reply.",
|
|
402
|
+
parameters: Type.Object({}),
|
|
403
|
+
minTier: "member",
|
|
404
|
+
run: (_args, turn) => {
|
|
405
|
+
turn.attachFile({
|
|
406
|
+
name: "sample.png",
|
|
407
|
+
data: Buffer.from(
|
|
408
|
+
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aD1sAAAAASUVORK5CYII=",
|
|
409
|
+
"base64",
|
|
410
|
+
),
|
|
411
|
+
});
|
|
412
|
+
return "The sample image is attached to this turn's reply.";
|
|
413
|
+
},
|
|
414
|
+
}),
|
|
415
|
+
],
|
|
416
|
+
}),
|
|
417
|
+
});
|
|
418
|
+
```
|
|
419
|
+
<!-- /example -->
|
|
420
|
+
|
|
421
|
+
`attachReplyFile(file): void`, `ReplyFile`, `ReplyFileError`, and the frozen `REPLY_FILE_LIMITS` are exported from `pi-roundtable`.
|
|
422
|
+
Raw `sessionTools` and Pi package tools may import and call `attachReplyFile` during their awaited tool execution; it is the same function as `turn.attachFile`.
|
|
423
|
+
It uses the current asynchronous turn, not a global channel queue, so simultaneous turns and nested conversation turns keep separate files.
|
|
424
|
+
A Pi package should use the host's peer dependency on `pi-roundtable`, not bundle another copy of it.
|
|
425
|
+
Transient `SessionContext.runTask` tasks return only text and cannot attach to their parent's reply.
|
|
426
|
+
Calling the helper outside `context.turns.run` or an agent-team turn, or after that turn finishes, throws `ReplyFileError`; direct standalone runtime calls have no reply collector.
|
|
427
|
+
Await work that produces files before returning from the tool.
|
|
428
|
+
|
|
429
|
+
| Case | Behavior |
|
|
430
|
+
|---|---|
|
|
431
|
+
| Successful final answer with no text | Files are posted without a placeholder text; a final answer with neither text nor files still fails in the Pi runtime |
|
|
432
|
+
| Stopped, timed-out, or failed turn | Accepted files are discarded; only the usual stopped or failure notice is posted |
|
|
433
|
+
| A tool fails but the model recovers and completes the turn | Files already accepted remain queued for the successful reply; a tool failure does not roll back earlier attachment calls |
|
|
434
|
+
| Unsupported surface | `ChatSurface.supportsFiles` must be `true`; absent or false makes attachment calls throw `ReplyFileError`, which Pi reports to the model as a tool error |
|
|
435
|
+
| Too many or too large files | At most 10 files per turn, 10 MiB (10,485,760 bytes) per file, and 50 MiB (52,428,800 bytes) in total across all tools; the exceeding call throws `ReplyFileError` without queuing that file |
|
|
436
|
+
| Invalid file | Empty bytes, non-`Uint8Array` data, empty filenames, names over 255 characters, paths, `.`/`..`, or control characters throw `ReplyFileError` |
|
|
437
|
+
| A surface rejects delivery | The reply path logs `reply not posted` or `agent reply not posted`; delivery may be partial, is not retried, and does not change the completed runtime result |
|
|
438
|
+
|
|
439
|
+
A surface declaring `supportsFiles: true` must deliver every `OutboundReply.files` entry with the reply's speaker identity, or reject with an error; its transport may impose stricter limits.
|
|
440
|
+
`SurfacePort.sendReply` also refuses direct file sends to a surface without that declaration instead of silently dropping files.
|
|
441
|
+
A replacement runtime may return `files` in its successful `TurnResult`; the turn path applies the same limits and capability check before posting.
|
|
442
|
+
Use either the helper or the result for each file: returning an already attached file queues a second copy, counted against the same limits.
|
|
443
|
+
A custom `ConversationTurnInput.reply(result)` receives `result.files` and owns their delivery instead of the default surface reply.
|
|
444
|
+
Test an attachment tool with an injected file-capable surface and inspect `harness.files` (see [`reply-files.test.ts`](../examples/reply-files.test.ts)); `runTool` itself posts nothing.
|
|
445
|
+
|
|
380
446
|
### `holdRules`, and a tool's `hold`: calls that wait for the owner
|
|
381
447
|
|
|
382
448
|
A held call is described to the owner, who approves or refuses it in Discord before the call runs.
|
|
@@ -1811,10 +1877,10 @@ The following fields and parts from 0.1.0 were removed because only built-in plu
|
|
|
1811
1877
|
|
|
1812
1878
|
## Official plugins
|
|
1813
1879
|
|
|
1814
|
-
The package ships
|
|
1815
|
-
`roundtable add plugin codex-images` and `roundtable add plugin
|
|
1880
|
+
The package ships three plugins you can copy into a project and change.
|
|
1881
|
+
`roundtable add plugin codex-images`, `roundtable add plugin dice`, and `roundtable add plugin release-notice` write `plugins/<name>.ts` and `plugins/<name>.test.ts`, import the plugin in `roundtable.config.ts`, and list it in `plugins`.
|
|
1816
1882
|
You can edit the copied files; `add plugin` refuses to overwrite existing ones.
|
|
1817
|
-
|
|
1883
|
+
The three names are reserved for these copies.
|
|
1818
1884
|
|
|
1819
1885
|
A separate package, [pi-roundtable-mcp](https://www.npmjs.com/package/pi-roundtable-mcp), adds two more plugins, `mcpConnectors` and `remoteMcp`.
|
|
1820
1886
|
The first lets the owner add MCP servers such as Notion or a calendar from Discord and gives your code the list; the second lets an agent outside Discord reach your agent.
|
|
@@ -1824,14 +1890,44 @@ Install it with `bun add pi-roundtable-mcp`.
|
|
|
1824
1890
|
|---|---|---|
|
|
1825
1891
|
| `codex-images` | Fills the [`images` slot](#providers-replace-a-part-the-core-runs-on), so agents can draw avatars from a prompt and reference pictures | A login to the `openai-codex` provider; setup throws a `PluginError` that says so when the host has none |
|
|
1826
1892
|
| `dice` | Adds the `roll_dice` tool for members: `2d6+3`, `4d6k3` (keep or drop the highest or lowest dice), several groups, and fate dice `dF`, answered as text such as `2d6+3: [3, 5] + 3 = 11` | Nothing; it takes at most 100 dice in all and 1000 sides per die |
|
|
1893
|
+
| `release-notice` | After a deploy, posts once in the coordinator's channel which commits the running release added since the release last announced, and says which channels the previous shutdown cut short | A `release.json` that your deploy writes (see below); without the file it posts nothing |
|
|
1827
1894
|
|
|
1828
1895
|
`codex-images` takes the login through [`context.apiKey("openai-codex")`](#the-context).
|
|
1829
1896
|
It sends requests to ChatGPT's Codex backend using the owner's ChatGPT subscription login.
|
|
1830
1897
|
OpenAI doesn't document the backend for this use, so it can stop working without notice; the subscription's terms apply.
|
|
1831
1898
|
The copied file begins with this warning.
|
|
1832
1899
|
|
|
1833
|
-
|
|
1834
|
-
|
|
1900
|
+
`release-notice` listens to two [events](#events-hear-what-the-core-does).
|
|
1901
|
+
When the agent server's team service reports `ready`, it reads the release file and posts if the release's `sha` differs from the one it last announced (`announced-release` in the data directory), or if the previous shutdown cut work short.
|
|
1902
|
+
It records the announced `sha` only after the post succeeds, so a failed post is tried again at the next start, and a `release.json` that is not valid (not JSON, an empty `sha`, or `commits` that is not a list of strings) fails the handler with its path in the log.
|
|
1903
|
+
At `shutdown(left)` it adds the channels in `left`, the work the drain gave up on after an hour, to `aborted-on-shutdown.json` in the data directory; the next start's announcement names them as cut short (a Discord channel key as a `<#id>` mention), and once it is posted they are removed from the file. A shutdown that arrives while a post is still in flight keeps its channels for the next announcement.
|
|
1904
|
+
A plain restart of the same release with nothing cut short posts nothing.
|
|
1905
|
+
|
|
1906
|
+
The deploy writes `release.json` into the release directory, where the bot runs, before it starts the bot.
|
|
1907
|
+
The file holds the commit the release was built from and the subjects of the commits it added over the release last announced, newest first, so that a release whose announcement failed or never ran is not skipped over:
|
|
1908
|
+
|
|
1909
|
+
```json
|
|
1910
|
+
{ "sha": "abc1234", "commits": ["fix: handle an empty reply", "feat: add a notes tool"] }
|
|
1911
|
+
```
|
|
1912
|
+
|
|
1913
|
+
The plugin keeps the sha it last announced in `announced-release` in its data directory, so a deploy script on the same host can read the range's start from there.
|
|
1914
|
+
This script writes the file from the checkout, and stops the deploy when `git log` or `jq` fails; the first deploy, with nothing announced yet, gets an empty `commits` list:
|
|
1915
|
+
|
|
1916
|
+
```sh
|
|
1917
|
+
set -euo pipefail
|
|
1918
|
+
previous=$(cat data/announced-release 2>/dev/null || true)
|
|
1919
|
+
commits='[]'
|
|
1920
|
+
if [ -n "$previous" ]; then
|
|
1921
|
+
commits=$(git log --format=%s "$previous..HEAD" | jq -R . | jq -s .)
|
|
1922
|
+
fi
|
|
1923
|
+
printf '{"sha":"%s","commits":%s}\n' "$(git rev-parse --short HEAD)" "$commits" > release.json
|
|
1924
|
+
```
|
|
1925
|
+
|
|
1926
|
+
`createReleaseNotice(options)` takes `releaseFile` (default `release.json` in the working directory), `dataDir` (default `./data`, where a new project's config keeps its data, so set it when the config's `dataDir` differs), and `announce` (default: the agent team's `announce`, which posts in the coordinator's channel and splits a long text).
|
|
1927
|
+
The plugin you list in the config, `releaseNotice`, uses the defaults.
|
|
1928
|
+
|
|
1929
|
+
Each copy has a test that runs offline: `codex-images` with a fake `fetch`, `dice` with a fake random source, `release-notice` with temporary directories and its own `announce`.
|
|
1930
|
+
Each file exports a `create...` function (`createCodexImages`, `createDice`, `createReleaseNotice`) that takes the part a test replaces, and the plugin you list in the config, built from it.
|
|
1835
1931
|
|
|
1836
1932
|
## Testing a plugin
|
|
1837
1933
|
|
|
@@ -1847,6 +1943,7 @@ It returns:
|
|
|
1847
1943
|
| `tools`, `tiers` | The tool names, and the table that says what tier each needs |
|
|
1848
1944
|
| `holds` | The plugin's `holdRules` chained as the host links them (`holdChain`): `holds(tool, input, { workspace? })` returns the description of a call that must be approved first, or `undefined` |
|
|
1849
1945
|
| `runTool(name, args, { speaker, channel }?)` | Runs a tool the way an agent's turn would, in the channel (default `test:1`) for the speaker, and returns the text the model reads |
|
|
1946
|
+
| `files` | Accepted files from `runTool`, recorded as `{ channel, file: ReplyFile }`; inject a surface with `supportsFiles: true` and pass its channel to test attachment tools |
|
|
1850
1947
|
| `events` | The events the plugin itself reported through `context.events`, and those of `context.turns` |
|
|
1851
1948
|
| `conversations`, `turns`, `surfaces` | What the plugin sees as `context.conversations`, `context.turns`, and `context.surfaces`, for a test to drive its claims |
|
|
1852
1949
|
| `runtime` | The runtime the plugin's `runtime` provider built, given stand-in dependencies; `undefined` when it fills no such slot |
|
|
@@ -2283,6 +2380,9 @@ Import from the entries listed below; source area files are internal.
|
|
|
2283
2380
|
| `ResolvedProviders` | `pi-roundtable` | type |
|
|
2284
2381
|
| `ResolvedSkill` | `pi-roundtable` | type |
|
|
2285
2382
|
| `Roundtable` | `pi-roundtable` | value |
|
|
2383
|
+
| `ReplyFile` | `pi-roundtable` | type |
|
|
2384
|
+
| `ReplyFileError` | `pi-roundtable` | value |
|
|
2385
|
+
| `REPLY_FILE_LIMITS` | `pi-roundtable` | value |
|
|
2286
2386
|
| `RoundtableConfig` | `pi-roundtable` | type |
|
|
2287
2387
|
| `RoundtableOptions` | `pi-roundtable` | type |
|
|
2288
2388
|
| `RoundtablePlugin` | `pi-roundtable` | type |
|
|
@@ -2336,6 +2436,7 @@ Import from the entries listed below; source area files are internal.
|
|
|
2336
2436
|
| `TurnResult` | `pi-roundtable` | type |
|
|
2337
2437
|
| `TurnSelection` | `pi-roundtable` | type |
|
|
2338
2438
|
| `Weekday` | `pi-roundtable` | type |
|
|
2439
|
+
| `attachReplyFile` | `pi-roundtable` | value |
|
|
2339
2440
|
| `channelKey` | `pi-roundtable` | value |
|
|
2340
2441
|
| `definePlugin` | `pi-roundtable` | value |
|
|
2341
2442
|
| `defineRoundtable` | `pi-roundtable` | value |
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import type { ChatSurface } from "pi-roundtable";
|
|
3
|
+
import { testPlugin } from "pi-roundtable/testing";
|
|
4
|
+
import { imageReply } from "./reply-files.ts";
|
|
5
|
+
|
|
6
|
+
test("reply_image records its attachment without posting ahead of the agent", async () => {
|
|
7
|
+
let posted = false;
|
|
8
|
+
const surface: ChatSurface = {
|
|
9
|
+
surface: "fake",
|
|
10
|
+
supportsFiles: true,
|
|
11
|
+
start: async () => undefined,
|
|
12
|
+
sendReply: async () => {
|
|
13
|
+
posted = true;
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
const harness = await testPlugin(imageReply, { surfaces: [surface] });
|
|
17
|
+
try {
|
|
18
|
+
expect(
|
|
19
|
+
await harness.runTool("reply_image", {}, { channel: "fake:room" }),
|
|
20
|
+
).toBe("The sample image is attached to this turn's reply.");
|
|
21
|
+
expect(posted).toBe(false);
|
|
22
|
+
expect(harness.files).toHaveLength(1);
|
|
23
|
+
expect(harness.files[0]?.channel).toBe("fake:room");
|
|
24
|
+
expect(harness.files[0]?.file.name).toBe("sample.png");
|
|
25
|
+
expect(harness.files[0]?.file.data.slice(0, 8)).toEqual(
|
|
26
|
+
new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]),
|
|
27
|
+
);
|
|
28
|
+
await expect(harness.runTool("reply_image", {})).rejects.toThrow(
|
|
29
|
+
"does not support reply files",
|
|
30
|
+
);
|
|
31
|
+
} finally {
|
|
32
|
+
await harness.stop();
|
|
33
|
+
}
|
|
34
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { definePlugin, defineTool } from "pi-roundtable";
|
|
2
|
+
import { Type } from "typebox";
|
|
3
|
+
|
|
4
|
+
/** A small PNG keeps this example runnable without an image provider. */
|
|
5
|
+
export const imageReply = definePlugin({
|
|
6
|
+
name: "image-reply",
|
|
7
|
+
setup: () => ({
|
|
8
|
+
tools: [
|
|
9
|
+
defineTool({
|
|
10
|
+
name: "reply_image",
|
|
11
|
+
description: "Attach a sample image to your reply.",
|
|
12
|
+
parameters: Type.Object({}),
|
|
13
|
+
minTier: "member",
|
|
14
|
+
run: (_args, turn) => {
|
|
15
|
+
turn.attachFile({
|
|
16
|
+
name: "sample.png",
|
|
17
|
+
data: Buffer.from(
|
|
18
|
+
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aD1sAAAAASUVORK5CYII=",
|
|
19
|
+
"base64",
|
|
20
|
+
),
|
|
21
|
+
});
|
|
22
|
+
return "The sample image is attached to this turn's reply.";
|
|
23
|
+
},
|
|
24
|
+
}),
|
|
25
|
+
],
|
|
26
|
+
}),
|
|
27
|
+
});
|
package/package.json
CHANGED
package/src/cli/cli.ts
CHANGED
|
@@ -36,7 +36,7 @@ const USAGE = `roundtable: a Discord agent server on Pi
|
|
|
36
36
|
roundtable doctor [--reachable] check the setup and say how to fix what is wrong
|
|
37
37
|
roundtable start run the checks that need no network, then the bot
|
|
38
38
|
roundtable add plugin <name> add plugins/<name>.ts and its test, and list it in the config
|
|
39
|
-
the names ${
|
|
39
|
+
the names ${new Intl.ListFormat("en").format(OFFICIAL_PLUGINS)} are reserved for the official plugins,
|
|
40
40
|
which are copied in ready to run instead of the template
|
|
41
41
|
roundtable add package <spec> install a Pi package with bun add, and add plugins/<name>.ts that
|
|
42
42
|
loads it and gives its tools to every agent turn
|
package/src/cli/templates.ts
CHANGED
|
@@ -20,7 +20,11 @@ const OFFICIAL_DIR = "official";
|
|
|
20
20
|
const PACKAGE_DIR = "package";
|
|
21
21
|
|
|
22
22
|
/** The plugins the package ships ready-made: `add plugin <name>` copies these instead of the `hello` template, so the names are reserved. */
|
|
23
|
-
export const OFFICIAL_PLUGINS = [
|
|
23
|
+
export const OFFICIAL_PLUGINS = [
|
|
24
|
+
"codex-images",
|
|
25
|
+
"dice",
|
|
26
|
+
"release-notice",
|
|
27
|
+
] as const;
|
|
24
28
|
|
|
25
29
|
/** Whether `name` is one of the official plugins. */
|
|
26
30
|
export const isOfficialPlugin = (name: string): boolean =>
|
|
@@ -2,6 +2,7 @@ import type { TurnAttachments } from "../domain/attachment.ts";
|
|
|
2
2
|
import type {
|
|
3
3
|
ChannelKey,
|
|
4
4
|
PendingConfirmation,
|
|
5
|
+
ReplyFile,
|
|
5
6
|
TurnResult,
|
|
6
7
|
} from "../domain/conversation.ts";
|
|
7
8
|
import { AgentError } from "../domain/errors.ts";
|
|
@@ -9,6 +10,7 @@ import type { AgentTurnScope } from "../domain/ports.ts";
|
|
|
9
10
|
import { messages } from "../i18n/index.ts";
|
|
10
11
|
import { splitReply } from "../presentation/reply-splitter.ts";
|
|
11
12
|
import { thinkingLine } from "../presentation/thinking-line.ts";
|
|
13
|
+
import { withReplyFiles } from "../reply-files.ts";
|
|
12
14
|
import { endOf, settleTurn } from "../routing/settle-turn.ts";
|
|
13
15
|
import type { Speaker } from "../speakers.ts";
|
|
14
16
|
import { toolTiers } from "../tool-tiers.ts";
|
|
@@ -183,17 +185,19 @@ export class TeamTurns {
|
|
|
183
185
|
try {
|
|
184
186
|
result = await settleTurn(
|
|
185
187
|
() =>
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
188
|
+
withReplyFiles(true, () =>
|
|
189
|
+
this.#options.runtime().runTurn({
|
|
190
|
+
channel: postTo,
|
|
191
|
+
selection: this.#options.selection(scope),
|
|
192
|
+
text,
|
|
193
|
+
...(extra.attachments ? { attachments: extra.attachments } : {}),
|
|
194
|
+
...(extra.confirmed ? { confirmed: true } : {}),
|
|
195
|
+
...(extra.steerable ? { steerable: true } : {}),
|
|
196
|
+
...(extra.interactive ? { interactive: true } : {}),
|
|
197
|
+
agent: scope,
|
|
198
|
+
speaker: chain.speaker,
|
|
199
|
+
}),
|
|
200
|
+
),
|
|
197
201
|
"agent turn",
|
|
198
202
|
);
|
|
199
203
|
} finally {
|
|
@@ -220,6 +224,7 @@ export class TeamTurns {
|
|
|
220
224
|
await this.#post(postTo, this.#current(agent), {
|
|
221
225
|
...(thinking ? { thinking } : {}),
|
|
222
226
|
chunks: result.ok ? splitReply(result.text) : [noticeOf(result)],
|
|
227
|
+
...(result.ok && result.files?.length ? { files: result.files } : {}),
|
|
223
228
|
});
|
|
224
229
|
} catch (error) {
|
|
225
230
|
logger.error({ agent: agent.name, err: error }, "agent reply not posted");
|
|
@@ -248,7 +253,12 @@ export class TeamTurns {
|
|
|
248
253
|
async #post(
|
|
249
254
|
channel: ChannelKey,
|
|
250
255
|
as: Agent,
|
|
251
|
-
body: {
|
|
256
|
+
body: {
|
|
257
|
+
thinking?: string;
|
|
258
|
+
chunks: string[];
|
|
259
|
+
files?: ReplyFile[];
|
|
260
|
+
threadId?: string;
|
|
261
|
+
},
|
|
252
262
|
): Promise<void> {
|
|
253
263
|
const { store, channels, studio, logger } = this.#options;
|
|
254
264
|
if (!channelOwner(store, channel)) {
|
|
@@ -39,6 +39,8 @@ export function channelKey(surface: string, id: string): ChannelKey {
|
|
|
39
39
|
export interface ChatSurface {
|
|
40
40
|
/** The key prefix of this surface's channels, such as "discord"; unique per host. */
|
|
41
41
|
readonly surface: string;
|
|
42
|
+
/** Explicit opt-in to delivering OutboundReply.files; absent or false refuses turn attachments. */
|
|
43
|
+
readonly supportsFiles?: boolean;
|
|
42
44
|
/**
|
|
43
45
|
* Connects and delivers every incoming message; the host passes its conversation router. A
|
|
44
46
|
* message whose channel key has another prefix is logged and dropped.
|
package/src/core/define.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import type { Static, TObject } from "typebox";
|
|
2
|
+
import type { ReplyFile } from "./domain/conversation.ts";
|
|
2
3
|
import { PluginError } from "./errors.ts";
|
|
3
4
|
import type { HoldRule } from "./holds.ts";
|
|
4
5
|
import { type RoundtablePlugin, refuseRemovedFields } from "./plugin.ts";
|
|
6
|
+
import { attachReplyFile } from "./reply-files.ts";
|
|
5
7
|
import type { AgentTurnScope, ChannelKey, SessionTool } from "./sessions.ts";
|
|
6
8
|
import { toolError, toolText } from "./shared/tool-result.ts";
|
|
7
9
|
import { type Speaker, TIERS, type Tier } from "./speakers.ts";
|
|
@@ -22,6 +24,8 @@ export interface ToolTurn {
|
|
|
22
24
|
/** The agent whose turn it is. */
|
|
23
25
|
agent: AgentTurnScope | undefined;
|
|
24
26
|
signal: AbortSignal | undefined;
|
|
27
|
+
/** Queues a file for this turn's successful reply; throws ReplyFileError when refused. */
|
|
28
|
+
attachFile(file: ReplyFile): void;
|
|
25
29
|
}
|
|
26
30
|
|
|
27
31
|
/**
|
|
@@ -108,6 +112,7 @@ export function defineTool<Schema extends TObject>(
|
|
|
108
112
|
channel: context.turnChannel,
|
|
109
113
|
agent: context.agent,
|
|
110
114
|
signal,
|
|
115
|
+
attachFile: attachReplyFile,
|
|
111
116
|
}),
|
|
112
117
|
);
|
|
113
118
|
} catch (error) {
|
|
@@ -25,8 +25,14 @@ export interface PendingConfirmation {
|
|
|
25
25
|
calls: HeldCall[];
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
/** A file produced for a reply; data is raw bytes, not a path or base64. */
|
|
29
|
+
export interface ReplyFile {
|
|
30
|
+
name: string;
|
|
31
|
+
data: Uint8Array;
|
|
32
|
+
}
|
|
33
|
+
|
|
28
34
|
export type TurnResult =
|
|
29
|
-
| { ok: true; text: string; thinking?: string }
|
|
35
|
+
| { ok: true; text: string; thinking?: string; files?: ReplyFile[] }
|
|
30
36
|
| { ok: false; error: Error; stopped?: true };
|
|
31
37
|
|
|
32
38
|
export interface OutboundReply {
|
|
@@ -37,5 +43,5 @@ export interface OutboundReply {
|
|
|
37
43
|
/** Message texts in posting order; the card is posted before them. */
|
|
38
44
|
chunks: string[];
|
|
39
45
|
/** Files the run produced, posted after the text. */
|
|
40
|
-
files?:
|
|
46
|
+
files?: ReplyFile[];
|
|
41
47
|
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import type { ReplyFile, TurnResult } from "./domain/conversation.ts";
|
|
3
|
+
|
|
4
|
+
/** Limits across all tools in one turn, independent of the surface's own limits. */
|
|
5
|
+
export const REPLY_FILE_LIMITS = Object.freeze({
|
|
6
|
+
maxFiles: 10,
|
|
7
|
+
maxFileBytes: 10 * 1024 * 1024,
|
|
8
|
+
maxTotalBytes: 50 * 1024 * 1024,
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
/** An attachment was refused; its message names the reason instead of dropping the file. */
|
|
12
|
+
export class ReplyFileError extends Error {
|
|
13
|
+
override name = "ReplyFileError";
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface ReplyFiles {
|
|
17
|
+
active: boolean;
|
|
18
|
+
supported: boolean;
|
|
19
|
+
files: ReplyFile[];
|
|
20
|
+
bytes: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const current = new AsyncLocalStorage<ReplyFiles | undefined>();
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Queues a copy of a file for the current turn's successful reply. Session tools and Pi package
|
|
27
|
+
* tools may call this directly; outside a supported running turn it throws ReplyFileError.
|
|
28
|
+
*/
|
|
29
|
+
export function attachReplyFile(file: ReplyFile): void {
|
|
30
|
+
const turn = current.getStore();
|
|
31
|
+
if (!turn?.active)
|
|
32
|
+
throw new ReplyFileError(
|
|
33
|
+
"attachReplyFile requires a running conversation turn; await it inside the turn's tool call.",
|
|
34
|
+
);
|
|
35
|
+
if (!turn.supported)
|
|
36
|
+
throw new ReplyFileError(
|
|
37
|
+
"this turn's chat surface does not support reply files.",
|
|
38
|
+
);
|
|
39
|
+
if (
|
|
40
|
+
!file ||
|
|
41
|
+
typeof file.name !== "string" ||
|
|
42
|
+
!file.name.trim() ||
|
|
43
|
+
file.name.length > 255 ||
|
|
44
|
+
/[\\/]/.test(file.name) ||
|
|
45
|
+
[...file.name].some(
|
|
46
|
+
(char) => char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127,
|
|
47
|
+
) ||
|
|
48
|
+
file.name === "." ||
|
|
49
|
+
file.name === ".."
|
|
50
|
+
)
|
|
51
|
+
throw new ReplyFileError(
|
|
52
|
+
"a reply file needs a non-empty filename of at most 255 characters, without paths or control characters.",
|
|
53
|
+
);
|
|
54
|
+
if (!(file.data instanceof Uint8Array) || file.data.byteLength === 0)
|
|
55
|
+
throw new ReplyFileError("a reply file needs non-empty Uint8Array data.");
|
|
56
|
+
if (turn.files.length >= REPLY_FILE_LIMITS.maxFiles)
|
|
57
|
+
throw new ReplyFileError(
|
|
58
|
+
`a turn may attach at most ${REPLY_FILE_LIMITS.maxFiles} reply files.`,
|
|
59
|
+
);
|
|
60
|
+
if (file.data.byteLength > REPLY_FILE_LIMITS.maxFileBytes)
|
|
61
|
+
throw new ReplyFileError(
|
|
62
|
+
`a reply file may contain at most ${REPLY_FILE_LIMITS.maxFileBytes} bytes.`,
|
|
63
|
+
);
|
|
64
|
+
if (turn.bytes + file.data.byteLength > REPLY_FILE_LIMITS.maxTotalBytes)
|
|
65
|
+
throw new ReplyFileError(
|
|
66
|
+
`a turn's reply files may contain at most ${REPLY_FILE_LIMITS.maxTotalBytes} bytes in total.`,
|
|
67
|
+
);
|
|
68
|
+
turn.files.push({ name: file.name, data: new Uint8Array(file.data) });
|
|
69
|
+
turn.bytes += file.data.byteLength;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Used by test fixtures to forward attachments only when called inside a turn. */
|
|
73
|
+
export function hasReplyFileScope(): boolean {
|
|
74
|
+
return current.getStore()?.active === true;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Whether an otherwise empty final answer has files to show. */
|
|
78
|
+
export function hasReplyFiles(): boolean {
|
|
79
|
+
const turn = current.getStore();
|
|
80
|
+
return turn?.active === true && turn.files.length > 0;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** One isolated collector per turn, also when turns run concurrently or nest. */
|
|
84
|
+
export async function withReplyFiles(
|
|
85
|
+
supported: boolean,
|
|
86
|
+
run: () => Promise<TurnResult>,
|
|
87
|
+
): Promise<TurnResult> {
|
|
88
|
+
const turn: ReplyFiles = { active: true, supported, files: [], bytes: 0 };
|
|
89
|
+
return current.run(turn, async () => {
|
|
90
|
+
try {
|
|
91
|
+
const result = await run();
|
|
92
|
+
if (!result.ok) return result;
|
|
93
|
+
// A replacement runtime may produce files in its result rather than calling the helper.
|
|
94
|
+
for (const file of result.files ?? []) attachReplyFile(file);
|
|
95
|
+
return turn.files.length ? { ...result, files: [...turn.files] } : result;
|
|
96
|
+
} finally {
|
|
97
|
+
turn.active = false;
|
|
98
|
+
turn.files = [];
|
|
99
|
+
turn.bytes = 0;
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** A transient task reports text, not a conversation reply: it cannot borrow its parent's collector. */
|
|
105
|
+
export function withoutReplyFiles<T>(run: () => Promise<T>): Promise<T> {
|
|
106
|
+
return current.run(undefined, run);
|
|
107
|
+
}
|
|
@@ -7,6 +7,7 @@ import type { Logger } from "../log.ts";
|
|
|
7
7
|
import type { EventSink } from "../plugin.ts";
|
|
8
8
|
import { splitReply } from "../presentation/reply-splitter.ts";
|
|
9
9
|
import { thinkingLine } from "../presentation/thinking-line.ts";
|
|
10
|
+
import { withReplyFiles } from "../reply-files.ts";
|
|
10
11
|
import type { ChannelKey, ToolSelection, TurnSelection } from "../sessions.ts";
|
|
11
12
|
import type { Speaker } from "../speakers.ts";
|
|
12
13
|
import { endOf, settleTurn } from "./settle-turn.ts";
|
|
@@ -85,20 +86,24 @@ export function conversationTurns(
|
|
|
85
86
|
try {
|
|
86
87
|
result = await settleTurn(
|
|
87
88
|
() =>
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
89
|
+
withReplyFiles(surfaces.of(channel)?.supportsFiles === true, () =>
|
|
90
|
+
runtime.runTurn({
|
|
91
|
+
channel,
|
|
92
|
+
kind,
|
|
93
|
+
selection: input.selection ?? {
|
|
94
|
+
id: DEFAULT_SELECTION,
|
|
95
|
+
...options.selection(),
|
|
96
|
+
},
|
|
97
|
+
text: input.text,
|
|
98
|
+
speaker,
|
|
99
|
+
...(input.attachments
|
|
100
|
+
? { attachments: input.attachments }
|
|
101
|
+
: {}),
|
|
102
|
+
...(input.confirmed ? { confirmed: true } : {}),
|
|
103
|
+
...(input.steerable ? { steerable: true } : {}),
|
|
104
|
+
...(input.interactive ? { interactive: true } : {}),
|
|
105
|
+
}),
|
|
106
|
+
),
|
|
102
107
|
"conversation turn",
|
|
103
108
|
);
|
|
104
109
|
} finally {
|
|
@@ -135,5 +140,6 @@ function replyOf(result: TurnResult) {
|
|
|
135
140
|
return {
|
|
136
141
|
...(thinking ? { thinking } : {}),
|
|
137
142
|
chunks: splitReply(result.text),
|
|
143
|
+
...(result.files?.length ? { files: result.files } : {}),
|
|
138
144
|
};
|
|
139
145
|
}
|
|
@@ -24,8 +24,14 @@ export function surfacePort(linked: () => readonly ChatSurface[]): SurfacePort {
|
|
|
24
24
|
};
|
|
25
25
|
return {
|
|
26
26
|
of,
|
|
27
|
-
sendReply: async (channel, reply) =>
|
|
28
|
-
served(channel)
|
|
27
|
+
sendReply: async (channel, reply) => {
|
|
28
|
+
const surface = served(channel);
|
|
29
|
+
if (reply.files?.length && surface.supportsFiles !== true)
|
|
30
|
+
throw new PluginError(
|
|
31
|
+
`surface ${surface.surface} does not support reply files; declare supportsFiles: true and deliver every file in sendReply.`,
|
|
32
|
+
);
|
|
33
|
+
await surface.sendReply(channel, reply);
|
|
34
|
+
},
|
|
29
35
|
startTyping: (channel) =>
|
|
30
36
|
of(channel)?.startTyping?.(channel) ?? (() => undefined),
|
|
31
37
|
showStop: (channel) =>
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
type ThinkingLevel,
|
|
25
25
|
type ThinkingSetting,
|
|
26
26
|
} from "../models.ts";
|
|
27
|
+
import { withoutReplyFiles } from "../reply-files.ts";
|
|
27
28
|
import { planOrder, type TransientTask } from "../sessions.ts";
|
|
28
29
|
import { textOf } from "../shared/session-messages.ts";
|
|
29
30
|
import { addressee, type Speaker, type Tier } from "../speakers.ts";
|
|
@@ -352,7 +353,7 @@ export class PiAgentRuntime implements AgentRuntime {
|
|
|
352
353
|
this.#tiers.allows(tier, name),
|
|
353
354
|
);
|
|
354
355
|
session.setActiveToolsByName([...worker.tools]);
|
|
355
|
-
await session.prompt(task.text);
|
|
356
|
+
await withoutReplyFiles(() => session.prompt(task.text));
|
|
356
357
|
return workerReport(
|
|
357
358
|
session.messages,
|
|
358
359
|
"the worker stopped before it reported",
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { TurnResult } from "../domain/conversation.ts";
|
|
2
2
|
import { AgentRunError } from "../domain/errors.ts";
|
|
3
|
+
import { hasReplyFiles } from "../reply-files.ts";
|
|
3
4
|
import { lastAssistant, textOf } from "../shared/session-messages.ts";
|
|
4
5
|
|
|
5
6
|
interface MessagePart {
|
|
@@ -67,7 +68,7 @@ export function turnAnswer(
|
|
|
67
68
|
.join("\n\n")
|
|
68
69
|
: textOf(last.message.content)
|
|
69
70
|
).trim();
|
|
70
|
-
if (!text)
|
|
71
|
+
if (!text && !hasReplyFiles())
|
|
71
72
|
return {
|
|
72
73
|
ok: false,
|
|
73
74
|
error: new AgentRunError("the final assistant message has no text"),
|
package/src/index.ts
CHANGED
|
@@ -77,6 +77,7 @@ export type {
|
|
|
77
77
|
HeldCall,
|
|
78
78
|
OutboundReply,
|
|
79
79
|
PendingConfirmation,
|
|
80
|
+
ReplyFile,
|
|
80
81
|
TranscriptEntry,
|
|
81
82
|
TurnResult,
|
|
82
83
|
} from "./core/domain/conversation.ts";
|
|
@@ -157,6 +158,11 @@ export type {
|
|
|
157
158
|
TurnEndEvent,
|
|
158
159
|
TurnEvent,
|
|
159
160
|
} from "./core/plugin.ts";
|
|
161
|
+
export {
|
|
162
|
+
attachReplyFile,
|
|
163
|
+
REPLY_FILE_LIMITS,
|
|
164
|
+
ReplyFileError,
|
|
165
|
+
} from "./core/reply-files.ts";
|
|
160
166
|
export type {
|
|
161
167
|
ConversationTurnInput,
|
|
162
168
|
ConversationTurns,
|
package/src/testing.ts
CHANGED
|
@@ -25,7 +25,10 @@ import type {
|
|
|
25
25
|
InteractionContribution,
|
|
26
26
|
} from "./core/discord/interaction-module.ts";
|
|
27
27
|
import { OwnerGuard, ownerRootCommand } from "./core/discord/owner-command.ts";
|
|
28
|
-
import type {
|
|
28
|
+
import type {
|
|
29
|
+
PendingConfirmation,
|
|
30
|
+
ReplyFile,
|
|
31
|
+
} from "./core/domain/conversation.ts";
|
|
29
32
|
import { NotLinkedError, PluginError } from "./core/errors.ts";
|
|
30
33
|
import { EventBus } from "./core/events.ts";
|
|
31
34
|
import type { HoldCheck } from "./core/holds.ts";
|
|
@@ -48,6 +51,11 @@ import {
|
|
|
48
51
|
} from "./core/registry/contributions.ts";
|
|
49
52
|
import { resolveProviders } from "./core/registry/providers.ts";
|
|
50
53
|
import { ServiceRegistry } from "./core/registry/services.ts";
|
|
54
|
+
import {
|
|
55
|
+
attachReplyFile,
|
|
56
|
+
hasReplyFileScope,
|
|
57
|
+
withReplyFiles,
|
|
58
|
+
} from "./core/reply-files.ts";
|
|
51
59
|
import { ChannelQueue } from "./core/routing/channel-queue.ts";
|
|
52
60
|
import { ChannelRouter } from "./core/routing/channel-router.ts";
|
|
53
61
|
import {
|
|
@@ -203,6 +211,8 @@ export interface TestPluginResult {
|
|
|
203
211
|
tools: readonly string[];
|
|
204
212
|
tiers: ToolTierTable;
|
|
205
213
|
events: RecordedEvent[];
|
|
214
|
+
/** Files accepted by successful runTool calls, copied and recorded with their channel. */
|
|
215
|
+
files: { channel: ChannelKey; file: ReplyFile }[];
|
|
206
216
|
runTool(
|
|
207
217
|
name: string,
|
|
208
218
|
args: Record<string, unknown>,
|
|
@@ -233,7 +243,10 @@ function unlinked(part: keyof typeof NOT_LINKED): never {
|
|
|
233
243
|
const PROBED = new Set(["then", "toJSON", "asymmetricMatch"]);
|
|
234
244
|
|
|
235
245
|
/** A service the test gave only some members of: reading another names the option that adds it. */
|
|
236
|
-
function partialService
|
|
246
|
+
function partialService<T extends object>(
|
|
247
|
+
key: ServiceKey<unknown>,
|
|
248
|
+
given: T,
|
|
249
|
+
): T {
|
|
237
250
|
return new Proxy(given, {
|
|
238
251
|
get(target, member, receiver) {
|
|
239
252
|
if (member in target || typeof member === "symbol" || PROBED.has(member))
|
|
@@ -495,6 +508,7 @@ export async function testPlugin(
|
|
|
495
508
|
for (const surface of options.surfaces ?? []) await surface.start(deliver);
|
|
496
509
|
await runtime?.preflight?.();
|
|
497
510
|
let stopped = false;
|
|
511
|
+
const files: { channel: ChannelKey; file: ReplyFile }[] = [];
|
|
498
512
|
return {
|
|
499
513
|
contribution,
|
|
500
514
|
holds: linked.holds,
|
|
@@ -505,6 +519,7 @@ export async function testPlugin(
|
|
|
505
519
|
tools: registry.tools.map((tool) => tool.name),
|
|
506
520
|
tiers,
|
|
507
521
|
events,
|
|
522
|
+
files,
|
|
508
523
|
async runTool(name, args, runOptions) {
|
|
509
524
|
const tool = registry.tools.find((item) => item.name === name);
|
|
510
525
|
if (!tool) throw new PluginError(`tool ${name} is not registered`);
|
|
@@ -539,8 +554,23 @@ export async function testPlugin(
|
|
|
539
554
|
const execute = registered[0]?.execute;
|
|
540
555
|
if (!execute)
|
|
541
556
|
throw new PluginError(`tool ${name} did not register in the session`);
|
|
542
|
-
const result = await
|
|
543
|
-
|
|
557
|
+
const result = await withReplyFiles(
|
|
558
|
+
surfaces.of(channel)?.supportsFiles === true,
|
|
559
|
+
async () => {
|
|
560
|
+
const output = await execute("test-call", args);
|
|
561
|
+
return {
|
|
562
|
+
ok: true,
|
|
563
|
+
text: output.content.map((item) => item.text ?? "").join("\n"),
|
|
564
|
+
};
|
|
565
|
+
},
|
|
566
|
+
);
|
|
567
|
+
if (!result.ok) throw result.error;
|
|
568
|
+
for (const file of result.files ?? []) {
|
|
569
|
+
files.push({ channel, file });
|
|
570
|
+
// When runTool is used by a fake runtime, forward into the enclosing real turn.
|
|
571
|
+
if (hasReplyFileScope()) attachReplyFile(file);
|
|
572
|
+
}
|
|
573
|
+
return result.text;
|
|
544
574
|
},
|
|
545
575
|
async stop() {
|
|
546
576
|
if (stopped) return;
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { afterEach, beforeEach, expect, test } from "bun:test";
|
|
2
|
+
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
AGENT_SERVER_PLUGIN,
|
|
7
|
+
AGENT_TEAM_SERVICE,
|
|
8
|
+
AGENTS,
|
|
9
|
+
type AgentTeam,
|
|
10
|
+
} from "pi-roundtable";
|
|
11
|
+
import { partial, servicePair, testPlugin } from "pi-roundtable/testing";
|
|
12
|
+
import { createReleaseNotice, noticeText } from "./release-notice.ts";
|
|
13
|
+
|
|
14
|
+
let dir: string;
|
|
15
|
+
let posted: string[];
|
|
16
|
+
|
|
17
|
+
beforeEach(async () => {
|
|
18
|
+
dir = await mkdtemp(join(tmpdir(), "release-notice-"));
|
|
19
|
+
posted = [];
|
|
20
|
+
});
|
|
21
|
+
afterEach(() => rm(dir, { recursive: true, force: true }));
|
|
22
|
+
|
|
23
|
+
const releaseFile = () => join(dir, "release.json");
|
|
24
|
+
const dataDir = () => join(dir, "data");
|
|
25
|
+
const writeRelease = (sha: string, commits: string[]) =>
|
|
26
|
+
writeFile(releaseFile(), JSON.stringify({ sha, commits }));
|
|
27
|
+
|
|
28
|
+
/** The plugin over the temporary directory, posting into `posted`; `fail` makes the post throw. */
|
|
29
|
+
async function started(fail = false) {
|
|
30
|
+
const harness = await testPlugin(
|
|
31
|
+
createReleaseNotice({
|
|
32
|
+
releaseFile: releaseFile(),
|
|
33
|
+
dataDir: dataDir(),
|
|
34
|
+
announce: async (text) => {
|
|
35
|
+
if (fail) throw new Error("the channel is gone");
|
|
36
|
+
posted.push(text);
|
|
37
|
+
},
|
|
38
|
+
}),
|
|
39
|
+
);
|
|
40
|
+
const events = harness.contribution.events;
|
|
41
|
+
return {
|
|
42
|
+
harness,
|
|
43
|
+
/** What the host delivers when the agent server's channels are up. */
|
|
44
|
+
ready: () =>
|
|
45
|
+
events?.serviceStarted?.({
|
|
46
|
+
plugin: AGENT_SERVER_PLUGIN,
|
|
47
|
+
service: AGENT_TEAM_SERVICE,
|
|
48
|
+
outcome: "ready",
|
|
49
|
+
}),
|
|
50
|
+
/** What the host delivers when the shutdown drain ends. */
|
|
51
|
+
shutdown: (left: string[]) => events?.shutdown?.(left),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
test("a start with no release.json announces nothing", async () => {
|
|
56
|
+
const run = await started();
|
|
57
|
+
await run.ready();
|
|
58
|
+
expect(posted).toEqual([]);
|
|
59
|
+
await run.harness.stop();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("a new release is announced once, with its commits", async () => {
|
|
63
|
+
await writeRelease("abc1234", ["fix: b", "feat: a"]);
|
|
64
|
+
const first = await started();
|
|
65
|
+
await first.ready();
|
|
66
|
+
expect(posted).toEqual(["🔄 Updated to `abc1234`\n- fix: b\n- feat: a"]);
|
|
67
|
+
await first.harness.stop();
|
|
68
|
+
// The next start of the same release says nothing.
|
|
69
|
+
const second = await started();
|
|
70
|
+
await second.ready();
|
|
71
|
+
expect(posted).toHaveLength(1);
|
|
72
|
+
await second.harness.stop();
|
|
73
|
+
// A different release is announced again.
|
|
74
|
+
await writeRelease("def5678", ["feat: c"]);
|
|
75
|
+
const third = await started();
|
|
76
|
+
await third.ready();
|
|
77
|
+
expect(posted[1]).toBe("🔄 Updated to `def5678`\n- feat: c");
|
|
78
|
+
await third.harness.stop();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("only the agent server's ready event announces", async () => {
|
|
82
|
+
await writeRelease("abc1234", ["fix: b"]);
|
|
83
|
+
const harness = await testPlugin(
|
|
84
|
+
createReleaseNotice({
|
|
85
|
+
releaseFile: releaseFile(),
|
|
86
|
+
dataDir: dataDir(),
|
|
87
|
+
announce: async (text) => {
|
|
88
|
+
posted.push(text);
|
|
89
|
+
},
|
|
90
|
+
}),
|
|
91
|
+
);
|
|
92
|
+
const serviceStarted = harness.contribution.events?.serviceStarted;
|
|
93
|
+
await serviceStarted?.({
|
|
94
|
+
plugin: AGENT_SERVER_PLUGIN,
|
|
95
|
+
service: AGENT_TEAM_SERVICE,
|
|
96
|
+
outcome: "failed",
|
|
97
|
+
});
|
|
98
|
+
await serviceStarted?.({
|
|
99
|
+
plugin: "other",
|
|
100
|
+
service: AGENT_TEAM_SERVICE,
|
|
101
|
+
outcome: "ready",
|
|
102
|
+
});
|
|
103
|
+
await serviceStarted?.({
|
|
104
|
+
plugin: AGENT_SERVER_PLUGIN,
|
|
105
|
+
service: "other",
|
|
106
|
+
outcome: "ready",
|
|
107
|
+
});
|
|
108
|
+
expect(posted).toEqual([]);
|
|
109
|
+
await harness.stop();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("work a shutdown cut short is reported once, after a restart of the same release", async () => {
|
|
113
|
+
await writeRelease("abc1234", ["fix: b"]);
|
|
114
|
+
const first = await started();
|
|
115
|
+
await first.ready();
|
|
116
|
+
await first.shutdown(["discord:111", "agentgroup:222.infra", "discord:111"]);
|
|
117
|
+
const second = await started();
|
|
118
|
+
await second.ready();
|
|
119
|
+
expect(posted[1]).toBe(
|
|
120
|
+
"🔄 Restarted (`abc1234`)\n-# Cut short by the restart, still running when the shutdown wait ended: <#111>, <#222>",
|
|
121
|
+
);
|
|
122
|
+
await second.harness.stop();
|
|
123
|
+
const third = await started();
|
|
124
|
+
await third.ready();
|
|
125
|
+
expect(posted).toHaveLength(2);
|
|
126
|
+
await third.harness.stop();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("a shutdown that left nothing records nothing", async () => {
|
|
130
|
+
await writeRelease("abc1234", []);
|
|
131
|
+
const first = await started();
|
|
132
|
+
await first.ready();
|
|
133
|
+
await first.shutdown([]);
|
|
134
|
+
await first.harness.stop();
|
|
135
|
+
const second = await started();
|
|
136
|
+
await second.ready();
|
|
137
|
+
expect(posted).toHaveLength(1);
|
|
138
|
+
await second.harness.stop();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("a post that fails leaves the release unannounced, so the next start tries again", async () => {
|
|
142
|
+
await writeRelease("abc1234", ["fix: b"]);
|
|
143
|
+
const failing = await started(true);
|
|
144
|
+
await expect(failing.ready()).rejects.toThrow("the channel is gone");
|
|
145
|
+
await failing.harness.stop();
|
|
146
|
+
expect(await Bun.file(join(dataDir(), "announced-release")).exists()).toBe(
|
|
147
|
+
false,
|
|
148
|
+
);
|
|
149
|
+
const next = await started();
|
|
150
|
+
await next.ready();
|
|
151
|
+
expect(posted).toEqual(["🔄 Updated to `abc1234`\n- fix: b"]);
|
|
152
|
+
await next.harness.stop();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("a release.json that is not a valid description fails with its path and announces nothing", async () => {
|
|
156
|
+
for (const bad of [
|
|
157
|
+
"",
|
|
158
|
+
"{ not json",
|
|
159
|
+
"null",
|
|
160
|
+
"[]",
|
|
161
|
+
"{}",
|
|
162
|
+
'{"sha":"","commits":[]}',
|
|
163
|
+
'{"sha":"abc1234"}',
|
|
164
|
+
'{"sha":"abc1234","commits":"fix: b"}',
|
|
165
|
+
'{"sha":"abc1234","commits":["fix: b",null,{}]}',
|
|
166
|
+
]) {
|
|
167
|
+
await writeFile(releaseFile(), bad);
|
|
168
|
+
const run = await started();
|
|
169
|
+
await expect(run.ready()).rejects.toThrow(
|
|
170
|
+
`${releaseFile()} is not a valid release description`,
|
|
171
|
+
);
|
|
172
|
+
await run.harness.stop();
|
|
173
|
+
}
|
|
174
|
+
expect(posted).toEqual([]);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("a recorded list that is not a list of strings fails with its path", async () => {
|
|
178
|
+
await writeRelease("abc1234", []);
|
|
179
|
+
await mkdir(dataDir(), { recursive: true });
|
|
180
|
+
await writeFile(join(dataDir(), "aborted-on-shutdown.json"), '["a", 1]');
|
|
181
|
+
const run = await started();
|
|
182
|
+
await expect(run.ready()).rejects.toThrow("is not a list of strings");
|
|
183
|
+
await run.harness.stop();
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("a shutdown during the post is not lost, and one that ends before the next start adds to the unannounced", async () => {
|
|
187
|
+
await writeRelease("abc1234", ["fix: b"]);
|
|
188
|
+
let finish = () => {};
|
|
189
|
+
const harness = await testPlugin(
|
|
190
|
+
createReleaseNotice({
|
|
191
|
+
releaseFile: releaseFile(),
|
|
192
|
+
dataDir: dataDir(),
|
|
193
|
+
announce: (text) =>
|
|
194
|
+
new Promise<void>((resolve) => {
|
|
195
|
+
posted.push(text);
|
|
196
|
+
finish = resolve;
|
|
197
|
+
}),
|
|
198
|
+
}),
|
|
199
|
+
);
|
|
200
|
+
const events = harness.contribution.events;
|
|
201
|
+
const announcing = events?.serviceStarted?.({
|
|
202
|
+
plugin: AGENT_SERVER_PLUGIN,
|
|
203
|
+
service: AGENT_TEAM_SERVICE,
|
|
204
|
+
outcome: "ready",
|
|
205
|
+
});
|
|
206
|
+
// The host shuts down while the notice is still being posted.
|
|
207
|
+
while (posted.length === 0) await Bun.sleep(1);
|
|
208
|
+
await events?.shutdown?.(["discord:111"]);
|
|
209
|
+
finish();
|
|
210
|
+
await announcing;
|
|
211
|
+
await events?.shutdown?.(["discord:333"]);
|
|
212
|
+
await harness.stop();
|
|
213
|
+
const next = await started();
|
|
214
|
+
await next.ready();
|
|
215
|
+
expect(posted[1]).toContain("<#111>, <#333>");
|
|
216
|
+
await next.harness.stop();
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test("without an announce option the notice goes to the agent team", async () => {
|
|
220
|
+
await writeRelease("abc1234", ["fix: b"]);
|
|
221
|
+
const harness = await testPlugin(
|
|
222
|
+
createReleaseNotice({ releaseFile: releaseFile(), dataDir: dataDir() }),
|
|
223
|
+
{
|
|
224
|
+
services: [
|
|
225
|
+
servicePair(AGENTS, {
|
|
226
|
+
team: partial<AgentTeam>({
|
|
227
|
+
announce: async (text) => {
|
|
228
|
+
posted.push(text);
|
|
229
|
+
},
|
|
230
|
+
}),
|
|
231
|
+
}),
|
|
232
|
+
],
|
|
233
|
+
},
|
|
234
|
+
);
|
|
235
|
+
await harness.contribution.events?.serviceStarted?.({
|
|
236
|
+
plugin: AGENT_SERVER_PLUGIN,
|
|
237
|
+
service: AGENT_TEAM_SERVICE,
|
|
238
|
+
outcome: "ready",
|
|
239
|
+
});
|
|
240
|
+
expect(posted).toEqual(["🔄 Updated to `abc1234`\n- fix: b"]);
|
|
241
|
+
await harness.stop();
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test("a channel key that is not Discord's is named as it is", () => {
|
|
245
|
+
expect(noticeText({ sha: "a", commits: [] }, false, ["slack:C1"])).toContain(
|
|
246
|
+
"slack:C1",
|
|
247
|
+
);
|
|
248
|
+
});
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
AGENT_SERVER_PLUGIN,
|
|
5
|
+
AGENT_TEAM_SERVICE,
|
|
6
|
+
AGENTS,
|
|
7
|
+
type AgentServer,
|
|
8
|
+
definePlugin,
|
|
9
|
+
} from "pi-roundtable";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* What a deploy writes as `release.json`: the commit the running release was built from, and
|
|
13
|
+
* the subjects of the commits it added since the release that ran before, newest first.
|
|
14
|
+
*/
|
|
15
|
+
export interface ReleaseInfo {
|
|
16
|
+
sha: string;
|
|
17
|
+
commits: string[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ReleaseNoticeOptions {
|
|
21
|
+
/** The file a deploy writes with the release's description; default `release.json` in the working directory. A start with no such file announces nothing. */
|
|
22
|
+
releaseFile?: string;
|
|
23
|
+
/** Where the plugin keeps what it has announced and what a shutdown cut short; default `./data`, the data directory a new project's config names. */
|
|
24
|
+
dataDir?: string;
|
|
25
|
+
/** Posts the announcement; default the coordinator's channel, through the agent server. It throws to say the post failed, and the announcement is then tried again at the next start. */
|
|
26
|
+
announce?: (text: string) => Promise<void>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const ANNOUNCED = "announced-release";
|
|
30
|
+
const ABORTED = "aborted-on-shutdown.json";
|
|
31
|
+
|
|
32
|
+
async function readText(path: string): Promise<string | undefined> {
|
|
33
|
+
return readFile(path, "utf8").catch((error: NodeJS.ErrnoException) => {
|
|
34
|
+
if (error.code === "ENOENT") return undefined;
|
|
35
|
+
throw error;
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A list of strings from JSON, such as the aborted channels; anything else fails with the file's path. */
|
|
40
|
+
function parseStrings(text: string, path: string): string[] {
|
|
41
|
+
let parsed: unknown;
|
|
42
|
+
try {
|
|
43
|
+
parsed = JSON.parse(text);
|
|
44
|
+
} catch (error) {
|
|
45
|
+
throw new Error(`${path} is not a JSON list`, { cause: error });
|
|
46
|
+
}
|
|
47
|
+
if (!isStrings(parsed)) throw new Error(`${path} is not a list of strings`);
|
|
48
|
+
return parsed;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const isStrings = (value: unknown): value is string[] =>
|
|
52
|
+
Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
53
|
+
|
|
54
|
+
/** `release.json`'s content; anything but a non-empty `sha` and a list of commit subjects fails with the file's path. */
|
|
55
|
+
function parseRelease(text: string, path: string): ReleaseInfo {
|
|
56
|
+
const invalid = (reason: string, cause?: unknown) =>
|
|
57
|
+
new Error(`${path} is not a valid release description: ${reason}`, {
|
|
58
|
+
cause,
|
|
59
|
+
});
|
|
60
|
+
let parsed: Partial<ReleaseInfo> | null;
|
|
61
|
+
try {
|
|
62
|
+
parsed = JSON.parse(text);
|
|
63
|
+
} catch (error) {
|
|
64
|
+
throw invalid("not JSON", error);
|
|
65
|
+
}
|
|
66
|
+
if (typeof parsed?.sha !== "string" || parsed.sha === "")
|
|
67
|
+
throw invalid("needs a non-empty sha");
|
|
68
|
+
if (!isStrings(parsed.commits))
|
|
69
|
+
throw invalid("commits is not a list of strings");
|
|
70
|
+
return { sha: parsed.sha, commits: parsed.commits };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** `discord:<id>` and `agentgroup:<id>.<agent>` as a Discord channel mention; any other key as it is. */
|
|
74
|
+
function channelMention(key: string): string {
|
|
75
|
+
const id =
|
|
76
|
+
/^discord:(\d+)$/.exec(key)?.[1] ?? /^agentgroup:(\d+)\./.exec(key)?.[1];
|
|
77
|
+
return id ? `<#${id}>` : key;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The announcement: the new version and its commits, or a plain restart, then any channels the previous shutdown cut short. */
|
|
81
|
+
export function noticeText(
|
|
82
|
+
release: ReleaseInfo,
|
|
83
|
+
updated: boolean,
|
|
84
|
+
aborted: string[],
|
|
85
|
+
): string {
|
|
86
|
+
const lines = updated
|
|
87
|
+
? [
|
|
88
|
+
`🔄 Updated to \`${release.sha}\``,
|
|
89
|
+
...release.commits.map((subject) => `- ${subject}`),
|
|
90
|
+
]
|
|
91
|
+
: [`🔄 Restarted (\`${release.sha}\`)`];
|
|
92
|
+
if (aborted.length > 0)
|
|
93
|
+
lines.push(
|
|
94
|
+
`-# Cut short by the restart, still running when the shutdown wait ended: ${[...new Set(aborted.map(channelMention))].join(", ")}`,
|
|
95
|
+
);
|
|
96
|
+
return lines.join("\n");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Posts through the agent server's team, read when the first notice is due, since the server is linked after setup. */
|
|
100
|
+
function throughTeam(agents: () => AgentServer) {
|
|
101
|
+
return (text: string) => agents().team.announce(text);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The plugin, with where the release is described, where its state is kept, and where the
|
|
106
|
+
* announcement goes replaceable. After the agent server is up it announces once when the
|
|
107
|
+
* running release differs from the one last announced, or when the previous shutdown cut work
|
|
108
|
+
* short; at shutdown it records the channels whose work the drain gave up on.
|
|
109
|
+
*/
|
|
110
|
+
export function createReleaseNotice(options: ReleaseNoticeOptions = {}) {
|
|
111
|
+
const releaseFile = options.releaseFile ?? "release.json";
|
|
112
|
+
const dataDir = options.dataDir ?? "./data";
|
|
113
|
+
const announcedPath = join(dataDir, ANNOUNCED);
|
|
114
|
+
const abortedPath = join(dataDir, ABORTED);
|
|
115
|
+
|
|
116
|
+
// The shutdown's record and an announcement's acknowledgement both rewrite the state files, and
|
|
117
|
+
// the host does not wait for a start's handlers before it shuts down, so they take turns.
|
|
118
|
+
let turn: Promise<unknown> = Promise.resolve();
|
|
119
|
+
const exclusive = <T>(work: () => Promise<T>): Promise<T> => {
|
|
120
|
+
const run = turn.then(work);
|
|
121
|
+
turn = run.catch(() => undefined);
|
|
122
|
+
return run;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
async function readAborted(): Promise<string[]> {
|
|
126
|
+
const text = await readText(abortedPath);
|
|
127
|
+
return text === undefined ? [] : parseStrings(text, abortedPath);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The announcement due now and what acknowledges it, or undefined when nothing is due. */
|
|
131
|
+
function pending() {
|
|
132
|
+
return exclusive(async () => {
|
|
133
|
+
const text = await readText(releaseFile);
|
|
134
|
+
if (text === undefined) return undefined;
|
|
135
|
+
const release = parseRelease(text, releaseFile);
|
|
136
|
+
const announced = (await readText(announcedPath))?.trim();
|
|
137
|
+
const aborted = await readAborted();
|
|
138
|
+
const updated = announced !== release.sha;
|
|
139
|
+
if (!updated && aborted.length === 0) return undefined;
|
|
140
|
+
return {
|
|
141
|
+
text: noticeText(release, updated, aborted),
|
|
142
|
+
// A shutdown may add channels while the post is in flight: only the ones it named are cleared.
|
|
143
|
+
done: () =>
|
|
144
|
+
exclusive(async () => {
|
|
145
|
+
await mkdir(dataDir, { recursive: true });
|
|
146
|
+
await writeFile(announcedPath, release.sha);
|
|
147
|
+
const rest = (await readAborted()).slice(aborted.length);
|
|
148
|
+
if (rest.length > 0)
|
|
149
|
+
await writeFile(abortedPath, JSON.stringify(rest));
|
|
150
|
+
else await rm(abortedPath, { force: true });
|
|
151
|
+
}),
|
|
152
|
+
};
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return definePlugin({
|
|
157
|
+
name: "release-notice",
|
|
158
|
+
setup: ({ logger, services }) => {
|
|
159
|
+
const announce = options.announce ?? throughTeam(services.lazy(AGENTS));
|
|
160
|
+
return {
|
|
161
|
+
events: {
|
|
162
|
+
// The agent server's team service is ready once its channels are up, so the notice posts after it.
|
|
163
|
+
serviceStarted: async ({ plugin, service, outcome }) => {
|
|
164
|
+
if (
|
|
165
|
+
plugin !== AGENT_SERVER_PLUGIN ||
|
|
166
|
+
service !== AGENT_TEAM_SERVICE ||
|
|
167
|
+
outcome !== "ready"
|
|
168
|
+
)
|
|
169
|
+
return;
|
|
170
|
+
const notice = await pending();
|
|
171
|
+
if (!notice) return;
|
|
172
|
+
await announce(notice.text);
|
|
173
|
+
await notice.done();
|
|
174
|
+
logger.info("release announced");
|
|
175
|
+
},
|
|
176
|
+
// The drain ended with work still running: the next start says it was cut short.
|
|
177
|
+
shutdown: (left) =>
|
|
178
|
+
left.length === 0
|
|
179
|
+
? undefined
|
|
180
|
+
: exclusive(async () => {
|
|
181
|
+
const earlier = await readAborted();
|
|
182
|
+
await mkdir(dataDir, { recursive: true });
|
|
183
|
+
await writeFile(
|
|
184
|
+
abortedPath,
|
|
185
|
+
JSON.stringify([...earlier, ...left]),
|
|
186
|
+
);
|
|
187
|
+
}),
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export const releaseNotice = createReleaseNotice();
|