@app-vitals/agent-runtime 0.0.1 → 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/Dockerfile +265 -0
- package/agent-account-status-ref.ts +53 -0
- package/agent-account-status-ref.unit.test.ts +24 -0
- package/agent-extension.ts +144 -0
- package/agent-extension.unit.test.ts +139 -0
- package/agent-repos-ref.ts +63 -0
- package/agent-repos-ref.unit.test.ts +84 -0
- package/agent-slack-membership-ref.ts +94 -0
- package/agent-slack-membership-ref.unit.test.ts +201 -0
- package/agent-trial-expiry-ref.ts +63 -0
- package/agent-trial-expiry-ref.unit.test.ts +84 -0
- package/anthropic-federation-guard.ts +72 -0
- package/anthropic-federation-guard.unit.test.ts +127 -0
- package/base-image-contract.smoke.test.ts +149 -0
- package/boot.ts +845 -0
- package/browser.integration.test.ts +52 -0
- package/browser.ts +34 -0
- package/chat-poller.integration.test.ts +109 -0
- package/chat-poller.ts +401 -0
- package/chat-poller.unit.test.ts +1581 -0
- package/chat-token-reporter.integration.test.ts +282 -0
- package/chat-token-reporter.ts +136 -0
- package/claude-version.ts +55 -0
- package/claude-version.unit.test.ts +73 -0
- package/claude.ts +1054 -0
- package/claude.unit.test.ts +3042 -0
- package/cleanup-after-days-ref.ts +83 -0
- package/cleanup-after-days-ref.unit.test.ts +89 -0
- package/cleanup-merged-worktrees-ref.ts +75 -0
- package/cleanup-merged-worktrees-ref.unit.test.ts +72 -0
- package/cli-args.ts +36 -0
- package/cli-args.unit.test.ts +94 -0
- package/clock.ts +47 -0
- package/config.ts +99 -0
- package/config.unit.test.ts +356 -0
- package/context-stamp.ts +189 -0
- package/context-stamp.unit.test.ts +159 -0
- package/cron-failure-reporter.ts +107 -0
- package/cron-failure-reporter.unit.test.ts +244 -0
- package/cron-handler.integration.test.ts +614 -0
- package/cron-handler.smoke.test.ts +1371 -0
- package/cron-handler.ts +750 -0
- package/cron-handler.unit.test.ts +1177 -0
- package/cron-run-reporter.integration.test.ts +806 -0
- package/cron-run-reporter.ts +398 -0
- package/crypto.ts +73 -0
- package/cutover-validate.integration.test.ts +245 -0
- package/cutover-validate.ts +84 -0
- package/entrypoint-main.ts +79 -0
- package/entrypoint.integration.test.ts +315 -0
- package/entrypoint.ts +154 -0
- package/errors.ts +61 -0
- package/fixtures/chat-service/attachment.json +15 -0
- package/fixtures/chat-service/messages.json +86 -0
- package/fixtures/chat-service/threads.json +50 -0
- package/fixtures/github-installations/installations.json +1231 -0
- package/fixtures/slack-provision-cassette.json +6 -0
- package/fixtures/specialties/fixture/manifest.yaml +35 -0
- package/fixtures/specialties/fixture/templates/CLAUDE.md.template +7 -0
- package/fixtures/specialties/fixture/templates/IDENTITY.md.template +8 -0
- package/fixtures/specialties/fixture/templates/SOUL.md.template +45 -0
- package/fixtures/stream-json/clean-exit-no-result.ts +53 -0
- package/fixtures/stream-json/clean-multi-turn.ts +209 -0
- package/fixtures/stream-json/progress-phases.ts +203 -0
- package/fixtures/stream-json/skill-attribution.ts +251 -0
- package/fixtures/stream-json/truncated-no-result.ts +84 -0
- package/format.ts +283 -0
- package/format.unit.test.ts +558 -0
- package/gh-helpers.ts +139 -0
- package/gh-helpers.unit.test.ts +253 -0
- package/gh-token-files.integration.test.ts +143 -0
- package/gh-token-files.ts +92 -0
- package/gh-token-files.unit.test.ts +63 -0
- package/github-app-auth.integration.test.ts +419 -0
- package/github-app-auth.ts +193 -0
- package/github-auth-deps.ts +128 -0
- package/github-auth-startup.ts +231 -0
- package/github-auth-startup.unit.test.ts +325 -0
- package/github-installations-manager.integration.test.ts +323 -0
- package/github-installations-reporter.integration.test.ts +124 -0
- package/github-installations-reporter.ts +150 -0
- package/github-installations-reporter.unit.test.ts +166 -0
- package/github-installations.integration.test.ts +88 -0
- package/github-installations.ts +462 -0
- package/github-installations.unit.test.ts +98 -0
- package/health.smoke.test.ts +244 -0
- package/health.ts +193 -0
- package/health.unit.test.ts +40 -0
- package/http-chat-service-client.integration.test.ts +345 -0
- package/http-chat-service-client.ts +287 -0
- package/http-chat-service-client.unit.test.ts +500 -0
- package/log-prefix.ts +12 -0
- package/log-prefix.unit.test.ts +24 -0
- package/markers.ts +178 -0
- package/markers.unit.test.ts +374 -0
- package/package.json +38 -4
- package/piper-voice.ts +116 -0
- package/piper-voice.unit.test.ts +124 -0
- package/pricing.ts +7 -0
- package/pricing.unit.test.ts +149 -0
- package/process-tree-kill.integration.test.ts +133 -0
- package/process-tree-kill.ts +325 -0
- package/process-tree-kill.unit.test.ts +265 -0
- package/progress-milestones.ts +86 -0
- package/progress-milestones.unit.test.ts +188 -0
- package/run-telemetry.ts +241 -0
- package/run-telemetry.unit.test.ts +235 -0
- package/sessions.integration.test.ts +273 -0
- package/sessions.ts +191 -0
- package/sessions.unit.test.ts +94 -0
- package/setup-github-auth.integration.test.ts +858 -0
- package/setup-github-auth.ts +297 -0
- package/setup.fixture.integration.test.ts +109 -0
- package/setup.integration.test.ts +1495 -0
- package/setup.ts +853 -0
- package/setup.unit.test.ts +286 -0
- package/shipwright-config-client.ts +36 -0
- package/shipwright-runtime-client.integration.test.ts +336 -0
- package/shipwright-runtime-client.ts +118 -0
- package/slack-account-status.unit.test.ts +251 -0
- package/slack-bolt-client.integration.test.ts +76 -0
- package/slack-chat-conversation.integration.test.ts +333 -0
- package/slack-progress.ts +510 -0
- package/slack-progress.unit.test.ts +806 -0
- package/slack-socket-mode-ping.integration.test.ts +137 -0
- package/slack-startup.ts +170 -0
- package/slack-startup.unit.test.ts +300 -0
- package/slack-thread-status-tracker.ts +70 -0
- package/slack-thread-status-tracker.unit.test.ts +118 -0
- package/slack-trial-expiry.unit.test.ts +269 -0
- package/slack.integration.test.ts +3979 -0
- package/slack.ts +1286 -0
- package/slack.unit.test.ts +1070 -0
- package/spawn-env.ts +58 -0
- package/spawn-env.unit.test.ts +96 -0
- package/startup-dm.integration.test.ts +156 -0
- package/startup-dm.ts +29 -0
- package/sync-config-clone.ts +238 -0
- package/sync-config-clone.unit.test.ts +429 -0
- package/test-env.ts +27 -0
- package/users.ts +90 -0
- package/users.unit.test.ts +108 -0
- package/voice.integration.test.ts +669 -0
- package/voice.ts +315 -0
- package/voice.unit.test.ts +105 -0
- package/workspace/BOOTSTRAP.md.template +43 -0
- package/workspace/BOOTSTRAP.md.template.content.test.ts +48 -0
- package/workspace/IDENTITY.md.template +8 -0
- package/workspace/RUNTIME.md.template +193 -0
- package/workspace/RUNTIME.md.template.content.test.ts +75 -0
- package/workspace/SOUL.md.template +45 -0
- package/workspace/SPECIALTY.md.template +37 -0
- package/workspace/SPECIALTY.md.template.content.test.ts +50 -0
- package/workspace/VOICE.md.template +8 -0
- package/workspace/mise.toml.template +12 -0
- package/workspace/requirements.txt.template +1 -0
- package/workspace-paths.ts +37 -0
- package/worktree-reaper.ts +244 -0
- package/worktree-reaper.unit.test.ts +380 -0
- package/README.md +0 -3
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent/src/runtime/slack-socket-mode-ping.integration.test.ts
|
|
3
|
+
*
|
|
4
|
+
* Regression coverage for the fleet-wide Slack reconnect-loop incident that
|
|
5
|
+
* followed the @slack/bolt 4.7.3 -> 5.0.0 bump (SSB-1.1, #3006): Bolt 5's
|
|
6
|
+
* bundled @slack/socket-mode@3.x builds its whole ping/pong heartbeat
|
|
7
|
+
* (SlackWebSocket's monitorPingToSlack / monitorPingFromSlack) on the real
|
|
8
|
+
* `undici` package's module-level `ping()` helper and its
|
|
9
|
+
* `undici:websocket:ping` / `undici:websocket:pong` diagnostics_channel
|
|
10
|
+
* events. Bun always intercepts the bare "undici" specifier with its own
|
|
11
|
+
* built-in polyfill — even though the real package is installed right
|
|
12
|
+
* alongside it — and that polyfill implements neither API
|
|
13
|
+
* (oven-sh/bun#37110, root-caused and reproduced by Bun's own team; fix in
|
|
14
|
+
* progress at PR #37111, unmerged as of this writing). Under Bun, every
|
|
15
|
+
* client-side ping tick threw `undici_1.ping is not a function` and
|
|
16
|
+
* immediately disconnected — a continuous ~5-7s reconnect loop across the
|
|
17
|
+
* whole agent fleet, not an intermittent Slack-side issue.
|
|
18
|
+
*
|
|
19
|
+
* patches/@slack%2Fsocket-mode@3.0.1.patch (see package.json
|
|
20
|
+
* `patchedDependencies`) fixes this by requiring `undici/index.js` (a
|
|
21
|
+
* subpath, which Bun's specifier interception does not match) instead of
|
|
22
|
+
* bare `"undici"` — resolving the real, complete undici package that
|
|
23
|
+
* already satisfies this file's own `peerDependencies: { undici: "^7.0.0"
|
|
24
|
+
* }` and sits right there as a sibling in its own resolved node_modules.
|
|
25
|
+
*
|
|
26
|
+
* No existing suite would catch a regression here:
|
|
27
|
+
* - slack.integration.test.ts / slack-bolt-client.integration.test.ts
|
|
28
|
+
* construct a real @slack/bolt App but never open a live socket-mode
|
|
29
|
+
* connection, so they never reach SlackWebSocket's ping/pong cycle.
|
|
30
|
+
* - A unit test mocking the websocket/undici surface would pass
|
|
31
|
+
* regardless of which `undici` Bun actually resolves at runtime — the
|
|
32
|
+
* whole failure mode is specific to Bun's real, unmocked module
|
|
33
|
+
* resolution.
|
|
34
|
+
*
|
|
35
|
+
* This test opens a REAL local WebSocket server and drives @slack/bolt's
|
|
36
|
+
* actual bundled @slack/socket-mode's SlackWebSocket against it — resolved
|
|
37
|
+
* via require.resolve anchored at @slack/bolt's own location, so it
|
|
38
|
+
* exercises the exact same file and dependency resolution Bolt uses in
|
|
39
|
+
* production — asserting both ping directions succeed and the connection
|
|
40
|
+
* survives multiple ping/pong cycles without disconnecting.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import { describe, expect, test } from "bun:test";
|
|
44
|
+
import { EventEmitter } from "node:events";
|
|
45
|
+
import { WebSocketServer } from "ws";
|
|
46
|
+
|
|
47
|
+
type SlackWebSocketCtor = new (options: {
|
|
48
|
+
url: string;
|
|
49
|
+
client: EventEmitter;
|
|
50
|
+
logger: {
|
|
51
|
+
getLevel: () => string;
|
|
52
|
+
debug: (...args: unknown[]) => void;
|
|
53
|
+
info: (...args: unknown[]) => void;
|
|
54
|
+
warn: (...args: unknown[]) => void;
|
|
55
|
+
error: (...args: unknown[]) => void;
|
|
56
|
+
};
|
|
57
|
+
pingInterval?: number;
|
|
58
|
+
clientPingTimeoutMS?: number;
|
|
59
|
+
serverPingTimeoutMS?: number;
|
|
60
|
+
}) => {
|
|
61
|
+
connect: () => Promise<void>;
|
|
62
|
+
disconnect: () => void;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
function resolveSlackWebSocket(): SlackWebSocketCtor {
|
|
66
|
+
const boltEntry = require.resolve("@slack/bolt");
|
|
67
|
+
const swPath = require.resolve(
|
|
68
|
+
"@slack/socket-mode/dist/src/SlackWebSocket.js",
|
|
69
|
+
{
|
|
70
|
+
paths: [boltEntry],
|
|
71
|
+
},
|
|
72
|
+
);
|
|
73
|
+
// biome-ignore lint/suspicious/noExplicitAny: dynamic require of an internal dist file
|
|
74
|
+
return (require(swPath) as any).SlackWebSocket;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
describe("@slack/socket-mode SlackWebSocket — ping/pong heartbeat under Bun", () => {
|
|
78
|
+
test("client ping, server ping, and pong detection all work without disconnecting", async () => {
|
|
79
|
+
const server = new WebSocketServer({ port: 0 });
|
|
80
|
+
await new Promise<void>((resolve) => server.once("listening", resolve));
|
|
81
|
+
const port = (server.address() as { port: number }).port;
|
|
82
|
+
|
|
83
|
+
let serverGotClientPing = false;
|
|
84
|
+
let serverSentPing = false;
|
|
85
|
+
server.on("connection", (ws) => {
|
|
86
|
+
ws.on("ping", () => {
|
|
87
|
+
serverGotClientPing = true;
|
|
88
|
+
});
|
|
89
|
+
// Simulate Slack's own server-initiated pings.
|
|
90
|
+
const interval = setInterval(() => {
|
|
91
|
+
serverSentPing = true;
|
|
92
|
+
ws.ping(Buffer.from("srv"));
|
|
93
|
+
}, 300);
|
|
94
|
+
ws.on("close", () => clearInterval(interval));
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const SlackWebSocket = resolveSlackWebSocket();
|
|
98
|
+
const client = new EventEmitter();
|
|
99
|
+
let disconnected = false;
|
|
100
|
+
client.on("close", () => {
|
|
101
|
+
disconnected = true;
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const errors: string[] = [];
|
|
105
|
+
const sw = new SlackWebSocket({
|
|
106
|
+
url: `ws://localhost:${port}`,
|
|
107
|
+
client,
|
|
108
|
+
logger: {
|
|
109
|
+
getLevel: () => "info",
|
|
110
|
+
debug: () => {},
|
|
111
|
+
info: () => {},
|
|
112
|
+
warn: () => {},
|
|
113
|
+
error: (m: unknown) => {
|
|
114
|
+
errors.push(String(m));
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
pingInterval: 300,
|
|
118
|
+
clientPingTimeoutMS: 900,
|
|
119
|
+
serverPingTimeoutMS: 1500,
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
await sw.connect();
|
|
124
|
+
// Long enough to cross several ping intervals in both directions —
|
|
125
|
+
// the original bug disconnected on the very first tick.
|
|
126
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
127
|
+
|
|
128
|
+
expect(errors).toEqual([]);
|
|
129
|
+
expect(serverGotClientPing).toBe(true);
|
|
130
|
+
expect(serverSentPing).toBe(true);
|
|
131
|
+
expect(disconnected).toBe(false);
|
|
132
|
+
} finally {
|
|
133
|
+
sw.disconnect();
|
|
134
|
+
server.close();
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
});
|
package/slack-startup.ts
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent/src/runtime/slack-startup.ts
|
|
3
|
+
*
|
|
4
|
+
* Decision logic + live-client box for (re)starting the Slack Bolt Socket
|
|
5
|
+
* Mode app once credentials are available — whether that happens at boot
|
|
6
|
+
* (Step 7 in index.ts) or later, when syncConfig() picks up a
|
|
7
|
+
* SLACK_BOT_TOKEN/SLACK_APP_TOKEN pair that was saved to the admin DB after
|
|
8
|
+
* the pod already started.
|
|
9
|
+
*
|
|
10
|
+
* Why this exists: before this module, both the Bolt `app` and the `slack`
|
|
11
|
+
* WebClient were built once at boot from a static config snapshot taken
|
|
12
|
+
* before syncConfig() ever ran. If credentials arrived even a few seconds
|
|
13
|
+
* late (a plausible race during provisioning), the boot-time
|
|
14
|
+
* hasSlackCredentials() check failed once and the agent stayed in permanent
|
|
15
|
+
* offline mode — syncConfig() re-applies process.env every tick but had no
|
|
16
|
+
* hook to retry Slack startup. This module gives syncConfig() (and the boot
|
|
17
|
+
* path) a single, shared, testable decision: "are credentials live now, and
|
|
18
|
+
* has Bolt not already started? If so, start it — exactly once."
|
|
19
|
+
*
|
|
20
|
+
* Kept dependency-injected and I/O-free itself (matches the project's
|
|
21
|
+
* no-`mock.module()` isolation rule) — real Slack/Bolt/DM calls are injected
|
|
22
|
+
* by the caller (index.ts), so this module's own tests use fakes/spies
|
|
23
|
+
* instead of hitting real Slack. Mirrors agent-repos-ref.ts /
|
|
24
|
+
* agent-slack-membership-ref.ts's get/set-ref style for the live client box,
|
|
25
|
+
* and the same style is reused here for the start-in-flight guard so no
|
|
26
|
+
* module-scope mutable state leaks across test cases (each test constructs
|
|
27
|
+
* its own guard via createSlackStartGuard()).
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import type { WebClient } from "@slack/web-api";
|
|
31
|
+
|
|
32
|
+
/** Minimal shape `startSlackIfPossible` needs from a started Bolt App. */
|
|
33
|
+
export interface StartableSlackApp {
|
|
34
|
+
start(): Promise<unknown>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface SlackClientRef {
|
|
38
|
+
/** Returns the current live Slack WebClient, or undefined if Slack hasn't started yet. */
|
|
39
|
+
get(): WebClient | undefined;
|
|
40
|
+
/** Replaces the current live Slack WebClient. */
|
|
41
|
+
set(client: WebClient | undefined): void;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Creates a new, independent Slack client ref defaulting to undefined (no client yet). */
|
|
45
|
+
export function createSlackClientRef(): SlackClientRef {
|
|
46
|
+
let client: WebClient | undefined;
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
get(): WebClient | undefined {
|
|
50
|
+
return client;
|
|
51
|
+
},
|
|
52
|
+
set(next: WebClient | undefined): void {
|
|
53
|
+
client = next;
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The process-wide Slack client ref. index.ts's cronDeps.slack and the
|
|
60
|
+
* back-online DM call site read through `.get()` rather than closing over a
|
|
61
|
+
* boot-time `const slack`, so a late (re)start via startSlackIfPossible()
|
|
62
|
+
* makes both see a live client without an agent restart.
|
|
63
|
+
*/
|
|
64
|
+
export const slackClientRef: SlackClientRef = createSlackClientRef();
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Guards startSlackIfPossible() against overlapping callers — e.g. two
|
|
68
|
+
* syncConfig() ticks racing because syncConfig() is dispatched via an
|
|
69
|
+
* unawaited setInterval, or a boot call racing a config-sync call. Kept as
|
|
70
|
+
* its own tiny ref (rather than a module-scope `let`) so tests can construct
|
|
71
|
+
* an independent, disposable guard instead of mutating shared state that
|
|
72
|
+
* would otherwise leak between test cases.
|
|
73
|
+
*/
|
|
74
|
+
interface SlackStartGuard {
|
|
75
|
+
isInFlight(): boolean;
|
|
76
|
+
set(inFlight: boolean): void;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function createSlackStartGuard(): SlackStartGuard {
|
|
80
|
+
let inFlight = false;
|
|
81
|
+
return {
|
|
82
|
+
isInFlight: () => inFlight,
|
|
83
|
+
set: (next: boolean) => {
|
|
84
|
+
inFlight = next;
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The process-wide start-in-flight guard used by index.ts's two real call
|
|
91
|
+
* sites (boot + syncConfig()). A single shared instance is required in
|
|
92
|
+
* production so a boot-time start and a config-sync-tick start can't race
|
|
93
|
+
* each other; tests should construct their own via createSlackStartGuard()
|
|
94
|
+
* instead of reusing this singleton.
|
|
95
|
+
*/
|
|
96
|
+
const slackStartGuard: SlackStartGuard = createSlackStartGuard();
|
|
97
|
+
|
|
98
|
+
export interface StartSlackDeps {
|
|
99
|
+
/** Reads live env-derived Slack config (botToken/appToken/signingSecret) at call time — not a static snapshot. */
|
|
100
|
+
buildSlackConfig: () => {
|
|
101
|
+
botToken: string;
|
|
102
|
+
appToken: string;
|
|
103
|
+
signingSecret: string;
|
|
104
|
+
};
|
|
105
|
+
/** Same credential-completeness check used at boot — see slack.ts. */
|
|
106
|
+
hasSlackCredentials: (cfg: { botToken: string; appToken: string }) => boolean;
|
|
107
|
+
/** True once Bolt has already been started (by any prior call, boot or config-sync). */
|
|
108
|
+
isAppStarted: () => boolean;
|
|
109
|
+
/** Builds (but does not start) the Bolt app from live config. */
|
|
110
|
+
createSlackApp: () => StartableSlackApp;
|
|
111
|
+
/** Records the started Bolt app so isAppStarted() reflects it on the next call. */
|
|
112
|
+
setApp: (app: StartableSlackApp) => void;
|
|
113
|
+
/** Builds the WebClient to publish via the slack client ref once credentials are confirmed live. */
|
|
114
|
+
createSlackClient: () => WebClient;
|
|
115
|
+
setSlackClient: (client: WebClient | undefined) => void;
|
|
116
|
+
markSlackConnected: () => void;
|
|
117
|
+
sendBackOnlineDm: (client: WebClient) => Promise<void>;
|
|
118
|
+
/** Start-in-flight guard — defaults to the process-wide singleton; tests inject their own. */
|
|
119
|
+
guard?: SlackStartGuard;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Attempts to start the Slack Bolt app if — and only if — credentials are
|
|
124
|
+
* complete right now AND Bolt hasn't already been started. Safe to call on
|
|
125
|
+
* every boot attempt and every syncConfig() tick: a no-op when credentials
|
|
126
|
+
* are still incomplete, and a no-op once a prior call already started Bolt.
|
|
127
|
+
*
|
|
128
|
+
* Returns true iff this call actually started Bolt; false for every no-op
|
|
129
|
+
* path (incomplete credentials, already started, or a racing call that lost
|
|
130
|
+
* the in-flight guard).
|
|
131
|
+
*
|
|
132
|
+
* Errors from `createSlackApp()`/`app.start()` propagate to the caller (not
|
|
133
|
+
* swallowed here) so index.ts's two call sites can each decide how to
|
|
134
|
+
* handle a start failure consistently with their surrounding context — see
|
|
135
|
+
* the call sites in index.ts for the boot-vs-config-sync tradeoff.
|
|
136
|
+
*/
|
|
137
|
+
export async function startSlackIfPossible(
|
|
138
|
+
deps: StartSlackDeps,
|
|
139
|
+
): Promise<boolean> {
|
|
140
|
+
const guard = deps.guard ?? slackStartGuard;
|
|
141
|
+
|
|
142
|
+
if (guard.isInFlight()) return false;
|
|
143
|
+
if (deps.isAppStarted()) return false;
|
|
144
|
+
|
|
145
|
+
const slackConfig = deps.buildSlackConfig();
|
|
146
|
+
if (!deps.hasSlackCredentials(slackConfig)) return false;
|
|
147
|
+
|
|
148
|
+
guard.set(true);
|
|
149
|
+
try {
|
|
150
|
+
// Re-check after acquiring the guard in case another call already
|
|
151
|
+
// started Bolt between the check above and here — defense in depth on
|
|
152
|
+
// top of the guard itself (JS has no preemption between the isInFlight
|
|
153
|
+
// check and the set(true) above, so this branch is unreachable today,
|
|
154
|
+
// but keeps the invariant obvious even if that ordering ever changes).
|
|
155
|
+
if (deps.isAppStarted()) return false;
|
|
156
|
+
|
|
157
|
+
const app = deps.createSlackApp();
|
|
158
|
+
await app.start();
|
|
159
|
+
deps.setApp(app);
|
|
160
|
+
deps.markSlackConnected();
|
|
161
|
+
|
|
162
|
+
const client = deps.createSlackClient();
|
|
163
|
+
deps.setSlackClient(client);
|
|
164
|
+
|
|
165
|
+
await deps.sendBackOnlineDm(client);
|
|
166
|
+
return true;
|
|
167
|
+
} finally {
|
|
168
|
+
guard.set(false);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent/src/runtime/slack-startup.unit.test.ts
|
|
3
|
+
*
|
|
4
|
+
* Unit tests for startSlackIfPossible() and the Slack client ref — pure
|
|
5
|
+
* logic, no I/O. All Slack/Bolt/DM calls are fakes/spies injected via
|
|
6
|
+
* StartSlackDeps; no real network or `mock.module()` usage.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, expect, it } from "bun:test";
|
|
10
|
+
import type { WebClient } from "@slack/web-api";
|
|
11
|
+
import {
|
|
12
|
+
createSlackClientRef,
|
|
13
|
+
createSlackStartGuard,
|
|
14
|
+
type StartableSlackApp,
|
|
15
|
+
type StartSlackDeps,
|
|
16
|
+
slackClientRef,
|
|
17
|
+
startSlackIfPossible,
|
|
18
|
+
} from "./slack-startup.ts";
|
|
19
|
+
|
|
20
|
+
/** Builds a minimal fake WebClient stand-in for identity/shape assertions. */
|
|
21
|
+
function fakeClient(tag: string): WebClient {
|
|
22
|
+
return { fake: tag } as unknown as WebClient;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Builds a fresh, fully-independent set of fakes for one test case —
|
|
27
|
+
* mirrors production semantics (isAppStarted flips true only after setApp)
|
|
28
|
+
* but with no real Slack I/O, and returns spy call counters for assertions.
|
|
29
|
+
*/
|
|
30
|
+
function buildFakeDeps(overrides?: { botToken?: string; appToken?: string }) {
|
|
31
|
+
let appStarted: StartableSlackApp | undefined;
|
|
32
|
+
const calls = {
|
|
33
|
+
createSlackApp: 0,
|
|
34
|
+
appStart: 0,
|
|
35
|
+
setApp: 0,
|
|
36
|
+
markSlackConnected: 0,
|
|
37
|
+
createSlackClient: 0,
|
|
38
|
+
setSlackClient: 0,
|
|
39
|
+
sendBackOnlineDm: 0,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const clientRef = createSlackClientRef();
|
|
43
|
+
const guard = createSlackStartGuard();
|
|
44
|
+
|
|
45
|
+
const deps: StartSlackDeps = {
|
|
46
|
+
buildSlackConfig: () => ({
|
|
47
|
+
botToken: overrides?.botToken ?? "xoxb-live",
|
|
48
|
+
appToken: overrides?.appToken ?? "xapp-live",
|
|
49
|
+
signingSecret: "shh",
|
|
50
|
+
}),
|
|
51
|
+
hasSlackCredentials: (cfg) =>
|
|
52
|
+
cfg.botToken.trim() !== "" && cfg.appToken.trim() !== "",
|
|
53
|
+
isAppStarted: () => appStarted !== undefined,
|
|
54
|
+
createSlackApp: () => {
|
|
55
|
+
calls.createSlackApp++;
|
|
56
|
+
return {
|
|
57
|
+
start: async () => {
|
|
58
|
+
calls.appStart++;
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
},
|
|
62
|
+
setApp: (app) => {
|
|
63
|
+
calls.setApp++;
|
|
64
|
+
appStarted = app;
|
|
65
|
+
},
|
|
66
|
+
createSlackClient: () => {
|
|
67
|
+
calls.createSlackClient++;
|
|
68
|
+
// A minimal stand-in — startSlackIfPossible only threads this through
|
|
69
|
+
// to setSlackClient/sendBackOnlineDm, never calls real WebClient methods.
|
|
70
|
+
return fakeClient("started");
|
|
71
|
+
},
|
|
72
|
+
setSlackClient: (client) => {
|
|
73
|
+
calls.setSlackClient++;
|
|
74
|
+
clientRef.set(client);
|
|
75
|
+
},
|
|
76
|
+
markSlackConnected: () => {
|
|
77
|
+
calls.markSlackConnected++;
|
|
78
|
+
},
|
|
79
|
+
sendBackOnlineDm: async () => {
|
|
80
|
+
calls.sendBackOnlineDm++;
|
|
81
|
+
},
|
|
82
|
+
guard,
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
deps,
|
|
87
|
+
calls,
|
|
88
|
+
clientRef,
|
|
89
|
+
guard,
|
|
90
|
+
isStarted: () => appStarted !== undefined,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
describe("startSlackIfPossible", () => {
|
|
95
|
+
it("credentials complete on a later tick (not boot) triggers exactly one Slack start", async () => {
|
|
96
|
+
const { deps, calls, clientRef } = buildFakeDeps();
|
|
97
|
+
|
|
98
|
+
const started = await startSlackIfPossible(deps);
|
|
99
|
+
|
|
100
|
+
expect(started).toBe(true);
|
|
101
|
+
expect(calls.createSlackApp).toBe(1);
|
|
102
|
+
expect(calls.appStart).toBe(1);
|
|
103
|
+
expect(calls.setApp).toBe(1);
|
|
104
|
+
expect(calls.markSlackConnected).toBe(1);
|
|
105
|
+
expect(calls.createSlackClient).toBe(1);
|
|
106
|
+
expect(calls.sendBackOnlineDm).toBe(1);
|
|
107
|
+
expect(clientRef.get()).toBeDefined();
|
|
108
|
+
expect((clientRef.get() as unknown as { fake: string }).fake).toBe(
|
|
109
|
+
"started",
|
|
110
|
+
);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("a later tick with the app already started does not re-trigger a start", async () => {
|
|
114
|
+
const { deps, calls } = buildFakeDeps();
|
|
115
|
+
|
|
116
|
+
const first = await startSlackIfPossible(deps);
|
|
117
|
+
const second = await startSlackIfPossible(deps);
|
|
118
|
+
|
|
119
|
+
expect(first).toBe(true);
|
|
120
|
+
expect(second).toBe(false);
|
|
121
|
+
expect(calls.createSlackApp).toBe(1);
|
|
122
|
+
expect(calls.appStart).toBe(1);
|
|
123
|
+
expect(calls.setApp).toBe(1);
|
|
124
|
+
expect(calls.markSlackConnected).toBe(1);
|
|
125
|
+
expect(calls.sendBackOnlineDm).toBe(1);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("a tick with still-incomplete credentials (missing app token) does not attempt a start", async () => {
|
|
129
|
+
const { deps, calls } = buildFakeDeps({
|
|
130
|
+
botToken: "xoxb-live",
|
|
131
|
+
appToken: "",
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
const started = await startSlackIfPossible(deps);
|
|
135
|
+
|
|
136
|
+
expect(started).toBe(false);
|
|
137
|
+
expect(calls.createSlackApp).toBe(0);
|
|
138
|
+
expect(calls.appStart).toBe(0);
|
|
139
|
+
expect(calls.setApp).toBe(0);
|
|
140
|
+
expect(calls.markSlackConnected).toBe(0);
|
|
141
|
+
expect(calls.createSlackClient).toBe(0);
|
|
142
|
+
expect(calls.sendBackOnlineDm).toBe(0);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("a tick with still-incomplete credentials (missing bot token) does not attempt a start", async () => {
|
|
146
|
+
const { deps, calls } = buildFakeDeps({
|
|
147
|
+
botToken: "",
|
|
148
|
+
appToken: "xapp-live",
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
const started = await startSlackIfPossible(deps);
|
|
152
|
+
|
|
153
|
+
expect(started).toBe(false);
|
|
154
|
+
expect(calls.createSlackApp).toBe(0);
|
|
155
|
+
expect(calls.appStart).toBe(0);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("a tick with both credentials missing does not attempt a start", async () => {
|
|
159
|
+
const { deps, calls } = buildFakeDeps({ botToken: "", appToken: "" });
|
|
160
|
+
|
|
161
|
+
const started = await startSlackIfPossible(deps);
|
|
162
|
+
|
|
163
|
+
expect(started).toBe(false);
|
|
164
|
+
expect(calls.createSlackApp).toBe(0);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("overlapping/concurrent calls (two ticks racing before the first app.start() resolves) do not double-start", async () => {
|
|
168
|
+
const { deps, calls } = buildFakeDeps();
|
|
169
|
+
|
|
170
|
+
// Make app.start() hang until we let both calls race against it, so the
|
|
171
|
+
// second call's isAppStarted() check still sees "not started" while the
|
|
172
|
+
// first is mid-flight — the in-flight guard, not timing luck, must be
|
|
173
|
+
// what prevents a double start.
|
|
174
|
+
let releaseStart: () => void = () => {};
|
|
175
|
+
const startGate = new Promise<void>((resolve) => {
|
|
176
|
+
releaseStart = resolve;
|
|
177
|
+
});
|
|
178
|
+
deps.createSlackApp = () => {
|
|
179
|
+
calls.createSlackApp++;
|
|
180
|
+
return {
|
|
181
|
+
start: async () => {
|
|
182
|
+
await startGate;
|
|
183
|
+
calls.appStart++;
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const firstCall = startSlackIfPossible(deps);
|
|
189
|
+
const secondCall = startSlackIfPossible(deps);
|
|
190
|
+
|
|
191
|
+
releaseStart();
|
|
192
|
+
const [first, second] = await Promise.all([firstCall, secondCall]);
|
|
193
|
+
|
|
194
|
+
expect([first, second].filter(Boolean)).toHaveLength(1);
|
|
195
|
+
expect(calls.createSlackApp).toBe(1);
|
|
196
|
+
expect(calls.appStart).toBe(1);
|
|
197
|
+
expect(calls.setApp).toBe(1);
|
|
198
|
+
expect(calls.markSlackConnected).toBe(1);
|
|
199
|
+
expect(calls.sendBackOnlineDm).toBe(1);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("app already started before syncConfig's first tick (boot succeeded) is a no-op", async () => {
|
|
203
|
+
const { deps, calls } = buildFakeDeps();
|
|
204
|
+
|
|
205
|
+
// Simulate boot having already started Bolt.
|
|
206
|
+
await startSlackIfPossible(deps);
|
|
207
|
+
calls.createSlackApp = 0;
|
|
208
|
+
calls.appStart = 0;
|
|
209
|
+
calls.setApp = 0;
|
|
210
|
+
calls.markSlackConnected = 0;
|
|
211
|
+
calls.sendBackOnlineDm = 0;
|
|
212
|
+
|
|
213
|
+
const started = await startSlackIfPossible(deps);
|
|
214
|
+
|
|
215
|
+
expect(started).toBe(false);
|
|
216
|
+
expect(calls.createSlackApp).toBe(0);
|
|
217
|
+
expect(calls.appStart).toBe(0);
|
|
218
|
+
expect(calls.setApp).toBe(0);
|
|
219
|
+
expect(calls.markSlackConnected).toBe(0);
|
|
220
|
+
expect(calls.sendBackOnlineDm).toBe(0);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("errors from app.start() propagate to the caller rather than being swallowed", async () => {
|
|
224
|
+
const { deps } = buildFakeDeps();
|
|
225
|
+
deps.createSlackApp = () => ({
|
|
226
|
+
start: async () => {
|
|
227
|
+
throw new Error("boom");
|
|
228
|
+
},
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
await expect(startSlackIfPossible(deps)).rejects.toThrow("boom");
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("errors from app.start() release the in-flight guard so a subsequent retry can proceed", async () => {
|
|
235
|
+
const { deps, calls, guard } = buildFakeDeps();
|
|
236
|
+
deps.createSlackApp = () => ({
|
|
237
|
+
start: async () => {
|
|
238
|
+
throw new Error("boom");
|
|
239
|
+
},
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
await expect(startSlackIfPossible(deps)).rejects.toThrow("boom");
|
|
243
|
+
expect(guard.isInFlight()).toBe(false);
|
|
244
|
+
|
|
245
|
+
// Retry with working deps — should succeed since the app never actually started.
|
|
246
|
+
deps.createSlackApp = () => {
|
|
247
|
+
calls.createSlackApp++;
|
|
248
|
+
return {
|
|
249
|
+
start: async () => {
|
|
250
|
+
calls.appStart++;
|
|
251
|
+
},
|
|
252
|
+
};
|
|
253
|
+
};
|
|
254
|
+
const started = await startSlackIfPossible(deps);
|
|
255
|
+
expect(started).toBe(true);
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
describe("SlackClientRef", () => {
|
|
260
|
+
it("createSlackClientRef() defaults to undefined (no client yet)", () => {
|
|
261
|
+
const ref = createSlackClientRef();
|
|
262
|
+
expect(ref.get()).toBeUndefined();
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("set() replaces the current client, reflected by the next get()", () => {
|
|
266
|
+
const ref = createSlackClientRef();
|
|
267
|
+
const client = fakeClient("a");
|
|
268
|
+
|
|
269
|
+
ref.set(client);
|
|
270
|
+
expect(ref.get()).toBe(client);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("multiple independent ref instances don't share state", () => {
|
|
274
|
+
const refA = createSlackClientRef();
|
|
275
|
+
const refB = createSlackClientRef();
|
|
276
|
+
const client = fakeClient("a");
|
|
277
|
+
|
|
278
|
+
refA.set(client);
|
|
279
|
+
|
|
280
|
+
expect(refA.get()).toBe(client);
|
|
281
|
+
expect(refB.get()).toBeUndefined();
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
describe("slackClientRef (process-wide singleton)", () => {
|
|
286
|
+
it("is a working ref that reflects set() through get(), independent of createSlackClientRef() instances", () => {
|
|
287
|
+
const independent = createSlackClientRef();
|
|
288
|
+
const leakClient = fakeClient("should-not-leak");
|
|
289
|
+
independent.set(leakClient);
|
|
290
|
+
|
|
291
|
+
const realClient = fakeClient("real");
|
|
292
|
+
slackClientRef.set(realClient);
|
|
293
|
+
|
|
294
|
+
expect(slackClientRef.get()).toBe(realClient);
|
|
295
|
+
expect(independent.get()).toBe(leakClient);
|
|
296
|
+
|
|
297
|
+
// Reset the process-wide singleton so this test doesn't leak state into siblings.
|
|
298
|
+
slackClientRef.set(undefined);
|
|
299
|
+
});
|
|
300
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent/src/runtime/slack-thread-status-tracker.ts
|
|
3
|
+
*
|
|
4
|
+
* Per-thread reference counter used by all three Slack handlers in
|
|
5
|
+
* slack.ts (app.message, app_mention, reaction_added) around their own
|
|
6
|
+
* SlackProgress instance's start()/finish() calls.
|
|
7
|
+
*
|
|
8
|
+
* Each handler constructs its own SlackProgress and calls start()/finish()
|
|
9
|
+
* independently around its own runner() call, unconditionally — every
|
|
10
|
+
* message gets its own independent chat.startStream/appendStream/stopStream
|
|
11
|
+
* lifecycle regardless of the refcount. enter()/exit() are ALWAYS called by
|
|
12
|
+
* every handler — every enter() needs a matching exit(), or the refcount
|
|
13
|
+
* leaks upward forever — but their boolean return values are used only to
|
|
14
|
+
* select (STS2-1.1):
|
|
15
|
+
*
|
|
16
|
+
* (a) the seed card's title ("Queued…" when enter() signals another
|
|
17
|
+
* message on this thread is already in flight, "Thinking…"
|
|
18
|
+
* otherwise), and
|
|
19
|
+
* (b) the session_status stopStream closes with ("processing" when
|
|
20
|
+
* exit() signals other messages are still in flight, "active" only
|
|
21
|
+
* for the genuine last one — see SlackProgress.finish() in
|
|
22
|
+
* slack-progress.ts).
|
|
23
|
+
*
|
|
24
|
+
* Construct one instance per createSlackApp() call (closure-scoped) — never
|
|
25
|
+
* module-level — so multiple app instances (e.g. in tests) stay isolated
|
|
26
|
+
* from each other's refcount state.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
export class ThreadStatusTracker {
|
|
30
|
+
private readonly counts = new Map<string, number>();
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Marks a run as starting for `key`. Always called. Returns true only
|
|
34
|
+
* when this is the transition from 0 in-flight runs to 1 — false for any
|
|
35
|
+
* overlapping run beyond the first. Callers call progress.start()
|
|
36
|
+
* unconditionally and use this only to pick the seed card's title (see
|
|
37
|
+
* the module doc comment).
|
|
38
|
+
*/
|
|
39
|
+
enter(key: string): boolean {
|
|
40
|
+
const current = this.counts.get(key) ?? 0;
|
|
41
|
+
const next = current + 1;
|
|
42
|
+
this.counts.set(key, next);
|
|
43
|
+
return current === 0;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Marks a run as finished for `key`. Always called — every enter() needs
|
|
48
|
+
* a matching exit(). Returns true only when this is the transition from 1
|
|
49
|
+
* in-flight run to 0 — false while other runs are still in flight.
|
|
50
|
+
* Callers call progress.finish() unconditionally and use this only to
|
|
51
|
+
* pick the session_status stopStream closes with (see the module doc
|
|
52
|
+
* comment).
|
|
53
|
+
*
|
|
54
|
+
* Calling exit() on a key that is already at 0 (never entered, or already
|
|
55
|
+
* fully drained) is a no-op that returns false rather than going negative
|
|
56
|
+
* or throwing.
|
|
57
|
+
*/
|
|
58
|
+
exit(key: string): boolean {
|
|
59
|
+
const current = this.counts.get(key) ?? 0;
|
|
60
|
+
if (current === 0) return false;
|
|
61
|
+
const next = current - 1;
|
|
62
|
+
if (next === 0) {
|
|
63
|
+
// Clean up so the map doesn't grow unbounded with fully-drained keys.
|
|
64
|
+
this.counts.delete(key);
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
this.counts.set(key, next);
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
}
|