@jc_stack/ez-agents 0.1.0-beta.47 → 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.
Files changed (72) hide show
  1. package/.env.example +3 -0
  2. package/AGENTS.md +2 -2
  3. package/CHANGELOG.md +21 -0
  4. package/Dockerfile +5 -1
  5. package/THIRD_PARTY_NOTICES.md +6 -3
  6. package/bin/ez +7 -0
  7. package/docker/broker-readiness.mjs +54 -0
  8. package/docker/broker-update-smoke.mjs +86 -0
  9. package/docker/run.ts +1 -1
  10. package/docs/application-channel.md +32 -1
  11. package/docs/architecture/ai-selection.md +18 -3
  12. package/docs/docker-runtime.md +29 -0
  13. package/docs/isolated-plugin-update-readiness.md +66 -0
  14. package/docs/releasing.md +4 -0
  15. package/docs/scheduling.md +100 -6
  16. package/docs/selective-monitoring.md +13 -0
  17. package/docs/upgrades.md +35 -0
  18. package/docs/work-preflight.md +9 -0
  19. package/package.json +1 -1
  20. package/src/ai.ts +22 -3
  21. package/src/application-channel.ts +43 -3
  22. package/src/application-cli.ts +7 -1
  23. package/src/application-origin.ts +2 -0
  24. package/src/audio.ts +37 -22
  25. package/src/client-defaults.ts +3 -2
  26. package/src/config.ts +9 -0
  27. package/src/control-state.ts +3 -1
  28. package/src/delivery-socket-client.d.mts +2 -0
  29. package/src/delivery-socket-client.mjs +2 -0
  30. package/src/delivery-socket.ts +24 -1
  31. package/src/event-sources.ts +2 -2
  32. package/src/executor.ts +72 -1
  33. package/src/files.ts +47 -3
  34. package/src/host-executor.ts +38 -29
  35. package/src/index.ts +41 -18
  36. package/src/install-tools.mjs +8 -5
  37. package/src/menu.ts +4 -2
  38. package/src/plugin-broker.mjs +47 -4
  39. package/src/plugins/manager.mjs +11 -4
  40. package/src/run-evidence.ts +78 -0
  41. package/src/runs.ts +12 -1
  42. package/src/schedule-cli.ts +69 -8
  43. package/src/schedule-preflight.ts +23 -0
  44. package/src/scheduled-tasks.ts +16 -12
  45. package/src/scheduler.ts +79 -18
  46. package/src/scripts.ts +167 -0
  47. package/src/setup.ts +2 -2
  48. package/src/task-capability.ts +17 -1
  49. package/src/task-executor.ts +10 -7
  50. package/src/task-mcp.ts +20 -8
  51. package/src/tasks.ts +89 -9
  52. package/src/updates/runtime.mjs +46 -7
  53. package/test/ai.test.ts +40 -0
  54. package/test/application-channel.test.ts +16 -1
  55. package/test/audio.test.ts +19 -0
  56. package/test/config.test.ts +8 -0
  57. package/test/executor.test.ts +37 -0
  58. package/test/files.test.ts +44 -0
  59. package/test/host-executor.test.ts +45 -2
  60. package/test/install-json.test.mjs +21 -0
  61. package/test/plugin-broker.test.mjs +64 -0
  62. package/test/plugin-manager.test.mjs +20 -0
  63. package/test/private-speech-config.test.ts +22 -0
  64. package/test/run-evidence.test.ts +78 -0
  65. package/test/schedule-preflight.test.ts +49 -0
  66. package/test/scheduler-host.test.ts +7 -0
  67. package/test/scheduler-relay.test.ts +38 -3
  68. package/test/scheduler.test.ts +1 -1
  69. package/test/script-schedules.test.ts +219 -0
  70. package/test/task-native.test.ts +50 -12
  71. package/test/tasks.test.ts +97 -4
  72. package/test/updates.test.mjs +45 -9
