@tealbrick/avm 0.1.3 → 0.2.5
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/README.md +19 -0
- package/ROUTINES.md +85 -0
- package/dist/client/routines-client.d.ts +6 -0
- package/dist/client/routines-client.js +8 -0
- package/dist/client/routines-eve.d.ts +32 -0
- package/dist/client/routines-eve.js +30 -0
- package/dist/client/routines.d.ts +54 -0
- package/dist/client/routines.js +136 -0
- package/dist/client/wake.d.ts +34 -0
- package/dist/client/wake.js +97 -0
- package/dist/extension/_manifest.json +3 -3
- package/package.json +24 -6
package/README.md
CHANGED
|
@@ -163,3 +163,22 @@ Deployment authority correction (2026-09-15): live AVMM/Eve/Teal Brick packages
|
|
|
163
163
|
belong exclusively on Nucbox, the Fedora N150. Neuu attempts above are historical
|
|
164
164
|
and were aimed at the wrong host. Nucbox is reachable and reports AVMM
|
|
165
165
|
0.13.0-alpha.2; its resident Aura still runs Eve 0.49.0. No live upgrade is implied.
|
|
166
|
+
|
|
167
|
+
### Optional routine wake adapter
|
|
168
|
+
|
|
169
|
+
`@tealbrick/avm/wake` exports `createAvmmWakeAdapter` for the Portal management
|
|
170
|
+
channel's optional host-wake interface. It requires a fixed trusted transport,
|
|
171
|
+
worker, stable ownerKey, explicit `allowed: true`, and verified Eve `timezone:
|
|
172
|
+
'UTC'`. Wake-only assignments preserve Eve as the execution owner. See the Portal
|
|
173
|
+
package's `MANAGEMENT.md` for supported timings, security boundary and acceptance
|
|
174
|
+
requirements. It is not a generic cron dispatcher or an exe.dev adapter.
|
|
175
|
+
|
|
176
|
+
### Shared host routine registry
|
|
177
|
+
|
|
178
|
+
`@tealbrick/avm/routines`, `/routines-client` and `/routines-eve` provide the
|
|
179
|
+
host-owned routine store, bounded RPC client and explicitly mounted Eve tools
|
|
180
|
+
and dispatcher. Desktop and authenticated agent turns edit the same revisioned
|
|
181
|
+
records; optional AVMM wake schedules are derived from those records. See
|
|
182
|
+
[ROUTINES.md](./ROUTINES.md) for mounting, migration, authorization and execution
|
|
183
|
+
semantics. This extends the operator-owned schedule boundary described above;
|
|
184
|
+
it does not grant general host access or provide an exe.dev wake adapter.
|
package/ROUTINES.md
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# Shared host routines (Eve 0.58.1)
|
|
2
|
+
|
|
3
|
+
The AVM package owns the host store, typed client and opt-in Eve routine tool.
|
|
4
|
+
The host store is authoritative. Neither the desktop nor Eve edits a second
|
|
5
|
+
schedule definition. AVMM wake assignments are derived projections of enabled
|
|
6
|
+
rows with `wake: true`; Eve runs one permanent minute dispatcher. Plain VPS and
|
|
7
|
+
exe.dev wake adapters are not supplied by this implementation.
|
|
8
|
+
|
|
9
|
+
Exports:
|
|
10
|
+
- `@tealbrick/avm/routines`: Node 24 host-only SQLite store and UTC cron validation.
|
|
11
|
+
- `@tealbrick/avm/routines-client`: fixed endpoint client; resolves its scoped
|
|
12
|
+
bearer at request time, refuses redirects and bounds request duration.
|
|
13
|
+
- `@tealbrick/avm/routines-eve`: `routineTool` and `routineDispatcher` factories.
|
|
14
|
+
|
|
15
|
+
Explicitly mount a `routineTool({bridge,scope})` as an authored tool and
|
|
16
|
+
`routineDispatcher({bridge,delivery})` as a root authored schedule. No package
|
|
17
|
+
import installs either. The delivery channel must implement proactive receive
|
|
18
|
+
and report completion using the occurrence ID. The Portal management channel
|
|
19
|
+
supports `routines:{...bridge,owner}` for authenticated desktop CRUD and durable
|
|
20
|
+
chat delivery. Keep build/restart configuration for explicit lifecycle actions.
|
|
21
|
+
|
|
22
|
+
## Scope and authority
|
|
23
|
+
|
|
24
|
+
A store is configured by the host operator for exactly one organization,
|
|
25
|
+
workspace, logical agent and owning user. Opening it with another scope fails.
|
|
26
|
+
The example host RPC binds an assigned worker IP plus a scoped bearer to that
|
|
27
|
+
store. Portal authenticates the human/agent; this host enrollment grants access
|
|
28
|
+
only to this store and its wake projection. No arbitrary host paths, commands,
|
|
29
|
+
VM selection or model-supplied identity is accepted. Keep the service token
|
|
30
|
+
inside the trusted guest runtime, never model inputs or browser storage.
|
|
31
|
+
|
|
32
|
+
The Eve tool requires the authenticated managing user in the current turn,
|
|
33
|
+
matching its configured owner and organization. Runtime/app and agent-to-agent
|
|
34
|
+
principals cannot create further routines. An agent can author and edit in
|
|
35
|
+
response to its authenticated user's conversation. Unattended self-modification
|
|
36
|
+
requires a separate deliberate permission design; it is not silently enabled.
|
|
37
|
+
Current deployment uses a fixed owner enrollment, not multi-owner delegation.
|
|
38
|
+
Persisted routines remain authorized host jobs until paused/removed or host
|
|
39
|
+
access is revoked; live Portal membership revocation is not revalidated at each
|
|
40
|
+
cron occurrence in this version.
|
|
41
|
+
|
|
42
|
+
## Records and operations
|
|
43
|
+
|
|
44
|
+
Routine: name, five-field UTC cron, instruction, enabled, wake, revision,
|
|
45
|
+
nextRunAt, updatedAt, tombstone. Scope belongs to the store, never request data.
|
|
46
|
+
`apply` supports upsert (create-only), update, delete (tombstone), and wake.
|
|
47
|
+
All changes require a stable requestId; updates require the observed revision.
|
|
48
|
+
Desktop and model retries cannot overwrite newer revisions. Names cannot be
|
|
49
|
+
changed; create a new routine if a new identity is wanted.
|
|
50
|
+
|
|
51
|
+
Changes commit transactionally before wake reconciliation. A failed wake update
|
|
52
|
+
is visibly pending and retried by the dispatcher. Existing wake registrations
|
|
53
|
+
may remain until reconciliation succeeds, but disabled/deleted rows cannot be
|
|
54
|
+
claimed. The legacy `/reconcile` RPC is disabled once shared storage is enabled.
|
|
55
|
+
|
|
56
|
+
`claim` atomically advances due rows and records stable occurrence IDs. Concurrent
|
|
57
|
+
claims return an occurrence once. A run can be claimed, dispatched, completed,
|
|
58
|
+
failed, missed or uncertain. Ambiguous dispatch is not replayed automatically:
|
|
59
|
+
this trades missed work for avoiding duplicate consequential actions. A tick more
|
|
60
|
+
than two minutes late is recorded missed; no catch-up burst. Unresolved claims
|
|
61
|
+
become uncertain after ten minutes. Already-running work is unaffected by edits.
|
|
62
|
+
The last 100 runs are returned; disk retention and retry controls remain operator
|
|
63
|
+
work. Completion means a final message was received, not verification of every
|
|
64
|
+
external side effect.
|
|
65
|
+
|
|
66
|
+
The store's in-process queue serializes async wake projections, and SQLite
|
|
67
|
+
transactions protect records. Run one host service per store (systemd); do not
|
|
68
|
+
run multiple independent wake reconcilers against the same file. Back up the
|
|
69
|
+
SQLite database using its backup facilities or with the service stopped,
|
|
70
|
+
including WAL files when applicable.
|
|
71
|
+
|
|
72
|
+
## Consumer layout
|
|
73
|
+
|
|
74
|
+
```
|
|
75
|
+
lib/routines.ts fixed client + operator scope
|
|
76
|
+
agent/tools/tealbrick_routines.ts routineTool factory
|
|
77
|
+
agent/schedules/tealbrick-routine-dispatcher.ts
|
|
78
|
+
agent/channels/management.ts Portal management + delivery adapter
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
For migration, preserve existing source and workflow state, seed the host store,
|
|
82
|
+
remove the corresponding old static schedule from discovery, then build/restart
|
|
83
|
+
once. Never leave both static and dynamic execution enabled for the same job.
|
|
84
|
+
Subsequent edits need no rebuild or restart. Desktop reads dynamic rows from
|
|
85
|
+
management discovery and hides the dispatcher implementation from its list.
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { RoutineBridge } from './routines.js';
|
|
2
|
+
/** Fixed operator-configured host endpoint. Secret is resolved at request time. */
|
|
3
|
+
export declare function createRoutineClient(options: {
|
|
4
|
+
url: string;
|
|
5
|
+
token: () => string | Promise<string>;
|
|
6
|
+
}): RoutineBridge;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Fixed operator-configured host endpoint. Secret is resolved at request time. */
|
|
2
|
+
export function createRoutineClient(options) {
|
|
3
|
+
const url = new URL(options.url);
|
|
4
|
+
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash)
|
|
5
|
+
throw Error('invalid_host_endpoint');
|
|
6
|
+
return { async call(action, input = {}) { const response = await fetch(url, { method: 'POST', redirect: 'error', signal: AbortSignal.timeout(30000), headers: { 'content-type': 'application/json', authorization: `Bearer ${await options.token()}` }, body: JSON.stringify({ action, input }) }); const body = await response.json(); if (!response.ok)
|
|
7
|
+
throw Error(typeof body.error === 'string' ? body.error : 'routine_host_unavailable'); return body; } };
|
|
8
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { RoutineBridge, RoutineScope } from './routines.js';
|
|
2
|
+
/** Explicitly mounted tool; no model-selected owner, host URL or shell command. */
|
|
3
|
+
export declare function routineTool(options: {
|
|
4
|
+
bridge: RoutineBridge;
|
|
5
|
+
scope: RoutineScope;
|
|
6
|
+
}): import("eve/tools").ToolDefinition<{
|
|
7
|
+
operation: "list" | "create" | "update" | "pause" | "resume" | "remove";
|
|
8
|
+
name?: string | undefined;
|
|
9
|
+
cron?: string | undefined;
|
|
10
|
+
instruction?: string | undefined;
|
|
11
|
+
wake?: boolean | undefined;
|
|
12
|
+
revision?: number | undefined;
|
|
13
|
+
requestId?: string | undefined;
|
|
14
|
+
}, any> & {
|
|
15
|
+
execute(input: {
|
|
16
|
+
operation: "list" | "create" | "update" | "pause" | "resume" | "remove";
|
|
17
|
+
name?: string | undefined;
|
|
18
|
+
cron?: string | undefined;
|
|
19
|
+
instruction?: string | undefined;
|
|
20
|
+
wake?: boolean | undefined;
|
|
21
|
+
revision?: number | undefined;
|
|
22
|
+
requestId?: string | undefined;
|
|
23
|
+
}, ctx: import("eve/tools").ToolContext): Promise<any>;
|
|
24
|
+
};
|
|
25
|
+
/** Install once; all recurrence definitions are read from the host at execution time. */
|
|
26
|
+
export declare function routineDispatcher(options: {
|
|
27
|
+
bridge: RoutineBridge;
|
|
28
|
+
delivery: any;
|
|
29
|
+
}): {
|
|
30
|
+
cron: string;
|
|
31
|
+
run({ to, waitUntil, appAuth }: import("eve/schedules").ScheduleHandlerArgs): void;
|
|
32
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { defineTool } from 'eve/tools';
|
|
2
|
+
import { defineSchedule } from 'eve/schedules';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
const input = z.object({ operation: z.enum(['list', 'create', 'update', 'pause', 'resume', 'remove']), name: z.string().optional(), cron: z.string().optional(), instruction: z.string().optional(), wake: z.boolean().optional(), revision: z.number().int().positive().optional(), requestId: z.string().min(16).optional() }).strict();
|
|
5
|
+
/** Explicitly mounted tool; no model-selected owner, host URL or shell command. */
|
|
6
|
+
export function routineTool(options) {
|
|
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
|
+
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')
|
|
10
|
+
throw Error('routine_manage_permission_required');
|
|
11
|
+
if (args.operation === 'list')
|
|
12
|
+
return options.bridge.call('status');
|
|
13
|
+
if (!args.requestId)
|
|
14
|
+
throw Error('request_id_required');
|
|
15
|
+
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
|
+
} });
|
|
18
|
+
}
|
|
19
|
+
/** Install once; all recurrence definitions are read from the host at execution time. */
|
|
20
|
+
export function routineDispatcher(options) {
|
|
21
|
+
return defineSchedule({ cron: '* * * * *', run({ to, waitUntil, appAuth }) { waitUntil((async () => { const { jobs } = await options.bridge.call('claim'); for (const job of jobs) {
|
|
22
|
+
try {
|
|
23
|
+
await to(options.delivery, { owner: job.owner, name: job.name, occurrence: job.occurrence }).send(job.instruction, { auth: appAuth });
|
|
24
|
+
await options.bridge.call('accepted', { id: job.occurrence });
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
await options.bridge.call('failed', { id: job.occurrence });
|
|
28
|
+
}
|
|
29
|
+
} })()); } });
|
|
30
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export interface RoutineScope {
|
|
2
|
+
org: string;
|
|
3
|
+
workspace: string;
|
|
4
|
+
agent: string;
|
|
5
|
+
owner: string;
|
|
6
|
+
}
|
|
7
|
+
export interface Routine {
|
|
8
|
+
name: string;
|
|
9
|
+
cron: string;
|
|
10
|
+
instruction: string;
|
|
11
|
+
enabled: boolean;
|
|
12
|
+
wake: boolean;
|
|
13
|
+
revision: number;
|
|
14
|
+
nextRunAt: number;
|
|
15
|
+
deleted?: boolean;
|
|
16
|
+
updatedAt: number;
|
|
17
|
+
}
|
|
18
|
+
export interface RoutineRun {
|
|
19
|
+
id: string;
|
|
20
|
+
name: string;
|
|
21
|
+
revision: number;
|
|
22
|
+
dueAt: number;
|
|
23
|
+
status: string;
|
|
24
|
+
updatedAt: number;
|
|
25
|
+
error?: string;
|
|
26
|
+
}
|
|
27
|
+
export interface RoutineSnapshot {
|
|
28
|
+
scope: RoutineScope;
|
|
29
|
+
routines: Routine[];
|
|
30
|
+
runs: RoutineRun[];
|
|
31
|
+
wake: Record<string, unknown>;
|
|
32
|
+
dynamic: true;
|
|
33
|
+
}
|
|
34
|
+
export interface RoutineBridge {
|
|
35
|
+
call(action: string, input?: Record<string, unknown>): Promise<any>;
|
|
36
|
+
}
|
|
37
|
+
export declare function nextRoutineTime(cron: string, after: number): number;
|
|
38
|
+
/** One trusted, fixed agent/workspace/owner scope per store. SQLite is host-side only. */
|
|
39
|
+
export declare function createRoutineStore(options: {
|
|
40
|
+
path: string;
|
|
41
|
+
scope: RoutineScope;
|
|
42
|
+
now?: () => number;
|
|
43
|
+
wake?: {
|
|
44
|
+
status(): Promise<any>;
|
|
45
|
+
validate(rows: {
|
|
46
|
+
name: string;
|
|
47
|
+
cron: string;
|
|
48
|
+
}[]): void;
|
|
49
|
+
reconcile(rows: {
|
|
50
|
+
name: string;
|
|
51
|
+
cron: string;
|
|
52
|
+
}[]): Promise<void>;
|
|
53
|
+
};
|
|
54
|
+
}): RoutineBridge;
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
2
|
+
import { mkdirSync, chmodSync } from 'node:fs';
|
|
3
|
+
import { dirname } from 'node:path';
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import { CronExpressionParser } from 'cron-parser';
|
|
6
|
+
import { z } from 'zod';
|
|
7
|
+
const name = z.string().regex(/^[a-z0-9][a-z0-9-]{0,47}$/);
|
|
8
|
+
const change = z.object({ operation: z.enum(['upsert', 'update', 'delete', 'wake']), name, requestId: z.string().min(16).max(80).regex(/^[a-zA-Z0-9-]+$/), revision: z.number().int().positive().optional(), cron: z.string().max(120).optional(), instruction: z.string().trim().min(1).max(12000).optional(), enabled: z.boolean().optional(), wake: z.boolean().optional() }).strict();
|
|
9
|
+
export function nextRoutineTime(cron, after) {
|
|
10
|
+
if (cron.trim().split(/\s+/).length !== 5)
|
|
11
|
+
throw Error('invalid_cron');
|
|
12
|
+
return CronExpressionParser.parse(cron, { currentDate: new Date(after), tz: 'UTC' }).next().getTime();
|
|
13
|
+
}
|
|
14
|
+
/** One trusted, fixed agent/workspace/owner scope per store. SQLite is host-side only. */
|
|
15
|
+
export function createRoutineStore(options) {
|
|
16
|
+
for (const value of Object.values(options.scope))
|
|
17
|
+
if (!value)
|
|
18
|
+
throw Error('invalid_scope');
|
|
19
|
+
mkdirSync(dirname(options.path), { recursive: true, mode: 0o700 });
|
|
20
|
+
const db = new DatabaseSync(options.path);
|
|
21
|
+
chmodSync(options.path, 0o600);
|
|
22
|
+
db.exec('PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000; CREATE TABLE IF NOT EXISTS state (id INTEGER PRIMARY KEY CHECK(id=1), json TEXT NOT NULL); CREATE TABLE IF NOT EXISTS receipts (id TEXT PRIMARY KEY,digest TEXT NOT NULL,json TEXT NOT NULL); CREATE TABLE IF NOT EXISTS runs (id TEXT PRIMARY KEY,json TEXT NOT NULL);');
|
|
23
|
+
db.prepare('INSERT OR IGNORE INTO state VALUES (1,?)').run(JSON.stringify({ scope: options.scope, routines: [], dirty: false }));
|
|
24
|
+
const read = () => JSON.parse(db.prepare('SELECT json FROM state WHERE id=1').get().json);
|
|
25
|
+
if (JSON.stringify(read().scope) !== JSON.stringify(options.scope))
|
|
26
|
+
throw Error('scope_mismatch');
|
|
27
|
+
const save = (s) => db.prepare('UPDATE state SET json=? WHERE id=1').run(JSON.stringify(s));
|
|
28
|
+
const now = options.now ?? Date.now;
|
|
29
|
+
const tx = (fn) => { db.exec('BEGIN IMMEDIATE'); try {
|
|
30
|
+
const value = fn();
|
|
31
|
+
db.exec('COMMIT');
|
|
32
|
+
return value;
|
|
33
|
+
}
|
|
34
|
+
catch (e) {
|
|
35
|
+
db.exec('ROLLBACK');
|
|
36
|
+
throw e;
|
|
37
|
+
} };
|
|
38
|
+
const runs = () => db.prepare('SELECT json FROM runs ORDER BY rowid DESC LIMIT 100').all().map((r) => JSON.parse(r.json));
|
|
39
|
+
const writeRun = (r) => db.prepare('INSERT OR REPLACE INTO runs VALUES (?,?)').run(r.id, JSON.stringify(r));
|
|
40
|
+
const reconcile = async () => { const s = read(); if (!s.dirty)
|
|
41
|
+
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); }); };
|
|
43
|
+
let queue = Promise.resolve();
|
|
44
|
+
const call = async (action, input = {}) => {
|
|
45
|
+
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) } };
|
|
47
|
+
if (action === 'apply') {
|
|
48
|
+
const patch = change.parse(input), digest = createHash('sha256').update(JSON.stringify(patch)).digest('hex');
|
|
49
|
+
const old = db.prepare('SELECT digest,json FROM receipts WHERE id=?').get(patch.requestId);
|
|
50
|
+
if (old) {
|
|
51
|
+
if (old.digest !== digest)
|
|
52
|
+
throw Error('request_id_conflict');
|
|
53
|
+
return JSON.parse(old.json);
|
|
54
|
+
}
|
|
55
|
+
const before = read(), prior = before.routines.find(r => r.name === patch.name);
|
|
56
|
+
if (patch.operation === 'upsert' && prior && !prior.deleted)
|
|
57
|
+
throw Error('schedule_exists');
|
|
58
|
+
if (patch.operation !== 'upsert' && (!prior || prior.deleted))
|
|
59
|
+
throw Error('schedule_not_found');
|
|
60
|
+
if (patch.operation !== 'upsert' && patch.revision !== prior.revision)
|
|
61
|
+
throw Error('revision_conflict');
|
|
62
|
+
const row = { name: patch.name, cron: patch.cron ?? prior?.cron ?? '', instruction: patch.instruction ?? prior?.instruction ?? '', enabled: patch.enabled ?? prior?.enabled ?? true, wake: patch.wake ?? prior?.wake ?? false, revision: (prior?.revision ?? 0) + 1, nextRunAt: 0, updatedAt: now(), deleted: patch.operation === 'delete' };
|
|
63
|
+
if (!row.instruction)
|
|
64
|
+
throw Error('instruction_required');
|
|
65
|
+
const next = nextRoutineTime(row.cron, now());
|
|
66
|
+
row.nextRunAt = prior && patch.cron === undefined && !(patch.enabled === true && !prior.enabled) ? prior.nextRunAt : next;
|
|
67
|
+
if (row.wake && row.enabled && !row.deleted) {
|
|
68
|
+
if (!options.wake || (await options.wake.status()).supported !== true)
|
|
69
|
+
throw Error('wake_unavailable');
|
|
70
|
+
options.wake.validate([row]);
|
|
71
|
+
}
|
|
72
|
+
tx(() => { const state = read(); const current = state.routines.find(r => r.name === row.name); if (current?.revision !== prior?.revision)
|
|
73
|
+
throw Error('revision_conflict'); state.routines = state.routines.filter(r => r.name !== row.name); state.routines.push(row); state.dirty ||= Boolean(row.wake || prior?.wake); save(state); db.prepare('INSERT INTO receipts VALUES (?,?,?)').run(patch.requestId, digest, JSON.stringify({ ok: true, dynamic: true, saved: true, routine: row })); });
|
|
74
|
+
try {
|
|
75
|
+
await reconcile();
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return { ok: true, dynamic: true, saved: true, routine: row, wakePending: true };
|
|
79
|
+
}
|
|
80
|
+
return { ok: true, dynamic: true, saved: true, routine: row };
|
|
81
|
+
}
|
|
82
|
+
if (action === 'claim') {
|
|
83
|
+
try {
|
|
84
|
+
await reconcile();
|
|
85
|
+
}
|
|
86
|
+
catch { /* Persisted pending intent remains observable. */ }
|
|
87
|
+
return tx(() => {
|
|
88
|
+
const s = read(), jobs = [];
|
|
89
|
+
const at = now();
|
|
90
|
+
// Ambiguous dispatch is not automatically repeated: no duplicate model side effects.
|
|
91
|
+
for (const run of runs())
|
|
92
|
+
if (['claimed', 'dispatched'].includes(run.status) && at - run.updatedAt > 600000) {
|
|
93
|
+
run.status = 'uncertain';
|
|
94
|
+
run.updatedAt = at;
|
|
95
|
+
writeRun(run);
|
|
96
|
+
}
|
|
97
|
+
for (const row of s.routines) {
|
|
98
|
+
if (row.deleted || !row.enabled || row.nextRunAt > at)
|
|
99
|
+
continue;
|
|
100
|
+
const due = row.nextRunAt;
|
|
101
|
+
row.nextRunAt = nextRoutineTime(row.cron, at);
|
|
102
|
+
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 };
|
|
104
|
+
if (!db.prepare('SELECT id FROM runs WHERE id=?').get(id)) {
|
|
105
|
+
writeRun(run);
|
|
106
|
+
if (run.status === 'claimed')
|
|
107
|
+
jobs.push({ ...row, occurrence: id, owner: s.scope.owner });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
save(s);
|
|
111
|
+
return { jobs };
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
if (['accepted', 'complete', 'failed'].includes(action))
|
|
115
|
+
return tx(() => {
|
|
116
|
+
if (typeof input.id !== 'string')
|
|
117
|
+
throw Error('invalid_occurrence');
|
|
118
|
+
const raw = db.prepare('SELECT json FROM runs WHERE id=?').get(input.id);
|
|
119
|
+
if (!raw)
|
|
120
|
+
throw Error('unknown_occurrence');
|
|
121
|
+
const run = JSON.parse(raw.json);
|
|
122
|
+
if (run.status === 'completed')
|
|
123
|
+
return { ok: true };
|
|
124
|
+
if (action === 'accepted' && run.status !== 'claimed')
|
|
125
|
+
return { ok: true };
|
|
126
|
+
run.status = action === 'complete' ? 'completed' : action === 'failed' ? 'failed' : 'dispatched';
|
|
127
|
+
run.updatedAt = now();
|
|
128
|
+
if (action === 'failed')
|
|
129
|
+
run.error = 'dispatch_failed';
|
|
130
|
+
writeRun(run);
|
|
131
|
+
return { ok: true };
|
|
132
|
+
});
|
|
133
|
+
throw Error('unsupported_operation');
|
|
134
|
+
};
|
|
135
|
+
return { call(action, input) { const pending = queue.then(() => call(action, input)); queue = pending.catch(() => { }); return pending; } };
|
|
136
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { Transport } from './index.js';
|
|
2
|
+
export interface WakeRoutine {
|
|
3
|
+
name: string;
|
|
4
|
+
cron: string;
|
|
5
|
+
}
|
|
6
|
+
/** Fixed-interval AVMM assignments, UTC only. Never invoke the routine payload. */
|
|
7
|
+
export declare function planWake(cron: string, now?: number, leadSeconds?: number): {
|
|
8
|
+
next: string;
|
|
9
|
+
every: number;
|
|
10
|
+
}[];
|
|
11
|
+
export declare function createAvmmWakeAdapter(options: {
|
|
12
|
+
transport: Transport;
|
|
13
|
+
worker: string;
|
|
14
|
+
ownerKey: string;
|
|
15
|
+
allowed: boolean; /** Must match Eve's production cron timezone. */
|
|
16
|
+
timezone: string;
|
|
17
|
+
credentialsRefreshOnRequest?: boolean;
|
|
18
|
+
harness?: string;
|
|
19
|
+
leadSeconds?: number;
|
|
20
|
+
}): {
|
|
21
|
+
status(): Promise<{
|
|
22
|
+
supported: boolean;
|
|
23
|
+
reason: string;
|
|
24
|
+
provider?: undefined;
|
|
25
|
+
timezone?: undefined;
|
|
26
|
+
} | {
|
|
27
|
+
supported: boolean;
|
|
28
|
+
provider: string;
|
|
29
|
+
timezone: string;
|
|
30
|
+
reason: string;
|
|
31
|
+
}>;
|
|
32
|
+
validate(routines: WakeRoutine[]): void;
|
|
33
|
+
reconcile(routines: WakeRoutine[]): Promise<void>;
|
|
34
|
+
};
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
/** Fixed-interval AVMM assignments, UTC only. Never invoke the routine payload. */
|
|
3
|
+
export function planWake(cron, now = Date.now(), leadSeconds = 60) {
|
|
4
|
+
const [m, h, d, mo, w, ...rest] = cron.trim().split(/\s+/);
|
|
5
|
+
if (rest.length || !w || d !== '*' || mo !== '*')
|
|
6
|
+
throw Error('unsupported_wake_cron');
|
|
7
|
+
const number = (s, max) => /^\d+$/.test(s) && Number(s) <= max ? Number(s) : null;
|
|
8
|
+
const minute = number(m, 59), hour = number(h, 23);
|
|
9
|
+
const after = now + leadSeconds * 1000;
|
|
10
|
+
const next = (anchor, every) => ({ next: new Date(anchor + Math.max(0, Math.floor((after - anchor) / (every * 1000)) + 1) * every * 1000 - leadSeconds * 1000).toISOString(), every });
|
|
11
|
+
if (w === '*' && h === '*' && (m === '*' || /^\*\/\d+$/.test(m))) {
|
|
12
|
+
const n = m === '*' ? 1 : Number(m.slice(2));
|
|
13
|
+
if (n < 1 || n > 60 || 60 % n)
|
|
14
|
+
throw Error('unsupported_wake_cron');
|
|
15
|
+
return [next(0, n * 60)];
|
|
16
|
+
}
|
|
17
|
+
if (w === '*' && minute !== null && (h === '*' || /^\*\/\d+$/.test(h))) {
|
|
18
|
+
const n = h === '*' ? 1 : Number(h.slice(2));
|
|
19
|
+
if (n < 1 || n > 24 || 24 % n)
|
|
20
|
+
throw Error('unsupported_wake_cron');
|
|
21
|
+
return [next(minute * 60000, n * 3600)];
|
|
22
|
+
}
|
|
23
|
+
if (minute === null || hour === null)
|
|
24
|
+
throw Error('unsupported_wake_cron');
|
|
25
|
+
const time = (hour * 60 + minute) * 60000;
|
|
26
|
+
if (w === '*')
|
|
27
|
+
return [next(time, 86400)];
|
|
28
|
+
const days = w === '1-5' ? [1, 2, 3, 4, 5] : number(w, 6) !== null ? [Number(w)] : null;
|
|
29
|
+
if (!days)
|
|
30
|
+
throw Error('unsupported_wake_cron');
|
|
31
|
+
// 1970-01-04 is a Sunday.
|
|
32
|
+
return days.map(day => next((3 + day) * 86400000 + time, 604800));
|
|
33
|
+
}
|
|
34
|
+
export function createAvmmWakeAdapter(options) {
|
|
35
|
+
if (!/^[a-zA-Z0-9_.-]+$/.test(options.worker) || !options.ownerKey)
|
|
36
|
+
throw Error('invalid_wake_scope');
|
|
37
|
+
const prefix = 'tealwake-' + createHash('sha256').update(options.ownerKey + '\0' + options.worker).digest('hex').slice(0, 20) + '-';
|
|
38
|
+
const lead = options.leadSeconds ?? 60;
|
|
39
|
+
if (!Number.isInteger(lead) || lead < 30 || lead > 300)
|
|
40
|
+
throw Error('invalid_wake_lead');
|
|
41
|
+
const run = async (argv) => {
|
|
42
|
+
const r = await options.transport.run(['--json', ...argv], { signal: AbortSignal.timeout(30000) });
|
|
43
|
+
if (r.exitCode !== 0)
|
|
44
|
+
throw Error('host_wake_denied_or_unavailable');
|
|
45
|
+
const body = JSON.parse(r.stdout);
|
|
46
|
+
if (body.api_version !== 'agentvm/v1' || body.error)
|
|
47
|
+
throw Error('host_wake_protocol');
|
|
48
|
+
return body.result;
|
|
49
|
+
};
|
|
50
|
+
return {
|
|
51
|
+
async status() {
|
|
52
|
+
if (options.timezone !== 'UTC')
|
|
53
|
+
return { supported: false, reason: 'Host wake requires Eve schedules configured in UTC.' };
|
|
54
|
+
if (!options.allowed)
|
|
55
|
+
return { supported: false, reason: 'Host wake permission has not been granted.' };
|
|
56
|
+
try {
|
|
57
|
+
const rows = await run(['schedule', 'ls']);
|
|
58
|
+
if (!Array.isArray(rows))
|
|
59
|
+
throw Error();
|
|
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)
|
|
62
|
+
throw Error();
|
|
63
|
+
if (worker.auth?.codex === 'shared' && (!options.credentialsRefreshOnRequest || !worker.capabilities?.includes('model:codex-subscription')))
|
|
64
|
+
throw Error();
|
|
65
|
+
if (!Array.isArray(worker.capabilities) || !worker.capabilities.includes(`harness:${options.harness ?? 'eve'}`))
|
|
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.' };
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return { supported: false, reason: 'Host scheduler, worker capability or idle timeout could not be verified.' };
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
validate(routines) { for (const r of routines)
|
|
74
|
+
planWake(r.cron, Date.now(), lead); },
|
|
75
|
+
async reconcile(routines) {
|
|
76
|
+
if (!(await this.status()).supported)
|
|
77
|
+
throw Error('host_wake_unavailable');
|
|
78
|
+
this.validate(routines);
|
|
79
|
+
const desired = new Set();
|
|
80
|
+
const existing = await run(['schedule', 'ls']);
|
|
81
|
+
const worker = await run(['worker', 'status', options.worker]);
|
|
82
|
+
const authCaps = worker.auth?.codex === 'shared' ? ['--cap', 'model:codex-subscription'] : [];
|
|
83
|
+
for (const routine of routines) {
|
|
84
|
+
const key = createHash('sha256').update(routine.name).digest('hex').slice(0, 16);
|
|
85
|
+
const plans = planWake(routine.cron, Date.now(), lead);
|
|
86
|
+
for (const [i, p] of plans.entries()) {
|
|
87
|
+
const id = prefix + key + '-' + i;
|
|
88
|
+
desired.add(id);
|
|
89
|
+
await run(['schedule', 'put', id, '--agent', options.worker, '--harness', options.harness ?? 'eve', '--next-wake-at', p.next, '--timezone', 'UTC', '--misfire-policy', 'skip', '--target', 'worker', '--worker', options.worker, '--every-seconds', String(p.every), '--keep-running', ...authCaps, '--', '/usr/bin/true']);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
for (const row of existing)
|
|
93
|
+
if (typeof row.schedule_id === 'string' && row.schedule_id.startsWith(prefix) && !desired.has(row.schedule_id))
|
|
94
|
+
await run(['schedule', 'remove', row.schedule_id]);
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tealbrick/avm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.5",
|
|
4
4
|
"description": "Typed host-owned AgentVM transport and opt-in Eve capabilities",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
"dist",
|
|
9
9
|
"README.md",
|
|
10
10
|
"PROVENANCE.md",
|
|
11
|
-
"LICENSE"
|
|
11
|
+
"LICENSE",
|
|
12
|
+
"ROUTINES.md"
|
|
12
13
|
],
|
|
13
14
|
"engines": {
|
|
14
15
|
"node": ">=24"
|
|
@@ -29,6 +30,22 @@
|
|
|
29
30
|
"./eve": {
|
|
30
31
|
"types": "./dist/index.d.ts",
|
|
31
32
|
"default": "./dist/index.mjs"
|
|
33
|
+
},
|
|
34
|
+
"./wake": {
|
|
35
|
+
"types": "./dist/client/wake.d.ts",
|
|
36
|
+
"import": "./dist/client/wake.js"
|
|
37
|
+
},
|
|
38
|
+
"./routines": {
|
|
39
|
+
"types": "./dist/client/routines.d.ts",
|
|
40
|
+
"import": "./dist/client/routines.js"
|
|
41
|
+
},
|
|
42
|
+
"./routines-client": {
|
|
43
|
+
"types": "./dist/client/routines-client.d.ts",
|
|
44
|
+
"import": "./dist/client/routines-client.js"
|
|
45
|
+
},
|
|
46
|
+
"./routines-eve": {
|
|
47
|
+
"types": "./dist/client/routines-eve.d.ts",
|
|
48
|
+
"import": "./dist/client/routines-eve.js"
|
|
32
49
|
}
|
|
33
50
|
},
|
|
34
51
|
"eve": {
|
|
@@ -43,15 +60,16 @@
|
|
|
43
60
|
"typecheck": "tsc --noEmit"
|
|
44
61
|
},
|
|
45
62
|
"dependencies": {
|
|
63
|
+
"cron-parser": "^5.4.0",
|
|
46
64
|
"zod": "^4.0.0"
|
|
47
65
|
},
|
|
48
66
|
"devDependencies": {
|
|
49
|
-
"
|
|
50
|
-
"
|
|
51
|
-
"
|
|
67
|
+
"@types/node": "^24.0.0",
|
|
68
|
+
"eve": "0.64.1",
|
|
69
|
+
"typescript": "^5.9.3"
|
|
52
70
|
},
|
|
53
71
|
"peerDependencies": {
|
|
54
|
-
"eve": ">=0.
|
|
72
|
+
"eve": ">=0.64.1 <0.65.0"
|
|
55
73
|
},
|
|
56
74
|
"peerDependenciesMeta": {
|
|
57
75
|
"eve": {
|