@amalgm/automations 0.3.2 → 0.4.1
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/AXIOMS.md +16 -1
- package/PURPOSE.md +30 -0
- package/README.md +53 -4
- package/dist/host/auth.js +4 -1
- package/dist/host/main.js +11 -1
- package/dist/host/notifications.d.ts +4 -0
- package/dist/host/notifications.js +19 -0
- package/dist/host/server.d.ts +1 -0
- package/dist/host/server.js +51 -10
- package/dist/host/skill.d.ts +2 -0
- package/dist/host/skill.js +33 -0
- package/dist/skills/amalgm-automations.5e4e14f0ace632c8383cd014e1f0f34b24ec0d5c5600132a840abc4c313b31d4.tgz +0 -0
- package/dist/skills/index.json +1 -0
- package/dist/src/cli/run.d.ts +1 -1
- package/dist/src/cli/run.js +17 -0
- package/dist/src/crud/triggers.js +0 -1
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.js +1 -0
- package/dist/src/machine-client.d.ts +3 -1
- package/dist/src/machine-client.js +6 -0
- package/dist/src/machine-event-stream.d.ts +8 -0
- package/dist/src/machine-event-stream.js +58 -0
- package/dist/src/machine-http.d.ts +2 -0
- package/dist/src/machine-http.js +8 -2
- package/dist/src/machine-notifications.d.ts +15 -0
- package/dist/src/machine-notifications.js +124 -0
- package/dist/src/machine.d.ts +1 -0
- package/dist/src/mcp.d.ts +2 -1
- package/dist/src/mcp.js +4 -2
- package/dist/src/runner.d.ts +3 -1
- package/dist/src/runner.js +69 -46
- package/dist/src/schema.js +9 -3
- package/dist/src/supabase-machine.d.ts +1 -0
- package/dist/src/supabase-machine.js +3 -0
- package/dist/src/tool-surface.js +21 -2
- package/package.json +3 -2
- package/skills/amalgm-automations/SKILL.md +49 -11
- package/skills/amalgm-automations/agents/openai.yaml +2 -2
- package/skills/amalgm-automations/references/command-contract.md +7 -0
- package/skills/amalgm-automations/references/setup-and-support.md +236 -0
- package/supabase/migrations/20260904020000_machine_run_notifications.sql +52 -0
- package/supabase/migrations/20260904030000_machine_notification_permissions.sql +4 -0
package/AXIOMS.md
CHANGED
|
@@ -91,7 +91,8 @@
|
|
|
91
91
|
33. MCP and CLI project one task-level command catalog: create, list, get,
|
|
92
92
|
update, delete, and run-now. Command names, input schemas, and invocation
|
|
93
93
|
behavior are defined once and neither adapter invents resource-level
|
|
94
|
-
lifecycle operations.
|
|
94
|
+
lifecycle operations. Unknown configuration fields are rejected, never
|
|
95
|
+
discarded by an adapter; declared JSON payloads remain open data.
|
|
95
96
|
34. A CLI running for a signed-in user reaches Automations through Shell's
|
|
96
97
|
authenticated loopback MCP route. It may possess the local runtime admission
|
|
97
98
|
token, but never a durable machine credential, device key, cached access
|
|
@@ -129,3 +130,17 @@
|
|
|
129
130
|
action or process results become an explicit byte count plus bounded JSON
|
|
130
131
|
preview before transport, so output can never strand a run outside the
|
|
131
132
|
ledger it is meant to update.
|
|
133
|
+
44. Agent onboarding delegates identity and runtime lifecycle to Shell and
|
|
134
|
+
proves Automations access with a successful read before declaring the
|
|
135
|
+
connection ready.
|
|
136
|
+
45. A machine claims work only after notification coverage is ready, on a wakeup,
|
|
137
|
+
or while draining previously discovered work; idle machines never poll.
|
|
138
|
+
46. Every run is committed before its wakeup, and every Fly host observes the
|
|
139
|
+
same private database notifications regardless of where admission occurred.
|
|
140
|
+
47. Retry and expired-lease wakeups follow the earliest durable eligibility time
|
|
141
|
+
for that user and machine; a notification is never proof of execution.
|
|
142
|
+
48. Losing upstream notification coverage closes downstream streams, and every
|
|
143
|
+
reconnect checks retained work before becoming idle again.
|
|
144
|
+
49. A complete definition validates all supplied configuration before its
|
|
145
|
+
first write; a later service failure preserves an identifiable disabled
|
|
146
|
+
draft and the original failure code.
|
package/PURPOSE.md
CHANGED
|
@@ -27,6 +27,21 @@ The product has two composable halves over that one state:
|
|
|
27
27
|
needs to know whether a machine is online: an unclaimed run is the complete
|
|
28
28
|
offline queue.
|
|
29
29
|
|
|
30
|
+
The local worker is idle when there is no work. It opens one authenticated
|
|
31
|
+
HTTPS notification stream, claims retained runs when that stream becomes ready,
|
|
32
|
+
and drains work when notified. Committed Supabase changes wake every Fly host;
|
|
33
|
+
each host forwards only to the owning machine and schedules the next durable
|
|
34
|
+
retry or lease deadline. Notifications carry no workflow data and never replace
|
|
35
|
+
the run ledger. Reconnecting re-establishes notification coverage before checking
|
|
36
|
+
the ledger. Connection health and active execution leases have bounded timers;
|
|
37
|
+
idle machines have no periodic work-claim timer.
|
|
38
|
+
|
|
39
|
+
Complete create and update requests validate their supplied schedules, workflow
|
|
40
|
+
plans, and field names before writing configuration. Unknown fields cannot
|
|
41
|
+
silently become a different request. If a service fails after staging begins,
|
|
42
|
+
the disabled draft remains inspectable and the error keeps that service's code,
|
|
43
|
+
so the agent can repair the same definition rather than repeat its creation.
|
|
44
|
+
|
|
30
45
|
The agent CLI is the command-line projection of the same task-level command
|
|
31
46
|
surface as MCP: create, list, get, update, delete, and run-now. Each command
|
|
32
47
|
accepts the same JSON object as its corresponding MCP tool and returns the same
|
|
@@ -38,6 +53,21 @@ CLI sees only the local runtime admission token. The standalone adapter may be
|
|
|
38
53
|
composed over an already-bound SDK for tests and other hosts, but it never owns
|
|
39
54
|
automation lifecycle or authorization rules.
|
|
40
55
|
|
|
56
|
+
The portable Automations skill guides an agent from installation and Google
|
|
57
|
+
sign-in to a verified Automations connection, then back to the user's requested
|
|
58
|
+
work. Setup uses Shell's public commands and the existing browser approval;
|
|
59
|
+
the skill never becomes an authentication implementation. It reuses a working
|
|
60
|
+
connection, distinguishes account approval from runtime readiness, and helps
|
|
61
|
+
the user reach aayush@amalgm.ai with bounded, redacted diagnostic evidence when
|
|
62
|
+
the failing boundary cannot be repaired. The packaged skill is the source for
|
|
63
|
+
installed copies and public setup/support guidance.
|
|
64
|
+
|
|
65
|
+
The standalone service publishes a public skill discovery index and an
|
|
66
|
+
integrity-checked archive built from that same packaged skill. Installation
|
|
67
|
+
needs no private repository access, and the archive includes its referenced
|
|
68
|
+
instructions. Public distribution owns no copy of authentication or workflow
|
|
69
|
+
behavior.
|
|
70
|
+
|
|
41
71
|
The CLI is global configuration control, not directory-local state. The agent
|
|
42
72
|
or person creating an automation may invoke it from any directory; the selected
|
|
43
73
|
machine and the persisted workflow decide where effects occur later. A process
|
package/README.md
CHANGED
|
@@ -47,7 +47,10 @@ the API request.
|
|
|
47
47
|
- `@amalgm/automations/host`: standalone Fly service composition.
|
|
48
48
|
- `amalgm-automations`: CLI adapter.
|
|
49
49
|
- `skills/amalgm-automations`: portable agent skill for operating either the
|
|
50
|
-
MCP or global CLI surface
|
|
50
|
+
MCP or global CLI surface, including installation, Google sign-in, runtime
|
|
51
|
+
readiness, troubleshooting, and support. The packaged skill is the source
|
|
52
|
+
for installed copies; its [setup and support reference](skills/amalgm-automations/references/setup-and-support.md)
|
|
53
|
+
supplies the public onboarding guidance.
|
|
51
54
|
|
|
52
55
|
## Agent CLI
|
|
53
56
|
|
|
@@ -95,15 +98,32 @@ one JSON line on stderr shaped as
|
|
|
95
98
|
`status`, and exits nonzero. Use `--stdin` for inputs containing credentials;
|
|
96
99
|
`--input` and `--file` are also supported.
|
|
97
100
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
+
Shell supplies the selected running user's loopback connection directly to
|
|
102
|
+
the SDK adapter. The CLI sends its runtime token only to the loopback
|
|
103
|
+
`/mcp/automations` route. Shell retains the
|
|
101
104
|
durable machine credential and device key and creates the short-lived access
|
|
102
105
|
token and fresh DPoP proof for the hosted request. For transition
|
|
103
106
|
compatibility, the standalone entry point still accepts the prior paired
|
|
104
107
|
`AMALGM_AUTOMATIONS_API_URL` and `AMALGM_AUTOMATIONS_AUTHORIZATION`
|
|
105
108
|
environment variables.
|
|
106
109
|
|
|
110
|
+
For public local MCP clients, Shell 0.1.176+ provides
|
|
111
|
+
`amalgm automations mcp --user person@example.com`. It exposes the same six
|
|
112
|
+
tools over stdio and discovers the current runtime connection per call.
|
|
113
|
+
`amalgm status --all` lists local account registrations. No pasted token or
|
|
114
|
+
private repository access is part of either flow; the standalone
|
|
115
|
+
`amalgm-automations-mcp` entry point remains for custom authenticated hosts.
|
|
116
|
+
|
|
117
|
+
Install the complete portable skill with Node.js 22.20+:
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
npx skills add https://automations.amalgm.ai --skill amalgm-automations
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
The host serves standard discovery at `/.well-known/agent-skills/index.json`
|
|
124
|
+
and a checksum-addressed archive built from `skills/amalgm-automations`.
|
|
125
|
+
The archive includes its references and agent metadata and requires no login.
|
|
126
|
+
|
|
107
127
|
The hosted service accepts verified Supabase user sessions for browser control
|
|
108
128
|
and Core-issued `amalgm-automations` DPoP grants for Shell. It does not run in
|
|
109
129
|
or depend on Amalgm Gateway.
|
|
@@ -200,6 +220,7 @@ The machine execution API is:
|
|
|
200
220
|
```text
|
|
201
221
|
POST /v1/machine/runs/claim
|
|
202
222
|
PATCH /v1/machine/runs/:runId
|
|
223
|
+
GET /v1/machine/runs/notifications
|
|
203
224
|
```
|
|
204
225
|
|
|
205
226
|
Public webhook admission uses the URL returned on each webhook trigger:
|
|
@@ -218,13 +239,41 @@ The target is never accepted in a machine request. It comes from the verified
|
|
|
218
239
|
access token. Run leases expire and are safely reclaimable; terminal updates
|
|
219
240
|
must present the active lease token.
|
|
220
241
|
|
|
242
|
+
The machine worker opens the authenticated HTTPS notification stream before
|
|
243
|
+
its first claim. `ready` and `wake` events cause it to drain retained work;
|
|
244
|
+
an idle worker has no polling interval. The stream sends a comment every
|
|
245
|
+
30 seconds to detect broken connections; comments never cause a claim or a
|
|
246
|
+
database read. Streams reconnect with fresh authorization at token expiry
|
|
247
|
+
(at most five minutes), and reconnect checks recover missed notifications.
|
|
248
|
+
|
|
249
|
+
Each Fly host subscribes to the same service-only Supabase Broadcast topic.
|
|
250
|
+
Run inserts and eligibility changes publish small owner/target wakeups from
|
|
251
|
+
the database transaction. Fly re-reads the earliest retry/lease deadline only
|
|
252
|
+
when a target connects, changes, or reaches that deadline. Losing the database
|
|
253
|
+
subscription closes the machine streams so they reconnect and catch up.
|
|
254
|
+
Cron scheduling remains hosted; no schedule timer runs on the user's machine.
|
|
255
|
+
|
|
256
|
+
Rollout order: apply the product migrations, deploy the Fly host, publish the
|
|
257
|
+
SDK, then release Shell with that exact SDK version. Shell wires
|
|
258
|
+
`notifications: runs.notifications` into `createAutomationMachineRunner`.
|
|
259
|
+
The polling option and `AMALGM_AUTOMATIONS_POLL_INTERVAL_MS` are removed.
|
|
260
|
+
Existing installed Shell releases keep their old behavior until updated.
|
|
261
|
+
|
|
221
262
|
## Verification
|
|
222
263
|
|
|
223
264
|
```bash
|
|
224
265
|
npm run verify
|
|
225
266
|
TEST_DATABASE_URL=postgres://... npm run test:supabase
|
|
267
|
+
npm run test:notifications
|
|
226
268
|
npm pack --dry-run
|
|
227
269
|
```
|
|
228
270
|
|
|
271
|
+
`test:supabase` requires an empty disposable Postgres database; its fixture
|
|
272
|
+
records the Supabase Broadcast boundary inside the same transaction.
|
|
273
|
+
`test:notifications` requires Node.js 22+, Docker, and Supabase CLI 2.26.9
|
|
274
|
+
(also pinned in CI). It starts and removes its own local Supabase project,
|
|
275
|
+
using real database Broadcast and two HTTP hosts to verify delivery, idle
|
|
276
|
+
behavior, offline catch-up, retry deadlines, and abandoned claims.
|
|
277
|
+
|
|
229
278
|
See [PURPOSE.md](./PURPOSE.md) and [AXIOMS.md](./AXIOMS.md) for the governing
|
|
230
279
|
ownership and behavior laws.
|
package/dist/host/auth.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { authorizeMachineResource, } from '@amalgm/core/authorization';
|
|
3
|
-
import { calculateJwkThumbprint, createRemoteJWKSet, decodeProtectedHeader, importJWK, jwtVerify, } from 'jose';
|
|
3
|
+
import { calculateJwkThumbprint, createRemoteJWKSet, decodeProtectedHeader, decodeJwt, importJWK, jwtVerify, } from 'jose';
|
|
4
4
|
import { ForbiddenError } from '../src/errors.js';
|
|
5
5
|
export function createAutomationsAuthenticators(options) {
|
|
6
6
|
const jwks = createRemoteJWKSet(new URL(`${options.issuer}/.well-known/jwks.json`));
|
|
@@ -28,10 +28,13 @@ export function createAutomationsAuthenticators(options) {
|
|
|
28
28
|
},
|
|
29
29
|
machine: async (request) => {
|
|
30
30
|
const principal = await authorize(request, options.issuer, 'runs:execute', ports);
|
|
31
|
+
// The exact token above has already passed Core's signature/lifetime checks.
|
|
32
|
+
const claims = decodeJwt(request.headers.get('authorization').trim().split(/\s+/)[1]);
|
|
31
33
|
return {
|
|
32
34
|
userId: principal.userId,
|
|
33
35
|
computerId: principal.computerId,
|
|
34
36
|
scopes: automationScopes(principal.scopes),
|
|
37
|
+
authorizationExpiresAt: claims.exp * 1000,
|
|
35
38
|
};
|
|
36
39
|
},
|
|
37
40
|
});
|
package/dist/host/main.js
CHANGED
|
@@ -12,6 +12,9 @@ import { WebhookEndpoints } from '../src/webhook.js';
|
|
|
12
12
|
import { createAutomationsAuthenticators } from './auth.js';
|
|
13
13
|
import { automationsHostConfig } from './config.js';
|
|
14
14
|
import { createAutomationsHost } from './server.js';
|
|
15
|
+
import { createMachineRunNotifications } from '../src/machine-notifications.js';
|
|
16
|
+
import { subscribeToRunChanges } from './notifications.js';
|
|
17
|
+
import { createPublicSkillApi } from './skill.js';
|
|
15
18
|
const config = automationsHostConfig();
|
|
16
19
|
const supabase = createClient(config.supabaseUrl, config.supabaseServiceRoleKey, {
|
|
17
20
|
auth: { persistSession: false, autoRefreshToken: false },
|
|
@@ -27,9 +30,15 @@ const controlApi = createAutomationApi({
|
|
|
27
30
|
authenticate: authentication.control,
|
|
28
31
|
});
|
|
29
32
|
const machineRepository = new SupabaseMachineRunRepository(supabase);
|
|
33
|
+
const notifications = createMachineRunNotifications({
|
|
34
|
+
wakeDelay: (userId, computerId) => machineRepository.wakeDelay(userId, computerId),
|
|
35
|
+
log,
|
|
36
|
+
});
|
|
37
|
+
const closeNotifications = subscribeToRunChanges(supabase, notifications);
|
|
30
38
|
const machineApi = createMachineRunsApi({
|
|
31
39
|
authenticate: authentication.machine,
|
|
32
40
|
runsFor: (principal) => createMachineRuns(machineRepository, principal),
|
|
41
|
+
notifications,
|
|
33
42
|
});
|
|
34
43
|
const eventsApi = createAutomationEventsApi({
|
|
35
44
|
delivery,
|
|
@@ -40,13 +49,14 @@ const host = createAutomationsHost({
|
|
|
40
49
|
controlApi,
|
|
41
50
|
machineApi,
|
|
42
51
|
eventsApi,
|
|
52
|
+
publicSkillApi: createPublicSkillApi(),
|
|
43
53
|
fireSchedules: () => delivery.fireDueCrons(),
|
|
44
54
|
schedulerIntervalMs: config.schedulerIntervalMs,
|
|
45
55
|
log,
|
|
46
56
|
});
|
|
47
57
|
host.server.listen(config.port, '0.0.0.0', () => log('host.ready', { port: config.port }));
|
|
48
58
|
for (const signal of ['SIGINT', 'SIGTERM']) {
|
|
49
|
-
process.once(signal, () => void host.close().finally(() => process.exit(0)));
|
|
59
|
+
process.once(signal, () => void closeNotifications().then(() => host.close()).finally(() => process.exit(0)));
|
|
50
60
|
}
|
|
51
61
|
function log(event, details = {}) {
|
|
52
62
|
console.log(JSON.stringify({ service: 'amalgm-automations', event, ...details }));
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { SupabaseClient } from '@supabase/supabase-js';
|
|
2
|
+
import type { MachineRunNotifications } from '../src/machine-notifications.js';
|
|
3
|
+
/** One private database subscription per Fly host, shared by its machine streams. */
|
|
4
|
+
export declare function subscribeToRunChanges(supabase: SupabaseClient, notifications: MachineRunNotifications): () => Promise<void>;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** One private database subscription per Fly host, shared by its machine streams. */
|
|
2
|
+
export function subscribeToRunChanges(supabase, notifications) {
|
|
3
|
+
const channel = supabase.channel('amalgm:automations:runs', { config: { private: true } })
|
|
4
|
+
.on('broadcast', { event: 'changed' }, ({ payload }) => {
|
|
5
|
+
if (typeof payload?.userId === 'string' && typeof payload?.targetId === 'string') {
|
|
6
|
+
notifications.changed(payload.userId, payload.targetId);
|
|
7
|
+
}
|
|
8
|
+
})
|
|
9
|
+
.subscribe((status) => {
|
|
10
|
+
if (status === 'SUBSCRIBED')
|
|
11
|
+
notifications.connected();
|
|
12
|
+
else
|
|
13
|
+
notifications.disconnected();
|
|
14
|
+
});
|
|
15
|
+
return async () => {
|
|
16
|
+
notifications.close();
|
|
17
|
+
await supabase.removeChannel(channel);
|
|
18
|
+
};
|
|
19
|
+
}
|
package/dist/host/server.d.ts
CHANGED
|
@@ -4,6 +4,7 @@ export declare function createAutomationsHost(options: {
|
|
|
4
4
|
readonly controlApi: (request: Request) => Promise<Response>;
|
|
5
5
|
readonly machineApi: (request: Request) => Promise<Response>;
|
|
6
6
|
readonly eventsApi?: (request: Request) => Promise<Response>;
|
|
7
|
+
readonly publicSkillApi?: (request: Request) => Promise<Response>;
|
|
7
8
|
readonly fireSchedules: () => Promise<unknown>;
|
|
8
9
|
readonly schedulerIntervalMs: number;
|
|
9
10
|
readonly maxRequestBodyBytes?: number;
|
package/dist/host/server.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { createServer } from 'node:http';
|
|
2
|
+
import { once } from 'node:events';
|
|
2
3
|
import { publicResourceRequestUrl } from '@amalgm/core/authorization';
|
|
3
4
|
export function createAutomationsHost(options) {
|
|
4
5
|
const log = options.log ?? (() => { });
|
|
6
|
+
const requests = new Set();
|
|
5
7
|
let scheduling = null;
|
|
6
8
|
const tick = () => {
|
|
7
9
|
if (scheduling)
|
|
@@ -14,34 +16,50 @@ export function createAutomationsHost(options) {
|
|
|
14
16
|
timer.unref();
|
|
15
17
|
tick();
|
|
16
18
|
const server = createServer(async (incoming, outgoing) => {
|
|
19
|
+
const controller = new AbortController();
|
|
20
|
+
requests.add(controller);
|
|
21
|
+
const abort = () => controller.abort();
|
|
22
|
+
outgoing.once('close', abort);
|
|
17
23
|
try {
|
|
18
24
|
if (incoming.url === '/healthz')
|
|
19
|
-
return send(outgoing, Response.json({ ok: true }));
|
|
20
|
-
const request = await webRequest(incoming, options.publicOrigin, options.maxRequestBodyBytes ?? 2 * 1024 * 1024);
|
|
25
|
+
return await send(outgoing, Response.json({ ok: true }), controller.signal);
|
|
26
|
+
const request = await webRequest(incoming, options.publicOrigin, options.maxRequestBodyBytes ?? 2 * 1024 * 1024, controller.signal);
|
|
21
27
|
const pathname = new URL(request.url).pathname;
|
|
22
|
-
const api = pathname.startsWith('/
|
|
23
|
-
? options.
|
|
24
|
-
: pathname.startsWith('/
|
|
25
|
-
|
|
28
|
+
const api = pathname.startsWith('/.well-known/agent-skills/') && options.publicSkillApi
|
|
29
|
+
? options.publicSkillApi
|
|
30
|
+
: pathname.startsWith('/e/') && options.eventsApi
|
|
31
|
+
? options.eventsApi
|
|
32
|
+
: pathname.startsWith('/v1/machine/') ? options.machineApi : options.controlApi;
|
|
33
|
+
await send(outgoing, await api(request), controller.signal);
|
|
26
34
|
}
|
|
27
35
|
catch (error) {
|
|
36
|
+
if (controller.signal.aborted)
|
|
37
|
+
return;
|
|
28
38
|
log('request.failed', { error: safe(error) });
|
|
39
|
+
if (outgoing.headersSent)
|
|
40
|
+
return outgoing.destroy();
|
|
29
41
|
const status = error instanceof HostRequestError ? error.status : 500;
|
|
30
42
|
await send(outgoing, Response.json({
|
|
31
43
|
error: status === 500 ? 'Automations service failed' : error instanceof Error ? error.message : String(error),
|
|
32
|
-
}, { status }));
|
|
44
|
+
}, { status }), controller.signal);
|
|
45
|
+
}
|
|
46
|
+
finally {
|
|
47
|
+
outgoing.removeListener('close', abort);
|
|
48
|
+
requests.delete(controller);
|
|
33
49
|
}
|
|
34
50
|
});
|
|
35
51
|
return {
|
|
36
52
|
server,
|
|
37
53
|
async close() {
|
|
38
54
|
clearInterval(timer);
|
|
55
|
+
for (const request of requests)
|
|
56
|
+
request.abort();
|
|
39
57
|
await scheduling;
|
|
40
58
|
await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
|
41
59
|
},
|
|
42
60
|
};
|
|
43
61
|
}
|
|
44
|
-
async function webRequest(request, publicOrigin, maximumBodyBytes) {
|
|
62
|
+
async function webRequest(request, publicOrigin, maximumBodyBytes, signal) {
|
|
45
63
|
const chunks = [];
|
|
46
64
|
let size = 0;
|
|
47
65
|
for await (const value of request) {
|
|
@@ -55,6 +73,7 @@ async function webRequest(request, publicOrigin, maximumBodyBytes) {
|
|
|
55
73
|
return new Request(publicResourceRequestUrl(publicOrigin, request.url ?? '/'), {
|
|
56
74
|
method: request.method ?? 'GET',
|
|
57
75
|
headers: request.headers,
|
|
76
|
+
signal,
|
|
58
77
|
...(body.length ? { body } : {}),
|
|
59
78
|
});
|
|
60
79
|
}
|
|
@@ -65,8 +84,30 @@ class HostRequestError extends Error {
|
|
|
65
84
|
this.status = status;
|
|
66
85
|
}
|
|
67
86
|
}
|
|
68
|
-
async function send(response, source) {
|
|
87
|
+
async function send(response, source, signal) {
|
|
69
88
|
response.writeHead(source.status, Object.fromEntries(source.headers));
|
|
70
|
-
response.
|
|
89
|
+
response.flushHeaders();
|
|
90
|
+
const reader = source.body?.getReader();
|
|
91
|
+
const abort = () => {
|
|
92
|
+
void reader?.cancel().catch(() => { });
|
|
93
|
+
response.destroy();
|
|
94
|
+
};
|
|
95
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
96
|
+
try {
|
|
97
|
+
signal.throwIfAborted();
|
|
98
|
+
while (reader) {
|
|
99
|
+
const { value, done } = await reader.read();
|
|
100
|
+
if (done)
|
|
101
|
+
break;
|
|
102
|
+
if (!response.write(value))
|
|
103
|
+
await once(response, 'drain', { signal });
|
|
104
|
+
}
|
|
105
|
+
response.end();
|
|
106
|
+
}
|
|
107
|
+
finally {
|
|
108
|
+
signal.removeEventListener('abort', abort);
|
|
109
|
+
await reader?.cancel().catch(() => { });
|
|
110
|
+
reader?.releaseLock();
|
|
111
|
+
}
|
|
71
112
|
}
|
|
72
113
|
const safe = (error) => (error instanceof Error ? error.message : String(error)).slice(0, 500);
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
const prefix = '/.well-known/agent-skills/';
|
|
3
|
+
/** Only the two packaged public artifacts are addressable; no arbitrary file reads. */
|
|
4
|
+
export function createPublicSkillApi(directory = new URL('../skills/', import.meta.url)) {
|
|
5
|
+
const index = readFileSync(new URL('index.json', directory));
|
|
6
|
+
const entry = JSON.parse(index.toString('utf8')).skills[0];
|
|
7
|
+
const filename = entry.url.replace(/^\.\//, '');
|
|
8
|
+
if (!/^amalgm-automations\.[a-f0-9]{64}\.tgz$/.test(filename))
|
|
9
|
+
throw new Error('Invalid skill artifact');
|
|
10
|
+
const files = new Map([
|
|
11
|
+
[`${prefix}index.json`, { bytes: index, type: 'application/json', cache: 'no-cache' }],
|
|
12
|
+
[`${prefix}${filename}`, {
|
|
13
|
+
bytes: readFileSync(new URL(filename, directory)), type: 'application/gzip',
|
|
14
|
+
cache: 'public, max-age=31536000, immutable',
|
|
15
|
+
}],
|
|
16
|
+
]);
|
|
17
|
+
return async (request) => {
|
|
18
|
+
const file = files.get(new URL(request.url).pathname);
|
|
19
|
+
if (!file)
|
|
20
|
+
return new Response('Not found', { status: 404 });
|
|
21
|
+
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
|
22
|
+
return new Response('Method not allowed', { status: 405, headers: { allow: 'GET, HEAD' } });
|
|
23
|
+
}
|
|
24
|
+
return new Response(request.method === 'HEAD' ? null : new Uint8Array(file.bytes), {
|
|
25
|
+
headers: {
|
|
26
|
+
'content-type': file.type,
|
|
27
|
+
'content-length': String(file.bytes.byteLength),
|
|
28
|
+
'cache-control': file.cache,
|
|
29
|
+
'x-content-type-options': 'nosniff',
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
};
|
|
33
|
+
}
|
|
Binary file
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"$schema":"https://schemas.agentskills.io/discovery/0.2.0/schema.json","skills":[{"name":"amalgm-automations","description":"Set up, operate, and troubleshoot Amalgm Automations through MCP or the amalgm automations CLI. Use for installation, Google sign-in, scheduled or webhook workflows, Run Now, run history, and Automations support.","type":"archive","url":"./amalgm-automations.5e4e14f0ace632c8383cd014e1f0f34b24ec0d5c5600132a840abc4c313b31d4.tgz","digest":"sha256:5e4e14f0ace632c8383cd014e1f0f34b24ec0d5c5600132a840abc4c313b31d4"}]}
|
package/dist/src/cli/run.d.ts
CHANGED
|
@@ -8,6 +8,6 @@ export interface AutomationCliIo extends AutomationCliInputPorts {
|
|
|
8
8
|
stdout?: Output;
|
|
9
9
|
stderr?: Output;
|
|
10
10
|
}
|
|
11
|
-
export declare const automationCliHelp = "amalgm automations \u2014 durable automation control for agents\n\nUsage:\n amalgm automations <command> [--input JSON | --file PATH | --stdin]\n amalgm-automations <command> [--input JSON | --file PATH | --stdin]\n\nCommands (identical to the Automations MCP task surface):\n create Create one complete definition\n list List the user's automations\n get Get one complete definition and optional run history\n update Apply grouped definition changes\n delete Delete current configuration; run history remains\n run-now Admit one durable manual run\n\nInput is one JSON object with the corresponding MCP tool's fields. Commands\nwithout options receive {}. Output is one JSON success or error envelope.\nUse --stdin when input contains credentials such as a webhook signing secret.\n\nWorkflow step lanes:\n version 1 action-only: {\"id\":\"...\",\"actionId\":\"product.action\",\"input\":{...}}\n version 2 action, command, or script steps:\n command {\"id\":\"...\",\"kind\":\"command\",\"command\":\"codex\",\"args\":[\"exec\",\"...\"],\"cwd\":\"/absolute/path\"}\n script {\"id\":\"...\",\"kind\":\"script\",\"runtime\":\"shell|node|python\",\"source\":\"...\",\"cwd\":\"/absolute/path\"}\n";
|
|
11
|
+
export declare const automationCliHelp = "amalgm automations \u2014 durable automation control for agents\n\nUsage:\n amalgm automations <command> [--input JSON | --file PATH | --stdin]\n amalgm-automations <command> [--input JSON | --file PATH | --stdin]\n\nCommands (identical to the Automations MCP task surface):\n create Create one complete definition\n list List the user's automations\n get Get one complete definition and optional run history\n update Apply grouped definition changes\n delete Delete current configuration; run history remains\n run-now Admit one durable manual run\n\nInput is one JSON object with the corresponding MCP tool's fields. Commands\nwithout options receive {}. Output is one JSON success or error envelope.\nUse --stdin when input contains credentials such as a webhook signing secret.\n\nSetup and support:\n Already connected? Reuse the running Amalgm Shell.\n First login: amalgm login --no-open\n Open the printed link, sign in with Google, and approve this computer.\n Keep the login process running; it hosts Shell after approval.\n Check: amalgm status --user EMAIL\n List local accounts: amalgm status --all\n Resume a registered computer: amalgm run --user EMAIL\n Verify access: amalgm automations list --user EMAIL --input '{\"limit\":1}'\n No local installation? https://amalgm.ai/setup\n Support: aayush@amalgm.ai\n Never copy private connection credentials into a command or support email.\n\nExternal agent MCP setup (after Shell login):\n amalgm automations mcp --user EMAIL\n Configure your client to launch this stdio command. No credential fields.\n\nWorkflow step lanes:\n version 1 action-only: {\"id\":\"...\",\"actionId\":\"product.action\",\"input\":{...}}\n version 2 action, command, or script steps:\n command {\"id\":\"...\",\"kind\":\"command\",\"command\":\"codex\",\"args\":[\"exec\",\"...\"],\"cwd\":\"/absolute/path\"}\n script {\"id\":\"...\",\"kind\":\"script\",\"runtime\":\"shell|node|python\",\"source\":\"...\",\"cwd\":\"/absolute/path\"}\n";
|
|
12
12
|
export declare function runAutomationCli(argv: string[], backend: AutomationCrud | AutomationCommands, io?: AutomationCliIo): Promise<number>;
|
|
13
13
|
export {};
|
package/dist/src/cli/run.js
CHANGED
|
@@ -20,6 +20,23 @@ Input is one JSON object with the corresponding MCP tool's fields. Commands
|
|
|
20
20
|
without options receive {}. Output is one JSON success or error envelope.
|
|
21
21
|
Use --stdin when input contains credentials such as a webhook signing secret.
|
|
22
22
|
|
|
23
|
+
Setup and support:
|
|
24
|
+
Already connected? Reuse the running Amalgm Shell.
|
|
25
|
+
First login: amalgm login --no-open
|
|
26
|
+
Open the printed link, sign in with Google, and approve this computer.
|
|
27
|
+
Keep the login process running; it hosts Shell after approval.
|
|
28
|
+
Check: amalgm status --user EMAIL
|
|
29
|
+
List local accounts: amalgm status --all
|
|
30
|
+
Resume a registered computer: amalgm run --user EMAIL
|
|
31
|
+
Verify access: amalgm automations list --user EMAIL --input '{"limit":1}'
|
|
32
|
+
No local installation? https://amalgm.ai/setup
|
|
33
|
+
Support: aayush@amalgm.ai
|
|
34
|
+
Never copy private connection credentials into a command or support email.
|
|
35
|
+
|
|
36
|
+
External agent MCP setup (after Shell login):
|
|
37
|
+
amalgm automations mcp --user EMAIL
|
|
38
|
+
Configure your client to launch this stdio command. No credential fields.
|
|
39
|
+
|
|
23
40
|
Workflow step lanes:
|
|
24
41
|
version 1 action-only: {"id":"...","actionId":"product.action","input":{...}}
|
|
25
42
|
version 2 action, command, or script steps:
|
|
@@ -22,7 +22,6 @@ export function triggerOperations(context) {
|
|
|
22
22
|
await context.exists(automationId);
|
|
23
23
|
const parsed = parseCreateScheduleTrigger(input);
|
|
24
24
|
const timezone = parsed.timezone || 'UTC';
|
|
25
|
-
schedule(parsed.cron, timezone);
|
|
26
25
|
if (parsed.id)
|
|
27
26
|
await unique(automationId, parsed.id);
|
|
28
27
|
return repository.createScheduleTrigger(principal.userId, automationId, {
|
package/dist/src/index.d.ts
CHANGED
|
@@ -19,6 +19,7 @@ export { SupabaseMachineRunRepository, type MachineRpcClient } from './supabase-
|
|
|
19
19
|
export { createMachineRuns } from './machine.js';
|
|
20
20
|
export type { AutomationMachinePrincipal, ClaimedAutomationRun, MachineRunRepository, MachineRuns, MachineRunUpdate, } from './machine.js';
|
|
21
21
|
export { createMachineRunsApi } from './machine-http.js';
|
|
22
|
+
export { createMachineRunNotifications, type MachineRunNotifications } from './machine-notifications.js';
|
|
22
23
|
export { createMachineRunsClient, type AutomationRequestHeaders } from './machine-client.js';
|
|
23
24
|
export { AutomationRunExecutor, automationPlan, type AutomationActionPort, type AutomationProcessExecution, type AutomationProcessPort, type AutomationRunExecutorOptions, } from './executor.js';
|
|
24
25
|
export { createNodeAutomationProcessHost, type NodeAutomationProcessHostOptions, } from './node-process-host.js';
|
package/dist/src/index.js
CHANGED
|
@@ -24,6 +24,7 @@ export { SupabaseStore } from './supabase-store.js';
|
|
|
24
24
|
export { SupabaseMachineRunRepository } from './supabase-machine.js';
|
|
25
25
|
export { createMachineRuns } from './machine.js';
|
|
26
26
|
export { createMachineRunsApi } from './machine-http.js';
|
|
27
|
+
export { createMachineRunNotifications } from './machine-notifications.js';
|
|
27
28
|
export { createMachineRunsClient } from './machine-client.js';
|
|
28
29
|
export { AutomationRunExecutor, automationPlan, } from './executor.js';
|
|
29
30
|
export { createNodeAutomationProcessHost, } from './node-process-host.js';
|
|
@@ -5,4 +5,6 @@ export declare function createMachineRunsClient(options: {
|
|
|
5
5
|
readonly headers: AutomationRequestHeaders;
|
|
6
6
|
readonly fetch?: typeof globalThis.fetch;
|
|
7
7
|
readonly requestTimeoutMs?: number;
|
|
8
|
-
}): MachineRuns
|
|
8
|
+
}): MachineRuns & {
|
|
9
|
+
notifications(signal: AbortSignal): AsyncIterable<void>;
|
|
10
|
+
};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { AutomationError } from './errors.js';
|
|
2
|
+
import { machineEventStream } from './machine-event-stream.js';
|
|
2
3
|
export function createMachineRunsClient(options) {
|
|
3
4
|
const baseUrl = options.baseUrl.replace(/\/$/, '');
|
|
4
5
|
const fetch = options.fetch ?? globalThis.fetch;
|
|
@@ -23,6 +24,11 @@ export function createMachineRunsClient(options) {
|
|
|
23
24
|
return payload;
|
|
24
25
|
};
|
|
25
26
|
return Object.freeze({
|
|
27
|
+
notifications(signal) {
|
|
28
|
+
return machineEventStream({
|
|
29
|
+
url: `${baseUrl}/v1/machine/runs/notifications`, headers: options.headers, fetch, signal,
|
|
30
|
+
});
|
|
31
|
+
},
|
|
26
32
|
async claim(input = {}) {
|
|
27
33
|
const result = await request('/v1/machine/runs/claim', 'POST', input);
|
|
28
34
|
return result.runs;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { AutomationRequestHeaders } from './machine-client.js';
|
|
2
|
+
/** A wakeup stream carries no run data; every reconnect gets fresh authorization. */
|
|
3
|
+
export declare function machineEventStream(options: {
|
|
4
|
+
readonly url: string;
|
|
5
|
+
readonly headers: AutomationRequestHeaders;
|
|
6
|
+
readonly fetch: typeof globalThis.fetch;
|
|
7
|
+
readonly signal: AbortSignal;
|
|
8
|
+
}): AsyncGenerator<void>;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { AutomationError } from './errors.js';
|
|
2
|
+
/** A wakeup stream carries no run data; every reconnect gets fresh authorization. */
|
|
3
|
+
export async function* machineEventStream(options) {
|
|
4
|
+
const connection = new AbortController();
|
|
5
|
+
const signal = AbortSignal.any([options.signal, connection.signal]);
|
|
6
|
+
let rejectAborted;
|
|
7
|
+
const aborted = new Promise((_, reject) => { rejectAborted = reject; });
|
|
8
|
+
const abort = () => rejectAborted(signal.reason);
|
|
9
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
10
|
+
let timeout = setTimeout(() => connection.abort(new Error('Notification connection timed out')), 15_000);
|
|
11
|
+
timeout.unref();
|
|
12
|
+
let reader;
|
|
13
|
+
try {
|
|
14
|
+
const headers = await Promise.race([
|
|
15
|
+
Promise.resolve().then(() => { signal.throwIfAborted(); return options.headers('GET', options.url); }),
|
|
16
|
+
aborted,
|
|
17
|
+
]);
|
|
18
|
+
signal.throwIfAborted();
|
|
19
|
+
const response = await options.fetch(options.url, {
|
|
20
|
+
headers: { ...headers, accept: 'text/event-stream' }, signal,
|
|
21
|
+
});
|
|
22
|
+
if (!response.ok)
|
|
23
|
+
throw new AutomationError('notifications_unavailable', `Automations notifications returned ${response.status}`, response.status);
|
|
24
|
+
if (!response.headers.get('content-type')?.startsWith('text/event-stream') || !response.body) {
|
|
25
|
+
throw new Error('Expected an Automations notification stream');
|
|
26
|
+
}
|
|
27
|
+
reader = response.body.getReader();
|
|
28
|
+
const decoder = new TextDecoder();
|
|
29
|
+
let pending = '';
|
|
30
|
+
while (!signal.aborted) {
|
|
31
|
+
clearTimeout(timeout);
|
|
32
|
+
timeout = setTimeout(() => connection.abort(new Error('Notification connection went silent')), 75_000);
|
|
33
|
+
timeout.unref();
|
|
34
|
+
const { value, done } = await reader.read();
|
|
35
|
+
if (done)
|
|
36
|
+
throw new Error('Automations notification connection closed');
|
|
37
|
+
pending += decoder.decode(value, { stream: true });
|
|
38
|
+
let boundary;
|
|
39
|
+
while ((boundary = /\r?\n\r?\n/.exec(pending))) {
|
|
40
|
+
const frame = pending.slice(0, boundary.index);
|
|
41
|
+
pending = pending.slice(boundary.index + boundary[0].length);
|
|
42
|
+
if (frame.length > 16_384)
|
|
43
|
+
throw new Error('Automations notification is too large');
|
|
44
|
+
if (/^event: ?(?:ready|wake)\r?$/m.test(frame))
|
|
45
|
+
yield;
|
|
46
|
+
}
|
|
47
|
+
if (pending.length > 16_384)
|
|
48
|
+
throw new Error('Automations notification is too large');
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
finally {
|
|
52
|
+
clearTimeout(timeout);
|
|
53
|
+
signal.removeEventListener('abort', abort);
|
|
54
|
+
connection.abort();
|
|
55
|
+
await reader?.cancel().catch(() => { });
|
|
56
|
+
reader?.releaseLock();
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { AutomationMachinePrincipal, MachineRuns } from './machine.js';
|
|
2
|
+
import type { MachineRunNotifications } from './machine-notifications.js';
|
|
2
3
|
export declare function createMachineRunsApi(options: {
|
|
3
4
|
readonly authenticate: (request: Request) => Promise<AutomationMachinePrincipal>;
|
|
4
5
|
readonly runsFor: (principal: AutomationMachinePrincipal) => MachineRuns;
|
|
6
|
+
readonly notifications?: Pick<MachineRunNotifications, 'open'>;
|
|
5
7
|
}): (request: Request) => Promise<Response>;
|
package/dist/src/machine-http.js
CHANGED
|
@@ -7,7 +7,13 @@ export function createMachineRunsApi(options) {
|
|
|
7
7
|
if (parts[0] !== 'v1' || parts[1] !== 'machine' || parts[2] !== 'runs') {
|
|
8
8
|
return json(404, { error: 'Not found' });
|
|
9
9
|
}
|
|
10
|
-
const
|
|
10
|
+
const principal = await options.authenticate(request);
|
|
11
|
+
if (parts[3] === 'notifications' && parts.length === 4 && request.method === 'GET') {
|
|
12
|
+
if (!options.notifications)
|
|
13
|
+
throw new AutomationError('unavailable', 'Notifications unavailable', 503);
|
|
14
|
+
return options.notifications.open(principal, request.signal);
|
|
15
|
+
}
|
|
16
|
+
const runs = options.runsFor(principal);
|
|
11
17
|
if (parts[3] === 'claim' && parts.length === 4 && request.method === 'POST') {
|
|
12
18
|
return json(200, { runs: await runs.claim(await body(request)) });
|
|
13
19
|
}
|
|
@@ -18,7 +24,7 @@ export function createMachineRunsApi(options) {
|
|
|
18
24
|
}
|
|
19
25
|
catch (error) {
|
|
20
26
|
if (error instanceof AutomationError)
|
|
21
|
-
return json(error.code === 'validation' ? 400 : 403, {
|
|
27
|
+
return json(error.status ?? (error.code === 'validation' ? 400 : 403), {
|
|
22
28
|
error: error.message, code: error.code,
|
|
23
29
|
});
|
|
24
30
|
const code = error instanceof Error && 'code' in error ? String(error.code) : 'internal';
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { AutomationMachinePrincipal } from './machine.js';
|
|
2
|
+
/** Ephemeral wakeups only. Supabase remains the queue and the eligibility clock. */
|
|
3
|
+
export declare function createMachineRunNotifications(options: {
|
|
4
|
+
readonly wakeDelay: (userId: string, computerId: string) => Promise<number | null>;
|
|
5
|
+
readonly heartbeatMs?: number;
|
|
6
|
+
readonly maxStreamMs?: number;
|
|
7
|
+
readonly log?: (event: string, details: Readonly<Record<string, unknown>>) => void;
|
|
8
|
+
}): Readonly<{
|
|
9
|
+
connected(): void;
|
|
10
|
+
disconnected: () => void;
|
|
11
|
+
close: () => void;
|
|
12
|
+
changed(userId: string, computerId: string): void;
|
|
13
|
+
open(principal: AutomationMachinePrincipal, signal: AbortSignal): Response;
|
|
14
|
+
}>;
|
|
15
|
+
export type MachineRunNotifications = ReturnType<typeof createMachineRunNotifications>;
|