@cuongtran001/kanna 0.43.2 → 0.45.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/dist/client/assets/index-DLVKyl74.js +1 -0
- package/dist/client/assets/index-DVqFbcyL.css +32 -0
- package/dist/client/assets/index-DrdIVK7H.js +2655 -0
- package/dist/client/index.html +2 -2
- package/dist/export-viewer/assets/{index-D5AqC1n_.js → index-DE2jvYMB.js} +95 -95
- package/dist/export-viewer/assets/index-V85HqTWK.css +1 -0
- package/dist/export-viewer/index.html +2 -2
- package/package.json +4 -3
- package/src/server/agent.test.ts +643 -0
- package/src/server/agent.ts +143 -16
- package/src/server/app-settings.test.ts +54 -1
- package/src/server/app-settings.ts +49 -0
- package/src/server/auth.test.ts +23 -1
- package/src/server/background-tasks.test.ts +293 -0
- package/src/server/background-tasks.ts +219 -0
- package/src/server/cli-runtime.test.ts +15 -1
- package/src/server/cloudflare-tunnel/e2e.test.ts +4 -1
- package/src/server/codex-app-server.test.ts +69 -0
- package/src/server/codex-app-server.ts +13 -1
- package/src/server/diff-store.ts +2 -2
- package/src/server/kanna-mcp.test.ts +90 -0
- package/src/server/kanna-mcp.ts +116 -0
- package/src/server/orphan-persistence.test.ts +148 -0
- package/src/server/orphan-persistence.ts +147 -0
- package/src/server/server.ts +32 -7
- package/src/server/terminal-manager.test.ts +100 -0
- package/src/server/terminal-manager.ts +16 -2
- package/src/server/test-helpers/worktree-repo.ts +37 -0
- package/src/server/uploads.test.ts +36 -0
- package/src/server/worktree-store.test.ts +209 -0
- package/src/server/worktree-store.ts +120 -0
- package/src/server/ws-router.test.ts +360 -2
- package/src/server/ws-router.ts +86 -2
- package/src/shared/protocol.ts +18 -1
- package/src/shared/tools.test.ts +65 -0
- package/src/shared/tools.ts +56 -0
- package/src/shared/types.ts +75 -0
- package/dist/client/assets/index-CC4TiTzA.css +0 -32
- package/dist/client/assets/index-UQWb6QtR.js +0 -2620
- package/dist/export-viewer/assets/index-BWQYh3zz.css +0 -1
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test"
|
|
2
|
+
import { spawn } from "bun"
|
|
3
|
+
import { BackgroundTaskRegistry, type BackgroundTask } from "./background-tasks"
|
|
4
|
+
import type { AnalyticsReporter } from "./analytics"
|
|
5
|
+
|
|
6
|
+
function makeAnalytics() {
|
|
7
|
+
const calls: Array<{ name: string; properties?: Record<string, unknown> }> = []
|
|
8
|
+
const reporter: AnalyticsReporter = {
|
|
9
|
+
track: (name, properties) => { calls.push({ name, properties }) },
|
|
10
|
+
trackLaunch: () => {},
|
|
11
|
+
}
|
|
12
|
+
return { reporter, calls }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const drainingSample = (
|
|
16
|
+
overrides: Partial<Extract<BackgroundTask, { kind: "draining_stream" }>> = {},
|
|
17
|
+
): Extract<BackgroundTask, { kind: "draining_stream" }> => ({
|
|
18
|
+
kind: "draining_stream",
|
|
19
|
+
id: "ds-1",
|
|
20
|
+
chatId: "chat-1",
|
|
21
|
+
startedAt: 1_700_000_000_000,
|
|
22
|
+
lastOutput: "",
|
|
23
|
+
...overrides,
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
describe("BackgroundTaskRegistry", () => {
|
|
27
|
+
it("registers and lists a task", () => {
|
|
28
|
+
const r = new BackgroundTaskRegistry()
|
|
29
|
+
r.register(drainingSample())
|
|
30
|
+
expect(r.list()).toHaveLength(1)
|
|
31
|
+
expect(r.list()[0].id).toBe("ds-1")
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it("filters by chatId", () => {
|
|
35
|
+
const r = new BackgroundTaskRegistry()
|
|
36
|
+
r.register(drainingSample())
|
|
37
|
+
r.register(drainingSample({ id: "ds-2", chatId: "chat-2" }))
|
|
38
|
+
expect(r.listByChat("chat-1").map((t) => t.id)).toEqual(["ds-1"])
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it("unregisters a task", () => {
|
|
42
|
+
const r = new BackgroundTaskRegistry()
|
|
43
|
+
r.register(drainingSample())
|
|
44
|
+
r.unregister("ds-1")
|
|
45
|
+
expect(r.list()).toHaveLength(0)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it("emits added/updated/removed events in order", () => {
|
|
49
|
+
const r = new BackgroundTaskRegistry()
|
|
50
|
+
const events: string[] = []
|
|
51
|
+
r.on("added", () => events.push("added"))
|
|
52
|
+
r.on("updated", () => events.push("updated"))
|
|
53
|
+
r.on("removed", () => events.push("removed"))
|
|
54
|
+
r.register(drainingSample())
|
|
55
|
+
r.update("ds-1", { lastOutput: "hi" })
|
|
56
|
+
r.unregister("ds-1")
|
|
57
|
+
expect(events).toEqual(["added", "updated", "removed"])
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it("update() throws when patch.kind mismatches the stored task kind", () => {
|
|
61
|
+
const r = new BackgroundTaskRegistry()
|
|
62
|
+
r.register(drainingSample())
|
|
63
|
+
expect(() =>
|
|
64
|
+
r.update("ds-1", { kind: "bash_shell" } as Partial<BackgroundTask>),
|
|
65
|
+
).toThrow("BackgroundTaskRegistry.update: kind mismatch (draining_stream -> bash_shell)")
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it("listByChat excludes terminal_pty tasks (no chatId field)", () => {
|
|
69
|
+
const r = new BackgroundTaskRegistry()
|
|
70
|
+
r.register(drainingSample({ id: "ds-1", chatId: "chat-1" }))
|
|
71
|
+
r.register({
|
|
72
|
+
kind: "terminal_pty",
|
|
73
|
+
id: "pty-1",
|
|
74
|
+
ptyId: "p1",
|
|
75
|
+
cwd: "/tmp",
|
|
76
|
+
startedAt: 1_700_000_000_000,
|
|
77
|
+
lastOutput: "",
|
|
78
|
+
})
|
|
79
|
+
const ids = r.listByChat("chat-1").map((t) => t.id)
|
|
80
|
+
expect(ids).toEqual(["ds-1"])
|
|
81
|
+
})
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
describe("BackgroundTaskRegistry.stop", () => {
|
|
85
|
+
it("sends SIGTERM, then SIGKILL after grace, on a real process", async () => {
|
|
86
|
+
// Spawn a Bun script that ignores SIGTERM and stays alive.
|
|
87
|
+
const child = spawn({
|
|
88
|
+
cmd: ["bun", "-e", "process.on('SIGTERM', () => {}); setInterval(() => {}, 1000);"],
|
|
89
|
+
stdin: "ignore",
|
|
90
|
+
})
|
|
91
|
+
// Wait for the child process to finish initializing its signal handlers
|
|
92
|
+
// before calling stop(), otherwise the SIGTERM arrives before registration.
|
|
93
|
+
await Bun.sleep(300)
|
|
94
|
+
const r = new BackgroundTaskRegistry()
|
|
95
|
+
r.register({
|
|
96
|
+
kind: "bash_shell",
|
|
97
|
+
id: "sh-1",
|
|
98
|
+
chatId: null,
|
|
99
|
+
command: "bun",
|
|
100
|
+
shellId: "shell-1",
|
|
101
|
+
pid: child.pid!,
|
|
102
|
+
startedAt: Date.now(),
|
|
103
|
+
lastOutput: "",
|
|
104
|
+
status: "running",
|
|
105
|
+
})
|
|
106
|
+
const result = await r.stop("sh-1", { graceMs: 200 })
|
|
107
|
+
expect(result.ok).toBe(true)
|
|
108
|
+
if (result.ok) {
|
|
109
|
+
expect(result.method).toBe("sigkill")
|
|
110
|
+
}
|
|
111
|
+
await child.exited
|
|
112
|
+
}, 5000)
|
|
113
|
+
|
|
114
|
+
it("force: true uses SIGKILL immediately", async () => {
|
|
115
|
+
const child = spawn({
|
|
116
|
+
cmd: ["bun", "-e", "setInterval(() => {}, 1000);"],
|
|
117
|
+
stdin: "ignore",
|
|
118
|
+
})
|
|
119
|
+
const r = new BackgroundTaskRegistry()
|
|
120
|
+
r.register({
|
|
121
|
+
kind: "bash_shell",
|
|
122
|
+
id: "sh-2",
|
|
123
|
+
chatId: null,
|
|
124
|
+
command: "bun",
|
|
125
|
+
shellId: "shell-2",
|
|
126
|
+
pid: child.pid!,
|
|
127
|
+
startedAt: Date.now(),
|
|
128
|
+
lastOutput: "",
|
|
129
|
+
status: "running",
|
|
130
|
+
})
|
|
131
|
+
const result = await r.stop("sh-2", { force: true })
|
|
132
|
+
expect(result.ok).toBe(true)
|
|
133
|
+
if (result.ok) {
|
|
134
|
+
expect(result.method).toBe("sigkill")
|
|
135
|
+
}
|
|
136
|
+
await child.exited
|
|
137
|
+
}, 5000)
|
|
138
|
+
|
|
139
|
+
it("PID-reuse guard: returns ok:false when comm does not match", async () => {
|
|
140
|
+
const r = new BackgroundTaskRegistry()
|
|
141
|
+
r.register({
|
|
142
|
+
kind: "bash_shell",
|
|
143
|
+
id: "sh-3",
|
|
144
|
+
chatId: null,
|
|
145
|
+
command: "definitely-not-this-one",
|
|
146
|
+
shellId: "shell-3",
|
|
147
|
+
pid: 1, // init/launchd, never matches "definitely-not-this-one"
|
|
148
|
+
startedAt: Date.now(),
|
|
149
|
+
lastOutput: "",
|
|
150
|
+
status: "running",
|
|
151
|
+
})
|
|
152
|
+
const result = await r.stop("sh-3")
|
|
153
|
+
expect(result.ok).toBe(false)
|
|
154
|
+
if (!result.ok) {
|
|
155
|
+
expect(result.error).toContain("PID mismatch")
|
|
156
|
+
}
|
|
157
|
+
expect(r.list()).toHaveLength(0) // dropped from registry
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it("word-boundary guard: 'bunbar' does not match a 'bun' process", async () => {
|
|
161
|
+
// Spawn a real bun process so we have a live PID that ps shows as "bun ..."
|
|
162
|
+
const child = spawn({
|
|
163
|
+
cmd: ["bun", "-e", "setInterval(() => {}, 1000);"],
|
|
164
|
+
stdin: "ignore",
|
|
165
|
+
})
|
|
166
|
+
await Bun.sleep(200)
|
|
167
|
+
const r = new BackgroundTaskRegistry()
|
|
168
|
+
r.register({
|
|
169
|
+
kind: "bash_shell",
|
|
170
|
+
id: "sh-wb",
|
|
171
|
+
chatId: null,
|
|
172
|
+
// "bunbar" starts with "bun" — the old substring match would wrongly accept it
|
|
173
|
+
command: "bunbar",
|
|
174
|
+
shellId: "shell-wb",
|
|
175
|
+
pid: child.pid!,
|
|
176
|
+
startedAt: Date.now(),
|
|
177
|
+
lastOutput: "",
|
|
178
|
+
status: "running",
|
|
179
|
+
})
|
|
180
|
+
const result = await r.stop("sh-wb")
|
|
181
|
+
// verifyComm should reject because no word boundary match for "bunbar"
|
|
182
|
+
expect(result.ok).toBe(false)
|
|
183
|
+
if (!result.ok) {
|
|
184
|
+
expect(result.error).toContain("PID mismatch")
|
|
185
|
+
}
|
|
186
|
+
// Clean up the orphaned child
|
|
187
|
+
try {
|
|
188
|
+
process.kill(child.pid!, "SIGKILL")
|
|
189
|
+
} catch {
|
|
190
|
+
// already dead
|
|
191
|
+
}
|
|
192
|
+
await child.exited
|
|
193
|
+
}, 5000)
|
|
194
|
+
|
|
195
|
+
it("analytics: bg_task_stopped emitted after successful stop", async () => {
|
|
196
|
+
const { reporter, calls } = makeAnalytics()
|
|
197
|
+
const child = spawn({
|
|
198
|
+
cmd: ["bun", "-e", "setInterval(() => {}, 1000);"],
|
|
199
|
+
stdin: "ignore",
|
|
200
|
+
})
|
|
201
|
+
await Bun.sleep(200)
|
|
202
|
+
const r = new BackgroundTaskRegistry({ analytics: reporter })
|
|
203
|
+
const startedAt = Date.now() - 500
|
|
204
|
+
r.register({
|
|
205
|
+
kind: "bash_shell",
|
|
206
|
+
id: "sh-analytics",
|
|
207
|
+
chatId: null,
|
|
208
|
+
command: "bun",
|
|
209
|
+
shellId: "shell-analytics",
|
|
210
|
+
pid: child.pid!,
|
|
211
|
+
startedAt,
|
|
212
|
+
lastOutput: "",
|
|
213
|
+
status: "running",
|
|
214
|
+
})
|
|
215
|
+
// bg_task_registered is emitted on register
|
|
216
|
+
expect(calls).toHaveLength(1)
|
|
217
|
+
expect(calls[0]?.name).toBe("bg_task_registered")
|
|
218
|
+
expect(calls[0]?.properties?.task_kind).toBe("bash_shell")
|
|
219
|
+
|
|
220
|
+
const result = await r.stop("sh-analytics", { force: true })
|
|
221
|
+
expect(result.ok).toBe(true)
|
|
222
|
+
// bg_task_stopped is emitted after successful stop
|
|
223
|
+
expect(calls).toHaveLength(2)
|
|
224
|
+
expect(calls[1]?.name).toBe("bg_task_stopped")
|
|
225
|
+
expect(calls[1]?.properties?.task_kind).toBe("bash_shell")
|
|
226
|
+
expect(calls[1]?.properties?.force).toBe(true)
|
|
227
|
+
expect(typeof calls[1]?.properties?.age_ms).toBe("number")
|
|
228
|
+
await child.exited
|
|
229
|
+
}, 5000)
|
|
230
|
+
|
|
231
|
+
it("analytics: bg_task_stopped not emitted on failed stop (PID mismatch)", async () => {
|
|
232
|
+
const { reporter, calls } = makeAnalytics()
|
|
233
|
+
const r = new BackgroundTaskRegistry({ analytics: reporter })
|
|
234
|
+
r.register({
|
|
235
|
+
kind: "bash_shell",
|
|
236
|
+
id: "sh-no-emit",
|
|
237
|
+
chatId: null,
|
|
238
|
+
command: "definitely-not-this-one",
|
|
239
|
+
shellId: "shell-no-emit",
|
|
240
|
+
pid: 1,
|
|
241
|
+
startedAt: Date.now(),
|
|
242
|
+
lastOutput: "",
|
|
243
|
+
status: "running",
|
|
244
|
+
})
|
|
245
|
+
const result = await r.stop("sh-no-emit")
|
|
246
|
+
expect(result.ok).toBe(false)
|
|
247
|
+
// Only bg_task_registered, no bg_task_stopped
|
|
248
|
+
expect(calls).toHaveLength(1)
|
|
249
|
+
expect(calls[0]?.name).toBe("bg_task_registered")
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
it("killShell strategy hook: invoked instead of POSIX signals", async () => {
|
|
253
|
+
const child = spawn({
|
|
254
|
+
cmd: ["bun", "-e", "setInterval(() => {}, 1000);"],
|
|
255
|
+
stdin: "ignore",
|
|
256
|
+
})
|
|
257
|
+
await Bun.sleep(200)
|
|
258
|
+
const r = new BackgroundTaskRegistry()
|
|
259
|
+
r.register({
|
|
260
|
+
kind: "bash_shell",
|
|
261
|
+
id: "sh-ks",
|
|
262
|
+
chatId: null,
|
|
263
|
+
command: "bun",
|
|
264
|
+
shellId: "shell-ks",
|
|
265
|
+
pid: child.pid!,
|
|
266
|
+
startedAt: Date.now(),
|
|
267
|
+
lastOutput: "",
|
|
268
|
+
status: "running",
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
let strategyCalled = 0
|
|
272
|
+
r.setStrategies({
|
|
273
|
+
killShell: async (task) => {
|
|
274
|
+
strategyCalled++
|
|
275
|
+
// Actually kill so the test doesn't leave a zombie
|
|
276
|
+
try {
|
|
277
|
+
process.kill(task.pid!, "SIGKILL")
|
|
278
|
+
} catch {
|
|
279
|
+
// already gone
|
|
280
|
+
}
|
|
281
|
+
},
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
const result = await r.stop("sh-ks")
|
|
285
|
+
expect(result.ok).toBe(true)
|
|
286
|
+
if (result.ok) {
|
|
287
|
+
expect(result.method).toBe("sigterm")
|
|
288
|
+
}
|
|
289
|
+
expect(strategyCalled).toBe(1)
|
|
290
|
+
expect(r.list()).toHaveLength(0)
|
|
291
|
+
await child.exited
|
|
292
|
+
}, 5000)
|
|
293
|
+
})
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import type { BackgroundTask } from "../shared/types"
|
|
2
|
+
import type { AnalyticsReporter } from "./analytics"
|
|
3
|
+
|
|
4
|
+
export type { BackgroundTask }
|
|
5
|
+
|
|
6
|
+
export type RegistryEvent = "added" | "updated" | "removed"
|
|
7
|
+
export type Listener = (task: BackgroundTask) => void
|
|
8
|
+
export type Unsubscribe = () => void
|
|
9
|
+
|
|
10
|
+
export type StopResult =
|
|
11
|
+
| { ok: true; method: "sigterm" | "sigkill" | "close" | "shutdown" }
|
|
12
|
+
| { ok: false; error: string }
|
|
13
|
+
|
|
14
|
+
export type StopOptions = { force?: boolean; graceMs?: number }
|
|
15
|
+
|
|
16
|
+
export type StopStrategies = {
|
|
17
|
+
killShell?: (task: Extract<BackgroundTask, { kind: "bash_shell" }>) => Promise<void>
|
|
18
|
+
closeStream?: (task: Extract<BackgroundTask, { kind: "draining_stream" }>) => Promise<void>
|
|
19
|
+
killPty?: (task: Extract<BackgroundTask, { kind: "terminal_pty" }>) => Promise<void>
|
|
20
|
+
shutdownCodex?: (task: Extract<BackgroundTask, { kind: "codex_session" }>) => Promise<void>
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function safeKill(pid: number, signal: "SIGTERM" | "SIGKILL"): Promise<void> {
|
|
24
|
+
// Try the process group first so child processes (e.g. spawned by a shell) are
|
|
25
|
+
// also signalled. Fall back to single-pid kill if the group signal fails.
|
|
26
|
+
// Note: ESRCH from -pid means "no such process group" (the pid is not a
|
|
27
|
+
// group leader), so we always fall through to the single-pid kill in that case.
|
|
28
|
+
let groupKilled = false
|
|
29
|
+
try {
|
|
30
|
+
process.kill(-pid, signal)
|
|
31
|
+
groupKilled = true
|
|
32
|
+
} catch {
|
|
33
|
+
// Any error (ESRCH = no group, EPERM, EINVAL) means group kill failed;
|
|
34
|
+
// fall through to single-pid kill below.
|
|
35
|
+
}
|
|
36
|
+
if (groupKilled) return
|
|
37
|
+
try {
|
|
38
|
+
process.kill(pid, signal)
|
|
39
|
+
} catch (err) {
|
|
40
|
+
const code = (err as NodeJS.ErrnoException).code
|
|
41
|
+
if (code === "ESRCH") return
|
|
42
|
+
throw err
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function waitForExit(pid: number, timeoutMs: number): Promise<boolean> {
|
|
47
|
+
const start = Date.now()
|
|
48
|
+
while (Date.now() - start < timeoutMs) {
|
|
49
|
+
try {
|
|
50
|
+
process.kill(pid, 0)
|
|
51
|
+
} catch (err) {
|
|
52
|
+
const code = (err as NodeJS.ErrnoException).code
|
|
53
|
+
if (code === "ESRCH") return true
|
|
54
|
+
// EPERM and other codes mean the process still exists; keep polling.
|
|
55
|
+
}
|
|
56
|
+
await Bun.sleep(50)
|
|
57
|
+
}
|
|
58
|
+
return false
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function verifyComm(pid: number, expectedCommand: string): Promise<boolean> {
|
|
62
|
+
try {
|
|
63
|
+
const proc = Bun.spawn({
|
|
64
|
+
cmd: ["ps", "-p", String(pid), "-o", "command="],
|
|
65
|
+
stdin: "ignore",
|
|
66
|
+
stdout: "pipe",
|
|
67
|
+
stderr: "ignore",
|
|
68
|
+
})
|
|
69
|
+
const out = (await new Response(proc.stdout).text()).trim()
|
|
70
|
+
await proc.exited
|
|
71
|
+
if (!out) return false
|
|
72
|
+
const cmdToken = expectedCommand.split(/\s+/)[0] ?? ""
|
|
73
|
+
if (!cmdToken) return true
|
|
74
|
+
const escaped = cmdToken.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
|
75
|
+
return new RegExp(`(?:^|[/\\s])${escaped}(?:\\s|$)`).test(out)
|
|
76
|
+
} catch {
|
|
77
|
+
return false
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface BackgroundTaskRegistryOptions {
|
|
82
|
+
analytics?: AnalyticsReporter
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export class BackgroundTaskRegistry {
|
|
86
|
+
private tasks = new Map<string, BackgroundTask>()
|
|
87
|
+
private listeners: Record<RegistryEvent, Set<Listener>> = {
|
|
88
|
+
added: new Set(),
|
|
89
|
+
updated: new Set(),
|
|
90
|
+
removed: new Set(),
|
|
91
|
+
}
|
|
92
|
+
private strategies: StopStrategies = {}
|
|
93
|
+
private readonly analytics: AnalyticsReporter | undefined
|
|
94
|
+
|
|
95
|
+
constructor(options: BackgroundTaskRegistryOptions = {}) {
|
|
96
|
+
this.analytics = options.analytics
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
setStrategies(strategies: StopStrategies): void {
|
|
100
|
+
this.strategies = { ...this.strategies, ...strategies }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
list(): BackgroundTask[] {
|
|
104
|
+
return Array.from(this.tasks.values())
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Returns tasks whose chatId matches the given value.
|
|
109
|
+
* terminal_pty tasks are intentionally excluded because they have no chatId field.
|
|
110
|
+
*/
|
|
111
|
+
listByChat(chatId: string): BackgroundTask[] {
|
|
112
|
+
return this.list().filter((t) => "chatId" in t && t.chatId === chatId)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
register(task: BackgroundTask): void {
|
|
116
|
+
this.tasks.set(task.id, task)
|
|
117
|
+
this.emit("added", task)
|
|
118
|
+
this.analytics?.track("bg_task_registered", { task_kind: task.kind })
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
update(id: string, patch: Partial<BackgroundTask>): void {
|
|
122
|
+
const prev = this.tasks.get(id)
|
|
123
|
+
if (!prev) return
|
|
124
|
+
if (patch.kind !== undefined && patch.kind !== prev.kind) {
|
|
125
|
+
throw new Error(`BackgroundTaskRegistry.update: kind mismatch (${prev.kind} -> ${patch.kind})`)
|
|
126
|
+
}
|
|
127
|
+
const next = { ...prev, ...patch } as BackgroundTask
|
|
128
|
+
this.tasks.set(id, next)
|
|
129
|
+
this.emit("updated", next)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
unregister(id: string): void {
|
|
133
|
+
const prev = this.tasks.get(id)
|
|
134
|
+
if (!prev) return
|
|
135
|
+
this.tasks.delete(id)
|
|
136
|
+
this.emit("removed", prev)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
on(event: RegistryEvent, cb: Listener): Unsubscribe {
|
|
140
|
+
this.listeners[event].add(cb)
|
|
141
|
+
return () => this.listeners[event].delete(cb)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async stop(id: string, opts: StopOptions = {}): Promise<StopResult> {
|
|
145
|
+
const task = this.tasks.get(id)
|
|
146
|
+
if (!task) return { ok: false, error: "task not found" }
|
|
147
|
+
|
|
148
|
+
const result = await this.runStop(task, opts)
|
|
149
|
+
|
|
150
|
+
if (result.ok) {
|
|
151
|
+
this.analytics?.track("bg_task_stopped", {
|
|
152
|
+
task_kind: task.kind,
|
|
153
|
+
age_ms: Date.now() - task.startedAt,
|
|
154
|
+
force: opts.force ?? false,
|
|
155
|
+
})
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return result
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
private async runStop(task: BackgroundTask, opts: StopOptions): Promise<StopResult> {
|
|
162
|
+
if (task.kind === "draining_stream") {
|
|
163
|
+
await this.strategies.closeStream?.(task)
|
|
164
|
+
this.unregister(task.id)
|
|
165
|
+
return { ok: true, method: "close" }
|
|
166
|
+
}
|
|
167
|
+
if (task.kind === "terminal_pty") {
|
|
168
|
+
await this.strategies.killPty?.(task)
|
|
169
|
+
this.unregister(task.id)
|
|
170
|
+
return { ok: true, method: "close" }
|
|
171
|
+
}
|
|
172
|
+
if (task.kind === "codex_session") {
|
|
173
|
+
await this.strategies.shutdownCodex?.(task)
|
|
174
|
+
this.unregister(task.id)
|
|
175
|
+
return { ok: true, method: "shutdown" }
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// bash_shell: signal lifecycle with PID-reuse guard
|
|
179
|
+
if (task.pid == null) return { ok: false, error: "no pid recorded" }
|
|
180
|
+
|
|
181
|
+
const commOk = await verifyComm(task.pid, task.command)
|
|
182
|
+
if (!commOk) {
|
|
183
|
+
this.unregister(task.id)
|
|
184
|
+
return { ok: false, error: "PID mismatch (process reused)" }
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (opts.force) {
|
|
188
|
+
this.update(task.id, { status: "stopping" })
|
|
189
|
+
await safeKill(task.pid, "SIGKILL")
|
|
190
|
+
this.unregister(task.id)
|
|
191
|
+
return { ok: true, method: "sigkill" }
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// If the SDK provides a custom kill strategy, delegate to it.
|
|
195
|
+
if (this.strategies.killShell) {
|
|
196
|
+
this.update(task.id, { status: "stopping" })
|
|
197
|
+
await this.strategies.killShell(task)
|
|
198
|
+
this.unregister(task.id)
|
|
199
|
+
return { ok: true, method: "sigterm" }
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
this.update(task.id, { status: "stopping" })
|
|
203
|
+
await safeKill(task.pid, "SIGTERM")
|
|
204
|
+
const grace = opts.graceMs ?? 3000
|
|
205
|
+
const exited = await waitForExit(task.pid, grace)
|
|
206
|
+
if (exited) {
|
|
207
|
+
this.unregister(task.id)
|
|
208
|
+
return { ok: true, method: "sigterm" }
|
|
209
|
+
}
|
|
210
|
+
await safeKill(task.pid, "SIGKILL")
|
|
211
|
+
await waitForExit(task.pid, 1000)
|
|
212
|
+
this.unregister(task.id)
|
|
213
|
+
return { ok: true, method: "sigkill" }
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
private emit(event: RegistryEvent, task: BackgroundTask): void {
|
|
217
|
+
for (const cb of this.listeners[event]) cb(task)
|
|
218
|
+
}
|
|
219
|
+
}
|
|
@@ -1,9 +1,14 @@
|
|
|
1
|
-
import { afterEach, describe, expect, test } from "bun:test"
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
|
2
2
|
import { compareVersions, classifyInstallVersionFailure, parseArgs, runCli } from "./cli-runtime"
|
|
3
3
|
import { CLI_SUPPRESS_OPEN_ONCE_ENV_VAR } from "./restart"
|
|
4
4
|
|
|
5
5
|
const originalRuntimeProfile = process.env.KANNA_RUNTIME_PROFILE
|
|
6
6
|
const originalSuppressOpen = process.env[CLI_SUPPRESS_OPEN_ONCE_ENV_VAR]
|
|
7
|
+
const originalDisableSelfUpdate = process.env.KANNA_DISABLE_SELF_UPDATE
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
delete process.env.KANNA_DISABLE_SELF_UPDATE
|
|
11
|
+
})
|
|
7
12
|
|
|
8
13
|
afterEach(() => {
|
|
9
14
|
if (originalRuntimeProfile === undefined) {
|
|
@@ -16,6 +21,11 @@ afterEach(() => {
|
|
|
16
21
|
} else {
|
|
17
22
|
process.env[CLI_SUPPRESS_OPEN_ONCE_ENV_VAR] = originalSuppressOpen
|
|
18
23
|
}
|
|
24
|
+
if (originalDisableSelfUpdate === undefined) {
|
|
25
|
+
delete process.env.KANNA_DISABLE_SELF_UPDATE
|
|
26
|
+
} else {
|
|
27
|
+
process.env.KANNA_DISABLE_SELF_UPDATE = originalDisableSelfUpdate
|
|
28
|
+
}
|
|
19
29
|
})
|
|
20
30
|
|
|
21
31
|
function createDeps(overrides: Partial<Parameters<typeof runCli>[1]> = {}) {
|
|
@@ -277,6 +287,7 @@ describe("runCli", () => {
|
|
|
277
287
|
test("starts normally when no newer version exists", async () => {
|
|
278
288
|
const { calls, deps } = createDeps()
|
|
279
289
|
process.env.KANNA_RUNTIME_PROFILE = "prod"
|
|
290
|
+
delete process.env.KANNA_DISABLE_SELF_UPDATE
|
|
280
291
|
|
|
281
292
|
const result = await runCli(["--port", "4000", "--no-open"], deps)
|
|
282
293
|
|
|
@@ -468,6 +479,7 @@ describe("runCli", () => {
|
|
|
468
479
|
})
|
|
469
480
|
|
|
470
481
|
test("returns restarting when a newer version is available", async () => {
|
|
482
|
+
delete process.env.KANNA_DISABLE_SELF_UPDATE
|
|
471
483
|
const { calls, deps } = createDeps({
|
|
472
484
|
fetchLatestVersion: async (packageName) => {
|
|
473
485
|
calls.fetchLatestVersion.push(packageName)
|
|
@@ -483,6 +495,7 @@ describe("runCli", () => {
|
|
|
483
495
|
})
|
|
484
496
|
|
|
485
497
|
test("falls back to current version when install fails", async () => {
|
|
498
|
+
delete process.env.KANNA_DISABLE_SELF_UPDATE
|
|
486
499
|
const { calls, deps } = createDeps({
|
|
487
500
|
fetchLatestVersion: async (packageName) => {
|
|
488
501
|
calls.fetchLatestVersion.push(packageName)
|
|
@@ -507,6 +520,7 @@ describe("runCli", () => {
|
|
|
507
520
|
})
|
|
508
521
|
|
|
509
522
|
test("falls back to current version when the registry check fails", async () => {
|
|
523
|
+
delete process.env.KANNA_DISABLE_SELF_UPDATE
|
|
510
524
|
const { calls, deps } = createDeps({
|
|
511
525
|
fetchLatestVersion: async (packageName) => {
|
|
512
526
|
calls.fetchLatestVersion.push(packageName)
|
|
@@ -51,6 +51,7 @@ describe("cloudflare tunnel e2e", () => {
|
|
|
51
51
|
let gateway: TunnelGateway
|
|
52
52
|
let pendingChildren: FakeChild[]
|
|
53
53
|
let broadcasts: string[]
|
|
54
|
+
let pendingWrites: Promise<void>[]
|
|
54
55
|
|
|
55
56
|
beforeEach(async () => {
|
|
56
57
|
dataDir = await mkdtemp(join(tmpdir(), "kanna-tunnel-e2e-"))
|
|
@@ -70,6 +71,7 @@ describe("cloudflare tunnel e2e", () => {
|
|
|
70
71
|
|
|
71
72
|
pendingChildren = []
|
|
72
73
|
broadcasts = []
|
|
74
|
+
pendingWrites = []
|
|
73
75
|
|
|
74
76
|
manager = new TunnelManager({
|
|
75
77
|
cloudflaredPath: "cloudflared",
|
|
@@ -79,7 +81,7 @@ describe("cloudflare tunnel e2e", () => {
|
|
|
79
81
|
return child
|
|
80
82
|
},
|
|
81
83
|
onEvent: (event: CloudflareTunnelEvent) => {
|
|
82
|
-
|
|
84
|
+
pendingWrites.push(store.appendTunnelEvent(event))
|
|
83
85
|
broadcasts.push(event.chatId)
|
|
84
86
|
},
|
|
85
87
|
})
|
|
@@ -102,6 +104,7 @@ describe("cloudflare tunnel e2e", () => {
|
|
|
102
104
|
afterEach(async () => {
|
|
103
105
|
gateway.shutdown()
|
|
104
106
|
appSettings.dispose()
|
|
107
|
+
await Promise.all(pendingWrites)
|
|
105
108
|
await rm(dataDir, { recursive: true, force: true })
|
|
106
109
|
})
|
|
107
110
|
|
|
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"
|
|
|
2
2
|
import { EventEmitter } from "node:events"
|
|
3
3
|
import { PassThrough } from "node:stream"
|
|
4
4
|
import { CodexAppServerManager } from "./codex-app-server"
|
|
5
|
+
import { BackgroundTaskRegistry } from "./background-tasks"
|
|
5
6
|
|
|
6
7
|
class FakeCodexProcess extends EventEmitter {
|
|
7
8
|
readonly stdin = new PassThrough()
|
|
@@ -1813,4 +1814,72 @@ describe("CodexAppServerManager", () => {
|
|
|
1813
1814
|
expect(resultEvent?.entry.subtype).toBe("error")
|
|
1814
1815
|
expect(resultEvent?.entry.result).toContain("fatal: app-server crashed")
|
|
1815
1816
|
})
|
|
1817
|
+
|
|
1818
|
+
test("registers codex_session entry in BackgroundTaskRegistry on startSession", async () => {
|
|
1819
|
+
const fakeProcess = new FakeCodexProcess((message, child) => {
|
|
1820
|
+
if (message.method === "initialize") {
|
|
1821
|
+
child.writeServerMessage({ id: message.id, result: { userAgent: "codex-test" } })
|
|
1822
|
+
} else if (message.method === "thread/start") {
|
|
1823
|
+
child.writeServerMessage({
|
|
1824
|
+
id: message.id,
|
|
1825
|
+
result: { thread: { id: "thread-reg-1" }, model: "gpt-5.4", reasoningEffort: "high" },
|
|
1826
|
+
})
|
|
1827
|
+
}
|
|
1828
|
+
})
|
|
1829
|
+
|
|
1830
|
+
const registry = new BackgroundTaskRegistry()
|
|
1831
|
+
const manager = new CodexAppServerManager({
|
|
1832
|
+
spawnProcess: () => fakeProcess as never,
|
|
1833
|
+
backgroundTasks: registry,
|
|
1834
|
+
})
|
|
1835
|
+
|
|
1836
|
+
await manager.startSession({
|
|
1837
|
+
chatId: "chat-reg-1",
|
|
1838
|
+
cwd: "/tmp/project",
|
|
1839
|
+
model: "gpt-5.4",
|
|
1840
|
+
sessionToken: null,
|
|
1841
|
+
})
|
|
1842
|
+
|
|
1843
|
+
const tasks = registry.list()
|
|
1844
|
+
const task = tasks.find((t) => t.id === "codex:chat-reg-1")
|
|
1845
|
+
expect(task).toBeDefined()
|
|
1846
|
+
expect(task?.kind).toBe("codex_session")
|
|
1847
|
+
if (task?.kind === "codex_session") {
|
|
1848
|
+
expect(task.chatId).toBe("chat-reg-1")
|
|
1849
|
+
expect(task.pid).toBeNull()
|
|
1850
|
+
expect(typeof task.startedAt).toBe("number")
|
|
1851
|
+
}
|
|
1852
|
+
})
|
|
1853
|
+
|
|
1854
|
+
test("unregisters codex_session entry from BackgroundTaskRegistry on stopSession", async () => {
|
|
1855
|
+
const fakeProcess = new FakeCodexProcess((message, child) => {
|
|
1856
|
+
if (message.method === "initialize") {
|
|
1857
|
+
child.writeServerMessage({ id: message.id, result: { userAgent: "codex-test" } })
|
|
1858
|
+
} else if (message.method === "thread/start") {
|
|
1859
|
+
child.writeServerMessage({
|
|
1860
|
+
id: message.id,
|
|
1861
|
+
result: { thread: { id: "thread-reg-2" }, model: "gpt-5.4", reasoningEffort: "high" },
|
|
1862
|
+
})
|
|
1863
|
+
}
|
|
1864
|
+
})
|
|
1865
|
+
|
|
1866
|
+
const registry = new BackgroundTaskRegistry()
|
|
1867
|
+
const manager = new CodexAppServerManager({
|
|
1868
|
+
spawnProcess: () => fakeProcess as never,
|
|
1869
|
+
backgroundTasks: registry,
|
|
1870
|
+
})
|
|
1871
|
+
|
|
1872
|
+
await manager.startSession({
|
|
1873
|
+
chatId: "chat-reg-2",
|
|
1874
|
+
cwd: "/tmp/project",
|
|
1875
|
+
model: "gpt-5.4",
|
|
1876
|
+
sessionToken: null,
|
|
1877
|
+
})
|
|
1878
|
+
|
|
1879
|
+
expect(registry.list().find((t) => t.id === "codex:chat-reg-2")).toBeDefined()
|
|
1880
|
+
|
|
1881
|
+
manager.stopSession("chat-reg-2")
|
|
1882
|
+
|
|
1883
|
+
expect(registry.list().find((t) => t.id === "codex:chat-reg-2")).toBeUndefined()
|
|
1884
|
+
})
|
|
1816
1885
|
})
|