pi-roundtable 0.6.1 → 0.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +44 -0
- package/docs/plugins.md +153 -10
- package/examples/reply-files.test.ts +34 -0
- package/examples/reply-files.ts +27 -0
- package/package.json +10 -3
- 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/biome.json.tmpl +1 -1
- package/templates/official/release-notice/plugin.test.ts.tmpl +248 -0
- package/templates/official/release-notice/plugin.ts +194 -0
- package/templates/package.json.tmpl +1 -1
|
@@ -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();
|