@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,14 @@
|
|
|
1
|
+
import { WorkforceWatch, WorkforceWatchServer } from './workforce-watch.js'
|
|
2
|
+
import { readFileSync } from 'node:fs'
|
|
3
|
+
const positive=(value:string|undefined,name:string,fallback:number):number=>{if(!value)return fallback;const parsed=Number(value);if(!Number.isSafeInteger(parsed)||parsed<=0)throw new Error(`${name} must be a positive integer`);return parsed}
|
|
4
|
+
const pagerDutyRoutingKey=()=>{const secretFile='/run/secrets/workforce_watch_pagerduty';try{const value=readFileSync(secretFile,'utf8').trim();if(!value||/[\r\n]/.test(value))throw new Error('Workforce PagerDuty secret must contain exactly one routing key');return value}catch(error){if((error as NodeJS.ErrnoException).code==='ENOENT')return undefined;throw error}}
|
|
5
|
+
const enrollmentToken=process.env.EZ_WATCH_ENROLL_TOKEN?.trim();if(!enrollmentToken)throw new Error('EZ_WATCH_ENROLL_TOKEN is required')
|
|
6
|
+
const telegramToken=process.env.TELEGRAM_BOT_TOKEN?.trim(),telegramChatId=process.env.EZ_WATCH_TELEGRAM_CHAT_ID?.trim();if(Boolean(telegramToken)!==Boolean(telegramChatId))throw new Error('Set both TELEGRAM_BOT_TOKEN and EZ_WATCH_TELEGRAM_CHAT_ID, or neither')
|
|
7
|
+
const routingKey=pagerDutyRoutingKey()
|
|
8
|
+
const notify=telegramToken&&telegramChatId?async(text:string)=>{const response=await fetch(`https://api.telegram.org/bot${telegramToken}/sendMessage`,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({chat_id:telegramChatId,text,disable_web_page_preview:true}),redirect:'error',signal:AbortSignal.timeout(10000)});if(!response.ok)throw new Error(`Telegram delivery returned HTTP ${response.status}`)}:undefined
|
|
9
|
+
const page=routingKey?async(event:import('./workforce-watch.js').WorkforcePage)=>{const response=await fetch('https://events.pagerduty.com/v2/enqueue',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({routing_key:routingKey,event_action:event.action,dedup_key:event.dedupKey,payload:{summary:event.summary,source:event.source,severity:event.severity,component:event.dedupKey.slice('ez:workforce:'.length),custom_details:event.customDetails}}),redirect:'error',signal:AbortSignal.timeout(10000)});if(!response.ok)throw new Error(`PagerDuty delivery returned HTTP ${response.status}`)}:undefined
|
|
10
|
+
if(!page)throw new Error('Mount the workforce PagerDuty secret at /run/secrets/workforce_watch_pagerduty')
|
|
11
|
+
const watch=new WorkforceWatch({stateDir:process.env.EZ_WATCH_STATE_DIR?.trim()||'/state',enrollmentToken,recoveryThreshold:positive(process.env.EZ_WATCH_RECOVERY_CHECKS,'EZ_WATCH_RECOVERY_CHECKS',2),notify,page,log:message=>console.error(message)})
|
|
12
|
+
const port=positive(process.env.EZ_WATCH_PORT,'EZ_WATCH_PORT',8080),host=process.env.EZ_WATCH_HOST?.trim()||'0.0.0.0',evaluateMs=positive(process.env.EZ_WATCH_EVALUATE_SECONDS,'EZ_WATCH_EVALUATE_SECONDS',30)*1000,server=new WorkforceWatchServer(watch,enrollmentToken)
|
|
13
|
+
await server.listen(port,host);watch.start(evaluateMs);console.log(`Workforce Watch listening on ${host}:${port}`)
|
|
14
|
+
for(const signal of ['SIGINT','SIGTERM'] as const)process.once(signal,()=>{watch.stop();void server.close().finally(()=>process.exit(0))})
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
|
|
2
|
+
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'
|
|
3
|
+
import { chmod, mkdir, readFile, rename, writeFile } from 'node:fs/promises'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
|
|
6
|
+
export type WatchStatus = 'ok' | 'failed'
|
|
7
|
+
export type WatchSeverity = 'warning' | 'critical'
|
|
8
|
+
export type WatchEvent = { at: string; status: WatchStatus; activity?: string; error?: string; runId?: string; logsHint?: string; terminal?: boolean }
|
|
9
|
+
export type WorkforcePage = { action: 'trigger' | 'resolve'; dedupKey: string; summary: string; severity: WatchSeverity; source: string; customDetails: Record<string, string> }
|
|
10
|
+
type Incident = { openedAt: number; reason: 'missed-check-in' | 'terminal-failure'; notifiedAt?: number; pagerDutyTriggeredAt?: number; recoveredAt?: number; telegramRecoveredAt?: number; pagerDutyResolvedAt?: number }
|
|
11
|
+
type Worker = { id: string; tokenHash: string; checkInMs: number; graceMs: number; severity: WatchSeverity; runbookUrl?: string; createdAt: number; lastSeenAt: number; recoveryChecks: number; history: WatchEvent[]; incident?: Incident }
|
|
12
|
+
type State = { version: 1; workers: Record<string, Worker> }
|
|
13
|
+
export type EnrollRequest = { workerId: string; checkInSeconds: number; graceSeconds: number; severity?: WatchSeverity; runbookUrl?: string }
|
|
14
|
+
export type CheckInRequest = { status: WatchStatus; activity?: string; error?: string; runId?: string; logsHint?: string; terminal?: boolean }
|
|
15
|
+
export type WorkforceWatchOptions = { stateDir: string; enrollmentToken: string; recoveryThreshold?: number; notify?: (message: string) => Promise<void>; page?: (event: WorkforcePage) => Promise<void>; now?: () => number; log?: (message: string) => void }
|
|
16
|
+
|
|
17
|
+
const WORKER_ID = /^[a-z][a-z0-9-]{0,63}$/
|
|
18
|
+
const MAX_HISTORY = 5, MAX_BODY_BYTES = 8192
|
|
19
|
+
const hash = (value: string): string => createHash('sha256').update(value).digest('hex')
|
|
20
|
+
const sameSecret = (candidate: string, expectedHash: string): boolean => {
|
|
21
|
+
const a = Buffer.from(hash(candidate), 'hex'), b = Buffer.from(expectedHash, 'hex')
|
|
22
|
+
return a.length === b.length && timingSafeEqual(a, b)
|
|
23
|
+
}
|
|
24
|
+
const positive = (value: unknown, field: string, minimum: number, maximum: number): number => {
|
|
25
|
+
if (!Number.isSafeInteger(value) || (value as number) < minimum || (value as number) > maximum) throw new Error(`${field} must be an integer from ${minimum} to ${maximum}`)
|
|
26
|
+
return value as number
|
|
27
|
+
}
|
|
28
|
+
const text = (value: unknown, field: string, maximum: number): string | undefined => {
|
|
29
|
+
if (value === undefined) return undefined
|
|
30
|
+
if (typeof value !== 'string' || !value.trim() || value.length > maximum) throw new Error(`Invalid ${field}`)
|
|
31
|
+
return value.trim()
|
|
32
|
+
}
|
|
33
|
+
const validateEnroll = (value: unknown): EnrollRequest => {
|
|
34
|
+
if (!value || typeof value !== 'object') throw new Error('Invalid enrollment')
|
|
35
|
+
const input = value as Record<string, unknown>, severity = input.severity === undefined ? 'critical' : input.severity
|
|
36
|
+
if (typeof input.workerId !== 'string' || !WORKER_ID.test(input.workerId)) throw new Error('Invalid workerId')
|
|
37
|
+
if (severity !== 'warning' && severity !== 'critical') throw new Error('Invalid severity')
|
|
38
|
+
const runbookUrl = text(input.runbookUrl, 'runbookUrl', 500)
|
|
39
|
+
if (runbookUrl) { let url: URL; try { url = new URL(runbookUrl) } catch { throw new Error('Invalid runbookUrl') }; if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.hash) throw new Error('Invalid runbookUrl') }
|
|
40
|
+
return { workerId: input.workerId, checkInSeconds: positive(input.checkInSeconds, 'checkInSeconds', 10, 86400), graceSeconds: positive(input.graceSeconds, 'graceSeconds', 0, 86400), severity, runbookUrl }
|
|
41
|
+
}
|
|
42
|
+
const validateCheckIn = (value: unknown): CheckInRequest => {
|
|
43
|
+
if (!value || typeof value !== 'object') throw new Error('Invalid check-in')
|
|
44
|
+
const input = value as Record<string, unknown>
|
|
45
|
+
if (input.status !== 'ok' && input.status !== 'failed') throw new Error('Invalid status')
|
|
46
|
+
if (input.terminal !== undefined && typeof input.terminal !== 'boolean') throw new Error('Invalid terminal')
|
|
47
|
+
return { status: input.status, activity: text(input.activity, 'activity', 300), error: text(input.error, 'error', 500), runId: text(input.runId, 'runId', 120), logsHint: text(input.logsHint, 'logsHint', 500), terminal: input.terminal }
|
|
48
|
+
}
|
|
49
|
+
const errorText = (error: unknown): string => error instanceof Error ? error.message : 'unknown error'
|
|
50
|
+
|
|
51
|
+
export class WorkforceWatch {
|
|
52
|
+
private readonly recoveryThreshold: number
|
|
53
|
+
private readonly now: () => number
|
|
54
|
+
private serial: Promise<unknown> = Promise.resolve()
|
|
55
|
+
private timer?: ReturnType<typeof setInterval>
|
|
56
|
+
constructor(private readonly options: WorkforceWatchOptions) {
|
|
57
|
+
if (!options.enrollmentToken.trim()) throw new Error('EZ_WATCH_ENROLL_TOKEN is required')
|
|
58
|
+
this.recoveryThreshold = positive(options.recoveryThreshold ?? 2, 'recoveryThreshold', 1, 10); this.now = options.now ?? Date.now
|
|
59
|
+
}
|
|
60
|
+
private get stateFile(): string { return join(this.options.stateDir, 'workforce-watch.json') }
|
|
61
|
+
private async state(): Promise<State> {
|
|
62
|
+
await mkdir(this.options.stateDir, { recursive: true, mode: 0o700 })
|
|
63
|
+
await chmod(this.options.stateDir, 0o700)
|
|
64
|
+
try { const parsed: unknown = JSON.parse(await readFile(this.stateFile, 'utf8')); if (!parsed || typeof parsed !== 'object' || (parsed as {version?:unknown}).version !== 1 || typeof (parsed as {workers?:unknown}).workers !== 'object') throw new Error('Invalid workforce watch state'); return parsed as State }
|
|
65
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { version: 1, workers: {} }; throw error }
|
|
66
|
+
}
|
|
67
|
+
private async save(state: State): Promise<void> { const temporary = `${this.stateFile}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`; await writeFile(temporary, `${JSON.stringify(state)}\n`, { mode: 0o600 }); await rename(temporary, this.stateFile) }
|
|
68
|
+
private enqueue<T>(work: () => Promise<T>): Promise<T> { const result = this.serial.then(work, work); this.serial = result.then(() => undefined, () => undefined); return result }
|
|
69
|
+
private latest(worker: Worker): WatchEvent | undefined { return worker.history.at(-1) }
|
|
70
|
+
private format(worker: Worker, action: 'opened' | 'recovered'): string {
|
|
71
|
+
const latest = this.latest(worker), lines = [`Workforce Watch ${action === 'opened' ? 'alert' : 'recovered'}: ${worker.id}`, action === 'opened' ? `Reason: ${worker.incident?.reason === 'terminal-failure' ? 'terminal failure' : 'missed check-in'}` : 'Recovery: required clean check-ins received', `Severity: ${worker.severity}`]
|
|
72
|
+
if (latest?.activity) lines.push(`Last activity: ${latest.activity}`); if (latest?.error) lines.push(`Last error: ${latest.error}`); if (latest?.runId) lines.push(`Run: ${latest.runId}`); if (latest) lines.push(`Last seen: ${latest.at}`); if (latest?.logsHint) lines.push(`Next: ${latest.logsHint}`); if (worker.runbookUrl) lines.push(`Runbook: ${worker.runbookUrl}`)
|
|
73
|
+
return lines.join('\n')
|
|
74
|
+
}
|
|
75
|
+
private page(worker: Worker, action: 'trigger' | 'resolve'): WorkforcePage {
|
|
76
|
+
const latest = this.latest(worker), incident = worker.incident!
|
|
77
|
+
const customDetails: Record<string, string> = { reason: incident.reason, lastSeen: latest?.at ?? new Date(worker.lastSeenAt).toISOString() }
|
|
78
|
+
if (latest?.activity) customDetails.activity = latest.activity
|
|
79
|
+
if (latest?.error) customDetails.error = latest.error
|
|
80
|
+
if (latest?.runId) customDetails.runId = latest.runId
|
|
81
|
+
if (latest?.logsHint) customDetails.logsHint = latest.logsHint
|
|
82
|
+
if (worker.runbookUrl) customDetails.runbookUrl = worker.runbookUrl
|
|
83
|
+
return { action, dedupKey: `ez:workforce:${worker.id}`, summary: `Workforce Watch ${action}: ${worker.id}`, severity: worker.severity, source: 'ez-workforce-watch', customDetails }
|
|
84
|
+
}
|
|
85
|
+
private reopen(worker: Worker, now: number, reason: Incident['reason']): void {
|
|
86
|
+
const incident = worker.incident
|
|
87
|
+
if (!incident) { worker.incident = { openedAt: now, reason }; return }
|
|
88
|
+
if (incident.recoveredAt === undefined) return
|
|
89
|
+
if (incident.pagerDutyResolvedAt !== undefined) delete incident.pagerDutyTriggeredAt
|
|
90
|
+
if (incident.telegramRecoveredAt !== undefined) delete incident.notifiedAt
|
|
91
|
+
delete incident.pagerDutyResolvedAt
|
|
92
|
+
delete incident.telegramRecoveredAt
|
|
93
|
+
delete incident.recoveredAt
|
|
94
|
+
incident.openedAt = now
|
|
95
|
+
incident.reason = reason
|
|
96
|
+
worker.recoveryChecks = 0
|
|
97
|
+
}
|
|
98
|
+
private async notifyOpen(state: State, worker: Worker): Promise<void> {
|
|
99
|
+
const incident = worker.incident; if (!incident) return
|
|
100
|
+
let failure: unknown, changed = false
|
|
101
|
+
if (incident.pagerDutyTriggeredAt === undefined && this.options.page) try { await this.options.page(this.page(worker, 'trigger')); incident.pagerDutyTriggeredAt = this.now(); changed = true } catch (error) { failure ??= error }
|
|
102
|
+
if (incident.notifiedAt === undefined && this.options.notify) try { await this.options.notify(this.format(worker, 'opened')); incident.notifiedAt = this.now(); changed = true } catch (error) { failure ??= error }
|
|
103
|
+
if (changed) await this.save(state)
|
|
104
|
+
if (failure) throw failure
|
|
105
|
+
}
|
|
106
|
+
private async notifyRecovery(state: State, worker: Worker): Promise<void> {
|
|
107
|
+
const incident = worker.incident; if (incident?.recoveredAt === undefined) return
|
|
108
|
+
let failure: unknown, changed = false
|
|
109
|
+
if (incident.pagerDutyTriggeredAt !== undefined && incident.pagerDutyResolvedAt === undefined && this.options.page) try { await this.options.page(this.page(worker, 'resolve')); incident.pagerDutyResolvedAt = this.now(); changed = true } catch (error) { failure ??= error }
|
|
110
|
+
if (incident.notifiedAt !== undefined && incident.telegramRecoveredAt === undefined && this.options.notify) try { await this.options.notify(this.format(worker, 'recovered')); incident.telegramRecoveredAt = this.now(); changed = true } catch (error) { failure ??= error }
|
|
111
|
+
const pagerDutyComplete = incident.pagerDutyTriggeredAt === undefined || incident.pagerDutyResolvedAt !== undefined
|
|
112
|
+
const telegramComplete = incident.notifiedAt === undefined || incident.telegramRecoveredAt !== undefined
|
|
113
|
+
if (changed && !(pagerDutyComplete && telegramComplete)) await this.save(state)
|
|
114
|
+
if (failure) throw failure
|
|
115
|
+
if (!(pagerDutyComplete && telegramComplete)) return
|
|
116
|
+
worker.incident = undefined
|
|
117
|
+
worker.recoveryChecks = 0
|
|
118
|
+
await this.save(state)
|
|
119
|
+
}
|
|
120
|
+
async enroll(token: string, input: unknown): Promise<{workerId:string;workerToken:string}> {
|
|
121
|
+
if (!sameSecret(token, hash(this.options.enrollmentToken))) throw new Error('Unauthorized')
|
|
122
|
+
const request = validateEnroll(input)
|
|
123
|
+
return this.enqueue(async () => { const state = await this.state(); if (state.workers[request.workerId]) throw new Error('Worker already enrolled'); const workerToken = randomBytes(32).toString('base64url'), now = this.now(); state.workers[request.workerId] = { id:request.workerId, tokenHash:hash(workerToken), checkInMs:request.checkInSeconds*1000, graceMs:request.graceSeconds*1000, severity:request.severity ?? 'critical', runbookUrl:request.runbookUrl, createdAt:now, lastSeenAt:now, recoveryChecks:0, history:[] }; await this.save(state); return {workerId:request.workerId,workerToken} })
|
|
124
|
+
}
|
|
125
|
+
async checkIn(workerId: string, token: string, input: unknown): Promise<{incident:boolean}> {
|
|
126
|
+
if (!WORKER_ID.test(workerId)) throw new Error('Unauthorized'); const request = validateCheckIn(input)
|
|
127
|
+
return this.enqueue(async () => { const state = await this.state(), worker = state.workers[workerId]; if (!worker || !sameSecret(token,worker.tokenHash)) throw new Error('Unauthorized'); const now = this.now(), event:WatchEvent={at:new Date(now).toISOString(),...request}; worker.lastSeenAt=now; worker.history=[...worker.history,event].slice(-MAX_HISTORY)
|
|
128
|
+
if (request.terminal) { worker.recoveryChecks=0; this.reopen(worker,now,'terminal-failure') }
|
|
129
|
+
else if (worker.incident?.recoveredAt !== undefined) { if(request.status==='failed') this.reopen(worker,now,worker.incident.reason) }
|
|
130
|
+
else if (worker.incident && request.status==='ok') { worker.recoveryChecks += 1; if (worker.recoveryChecks >= this.recoveryThreshold) { worker.incident.recoveredAt=now; await this.save(state); await this.notifyRecovery(state,worker); return {incident:Boolean(worker.incident)} } }
|
|
131
|
+
else if (request.status==='failed') worker.recoveryChecks=0
|
|
132
|
+
await this.save(state); await this.notifyOpen(state,worker); return {incident:Boolean(worker.incident)} })
|
|
133
|
+
}
|
|
134
|
+
async rotate(token: string, workerId: string): Promise<{workerId:string;workerToken:string}> {
|
|
135
|
+
if (!sameSecret(token, hash(this.options.enrollmentToken)) || !WORKER_ID.test(workerId)) throw new Error('Unauthorized')
|
|
136
|
+
return this.enqueue(async () => { const state=await this.state(), worker=state.workers[workerId]; if(!worker) throw new Error('Not found'); const workerToken=randomBytes(32).toString('base64url'); worker.tokenHash=hash(workerToken); worker.lastSeenAt=this.now(); worker.recoveryChecks=0; await this.save(state); return {workerId,workerToken} })
|
|
137
|
+
}
|
|
138
|
+
async evaluate(): Promise<void> { await this.enqueue(async () => { const state=await this.state(), now=this.now(); let changed=false; for (const worker of Object.values(state.workers)) if ((!worker.incident || worker.incident.recoveredAt !== undefined) && now > worker.lastSeenAt+worker.checkInMs+worker.graceMs) { this.reopen(worker,now,'missed-check-in'); worker.recoveryChecks=0; changed=true }; if(changed) await this.save(state); for(const worker of Object.values(state.workers)) { if(worker.incident?.recoveredAt !== undefined) await this.notifyRecovery(state,worker); else await this.notifyOpen(state,worker) } }) }
|
|
139
|
+
async inspect(workerId?: string): Promise<unknown> { return this.enqueue(async () => { const state=await this.state(); const at=(value:number|undefined):string|undefined=>value===undefined?undefined:new Date(value).toISOString(), redact=(worker:Worker) => ({id:worker.id,checkInSeconds:worker.checkInMs/1000,graceSeconds:worker.graceMs/1000,severity:worker.severity,runbookUrl:worker.runbookUrl,createdAt:new Date(worker.createdAt).toISOString(),lastSeenAt:new Date(worker.lastSeenAt).toISOString(),incident:worker.incident&&{openedAt:at(worker.incident.openedAt),reason:worker.incident.reason,notifiedAt:at(worker.incident.notifiedAt),pagerDutyTriggeredAt:at(worker.incident.pagerDutyTriggeredAt),recoveredAt:at(worker.incident.recoveredAt),telegramRecoveredAt:at(worker.incident.telegramRecoveredAt),pagerDutyResolvedAt:at(worker.incident.pagerDutyResolvedAt)},history:worker.history}); if(workerId){const worker=state.workers[workerId];if(!worker)throw new Error('Not found');return redact(worker)} return Object.values(state.workers).map(redact) }) }
|
|
140
|
+
start(evaluateMs: number): void { if(this.timer)return; void this.evaluate().catch(e=>this.options.log?.(`Initial evaluation failed: ${errorText(e)}`)); this.timer=setInterval(()=>void this.evaluate().catch(e=>this.options.log?.(`Evaluation failed: ${errorText(e)}`)),evaluateMs);this.timer.unref() }
|
|
141
|
+
stop(): void { if(this.timer)clearInterval(this.timer);this.timer=undefined }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const bearer = (request:IncomingMessage): string|undefined => request.headers.authorization?.startsWith('Bearer ') ? request.headers.authorization.slice(7) : undefined
|
|
145
|
+
const json = (response:ServerResponse,status:number,value:unknown):void => { response.writeHead(status,{'content-type':'application/json; charset=utf-8','cache-control':'no-store'});response.end(JSON.stringify(value)) }
|
|
146
|
+
const requestJson = async (request:IncomingMessage):Promise<unknown> => { let body='';for await(const chunk of request){body+=chunk;if(Buffer.byteLength(body)>MAX_BODY_BYTES)throw new Error('Request too large')}try{return JSON.parse(body)}catch{throw new Error('Invalid JSON')} }
|
|
147
|
+
export class WorkforceWatchServer {
|
|
148
|
+
private server?:Server
|
|
149
|
+
constructor(private readonly watch:WorkforceWatch,private readonly enrollmentToken:string,private readonly log:(message:string)=>void=console.error) {}
|
|
150
|
+
async listen(port:number,host:string):Promise<void> { if(this.server)throw new Error('Server already listening');this.server=createServer((request,response)=>void this.route(request,response));await new Promise<void>((resolve,reject)=>{this.server!.once('error',reject);this.server!.listen(port,host,resolve)}) }
|
|
151
|
+
async close():Promise<void> { if(!this.server)return;await new Promise<void>((resolve,reject)=>this.server!.close(error=>error?reject(error):resolve()));this.server=undefined }
|
|
152
|
+
port():number { const address=this.server?.address(); if (!address || typeof address === 'string') throw new Error('Server is not listening'); return address.port }
|
|
153
|
+
private authorized(token:string|undefined):boolean { return Boolean(token)&&sameSecret(token!,hash(this.enrollmentToken)) }
|
|
154
|
+
private async route(request:IncomingMessage,response:ServerResponse):Promise<void> { try { const url=new URL(request.url??'/','http://localhost');if(request.method==='GET'&&url.pathname==='/healthz')return json(response,200,{status:'ok'});if(request.method==='POST'&&url.pathname==='/v1/enroll'){const token=bearer(request);if(!this.authorized(token))return json(response,401,{error:'unauthorized'});return json(response,201,await this.watch.enroll(token!,await requestJson(request)))}if(request.method==='GET'&&(url.pathname==='/v1/workers'||/^\/v1\/workers\/[a-z][a-z0-9-]{0,63}$/.test(url.pathname))){if(!this.authorized(bearer(request)))return json(response,401,{error:'unauthorized'});return json(response,200,await this.watch.inspect(url.pathname==='/v1/workers'?undefined:url.pathname.slice(12)))}const rotate=request.method==='POST'&&url.pathname.match(/^\/v1\/workers\/([a-z][a-z0-9-]{0,63})\/rotate$/);if(rotate){const token=bearer(request);if(!this.authorized(token))return json(response,401,{error:'unauthorized'});return json(response,200,await this.watch.rotate(token!,rotate[1]))}const match=request.method==='POST'&&url.pathname.match(/^\/v1\/workers\/([a-z][a-z0-9-]{0,63})\/check-in$/);if(match){try{return json(response,200,await this.watch.checkIn(match[1],bearer(request)??'',await requestJson(request)))}catch(error){if(errorText(error)==='Unauthorized')return json(response,401,{error:'unauthorized'});throw error}}return json(response,404,{error:'not found'}) }catch(error){this.log(`Workforce Watch request failed: ${errorText(error)}`);return json(response,400,{error:'invalid request'})} }
|
|
155
|
+
}
|
|
@@ -11,3 +11,14 @@ brief, relevant files, acceptance criteria, and a stopping point. Choose
|
|
|
11
11
|
delegation, model, and effort from the task—not a fixed routing rule. Keep one
|
|
12
12
|
writer per workspace; the primary agent owns integration, verification, and
|
|
13
13
|
external actions.
|
|
14
|
+
|
|
15
|
+
## Telegram replies
|
|
16
|
+
|
|
17
|
+
Use the messaging CLI for the current run's source chat, normally the paired
|
|
18
|
+
owner/admin Telegram chat. It cannot choose another recipient; never put a chat
|
|
19
|
+
ID in a message command. Format the payload as Telegram text: use actual newline
|
|
20
|
+
characters for paragraphs and lists. The literal strings `\n`, `\\n`, or `/n` are
|
|
21
|
+
visible text, not line breaks. For multiline replies, prefer
|
|
22
|
+
`ezenciel-agents-message --text-file ./work/reply.md` and put the real line
|
|
23
|
+
breaks in that file. Keep replies concise and use ordinary Markdown where it
|
|
24
|
+
improves readability.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Responsive conversation
|
|
2
|
+
|
|
3
|
+
Treat a chat channel as a conversation with the person, whether Telegram,
|
|
4
|
+
WhatsApp, or another connected channel. Keep the turn focused and respond
|
|
5
|
+
concisely using the current conversation and verified receipts. Read more
|
|
6
|
+
context only when the answer or action requires it; do not reload history,
|
|
7
|
+
explore files, or narrate a plan for a simple reply.
|
|
8
|
+
|
|
9
|
+
Complete small authorized actions directly and check their receipts. For
|
|
10
|
+
substantial work, use an available, authorized durable handoff tool, then end
|
|
11
|
+
the conversational turn after it returns a task ID. Do not wait or poll here
|
|
12
|
+
for the worker. Never claim work was delegated before that receipt exists.
|
|
13
|
+
If this session lacks a delegation capability, use its available reporting
|
|
14
|
+
path to explain the limitation; do not invent a tool or expand permissions.
|
|
15
|
+
|
|
16
|
+
Choose the worker's model and effort for the difficulty and consequences of
|
|
17
|
+
the job, independently of the conversational choice. Include the objective,
|
|
18
|
+
relevant context and paths, constraints, authorized actions, acceptance checks,
|
|
19
|
+
and where to deliver the result. Use native subagents within the worker when
|
|
20
|
+
useful. Preserve one writer per workspace and coordinate shared resources.
|
|
21
|
+
The worker owns completing and verifying the job and delivering the result;
|
|
22
|
+
a quick conversational reply is not completion. If the person asks for status,
|
|
23
|
+
check actual task evidence and distinguish queued, running, and verified results.
|
|
@@ -8,6 +8,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
|
8
8
|
import { promisify } from 'node:util'
|
|
9
9
|
import test from 'node:test'
|
|
10
10
|
import { desktopJobPrompt } from '../src/desktop-bridge.js'
|
|
11
|
+
import { chatGuidance } from '../src/agent-guidance.js'
|
|
11
12
|
import { executorJobPrompt } from '../src/executor.js'
|
|
12
13
|
import { taskArguments } from '../src/task-executor.js'
|
|
13
14
|
import { initializeWorkspace } from '../src/workspace.js'
|
|
@@ -27,7 +28,21 @@ test('CLI and desktop prompt builders use current package guidance', async () =>
|
|
|
27
28
|
['desktop', desktopJobPrompt('tg_owner_gui', ['owner request'], undefined, '/tmp/bin', '/tmp/control')],
|
|
28
29
|
] as const
|
|
29
30
|
for (const [kind, prompt] of prompts)
|
|
31
|
+
{
|
|
30
32
|
assert.ok(prompt.includes(shared), `${kind} prompt is missing the current package guidance`)
|
|
33
|
+
assert.ok(prompt.includes(chatGuidance()), `${kind} prompt is missing channel guidance`)
|
|
34
|
+
}
|
|
35
|
+
assert.ok(!executorJobPrompt('r_schedule_job', ['work']).includes(chatGuidance()))
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('shared guidance teaches source-chat delivery and real Telegram line breaks', async () => {
|
|
39
|
+
const shared = await readFile(sharedGuidancePath, 'utf8')
|
|
40
|
+
assert.ok(shared.includes("current run's source chat"))
|
|
41
|
+
assert.match(shared, /actual newline\s+characters/)
|
|
42
|
+
assert.ok(shared.includes('`\\n`'))
|
|
43
|
+
assert.ok(shared.includes('`\\\\n`'))
|
|
44
|
+
assert.ok(shared.includes('`/n`'))
|
|
45
|
+
assert.ok(shared.includes('ezenciel-agents-message --text-file ./work/reply.md'))
|
|
31
46
|
})
|
|
32
47
|
|
|
33
48
|
test('package guidance resolution ignores a workspace shadow file', async () => {
|
package/test/ai.test.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { mkdtemp, mkdir, writeFile, readFile, rm } from 'node:fs/promises'
|
|
|
4
4
|
import { tmpdir } from 'node:os'
|
|
5
5
|
import { join } from 'node:path'
|
|
6
6
|
import { ControlStore } from '../src/control-state.js'
|
|
7
|
-
import { initialPreset, readModels, isPreset } from '../src/ai.js'
|
|
7
|
+
import { initialPreset, chatPreset, readModels, isPreset } from '../src/ai.js'
|
|
8
8
|
import { EXECUTOR_REGISTRY, nativeSessionId } from '../src/executor.js'
|
|
9
9
|
import { InboxStore } from '../src/inbox.js'
|
|
10
10
|
import type { Update } from 'grammy/types'
|
|
@@ -80,10 +80,13 @@ test('model catalog projects native metadata only, excluding hidden entries and
|
|
|
80
80
|
{ slug: 'fixture-model', display_name: 'Fixture', visibility: 'list',
|
|
81
81
|
supported_reasoning_levels: [{ effort: 'medium' }, { effort: 'bad value' }],
|
|
82
82
|
model_messages: 'Untrusted instructions must not be imported', api_key: 'fixture-secret' },
|
|
83
|
+
{ slug: 'gpt-5.6-luna', display_name: 'Luna', visibility: 'list',
|
|
84
|
+
supported_reasoning_levels: [{ effort: 'high' }, { effort: 'xhigh' }, { effort: 'max' }] },
|
|
83
85
|
{ slug: 'hidden-model', visibility: 'hide' },
|
|
84
86
|
] }))
|
|
85
87
|
assert.deepEqual(await readModels(home, async (cli) => cli === 'codex'), [
|
|
86
88
|
{ cli: 'codex', model: 'fixture-model', name: 'Fixture', efforts: ['medium'] },
|
|
89
|
+
{ cli: 'codex', model: 'gpt-5.6-luna', name: 'Luna', efforts: ['high', 'xhigh'] },
|
|
87
90
|
])
|
|
88
91
|
assert.equal(isPreset({ id: 'x', name: 'x', cli: 'grok', model: '--shell escape' }), false)
|
|
89
92
|
} finally { await rm(home, { recursive: true, force: true }) }
|
|
@@ -144,3 +147,39 @@ for (const cli of ['codex', 'codex-gui']) {
|
|
|
144
147
|
} finally { await rm(dir, { recursive: true, force: true }) }
|
|
145
148
|
})
|
|
146
149
|
}
|
|
150
|
+
|
|
151
|
+
for (const cli of ['codex', 'codex-gui']) {
|
|
152
|
+
test(`${cli} separates responsive chat from worker defaults and preserves upgrade choices`, async () => {
|
|
153
|
+
const dir = await mkdtemp(join(tmpdir(), 'ez-chat-default-'))
|
|
154
|
+
try {
|
|
155
|
+
const store = new ControlStore(dir, 1000)
|
|
156
|
+
await store.syncClientPresets(chatPreset(cli), [])
|
|
157
|
+
const chat = await store.captureChoice(chatPreset(cli))
|
|
158
|
+
assert.equal(chat.preset.model, 'gpt-5.6-sol')
|
|
159
|
+
assert.equal(chat.preset.effort, 'medium')
|
|
160
|
+
assert.equal(initialPreset(cli).model, 'gpt-5.6-terra')
|
|
161
|
+
assert.equal(initialPreset(cli).effort, 'high')
|
|
162
|
+
const old = initialPreset(cli)
|
|
163
|
+
await store.savePreset(old)
|
|
164
|
+
await store.defaultPreset(old.id)
|
|
165
|
+
await store.resetSession()
|
|
166
|
+
const captured = await store.captureChoice(old)
|
|
167
|
+
await store.syncClientPresets(chatPreset(cli), [])
|
|
168
|
+
assert.deepEqual(await store.captureChoice(chatPreset(cli)), captured)
|
|
169
|
+
assert.equal((await store.aiState(chatPreset(cli))).presets.filter(p => p.id === 'chat-default').length, 1)
|
|
170
|
+
} finally { await rm(dir, { recursive: true, force: true }) }
|
|
171
|
+
})
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
test('upgrades expose responsive chat without replacing an existing default or queued snapshot', async () => {
|
|
175
|
+
const dir = await mkdtemp(join(tmpdir(), 'ez-chat-upgrade-'))
|
|
176
|
+
try {
|
|
177
|
+
const store = new ControlStore(dir, 1000), old = initialPreset('codex')
|
|
178
|
+
const captured = await store.captureChoice(old)
|
|
179
|
+
await store.syncClientPresets(chatPreset('codex'), [])
|
|
180
|
+
assert.deepEqual(await store.captureChoice(chatPreset('codex')), captured)
|
|
181
|
+
const state = await store.aiState(chatPreset('codex'))
|
|
182
|
+
assert.equal(state.defaultId, old.id)
|
|
183
|
+
assert.ok(state.presets.some(p => p.id === 'chat-default'))
|
|
184
|
+
} finally { await rm(dir, { recursive: true, force: true }) }
|
|
185
|
+
})
|
|
@@ -133,6 +133,8 @@ test('relay launches the approved initial task and routes only matching replies
|
|
|
133
133
|
await f.relay.drainSources()
|
|
134
134
|
assert.equal(f.launches.length, 1); assert.equal(f.launches[0].options.cli, 'codex')
|
|
135
135
|
assert.equal(f.launches[0].options.isResume, false)
|
|
136
|
+
assert.equal(f.launches[0].options.model, 'gpt-5.6-sol')
|
|
137
|
+
assert.equal(f.launches[0].options.effort, 'medium')
|
|
136
138
|
f.children[0].kill()
|
|
137
139
|
await until(async () => !(await f.runs.list()).some(r => r.status === 'running'))
|
|
138
140
|
const receivedAt = Date.now()
|
|
@@ -141,6 +143,8 @@ test('relay launches the approved initial task and routes only matching replies
|
|
|
141
143
|
await f.relay.drainSources()
|
|
142
144
|
assert.equal(f.launches.length, 2); assert.equal(f.launches[1].options.eventSource, 'fixture')
|
|
143
145
|
assert.notEqual(f.launches[1].options.sessionId, f.launches[0].options.sessionId)
|
|
146
|
+
assert.equal(f.launches[1].options.model, 'gpt-5.6-sol')
|
|
147
|
+
assert.equal(f.launches[1].options.effort, 'medium')
|
|
144
148
|
f.children[1].kill()
|
|
145
149
|
await until(async () => !(await f.runs.list()).some(r => r.status === 'running'))
|
|
146
150
|
await f.relay.drainSources()
|
package/test/failure.test.ts
CHANGED
|
@@ -137,7 +137,7 @@ test('relay shutdown waits for executor cleanup and final run state', async () =
|
|
|
137
137
|
} finally {release();await relay.stop();await rm(dir,{recursive:true,force:true})}
|
|
138
138
|
})
|
|
139
139
|
|
|
140
|
-
for (const cleanupFails of [false,true]) test(`
|
|
140
|
+
for (const cleanupFails of [false,true]) test(`polling conflict preserves work until an explicit shutdown (cleanup fails: ${cleanupFails})`, async t => {
|
|
141
141
|
const dir=await mkdtemp(join(tmpdir(),'ez-polling-conflict-')),control=new ControlStore(dir,1000),runs=new RunStore(dir)
|
|
142
142
|
let release!:()=>void,entered!:()=>void,releaseDelivery!:()=>void,sending!:()=>void,child:ReturnType<typeof spawn>|undefined,polls=0
|
|
143
143
|
const gate=new Promise<void>(resolve=>{release=resolve}),cleaning=new Promise<void>(resolve=>{entered=resolve})
|
|
@@ -170,20 +170,26 @@ for (const cleanupFails of [false,true]) test(`fatal polling conflict waits for
|
|
|
170
170
|
const delivery=relay.drainOutbox()
|
|
171
171
|
await deliveryStarted
|
|
172
172
|
let finished=false
|
|
173
|
-
const start=
|
|
174
|
-
await
|
|
173
|
+
const start=relay.start().finally(()=>{finished=true})
|
|
174
|
+
await until(async()=>polls===1)
|
|
175
|
+
assert.equal(sourceStops,0,'a polling conflict must not stop the relay')
|
|
176
|
+
assert.equal((await runs.get('tg_92'))?.status,'running')
|
|
177
|
+
assert.ok(child && child.exitCode===null && child.signalCode===null)
|
|
175
178
|
const stopping=relay.stop()
|
|
176
179
|
assert.equal(relay.stop(),stopping,'concurrent stop calls share one promise')
|
|
177
180
|
const stopped=cleanupFails?assert.rejects(stopping,/Synthetic shutdown failure/):stopping
|
|
178
|
-
assert.equal(finished,false,'
|
|
181
|
+
assert.equal(finished,false,'relay start must wait for the explicit shutdown')
|
|
182
|
+
await cleaning
|
|
179
183
|
release()
|
|
180
184
|
await until(async()=>(await runs.get('tg_92'))?.status!=='running')
|
|
181
|
-
assert.equal(finished,false,'
|
|
182
|
-
releaseDelivery();await delivery
|
|
185
|
+
assert.equal(finished,false,'shutdown must wait for the in-flight delivery receipt')
|
|
186
|
+
releaseDelivery();await delivery
|
|
187
|
+
if(cleanupFails) await assert.rejects(start,/Synthetic shutdown failure/);else await start
|
|
188
|
+
await stopped
|
|
183
189
|
assert.equal(relay.stop(),stopping,'finished shutdown remains idempotent')
|
|
184
190
|
assert.equal(sourceStops,1)
|
|
185
191
|
assert.deepEqual(JSON.parse(await readFile(join(dir,'outbox',`${item.id}.sent.json`),'utf8')).receipt.messageIds,[1])
|
|
186
|
-
assert.equal(polls,1,'a conflict must not start another polling loop')
|
|
192
|
+
assert.equal(polls,1,'a conflict must not start another polling loop before the retry delay')
|
|
187
193
|
assert.equal(relay.bot.isRunning(),false)
|
|
188
194
|
assert.ok(child && (child.exitCode!==null || child.signalCode!==null))
|
|
189
195
|
assert.notEqual((await runs.get('tg_92'))?.status,'running')
|
|
@@ -160,6 +160,22 @@ test('client tolerates missing heartbeat and consumes completion before checking
|
|
|
160
160
|
} finally {client.kill();await closed;await rm(root,{recursive:true,force:true})}
|
|
161
161
|
})
|
|
162
162
|
|
|
163
|
+
test('client records a relay interruption before exiting 130',async()=>{
|
|
164
|
+
const root=await mkdtemp(path.join(tmpdir(),'ez-host-interrupt-'))
|
|
165
|
+
const directory=path.join(root,'host-executor');await mkdir(directory)
|
|
166
|
+
const client=spawn(process.execPath,['--import',fileURLToPath(new URL('../node_modules/tsx/dist/loader.mjs',import.meta.url)),fileURLToPath(new URL('../src/host-executor-client.ts',import.meta.url)),root,'tg_97'],{stdio:['pipe','pipe','pipe']})
|
|
167
|
+
let stderr='';client.stderr.on('data',chunk=>stderr+=chunk);client.stdout.resume()
|
|
168
|
+
client.stdin.end(JSON.stringify({texts:['test'],options:{}}))
|
|
169
|
+
try {
|
|
170
|
+
for(let n=0;n<100;n++){try{await readFile(path.join(directory,'tg_97.request.json'));break}catch{await new Promise(r=>setTimeout(r,20))}}
|
|
171
|
+
const closed=new Promise<number|null>(resolve=>client.once('close',resolve))
|
|
172
|
+
client.kill('SIGTERM')
|
|
173
|
+
assert.equal(await closed,130)
|
|
174
|
+
assert.equal(await readFile(path.join(directory,'tg_97.cancel'),'utf8'),'')
|
|
175
|
+
assert.match(stderr,/Host executor client interrupted by SIGTERM/)
|
|
176
|
+
} finally {if(client.exitCode===null && client.signalCode===null)client.kill();await rm(root,{recursive:true,force:true})}
|
|
177
|
+
})
|
|
178
|
+
|
|
163
179
|
test('client cancels on stale or invalid heartbeat instead of waiting indefinitely',async()=>{
|
|
164
180
|
for(const heartbeat of [{at:Date.now()-60000},{at:'invalid'}]) {
|
|
165
181
|
const root=await mkdtemp(path.join(tmpdir(),'ez-heartbeat-invalid-'))
|
|
@@ -225,6 +225,10 @@ test('four-item menu is owner-only; saved AI buttons work and forged/stale butto
|
|
|
225
225
|
assert.match(f.replies.at(-1)!, /Menu expired/)
|
|
226
226
|
await f.relay.bot.handleUpdate(message(7, '/settings'))
|
|
227
227
|
assert.match(f.replies.at(-1)!, /Default for new conversations/)
|
|
228
|
+
await f.relay.bot.handleUpdate(message(8, '/status'))
|
|
229
|
+
assert.ok(f.keyboards.at(-1)!.flat().some((button) => button.text === 'Scheduled tasks'))
|
|
230
|
+
await f.relay.bot.handleUpdate(callback(9, 'menu:scheduled-tasks'))
|
|
231
|
+
assert.match(f.replies.at(-1)!, /No scheduled tasks for this owner/)
|
|
228
232
|
assert.equal(f.launched.length, 0)
|
|
229
233
|
} finally { await f.close() }
|
|
230
234
|
})
|
|
@@ -11,7 +11,7 @@ import { taskArguments } from '../src/task-executor.js'
|
|
|
11
11
|
import { runCodexSession } from '../src/codex-session.js'
|
|
12
12
|
import { runDesktopTurn } from '../src/desktop-bridge.js'
|
|
13
13
|
|
|
14
|
-
test('all model selections and launches reject effort above high before spawning', async () => {
|
|
14
|
+
test('all non-Luna model selections and launches reject effort above high before spawning', async () => {
|
|
15
15
|
for (const cli of ['codex', 'codex-gui', 'grok', 'claude', 'opencode', 'agy']) {
|
|
16
16
|
for (const effort of ['xhigh', 'max', 'ultra', 'unknown']) {
|
|
17
17
|
const preset = { id:'blocked', name:'Blocked', cli, model:'any-model', effort }
|
|
@@ -23,6 +23,14 @@ test('all model selections and launches reject effort above high before spawning
|
|
|
23
23
|
await assert.rejects(runDesktopTurn({workspace:'/unused',controlDir:'/unused',binDir:'/unused',runId:'unused',prompt:'',effort:'ultra'}), /capped at high/)
|
|
24
24
|
})
|
|
25
25
|
|
|
26
|
+
test('Codex Luna accepts xhigh while every other model and CLI remains capped', async () => {
|
|
27
|
+
const luna = { id:'luna', name:'Luna', cli:'codex', model:'gpt-5.6-luna', effort:'xhigh' }
|
|
28
|
+
await validateSelection(luna, [{ cli:'codex', model:'gpt-5.6-luna', name:'Luna', efforts:['high','xhigh'] }], async () => true)
|
|
29
|
+
assert.deepEqual(executionDefaults('codex', { model:'gpt-5.6-luna', effort:'xhigh' }), { model:'gpt-5.6-luna', effort:'xhigh' })
|
|
30
|
+
assert.throws(() => executionDefaults('grok', { model:'gpt-5.6-luna', effort:'xhigh' }), /capped at high/)
|
|
31
|
+
assert.throws(() => executionDefaults('codex', { model:'gpt-5.6-terra', effort:'xhigh' }), /capped at high/)
|
|
32
|
+
})
|
|
33
|
+
|
|
26
34
|
test('restricted tasks pin Terra high, preserve explicit choices and reject higher effort', () => {
|
|
27
35
|
const args = taskArguments('/unused', ['broker'], 'prompt')
|
|
28
36
|
assert.equal(args[args.indexOf('--model')+1], 'gpt-5.6-terra')
|
|
@@ -225,3 +225,52 @@ test('standalone rejects relay binding and preserves literal plugin arguments ac
|
|
|
225
225
|
await fs.writeFile(path.join(f.home,'config.json'),JSON.stringify({schemaVersion:1,workspace:f.workspace,catalog:{},deploymentDir:'/missing'}));
|
|
226
226
|
await assert.rejects(exec(launcher,['status']),/deployment-bound/); // never hide a broken relay binding
|
|
227
227
|
});
|
|
228
|
+
|
|
229
|
+
test('existing folders are read-only, persistent and fail closed when missing', async t => {
|
|
230
|
+
const f=await fixture(t);await init(f.home,f.workspace);
|
|
231
|
+
const inspected=JSON.parse((await f.call('plugins','inspect','sample','--source',f.source)).stdout);
|
|
232
|
+
await f.call('plugins','install','sample','--source',f.source,'--revision',inspected.revision);
|
|
233
|
+
const source=await fs.realpath(f.workspace);
|
|
234
|
+
await f.call('plugins','folder-bind','sample','--service','sample','--source',source,'--target','/data/files');
|
|
235
|
+
const config=JSON.parse(await fs.readFile(path.join(f.home,'config.json'),'utf8'));
|
|
236
|
+
assert.deepEqual(config.folders.sample,[{service:'sample',source,target:'/data/files'}]);
|
|
237
|
+
const c=JSON.parse(await fs.readFile(path.join(f.home,'packages/sample/compose.json'),'utf8'));
|
|
238
|
+
assert.deepEqual(c.services.sample.volumes.find(v=>v.target==='/data/files'),{type:'bind',source,target:'/data/files',read_only:true,bind:{create_host_path:false}});
|
|
239
|
+
await assert.rejects(f.call('plugins','folder-bind','sample','--service','sample','--source',source,'--target','/data'),/child of a declared volume|Overlapping/);
|
|
240
|
+
await assert.rejects(f.call('plugins','folder-bind','sample','--service','sample','--source',source,'--target','/data/files/child'),/Overlapping/);
|
|
241
|
+
await assert.rejects(f.call('plugins','folder-bind','sample','--service','sample','--source',await fs.realpath(f.home),'--target','/data/private'),/private plugin state/);
|
|
242
|
+
await assert.rejects(f.call('plugins','folder-bind','sample','--service','sample','--source','/','--target','/data/root'),/private plugin state/);
|
|
243
|
+
await f.call('plugins','start','sample');
|
|
244
|
+
await fs.rename(f.workspace,f.workspace+'-moved');
|
|
245
|
+
await assert.rejects(f.call('plugins','start','sample'),/ENOENT/);
|
|
246
|
+
await assert.rejects(f.call('sample','read'),/ENOENT/);
|
|
247
|
+
await f.call('plugins','folder-unbind','sample','--service','sample','--target','/data/files');
|
|
248
|
+
assert.deepEqual(JSON.parse((await f.call('plugins','folders','sample')).stdout),[]);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test('folder bindings survive compatible descriptors and reject incompatible updates',async t=>{
|
|
252
|
+
const f=await fixture(t);const p=await snapshot(f.source);
|
|
253
|
+
const record={...p,project:'ezp-test-sample'};
|
|
254
|
+
const config={workspace:f.workspace,folders:{sample:[{service:'sample',source:f.workspace,target:'/data/files'}]}};
|
|
255
|
+
assert.equal(compose(config,record).services.sample.volumes.find(v=>v.target==='/data/files').read_only,true);
|
|
256
|
+
const next=structuredClone(record);next.deployment.services.sample.volumes={data:'/new-state'};
|
|
257
|
+
assert.throws(()=>compose(config,next),/child of a declared volume/);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test('folder rebind rejects every live project container including one-shots',async t=>{
|
|
261
|
+
const f=await fixture(t);await init(f.home,f.workspace);const p=await snapshot(f.source);
|
|
262
|
+
await f.call('plugins','install','sample','--source',f.source,'--revision',p.revision);
|
|
263
|
+
await fs.writeFile(path.join(f.fake,'docker'),`#!${process.execPath}\nif(process.argv[2]==='ps')console.log('paused-or-restarting-or-one-shot');\n`,{mode:0o700});
|
|
264
|
+
await assert.rejects(f.call('plugins','folder-bind','sample','--service','sample','--source',await fs.realpath(f.workspace),'--target','/data/files'),/Stop the plugin/);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test('registered calls honor registry lock and regenerate stale Compose from current folders',async t=>{
|
|
268
|
+
const f=await fixture(t);await init(f.home,f.workspace);const p=await snapshot(f.source);
|
|
269
|
+
await f.call('plugins','install','sample','--source',f.source,'--revision',p.revision);
|
|
270
|
+
await f.call('plugins','folder-bind','sample','--service','sample','--source',await fs.realpath(f.workspace),'--target','/data/files');
|
|
271
|
+
const file=path.join(f.home,'packages/sample/compose.json');await fs.writeFile(file,'{}');
|
|
272
|
+
await f.call('sample','read');
|
|
273
|
+
assert.equal(JSON.parse(await fs.readFile(file,'utf8')).services.sample.volumes.find(v=>v.target==='/data/files').read_only,true);
|
|
274
|
+
await fs.writeFile(path.join(f.home,'registry.lock'),'test');
|
|
275
|
+
await assert.rejects(f.call('sample','read'),/busy|EEXIST|locked/i);
|
|
276
|
+
});
|
package/test/reply.test.ts
CHANGED
|
@@ -129,3 +129,25 @@ test('a parallel reply delivered during a normal turn is retained for the follow
|
|
|
129
129
|
assert.deepEqual(await parallelReplyHistory(root,current),[])
|
|
130
130
|
}finally{await rm(root,{recursive:true,force:true})}
|
|
131
131
|
})
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
test('reply handoff accepts independent worker choices and rejects invalid or unauthorized overrides', async () => {
|
|
135
|
+
const root=await mkdtemp(join(tmpdir(),'ez-reply-worker-')), runs=new RunStore(root)
|
|
136
|
+
try {
|
|
137
|
+
await ownerRun(root,'owner')
|
|
138
|
+
await runs.create({id:'tg_10',chatId:101,telegramUserId:101,texts:['Analyze the report'],execution:{sessionId:'c5dd1edc-be24-47b8-a579-0bc70f44cf43',preset:{id:'chat',name:'Chat',cli:'codex',model:'gpt-5.6-sol',effort:'medium'}}})
|
|
139
|
+
await runs.patch('tg_10',{status:'running',replyOnly:true})
|
|
140
|
+
for (const args of [{model:42}, {model:'bad model'}, {effort:'ultra'}, {effort:'invalid'}, {cli:'claude'}])
|
|
141
|
+
await assert.rejects(replyCall(root,'tg_10',root,'defer',{text:'Analyze and verify the result',...args}))
|
|
142
|
+
await assert.rejects(replyCall(root,'tg_10',root,'send',{text:'Hello',model:'gpt-6-astra'}),/Unexpected/)
|
|
143
|
+
await replyCall(root,'tg_10',root,'defer',{text:'Analyze and verify the result',model:'gpt-6-astra',effort:'high'})
|
|
144
|
+
const file=join(root,'schedules','s_reply_tg_10.json')
|
|
145
|
+
const saved=JSON.parse(await readFile(file,'utf8'))
|
|
146
|
+
assert.equal(saved.execution.preset.model,'gpt-6-astra')
|
|
147
|
+
assert.equal(saved.execution.preset.effort,'high')
|
|
148
|
+
await replyCall(root,'tg_10',root,'defer',{text:'retry',model:'gpt-5.6-sol',effort:'low'})
|
|
149
|
+
assert.deepEqual(JSON.parse(await readFile(file,'utf8')),saved)
|
|
150
|
+
await new ControlStore(root,900000).revokeOwner()
|
|
151
|
+
await assert.rejects(replyCall(root,'tg_10',root,'defer',{text:'after revocation',model:'gpt-6-astra'}),/owner-mismatch/)
|
|
152
|
+
} finally { await rm(root,{recursive:true,force:true}) }
|
|
153
|
+
})
|
package/test/runs.test.ts
CHANGED
|
@@ -22,6 +22,13 @@ test('creates a queued run and binds chat id outside the workspace', async () =>
|
|
|
22
22
|
assert.equal((await store.get(created.id))?.chatId, 101)
|
|
23
23
|
}))
|
|
24
24
|
|
|
25
|
+
test('running does not reap a process from another executor namespace', async () => fixture(async (store) => {
|
|
26
|
+
const created = await store.create({ chatId: 101, telegramUserId: 101, texts: ['host-backed'] })
|
|
27
|
+
await store.patch(created.id, { status: 'running', pid: 999_999_999 })
|
|
28
|
+
assert.equal((await store.running())?.id, created.id)
|
|
29
|
+
assert.equal((await store.get(created.id))?.status, 'running')
|
|
30
|
+
}))
|
|
31
|
+
|
|
25
32
|
test('ez message writes an outbox item for the bound run, not a telegram send', async () => fixture(async (store) => {
|
|
26
33
|
const created = await store.create({ chatId: 9, telegramUserId: 9, texts: ['hello'] })
|
|
27
34
|
await store.patch(created.id, { status: 'running' })
|
|
@@ -24,6 +24,8 @@ test('public scheduler CLI saves literal text, reads back, edits, pauses, and re
|
|
|
24
24
|
assert.equal(saved.text,args.at(-1));assert.equal(saved.execution.preset.cli,'codex')
|
|
25
25
|
assert.equal(saved.execution.preset.model,'gpt-5.6-terra');assert.equal(saved.execution.preset.effort,'high')
|
|
26
26
|
await assert.rejects(exec(process.execPath,[bin,'create','blocked','--at','2027-09-09T09:00:00+04:00','--text','test','--effort','xhigh'],{env}),/capped at high/)
|
|
27
|
+
const luna=JSON.parse((await exec(process.execPath,[bin,'create','luna','--at','2027-09-10T09:00:00+04:00','--text','Luna xhigh task','--model','gpt-5.6-luna','--effort','xhigh'],{env})).stdout)
|
|
28
|
+
assert.equal(luna.execution.preset.model,'gpt-5.6-luna');assert.equal(luna.execution.preset.effort,'xhigh')
|
|
27
29
|
assert.equal(saved.nextEligibleAt,'2027-09-09T05:00:00.000Z')
|
|
28
30
|
await assert.rejects(exec(process.execPath,[bin,...args],{env}),/exists/)
|
|
29
31
|
assert.equal(JSON.parse((await exec(process.execPath,[bin,'pause','test'],{env})).stdout).enabled,false)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { access, mkdtemp, readFile, readdir, rm } from 'node:fs/promises'
|
|
4
|
+
import { randomUUID } from 'node:crypto'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import { tmpdir } from 'node:os'
|
|
7
|
+
import { Scheduler } from '../src/scheduler.js'
|
|
8
|
+
import { scheduledTasksText } from '../src/scheduled-tasks.js'
|
|
9
|
+
|
|
10
|
+
const owner = { telegramUserId: 101, telegramChatId: 101, pairedAt: '2026-09-11T00:00:00.000Z' }
|
|
11
|
+
const execution = { sessionId: randomUUID(), preset: { id: 'fixture', name: 'Fixture', cli: 'codex' } }
|
|
12
|
+
|
|
13
|
+
test('scheduled task view is read-only, owner-bound, and shows stored task contents', async (t) => {
|
|
14
|
+
const dir = await mkdtemp(join(tmpdir(), 'ez-scheduled-tasks-'))
|
|
15
|
+
t.after(() => rm(dir, { recursive: true, force: true }))
|
|
16
|
+
const scheduler = new Scheduler(dir)
|
|
17
|
+
|
|
18
|
+
assert.deepEqual(await scheduler.listReadOnly(), [])
|
|
19
|
+
await assert.rejects(access(join(dir, 'schedules')), /ENOENT/)
|
|
20
|
+
|
|
21
|
+
await scheduler.save({
|
|
22
|
+
id: 'owner-task', name: 'Daily report', text: 'Read the ledger and send the owner a concise report.', owner, execution, enabled: true,
|
|
23
|
+
trigger: { cron: '0 9 * * 1-5', timezone: 'Asia/Dubai', start: '2026-01-01T00:00:00.000Z' },
|
|
24
|
+
})
|
|
25
|
+
await scheduler.save({
|
|
26
|
+
id: 'other-task', name: 'Other owner task', text: 'This must never be visible.',
|
|
27
|
+
owner: { ...owner, telegramUserId: 202, telegramChatId: 202 }, execution, enabled: true,
|
|
28
|
+
trigger: { at: '2027-01-01T00:00:00.000Z' },
|
|
29
|
+
})
|
|
30
|
+
const scheduleDir = join(dir, 'schedules')
|
|
31
|
+
const before = await readFile(join(scheduleDir, 'owner-task.json'), 'utf8')
|
|
32
|
+
const entries = await readdir(scheduleDir)
|
|
33
|
+
const text = scheduledTasksText(await scheduler.listReadOnly(), owner, Date.parse('2026-09-11T00:00:00.000Z'))
|
|
34
|
+
|
|
35
|
+
assert.match(text, /Title: Daily report/)
|
|
36
|
+
assert.match(text, /Instructions:\nRead the ledger and send the owner a concise report\./)
|
|
37
|
+
assert.match(text, /Timing: Cron 0 9 \* \* 1-5 · Asia\/Dubai/)
|
|
38
|
+
assert.match(text, /State: Scheduled/)
|
|
39
|
+
assert.match(text, /Next run: 2026-09-11T05:00:00.000Z/)
|
|
40
|
+
assert.doesNotMatch(text, /Other owner task|This must never be visible/)
|
|
41
|
+
assert.equal(await readFile(join(scheduleDir, 'owner-task.json'), 'utf8'), before)
|
|
42
|
+
assert.deepEqual(await readdir(scheduleDir), entries)
|
|
43
|
+
})
|