@@ -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
- try { handle=await fs.open(lock,'wx',0o600); }
101
- catch(error) { if(error.code==='EEXIST') throw Error('Registry busy; inspect registry.lock before recovering an interrupted manager'); throw error; }
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,6 +150,7 @@ export class RunStore {
141
150
  taskId?: string
142
151
  application?: ApplicationOrigin
143
152
  delivery?: { bindingId: string; scope: string }
153
+ script?: ScriptRunRef
144
154
  }, exclusiveSchedule = false): Promise<RunRecord> {
145
155
  const store = runsFor(this.controlDir)
146
156
  if (input.id) {
@@ -172,6 +182,7 @@ export class RunStore {
172
182
  application: input.application,
173
183
  delivery: input.delivery,
174
184
  scheduled: input.scheduled,
185
+ ...(input.script ? { script: input.script } : {}),
175
186
  status: 'queued',
176
187
  createdAt: new Date().toISOString(),
177
188
  }
@@ -187,7 +198,7 @@ export class RunStore {
187
198
 
188
199
  async patch(
189
200
  id: string,
190
- 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'>>,
191
202
  ): Promise<RunRecord> {
192
203
  const store = runsFor(this.controlDir)
193
204
  const run = store.get(id)
@@ -1,6 +1,7 @@
1
1
  import { needsFailureReview, failureStamp, redactFailure } from './failure.js'
2
2
  import { parseArgs } from 'node:util'
3
3
  import { readFile } from 'node:fs/promises'
4
+ import { resolve } from 'node:path'
4
5
  import { randomUUID } from 'node:crypto'
5
6
  import { loadControlConfig } from './config.js'
6
7
  import { ControlStore, sameOwner } from './control-state.js'
@@ -9,26 +10,44 @@ import type { RunRecord } from './runs.js'
9
10
  import { callDeliverySocket, socketPathFor } from './delivery-socket.js'
10
11
  import { initialPreset, isPreset } from './ai.js'
11
12
  import { executionOverrides } from './model-policy.js'
12
- import { holdsSchedule, Scheduler } from './scheduler.js'
13
+ import { holdsSchedule, Scheduler, executionType } from './scheduler.js'
14
+ import { Scripts, fileSha256, workspaceEntry, DEFAULT_SCRIPT_TIMEOUT_SECONDS } from './scripts.js'
15
+ import { executorEnvironment } from './executor.js'
13
16
  import { ownsRun } from './identity.js'
14
17
  import { type Trigger } from './schedule-time.js'
15
18
 
16
19
  async function main() {
17
- const { values:v, positionals:[action='list',id] } = parseArgs({allowPositionals:true,options:{
20
+ const { values:v, positionals:[action='list',id,scriptId] } = parseArgs({allowPositionals:true,options:{
21
+ script:{type:'string'}, arg:{type:'string',multiple:true}, file:{type:'string'}, interpreter:{type:'string'}, 'timeout-seconds':{type:'string'},
18
22
  cli:{type:'string'}, model:{type:'string'}, effort:{type:'string'},
19
- all:{type:'boolean'}, limit:{type:'string'}, key:{type:'string'}, when:{type:'string'}, status:{type:'string'}, diagnosis:{type:'string'}, recovery:{type:'string'}, outcome:{type:'string'}, 'failed-at':{type:'string'},
20
- name:{type:'string'}, text:{type:'string'}, 'text-file':{type:'string'}, at:{type:'string'}, now:{type:'boolean'},
23
+ all:{type:'boolean'}, limit:{type:'string'}, offset:{type:'string'}, expected:{type:'string'}, key:{type:'string'}, when:{type:'string'}, status:{type:'string'}, diagnosis:{type:'string'}, recovery:{type:'string'}, outcome:{type:'string'}, 'failed-at':{type:'string'},
24
+ 'preflight-file':{type:'string'},'clear-preflight':{type:'boolean'},name:{type:'string'}, text:{type:'string'}, 'text-file':{type:'string'}, at:{type:'string'}, now:{type:'boolean'},
21
25
  cron:{type:'string'}, timezone:{type:'string'}, 'every-seconds':{type:'string'}, start:{type:'string'}, until:{type:'string'}, help:{type:'boolean'},
22
26
  }})
23
27
  if(v.help){console.log(`ezenciel-agents-schedule list | runs | show ID | pause ID | resume ID | remove ID | cancel RUN_ID
24
28
  failures [--all] [--limit N] | run RUN_ID | context
29
+ evidence [RUN_ID] [--offset N --limit N --expected SNAPSHOT_SHA256]
25
30
  trigger SCHEDULE_ID --key REQUEST_KEY
26
31
  review RUN_ID --failed-at ISO --status resolved|attention --diagnosis TEXT --recovery TEXT --outcome TEXT
27
- create [ID] | edit ID --name NAME (--text TEXT | --text-file FILE)
32
+ create [ID] | edit ID --name NAME (--text TEXT | --text-file FILE | --script SCRIPT_ID [--arg=VALUE ...])
28
33
  --now | --at ISO_WITH_OFFSET | --every-seconds N | --cron 'MIN HOUR DAY MONTH WEEKDAY' --timezone IANA
29
34
  [--cli EXECUTOR] [--model MODEL] [--effort <native-effort>]
35
+ [--preflight-file FILE | --clear-preflight]
30
36
  [--start ISO_WITH_OFFSET] [--until ISO_WITH_OFFSET] [--when unreviewed-failures]
37
+ script list | show SCRIPT_ID | remove SCRIPT_ID
38
+ script register|update SCRIPT_ID --file WORKSPACE_PATH --interpreter COMMAND [--arg=VALUE ...] [--timeout-seconds N]
39
+ Execution types: agent (prompt, engine, model, effort) or script (registered script ID and saved arguments).
40
+ A script registration references an entry point inside the agent workspace and records its SHA-256. Core invokes
41
+ the installed interpreter with the entry point and arguments directly, without a shell or a model. Changed entry-point
42
+ bytes refuse to run until an explicit script update. The hash covers that file only, not imported dependencies.
43
+ Script runs receive EZ_RUN_ID, EZ_SCHEDULE_ID, EZ_SCHEDULE_REVISION, EZ_DUE_AT, EZ_SCRIPT_ID, EZ_SCRIPT_REVISION and
44
+ EZ_SCRIPT_SHA256 plus the normal executor allowlist. Stdout/stderr are bounded diagnostics in run, never owner messages.
45
+ Script runs have a timeout (default ${DEFAULT_SCRIPT_TIMEOUT_SECONDS}s) and are never retried automatically.
31
46
  Context reads the current run only.
47
+ Evidence exports sanitized current-owner Telegram operational metadata only.
48
+ Follow nextOffset with --expected; a changed snapshot requires restarting.
49
+ Relay retention is incomplete. Content hashes identify submitted text, not recipient content.
50
+ Native sessions, tool/delegation events and failure/review prose are not exported.
32
51
  Failures default to unreviewed owner runs. Review records a diagnosis; it never changes execution status or retries work.
33
52
  A conditional review schedule consumes no model run when there are no unreviewed failures.
34
53
  New tasks capture the selected engine settings; edit preserves existing settings unless overridden.
@@ -66,6 +85,10 @@ Cron uses numeric five-field syntax, lists/ranges/steps, and traditional day/wee
66
85
  if(process.env.EZ_RUN_ID && (!caller || caller.status!=='running' || caller.external || caller.taskId || caller.replyOnly ||
67
86
  !ownsRun(owner, caller) ||
68
87
  (caller.scheduled && caller.scheduled.pairedAt!==owner.pairedAt)))throw new Error('Scheduling requires an active owner-authorized run')
88
+ // A script occurrence may read state and deliver through bound plugins; it
89
+ // cannot change registrations or schedules, including its own.
90
+ const readOnly=['list','runs','show','run','context','failures','evidence'].includes(action) || (action==='script' && ['list','show'].includes(id ?? ''))
91
+ if(caller?.script && !readOnly)throw new Error('Script runs cannot change scripts or schedules')
69
92
  const owned=(s:{owner:typeof owner})=>sameOwner(s.owner,owner)
70
93
  const ownsFailureRun=(r:RunRecord | null)=>r && ownsRun(owner,r) && (!r.scheduled || r.scheduled.pairedAt===owner.pairedAt)
71
94
  const show=async(s:Awaited<ReturnType<Scheduler['get']>>)=>{
@@ -73,11 +96,38 @@ Cron uses numeric five-field syntax, lists/ranges/steps, and traditional day/wee
73
96
  const interruptedRunIds=held.filter(r=>r.interrupted).map(r=>r.id)
74
97
  const failedReviewRunIds=held.filter(r=>!r.interrupted).map(r=>r.id)
75
98
  const next=s.enabled && !held.length ? await scheduler.pendingOccurrence(s) : null
76
- return {...s,interruptedRunIds,failedReviewRunIds,nextEligibleAt:next===null ? null : new Date(next).toISOString(),
99
+ return {...s,executionType:executionType(s),preflightReceipt:await scheduler.preflightReceipt(s),interruptedRunIds,failedReviewRunIds,nextEligibleAt:next===null ? null : new Date(next).toISOString(),
77
100
  ...(held.length ? {recovery:'Inspect the failed run and explicitly edit this schedule to resume; pause/resume does not clear the stop.'} : {})}
78
101
  }
102
+ const scripts=new Scripts(config.controlDir)
79
103
  let result:unknown
80
- if(action==='context'){
104
+ if(action==='script'){
105
+ const workspace=process.env.EZ_AGENT_WORKSPACE?.trim() || process.cwd()
106
+ if(id==='list')result=await scripts.list(owner)
107
+ else if(!scriptId)throw new Error('Script ID required')
108
+ else if(id==='show'){
109
+ const registration=await scripts.owned(scriptId,owner)
110
+ let current:string|null=null
111
+ try{current=await fileSha256((await workspaceEntry(workspace,registration.entry)).file)}catch{}
112
+ result={...registration,currentSha256:current,matchesRegistration:current===registration.sha256,
113
+ schedules:(await scheduler.list()).filter(s=>owned(s) && s.script?.id===scriptId).map(s=>s.id)}
114
+ }else if(id==='remove'){
115
+ const users=(await scheduler.list()).filter(s=>owned(s) && s.script?.id===scriptId).map(s=>s.id)
116
+ if(users.length)throw new Error(`Script is used by schedules ${users.join(', ')}; edit or remove them first`)
117
+ await scripts.remove(scriptId,owner);result={removed:scriptId}
118
+ }else if(id==='register' || id==='update'){
119
+ const previous=id==='update' ? await scripts.owned(scriptId,owner) : undefined
120
+ if(id==='register' && (!v.file || !v.interpreter))throw new Error('Register requires --file and --interpreter')
121
+ const timeout=v['timeout-seconds']===undefined ? previous?.timeoutSeconds ?? DEFAULT_SCRIPT_TIMEOUT_SECONDS : Number(v['timeout-seconds'])
122
+ result=await scripts.save({id:scriptId,owner,workspace,entry:v.file ? resolve(v.file) : previous!.entry,interpreter:v.interpreter ?? previous!.interpreter,
123
+ args:v.arg ?? previous?.args ?? [],timeoutSeconds:timeout,pathValue:executorEnvironment().PATH},id==='register')
124
+ }else throw new Error('Unknown script action; use --help')
125
+ }else if(action==='evidence'){
126
+ result=await callDeliverySocket(socketPath,{op:'evidence',payload:{
127
+ ...(caller ? {callerRunId:caller.id} : {}), ...(id ? {runId:id} : {}),
128
+ offset:Number(v.offset ?? 0),limit:Number(v.limit ?? 100),expected:v.expected,
129
+ }})
130
+ }else if(action==='context'){
81
131
  if(!caller)throw new Error('Context requires an active owner run')
82
132
  const origin=caller.scheduled?.originRunId ? await runs.get(caller.scheduled.originRunId) : null
83
133
  if(origin && !ownsFailureRun(origin))throw new Error('Source context is outside this owner binding')
@@ -108,14 +158,24 @@ Cron uses numeric five-field syntax, lists/ranges/steps, and traditional day/wee
108
158
  if(action==='edit' && (!id || !owned(await scheduler.get(id))))throw new Error('Unknown schedule')
109
159
  if(action==='create' && id && (await scheduler.list()).some(s=>s.id===id))throw new Error('Schedule exists; use edit')
110
160
  if([v.now,v.at,v.cron,v['every-seconds']].filter(Boolean).length!==1)throw new Error('Choose exactly one trigger')
111
- if(Boolean(v.text)===Boolean(v['text-file']))throw new Error('Choose --text or --text-file')
161
+ if([v.text,v['text-file'],v.script].filter(Boolean).length!==1)throw new Error('Choose --text, --text-file or --script')
162
+ if(v.script && (v.cli || v.model || v.effort))throw new Error('Script schedules run no model; --cli, --model and --effort do not apply')
163
+ if(v.arg && !v.script)throw new Error('--arg applies to script schedules only')
112
164
  const start=v.start || new Date(Date.now()+1000).toISOString()
113
165
  const trigger:Trigger=v.now ? {at:new Date(Date.now()+1000).toISOString()} : v.at ? {at:v.at} :
114
166
  v.cron ? {cron:v.cron,timezone:v.timezone!,start,until:v.until} : {everySeconds:Number(v['every-seconds']),start,until:v.until}
167
+ if(v['clear-preflight'] && v['preflight-file']) throw Error('Choose one preflight operation')
115
168
  const previousSchedule = action === 'edit' ? await scheduler.get(id!) : undefined
169
+ const preflight = v['clear-preflight'] ? undefined : v['preflight-file'] ? JSON.parse(await readFile(v['preflight-file'],'utf8')) : previousSchedule?.preflight
116
170
  const origin = caller?.application ?? caller?.delivery
117
171
  const delivery = previousSchedule ? previousSchedule.delivery : (origin ? {bindingId:origin.bindingId,scope:origin.scope} : undefined)
118
172
  if (!delivery && !owner.telegramChatId) throw new Error('Create the schedule from an authenticated channel turn to bind its reply destination')
173
+ if (v.script) {
174
+ result=await show(await scheduler.save({id:id || 's_'+randomUUID(),name:v.name || v.script,preflight,
175
+ originRunId:previousSchedule?.originRunId ?? caller?.scheduled?.originRunId ?? caller?.id,delivery,text:'',when:v.when as 'unreviewed-failures' | undefined,trigger,enabled:true,owner,
176
+ script:{id:v.script,args:v.arg ?? []}},action==='create'))
177
+ console.log(JSON.stringify(result,null,2));return
178
+ }
119
179
  const previous = previousSchedule?.execution
120
180
  const state = await control.status()
121
181
  const selected = state.ai?.presets.find(p => p.id === state.ai!.selectedId)
@@ -123,6 +183,7 @@ Cron uses numeric five-field syntax, lists/ranges/steps, and traditional day/wee
123
183
  const preset = executionOverrides(base.cli, base, v.model, v.effort)
124
184
  if (!isPreset(preset)) throw new Error('Invalid task AI selection')
125
185
  result=await show(await scheduler.save({id:id || 's_'+randomUUID(),name:v.name || 'Task',
186
+ preflight,
126
187
  originRunId:previousSchedule?.originRunId ?? caller?.scheduled?.originRunId ?? caller?.id,delivery,text:v.text || await readFile(v['text-file']!,'utf8'),when:v.when as 'unreviewed-failures' | undefined,trigger,enabled:true,owner,
127
188
  execution:{sessionId:previous?.sessionId || randomUUID(),preset}},action==='create'))
128
189
  }else{
@@ -0,0 +1,23 @@
1
+ import { createHash, randomUUID } from 'node:crypto'
2
+ import { callLocalSocket } from './delivery-socket-client.mjs'
3
+
4
+ export type Preflight = {on: 'eligible' | 'changed'; checks: {alias: string; args: string[]}[]}
5
+ export type PreflightReceipt = {eligible: boolean; fingerprint: string; observedAt: string; count: number}
6
+ export function validPreflight(v: unknown): v is Preflight {
7
+ const p=v as Preflight
8
+ return Boolean(p && !Array.isArray(p) && Object.keys(p).every(k=>['on','checks'].includes(k)) && ['eligible','changed'].includes(p.on) &&
9
+ Array.isArray(p.checks) && p.checks.length>0 && p.checks.length<=8 && p.checks.every(c=>c && Object.keys(c).every(k=>['alias','args'].includes(k)) &&
10
+ /^[a-z][a-z0-9-]{0,39}$/.test(c.alias) && Array.isArray(c.args) && c.args.length<=100 && c.args.every(a=>typeof a==='string' && !/[\0\r\n]/.test(a) && Buffer.byteLength(a)<=8192)))
11
+ }
12
+ export function combinePreflight(results: unknown[]): PreflightReceipt {
13
+ const values=results as (PreflightReceipt & {schemaVersion:number})[]
14
+ if(!values.length || values.some(v=>!v || v.schemaVersion!==1 || typeof v.eligible!=='boolean' || !/^[a-f0-9]{64}$/.test(v.fingerprint) ||
15
+ !Number.isSafeInteger(v.count) || v.count<0 || typeof v.observedAt!=='string' || !Number.isFinite(Date.parse(v.observedAt)) || Date.parse(v.observedAt)>Date.now()+1000 || Date.now()-Date.parse(v.observedAt)>300000)) throw Error('Invalid or stale plugin preflight result')
16
+ return {eligible:values.some(v=>v.eligible),fingerprint:createHash('sha256').update(JSON.stringify(values.map(v=>v.fingerprint))).digest('hex'),
17
+ observedAt:new Date().toISOString(),count:values.reduce((n,v)=>n+v.count,0)}
18
+ }
19
+ export async function installedPreflight(scheduleId:string, revision:string): Promise<PreflightReceipt> {
20
+ const socket=process.env.EZ_PLUGIN_BROKER_SOCKET
21
+ if(!socket) throw Error('Scheduled preflight requires the bound plugin broker')
22
+ return await callLocalSocket(socket,{version:1,id:randomUUID(),operation:'preflight',scheduleId,revision},120000) as PreflightReceipt
23
+ }
@@ -11,11 +11,16 @@ const ownsSchedule = (owner: Owner, schedule: Schedule) =>
11
11
  // Saved schedules created under an earlier model policy remain inspectable. The
12
12
  // executor will apply the same defaults (and enforce its current policy) when
13
13
  // it starts the run; an outdated saved selection must not hide every menu row.
14
- const displayedPreset = (schedule: Schedule) => {
15
- try { return executionDefaults(schedule.execution.preset.cli, schedule.execution.preset) }
16
- catch { return schedule.execution.preset }
14
+ const displayedPreset = (execution: NonNullable<Schedule['execution']>) => {
15
+ try { return executionDefaults(execution.preset.cli, execution.preset) }
16
+ catch { return execution.preset }
17
17
  }
18
18
 
19
+ // Script schedules have no AI preset; show what core actually invokes.
20
+ const engineLabel = (schedule: ActiveSchedule) => schedule.script
21
+ ? `Script · ${schedule.script.id} · ${schedule.scriptRevision ? `rev ${schedule.scriptRevision.slice(0, 8)}` : 'not registered'}`
22
+ : presetLabel(displayedPreset(schedule.execution!))
23
+
19
24
  const weekdays = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat']
20
25
  const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']
21
26
  const time = (value: number | string) => {
@@ -25,6 +30,7 @@ const time = (value: number | string) => {
25
30
  }
26
31
 
27
32
  const preview = (schedule: Schedule) => {
33
+ if (schedule.script) return schedule.script.args.length ? `Arguments: ${schedule.script.args.join(' ')}`.slice(0, 140) : 'No saved arguments'
28
34
  const sentence = schedule.text.trim().replace(/\s+/gu,' ').split(/(?<=[.!?])\s/u)[0]
29
35
  const chars = Array.from(sentence)
30
36
  return chars.length > 140 ? chars.slice(0,139).join('')+'…' : sentence
@@ -46,15 +52,13 @@ export const ownedScheduledTasks = (schedules: ActiveSchedule[], owner: Owner) =
46
52
  export const scheduledTasksText = (schedules: ActiveSchedule[], owner: Owner) => {
47
53
  const owned = ownedScheduledTasks(schedules, owner)
48
54
  if (!owned.length) return '📅 Scheduled tasks\n\nNo active scheduled tasks.'
49
- return [`📅 Scheduled tasks · ${owned.length} active`, 'All times UTC. Use /tasks 1 for task details.', ...owned.map((schedule, index) => {
50
- const preset = displayedPreset(schedule)
51
- return `${index + 1} · ${schedule.name}\n ${currentState(schedule)}\n Next · ${schedule.nextAt === null ? '—' : time(schedule.nextAt)}\n Last · ${lastRun(schedule)}\n ${presetLabel(preset)}\n ${preview(schedule)}`
52
- })].join('\n\n')
55
+ return [`📅 Scheduled tasks · ${owned.length} active`, 'All times UTC. Use /tasks 1 for task details.', ...owned.map((schedule, index) =>
56
+ `${index + 1} · ${schedule.name}\n ${currentState(schedule)}\n Next · ${schedule.nextAt === null ? '—' : time(schedule.nextAt)}\n Last · ${lastRun(schedule)}\n ${engineLabel(schedule)}\n ${preview(schedule)}`
57
+ )].join('\n\n')
53
58
  }
54
59
 
55
60
  export const scheduledTaskDetailText = (schedule: ActiveSchedule, number: number, total?: number) => {
56
- const preset = displayedPreset(schedule)
57
- const instructions = schedule.text.trim()
61
+ const instructions = schedule.script ? preview(schedule) : schedule.text.trim()
58
62
  const capped = instructions.length > 2000 ? instructions.slice(0, 2000) + '… (truncated; full text lives in the workspace)' : instructions
59
63
  return [
60
64
  `📅 ${number}${total === undefined ? '' : ` of ${total}`} · ${schedule.name}`,
@@ -66,10 +70,10 @@ export const scheduledTaskDetailText = (schedule: ActiveSchedule, number: number
66
70
  'Last run',
67
71
  lastRun(schedule),
68
72
  '',
69
- 'Engine',
70
- presetLabel(preset),
73
+ schedule.script ? 'Execution' : 'Engine',
74
+ engineLabel(schedule),
71
75
  '',
72
- 'Instructions',
76
+ schedule.script ? 'Script arguments' : 'Instructions',
73
77
  capped,
74
78
  ].join('\n')
75
79
  }
package/src/scheduler.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { validPreflight, installedPreflight, type Preflight, type PreflightReceipt } from './schedule-preflight.js'
1
2
  import { assertEffort, assertScheduledModel } from './model-policy.js'
2
3
  import { needsFailureReview } from './failure.js'
3
4
  import { mkdir, readFile, readdir, writeFile, rename, link, rm } from 'node:fs/promises'
@@ -10,12 +11,17 @@ import { assertId, ownsRun } from './identity.js'
10
11
  import { type ExecutionChoice, isExecutionChoice, persistedPreset } from './ai.js'
11
12
  import { type Trigger, validateTrigger, nextOccurrence } from './schedule-time.js'
12
13
  import { RunStore, type RunRecord } from './runs.js'
14
+ import { Scripts, assertScriptArgs, assertScriptId, type ScriptRunRef } from './scripts.js'
13
15
 
14
16
  export type Schedule = {
15
17
  originRunId?: string
16
18
  when?: 'unreviewed-failures'
19
+ preflight?: Preflight
17
20
  version: 1; id: string; revision: string; name: string; text: string; trigger: Trigger; enabled: boolean
18
- owner: Owner; execution: ExecutionChoice
21
+ owner: Owner
22
+ // Exactly one execution type: an agent turn (execution) or a registered script.
23
+ execution?: ExecutionChoice
24
+ script?: { id: string; args: string[] }
19
25
  delivery?: { bindingId: string; scope: string }
20
26
  }
21
27
  export type ScheduledRunSummary = {
@@ -23,13 +29,24 @@ export type ScheduledRunSummary = {
23
29
  at: string
24
30
  currentRevision: boolean
25
31
  }
26
- export type ActiveSchedule = Schedule & { nextAt: number | null; runState?: 'queued' | 'running'; lastRun?: ScheduledRunSummary }
32
+ export type ActiveSchedule = Schedule & { nextAt: number | null; runState?: 'queued' | 'running'; lastRun?: ScheduledRunSummary; scriptRevision?: string }
27
33
  export type ScheduledOrigin = { id: string; revision: string; dueAt: string; pairedAt: string; originRunId?: string }
28
34
  export const validScheduledOrigin = (v: unknown): v is ScheduledOrigin => {
29
35
  const s = v as ScheduledOrigin
30
36
  return Boolean(s && /^[a-zA-Z0-9_-]+$/.test(s.id) && /^[a-zA-Z0-9_-]+$/.test(s.revision) &&
31
37
  Number.isFinite(Date.parse(s.dueAt)) && typeof s.pairedAt === 'string' && (s.originRunId === undefined || /^[a-zA-Z0-9_-]+$/.test(s.originRunId)))
32
38
  }
39
+ export const executionType = (s: Pick<Schedule,'script'>): 'agent' | 'script' => s.script ? 'script' : 'agent'
40
+ const validExecution = (s: Pick<Schedule,'execution'|'script'|'text'>) => s.script
41
+ ? s.execution === undefined && (() => { try { assertScriptId(s.script!.id); assertScriptArgs(s.script!.args); return true } catch { return false } })()
42
+ : isExecutionChoice(s.execution) && typeof s.text === 'string' && Boolean(s.text.trim())
43
+ // The run carries the registration revision/hash current when it was queued.
44
+ const scriptRef = async (controlDir: string, s: Schedule, owner: Owner): Promise<ScriptRunRef> => {
45
+ const ref: ScriptRunRef = { id: s.script!.id, args: s.script!.args }
46
+ try { const r = await new Scripts(controlDir).owned(s.script!.id, owner); return { ...ref, revision: r.revision, sha256: r.sha256 } }
47
+ catch { return ref }
48
+ }
49
+ const runTexts = (s: Schedule, dueAt: string) => s.script ? [`[schedule ${s.id} due ${dueAt}] script ${s.script.id}`] : [`[schedule ${s.id} due ${dueAt}]`, s.text]
33
50
  export const scheduledRunId = (s: Schedule, due: number) => 'r_schedule_' + createHash('sha256')
34
51
  .update(JSON.stringify([s.id,s.revision,due])).digest('hex')
35
52
  export const holdsSchedule = (s: Schedule, r: RunRecord): boolean =>
@@ -45,13 +62,13 @@ const atomic = async (file: string, value: unknown, exclusive = false) => {
45
62
  }
46
63
  export class Scheduler {
47
64
  private dir: string
48
- constructor(private controlDir: string) { this.dir = join(controlDir,'schedules') }
65
+ constructor(private controlDir: string, private checkPreflight = installedPreflight) { this.dir = join(controlDir,'schedules') }
49
66
  private async ensure() { await mkdir(this.dir,{recursive:true,mode:0o700}) }
50
67
  async get(id: string): Promise<Schedule> {
51
68
  const s = JSON.parse(await readFile(join(this.dir,assertId(id)+'.json'),'utf8')) as Schedule
52
69
  if (s.version !== 1 || s.id !== id || !validScheduledOrigin({id:s.id,revision:s.revision,dueAt:new Date().toISOString(),pairedAt:s.owner?.pairedAt,originRunId:s.originRunId}) ||
53
- (s.when !== undefined && s.when !== 'unreviewed-failures') || typeof s.enabled !== 'boolean' || !s.name || typeof s.text !== 'string' || !s.text.trim() ||
54
- !validOwner(s.owner) || !isExecutionChoice(s.execution) ||
70
+ (s.when !== undefined && s.when !== 'unreviewed-failures') || (s.preflight !== undefined && !validPreflight(s.preflight)) || typeof s.enabled !== 'boolean' || !s.name || typeof s.text !== 'string' ||
71
+ !validOwner(s.owner) || !validExecution(s) ||
55
72
  (s.delivery !== undefined && !validApplicationOrigin({...s.delivery, requestId: s.id})))
56
73
  throw new Error('Invalid schedule record')
57
74
  validateTrigger(s.trigger)
@@ -99,7 +116,8 @@ export class Scheduler {
99
116
  if (!runState && current.some(r => holdsSchedule(s,r))) continue
100
117
  try {
101
118
  const nextAt = await this.pendingOccurrence(s)
102
- if (runState || nextAt !== null) active.push({...s,nextAt,runState,...(last ? {
119
+ const scriptRevision = s.script ? await new Scripts(this.controlDir).get(s.script.id).then(r => r.revision, () => undefined) : undefined
120
+ if (runState || nextAt !== null) active.push({...s,nextAt,runState,...(scriptRevision ? {scriptRevision} : {}),...(last ? {
103
121
  lastRun: {status:last.status,at:last.endedAt ?? last.startedAt ?? last.createdAt,currentRevision:last.scheduled!.revision===s.revision},
104
122
  } : {})})
105
123
  }
@@ -110,11 +128,21 @@ export class Scheduler {
110
128
  async save(input: Omit<Schedule,'version'|'revision'>, exclusive = false): Promise<Schedule> {
111
129
  await this.ensure(); assertId(input.id)
112
130
  if (input.when !== undefined && input.when !== 'unreviewed-failures') throw new Error('Unknown schedule condition')
113
- const execution: ExecutionChoice = {...input.execution, preset: persistedPreset(input.execution.preset)}
114
- if (!input.name || !input.text?.trim() || !isExecutionChoice(execution)) throw new Error('Schedule needs name, text and an AI selection')
115
- assertScheduledModel(execution.preset.model)
116
- assertEffort(execution.preset.effort, execution.preset.model, execution.preset.cli)
117
- const s: Schedule = {...input, execution, trigger:validateTrigger(input.trigger),version:1,revision:randomUUID()}
131
+ if(input.preflight !== undefined && !validPreflight(input.preflight)) throw Error('Invalid schedule preflight')
132
+ let s: Schedule
133
+ if (input.script) {
134
+ if (input.execution !== undefined || !input.name) throw new Error('Script schedules need a name and no AI selection')
135
+ const script = {id:assertScriptId(input.script.id),args:assertScriptArgs(input.script.args)}
136
+ await new Scripts(this.controlDir).owned(script.id, input.owner)
137
+ s = {...input, text:'', script, trigger:validateTrigger(input.trigger),version:1,revision:randomUUID()}
138
+ } else {
139
+ if (!input.execution) throw new Error('Schedule needs name, text and an AI selection')
140
+ const execution: ExecutionChoice = {...input.execution, preset: persistedPreset(input.execution.preset)}
141
+ if (!input.name || !input.text?.trim() || !isExecutionChoice(execution)) throw new Error('Schedule needs name, text and an AI selection')
142
+ assertScheduledModel(execution.preset.model)
143
+ assertEffort(execution.preset.effort, execution.preset.model, execution.preset.cli)
144
+ s = {...input, execution, trigger:validateTrigger(input.trigger),version:1,revision:randomUUID()}
145
+ }
118
146
  const now = Date.now()
119
147
  let next = nextOccurrence(s.trigger,now-1)
120
148
  if (next === null) throw new Error('Schedule has no future occurrence within eight years')
@@ -129,12 +157,26 @@ export class Scheduler {
129
157
  }
130
158
  // Publish the cursor first so a crash cannot expose a new revision with
131
159
  // a missing cursor that falls back to the trigger's historical start.
132
- await atomic(join(this.dir,`${s.id}.${s.revision}.cursor`),{next})
160
+ const prior=await this.preflightReceipt(previous)
161
+ await atomic(join(this.dir,`${s.id}.${s.revision}.cursor`),{next,...(JSON.stringify(previous.preflight)===JSON.stringify(s.preflight) && prior ? {preflight:prior} : {})})
133
162
  }
134
163
  }
135
164
  await atomic(join(this.dir,s.id+'.json'),s,exclusive)
136
165
  return s
137
166
  }
167
+ async preflight(id:string, revision:string) {
168
+ const s=await this.get(id)
169
+ if(s.revision!==revision || !s.enabled || !s.preflight) throw Error('Schedule preflight is unavailable')
170
+ const receipt=await this.checkPreflight(id,revision)
171
+ const cursor=join(this.dir,`${s.id}.${s.revision}.cursor`)
172
+ const previous=await readFile(cursor,'utf8').then(v=>JSON.parse(v)).catch(async e=>{if(e.code==='ENOENT')return {next:await this.pendingOccurrence(s)};throw e})
173
+ const admitted=receipt.eligible && (s.preflight.on!=='changed' || previous.preflight?.fingerprint!==receipt.fingerprint)
174
+ return {receipt,admitted,previous,cursor}
175
+ }
176
+ async preflightReceipt(s:Schedule) {
177
+ try {return JSON.parse(await readFile(join(this.dir,`${s.id}.${s.revision}.cursor`),'utf8')).preflight}
178
+ catch(e) {if((e as NodeJS.ErrnoException).code==='ENOENT')return undefined;throw e}
179
+ }
138
180
  async enable(id: string, enabled: boolean): Promise<Schedule> {
139
181
  const s = await this.get(id)
140
182
  // Pausing preserves the cursor. Resuming coalesces missed recurrences like restart.
@@ -163,7 +205,8 @@ export class Scheduler {
163
205
  async trigger(id: string, revision: string, key: string, owner: Owner, runs: RunStore): Promise<RunRecord> {
164
206
  const s=await this.get(id)
165
207
  if (!sameOwner(s.owner,owner) || !s.enabled || s.revision !== revision) throw new Error('Schedule is disabled, changed or outside this owner binding')
166
- assertScheduledModel(s.execution.preset.model)
208
+ if (s.script) await new Scripts(this.controlDir).owned(s.script.id, owner)
209
+ else assertScheduledModel(s.execution!.preset.model)
167
210
  if (s.delivery) {
168
211
  const binding=(await new ApplicationBindings(this.controlDir).list()).find(b=>b.bindingId===s.delivery!.bindingId)
169
212
  if (!binding || !sameOwner(binding.owner,owner)) throw new Error('Schedule delivery binding is unavailable')
@@ -177,11 +220,20 @@ export class Scheduler {
177
220
  const all=await runs.list()
178
221
  if (all.some(r=>holdsSchedule(s,r))) throw new Error('Inspect the held occurrence and edit the schedule before retrying')
179
222
  if (s.when === 'unreviewed-failures' && !all.some(r=>needsFailureReview(r) && ownsRun(owner,r) && (!r.scheduled || r.scheduled.pairedAt===owner.pairedAt))) throw new Error('Schedule condition is not met')
223
+ let checked: Awaited<ReturnType<Scheduler['preflight']>> | undefined
224
+ if(s.preflight) {
225
+ checked=await this.preflight(s.id,s.revision)
226
+ if(!checked.receipt.eligible) throw Error('Schedule preflight has no eligible work')
227
+ const current=await this.get(s.id)
228
+ if(!current.enabled || current.revision!==s.revision) throw Error('Schedule changed during preflight')
229
+ }
180
230
  const dueAt=new Date().toISOString()
181
- return runs.create({id:runId,ownerId:ownerId(owner),ownerEpoch:ownerEpoch(owner),
231
+ const run=await runs.create({id:runId,ownerId:ownerId(owner),ownerEpoch:ownerEpoch(owner),
182
232
  ...(s.delivery ? {delivery:s.delivery} : {chatId:s.owner.telegramChatId,telegramUserId:s.owner.telegramUserId,telegramEpoch:s.owner.telegramLinkedAt ?? s.owner.pairedAt}),
183
- texts:[`[schedule ${s.id} due ${dueAt}]`,s.text],execution:{...s.execution,sessionId:randomUUID()},
233
+ texts:runTexts(s,dueAt),...(s.script ? {script:await scriptRef(this.controlDir,s,owner)} : {execution:{...s.execution!,sessionId:randomUUID()}}),
184
234
  scheduled:{id:s.id,revision:s.revision,dueAt,pairedAt:s.owner.pairedAt,...(s.originRunId ? {originRunId:s.originRunId} : {})}},true)
235
+ if(checked) await atomic(checked.cursor,{...checked.previous,preflight:checked.receipt})
236
+ return run
185
237
  }
186
238
  async recover(runs: RunStore) {
187
239
  for (const run of await runs.list()) {
@@ -204,7 +256,7 @@ export class Scheduler {
204
256
  try {
205
257
  const next = await this.pendingOccurrence(s)
206
258
  if (next === null || next > now) continue
207
- assertScheduledModel(s.execution.preset.model)
259
+ if (!s.script) assertScheduledModel(s.execution!.preset.model)
208
260
  // One occurrence at a time. A failed reviewer stops this revision just like
209
261
  // interrupted work: retain its receipt until an explicit schedule edit.
210
262
  if ((await runs.list()).some(r => r.scheduled?.id === s.id &&
@@ -213,14 +265,23 @@ export class Scheduler {
213
265
  if (s.when === 'unreviewed-failures' && !(await runs.list()).some(r => needsFailureReview(r) && ownsRun(owner, r) && (!r.scheduled || r.scheduled.pairedAt === owner.pairedAt))) {
214
266
  await atomic(cursor,{next:future}); continue
215
267
  }
268
+ let preflight: PreflightReceipt | undefined
269
+ if(s.preflight) {
270
+ try {
271
+ const checked=await this.preflight(s.id,s.revision)
272
+ preflight=checked.receipt
273
+ if(!checked.admitted) {await atomic(cursor,{next:future,preflight});continue}
274
+ const current=await this.get(s.id);if(!current.enabled || current.revision!==s.revision)continue
275
+ } catch {const prior=await readFile(cursor,'utf8').then(v=>JSON.parse(v)).catch(e=>{if(e.code==='ENOENT')return {};throw e});await atomic(cursor,{next:future,preflight:{...prior.preflight,state:'unavailable',observedAt:new Date().toISOString()}});continue}
276
+ }
216
277
  const dueAt = new Date(next).toISOString()
217
278
  await runs.create({id:scheduledRunId(s,next),
218
279
  ownerId:ownerId(owner),ownerEpoch:ownerEpoch(owner),
219
280
  ...(s.delivery ? {delivery:s.delivery} : {chatId:s.owner.telegramChatId,telegramUserId:s.owner.telegramUserId,telegramEpoch:s.owner.telegramLinkedAt ?? s.owner.pairedAt}),
220
- texts:[`[schedule ${s.id} due ${dueAt}]`, s.text],execution:s.execution,
281
+ texts:runTexts(s,dueAt),...(s.script ? {script:await scriptRef(this.controlDir,s,owner)} : {execution:s.execution}),
221
282
  scheduled:{id:s.id,revision:s.revision,dueAt,pairedAt:s.owner.pairedAt,...(s.originRunId?{originRunId:s.originRunId}:{})}},true)
222
283
  // A restart between run creation and this cursor write sees the same occurrence ID.
223
- await atomic(cursor,{next:future})
284
+ await atomic(cursor,{next:future,...(preflight ? {preflight} : {})})
224
285
  } catch (error) { console.error('Schedule dispatch failed',s.id,error instanceof Error ? error.message : 'Unknown error') }
225
286
  }
226
287
  }