@zooid/transport-matrix 0.12.0 → 0.14.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/dist/index.d.ts +164 -5
- package/dist/index.js +946 -101
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/context-provider.test.ts +178 -3
- package/src/context-provider.ts +62 -5
- package/src/event-encoders.test.ts +69 -1
- package/src/event-encoders.ts +41 -1
- package/src/index.ts +23 -3
- package/src/invocation-registry.test.ts +22 -0
- package/src/invocation-registry.ts +32 -0
- package/src/matrix-client.ts +58 -34
- package/src/router.test.ts +129 -10
- package/src/router.ts +78 -2
- package/src/task-completion.test.ts +23 -0
- package/src/task-completion.ts +37 -0
- package/src/task-dispatch.test.ts +53 -0
- package/src/task-dispatch.ts +68 -0
- package/src/task-envelope.test.ts +27 -0
- package/src/task-registry.test.ts +50 -0
- package/src/task-registry.ts +152 -0
- package/src/transport.test.ts +520 -111
- package/src/transport.ts +760 -116
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { renderAssigneeEnvelope, renderDelivery } from './task-dispatch.js'
|
|
3
|
+
|
|
4
|
+
describe('renderAssigneeEnvelope', () => {
|
|
5
|
+
it('names the caller, the owed result, and the depth cap', () => {
|
|
6
|
+
const out = renderAssigneeEnvelope({
|
|
7
|
+
parentAgent: 'zooid-assistant',
|
|
8
|
+
prompt: 'Write a one-sentence bug report.',
|
|
9
|
+
})
|
|
10
|
+
expect(out).toMatch(/^\[task\] from zooid-assistant/)
|
|
11
|
+
expect(out).toMatch(/zooid_complete_task/)
|
|
12
|
+
expect(out).toMatch(/ending your turn without one/i)
|
|
13
|
+
expect(out).toMatch(/@mention/)
|
|
14
|
+
expect(out.endsWith('Write a one-sentence bug report.')).toBe(true)
|
|
15
|
+
})
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
describe('renderDelivery', () => {
|
|
19
|
+
it('tells a notify:caller supervisor to stop and wait', () => {
|
|
20
|
+
expect(renderDelivery('caller')).toMatch(/new turn/)
|
|
21
|
+
expect(renderDelivery('caller')).toMatch(/do not read the task thread/i)
|
|
22
|
+
})
|
|
23
|
+
it('tells a notify:none supervisor the thread is the result surface', () => {
|
|
24
|
+
expect(renderDelivery('none')).toMatch(/no result returns/i)
|
|
25
|
+
expect(renderDelivery('none')).not.toMatch(/new turn/)
|
|
26
|
+
})
|
|
27
|
+
})
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { MAX_OPEN_TASKS_PER_ROOM, TaskRegistry } from './task-registry.js'
|
|
3
|
+
|
|
4
|
+
const parent = {
|
|
5
|
+
agent: 'supervisor',
|
|
6
|
+
threadRoot: '$parent',
|
|
7
|
+
sessionKey: '$parent',
|
|
8
|
+
generation: 0,
|
|
9
|
+
}
|
|
10
|
+
const reserve = (r: TaskRegistry, roomId = '!room:hs') =>
|
|
11
|
+
r.reserve({ roomId, assignee: 'worker', notify: 'caller', parent })
|
|
12
|
+
|
|
13
|
+
describe('TaskRegistry', () => {
|
|
14
|
+
it('enforces the open-task cap independently per room and releases once', () => {
|
|
15
|
+
const r = new TaskRegistry({
|
|
16
|
+
newId: (() => {
|
|
17
|
+
let i = 0
|
|
18
|
+
return () => `t${++i}`
|
|
19
|
+
})(),
|
|
20
|
+
})
|
|
21
|
+
for (let i = 0; i < MAX_OPEN_TASKS_PER_ROOM; i++) expect(reserve(r)).toBeDefined()
|
|
22
|
+
expect(reserve(r)).toBeUndefined()
|
|
23
|
+
const task = r.taskForRoot('$root')
|
|
24
|
+
expect(task).toBeUndefined()
|
|
25
|
+
r.activate('t1', '$root')
|
|
26
|
+
expect(r.close('t1')).toBe(true)
|
|
27
|
+
expect(r.close('t1')).toBe(false)
|
|
28
|
+
expect(reserve(r)).toBeDefined()
|
|
29
|
+
expect(reserve(r, '!other:hs')).toBeDefined()
|
|
30
|
+
})
|
|
31
|
+
it('adopts only issued attempts, retains closed roots, and protects uncertain sends', () => {
|
|
32
|
+
const r = new TaskRegistry({ maxOpenPerRoom: 1, newId: () => 'attempt' })
|
|
33
|
+
const task = reserve(r)!
|
|
34
|
+
r.markUncertain(task.taskId)
|
|
35
|
+
expect(reserve(r)).toBeUndefined()
|
|
36
|
+
expect(r.adopt('forged', '$bad')).toBeUndefined()
|
|
37
|
+
expect(r.adopt(task.attemptId, '$root')?.phase).toBe('open')
|
|
38
|
+
expect(r.openTaskFor('worker', '$root')?.taskId).toBe(task.taskId)
|
|
39
|
+
expect(r.recordSummary(task.taskId, 'first')).toBe('recorded')
|
|
40
|
+
expect(r.recordSummary(task.taskId, 'second')).toBe('already_recorded')
|
|
41
|
+
r.close(task.taskId)
|
|
42
|
+
expect(r.taskForRoot('$root')?.phase).toBe('closed')
|
|
43
|
+
})
|
|
44
|
+
it('bumps session generations after a reset', () => {
|
|
45
|
+
const r = new TaskRegistry()
|
|
46
|
+
expect(r.generationOf('a', '$s')).toBe(0)
|
|
47
|
+
r.bumpGeneration('a', '$s')
|
|
48
|
+
expect(r.generationOf('a', '$s')).toBe(1)
|
|
49
|
+
})
|
|
50
|
+
})
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
export const MAX_OPEN_TASKS_PER_ROOM = 5
|
|
3
|
+
export type TaskPhase = 'reserved' | 'uncertain' | 'open' | 'closed'
|
|
4
|
+
export interface TaskRecord {
|
|
5
|
+
taskId: string
|
|
6
|
+
attemptId: string
|
|
7
|
+
roomId: string
|
|
8
|
+
assignee: string
|
|
9
|
+
notify: 'caller' | 'none'
|
|
10
|
+
parent: {
|
|
11
|
+
agent: string
|
|
12
|
+
threadRoot: string
|
|
13
|
+
sessionKey: string
|
|
14
|
+
generation: number
|
|
15
|
+
}
|
|
16
|
+
phase: TaskPhase
|
|
17
|
+
threadRoot?: string
|
|
18
|
+
summary?: string
|
|
19
|
+
runId?: string
|
|
20
|
+
closedAt?: string
|
|
21
|
+
}
|
|
22
|
+
export interface PersistedTask extends Required<Pick<TaskRecord, 'taskId' | 'attemptId' | 'roomId' | 'assignee' | 'notify' | 'parent' | 'phase'>> {
|
|
23
|
+
threadRoot?: string
|
|
24
|
+
summary?: string
|
|
25
|
+
runId: string
|
|
26
|
+
closedAt?: string
|
|
27
|
+
}
|
|
28
|
+
export interface TaskJournal { load(): PersistedTask[]; save(tasks: PersistedTask[]): void }
|
|
29
|
+
export class TaskRegistry {
|
|
30
|
+
private readonly tasks = new Map<string, TaskRecord>()
|
|
31
|
+
private readonly byRoot = new Map<string, string>()
|
|
32
|
+
private readonly generations = new Map<string, number>()
|
|
33
|
+
private readonly runIdValue: string
|
|
34
|
+
constructor(
|
|
35
|
+
private readonly opts: {
|
|
36
|
+
maxOpenPerRoom?: number
|
|
37
|
+
newId?: () => string
|
|
38
|
+
journal?: TaskJournal
|
|
39
|
+
runId?: string
|
|
40
|
+
maxClosedRecords?: number
|
|
41
|
+
} = {},
|
|
42
|
+
) {
|
|
43
|
+
this.runIdValue = this.opts.runId ?? randomUUID()
|
|
44
|
+
}
|
|
45
|
+
private get max() {
|
|
46
|
+
return this.opts.maxOpenPerRoom ?? MAX_OPEN_TASKS_PER_ROOM
|
|
47
|
+
}
|
|
48
|
+
private get runId() { return this.runIdValue }
|
|
49
|
+
private save() {
|
|
50
|
+
if (!this.opts.journal) return
|
|
51
|
+
const rows = [...this.tasks.values()].map((r) => ({ ...r, runId: r.runId ?? this.runId }) as PersistedTask)
|
|
52
|
+
const max = this.opts.maxClosedRecords ?? 500
|
|
53
|
+
const open = rows.filter((r) => r.phase !== 'closed')
|
|
54
|
+
const closed = rows.filter((r) => r.phase === 'closed').sort((a, b) => (b.closedAt ?? '').localeCompare(a.closedAt ?? '')).slice(0, max)
|
|
55
|
+
this.opts.journal.save([...open, ...closed])
|
|
56
|
+
}
|
|
57
|
+
openCount(roomId: string) {
|
|
58
|
+
return [...this.tasks.values()].filter((t) => t.roomId === roomId && t.phase !== 'closed')
|
|
59
|
+
.length
|
|
60
|
+
}
|
|
61
|
+
reserve(
|
|
62
|
+
input: Omit<TaskRecord, 'taskId' | 'attemptId' | 'phase' | 'threadRoot' | 'summary'>,
|
|
63
|
+
): TaskRecord | undefined {
|
|
64
|
+
if (this.openCount(input.roomId) >= this.max) return
|
|
65
|
+
const id = this.opts.newId?.() ?? randomUUID()
|
|
66
|
+
const rec: TaskRecord = {
|
|
67
|
+
taskId: id,
|
|
68
|
+
attemptId: id,
|
|
69
|
+
phase: 'reserved',
|
|
70
|
+
runId: this.runId,
|
|
71
|
+
...input,
|
|
72
|
+
}
|
|
73
|
+
this.tasks.set(id, rec)
|
|
74
|
+
this.save()
|
|
75
|
+
return rec
|
|
76
|
+
}
|
|
77
|
+
activate(taskId: string, threadRoot: string) {
|
|
78
|
+
const r = this.tasks.get(taskId)
|
|
79
|
+
if (!r) return
|
|
80
|
+
r.phase = 'open'
|
|
81
|
+
r.threadRoot = threadRoot
|
|
82
|
+
this.byRoot.set(threadRoot, taskId)
|
|
83
|
+
this.save()
|
|
84
|
+
}
|
|
85
|
+
abandon(taskId: string) {
|
|
86
|
+
this.tasks.delete(taskId)
|
|
87
|
+
this.save()
|
|
88
|
+
}
|
|
89
|
+
markUncertain(taskId: string) {
|
|
90
|
+
const r = this.tasks.get(taskId)
|
|
91
|
+
if (r?.phase === 'reserved') r.phase = 'uncertain'
|
|
92
|
+
this.save()
|
|
93
|
+
}
|
|
94
|
+
adopt(attemptId: string, threadRoot: string) {
|
|
95
|
+
const r = this.tasks.get(attemptId)
|
|
96
|
+
if (!r) return
|
|
97
|
+
if (r.phase === 'closed' || (r.threadRoot && r.threadRoot !== threadRoot)) return r
|
|
98
|
+
this.activate(r.taskId, threadRoot)
|
|
99
|
+
return r
|
|
100
|
+
}
|
|
101
|
+
taskForRoot(threadRoot: string) {
|
|
102
|
+
const id = this.byRoot.get(threadRoot)
|
|
103
|
+
return id ? this.tasks.get(id) : undefined
|
|
104
|
+
}
|
|
105
|
+
openTaskFor(agent: string, root: string) {
|
|
106
|
+
const r = this.taskForRoot(root)
|
|
107
|
+
return r?.phase === 'open' && r.assignee === agent ? r : undefined
|
|
108
|
+
}
|
|
109
|
+
recordSummary(id: string, summary: string) {
|
|
110
|
+
const r = this.tasks.get(id)
|
|
111
|
+
if (!r || r.summary !== undefined) return 'already_recorded' as const
|
|
112
|
+
r.summary = summary
|
|
113
|
+
this.save()
|
|
114
|
+
return 'recorded' as const
|
|
115
|
+
}
|
|
116
|
+
clearSummary(id: string) {
|
|
117
|
+
const r = this.tasks.get(id)
|
|
118
|
+
if (r) r.summary = undefined
|
|
119
|
+
this.save()
|
|
120
|
+
}
|
|
121
|
+
close(id: string) {
|
|
122
|
+
const r = this.tasks.get(id)
|
|
123
|
+
if (!r || r.phase === 'closed') return false
|
|
124
|
+
r.phase = 'closed'
|
|
125
|
+
r.closedAt = new Date().toISOString()
|
|
126
|
+
this.save()
|
|
127
|
+
return true
|
|
128
|
+
}
|
|
129
|
+
/** Reconcile records from a prior daemon run and retain closed roots for trust checks. */
|
|
130
|
+
restore(): TaskRecord[] {
|
|
131
|
+
const rows = this.opts.journal?.load() ?? []
|
|
132
|
+
const interrupted: TaskRecord[] = []
|
|
133
|
+
for (const row of rows) {
|
|
134
|
+
const rec: TaskRecord = { ...row }
|
|
135
|
+
if (rec.phase !== 'closed' && rec.runId !== this.runId) {
|
|
136
|
+
rec.phase = 'closed'; rec.closedAt = new Date().toISOString()
|
|
137
|
+
interrupted.push(rec)
|
|
138
|
+
}
|
|
139
|
+
this.tasks.set(rec.taskId, rec)
|
|
140
|
+
if (rec.threadRoot) this.byRoot.set(rec.threadRoot, rec.taskId)
|
|
141
|
+
}
|
|
142
|
+
this.save()
|
|
143
|
+
return interrupted
|
|
144
|
+
}
|
|
145
|
+
generationOf(agent: string, session: string) {
|
|
146
|
+
return this.generations.get(`${agent}::${session}`) ?? 0
|
|
147
|
+
}
|
|
148
|
+
bumpGeneration(agent: string, session: string) {
|
|
149
|
+
const k = `${agent}::${session}`
|
|
150
|
+
this.generations.set(k, this.generationOf(agent, session) + 1)
|
|
151
|
+
}
|
|
152
|
+
}
|