zooid 0.6.1 → 0.7.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.
Files changed (78) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +36 -444
  3. package/dist/bin.js +1797 -0
  4. package/dist/bin.js.map +1 -0
  5. package/dist/chunk-N5POSZX5.js +31000 -0
  6. package/dist/chunk-N5POSZX5.js.map +1 -0
  7. package/dist/index.d.ts +40 -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-BT_v3DKu.js +295 -0
  14. package/dist/web/assets/index-DJTghnF-.css +1 -0
  15. package/dist/web/assets/index-JZMMlqDP.js +118066 -0
  16. package/dist/web/assets/reaction-picker-emoji-Kh7emgFa.js +716 -0
  17. package/dist/web/favicon.svg +1 -0
  18. package/dist/web/index.html +14 -0
  19. package/package.json +42 -22
  20. package/src/bin.ts +112 -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 +142 -0
  36. package/src/build-registry.zod043.test.ts +64 -0
  37. package/src/commands/dev-cascade.test.ts +38 -0
  38. package/src/commands/dev-cascade.ts +35 -0
  39. package/src/commands/dev-data-layout.test.ts +55 -0
  40. package/src/commands/dev.ts +308 -0
  41. package/src/commands/init/generators.test.ts +97 -0
  42. package/src/commands/init/generators.ts +119 -0
  43. package/src/commands/init/prompts.ts +123 -0
  44. package/src/commands/init/registry.test.ts +33 -0
  45. package/src/commands/init/registry.ts +75 -0
  46. package/src/commands/init/sniff.test.ts +41 -0
  47. package/src/commands/init/sniff.ts +30 -0
  48. package/src/commands/init.test.ts +131 -0
  49. package/src/commands/init.ts +158 -0
  50. package/src/commands/logs.test.ts +105 -0
  51. package/src/commands/logs.ts +108 -0
  52. package/src/commands/start.ts +28 -0
  53. package/src/commands/status.test.ts +73 -0
  54. package/src/commands/status.ts +100 -0
  55. package/src/daemon/start-daemon.test.ts +67 -0
  56. package/src/daemon/start-daemon.ts +243 -0
  57. package/src/index.ts +2 -0
  58. package/src/observability/capture-agent.ts +58 -0
  59. package/src/observability/capture-tuwunel.test.ts +57 -0
  60. package/src/observability/capture-tuwunel.ts +24 -0
  61. package/src/observability/file-sink.test.ts +68 -0
  62. package/src/observability/file-sink.ts +78 -0
  63. package/src/observability/observability.integration.test.ts +162 -0
  64. package/src/observability/paths.test.ts +104 -0
  65. package/src/observability/paths.ts +94 -0
  66. package/src/services/tuwunel.test.ts +64 -0
  67. package/src/services/tuwunel.ts +91 -0
  68. package/src/web/resolve.test.ts +50 -0
  69. package/src/web/resolve.ts +31 -0
  70. package/src/web/static.test.ts +51 -0
  71. package/src/web/static.ts +61 -0
  72. package/src/web/watch.ts +103 -0
  73. package/dist/chunk-67ZRMVHO.js +0 -174
  74. package/dist/chunk-AR456MHY.js +0 -29
  75. package/dist/chunk-VBGU2NST.js +0 -139
  76. package/dist/client-4VMFEFDX.js +0 -22
  77. package/dist/config-2KK5GX42.js +0 -27
  78. 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,64 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { buildRunArgs } from './tuwunel.js'
