@jc_stack/ez-agents 0.1.0-beta.46 → 0.1.0-beta.48
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/.dockerignore +2 -0
- package/.env.example +3 -0
- package/AGENTS.md +2 -2
- package/CHANGELOG.md +38 -0
- package/CONTRIBUTING.md +1 -1
- package/Dockerfile +18 -10
- package/THIRD_PARTY_NOTICES.md +6 -3
- package/bin/ez +7 -0
- package/docker/broker-readiness.mjs +54 -0
- package/docker/broker-update-smoke.mjs +86 -0
- package/docker/engine-pnpm-workspace.yaml +4 -0
- package/docker/run.ts +1 -1
- package/docker/smoke.mjs +1 -1
- package/docs/application-channel.md +32 -1
- package/docs/architecture/ai-selection.md +26 -5
- package/docs/architecture/authority-boundaries.md +2 -2
- package/docs/development-and-testing.md +1 -1
- package/docs/docker-runtime.md +39 -0
- package/docs/isolated-plugin-update-readiness.md +66 -0
- package/docs/plugin-guide.md +1 -1
- package/docs/plugins.md +1 -1
- package/docs/releasing.md +5 -1
- package/docs/scheduling.md +117 -9
- package/docs/selective-monitoring.md +13 -0
- package/docs/upgrades.md +38 -1
- package/docs/work-preflight.md +9 -0
- package/package.json +4 -3
- package/pnpm-workspace.yaml +7 -0
- package/scripts/smoke-scheduler.ts +4 -1
- package/src/ai.ts +41 -4
- package/src/application-channel.ts +43 -3
- package/src/application-cli.ts +7 -1
- package/src/application-origin.ts +2 -0
- package/src/audio.ts +37 -22
- package/src/client-defaults.ts +3 -2
- package/src/config.ts +9 -0
- package/src/control-state.ts +3 -1
- package/src/delivery-socket-client.d.mts +2 -0
- package/src/delivery-socket-client.mjs +2 -0
- package/src/delivery-socket.ts +38 -1
- package/src/event-sources.ts +2 -2
- package/src/executor.ts +72 -1
- package/src/files.ts +47 -3
- package/src/host-executor.ts +40 -26
- package/src/index.ts +42 -17
- package/src/install-tools.mjs +18 -9
- package/src/menu.ts +4 -2
- package/src/model-policy.ts +4 -0
- package/src/plugin-broker.mjs +47 -4
- package/src/plugins/manager.mjs +11 -4
- package/src/run-evidence.ts +78 -0
- package/src/runs.ts +16 -2
- package/src/schedule-cli.ts +80 -10
- package/src/schedule-preflight.ts +23 -0
- package/src/scheduled-tasks.ts +16 -12
- package/src/scheduler.ts +102 -16
- package/src/scripts.ts +167 -0
- package/src/setup.ts +2 -2
- package/src/task-capability.ts +17 -1
- package/src/task-executor.ts +11 -8
- package/src/task-mcp.ts +20 -8
- package/src/tasks.ts +89 -9
- package/src/updates/runtime.mjs +53 -11
- package/src/updates/supervisor.mjs +23 -13
- package/test/ai.test.ts +85 -5
- package/test/application-channel.test.ts +16 -1
- package/test/audio.test.ts +19 -0
- package/test/channel-owner.test.ts +2 -2
- package/test/config.test.ts +8 -0
- package/test/desktop-bridge.test.ts +2 -1
- package/test/executor.test.ts +37 -0
- package/test/failure.test.ts +4 -4
- package/test/files.test.ts +44 -0
- package/test/host-executor.test.ts +49 -6
- package/test/install-json.test.mjs +21 -0
- package/test/install-tools.test.mjs +6 -5
- package/test/native-tasks.test.ts +1 -1
- package/test/plugin-broker.test.mjs +64 -0
- package/test/plugin-manager.test.mjs +20 -0
- package/test/private-speech-config.test.ts +22 -0
- package/test/run-evidence.test.ts +78 -0
- package/test/schedule-cli.test.ts +47 -3
- package/test/schedule-preflight.test.ts +49 -0
- package/test/scheduled-tasks.test.ts +2 -2
- package/test/scheduler-host.test.ts +21 -3
- package/test/scheduler-relay.test.ts +84 -4
- package/test/scheduler.test.ts +21 -1
- package/test/script-schedules.test.ts +219 -0
- package/test/task-native.test.ts +50 -12
- package/test/tasks.test.ts +97 -4
- package/test/updates.test.mjs +77 -18
package/src/index.ts
CHANGED
|
@@ -11,6 +11,8 @@ import { dispatchChannel } from './channel-backend.js'
|
|
|
11
11
|
import { randomUUID } from 'node:crypto'
|
|
12
12
|
import { stat } from 'node:fs/promises'
|
|
13
13
|
import { Scheduler } from './scheduler.js'
|
|
14
|
+
import { Scripts } from './scripts.js'
|
|
15
|
+
import { redactFailure } from './failure.js'
|
|
14
16
|
import { ownedScheduledTasks, scheduledTaskDetailText, scheduledTasksText } from './scheduled-tasks.js'
|
|
15
17
|
import { EventSources, eventRunId, batchReady, type SourceEvent } from './event-sources.js'
|
|
16
18
|
import {removeTaskAttachments} from './task-attachments.js'
|
|
@@ -25,7 +27,8 @@ import { InboxStore, type IncomingItem } from './inbox.js'
|
|
|
25
27
|
import { loadConfig, type Config } from './config.js'
|
|
26
28
|
import { ControlStore, telegramOwner, ownerId, ownerEpoch } from './control-state.js'
|
|
27
29
|
import { ApprovalStore } from './approval.js'
|
|
28
|
-
import { startExecutorJob, terminateJob, opencodeDataHome } from './executor.js'
|
|
30
|
+
import { startExecutorJob, terminateJob, opencodeDataHome, claudeConfigHome } from './executor.js'
|
|
31
|
+
import { assertScheduledModel } from './model-policy.js'
|
|
29
32
|
import { RunStore, type RunRecord } from './runs.js'
|
|
30
33
|
import { splitTelegramText } from './reply.js'
|
|
31
34
|
import { markdownToTelegramHtml, escapeHtml } from './format.js'
|
|
@@ -43,7 +46,7 @@ import { softwareStatus } from './software-status.js'
|
|
|
43
46
|
export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
44
47
|
const safeError = (error: unknown): string => {
|
|
45
48
|
let message = error instanceof Error ? error.message : typeof error === 'string' ? error : 'Unknown error'
|
|
46
|
-
for (const secret of [config.channelBackendToken, config.telegramBotToken, config.geminiApiKey, config.openaiApiKey]) {
|
|
49
|
+
for (const secret of [config.channelBackendToken, config.telegramBotToken, config.geminiApiKey, config.openaiApiKey, config.openrouterApiKey]) {
|
|
47
50
|
if (secret) message = message.replaceAll(secret, '[redacted]')
|
|
48
51
|
}
|
|
49
52
|
return message
|
|
@@ -59,7 +62,9 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
59
62
|
const scheduler = new Scheduler(config.controlDir)
|
|
60
63
|
const background = new Map<string, ChildProcess>()
|
|
61
64
|
const completions = new Set<Promise<void>>()
|
|
62
|
-
const
|
|
65
|
+
const speechOptions = { geminiApiKey: config.geminiApiKey, openrouterApiKey: config.openrouterApiKey, speechProvider: config.speechProvider, speechModel: config.speechModel }
|
|
66
|
+
const speechConfigured = config.speechProvider === 'openrouter' ? Boolean(config.openrouterApiKey) : Boolean(config.geminiApiKey)
|
|
67
|
+
const tasks = new Tasks(config.controlDir, speechConfigured ? (text) => synthesizeSpeech(text, { ...speechOptions, voice: config.speechVoiceEn, signal: AbortSignal.timeout(20000) }) : undefined)
|
|
63
68
|
const processTaskRequests = taskRequests(tasks)
|
|
64
69
|
let taskWork: Promise<void> | undefined
|
|
65
70
|
const drainTaskRequests = (): Promise<void> => {
|
|
@@ -102,8 +107,8 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
102
107
|
const applicationChannel = new ApplicationChannel({
|
|
103
108
|
controlDir: config.controlDir, workspace: config.workspace, isolation: config.isolation, initial: aiMenu.initial,
|
|
104
109
|
aiControls: aiMenu,
|
|
105
|
-
speech:
|
|
106
|
-
|
|
110
|
+
speech: speechConfigured ? (text, language) => synthesizeSpeech(text, {
|
|
111
|
+
...speechOptions,
|
|
107
112
|
voice: language === 'es' ? config.speechVoiceEs : config.speechVoiceEn,
|
|
108
113
|
format: 'wav',
|
|
109
114
|
}) : undefined,
|
|
@@ -206,8 +211,8 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
206
211
|
}
|
|
207
212
|
if (run.scheduled && !(await scheduler.get(run.scheduled.id)).enabled) return
|
|
208
213
|
// A long scheduled turn must not occupy the owner's chat stream.
|
|
209
|
-
//
|
|
210
|
-
if (run.scheduled ? background.size
|
|
214
|
+
// Foreground stays serial; scheduled jobs have independent native sessions.
|
|
215
|
+
if (run.scheduled ? background.size >= (config.scheduledConcurrency ?? 6) : activeChild !== null) return
|
|
211
216
|
let texts = run.texts
|
|
212
217
|
if (run.external) {
|
|
213
218
|
// Availability failures leave durable queued work for a later check.
|
|
@@ -234,13 +239,23 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
234
239
|
try {
|
|
235
240
|
const launchStarted = performance.now()
|
|
236
241
|
const started = await runs.patch(run.id, { status: 'running', startedAt: new Date().toISOString() })
|
|
237
|
-
|
|
242
|
+
// A registered script occurrence: no model, prompt or native session.
|
|
243
|
+
const script = run.script && run.scheduled
|
|
244
|
+
? { registration: await new Scripts(config.controlDir).owned(run.script.id, owner), ref: run.script, scheduled: run.scheduled }
|
|
245
|
+
: undefined
|
|
246
|
+
if (run.script && !script) throw new Error('Script runs require a scheduled occurrence')
|
|
247
|
+
if (!started.execution && !run.taskId && !script) throw new Error('Legacy queued work has no pinned AI. Resend the request after /new.')
|
|
238
248
|
const startsOwnSession = Boolean(run.external || run.taskId || run.scheduled)
|
|
239
249
|
const session = startsOwnSession
|
|
240
250
|
? { sessionId: randomUUID(), hasStarted: false, nativeSessionId: undefined }
|
|
241
251
|
: await control.executionSession(started.execution!)
|
|
242
|
-
const selected = run.taskId ? (started.execution?.preset.cli === 'codex' ? started.execution.preset : initialPreset('codex')) : started.execution!.preset
|
|
243
|
-
|
|
252
|
+
const selected = script ? initialPreset('codex') : run.taskId ? (started.execution?.preset.cli === 'codex' ? started.execution.preset : initialPreset('codex')) : started.execution!.preset
|
|
253
|
+
if (run.scheduled && !script) assertScheduledModel(selected.model)
|
|
254
|
+
const { child, cleanup } = await launch(texts, script ? {
|
|
255
|
+
workspace: config.workspace, timeoutMs: 0, repairEnabled: config.repairEnabled, runId: started.id,
|
|
256
|
+
controlDir: config.controlDir, binDir,
|
|
257
|
+
script: { ...script.ref, scheduleId: script.scheduled.id, scheduleRevision: script.scheduled.revision, dueAt: script.scheduled.dueAt },
|
|
258
|
+
} : {
|
|
244
259
|
workspace: config.workspace,
|
|
245
260
|
timeoutMs: config.executorTimeoutMs,
|
|
246
261
|
repairEnabled: config.repairEnabled,
|
|
@@ -263,9 +278,14 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
263
278
|
const executionStarted = performance.now()
|
|
264
279
|
// Attach before disk writes: a fast child can close while PID persistence
|
|
265
280
|
// is pending, and Node drains its remaining pipes during process close.
|
|
266
|
-
let failureReason = 'executor-exit', errorTail = '', interrupted = false
|
|
281
|
+
let failureReason = 'executor-exit', errorTail = '', interrupted = false, outputTail = '', timedOut = false
|
|
282
|
+
// Script output is bounded diagnostics for run inspection, never chat.
|
|
283
|
+
if (script) child.stdout?.setEncoding('utf8').on('data', (chunk: string) => { outputTail = (outputTail + chunk).slice(-16384) })
|
|
284
|
+
const scriptTimer = script ? setTimeout(() => { timedOut = true; terminateJob(child) }, script.registration.timeoutSeconds * 1000) : undefined
|
|
285
|
+
child.once('close', () => clearTimeout(scriptTimer))
|
|
267
286
|
child.stderr?.setEncoding('utf8').on('data', (chunk: string) => {
|
|
268
287
|
errorTail = (errorTail + chunk).slice(-16384)
|
|
288
|
+
if (script) outputTail = (outputTail + chunk).slice(-16384)
|
|
269
289
|
if (chunk.includes('Host CLI executor is offline')) failureReason = 'host-executor-offline'
|
|
270
290
|
if (chunk.includes('Host executor client interrupted by')) {
|
|
271
291
|
failureReason = 'host-executor-transport-interrupted'
|
|
@@ -286,7 +306,8 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
286
306
|
console.info('run started', {
|
|
287
307
|
run_id: started.id,
|
|
288
308
|
pid: child.pid,
|
|
289
|
-
cli: selected.cli,
|
|
309
|
+
cli: script ? 'script' : selected.cli,
|
|
310
|
+
...(script ? { script: script.ref.id, script_revision: script.ref.revision } : {}),
|
|
290
311
|
session: session.sessionId,
|
|
291
312
|
isResume: session.hasStarted,
|
|
292
313
|
})
|
|
@@ -304,8 +325,12 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
304
325
|
try {
|
|
305
326
|
await cleanup()
|
|
306
327
|
if (code === 0 && !run.external && !run.taskId && !run.scheduled) await control.markSessionStarted(session.sessionId)
|
|
307
|
-
const cancelled = ownerStopped.has(child) || Boolean(run.scheduled && await scheduler.cancelled(run.id))
|
|
308
|
-
|
|
328
|
+
const cancelled = !timedOut && (ownerStopped.has(child) || Boolean(run.scheduled && await scheduler.cancelled(run.id)))
|
|
329
|
+
if (timedOut) failureReason = 'script-timeout'
|
|
330
|
+
const failed = !cancelled && (code !== 0 || timedOut)
|
|
331
|
+
await runs.patch(started.id, { status: cancelled ? 'cancelled' : failed ? 'failed' : 'completed', endedAt: new Date().toISOString(), exitCode: code,
|
|
332
|
+
...(script ? { output: redactFailure(safeError(outputTail)), ...(timedOut ? { timedOut } : {}) } : {}),
|
|
333
|
+
...(failed ? { failureReason, interrupted, failure: await failureEvidence(config.controlDir, safeError(timedOut ? `Script exceeded its ${script!.registration.timeoutSeconds}s timeout and was terminated` : errorTail || `Executor exited with ${code === null ? 'a signal' : `code ${code}`}`)) } : {}) })
|
|
309
334
|
await releaseExternal(run)
|
|
310
335
|
} catch (error) {
|
|
311
336
|
await runs.patch(started.id, { status: ownerStopped.has(child) ? 'cancelled' : 'failed', failureReason: 'session-finalization', failure: await failureEvidence(config.controlDir, safeError(error)), endedAt: new Date().toISOString() })
|
|
@@ -585,8 +610,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
585
610
|
} else if (item.type === 'voice' && item.voiceText) {
|
|
586
611
|
await bot!.api.sendChatAction(item.chatId, 'record_voice')
|
|
587
612
|
const { buffer } = await synthesizeSpeech(item.voiceText, {
|
|
588
|
-
|
|
589
|
-
openaiApiKey: config.openaiApiKey,
|
|
613
|
+
...speechOptions,
|
|
590
614
|
voice: config.speechVoiceEn,
|
|
591
615
|
})
|
|
592
616
|
await paceSend()
|
|
@@ -717,7 +741,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
717
741
|
const delivery = await runs.deliveryStatus()
|
|
718
742
|
const ai = await control.aiState(aiMenu.initial)
|
|
719
743
|
const selected = ai.presets.find((p) => p.id === ai.selectedId)!
|
|
720
|
-
const discovered = await discoverDefaults(config.workspace, { codexHome, nativeCodexFallback: true })
|
|
744
|
+
const discovered = await discoverDefaults(config.workspace, { codexHome, claudeHome: claudeConfigHome(config.controlDir), nativeCodexFallback: true })
|
|
721
745
|
const displayedSelected = statusPreset(selected, discovered)
|
|
722
746
|
const scheduled = all.filter(run => run.scheduled && run.status === 'running').length
|
|
723
747
|
const queued = all.filter(run => run.status === 'queued').length
|
|
@@ -741,6 +765,7 @@ export const createRelay = (config: Config, launch = startExecutorJob) => {
|
|
|
741
765
|
'',
|
|
742
766
|
'Work',
|
|
743
767
|
`Current: ${running ? (waitingForHost ? 'waiting for the workspace' : 'running') : 'idle'}`,
|
|
768
|
+
`Scheduled limit: ${config.scheduledConcurrency ?? 6}`,
|
|
744
769
|
`Background: ${scheduled ? `${scheduled} scheduled task${scheduled === 1 ? '' : 's'} running` : 'none'}`,
|
|
745
770
|
`Queue: ${queued || incoming.pending ? `${queued} run${queued === 1 ? '' : 's'}; ${incoming.pending} incoming message${incoming.pending === 1 ? '' : 's'}` : 'empty'}`,
|
|
746
771
|
...(attention.length ? ['', 'Needs attention', ...attention] : []),
|
package/src/install-tools.mjs
CHANGED
|
@@ -16,13 +16,14 @@ export const hostEnvironment=()=>Object.fromEntries(['HOME','PATH','LANG','LC_AL
|
|
|
16
16
|
async function atomic(file,value) {
|
|
17
17
|
const tmp=file+'.'+randomUUID()+'.tmp';await fs.writeFile(tmp,JSON.stringify(value)+'\n',{mode:0o600,flag:'wx'});await fs.rename(tmp,file);
|
|
18
18
|
}
|
|
19
|
-
export function run(command,args,{cwd,log,timeout=15000}={}) {
|
|
19
|
+
export function run(command,args,{cwd,log,timeout=15000,stdoutOnly=false}={}) {
|
|
20
20
|
return new Promise((resolve,reject)=>{
|
|
21
|
-
const child=spawn(command,args,{cwd,env:hostEnvironment(),stdio:['ignore',log??'pipe',log??'pipe']});let output='';
|
|
22
|
-
|
|
21
|
+
const child=spawn(command,args,{cwd,env:hostEnvironment(),stdio:['ignore',log??'pipe',log??'pipe']});let output='',stdout='';
|
|
22
|
+
child.stdout?.on('data',b=>{output=(output+b).slice(-200000);if(stdoutOnly)stdout=(stdout+b).slice(-200000);});
|
|
23
|
+
child.stderr?.on('data',b=>output=(output+b).slice(-200000));
|
|
23
24
|
const timer=setTimeout(()=>child.kill('SIGTERM'),timeout);
|
|
24
25
|
child.once('error',e=>{clearTimeout(timer);reject(e);});
|
|
25
|
-
child.once('close',code=>{clearTimeout(timer);if(code!==0)reject(Error(`${command} failed (${code})${log===undefined?': '+output.trim():''}`));else resolve(output.trim());});
|
|
26
|
+
child.once('close',code=>{clearTimeout(timer);if(code!==0)reject(Error(`${command} failed (${code})${log===undefined?': '+output.trim():''}`));else resolve((stdoutOnly?stdout:output).trim());});
|
|
26
27
|
});
|
|
27
28
|
}
|
|
28
29
|
export const defaultHome=()=>path.join(process.env.XDG_DATA_HOME||path.join(homedir(),'.local/share'),'ez');
|
|
@@ -114,6 +115,13 @@ export async function migrateLedger({ deployment } = {}) {
|
|
|
114
115
|
return { ok: true, deployment: directory, changed: true, port, overlay };
|
|
115
116
|
}
|
|
116
117
|
|
|
118
|
+
export async function latestCodexVersion(invoke,manager={command:"npm",args:[]}) {
|
|
119
|
+
const output=await invoke(manager.command,[...manager.args,'view','@openai/codex@latest','version','--json','--registry','https://registry.npmjs.org/'],{stdoutOnly:true});
|
|
120
|
+
let version;
|
|
121
|
+
try {version=JSON.parse(output);}catch {throw Error('Codex version discovery returned invalid JSON on stdout');}
|
|
122
|
+
if(typeof version!=='string'||!/^\d+\.\d+\.\d+$/.test(version))throw Error('Invalid latest Codex version');
|
|
123
|
+
return version;
|
|
124
|
+
}
|
|
117
125
|
async function fingerprintOf(source,invoke) {
|
|
118
126
|
const listing=JSON.parse(await invoke('npm',['pack','--dry-run','--ignore-scripts','--json'],{cwd:source}));
|
|
119
127
|
const hash=createHash('sha256');
|
|
@@ -144,8 +152,9 @@ async function buildIdentity(source,label,invoke) {
|
|
|
144
152
|
export async function build({home=defaultHome(),source=root,label},invoke=run) {
|
|
145
153
|
source=await fs.realpath(source);
|
|
146
154
|
const identity=await buildIdentity(source,label,invoke);
|
|
155
|
+
const codexVersion=await latestCodexVersion(invoke);
|
|
147
156
|
const fingerprint=await fingerprintOf(source,invoke);
|
|
148
|
-
const artifact=createHash('sha256').update(JSON.stringify([fingerprint,identity.label??'',identity.sha??''])).digest('hex');
|
|
157
|
+
const artifact=createHash('sha256').update(JSON.stringify([fingerprint,identity.label??'',identity.sha??'',codexVersion])).digest('hex');
|
|
149
158
|
const image='ezenciel-agents:install-'+artifact.slice(0,24);
|
|
150
159
|
const dir=path.join(path.resolve(home),'builds',artifact);await fs.mkdir(dir,{recursive:true,mode:0o700});
|
|
151
160
|
const lock=path.join(dir,'lock'),receipt=path.join(dir,'status.json'),log=path.join(dir,'build.log');
|
|
@@ -155,14 +164,14 @@ export async function build({home=defaultHome(),source=root,label},invoke=run) {
|
|
|
155
164
|
await handle.writeFile(JSON.stringify({pid:process.pid,source,image,...identity}));
|
|
156
165
|
const prior=await read(receipt).catch(absent);
|
|
157
166
|
if(prior?.state==='completed')try{if(await invoke('docker',['image','inspect','--format','{{.Id}}',image])===prior.imageId)return {...prior,reused:true};}catch{/* Image was removed; rebuild under the same exclusive lock. */}
|
|
158
|
-
await atomic(receipt,{state:'building',pid:process.pid,source,image,log,...identity});
|
|
159
|
-
console.error(JSON.stringify({state:'building',image,log,...identity}));
|
|
160
|
-
const buildArgs=identity.label?['--build-arg',`BUILD_TAG=${identity.label}`,'--build-arg',`BUILD_SHA=${identity.sha}`]:[];
|
|
167
|
+
await atomic(receipt,{state:'building',pid:process.pid,source,image,log,codexVersion,...identity});
|
|
168
|
+
console.error(JSON.stringify({state:'building',image,log,codexVersion,...identity}));
|
|
169
|
+
const buildArgs=['--build-arg',`CODEX_CLI_VERSION=${codexVersion}`,...(identity.label?['--build-arg',`BUILD_TAG=${identity.label}`,'--build-arg',`BUILD_SHA=${identity.sha}`]:[])];
|
|
161
170
|
const tags=[image,...(identity.label?[`ezenciel-agents:${identity.label}`]:[])];
|
|
162
171
|
const output=await fs.open(log,'a',0o600);
|
|
163
172
|
try{await invoke('docker',['build','--progress','plain','--target','runtime',...buildArgs,...tags.flatMap(t=>['-t',t]),source],{log:output.fd,timeout:3600000});}finally{await output.close();}
|
|
164
173
|
if(await fingerprintOf(source,invoke)!==fingerprint)throw Error('Package changed during build; keep the source stable and retry');
|
|
165
|
-
const result={state:'completed',source,image,imageId:await invoke('docker',['image','inspect','--format','{{.Id}}',image]),log,...identity};await atomic(receipt,result);return result;
|
|
174
|
+
const result={state:'completed',source,image,imageId:await invoke('docker',['image','inspect','--format','{{.Id}}',image]),log,codexVersion,...identity};await atomic(receipt,result);return result;
|
|
166
175
|
}catch(e){await atomic(receipt,{state:'failed',source,image,log,error:e.message,...identity});throw e;}
|
|
167
176
|
finally{await handle.close();await fs.rm(lock);}
|
|
168
177
|
}
|
package/src/menu.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { InlineKeyboard, type Context } from 'grammy'
|
|
|
6
6
|
import { ControlStore, type ControlGuard } from './control-state.js'
|
|
7
7
|
import { chatPreset, installed, persistedPreset, presetLabel, presetProvider, readModels, validateSelection, type AiPreset, type ModelChoice } from './ai.js'
|
|
8
8
|
import { discoverDefaults } from './client-defaults.js'
|
|
9
|
+
import { claudeConfigHome } from './executor.js'
|
|
9
10
|
|
|
10
11
|
export const mainCommands = [
|
|
11
12
|
{ command: 'new', description: 'New conversation' },
|
|
@@ -38,14 +39,15 @@ export const createAiMenu = (control: ControlStore, cli: string, catalog = readM
|
|
|
38
39
|
// directory, but the default catalog probed the process home instead. Bind it
|
|
39
40
|
// to the same agent-bound home so /ai lists the models Codex can actually run.
|
|
40
41
|
else if (codexHome && catalog === readModels) catalog = () => readModels(undefined, isInstalled, codexHome, undefined, opencodeDataHome, undefined, process.env.EZ_CONTROL_DIR)
|
|
41
|
-
const
|
|
42
|
+
const claudeHome = process.env.EZ_CONTROL_DIR ? claudeConfigHome(process.env.EZ_CONTROL_DIR) : undefined
|
|
43
|
+
const refresh = async () => control.syncClientPresets(initial, host ? [] : await discoverDefaults(workspace, { codexHome, claudeHome }))
|
|
42
44
|
const validate = async (preset: AiPreset) => {
|
|
43
45
|
assertEffort(preset.effort, preset.model, preset.cli)
|
|
44
46
|
if (preset.id === initial.id) return
|
|
45
47
|
if (preset.id.startsWith('detected_')) {
|
|
46
48
|
if (host) await validateSelection(preset, await catalog(), async name => (await catalog()).some(model => model.cli === name))
|
|
47
49
|
else {
|
|
48
|
-
const detected = await discoverDefaults(workspace, { codexHome })
|
|
50
|
+
const detected = await discoverDefaults(workspace, { codexHome, claudeHome })
|
|
49
51
|
if (!detected.some((p) => p.id === preset.id)) throw new Error('Client settings changed. Refresh available AIs and select the updated choice.')
|
|
50
52
|
}
|
|
51
53
|
} else await validateSelection(preset, await catalog(), host ? async name => (await catalog()).some(model => model.cli === name) : isInstalled)
|
package/src/model-policy.ts
CHANGED
|
@@ -3,6 +3,10 @@ export const allowedEffort = (effort?: string, _model?: string, _cli?: string) =
|
|
|
3
3
|
export function assertEffort(effort?: string, model?: string, cli?: string) {
|
|
4
4
|
if (!allowedEffort(effort,model,cli)) throw new Error('Invalid reasoning effort')
|
|
5
5
|
}
|
|
6
|
+
export function assertScheduledModel(model: unknown): asserts model is string {
|
|
7
|
+
if (typeof model !== 'string' || !/^[a-zA-Z0-9_./:-]{1,160}$/.test(model))
|
|
8
|
+
throw new Error('Scheduled tasks require a saved explicit model; configure the task with --model')
|
|
9
|
+
}
|
|
6
10
|
export function executionDefaults<T extends { model?: string; effort?: string }>(cli: string, options: T): T {
|
|
7
11
|
assertEffort(options.effort,options.model,cli)
|
|
8
12
|
return options
|
package/src/plugin-broker.mjs
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { combinePreflight } from './schedule-preflight.js';
|
|
1
2
|
import * as fs from 'node:fs/promises';
|
|
3
|
+
import {callDeliverySocket} from './delivery-socket-client.mjs';
|
|
2
4
|
import path from 'node:path';
|
|
3
5
|
import { createServer } from 'node:net';
|
|
4
6
|
import { randomBytes, randomUUID, createHash } from 'node:crypto';
|
|
@@ -223,23 +225,37 @@ const managerCommand = async (binding, args, signal, runId) => new Promise((reso
|
|
|
223
225
|
});
|
|
224
226
|
});
|
|
225
227
|
|
|
226
|
-
const
|
|
228
|
+
const authorizeTaskLaunch=async(binding,request)=>{
|
|
229
|
+
const input=JSON.parse(request.stdin)
|
|
230
|
+
if(Object.keys(input).some(k=>!['taskId','taskToken','expiresAt'].includes(k)) || typeof input.taskToken!=='string' || !/^[a-f0-9]{64}$/.test(input.taskToken))throw Error('Invalid task launch input')
|
|
231
|
+
const ticketHash=createHash('sha256').update(input.taskToken).digest('hex')
|
|
232
|
+
const grant=await callDeliverySocket(deliverySocketPath(binding.controlDir),{op:'taskApplication',payload:{runId:request.runId,ticketHash}})
|
|
233
|
+
if(input.taskId!==grant.taskId || input.expiresAt!==grant.expiresAt)throw Error('Task launch authority mismatch')
|
|
234
|
+
return grant.launch
|
|
235
|
+
}
|
|
236
|
+
const invokePlugin = async (binding, request, signal, scheduledPreflight = false) => {
|
|
227
237
|
const startedAt = new Date().toISOString();
|
|
228
238
|
let plugin = request.alias;
|
|
229
239
|
let revision = request.revision;
|
|
230
240
|
let result;
|
|
231
241
|
let failure;
|
|
232
242
|
try {
|
|
233
|
-
|
|
243
|
+
if (!scheduledPreflight) {
|
|
244
|
+
if(request.operation==='task-launch') {
|
|
245
|
+
const launch=await authorizeTaskLaunch(binding,request)
|
|
246
|
+
if(launch.command!==request.alias || JSON.stringify(launch.args)!==JSON.stringify(request.args))throw Error('Task launch command changed')
|
|
247
|
+
}else await authorize(binding, request.runId);
|
|
248
|
+
}
|
|
234
249
|
const current = await registry(binding.home);
|
|
235
250
|
const selected = recordForAlias(current, request.alias);
|
|
251
|
+
if (scheduledPreflight && (!selected.command.exposure || selected.command.exposure.changesRecords !== false || selected.command.exposure.sendsExternally !== false)) throw Error('Scheduled preflight requires an explicitly read-only plugin command');
|
|
236
252
|
plugin = selected.plugin;
|
|
237
253
|
revision = selected.record.revision;
|
|
238
254
|
if (selected.record.revision !== request.revision) throw Error('Plugin changed; discover again');
|
|
239
255
|
const command = await prepareCommand(binding.home, request.alias, request.args, {
|
|
240
256
|
revision: request.revision,
|
|
241
257
|
invocation: true,
|
|
242
|
-
environment: { EZ_CONTROL_DIR: binding.controlDir, EZ_RUN_ID: request.runId, EZ_DELIVERY_SOCKET: process.env.EZ_DELIVERY_SOCKET ?? deliverySocketPath(binding.controlDir) },
|
|
258
|
+
environment: { EZ_CONTROL_DIR: binding.controlDir, ...(scheduledPreflight ? {} : {EZ_RUN_ID: request.runId}), EZ_DELIVERY_SOCKET: process.env.EZ_DELIVERY_SOCKET ?? deliverySocketPath(binding.controlDir) },
|
|
243
259
|
});
|
|
244
260
|
try {
|
|
245
261
|
result = await dockerRun(command.argv, {
|
|
@@ -282,8 +298,17 @@ const invokePlugin = async (binding, request, signal) => {
|
|
|
282
298
|
|
|
283
299
|
export async function validateBrokerRequest(request, workspace) {
|
|
284
300
|
if (!request || typeof request !== 'object' || request.version !== 1 || !REQUEST_ID.test(request.id)) throw Error('Invalid plugin broker request');
|
|
285
|
-
if
|
|
301
|
+
if(request.operation === 'preflight') {
|
|
302
|
+
validId(request.scheduleId,RUN_ID,'schedule ID'); validId(request.revision,/^[a-f0-9-]{36}$/,'schedule revision');
|
|
303
|
+
if(Object.keys(request).some(k=>!['version','id','operation','scheduleId','revision'].includes(k))) throw Error('Unexpected schedule preflight field');
|
|
304
|
+
return request;
|
|
305
|
+
}
|
|
306
|
+
if (!['resolve', 'invoke', 'manager','task-launch'].includes(request.operation)) throw Error('Unknown plugin broker operation');
|
|
286
307
|
validId(request.runId, RUN_ID, 'plugin run ID');
|
|
308
|
+
if(request.operation==='task-launch') {
|
|
309
|
+
if(Object.keys(request).some(k=>!['version','id','operation','runId','stdin'].includes(k)))throw Error('Unexpected task launch field')
|
|
310
|
+
boundedString(request.stdin,1024,'task launch stdin');return request
|
|
311
|
+
}
|
|
287
312
|
if (request.operation === 'manager') {
|
|
288
313
|
await validateManagerArgs(request.args, workspace);
|
|
289
314
|
if (Object.keys(request).some(key => !['version', 'id', 'operation', 'runId', 'args'].includes(key))) throw Error('Unexpected broker management field');
|
|
@@ -328,6 +353,18 @@ export async function servePluginBroker(binding, signal = new AbortController().
|
|
|
328
353
|
(async () => {
|
|
329
354
|
try {
|
|
330
355
|
await validateBrokerRequest(request, binding.workspace);
|
|
356
|
+
if (request.operation === 'preflight') {
|
|
357
|
+
const condition=await (await import('./delivery-socket-client.mjs')).callDeliverySocket(deliverySocketPath(binding.controlDir),{op:'schedulePreflight',payload:{scheduleId:request.scheduleId,revision:request.revision}});
|
|
358
|
+
const values=[];
|
|
359
|
+
for(const check of condition.checks) {
|
|
360
|
+
const selected=recordForAlias(await registry(binding.home),check.alias);
|
|
361
|
+
const result=await invokePlugin(binding,{alias:check.alias,args:check.args,revision:selected.record.revision,runId:`preflight_${request.scheduleId}`,id:randomUUID()},abort.signal,true);
|
|
362
|
+
if(!result.ok || result.code!==0) throw Error('Scheduled plugin preflight unavailable');
|
|
363
|
+
values.push(JSON.parse(result.stdout));
|
|
364
|
+
}
|
|
365
|
+
response(socket,{version:1,id:request.id,ok:true,result:combinePreflight(values)});
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
331
368
|
if (request.operation === 'resolve') {
|
|
332
369
|
await authorize(binding, request.runId);
|
|
333
370
|
const selected = recordForAlias(await registry(binding.home), request.alias);
|
|
@@ -338,6 +375,12 @@ export async function servePluginBroker(binding, signal = new AbortController().
|
|
|
338
375
|
response(socket, { version: 1, id: request.id, ok: true, plugin: selected.plugin, alias: request.alias, revision: selected.record.revision, capability });
|
|
339
376
|
return;
|
|
340
377
|
}
|
|
378
|
+
if(request.operation==='task-launch') {
|
|
379
|
+
const launch=await authorizeTaskLaunch(binding,request)
|
|
380
|
+
const selected=recordForAlias(await registry(binding.home),launch.command)
|
|
381
|
+
const result=await invokePlugin(binding,{...request,alias:launch.command,args:launch.args,revision:selected.record.revision},abort.signal)
|
|
382
|
+
response(socket,{version:1,id:request.id,...result});return
|
|
383
|
+
}
|
|
341
384
|
if (request.operation === 'manager') {
|
|
342
385
|
await authorize(binding, request.runId);
|
|
343
386
|
const result = await managerCommand(binding, request.args, abort.signal, request.runId);
|
package/src/plugins/manager.mjs
CHANGED
|
@@ -94,11 +94,18 @@ async function invocationLease(home,container) {
|
|
|
94
94
|
await stewardOwned(file);
|
|
95
95
|
return async()=>{await fs.rm(file,{force:true});};
|
|
96
96
|
}
|
|
97
|
-
export async function locked(home, fn, {allowInvocations=false}={}) {
|
|
97
|
+
export async function locked(home, fn, {allowInvocations=false, waitMs=0}={}) {
|
|
98
98
|
const lock = path.join(home,'registry.lock');
|
|
99
99
|
let handle;
|
|
100
|
-
|
|
101
|
-
|
|
100
|
+
const deadline=Date.now()+waitMs;
|
|
101
|
+
while(!handle) {
|
|
102
|
+
try { handle=await fs.open(lock,'wx',0o600); }
|
|
103
|
+
catch(error) {
|
|
104
|
+
if(error.code!=='EEXIST')throw error;
|
|
105
|
+
if(Date.now()>=deadline)throw Error('Registry busy; inspect registry.lock before recovering an interrupted manager');
|
|
106
|
+
await new Promise(resolve=>setTimeout(resolve,25));
|
|
107
|
+
}
|
|
108
|
+
}
|
|
102
109
|
try {
|
|
103
110
|
await handle.writeFile(JSON.stringify({pid:process.pid}));
|
|
104
111
|
if(!allowInvocations&&await activeInvocations(home))throw Error('Plugin commands are active; retry the registry or lifecycle change after they finish');
|
|
@@ -498,7 +505,7 @@ export async function prepareCommand(home,alias,args,{revision,exclude,publish,i
|
|
|
498
505
|
const invocationRelease=invocation?await invocationLease(home,container):undefined;
|
|
499
506
|
const release=(invocationRelease||contextFile)?(async()=>{try{if(contextFile)await fs.rm(contextFile,{force:true});}finally{await invocationRelease?.();}}):undefined;
|
|
500
507
|
return {container,plugin:record.manifest.id,revision:record.revision,contextFile,argv:[...composeArgs(record),...(contextFile?['--file',contextFile]:[]),'run','--rm','--no-deps','-T','--name',container,...(publish?['--publish',publish]:[]),'--entrypoint',binding.argv[0],binding.service,...binding.argv.slice(1),...record.manifest.commands[alias].args,...args,...(binding.suffix||[])],release};
|
|
501
|
-
},{allowInvocations:true});
|
|
508
|
+
},{allowInvocations:true,waitMs:5000});
|
|
502
509
|
}
|
|
503
510
|
export async function init(home,workspace,catalogFile,hostConfig,standalone=false) {
|
|
504
511
|
if(standalone && hostConfig) throw Error('Standalone setup cannot bind a relay host config');
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { readOnlyOwner, requireOwnerExecution } from './execution-authority.js'
|
|
3
|
+
import { ownsRun, assertId } from './identity.js'
|
|
4
|
+
import { RunStore, retainedOutbox, type RunRecord, type StoredOutboxItem } from './runs.js'
|
|
5
|
+
import type { Owner } from './control-state.js'
|
|
6
|
+
|
|
7
|
+
const sha256 = (value: string) => createHash('sha256').update(value).digest('hex')
|
|
8
|
+
const version = (value?: string) => value && /^[0-9A-Za-z.+-]{1,80}$/.test(value) ? value : undefined
|
|
9
|
+
|
|
10
|
+
function delivery(item: StoredOutboxItem) {
|
|
11
|
+
const receipt = item.receipt as { messageIds?: unknown; deliveredAt?: unknown } | undefined
|
|
12
|
+
const valid = item.state === 'sent' && !item.deliveryUnknown &&
|
|
13
|
+
Array.isArray(receipt?.messageIds) && receipt.messageIds.length > 0 &&
|
|
14
|
+
receipt.messageIds.every(id => Number.isSafeInteger(id) && id > 0) &&
|
|
15
|
+
typeof receipt.deliveredAt === 'string' && Number.isFinite(Date.parse(receipt.deliveredAt))
|
|
16
|
+
const field = typeof item.text === 'string' ? 'text' : typeof item.voiceText === 'string' ? 'voiceText' : undefined
|
|
17
|
+
const content = field === 'text' ? item.text : field === 'voiceText' ? item.voiceText : undefined
|
|
18
|
+
return { id: item.id, createdAt: item.createdAt, type: item.type ?? 'message',
|
|
19
|
+
state: item.deliveryUnknown ? 'unknown' : item.state ?? 'queued', receiptVerified: Boolean(valid),
|
|
20
|
+
...(valid ? { deliveredAt: receipt!.deliveredAt, messageCount: (receipt!.messageIds as number[]).length } : {}),
|
|
21
|
+
...(content === undefined ? {} : { content: { field, sha256: sha256(content), bytes: Buffer.byteLength(content), representation: 'submitted UTF-8' } }),
|
|
22
|
+
recipientContentReadback: 'unavailable', attachmentContent: 'not exported' }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Projection only: never read native sessions, tool arguments/results or model text.
|
|
26
|
+
export function projectRunEvidence(runs: RunRecord[], outbox: StoredOutboxItem[], owner: Owner) {
|
|
27
|
+
return runs.filter(run => ownsRun(owner, run) && Date.parse(run.createdAt) >= Date.parse(owner.pairedAt) &&
|
|
28
|
+
!run.external && !run.taskId && !run.application && !run.delivery &&
|
|
29
|
+
(!run.scheduled || run.scheduled.pairedAt === owner.pairedAt))
|
|
30
|
+
.sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id))
|
|
31
|
+
.map(run => ({ id: run.id, status: run.status, createdAt: run.createdAt,
|
|
32
|
+
startedAt: run.startedAt, endedAt: run.endedAt, exitCode: run.exitCode, interrupted: run.interrupted,
|
|
33
|
+
scheduled: run.scheduled ? { id: run.scheduled.id, revision: run.scheduled.revision, dueAt: run.scheduled.dueAt } : undefined,
|
|
34
|
+
executionType: run.script ? 'script' : 'agent',
|
|
35
|
+
script: run.script ? { id: run.script.id, revision: run.script.revision, sha256: run.script.sha256, timedOut: Boolean(run.timedOut),
|
|
36
|
+
outputSha256: run.output ? sha256(run.output) : undefined } : undefined,
|
|
37
|
+
preset: run.execution ? { cli: run.execution.preset.cli, model: run.execution.preset.model, effort: run.execution.preset.effort } : undefined,
|
|
38
|
+
failure: run.failure ? { recorded: true, errorSha256: sha256(run.failure.error), relayVersion: version(run.failure.relayVersion), hostVersion: version(run.failure.hostVersion) } : undefined,
|
|
39
|
+
failureReasonRecorded: Boolean(run.failureReason), blockReasonRecorded: Boolean(run.blockReason),
|
|
40
|
+
failureReview: run.failureReview ? { failedAt: run.failureReview.failedAt, reviewedAt: run.failureReview.reviewedAt, status: run.failureReview.status } : undefined,
|
|
41
|
+
nativeSessionRecorded: Boolean(run.nativeSessionId),
|
|
42
|
+
toolDelegationMetadata: { state: 'unavailable', reason: 'The relay does not retain native tool or delegation events.' },
|
|
43
|
+
deliveries: outbox.filter(item => item.runId === run.id && item.chatId === owner.telegramChatId &&
|
|
44
|
+
Date.parse(item.createdAt) >= Date.parse(owner.telegramLinkedAt ?? owner.pairedAt))
|
|
45
|
+
.sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id)).map(delivery),
|
|
46
|
+
}))
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type EvidenceOptions = { callerRunId?: string; runId?: string; offset?: number; limit?: number; expected?: string }
|
|
50
|
+
|
|
51
|
+
export async function runEvidence(controlDir: string, options: EvidenceOptions = {}) {
|
|
52
|
+
const readStartedAt = new Date().toISOString()
|
|
53
|
+
const offset = options.offset ?? 0, limit = options.limit ?? 100
|
|
54
|
+
if (!Number.isSafeInteger(offset) || offset < 0 || !Number.isSafeInteger(limit) || limit < 1 || limit > 100)
|
|
55
|
+
throw new Error('Offset must be nonnegative; limit must be 1..100')
|
|
56
|
+
if (offset > 0 && !options.expected) throw new Error('Continuation requires --expected snapshot SHA-256')
|
|
57
|
+
if (options.expected !== undefined && !/^[a-f0-9]{64}$/.test(options.expected)) throw new Error('Invalid expected snapshot SHA-256')
|
|
58
|
+
if (options.callerRunId !== undefined) {
|
|
59
|
+
const caller = await requireOwnerExecution(controlDir, options.callerRunId)
|
|
60
|
+
if (caller.application || caller.delivery || caller.replyOnly) throw new Error('Evidence requires Telegram owner execution or the bound operator')
|
|
61
|
+
}
|
|
62
|
+
const owner = await readOnlyOwner(controlDir)
|
|
63
|
+
if (!owner) throw new Error('No authorized owner')
|
|
64
|
+
const records = projectRunEvidence(await new RunStore(controlDir).list(), retainedOutbox(controlDir), owner)
|
|
65
|
+
.filter(run => options.runId === undefined || run.id === assertId(options.runId))
|
|
66
|
+
if (options.runId !== undefined && !records.length) throw new Error('Unknown owner run')
|
|
67
|
+
const snapshotSha256 = sha256(JSON.stringify(records))
|
|
68
|
+
if (options.expected !== undefined && options.expected !== snapshotSha256) throw new Error('Evidence changed; restart at offset zero')
|
|
69
|
+
if (offset > records.length) throw new Error('Offset exceeds retained coverage')
|
|
70
|
+
const page = records.slice(offset, offset + limit)
|
|
71
|
+
return { schemaVersion: 1, readStartedAt, observedAt: new Date().toISOString(), snapshotSha256,
|
|
72
|
+
coverage: { scope: 'current-owner Telegram foreground and scheduled runs', total: records.length, offset, limit, returned: page.length,
|
|
73
|
+
nextOffset: offset + page.length < records.length ? offset + page.length : null,
|
|
74
|
+
history: 'relay memory; resets on restart; at most 2000 terminal runs and 2000 terminal outbox items retained',
|
|
75
|
+
completeness: 'retained records only; older history and unassociated deliveries unknown',
|
|
76
|
+
excluded: ['external tasks', 'isolated tasks', 'application/channel runs', 'native sessions', 'tool/delegation events', 'failure/review prose', 'message content', 'recipient-side content readback'] },
|
|
77
|
+
runs: page }
|
|
78
|
+
}
|
package/src/runs.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { validScheduledOrigin, type ScheduledOrigin } from './scheduler.js'
|
|
|
7
7
|
import type { IncomingItem } from './inbox.js'
|
|
8
8
|
import { assertId } from './identity.js'
|
|
9
9
|
import { isExecutionChoice, type ExecutionChoice } from './ai.js'
|
|
10
|
+
import type { ScriptRunRef } from './scripts.js'
|
|
10
11
|
import {authorizeDeliveryContext,currentDeliveryOwner,type DeliveryContext} from './delivery-context.mjs'
|
|
11
12
|
|
|
12
13
|
export type RunStatus = 'queued' | 'running' | 'completed' | 'failed' | 'cancelled'
|
|
@@ -44,6 +45,11 @@ export type RunRecord = {
|
|
|
44
45
|
application?: ApplicationOrigin
|
|
45
46
|
telegramApplication?: ApplicationOrigin
|
|
46
47
|
delivery?: { bindingId: string; scope: string }
|
|
48
|
+
// Registered script occurrence (no execution preset or native session).
|
|
49
|
+
script?: ScriptRunRef
|
|
50
|
+
// Bounded, redacted script stdout/stderr tail; diagnostic, never delivered.
|
|
51
|
+
output?: string
|
|
52
|
+
timedOut?: boolean
|
|
47
53
|
}
|
|
48
54
|
|
|
49
55
|
export type OutboxItemType = 'message' | 'reaction' | 'document' | 'voice' | 'approval'
|
|
@@ -71,6 +77,9 @@ export type StoredOutboxItem = OutboxItem & { state?: 'queued' | 'sending' | 'se
|
|
|
71
77
|
export const sentOutbox = (controlDir: string): StoredOutboxItem[] =>
|
|
72
78
|
[...outboxFor(controlDir).values()].filter(item => item.state === 'sent')
|
|
73
79
|
|
|
80
|
+
// Explicit operational evidence reads; the projection verifies owner/run scope.
|
|
81
|
+
export const retainedOutbox = (controlDir: string): StoredOutboxItem[] => [...outboxFor(controlDir).values()]
|
|
82
|
+
|
|
74
83
|
// Stateless pipe: the relay owns the ledger in process memory. Cross-process
|
|
75
84
|
// producers (engine children, plugin children, host executor) reach it through
|
|
76
85
|
// the delivery socket (see delivery-socket.ts), never through control/ files.
|
|
@@ -141,7 +150,8 @@ export class RunStore {
|
|
|
141
150
|
taskId?: string
|
|
142
151
|
application?: ApplicationOrigin
|
|
143
152
|
delivery?: { bindingId: string; scope: string }
|
|
144
|
-
|
|
153
|
+
script?: ScriptRunRef
|
|
154
|
+
}, exclusiveSchedule = false): Promise<RunRecord> {
|
|
145
155
|
const store = runsFor(this.controlDir)
|
|
146
156
|
if (input.id) {
|
|
147
157
|
assertId(input.id)
|
|
@@ -152,6 +162,9 @@ export class RunStore {
|
|
|
152
162
|
return existing
|
|
153
163
|
}
|
|
154
164
|
}
|
|
165
|
+
if (exclusiveSchedule && input.scheduled && [...store.values()].some(run =>
|
|
166
|
+
run.scheduled?.id === input.scheduled!.id && ['queued','running'].includes(run.status)))
|
|
167
|
+
throw new Error('Schedule already has a queued or running occurrence')
|
|
155
168
|
const run: RunRecord = {
|
|
156
169
|
version: input.taskId ? 2 : 1,
|
|
157
170
|
taskId: input.taskId,
|
|
@@ -169,6 +182,7 @@ export class RunStore {
|
|
|
169
182
|
application: input.application,
|
|
170
183
|
delivery: input.delivery,
|
|
171
184
|
scheduled: input.scheduled,
|
|
185
|
+
...(input.script ? { script: input.script } : {}),
|
|
172
186
|
status: 'queued',
|
|
173
187
|
createdAt: new Date().toISOString(),
|
|
174
188
|
}
|
|
@@ -184,7 +198,7 @@ export class RunStore {
|
|
|
184
198
|
|
|
185
199
|
async patch(
|
|
186
200
|
id: string,
|
|
187
|
-
change: Partial<Pick<RunRecord, 'status' | 'startedAt' | 'endedAt' | 'pid' | 'nativeSessionId' | 'interrupted' | 'blockReason' | 'backendSubmitted' | 'replyOnly' | 'exitCode' | 'failureReason' | 'failure' | 'failureReview' | 'externalReleased'>>,
|
|
201
|
+
change: Partial<Pick<RunRecord, 'status' | 'startedAt' | 'endedAt' | 'pid' | 'nativeSessionId' | 'interrupted' | 'blockReason' | 'backendSubmitted' | 'replyOnly' | 'exitCode' | 'failureReason' | 'failure' | 'failureReview' | 'externalReleased' | 'output' | 'timedOut'>>,
|
|
188
202
|
): Promise<RunRecord> {
|
|
189
203
|
const store = runsFor(this.controlDir)
|
|
190
204
|
const run = store.get(id)
|