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.
- package/LICENSE +1 -1
- package/README.md +36 -444
- package/dist/bin.js +1891 -0
- package/dist/bin.js.map +1 -0
- package/dist/chunk-SUPTPSN3.js +31434 -0
- package/dist/chunk-SUPTPSN3.js.map +1 -0
- package/dist/index.d.ts +64 -0
- package/dist/index.js +4 -3790
- package/dist/index.js.map +1 -0
- package/dist/web/assets/geist-cyrillic-wght-normal-CHSlOQsW.woff2 +0 -0
- package/dist/web/assets/geist-latin-ext-wght-normal-DMtmJ5ZE.woff2 +0 -0
- package/dist/web/assets/geist-latin-wght-normal-Dm3htQBi.woff2 +0 -0
- package/dist/web/assets/index-C2MAIew3.css +1 -0
- package/dist/web/assets/index-CqfWXqvO.js +118066 -0
- package/dist/web/assets/index-Cz5fEZBc.js +295 -0
- package/dist/web/assets/reaction-picker-emoji-cME0vOsX.js +716 -0
- package/dist/web/favicon.svg +1 -0
- package/dist/web/index.html +14 -0
- package/package.json +43 -22
- package/src/bin.ts +115 -0
- package/src/bootstrap/admin.test.ts +69 -0
- package/src/bootstrap/admin.ts +37 -0
- package/src/bootstrap/configs.test.ts +24 -0
- package/src/bootstrap/configs.ts +64 -0
- package/src/bootstrap/data-layout.test.ts +25 -0
- package/src/bootstrap/data-layout.ts +20 -0
- package/src/bootstrap/derive.test.ts +74 -0
- package/src/bootstrap/derive.ts +40 -0
- package/src/bootstrap/paths.test.ts +22 -0
- package/src/bootstrap/paths.ts +27 -0
- package/src/bootstrap/tokens.test.ts +44 -0
- package/src/bootstrap/tokens.ts +43 -0
- package/src/build-registry.context.test.ts +61 -0
- package/src/build-registry.test.ts +59 -0
- package/src/build-registry.ts +343 -0
- package/src/build-registry.zod043.test.ts +64 -0
- package/src/build-registry.zod044.test.ts +261 -0
- package/src/commands/dev-cascade.test.ts +38 -0
- package/src/commands/dev-cascade.ts +35 -0
- package/src/commands/dev-data-layout.test.ts +55 -0
- package/src/commands/dev.ts +316 -0
- package/src/commands/init/generators.test.ts +97 -0
- package/src/commands/init/generators.ts +124 -0
- package/src/commands/init/prompts.ts +123 -0
- package/src/commands/init/registry.test.ts +33 -0
- package/src/commands/init/registry.ts +75 -0
- package/src/commands/init/sniff.test.ts +41 -0
- package/src/commands/init/sniff.ts +30 -0
- package/src/commands/init.test.ts +131 -0
- package/src/commands/init.ts +158 -0
- package/src/commands/logs.test.ts +105 -0
- package/src/commands/logs.ts +108 -0
- package/src/commands/start.ts +28 -0
- package/src/commands/status.test.ts +73 -0
- package/src/commands/status.ts +105 -0
- package/src/daemon/start-daemon.test.ts +67 -0
- package/src/daemon/start-daemon.ts +270 -0
- package/src/index.ts +2 -0
- package/src/observability/capture-agent.ts +58 -0
- package/src/observability/capture-tuwunel.test.ts +57 -0
- package/src/observability/capture-tuwunel.ts +24 -0
- package/src/observability/file-sink.test.ts +68 -0
- package/src/observability/file-sink.ts +78 -0
- package/src/observability/observability.integration.test.ts +162 -0
- package/src/observability/paths.test.ts +104 -0
- package/src/observability/paths.ts +94 -0
- package/src/prepull-images.test.ts +149 -0
- package/src/prepull-images.ts +100 -0
- package/src/services/tuwunel.test.ts +64 -0
- package/src/services/tuwunel.ts +91 -0
- package/src/web/resolve.test.ts +50 -0
- package/src/web/resolve.ts +31 -0
- package/src/web/static.test.ts +51 -0
- package/src/web/static.ts +61 -0
- package/src/web/watch.ts +103 -0
- package/dist/chunk-67ZRMVHO.js +0 -174
- package/dist/chunk-AR456MHY.js +0 -29
- package/dist/chunk-VBGU2NST.js +0 -139
- package/dist/client-4VMFEFDX.js +0 -22
- package/dist/config-2KK5GX42.js +0 -27
- 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
|
+
})
|