@hellcoder/companion 0.96.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/bin/cli.ts +168 -0
- package/bin/ctl.ts +528 -0
- package/bin/generate-token.ts +28 -0
- package/dist/apple-touch-icon.png +0 -0
- package/dist/assets/AgentsPage-DCFhrJ28.js +13 -0
- package/dist/assets/CronManager-EGwLJONv.js +1 -0
- package/dist/assets/IntegrationsPage-CTMRnbQS.js +1 -0
- package/dist/assets/LinearOAuthSettingsPage-CgQFMIgr.js +1 -0
- package/dist/assets/LinearSettingsPage-C9nok1qi.js +1 -0
- package/dist/assets/Playground-BV3k0RbV.js +109 -0
- package/dist/assets/PromptsPage-CFojqNKP.js +4 -0
- package/dist/assets/RunsPage-DUJ1QUSa.js +1 -0
- package/dist/assets/SandboxManager-CrVQ-VU_.js +8 -0
- package/dist/assets/SettingsPage-D1fPCL19.js +1 -0
- package/dist/assets/TailscalePage-D06cyvyC.js +1 -0
- package/dist/assets/index-BhUa1e6X.css +1 -0
- package/dist/assets/index-DkqeP-R9.js +134 -0
- package/dist/assets/sw-register-BibwRdvC.js +1 -0
- package/dist/assets/workbox-window.prod.es5-BIl4cyR9.js +2 -0
- package/dist/favicon.svg +8 -0
- package/dist/fonts/MesloLGSNerdFontMono-Bold.woff2 +0 -0
- package/dist/fonts/MesloLGSNerdFontMono-Regular.woff2 +0 -0
- package/dist/icon-192.png +0 -0
- package/dist/icon-512.png +0 -0
- package/dist/index.html +20 -0
- package/dist/logo-codex.svg +14 -0
- package/dist/logo-docker.svg +4 -0
- package/dist/logo.svg +14 -0
- package/dist/manifest.json +24 -0
- package/dist/sw.js +2 -0
- package/package.json +104 -0
- package/server/agent-cron-migrator.test.ts +610 -0
- package/server/agent-cron-migrator.ts +85 -0
- package/server/agent-executor.test.ts +1108 -0
- package/server/agent-executor.ts +346 -0
- package/server/agent-store.test.ts +588 -0
- package/server/agent-store.ts +185 -0
- package/server/agent-types.ts +138 -0
- package/server/ai-validation-settings.test.ts +128 -0
- package/server/ai-validation-settings.ts +35 -0
- package/server/ai-validator.test.ts +387 -0
- package/server/ai-validator.ts +271 -0
- package/server/auth-manager.test.ts +83 -0
- package/server/auth-manager.ts +150 -0
- package/server/auto-namer.test.ts +252 -0
- package/server/auto-namer.ts +78 -0
- package/server/backend-adapter.test.ts +38 -0
- package/server/backend-adapter.ts +54 -0
- package/server/cache-headers.test.ts +98 -0
- package/server/cache-headers.ts +61 -0
- package/server/claude-adapter.test.ts +1363 -0
- package/server/claude-adapter.ts +889 -0
- package/server/claude-container-auth.test.ts +44 -0
- package/server/claude-container-auth.ts +30 -0
- package/server/claude-protocol-contract.test.ts +71 -0
- package/server/claude-protocol-drift.test.ts +78 -0
- package/server/claude-session-discovery.test.ts +132 -0
- package/server/claude-session-discovery.ts +157 -0
- package/server/claude-session-history.test.ts +158 -0
- package/server/claude-session-history.ts +410 -0
- package/server/cli-launcher.test.ts +1343 -0
- package/server/cli-launcher.ts +1298 -0
- package/server/cli.test.ts +16 -0
- package/server/codex-adapter.test.ts +5545 -0
- package/server/codex-adapter.ts +3062 -0
- package/server/codex-container-auth.test.ts +50 -0
- package/server/codex-container-auth.ts +24 -0
- package/server/codex-home.test.ts +61 -0
- package/server/codex-home.ts +26 -0
- package/server/codex-protocol-contract.test.ts +96 -0
- package/server/codex-protocol-drift.test.ts +123 -0
- package/server/codex-ws-proxy.cjs +226 -0
- package/server/commands-discovery.test.ts +179 -0
- package/server/commands-discovery.ts +81 -0
- package/server/constants.ts +7 -0
- package/server/container-manager.test.ts +1211 -0
- package/server/container-manager.ts +1053 -0
- package/server/cron-scheduler.test.ts +957 -0
- package/server/cron-scheduler.ts +243 -0
- package/server/cron-store.test.ts +422 -0
- package/server/cron-store.ts +148 -0
- package/server/cron-types.ts +63 -0
- package/server/env-manager.test.ts +268 -0
- package/server/env-manager.ts +161 -0
- package/server/event-bus-types.ts +64 -0
- package/server/event-bus.test.ts +244 -0
- package/server/event-bus.ts +124 -0
- package/server/execution-store.test.ts +307 -0
- package/server/execution-store.ts +170 -0
- package/server/fs-utils.ts +15 -0
- package/server/git-utils.test.ts +938 -0
- package/server/git-utils.ts +421 -0
- package/server/github-pr.test.ts +498 -0
- package/server/github-pr.ts +379 -0
- package/server/image-pull-manager.test.ts +303 -0
- package/server/image-pull-manager.ts +279 -0
- package/server/index.ts +396 -0
- package/server/linear-agent-bridge.test.ts +1157 -0
- package/server/linear-agent-bridge.ts +629 -0
- package/server/linear-agent.test.ts +473 -0
- package/server/linear-agent.ts +479 -0
- package/server/linear-cache.test.ts +136 -0
- package/server/linear-cache.ts +113 -0
- package/server/linear-connections.test.ts +350 -0
- package/server/linear-connections.ts +231 -0
- package/server/linear-credential-migration.test.ts +337 -0
- package/server/linear-credential-migration.ts +63 -0
- package/server/linear-oauth-connections-migration.test.ts +268 -0
- package/server/linear-oauth-connections.test.ts +365 -0
- package/server/linear-oauth-connections.ts +294 -0
- package/server/linear-project-manager.test.ts +162 -0
- package/server/linear-project-manager.ts +111 -0
- package/server/linear-prompt-builder.test.ts +74 -0
- package/server/linear-prompt-builder.ts +61 -0
- package/server/linear-staging.test.ts +276 -0
- package/server/linear-staging.ts +142 -0
- package/server/logger.test.ts +393 -0
- package/server/logger.ts +259 -0
- package/server/metrics-collector.test.ts +413 -0
- package/server/metrics-collector.ts +350 -0
- package/server/metrics-types.ts +108 -0
- package/server/middleware/managed-auth.test.ts +264 -0
- package/server/middleware/managed-auth.ts +195 -0
- package/server/novnc-proxy.test.ts +333 -0
- package/server/novnc-proxy.ts +99 -0
- package/server/path-resolver.test.ts +552 -0
- package/server/path-resolver.ts +186 -0
- package/server/paths.test.ts +31 -0
- package/server/paths.ts +11 -0
- package/server/pr-poller.test.ts +191 -0
- package/server/pr-poller.ts +162 -0
- package/server/prompt-manager.test.ts +211 -0
- package/server/prompt-manager.ts +211 -0
- package/server/protocol/claude-upstream/README.md +19 -0
- package/server/protocol/claude-upstream/sdk.d.ts.txt +1943 -0
- package/server/protocol/codex-upstream/ClientNotification.ts.txt +5 -0
- package/server/protocol/codex-upstream/ClientRequest.ts.txt +60 -0
- package/server/protocol/codex-upstream/README.md +18 -0
- package/server/protocol/codex-upstream/ServerNotification.ts.txt +41 -0
- package/server/protocol/codex-upstream/ServerRequest.ts.txt +16 -0
- package/server/protocol/codex-upstream/v2/DynamicToolCallParams.ts.txt +6 -0
- package/server/protocol/codex-upstream/v2/DynamicToolCallResponse.ts.txt +6 -0
- package/server/protocol-monitor.ts +50 -0
- package/server/recorder.test.ts +454 -0
- package/server/recorder.ts +374 -0
- package/server/recording-hub/compat-validator.test.ts +150 -0
- package/server/recording-hub/compat-validator.ts +284 -0
- package/server/recording-hub/diagnostics.test.ts +140 -0
- package/server/recording-hub/diagnostics.ts +299 -0
- package/server/recording-hub/hub-config.test.ts +44 -0
- package/server/recording-hub/hub-config.ts +19 -0
- package/server/recording-hub/hub-routes.test.ts +417 -0
- package/server/recording-hub/hub-routes.ts +236 -0
- package/server/recording-hub/hub-store.test.ts +262 -0
- package/server/recording-hub/hub-store.ts +265 -0
- package/server/recording-hub/replay-adapter.test.ts +294 -0
- package/server/recording-hub/replay-adapter.ts +207 -0
- package/server/relay-client.test.ts +337 -0
- package/server/relay-client.ts +320 -0
- package/server/replay.test.ts +200 -0
- package/server/replay.ts +78 -0
- package/server/routes/agent-routes.test.ts +1400 -0
- package/server/routes/agent-routes.ts +409 -0
- package/server/routes/cron-routes.test.ts +881 -0
- package/server/routes/cron-routes.ts +103 -0
- package/server/routes/env-routes.test.ts +383 -0
- package/server/routes/env-routes.ts +95 -0
- package/server/routes/fs-routes.test.ts +1198 -0
- package/server/routes/fs-routes.ts +605 -0
- package/server/routes/git-routes.test.ts +813 -0
- package/server/routes/git-routes.ts +97 -0
- package/server/routes/linear-agent-routes.test.ts +721 -0
- package/server/routes/linear-agent-routes.ts +304 -0
- package/server/routes/linear-connection-routes.test.ts +927 -0
- package/server/routes/linear-connection-routes.ts +244 -0
- package/server/routes/linear-oauth-connection-routes.test.ts +406 -0
- package/server/routes/linear-oauth-connection-routes.ts +129 -0
- package/server/routes/linear-routes.test.ts +1510 -0
- package/server/routes/linear-routes.ts +953 -0
- package/server/routes/metrics-routes.test.ts +103 -0
- package/server/routes/metrics-routes.ts +13 -0
- package/server/routes/prompt-routes.ts +67 -0
- package/server/routes/sandbox-routes.test.ts +513 -0
- package/server/routes/sandbox-routes.ts +127 -0
- package/server/routes/settings-routes.ts +270 -0
- package/server/routes/skills-routes.test.ts +690 -0
- package/server/routes/skills-routes.ts +100 -0
- package/server/routes/system-routes.test.ts +637 -0
- package/server/routes/system-routes.ts +228 -0
- package/server/routes/tailscale-routes.test.ts +176 -0
- package/server/routes/tailscale-routes.ts +22 -0
- package/server/routes.test.ts +4655 -0
- package/server/routes.ts +1277 -0
- package/server/sandbox-manager.test.ts +378 -0
- package/server/sandbox-manager.ts +168 -0
- package/server/service.test.ts +1419 -0
- package/server/service.ts +718 -0
- package/server/session-creation-service.test.ts +661 -0
- package/server/session-creation-service.ts +473 -0
- package/server/session-git-info.ts +104 -0
- package/server/session-linear-issues.test.ts +118 -0
- package/server/session-linear-issues.ts +88 -0
- package/server/session-names.test.ts +94 -0
- package/server/session-names.ts +67 -0
- package/server/session-orchestrator.test.ts +1784 -0
- package/server/session-orchestrator.ts +973 -0
- package/server/session-state-machine.test.ts +606 -0
- package/server/session-state-machine.ts +207 -0
- package/server/session-store.test.ts +290 -0
- package/server/session-store.ts +146 -0
- package/server/session-types.ts +509 -0
- package/server/settings-manager.test.ts +275 -0
- package/server/settings-manager.ts +173 -0
- package/server/tailscale-manager.test.ts +553 -0
- package/server/tailscale-manager.ts +451 -0
- package/server/terminal-manager.ts +240 -0
- package/server/update-checker.test.ts +306 -0
- package/server/update-checker.ts +197 -0
- package/server/usage-limits.test.ts +536 -0
- package/server/usage-limits.ts +225 -0
- package/server/worktree-tracker.test.ts +243 -0
- package/server/worktree-tracker.ts +84 -0
- package/server/ws-auth.test.ts +59 -0
- package/server/ws-auth.ts +41 -0
- package/server/ws-bridge-browser-ingest.test.ts +272 -0
- package/server/ws-bridge-browser-ingest.ts +72 -0
- package/server/ws-bridge-browser.ts +112 -0
- package/server/ws-bridge-cli-ingest.test.ts +302 -0
- package/server/ws-bridge-cli-ingest.ts +81 -0
- package/server/ws-bridge-codex.test.ts +1837 -0
- package/server/ws-bridge-codex.ts +266 -0
- package/server/ws-bridge-controls.test.ts +124 -0
- package/server/ws-bridge-controls.ts +20 -0
- package/server/ws-bridge-persist.test.ts +296 -0
- package/server/ws-bridge-persist.ts +66 -0
- package/server/ws-bridge-publish.test.ts +234 -0
- package/server/ws-bridge-publish.ts +79 -0
- package/server/ws-bridge-replay.test.ts +44 -0
- package/server/ws-bridge-replay.ts +61 -0
- package/server/ws-bridge-types.ts +106 -0
- package/server/ws-bridge.test.ts +4777 -0
- package/server/ws-bridge.ts +1279 -0
|
@@ -0,0 +1,1784 @@
|
|
|
1
|
+
import { vi, describe, it, expect, beforeEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
// ── Module mocks ────────────────────────────────────────────────────────────
|
|
4
|
+
// Must be declared before any imports that reference them.
|
|
5
|
+
|
|
6
|
+
vi.mock("./env-manager.js", () => ({
|
|
7
|
+
getEnv: vi.fn(() => null),
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
vi.mock("./sandbox-manager.js", () => ({
|
|
11
|
+
getSandbox: vi.fn(() => null),
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
vi.mock("./git-utils.js", () => ({
|
|
15
|
+
getRepoInfo: vi.fn(() => null),
|
|
16
|
+
gitFetch: vi.fn(() => ({ success: true, output: "" })),
|
|
17
|
+
gitPull: vi.fn(() => ({ success: true, output: "" })),
|
|
18
|
+
checkoutOrCreateBranch: vi.fn(() => ({ created: false })),
|
|
19
|
+
ensureWorktree: vi.fn(() => ({ worktreePath: "/wt/feat", actualBranch: "feat", isNew: true })),
|
|
20
|
+
isWorktreeDirty: vi.fn(() => false),
|
|
21
|
+
removeWorktree: vi.fn(() => ({ removed: true })),
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
vi.mock("./session-names.js", () => ({
|
|
25
|
+
getName: vi.fn(() => undefined),
|
|
26
|
+
setName: vi.fn(),
|
|
27
|
+
getAllNames: vi.fn(() => ({})),
|
|
28
|
+
removeName: vi.fn(),
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
vi.mock("./session-linear-issues.js", () => ({
|
|
32
|
+
getLinearIssue: vi.fn(() => undefined),
|
|
33
|
+
setLinearIssue: vi.fn(),
|
|
34
|
+
removeLinearIssue: vi.fn(),
|
|
35
|
+
getAllLinearIssues: vi.fn(() => ({})),
|
|
36
|
+
}));
|
|
37
|
+
|
|
38
|
+
vi.mock("./settings-manager.js", () => ({
|
|
39
|
+
getSettings: vi.fn(() => ({
|
|
40
|
+
anthropicApiKey: "",
|
|
41
|
+
anthropicModel: "claude-sonnet-4-6",
|
|
42
|
+
linearApiKey: "",
|
|
43
|
+
linearAutoTransition: false,
|
|
44
|
+
linearAutoTransitionStateId: "",
|
|
45
|
+
linearAutoTransitionStateName: "",
|
|
46
|
+
linearArchiveTransition: false,
|
|
47
|
+
linearArchiveTransitionStateId: "",
|
|
48
|
+
linearArchiveTransitionStateName: "",
|
|
49
|
+
claudeCodeOAuthToken: "",
|
|
50
|
+
openaiApiKey: "",
|
|
51
|
+
onboardingCompleted: false,
|
|
52
|
+
})),
|
|
53
|
+
}));
|
|
54
|
+
|
|
55
|
+
vi.mock("./linear-connections.js", () => ({
|
|
56
|
+
getConnection: vi.fn(() => null),
|
|
57
|
+
resolveApiKey: vi.fn(() => null),
|
|
58
|
+
}));
|
|
59
|
+
|
|
60
|
+
vi.mock("./linear-prompt-builder.js", () => ({
|
|
61
|
+
buildLinearSystemPrompt: vi.fn(() => ""),
|
|
62
|
+
}));
|
|
63
|
+
|
|
64
|
+
vi.mock("./routes/linear-routes.js", () => ({
|
|
65
|
+
transitionLinearIssue: vi.fn(async () => ({ ok: true })),
|
|
66
|
+
fetchLinearTeamStates: vi.fn(async () => []),
|
|
67
|
+
}));
|
|
68
|
+
|
|
69
|
+
vi.mock("./claude-container-auth.js", () => ({
|
|
70
|
+
hasContainerClaudeAuth: vi.fn(() => true),
|
|
71
|
+
}));
|
|
72
|
+
|
|
73
|
+
vi.mock("./codex-container-auth.js", () => ({
|
|
74
|
+
hasContainerCodexAuth: vi.fn(() => true),
|
|
75
|
+
}));
|
|
76
|
+
|
|
77
|
+
vi.mock("./commands-discovery.js", () => ({
|
|
78
|
+
discoverCommandsAndSkills: vi.fn(async () => ({ slash_commands: [], skills: [] })),
|
|
79
|
+
}));
|
|
80
|
+
|
|
81
|
+
vi.mock("./auto-namer.js", () => ({
|
|
82
|
+
generateSessionTitle: vi.fn(async () => "Test Title"),
|
|
83
|
+
}));
|
|
84
|
+
|
|
85
|
+
const mockImagePullIsReady = vi.hoisted(() => vi.fn(() => true));
|
|
86
|
+
const mockImagePullGetState = vi.hoisted(() => vi.fn(() => ({ image: "", status: "ready", progress: [] })));
|
|
87
|
+
const mockImagePullEnsureImage = vi.hoisted(() => vi.fn());
|
|
88
|
+
const mockImagePullWaitForReady = vi.hoisted(() => vi.fn(async () => true));
|
|
89
|
+
const mockImagePullOnProgress = vi.hoisted(() => vi.fn(() => () => {}));
|
|
90
|
+
|
|
91
|
+
vi.mock("./image-pull-manager.js", () => ({
|
|
92
|
+
imagePullManager: {
|
|
93
|
+
isReady: mockImagePullIsReady,
|
|
94
|
+
getState: mockImagePullGetState,
|
|
95
|
+
ensureImage: mockImagePullEnsureImage,
|
|
96
|
+
waitForReady: mockImagePullWaitForReady,
|
|
97
|
+
onProgress: mockImagePullOnProgress,
|
|
98
|
+
},
|
|
99
|
+
}));
|
|
100
|
+
|
|
101
|
+
vi.mock("./container-manager.js", () => ({
|
|
102
|
+
containerManager: {
|
|
103
|
+
removeContainer: vi.fn(),
|
|
104
|
+
createContainer: vi.fn(() => ({
|
|
105
|
+
containerId: "cid-1",
|
|
106
|
+
name: "companion-1",
|
|
107
|
+
image: "the-companion:latest",
|
|
108
|
+
portMappings: [],
|
|
109
|
+
hostCwd: "/test",
|
|
110
|
+
containerCwd: "/workspace",
|
|
111
|
+
state: "running",
|
|
112
|
+
})),
|
|
113
|
+
imageExists: vi.fn(() => true),
|
|
114
|
+
retrack: vi.fn(),
|
|
115
|
+
copyWorkspaceToContainer: vi.fn(async () => {}),
|
|
116
|
+
reseedGitAuth: vi.fn(),
|
|
117
|
+
gitOpsInContainer: vi.fn(() => ({
|
|
118
|
+
fetchOk: true,
|
|
119
|
+
checkoutOk: true,
|
|
120
|
+
pullOk: true,
|
|
121
|
+
errors: [],
|
|
122
|
+
})),
|
|
123
|
+
execInContainerAsync: vi.fn(async () => ({ exitCode: 0, output: "ok" })),
|
|
124
|
+
isContainerAlive: vi.fn(() => "not_found"),
|
|
125
|
+
},
|
|
126
|
+
}));
|
|
127
|
+
|
|
128
|
+
// ── Imports (after mocks) ───────────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
import { SessionOrchestrator } from "./session-orchestrator.js";
|
|
131
|
+
import type { SessionOrchestratorDeps } from "./session-orchestrator.js";
|
|
132
|
+
import { containerManager } from "./container-manager.js";
|
|
133
|
+
import * as envManager from "./env-manager.js";
|
|
134
|
+
import * as sandboxManager from "./sandbox-manager.js";
|
|
135
|
+
import * as gitUtils from "./git-utils.js";
|
|
136
|
+
import * as sessionNames from "./session-names.js";
|
|
137
|
+
import * as sessionLinearIssues from "./session-linear-issues.js";
|
|
138
|
+
import * as settingsManager from "./settings-manager.js";
|
|
139
|
+
import { resolveApiKey } from "./linear-connections.js";
|
|
140
|
+
import { transitionLinearIssue, fetchLinearTeamStates } from "./routes/linear-routes.js";
|
|
141
|
+
import { hasContainerClaudeAuth } from "./claude-container-auth.js";
|
|
142
|
+
import { hasContainerCodexAuth } from "./codex-container-auth.js";
|
|
143
|
+
import { generateSessionTitle } from "./auto-namer.js";
|
|
144
|
+
import { companionBus } from "./event-bus.js";
|
|
145
|
+
|
|
146
|
+
// ── Mock factories ──────────────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
function createMockLauncher() {
|
|
149
|
+
return {
|
|
150
|
+
launch: vi.fn(() => ({
|
|
151
|
+
sessionId: "session-1",
|
|
152
|
+
state: "starting",
|
|
153
|
+
cwd: "/test",
|
|
154
|
+
createdAt: Date.now(),
|
|
155
|
+
})),
|
|
156
|
+
kill: vi.fn(async () => true),
|
|
157
|
+
relaunch: vi.fn(async () => ({ ok: true })),
|
|
158
|
+
listSessions: vi.fn(() => []),
|
|
159
|
+
getSession: vi.fn(() => undefined),
|
|
160
|
+
setArchived: vi.fn(),
|
|
161
|
+
removeSession: vi.fn(),
|
|
162
|
+
setCLISessionId: vi.fn(),
|
|
163
|
+
getStartingSessions: vi.fn(() => []),
|
|
164
|
+
} as any;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function createMockBridge() {
|
|
168
|
+
return {
|
|
169
|
+
closeSession: vi.fn(),
|
|
170
|
+
isCliConnected: vi.fn(() => false),
|
|
171
|
+
getSession: vi.fn(() => null),
|
|
172
|
+
getAllSessions: vi.fn(() => []),
|
|
173
|
+
markContainerized: vi.fn(),
|
|
174
|
+
prePopulateCommands: vi.fn(),
|
|
175
|
+
broadcastNameUpdate: vi.fn(),
|
|
176
|
+
broadcastToSession: vi.fn(),
|
|
177
|
+
injectSystemPrompt: vi.fn(),
|
|
178
|
+
attachBackendAdapter: vi.fn(),
|
|
179
|
+
cancelDisconnectTimer: vi.fn(() => false),
|
|
180
|
+
} as any;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function createMockStore() {
|
|
184
|
+
return {
|
|
185
|
+
setArchived: vi.fn(() => true),
|
|
186
|
+
} as any;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function createMockTracker() {
|
|
190
|
+
return {
|
|
191
|
+
addMapping: vi.fn(),
|
|
192
|
+
getBySession: vi.fn(() => null),
|
|
193
|
+
removeBySession: vi.fn(),
|
|
194
|
+
isWorktreeInUse: vi.fn(() => false),
|
|
195
|
+
} as any;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function createDeps(overrides?: Partial<SessionOrchestratorDeps>) {
|
|
199
|
+
const launcher = createMockLauncher();
|
|
200
|
+
const wsBridge = createMockBridge();
|
|
201
|
+
const sessionStore = createMockStore();
|
|
202
|
+
const worktreeTracker = createMockTracker();
|
|
203
|
+
const prPoller = { watch: vi.fn(), unwatch: vi.fn() };
|
|
204
|
+
const agentExecutor = { handleSessionExited: vi.fn() } as any;
|
|
205
|
+
return {
|
|
206
|
+
launcher,
|
|
207
|
+
wsBridge,
|
|
208
|
+
sessionStore,
|
|
209
|
+
worktreeTracker,
|
|
210
|
+
prPoller,
|
|
211
|
+
agentExecutor,
|
|
212
|
+
...overrides,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ── Tests ───────────────────────────────────────────────────────────────────
|
|
217
|
+
|
|
218
|
+
describe("SessionOrchestrator", () => {
|
|
219
|
+
let deps: ReturnType<typeof createDeps>;
|
|
220
|
+
let orchestrator: SessionOrchestrator;
|
|
221
|
+
|
|
222
|
+
beforeEach(() => {
|
|
223
|
+
vi.clearAllMocks();
|
|
224
|
+
companionBus.clear();
|
|
225
|
+
mockImagePullIsReady.mockReturnValue(true);
|
|
226
|
+
// Re-establish mocks that may have been overridden by mockImplementation in
|
|
227
|
+
// previous tests (clearAllMocks resets calls/results but NOT implementations).
|
|
228
|
+
vi.mocked(hasContainerClaudeAuth).mockReturnValue(true);
|
|
229
|
+
vi.mocked(hasContainerCodexAuth).mockReturnValue(true);
|
|
230
|
+
vi.mocked(containerManager.createContainer).mockReturnValue({
|
|
231
|
+
containerId: "cid-1",
|
|
232
|
+
name: "companion-1",
|
|
233
|
+
image: "the-companion:latest",
|
|
234
|
+
portMappings: [],
|
|
235
|
+
hostCwd: "/test",
|
|
236
|
+
containerCwd: "/workspace",
|
|
237
|
+
state: "running",
|
|
238
|
+
} as any);
|
|
239
|
+
vi.mocked(containerManager.gitOpsInContainer).mockReturnValue({
|
|
240
|
+
fetchOk: true,
|
|
241
|
+
checkoutOk: true,
|
|
242
|
+
pullOk: true,
|
|
243
|
+
errors: [],
|
|
244
|
+
} as any);
|
|
245
|
+
vi.mocked(containerManager.execInContainerAsync).mockResolvedValue({ exitCode: 0, output: "ok" });
|
|
246
|
+
deps = createDeps();
|
|
247
|
+
orchestrator = new SessionOrchestrator(deps);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
// ── Initialization / Event wiring ─────────────────────────────────────────
|
|
251
|
+
|
|
252
|
+
describe("initialize()", () => {
|
|
253
|
+
it("registers all expected event listeners on companionBus", () => {
|
|
254
|
+
// Verifies that initialize() wires up all event handlers on the bus
|
|
255
|
+
orchestrator.initialize();
|
|
256
|
+
|
|
257
|
+
expect(companionBus.listenerCount("session:cli-id-received")).toBeGreaterThan(0);
|
|
258
|
+
expect(companionBus.listenerCount("backend:codex-adapter-created")).toBeGreaterThan(0);
|
|
259
|
+
expect(companionBus.listenerCount("session:exited")).toBeGreaterThan(0);
|
|
260
|
+
expect(companionBus.listenerCount("session:git-info-ready")).toBeGreaterThan(0);
|
|
261
|
+
expect(companionBus.listenerCount("session:relaunch-needed")).toBeGreaterThan(0);
|
|
262
|
+
expect(companionBus.listenerCount("session:idle-kill")).toBeGreaterThan(0);
|
|
263
|
+
expect(companionBus.listenerCount("session:first-turn-completed")).toBeGreaterThan(0);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("CLI session ID callback delegates to launcher.setCLISessionId", () => {
|
|
267
|
+
orchestrator.initialize();
|
|
268
|
+
|
|
269
|
+
// Emit event on the bus instead of extracting callback
|
|
270
|
+
companionBus.emit("session:cli-id-received", { sessionId: "s1", cliSessionId: "cli-id-123" });
|
|
271
|
+
|
|
272
|
+
expect(deps.launcher.setCLISessionId).toHaveBeenCalledWith("s1", "cli-id-123");
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("session exit callback notifies agentExecutor", () => {
|
|
276
|
+
orchestrator.initialize();
|
|
277
|
+
|
|
278
|
+
companionBus.emit("session:exited", { sessionId: "s1", exitCode: 0 });
|
|
279
|
+
|
|
280
|
+
expect(deps.agentExecutor.handleSessionExited).toHaveBeenCalledWith("s1", 0);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it("git info ready callback starts PR polling", () => {
|
|
284
|
+
orchestrator.initialize();
|
|
285
|
+
|
|
286
|
+
companionBus.emit("session:git-info-ready", { sessionId: "s1", cwd: "/repo", branch: "main" });
|
|
287
|
+
|
|
288
|
+
expect(deps.prPoller.watch).toHaveBeenCalledWith("s1", "/repo", "main");
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it("idle kill callback does not kill archived sessions", async () => {
|
|
292
|
+
deps.launcher.getSession.mockReturnValue({ archived: true });
|
|
293
|
+
orchestrator.initialize();
|
|
294
|
+
|
|
295
|
+
companionBus.emit("session:idle-kill", { sessionId: "s1" });
|
|
296
|
+
await new Promise(r => setTimeout(r, 0));
|
|
297
|
+
|
|
298
|
+
// Should not kill because session is archived
|
|
299
|
+
expect(deps.launcher.kill).not.toHaveBeenCalled();
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it("idle kill callback kills CLI but preserves container", async () => {
|
|
303
|
+
deps.launcher.getSession.mockReturnValue({ archived: false });
|
|
304
|
+
orchestrator.initialize();
|
|
305
|
+
|
|
306
|
+
companionBus.emit("session:idle-kill", { sessionId: "s1" });
|
|
307
|
+
await new Promise(r => setTimeout(r, 0));
|
|
308
|
+
|
|
309
|
+
expect(deps.launcher.kill).toHaveBeenCalledWith("s1");
|
|
310
|
+
// Container must NOT be removed — idle-kill only stops the CLI process
|
|
311
|
+
// so the container can be reused on relaunch.
|
|
312
|
+
expect(containerManager.removeContainer).not.toHaveBeenCalled();
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it("after idle-kill, relaunch reuses preserved container without creating a new one", async () => {
|
|
316
|
+
// End-to-end scenario: idle-kill fires, container survives, browser
|
|
317
|
+
// reconnects, and the CLI is relaunched into the existing container.
|
|
318
|
+
vi.useFakeTimers();
|
|
319
|
+
deps.launcher.getSession.mockReturnValue({
|
|
320
|
+
archived: false,
|
|
321
|
+
state: "exited",
|
|
322
|
+
containerId: "cid-preserved",
|
|
323
|
+
pid: undefined,
|
|
324
|
+
} as any);
|
|
325
|
+
deps.wsBridge.isCliConnected.mockReturnValue(false);
|
|
326
|
+
deps.launcher.relaunch.mockResolvedValue({ ok: true });
|
|
327
|
+
orchestrator.initialize();
|
|
328
|
+
|
|
329
|
+
// 1. Idle-kill fires — CLI killed, container preserved
|
|
330
|
+
companionBus.emit("session:idle-kill", { sessionId: "s1" });
|
|
331
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
332
|
+
expect(deps.launcher.kill).toHaveBeenCalledWith("s1");
|
|
333
|
+
expect(containerManager.removeContainer).not.toHaveBeenCalled();
|
|
334
|
+
|
|
335
|
+
// 2. Browser reconnects — triggers auto-relaunch
|
|
336
|
+
companionBus.emit("session:relaunch-needed", { sessionId: "s1" });
|
|
337
|
+
await vi.advanceTimersByTimeAsync(15_000);
|
|
338
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
339
|
+
|
|
340
|
+
// 3. Relaunch succeeds using the preserved container — no new container created
|
|
341
|
+
expect(deps.launcher.relaunch).toHaveBeenCalledWith("s1");
|
|
342
|
+
expect(containerManager.createContainer).not.toHaveBeenCalled();
|
|
343
|
+
|
|
344
|
+
vi.useRealTimers();
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it("idle kill clears auto-relaunch counter so session can be fully relaunched later", async () => {
|
|
348
|
+
// After idle-kill, the auto-relaunch counter must be reset. Without this,
|
|
349
|
+
// a session that previously had failed relaunch attempts would be stuck at
|
|
350
|
+
// max and never relaunch when the user returns.
|
|
351
|
+
vi.useFakeTimers();
|
|
352
|
+
deps.launcher.getSession.mockReturnValue({ archived: false, state: "exited", pid: undefined } as any);
|
|
353
|
+
deps.wsBridge.isCliConnected.mockReturnValue(false);
|
|
354
|
+
deps.launcher.relaunch.mockResolvedValue({ ok: false, error: "failed" });
|
|
355
|
+
orchestrator.initialize();
|
|
356
|
+
|
|
357
|
+
// Exhaust 2 of 3 relaunch attempts
|
|
358
|
+
for (let i = 0; i < 2; i++) {
|
|
359
|
+
companionBus.emit("session:relaunch-needed", { sessionId: "s1" });
|
|
360
|
+
await vi.advanceTimersByTimeAsync(15_000);
|
|
361
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
362
|
+
}
|
|
363
|
+
expect(deps.launcher.relaunch).toHaveBeenCalledTimes(2);
|
|
364
|
+
|
|
365
|
+
// Now idle-kill the session — this should clear the counter
|
|
366
|
+
companionBus.emit("session:idle-kill", { sessionId: "s1" });
|
|
367
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
368
|
+
|
|
369
|
+
// After idle-kill, we should get a fresh budget of 3 relaunch attempts.
|
|
370
|
+
// Reset the mock to track new calls.
|
|
371
|
+
deps.launcher.relaunch.mockClear();
|
|
372
|
+
deps.launcher.relaunch.mockResolvedValue({ ok: false, error: "failed" });
|
|
373
|
+
|
|
374
|
+
for (let i = 0; i < 3; i++) {
|
|
375
|
+
companionBus.emit("session:relaunch-needed", { sessionId: "s1" });
|
|
376
|
+
await vi.advanceTimersByTimeAsync(15_000);
|
|
377
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// All 3 attempts should succeed (not blocked by previous count)
|
|
381
|
+
expect(deps.launcher.relaunch).toHaveBeenCalledTimes(3);
|
|
382
|
+
vi.useRealTimers();
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it("is idempotent — calling initialize() twice does not double-register listeners", () => {
|
|
386
|
+
// Guards against accidental re-initialization which would cause
|
|
387
|
+
// all event handlers to fire multiple times per event.
|
|
388
|
+
orchestrator.initialize();
|
|
389
|
+
const countsAfterFirst = {
|
|
390
|
+
cliId: companionBus.listenerCount("session:cli-id-received"),
|
|
391
|
+
codex: companionBus.listenerCount("backend:codex-adapter-created"),
|
|
392
|
+
exited: companionBus.listenerCount("session:exited"),
|
|
393
|
+
relaunch: companionBus.listenerCount("session:relaunch-needed"),
|
|
394
|
+
idleKill: companionBus.listenerCount("session:idle-kill"),
|
|
395
|
+
firstTurn: companionBus.listenerCount("session:first-turn-completed"),
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
orchestrator.initialize();
|
|
399
|
+
|
|
400
|
+
// Listener counts should not have doubled after the second initialize()
|
|
401
|
+
expect(companionBus.listenerCount("session:cli-id-received")).toBe(countsAfterFirst.cliId);
|
|
402
|
+
expect(companionBus.listenerCount("backend:codex-adapter-created")).toBe(countsAfterFirst.codex);
|
|
403
|
+
expect(companionBus.listenerCount("session:exited")).toBe(countsAfterFirst.exited);
|
|
404
|
+
expect(companionBus.listenerCount("session:relaunch-needed")).toBe(countsAfterFirst.relaunch);
|
|
405
|
+
expect(companionBus.listenerCount("session:idle-kill")).toBe(countsAfterFirst.idleKill);
|
|
406
|
+
expect(companionBus.listenerCount("session:first-turn-completed")).toBe(countsAfterFirst.firstTurn);
|
|
407
|
+
});
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
// ── Session Creation ──────────────────────────────────────────────────────
|
|
411
|
+
|
|
412
|
+
describe("createSession()", () => {
|
|
413
|
+
it("creates a basic session with defaults", async () => {
|
|
414
|
+
const result = await orchestrator.createSession({ cwd: "/test" });
|
|
415
|
+
|
|
416
|
+
expect(result.ok).toBe(true);
|
|
417
|
+
if (result.ok) {
|
|
418
|
+
expect(result.session.sessionId).toBe("session-1");
|
|
419
|
+
}
|
|
420
|
+
expect(deps.launcher.launch).toHaveBeenCalledWith(
|
|
421
|
+
expect.objectContaining({
|
|
422
|
+
cwd: "/test",
|
|
423
|
+
backendType: "claude",
|
|
424
|
+
}),
|
|
425
|
+
);
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
it("returns 400 for invalid backend", async () => {
|
|
429
|
+
const result = await orchestrator.createSession({ cwd: "/test", backend: "invalid" });
|
|
430
|
+
|
|
431
|
+
expect(result.ok).toBe(false);
|
|
432
|
+
if (!result.ok) {
|
|
433
|
+
expect(result.error).toContain("Invalid backend");
|
|
434
|
+
expect(result.status).toBe(400);
|
|
435
|
+
}
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it("resolves environment variables from envSlug", async () => {
|
|
439
|
+
vi.mocked(envManager.getEnv).mockReturnValue({
|
|
440
|
+
name: "Production",
|
|
441
|
+
slug: "production",
|
|
442
|
+
variables: { API_KEY: "secret", DB_HOST: "db.example.com" },
|
|
443
|
+
createdAt: 1000,
|
|
444
|
+
updatedAt: 1000,
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
const result = await orchestrator.createSession({ cwd: "/test", envSlug: "production" });
|
|
448
|
+
|
|
449
|
+
expect(result.ok).toBe(true);
|
|
450
|
+
expect(envManager.getEnv).toHaveBeenCalledWith("production");
|
|
451
|
+
expect(deps.launcher.launch).toHaveBeenCalledWith(
|
|
452
|
+
expect.objectContaining({
|
|
453
|
+
env: expect.objectContaining({ API_KEY: "secret", DB_HOST: "db.example.com" }),
|
|
454
|
+
}),
|
|
455
|
+
);
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
// ── Global token injection from settings ───────────────────────────
|
|
459
|
+
|
|
460
|
+
// Verifies that CLAUDE_CODE_OAUTH_TOKEN is injected from global settings
|
|
461
|
+
// when the session backend is "claude" and no token is already set
|
|
462
|
+
it("injects CLAUDE_CODE_OAUTH_TOKEN from global settings for claude backend", async () => {
|
|
463
|
+
vi.mocked(settingsManager.getSettings).mockReturnValue({
|
|
464
|
+
...settingsManager.getSettings(),
|
|
465
|
+
claudeCodeOAuthToken: "global-oauth-token",
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
await orchestrator.createSession({ cwd: "/test", backend: "claude" });
|
|
469
|
+
|
|
470
|
+
expect(deps.launcher.launch).toHaveBeenCalledWith(
|
|
471
|
+
expect.objectContaining({
|
|
472
|
+
env: expect.objectContaining({ CLAUDE_CODE_OAUTH_TOKEN: "global-oauth-token" }),
|
|
473
|
+
}),
|
|
474
|
+
);
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
// Verifies that OPENAI_API_KEY is injected from global settings
|
|
478
|
+
// when the session backend is "codex" and no key is already set
|
|
479
|
+
it("injects OPENAI_API_KEY from global settings for codex backend", async () => {
|
|
480
|
+
vi.mocked(settingsManager.getSettings).mockReturnValue({
|
|
481
|
+
...settingsManager.getSettings(),
|
|
482
|
+
openaiApiKey: "sk-global-key",
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
await orchestrator.createSession({ cwd: "/test", backend: "codex" });
|
|
486
|
+
|
|
487
|
+
expect(deps.launcher.launch).toHaveBeenCalledWith(
|
|
488
|
+
expect.objectContaining({
|
|
489
|
+
env: expect.objectContaining({ OPENAI_API_KEY: "sk-global-key" }),
|
|
490
|
+
}),
|
|
491
|
+
);
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
// Verifies that env-profile tokens take precedence over global settings
|
|
495
|
+
it("does not overwrite CLAUDE_CODE_OAUTH_TOKEN when already set by env profile", async () => {
|
|
496
|
+
vi.mocked(settingsManager.getSettings).mockReturnValue({
|
|
497
|
+
...settingsManager.getSettings(),
|
|
498
|
+
claudeCodeOAuthToken: "global-token",
|
|
499
|
+
});
|
|
500
|
+
vi.mocked(envManager.getEnv).mockReturnValue({
|
|
501
|
+
name: "Custom",
|
|
502
|
+
slug: "custom",
|
|
503
|
+
variables: { CLAUDE_CODE_OAUTH_TOKEN: "env-profile-token" },
|
|
504
|
+
createdAt: 1000,
|
|
505
|
+
updatedAt: 1000,
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
await orchestrator.createSession({ cwd: "/test", backend: "claude", envSlug: "custom" });
|
|
509
|
+
|
|
510
|
+
expect(deps.launcher.launch).toHaveBeenCalledWith(
|
|
511
|
+
expect.objectContaining({
|
|
512
|
+
env: expect.objectContaining({ CLAUDE_CODE_OAUTH_TOKEN: "env-profile-token" }),
|
|
513
|
+
}),
|
|
514
|
+
);
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
// Verifies that no token is injected when global settings have empty values
|
|
518
|
+
it("does not inject token when global setting is empty", async () => {
|
|
519
|
+
vi.mocked(settingsManager.getSettings).mockReturnValue({
|
|
520
|
+
...settingsManager.getSettings(),
|
|
521
|
+
claudeCodeOAuthToken: "",
|
|
522
|
+
openaiApiKey: "",
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
await orchestrator.createSession({ cwd: "/test", backend: "claude" });
|
|
526
|
+
|
|
527
|
+
const launchCall = vi.mocked(deps.launcher.launch).mock.calls[0][0];
|
|
528
|
+
expect(launchCall.env?.CLAUDE_CODE_OAUTH_TOKEN).toBeUndefined();
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
it("validates branch name to prevent injection", async () => {
|
|
532
|
+
const result = await orchestrator.createSession({ cwd: "/test", branch: "bad branch name!" });
|
|
533
|
+
|
|
534
|
+
expect(result.ok).toBe(false);
|
|
535
|
+
if (!result.ok) {
|
|
536
|
+
expect(result.error).toContain("Invalid branch name");
|
|
537
|
+
expect(result.status).toBe(400);
|
|
538
|
+
}
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
it("performs git fetch, checkout, and pull for non-docker branch", async () => {
|
|
542
|
+
vi.mocked(gitUtils.getRepoInfo).mockReturnValue({
|
|
543
|
+
repoRoot: "/repo",
|
|
544
|
+
repoName: "my-repo",
|
|
545
|
+
currentBranch: "develop",
|
|
546
|
+
defaultBranch: "main",
|
|
547
|
+
isWorktree: false,
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
const result = await orchestrator.createSession({ cwd: "/repo", branch: "main" });
|
|
551
|
+
|
|
552
|
+
expect(result.ok).toBe(true);
|
|
553
|
+
expect(gitUtils.gitFetch).toHaveBeenCalledWith("/repo");
|
|
554
|
+
expect(gitUtils.checkoutOrCreateBranch).toHaveBeenCalledWith("/repo", "main", {
|
|
555
|
+
createBranch: undefined,
|
|
556
|
+
defaultBranch: "main",
|
|
557
|
+
});
|
|
558
|
+
expect(gitUtils.gitPull).toHaveBeenCalledWith("/repo");
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
it("skips checkout when branch matches current branch", async () => {
|
|
562
|
+
vi.mocked(gitUtils.getRepoInfo).mockReturnValue({
|
|
563
|
+
repoRoot: "/repo",
|
|
564
|
+
repoName: "my-repo",
|
|
565
|
+
currentBranch: "main",
|
|
566
|
+
defaultBranch: "main",
|
|
567
|
+
isWorktree: false,
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
await orchestrator.createSession({ cwd: "/repo", branch: "main" });
|
|
571
|
+
|
|
572
|
+
expect(gitUtils.gitFetch).toHaveBeenCalled();
|
|
573
|
+
expect(gitUtils.checkoutOrCreateBranch).not.toHaveBeenCalled();
|
|
574
|
+
expect(gitUtils.gitPull).toHaveBeenCalled();
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
it("creates worktree when useWorktree is true", async () => {
|
|
578
|
+
vi.mocked(gitUtils.getRepoInfo).mockReturnValue({
|
|
579
|
+
repoRoot: "/repo",
|
|
580
|
+
repoName: "my-repo",
|
|
581
|
+
currentBranch: "main",
|
|
582
|
+
defaultBranch: "main",
|
|
583
|
+
isWorktree: false,
|
|
584
|
+
});
|
|
585
|
+
vi.mocked(gitUtils.ensureWorktree).mockReturnValue({
|
|
586
|
+
worktreePath: "/wt/feat",
|
|
587
|
+
branch: "feat",
|
|
588
|
+
actualBranch: "feat",
|
|
589
|
+
isNew: true,
|
|
590
|
+
} as any);
|
|
591
|
+
|
|
592
|
+
const result = await orchestrator.createSession({ cwd: "/repo", branch: "feat", useWorktree: true });
|
|
593
|
+
|
|
594
|
+
expect(result.ok).toBe(true);
|
|
595
|
+
expect(gitUtils.ensureWorktree).toHaveBeenCalledWith("/repo", "feat", {
|
|
596
|
+
baseBranch: "main",
|
|
597
|
+
createBranch: undefined,
|
|
598
|
+
forceNew: true,
|
|
599
|
+
});
|
|
600
|
+
// Launch should use worktree path as cwd
|
|
601
|
+
expect(deps.launcher.launch).toHaveBeenCalledWith(
|
|
602
|
+
expect.objectContaining({ cwd: "/wt/feat" }),
|
|
603
|
+
);
|
|
604
|
+
// Should track the worktree mapping
|
|
605
|
+
expect(deps.worktreeTracker.addMapping).toHaveBeenCalledWith(
|
|
606
|
+
expect.objectContaining({
|
|
607
|
+
sessionId: "session-1",
|
|
608
|
+
repoRoot: "/repo",
|
|
609
|
+
branch: "feat",
|
|
610
|
+
worktreePath: "/wt/feat",
|
|
611
|
+
}),
|
|
612
|
+
);
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
it("proceeds when git fetch fails (non-fatal)", async () => {
|
|
616
|
+
vi.mocked(gitUtils.getRepoInfo).mockReturnValue({
|
|
617
|
+
repoRoot: "/repo",
|
|
618
|
+
repoName: "my-repo",
|
|
619
|
+
currentBranch: "main",
|
|
620
|
+
defaultBranch: "main",
|
|
621
|
+
isWorktree: false,
|
|
622
|
+
});
|
|
623
|
+
vi.mocked(gitUtils.gitFetch).mockReturnValue({ success: false, output: "network error" });
|
|
624
|
+
|
|
625
|
+
const result = await orchestrator.createSession({ cwd: "/repo", branch: "main" });
|
|
626
|
+
|
|
627
|
+
expect(result.ok).toBe(true);
|
|
628
|
+
expect(deps.launcher.launch).toHaveBeenCalled();
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
it("returns 400 when containerized Claude lacks auth", async () => {
|
|
632
|
+
vi.mocked(hasContainerClaudeAuth).mockReturnValue(false);
|
|
633
|
+
vi.mocked(envManager.getEnv).mockReturnValue({
|
|
634
|
+
name: "E",
|
|
635
|
+
slug: "e",
|
|
636
|
+
variables: {},
|
|
637
|
+
createdAt: 1,
|
|
638
|
+
updatedAt: 1,
|
|
639
|
+
} as any);
|
|
640
|
+
|
|
641
|
+
const result = await orchestrator.createSession({
|
|
642
|
+
cwd: "/test",
|
|
643
|
+
sandboxEnabled: true,
|
|
644
|
+
envSlug: "e",
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
expect(result.ok).toBe(false);
|
|
648
|
+
if (!result.ok) {
|
|
649
|
+
expect(result.error).toContain("Containerized Claude requires auth");
|
|
650
|
+
expect(result.status).toBe(400);
|
|
651
|
+
}
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
it("returns 400 when containerized Codex lacks auth", async () => {
|
|
655
|
+
vi.mocked(hasContainerCodexAuth).mockReturnValue(false);
|
|
656
|
+
vi.mocked(envManager.getEnv).mockReturnValue({
|
|
657
|
+
name: "E",
|
|
658
|
+
slug: "e",
|
|
659
|
+
variables: {},
|
|
660
|
+
createdAt: 1,
|
|
661
|
+
updatedAt: 1,
|
|
662
|
+
} as any);
|
|
663
|
+
|
|
664
|
+
const result = await orchestrator.createSession({
|
|
665
|
+
cwd: "/test",
|
|
666
|
+
backend: "codex",
|
|
667
|
+
sandboxEnabled: true,
|
|
668
|
+
envSlug: "e",
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
expect(result.ok).toBe(false);
|
|
672
|
+
if (!result.ok) {
|
|
673
|
+
expect(result.error).toContain("Containerized Codex requires auth");
|
|
674
|
+
expect(result.status).toBe(400);
|
|
675
|
+
}
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
it("creates container for sandboxed sessions", async () => {
|
|
679
|
+
vi.mocked(envManager.getEnv).mockReturnValue({
|
|
680
|
+
name: "Docker",
|
|
681
|
+
slug: "docker",
|
|
682
|
+
variables: { CLAUDE_CODE_OAUTH_TOKEN: "token" },
|
|
683
|
+
createdAt: 1,
|
|
684
|
+
updatedAt: 1,
|
|
685
|
+
} as any);
|
|
686
|
+
vi.mocked(sandboxManager.getSandbox).mockReturnValue({
|
|
687
|
+
name: "Docker",
|
|
688
|
+
slug: "docker",
|
|
689
|
+
createdAt: 1,
|
|
690
|
+
updatedAt: 1,
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
const result = await orchestrator.createSession({
|
|
694
|
+
cwd: "/test",
|
|
695
|
+
envSlug: "docker",
|
|
696
|
+
sandboxEnabled: true,
|
|
697
|
+
sandboxSlug: "docker",
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
expect(result.ok).toBe(true);
|
|
701
|
+
expect(containerManager.createContainer).toHaveBeenCalled();
|
|
702
|
+
expect(containerManager.copyWorkspaceToContainer).toHaveBeenCalled();
|
|
703
|
+
expect(containerManager.retrack).toHaveBeenCalledWith("cid-1", "session-1");
|
|
704
|
+
expect(deps.wsBridge.markContainerized).toHaveBeenCalledWith("session-1", "/test");
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
it("returns 503 when container creation fails", async () => {
|
|
708
|
+
vi.mocked(envManager.getEnv).mockReturnValue({
|
|
709
|
+
name: "E",
|
|
710
|
+
slug: "e",
|
|
711
|
+
variables: { CLAUDE_CODE_OAUTH_TOKEN: "token" },
|
|
712
|
+
createdAt: 1,
|
|
713
|
+
updatedAt: 1,
|
|
714
|
+
} as any);
|
|
715
|
+
vi.mocked(containerManager.createContainer).mockImplementation(() => {
|
|
716
|
+
throw new Error("docker daemon timeout");
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
const result = await orchestrator.createSession({
|
|
720
|
+
cwd: "/test",
|
|
721
|
+
sandboxEnabled: true,
|
|
722
|
+
envSlug: "e",
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
expect(result.ok).toBe(false);
|
|
726
|
+
if (!result.ok) {
|
|
727
|
+
expect(result.error).toContain("container startup failed");
|
|
728
|
+
expect(result.status).toBe(503);
|
|
729
|
+
}
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
it("runs init script for sandbox sessions", async () => {
|
|
733
|
+
vi.mocked(envManager.getEnv).mockReturnValue({
|
|
734
|
+
name: "E",
|
|
735
|
+
slug: "e",
|
|
736
|
+
variables: { CLAUDE_CODE_OAUTH_TOKEN: "token" },
|
|
737
|
+
createdAt: 1,
|
|
738
|
+
updatedAt: 1,
|
|
739
|
+
} as any);
|
|
740
|
+
vi.mocked(sandboxManager.getSandbox).mockReturnValue({
|
|
741
|
+
name: "E",
|
|
742
|
+
slug: "e",
|
|
743
|
+
initScript: "npm install",
|
|
744
|
+
createdAt: 1,
|
|
745
|
+
updatedAt: 1,
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
const result = await orchestrator.createSession({
|
|
749
|
+
cwd: "/test",
|
|
750
|
+
sandboxEnabled: true,
|
|
751
|
+
sandboxSlug: "e",
|
|
752
|
+
envSlug: "e",
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
expect(result.ok).toBe(true);
|
|
756
|
+
expect(containerManager.execInContainerAsync).toHaveBeenCalledWith(
|
|
757
|
+
"cid-1",
|
|
758
|
+
["sh", "-lc", "npm install"],
|
|
759
|
+
expect.objectContaining({ timeout: expect.any(Number) }),
|
|
760
|
+
);
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
it("returns 503 when init script fails", async () => {
|
|
764
|
+
vi.mocked(envManager.getEnv).mockReturnValue({
|
|
765
|
+
name: "E",
|
|
766
|
+
slug: "e",
|
|
767
|
+
variables: { CLAUDE_CODE_OAUTH_TOKEN: "token" },
|
|
768
|
+
createdAt: 1,
|
|
769
|
+
updatedAt: 1,
|
|
770
|
+
} as any);
|
|
771
|
+
vi.mocked(sandboxManager.getSandbox).mockReturnValue({
|
|
772
|
+
name: "E",
|
|
773
|
+
slug: "e",
|
|
774
|
+
initScript: "exit 1",
|
|
775
|
+
createdAt: 1,
|
|
776
|
+
updatedAt: 1,
|
|
777
|
+
});
|
|
778
|
+
vi.mocked(containerManager.execInContainerAsync).mockResolvedValue({ exitCode: 1, output: "npm ERR!" });
|
|
779
|
+
|
|
780
|
+
const result = await orchestrator.createSession({
|
|
781
|
+
cwd: "/test",
|
|
782
|
+
sandboxEnabled: true,
|
|
783
|
+
sandboxSlug: "e",
|
|
784
|
+
envSlug: "e",
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
expect(result.ok).toBe(false);
|
|
788
|
+
if (!result.ok) {
|
|
789
|
+
expect(result.error).toContain("Init script failed");
|
|
790
|
+
expect(result.status).toBe(503);
|
|
791
|
+
// Container should be cleaned up
|
|
792
|
+
expect(containerManager.removeContainer).toHaveBeenCalled();
|
|
793
|
+
}
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
it("runs git ops inside container for Docker sessions with branch", async () => {
|
|
797
|
+
vi.mocked(gitUtils.getRepoInfo).mockReturnValue({
|
|
798
|
+
repoRoot: "/repo",
|
|
799
|
+
repoName: "my-repo",
|
|
800
|
+
currentBranch: "main",
|
|
801
|
+
defaultBranch: "main",
|
|
802
|
+
isWorktree: false,
|
|
803
|
+
} as any);
|
|
804
|
+
vi.mocked(envManager.getEnv).mockReturnValue({
|
|
805
|
+
name: "Docker",
|
|
806
|
+
slug: "docker",
|
|
807
|
+
variables: { CLAUDE_CODE_OAUTH_TOKEN: "token" },
|
|
808
|
+
createdAt: 1,
|
|
809
|
+
updatedAt: 1,
|
|
810
|
+
} as any);
|
|
811
|
+
vi.mocked(sandboxManager.getSandbox).mockReturnValue({
|
|
812
|
+
name: "Docker",
|
|
813
|
+
slug: "docker",
|
|
814
|
+
createdAt: 1,
|
|
815
|
+
updatedAt: 1,
|
|
816
|
+
});
|
|
817
|
+
|
|
818
|
+
const result = await orchestrator.createSession({
|
|
819
|
+
cwd: "/repo",
|
|
820
|
+
branch: "feat/new",
|
|
821
|
+
envSlug: "docker",
|
|
822
|
+
sandboxEnabled: true,
|
|
823
|
+
sandboxSlug: "docker",
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
expect(result.ok).toBe(true);
|
|
827
|
+
// Host git ops should NOT have been called
|
|
828
|
+
expect(gitUtils.gitFetch).not.toHaveBeenCalled();
|
|
829
|
+
expect(gitUtils.checkoutOrCreateBranch).not.toHaveBeenCalled();
|
|
830
|
+
expect(gitUtils.gitPull).not.toHaveBeenCalled();
|
|
831
|
+
// In-container git ops SHOULD have been called
|
|
832
|
+
expect(containerManager.gitOpsInContainer).toHaveBeenCalledWith(
|
|
833
|
+
"cid-1",
|
|
834
|
+
expect.objectContaining({ branch: "feat/new", currentBranch: "main" }),
|
|
835
|
+
);
|
|
836
|
+
});
|
|
837
|
+
|
|
838
|
+
it("returns 400 when in-container checkout fails", async () => {
|
|
839
|
+
vi.mocked(gitUtils.getRepoInfo).mockReturnValue({
|
|
840
|
+
repoRoot: "/repo",
|
|
841
|
+
repoName: "my-repo",
|
|
842
|
+
currentBranch: "main",
|
|
843
|
+
defaultBranch: "main",
|
|
844
|
+
isWorktree: false,
|
|
845
|
+
} as any);
|
|
846
|
+
vi.mocked(envManager.getEnv).mockReturnValue({
|
|
847
|
+
name: "E",
|
|
848
|
+
slug: "e",
|
|
849
|
+
variables: { CLAUDE_CODE_OAUTH_TOKEN: "token" },
|
|
850
|
+
createdAt: 1,
|
|
851
|
+
updatedAt: 1,
|
|
852
|
+
} as any);
|
|
853
|
+
vi.mocked(sandboxManager.getSandbox).mockReturnValue({
|
|
854
|
+
name: "E",
|
|
855
|
+
slug: "e",
|
|
856
|
+
createdAt: 1,
|
|
857
|
+
updatedAt: 1,
|
|
858
|
+
});
|
|
859
|
+
vi.mocked(containerManager.gitOpsInContainer).mockReturnValue({
|
|
860
|
+
fetchOk: true,
|
|
861
|
+
checkoutOk: false,
|
|
862
|
+
pullOk: false,
|
|
863
|
+
errors: ['branch "nonexistent" does not exist'],
|
|
864
|
+
});
|
|
865
|
+
|
|
866
|
+
const result = await orchestrator.createSession({
|
|
867
|
+
cwd: "/repo",
|
|
868
|
+
branch: "nonexistent",
|
|
869
|
+
sandboxEnabled: true,
|
|
870
|
+
sandboxSlug: "e",
|
|
871
|
+
envSlug: "e",
|
|
872
|
+
});
|
|
873
|
+
|
|
874
|
+
expect(result.ok).toBe(false);
|
|
875
|
+
if (!result.ok) {
|
|
876
|
+
expect(result.error).toContain("Failed to checkout branch");
|
|
877
|
+
expect(result.status).toBe(400);
|
|
878
|
+
expect(containerManager.removeContainer).toHaveBeenCalled();
|
|
879
|
+
}
|
|
880
|
+
});
|
|
881
|
+
|
|
882
|
+
it("passes resumeSessionAt and forkSession to launcher", async () => {
|
|
883
|
+
const result = await orchestrator.createSession({
|
|
884
|
+
cwd: "/test",
|
|
885
|
+
resumeSessionAt: " existing-session-id ",
|
|
886
|
+
forkSession: true,
|
|
887
|
+
});
|
|
888
|
+
|
|
889
|
+
expect(result.ok).toBe(true);
|
|
890
|
+
expect(deps.launcher.launch).toHaveBeenCalledWith(
|
|
891
|
+
expect.objectContaining({
|
|
892
|
+
resumeSessionAt: "existing-session-id",
|
|
893
|
+
forkSession: true,
|
|
894
|
+
}),
|
|
895
|
+
);
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
it("passes backendType codex to launcher", async () => {
|
|
899
|
+
const result = await orchestrator.createSession({
|
|
900
|
+
cwd: "/test",
|
|
901
|
+
backend: "codex",
|
|
902
|
+
model: "gpt-5",
|
|
903
|
+
});
|
|
904
|
+
|
|
905
|
+
expect(result.ok).toBe(true);
|
|
906
|
+
expect(deps.launcher.launch).toHaveBeenCalledWith(
|
|
907
|
+
expect.objectContaining({ backendType: "codex", model: "gpt-5" }),
|
|
908
|
+
);
|
|
909
|
+
});
|
|
910
|
+
|
|
911
|
+
it("catches thrown errors from launcher.launch and returns 503", async () => {
|
|
912
|
+
deps.launcher.launch.mockImplementation(() => {
|
|
913
|
+
throw new Error("CLI binary not found");
|
|
914
|
+
});
|
|
915
|
+
|
|
916
|
+
const result = await orchestrator.createSession({ cwd: "/test" });
|
|
917
|
+
|
|
918
|
+
expect(result.ok).toBe(false);
|
|
919
|
+
if (!result.ok) {
|
|
920
|
+
expect(result.error).toContain("CLI binary not found");
|
|
921
|
+
expect(result.status).toBe(503);
|
|
922
|
+
}
|
|
923
|
+
});
|
|
924
|
+
|
|
925
|
+
it("cleans up container when launcher.launch throws after container creation", async () => {
|
|
926
|
+
// If a container was created but launcher.launch throws, the container
|
|
927
|
+
// should be cleaned up to avoid leaking Docker resources.
|
|
928
|
+
vi.mocked(envManager.getEnv).mockReturnValue({
|
|
929
|
+
name: "E",
|
|
930
|
+
slug: "e",
|
|
931
|
+
variables: { CLAUDE_CODE_OAUTH_TOKEN: "token" },
|
|
932
|
+
createdAt: 1,
|
|
933
|
+
updatedAt: 1,
|
|
934
|
+
} as any);
|
|
935
|
+
deps.launcher.launch.mockImplementation(() => {
|
|
936
|
+
throw new Error("Binary not found");
|
|
937
|
+
});
|
|
938
|
+
|
|
939
|
+
const result = await orchestrator.createSession({
|
|
940
|
+
cwd: "/test",
|
|
941
|
+
sandboxEnabled: true,
|
|
942
|
+
envSlug: "e",
|
|
943
|
+
});
|
|
944
|
+
|
|
945
|
+
expect(result.ok).toBe(false);
|
|
946
|
+
if (!result.ok) {
|
|
947
|
+
expect(result.error).toContain("Failed to launch CLI");
|
|
948
|
+
expect(result.status).toBe(503);
|
|
949
|
+
}
|
|
950
|
+
// Container should be cleaned up after launch failure
|
|
951
|
+
expect(containerManager.removeContainer).toHaveBeenCalled();
|
|
952
|
+
});
|
|
953
|
+
});
|
|
954
|
+
|
|
955
|
+
// ── Streaming Session Creation ────────────────────────────────────────────
|
|
956
|
+
|
|
957
|
+
describe("createSessionStreaming()", () => {
|
|
958
|
+
it("calls progress callback during creation", async () => {
|
|
959
|
+
const onProgress = vi.fn();
|
|
960
|
+
const result = await orchestrator.createSessionStreaming({ cwd: "/test" }, onProgress);
|
|
961
|
+
|
|
962
|
+
expect(result.ok).toBe(true);
|
|
963
|
+
// Should have at least resolving_env and launching_cli progress events
|
|
964
|
+
expect(onProgress).toHaveBeenCalledWith("resolving_env", expect.any(String), "in_progress");
|
|
965
|
+
expect(onProgress).toHaveBeenCalledWith("resolving_env", expect.any(String), "done");
|
|
966
|
+
expect(onProgress).toHaveBeenCalledWith("launching_cli", expect.any(String), "in_progress");
|
|
967
|
+
expect(onProgress).toHaveBeenCalledWith("launching_cli", expect.any(String), "done");
|
|
968
|
+
});
|
|
969
|
+
|
|
970
|
+
it("emits correct label for codex backend", async () => {
|
|
971
|
+
const onProgress = vi.fn();
|
|
972
|
+
await orchestrator.createSessionStreaming({ cwd: "/test", backend: "codex" }, onProgress);
|
|
973
|
+
|
|
974
|
+
expect(onProgress).toHaveBeenCalledWith("launching_cli", "Launching Codex...", "in_progress");
|
|
975
|
+
});
|
|
976
|
+
|
|
977
|
+
it("emits correct label for claude backend", async () => {
|
|
978
|
+
const onProgress = vi.fn();
|
|
979
|
+
await orchestrator.createSessionStreaming({ cwd: "/test" }, onProgress);
|
|
980
|
+
|
|
981
|
+
expect(onProgress).toHaveBeenCalledWith("launching_cli", "Launching Claude Code...", "in_progress");
|
|
982
|
+
});
|
|
983
|
+
});
|
|
984
|
+
|
|
985
|
+
// ── Kill ───────────────────────────────────────────────────────────────────
|
|
986
|
+
|
|
987
|
+
describe("killSession()", () => {
|
|
988
|
+
it("kills launcher and removes container", async () => {
|
|
989
|
+
deps.launcher.kill.mockResolvedValue(true);
|
|
990
|
+
const result = await orchestrator.killSession("s1");
|
|
991
|
+
|
|
992
|
+
expect(result.ok).toBe(true);
|
|
993
|
+
expect(deps.launcher.kill).toHaveBeenCalledWith("s1");
|
|
994
|
+
expect(containerManager.removeContainer).toHaveBeenCalledWith("s1");
|
|
995
|
+
});
|
|
996
|
+
|
|
997
|
+
it("returns ok=false and does not remove container when session not found", async () => {
|
|
998
|
+
// When launcher.kill returns false (session not found), removeContainer
|
|
999
|
+
// should NOT be called to preserve the original behavior from routes.ts.
|
|
1000
|
+
deps.launcher.kill.mockResolvedValue(false);
|
|
1001
|
+
const result = await orchestrator.killSession("s1");
|
|
1002
|
+
|
|
1003
|
+
expect(result.ok).toBe(false);
|
|
1004
|
+
expect(containerManager.removeContainer).not.toHaveBeenCalled();
|
|
1005
|
+
});
|
|
1006
|
+
});
|
|
1007
|
+
|
|
1008
|
+
// ── Relaunch ──────────────────────────────────────────────────────────────
|
|
1009
|
+
|
|
1010
|
+
describe("relaunchSession()", () => {
|
|
1011
|
+
it("delegates to launcher.relaunch", async () => {
|
|
1012
|
+
const result = await orchestrator.relaunchSession("s1");
|
|
1013
|
+
|
|
1014
|
+
expect(result.ok).toBe(true);
|
|
1015
|
+
expect(deps.launcher.relaunch).toHaveBeenCalledWith("s1");
|
|
1016
|
+
});
|
|
1017
|
+
|
|
1018
|
+
it("rejects relaunching archived sessions", async () => {
|
|
1019
|
+
deps.launcher.getSession.mockReturnValue({ archived: true });
|
|
1020
|
+
|
|
1021
|
+
const result = await orchestrator.relaunchSession("s1");
|
|
1022
|
+
|
|
1023
|
+
expect(result.ok).toBe(false);
|
|
1024
|
+
expect(result.error).toContain("archived");
|
|
1025
|
+
expect(deps.launcher.relaunch).not.toHaveBeenCalled();
|
|
1026
|
+
});
|
|
1027
|
+
|
|
1028
|
+
it("propagates error from launcher.relaunch", async () => {
|
|
1029
|
+
deps.launcher.relaunch.mockResolvedValue({ ok: false, error: "Container removed externally" });
|
|
1030
|
+
|
|
1031
|
+
const result = await orchestrator.relaunchSession("s1");
|
|
1032
|
+
|
|
1033
|
+
expect(result.ok).toBe(false);
|
|
1034
|
+
expect(result.error).toContain("Container removed externally");
|
|
1035
|
+
});
|
|
1036
|
+
});
|
|
1037
|
+
|
|
1038
|
+
// ── Archive ───────────────────────────────────────────────────────────────
|
|
1039
|
+
|
|
1040
|
+
describe("archiveSession()", () => {
|
|
1041
|
+
it("kills, removes container, unwatches PR, and marks archived", async () => {
|
|
1042
|
+
const result = await orchestrator.archiveSession("s1");
|
|
1043
|
+
|
|
1044
|
+
expect(result.ok).toBe(true);
|
|
1045
|
+
expect(deps.launcher.kill).toHaveBeenCalledWith("s1");
|
|
1046
|
+
expect(containerManager.removeContainer).toHaveBeenCalledWith("s1");
|
|
1047
|
+
expect(deps.prPoller.unwatch).toHaveBeenCalledWith("s1");
|
|
1048
|
+
expect(deps.launcher.setArchived).toHaveBeenCalledWith("s1", true);
|
|
1049
|
+
expect(deps.sessionStore.setArchived).toHaveBeenCalledWith("s1", true);
|
|
1050
|
+
});
|
|
1051
|
+
|
|
1052
|
+
it("performs Linear transition when linearTransition=backlog", async () => {
|
|
1053
|
+
// Set up linked issue
|
|
1054
|
+
vi.mocked(sessionLinearIssues.getLinearIssue).mockReturnValue({
|
|
1055
|
+
id: "issue-1",
|
|
1056
|
+
identifier: "ENG-42",
|
|
1057
|
+
teamId: "team-1",
|
|
1058
|
+
connectionId: "conn-1",
|
|
1059
|
+
} as any);
|
|
1060
|
+
vi.mocked(resolveApiKey).mockReturnValue({ apiKey: "lin_api_123", connectionId: "conn-1" });
|
|
1061
|
+
vi.mocked(fetchLinearTeamStates).mockResolvedValue([
|
|
1062
|
+
{
|
|
1063
|
+
id: "team-1",
|
|
1064
|
+
key: "ENG",
|
|
1065
|
+
name: "Engineering",
|
|
1066
|
+
states: [
|
|
1067
|
+
{ id: "state-backlog", name: "Backlog", type: "backlog" },
|
|
1068
|
+
{ id: "state-done", name: "Done", type: "completed" },
|
|
1069
|
+
],
|
|
1070
|
+
},
|
|
1071
|
+
]);
|
|
1072
|
+
vi.mocked(transitionLinearIssue).mockResolvedValue({
|
|
1073
|
+
ok: true,
|
|
1074
|
+
issue: { id: "issue-1", identifier: "ENG-42", stateName: "Backlog", stateType: "backlog" },
|
|
1075
|
+
} as any);
|
|
1076
|
+
|
|
1077
|
+
const result = await orchestrator.archiveSession("s1", { linearTransition: "backlog" });
|
|
1078
|
+
|
|
1079
|
+
expect(result.ok).toBe(true);
|
|
1080
|
+
expect(fetchLinearTeamStates).toHaveBeenCalledWith("lin_api_123");
|
|
1081
|
+
expect(transitionLinearIssue).toHaveBeenCalledWith("issue-1", "state-backlog", "lin_api_123", "conn-1");
|
|
1082
|
+
// Session should still be archived even with transition
|
|
1083
|
+
expect(deps.launcher.setArchived).toHaveBeenCalledWith("s1", true);
|
|
1084
|
+
});
|
|
1085
|
+
|
|
1086
|
+
it("archives even when Linear transition fails", async () => {
|
|
1087
|
+
vi.mocked(sessionLinearIssues.getLinearIssue).mockReturnValue({
|
|
1088
|
+
id: "issue-1",
|
|
1089
|
+
identifier: "ENG-42",
|
|
1090
|
+
teamId: "team-1",
|
|
1091
|
+
connectionId: "conn-1",
|
|
1092
|
+
} as any);
|
|
1093
|
+
vi.mocked(resolveApiKey).mockReturnValue({ apiKey: "lin_api_123", connectionId: "conn-1" });
|
|
1094
|
+
vi.mocked(fetchLinearTeamStates).mockResolvedValue([{
|
|
1095
|
+
id: "team-1",
|
|
1096
|
+
key: "ENG",
|
|
1097
|
+
name: "Engineering",
|
|
1098
|
+
states: [{ id: "state-backlog", name: "Backlog", type: "backlog" }],
|
|
1099
|
+
}]);
|
|
1100
|
+
vi.mocked(transitionLinearIssue).mockResolvedValue({ ok: false, error: "API error" });
|
|
1101
|
+
|
|
1102
|
+
const result = await orchestrator.archiveSession("s1", { linearTransition: "backlog" });
|
|
1103
|
+
|
|
1104
|
+
expect(result.ok).toBe(true);
|
|
1105
|
+
expect(result.linearTransition?.ok).toBe(false);
|
|
1106
|
+
expect(deps.launcher.setArchived).toHaveBeenCalledWith("s1", true);
|
|
1107
|
+
});
|
|
1108
|
+
|
|
1109
|
+
it("catches thrown transition errors and still archives", async () => {
|
|
1110
|
+
// When transitionLinearIssue throws, archiveSession should catch it
|
|
1111
|
+
// and continue with the archive operation.
|
|
1112
|
+
vi.mocked(sessionLinearIssues.getLinearIssue).mockReturnValue({
|
|
1113
|
+
id: "issue-1",
|
|
1114
|
+
identifier: "ENG-42",
|
|
1115
|
+
teamId: "team-1",
|
|
1116
|
+
connectionId: "conn-1",
|
|
1117
|
+
} as any);
|
|
1118
|
+
vi.mocked(resolveApiKey).mockReturnValue({ apiKey: "lin_api_123", connectionId: "conn-1" });
|
|
1119
|
+
vi.mocked(fetchLinearTeamStates).mockResolvedValue([{
|
|
1120
|
+
id: "team-1",
|
|
1121
|
+
key: "ENG",
|
|
1122
|
+
name: "Engineering",
|
|
1123
|
+
states: [{ id: "state-backlog", name: "Backlog", type: "backlog" }],
|
|
1124
|
+
}]);
|
|
1125
|
+
vi.mocked(transitionLinearIssue).mockRejectedValue(new Error("Network error"));
|
|
1126
|
+
|
|
1127
|
+
const result = await orchestrator.archiveSession("s1", { linearTransition: "backlog" });
|
|
1128
|
+
|
|
1129
|
+
expect(result.ok).toBe(true);
|
|
1130
|
+
expect(result.linearTransition).toEqual({ ok: false, error: "Transition failed unexpectedly" });
|
|
1131
|
+
expect(deps.launcher.setArchived).toHaveBeenCalledWith("s1", true);
|
|
1132
|
+
});
|
|
1133
|
+
|
|
1134
|
+
it("skips transition when no target state found", async () => {
|
|
1135
|
+
// When the target state cannot be found (e.g., team has no backlog state),
|
|
1136
|
+
// linearTransition should be marked as skipped.
|
|
1137
|
+
vi.mocked(sessionLinearIssues.getLinearIssue).mockReturnValue({
|
|
1138
|
+
id: "issue-1",
|
|
1139
|
+
identifier: "ENG-42",
|
|
1140
|
+
teamId: "team-1",
|
|
1141
|
+
connectionId: "conn-1",
|
|
1142
|
+
} as any);
|
|
1143
|
+
vi.mocked(resolveApiKey).mockReturnValue({ apiKey: "lin_api_123", connectionId: "conn-1" });
|
|
1144
|
+
vi.mocked(fetchLinearTeamStates).mockResolvedValue([{
|
|
1145
|
+
id: "team-1",
|
|
1146
|
+
key: "ENG",
|
|
1147
|
+
name: "Engineering",
|
|
1148
|
+
states: [{ id: "state-done", name: "Done", type: "completed" }],
|
|
1149
|
+
// No backlog state
|
|
1150
|
+
}]);
|
|
1151
|
+
|
|
1152
|
+
const result = await orchestrator.archiveSession("s1", { linearTransition: "backlog" });
|
|
1153
|
+
|
|
1154
|
+
expect(result.ok).toBe(true);
|
|
1155
|
+
expect(result.linearTransition).toEqual({ ok: true, skipped: true });
|
|
1156
|
+
});
|
|
1157
|
+
|
|
1158
|
+
it("cleans up worktree during archive", async () => {
|
|
1159
|
+
deps.worktreeTracker.getBySession.mockReturnValue({
|
|
1160
|
+
sessionId: "s1",
|
|
1161
|
+
repoRoot: "/repo",
|
|
1162
|
+
branch: "feat",
|
|
1163
|
+
worktreePath: "/wt/feat",
|
|
1164
|
+
createdAt: 1000,
|
|
1165
|
+
});
|
|
1166
|
+
|
|
1167
|
+
const result = await orchestrator.archiveSession("s1");
|
|
1168
|
+
|
|
1169
|
+
expect(result.ok).toBe(true);
|
|
1170
|
+
expect(result.worktree).toMatchObject({ cleaned: true, path: "/wt/feat" });
|
|
1171
|
+
expect(gitUtils.removeWorktree).toHaveBeenCalledWith("/repo", "/wt/feat", {
|
|
1172
|
+
force: false,
|
|
1173
|
+
branchToDelete: undefined,
|
|
1174
|
+
});
|
|
1175
|
+
});
|
|
1176
|
+
});
|
|
1177
|
+
|
|
1178
|
+
// ── Delete ────────────────────────────────────────────────────────────────
|
|
1179
|
+
|
|
1180
|
+
describe("deleteSession()", () => {
|
|
1181
|
+
it("performs full cleanup: kill, container, worktree, PR, Linear, bridge", async () => {
|
|
1182
|
+
const result = await orchestrator.deleteSession("s1");
|
|
1183
|
+
|
|
1184
|
+
expect(result.ok).toBe(true);
|
|
1185
|
+
expect(deps.launcher.kill).toHaveBeenCalledWith("s1");
|
|
1186
|
+
expect(containerManager.removeContainer).toHaveBeenCalledWith("s1");
|
|
1187
|
+
expect(deps.prPoller.unwatch).toHaveBeenCalledWith("s1");
|
|
1188
|
+
expect(sessionLinearIssues.removeLinearIssue).toHaveBeenCalledWith("s1");
|
|
1189
|
+
expect(deps.launcher.removeSession).toHaveBeenCalledWith("s1");
|
|
1190
|
+
expect(deps.wsBridge.closeSession).toHaveBeenCalledWith("s1");
|
|
1191
|
+
});
|
|
1192
|
+
|
|
1193
|
+
it("returns worktree cleanup info", async () => {
|
|
1194
|
+
deps.worktreeTracker.getBySession.mockReturnValue({
|
|
1195
|
+
sessionId: "s1",
|
|
1196
|
+
repoRoot: "/repo",
|
|
1197
|
+
branch: "feat",
|
|
1198
|
+
worktreePath: "/wt/feat",
|
|
1199
|
+
createdAt: 1000,
|
|
1200
|
+
});
|
|
1201
|
+
|
|
1202
|
+
const result = await orchestrator.deleteSession("s1");
|
|
1203
|
+
|
|
1204
|
+
expect(result.ok).toBe(true);
|
|
1205
|
+
expect(result.worktree).toMatchObject({ cleaned: true, path: "/wt/feat" });
|
|
1206
|
+
});
|
|
1207
|
+
|
|
1208
|
+
it("passes branchToDelete when actualBranch differs from branch", async () => {
|
|
1209
|
+
// When actualBranch differs from branch, the worktree-unique branch should be deleted.
|
|
1210
|
+
// force=true in deleteSession means "skip dirty check", but removeWorktree gets
|
|
1211
|
+
// force: dirty (isWorktreeDirty() result), which is false by default.
|
|
1212
|
+
deps.worktreeTracker.getBySession.mockReturnValue({
|
|
1213
|
+
sessionId: "s1",
|
|
1214
|
+
repoRoot: "/repo",
|
|
1215
|
+
branch: "feat",
|
|
1216
|
+
actualBranch: "feat-wt-1234",
|
|
1217
|
+
worktreePath: "/wt/feat",
|
|
1218
|
+
createdAt: 1000,
|
|
1219
|
+
});
|
|
1220
|
+
|
|
1221
|
+
await orchestrator.deleteSession("s1");
|
|
1222
|
+
|
|
1223
|
+
expect(gitUtils.removeWorktree).toHaveBeenCalledWith("/repo", "/wt/feat", {
|
|
1224
|
+
force: false,
|
|
1225
|
+
branchToDelete: "feat-wt-1234",
|
|
1226
|
+
});
|
|
1227
|
+
});
|
|
1228
|
+
|
|
1229
|
+
it("removes container unconditionally during delete (unlike kill)", async () => {
|
|
1230
|
+
// deleteSession always removes the container, even if kill reports no process found,
|
|
1231
|
+
// because we're permanently removing the session and must clean up all resources.
|
|
1232
|
+
deps.launcher.kill.mockResolvedValue(false);
|
|
1233
|
+
|
|
1234
|
+
await orchestrator.deleteSession("s1");
|
|
1235
|
+
|
|
1236
|
+
expect(containerManager.removeContainer).toHaveBeenCalledWith("s1");
|
|
1237
|
+
});
|
|
1238
|
+
});
|
|
1239
|
+
|
|
1240
|
+
// ── Unarchive ─────────────────────────────────────────────────────────────
|
|
1241
|
+
|
|
1242
|
+
describe("unarchiveSession()", () => {
|
|
1243
|
+
it("unsets archived flag on launcher and store", () => {
|
|
1244
|
+
const result = orchestrator.unarchiveSession("s1");
|
|
1245
|
+
|
|
1246
|
+
expect(result.ok).toBe(true);
|
|
1247
|
+
expect(deps.launcher.setArchived).toHaveBeenCalledWith("s1", false);
|
|
1248
|
+
expect(deps.sessionStore.setArchived).toHaveBeenCalledWith("s1", false);
|
|
1249
|
+
});
|
|
1250
|
+
});
|
|
1251
|
+
|
|
1252
|
+
// ── Auto-naming ───────────────────────────────────────────────────────────
|
|
1253
|
+
|
|
1254
|
+
describe("handleAutoNaming (via initialize)", () => {
|
|
1255
|
+
it("generates title when anthropicApiKey is set and no name exists", async () => {
|
|
1256
|
+
vi.mocked(settingsManager.getSettings).mockReturnValue({
|
|
1257
|
+
anthropicApiKey: "sk-ant-123",
|
|
1258
|
+
} as any);
|
|
1259
|
+
vi.mocked(sessionNames.getName).mockReturnValue(undefined);
|
|
1260
|
+
deps.launcher.getSession.mockReturnValue({ model: "claude-sonnet-4-6" });
|
|
1261
|
+
vi.mocked(generateSessionTitle).mockResolvedValue("Test Title");
|
|
1262
|
+
|
|
1263
|
+
orchestrator.initialize();
|
|
1264
|
+
companionBus.emit("session:first-turn-completed", { sessionId: "s1", firstUserMessage: "Hello world" });
|
|
1265
|
+
await new Promise(r => setTimeout(r, 0));
|
|
1266
|
+
|
|
1267
|
+
expect(generateSessionTitle).toHaveBeenCalledWith("Hello world", "claude-sonnet-4-6");
|
|
1268
|
+
expect(sessionNames.setName).toHaveBeenCalledWith("s1", "Test Title");
|
|
1269
|
+
expect(deps.wsBridge.broadcastNameUpdate).toHaveBeenCalledWith("s1", "Test Title");
|
|
1270
|
+
});
|
|
1271
|
+
|
|
1272
|
+
it("skips naming when session already has a name", async () => {
|
|
1273
|
+
vi.mocked(settingsManager.getSettings).mockReturnValue({ anthropicApiKey: "sk-ant-123" } as any);
|
|
1274
|
+
vi.mocked(sessionNames.getName).mockReturnValue("Existing Name");
|
|
1275
|
+
|
|
1276
|
+
orchestrator.initialize();
|
|
1277
|
+
companionBus.emit("session:first-turn-completed", { sessionId: "s1", firstUserMessage: "Hello" });
|
|
1278
|
+
await new Promise(r => setTimeout(r, 0));
|
|
1279
|
+
|
|
1280
|
+
expect(generateSessionTitle).not.toHaveBeenCalled();
|
|
1281
|
+
});
|
|
1282
|
+
|
|
1283
|
+
it("skips naming when no API key is configured", async () => {
|
|
1284
|
+
vi.mocked(settingsManager.getSettings).mockReturnValue({ anthropicApiKey: "" } as any);
|
|
1285
|
+
|
|
1286
|
+
orchestrator.initialize();
|
|
1287
|
+
companionBus.emit("session:first-turn-completed", { sessionId: "s1", firstUserMessage: "Hello" });
|
|
1288
|
+
await new Promise(r => setTimeout(r, 0));
|
|
1289
|
+
|
|
1290
|
+
expect(generateSessionTitle).not.toHaveBeenCalled();
|
|
1291
|
+
});
|
|
1292
|
+
});
|
|
1293
|
+
|
|
1294
|
+
// ── Reconnection watchdog ─────────────────────────────────────────────────
|
|
1295
|
+
|
|
1296
|
+
describe("startReconnectionWatchdog (via initialize)", () => {
|
|
1297
|
+
it("does nothing when no sessions are starting", () => {
|
|
1298
|
+
deps.launcher.getStartingSessions.mockReturnValue([]);
|
|
1299
|
+
orchestrator.initialize();
|
|
1300
|
+
|
|
1301
|
+
// No error thrown, no relaunch called
|
|
1302
|
+
expect(deps.launcher.getStartingSessions).toHaveBeenCalled();
|
|
1303
|
+
});
|
|
1304
|
+
|
|
1305
|
+
it("schedules relaunch for stale starting sessions", async () => {
|
|
1306
|
+
vi.useFakeTimers();
|
|
1307
|
+
try {
|
|
1308
|
+
deps.launcher.getStartingSessions
|
|
1309
|
+
.mockReturnValueOnce([{ sessionId: "s1", state: "starting" }])
|
|
1310
|
+
.mockReturnValueOnce([{ sessionId: "s1", state: "starting" }]);
|
|
1311
|
+
|
|
1312
|
+
orchestrator.initialize();
|
|
1313
|
+
|
|
1314
|
+
// Advance past the reconnect grace period (default 30s)
|
|
1315
|
+
await vi.advanceTimersByTimeAsync(30_000);
|
|
1316
|
+
|
|
1317
|
+
expect(deps.launcher.relaunch).toHaveBeenCalledWith("s1");
|
|
1318
|
+
} finally {
|
|
1319
|
+
vi.useRealTimers();
|
|
1320
|
+
}
|
|
1321
|
+
});
|
|
1322
|
+
|
|
1323
|
+
it("skips archived sessions during reconnection watchdog", async () => {
|
|
1324
|
+
vi.useFakeTimers();
|
|
1325
|
+
try {
|
|
1326
|
+
deps.launcher.getStartingSessions
|
|
1327
|
+
.mockReturnValueOnce([{ sessionId: "s1", state: "starting" }])
|
|
1328
|
+
.mockReturnValueOnce([{ sessionId: "s1", state: "starting", archived: true }]);
|
|
1329
|
+
|
|
1330
|
+
orchestrator.initialize();
|
|
1331
|
+
await vi.advanceTimersByTimeAsync(30_000);
|
|
1332
|
+
|
|
1333
|
+
// Should NOT relaunch archived session
|
|
1334
|
+
expect(deps.launcher.relaunch).not.toHaveBeenCalled();
|
|
1335
|
+
} finally {
|
|
1336
|
+
vi.useRealTimers();
|
|
1337
|
+
}
|
|
1338
|
+
});
|
|
1339
|
+
});
|
|
1340
|
+
|
|
1341
|
+
// ── Worktree cleanup ──────────────────────────────────────────────────────
|
|
1342
|
+
|
|
1343
|
+
describe("cleanupWorktree (via deleteSession/archiveSession)", () => {
|
|
1344
|
+
it("returns undefined when session has no worktree mapping", async () => {
|
|
1345
|
+
deps.worktreeTracker.getBySession.mockReturnValue(null);
|
|
1346
|
+
|
|
1347
|
+
const result = await orchestrator.deleteSession("s1");
|
|
1348
|
+
|
|
1349
|
+
expect(result.worktree).toBeUndefined();
|
|
1350
|
+
});
|
|
1351
|
+
|
|
1352
|
+
it("does not remove worktree in use by another session", async () => {
|
|
1353
|
+
deps.worktreeTracker.getBySession.mockReturnValue({
|
|
1354
|
+
sessionId: "s1",
|
|
1355
|
+
repoRoot: "/repo",
|
|
1356
|
+
branch: "feat",
|
|
1357
|
+
worktreePath: "/wt/feat",
|
|
1358
|
+
createdAt: 1000,
|
|
1359
|
+
});
|
|
1360
|
+
deps.worktreeTracker.isWorktreeInUse.mockReturnValue(true);
|
|
1361
|
+
|
|
1362
|
+
const result = await orchestrator.deleteSession("s1");
|
|
1363
|
+
|
|
1364
|
+
expect(result.worktree).toMatchObject({ cleaned: false, path: "/wt/feat" });
|
|
1365
|
+
expect(gitUtils.removeWorktree).not.toHaveBeenCalled();
|
|
1366
|
+
});
|
|
1367
|
+
|
|
1368
|
+
it("does not remove dirty worktree unless forced", async () => {
|
|
1369
|
+
deps.worktreeTracker.getBySession.mockReturnValue({
|
|
1370
|
+
sessionId: "s1",
|
|
1371
|
+
repoRoot: "/repo",
|
|
1372
|
+
branch: "feat",
|
|
1373
|
+
worktreePath: "/wt/feat",
|
|
1374
|
+
createdAt: 1000,
|
|
1375
|
+
});
|
|
1376
|
+
vi.mocked(gitUtils.isWorktreeDirty).mockReturnValue(true);
|
|
1377
|
+
|
|
1378
|
+
// Archive without force
|
|
1379
|
+
const result = await orchestrator.archiveSession("s1");
|
|
1380
|
+
|
|
1381
|
+
expect(result.worktree).toMatchObject({ cleaned: false, dirty: true, path: "/wt/feat" });
|
|
1382
|
+
expect(gitUtils.removeWorktree).not.toHaveBeenCalled();
|
|
1383
|
+
});
|
|
1384
|
+
|
|
1385
|
+
it("force-removes dirty worktree when force=true", async () => {
|
|
1386
|
+
deps.worktreeTracker.getBySession.mockReturnValue({
|
|
1387
|
+
sessionId: "s1",
|
|
1388
|
+
repoRoot: "/repo",
|
|
1389
|
+
branch: "feat",
|
|
1390
|
+
worktreePath: "/wt/feat",
|
|
1391
|
+
createdAt: 1000,
|
|
1392
|
+
});
|
|
1393
|
+
vi.mocked(gitUtils.isWorktreeDirty).mockReturnValue(true);
|
|
1394
|
+
|
|
1395
|
+
const result = await orchestrator.archiveSession("s1", { force: true });
|
|
1396
|
+
|
|
1397
|
+
expect(result.worktree).toMatchObject({ cleaned: true, path: "/wt/feat" });
|
|
1398
|
+
expect(gitUtils.removeWorktree).toHaveBeenCalledWith("/repo", "/wt/feat", {
|
|
1399
|
+
force: true,
|
|
1400
|
+
branchToDelete: undefined,
|
|
1401
|
+
});
|
|
1402
|
+
});
|
|
1403
|
+
});
|
|
1404
|
+
|
|
1405
|
+
// ── getSession ────────────────────────────────────────────────────────────
|
|
1406
|
+
|
|
1407
|
+
describe("getSession()", () => {
|
|
1408
|
+
it("delegates to launcher.getSession", () => {
|
|
1409
|
+
const mockSession = { sessionId: "s1", state: "connected" };
|
|
1410
|
+
deps.launcher.getSession.mockReturnValue(mockSession);
|
|
1411
|
+
|
|
1412
|
+
const result = orchestrator.getSession("s1");
|
|
1413
|
+
|
|
1414
|
+
expect(result).toBe(mockSession);
|
|
1415
|
+
expect(deps.launcher.getSession).toHaveBeenCalledWith("s1");
|
|
1416
|
+
});
|
|
1417
|
+
|
|
1418
|
+
it("returns undefined for unknown session", () => {
|
|
1419
|
+
deps.launcher.getSession.mockReturnValue(undefined);
|
|
1420
|
+
|
|
1421
|
+
const result = orchestrator.getSession("unknown");
|
|
1422
|
+
|
|
1423
|
+
expect(result).toBeUndefined();
|
|
1424
|
+
});
|
|
1425
|
+
});
|
|
1426
|
+
|
|
1427
|
+
// ── Auto-relaunch ──────────────────────────────────────────────────────────
|
|
1428
|
+
|
|
1429
|
+
describe("handleAutoRelaunch (via initialize)", () => {
|
|
1430
|
+
beforeEach(() => {
|
|
1431
|
+
vi.useFakeTimers();
|
|
1432
|
+
});
|
|
1433
|
+
|
|
1434
|
+
afterEach(() => {
|
|
1435
|
+
vi.useRealTimers();
|
|
1436
|
+
});
|
|
1437
|
+
|
|
1438
|
+
it("skips relaunch for archived sessions", async () => {
|
|
1439
|
+
// Archived sessions should not be auto-relaunched.
|
|
1440
|
+
deps.launcher.getSession.mockReturnValue({ archived: true } as any);
|
|
1441
|
+
orchestrator.initialize();
|
|
1442
|
+
|
|
1443
|
+
companionBus.emit("session:relaunch-needed", { sessionId: "s1" });
|
|
1444
|
+
// Advance past the grace period and flush microtasks for the async handler
|
|
1445
|
+
await vi.advanceTimersByTimeAsync(15_000);
|
|
1446
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
1447
|
+
|
|
1448
|
+
expect(deps.launcher.relaunch).not.toHaveBeenCalled();
|
|
1449
|
+
});
|
|
1450
|
+
|
|
1451
|
+
it("skips relaunch when CLI reconnects during grace period", async () => {
|
|
1452
|
+
// During the grace period, if CLI reconnects, relaunch should be skipped.
|
|
1453
|
+
deps.launcher.getSession.mockReturnValue({ archived: false } as any);
|
|
1454
|
+
deps.wsBridge.isCliConnected.mockReturnValue(true);
|
|
1455
|
+
orchestrator.initialize();
|
|
1456
|
+
|
|
1457
|
+
companionBus.emit("session:relaunch-needed", { sessionId: "s1" });
|
|
1458
|
+
await vi.advanceTimersByTimeAsync(15_000);
|
|
1459
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
1460
|
+
|
|
1461
|
+
expect(deps.launcher.relaunch).not.toHaveBeenCalled();
|
|
1462
|
+
});
|
|
1463
|
+
|
|
1464
|
+
it("skips relaunch when session state is 'connected' after grace", async () => {
|
|
1465
|
+
// If the session reconnects (state=connected) during grace, skip relaunch.
|
|
1466
|
+
deps.launcher.getSession
|
|
1467
|
+
.mockReturnValueOnce({ archived: false } as any) // check archived
|
|
1468
|
+
.mockReturnValueOnce({ state: "connected" } as any); // after grace
|
|
1469
|
+
deps.wsBridge.isCliConnected.mockReturnValue(false);
|
|
1470
|
+
orchestrator.initialize();
|
|
1471
|
+
|
|
1472
|
+
companionBus.emit("session:relaunch-needed", { sessionId: "s1" });
|
|
1473
|
+
await vi.advanceTimersByTimeAsync(15_000);
|
|
1474
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
1475
|
+
|
|
1476
|
+
expect(deps.launcher.relaunch).not.toHaveBeenCalled();
|
|
1477
|
+
});
|
|
1478
|
+
|
|
1479
|
+
it("skips relaunch when session is still starting", async () => {
|
|
1480
|
+
// A session in "starting" state should not be relaunched — it's still
|
|
1481
|
+
// initializing. The starting guard at line 771 prevents this.
|
|
1482
|
+
deps.launcher.getSession
|
|
1483
|
+
.mockReturnValueOnce({ archived: false } as any) // check archived
|
|
1484
|
+
.mockReturnValueOnce({ state: "starting", pid: process.pid } as any); // after grace: still starting
|
|
1485
|
+
deps.wsBridge.isCliConnected.mockReturnValue(false);
|
|
1486
|
+
orchestrator.initialize();
|
|
1487
|
+
|
|
1488
|
+
companionBus.emit("session:relaunch-needed", { sessionId: "s1" });
|
|
1489
|
+
await vi.advanceTimersByTimeAsync(15_000);
|
|
1490
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
1491
|
+
|
|
1492
|
+
expect(deps.launcher.relaunch).not.toHaveBeenCalled();
|
|
1493
|
+
});
|
|
1494
|
+
|
|
1495
|
+
it("relaunches exited session even when PID was recycled to a live process", async () => {
|
|
1496
|
+
// After idle-kill, the session state is "exited" but the PID field stays
|
|
1497
|
+
// set. If the kernel recycles the PID to a different process, we must NOT
|
|
1498
|
+
// let the PID check prevent relaunch. The fix skips PID liveness for
|
|
1499
|
+
// exited sessions entirely.
|
|
1500
|
+
deps.launcher.getSession
|
|
1501
|
+
.mockReturnValueOnce({ archived: false } as any) // check archived
|
|
1502
|
+
.mockReturnValueOnce({ state: "exited", pid: process.pid } as any); // after grace: PID is alive (recycled!)
|
|
1503
|
+
deps.wsBridge.isCliConnected.mockReturnValue(false);
|
|
1504
|
+
orchestrator.initialize();
|
|
1505
|
+
|
|
1506
|
+
companionBus.emit("session:relaunch-needed", { sessionId: "s1" });
|
|
1507
|
+
await vi.advanceTimersByTimeAsync(15_000);
|
|
1508
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
1509
|
+
|
|
1510
|
+
// Should relaunch despite the PID being alive — exited sessions skip PID check
|
|
1511
|
+
expect(deps.launcher.relaunch).toHaveBeenCalledWith("s1");
|
|
1512
|
+
});
|
|
1513
|
+
|
|
1514
|
+
it("skips relaunch for containerized session when container is still running", async () => {
|
|
1515
|
+
// For non-exited containerized sessions, use container liveness instead
|
|
1516
|
+
// of PID check. If the container is running, skip relaunch to let the
|
|
1517
|
+
// CLI reconnect on its own. Use state "starting" to bypass the earlier
|
|
1518
|
+
// connected/running guard and actually exercise the container check path.
|
|
1519
|
+
vi.mocked(containerManager.isContainerAlive).mockReturnValue("running" as any);
|
|
1520
|
+
deps.launcher.getSession
|
|
1521
|
+
.mockReturnValueOnce({ archived: false } as any) // check archived
|
|
1522
|
+
.mockReturnValueOnce({ state: "starting", containerId: "cid-abc", pid: 99999 } as any); // after grace
|
|
1523
|
+
deps.wsBridge.isCliConnected.mockReturnValue(false);
|
|
1524
|
+
orchestrator.initialize();
|
|
1525
|
+
|
|
1526
|
+
companionBus.emit("session:relaunch-needed", { sessionId: "s1" });
|
|
1527
|
+
await vi.advanceTimersByTimeAsync(15_000);
|
|
1528
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
1529
|
+
|
|
1530
|
+
expect(containerManager.isContainerAlive).toHaveBeenCalledWith("cid-abc");
|
|
1531
|
+
expect(deps.launcher.relaunch).not.toHaveBeenCalled();
|
|
1532
|
+
});
|
|
1533
|
+
|
|
1534
|
+
it("relaunches exited containerized session even when container was removed", async () => {
|
|
1535
|
+
// If a container was removed externally (e.g. docker prune), the session
|
|
1536
|
+
// state becomes "exited". The fix skips PID/container checks for exited
|
|
1537
|
+
// sessions entirely, so relaunch proceeds.
|
|
1538
|
+
vi.mocked(containerManager.isContainerAlive).mockReturnValue("not_found" as any);
|
|
1539
|
+
deps.launcher.getSession
|
|
1540
|
+
.mockReturnValueOnce({ archived: false } as any) // check archived
|
|
1541
|
+
.mockReturnValueOnce({ state: "exited", containerId: "cid-dead", pid: 99999 } as any); // after grace
|
|
1542
|
+
deps.wsBridge.isCliConnected.mockReturnValue(false);
|
|
1543
|
+
orchestrator.initialize();
|
|
1544
|
+
|
|
1545
|
+
companionBus.emit("session:relaunch-needed", { sessionId: "s1" });
|
|
1546
|
+
await vi.advanceTimersByTimeAsync(15_000);
|
|
1547
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
1548
|
+
|
|
1549
|
+
// Exited sessions skip the container/PID check entirely, so relaunch proceeds
|
|
1550
|
+
expect(deps.launcher.relaunch).toHaveBeenCalledWith("s1");
|
|
1551
|
+
});
|
|
1552
|
+
|
|
1553
|
+
it("relaunches when CLI does not reconnect after grace period", async () => {
|
|
1554
|
+
// When CLI disconnects and doesn't reconnect, the session should be relaunched.
|
|
1555
|
+
deps.launcher.getSession
|
|
1556
|
+
.mockReturnValueOnce({ archived: false } as any) // First call: check archived
|
|
1557
|
+
.mockReturnValueOnce({ state: "exited", pid: undefined } as any); // Second call: after grace
|
|
1558
|
+
deps.wsBridge.isCliConnected.mockReturnValue(false);
|
|
1559
|
+
orchestrator.initialize();
|
|
1560
|
+
|
|
1561
|
+
companionBus.emit("session:relaunch-needed", { sessionId: "s1" });
|
|
1562
|
+
// Advance past grace (10s) + cooldown (5s) and flush microtasks
|
|
1563
|
+
await vi.advanceTimersByTimeAsync(15_000);
|
|
1564
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
1565
|
+
|
|
1566
|
+
expect(deps.launcher.relaunch).toHaveBeenCalledWith("s1");
|
|
1567
|
+
});
|
|
1568
|
+
|
|
1569
|
+
it("preserves retry budget when relaunch returns ok:false without error", async () => {
|
|
1570
|
+
// A silent failure (ok:false, no error string) should NOT reset the auto-relaunch
|
|
1571
|
+
// count. This prevents unlimited retries when the launcher silently fails.
|
|
1572
|
+
deps.launcher.getSession.mockReturnValue({ archived: false, state: "exited", pid: undefined } as any);
|
|
1573
|
+
deps.wsBridge.isCliConnected.mockReturnValue(false);
|
|
1574
|
+
deps.launcher.relaunch.mockResolvedValue({ ok: false }); // no error string
|
|
1575
|
+
orchestrator.initialize();
|
|
1576
|
+
|
|
1577
|
+
// Trigger 3 silent-failure relaunches (the max)
|
|
1578
|
+
for (let i = 0; i < 3; i++) {
|
|
1579
|
+
companionBus.emit("session:relaunch-needed", { sessionId: "s1" });
|
|
1580
|
+
await vi.advanceTimersByTimeAsync(15_000);
|
|
1581
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
// 4th attempt should hit the MAX_AUTO_RELAUNCHES limit
|
|
1585
|
+
companionBus.emit("session:relaunch-needed", { sessionId: "s1" });
|
|
1586
|
+
await vi.advanceTimersByTimeAsync(15_000);
|
|
1587
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
1588
|
+
|
|
1589
|
+
// Only 3 relaunch calls, 4th was rejected at the limit
|
|
1590
|
+
expect(deps.launcher.relaunch).toHaveBeenCalledTimes(3);
|
|
1591
|
+
});
|
|
1592
|
+
|
|
1593
|
+
it("stops after MAX_AUTO_RELAUNCHES attempts", async () => {
|
|
1594
|
+
// After reaching the max auto-relaunch count, give up and notify the user.
|
|
1595
|
+
// Mock relaunch to return an error so the count doesn't get cleared
|
|
1596
|
+
// (successful relaunch clears the count, simulating recovery).
|
|
1597
|
+
deps.launcher.getSession.mockReturnValue({ archived: false, state: "exited", pid: undefined } as any);
|
|
1598
|
+
deps.wsBridge.isCliConnected.mockReturnValue(false);
|
|
1599
|
+
deps.launcher.relaunch.mockResolvedValue({ ok: false, error: "crashed again" });
|
|
1600
|
+
orchestrator.initialize();
|
|
1601
|
+
|
|
1602
|
+
// Trigger 3 relaunches (the max). Each needs the relaunchingSet cooldown
|
|
1603
|
+
// to clear before the next attempt can proceed.
|
|
1604
|
+
for (let i = 0; i < 3; i++) {
|
|
1605
|
+
companionBus.emit("session:relaunch-needed", { sessionId: "s1" });
|
|
1606
|
+
await vi.advanceTimersByTimeAsync(15_000);
|
|
1607
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
// 4th attempt should be rejected since count reached MAX_AUTO_RELAUNCHES
|
|
1611
|
+
companionBus.emit("session:relaunch-needed", { sessionId: "s1" });
|
|
1612
|
+
await vi.advanceTimersByTimeAsync(15_000);
|
|
1613
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
1614
|
+
|
|
1615
|
+
// relaunch should have been called 3 times, not 4
|
|
1616
|
+
expect(deps.launcher.relaunch).toHaveBeenCalledTimes(3);
|
|
1617
|
+
// Should broadcast error message to session
|
|
1618
|
+
expect(deps.wsBridge.broadcastToSession).toHaveBeenCalledWith("s1", expect.objectContaining({
|
|
1619
|
+
type: "error",
|
|
1620
|
+
message: expect.stringContaining("keeps crashing"),
|
|
1621
|
+
}));
|
|
1622
|
+
});
|
|
1623
|
+
});
|
|
1624
|
+
|
|
1625
|
+
// ── Proactive keepalive ───────────────────────────────────────────────────
|
|
1626
|
+
|
|
1627
|
+
describe("proactive keepalive (auto-relaunch on exit without browser)", () => {
|
|
1628
|
+
beforeEach(() => {
|
|
1629
|
+
vi.useFakeTimers();
|
|
1630
|
+
});
|
|
1631
|
+
|
|
1632
|
+
afterEach(() => {
|
|
1633
|
+
vi.useRealTimers();
|
|
1634
|
+
});
|
|
1635
|
+
|
|
1636
|
+
it("schedules relaunch when CLI exits unexpectedly", async () => {
|
|
1637
|
+
// When a CLI process exits (crash) and is not an intentional kill,
|
|
1638
|
+
// the orchestrator should proactively relaunch it after a short delay
|
|
1639
|
+
// even if no browsers are connected.
|
|
1640
|
+
deps.launcher.getSession.mockReturnValue({
|
|
1641
|
+
archived: false,
|
|
1642
|
+
state: "exited",
|
|
1643
|
+
pid: undefined,
|
|
1644
|
+
} as any);
|
|
1645
|
+
deps.wsBridge.isCliConnected.mockReturnValue(false);
|
|
1646
|
+
orchestrator.initialize();
|
|
1647
|
+
|
|
1648
|
+
// Simulate CLI exit
|
|
1649
|
+
companionBus.emit("session:exited", { sessionId: "s1", exitCode: 1 });
|
|
1650
|
+
|
|
1651
|
+
// Advance past keepalive delay (3s) + relaunch grace (10s) + cooldown
|
|
1652
|
+
await vi.advanceTimersByTimeAsync(3_000);
|
|
1653
|
+
await vi.advanceTimersByTimeAsync(15_000);
|
|
1654
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
1655
|
+
|
|
1656
|
+
expect(deps.launcher.relaunch).toHaveBeenCalledWith("s1");
|
|
1657
|
+
});
|
|
1658
|
+
|
|
1659
|
+
it("does NOT proactively relaunch after idle-kill (intentional kill)", async () => {
|
|
1660
|
+
// Idle-kill is intentional — the proactive keepalive should NOT trigger.
|
|
1661
|
+
// The debounce timer in ws-bridge is also cancelled by the idle-kill
|
|
1662
|
+
// handler (via cancelDisconnectTimer), so session:relaunch-needed never
|
|
1663
|
+
// fires from the debounce path. A browser reconnect CAN still relaunch.
|
|
1664
|
+
deps.launcher.getSession.mockReturnValue({
|
|
1665
|
+
archived: false,
|
|
1666
|
+
state: "exited",
|
|
1667
|
+
pid: undefined,
|
|
1668
|
+
} as any);
|
|
1669
|
+
deps.wsBridge.isCliConnected.mockReturnValue(false);
|
|
1670
|
+
orchestrator.initialize();
|
|
1671
|
+
|
|
1672
|
+
// Simulate idle-kill followed by session exit
|
|
1673
|
+
companionBus.emit("session:idle-kill", { sessionId: "s1" });
|
|
1674
|
+
companionBus.emit("session:exited", { sessionId: "s1", exitCode: 0 });
|
|
1675
|
+
|
|
1676
|
+
// Advance well past any possible keepalive delay
|
|
1677
|
+
await vi.advanceTimersByTimeAsync(30_000);
|
|
1678
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
1679
|
+
|
|
1680
|
+
// Proactive keepalive should NOT have relaunched
|
|
1681
|
+
expect(deps.launcher.relaunch).not.toHaveBeenCalled();
|
|
1682
|
+
// Disconnect debounce timer should have been cancelled
|
|
1683
|
+
expect(deps.wsBridge.cancelDisconnectTimer).toHaveBeenCalledWith("s1");
|
|
1684
|
+
});
|
|
1685
|
+
|
|
1686
|
+
it("does NOT relaunch archived sessions", async () => {
|
|
1687
|
+
// Archived sessions should not be relaunched proactively.
|
|
1688
|
+
deps.launcher.getSession.mockReturnValue({
|
|
1689
|
+
archived: true,
|
|
1690
|
+
state: "exited",
|
|
1691
|
+
pid: undefined,
|
|
1692
|
+
} as any);
|
|
1693
|
+
orchestrator.initialize();
|
|
1694
|
+
|
|
1695
|
+
companionBus.emit("session:exited", { sessionId: "s1", exitCode: 1 });
|
|
1696
|
+
|
|
1697
|
+
await vi.advanceTimersByTimeAsync(30_000);
|
|
1698
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
1699
|
+
|
|
1700
|
+
expect(deps.launcher.relaunch).not.toHaveBeenCalled();
|
|
1701
|
+
});
|
|
1702
|
+
|
|
1703
|
+
it("uses exponential backoff on repeated crashes (3s → 6s → 12s)", async () => {
|
|
1704
|
+
// Each crash should increase the delay before the keepalive timer fires.
|
|
1705
|
+
let relaunchCount = 0;
|
|
1706
|
+
deps.launcher.getSession.mockReturnValue({
|
|
1707
|
+
archived: false,
|
|
1708
|
+
state: "exited",
|
|
1709
|
+
pid: undefined,
|
|
1710
|
+
} as any);
|
|
1711
|
+
deps.wsBridge.isCliConnected.mockReturnValue(false);
|
|
1712
|
+
// Simulate repeated failures so the auto-relaunch count increments
|
|
1713
|
+
deps.launcher.relaunch.mockImplementation(async () => {
|
|
1714
|
+
relaunchCount++;
|
|
1715
|
+
return { ok: false, error: "crashed" };
|
|
1716
|
+
});
|
|
1717
|
+
orchestrator.initialize();
|
|
1718
|
+
|
|
1719
|
+
// ── 1st crash: 3s keepalive delay ──
|
|
1720
|
+
companionBus.emit("session:exited", { sessionId: "s1", exitCode: 1 });
|
|
1721
|
+
|
|
1722
|
+
// At 2s: nothing yet (3s delay not elapsed)
|
|
1723
|
+
await vi.advanceTimersByTimeAsync(2_000);
|
|
1724
|
+
expect(relaunchCount).toBe(0);
|
|
1725
|
+
|
|
1726
|
+
// At 3s: keepalive fires → handleAutoRelaunch with 10s grace
|
|
1727
|
+
await vi.advanceTimersByTimeAsync(1_000);
|
|
1728
|
+
await vi.advanceTimersByTimeAsync(15_000);
|
|
1729
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
1730
|
+
expect(relaunchCount).toBe(1);
|
|
1731
|
+
|
|
1732
|
+
// ── 2nd crash: 6s keepalive delay ──
|
|
1733
|
+
companionBus.emit("session:exited", { sessionId: "s1", exitCode: 1 });
|
|
1734
|
+
|
|
1735
|
+
// At 5s: nothing yet (6s delay not elapsed)
|
|
1736
|
+
await vi.advanceTimersByTimeAsync(5_000);
|
|
1737
|
+
expect(relaunchCount).toBe(1);
|
|
1738
|
+
|
|
1739
|
+
// At 6s: keepalive fires → handleAutoRelaunch with 10s grace
|
|
1740
|
+
await vi.advanceTimersByTimeAsync(1_000);
|
|
1741
|
+
await vi.advanceTimersByTimeAsync(15_000);
|
|
1742
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
1743
|
+
expect(relaunchCount).toBe(2);
|
|
1744
|
+
|
|
1745
|
+
// ── 3rd crash: 12s keepalive delay ──
|
|
1746
|
+
companionBus.emit("session:exited", { sessionId: "s1", exitCode: 1 });
|
|
1747
|
+
|
|
1748
|
+
// At 11s: nothing yet (12s delay not elapsed)
|
|
1749
|
+
await vi.advanceTimersByTimeAsync(11_000);
|
|
1750
|
+
expect(relaunchCount).toBe(2);
|
|
1751
|
+
|
|
1752
|
+
// At 12s: keepalive fires → handleAutoRelaunch with 10s grace
|
|
1753
|
+
await vi.advanceTimersByTimeAsync(1_000);
|
|
1754
|
+
await vi.advanceTimersByTimeAsync(15_000);
|
|
1755
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
1756
|
+
expect(relaunchCount).toBe(3);
|
|
1757
|
+
});
|
|
1758
|
+
|
|
1759
|
+
it("cancels keepalive timer on session delete", async () => {
|
|
1760
|
+
// If user deletes a session while a keepalive timer is pending,
|
|
1761
|
+
// the timer should be cancelled and no relaunch should occur.
|
|
1762
|
+
deps.launcher.getSession.mockReturnValue({
|
|
1763
|
+
archived: false,
|
|
1764
|
+
state: "exited",
|
|
1765
|
+
pid: undefined,
|
|
1766
|
+
} as any);
|
|
1767
|
+
deps.wsBridge.isCliConnected.mockReturnValue(false);
|
|
1768
|
+
orchestrator.initialize();
|
|
1769
|
+
|
|
1770
|
+
// Simulate CLI exit
|
|
1771
|
+
companionBus.emit("session:exited", { sessionId: "s1", exitCode: 1 });
|
|
1772
|
+
|
|
1773
|
+
// Delete the session before the keepalive timer fires
|
|
1774
|
+
await orchestrator.deleteSession("s1");
|
|
1775
|
+
|
|
1776
|
+
// Advance past all delays
|
|
1777
|
+
await vi.advanceTimersByTimeAsync(30_000);
|
|
1778
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
1779
|
+
|
|
1780
|
+
// kill() is called by deleteSession, but relaunch should NOT be
|
|
1781
|
+
expect(deps.launcher.relaunch).not.toHaveBeenCalled();
|
|
1782
|
+
});
|
|
1783
|
+
});
|
|
1784
|
+
});
|