@amalgm/automations 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/AXIOMS.md +11 -0
  2. package/PURPOSE.md +18 -0
  3. package/README.md +34 -0
  4. package/dist/host/auth.js +4 -1
  5. package/dist/host/main.js +9 -1
  6. package/dist/host/notifications.d.ts +4 -0
  7. package/dist/host/notifications.js +19 -0
  8. package/dist/host/server.js +46 -7
  9. package/dist/src/cli/run.d.ts +1 -1
  10. package/dist/src/cli/run.js +12 -0
  11. package/dist/src/index.d.ts +1 -0
  12. package/dist/src/index.js +1 -0
  13. package/dist/src/machine-client.d.ts +3 -1
  14. package/dist/src/machine-client.js +6 -0
  15. package/dist/src/machine-event-stream.d.ts +8 -0
  16. package/dist/src/machine-event-stream.js +58 -0
  17. package/dist/src/machine-http.d.ts +2 -0
  18. package/dist/src/machine-http.js +8 -2
  19. package/dist/src/machine-notifications.d.ts +15 -0
  20. package/dist/src/machine-notifications.js +124 -0
  21. package/dist/src/machine.d.ts +1 -0
  22. package/dist/src/runner.d.ts +3 -1
  23. package/dist/src/runner.js +69 -46
  24. package/dist/src/supabase-machine.d.ts +1 -0
  25. package/dist/src/supabase-machine.js +3 -0
  26. package/package.json +2 -1
  27. package/skills/amalgm-automations/SKILL.md +148 -0
  28. package/skills/amalgm-automations/agents/openai.yaml +4 -0
  29. package/skills/amalgm-automations/references/command-contract.md +173 -0
  30. package/skills/amalgm-automations/references/setup-and-support.md +195 -0
  31. package/skills/amalgm-automations/references/workflow-plans.md +152 -0
  32. package/supabase/migrations/20260904020000_machine_run_notifications.sql +52 -0
  33. package/supabase/migrations/20260904030000_machine_notification_permissions.sql +4 -0
  34. package/skills/automations/SKILL.md +0 -141
