zooid 0.6.0 → 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 -3788
- 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,73 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
5
|
+
import { collectStatus } from './status.js'
|
|
6
|
+
|
|
7
|
+
let dir: string
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
dir = mkdtempSync(join(tmpdir(), 'zooid-status-'))
|
|
10
|
+
})
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
rmSync(dir, { recursive: true, force: true })
|
|
13
|
+
vi.restoreAllMocks()
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
const yaml = [
|
|
17
|
+
'transports:',
|
|
18
|
+
' m:',
|
|
19
|
+
' type: matrix',
|
|
20
|
+
' homeserver: http://localhost:8448',
|
|
21
|
+
' as_token: as-x',
|
|
22
|
+
' hs_token: hs-x',
|
|
23
|
+
' sender_localpart: zooid',
|
|
24
|
+
' user_namespace: "@.*:localhost"',
|
|
25
|
+
' port: 9099',
|
|
26
|
+
'agents:',
|
|
27
|
+
' assistant:',
|
|
28
|
+
' workdir: .',
|
|
29
|
+
' acp: { preset: claude }',
|
|
30
|
+
' matrix:',
|
|
31
|
+
' transport: m',
|
|
32
|
+
' user_id: "@assistant:localhost"',
|
|
33
|
+
' rooms: ["!general:localhost"]',
|
|
34
|
+
' trigger: mention',
|
|
35
|
+
].join('\n')
|
|
36
|
+
|
|
37
|
+
describe('collectStatus', () => {
|
|
38
|
+
it('reports tuwunel up + daemon up + agents listed', async () => {
|
|
39
|
+
writeFileSync(join(dir, 'zooid.yaml'), yaml)
|
|
40
|
+
vi.stubGlobal(
|
|
41
|
+
'fetch',
|
|
42
|
+
vi.fn(async (url: string) => {
|
|
43
|
+
if (url.includes('/_matrix/client/versions')) {
|
|
44
|
+
return new Response(JSON.stringify({ versions: ['v1.13'] }), { status: 200 })
|
|
45
|
+
}
|
|
46
|
+
return new Response('not found', { status: 404 })
|
|
47
|
+
}),
|
|
48
|
+
)
|
|
49
|
+
const s = await collectStatus({ cwd: dir, tuwunelUrl: 'http://localhost:8448' })
|
|
50
|
+
expect(s.tuwunel).toEqual({ status: 'up', url: 'http://localhost:8448' })
|
|
51
|
+
expect(s.daemon).toEqual({ status: 'up', url: 'http://localhost:9099' })
|
|
52
|
+
expect(s.agents).toEqual([
|
|
53
|
+
{ name: 'assistant', userId: '@assistant:localhost', trigger: 'mention' },
|
|
54
|
+
])
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('reports daemon down when the AS callback port refuses the connection', async () => {
|
|
58
|
+
writeFileSync(join(dir, 'zooid.yaml'), yaml)
|
|
59
|
+
vi.stubGlobal(
|
|
60
|
+
'fetch',
|
|
61
|
+
vi.fn(async (url: string) => {
|
|
62
|
+
if (url.includes('/_matrix/client/versions')) {
|
|
63
|
+
return new Response(JSON.stringify({ versions: ['v1.13'] }), { status: 200 })
|
|
64
|
+
}
|
|
65
|
+
throw new Error('ECONNREFUSED')
|
|
66
|
+
}),
|
|
67
|
+
)
|
|
68
|
+
const s = await collectStatus({ cwd: dir, tuwunelUrl: 'http://localhost:8448' })
|
|
69
|
+
expect(s.tuwunel.status).toBe('up')
|
|
70
|
+
if ('status' in s.daemon) expect(s.daemon.status).toBe('down')
|
|
71
|
+
else throw new Error('expected daemon to have status field')
|
|
72
|
+
})
|
|
73
|
+
})
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import chalk from 'chalk'
|
|
3
|
+
import { findConfigFile, findMatrixTransport, loadZooidConfig } from '@zooid/core'
|
|
4
|
+
import { deriveHomeserverShape } from '../bootstrap/derive.js'
|
|
5
|
+
|
|
6
|
+
export interface StatusFlags {
|
|
7
|
+
cwd?: string
|
|
8
|
+
dataDir: string
|
|
9
|
+
/** Tuwunel host port. If omitted, derived from zooid.yaml's matrix transport. */
|
|
10
|
+
port?: number
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface StatusReport {
|
|
14
|
+
tuwunel: { status: 'up' | 'down'; url: string }
|
|
15
|
+
daemon: { status: 'up' | 'down'; url: string } | { status: 'unknown'; reason: string }
|
|
16
|
+
agents: { name: string; userId: string; trigger: string }[]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function probe(url: string, timeoutMs = 2_000): Promise<boolean> {
|
|
20
|
+
try {
|
|
21
|
+
const r = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) })
|
|
22
|
+
return r.status > 0
|
|
23
|
+
} catch {
|
|
24
|
+
return false
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function collectStatus(opts: {
|
|
29
|
+
cwd: string
|
|
30
|
+
tuwunelUrl: string
|
|
31
|
+
}): Promise<StatusReport> {
|
|
32
|
+
const tuwunelUp = await probe(`${opts.tuwunelUrl}/_matrix/client/versions`)
|
|
33
|
+
const tuwunel: StatusReport['tuwunel'] = {
|
|
34
|
+
status: tuwunelUp ? 'up' : 'down',
|
|
35
|
+
url: opts.tuwunelUrl,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const found = findConfigFile(opts.cwd)
|
|
39
|
+
if (!found) {
|
|
40
|
+
return {
|
|
41
|
+
tuwunel,
|
|
42
|
+
daemon: { status: 'unknown', reason: 'no zooid.yaml' },
|
|
43
|
+
agents: [],
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const cfg = loadZooidConfig(readFileSync(found.path, 'utf8'))
|
|
47
|
+
const matrixEntry = Object.entries(cfg.transports).find(
|
|
48
|
+
([, t]) => t.type === 'matrix',
|
|
49
|
+
) as [string, { port?: number }] | undefined
|
|
50
|
+
const daemonPort = matrixEntry?.[1]?.port ?? 8080
|
|
51
|
+
const daemonUrl = `http://localhost:${daemonPort}`
|
|
52
|
+
const daemonUp = await probe(daemonUrl)
|
|
53
|
+
const agents: StatusReport['agents'] = []
|
|
54
|
+
for (const a of Object.values(cfg.agents)) {
|
|
55
|
+
if (a.matrix) {
|
|
56
|
+
agents.push({
|
|
57
|
+
name: a.name,
|
|
58
|
+
userId: a.matrix.user_id,
|
|
59
|
+
trigger: a.matrix.trigger,
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
tuwunel,
|
|
65
|
+
daemon: { status: daemonUp ? 'up' : 'down', url: daemonUrl },
|
|
66
|
+
agents,
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function runStatus(flags: StatusFlags): Promise<void> {
|
|
71
|
+
const cwd = flags.cwd ?? process.cwd()
|
|
72
|
+
let port = flags.port
|
|
73
|
+
if (port === undefined) {
|
|
74
|
+
const found = findConfigFile(cwd)
|
|
75
|
+
if (found) {
|
|
76
|
+
const cfg = loadZooidConfig(readFileSync(found.path, 'utf8'))
|
|
77
|
+
const matrix = findMatrixTransport(cfg)
|
|
78
|
+
if (matrix) {
|
|
79
|
+
const userIds = Object.values(cfg.agents)
|
|
80
|
+
.filter((a) => a.matrix?.transport === matrix.name)
|
|
81
|
+
.map((a) => a.matrix!.user_id)
|
|
82
|
+
port = deriveHomeserverShape(matrix.transport, userIds).port
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const tuwunelUrl = `http://localhost:${port ?? 8448}`
|
|
87
|
+
const s = await collectStatus({ cwd, tuwunelUrl })
|
|
88
|
+
const fmt = (st: 'up' | 'down' | 'unknown'): string =>
|
|
89
|
+
st === 'up' ? chalk.green('up') : st === 'down' ? chalk.red('down') : chalk.yellow('unknown')
|
|
90
|
+
process.stdout.write(
|
|
91
|
+
[
|
|
92
|
+
`tuwunel ${fmt(s.tuwunel.status)} ${s.tuwunel.url}`,
|
|
93
|
+
`daemon ${fmt(s.daemon.status)} ${'url' in s.daemon ? s.daemon.url : s.daemon.reason}`,
|
|
94
|
+
...s.agents.map(
|
|
95
|
+
(a) => ` agent: ${a.name} (${a.userId}, trigger: ${a.trigger})`,
|
|
96
|
+
),
|
|
97
|
+
'',
|
|
98
|
+
].join('\n'),
|
|
99
|
+
)
|
|
100
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
5
|
+
import { startDaemon } from './start-daemon.js'
|
|
6
|
+
|
|
7
|
+
let dir: string
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
dir = mkdtempSync(join(tmpdir(), 'zooid-startd-'))
|
|
10
|
+
})
|
|
11
|
+
afterEach(() => rmSync(dir, { recursive: true, force: true }))
|
|
12
|
+
|
|
13
|
+
describe('startDaemon — http transport (smoke without Matrix)', () => {
|
|
14
|
+
it('starts an http transport when zooid.yaml only declares one', async () => {
|
|
15
|
+
const yamlPath = join(dir, 'zooid.yaml')
|
|
16
|
+
writeFileSync(
|
|
17
|
+
yamlPath,
|
|
18
|
+
[
|
|
19
|
+
'runtime: local',
|
|
20
|
+
'transports:',
|
|
21
|
+
' http-local:',
|
|
22
|
+
' type: http',
|
|
23
|
+
' port: 0',
|
|
24
|
+
'agents:',
|
|
25
|
+
' noop:',
|
|
26
|
+
' workdir: .',
|
|
27
|
+
' acp: { preset: claude }',
|
|
28
|
+
' http: { transport: http-local }',
|
|
29
|
+
].join('\n'),
|
|
30
|
+
)
|
|
31
|
+
process.env.ZOOID_TOKEN = 'test-token'
|
|
32
|
+
|
|
33
|
+
const handle = await startDaemon({
|
|
34
|
+
configPath: yamlPath,
|
|
35
|
+
installSignalHandlers: false,
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
expect(handle.port).toBeGreaterThan(0)
|
|
39
|
+
expect(handle.agentNames).toEqual(['noop'])
|
|
40
|
+
|
|
41
|
+
const r = await fetch(`http://localhost:${handle.port}/`, {
|
|
42
|
+
headers: { authorization: 'Bearer test-token' },
|
|
43
|
+
})
|
|
44
|
+
expect(r.status).toBeGreaterThan(0)
|
|
45
|
+
|
|
46
|
+
await handle.stop()
|
|
47
|
+
await handle.stop()
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('rejects when no transport is declared', async () => {
|
|
51
|
+
const yamlPath = join(dir, 'zooid.yaml')
|
|
52
|
+
writeFileSync(
|
|
53
|
+
yamlPath,
|
|
54
|
+
[
|
|
55
|
+
'runtime: local',
|
|
56
|
+
'agents:',
|
|
57
|
+
' a:',
|
|
58
|
+
' workdir: .',
|
|
59
|
+
' acp: { preset: claude }',
|
|
60
|
+
' http: { transport: x }',
|
|
61
|
+
].join('\n'),
|
|
62
|
+
)
|
|
63
|
+
await expect(
|
|
64
|
+
startDaemon({ configPath: yamlPath, installSignalHandlers: false }),
|
|
65
|
+
).rejects.toThrow()
|
|
66
|
+
})
|
|
67
|
+
})
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { mkdir, unlink } from 'node:fs/promises'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { dirname, join } from 'node:path'
|
|
5
|
+
import type { AddressInfo } from 'node:net'
|
|
6
|
+
import { serve, type ServerType } from '@hono/node-server'
|
|
7
|
+
import {
|
|
8
|
+
ApprovalCorrelator,
|
|
9
|
+
findConfigFile,
|
|
10
|
+
findHttpTransport,
|
|
11
|
+
findMatrixTransport,
|
|
12
|
+
loadZooidConfig,
|
|
13
|
+
mergeCliFlags,
|
|
14
|
+
type CliFlags,
|
|
15
|
+
type TapEvent,
|
|
16
|
+
} from '@zooid/core'
|
|
17
|
+
import { createApp } from '@zooid/transport-http'
|
|
18
|
+
import {
|
|
19
|
+
MatrixClient,
|
|
20
|
+
createMatrixTransport,
|
|
21
|
+
ensureWorkforceSpace,
|
|
22
|
+
startWorkforcePublisher,
|
|
23
|
+
type AgentBinding,
|
|
24
|
+
type PublisherHandle,
|
|
25
|
+
} from '@zooid/transport-matrix'
|
|
26
|
+
import {
|
|
27
|
+
SpawnRegistry,
|
|
28
|
+
startDaemonSocketServer,
|
|
29
|
+
type DaemonSocketHandle,
|
|
30
|
+
} from '@zooid/context-mcp'
|
|
31
|
+
import { buildAcpRegistry } from '../build-registry.js'
|
|
32
|
+
|
|
33
|
+
export interface StartDaemonOpts {
|
|
34
|
+
configPath?: string
|
|
35
|
+
cwd?: string
|
|
36
|
+
cliFlags?: CliFlags
|
|
37
|
+
installSignalHandlers?: boolean
|
|
38
|
+
/** Admin Matrix user ID. Forwarded to the matrix transport for room bootstrap. */
|
|
39
|
+
adminUserId?: string
|
|
40
|
+
/** Observability tap forwarded to each AcpClient. */
|
|
41
|
+
onTap?: (agentName: string, event: TapEvent) => void
|
|
42
|
+
/**
|
|
43
|
+
* Per-agent state root (`<dataRoot>/agents/`). Threaded into
|
|
44
|
+
* `buildAcpRegistry` so each AcpClient persists `sessionId`s across
|
|
45
|
+
* daemon restarts.
|
|
46
|
+
*/
|
|
47
|
+
agentsDir?: string
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface DaemonHandle {
|
|
51
|
+
port: number
|
|
52
|
+
agentNames: string[]
|
|
53
|
+
stop(): Promise<void>
|
|
54
|
+
whenStopped: Promise<void>
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function listenAsync(server: ServerType): Promise<number> {
|
|
58
|
+
return new Promise((resolve) => {
|
|
59
|
+
const check = () => {
|
|
60
|
+
const addr = server.address() as AddressInfo | string | null
|
|
61
|
+
if (addr && typeof addr === 'object') resolve(addr.port)
|
|
62
|
+
else setImmediate(check)
|
|
63
|
+
}
|
|
64
|
+
check()
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function closeAsync(server: ServerType): Promise<void> {
|
|
69
|
+
return new Promise((resolve) => {
|
|
70
|
+
server.close(() => resolve())
|
|
71
|
+
})
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function startDaemon(opts: StartDaemonOpts = {}): Promise<DaemonHandle> {
|
|
75
|
+
const cwd = opts.cwd ?? process.cwd()
|
|
76
|
+
const found = opts.configPath ? { path: opts.configPath } : findConfigFile(cwd)
|
|
77
|
+
if (!found) throw new Error('zooid.yaml is required')
|
|
78
|
+
const base = loadZooidConfig(readFileSync(found.path, 'utf8'))
|
|
79
|
+
const config = mergeCliFlags(base, opts.cliFlags ?? {})
|
|
80
|
+
|
|
81
|
+
const approvals = new ApprovalCorrelator()
|
|
82
|
+
|
|
83
|
+
const daemonSockPath = opts.agentsDir
|
|
84
|
+
? join(opts.agentsDir, '..', 'run', 'context.sock')
|
|
85
|
+
: join(tmpdir(), `zooid-context-${process.pid}.sock`)
|
|
86
|
+
await mkdir(dirname(daemonSockPath), { recursive: true }).catch(() => {})
|
|
87
|
+
const contextSpawnRegistry = new SpawnRegistry()
|
|
88
|
+
let contextSocket: DaemonSocketHandle | null = null
|
|
89
|
+
try {
|
|
90
|
+
contextSocket = await startDaemonSocketServer({
|
|
91
|
+
sockPath: daemonSockPath,
|
|
92
|
+
registry: contextSpawnRegistry,
|
|
93
|
+
})
|
|
94
|
+
} catch (err) {
|
|
95
|
+
console.warn('[context] daemon socket startup failed; zooid-context MCP disabled:', err)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const registry = buildAcpRegistry(config, {
|
|
99
|
+
approvals,
|
|
100
|
+
onTap: opts.onTap,
|
|
101
|
+
agentsDir: opts.agentsDir,
|
|
102
|
+
contextSpawnRegistry: contextSocket ? contextSpawnRegistry : undefined,
|
|
103
|
+
daemonSockPath: contextSocket ? daemonSockPath : undefined,
|
|
104
|
+
})
|
|
105
|
+
const agentNames = Object.keys(config.agents)
|
|
106
|
+
|
|
107
|
+
console.log(
|
|
108
|
+
`[context] socket=${daemonSockPath} ` +
|
|
109
|
+
`status=${contextSocket ? 'listening' : 'disabled'} ` +
|
|
110
|
+
`agents={${agentNames
|
|
111
|
+
.map((n) => `${n}:${registry.hasContextSpawn(n) ? 'yes' : 'no'}`)
|
|
112
|
+
.join(', ')}}`,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
let server: ServerType | null = null
|
|
116
|
+
let stopped = false
|
|
117
|
+
let resolveStopped!: () => void
|
|
118
|
+
const whenStopped = new Promise<void>((r) => {
|
|
119
|
+
resolveStopped = r
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
const matrix = findMatrixTransport(config)
|
|
123
|
+
let port: number
|
|
124
|
+
|
|
125
|
+
if (matrix) {
|
|
126
|
+
const client = new MatrixClient({
|
|
127
|
+
homeserver: matrix.transport.homeserver,
|
|
128
|
+
asToken: matrix.transport.as_token,
|
|
129
|
+
})
|
|
130
|
+
const bindings: AgentBinding[] = []
|
|
131
|
+
for (const a of Object.values(config.agents)) {
|
|
132
|
+
if (a.matrix?.transport !== matrix.name) continue
|
|
133
|
+
const binding: AgentBinding = {
|
|
134
|
+
name: a.name,
|
|
135
|
+
userId: a.matrix.user_id,
|
|
136
|
+
rooms: a.matrix.rooms,
|
|
137
|
+
trigger: a.matrix.trigger,
|
|
138
|
+
}
|
|
139
|
+
if (a.matrix.display_name !== undefined) binding.displayName = a.matrix.display_name
|
|
140
|
+
bindings.push(binding)
|
|
141
|
+
}
|
|
142
|
+
const transport = createMatrixTransport({
|
|
143
|
+
agents: registry,
|
|
144
|
+
approvals,
|
|
145
|
+
client,
|
|
146
|
+
bindings,
|
|
147
|
+
hsToken: matrix.transport.hs_token,
|
|
148
|
+
adminUserId: opts.adminUserId,
|
|
149
|
+
})
|
|
150
|
+
const requestedPort = matrix.transport.port ?? 8080
|
|
151
|
+
// Bind 0.0.0.0 explicitly — @hono/node-server defaults to IPv6-only on
|
|
152
|
+
// macOS, which Docker's NAT bridge can't reach when Tuwunel pushes AS
|
|
153
|
+
// events back to host.docker.internal:<port>.
|
|
154
|
+
server = serve({ fetch: transport.app.fetch, port: requestedPort, hostname: '0.0.0.0' })
|
|
155
|
+
port = await listenAsync(server)
|
|
156
|
+
|
|
157
|
+
// user_namespace is a regex like `@.*:localhost`; the part after the last
|
|
158
|
+
// `:` is the homeserver's server_name. Fall back to the homeserver URL's
|
|
159
|
+
// host if the namespace shape is unexpected.
|
|
160
|
+
const serverName =
|
|
161
|
+
matrix.transport.user_namespace.split(':').slice(1).join(':').replace(/\\?\)?$/, '') ||
|
|
162
|
+
new URL(matrix.transport.homeserver).hostname
|
|
163
|
+
const asUserId = `@${matrix.transport.sender_localpart}:${serverName}`
|
|
164
|
+
const spaceLocalpart = matrix.transport.space ?? 'dev'
|
|
165
|
+
let spaceRoomId: string | undefined
|
|
166
|
+
try {
|
|
167
|
+
spaceRoomId = await ensureWorkforceSpace({
|
|
168
|
+
client,
|
|
169
|
+
asUserId,
|
|
170
|
+
serverName,
|
|
171
|
+
spaceLocalpart,
|
|
172
|
+
preset: 'public_chat',
|
|
173
|
+
})
|
|
174
|
+
console.log(`[matrix] ensured workforce space #${spaceLocalpart}:${serverName} → ${spaceRoomId}`)
|
|
175
|
+
} catch (err) {
|
|
176
|
+
console.warn('[matrix] workforce space provisioning failed:', err)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Bootstrap *after* the listener is up: registerBot/createRoom/joinRoom
|
|
180
|
+
// each trigger Tuwunel to push events back to the AS, and Tuwunel only
|
|
181
|
+
// retries those a few times before dropping. Listening first ensures
|
|
182
|
+
// those pushes don't hit a connection-refused. Bootstrap also runs
|
|
183
|
+
// *after* the space exists so BotPool can attach each agent room as
|
|
184
|
+
// m.space.child while joining it.
|
|
185
|
+
await transport.bootstrap({ spaceRoomId, asUserId })
|
|
186
|
+
|
|
187
|
+
if (spaceRoomId) {
|
|
188
|
+
try {
|
|
189
|
+
const publisher: PublisherHandle = await startWorkforcePublisher({
|
|
190
|
+
client,
|
|
191
|
+
spaceRoomId,
|
|
192
|
+
asUserId,
|
|
193
|
+
getAgents: () => bindings,
|
|
194
|
+
})
|
|
195
|
+
console.log(`[matrix] published eco.zoon.workforce (${bindings.length} agents)`)
|
|
196
|
+
void publisher
|
|
197
|
+
} catch (err) {
|
|
198
|
+
console.warn('[matrix] workforce roster publication failed:', err)
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
} else {
|
|
202
|
+
const http = findHttpTransport(config)
|
|
203
|
+
if (!http) throw new Error('no transport declared in zooid.yaml')
|
|
204
|
+
const token = process.env.ZOOID_TOKEN
|
|
205
|
+
if (!token) throw new Error('ZOOID_TOKEN is required for http transport')
|
|
206
|
+
const app = createApp({ agents: registry, approvals, token })
|
|
207
|
+
server = serve({ fetch: app.fetch, port: http.transport.port, hostname: '0.0.0.0' })
|
|
208
|
+
port = await listenAsync(server)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const stop = async (): Promise<void> => {
|
|
212
|
+
if (stopped) return whenStopped
|
|
213
|
+
stopped = true
|
|
214
|
+
try {
|
|
215
|
+
if (server) await closeAsync(server)
|
|
216
|
+
} catch {
|
|
217
|
+
// swallow
|
|
218
|
+
}
|
|
219
|
+
try {
|
|
220
|
+
await registry.stopAll()
|
|
221
|
+
} catch (err) {
|
|
222
|
+
console.error('stopAll:', err)
|
|
223
|
+
}
|
|
224
|
+
try {
|
|
225
|
+
if (contextSocket) await contextSocket.close()
|
|
226
|
+
await unlink(daemonSockPath).catch(() => {})
|
|
227
|
+
} catch {
|
|
228
|
+
// swallow
|
|
229
|
+
}
|
|
230
|
+
resolveStopped()
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (opts.installSignalHandlers !== false) {
|
|
234
|
+
const handler = (sig: NodeJS.Signals): void => {
|
|
235
|
+
console.log(`received ${sig}, stopping agents...`)
|
|
236
|
+
void stop().then(() => process.exit(0))
|
|
237
|
+
}
|
|
238
|
+
process.on('SIGINT', () => handler('SIGINT'))
|
|
239
|
+
process.on('SIGTERM', () => handler('SIGTERM'))
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return { port, agentNames, stop, whenStopped }
|
|
243
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { TapEvent } from '@zooid/core'
|
|
2
|
+
import type { LogPaths } from './paths.js'
|
|
3
|
+
import { JsonlSink, shouldCaptureUpdate, type Verbosity } from './file-sink.js'
|
|
4
|
+
|
|
5
|
+
export interface MatrixContext {
|
|
6
|
+
room_id: string
|
|
7
|
+
event_id: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface WireAgentCaptureOpts {
|
|
11
|
+
agent: string
|
|
12
|
+
paths: LogPaths
|
|
13
|
+
verbosity: Verbosity
|
|
14
|
+
/** Returns the most-recently-known matrix context for the agent, or null. */
|
|
15
|
+
matrixContext: () => MatrixContext | null
|
|
16
|
+
/** Override for tests. */
|
|
17
|
+
now?: () => Date
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface AgentCapture {
|
|
21
|
+
onTap: (event: TapEvent) => void
|
|
22
|
+
close: () => Promise<void>
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function wireAgentCapture(opts: WireAgentCaptureOpts): AgentCapture {
|
|
26
|
+
const sink = new JsonlSink(opts.paths.agentTap(opts.agent))
|
|
27
|
+
const now = opts.now ?? (() => new Date())
|
|
28
|
+
const pending: Promise<unknown>[] = []
|
|
29
|
+
|
|
30
|
+
const onTap = (event: TapEvent): void => {
|
|
31
|
+
if (event.kind === 'session_update') {
|
|
32
|
+
const variant =
|
|
33
|
+
(event.update as { sessionUpdate?: string }).sessionUpdate ?? 'unknown'
|
|
34
|
+
if (!shouldCaptureUpdate(opts.verbosity, variant)) return
|
|
35
|
+
}
|
|
36
|
+
const matrix = opts.matrixContext()
|
|
37
|
+
const envelope: Record<string, unknown> = {
|
|
38
|
+
ts: now().toISOString(),
|
|
39
|
+
agent: opts.agent,
|
|
40
|
+
session_id: event.sessionId,
|
|
41
|
+
turn_id: event.turnId,
|
|
42
|
+
kind: event.kind,
|
|
43
|
+
}
|
|
44
|
+
if (matrix) envelope.matrix = matrix
|
|
45
|
+
if (event.kind === 'session_update') envelope.notification = event.update
|
|
46
|
+
else if (event.kind === 'turn_started') envelope.prompt_text = event.promptText
|
|
47
|
+
else if (event.kind === 'turn_completed') envelope.stop_reason = event.stopReason
|
|
48
|
+
pending.push(sink.write(envelope))
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
onTap,
|
|
53
|
+
close: async () => {
|
|
54
|
+
await Promise.all(pending)
|
|
55
|
+
await sink.close()
|
|
56
|
+
},
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
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 { spawn } from 'node:child_process'
|
|
7
|
+
import { buildRunArgs } from '../services/tuwunel.js'
|
|
8
|
+
import { captureChildToFile } from './capture-tuwunel.js'
|
|
9
|
+
|
|
10
|
+
const base = {
|
|
11
|
+
name: 'zooid-tuwunel',
|
|
12
|
+
hostPort: 8448,
|
|
13
|
+
paths: {
|
|
14
|
+
dataDir: '/abs/data/matrix',
|
|
15
|
+
dbDir: '/abs/data/matrix/db',
|
|
16
|
+
mediaDir: '/abs/data/matrix/media',
|
|
17
|
+
configDir: '/abs/data/matrix/config',
|
|
18
|
+
registrationsDir: '/abs/data/matrix/config/registrations',
|
|
19
|
+
tuwunelTomlPath: '/abs/data/matrix/config/tuwunel.toml',
|
|
20
|
+
appserviceYamlPath: '/abs/data/matrix/config/registrations/zooid.yaml',
|
|
21
|
+
envPath: '/abs/data/matrix/config/.env',
|
|
22
|
+
},
|
|
23
|
+
} as const
|
|
24
|
+
|
|
25
|
+
describe('buildRunArgs (post-floor)', () => {
|
|
26
|
+
it('no longer includes -d; container is foregrounded so the parent owns its stdio', () => {
|
|
27
|
+
const args = buildRunArgs({ ...base, engine: 'docker' })
|
|
28
|
+
expect(args).not.toContain('-d')
|
|
29
|
+
expect(args).toContain('--rm')
|
|
30
|
+
expect(args).toContain('--name')
|
|
31
|
+
})
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
describe('captureChildToFile', () => {
|
|
35
|
+
let dir: string
|
|
36
|
+
beforeEach(async () => {
|
|
37
|
+
dir = await mkdtemp(join(tmpdir(), 'zooid-obs-tw-'))
|
|
38
|
+
})
|
|
39
|
+
afterEach(() => {
|
|
40
|
+
rmSync(dir, { recursive: true, force: true })
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('tees stdout AND stderr of a child to the same log file in arrival order', async () => {
|
|
44
|
+
const path = join(dir, 'tuwunel.log')
|
|
45
|
+
const child = spawn(
|
|
46
|
+
process.execPath,
|
|
47
|
+
['-e', 'process.stdout.write("alpha\\n"); process.stderr.write("beta\\n");'],
|
|
48
|
+
{ stdio: ['ignore', 'pipe', 'pipe'] },
|
|
49
|
+
)
|
|
50
|
+
const done = captureChildToFile(child, path)
|
|
51
|
+
await new Promise<void>((r) => child.on('exit', () => r()))
|
|
52
|
+
await done
|
|
53
|
+
const text = await readFile(path, 'utf8')
|
|
54
|
+
expect(text).toContain('alpha')
|
|
55
|
+
expect(text).toContain('beta')
|
|
56
|
+
})
|
|
57
|
+
})
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { createWriteStream } from 'node:fs'
|
|
2
|
+
import { mkdir } from 'node:fs/promises'
|
|
3
|
+
import { dirname } from 'node:path'
|
|
4
|
+
import type { ChildProcess } from 'node:child_process'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Tee a child's stdout and stderr to one log file, in arrival order.
|
|
8
|
+
* Returns a promise that resolves once the file has been flushed after
|
|
9
|
+
* the child exits.
|
|
10
|
+
*/
|
|
11
|
+
export function captureChildToFile(
|
|
12
|
+
child: ChildProcess,
|
|
13
|
+
path: string,
|
|
14
|
+
): Promise<void> {
|
|
15
|
+
return (async () => {
|
|
16
|
+
await mkdir(dirname(path), { recursive: true })
|
|
17
|
+
const stream = createWriteStream(path, { flags: 'a' })
|
|
18
|
+
if (child.stdout) child.stdout.on('data', (b: Buffer) => stream.write(b))
|
|
19
|
+
if (child.stderr) child.stderr.on('data', (b: Buffer) => stream.write(b))
|
|
20
|
+
await new Promise<void>((resolve) => {
|
|
21
|
+
child.on('exit', () => stream.end(() => resolve()))
|
|
22
|
+
})
|
|
23
|
+
})()
|
|
24
|
+
}
|