pi-roundtable 0.1.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 +28 -0
- package/LICENSE +21 -0
- package/README.md +140 -0
- package/docs/plugins.md +924 -0
- package/examples/channels.test.ts +13 -0
- package/examples/channels.ts +27 -0
- package/examples/dashboard.test.ts +11 -0
- package/examples/dashboard.ts +9 -0
- package/examples/events.test.ts +26 -0
- package/examples/events.ts +25 -0
- package/examples/guide.test.ts +52 -0
- package/examples/holds.test.ts +16 -0
- package/examples/holds.ts +30 -0
- package/examples/http.test.ts +12 -0
- package/examples/http.ts +17 -0
- package/examples/interactions.test.ts +25 -0
- package/examples/interactions.ts +28 -0
- package/examples/migrations.test.ts +31 -0
- package/examples/migrations.ts +42 -0
- package/examples/packages.test.ts +9 -0
- package/examples/packages.ts +9 -0
- package/examples/preflight.test.ts +11 -0
- package/examples/preflight.ts +19 -0
- package/examples/prompt.test.ts +25 -0
- package/examples/prompt.ts +20 -0
- package/examples/providers.test.ts +10 -0
- package/examples/providers.ts +17 -0
- package/examples/seeds.test.ts +12 -0
- package/examples/seeds.ts +18 -0
- package/examples/selection.test.ts +13 -0
- package/examples/selection.ts +14 -0
- package/examples/services.test.ts +20 -0
- package/examples/services.ts +30 -0
- package/examples/session-tools.test.ts +24 -0
- package/examples/session-tools.ts +34 -0
- package/examples/tools.test.ts +18 -0
- package/examples/tools.ts +28 -0
- package/package.json +55 -0
- package/src/cli/add-plugin.test.ts +107 -0
- package/src/cli/add-plugin.ts +75 -0
- package/src/cli/checks/basic.test.ts +276 -0
- package/src/cli/checks/bun.ts +26 -0
- package/src/cli/checks/configuration.ts +28 -0
- package/src/cli/checks/database.test.ts +117 -0
- package/src/cli/checks/database.ts +100 -0
- package/src/cli/checks/discord.test.ts +200 -0
- package/src/cli/checks/discord.ts +218 -0
- package/src/cli/checks/environment.ts +27 -0
- package/src/cli/checks/model.ts +28 -0
- package/src/cli/checks/public-url.ts +40 -0
- package/src/cli/cli.test.ts +122 -0
- package/src/cli/cli.ts +166 -0
- package/src/cli/config-edit.test.ts +130 -0
- package/src/cli/config-edit.ts +199 -0
- package/src/cli/discord-api.ts +96 -0
- package/src/cli/doctor.test.ts +184 -0
- package/src/cli/doctor.ts +92 -0
- package/src/cli/http.ts +23 -0
- package/src/cli/init.test.ts +127 -0
- package/src/cli/init.ts +82 -0
- package/src/cli/main.ts +13 -0
- package/src/cli/project.ts +123 -0
- package/src/cli/report.ts +68 -0
- package/src/cli/roundtable.mjs +22 -0
- package/src/cli/runtime.ts +53 -0
- package/src/cli/size.test.ts +9 -0
- package/src/cli/start.ts +33 -0
- package/src/cli/templates.test.ts +129 -0
- package/src/cli/templates.ts +119 -0
- package/src/cli/testing/fixtures.ts +106 -0
- package/src/core/agents/agent-claim.test.ts +142 -0
- package/src/core/agents/agent-claim.ts +170 -0
- package/src/core/agents/agent-dashboard.test.ts +120 -0
- package/src/core/agents/agent-dashboard.ts +185 -0
- package/src/core/agents/agent-guild.test.ts +124 -0
- package/src/core/agents/agent-messages.ts +189 -0
- package/src/core/agents/agent-ports.ts +103 -0
- package/src/core/agents/agent-prompt.ts +163 -0
- package/src/core/agents/agent-rules.ts +106 -0
- package/src/core/agents/agent-schema.ts +115 -0
- package/src/core/agents/agent-settings.ts +49 -0
- package/src/core/agents/agent-store.test.ts +205 -0
- package/src/core/agents/agent-store.ts +384 -0
- package/src/core/agents/agent-team.ts +445 -0
- package/src/core/agents/agent-tools.ts +386 -0
- package/src/core/agents/avatar-studio.test.ts +88 -0
- package/src/core/agents/avatar-studio.ts +147 -0
- package/src/core/agents/group-messages.ts +99 -0
- package/src/core/agents/group-round.test.ts +143 -0
- package/src/core/agents/group-round.ts +117 -0
- package/src/core/agents/group-turns.ts +132 -0
- package/src/core/agents/owner-identity.test.ts +172 -0
- package/src/core/agents/team-editing.ts +205 -0
- package/src/core/agents/team-keys.ts +40 -0
- package/src/core/agents/team-layout.ts +135 -0
- package/src/core/agents/team-lifecycle.ts +188 -0
- package/src/core/agents/team-options.ts +52 -0
- package/src/core/agents/team-status.ts +80 -0
- package/src/core/agents/team-text.ts +60 -0
- package/src/core/agents/team-turn-types.ts +107 -0
- package/src/core/agents/team-turns.test.ts +278 -0
- package/src/core/agents/team-turns.ts +323 -0
- package/src/core/assets/neutral.png +0 -0
- package/src/core/assets/prompts/shared-guest.md +11 -0
- package/src/core/assets/prompts/shared.md +11 -0
- package/src/core/assets/skills/writing-skills/SKILL.md +21 -0
- package/src/core/attachments/attachment-dir.ts +15 -0
- package/src/core/attachments/attachment-fetcher.ts +83 -0
- package/src/core/attachments/attachments.test.ts +85 -0
- package/src/core/attachments/image-prep.ts +50 -0
- package/src/core/attachments/prompt-block.ts +37 -0
- package/src/core/attachments/turn-attachments.ts +79 -0
- package/src/core/boundary.test.ts +45 -0
- package/src/core/builtin/agent-server.ts +275 -0
- package/src/core/builtin/discord.ts +95 -0
- package/src/core/builtin/modules.test.ts +127 -0
- package/src/core/builtin/modules.ts +176 -0
- package/src/core/builtin/seeds.ts +10 -0
- package/src/core/builtin/stores.ts +42 -0
- package/src/core/config/config.test.ts +135 -0
- package/src/core/config/config.ts +285 -0
- package/src/core/config/schema.ts +181 -0
- package/src/core/contract/channels.ts +152 -0
- package/src/core/contract/discord.ts +33 -0
- package/src/core/contract/providers.ts +67 -0
- package/src/core/db/guild-scope.ts +53 -0
- package/src/core/db/migrations.test.ts +237 -0
- package/src/core/db/migrations.ts +37 -0
- package/src/core/define-roundtable.test.ts +134 -0
- package/src/core/define-roundtable.ts +195 -0
- package/src/core/define.test.ts +144 -0
- package/src/core/define.ts +154 -0
- package/src/core/discord/agent-commands.test.ts +58 -0
- package/src/core/discord/agent-commands.ts +385 -0
- package/src/core/discord/agent-discord.test.ts +70 -0
- package/src/core/discord/agent-discord.ts +367 -0
- package/src/core/discord/channel-executor.ts +404 -0
- package/src/core/discord/channel-operations.ts +329 -0
- package/src/core/discord/discord-surface.ts +431 -0
- package/src/core/discord/dispatch-thread-host.test.ts +114 -0
- package/src/core/discord/dispatch-thread-host.ts +72 -0
- package/src/core/discord/dispatch-threads.test.ts +94 -0
- package/src/core/discord/dispatch-threads.ts +180 -0
- package/src/core/discord/interaction-module.ts +1 -0
- package/src/core/discord/owner-cards.test.ts +435 -0
- package/src/core/discord/owner-cards.ts +463 -0
- package/src/core/discord/owner-command.ts +126 -0
- package/src/core/discord/owner-discord-access.ts +183 -0
- package/src/core/discord/owner-discord-threads.ts +160 -0
- package/src/core/discord/owner-discord.test.ts +325 -0
- package/src/core/discord/owner-discord.ts +282 -0
- package/src/core/discord/owner-panel.ts +103 -0
- package/src/core/discord/schedule-commands.ts +149 -0
- package/src/core/discord/stop-button.ts +20 -0
- package/src/core/domain/attachment.ts +39 -0
- package/src/core/domain/conversation.ts +42 -0
- package/src/core/domain/errors.ts +31 -0
- package/src/core/domain/expression.ts +21 -0
- package/src/core/domain/owner-prompts.ts +45 -0
- package/src/core/domain/ports.ts +91 -0
- package/src/core/domain/profile.ts +32 -0
- package/src/core/drain.test.ts +46 -0
- package/src/core/drain.ts +33 -0
- package/src/core/errors.ts +29 -0
- package/src/core/events.test.ts +81 -0
- package/src/core/events.ts +55 -0
- package/src/core/holds.test.ts +54 -0
- package/src/core/holds.ts +46 -0
- package/src/core/host.test.ts +536 -0
- package/src/core/host.ts +297 -0
- package/src/core/http/listeners.test.ts +164 -0
- package/src/core/http/listeners.ts +151 -0
- package/src/core/i18n/agent-panel.ts +96 -0
- package/src/core/i18n/cards.ts +64 -0
- package/src/core/i18n/channels.ts +28 -0
- package/src/core/i18n/conversation.ts +49 -0
- package/src/core/i18n/dashboard.ts +72 -0
- package/src/core/i18n/discord.ts +23 -0
- package/src/core/i18n/en.ts +27 -0
- package/src/core/i18n/i18n.test.ts +139 -0
- package/src/core/i18n/index.ts +58 -0
- package/src/core/i18n/owner.ts +35 -0
- package/src/core/i18n/schedules.ts +92 -0
- package/src/core/i18n/time-zones.ts +27 -0
- package/src/core/i18n/types.ts +7 -0
- package/src/core/i18n/zh-tw.ts +26 -0
- package/src/core/identity.test.ts +18 -0
- package/src/core/identity.ts +31 -0
- package/src/core/judging/confirmation-judge.ts +68 -0
- package/src/core/judging/effort-judge.test.ts +112 -0
- package/src/core/judging/effort-judge.ts +158 -0
- package/src/core/judging/model-judge.test.ts +126 -0
- package/src/core/judging/model-judge.ts +193 -0
- package/src/core/log.ts +45 -0
- package/src/core/models.ts +52 -0
- package/src/core/modules/background/background-turns.ts +78 -0
- package/src/core/modules/delegation/delegate.ts +54 -0
- package/src/core/modules/delegation/delegator.test.ts +170 -0
- package/src/core/modules/delegation/delegator.ts +191 -0
- package/src/core/modules/delegation/sol-worker.ts +78 -0
- package/src/core/modules/discord-admin/discord-admin.ts +288 -0
- package/src/core/modules/host-shell/shell-policy.ts +386 -0
- package/src/core/modules/memory/owner-memory-store.test.ts +244 -0
- package/src/core/modules/memory/owner-memory-store.ts +228 -0
- package/src/core/modules/memory/owner-memory.ts +153 -0
- package/src/core/modules/notify/notify.ts +26 -0
- package/src/core/modules/schedules/recurrence.ts +206 -0
- package/src/core/modules/schedules/schedule-store.ts +213 -0
- package/src/core/modules/schedules/schedule-tools.ts +196 -0
- package/src/core/modules/schedules/schedule.test.ts +358 -0
- package/src/core/modules/schedules/scheduler.ts +121 -0
- package/src/core/modules/schedules/schedules.ts +87 -0
- package/src/core/modules/skills/repo-name.ts +11 -0
- package/src/core/modules/skills/skill-kind.test.ts +55 -0
- package/src/core/modules/skills/skill-link.ts +69 -0
- package/src/core/modules/skills/skill-listing.ts +128 -0
- package/src/core/modules/skills/skill-registry.test.ts +376 -0
- package/src/core/modules/skills/skill-registry.ts +323 -0
- package/src/core/modules/skills/skill-rules.ts +120 -0
- package/src/core/modules/skills/skill-store.ts +285 -0
- package/src/core/modules/skills/skill-tools.ts +207 -0
- package/src/core/ops/error-reporter.test.ts +283 -0
- package/src/core/ops/error-reporter.ts +271 -0
- package/src/core/plugin.ts +200 -0
- package/src/core/presentation/card-cadence.ts +41 -0
- package/src/core/presentation/headline.ts +34 -0
- package/src/core/presentation/presentation.test.ts +166 -0
- package/src/core/presentation/quiet-links.ts +36 -0
- package/src/core/presentation/reply-splitter.ts +110 -0
- package/src/core/presentation/thinking-line.ts +24 -0
- package/src/core/public-entry.test.ts +59 -0
- package/src/core/registry/contributions.test.ts +215 -0
- package/src/core/registry/contributions.ts +249 -0
- package/src/core/registry/interactions.test.ts +87 -0
- package/src/core/registry/interactions.ts +55 -0
- package/src/core/registry/providers.test.ts +103 -0
- package/src/core/registry/providers.ts +55 -0
- package/src/core/routing/channel-queue.test.ts +31 -0
- package/src/core/routing/channel-queue.ts +44 -0
- package/src/core/routing/channel-router.test.ts +326 -0
- package/src/core/routing/channel-router.ts +157 -0
- package/src/core/routing/conversation-kind.ts +14 -0
- package/src/core/routing/forward-join.ts +65 -0
- package/src/core/routing/message-text.ts +16 -0
- package/src/core/routing/settle-turn.test.ts +21 -0
- package/src/core/routing/settle-turn.ts +26 -0
- package/src/core/runtime/compaction-tiers.test.ts +227 -0
- package/src/core/runtime/compaction-tiers.ts +203 -0
- package/src/core/runtime/conversation-sessions.ts +162 -0
- package/src/core/runtime/extensions/agent-prompt.ts +16 -0
- package/src/core/runtime/extensions/ask-user.test.ts +114 -0
- package/src/core/runtime/extensions/ask-user.ts +98 -0
- package/src/core/runtime/extensions/confirmation-gate.ts +247 -0
- package/src/core/runtime/extensions/self-compact-guard.test.ts +50 -0
- package/src/core/runtime/extensions/self-compact-guard.ts +23 -0
- package/src/core/runtime/mcp.ts +23 -0
- package/src/core/runtime/pending-confirmation-store.test.ts +52 -0
- package/src/core/runtime/pending-confirmation-store.ts +75 -0
- package/src/core/runtime/pi-agent-runtime.ts +477 -0
- package/src/core/runtime/prompt-slot.ts +81 -0
- package/src/core/runtime/runtime-types.ts +150 -0
- package/src/core/runtime/session-archive.test.ts +20 -0
- package/src/core/runtime/session-archive.ts +17 -0
- package/src/core/runtime/session-factory.ts +291 -0
- package/src/core/runtime/steerable-run.test.ts +264 -0
- package/src/core/runtime/steerable-run.ts +109 -0
- package/src/core/runtime/text-tools.test.ts +110 -0
- package/src/core/runtime/text-tools.ts +66 -0
- package/src/core/runtime/turn-answer.test.ts +72 -0
- package/src/core/runtime/turn-answer.ts +79 -0
- package/src/core/runtime/worker-task.test.ts +82 -0
- package/src/core/runtime/worker-task.ts +65 -0
- package/src/core/services.test.ts +28 -0
- package/src/core/services.ts +107 -0
- package/src/core/sessions.test.ts +85 -0
- package/src/core/sessions.ts +149 -0
- package/src/core/shared/attachment-reader.ts +91 -0
- package/src/core/shared/delegate-tool.ts +15 -0
- package/src/core/shared/mcp-adapter.ts +58 -0
- package/src/core/shared/package-dir.ts +9 -0
- package/src/core/shared/profile-tools.ts +16 -0
- package/src/core/shared/read-attachment-tool.ts +43 -0
- package/src/core/shared/schedule-tools.ts +118 -0
- package/src/core/shared/session-messages.ts +50 -0
- package/src/core/shared/tool-result.ts +9 -0
- package/src/core/shared/unix-server.ts +18 -0
- package/src/core/size.test.ts +9 -0
- package/src/core/speakers.test.ts +78 -0
- package/src/core/speakers.ts +143 -0
- package/src/core/testing/database.ts +52 -0
- package/src/core/testing/file-size.ts +34 -0
- package/src/core/testing/locale.ts +8 -0
- package/src/core/testing/modules.ts +97 -0
- package/src/core/testing/owner.ts +15 -0
- package/src/core/testing/thread-host.ts +60 -0
- package/src/core/time.test.ts +166 -0
- package/src/core/time.ts +67 -0
- package/src/core/tool-tiers.test.ts +117 -0
- package/src/core/tool-tiers.ts +127 -0
- package/src/entries.test.ts +112 -0
- package/src/index.ts +28 -0
- package/src/testing.test.ts +156 -0
- package/src/testing.ts +182 -0
- package/templates/.env.example +22 -0
- package/templates/README.md +11 -0
- package/templates/_gitignore +3 -0
- package/templates/agents.ts +12 -0
- package/templates/biome.json.tmpl +7 -0
- package/templates/docker-compose.yml +16 -0
- package/templates/package.json.tmpl +24 -0
- package/templates/persona/shared.md +2 -0
- package/templates/plugin/plugin.test.ts.tmpl +13 -0
- package/templates/plugin/plugin.ts +18 -0
- package/templates/roundtable.config.ts +24 -0
- package/templates/tsconfig.json.tmpl +16 -0
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import pino from "pino";
|
|
3
|
+
import type { ScheduledOutcome } from "../contract/channels.ts";
|
|
4
|
+
import { createLogger, type LogEntry } from "../log.ts";
|
|
5
|
+
import type { ChannelKey } from "../sessions.ts";
|
|
6
|
+
import { ErrorReporter, fingerprint, reportText } from "./error-reporter.ts";
|
|
7
|
+
|
|
8
|
+
const HOUR = 3_600_000;
|
|
9
|
+
|
|
10
|
+
/** The ops agent as the delivery sees it: active with a channel, or not. */
|
|
11
|
+
interface OpsAgent {
|
|
12
|
+
status: "active" | "archived";
|
|
13
|
+
channelId?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const infra: OpsAgent = { status: "active", channelId: "42" };
|
|
17
|
+
|
|
18
|
+
function stack(file: string, line: number): string {
|
|
19
|
+
return `Error: boom\n at run (/opt/app/${file}:${line}:7)\n at next (/opt/app/node_modules/x/index.js:1:1)`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function entry(msg: string, fields: LogEntry = {}): LogEntry {
|
|
23
|
+
return {
|
|
24
|
+
level: 50,
|
|
25
|
+
time: Date.now(),
|
|
26
|
+
pid: 1,
|
|
27
|
+
hostname: "h",
|
|
28
|
+
app: "roundtable",
|
|
29
|
+
msg,
|
|
30
|
+
...fields,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function setup(options: { agent?: OpsAgent; turn?: () => Promise<void> } = {}) {
|
|
35
|
+
let now = 1_000_000;
|
|
36
|
+
const timers: { run: () => void; at: number }[] = [];
|
|
37
|
+
const reporter = new ErrorReporter({
|
|
38
|
+
opsAgent: "infra",
|
|
39
|
+
app: "Roundtable",
|
|
40
|
+
now: () => now,
|
|
41
|
+
setTimer: (run, ms) => timers.push({ run, at: now + ms }),
|
|
42
|
+
ignored: [/expected noise/],
|
|
43
|
+
});
|
|
44
|
+
const posts: { channel: ChannelKey; text: string }[] = [];
|
|
45
|
+
const turns: string[] = [];
|
|
46
|
+
const lines: LogEntry[] = [];
|
|
47
|
+
const logger = pino(
|
|
48
|
+
{ base: { app: "roundtable" } },
|
|
49
|
+
{
|
|
50
|
+
write(line: string) {
|
|
51
|
+
const logged = JSON.parse(line) as LogEntry;
|
|
52
|
+
lines.push(logged);
|
|
53
|
+
reporter.record(logged);
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
);
|
|
57
|
+
const agent = "agent" in options ? options.agent : infra;
|
|
58
|
+
const delivery = {
|
|
59
|
+
channelOf: (name: string): ChannelKey | undefined =>
|
|
60
|
+
name === "infra" && agent?.status === "active" && agent.channelId
|
|
61
|
+
? `discord:${agent.channelId}`
|
|
62
|
+
: undefined,
|
|
63
|
+
post: async (channel: ChannelKey, text: string) => {
|
|
64
|
+
posts.push({ channel, text });
|
|
65
|
+
},
|
|
66
|
+
turn: async (_channel: ChannelKey, text: string) => {
|
|
67
|
+
turns.push(text);
|
|
68
|
+
await options.turn?.();
|
|
69
|
+
return { status: "ran" } as ScheduledOutcome;
|
|
70
|
+
},
|
|
71
|
+
logger,
|
|
72
|
+
};
|
|
73
|
+
return {
|
|
74
|
+
reporter,
|
|
75
|
+
delivery,
|
|
76
|
+
posts,
|
|
77
|
+
turns,
|
|
78
|
+
lines,
|
|
79
|
+
logger,
|
|
80
|
+
connect: () => reporter.connect(delivery),
|
|
81
|
+
advance(ms: number) {
|
|
82
|
+
now += ms;
|
|
83
|
+
for (const timer of timers.splice(0))
|
|
84
|
+
if (timer.at <= now) timer.run();
|
|
85
|
+
else timers.push(timer);
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
|
|
91
|
+
|
|
92
|
+
describe("ErrorReporter", () => {
|
|
93
|
+
test("an error is posted in Infra's channel and starts its report turn", async () => {
|
|
94
|
+
const { reporter, connect, posts, turns } = setup();
|
|
95
|
+
connect();
|
|
96
|
+
reporter.record(
|
|
97
|
+
entry("webhook post failed", {
|
|
98
|
+
module: "discord",
|
|
99
|
+
channel: "discord:7",
|
|
100
|
+
err: { type: "Error", message: "boom", stack: stack("src/a.ts", 12) },
|
|
101
|
+
}),
|
|
102
|
+
);
|
|
103
|
+
await settle();
|
|
104
|
+
expect(posts).toHaveLength(1);
|
|
105
|
+
expect(posts[0]?.channel).toBe("discord:42");
|
|
106
|
+
const text = posts[0]?.text ?? "";
|
|
107
|
+
expect(text).toStartWith(
|
|
108
|
+
"Roundtable logged an error: webhook post failed\n",
|
|
109
|
+
);
|
|
110
|
+
expect(text).toContain("app: roundtable · module: discord");
|
|
111
|
+
expect(text).toContain("channel: discord:7");
|
|
112
|
+
expect(text).toContain("at run (/opt/app/src/a.ts:12:7)");
|
|
113
|
+
expect(text).not.toContain("pid");
|
|
114
|
+
expect(turns).toEqual([text]);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("a long report is trimmed", () => {
|
|
118
|
+
const text = reportText(
|
|
119
|
+
entry("big", {
|
|
120
|
+
err: { message: "x", stack: `Error: ${"y".repeat(5000)}` },
|
|
121
|
+
}),
|
|
122
|
+
"Roundtable",
|
|
123
|
+
);
|
|
124
|
+
expect(text.length).toBeLessThanOrEqual(1500);
|
|
125
|
+
expect(text).toEndWith("…");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("the fingerprint ignores times and ids but not where the error was raised", () => {
|
|
129
|
+
const a = entry("failed", {
|
|
130
|
+
channel: "discord:1",
|
|
131
|
+
err: { stack: stack("src/a.ts", 3) },
|
|
132
|
+
});
|
|
133
|
+
const b = entry("failed", {
|
|
134
|
+
channel: "discord:2",
|
|
135
|
+
time: 5,
|
|
136
|
+
err: { stack: stack("src/a.ts", 3) },
|
|
137
|
+
});
|
|
138
|
+
const c = entry("failed", { err: { stack: stack("src/b.ts", 3) } });
|
|
139
|
+
expect(fingerprint(a)).toBe(fingerprint(b));
|
|
140
|
+
expect(fingerprint(a)).not.toBe(fingerprint(c));
|
|
141
|
+
expect(fingerprint(entry("no stack", { job: 1 }))).toBe(
|
|
142
|
+
fingerprint(entry("no stack", { job: 2 })),
|
|
143
|
+
);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("the same error twice in an hour is reported once, and again after it", async () => {
|
|
147
|
+
const { reporter, connect, posts, advance } = setup();
|
|
148
|
+
connect();
|
|
149
|
+
const same = () =>
|
|
150
|
+
entry("failed", { err: { stack: stack("src/a.ts", 3) } });
|
|
151
|
+
reporter.record(same());
|
|
152
|
+
await settle();
|
|
153
|
+
advance(HOUR - 1);
|
|
154
|
+
reporter.record(same());
|
|
155
|
+
await settle();
|
|
156
|
+
expect(posts).toHaveLength(1);
|
|
157
|
+
advance(1);
|
|
158
|
+
reporter.record(same());
|
|
159
|
+
await settle();
|
|
160
|
+
expect(posts).toHaveLength(2);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("six distinct errors in an hour make five reports and a later summary", async () => {
|
|
164
|
+
const { reporter, connect, posts, turns, advance } = setup();
|
|
165
|
+
connect();
|
|
166
|
+
for (let i = 0; i < 6; i++) {
|
|
167
|
+
reporter.record(entry(`failure ${i}`));
|
|
168
|
+
advance(60_000);
|
|
169
|
+
}
|
|
170
|
+
reporter.record(entry("failure 5"));
|
|
171
|
+
await settle();
|
|
172
|
+
expect(posts).toHaveLength(5);
|
|
173
|
+
advance(HOUR);
|
|
174
|
+
await settle();
|
|
175
|
+
expect(posts).toHaveLength(6);
|
|
176
|
+
expect(posts[5]?.text).toBe(
|
|
177
|
+
"Roundtable error reports: 2 more errors suppressed: failure 5 (×2)",
|
|
178
|
+
);
|
|
179
|
+
expect(turns).toHaveLength(6);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("errors logged while Infra runs an error-report turn are summed up after it", async () => {
|
|
183
|
+
let finish = () => {};
|
|
184
|
+
const running = new Promise<void>((resolve) => {
|
|
185
|
+
finish = resolve;
|
|
186
|
+
});
|
|
187
|
+
const { reporter, connect, posts, logger } = setup({ turn: () => running });
|
|
188
|
+
connect();
|
|
189
|
+
reporter.record(entry("first"));
|
|
190
|
+
await settle();
|
|
191
|
+
reporter.record(entry("during the turn"));
|
|
192
|
+
logger.error("also during the turn");
|
|
193
|
+
await settle();
|
|
194
|
+
expect(posts).toHaveLength(1);
|
|
195
|
+
finish();
|
|
196
|
+
await settle();
|
|
197
|
+
await settle();
|
|
198
|
+
expect(posts).toHaveLength(2);
|
|
199
|
+
expect(posts[1]?.text).toStartWith(
|
|
200
|
+
"Roundtable error reports: 2 more errors suppressed:",
|
|
201
|
+
);
|
|
202
|
+
expect(posts[1]?.text).toContain("during the turn");
|
|
203
|
+
expect(posts[1]?.text).toContain("also during the turn");
|
|
204
|
+
await settle();
|
|
205
|
+
reporter.record(entry("after the turn"));
|
|
206
|
+
await settle();
|
|
207
|
+
expect(posts).toHaveLength(3);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("the reporter's own failures are only logged", async () => {
|
|
211
|
+
const { reporter, delivery, lines, turns } = setup();
|
|
212
|
+
delivery.post = async () => {
|
|
213
|
+
throw new Error("discord down");
|
|
214
|
+
};
|
|
215
|
+
delivery.turn = async () => {
|
|
216
|
+
throw new Error("turn broke");
|
|
217
|
+
};
|
|
218
|
+
reporter.connect(delivery);
|
|
219
|
+
reporter.record(entry("first"));
|
|
220
|
+
await settle();
|
|
221
|
+
await settle();
|
|
222
|
+
expect(lines.map((l) => l.msg)).toEqual([
|
|
223
|
+
"error report not posted",
|
|
224
|
+
"error report turn failed",
|
|
225
|
+
]);
|
|
226
|
+
expect(turns).toHaveLength(0);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("nothing is reported when Infra is missing or archived", async () => {
|
|
230
|
+
for (const agent of [
|
|
231
|
+
undefined,
|
|
232
|
+
{ ...infra, status: "archived" as const },
|
|
233
|
+
]) {
|
|
234
|
+
const { reporter, connect, posts, turns } = setup({ agent });
|
|
235
|
+
connect();
|
|
236
|
+
reporter.record(entry("failed"));
|
|
237
|
+
await settle();
|
|
238
|
+
expect(posts).toHaveLength(0);
|
|
239
|
+
expect(turns).toHaveLength(0);
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("warnings and ignored errors are not reported", async () => {
|
|
244
|
+
const { reporter, connect, posts } = setup();
|
|
245
|
+
connect();
|
|
246
|
+
reporter.record({ ...entry("just a warning"), level: 40 });
|
|
247
|
+
reporter.record(entry("some expected noise here"));
|
|
248
|
+
await settle();
|
|
249
|
+
expect(posts).toHaveLength(0);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test("errors before the agent server is ready wait, the last twenty kept", async () => {
|
|
253
|
+
const { reporter, connect, posts, lines } = setup();
|
|
254
|
+
for (let i = 0; i < 23; i++) reporter.record(entry(`early ${i}`));
|
|
255
|
+
expect(posts).toHaveLength(0);
|
|
256
|
+
connect();
|
|
257
|
+
await settle();
|
|
258
|
+
expect(posts.map((p) => p.text.split("\n")[0])).toEqual(
|
|
259
|
+
[3, 4, 5, 6, 7].map((i) => `Roundtable logged an error: early ${i}`),
|
|
260
|
+
);
|
|
261
|
+
expect(lines[0]).toMatchObject({
|
|
262
|
+
level: 40,
|
|
263
|
+
dropped: 3,
|
|
264
|
+
msg: "early errors dropped before reporting started",
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
describe("createLogger", () => {
|
|
270
|
+
test("hands error and fatal entries to the sink, not warnings", () => {
|
|
271
|
+
const taken: LogEntry[] = [];
|
|
272
|
+
const logger = createLogger("roundtable", (logged) => taken.push(logged));
|
|
273
|
+
logger.level = "error";
|
|
274
|
+
logger.warn("a warning");
|
|
275
|
+
logger.error({ job: 1 }, "an error");
|
|
276
|
+
logger.fatal("fatal");
|
|
277
|
+
expect(taken.map((t) => [t.level, t.msg, t.app])).toEqual([
|
|
278
|
+
[50, "an error", "roundtable"],
|
|
279
|
+
[60, "fatal", "roundtable"],
|
|
280
|
+
]);
|
|
281
|
+
expect(taken[0]?.job).toBe(1);
|
|
282
|
+
});
|
|
283
|
+
});
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import type { ScheduledOutcome } from "../contract/channels.ts";
|
|
3
|
+
import type { LogEntry, Logger } from "../log.ts";
|
|
4
|
+
import type { ChannelKey } from "../sessions.ts";
|
|
5
|
+
|
|
6
|
+
/** Errors that are expected and never reported, matched against the message and the error's. */
|
|
7
|
+
export const IGNORED_ERRORS: readonly RegExp[] = [];
|
|
8
|
+
|
|
9
|
+
const HOUR_MS = 3_600_000;
|
|
10
|
+
/** Reports, summaries included, per rolling hour. */
|
|
11
|
+
const REPORTS_PER_HOUR = 5;
|
|
12
|
+
/** Errors kept from before the agent server is ready; older ones are dropped and counted. */
|
|
13
|
+
const EARLY_BUFFER = 20;
|
|
14
|
+
const REPORT_LIMIT = 1500;
|
|
15
|
+
const FIELD_LIMIT = 200;
|
|
16
|
+
const STACK_FRAMES = 8;
|
|
17
|
+
/** pino's own keys, and the ones the report shows apart from the context. */
|
|
18
|
+
const OWN_KEYS = new Set(["level", "time", "pid", "hostname", "msg", "err"]);
|
|
19
|
+
const HEAD_KEYS = ["app", "module"];
|
|
20
|
+
const APP_FRAME = /((?:src|shared|worker)\/[^\s():]+\.ts):(\d+)/;
|
|
21
|
+
|
|
22
|
+
/** Where the reports go, known once the Discord surface and the agent team are up. */
|
|
23
|
+
export interface ErrorReportDelivery {
|
|
24
|
+
/** The agent's channel while it is active; undefined for an archived or unknown agent. */
|
|
25
|
+
channelOf(agent: string): ChannelKey | undefined;
|
|
26
|
+
/** The visible message in the agent's channel. */
|
|
27
|
+
post(channel: ChannelKey, text: string): Promise<void>;
|
|
28
|
+
/** The agent's report turn; resolves when it has run. */
|
|
29
|
+
turn(channel: ChannelKey, text: string): Promise<ScheduledOutcome>;
|
|
30
|
+
logger: Logger;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ErrorReporterOptions {
|
|
34
|
+
/** The agent that investigates the errors, reported in its channel. */
|
|
35
|
+
opsAgent: string;
|
|
36
|
+
/** The name the reports give the process, such as "Roundtable". */
|
|
37
|
+
app: string;
|
|
38
|
+
ignored?: readonly RegExp[];
|
|
39
|
+
/** Replaceable in tests. */
|
|
40
|
+
now?: () => number;
|
|
41
|
+
/** Replaceable in tests. */
|
|
42
|
+
setTimer?: (run: () => void, ms: number) => void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The process's own `error` and `fatal` log entries, each reported to the ops agent as a visible
|
|
47
|
+
* message and a report turn in its channel. The same error goes at most once an hour, and at
|
|
48
|
+
* most five reports an hour overall; the rest are counted and summed up in one report when the
|
|
49
|
+
* hour allows. Errors from the reporting itself are only logged; errors logged elsewhere while
|
|
50
|
+
* an error-report turn runs are counted and summed up once it ends.
|
|
51
|
+
*/
|
|
52
|
+
export class ErrorReporter {
|
|
53
|
+
readonly #opsAgent: string;
|
|
54
|
+
readonly #app: string;
|
|
55
|
+
readonly #ignored: readonly RegExp[];
|
|
56
|
+
readonly #now: () => number;
|
|
57
|
+
readonly #setTimer: (run: () => void, ms: number) => void;
|
|
58
|
+
/** Set inside the reporting path: posting, the turn, and the reporter's own logging. */
|
|
59
|
+
readonly #reporting = new AsyncLocalStorage<true>();
|
|
60
|
+
#delivery: ErrorReportDelivery | undefined;
|
|
61
|
+
#early: LogEntry[] = [];
|
|
62
|
+
#earlyDropped = 0;
|
|
63
|
+
/** Report turns started and not yet finished. */
|
|
64
|
+
#turns = 0;
|
|
65
|
+
#sent: number[] = [];
|
|
66
|
+
readonly #lastSent = new Map<string, number>();
|
|
67
|
+
readonly #suppressed = new Map<string, number>();
|
|
68
|
+
#summaryDue = false;
|
|
69
|
+
|
|
70
|
+
constructor(options: ErrorReporterOptions) {
|
|
71
|
+
this.#opsAgent = options.opsAgent;
|
|
72
|
+
this.#app = options.app;
|
|
73
|
+
this.#ignored = options.ignored ?? IGNORED_ERRORS;
|
|
74
|
+
this.#now = options.now ?? Date.now;
|
|
75
|
+
this.#setTimer =
|
|
76
|
+
options.setTimer ??
|
|
77
|
+
((run, ms) => {
|
|
78
|
+
setTimeout(run, ms).unref();
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Takes one log entry; the logger calls it for every `error` and `fatal` line. */
|
|
83
|
+
record(entry: LogEntry): void {
|
|
84
|
+
if (typeof entry.level !== "number" || entry.level < 50) return;
|
|
85
|
+
if (this.#reporting.getStore()) return;
|
|
86
|
+
if (this.#isIgnored(entry)) return;
|
|
87
|
+
if (this.#turns > 0) {
|
|
88
|
+
// The ops agent is already looking at an error; these wait for the summary after its turn.
|
|
89
|
+
const key = fingerprint(entry);
|
|
90
|
+
this.#suppressed.set(key, (this.#suppressed.get(key) ?? 0) + 1);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (!this.#delivery) {
|
|
94
|
+
this.#early.push(entry);
|
|
95
|
+
if (this.#early.length > EARLY_BUFFER) {
|
|
96
|
+
this.#early.shift();
|
|
97
|
+
this.#earlyDropped += 1;
|
|
98
|
+
}
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
this.#consider(entry);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Starts reporting, the errors kept from startup first; later calls do nothing. */
|
|
105
|
+
connect(delivery: ErrorReportDelivery): void {
|
|
106
|
+
if (this.#delivery) return;
|
|
107
|
+
this.#delivery = delivery;
|
|
108
|
+
const early = this.#early;
|
|
109
|
+
this.#early = [];
|
|
110
|
+
if (this.#earlyDropped > 0)
|
|
111
|
+
this.#reporting.run(true, () =>
|
|
112
|
+
delivery.logger.warn(
|
|
113
|
+
{ dropped: this.#earlyDropped },
|
|
114
|
+
"early errors dropped before reporting started",
|
|
115
|
+
),
|
|
116
|
+
);
|
|
117
|
+
this.#earlyDropped = 0;
|
|
118
|
+
for (const entry of early) this.#consider(entry);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
#isIgnored(entry: LogEntry): boolean {
|
|
122
|
+
const text = `${String(entry.msg ?? "")}\n${errorOf(entry)?.message ?? ""}`;
|
|
123
|
+
return this.#ignored.some((pattern) => pattern.test(text));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
#consider(entry: LogEntry): void {
|
|
127
|
+
const now = this.#now();
|
|
128
|
+
const key = fingerprint(entry);
|
|
129
|
+
const last = this.#lastSent.get(key);
|
|
130
|
+
if (last !== undefined && now - last < HOUR_MS) return;
|
|
131
|
+
if (!this.#hasRoom(now)) {
|
|
132
|
+
this.#suppressed.set(key, (this.#suppressed.get(key) ?? 0) + 1);
|
|
133
|
+
this.#scheduleSummary(now);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
this.#lastSent.set(key, now);
|
|
137
|
+
this.#sent.push(now);
|
|
138
|
+
void this.#deliver(reportText(entry, this.#app));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
#hasRoom(now: number): boolean {
|
|
142
|
+
this.#sent = this.#sent.filter((at) => now - at < HOUR_MS);
|
|
143
|
+
return this.#sent.length < REPORTS_PER_HOUR;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
#scheduleSummary(now: number): void {
|
|
147
|
+
if (this.#summaryDue) return;
|
|
148
|
+
this.#summaryDue = true;
|
|
149
|
+
const oldest = this.#sent[0] ?? now;
|
|
150
|
+
this.#setTimer(() => this.#summarize(), oldest + HOUR_MS - now);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
#summarize(): void {
|
|
154
|
+
this.#summaryDue = false;
|
|
155
|
+
const now = this.#now();
|
|
156
|
+
if (this.#suppressed.size === 0) return;
|
|
157
|
+
if (!this.#hasRoom(now)) {
|
|
158
|
+
this.#scheduleSummary(now);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
const counts = [...this.#suppressed];
|
|
162
|
+
this.#suppressed.clear();
|
|
163
|
+
const total = counts.reduce((sum, [, n]) => sum + n, 0);
|
|
164
|
+
for (const [key] of counts) this.#lastSent.set(key, now);
|
|
165
|
+
this.#sent.push(now);
|
|
166
|
+
const list = counts
|
|
167
|
+
.map(([key, n]) => (n > 1 ? `${key} (×${n})` : key))
|
|
168
|
+
.join("; ");
|
|
169
|
+
void this.#deliver(
|
|
170
|
+
trim(
|
|
171
|
+
`${this.#app} error reports: ${total} more errors suppressed: ${list}`,
|
|
172
|
+
),
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
#deliver(text: string): Promise<void> {
|
|
177
|
+
const delivery = this.#delivery;
|
|
178
|
+
if (!delivery) return Promise.resolve();
|
|
179
|
+
return this.#reporting.run(true, async () => {
|
|
180
|
+
const channel = delivery.channelOf(this.#opsAgent);
|
|
181
|
+
if (!channel) return;
|
|
182
|
+
try {
|
|
183
|
+
await delivery.post(channel, text);
|
|
184
|
+
// pi-lens-ignore: missing-error-propagation — a report never throws back into the logger
|
|
185
|
+
} catch (error) {
|
|
186
|
+
delivery.logger.error(
|
|
187
|
+
{ channel, err: error },
|
|
188
|
+
"error report not posted",
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
this.#turns += 1;
|
|
192
|
+
try {
|
|
193
|
+
const outcome = await delivery.turn(channel, text);
|
|
194
|
+
if (outcome.status !== "ran")
|
|
195
|
+
delivery.logger.warn(
|
|
196
|
+
{ channel, outcome },
|
|
197
|
+
"error report not answered",
|
|
198
|
+
);
|
|
199
|
+
// pi-lens-ignore: missing-error-propagation — a report never throws back into the logger
|
|
200
|
+
} catch (error) {
|
|
201
|
+
delivery.logger.error(
|
|
202
|
+
{ channel, err: error },
|
|
203
|
+
"error report turn failed",
|
|
204
|
+
);
|
|
205
|
+
} finally {
|
|
206
|
+
this.#turns -= 1;
|
|
207
|
+
if (this.#turns === 0 && this.#suppressed.size > 0) this.#summarize();
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function errorOf(
|
|
214
|
+
entry: LogEntry,
|
|
215
|
+
): { type?: string; message?: string; stack?: string } | undefined {
|
|
216
|
+
const err = entry.err;
|
|
217
|
+
return err && typeof err === "object"
|
|
218
|
+
? (err as Record<string, string>)
|
|
219
|
+
: undefined;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* What makes two errors the same: the message and where it was raised, the first frame of
|
|
224
|
+
* the process's own code, or else the names of the logged fields. Never times or ids.
|
|
225
|
+
*/
|
|
226
|
+
export function fingerprint(entry: LogEntry): string {
|
|
227
|
+
const frame = errorOf(entry)?.stack?.match(APP_FRAME);
|
|
228
|
+
const where = frame
|
|
229
|
+
? `${frame[1]}:${frame[2]}`
|
|
230
|
+
: // pi-lens-ignore: no-sort-without-comparator — field names, in string order
|
|
231
|
+
contextKeys(entry).sort().join(",");
|
|
232
|
+
const msg = String(entry.msg ?? "");
|
|
233
|
+
return where ? `${msg} @ ${where}` : msg;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function contextKeys(entry: LogEntry): string[] {
|
|
237
|
+
return Object.keys(entry).filter(
|
|
238
|
+
(key) => !OWN_KEYS.has(key) && !HEAD_KEYS.includes(key),
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** The report: the message, app and module, the logged fields, and a trimmed stack. */
|
|
243
|
+
export function reportText(entry: LogEntry, app: string): string {
|
|
244
|
+
const lines = [`${app} logged an error: ${String(entry.msg ?? "")}`];
|
|
245
|
+
const head = HEAD_KEYS.flatMap((key) =>
|
|
246
|
+
entry[key] === undefined ? [] : [`${key}: ${String(entry[key])}`],
|
|
247
|
+
);
|
|
248
|
+
if (head.length > 0) lines.push(head.join(" · "));
|
|
249
|
+
for (const key of contextKeys(entry))
|
|
250
|
+
lines.push(`${key}: ${short(entry[key])}`);
|
|
251
|
+
const err = errorOf(entry);
|
|
252
|
+
if (err?.stack) {
|
|
253
|
+
const [first, ...frames] = err.stack.split("\n");
|
|
254
|
+
lines.push(
|
|
255
|
+
first ?? "",
|
|
256
|
+
...frames.slice(0, STACK_FRAMES).map((frame) => frame.trimEnd()),
|
|
257
|
+
);
|
|
258
|
+
} else if (err?.message) lines.push(`${err.type ?? "Error"}: ${err.message}`);
|
|
259
|
+
return trim(lines.join("\n"));
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function short(value: unknown): string {
|
|
263
|
+
const text = typeof value === "string" ? value : JSON.stringify(value);
|
|
264
|
+
return text.length > FIELD_LIMIT ? `${text.slice(0, FIELD_LIMIT)}…` : text;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function trim(text: string): string {
|
|
268
|
+
return text.length > REPORT_LIMIT
|
|
269
|
+
? `${text.slice(0, REPORT_LIMIT - 1)}…`
|
|
270
|
+
: text;
|
|
271
|
+
}
|