@@ -1,61 +1,84 @@
1
+ import { setTimeout as delay } from 'node:timers/promises';
2
+ /** Listen first, then drain. There is no timer that asks an idle machine for work. */
1
3
  export function createAutomationMachineRunner(options) {
2
4
  const batchSize = integer(options.batchSize ?? 8, 1, 20, 'batchSize');
3
5
  const leaseSeconds = integer(options.leaseSeconds ?? 60, 30, 900, 'leaseSeconds');
4
- const pollIntervalMs = integer(options.pollIntervalMs ?? 1_000, 1, 60_000, 'pollIntervalMs');
5
- const maxBackoffMs = integer(options.maxBackoffMs ?? Math.max(30_000, pollIntervalMs), pollIntervalMs, 300_000, 'maxBackoffMs');
6
- let active = null;
7
- let timer = null;
8
- let stopped = true;
9
- let failures = 0;
10
- let nextDelay = pollIntervalMs;
11
- const schedule = (delay) => {
12
- if (stopped)
6
+ const reconnectDelayMs = integer(options.reconnectDelayMs ?? 1_000, 1, 60_000, 'reconnectDelayMs');
7
+ const maxBackoffMs = integer(options.maxBackoffMs ?? 30_000, reconnectDelayMs, 300_000, 'maxBackoffMs');
8
+ let lifetime = null;
9
+ let listening = null;
10
+ let draining = null;
11
+ let pending = false;
12
+ const wake = (connection, signal) => {
13
+ pending = true;
14
+ if (draining)
13
15
  return;
14
- timer = setTimeout(drain, delay);
15
- timer.unref?.();
16
- };
17
- const drain = () => {
18
- if (stopped || active)
19
- return active;
20
- active = options.runs.claim({ limit: batchSize, leaseSeconds })
21
- .then(async (runs) => {
22
- failures = 0;
23
- if (runs.length)
24
- options.log?.('automations.claimed', { count: runs.length });
25
- const settled = await Promise.allSettled(runs.map(options.execute));
26
- settled.forEach((result) => {
27
- if (result.status === 'rejected') {
28
- options.log?.('automations.run.failed', { error: safe(result.reason) });
16
+ draining = (async () => {
17
+ while (pending && !signal.aborted) {
18
+ pending = false;
19
+ const runs = await options.runs.claim({ limit: batchSize, leaseSeconds });
20
+ if (runs.length)
21
+ options.log?.('automations.claimed', { count: runs.length });
22
+ const settled = await Promise.allSettled(runs.map(options.execute));
23
+ for (const result of settled) {
24
+ if (result.status === 'rejected')
25
+ options.log?.('automations.run.failed', { error: safe(result.reason) });
29
26
  }
30
- });
31
- nextDelay = runs.length === batchSize ? 0 : pollIntervalMs;
32
- })
33
- .catch((error) => {
34
- failures += 1;
35
- const delayMs = Math.min(maxBackoffMs, pollIntervalMs * (2 ** Math.min(failures - 1, 10)));
36
- options.log?.('automations.poll.failed', { error: safe(error), retryInMs: delayMs });
37
- nextDelay = delayMs;
38
- })
39
- .finally(() => {
40
- active = null;
41
- schedule(nextDelay);
27
+ // Work may arrive while execution is busy, including a partial last batch.
28
+ pending ||= runs.length > 0;
29
+ }
30
+ })().catch((error) => {
31
+ options.log?.('automations.claim.failed', { error: safe(error) });
32
+ connection.abort(error);
33
+ }).finally(() => {
34
+ draining = null;
35
+ if (pending && !signal.aborted && !connection.signal.aborted)
36
+ wake(connection, signal);
42
37
  });
43
- return active;
38
+ };
39
+ const listen = async (signal) => {
40
+ let failures = 0;
41
+ while (!signal.aborted) {
42
+ const connection = new AbortController();
43
+ const connectedSignal = AbortSignal.any([signal, connection.signal]);
44
+ const startedAt = Date.now();
45
+ try {
46
+ for await (const _ of options.notifications(connectedSignal)) {
47
+ if (connectedSignal.aborted)
48
+ break;
49
+ wake(connection, signal);
50
+ }
51
+ }
52
+ catch (error) {
53
+ if (!signal.aborted)
54
+ options.log?.('automations.notifications.disconnected', { error: safe(error) });
55
+ }
56
+ finally {
57
+ connection.abort();
58
+ await draining;
59
+ }
60
+ if (signal.aborted)
61
+ break;
62
+ // A flapping stream must not reset its backoff merely by sending ready.
63
+ failures = Date.now() - startedAt >= 30_000 ? 0 : failures + 1;
64
+ const backoff = Math.min(maxBackoffMs, reconnectDelayMs * 2 ** Math.min(Math.max(0, failures - 1), 10));
65
+ await delay(backoff, undefined, { signal, ref: false }).catch(() => { });
66
+ }
44
67
  };
45
68
  return Object.freeze({
46
69
  start() {
47
- if (!stopped)
70
+ if (lifetime)
48
71
  return;
49
- stopped = false;
50
- failures = 0;
51
- void drain();
72
+ lifetime = new AbortController();
73
+ pending = false;
74
+ listening = listen(lifetime.signal);
52
75
  },
53
76
  async close() {
54
- stopped = true;
55
- if (timer)
56
- clearTimeout(timer);
57
- timer = null;
58
- await active;
77
+ lifetime?.abort();
78
+ await listening;
79
+ await draining;
80
+ lifetime = null;
81
+ listening = null;
59
82
  },
60
83
  });
61
84
  }
@@ -11,6 +11,7 @@ export declare class SupabaseMachineRunRepository implements MachineRunRepositor
11
11
  #private;
12
12
  private readonly client;
13
13
  constructor(client: MachineRpcClient);
14
+ wakeDelay(userId: string, computerId: string): Promise<number | null>;
14
15
  claim(input: Parameters<MachineRunRepository['claim']>[0]): Promise<ClaimedAutomationRun[]>;
15
16
  update(input: Parameters<MachineRunRepository['update']>[0]): Promise<ClaimedAutomationRun | null>;
16
17
  }
@@ -4,6 +4,9 @@ export class SupabaseMachineRunRepository {
4
4
  constructor(client) {
5
5
  this.client = client;
6
6
  }
7
+ wakeDelay(userId, computerId) {
8
+ return this.#call('amalgm_machine_wake_delay', { p_user_id: userId, p_target_id: computerId });
9
+ }
7
10
  async claim(input) {
8
11
  const rows = await this.#call('claim_amalgm_machine_runs', {
9
12
  p_user_id: input.userId,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amalgm/automations",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "Amalgm's automation SDK: durable trigger admission and target-machine execution.",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -49,6 +49,7 @@
49
49
  "check": "tsx scripts/check-tree.ts && tsc -p tsconfig.json --noEmit",
50
50
  "test": "tsx --test test/*.test.ts",
51
51
  "test:supabase": "tsx --test test/integration/postgres.test.ts",
52
+ "test:notifications": "tsx scripts/test-notifications.ts",
52
53
  "verify": "npm run check && npm test && npm run build && tsx scripts/check-cli-artifact.ts",
53
54
  "release:check": "npm run verify && npm run test:supabase",
54
55
  "prepack": "npm run build",
@@ -0,0 +1,148 @@
1
+ ---
2
+ name: amalgm-automations
3
+ 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.
4
+ ---
5
+
6
+ # Amalgm Automations
7
+
8
+ Use this skill to turn a user's requested recurring, event-driven, or manual
9
+ workflow into one durable Amalgm automation and verify what actually ran.
10
+ Automations owns configuration and permanent run history; the selected Amalgm
11
+ machine executes the workflow.
12
+
13
+ Amalgm Automations is free. Guide the person through Google sign-in and computer
14
+ approval when needed; do not introduce a payment step. A native agent or other
15
+ external service still uses its own account and may have its own usage costs.
16
+
17
+ ## Choose the available adapter
18
+
19
+ Prefer the six `amalgm_automations_*` MCP tools when they are callable. In a
20
+ native local agent without that MCP server, use the matching global CLI:
21
+
22
+ ```text
23
+ MCP CLI
24
+ amalgm_automations_create amalgm automations create
25
+ amalgm_automations_list amalgm automations list
26
+ amalgm_automations_get amalgm automations get
27
+ amalgm_automations_update amalgm automations update
28
+ amalgm_automations_delete amalgm automations delete
29
+ amalgm_automations_run_now amalgm automations run-now
30
+ ```
31
+
32
+ Both adapters accept the same JSON object and call the same SDK command
33
+ surface. Do not translate one into a different resource-level API.
34
+
35
+ Reuse a working connection. For an available MCP connection, a small `list`
36
+ request with `{"limit":1}` proves access; do not install or log in again. For
37
+ CLI use, check `amalgm automations --help` and use
38
+ `amalgm automations list --input '{"limit":1}'`. An empty successful list is
39
+ ready, not a setup failure. Respect an explicitly selected account.
40
+
41
+ If the user asks to install or sign in, or that read fails, read
42
+ [setup and support](references/setup-and-support.md). It covers the supported
43
+ install, browser approval, account selection, runtime restart, and recovery
44
+ commands. Carry setup through to a successful Automations read, then resume
45
+ the original request. Do not stop at “Shell must be running.”
46
+
47
+ Shell owns authentication. Never read, copy, or ask the user to supply a runtime
48
+ token, DPoP proof, device key, or machine credential. The public connection
49
+ commands obtain what they need; environment-variable credential setup is not
50
+ the user login flow.
51
+
52
+ Read [references/command-contract.md](references/command-contract.md) whenever
53
+ composing `create` or `update`, or when exact fields are uncertain. Read
54
+ [references/workflow-plans.md](references/workflow-plans.md) whenever a
55
+ workflow runs an Amalgm action, native executable, code script, or trigger
56
+ input.
57
+
58
+ ## Preserve scope and intent
59
+
60
+ - List or get before changing an existing automation. Identify the exact
61
+ automation and preserve fields the user did not ask to change.
62
+ - The CLI is global. Its invocation directory never scopes the automation.
63
+ Execution belongs to the selected target machine.
64
+ - Omit `targetId` in a machine-bound session. Never guess or copy an opaque
65
+ target id. If the principal has multiple targets, let the service require an
66
+ explicit user choice.
67
+ - A process `cwd`, when supplied, must be absolute. If omitted, the selected
68
+ machine uses its documented default (normally that user's OS home).
69
+ - Workflow `env` is persisted ordinary configuration, not a secret store.
70
+ Never place Shell credentials or reusable service secrets in it.
71
+ - Use `--stdin` for CLI payloads containing a webhook signing secret or
72
+ complex source. Do not expose those values in command arguments or logs.
73
+ - Delete only after the exact id is established and deletion is within the
74
+ user's request. Deleting configuration intentionally retains run history.
75
+
76
+ ## Build one complete definition
77
+
78
+ Create the automation, its schedule/webhook triggers, and its optional workflow
79
+ in one task-level call. The service stages multi-resource changes safely and
80
+ enables the definition only after setup succeeds. If setup fails, report the
81
+ returned disabled draft id rather than silently creating a replacement.
82
+
83
+ Use a finite `maxOccurrences` when the user's request is bounded. Do not turn
84
+ "ten times" into an unbounded schedule plus a future cleanup promise.
85
+
86
+ Choose workflow lanes explicitly:
87
+
88
+ - An Amalgm tool or Amalgm agent is an action step. `chat.chat_agent_run` is
89
+ the Amalgm-agent path and requires a configured agent installation.
90
+ - A user's native Codex, Claude Code, OpenCode, or other installed program is
91
+ a command step. Arguments are literal argv; no shell is implied.
92
+ - Inline shell, Node.js, or Python source is a script step. Choose this lane
93
+ only when code or shell semantics are intentional.
94
+
95
+ Version 1 is action-only. Use version 2 for command or script steps and when
96
+ mixing lanes. Discover Amalgm action ids through the Tools product and native
97
+ executables through the selected machine; never guess either.
98
+
99
+ ## Verify Run Now to completion
100
+
101
+ `run-now` performs durable manual admission. Its initial `pending` result is
102
+ not evidence that work completed.
103
+
104
+ 1. Supply a stable, caller-chosen `idempotency_key` whenever the request might
105
+ be retried.
106
+ 2. If admission is retried, send the same automation id, input, and key. The
107
+ returned run id must remain the same.
108
+ 3. Poll `get` with `include_runs: true` or a `run_query` until the admitted run
109
+ becomes `completed` or `failed`. Match the exact run id; do not assume the
110
+ newest unrelated run is yours. Space out reads and back off while state is
111
+ unchanged; a rate-limit response is a reason to wait, not to submit again.
112
+ 4. Inspect every step's status and bounded output. Report failure honestly,
113
+ including the failing step and service error.
114
+
115
+ `pending` means durable and awaiting its machine. `sent` or `running` means the
116
+ machine holds a lease. Only `completed` and `failed` are terminal.
117
+
118
+ Keep the user informed while waiting. If the machine stays unavailable or the
119
+ current session cannot keep waiting, report the exact run id and observed
120
+ nonterminal state; do not claim completion, create a replacement, or promise
121
+ background monitoring that has not been set up.
122
+
123
+ Diagnose at the failing boundary. Examples: `process_execution_unavailable`
124
+ means that machine lacks the process host; an executable-not-found error is a
125
+ machine PATH/install problem; `Installed agent has no model` is agent
126
+ configuration; an action-specific HTTP error belongs to that action service.
127
+ Do not relabel these as Run Now failures or patch the automation around them.
128
+
129
+ ## Help when something goes wrong
130
+
131
+ Use [setup and support](references/setup-and-support.md) for login/runtime
132
+ failures, HTTP 429 or reported rate limits, and contacting support. Preserve
133
+ the actual error code, distinguish the failing service, and use bounded retries.
134
+ Do not invent minute/hour allowances or reset times that the service has not
135
+ reported.
136
+
137
+ For an unresolved issue, offer **Email Aayush at aayush@amalgm.ai** and prepare
138
+ a concise, redacted draft when helpful. Send only with the user's explicit
139
+ authorization and an available email tool; otherwise give them the draft.
140
+
141
+ ## Report the outcome
142
+
143
+ Return the automation id, target choice if the user selected one, trigger
144
+ summary, workflow lanes, and—when execution was requested—the exact run id and
145
+ terminal result. Mention that run history is durable. Never claim a schedule
146
+ fired merely because configuration creation succeeded.
147
+ Include automation/run links when returned by the product; do not invent a
148
+ deep link or use a private webhook URL as a viewing link.
@@ -0,0 +1,4 @@
1
+ interface:
2
+ display_name: "Amalgm Automations"
3
+ short_description: "Set up, run, and troubleshoot Amalgm automations"
4
+ default_prompt: "Use $amalgm-automations to help me connect my account and create and verify an automation."
@@ -0,0 +1,173 @@
1
+ # Command contract
2
+
3
+ Use the field names below exactly. MCP receives the object directly. CLI
4
+ receives it through `--input '<json>'`, `--file <path>`, or `--stdin` and emits
5
+ one JSON envelope:
6
+
7
+ ```json
8
+ {"result": {}}
9
+ ```
10
+
11
+ Failures use stderr, a nonzero exit, and:
12
+
13
+ ```json
14
+ {"error":{"code":"...","message":"...","status":400}}
15
+ ```
16
+
17
+ Do not scrape prose or infer success from exit code alone; parse the envelope.
18
+
19
+ ## create
20
+
21
+ ```json
22
+ {
23
+ "id": "optional-readable-id",
24
+ "targetId": "optional-explicit-target",
25
+ "name": "Optional name",
26
+ "description": "Optional description",
27
+ "enabled": true,
28
+ "schedules": [{
29
+ "id": "optional-trigger-id",
30
+ "cron": "0 9 * * 1-5",
31
+ "timezone": "America/Los_Angeles",
32
+ "enabled": true,
33
+ "maxOccurrences": 10
34
+ }],
35
+ "webhooks": [{
36
+ "id": "optional-trigger-id",
37
+ "source": "github",
38
+ "event": "push",
39
+ "secret": "optional-provider-secret-at-least-16-characters",
40
+ "enabled": true
41
+ }],
42
+ "workflow": {
43
+ "id": "optional-workflow-id",
44
+ "name": "Optional workflow name",
45
+ "script": "Human-readable summary of what the workflow does.",
46
+ "compiled": {
47
+ "version": 1,
48
+ "steps": [{
49
+ "id": "notify",
50
+ "actionId": "channels.notify_user",
51
+ "input": {"title":"Reminder","message":"Call Mom"}
52
+ }]
53
+ },
54
+ "allowlist": null,
55
+ "limits": null
56
+ }
57
+ }
58
+ ```
59
+
60
+ At least one executable step is required when `compiled` is supplied. Omit
61
+ `targetId` when Shell has bound the principal to one machine. The returned
62
+ webhook URL is the provider destination; a configured signing secret is
63
+ write-only on later reads.
64
+
65
+ CLI example:
66
+
67
+ ```bash
68
+ printf '%s\n' '{"name":"Weekday check","schedules":[{"cron":"0 9 * * 1-5","timezone":"America/Los_Angeles"}],"workflow":{"script":"Run the weekday check.","compiled":{"version":2,"steps":[{"id":"check","kind":"command","command":"my-check","cwd":"/absolute/project"}]}}}' \
69
+ | amalgm automations create --stdin
70
+ ```
71
+
72
+ ## list
73
+
74
+ All fields are optional:
75
+
76
+ ```json
77
+ {"targetId":"machine-id","enabled":true,"limit":20,"offset":0}
78
+ ```
79
+
80
+ Use it to find candidates, then `get` the exact definition before mutation.
81
+
82
+ ## get
83
+
84
+ ```json
85
+ {
86
+ "automation_id": "automation-id",
87
+ "include_runs": true,
88
+ "run_query": {"status":"completed","limit":20,"offset":0}
89
+ }
90
+ ```
91
+
92
+ `include_runs` and `run_query` are optional. `run_query.status` is one of
93
+ `pending`, `sent`, `running`, `completed`, or `failed`.
94
+
95
+ ## update
96
+
97
+ Updates are grouped so one call can change metadata and owned resources:
98
+
99
+ ```json
100
+ {
101
+ "automation_id": "automation-id",
102
+ "patch": {
103
+ "targetId": "optional-new-target",
104
+ "name": "New name or null",
105
+ "description": "New description or null",
106
+ "enabled": true
107
+ },
108
+ "schedules": {
109
+ "create": [{"cron":"0 9 * * *","timezone":"UTC"}],
110
+ "update": [{"id":"schedule-id","patch":{"enabled":false}}],
111
+ "delete": ["old-schedule-id"]
112
+ },
113
+ "webhooks": {
114
+ "create": [{"source":"github","event":"push"}],
115
+ "update": [{"id":"webhook-id","patch":{"rotateUrl":true}}],
116
+ "delete": ["old-webhook-id"]
117
+ },
118
+ "workflow": {
119
+ "update": {
120
+ "name": "Updated workflow name",
121
+ "script": "Updated summary",
122
+ "compiled": {
123
+ "version": 2,
124
+ "steps": [{
125
+ "id": "check",
126
+ "kind": "command",
127
+ "command": "my-check",
128
+ "cwd": "/absolute/project"
129
+ }]
130
+ },
131
+ "allowlist": null,
132
+ "limits": null
133
+ }
134
+ }
135
+ }
136
+ ```
137
+
138
+ Every included patch must contain a real change. Workflow change is exactly one
139
+ of `{ "create": ... }`, `{ "update": ... }`, or `{ "delete": true }`.
140
+ Schedule and webhook changes may combine create/update/delete arrays. Preserve
141
+ resource ids returned by `get`; ids are scoped to their automation.
142
+
143
+ ## delete
144
+
145
+ ```json
146
+ {"automation_id":"automation-id"}
147
+ ```
148
+
149
+ This deletes current configuration, triggers, and workflow. It does not delete
150
+ historical runs.
151
+
152
+ ## run-now
153
+
154
+ ```json
155
+ {
156
+ "automation_id": "automation-id",
157
+ "input": {"requestedBy":"user"},
158
+ "idempotency_key": "stable-key-for-this-logical-request"
159
+ }
160
+ ```
161
+
162
+ `input` may be any JSON value. Admission requires an enabled automation with an
163
+ executable compiled workflow. The returned run starts as `pending`; follow it
164
+ with `get` until terminal. Reusing the same key for the same logical request
165
+ returns the same run rather than creating a duplicate.
166
+
167
+ ## Webhook completion
168
+
169
+ Creating an Amalgm webhook trigger does not register it at GitHub or another
170
+ provider. Configure the returned URL in that provider separately, use JSON
171
+ content type, select only the requested events, and use the same optional
172
+ signing secret on both sides. Rotating the URL invalidates the old bearer
173
+ capability.