@app-vitals/agent-runtime 0.0.0-stage → 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 +40 -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,315 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent/src/runtime/entrypoint.integration.test.ts
|
|
3
|
+
*
|
|
4
|
+
* Integration tests for the startup sequence in entrypoint.ts.
|
|
5
|
+
* Uses RecordedShipwrightConfigClient (cassette-backed) — no real HTTP.
|
|
6
|
+
* Isolation contract: no mock.module(), no global overrides.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
10
|
+
import { existsSync, mkdirSync, rmSync } from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import type { AgentConfigResponse } from "@app-vitals/agent-admin";
|
|
13
|
+
import type { EntrypointDeps } from "./entrypoint.ts";
|
|
14
|
+
import { runEntrypoint } from "./entrypoint.ts";
|
|
15
|
+
import type { ShipwrightConfigClient } from "./shipwright-config-client.ts";
|
|
16
|
+
import { RecordedShipwrightConfigClient } from "./shipwright-config-client.ts";
|
|
17
|
+
|
|
18
|
+
// ─── Test fixtures ─────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
const SAMPLE_CONFIG: AgentConfigResponse = {
|
|
21
|
+
env: {
|
|
22
|
+
ANTHROPIC_MODEL: "claude-sonnet-4-6",
|
|
23
|
+
CUSTOM_VAR: "custom-value",
|
|
24
|
+
},
|
|
25
|
+
allowedTools: ["Read", "Write", "Bash"],
|
|
26
|
+
plugins: [{ marketplace: "my-market", plugin: "my-plugin" }],
|
|
27
|
+
repos: [],
|
|
28
|
+
reviewAuthorAllowlist: [],
|
|
29
|
+
patchAuthorAllowlist: [],
|
|
30
|
+
restrictSlackToMembers: false,
|
|
31
|
+
memberEmails: [],
|
|
32
|
+
autoPostReviews: true,
|
|
33
|
+
allowSelfReview: false,
|
|
34
|
+
minConfidence: 75,
|
|
35
|
+
maxFindings: 5,
|
|
36
|
+
cleanupMergedWorktrees: true,
|
|
37
|
+
cleanupAfterDays: 14,
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// ─── Temp dir helpers ──────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
const TMP_BASE = "/tmp/test-entrypoint-home";
|
|
43
|
+
let testHome: string;
|
|
44
|
+
let testClaudeTarget: string;
|
|
45
|
+
|
|
46
|
+
beforeEach(() => {
|
|
47
|
+
const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
48
|
+
testHome = `${TMP_BASE}-${id}`;
|
|
49
|
+
testClaudeTarget = `${testHome}/dot-claude`;
|
|
50
|
+
mkdirSync(testHome, { recursive: true });
|
|
51
|
+
mkdirSync(testClaudeTarget, { recursive: true });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
afterEach(() => {
|
|
55
|
+
if (existsSync(testHome)) {
|
|
56
|
+
rmSync(testHome, { recursive: true, force: true });
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// ─── Helpers ───────────────────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
interface SpawnCall {
|
|
63
|
+
cmd: string;
|
|
64
|
+
args: string[];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface SetupGitHubAuthCall {
|
|
68
|
+
called: boolean;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function makeDeps(
|
|
72
|
+
configClient: ShipwrightConfigClient,
|
|
73
|
+
overrides: Partial<EntrypointDeps> = {},
|
|
74
|
+
): {
|
|
75
|
+
deps: EntrypointDeps;
|
|
76
|
+
spawnCalls: SpawnCall[];
|
|
77
|
+
appliedEnv: Record<string, string>;
|
|
78
|
+
symlinkCalls: Array<{ target: string; linkPath: string }>;
|
|
79
|
+
githubAuthCalled: SetupGitHubAuthCall;
|
|
80
|
+
miseCalls: string[][];
|
|
81
|
+
pluginCalls: string[];
|
|
82
|
+
exitCodes: number[];
|
|
83
|
+
} {
|
|
84
|
+
const spawnCalls: SpawnCall[] = [];
|
|
85
|
+
const appliedEnv: Record<string, string> = {};
|
|
86
|
+
const symlinkCalls: Array<{ target: string; linkPath: string }> = [];
|
|
87
|
+
const githubAuthCalled: SetupGitHubAuthCall = { called: false };
|
|
88
|
+
const miseCalls: string[][] = [];
|
|
89
|
+
const pluginCalls: string[] = [];
|
|
90
|
+
const exitCodes: number[] = [];
|
|
91
|
+
|
|
92
|
+
const deps: EntrypointDeps = {
|
|
93
|
+
agentId: "test-agent-id",
|
|
94
|
+
apiUrl: "https://api.test.com",
|
|
95
|
+
apiKey: "test-key",
|
|
96
|
+
agentHome: testHome,
|
|
97
|
+
configClient,
|
|
98
|
+
applyEnv: (env: Record<string, string>) => {
|
|
99
|
+
Object.assign(appliedEnv, env);
|
|
100
|
+
},
|
|
101
|
+
symlinkDotClaude: (target: string, linkPath: string) => {
|
|
102
|
+
symlinkCalls.push({ target, linkPath });
|
|
103
|
+
},
|
|
104
|
+
setupGitHubAuth: async () => {
|
|
105
|
+
githubAuthCalled.called = true;
|
|
106
|
+
},
|
|
107
|
+
runMiseStartup: async (_home: string, _execFn) => {
|
|
108
|
+
miseCalls.push(["runMiseStartup", _home]);
|
|
109
|
+
return Promise.resolve();
|
|
110
|
+
},
|
|
111
|
+
installPlugins: async (_execFn, _cwd, plugins) => {
|
|
112
|
+
pluginCalls.push(...(plugins ?? []).map((p) => p.plugin));
|
|
113
|
+
return Promise.resolve();
|
|
114
|
+
},
|
|
115
|
+
spawnAgentServer: (cmd: string, args: string[]) => {
|
|
116
|
+
spawnCalls.push({ cmd, args });
|
|
117
|
+
},
|
|
118
|
+
exit: (code: number) => {
|
|
119
|
+
exitCodes.push(code);
|
|
120
|
+
},
|
|
121
|
+
...overrides,
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
deps,
|
|
126
|
+
spawnCalls,
|
|
127
|
+
appliedEnv,
|
|
128
|
+
symlinkCalls,
|
|
129
|
+
githubAuthCalled,
|
|
130
|
+
miseCalls,
|
|
131
|
+
pluginCalls,
|
|
132
|
+
exitCodes,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ─── Tests ────────────────────────────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
describe("runEntrypoint — happy path", () => {
|
|
139
|
+
it("exports the specialty to the server process when the config carries one (ARD-6.1)", async () => {
|
|
140
|
+
const configClient = new RecordedShipwrightConfigClient({
|
|
141
|
+
...SAMPLE_CONFIG,
|
|
142
|
+
typeName: "specialist",
|
|
143
|
+
});
|
|
144
|
+
const { deps, appliedEnv } = makeDeps(configClient);
|
|
145
|
+
await runEntrypoint(deps);
|
|
146
|
+
expect(appliedEnv.SHIPWRIGHT_AGENT_TYPE).toBe("specialist");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("does not set SHIPWRIGHT_AGENT_TYPE when the config has no typeName (ARD-6.1)", async () => {
|
|
150
|
+
const configClient = new RecordedShipwrightConfigClient(SAMPLE_CONFIG);
|
|
151
|
+
const { deps, appliedEnv } = makeDeps(configClient);
|
|
152
|
+
await runEntrypoint(deps);
|
|
153
|
+
expect(appliedEnv.SHIPWRIGHT_AGENT_TYPE).toBeUndefined();
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("fetches config and applies env vars from the bundle", async () => {
|
|
157
|
+
const configClient = new RecordedShipwrightConfigClient(SAMPLE_CONFIG);
|
|
158
|
+
const { deps, appliedEnv } = makeDeps(configClient);
|
|
159
|
+
|
|
160
|
+
await runEntrypoint(deps);
|
|
161
|
+
|
|
162
|
+
expect(appliedEnv.ANTHROPIC_MODEL).toBe("claude-sonnet-4-6");
|
|
163
|
+
expect(appliedEnv.CUSTOM_VAR).toBe("custom-value");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("symlinks ~/.claude to $AGENT_HOME/dot-claude", async () => {
|
|
167
|
+
const configClient = new RecordedShipwrightConfigClient(SAMPLE_CONFIG);
|
|
168
|
+
const { deps, symlinkCalls } = makeDeps(configClient);
|
|
169
|
+
|
|
170
|
+
await runEntrypoint(deps);
|
|
171
|
+
|
|
172
|
+
expect(symlinkCalls.length).toBe(1);
|
|
173
|
+
expect(symlinkCalls[0].target).toBe(join(testHome, "dot-claude"));
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("calls setupGitHubAuth", async () => {
|
|
177
|
+
const configClient = new RecordedShipwrightConfigClient(SAMPLE_CONFIG);
|
|
178
|
+
const { deps, githubAuthCalled } = makeDeps(configClient);
|
|
179
|
+
|
|
180
|
+
await runEntrypoint(deps);
|
|
181
|
+
|
|
182
|
+
expect(githubAuthCalled.called).toBe(true);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("calls runMiseStartup with agentHome", async () => {
|
|
186
|
+
const configClient = new RecordedShipwrightConfigClient(SAMPLE_CONFIG);
|
|
187
|
+
const { deps, miseCalls } = makeDeps(configClient);
|
|
188
|
+
|
|
189
|
+
await runEntrypoint(deps);
|
|
190
|
+
|
|
191
|
+
expect(miseCalls.length).toBe(1);
|
|
192
|
+
expect(miseCalls[0][1]).toBe(testHome);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("calls installPlugins with plugins from config", async () => {
|
|
196
|
+
const configClient = new RecordedShipwrightConfigClient(SAMPLE_CONFIG);
|
|
197
|
+
const { deps, pluginCalls } = makeDeps(configClient);
|
|
198
|
+
|
|
199
|
+
await runEntrypoint(deps);
|
|
200
|
+
|
|
201
|
+
expect(pluginCalls).toContain("my-plugin");
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("spawns the agent server after setup completes", async () => {
|
|
205
|
+
const configClient = new RecordedShipwrightConfigClient(SAMPLE_CONFIG);
|
|
206
|
+
const { deps, spawnCalls } = makeDeps(configClient);
|
|
207
|
+
|
|
208
|
+
await runEntrypoint(deps);
|
|
209
|
+
|
|
210
|
+
expect(spawnCalls.length).toBe(1);
|
|
211
|
+
expect(spawnCalls[0].args[1]).toContain("index.ts");
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("does not call exit on success", async () => {
|
|
215
|
+
const configClient = new RecordedShipwrightConfigClient(SAMPLE_CONFIG);
|
|
216
|
+
const { deps, exitCodes } = makeDeps(configClient);
|
|
217
|
+
|
|
218
|
+
await runEntrypoint(deps);
|
|
219
|
+
|
|
220
|
+
expect(exitCodes.length).toBe(0);
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
describe("runEntrypoint — missing required vars", () => {
|
|
225
|
+
it("exits non-zero when agentId is missing", async () => {
|
|
226
|
+
const configClient = new RecordedShipwrightConfigClient(SAMPLE_CONFIG);
|
|
227
|
+
const { deps, exitCodes } = makeDeps(configClient, { agentId: undefined });
|
|
228
|
+
|
|
229
|
+
await runEntrypoint(deps);
|
|
230
|
+
|
|
231
|
+
expect(exitCodes.length).toBe(1);
|
|
232
|
+
expect(exitCodes[0]).not.toBe(0);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("exits non-zero when apiUrl is missing", async () => {
|
|
236
|
+
const configClient = new RecordedShipwrightConfigClient(SAMPLE_CONFIG);
|
|
237
|
+
const { deps, exitCodes } = makeDeps(configClient, { apiUrl: undefined });
|
|
238
|
+
|
|
239
|
+
await runEntrypoint(deps);
|
|
240
|
+
|
|
241
|
+
expect(exitCodes.length).toBe(1);
|
|
242
|
+
expect(exitCodes[0]).not.toBe(0);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("exits non-zero when apiKey is missing", async () => {
|
|
246
|
+
const configClient = new RecordedShipwrightConfigClient(SAMPLE_CONFIG);
|
|
247
|
+
const { deps, exitCodes } = makeDeps(configClient, { apiKey: undefined });
|
|
248
|
+
|
|
249
|
+
await runEntrypoint(deps);
|
|
250
|
+
|
|
251
|
+
expect(exitCodes.length).toBe(1);
|
|
252
|
+
expect(exitCodes[0]).not.toBe(0);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("does not fetch config when required vars are missing", async () => {
|
|
256
|
+
const configClient = new RecordedShipwrightConfigClient(SAMPLE_CONFIG);
|
|
257
|
+
let fetchCalled = false;
|
|
258
|
+
const trackingClient: ShipwrightConfigClient = {
|
|
259
|
+
getConfig: async (agentId: string) => {
|
|
260
|
+
fetchCalled = true;
|
|
261
|
+
return configClient.getConfig(agentId);
|
|
262
|
+
},
|
|
263
|
+
};
|
|
264
|
+
const { deps } = makeDeps(trackingClient, { agentId: undefined });
|
|
265
|
+
|
|
266
|
+
await runEntrypoint(deps);
|
|
267
|
+
|
|
268
|
+
expect(fetchCalled).toBe(false);
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
describe("runEntrypoint — config with empty env", () => {
|
|
273
|
+
it("handles config with no env vars gracefully", async () => {
|
|
274
|
+
const emptyConfig: AgentConfigResponse = {
|
|
275
|
+
env: {},
|
|
276
|
+
allowedTools: [],
|
|
277
|
+
plugins: [],
|
|
278
|
+
repos: [],
|
|
279
|
+
reviewAuthorAllowlist: [],
|
|
280
|
+
patchAuthorAllowlist: [],
|
|
281
|
+
restrictSlackToMembers: false,
|
|
282
|
+
memberEmails: [],
|
|
283
|
+
autoPostReviews: true,
|
|
284
|
+
allowSelfReview: false,
|
|
285
|
+
minConfidence: 75,
|
|
286
|
+
maxFindings: 5,
|
|
287
|
+
cleanupMergedWorktrees: true,
|
|
288
|
+
cleanupAfterDays: 14,
|
|
289
|
+
};
|
|
290
|
+
const configClient = new RecordedShipwrightConfigClient(emptyConfig);
|
|
291
|
+
const { deps, exitCodes, spawnCalls } = makeDeps(configClient);
|
|
292
|
+
|
|
293
|
+
await runEntrypoint(deps);
|
|
294
|
+
|
|
295
|
+
expect(exitCodes.length).toBe(0);
|
|
296
|
+
expect(spawnCalls.length).toBe(1);
|
|
297
|
+
expect(spawnCalls[0].args[1]).toContain("index.ts");
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
describe("runEntrypoint — startup timeout", () => {
|
|
302
|
+
it("exits non-zero when startup exceeds startupTimeoutMs", async () => {
|
|
303
|
+
const hangingClient: ShipwrightConfigClient = {
|
|
304
|
+
getConfig: () => new Promise(() => {}), // never resolves
|
|
305
|
+
};
|
|
306
|
+
const { deps, exitCodes } = makeDeps(hangingClient, {
|
|
307
|
+
startupTimeoutMs: 50, // 50ms for fast test
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
await runEntrypoint(deps);
|
|
311
|
+
|
|
312
|
+
expect(exitCodes.length).toBe(1);
|
|
313
|
+
expect(exitCodes[0]).not.toBe(0);
|
|
314
|
+
});
|
|
315
|
+
});
|
package/entrypoint.ts
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent/src/runtime/entrypoint.ts
|
|
3
|
+
*
|
|
4
|
+
* Container startup sequence for the Shipwright agent.
|
|
5
|
+
*
|
|
6
|
+
* Sequence:
|
|
7
|
+
* 1. Validate required env vars (fail fast with clear log on missing)
|
|
8
|
+
* 2. Fetch agent config from GET /agents/:id/config
|
|
9
|
+
* 3. Apply env vars from config bundle to process.env
|
|
10
|
+
* 4. Symlink ~/.claude → $AGENT_HOME/dot-claude
|
|
11
|
+
* 5. Setup GitHub auth
|
|
12
|
+
* 6. Run mise startup (install tools, prepend shims to PATH)
|
|
13
|
+
* 7. Install plugins from config
|
|
14
|
+
* 8. Spawn the agent server process
|
|
15
|
+
*
|
|
16
|
+
* Dependency-injected for testability — pass real deps in production,
|
|
17
|
+
* doubles in tests.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
import type { AgentPlugin } from "@app-vitals/agent-admin";
|
|
22
|
+
import type { ShipwrightConfigClient } from "./shipwright-config-client.ts";
|
|
23
|
+
|
|
24
|
+
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
type ExecFn = (
|
|
27
|
+
cmd: string,
|
|
28
|
+
args: string[],
|
|
29
|
+
opts: { cwd: string },
|
|
30
|
+
) => Promise<{ stdout: string; exitCode: number }>;
|
|
31
|
+
|
|
32
|
+
export interface EntrypointDeps {
|
|
33
|
+
agentId: string | undefined;
|
|
34
|
+
apiUrl: string | undefined;
|
|
35
|
+
apiKey: string | undefined;
|
|
36
|
+
agentHome: string;
|
|
37
|
+
configClient: ShipwrightConfigClient;
|
|
38
|
+
applyEnv: (env: Record<string, string>) => void;
|
|
39
|
+
symlinkDotClaude: (target: string, linkPath: string) => void;
|
|
40
|
+
setupGitHubAuth: () => Promise<void>;
|
|
41
|
+
runMiseStartup: (home: string, execFn?: ExecFn) => Promise<void>;
|
|
42
|
+
installPlugins: (
|
|
43
|
+
execFn?: ExecFn,
|
|
44
|
+
cwd?: string,
|
|
45
|
+
plugins?: AgentPlugin[],
|
|
46
|
+
) => Promise<void>;
|
|
47
|
+
spawnAgentServer: (cmd: string, args: string[]) => void;
|
|
48
|
+
exit: (code: number) => void;
|
|
49
|
+
startupTimeoutMs?: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ─── Main entrypoint ──────────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
// 180s, not 60s: `mise install` during startup can take well over a minute on a
|
|
55
|
+
// cold or contended node (e.g. during a GKE node rotation), and the original 60s
|
|
56
|
+
// budget crash-looped agents that were otherwise healthy. Overridable per-agent
|
|
57
|
+
// via SHIPWRIGHT_STARTUP_TIMEOUT_MS. Keep this aligned with the manifest
|
|
58
|
+
// startupProbe grace window in admin/src/agent-manifest.ts.
|
|
59
|
+
const DEFAULT_STARTUP_TIMEOUT_MS = 180_000;
|
|
60
|
+
|
|
61
|
+
export async function runEntrypoint(deps: EntrypointDeps): Promise<void> {
|
|
62
|
+
const {
|
|
63
|
+
agentId,
|
|
64
|
+
apiUrl,
|
|
65
|
+
apiKey,
|
|
66
|
+
agentHome,
|
|
67
|
+
configClient,
|
|
68
|
+
applyEnv,
|
|
69
|
+
symlinkDotClaude,
|
|
70
|
+
setupGitHubAuth,
|
|
71
|
+
runMiseStartup,
|
|
72
|
+
installPlugins,
|
|
73
|
+
spawnAgentServer,
|
|
74
|
+
exit,
|
|
75
|
+
startupTimeoutMs = DEFAULT_STARTUP_TIMEOUT_MS,
|
|
76
|
+
} = deps;
|
|
77
|
+
|
|
78
|
+
// ─── Step 1: Validate required vars ─────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
const missing: string[] = [];
|
|
81
|
+
if (!agentId) missing.push("SHIPWRIGHT_AGENT_ID (or --agent-id)");
|
|
82
|
+
if (!apiUrl) missing.push("SHIPWRIGHT_API_URL (or --api-url)");
|
|
83
|
+
if (!apiKey) missing.push("SHIPWRIGHT_AGENT_API_KEY (or --api-key)");
|
|
84
|
+
|
|
85
|
+
if (missing.length > 0) {
|
|
86
|
+
for (const m of missing) {
|
|
87
|
+
console.error(`[entrypoint] FATAL: missing required variable: ${m}`);
|
|
88
|
+
}
|
|
89
|
+
exit(1);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ─── Steps 2–8: run startup sequence with a hard deadline ────────────────────
|
|
94
|
+
|
|
95
|
+
const timeoutError = new Error(
|
|
96
|
+
`[entrypoint] FATAL: startup sequence did not complete within ${startupTimeoutMs}ms`,
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
const startup = async () => {
|
|
100
|
+
// Step 2: Fetch config from Shipwright API
|
|
101
|
+
let config: Awaited<ReturnType<ShipwrightConfigClient["getConfig"]>>;
|
|
102
|
+
try {
|
|
103
|
+
config = await configClient.getConfig(agentId as string);
|
|
104
|
+
console.log(
|
|
105
|
+
`[entrypoint] fetched config for agent ${agentId}: ${Object.keys(config.env).length} env vars, ${config.plugins.length} plugins`,
|
|
106
|
+
);
|
|
107
|
+
} catch (err) {
|
|
108
|
+
console.error(
|
|
109
|
+
`[entrypoint] FATAL: failed to fetch agent config: ${(err as Error).message}`,
|
|
110
|
+
);
|
|
111
|
+
exit(1);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Step 3: Apply env vars
|
|
116
|
+
applyEnv(config.env);
|
|
117
|
+
// ARD-6.1: hand the specialty to the server process (inherits our env) so
|
|
118
|
+
// ensureAgentHome() can pick type-specific workspace templates.
|
|
119
|
+
if (config.typeName) {
|
|
120
|
+
applyEnv({ SHIPWRIGHT_AGENT_TYPE: config.typeName });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Step 4: Symlink ~/.claude → $AGENT_HOME/dot-claude
|
|
124
|
+
const dotClaudeTarget = join(agentHome, "dot-claude");
|
|
125
|
+
const dotClaudeLinkPath = join(process.env.HOME ?? "/root", ".claude");
|
|
126
|
+
symlinkDotClaude(dotClaudeTarget, dotClaudeLinkPath);
|
|
127
|
+
|
|
128
|
+
// Step 5: GitHub auth
|
|
129
|
+
await setupGitHubAuth();
|
|
130
|
+
|
|
131
|
+
// Step 6: Mise startup
|
|
132
|
+
await runMiseStartup(agentHome);
|
|
133
|
+
|
|
134
|
+
// Step 7: Install plugins
|
|
135
|
+
await installPlugins(undefined, undefined, config.plugins);
|
|
136
|
+
|
|
137
|
+
// Step 8: Spawn agent server
|
|
138
|
+
spawnAgentServer("bun", ["run", join(import.meta.dir, "..", "index.ts")]);
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
let deadlineTimer: ReturnType<typeof setTimeout> | undefined;
|
|
142
|
+
const deadline = new Promise<never>((_, reject) => {
|
|
143
|
+
deadlineTimer = setTimeout(() => reject(timeoutError), startupTimeoutMs);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
await Promise.race([startup(), deadline]);
|
|
148
|
+
} catch (err) {
|
|
149
|
+
console.error((err as Error).message);
|
|
150
|
+
exit(1);
|
|
151
|
+
} finally {
|
|
152
|
+
clearTimeout(deadlineTimer);
|
|
153
|
+
}
|
|
154
|
+
}
|
package/errors.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent/src/runtime/errors.ts
|
|
3
|
+
* Typed HTTP error classes for use across agent service handlers.
|
|
4
|
+
* Handlers throw these; error middleware maps them to HTTP responses.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// ─── Base class ───────────────────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
export class ApiError extends Error {
|
|
10
|
+
constructor(
|
|
11
|
+
public readonly statusCode: number,
|
|
12
|
+
message: string,
|
|
13
|
+
) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.name = "ApiError";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// ─── HTTP error subclasses ────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
export class NotFoundError extends ApiError {
|
|
22
|
+
constructor(message = "Not found") {
|
|
23
|
+
super(404, message);
|
|
24
|
+
this.name = "NotFoundError";
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class ConflictError extends ApiError {
|
|
29
|
+
constructor(message = "Conflict") {
|
|
30
|
+
super(409, message);
|
|
31
|
+
this.name = "ConflictError";
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export class BadRequestError extends ApiError {
|
|
36
|
+
constructor(message = "Bad request") {
|
|
37
|
+
super(400, message);
|
|
38
|
+
this.name = "BadRequestError";
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export class ForbiddenError extends ApiError {
|
|
43
|
+
constructor(message = "Forbidden") {
|
|
44
|
+
super(403, message);
|
|
45
|
+
this.name = "ForbiddenError";
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export class UnprocessableEntityError extends ApiError {
|
|
50
|
+
constructor(message = "Unprocessable entity") {
|
|
51
|
+
super(422, message);
|
|
52
|
+
this.name = "UnprocessableEntityError";
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export class BadGatewayError extends ApiError {
|
|
57
|
+
constructor(message = "Bad gateway") {
|
|
58
|
+
super(502, message);
|
|
59
|
+
this.name = "BadGatewayError";
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"getAttachment_success": {
|
|
3
|
+
"status": 200,
|
|
4
|
+
"bodyEncoding": "base64",
|
|
5
|
+
"body": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
|
|
6
|
+
},
|
|
7
|
+
"getAttachment_404": {
|
|
8
|
+
"status": 404,
|
|
9
|
+
"body": { "error": "no attachment" }
|
|
10
|
+
},
|
|
11
|
+
"getAttachment_500": {
|
|
12
|
+
"status": 500,
|
|
13
|
+
"body": { "error": "internal server error" }
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
{
|
|
2
|
+
"claimMessage_success": {
|
|
3
|
+
"status": 200,
|
|
4
|
+
"body": {
|
|
5
|
+
"id": "msg-xyz",
|
|
6
|
+
"threadId": "thread-abc",
|
|
7
|
+
"role": "user",
|
|
8
|
+
"body": "Hello, can you help me?",
|
|
9
|
+
"claimedBy": "agent-1",
|
|
10
|
+
"claimedAt": "2026-01-01T00:10:00.000Z",
|
|
11
|
+
"repliedAt": null,
|
|
12
|
+
"tokens": null,
|
|
13
|
+
"costUsd": null,
|
|
14
|
+
"attachmentFilename": null,
|
|
15
|
+
"createdAt": "2026-01-01T00:09:00.000Z",
|
|
16
|
+
"updatedAt": "2026-01-01T00:10:00.000Z"
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"claimMessage_404": {
|
|
20
|
+
"status": 404,
|
|
21
|
+
"body": { "error": "no unclaimed messages in thread" }
|
|
22
|
+
},
|
|
23
|
+
"claimMessage_500": {
|
|
24
|
+
"status": 500,
|
|
25
|
+
"body": { "error": "internal server error" }
|
|
26
|
+
},
|
|
27
|
+
"replyToMessage_success": {
|
|
28
|
+
"status": 201,
|
|
29
|
+
"body": {
|
|
30
|
+
"userMessage": {
|
|
31
|
+
"id": "msg-xyz",
|
|
32
|
+
"threadId": "thread-abc",
|
|
33
|
+
"role": "user",
|
|
34
|
+
"body": "Hello, can you help me?",
|
|
35
|
+
"claimedBy": "agent-1",
|
|
36
|
+
"claimedAt": "2026-01-01T00:10:00.000Z",
|
|
37
|
+
"repliedAt": "2026-01-01T00:11:00.000Z",
|
|
38
|
+
"tokens": null,
|
|
39
|
+
"costUsd": null,
|
|
40
|
+
"attachmentFilename": null,
|
|
41
|
+
"createdAt": "2026-01-01T00:09:00.000Z",
|
|
42
|
+
"updatedAt": "2026-01-01T00:11:00.000Z"
|
|
43
|
+
},
|
|
44
|
+
"assistantMessage": {
|
|
45
|
+
"id": "msg-assist-1",
|
|
46
|
+
"threadId": "thread-abc",
|
|
47
|
+
"role": "assistant",
|
|
48
|
+
"body": "Sure, here's how...",
|
|
49
|
+
"claimedBy": null,
|
|
50
|
+
"claimedAt": null,
|
|
51
|
+
"repliedAt": null,
|
|
52
|
+
"tokens": { "input_tokens": 120, "output_tokens": 42 },
|
|
53
|
+
"costUsd": 0.0021,
|
|
54
|
+
"attachmentFilename": null,
|
|
55
|
+
"createdAt": "2026-01-01T00:11:00.000Z",
|
|
56
|
+
"updatedAt": "2026-01-01T00:11:00.000Z"
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
"replyToMessage_400": {
|
|
61
|
+
"status": 400,
|
|
62
|
+
"body": { "error": "body is required" }
|
|
63
|
+
},
|
|
64
|
+
"heartbeat_success": {
|
|
65
|
+
"status": 200,
|
|
66
|
+
"body": {
|
|
67
|
+
"id": "msg-xyz",
|
|
68
|
+
"threadId": "thread-abc",
|
|
69
|
+
"role": "user",
|
|
70
|
+
"body": "Hello, can you help me?",
|
|
71
|
+
"claimedBy": "agent-1",
|
|
72
|
+
"claimedAt": "2026-01-01T00:10:00.000Z",
|
|
73
|
+
"heartbeatAt": "2026-01-01T00:10:30.000Z",
|
|
74
|
+
"repliedAt": null,
|
|
75
|
+
"tokens": null,
|
|
76
|
+
"costUsd": null,
|
|
77
|
+
"attachmentFilename": null,
|
|
78
|
+
"createdAt": "2026-01-01T00:09:00.000Z",
|
|
79
|
+
"updatedAt": "2026-01-01T00:10:30.000Z"
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
"heartbeat_404": {
|
|
83
|
+
"status": 404,
|
|
84
|
+
"body": { "error": "message not found" }
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"listThreads_success": {
|
|
3
|
+
"status": 200,
|
|
4
|
+
"body": {
|
|
5
|
+
"threads": [
|
|
6
|
+
{
|
|
7
|
+
"id": "thread-abc",
|
|
8
|
+
"agentId": "agent-1",
|
|
9
|
+
"memberId": "member-1",
|
|
10
|
+
"title": "Test Thread",
|
|
11
|
+
"createdAt": "2026-01-01T00:00:00.000Z",
|
|
12
|
+
"updatedAt": "2026-01-01T00:05:00.000Z"
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"id": "thread-def",
|
|
16
|
+
"agentId": "agent-1",
|
|
17
|
+
"memberId": null,
|
|
18
|
+
"title": null,
|
|
19
|
+
"createdAt": "2026-01-02T00:00:00.000Z",
|
|
20
|
+
"updatedAt": "2026-01-02T00:05:00.000Z"
|
|
21
|
+
}
|
|
22
|
+
],
|
|
23
|
+
"total": 2,
|
|
24
|
+
"limit": 50,
|
|
25
|
+
"offset": 0
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"listThreads_with_query_params": {
|
|
29
|
+
"status": 200,
|
|
30
|
+
"body": {
|
|
31
|
+
"threads": [
|
|
32
|
+
{
|
|
33
|
+
"id": "thread-abc",
|
|
34
|
+
"agentId": "agent-1",
|
|
35
|
+
"memberId": "member-1",
|
|
36
|
+
"title": "Test Thread",
|
|
37
|
+
"createdAt": "2026-01-01T00:00:00.000Z",
|
|
38
|
+
"updatedAt": "2026-01-01T00:05:00.000Z"
|
|
39
|
+
}
|
|
40
|
+
],
|
|
41
|
+
"total": 6,
|
|
42
|
+
"limit": 10,
|
|
43
|
+
"offset": 5
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
"listThreads_500": {
|
|
47
|
+
"status": 500,
|
|
48
|
+
"body": { "error": "internal server error" }
|
|
49
|
+
}
|
|
50
|
+
}
|