@tealbrick/avm 0.2.6 → 0.2.7

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/ROUTINES.md CHANGED
@@ -1,4 +1,4 @@
1
- # Shared host routines (Eve 0.58.1)
1
+ # Shared host routines (Eve 0.66.0)
2
2
 
3
3
  The AVM package owns the host store, typed client and opt-in Eve routine tool.
4
4
  The host store is authoritative. Neither the desktop nor Eve edits a second
@@ -83,3 +83,17 @@ remove the corresponding old static schedule from discovery, then build/restart
83
83
  once. Never leave both static and dynamic execution enabled for the same job.
84
84
  Subsequent edits need no rebuild or restart. Desktop reads dynamic rows from
85
85
  management discovery and hides the dispatcher implementation from its list.
86
+
87
+ ## Deployment recovery and standing delegation (0.2.7)
88
+
89
+ Use `startRoutineRecovery` from `@tealbrick/avm/routines-recovery` in the host
90
+ service. It reconciles at startup and on a bounded timer without guest calls.
91
+ Keep its stop handle for shutdown. `reconcile` is an internal host-store action;
92
+ the example HTTP bridge does not expose it to the guest.
93
+
94
+ Optional `delegation()` is re-read for native app-auth CRUD calls. It binds the
95
+ exact scope, expiry, allowed operations, name prefix and wake permission.
96
+ `authorizeExecution()` independently gates each claim batch. The supplied host
97
+ service requires an external execution grant and fails closed without one.
98
+ `misfireGraceMs` controls the explicit late-start window. No backlog burst or
99
+ uncertain side-effect retry is added. See the root DEPLOYMENT-FIXES.md contract.
@@ -0,0 +1,39 @@
1
+ #!/usr/bin/env node
2
+ // Run only on the AVMM host. Bind its worker bridge IP, not a public interface.
3
+ import {createServer} from 'node:http';
4
+ import {execFile} from 'node:child_process';
5
+ import {promisify} from 'node:util';
6
+ import {timingSafeEqual} from 'node:crypto';
7
+ import {readFileSync,lstatSync} from 'node:fs';
8
+ import {startRoutineRecovery} from '@tealbrick/avm/routines-recovery';
9
+ import {createRoutineStore} from '@tealbrick/avm/routines';
10
+ import {createAvmmWakeAdapter} from '@tealbrick/avm/wake';
11
+ const configPath=process.env.TEAL_WAKE_CONFIG;if(!configPath)throw Error('routine_host_config_required');
12
+ const configStat=lstatSync(configPath);if(!configStat.isFile()||configStat.isSymbolicLink()||(configStat.mode&0o077))throw Error('routine_host_config_permissions');
13
+ const config=JSON.parse(readFileSync(process.env.TEAL_WAKE_CONFIG,'utf8'));
14
+ if(typeof config.token!=='string'||config.token.length<32||!config.routineScope||!config.routineStore||!config.executionGrantFile)throw Error('routine_host_enrollment_required');
15
+ const run=promisify(execFile);
16
+ const adapter=createAvmmWakeAdapter({allowed:true,timezone:'UTC',credentialsRefreshOnRequest:config.credentialsRefreshOnRequest===true,worker:config.worker,ownerKey:config.ownerKey,transport:{async run(argv,{signal}={}){try{const r=await run('/usr/local/bin/avm',argv,{signal,maxBuffer:1048576});return {exitCode:0,...r};}catch{return {exitCode:1,stdout:'',stderr:''};}}}});
17
+ const routines=config.routineScope?createRoutineStore({path:config.routineStore,scope:config.routineScope,misfireGraceMs:config.misfireGraceMs,wake:adapter,delegation:async()=>{try{return JSON.parse(readFileSync(config.delegationFile,'utf8'));}catch{return null;}},authorizeExecution:async()=>{try{const grant=JSON.parse(readFileSync(config.executionGrantFile,'utf8'));return grant.enabled===true&&grant.expiresAt>Date.now()&&Object.entries(config.routineScope).every(([key,value])=>grant.scope?.[key]===value);}catch{return false;}}}):null;
18
+ const recovery=routines?startRoutineRecovery(routines,{onError:()=>console.error('routine_wake_reconciliation_pending')}):null;
19
+ process.once('SIGTERM',()=>{recovery?.stop();server.close();});
20
+ const server=createServer(async(req,res)=>{
21
+ const expected=Buffer.from(`Bearer ${config.token}`), supplied=Buffer.from(req.headers.authorization??'');
22
+ if(req.socket.remoteAddress!==config.workerIp||supplied.length!==expected.length||!timingSafeEqual(supplied,expected)){res.writeHead(403).end();return;}
23
+ try{
24
+ let result;
25
+ if(req.method==='GET'&&req.url==='/status') result=await adapter.status();
26
+ else if(req.method==='POST'&&req.url==='/routines'&&routines){
27
+ let body='';for await(const chunk of req){body+=chunk;if(body.length>20000)throw Error('too_large');}
28
+ const {action,input}=JSON.parse(body);if(!['status','apply','claim','accepted','complete','failed','delegated-status','delegated-apply'].includes(action))throw Error('unsupported_operation');result=await routines.call(action,input);
29
+ }
30
+ else if(req.method==='POST'&&req.url==='/reconcile'){
31
+ if(routines)throw Error('shared_registry_owns_wake');
32
+ let body='';for await(const chunk of req){body+=chunk;if(body.length>20000)throw Error('too_large');}
33
+ const routines=JSON.parse(body);
34
+ if(!Array.isArray(routines)||routines.length>50||routines.some(r=>!r||typeof r.name!=='string'||!/^[a-z0-9][a-z0-9-]{0,47}$/.test(r.name)||typeof r.cron!=='string'))throw Error('invalid_routines');
35
+ await adapter.reconcile(routines);result={ok:true};
36
+ }else{res.writeHead(404).end();return;}
37
+ res.writeHead(200,{'content-type':'application/json'}).end(JSON.stringify(result));
38
+ }catch(error){const allowed=['revision_conflict','schedule_exists','schedule_not_found','wake_unavailable','unsupported_wake_cron','request_id_conflict'];res.writeHead(409,{'content-type':'application/json'}).end(JSON.stringify({ok:false,error:allowed.includes(error?.message)?error.message:'host_operation_failed'}));}
39
+ }).listen(config.port,config.bindIp);
@@ -6,14 +6,15 @@ const input = z.object({ operation: z.enum(['list', 'create', 'update', 'pause',
6
6
  export function routineTool(options) {
7
7
  return defineTool({ description: 'Manage your host-persisted routines shared with desktop. List first to get current revisions. Create needs a unique lowercase hyphenated name, five-field UTC cron and instruction. Update/pause/resume/remove require the revision returned by list. Wake is optional and off by default. Runs deliver into the owner’s desktop chat. requestId must be a stable UUID for each intended change; reuse it only for an identical retry. Do not claim execution from a saved routine.', inputSchema: input, async execute(args, ctx) {
8
8
  const auth = ctx.session.auth.current;
9
- if (auth?.authenticator !== 'tealbrick' || auth.principalType !== 'user' || auth.principalId !== options.scope.owner || auth.attributes.org !== options.scope.org || auth.attributes.agentGrantLevel !== 'manage')
9
+ const delegated = auth?.authenticator === 'app' && auth.principalType === 'runtime' && auth.principalId === 'eve:app';
10
+ if (!delegated && (auth?.authenticator !== 'tealbrick' || auth.principalType !== 'user' || auth.principalId !== options.scope.owner || auth.attributes.org !== options.scope.org || auth.attributes.agentGrantLevel !== 'manage'))
10
11
  throw Error('routine_manage_permission_required');
11
12
  if (args.operation === 'list')
12
- return options.bridge.call('status');
13
+ return options.bridge.call(delegated ? 'delegated-status' : 'status');
13
14
  if (!args.requestId)
14
15
  throw Error('request_id_required');
15
16
  const { operation, ...fields } = args;
16
- return options.bridge.call('apply', { ...fields, operation: operation === 'create' ? 'upsert' : operation === 'remove' ? 'delete' : 'update', ...(operation === 'pause' ? { enabled: false } : operation === 'resume' ? { enabled: true } : {}) });
17
+ return options.bridge.call(delegated ? 'delegated-apply' : 'apply', { ...fields, operation: operation === 'create' ? 'upsert' : operation === 'remove' ? 'delete' : 'update', ...(operation === 'pause' ? { enabled: false } : operation === 'resume' ? { enabled: true } : {}) });
17
18
  } });
18
19
  }
19
20
  /** Install once; all recurrence definitions are read from the host at execution time. */
@@ -0,0 +1,9 @@
1
+ import type { RoutineBridge } from './routines.js';
2
+ /** Host lifecycle worker: recovery never depends on a running guest. */
3
+ export declare function startRoutineRecovery(bridge: RoutineBridge, options?: {
4
+ intervalMs?: number;
5
+ onError?: (error: unknown) => void;
6
+ }): {
7
+ ready: Promise<void>;
8
+ stop(): void;
9
+ };
@@ -0,0 +1,23 @@
1
+ /** Host lifecycle worker: recovery never depends on a running guest. */
2
+ export function startRoutineRecovery(bridge, options = {}) {
3
+ const interval = options.intervalMs ?? 15000;
4
+ if (!Number.isInteger(interval) || interval < 10 || interval > 300000)
5
+ throw Error('invalid_recovery_interval');
6
+ let stopped = false;
7
+ let timer;
8
+ const tick = async () => {
9
+ try {
10
+ await bridge.call('reconcile');
11
+ }
12
+ catch (error) {
13
+ options.onError?.(error);
14
+ }
15
+ if (!stopped) {
16
+ timer = setTimeout(tick, interval);
17
+ timer.unref();
18
+ }
19
+ };
20
+ const ready = tick();
21
+ return { ready, stop() { stopped = true; if (timer)
22
+ clearTimeout(timer); } };
23
+ }
@@ -31,6 +31,13 @@ export interface RoutineSnapshot {
31
31
  wake: Record<string, unknown>;
32
32
  dynamic: true;
33
33
  }
34
+ export interface RoutineDelegation {
35
+ scope: RoutineScope;
36
+ expiresAt: number;
37
+ operations: string[];
38
+ namePrefix: string;
39
+ allowWake: boolean;
40
+ }
34
41
  export interface RoutineBridge {
35
42
  call(action: string, input?: Record<string, unknown>): Promise<any>;
36
43
  }
@@ -40,6 +47,9 @@ export declare function createRoutineStore(options: {
40
47
  path: string;
41
48
  scope: RoutineScope;
42
49
  now?: () => number;
50
+ misfireGraceMs?: number;
51
+ delegation?: () => Promise<RoutineDelegation | null>;
52
+ authorizeExecution?: () => Promise<boolean>;
43
53
  wake?: {
44
54
  status(): Promise<any>;
45
55
  validate(rows: {
@@ -26,6 +26,9 @@ export function createRoutineStore(options) {
26
26
  throw Error('scope_mismatch');
27
27
  const save = (s) => db.prepare('UPDATE state SET json=? WHERE id=1').run(JSON.stringify(s));
28
28
  const now = options.now ?? Date.now;
29
+ const grace = options.misfireGraceMs ?? 120000;
30
+ if (!Number.isInteger(grace) || grace < 0 || grace > 86400000)
31
+ throw Error('invalid_misfire_grace');
29
32
  const tx = (fn) => { db.exec('BEGIN IMMEDIATE'); try {
30
33
  const value = fn();
31
34
  db.exec('COMMIT');
@@ -39,18 +42,44 @@ export function createRoutineStore(options) {
39
42
  const writeRun = (r) => db.prepare('INSERT OR REPLACE INTO runs VALUES (?,?)').run(r.id, JSON.stringify(r));
40
43
  const reconcile = async () => { const s = read(); if (!s.dirty)
41
44
  return; if (!options.wake)
42
- throw Error('wake_unavailable'); await options.wake.reconcile(s.routines.filter(r => !r.deleted && r.enabled && r.wake)); tx(() => { const current = read(); current.dirty = false; save(current); }); };
45
+ throw Error('wake_unavailable'); await options.wake.reconcile(s.routines.filter(r => !r.deleted && r.enabled && r.wake)); tx(() => { const current = read(); current.dirty = JSON.stringify(current.routines) !== JSON.stringify(s.routines); save(current); }); };
43
46
  let queue = Promise.resolve();
44
47
  const call = async (action, input = {}) => {
48
+ if (action === 'reconcile') {
49
+ await reconcile();
50
+ return { ok: true, pending: read().dirty };
51
+ }
52
+ if (action === 'delegated-status' || action === 'delegated-apply') {
53
+ const grant = await options.delegation?.();
54
+ const operation = action === 'delegated-status' ? 'list' : String(input.operation);
55
+ if (!grant || !Object.entries(options.scope).every(([key, value]) => grant.scope?.[key] === value) || !Number.isFinite(grant.expiresAt) || grant.expiresAt <= now() || !grant.namePrefix || !grant.operations.includes(operation))
56
+ throw Error('routine_delegation_required');
57
+ if (action === 'delegated-apply') {
58
+ if (typeof input.name !== 'string' || !input.name.startsWith(grant.namePrefix))
59
+ throw Error('routine_delegation_scope');
60
+ const existing = read().routines.find(r => r.name === input.name);
61
+ if (!grant.allowWake && (input.wake === true || existing?.wake))
62
+ throw Error('routine_delegation_wake_denied');
63
+ }
64
+ if (action === 'delegated-status') {
65
+ const result = await call('status');
66
+ return { ...result, routines: result.routines.filter((r) => r.name.startsWith(grant.namePrefix)), runs: result.runs.filter((r) => r.name.startsWith(grant.namePrefix)) };
67
+ }
68
+ action = 'apply';
69
+ }
45
70
  if (action === 'status')
46
- return { scope: options.scope, dynamic: true, routines: read().routines.filter(r => !r.deleted), runs: runs(), wake: { ...(await options.wake?.status() ?? { supported: false }), pending: read().dirty, managed: read().routines.filter(r => !r.deleted).map(r => r.name), routines: read().routines.filter(r => !r.deleted && r.wake).map(r => r.name) } };
71
+ return { executionGranted: options.authorizeExecution ? await options.authorizeExecution() : null, scope: options.scope, dynamic: true, routines: read().routines.filter(r => !r.deleted), runs: runs(), wake: { ...(await options.wake?.status() ?? { supported: false }), pending: read().dirty, managed: read().routines.filter(r => !r.deleted).map(r => r.name), routines: read().routines.filter(r => !r.deleted && r.wake).map(r => r.name) } };
47
72
  if (action === 'apply') {
48
73
  const patch = change.parse(input), digest = createHash('sha256').update(JSON.stringify(patch)).digest('hex');
49
74
  const old = db.prepare('SELECT digest,json FROM receipts WHERE id=?').get(patch.requestId);
50
75
  if (old) {
51
76
  if (old.digest !== digest)
52
77
  throw Error('request_id_conflict');
53
- return JSON.parse(old.json);
78
+ try {
79
+ await reconcile();
80
+ }
81
+ catch { /* Report current persisted state on retries. */ }
82
+ return { ...JSON.parse(old.json), wakePending: read().dirty };
54
83
  }
55
84
  const before = read(), prior = before.routines.find(r => r.name === patch.name);
56
85
  if (patch.operation === 'upsert' && prior && !prior.deleted)
@@ -80,6 +109,8 @@ export function createRoutineStore(options) {
80
109
  return { ok: true, dynamic: true, saved: true, routine: row };
81
110
  }
82
111
  if (action === 'claim') {
112
+ if (options.authorizeExecution && !await options.authorizeExecution())
113
+ return { jobs: [], blocked: 'routine_execution_not_granted' };
83
114
  try {
84
115
  await reconcile();
85
116
  }
@@ -100,7 +131,7 @@ export function createRoutineStore(options) {
100
131
  const due = row.nextRunAt;
101
132
  row.nextRunAt = nextRoutineTime(row.cron, at);
102
133
  const id = createHash('sha256').update(JSON.stringify([s.scope, row.name, row.revision, due])).digest('hex');
103
- const run = { id, name: row.name, revision: row.revision, dueAt: due, status: at - due > 120000 ? 'missed' : 'claimed', updatedAt: at };
134
+ const run = { id, name: row.name, revision: row.revision, dueAt: due, status: at - due > grace ? 'missed' : 'claimed', updatedAt: at };
104
135
  if (!db.prepare('SELECT id FROM runs WHERE id=?').get(id)) {
105
136
  writeRun(run);
106
137
  if (run.status === 'claimed')
@@ -21,10 +21,12 @@ export declare function createAvmmWakeAdapter(options: {
21
21
  status(): Promise<{
22
22
  supported: boolean;
23
23
  reason: string;
24
+ wakeRequired?: undefined;
24
25
  provider?: undefined;
25
26
  timezone?: undefined;
26
27
  } | {
27
28
  supported: boolean;
29
+ wakeRequired: boolean;
28
30
  provider: string;
29
31
  timezone: string;
30
32
  reason: string;
@@ -58,13 +58,13 @@ export function createAvmmWakeAdapter(options) {
58
58
  if (!Array.isArray(rows))
59
59
  throw Error();
60
60
  const worker = await run(['worker', 'status', options.worker]);
61
- if (worker.mode !== 'durable' || !Number.isFinite(worker.idle_timeout_seconds) || worker.idle_timeout_seconds <= lead)
61
+ if (worker.mode !== 'durable' || !Number.isFinite(worker.idle_timeout_seconds) || (worker.idle_timeout_seconds !== 0 && worker.idle_timeout_seconds <= lead))
62
62
  throw Error();
63
63
  if (worker.auth?.codex === 'shared' && (!options.credentialsRefreshOnRequest || !worker.capabilities?.includes('model:codex-subscription')))
64
64
  throw Error();
65
65
  if (!Array.isArray(worker.capabilities) || !worker.capabilities.includes(`harness:${options.harness ?? 'eve'}`))
66
66
  throw Error();
67
- return { supported: true, provider: 'avmm', timezone: 'UTC', reason: 'Minute/hour intervals, daily, weekdays and weekly routines. Host wakes only; Eve executes.' };
67
+ return { supported: true, wakeRequired: worker.idle_timeout_seconds !== 0, provider: 'avmm', timezone: 'UTC', reason: 'Minute/hour intervals, daily, weekdays and weekly routines. Host wakes only; Eve executes.' };
68
68
  }
69
69
  catch {
70
70
  return { supported: false, reason: 'Host scheduler, worker capability or idle timeout could not be verified.' };
@@ -80,7 +80,7 @@ export function createAvmmWakeAdapter(options) {
80
80
  const existing = await run(['schedule', 'ls']);
81
81
  const worker = await run(['worker', 'status', options.worker]);
82
82
  const authCaps = worker.auth?.codex === 'shared' ? ['--cap', 'model:codex-subscription'] : [];
83
- for (const routine of routines) {
83
+ for (const routine of worker.idle_timeout_seconds === 0 ? [] : routines) {
84
84
  const key = createHash('sha256').update(routine.name).digest('hex').slice(0, 16);
85
85
  const plans = planWake(routine.cron, Date.now(), lead);
86
86
  for (const [i, p] of plans.entries()) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tealbrick/avm",
3
- "version": "0.2.6",
3
+ "version": "0.2.7",
4
4
  "description": "Typed host-owned AgentVM transport and opt-in Eve capabilities",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -9,7 +9,8 @@
9
9
  "README.md",
10
10
  "PROVENANCE.md",
11
11
  "LICENSE",
12
- "ROUTINES.md"
12
+ "ROUTINES.md",
13
+ "bin"
13
14
  ],
14
15
  "engines": {
15
16
  "node": ">=24"
@@ -46,6 +47,10 @@
46
47
  "./routines-eve": {
47
48
  "types": "./dist/client/routines-eve.d.ts",
48
49
  "import": "./dist/client/routines-eve.js"
50
+ },
51
+ "./routines-recovery": {
52
+ "types": "./dist/client/routines-recovery.d.ts",
53
+ "import": "./dist/client/routines-recovery.js"
49
54
  }
50
55
  },
51
56
  "eve": {
@@ -79,5 +84,8 @@
79
84
  "publishConfig": {
80
85
  "access": "public",
81
86
  "registry": "https://registry.npmjs.org"
87
+ },
88
+ "bin": {
89
+ "tealbrick-routine-host": "bin/routine-host.mjs"
82
90
  }
83
91
  }