zooid 0.6.1 → 0.7.1

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.
Files changed (81) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +36 -444
  3. package/dist/bin.js +1891 -0
  4. package/dist/bin.js.map +1 -0
  5. package/dist/chunk-SUPTPSN3.js +31434 -0
  6. package/dist/chunk-SUPTPSN3.js.map +1 -0
  7. package/dist/index.d.ts +64 -0
  8. package/dist/index.js +4 -3790
  9. package/dist/index.js.map +1 -0
  10. package/dist/web/assets/geist-cyrillic-wght-normal-CHSlOQsW.woff2 +0 -0
  11. package/dist/web/assets/geist-latin-ext-wght-normal-DMtmJ5ZE.woff2 +0 -0
  12. package/dist/web/assets/geist-latin-wght-normal-Dm3htQBi.woff2 +0 -0
  13. package/dist/web/assets/index-C2MAIew3.css +1 -0
  14. package/dist/web/assets/index-CqfWXqvO.js +118066 -0
  15. package/dist/web/assets/index-Cz5fEZBc.js +295 -0
  16. package/dist/web/assets/reaction-picker-emoji-cME0vOsX.js +716 -0
  17. package/dist/web/favicon.svg +1 -0
  18. package/dist/web/index.html +14 -0
  19. package/package.json +43 -22
  20. package/src/bin.ts +115 -0
  21. package/src/bootstrap/admin.test.ts +69 -0
  22. package/src/bootstrap/admin.ts +37 -0
  23. package/src/bootstrap/configs.test.ts +24 -0
  24. package/src/bootstrap/configs.ts +64 -0
  25. package/src/bootstrap/data-layout.test.ts +25 -0
  26. package/src/bootstrap/data-layout.ts +20 -0
  27. package/src/bootstrap/derive.test.ts +74 -0
  28. package/src/bootstrap/derive.ts +40 -0
  29. package/src/bootstrap/paths.test.ts +22 -0
  30. package/src/bootstrap/paths.ts +27 -0
  31. package/src/bootstrap/tokens.test.ts +44 -0
  32. package/src/bootstrap/tokens.ts +43 -0
  33. package/src/build-registry.context.test.ts +61 -0
  34. package/src/build-registry.test.ts +59 -0
  35. package/src/build-registry.ts +343 -0
  36. package/src/build-registry.zod043.test.ts +64 -0
  37. package/src/build-registry.zod044.test.ts +261 -0
  38. package/src/commands/dev-cascade.test.ts +38 -0
  39. package/src/commands/dev-cascade.ts +35 -0
  40. package/src/commands/dev-data-layout.test.ts +55 -0
  41. package/src/commands/dev.ts +316 -0
  42. package/src/commands/init/generators.test.ts +97 -0
  43. package/src/commands/init/generators.ts +124 -0
  44. package/src/commands/init/prompts.ts +123 -0
  45. package/src/commands/init/registry.test.ts +33 -0
  46. package/src/commands/init/registry.ts +75 -0
  47. package/src/commands/init/sniff.test.ts +41 -0
  48. package/src/commands/init/sniff.ts +30 -0
  49. package/src/commands/init.test.ts +131 -0
  50. package/src/commands/init.ts +158 -0
  51. package/src/commands/logs.test.ts +105 -0
  52. package/src/commands/logs.ts +108 -0
  53. package/src/commands/start.ts +28 -0
  54. package/src/commands/status.test.ts +73 -0
  55. package/src/commands/status.ts +105 -0
  56. package/src/daemon/start-daemon.test.ts +67 -0
  57. package/src/daemon/start-daemon.ts +270 -0
  58. package/src/index.ts +2 -0
  59. package/src/observability/capture-agent.ts +58 -0
  60. package/src/observability/capture-tuwunel.test.ts +57 -0
  61. package/src/observability/capture-tuwunel.ts +24 -0
  62. package/src/observability/file-sink.test.ts +68 -0
  63. package/src/observability/file-sink.ts +78 -0
  64. package/src/observability/observability.integration.test.ts +162 -0
  65. package/src/observability/paths.test.ts +104 -0
  66. package/src/observability/paths.ts +94 -0
  67. package/src/prepull-images.test.ts +149 -0
  68. package/src/prepull-images.ts +100 -0
  69. package/src/services/tuwunel.test.ts +64 -0
  70. package/src/services/tuwunel.ts +91 -0
  71. package/src/web/resolve.test.ts +50 -0
  72. package/src/web/resolve.ts +31 -0
  73. package/src/web/static.test.ts +51 -0
  74. package/src/web/static.ts +61 -0
  75. package/src/web/watch.ts +103 -0
  76. package/dist/chunk-67ZRMVHO.js +0 -174
  77. package/dist/chunk-AR456MHY.js +0 -29
  78. package/dist/chunk-VBGU2NST.js +0 -139
  79. package/dist/client-4VMFEFDX.js +0 -22
  80. package/dist/config-2KK5GX42.js +0 -27
  81. package/dist/template-T5IB4YWC.js +0 -92