3
+
4
+ describe('buildRunArgs', () => {
5
+ const base = {
6
+ name: 'zooid-tuwunel',
7
+ image: 'ghcr.io/matrix-construct/tuwunel:latest',
8
+ hostPort: 8448,
9
+ paths: {
10
+ dataDir: '/abs/data/matrix',
11
+ dbDir: '/abs/data/matrix/db',
12
+ mediaDir: '/abs/data/matrix/media',
13
+ configDir: '/abs/data/matrix/config',
14
+ registrationsDir: '/abs/data/matrix/config/registrations',
15
+ tuwunelTomlPath: '/abs/data/matrix/config/tuwunel.toml',
16
+ appserviceYamlPath: '/abs/data/matrix/config/registrations/zooid.yaml',
17
+ envPath: '/abs/data/matrix/config/.env',
18
+ },
19
+ }
20
+
21
+ it('uses docker by default, mounts persistent volumes and config read-only', () => {
22
+ const args = buildRunArgs({ ...base, engine: 'docker' })
23
+ expect(args[0]).toBe('run')
24
+ expect(args).toContain('--rm')
25
+ // Foregrounded so the parent process owns Tuwunel's stdio (logs).
26
+ expect(args).not.toContain('-d')
27
+ expect(args).toContain('--name')
28
+ expect(args).toContain('zooid-tuwunel')
29
+ expect(args).toContain('-p')
30
+ expect(args).toContain('8448:8448')
31
+ expect(args).toContain('/abs/data/matrix/db:/var/lib/tuwunel/db')
32
+ expect(args).toContain('/abs/data/matrix/media:/var/lib/tuwunel/media')
33
+ expect(args).toContain(
34
+ '/abs/data/matrix/config/tuwunel.toml:/etc/tuwunel/tuwunel.toml:ro',
35
+ )
36
+ expect(args).toContain(
37
+ '/abs/data/matrix/config/registrations:/var/lib/tuwunel/registrations:ro',
38
+ )
39
+ // ZOD041 deliberately does NOT mount ${dataRoot}/logs into the container.
40
+ // Today the daemon captures tuwunel's stdout/stderr via captureChildToFile
41
+ // — there's no internal tuwunel log directive yet. A future cycle that flips
42
+ // tuwunel.toml to write its own logs can add the mount alongside that change.
43
+ expect(args.some((a) => a.endsWith(':/var/log/tuwunel'))).toBe(false)
44
+ expect(args).toContain('TUWUNEL_CONFIG=/etc/tuwunel/tuwunel.toml')
45
+ expect(args[args.length - 1]).toBe(base.image)
46
+ })
47
+
48
+ it('engine: podman switches the binary (caller chooses), args are identical', () => {
49
+ const docker = buildRunArgs({ ...base, engine: 'docker' })
50
+ const podman = buildRunArgs({ ...base, engine: 'podman' })
51
+ expect(podman).toEqual(docker)
52
+ })
53
+
54
+ it('honors a custom container name and host port', () => {
55
+ const args = buildRunArgs({
56
+ ...base,
57
+ name: 'my-zooid-tuwunel',
58
+ hostPort: 9000,
59
+ engine: 'docker',
60
+ })
61
+ expect(args).toContain('my-zooid-tuwunel')
62
+ expect(args).toContain('9000:8448')
63
+ })
64
+ })
@@ -0,0 +1,91 @@
1
+ import { spawn, type ChildProcess } from 'node:child_process'
2
+ import type { Paths } from '../bootstrap/paths.js'
3
+
4
+ export interface TuwunelOpts {
5
+ name: string
6
+ image?: string
7
+ hostPort: number
8
+ paths: Paths
9
+ engine: 'docker' | 'podman'
10
+ }
11
+
12
+ const DEFAULT_IMAGE = 'ghcr.io/matrix-construct/tuwunel:latest'
13
+
14
+ export function buildRunArgs(opts: TuwunelOpts): string[] {
15
+ const image = opts.image ?? DEFAULT_IMAGE
16
+ return [
17
+ 'run',
18
+ '--rm',
19
+ '--name',
20
+ opts.name,
21
+ '-p',
22
+ `${opts.hostPort}:8448`,
23
+ '-v',
24
+ `${opts.paths.dbDir}:/var/lib/tuwunel/db`,
25
+ '-v',
26
+ `${opts.paths.mediaDir}:/var/lib/tuwunel/media`,
27
+ '-v',
28
+ `${opts.paths.tuwunelTomlPath}:/etc/tuwunel/tuwunel.toml:ro`,
29
+ '-v',
30
+ `${opts.paths.registrationsDir}:/var/lib/tuwunel/registrations:ro`,
31
+ '-e',
32
+ 'TUWUNEL_CONFIG=/etc/tuwunel/tuwunel.toml',
33
+ image,
34
+ ]
35
+ }
36
+
37
+ export class TuwunelService {
38
+ private child: ChildProcess | null = null
39
+ constructor(private readonly opts: TuwunelOpts) {}
40
+
41
+ start(): ChildProcess {
42
+ this.child = spawn(this.opts.engine, buildRunArgs(this.opts), {
43
+ stdio: ['ignore', 'pipe', 'pipe'],
44
+ })
45
+ return this.child
46
+ }
47
+
48
+ async stop(): Promise<void> {
49
+ // Foregrounded container: kill the engine process; --rm cleans up after.
50
+ if (this.child && this.child.exitCode === null) {
51
+ this.child.kill('SIGTERM')
52
+ await new Promise<void>((resolve) => {
53
+ this.child!.on('exit', () => resolve())
54
+ })
55
+ }
56
+ this.child = null
57
+ // Defensive: the parent may have died while the container was running, in
58
+ // which case --rm doesn't fire. `<engine> stop` is a no-op if it's already
59
+ // gone (errors are swallowed).
60
+ await execEngine(this.opts.engine, ['stop', this.opts.name]).catch(() => {})
61
+ }
62
+
63
+ async waitHealthy(opts: { url: string; timeoutMs: number }): Promise<void> {
64
+ const deadline = Date.now() + opts.timeoutMs
65
+ while (Date.now() < deadline) {
66
+ try {
67
+ const r = await fetch(`${opts.url}/_matrix/client/versions`)
68
+ if (r.ok) return
69
+ } catch {
70
+ // not yet
71
+ }
72
+ await new Promise((resolve) => setTimeout(resolve, 500))
73
+ }
74
+ throw new Error(
75
+ `Tuwunel did not become healthy at ${opts.url} in ${opts.timeoutMs}ms`,
76
+ )
77
+ }
78
+ }
79
+
80
+ function execEngine(engine: string, args: string[]): Promise<void> {
81
+ return new Promise((resolve, reject) => {
82
+ const child = spawn(engine, args, { stdio: 'pipe' })
83
+ let stderr = ''
84
+ child.stderr.on('data', (b) => (stderr += String(b)))
85
+ child.on('exit', (code) => {
86
+ if (code === 0) resolve()
87
+ else reject(new Error(`${engine} ${args.join(' ')} failed: ${stderr.trim()}`))
88
+ })
89
+ child.on('error', reject)
90
+ })
91
+ }