switchroom 0.5.0 โ 0.7.9
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/README.md +142 -121
- package/bin/autoaccept.exp +29 -6
- package/dist/agent-scheduler/index.js +12261 -0
- package/dist/cli/autoaccept-poll.js +10 -0
- package/dist/cli/switchroom.js +27250 -25324
- package/dist/vault/approvals/kernel-server.js +12709 -0
- package/dist/vault/broker/server.js +15724 -0
- package/package.json +4 -3
- package/profiles/_base/start.sh.hbs +133 -0
- package/profiles/_shared/telegram-style.md.hbs +3 -3
- package/profiles/default/CLAUDE.md +3 -3
- package/profiles/default/CLAUDE.md.hbs +2 -2
- package/profiles/default/workspace/CLAUDE.md.hbs +9 -0
- package/skills/docx/VENDORED.md +1 -1
- package/skills/mcp-builder/VENDORED.md +1 -1
- package/skills/pdf/VENDORED.md +1 -1
- package/skills/pptx/VENDORED.md +1 -1
- package/skills/skill-creator/VENDORED.md +1 -1
- package/skills/switchroom-architecture/SKILL.md +8 -7
- package/skills/switchroom-cli/SKILL.md +23 -15
- package/skills/switchroom-health/SKILL.md +7 -7
- package/skills/switchroom-install/SKILL.md +36 -39
- package/skills/switchroom-manage/SKILL.md +4 -4
- package/skills/switchroom-status/SKILL.md +1 -1
- package/skills/webapp-testing/VENDORED.md +1 -1
- package/skills/xlsx/VENDORED.md +1 -1
- package/telegram-plugin/admin-commands/dispatch.test.ts +119 -1
- package/telegram-plugin/admin-commands/index.ts +71 -0
- package/telegram-plugin/ask-user.ts +1 -0
- package/telegram-plugin/card-event-log.ts +138 -0
- package/telegram-plugin/dist/bridge/bridge.js +178 -31
- package/telegram-plugin/dist/foreman/foreman.js +6875 -6526
- package/telegram-plugin/dist/gateway/gateway.js +13862 -11834
- package/telegram-plugin/dist/server.js +202 -40
- package/telegram-plugin/fleet-state.ts +25 -10
- package/telegram-plugin/foreman/foreman.ts +38 -3
- package/telegram-plugin/gateway/approval-callback.ts +126 -0
- package/telegram-plugin/gateway/approval-card.test.ts +90 -0
- package/telegram-plugin/gateway/approval-card.ts +127 -0
- package/telegram-plugin/gateway/approvals-commands.ts +126 -0
- package/telegram-plugin/gateway/boot-card.ts +31 -6
- package/telegram-plugin/gateway/boot-probes.ts +510 -72
- package/telegram-plugin/gateway/gateway.ts +822 -94
- package/telegram-plugin/gateway/ipc-protocol.ts +34 -1
- package/telegram-plugin/gateway/ipc-server.ts +35 -0
- package/telegram-plugin/gateway/startup-mutex.ts +110 -2
- package/telegram-plugin/hooks/hooks.json +19 -0
- package/telegram-plugin/hooks/tool-label-pretool.mjs +216 -0
- package/telegram-plugin/hooks/tool-label-stop.mjs +63 -0
- package/telegram-plugin/package.json +4 -1
- package/telegram-plugin/plugin-logger.ts +20 -1
- package/telegram-plugin/progress-card-driver.ts +202 -13
- package/telegram-plugin/progress-card.ts +2 -2
- package/telegram-plugin/quota-check.ts +1 -0
- package/telegram-plugin/registry/subagents-schema.ts +37 -0
- package/telegram-plugin/registry/subagents.test.ts +64 -0
- package/telegram-plugin/session-tail.ts +58 -5
- package/telegram-plugin/shared/bot-runtime.ts +48 -2
- package/telegram-plugin/subagent-watcher.ts +139 -7
- package/telegram-plugin/tests/_progress-card-harness.ts +4 -0
- package/telegram-plugin/tests/bg-agent-progress-card-757.test.ts +201 -0
- package/telegram-plugin/tests/boot-card-probe-target.test.ts +10 -34
- package/telegram-plugin/tests/boot-card-render.test.ts +6 -5
- package/telegram-plugin/tests/boot-probes.test.ts +564 -0
- package/telegram-plugin/tests/card-event-log.test.ts +145 -0
- package/telegram-plugin/tests/gateway-startup-mutex.test.ts +102 -0
- package/telegram-plugin/tests/ipc-server-validate-inject-inbound.test.ts +134 -0
- package/telegram-plugin/tests/progress-card-delay-842.test.ts +160 -0
- package/telegram-plugin/tests/quota-check.test.ts +37 -1
- package/telegram-plugin/tests/subagent-registry-bugs.test.ts +5 -0
- package/telegram-plugin/tests/subagent-watcher-stall-notification.test.ts +104 -1
- package/telegram-plugin/tests/subagent-watcher.test.ts +5 -0
- package/telegram-plugin/tests/tool-label-sidecar.test.ts +114 -0
- package/telegram-plugin/tests/two-zone-bg-done-when-all-terminal.test.ts +5 -3
- package/telegram-plugin/tests/two-zone-card-header-phases.test.ts +10 -0
- package/telegram-plugin/tests/two-zone-snapshot-extras.test.ts +58 -14
- package/telegram-plugin/tests/welcome-text.test.ts +57 -0
- package/telegram-plugin/tool-label-sidecar.ts +140 -0
- package/telegram-plugin/tool-labels.ts +55 -0
- package/telegram-plugin/two-zone-card.ts +27 -7
- package/telegram-plugin/uat/SETUP.md +160 -0
- package/telegram-plugin/uat/assertions.ts +140 -0
- package/telegram-plugin/uat/driver.ts +174 -0
- package/telegram-plugin/uat/harness.ts +161 -0
- package/telegram-plugin/uat/login.ts +134 -0
- package/telegram-plugin/uat/port-allocator.ts +71 -0
- package/telegram-plugin/uat/scenarios/smoke-clerk-reply.test.ts +61 -0
- package/telegram-plugin/welcome-text.ts +44 -2
- package/bin/bridge-watchdog.sh +0 -967
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scenario harness for UAT runs.
|
|
3
|
+
*
|
|
4
|
+
* Issue: https://github.com/switchroom/switchroom/issues/866
|
|
5
|
+
*
|
|
6
|
+
* `spinUp({ agent, topic })` is the single entry point a scenario
|
|
7
|
+
* uses. Phase 1 ships the type shape + the lifecycle skeleton; the
|
|
8
|
+
* actual `child_process.spawn` of the agent under test is stubbed
|
|
9
|
+
* with TODO markers so the reviewer can see exactly where Phase 2
|
|
10
|
+
* lands.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { Driver } from "./driver.js";
|
|
14
|
+
import {
|
|
15
|
+
expectMessage,
|
|
16
|
+
expectPinnedCard,
|
|
17
|
+
expectReaction,
|
|
18
|
+
waitForCardPhase,
|
|
19
|
+
type PollOptions,
|
|
20
|
+
type PinnedCardSnapshot,
|
|
21
|
+
} from "./assertions.js";
|
|
22
|
+
import { allocatePort } from "./port-allocator.js";
|
|
23
|
+
|
|
24
|
+
export interface SpinUpOptions {
|
|
25
|
+
/** Agent name to install + run, e.g. `"clerk"`, `"test-harness"`. */
|
|
26
|
+
agent: string;
|
|
27
|
+
/**
|
|
28
|
+
* Forum topic slug for isolation. The harness creates the topic in
|
|
29
|
+
* the test supergroup and tears it down after the scenario.
|
|
30
|
+
*/
|
|
31
|
+
topic: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface Scenario {
|
|
35
|
+
/** mtcute driver, already connected. */
|
|
36
|
+
driver: Driver;
|
|
37
|
+
/** Negative supergroup chat id; from `$SWITCHROOM_UAT_CHAT_ID`. */
|
|
38
|
+
chatId: number;
|
|
39
|
+
/** Topic id created for this scenario. */
|
|
40
|
+
threadId: number;
|
|
41
|
+
|
|
42
|
+
// Sugar over the assertion helpers, pre-bound to this scenario's
|
|
43
|
+
// chat + thread. Phase 1 returns a thin pass-through.
|
|
44
|
+
expectMessage: (
|
|
45
|
+
match: Parameters<typeof expectMessage>[2],
|
|
46
|
+
opts: PollOptions & { from?: "bot" | "user" },
|
|
47
|
+
) => ReturnType<typeof expectMessage>;
|
|
48
|
+
expectReaction: (
|
|
49
|
+
messageId: number,
|
|
50
|
+
sequence: string[],
|
|
51
|
+
opts: PollOptions,
|
|
52
|
+
) => ReturnType<typeof expectReaction>;
|
|
53
|
+
expectPinnedCard: (opts: PollOptions) => ReturnType<typeof expectPinnedCard>;
|
|
54
|
+
waitForCardPhase: (
|
|
55
|
+
card: PinnedCardSnapshot,
|
|
56
|
+
phase: "boot" | "working" | "done" | "error",
|
|
57
|
+
opts: PollOptions,
|
|
58
|
+
) => ReturnType<typeof waitForCardPhase>;
|
|
59
|
+
|
|
60
|
+
/** Stop the agent process, delete the topic, disconnect the driver. */
|
|
61
|
+
tearDown: () => Promise<void>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Spin up an isolated agent + scenario context.
|
|
66
|
+
*
|
|
67
|
+
* Phase 1: returns a stub Scenario whose tools throw helpful "not
|
|
68
|
+
* implemented" errors. The shape is correct so scenarios written
|
|
69
|
+
* against it will typecheck today and run for real once Phase 2
|
|
70
|
+
* lands.
|
|
71
|
+
*/
|
|
72
|
+
export async function spinUp(opts: SpinUpOptions): Promise<Scenario> {
|
|
73
|
+
// TODO(#866): resolve secrets from vault.
|
|
74
|
+
// - `telegram-test-bot-token` for the agent under test
|
|
75
|
+
// - `telegram-uat-driver-session` for the mtcute driver
|
|
76
|
+
// - `TELEGRAM_API_ID` / `TELEGRAM_API_HASH` from env
|
|
77
|
+
// For now we throw early with a clear message so accidental runs
|
|
78
|
+
// before Phase 2 don't crash with confusing stack traces.
|
|
79
|
+
if (!process.env.SWITCHROOM_UAT_CHAT_ID) {
|
|
80
|
+
throw new Error(
|
|
81
|
+
"[uat/harness] SWITCHROOM_UAT_CHAT_ID is not set โ see uat/SETUP.md ยง2",
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
const chatId = Number.parseInt(process.env.SWITCHROOM_UAT_CHAT_ID, 10);
|
|
85
|
+
if (!Number.isFinite(chatId) || chatId >= 0) {
|
|
86
|
+
throw new Error(
|
|
87
|
+
`[uat/harness] SWITCHROOM_UAT_CHAT_ID must be a negative supergroup id (got ${process.env.SWITCHROOM_UAT_CHAT_ID})`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// TODO(#866): allocate gateway port + ephemeral STATE_DIR.
|
|
92
|
+
// const port = await allocatePort();
|
|
93
|
+
// const stateDir = await mkdtemp(join(tmpdir(), `uat-${opts.agent}-`));
|
|
94
|
+
// process.env.STATE_DIR is per-process โ we instead pass STATE_DIR
|
|
95
|
+
// in the spawned child's env, never mutate ours.
|
|
96
|
+
const port = await allocatePort();
|
|
97
|
+
void port; // Phase 2: feed into agent child env
|
|
98
|
+
|
|
99
|
+
// TODO(#866): create the forum topic via Bot API
|
|
100
|
+
// (`createForumTopic`) using the test bot token; capture the
|
|
101
|
+
// returned `message_thread_id` and stash for tearDown's
|
|
102
|
+
// `deleteForumTopic`.
|
|
103
|
+
const threadId = -1; // sentinel; Phase 2 fills in
|
|
104
|
+
|
|
105
|
+
// TODO(#866): spawn the agent under test as a child process.
|
|
106
|
+
// const child = spawn(process.execPath, [agentEntry], {
|
|
107
|
+
// env: {
|
|
108
|
+
// ...process.env,
|
|
109
|
+
// STATE_DIR: stateDir,
|
|
110
|
+
// TELEGRAM_GATEWAY_PORT: String(port),
|
|
111
|
+
// SWITCHROOM_AGENT_NAME: opts.agent,
|
|
112
|
+
// BOT_TOKEN: <vault: telegram-test-bot-token>,
|
|
113
|
+
// },
|
|
114
|
+
// stdio: ["ignore", "pipe", "pipe"],
|
|
115
|
+
// });
|
|
116
|
+
// await waitForGatewayReady(port, { timeout: 30_000 });
|
|
117
|
+
|
|
118
|
+
// TODO(#866): connect mtcute driver.
|
|
119
|
+
// const driver = new Driver({ apiId, apiHash, session });
|
|
120
|
+
// await driver.connect();
|
|
121
|
+
const driver = new Driver({
|
|
122
|
+
apiId: 0,
|
|
123
|
+
apiHash: "",
|
|
124
|
+
session: "<resolved-from-vault>",
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const scenario: Scenario = {
|
|
128
|
+
driver,
|
|
129
|
+
chatId,
|
|
130
|
+
threadId,
|
|
131
|
+
expectMessage: (match, pollOpts) =>
|
|
132
|
+
expectMessage(driver, chatId, match, {
|
|
133
|
+
...pollOpts,
|
|
134
|
+
threadId,
|
|
135
|
+
}),
|
|
136
|
+
expectReaction: (messageId, sequence, pollOpts) =>
|
|
137
|
+
expectReaction(driver, chatId, messageId, sequence, pollOpts),
|
|
138
|
+
expectPinnedCard: (pollOpts) =>
|
|
139
|
+
expectPinnedCard(driver, chatId, { ...pollOpts, threadId }),
|
|
140
|
+
waitForCardPhase: (card, phase, pollOpts) =>
|
|
141
|
+
waitForCardPhase(driver, card, phase, pollOpts),
|
|
142
|
+
tearDown: async () => {
|
|
143
|
+
// TODO(#866): SIGTERM child, await exit (or SIGKILL after 5s),
|
|
144
|
+
// delete forum topic, rm -rf state dir, disconnect driver.
|
|
145
|
+
await driver.disconnect().catch(() => {
|
|
146
|
+
/* idempotent */
|
|
147
|
+
});
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
// Phase 1 marker so accidental runs fail loudly instead of silently
|
|
152
|
+
// sending nothing.
|
|
153
|
+
void opts;
|
|
154
|
+
throw new Error(
|
|
155
|
+
"[uat/harness] spinUp is scaffolded but not wired (Phase 1 stub) โ see TODO markers in uat/harness.ts",
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
// unreachable in Phase 1; left for shape:
|
|
159
|
+
// eslint-disable-next-line no-unreachable
|
|
160
|
+
return scenario;
|
|
161
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One-time interactive login script for the UAT mtcute driver.
|
|
3
|
+
*
|
|
4
|
+
* Issue: https://github.com/switchroom/switchroom/issues/865
|
|
5
|
+
*
|
|
6
|
+
* Run via `bun run uat:login` from telegram-plugin/. Prompts for
|
|
7
|
+
* phone, login code, and (optionally) 2FA password on stdin. Captures
|
|
8
|
+
* the session string in memory and writes it to vault under
|
|
9
|
+
* `telegram-uat-driver-session`. The session string is **never
|
|
10
|
+
* printed** โ not to stdout, not to stderr, not to logs. If you see
|
|
11
|
+
* one in scrollback, file an incident.
|
|
12
|
+
*
|
|
13
|
+
* Required env:
|
|
14
|
+
* TELEGRAM_API_ID โ from https://my.telegram.org/apps
|
|
15
|
+
* TELEGRAM_API_HASH โ from https://my.telegram.org/apps
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { spawn } from "node:child_process";
|
|
19
|
+
import { createInterface } from "node:readline/promises";
|
|
20
|
+
import { stdin as input, stdout as output } from "node:process";
|
|
21
|
+
import { TelegramClient } from "@mtcute/node";
|
|
22
|
+
|
|
23
|
+
const VAULT_KEY = "telegram-uat-driver-session";
|
|
24
|
+
|
|
25
|
+
async function main(): Promise<void> {
|
|
26
|
+
const apiId = Number.parseInt(process.env.TELEGRAM_API_ID ?? "", 10);
|
|
27
|
+
const apiHash = process.env.TELEGRAM_API_HASH ?? "";
|
|
28
|
+
if (!Number.isFinite(apiId) || !apiHash) {
|
|
29
|
+
fail(
|
|
30
|
+
"TELEGRAM_API_ID and TELEGRAM_API_HASH must be set. See uat/SETUP.md ยง3.",
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const rl = createInterface({ input, output, terminal: true });
|
|
35
|
+
|
|
36
|
+
// Confirm the operator understands the security posture before we
|
|
37
|
+
// mint a bearer-equivalent credential.
|
|
38
|
+
const ack = await rl.question(
|
|
39
|
+
[
|
|
40
|
+
"",
|
|
41
|
+
"About to mint a Telegram USER session string for the UAT driver.",
|
|
42
|
+
"This is bearer-equivalent to the user account. It will be stored",
|
|
43
|
+
"in vault under `" + VAULT_KEY + "` and NEVER printed.",
|
|
44
|
+
"",
|
|
45
|
+
"Type YES to proceed: ",
|
|
46
|
+
].join("\n"),
|
|
47
|
+
);
|
|
48
|
+
if (ack.trim() !== "YES") {
|
|
49
|
+
fail("Aborted.");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const phone = (await rl.question("Phone number (E.164, e.g. +14155551234): ")).trim();
|
|
53
|
+
if (!phone.startsWith("+")) fail("Phone must start with '+'.");
|
|
54
|
+
|
|
55
|
+
const client = new TelegramClient({ apiId, apiHash });
|
|
56
|
+
|
|
57
|
+
// mtcute exposes a `start()` flow that takes async callbacks for
|
|
58
|
+
// each interactive step. Exact callback names may shift across
|
|
59
|
+
// versions โ verify against the pinned mtcute version before first
|
|
60
|
+
// run.
|
|
61
|
+
await client.start({
|
|
62
|
+
phone: async () => phone,
|
|
63
|
+
code: async () =>
|
|
64
|
+
(await rl.question("Login code (from Telegram app or SMS): ")).trim(),
|
|
65
|
+
password: async () =>
|
|
66
|
+
(await rl.question("2FA password (leave blank if none): ")),
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// TODO(#865): mtcute v0.27 exports sessions via the
|
|
70
|
+
// `@mtcute/core/utils.js` `StringSessionStorage` adapter; the
|
|
71
|
+
// exact call is `await client.exportSession()` only when that
|
|
72
|
+
// storage is configured, otherwise sessions live in the SQLite
|
|
73
|
+
// file at `client.session`. Phase 2 wires the string-session
|
|
74
|
+
// storage so this script can mint a string. For now we throw
|
|
75
|
+
// before producing a value so the operator never gets a half-
|
|
76
|
+
// baked session in vault.
|
|
77
|
+
const session: string = await Promise.reject(
|
|
78
|
+
new Error(
|
|
79
|
+
"uat:login: Phase 1 stub โ Phase 2 wires StringSessionStorage. See uat/SETUP.md ยง3.",
|
|
80
|
+
),
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
await client.destroy();
|
|
84
|
+
rl.close();
|
|
85
|
+
|
|
86
|
+
// Write to vault via the switchroom CLI's stdin path so the
|
|
87
|
+
// session never appears in argv (which would land in `ps` output).
|
|
88
|
+
await writeToVault(VAULT_KEY, session);
|
|
89
|
+
|
|
90
|
+
// Belt-and-suspenders: zero out the local copy.
|
|
91
|
+
scrub(session);
|
|
92
|
+
|
|
93
|
+
process.stdout.write(
|
|
94
|
+
`\nDone. Session stored in vault as \`${VAULT_KEY}\`.\n` +
|
|
95
|
+
"If you ever see the actual session string in your terminal, file an incident.\n",
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function writeToVault(key: string, value: string): Promise<void> {
|
|
100
|
+
return new Promise((resolve, reject) => {
|
|
101
|
+
const proc = spawn("switchroom", ["vault", "set", key], {
|
|
102
|
+
stdio: ["pipe", "inherit", "inherit"],
|
|
103
|
+
});
|
|
104
|
+
proc.on("error", reject);
|
|
105
|
+
proc.on("exit", (code) => {
|
|
106
|
+
if (code === 0) resolve();
|
|
107
|
+
else reject(new Error(`switchroom vault set exited ${code}`));
|
|
108
|
+
});
|
|
109
|
+
proc.stdin.end(value + "\n");
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function scrub(_s: string): void {
|
|
114
|
+
// JS strings are immutable โ best we can do is drop the reference
|
|
115
|
+
// and trust the GC. Documented here so a future hardening pass
|
|
116
|
+
// (e.g. SecureBuffer) has a hook.
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function fail(msg: string): never {
|
|
120
|
+
process.stderr.write(`uat:login: ${msg}\n`);
|
|
121
|
+
process.exit(1);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
main().catch((err) => {
|
|
125
|
+
// Defensive: if mtcute throws, the error MAY contain the session
|
|
126
|
+
// string in some adapters. Strip anything that looks like a base64
|
|
127
|
+
// blob > 64 chars before printing.
|
|
128
|
+
const sanitized = String(err?.message ?? err).replace(
|
|
129
|
+
/[A-Za-z0-9+/=_-]{64,}/g,
|
|
130
|
+
"<redacted>",
|
|
131
|
+
);
|
|
132
|
+
process.stderr.write(`uat:login failed: ${sanitized}\n`);
|
|
133
|
+
process.exit(1);
|
|
134
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Process-wide unique port allocator for the UAT harness.
|
|
3
|
+
*
|
|
4
|
+
* Why this exists: each scenario `spinUp`s an agent child process
|
|
5
|
+
* with its own gateway listening on `TELEGRAM_GATEWAY_PORT`. If two
|
|
6
|
+
* sibling scenarios race to pick a port we get silent collisions
|
|
7
|
+
* that masquerade as flaky assertions ("the bridge couldn't reach
|
|
8
|
+
* the gateway, so no replies showed up"). See SETUP.md ยง6 for the
|
|
9
|
+
* port-vs-unix-socket decision.
|
|
10
|
+
*
|
|
11
|
+
* The allocator is monotonic per process and probes the candidate
|
|
12
|
+
* by `bind()`ing a transient socket โ catches the case where some
|
|
13
|
+
* other process on the host has stolen a port out of our range.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { createServer } from "node:net";
|
|
17
|
+
|
|
18
|
+
const DEFAULT_BASE_PORT = 47000;
|
|
19
|
+
const DEFAULT_MAX_PORT = 47999;
|
|
20
|
+
|
|
21
|
+
let cursor = DEFAULT_BASE_PORT;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Allocate the next free TCP port in the harness range.
|
|
25
|
+
*
|
|
26
|
+
* Throws if every port in [base, max] is taken โ almost certainly
|
|
27
|
+
* means a previous run leaked agent processes. Recovery: `pkill -f
|
|
28
|
+
* test-harness-uat` and try again.
|
|
29
|
+
*/
|
|
30
|
+
export async function allocatePort(opts?: {
|
|
31
|
+
base?: number;
|
|
32
|
+
max?: number;
|
|
33
|
+
}): Promise<number> {
|
|
34
|
+
const base = opts?.base ?? DEFAULT_BASE_PORT;
|
|
35
|
+
const max = opts?.max ?? DEFAULT_MAX_PORT;
|
|
36
|
+
|
|
37
|
+
if (cursor < base) cursor = base;
|
|
38
|
+
|
|
39
|
+
const start = cursor;
|
|
40
|
+
for (;;) {
|
|
41
|
+
const port = cursor;
|
|
42
|
+
cursor = cursor + 1 > max ? base : cursor + 1;
|
|
43
|
+
|
|
44
|
+
if (await isPortFree(port)) {
|
|
45
|
+
return port;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (cursor === start) {
|
|
49
|
+
throw new Error(
|
|
50
|
+
`[uat/port-allocator] no free ports in [${base}, ${max}]; ` +
|
|
51
|
+
"likely leaked agent child processes โ try `pkill -f test-harness-uat`",
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Reset the allocator. Test-only. */
|
|
58
|
+
export function _resetAllocatorForTests(): void {
|
|
59
|
+
cursor = DEFAULT_BASE_PORT;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function isPortFree(port: number): Promise<boolean> {
|
|
63
|
+
return new Promise((resolve) => {
|
|
64
|
+
const probe = createServer();
|
|
65
|
+
probe.once("error", () => resolve(false));
|
|
66
|
+
probe.once("listening", () => {
|
|
67
|
+
probe.close(() => resolve(true));
|
|
68
|
+
});
|
|
69
|
+
probe.listen(port, "127.0.0.1");
|
|
70
|
+
});
|
|
71
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Smoke scenario โ clerk replies to a simple text message.
|
|
3
|
+
*
|
|
4
|
+
* Part of: https://github.com/switchroom/switchroom/issues/866
|
|
5
|
+
*
|
|
6
|
+
* Phase 1 status: this file exercises the harness shape (typecheck-
|
|
7
|
+
* clean, reads as the canonical example) but will FAIL at runtime
|
|
8
|
+
* because `harness.spinUp` is stubbed. Phase 2 wires the real
|
|
9
|
+
* lifecycle and this becomes the first green UAT scenario.
|
|
10
|
+
*
|
|
11
|
+
* The shape mirrors the canonical scenario in epic #863 so reviewers
|
|
12
|
+
* can map directly between the design doc and the code.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { describe, it, expect } from "vitest";
|
|
16
|
+
import { spinUp } from "../harness.js";
|
|
17
|
+
|
|
18
|
+
describe("uat: clerk smoke", () => {
|
|
19
|
+
it("replies to a text message with the right reaction sequence + HTML reply", async () => {
|
|
20
|
+
const sc = await spinUp({ agent: "clerk", topic: "smoke-clerk-reply" });
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
const sent = await sc.driver.sendText(
|
|
24
|
+
sc.chatId,
|
|
25
|
+
"summarize this short note",
|
|
26
|
+
{ messageThreadId: sc.threadId },
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
// Status reactions should walk ๐ โ ๐ค โ ๐ฅ โ ๐ on the inbound
|
|
30
|
+
// message within 30s on a healthy run.
|
|
31
|
+
await sc.expectReaction(
|
|
32
|
+
sent.messageId,
|
|
33
|
+
["๐", "๐ค", "๐ฅ", "๐"],
|
|
34
|
+
{ timeout: 30_000 },
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
// The progress card should be pinned within a few seconds.
|
|
38
|
+
const card = await sc.expectPinnedCard({ timeout: 5_000 });
|
|
39
|
+
|
|
40
|
+
// And ride to `done` within the model's normal turn budget.
|
|
41
|
+
const finalCard = await sc.waitForCardPhase(card, "done", {
|
|
42
|
+
timeout: 60_000,
|
|
43
|
+
});
|
|
44
|
+
expect(finalCard.text).toMatch(/Done|โ
/);
|
|
45
|
+
|
|
46
|
+
// The actual reply prose lands as a fresh bot message, in HTML.
|
|
47
|
+
const reply = await sc.expectMessage(/./, {
|
|
48
|
+
from: "bot",
|
|
49
|
+
timeout: 60_000,
|
|
50
|
+
});
|
|
51
|
+
// Reviewer note: parse_mode isn't on the wire-level update;
|
|
52
|
+
// we proxy via "did the rendered HTML contain a `<` we
|
|
53
|
+
// recognize, or did markdown leak as plain `*`?". Phase 2
|
|
54
|
+
// will pick the right shape โ left as a TODO so this file
|
|
55
|
+
// typechecks today.
|
|
56
|
+
expect(reply.text.length).toBeGreaterThan(0);
|
|
57
|
+
} finally {
|
|
58
|
+
await sc.tearDown();
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -48,6 +48,21 @@ export type AgentAudit = {
|
|
|
48
48
|
memoryBank?: string;
|
|
49
49
|
};
|
|
50
50
|
|
|
51
|
+
/**
|
|
52
|
+
* One live probe row for the `/status` health block. Mirrors the
|
|
53
|
+
* `ProbeResult` shape used by the boot card without dragging the
|
|
54
|
+
* boot-probes module into welcome-text โ keeps welcome-text dependency-
|
|
55
|
+
* free for unit tests.
|
|
56
|
+
*/
|
|
57
|
+
export type StatusProbeRow = {
|
|
58
|
+
/** ProbeStatus shape from boot-probes: ok / degraded / fail. */
|
|
59
|
+
status: 'ok' | 'degraded' | 'fail';
|
|
60
|
+
/** Display label, e.g. "Broker", "Scheduler". */
|
|
61
|
+
label: string;
|
|
62
|
+
/** Free-text detail, e.g. "running (pid 23) ยท last fire 4m ago". */
|
|
63
|
+
detail: string;
|
|
64
|
+
};
|
|
65
|
+
|
|
51
66
|
export type AgentMetadata = {
|
|
52
67
|
agentName: string;
|
|
53
68
|
model: string | null;
|
|
@@ -59,6 +74,13 @@ export type AgentMetadata = {
|
|
|
59
74
|
auth: AuthSummary | null;
|
|
60
75
|
/** Live audit details โ present only when switchroom.yaml loaded cleanly. */
|
|
61
76
|
audit?: AgentAudit;
|
|
77
|
+
/**
|
|
78
|
+
* Live probe results gathered at request time. Same probe set as the
|
|
79
|
+
* boot card. Unlike the boot card (silent-when-healthy), `/status`
|
|
80
|
+
* shows EVERY row including the green ones โ the user explicitly
|
|
81
|
+
* asked for current state, so terseness loses to completeness here.
|
|
82
|
+
*/
|
|
83
|
+
live?: StatusProbeRow[];
|
|
62
84
|
};
|
|
63
85
|
|
|
64
86
|
// Tiny escaper โ duplicates the one in gateway.ts / server.ts so this
|
|
@@ -151,6 +173,12 @@ export function helpText(agentName: string): string {
|
|
|
151
173
|
* Version. This is the on-demand reincarnation of the SessionStart
|
|
152
174
|
* greeting card deleted in #142 PR 1.
|
|
153
175
|
*/
|
|
176
|
+
const STATUS_DOT: Record<StatusProbeRow['status'], string> = {
|
|
177
|
+
ok: '๐ข',
|
|
178
|
+
degraded: '๐ก',
|
|
179
|
+
fail: '๐ด',
|
|
180
|
+
};
|
|
181
|
+
|
|
154
182
|
export function statusPairedText(params: {
|
|
155
183
|
user: string;
|
|
156
184
|
meta: AgentMetadata;
|
|
@@ -164,6 +192,19 @@ export function statusPairedText(params: {
|
|
|
164
192
|
];
|
|
165
193
|
if (meta.status) lines.push(`Status: <code>${escapeHtml(meta.status)}</code>${meta.uptime ? ` ยท up ${escapeHtml(meta.uptime)}` : ""}`);
|
|
166
194
|
|
|
195
|
+
// Live health block โ every probe (green and otherwise) so the user
|
|
196
|
+
// can see at a glance what's working AND what isn't. This is the
|
|
197
|
+
// `/status`-specific opposite of the boot card's silent-when-healthy
|
|
198
|
+
// contract: the boot card is a quiet ack, /status is the dashboard.
|
|
199
|
+
if (meta.live && meta.live.length > 0) {
|
|
200
|
+
lines.push("");
|
|
201
|
+
lines.push("<b>Health</b>");
|
|
202
|
+
for (const row of meta.live) {
|
|
203
|
+
const dot = STATUS_DOT[row.status] ?? STATUS_DOT.fail;
|
|
204
|
+
lines.push(`${dot} <b>${escapeHtml(row.label)}</b> ${escapeHtml(row.detail)}`);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
167
208
|
const audit = meta.audit;
|
|
168
209
|
if (audit) {
|
|
169
210
|
// Blank separator before the audit block so the reply reads as two
|
|
@@ -312,7 +353,8 @@ export function switchroomHelpText(agentName: string): string {
|
|
|
312
353
|
`<code>/memory <query></code> โ search agent memory`,
|
|
313
354
|
``,
|
|
314
355
|
`<b>Fleet management</b>`,
|
|
315
|
-
`<code>/
|
|
356
|
+
`<code>/upgradestatus</code> โ read-only: CLI version, image age, container age per service`,
|
|
357
|
+
`<code>/update</code> โ dry-run plan; <code>/update apply</code> โ actually pull images, reconcile, restart`,
|
|
316
358
|
`<code>/restart [name|all]</code> โ bounce agent (drains in-flight turn by default)`,
|
|
317
359
|
`<code>/version</code> โ show versions + running agent health summary`,
|
|
318
360
|
``,
|
|
@@ -336,7 +378,7 @@ export function switchroomHelpText(agentName: string): string {
|
|
|
336
378
|
`<code>/inject <slash></code> โ inject a Claude Code REPL slash command (e.g. <code>/inject /cost</code>; allowlisted)`,
|
|
337
379
|
`<code>/commands</code> โ this help`,
|
|
338
380
|
``,
|
|
339
|
-
`<i>Tip: <code>/update</code>
|
|
381
|
+
`<i>Tip: <code>/update</code> shows the plan; <code>/update apply</code> executes it; <code>/restart</code> bounces a stuck agent; <code>/version</code> checks what's running.</i>`,
|
|
340
382
|
].join("\n");
|
|
341
383
|
}
|
|
342
384
|
|