@@ -0,0 +1,68 @@
1
+ import { describe, expect, it, beforeEach, afterEach } from 'vitest'
2
+ import { mkdtemp, readFile } from 'node:fs/promises'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { rmSync } from 'node:fs'
6
+ import { JsonlSink, shouldCaptureUpdate, type Verbosity } from './file-sink.js'
7
+
8
+ describe('JsonlSink', () => {
9
+ let dir: string
10
+ beforeEach(async () => {
11
+ dir = await mkdtemp(join(tmpdir(), 'zooid-obs-sink-'))
12
+ })
13
+ afterEach(() => {
14
+ rmSync(dir, { recursive: true, force: true })
15
+ })
16
+
17
+ it('appends one JSON object per line and survives close+reopen', async () => {
18
+ const path = join(dir, 'agent-docs.acp.jsonl')
19
+ const sink1 = new JsonlSink(path)
20
+ await sink1.write({ a: 1 })
21
+ await sink1.write({ b: 2 })
22
+ await sink1.close()
23
+ const sink2 = new JsonlSink(path)
24
+ await sink2.write({ c: 3 })
25
+ await sink2.close()
26
+ const text = await readFile(path, 'utf8')
27
+ const lines = text.trim().split('\n').map((l) => JSON.parse(l))
28
+ expect(lines).toEqual([{ a: 1 }, { b: 2 }, { c: 3 }])
29
+ })
30
+
31
+ it('truncates string fields longer than 4 KB on a top-level cap', async () => {
32
+ const path = join(dir, 'big.jsonl')
33
+ const sink = new JsonlSink(path, { maxStringLen: 4096 })
34
+ const big = 'x'.repeat(10_000)
35
+ await sink.write({ a: big })
36
+ await sink.close()
37
+ const line = JSON.parse((await readFile(path, 'utf8')).trim())
38
+ expect(line.a.length).toBeLessThanOrEqual(4096 + '…[truncated]'.length)
39
+ expect(line.a.endsWith('…[truncated]')).toBe(true)
40
+ })
41
+ })
42
+
43
+ describe('shouldCaptureUpdate (default verbosity)', () => {
44
+ const cases: Array<[string, boolean]> = [
45
+ ['tool_call', true],
46
+ ['tool_call_update', true],
47
+ ['plan', true],
48
+ ['available_commands_update', true],
49
+ ['current_mode_update', true],
50
+ ['user_message_chunk', true],
51
+ ['agent_message_chunk', false],
52
+ ['agent_thought_chunk', false],
53
+ ]
54
+ it.each(cases)('default(%s) → %s', (variant, expected) => {
55
+ const v: Verbosity = 'default'
56
+ expect(shouldCaptureUpdate(v, variant)).toBe(expected)
57
+ })
58
+
59
+ it('verbose-thoughts captures thought chunks but not message chunks', () => {
60
+ expect(shouldCaptureUpdate('verbose-thoughts', 'agent_thought_chunk')).toBe(true)
61
+ expect(shouldCaptureUpdate('verbose-thoughts', 'agent_message_chunk')).toBe(false)
62
+ })
63
+
64
+ it('verbose captures everything', () => {
65
+ expect(shouldCaptureUpdate('verbose', 'agent_message_chunk')).toBe(true)
66
+ expect(shouldCaptureUpdate('verbose', 'agent_thought_chunk')).toBe(true)
67
+ })
68
+ })
@@ -0,0 +1,78 @@
1
+ import { createWriteStream, type WriteStream } from 'node:fs'
2
+ import { mkdir } from 'node:fs/promises'
3
+ import { dirname } from 'node:path'
4
+
5
+ export type Verbosity = 'default' | 'verbose-thoughts' | 'verbose'
6
+
7
+ export interface JsonlSinkOpts {
8
+ /** Truncate any single string field longer than this. Default 4 KB. */
9
+ maxStringLen?: number
10
+ }
11
+
12
+ const TRUNC_MARKER = '…[truncated]'
13
+
14
+ export class JsonlSink {
15
+ private stream: WriteStream | null = null
16
+ private readonly maxStringLen: number
17
+ private readonly readyPromise: Promise<void>
18
+
19
+ constructor(
20
+ private readonly path: string,
21
+ opts: JsonlSinkOpts = {},
22
+ ) {
23
+ this.maxStringLen = opts.maxStringLen ?? 4096
24
+ this.readyPromise = (async () => {
25
+ await mkdir(dirname(this.path), { recursive: true })
26
+ this.stream = createWriteStream(this.path, { flags: 'a' })
27
+ })()
28
+ }
29
+
30
+ async write(obj: unknown): Promise<void> {
31
+ await this.readyPromise
32
+ const capped = capStrings(obj, this.maxStringLen)
33
+ const line = JSON.stringify(capped) + '\n'
34
+ return new Promise((resolve, reject) => {
35
+ this.stream!.write(line, (err) => (err ? reject(err) : resolve()))
36
+ })
37
+ }
38
+
39
+ async close(): Promise<void> {
40
+ await this.readyPromise
41
+ return new Promise((resolve) => {
42
+ this.stream!.end(() => resolve())
43
+ })
44
+ }
45
+ }
46
+
47
+ function capStrings(v: unknown, max: number): unknown {
48
+ if (typeof v === 'string') {
49
+ return v.length <= max ? v : v.slice(0, max) + TRUNC_MARKER
50
+ }
51
+ if (Array.isArray(v)) return v.map((x) => capStrings(x, max))
52
+ if (v && typeof v === 'object') {
53
+ const out: Record<string, unknown> = {}
54
+ for (const [k, val] of Object.entries(v)) out[k] = capStrings(val, max)
55
+ return out
56
+ }
57
+ return v
58
+ }
59
+
60
+ const DEFAULT_VARIANTS = new Set([
61
+ 'tool_call',
62
+ 'tool_call_update',
63
+ 'plan',
64
+ 'available_commands_update',
65
+ 'current_mode_update',
66
+ 'user_message_chunk',
67
+ ])
68
+
69
+ export function shouldCaptureUpdate(
70
+ verbosity: Verbosity,
71
+ variant: string,
72
+ ): boolean {
73
+ if (verbosity === 'verbose') return true
74
+ if (verbosity === 'verbose-thoughts') {
75
+ return DEFAULT_VARIANTS.has(variant) || variant === 'agent_thought_chunk'
76
+ }
77
+ return DEFAULT_VARIANTS.has(variant)
78
+ }
@@ -0,0 +1,162 @@
1
+ import { describe, expect, it, beforeEach, afterEach } from 'vitest'
2
+ import { mkdtemp, readFile } from 'node:fs/promises'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { rmSync } from 'node:fs'
6
+ import { wireAgentCapture } from './capture-agent.js'
7
+ import { resolveLogPaths, ensureDayFolder } from './paths.js'
8
+ import type { TapEvent } from '@zooid/core'
9
+
10
+ describe('agent capture end-to-end', () => {
11
+ let dataDir: string
12
+ beforeEach(async () => {
13
+ dataDir = await mkdtemp(join(tmpdir(), 'zooid-obs-int-'))
14
+ })
15
+ afterEach(() => {
16
+ rmSync(dataDir, { recursive: true, force: true })
17
+ })
18
+
19
+ it('writes one JSONL line per tap event with envelope + turn correlation', async () => {
20
+ const now = new Date('2026-05-06T10:00:00Z')
21
+ const paths = resolveLogPaths({ dataDir, now })
22
+ await ensureDayFolder(paths)
23
+
24
+ const cap = wireAgentCapture({
25
+ agent: 'docs',
26
+ paths,
27
+ verbosity: 'default',
28
+ matrixContext: () => ({ room_id: '!abc:localhost', event_id: '$xyz' }),
29
+ now: () => now,
30
+ })
31
+
32
+ const events: TapEvent[] = [
33
+ {
34
+ kind: 'turn_started',
35
+ agentId: 'docs',
36
+ sessionId: 'sess_1',
37
+ turnId: 'turn_1',
38
+ promptText: 'write the overview page',
39
+ },
40
+ {
41
+ kind: 'session_update',
42
+ agentId: 'docs',
43
+ sessionId: 'sess_1',
44
+ turnId: 'turn_1',
45
+ update: {
46
+ sessionUpdate: 'tool_call',
47
+ toolCallId: 'tc_1',
48
+ kind: 'edit',
49
+ title: 'edit overview.md',
50
+ } as never,
51
+ },
52
+ {
53
+ kind: 'session_update',
54
+ agentId: 'docs',
55
+ sessionId: 'sess_1',
56
+ turnId: 'turn_1',
57
+ update: {
58
+ sessionUpdate: 'tool_call_update',
59
+ toolCallId: 'tc_1',
60
+ status: 'completed',
61
+ } as never,
62
+ },
63
+ {
64
+ kind: 'session_update',
65
+ agentId: 'docs',
66
+ sessionId: 'sess_1',
67
+ turnId: 'turn_1',
68
+ update: {
69
+ sessionUpdate: 'agent_message_chunk',
70
+ content: { type: 'text', text: 'done' },
71
+ } as never,
72
+ },
73
+ {
74
+ kind: 'turn_completed',
75
+ agentId: 'docs',
76
+ sessionId: 'sess_1',
77
+ turnId: 'turn_1',
78
+ stopReason: 'end_turn',
79
+ },
80
+ ]
81
+ for (const e of events) cap.onTap(e)
82
+ await cap.close()
83
+
84
+ const text = await readFile(paths.agentTap('docs'), 'utf8')
85
+ const lines = text.trim().split('\n').map((l) => JSON.parse(l))
86
+
87
+ expect(lines.map((l) => l.kind)).toEqual([
88
+ 'turn_started',
89
+ 'session_update',
90
+ 'session_update',
91
+ 'turn_completed',
92
+ ])
93
+ for (const l of lines) {
94
+ expect(l.ts).toBe('2026-05-06T10:00:00.000Z')
95
+ expect(l.agent).toBe('docs')
96
+ expect(l.session_id).toBe('sess_1')
97
+ expect(l.turn_id).toBe('turn_1')
98
+ expect(l.matrix).toEqual({ room_id: '!abc:localhost', event_id: '$xyz' })
99
+ }
100
+ })
101
+
102
+ it('thought chunks are dropped by default but kept under verbose-thoughts', async () => {
103
+ const paths = resolveLogPaths({ dataDir, now: new Date('2026-05-06T10:00:00Z') })
104
+ await ensureDayFolder(paths)
105
+ const cap = wireAgentCapture({
106
+ agent: 'docs',
107
+ paths,
108
+ verbosity: 'verbose-thoughts',
109
+ matrixContext: () => null,
110
+ now: () => new Date('2026-05-06T10:00:00Z'),
111
+ })
112
+ cap.onTap({
113
+ kind: 'session_update',
114
+ agentId: 'docs',
115
+ sessionId: 's',
116
+ turnId: 't',
117
+ update: {
118
+ sessionUpdate: 'agent_thought_chunk',
119
+ content: { type: 'text', text: 'thinking…' },
120
+ } as never,
121
+ })
122
+ cap.onTap({
123
+ kind: 'session_update',
124
+ agentId: 'docs',
125
+ sessionId: 's',
126
+ turnId: 't',
127
+ update: {
128
+ sessionUpdate: 'agent_message_chunk',
129
+ content: { type: 'text', text: 'output' },
130
+ } as never,
131
+ })
132
+ await cap.close()
133
+ const lines = (await readFile(paths.agentTap('docs'), 'utf8'))
134
+ .trim()
135
+ .split('\n')
136
+ .map((l) => JSON.parse(l))
137
+ expect(lines).toHaveLength(1)
138
+ expect(lines[0].notification.sessionUpdate).toBe('agent_thought_chunk')
139
+ })
140
+
141
+ it('omits matrix context cleanly when not running on Matrix', async () => {
142
+ const paths = resolveLogPaths({ dataDir, now: new Date('2026-05-06T10:00:00Z') })
143
+ await ensureDayFolder(paths)
144
+ const cap = wireAgentCapture({
145
+ agent: 'echo',
146
+ paths,
147
+ verbosity: 'default',
148
+ matrixContext: () => null,
149
+ now: () => new Date('2026-05-06T10:00:00Z'),
150
+ })
151
+ cap.onTap({
152
+ kind: 'turn_started',
153
+ agentId: 'echo',
154
+ sessionId: 's',
155
+ turnId: 't',
156
+ promptText: 'ping',
157
+ })
158
+ await cap.close()
159
+ const line = JSON.parse((await readFile(paths.agentTap('echo'), 'utf8')).trim())
160
+ expect(line.matrix).toBeUndefined()
161
+ })
162
+ })
@@ -0,0 +1,104 @@
1
+ import { describe, expect, it, beforeEach, afterEach } from 'vitest'
2
+ import { mkdtemp, mkdir, readlink, readdir, writeFile, stat } from 'node:fs/promises'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { rmSync } from 'node:fs'
6
+ import { resolveLogPaths, ensureDayFolder, pruneOldDays } from './paths.js'
7
+
8
+ describe('observability paths', () => {
9
+ let dataDir: string
10
+ beforeEach(async () => {
11
+ dataDir = await mkdtemp(join(tmpdir(), 'zooid-obs-paths-'))
12
+ })
13
+ afterEach(() => {
14
+ rmSync(dataDir, { recursive: true, force: true })
15
+ })
16
+
17
+ it('places each source under logs/YYYY-MM-DD/', () => {
18
+ const p = resolveLogPaths({ dataDir, now: new Date('2026-05-06T10:00:00Z') })
19
+ expect(p.dayDir).toBe(join(dataDir, 'logs', '2026-05-06'))
20
+ expect(p.tuwunelLog).toBe(join(p.dayDir, 'tuwunel.log'))
21
+ expect(p.daemonLog).toBe(join(p.dayDir, 'daemon.log'))
22
+ expect(p.devLog).toBe(join(p.dayDir, 'dev.log'))
23
+ expect(p.agentLog('docs')).toBe(join(p.dayDir, 'agent-docs.log'))
24
+ expect(p.agentTap('docs')).toBe(join(p.dayDir, 'agent-docs.acp.jsonl'))
25
+ expect(p.todayLink).toBe(join(dataDir, 'logs', 'today'))
26
+ })
27
+
28
+ it('uses local-time date, not UTC', () => {
29
+ // 2026-05-06 23:30 UTC is 2026-05-07 in many timezones; we want the
30
+ // day the user *started* the session by their wall clock.
31
+ const local = new Date(2026, 4, 6, 23, 30) // month is 0-indexed: May = 4
32
+ const p = resolveLogPaths({ dataDir, now: local })
33
+ expect(p.dayDir).toBe(join(dataDir, 'logs', '2026-05-06'))
34
+ })
35
+
36
+ it('ensureDayFolder creates the dir and points today→ at it', async () => {
37
+ const p = resolveLogPaths({ dataDir, now: new Date('2026-05-06T10:00:00Z') })
38
+ await ensureDayFolder(p)
39
+ const linkTarget = await readlink(p.todayLink)
40
+ expect(linkTarget).toBe('2026-05-06')
41
+ const s = await stat(p.dayDir)
42
+ expect(s.isDirectory()).toBe(true)
43
+ })
44
+
45
+ it('ensureDayFolder repoints today→ when the day rolls over', async () => {
46
+ const day1 = resolveLogPaths({ dataDir, now: new Date('2026-05-06T10:00:00Z') })
47
+ await ensureDayFolder(day1)
48
+ const day2 = resolveLogPaths({ dataDir, now: new Date('2026-05-07T10:00:00Z') })
49
+ await ensureDayFolder(day2)
50
+ expect(await readlink(day2.todayLink)).toBe('2026-05-07')
51
+ })
52
+
53
+ it('pruneOldDays removes folders older than retainDays', async () => {
54
+ const logs = join(dataDir, 'logs')
55
+ for (const d of ['2026-04-20', '2026-05-01', '2026-05-05', '2026-05-06']) {
56
+ await mkdir(join(logs, d), { recursive: true })
57
+ await writeFile(join(logs, d, 'tuwunel.log'), 'x')
58
+ }
59
+ const removed = await pruneOldDays({
60
+ dataDir,
61
+ now: new Date('2026-05-06T10:00:00Z'),
62
+ retainDays: 3,
63
+ })
64
+ expect(removed.sort()).toEqual(['2026-04-20', '2026-05-01'])
65
+ const remaining = (await readdir(logs)).sort()
66
+ expect(remaining).toEqual(['2026-05-05', '2026-05-06'])
67
+ })
68
+
69
+ it('pruneOldDays with retainDays=0 is a no-op', async () => {
70
+ const logs = join(dataDir, 'logs')
71
+ await mkdir(join(logs, '2024-01-01'), { recursive: true })
72
+ const removed = await pruneOldDays({
73
+ dataDir,
74
+ now: new Date('2026-05-06T10:00:00Z'),
75
+ retainDays: 0,
76
+ })
77
+ expect(removed).toEqual([])
78
+ })
79
+
80
+ it('pruneOldDays ignores non-date entries (the today symlink, junk)', async () => {
81
+ const logs = join(dataDir, 'logs')
82
+ await mkdir(join(logs, '2026-05-06'), { recursive: true })
83
+ await mkdir(join(logs, 'not-a-date'), { recursive: true })
84
+ const p = resolveLogPaths({ dataDir, now: new Date('2026-05-06T10:00:00Z') })
85
+ await ensureDayFolder(p)
86
+ const removed = await pruneOldDays({
87
+ dataDir,
88
+ now: new Date('2026-05-06T10:00:00Z'),
89
+ retainDays: 1,
90
+ })
91
+ expect(removed).toEqual([])
92
+ })
93
+
94
+ it('treats its dataDir argument as the data root: logs become a top-level sibling', () => {
95
+ // Pre-ZOD041 we passed the matrix dir, so logs landed at <matrix>/logs/.
96
+ // Post-ZOD041 we pass the root, so logs land at <root>/logs/ (matrix is its sibling).
97
+ const root = '/abs/data'
98
+ const p = resolveLogPaths({ dataDir: root, now: new Date('2026-05-06T10:00:00Z') })
99
+ expect(p.logsDir).toBe('/abs/data/logs')
100
+ expect(p.dayDir).toBe('/abs/data/logs/2026-05-06')
101
+ // Sanity: NOT under matrix.
102
+ expect(p.logsDir.startsWith('/abs/data/matrix')).toBe(false)
103
+ })
104
+ })
@@ -0,0 +1,94 @@
1
+ import { mkdir, readdir, rm, symlink, unlink } from 'node:fs/promises'
2
+ import { join } from 'node:path'
3
+
4
+ export interface LogPaths {
5
+ dataDir: string
6
+ logsDir: string
7
+ dayDir: string
8
+ daySlug: string
9
+ todayLink: string
10
+ tuwunelLog: string
11
+ daemonLog: string
12
+ devLog: string
13
+ agentLog: (agentName: string) => string
14
+ agentTap: (agentName: string) => string
15
+ }
16
+
17
+ export interface ResolveOpts {
18
+ dataDir: string
19
+ /** Defaults to `new Date()`. Tests pin this for determinism. */
20
+ now?: Date
21
+ }
22
+
23
+ // Local-time slug — we want the wall-clock day a session started, not UTC.
24
+ function localDateSlug(d: Date): string {
25
+ const y = d.getFullYear()
26
+ const m = String(d.getMonth() + 1).padStart(2, '0')
27
+ const day = String(d.getDate()).padStart(2, '0')
28
+ return `${y}-${m}-${day}`
29
+ }
30
+
31
+ const DAY_RE = /^\d{4}-\d{2}-\d{2}$/
32
+
33
+ export function resolveLogPaths({ dataDir, now }: ResolveOpts): LogPaths {
34
+ const slug = localDateSlug(now ?? new Date())
35
+ const logsDir = join(dataDir, 'logs')
36
+ const dayDir = join(logsDir, slug)
37
+ return {
38
+ dataDir,
39
+ logsDir,
40
+ dayDir,
41
+ daySlug: slug,
42
+ todayLink: join(logsDir, 'today'),
43
+ tuwunelLog: join(dayDir, 'tuwunel.log'),
44
+ daemonLog: join(dayDir, 'daemon.log'),
45
+ devLog: join(dayDir, 'dev.log'),
46
+ agentLog: (n) => join(dayDir, `agent-${n}.log`),
47
+ agentTap: (n) => join(dayDir, `agent-${n}.acp.jsonl`),
48
+ }
49
+ }
50
+
51
+ export async function ensureDayFolder(p: LogPaths): Promise<void> {
52
+ await mkdir(p.dayDir, { recursive: true })
53
+ // Symlink target is the relative slug so the link survives a moved data dir.
54
+ try {
55
+ await unlink(p.todayLink)
56
+ } catch {
57
+ // may not exist
58
+ }
59
+ await symlink(p.daySlug, p.todayLink)
60
+ }
61
+
62
+ export interface PruneOpts {
63
+ dataDir: string
64
+ now?: Date
65
+ retainDays: number
66
+ }
67
+
68
+ export async function pruneOldDays(opts: PruneOpts): Promise<string[]> {
69
+ if (opts.retainDays <= 0) return []
70
+ const logsDir = join(opts.dataDir, 'logs')
71
+ let entries: string[]
72
+ try {
73
+ entries = await readdir(logsDir)
74
+ } catch {
75
+ return []
76
+ }
77
+ const today = opts.now ?? new Date()
78
+ const cutoff = new Date(
79
+ today.getFullYear(),
80
+ today.getMonth(),
81
+ today.getDate() - opts.retainDays + 1,
82
+ ).getTime()
83
+ const removed: string[] = []
84
+ for (const name of entries) {
85
+ if (!DAY_RE.test(name)) continue
86
+ const [y, m, d] = name.split('-').map(Number)
87
+ const t = new Date(y, m - 1, d).getTime()
88
+ if (t < cutoff) {
89
+ await rm(join(logsDir, name), { recursive: true, force: true })
90
+ removed.push(name)
91
+ }
92
+ }
93
+ return removed
94
+ }
@@ -0,0 +1,149 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import { prepullImages } from './prepull-images.js'
3
+
4
+ type Call = { argv: string[] }
5
+
6
+ function mkExec(
7
+ plan: Record<
8
+ string,
9
+ { inspectExit: number; pullExit?: number; pullStderr?: string }
10
+ >,
11
+ ) {
12
+ const calls: Call[] = []
13
+ const exec = vi.fn(async (cmd: string, args: string[]) => {
14
+ calls.push({ argv: [cmd, ...args] })
15
+ const op = args[0] === 'image' ? 'inspect' : 'pull'
16
+ const image = op === 'inspect' ? args[2] : args[1]
17
+ const entry = plan[image!]
18
+ if (!entry) throw new Error(`unexpected image: ${image}`)
19
+ if (op === 'inspect') {
20
+ return { code: entry.inspectExit, stdout: '', stderr: '' }
21
+ }
22
+ return { code: entry.pullExit ?? 0, stdout: '', stderr: entry.pullStderr ?? '' }
23
+ })
24
+ return { exec, calls }
25
+ }
26
+
27
+ const mkRegistry = (imagesByAgent: Record<string, string | undefined>) => ({
28
+ resolveSpawnImage: (n: string) => imagesByAgent[n],
29
+ agentNames: () => Object.keys(imagesByAgent),
30
+ })
31
+
32
+ describe('prepullImages', () => {
33
+ it('skips locally cached images (inspect exit 0)', async () => {
34
+ const { exec, calls } = mkExec({
35
+ 'ghcr.io/zooid-ai/agent-claude:latest': { inspectExit: 0 },
36
+ })
37
+ const lines: string[] = []
38
+ await prepullImages(
39
+ mkRegistry({ alice: 'ghcr.io/zooid-ai/agent-claude:latest' }) as any,
40
+ {
41
+ engine: 'docker',
42
+ runtime: 'docker',
43
+ exec,
44
+ log: (l) => lines.push(l),
45
+ },
46
+ )
47
+ expect(calls.filter((c) => c.argv.includes('pull'))).toHaveLength(0)
48
+ expect(lines.some((l) => /1 unique images \(1 cached, 0 to pull\)/.test(l))).toBe(
49
+ true,
50
+ )
51
+ })
52
+
53
+ it('pulls only missing images (inspect exit non-zero → pull)', async () => {
54
+ const { exec, calls } = mkExec({
55
+ 'cached:1': { inspectExit: 0 },
56
+ 'missing:1': { inspectExit: 1, pullExit: 0 },
57
+ })
58
+ const lines: string[] = []
59
+ await prepullImages(
60
+ mkRegistry({ a: 'cached:1', b: 'missing:1' }) as any,
61
+ { engine: 'docker', runtime: 'docker', exec, log: (l) => lines.push(l) },
62
+ )
63
+ const pulls = calls.filter((c) => c.argv.includes('pull')).map((c) => c.argv[2])
64
+ expect(pulls).toEqual(['missing:1'])
65
+ expect(lines.some((l) => /1 cached, 1 to pull/.test(l))).toBe(true)
66
+ expect(lines.some((l) => /missing:1.*✓/.test(l))).toBe(true)
67
+ })
68
+
69
+ it('dedupes by image — five agents on the same image pull once', async () => {
70
+ const { exec, calls } = mkExec({
71
+ 'shared:1': { inspectExit: 1, pullExit: 0 },
72
+ })
73
+ await prepullImages(
74
+ mkRegistry({
75
+ a: 'shared:1',
76
+ b: 'shared:1',
77
+ c: 'shared:1',
78
+ d: 'shared:1',
79
+ e: 'shared:1',
80
+ }) as any,
81
+ { engine: 'docker', runtime: 'docker', exec, log: () => {} },
82
+ )
83
+ expect(calls.filter((c) => c.argv.includes('pull'))).toHaveLength(1)
84
+ })
85
+
86
+ it('refresh=true skips the inspect check and pulls every image', async () => {
87
+ const { exec, calls } = mkExec({
88
+ 'a:1': { inspectExit: 0, pullExit: 0 },
89
+ 'b:1': { inspectExit: 0, pullExit: 0 },
90
+ })
91
+ await prepullImages(mkRegistry({ x: 'a:1', y: 'b:1' }) as any, {
92
+ engine: 'docker',
93
+ runtime: 'docker',
94
+ exec,
95
+ refresh: true,
96
+ log: () => {},
97
+ })
98
+ expect(calls.filter((c) => c.argv.includes('inspect'))).toHaveLength(0)
99
+ expect(calls.filter((c) => c.argv.includes('pull'))).toHaveLength(2)
100
+ })
101
+
102
+ it('fail-fast: pull exit non-zero throws with engine stderr embedded', async () => {
103
+ const { exec } = mkExec({
104
+ 'broken:1': { inspectExit: 1, pullExit: 1, pullStderr: 'manifest unknown' },
105
+ })
106
+ await expect(
107
+ prepullImages(mkRegistry({ a: 'broken:1' }) as any, {
108
+ engine: 'docker',
109
+ runtime: 'docker',
110
+ exec,
111
+ log: () => {},
112
+ }),
113
+ ).rejects.toThrow(/broken:1[\s\S]*manifest unknown/)
114
+ })
115
+
116
+ it('skip=true is a no-op (returns immediately, no exec calls)', async () => {
117
+ const { exec, calls } = mkExec({})
118
+ await prepullImages(mkRegistry({ a: 'whatever:1' }) as any, {
119
+ engine: 'docker',
120
+ runtime: 'docker',
121
+ exec,
122
+ skip: true,
123
+ log: () => {},
124
+ })
125
+ expect(calls).toHaveLength(0)
126
+ })
127
+
128
+ it('runtime: local is a no-op regardless of opts', async () => {
129
+ const { exec, calls } = mkExec({})
130
+ await prepullImages(mkRegistry({ a: 'whatever:1' }) as any, {
131
+ engine: 'docker',
132
+ runtime: 'local',
133
+ exec,
134
+ log: () => {},
135
+ })
136
+ expect(calls).toHaveLength(0)
137
+ })
138
+
139
+ it('uses the configured engine (podman) in argv', async () => {
140
+ const { exec, calls } = mkExec({ 'x:1': { inspectExit: 1, pullExit: 0 } })
141
+ await prepullImages(mkRegistry({ a: 'x:1' }) as any, {
142
+ engine: 'podman',
143
+ runtime: 'podman',
144
+ exec,
145
+ log: () => {},
146
+ })
147
+ expect(calls.every((c) => c.argv[0] === 'podman')).toBe(true)
148
+ })
149
+ })