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.
- package/LICENSE +1 -1
- package/README.md +36 -444
- package/dist/bin.js +1797 -0
- package/dist/bin.js.map +1 -0
- package/dist/chunk-N5POSZX5.js +31000 -0
- package/dist/chunk-N5POSZX5.js.map +1 -0
- package/dist/index.d.ts +40 -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-BT_v3DKu.js +295 -0
- package/dist/web/assets/index-DJTghnF-.css +1 -0
- package/dist/web/assets/index-JZMMlqDP.js +118066 -0
- package/dist/web/assets/reaction-picker-emoji-Kh7emgFa.js +716 -0
- package/dist/web/favicon.svg +1 -0
- package/dist/web/index.html +14 -0
- package/package.json +42 -22
- package/src/bin.ts +112 -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 +142 -0
- package/src/build-registry.zod043.test.ts +64 -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 +308 -0
- package/src/commands/init/generators.test.ts +97 -0
- package/src/commands/init/generators.ts +119 -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 +100 -0
- package/src/daemon/start-daemon.test.ts +67 -0
- package/src/daemon/start-daemon.ts +243 -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/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,43 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto'
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { dirname } from 'node:path'
|
|
4
|
+
|
|
5
|
+
export interface Tokens {
|
|
6
|
+
asToken: string
|
|
7
|
+
hsToken: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const AS_RE = /^MATRIX_AS_TOKEN=(.+)$/m
|
|
11
|
+
const HS_RE = /^MATRIX_HS_TOKEN=(.+)$/m
|
|
12
|
+
|
|
13
|
+
function genToken(prefix: string): string {
|
|
14
|
+
return `${prefix}-${randomBytes(24).toString('hex')}`
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function readTokens(envPath: string): Tokens | null {
|
|
18
|
+
if (!existsSync(envPath)) return null
|
|
19
|
+
const text = readFileSync(envPath, 'utf8')
|
|
20
|
+
const as = text.match(AS_RE)?.[1]
|
|
21
|
+
const hs = text.match(HS_RE)?.[1]
|
|
22
|
+
if (!as && !hs) return null
|
|
23
|
+
if (!as || !hs) {
|
|
24
|
+
throw new Error(
|
|
25
|
+
`${envPath}: missing MATRIX_AS_TOKEN or MATRIX_HS_TOKEN. Delete the file to regenerate, or restore the missing value.`,
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
return { asToken: as, hsToken: hs }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function ensureTokens(envPath: string): Tokens {
|
|
32
|
+
const existing = readTokens(envPath)
|
|
33
|
+
if (existing) return existing
|
|
34
|
+
const tokens: Tokens = { asToken: genToken('as'), hsToken: genToken('hs') }
|
|
35
|
+
mkdirSync(dirname(envPath), { recursive: true })
|
|
36
|
+
const previous = existsSync(envPath) ? readFileSync(envPath, 'utf8') : ''
|
|
37
|
+
const next =
|
|
38
|
+
previous.trimEnd() +
|
|
39
|
+
(previous ? '\n' : '') +
|
|
40
|
+
`MATRIX_AS_TOKEN=${tokens.asToken}\nMATRIX_HS_TOKEN=${tokens.hsToken}\n`
|
|
41
|
+
writeFileSync(envPath, next, { mode: 0o600 })
|
|
42
|
+
return tokens
|
|
43
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
2
|
+
import { buildAcpRegistry } from './build-registry.js'
|
|
3
|
+
import type { ZooidConfig } from '@zooid/core'
|
|
4
|
+
|
|
5
|
+
const matrixCfg: ZooidConfig = {
|
|
6
|
+
runtime: 'local',
|
|
7
|
+
transports: {
|
|
8
|
+
mx: {
|
|
9
|
+
type: 'matrix',
|
|
10
|
+
homeserver: 'https://hs',
|
|
11
|
+
as_token: 'as',
|
|
12
|
+
hs_token: 'hs',
|
|
13
|
+
user_namespace: '@.*:hs',
|
|
14
|
+
sender_localpart: '_zooid',
|
|
15
|
+
space: 'dev',
|
|
16
|
+
port: 0,
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
agents: {
|
|
20
|
+
architect: {
|
|
21
|
+
name: 'architect',
|
|
22
|
+
workdir: '/tmp',
|
|
23
|
+
hooks: {},
|
|
24
|
+
acp: { command: 'fake', args: [] },
|
|
25
|
+
approval_timeout_ms: 0,
|
|
26
|
+
matrix: {
|
|
27
|
+
transport: 'mx',
|
|
28
|
+
user_id: '@architect:hs',
|
|
29
|
+
rooms: ['!r:hs'],
|
|
30
|
+
trigger: 'mention',
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
hooks: {},
|
|
35
|
+
} as unknown as ZooidConfig
|
|
36
|
+
|
|
37
|
+
describe('buildAcpRegistry — context provider wiring', () => {
|
|
38
|
+
it('attaches a Matrix-backed context provider to agents bound to a matrix transport', () => {
|
|
39
|
+
const registry = buildAcpRegistry(matrixCfg, {
|
|
40
|
+
approvals: { register: vi.fn(), on: vi.fn() } as never,
|
|
41
|
+
contextSpawnRegistry: {} as never,
|
|
42
|
+
daemonSockPath: '/tmp/zooid-test.sock',
|
|
43
|
+
})
|
|
44
|
+
expect(registry.hasContextSpawn('architect')).toBe(true)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('does not attach a context provider to http-bound agents', () => {
|
|
48
|
+
const httpCfg = structuredClone(matrixCfg)
|
|
49
|
+
httpCfg.transports = {
|
|
50
|
+
h: { type: 'http', port: 0 } as never,
|
|
51
|
+
}
|
|
52
|
+
httpCfg.agents.architect.matrix = undefined as never
|
|
53
|
+
;(httpCfg.agents.architect as { http?: unknown }).http = { transport: 'h' }
|
|
54
|
+
const registry = buildAcpRegistry(httpCfg, {
|
|
55
|
+
approvals: { register: vi.fn(), on: vi.fn() } as never,
|
|
56
|
+
contextSpawnRegistry: {} as never,
|
|
57
|
+
daemonSockPath: '/tmp/zooid-test.sock',
|
|
58
|
+
})
|
|
59
|
+
expect(registry.hasContextSpawn('architect')).toBe(false)
|
|
60
|
+
})
|
|
61
|
+
})
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { LocalAcpRuntime } from '@zooid/runtime-local'
|
|
3
|
+
import { DockerAcpRuntime } from '@zooid/runtime-docker'
|
|
4
|
+
import type { ZooidConfig } from '@zooid/core'
|
|
5
|
+
import { buildAcpRegistry } from './build-registry.js'
|
|
6
|
+
|
|
7
|
+
function cfg(overrides: Partial<ZooidConfig> & Pick<ZooidConfig, 'runtime'>): ZooidConfig {
|
|
8
|
+
return {
|
|
9
|
+
runtime: overrides.runtime,
|
|
10
|
+
transports: overrides.transports ?? { 'http-local': { type: 'http', port: 8080 } },
|
|
11
|
+
agents: overrides.agents ?? {
|
|
12
|
+
a: {
|
|
13
|
+
name: 'a',
|
|
14
|
+
workdir: '.',
|
|
15
|
+
hooks: {},
|
|
16
|
+
acp: { preset: 'claude' },
|
|
17
|
+
approval_timeout_ms: 0,
|
|
18
|
+
http: { transport: 'http-local' },
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
hooks: {},
|
|
22
|
+
container: overrides.container,
|
|
23
|
+
} as ZooidConfig
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe('buildAcpRegistry', () => {
|
|
27
|
+
it('constructs with LocalAcpRuntime for runtime: local', () => {
|
|
28
|
+
const reg = buildAcpRegistry(cfg({ runtime: 'local' }))
|
|
29
|
+
expect((reg as unknown as { opts: { runtime: unknown } }).opts.runtime).toBeInstanceOf(LocalAcpRuntime)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('constructs with DockerAcpRuntime (docker engine) for runtime: docker', () => {
|
|
33
|
+
const reg = buildAcpRegistry(
|
|
34
|
+
cfg({ runtime: 'docker', container: { image: 'img:latest' } }),
|
|
35
|
+
)
|
|
36
|
+
const rt = (reg as unknown as { opts: { runtime: DockerAcpRuntime } }).opts.runtime
|
|
37
|
+
expect(rt).toBeInstanceOf(DockerAcpRuntime)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('constructs with DockerAcpRuntime (podman engine) for runtime: podman', () => {
|
|
41
|
+
const reg = buildAcpRegistry(
|
|
42
|
+
cfg({ runtime: 'podman', container: { image: 'img:latest' } }),
|
|
43
|
+
)
|
|
44
|
+
const rt = (reg as unknown as { opts: { runtime: DockerAcpRuntime } }).opts.runtime
|
|
45
|
+
expect(rt).toBeInstanceOf(DockerAcpRuntime)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('throws if any agent has no acp block (defense in depth)', () => {
|
|
49
|
+
const c: ZooidConfig = {
|
|
50
|
+
runtime: 'local',
|
|
51
|
+
transports: { 'http-local': { type: 'http', port: 8080 } },
|
|
52
|
+
agents: {
|
|
53
|
+
a: { name: 'a', workdir: '.', hooks: {}, http: { transport: 'http-local' } } as never,
|
|
54
|
+
},
|
|
55
|
+
hooks: {},
|
|
56
|
+
}
|
|
57
|
+
expect(() => buildAcpRegistry(c)).toThrow(/agents\.a.*acp/i)
|
|
58
|
+
})
|
|
59
|
+
})
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { LocalAcpRuntime } from '@zooid/runtime-local'
|
|
2
|
+
import { DockerAcpRuntime } from '@zooid/runtime-docker'
|
|
3
|
+
import {
|
|
4
|
+
AcpAgentRegistry,
|
|
5
|
+
type AcpRuntime,
|
|
6
|
+
type ApprovalCorrelator,
|
|
7
|
+
type ContextSpawnFactory,
|
|
8
|
+
type TapEvent,
|
|
9
|
+
type ZooidConfig,
|
|
10
|
+
type TransportContextProvider,
|
|
11
|
+
} from '@zooid/core'
|
|
12
|
+
import { MatrixClient, MatrixContextProvider } from '@zooid/transport-matrix'
|
|
13
|
+
import { SpawnRegistry, buildContextServerSpec } from '@zooid/context-mcp'
|
|
14
|
+
|
|
15
|
+
export interface BuildAcpRegistryOptions {
|
|
16
|
+
/** Override the runtime selection (tests). */
|
|
17
|
+
runtime?: AcpRuntime
|
|
18
|
+
/** When set, the registry's approval handler routes through this correlator. */
|
|
19
|
+
approvals?: ApprovalCorrelator
|
|
20
|
+
/** Observability tap forwarded to each AcpClient. */
|
|
21
|
+
onTap?: (agentName: string, event: TapEvent) => void
|
|
22
|
+
/**
|
|
23
|
+
* Per-agent state root (`<dataRoot>/agents/`). When set, each AcpClient
|
|
24
|
+
* persists its `(threadId → sessionId)` map under
|
|
25
|
+
* `<agentsDir>/<agentName>/sessions.json` so threads survive daemon restarts.
|
|
26
|
+
*/
|
|
27
|
+
agentsDir?: string
|
|
28
|
+
/**
|
|
29
|
+
* Per-spawn binding store for the zooid-context MCP server. When set
|
|
30
|
+
* together with `daemonSockPath`, agents bound to a transport that owns
|
|
31
|
+
* conversation context get a `contextSpawn` factory threaded into their
|
|
32
|
+
* AcpClient so `session/new mcpServers` includes the zooid-context entry.
|
|
33
|
+
*/
|
|
34
|
+
contextSpawnRegistry?: SpawnRegistry
|
|
35
|
+
/** Path to the daemon's context Unix socket. Passed to the MCP server bin. */
|
|
36
|
+
daemonSockPath?: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Build an `AcpAgentRegistry` from a parsed workforce config.
|
|
41
|
+
*
|
|
42
|
+
* - `runtime: local` → `LocalAcpRuntime`
|
|
43
|
+
* - `runtime: docker` → `DockerAcpRuntime` (engine: docker)
|
|
44
|
+
* - `runtime: podman` → `DockerAcpRuntime` (engine: podman)
|
|
45
|
+
*
|
|
46
|
+
* Per-agent `container.env` is passed through to each `AcpClient`'s spawn
|
|
47
|
+
* spec verbatim (interpolation happens at parse time in `@zooid/core`).
|
|
48
|
+
* The image is resolved as `agent.container?.image ?? cfg.container?.image`.
|
|
49
|
+
*/
|
|
50
|
+
export function buildAcpRegistry(
|
|
51
|
+
cfg: ZooidConfig,
|
|
52
|
+
opts: BuildAcpRegistryOptions = {},
|
|
53
|
+
): AcpAgentRegistry {
|
|
54
|
+
for (const [name, agent] of Object.entries(cfg.agents)) {
|
|
55
|
+
if (!agent.acp) {
|
|
56
|
+
throw new Error(`agents.${name}: missing acp block (parser should have caught this)`)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const runtime = opts.runtime ?? defaultRuntimeFor(cfg)
|
|
61
|
+
const env: Record<string, Record<string, string>> = {}
|
|
62
|
+
const image: Record<string, string | undefined> = {}
|
|
63
|
+
for (const [name, agent] of Object.entries(cfg.agents)) {
|
|
64
|
+
env[name] = agent.container?.env ?? {}
|
|
65
|
+
image[name] = agent.container?.image ?? cfg.container?.image
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const contextSpawns = buildContextSpawns(cfg, opts)
|
|
69
|
+
|
|
70
|
+
return new AcpAgentRegistry({
|
|
71
|
+
runtime,
|
|
72
|
+
agents: cfg.agents,
|
|
73
|
+
env,
|
|
74
|
+
image,
|
|
75
|
+
approvals: opts.approvals,
|
|
76
|
+
onTap: opts.onTap,
|
|
77
|
+
agentsDir: opts.agentsDir,
|
|
78
|
+
contextSpawns,
|
|
79
|
+
})
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function buildContextSpawns(
|
|
83
|
+
cfg: ZooidConfig,
|
|
84
|
+
opts: BuildAcpRegistryOptions,
|
|
85
|
+
): Record<string, ContextSpawnFactory | undefined> | undefined {
|
|
86
|
+
if (!opts.contextSpawnRegistry || !opts.daemonSockPath) return undefined
|
|
87
|
+
const registry = opts.contextSpawnRegistry
|
|
88
|
+
const sockPath = opts.daemonSockPath
|
|
89
|
+
|
|
90
|
+
// Share one MatrixClient per matrix transport (only state is homeserver +
|
|
91
|
+
// asToken). Per-agent providers wrap that client with the agent's own
|
|
92
|
+
// user_id as asUserId — when the AS impersonates the agent (via ?user_id=)
|
|
93
|
+
// it sees the rooms that bot is a member of. The AS sender_localpart
|
|
94
|
+
// typically isn't in those rooms, so impersonating it returns empty
|
|
95
|
+
// chunks.
|
|
96
|
+
const matrixClients = new Map<string, MatrixClient>()
|
|
97
|
+
const agentBots = new Map<string, string>()
|
|
98
|
+
for (const [name, agent] of Object.entries(cfg.agents)) {
|
|
99
|
+
if (agent.matrix?.user_id) agentBots.set(agent.matrix.user_id, name)
|
|
100
|
+
}
|
|
101
|
+
for (const [tname, tcfg] of Object.entries(cfg.transports)) {
|
|
102
|
+
if (tcfg.type !== 'matrix') continue
|
|
103
|
+
matrixClients.set(
|
|
104
|
+
tname,
|
|
105
|
+
new MatrixClient({ homeserver: tcfg.homeserver, asToken: tcfg.as_token }),
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const result: Record<string, ContextSpawnFactory | undefined> = {}
|
|
110
|
+
for (const [name, agent] of Object.entries(cfg.agents)) {
|
|
111
|
+
if (agent.matrix && matrixClients.has(agent.matrix.transport)) {
|
|
112
|
+
const client = matrixClients.get(agent.matrix.transport)!
|
|
113
|
+
const provider: TransportContextProvider = new MatrixContextProvider({
|
|
114
|
+
client,
|
|
115
|
+
asUserId: agent.matrix.user_id,
|
|
116
|
+
agentBots,
|
|
117
|
+
})
|
|
118
|
+
result[name] = async (threadId: string, channelId?: string) => {
|
|
119
|
+
const spawnId = registry.register({
|
|
120
|
+
agentName: name,
|
|
121
|
+
threadRef: { channelId: channelId ?? threadId, threadId },
|
|
122
|
+
provider,
|
|
123
|
+
})
|
|
124
|
+
return buildContextServerSpec({ spawnId, sockPath })
|
|
125
|
+
}
|
|
126
|
+
} else {
|
|
127
|
+
result[name] = undefined
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return result
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function defaultRuntimeFor(cfg: ZooidConfig): AcpRuntime {
|
|
134
|
+
if (cfg.runtime === 'local') return new LocalAcpRuntime()
|
|
135
|
+
if (cfg.runtime === 'docker') {
|
|
136
|
+
return new DockerAcpRuntime({ defaultImage: cfg.container?.image, engine: 'docker' })
|
|
137
|
+
}
|
|
138
|
+
if (cfg.runtime === 'podman') {
|
|
139
|
+
return new DockerAcpRuntime({ defaultImage: cfg.container?.image, engine: 'podman' })
|
|
140
|
+
}
|
|
141
|
+
throw new Error(`unsupported runtime: ${cfg.runtime}`)
|
|
142
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { buildAcpRegistry } from './build-registry.js'
|
|
3
|
+
import type { ZooidConfig } from '@zooid/core'
|
|
4
|
+
|
|
5
|
+
const baseTransports = {
|
|
6
|
+
m1: {
|
|
7
|
+
type: 'matrix' as const,
|
|
8
|
+
homeserver: 'http://localhost:8448',
|
|
9
|
+
as_token: 't',
|
|
10
|
+
hs_token: 'h',
|
|
11
|
+
sender_localpart: 'z',
|
|
12
|
+
user_namespace: '@.*:localhost',
|
|
13
|
+
},
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function makeCfg(over: Partial<ZooidConfig> = {}): ZooidConfig {
|
|
17
|
+
return {
|
|
18
|
+
runtime: 'docker',
|
|
19
|
+
container: { image: 'workforce-default:1' },
|
|
20
|
+
transports: baseTransports,
|
|
21
|
+
agents: {
|
|
22
|
+
alice: {
|
|
23
|
+
name: 'alice',
|
|
24
|
+
workdir: './alice',
|
|
25
|
+
hooks: {},
|
|
26
|
+
acp: { preset: 'claude' },
|
|
27
|
+
approval_timeout_ms: 0,
|
|
28
|
+
matrix: {
|
|
29
|
+
transport: 'm1',
|
|
30
|
+
user_id: '@alice:localhost',
|
|
31
|
+
rooms: ['!r:localhost'],
|
|
32
|
+
trigger: 'mention',
|
|
33
|
+
},
|
|
34
|
+
container: { env: { LOG_LEVEL: 'info' } },
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
hooks: {},
|
|
38
|
+
...over,
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe('buildAcpRegistry — ZOD043', () => {
|
|
43
|
+
it('passes agent.container.env through as the spawn env (no forward_env layer)', () => {
|
|
44
|
+
const cfg = makeCfg()
|
|
45
|
+
const reg = buildAcpRegistry(cfg)
|
|
46
|
+
expect(reg.resolveSpawnEnv('alice')).toEqual({ LOG_LEVEL: 'info' })
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('resolves image with per-agent override beating workforce default', () => {
|
|
50
|
+
const cfg = makeCfg()
|
|
51
|
+
cfg.agents.alice!.container = {
|
|
52
|
+
...cfg.agents.alice!.container,
|
|
53
|
+
image: 'alice:2',
|
|
54
|
+
}
|
|
55
|
+
const reg = buildAcpRegistry(cfg)
|
|
56
|
+
expect(reg.resolveSpawnImage('alice')).toBe('alice:2')
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('falls back to workforce container.image when no per-agent image', () => {
|
|
60
|
+
const cfg = makeCfg()
|
|
61
|
+
const reg = buildAcpRegistry(cfg)
|
|
62
|
+
expect(reg.resolveSpawnImage('alice')).toBe('workforce-default:1')
|
|
63
|
+
})
|
|
64
|
+
})
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { buildShutdown } from './dev-cascade.js'
|
|
3
|
+
|
|
4
|
+
describe('buildShutdown', () => {
|
|
5
|
+
it('stops in order: ui → daemon → tuwunel and is idempotent', async () => {
|
|
6
|
+
const calls: string[] = []
|
|
7
|
+
const stopUi = vi.fn(async () => {
|
|
8
|
+
calls.push('ui')
|
|
9
|
+
})
|
|
10
|
+
const stopDaemon = vi.fn(async () => {
|
|
11
|
+
calls.push('daemon')
|
|
12
|
+
})
|
|
13
|
+
const stopTuwunel = vi.fn(async () => {
|
|
14
|
+
calls.push('tuwunel')
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
const shutdown = buildShutdown({ stopUi, stopDaemon, stopTuwunel })
|
|
18
|
+
await shutdown()
|
|
19
|
+
expect(calls).toEqual(['ui', 'daemon', 'tuwunel'])
|
|
20
|
+
|
|
21
|
+
await shutdown()
|
|
22
|
+
expect(stopUi).toHaveBeenCalledTimes(1)
|
|
23
|
+
expect(stopDaemon).toHaveBeenCalledTimes(1)
|
|
24
|
+
expect(stopTuwunel).toHaveBeenCalledTimes(1)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('continues stopping later layers even if an earlier one throws', async () => {
|
|
28
|
+
const stopUi = vi.fn(async () => {
|
|
29
|
+
throw new Error('ui boom')
|
|
30
|
+
})
|
|
31
|
+
const stopDaemon = vi.fn(async () => {})
|
|
32
|
+
const stopTuwunel = vi.fn(async () => {})
|
|
33
|
+
const shutdown = buildShutdown({ stopUi, stopDaemon, stopTuwunel })
|
|
34
|
+
await shutdown()
|
|
35
|
+
expect(stopDaemon).toHaveBeenCalled()
|
|
36
|
+
expect(stopTuwunel).toHaveBeenCalled()
|
|
37
|
+
})
|
|
38
|
+
})
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export interface ShutdownLayers {
|
|
2
|
+
stopUi: () => Promise<void>
|
|
3
|
+
stopDaemon: () => Promise<void>
|
|
4
|
+
stopTuwunel: () => Promise<void>
|
|
5
|
+
stopWebWatch?: () => Promise<void>
|
|
6
|
+
/** Flush per-agent ACP taps before stopping the daemon. */
|
|
7
|
+
stopCaptures?: () => Promise<void>
|
|
8
|
+
/** Resolves once Tuwunel's captured stdio file is flushed. */
|
|
9
|
+
finalizeTuwunelCapture?: () => Promise<void>
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function buildShutdown(layers: ShutdownLayers): () => Promise<void> {
|
|
13
|
+
let started: Promise<void> | null = null
|
|
14
|
+
return () => {
|
|
15
|
+
if (started) return started
|
|
16
|
+
started = (async () => {
|
|
17
|
+
const order = [
|
|
18
|
+
'stopUi',
|
|
19
|
+
'stopWebWatch',
|
|
20
|
+
'stopCaptures',
|
|
21
|
+
'stopDaemon',
|
|
22
|
+
'stopTuwunel',
|
|
23
|
+
'finalizeTuwunelCapture',
|
|
24
|
+
] as const
|
|
25
|
+
for (const step of order) {
|
|
26
|
+
try {
|
|
27
|
+
await layers[step]?.()
|
|
28
|
+
} catch (err) {
|
|
29
|
+
console.error(`${step}:`, err)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
})()
|
|
33
|
+
return started
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, expect, it, beforeEach, afterEach } from 'vitest'
|
|
2
|
+
import { mkdtemp } from 'node:fs/promises'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { rmSync, existsSync } from 'node:fs'
|
|
6
|
+
import { resolveDataLayout } from '../bootstrap/data-layout.js'
|
|
7
|
+
import { resolvePaths } from '../bootstrap/paths.js'
|
|
8
|
+
import { writeBootstrapConfigs } from '../bootstrap/configs.js'
|
|
9
|
+
|
|
10
|
+
// This test exercises the *layout boundary*: given a data root, the bootstrap
|
|
11
|
+
// helpers write matrix-specific files under <root>/matrix/ and never elsewhere.
|
|
12
|
+
// It does not boot tuwunel.
|
|
13
|
+
|
|
14
|
+
describe('dev bootstrap honours the data-root layout', () => {
|
|
15
|
+
let root: string
|
|
16
|
+
beforeEach(async () => {
|
|
17
|
+
root = await mkdtemp(join(tmpdir(), 'zooid-dev-layout-'))
|
|
18
|
+
})
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
rmSync(root, { recursive: true, force: true })
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('writes tuwunel.toml + appservice.yaml under <root>/matrix/, agents/ stays empty', () => {
|
|
24
|
+
const layout = resolveDataLayout(root)
|
|
25
|
+
const paths = resolvePaths(layout.matrixDir)
|
|
26
|
+
writeBootstrapConfigs({
|
|
27
|
+
paths,
|
|
28
|
+
serverName: 'localhost',
|
|
29
|
+
asToken: 'as-token',
|
|
30
|
+
hsToken: 'hs-token',
|
|
31
|
+
senderLocalpart: 'zooid',
|
|
32
|
+
userNamespace: '@.*:localhost',
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
// Matrix-side files exist where expected.
|
|
36
|
+
expect(existsSync(join(root, 'matrix', 'config', 'tuwunel.toml'))).toBe(true)
|
|
37
|
+
expect(
|
|
38
|
+
existsSync(join(root, 'matrix', 'config', 'registrations', 'zooid.yaml')),
|
|
39
|
+
).toBe(true)
|
|
40
|
+
expect(existsSync(join(root, 'matrix', 'db'))).toBe(true)
|
|
41
|
+
expect(existsSync(join(root, 'matrix', 'media'))).toBe(true)
|
|
42
|
+
|
|
43
|
+
// Crucially: nothing writes to <root>/matrix/logs or <root>/matrix/agents.
|
|
44
|
+
expect(existsSync(join(root, 'matrix', 'logs'))).toBe(false)
|
|
45
|
+
expect(existsSync(join(root, 'matrix', 'agents'))).toBe(false)
|
|
46
|
+
|
|
47
|
+
// agents/ is reserved for ZOD028 and not pre-created by bootstrap.
|
|
48
|
+
expect(existsSync(join(root, 'agents'))).toBe(false)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('agentDir(id) resolves a deterministic path even before the dir exists', () => {
|
|
52
|
+
const layout = resolveDataLayout(root)
|
|
53
|
+
expect(layout.agentDir('docs')).toBe(join(root, 'agents', 'docs'))
|
|
54
|
+
})
|
|
55
|
+
})
|