@jc_stack/ez-agents 0.1.0-beta.18 → 0.1.0-beta.19
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/.env.example +5 -0
- package/AGENTS.md +6 -0
- package/CHANGELOG.md +14 -0
- package/CONTRIBUTING.md +29 -3
- package/Dockerfile +6 -0
- package/README.md +8 -2
- package/bin/ezenciel-agents-watch.mjs +8 -0
- package/compose.workforce-watch.yaml +33 -0
- package/docs/architecture/ai-selection.md +14 -7
- package/docs/plugin-catalog.md +1 -0
- package/docs/plugins.md +34 -0
- package/docs/responsive-channels.md +57 -0
- package/docs/scheduling.md +6 -4
- package/docs/setup.md +10 -6
- package/docs/workforce-watch.md +101 -0
- package/package.json +4 -2
- package/src/agent-guidance.ts +4 -0
- package/src/ai.ts +14 -6
- package/src/control-state.ts +5 -3
- package/src/desktop-bridge.ts +4 -2
- package/src/executor.ts +4 -2
- package/src/host-executor-client.ts +7 -1
- package/src/index.ts +58 -18
- package/src/menu.ts +4 -4
- package/src/model-policy.ts +8 -5
- package/src/plugins/manager.mjs +70 -2
- package/src/reply-context.ts +7 -3
- package/src/reply-executor.ts +2 -1
- package/src/reply-mcp.ts +1 -1
- package/src/runs.ts +0 -13
- package/src/schedule-cli.ts +1 -1
- package/src/scheduled-tasks.ts +33 -0
- package/src/scheduler.ts +11 -2
- package/src/setup.ts +2 -2
- package/src/task-executor.ts +2 -1
- package/src/updates/runtime.mjs +2 -1
- package/src/workforce-watch-cli.ts +14 -0
- package/src/workforce-watch.ts +155 -0
- package/templates/agent-guidance.md +11 -0
- package/templates/chat-guidance.md +23 -0
- package/test/agent-guidance.test.ts +15 -0
- package/test/ai.test.ts +40 -1
- package/test/event-sources.test.ts +4 -0
- package/test/failure.test.ts +13 -7
- package/test/host-executor.test.ts +16 -0
- package/test/intake-relay.test.ts +4 -0
- package/test/model-policy.test.ts +9 -1
- package/test/plugin-manager.test.mjs +49 -0
- package/test/reply.test.ts +22 -0
- package/test/runs.test.ts +7 -0
- package/test/schedule-cli.test.ts +2 -0
- package/test/scheduled-tasks.test.ts +43 -0
- package/test/workforce-watch.test.ts +180 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import test from 'node:test'
|
|
3
|
+
import { mkdtemp, rm } from 'node:fs/promises'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import { WorkforceWatch, WorkforceWatchServer } from '../src/workforce-watch.js'
|
|
7
|
+
|
|
8
|
+
const fixture = async () => {
|
|
9
|
+
const stateDir = await mkdtemp(join(tmpdir(), 'workforce-watch-'))
|
|
10
|
+
let now = 0
|
|
11
|
+
const notices: string[] = []
|
|
12
|
+
const watch = new WorkforceWatch({ stateDir, enrollmentToken: 'fleet-secret', now: () => now, notify: async text => { notices.push(text) } })
|
|
13
|
+
return { stateDir, watch, notices, advance: (ms: number) => { now += ms }, close: () => rm(stateDir, { recursive: true, force: true }) }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
test('worker enrollment, terminal alert context, and sustained recovery preserve only redacted state', async () => {
|
|
17
|
+
const f = await fixture()
|
|
18
|
+
try {
|
|
19
|
+
await assert.rejects(() => f.watch.enroll('wrong', { workerId: 'stocks-production', checkInSeconds: 10, graceSeconds: 0 }), /Unauthorized/)
|
|
20
|
+
const enrolled = await f.watch.enroll('fleet-secret', { workerId: 'stocks-production', checkInSeconds: 10, graceSeconds: 0, runbookUrl: 'https://runbooks.example/stocks' })
|
|
21
|
+
await assert.rejects(() => f.watch.checkIn('stocks-production', 'wrong', { status: 'ok' }), /Unauthorized/)
|
|
22
|
+
await f.watch.checkIn(enrolled.workerId, enrolled.workerToken, { status: 'failed', terminal: true, activity: 'IBKR session probe', error: 'No broker server time', runId: 'daily-42', logsHint: 'journalctl --user -u ez-stocks.service --since 30m' })
|
|
23
|
+
assert.match(f.notices[0], /IBKR session probe/)
|
|
24
|
+
assert.match(f.notices[0], /No broker server time/)
|
|
25
|
+
const state = await f.watch.inspect('stocks-production') as Record<string, unknown>
|
|
26
|
+
assert.equal('tokenHash' in state, false)
|
|
27
|
+
assert.deepEqual((state.history as Array<Record<string, unknown>>).at(-1)?.runId, 'daily-42')
|
|
28
|
+
await f.watch.checkIn(enrolled.workerId, enrolled.workerToken, { status: 'ok', activity: 'retry one' })
|
|
29
|
+
await f.watch.checkIn(enrolled.workerId, enrolled.workerToken, { status: 'ok', activity: 'retry two' })
|
|
30
|
+
assert.equal(f.notices.length, 2)
|
|
31
|
+
assert.match(f.notices[1], /recovered/)
|
|
32
|
+
} finally { await f.close() }
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
test('a missed check-in alerts once and requires clean check-ins to recover', async () => {
|
|
36
|
+
const f = await fixture()
|
|
37
|
+
try {
|
|
38
|
+
const enrolled = await f.watch.enroll('fleet-secret', { workerId: 'annie-pa', checkInSeconds: 10, graceSeconds: 2, severity: 'warning' })
|
|
39
|
+
f.advance(12_001)
|
|
40
|
+
await f.watch.evaluate(); await f.watch.evaluate()
|
|
41
|
+
assert.equal(f.notices.length, 1)
|
|
42
|
+
assert.match(f.notices[0], /missed check-in/)
|
|
43
|
+
await f.watch.checkIn(enrolled.workerId, enrolled.workerToken, { status: 'ok', activity: 'relay available' })
|
|
44
|
+
assert.equal(f.notices.length, 1)
|
|
45
|
+
await f.watch.checkIn(enrolled.workerId, enrolled.workerToken, { status: 'ok', activity: 'relay available' })
|
|
46
|
+
assert.equal(f.notices.length, 2)
|
|
47
|
+
} finally { await f.close() }
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test('PagerDuty uses one worker-specific deduplication key and resolves only a triggered incident', async () => {
|
|
51
|
+
const stateDir = await mkdtemp(join(tmpdir(), 'workforce-watch-'))
|
|
52
|
+
const pages: Array<{ action: string; dedupKey: string; customDetails: Record<string, string> }> = []
|
|
53
|
+
const watch = new WorkforceWatch({ stateDir, enrollmentToken: 'fleet-secret', page: async event => { pages.push(event) } })
|
|
54
|
+
try {
|
|
55
|
+
const enrolled = await watch.enroll('fleet-secret', { workerId: 'stocks', checkInSeconds: 10, graceSeconds: 0, severity: 'critical' })
|
|
56
|
+
await watch.checkIn(enrolled.workerId, enrolled.workerToken, { status: 'failed', terminal: true, activity: 'broker probe', error: 'gateway unavailable', logsHint: 'journalctl -u stocks' })
|
|
57
|
+
await watch.checkIn(enrolled.workerId, enrolled.workerToken, { status: 'ok' })
|
|
58
|
+
await watch.checkIn(enrolled.workerId, enrolled.workerToken, { status: 'ok' })
|
|
59
|
+
assert.deepEqual(pages.map(page => [page.action, page.dedupKey]), [['trigger', 'ez:workforce:stocks'], ['resolve', 'ez:workforce:stocks']])
|
|
60
|
+
assert.equal(pages[0]?.customDetails.error, 'gateway unavailable')
|
|
61
|
+
} finally { await rm(stateDir, { recursive: true, force: true }) }
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test('PagerDuty trigger is not duplicated when supplemental Telegram delivery fails', async () => {
|
|
65
|
+
const stateDir = await mkdtemp(join(tmpdir(), 'workforce-watch-'))
|
|
66
|
+
const pages: string[] = []
|
|
67
|
+
const watch = new WorkforceWatch({ stateDir, enrollmentToken: 'fleet-secret', page: async event => { pages.push(event.action) }, notify: async () => { throw new Error('Telegram unavailable') } })
|
|
68
|
+
try {
|
|
69
|
+
const enrolled = await watch.enroll('fleet-secret', { workerId: 'aifit', checkInSeconds: 10, graceSeconds: 0 })
|
|
70
|
+
await assert.rejects(() => watch.checkIn(enrolled.workerId, enrolled.workerToken, { status: 'failed', terminal: true }), /Telegram unavailable/)
|
|
71
|
+
await assert.rejects(() => watch.evaluate(), /Telegram unavailable/)
|
|
72
|
+
assert.deepEqual(pages, ['trigger'])
|
|
73
|
+
} finally { await rm(stateDir, { recursive: true, force: true }) }
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test('a failed recovery notification stays pending and is retried on evaluation', async () => {
|
|
77
|
+
const stateDir = await mkdtemp(join(tmpdir(), 'workforce-watch-'))
|
|
78
|
+
let now = 0, failRecovery = true
|
|
79
|
+
const notices: string[] = []
|
|
80
|
+
const watch = new WorkforceWatch({ stateDir, enrollmentToken: 'fleet-secret', now: () => now, notify: async text => {
|
|
81
|
+
if (text.includes('recovered') && failRecovery) throw new Error('Telegram unavailable')
|
|
82
|
+
notices.push(text)
|
|
83
|
+
} })
|
|
84
|
+
try {
|
|
85
|
+
const enrolled = await watch.enroll('fleet-secret', { workerId: 'dusk-dune', checkInSeconds: 10, graceSeconds: 0 })
|
|
86
|
+
await watch.checkIn(enrolled.workerId, enrolled.workerToken, { status: 'failed', terminal: true })
|
|
87
|
+
await watch.checkIn(enrolled.workerId, enrolled.workerToken, { status: 'ok' })
|
|
88
|
+
await assert.rejects(() => watch.checkIn(enrolled.workerId, enrolled.workerToken, { status: 'ok' }), /Telegram unavailable/)
|
|
89
|
+
assert.ok((await watch.inspect('dusk-dune') as { incident?: unknown }).incident)
|
|
90
|
+
failRecovery = false; now += 1
|
|
91
|
+
await watch.evaluate()
|
|
92
|
+
assert.equal((await watch.inspect('dusk-dune') as { incident?: unknown }).incident, undefined)
|
|
93
|
+
assert.equal(notices.length, 2)
|
|
94
|
+
} finally { await rm(stateDir, { recursive: true, force: true }) }
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
test('an incident whose opening alert never delivered resolves quietly', async () => {
|
|
98
|
+
const stateDir = await mkdtemp(join(tmpdir(), 'workforce-watch-'))
|
|
99
|
+
const notices: string[] = []
|
|
100
|
+
const watch = new WorkforceWatch({ stateDir, enrollmentToken: 'fleet-secret', notify: async text => {
|
|
101
|
+
if (text.includes('alert')) throw new Error('Telegram unavailable')
|
|
102
|
+
notices.push(text)
|
|
103
|
+
} })
|
|
104
|
+
try {
|
|
105
|
+
const enrolled = await watch.enroll('fleet-secret', { workerId: 'jc-stack', checkInSeconds: 10, graceSeconds: 0 })
|
|
106
|
+
await assert.rejects(() => watch.checkIn(enrolled.workerId, enrolled.workerToken, { status: 'failed', terminal: true }), /Telegram unavailable/)
|
|
107
|
+
await assert.rejects(() => watch.checkIn(enrolled.workerId, enrolled.workerToken, { status: 'ok' }), /Telegram unavailable/)
|
|
108
|
+
await watch.checkIn(enrolled.workerId, enrolled.workerToken, { status: 'ok' })
|
|
109
|
+
assert.deepEqual(notices, [])
|
|
110
|
+
assert.equal((await watch.inspect('jc-stack') as { incident?: unknown }).incident, undefined)
|
|
111
|
+
} finally { await rm(stateDir, { recursive: true, force: true }) }
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
test('an enrollment-authorized token rotation invalidates the prior worker secret', async () => {
|
|
115
|
+
const f = await fixture()
|
|
116
|
+
try {
|
|
117
|
+
const enrolled = await f.watch.enroll('fleet-secret', { workerId: 'aifit', checkInSeconds: 10, graceSeconds: 0 })
|
|
118
|
+
await assert.rejects(() => f.watch.rotate('wrong', 'aifit'), /Unauthorized/)
|
|
119
|
+
const rotated = await f.watch.rotate('fleet-secret', 'aifit')
|
|
120
|
+
await assert.rejects(() => f.watch.checkIn('aifit', enrolled.workerToken, { status: 'ok' }), /Unauthorized/)
|
|
121
|
+
await f.watch.checkIn('aifit', rotated.workerToken, { status: 'ok' })
|
|
122
|
+
} finally { await f.close() }
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
test('HTTP enrollment and check-in endpoints reject secrets not owned by the caller', async () => {
|
|
126
|
+
const f = await fixture(), server = new WorkforceWatchServer(f.watch, 'fleet-secret', () => {})
|
|
127
|
+
try {
|
|
128
|
+
await server.listen(0, '127.0.0.1')
|
|
129
|
+
const base = `http://127.0.0.1:${server.port()}`
|
|
130
|
+
assert.equal((await fetch(`${base}/v1/workers`)).status, 401)
|
|
131
|
+
const enrollment = await fetch(`${base}/v1/enroll`, { method: 'POST', headers: { authorization: 'Bearer fleet-secret', 'content-type': 'application/json' }, body: JSON.stringify({ workerId: 'ez-cto', checkInSeconds: 10, graceSeconds: 0 }) })
|
|
132
|
+
assert.equal(enrollment.status, 201)
|
|
133
|
+
const { workerToken } = await enrollment.json() as { workerToken: string }
|
|
134
|
+
assert.equal((await fetch(`${base}/v1/workers/ez-cto/check-in`, { method: 'POST', headers: { authorization: 'Bearer wrong', 'content-type': 'application/json' }, body: '{"status":"ok"}' })).status, 401)
|
|
135
|
+
assert.equal((await fetch(`${base}/v1/workers/ez-cto/check-in`, { method: 'POST', headers: { authorization: `Bearer ${workerToken}`, 'content-type': 'application/json' }, body: '{"status":"ok","activity":"relay started"}' })).status, 200)
|
|
136
|
+
const rotation = await fetch(`${base}/v1/workers/ez-cto/rotate`, { method: 'POST', headers: { authorization: 'Bearer fleet-secret' } })
|
|
137
|
+
assert.equal(rotation.status, 200)
|
|
138
|
+
const rotated = await rotation.json() as { workerToken: string }
|
|
139
|
+
assert.equal((await fetch(`${base}/v1/workers/ez-cto/check-in`, { method: 'POST', headers: { authorization: `Bearer ${workerToken}`, 'content-type': 'application/json' }, body: '{"status":"ok"}' })).status, 401)
|
|
140
|
+
assert.equal((await fetch(`${base}/v1/workers/ez-cto/check-in`, { method: 'POST', headers: { authorization: `Bearer ${rotated.workerToken}`, 'content-type': 'application/json' }, body: '{"status":"ok"}' })).status, 200)
|
|
141
|
+
} finally { await server.close(); await f.close() }
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
for (const recoveredChannel of ['pagerduty', 'telegram'] as const) {
|
|
145
|
+
for (const relapse of ['terminal', 'failed', 'missed'] as const) {
|
|
146
|
+
test(`a ${relapse} relapse reopens only the recovered ${recoveredChannel} channel after restart`, async () => {
|
|
147
|
+
const stateDir = await mkdtemp(join(tmpdir(), 'workforce-relapse-'))
|
|
148
|
+
let now = 0, failRecovery = true
|
|
149
|
+
const pages: string[] = [], notices: string[] = []
|
|
150
|
+
const options = { stateDir, enrollmentToken: 'synthetic', now: () => now,
|
|
151
|
+
page: async (event: { action: string }) => {
|
|
152
|
+
if (event.action === 'resolve' && recoveredChannel === 'telegram' && failRecovery) throw new Error('recovery unavailable')
|
|
153
|
+
pages.push(event.action)
|
|
154
|
+
},
|
|
155
|
+
notify: async (message: string) => {
|
|
156
|
+
if (message.includes('recovered') && recoveredChannel === 'pagerduty' && failRecovery) throw new Error('recovery unavailable')
|
|
157
|
+
notices.push(message.includes('recovered') ? 'resolve' : 'trigger')
|
|
158
|
+
} }
|
|
159
|
+
try {
|
|
160
|
+
let watch = new WorkforceWatch(options)
|
|
161
|
+
const worker = await watch.enroll('synthetic', { workerId: 'synthetic-worker', checkInSeconds: 10, graceSeconds: 0 })
|
|
162
|
+
await watch.checkIn(worker.workerId, worker.workerToken, { status: 'failed', terminal: true })
|
|
163
|
+
await watch.checkIn(worker.workerId, worker.workerToken, { status: 'ok' })
|
|
164
|
+
await assert.rejects(() => watch.checkIn(worker.workerId, worker.workerToken, { status: 'ok' }), /recovery unavailable/)
|
|
165
|
+
watch = new WorkforceWatch(options)
|
|
166
|
+
now = relapse === 'missed' ? 10_001 : 1
|
|
167
|
+
if (relapse === 'missed') await watch.evaluate()
|
|
168
|
+
else await watch.checkIn(worker.workerId, worker.workerToken, { status: 'failed', terminal: relapse === 'terminal' })
|
|
169
|
+
assert.deepEqual(pages, recoveredChannel === 'pagerduty' ? ['trigger', 'resolve', 'trigger'] : ['trigger'])
|
|
170
|
+
assert.deepEqual(notices, recoveredChannel === 'telegram' ? ['trigger', 'resolve', 'trigger'] : ['trigger'])
|
|
171
|
+
failRecovery = false
|
|
172
|
+
await watch.checkIn(worker.workerId, worker.workerToken, { status: 'ok' })
|
|
173
|
+
await watch.checkIn(worker.workerId, worker.workerToken, { status: 'ok' })
|
|
174
|
+
assert.equal((await watch.inspect(worker.workerId) as { incident?: unknown }).incident, undefined)
|
|
175
|
+
assert.equal(pages.at(-1), 'resolve')
|
|
176
|
+
assert.equal(notices.at(-1), 'resolve')
|
|
177
|
+
} finally { await rm(stateDir, { recursive: true, force: true }) }
|
|
178
|
+
})
|
|
179
|
+
}
|
|
180
|
+
}
|