zooid 0.7.0 → 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.
@@ -1,4 +1,5 @@
1
1
  import { readFileSync } from 'node:fs'
2
+ import { dirname } from 'node:path'
2
3
  import chalk from 'chalk'
3
4
  import { findConfigFile, findMatrixTransport, loadZooidConfig } from '@zooid/core'
4
5
  import { deriveHomeserverShape } from '../bootstrap/derive.js'
@@ -43,7 +44,9 @@ export async function collectStatus(opts: {
43
44
  agents: [],
44
45
  }
45
46
  }
46
- const cfg = loadZooidConfig(readFileSync(found.path, 'utf8'))
47
+ const cfg = loadZooidConfig(readFileSync(found.path, 'utf8'), {
48
+ configDir: dirname(found.path),
49
+ })
47
50
  const matrixEntry = Object.entries(cfg.transports).find(
48
51
  ([, t]) => t.type === 'matrix',
49
52
  ) as [string, { port?: number }] | undefined
@@ -73,7 +76,9 @@ export async function runStatus(flags: StatusFlags): Promise<void> {
73
76
  if (port === undefined) {
74
77
  const found = findConfigFile(cwd)
75
78
  if (found) {
76
- const cfg = loadZooidConfig(readFileSync(found.path, 'utf8'))
79
+ const cfg = loadZooidConfig(readFileSync(found.path, 'utf8'), {
80
+ configDir: dirname(found.path),
81
+ })
77
82
  const matrix = findMatrixTransport(cfg)
78
83
  if (matrix) {
79
84
  const userIds = Object.values(cfg.agents)
@@ -29,6 +29,7 @@ import {
29
29
  type DaemonSocketHandle,
30
30
  } from '@zooid/context-mcp'
31
31
  import { buildAcpRegistry } from '../build-registry.js'
32
+ import { prepullImages } from '../prepull-images.js'
32
33
 
33
34
  export interface StartDaemonOpts {
34
35
  configPath?: string
@@ -45,6 +46,17 @@ export interface StartDaemonOpts {
45
46
  * daemon restarts.
46
47
  */
47
48
  agentsDir?: string
49
+ /** Skip the startup image-prepull pass entirely. */
50
+ noPrepull?: boolean
51
+ /** Force a re-pull of every resolved image at startup (no inspect check). */
52
+ refreshImages?: boolean
53
+ /**
54
+ * Per-line progress hook for the image-prepull pass. Called once per
55
+ * top-level summary plus once per image (start + completion). Used by
56
+ * `zooid dev` to surface pull progress under the "Start daemon" listr
57
+ * task instead of leaving the user staring at an unmoving spinner.
58
+ */
59
+ prepullLog?: (line: string) => void
48
60
  }
49
61
 
50
62
  export interface DaemonHandle {
@@ -75,7 +87,8 @@ export async function startDaemon(opts: StartDaemonOpts = {}): Promise<DaemonHan
75
87
  const cwd = opts.cwd ?? process.cwd()
76
88
  const found = opts.configPath ? { path: opts.configPath } : findConfigFile(cwd)
77
89
  if (!found) throw new Error('zooid.yaml is required')
78
- const base = loadZooidConfig(readFileSync(found.path, 'utf8'))
90
+ const configDir = dirname(found.path)
91
+ const base = loadZooidConfig(readFileSync(found.path, 'utf8'), { configDir })
79
92
  const config = mergeCliFlags(base, opts.cliFlags ?? {})
80
93
 
81
94
  const approvals = new ApprovalCorrelator()
@@ -95,15 +108,29 @@ export async function startDaemon(opts: StartDaemonOpts = {}): Promise<DaemonHan
95
108
  console.warn('[context] daemon socket startup failed; zooid-context MCP disabled:', err)
96
109
  }
97
110
 
111
+ const dataDir = opts.agentsDir ? dirname(opts.agentsDir) : undefined
98
112
  const registry = buildAcpRegistry(config, {
99
113
  approvals,
100
114
  onTap: opts.onTap,
101
115
  agentsDir: opts.agentsDir,
102
116
  contextSpawnRegistry: contextSocket ? contextSpawnRegistry : undefined,
103
117
  daemonSockPath: contextSocket ? daemonSockPath : undefined,
118
+ configDir,
119
+ dataDir,
120
+ daemonHome: process.env.HOME,
104
121
  })
105
122
  const agentNames = Object.keys(config.agents)
106
123
 
124
+ if (config.runtime !== 'local') {
125
+ await prepullImages(registry, {
126
+ engine: config.runtime === 'podman' ? 'podman' : 'docker',
127
+ runtime: config.runtime,
128
+ skip: opts.noPrepull ?? process.env.ZOOID_NO_PREPULL === '1',
129
+ refresh: opts.refreshImages ?? process.env.ZOOID_REFRESH_IMAGES === '1',
130
+ log: opts.prepullLog,
131
+ })
132
+ }
133
+
107
134
  console.log(
108
135
  `[context] socket=${daemonSockPath} ` +
109
136
  `status=${contextSocket ? 'listening' : 'disabled'} ` +
@@ -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
+ })
@@ -0,0 +1,100 @@
1
+ import { execFile } from 'node:child_process'
2
+ import { promisify } from 'node:util'
3
+
4
+ const pExecFile = promisify(execFile)
5
+
6
+ export interface PrepullExec {
7
+ (cmd: string, args: string[]): Promise<{ code: number; stdout: string; stderr: string }>
8
+ }
9
+
10
+ export interface PrepullOptions {
11
+ engine: 'docker' | 'podman'
12
+ runtime: 'local' | 'docker' | 'podman'
13
+ /** Injectable for tests. Defaults to a child_process.execFile-backed exec. */
14
+ exec?: PrepullExec
15
+ /** When true, skip the inspect check and pull every image. */
16
+ refresh?: boolean
17
+ /** When true, the function returns immediately without touching the engine. */
18
+ skip?: boolean
19
+ log?: (line: string) => void
20
+ }
21
+
22
+ export interface PrepullableRegistry {
23
+ agentNames(): string[]
24
+ resolveSpawnImage(name: string): string | undefined
25
+ }
26
+
27
+ /**
28
+ * Walk the registry's unique resolved-image set, `<engine> image inspect`
29
+ * each one, and `<engine> pull` the ones not cached locally. Parallel and
30
+ * fail-fast: any pull exit ≠ 0 throws with the engine's stderr embedded.
31
+ * No-op when `opts.skip` or `opts.runtime === 'local'`.
32
+ */
33
+ export async function prepullImages(
34
+ registry: PrepullableRegistry,
35
+ opts: PrepullOptions,
36
+ ): Promise<void> {
37
+ if (opts.skip || opts.runtime === 'local') return
38
+ const exec = opts.exec ?? defaultExec
39
+ const log = opts.log ?? ((l: string) => console.log(l))
40
+
41
+ const images = new Set<string>()
42
+ for (const name of registry.agentNames()) {
43
+ const img = registry.resolveSpawnImage(name)
44
+ if (img) images.add(img)
45
+ }
46
+
47
+ const toPull: string[] = []
48
+ let cached = 0
49
+ if (opts.refresh) {
50
+ toPull.push(...images)
51
+ } else {
52
+ await Promise.all(
53
+ [...images].map(async (img) => {
54
+ const r = await exec(opts.engine, ['image', 'inspect', img])
55
+ if (r.code === 0) cached++
56
+ else toPull.push(img)
57
+ }),
58
+ )
59
+ }
60
+
61
+ log(
62
+ `[zooid] image prepull: ${images.size} unique images (${cached} cached, ${toPull.length} to pull)`,
63
+ )
64
+ if (toPull.length === 0) return
65
+
66
+ await Promise.all(
67
+ toPull.map(async (img) => {
68
+ log(`[zooid] ${img} pulling…`)
69
+ const start = Date.now()
70
+ const r = await exec(opts.engine, ['pull', img])
71
+ const secs = ((Date.now() - start) / 1000).toFixed(1)
72
+ if (r.code !== 0) {
73
+ throw new Error(
74
+ `image prepull failed for ${img}:\n${r.stderr.trim() || '(no stderr)'}`,
75
+ )
76
+ }
77
+ log(`[zooid] ${img} ✓ ${secs}s`)
78
+ }),
79
+ )
80
+ }
81
+
82
+ const defaultExec: PrepullExec = async (cmd, args) => {
83
+ try {
84
+ const { stdout, stderr } = await pExecFile(cmd, args, {
85
+ maxBuffer: 16 * 1024 * 1024,
86
+ })
87
+ return { code: 0, stdout, stderr }
88
+ } catch (err) {
89
+ const e = err as NodeJS.ErrnoException & {
90
+ code?: number | string
91
+ stdout?: string
92
+ stderr?: string
93
+ }
94
+ return {
95
+ code: typeof e.code === 'number' ? e.code : 1,
96
+ stdout: e.stdout ?? '',
97
+ stderr: e.stderr ?? String(e),
98
+ }
99
+ }
100
+ }