@amalgm/automations 0.1.1 → 0.2.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.
- package/AXIOMS.md +14 -0
- package/PURPOSE.md +15 -3
- package/README.md +61 -100
- package/dist/host/auth.d.ts +11 -0
- package/dist/host/auth.js +89 -0
- package/dist/host/config.d.ts +8 -0
- package/dist/host/config.js +29 -0
- package/dist/host/index.d.ts +3 -0
- package/dist/host/index.js +3 -0
- package/dist/host/main.d.ts +1 -0
- package/dist/host/main.js +47 -0
- package/dist/host/server.d.ts +11 -0
- package/dist/host/server.js +53 -0
- package/dist/src/automations.js +4 -1
- package/dist/src/client.d.ts +3 -1
- package/dist/src/client.js +6 -4
- package/dist/src/contract.d.ts +17 -1
- package/dist/src/crud/automations.d.ts +3 -0
- package/dist/src/crud/automations.js +55 -0
- package/dist/src/crud/context.d.ts +13 -0
- package/dist/src/crud/context.js +34 -0
- package/dist/src/crud/repository.d.ts +35 -0
- package/dist/src/crud/repository.js +1 -0
- package/dist/src/crud/runs.d.ts +3 -0
- package/dist/src/crud/runs.js +19 -0
- package/dist/src/crud/triggers.d.ts +3 -0
- package/dist/src/crud/triggers.js +118 -0
- package/dist/src/crud/workflow.d.ts +3 -0
- package/dist/src/crud/workflow.js +42 -0
- package/dist/src/crud.d.ts +3 -33
- package/dist/src/crud.js +10 -237
- package/dist/src/events-http.d.ts +19 -0
- package/dist/src/events-http.js +107 -0
- package/dist/src/executor.d.ts +16 -0
- package/dist/src/executor.js +76 -0
- package/dist/src/index.d.ts +9 -2
- package/dist/src/index.js +6 -0
- package/dist/src/machine-client.d.ts +7 -0
- package/dist/src/machine-client.js +30 -0
- package/dist/src/machine-http.d.ts +5 -0
- package/dist/src/machine-http.js +40 -0
- package/dist/src/machine.d.ts +41 -0
- package/dist/src/machine.js +43 -0
- package/dist/src/mcp.js +2 -2
- package/dist/src/schema.d.ts +8 -0
- package/dist/src/schema.js +2 -0
- package/dist/src/supabase-crud/automations.d.ts +5 -0
- package/dist/src/supabase-crud/automations.js +36 -0
- package/dist/src/supabase-crud/mappers.d.ts +8 -0
- package/dist/src/supabase-crud/mappers.js +81 -0
- package/dist/src/supabase-crud/rows.d.ts +56 -0
- package/dist/src/supabase-crud/rows.js +1 -0
- package/dist/src/supabase-crud/rpc.d.ts +10 -0
- package/dist/src/supabase-crud/rpc.js +19 -0
- package/dist/src/supabase-crud/triggers.d.ts +5 -0
- package/dist/src/supabase-crud/triggers.js +46 -0
- package/dist/src/supabase-crud/workflow-runs.d.ts +5 -0
- package/dist/src/supabase-crud/workflow-runs.js +41 -0
- package/dist/src/supabase-crud.d.ts +5 -41
- package/dist/src/supabase-crud.js +4 -267
- package/dist/src/supabase-machine.d.ts +16 -0
- package/dist/src/supabase-machine.js +63 -0
- package/dist/src/supabase-store.js +1 -0
- package/dist/src/types.d.ts +1 -0
- package/package.json +13 -4
- package/skills/automations/SKILL.md +52 -0
- package/supabase/migrations/20260829010000_bounded_schedules_and_machine_claims.sql +311 -0
package/AXIOMS.md
CHANGED
|
@@ -35,3 +35,17 @@
|
|
|
35
35
|
firing instant that was claimed.
|
|
36
36
|
14. Legacy local automation storage is not a compatibility authority. Engine
|
|
37
37
|
cutover migrates callers to this SDK and then deletes the old store.
|
|
38
|
+
15. Event ingress returns success only after every admitted run is durable in
|
|
39
|
+
Supabase. Its recent-event list is a bounded, secret-free operational view,
|
|
40
|
+
never a second event or run authority.
|
|
41
|
+
16. A finite schedule decrements its durable remaining occurrence count in the
|
|
42
|
+
same transaction that admits a run; zero disables the trigger.
|
|
43
|
+
17. A machine receives work only by exclusively leasing runs whose persisted
|
|
44
|
+
target equals the `computer_id` in its DPoP-bound access token.
|
|
45
|
+
18. Machine execution consumes the immutable workflow snapshot stored on the
|
|
46
|
+
run. It never rediscovers or silently updates the automation definition.
|
|
47
|
+
19. A compiled workflow is a small declarative sequence of tool actions. The
|
|
48
|
+
executor receives tool calling as a host capability and never embeds a
|
|
49
|
+
Channels, Shell, CLI, or provider special case.
|
|
50
|
+
20. Automations is a standalone hosted service. Gateway owns none of its API,
|
|
51
|
+
scheduling, claim, execution, or persistence path.
|
package/PURPOSE.md
CHANGED
|
@@ -21,9 +21,21 @@ The product has two composable halves over that one state:
|
|
|
21
21
|
idempotency laws that decide when a run happens. It consumes the control
|
|
22
22
|
plane's persisted configuration; it does not expose another definition
|
|
23
23
|
write path. When a machine is offline, new runs remain pending; when it
|
|
24
|
-
reconnects,
|
|
25
|
-
|
|
26
|
-
machine.
|
|
24
|
+
reconnects, Shell claims pending runs directly from the Automations service
|
|
25
|
+
with its machine-bound DPoP identity and executes the persisted tool-action
|
|
26
|
+
plan. Execution itself stays on the selected machine.
|
|
27
|
+
|
|
28
|
+
Automations is its own hosted service and Fly machine. Its API and scheduler
|
|
29
|
+
share the same SDK and Supabase authority; neither is composed into, proxied by,
|
|
30
|
+
or dependent on Amalgm Gateway. A finite schedule carries its remaining
|
|
31
|
+
occurrence count in durable state, so “every minute for ten minutes” means ten
|
|
32
|
+
admitted runs and then an automatically disabled trigger — not a timer that a
|
|
33
|
+
machine must remember.
|
|
34
|
+
|
|
35
|
+
The event HTTP adapter is one thin door over that delivery rail. It bounds and
|
|
36
|
+
parses the webhook body, resolves the authenticated target supplied by the
|
|
37
|
+
host, and returns only after matching runs have been stored in Supabase. Its
|
|
38
|
+
recent-event response is an ephemeral, secret-free operational projection.
|
|
27
39
|
|
|
28
40
|
Amalgm supplies a resolved authenticated principal from its user session or
|
|
29
41
|
HMAC-refresh flow; future API keys resolve to the same principal capability.
|
package/README.md
CHANGED
|
@@ -1,128 +1,89 @@
|
|
|
1
1
|
# @amalgm/automations
|
|
2
2
|
|
|
3
|
-
The
|
|
4
|
-
|
|
5
|
-
happen. Supabase owns everything except execution.
|
|
6
|
-
|
|
7
|
-
## SDK contract (configuration control plane)
|
|
8
|
-
|
|
9
|
-
The public control plane is an auth-bound SDK. API, CLI, MCP, and the
|
|
10
|
-
automation skill adapt this contract; only the service implementation accesses
|
|
11
|
-
Supabase.
|
|
3
|
+
The SDK and standalone hosted service for automation definitions, triggers,
|
|
4
|
+
durable runs, and execution delivery to an Amalgm machine.
|
|
12
5
|
|
|
13
6
|
```ts
|
|
14
7
|
const sdk = service.for(principal);
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
await sdk.triggers.webhook.create('automation-id', {
|
|
19
|
-
source: 'github', event: 'push', secret: 'a-secret-at-least-16-characters',
|
|
8
|
+
const automation = await sdk.automations.create({
|
|
9
|
+
targetId: computerId,
|
|
10
|
+
name: 'Call Mom',
|
|
20
11
|
});
|
|
21
|
-
await sdk.workflow.create(
|
|
22
|
-
script: '
|
|
12
|
+
await sdk.workflow.create(automation.id, {
|
|
13
|
+
script: 'Notify me to call Mom.',
|
|
14
|
+
compiled: {
|
|
15
|
+
version: 1,
|
|
16
|
+
steps: [{
|
|
17
|
+
id: 'notify',
|
|
18
|
+
actionId: 'channels.notify_user',
|
|
19
|
+
input: { message: 'Call Mom' },
|
|
20
|
+
}],
|
|
21
|
+
},
|
|
23
22
|
});
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
or one workflow. Every component can be created, read, changed, and deleted
|
|
29
|
-
independently. Webhook secrets are write-only; reads return `secretConfigured`.
|
|
30
|
-
Deleting configuration never deletes run history.
|
|
31
|
-
|
|
32
|
-
### Composition
|
|
33
|
-
|
|
34
|
-
```ts
|
|
35
|
-
import {
|
|
36
|
-
AutomationCrudService,
|
|
37
|
-
SupabaseAutomationCrudRepository,
|
|
38
|
-
createAutomationApi,
|
|
39
|
-
} from '@amalgm/automations';
|
|
40
|
-
|
|
41
|
-
const service = new AutomationCrudService(
|
|
42
|
-
new SupabaseAutomationCrudRepository(supabaseRpcClient),
|
|
43
|
-
);
|
|
44
|
-
|
|
45
|
-
const api = createAutomationApi({
|
|
46
|
-
service,
|
|
47
|
-
authenticate: resolveAmalgmPrincipal,
|
|
23
|
+
await sdk.triggers.schedule.create(automation.id, {
|
|
24
|
+
cron: '* * * * *',
|
|
25
|
+
timezone: 'America/Los_Angeles',
|
|
26
|
+
maxOccurrences: 10,
|
|
48
27
|
});
|
|
49
28
|
```
|
|
50
29
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
30
|
+
That schedule admits exactly ten durable runs, then disables itself. If the
|
|
31
|
+
machine is offline, the runs remain pending. Shell later claims only work whose
|
|
32
|
+
target matches its DPoP `computer_id`, executes the immutable tool-action plan,
|
|
33
|
+
and commits the result.
|
|
55
34
|
|
|
56
|
-
|
|
57
|
-
CLI and MCP server use this same client with
|
|
58
|
-
`AMALGM_AUTOMATIONS_API_URL` and `AMALGM_AUTOMATIONS_AUTHORIZATION`.
|
|
35
|
+
## Surfaces
|
|
59
36
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
amalgm
|
|
63
|
-
amalgm
|
|
64
|
-
amalgm-automations
|
|
65
|
-
```
|
|
37
|
+
- `@amalgm/automations`: SDK, API/client, scheduler, machine claim client, and
|
|
38
|
+
generic tool-action executor.
|
|
39
|
+
- `@amalgm/automations/mcp`: agent tools over the same SDK.
|
|
40
|
+
- `@amalgm/automations/host`: standalone Fly service composition.
|
|
41
|
+
- `amalgm-automations`: CLI adapter.
|
|
66
42
|
|
|
67
|
-
The
|
|
68
|
-
|
|
43
|
+
The hosted service accepts verified Supabase user sessions for browser control
|
|
44
|
+
and Core-issued `amalgm-automations` DPoP grants for Shell. It does not run in
|
|
45
|
+
or depend on Amalgm Gateway.
|
|
69
46
|
|
|
70
|
-
##
|
|
47
|
+
## Durable model
|
|
71
48
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
49
|
+
An automation belongs to one user and target. It owns any number of schedule
|
|
50
|
+
or webhook triggers and zero or one workflow. Supabase owns definitions,
|
|
51
|
+
schedule clocks, immutable run snapshots, machine leases, and permanent run
|
|
52
|
+
history. Webhook secrets are write-only.
|
|
53
|
+
|
|
54
|
+
Compiled workflows use one small format:
|
|
75
55
|
|
|
76
56
|
```ts
|
|
77
|
-
|
|
57
|
+
type AutomationPlan = {
|
|
58
|
+
version: 1;
|
|
59
|
+
steps: Array<{ id: string; actionId: string; input: Json }>;
|
|
60
|
+
};
|
|
61
|
+
```
|
|
78
62
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
});
|
|
63
|
+
The executor does not know about Channels or any other product. Shell injects
|
|
64
|
+
one action-calling capability, and each step receives the stable idempotency key
|
|
65
|
+
`<run-id>:<step-id>`.
|
|
83
66
|
|
|
84
|
-
|
|
85
|
-
target: await core.resolveEventTarget(eventRef),
|
|
86
|
-
headers,
|
|
87
|
-
body,
|
|
88
|
-
payload,
|
|
89
|
-
});
|
|
67
|
+
## HTTP
|
|
90
68
|
|
|
91
|
-
|
|
69
|
+
The control API lives under `/v1/automations`. The machine execution API is:
|
|
92
70
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
const history = await sdk.runs.list(automationId);
|
|
71
|
+
```text
|
|
72
|
+
POST /v1/machine/runs/claim
|
|
73
|
+
PATCH /v1/machine/runs/:runId
|
|
99
74
|
```
|
|
100
75
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
Engine route, scheduler callback, and tunnel callback only translate and call
|
|
105
|
-
these SDK operations.
|
|
106
|
-
|
|
107
|
-
Every firing stores an immutable run snapshot in Supabase. Delivery and machine
|
|
108
|
-
execution update that record instead of deleting it. When a machine is offline,
|
|
109
|
-
new runs remain pending; on reconnect every pending run drains through the
|
|
110
|
-
existing transport.
|
|
111
|
-
|
|
112
|
-
The implementation covers Supabase definitions, cron and event admission,
|
|
113
|
-
secret verification, permanent run history, and reconnect drain. Engine
|
|
114
|
-
cutover must call these published contracts and delete its local automation
|
|
115
|
-
store; this package intentionally provides no compatibility storage path.
|
|
116
|
-
|
|
117
|
-
The purpose is in [PURPOSE.md](./PURPOSE.md), and the non-negotiable ownership
|
|
118
|
-
rules are in [AXIOMS.md](./AXIOMS.md).
|
|
76
|
+
The target is never accepted in a machine request. It comes from the verified
|
|
77
|
+
access token. Run leases expire and are safely reclaimable; terminal updates
|
|
78
|
+
must present the active lease token.
|
|
119
79
|
|
|
120
80
|
## Verification
|
|
121
81
|
|
|
122
|
-
```
|
|
123
|
-
npm
|
|
124
|
-
npm run
|
|
82
|
+
```bash
|
|
83
|
+
npm run verify
|
|
84
|
+
TEST_DATABASE_URL=postgres://... npm run test:supabase
|
|
85
|
+
npm pack --dry-run
|
|
125
86
|
```
|
|
126
87
|
|
|
127
|
-
|
|
128
|
-
|
|
88
|
+
See [PURPOSE.md](./PURPOSE.md) and [AXIOMS.md](./AXIOMS.md) for the governing
|
|
89
|
+
ownership and behavior laws.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { SupabaseClient } from '@supabase/supabase-js';
|
|
2
|
+
import type { AutomationPrincipal } from '../src/contract.js';
|
|
3
|
+
import type { AutomationMachinePrincipal } from '../src/machine.js';
|
|
4
|
+
export declare function createAutomationsAuthenticators(options: {
|
|
5
|
+
readonly issuer: string;
|
|
6
|
+
readonly supabase: SupabaseClient;
|
|
7
|
+
readonly now?: () => number;
|
|
8
|
+
}): Readonly<{
|
|
9
|
+
control: (request: Request) => Promise<AutomationPrincipal>;
|
|
10
|
+
machine: (request: Request) => Promise<AutomationMachinePrincipal>;
|
|
11
|
+
}>;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { authorizeMachineResource, } from '@amalgm/core/authorization';
|
|
3
|
+
import { calculateJwkThumbprint, createRemoteJWKSet, decodeProtectedHeader, importJWK, jwtVerify, } from 'jose';
|
|
4
|
+
import { ForbiddenError } from '../src/errors.js';
|
|
5
|
+
export function createAutomationsAuthenticators(options) {
|
|
6
|
+
const jwks = createRemoteJWKSet(new URL(`${options.issuer}/.well-known/jwks.json`));
|
|
7
|
+
const ports = verifierPorts(options, jwks);
|
|
8
|
+
return Object.freeze({
|
|
9
|
+
control: async (request) => {
|
|
10
|
+
const authorization = request.headers.get('authorization');
|
|
11
|
+
if (authorization?.startsWith('Bearer ')) {
|
|
12
|
+
const result = await options.supabase.auth.getUser(authorization.slice(7).trim());
|
|
13
|
+
if (result.error || !result.data.user)
|
|
14
|
+
throw new ForbiddenError('User authorization denied');
|
|
15
|
+
return {
|
|
16
|
+
userId: result.data.user.id,
|
|
17
|
+
scopes: ['automations:read', 'automations:write', 'runs:read'],
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
const scope = controlScope(request);
|
|
21
|
+
const principal = await authorize(request, options.issuer, scope, ports);
|
|
22
|
+
return {
|
|
23
|
+
userId: principal.userId,
|
|
24
|
+
computerId: principal.computerId,
|
|
25
|
+
scopes: automationScopes(principal.scopes),
|
|
26
|
+
targetIds: [principal.computerId],
|
|
27
|
+
};
|
|
28
|
+
},
|
|
29
|
+
machine: async (request) => {
|
|
30
|
+
const principal = await authorize(request, options.issuer, 'runs:execute', ports);
|
|
31
|
+
return {
|
|
32
|
+
userId: principal.userId,
|
|
33
|
+
computerId: principal.computerId,
|
|
34
|
+
scopes: automationScopes(principal.scopes),
|
|
35
|
+
};
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
async function authorize(request, issuer, scope, ports) {
|
|
40
|
+
return authorizeMachineResource({
|
|
41
|
+
authorization: request.headers.get('authorization'),
|
|
42
|
+
dpop: request.headers.get('dpop'),
|
|
43
|
+
method: request.method,
|
|
44
|
+
url: request.url,
|
|
45
|
+
issuer,
|
|
46
|
+
audience: 'amalgm-automations',
|
|
47
|
+
requiredScopes: [scope],
|
|
48
|
+
}, ports);
|
|
49
|
+
}
|
|
50
|
+
function verifierPorts(options, jwks) {
|
|
51
|
+
return {
|
|
52
|
+
nowSeconds: options.now ?? (() => Math.floor(Date.now() / 1000)),
|
|
53
|
+
async verifyAccessToken(token) {
|
|
54
|
+
const value = await jwtVerify(token, jwks, {
|
|
55
|
+
issuer: options.issuer, audience: 'amalgm-automations', algorithms: ['ES256'],
|
|
56
|
+
});
|
|
57
|
+
return { header: value.protectedHeader, claims: value.payload };
|
|
58
|
+
},
|
|
59
|
+
async verifyDpopProof(proof) {
|
|
60
|
+
const header = decodeProtectedHeader(proof);
|
|
61
|
+
const jwk = header.jwk;
|
|
62
|
+
if (!jwk)
|
|
63
|
+
throw new Error('DPoP proof has no public key');
|
|
64
|
+
const value = await jwtVerify(proof, await importJWK(jwk, 'ES256'), {
|
|
65
|
+
algorithms: ['ES256'], typ: 'dpop+jwt',
|
|
66
|
+
});
|
|
67
|
+
return { header: value.protectedHeader, claims: value.payload };
|
|
68
|
+
},
|
|
69
|
+
jwkThumbprint: (jwk) => calculateJwkThumbprint(jwk, 'sha256'),
|
|
70
|
+
accessTokenHash: async (token) => createHash('sha256').update(token).digest('base64url'),
|
|
71
|
+
async consumeProof(keyThumbprint, jti) {
|
|
72
|
+
const result = await options.supabase.rpc('consume_authorization_dpop_proof', {
|
|
73
|
+
p_key_thumbprint: keyThumbprint, p_jti: jti,
|
|
74
|
+
});
|
|
75
|
+
if (result.error)
|
|
76
|
+
throw result.error;
|
|
77
|
+
return result.data === true;
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
function controlScope(request) {
|
|
82
|
+
if (request.method.toUpperCase() !== 'GET')
|
|
83
|
+
return 'automations:write';
|
|
84
|
+
return new URL(request.url).pathname.includes('/runs') ? 'runs:read' : 'automations:read';
|
|
85
|
+
}
|
|
86
|
+
function automationScopes(scopes) {
|
|
87
|
+
return scopes.filter((scope) => (scope === 'automations:read' || scope === 'automations:write'
|
|
88
|
+
|| scope === 'runs:read' || scope === 'runs:execute'));
|
|
89
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export interface AutomationsHostConfig {
|
|
2
|
+
readonly port: number;
|
|
3
|
+
readonly supabaseUrl: string;
|
|
4
|
+
readonly supabaseServiceRoleKey: string;
|
|
5
|
+
readonly authorizationIssuer: string;
|
|
6
|
+
readonly schedulerIntervalMs: number;
|
|
7
|
+
}
|
|
8
|
+
export declare function automationsHostConfig(env?: Readonly<Record<string, string | undefined>>): AutomationsHostConfig;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export function automationsHostConfig(env = process.env) {
|
|
2
|
+
return Object.freeze({
|
|
3
|
+
port: integer(env.PORT, 8080, 1, 65_535),
|
|
4
|
+
supabaseUrl: url(env.SUPABASE_URL ?? env.NEXT_PUBLIC_SUPABASE_URL, 'SUPABASE_URL'),
|
|
5
|
+
supabaseServiceRoleKey: required(env.SUPABASE_SERVICE_ROLE_KEY, 'SUPABASE_SERVICE_ROLE_KEY'),
|
|
6
|
+
authorizationIssuer: url(env.AMALGM_AUTHORIZATION_ISSUER, 'AMALGM_AUTHORIZATION_ISSUER'),
|
|
7
|
+
schedulerIntervalMs: integer(env.AUTOMATIONS_SCHEDULER_INTERVAL_MS, 1_000, 250, 60_000),
|
|
8
|
+
});
|
|
9
|
+
}
|
|
10
|
+
function required(value, name) {
|
|
11
|
+
if (!value?.trim())
|
|
12
|
+
throw new Error(`${name} is required`);
|
|
13
|
+
return value.trim();
|
|
14
|
+
}
|
|
15
|
+
function url(value, name) {
|
|
16
|
+
const parsed = new URL(required(value, name));
|
|
17
|
+
const local = parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1';
|
|
18
|
+
if (parsed.protocol !== 'https:' && !(local && parsed.protocol === 'http:')) {
|
|
19
|
+
throw new Error(`${name} must use HTTPS`);
|
|
20
|
+
}
|
|
21
|
+
return parsed.toString().replace(/\/$/, '');
|
|
22
|
+
}
|
|
23
|
+
function integer(value, fallback, minimum, maximum) {
|
|
24
|
+
const parsed = value === undefined ? fallback : Number(value);
|
|
25
|
+
if (!Number.isInteger(parsed) || parsed < minimum || parsed > maximum) {
|
|
26
|
+
throw new Error(`Expected an integer from ${minimum} to ${maximum}`);
|
|
27
|
+
}
|
|
28
|
+
return parsed;
|
|
29
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { createClient } from '@supabase/supabase-js';
|
|
2
|
+
import { Automations } from '../src/automations.js';
|
|
3
|
+
import { AutomationCrudService } from '../src/crud.js';
|
|
4
|
+
import { createAutomationApi } from '../src/http.js';
|
|
5
|
+
import { createMachineRuns } from '../src/machine.js';
|
|
6
|
+
import { createMachineRunsApi } from '../src/machine-http.js';
|
|
7
|
+
import { SupabaseAutomationCrudRepository } from '../src/supabase-crud.js';
|
|
8
|
+
import { SupabaseMachineRunRepository } from '../src/supabase-machine.js';
|
|
9
|
+
import { SupabaseStore } from '../src/supabase-store.js';
|
|
10
|
+
import { createAutomationsAuthenticators } from './auth.js';
|
|
11
|
+
import { automationsHostConfig } from './config.js';
|
|
12
|
+
import { createAutomationsHost } from './server.js';
|
|
13
|
+
const config = automationsHostConfig();
|
|
14
|
+
const supabase = createClient(config.supabaseUrl, config.supabaseServiceRoleKey, {
|
|
15
|
+
auth: { persistSession: false, autoRefreshToken: false },
|
|
16
|
+
});
|
|
17
|
+
const authentication = createAutomationsAuthenticators({
|
|
18
|
+
issuer: config.authorizationIssuer,
|
|
19
|
+
supabase,
|
|
20
|
+
});
|
|
21
|
+
const delivery = new Automations(new SupabaseStore(supabase), {
|
|
22
|
+
isOnline: () => false,
|
|
23
|
+
send: async () => false,
|
|
24
|
+
}, (event, details) => log(event, details));
|
|
25
|
+
const controlApi = createAutomationApi({
|
|
26
|
+
service: new AutomationCrudService(new SupabaseAutomationCrudRepository(supabase)),
|
|
27
|
+
authenticate: authentication.control,
|
|
28
|
+
});
|
|
29
|
+
const machineRepository = new SupabaseMachineRunRepository(supabase);
|
|
30
|
+
const machineApi = createMachineRunsApi({
|
|
31
|
+
authenticate: authentication.machine,
|
|
32
|
+
runsFor: (principal) => createMachineRuns(machineRepository, principal),
|
|
33
|
+
});
|
|
34
|
+
const host = createAutomationsHost({
|
|
35
|
+
controlApi,
|
|
36
|
+
machineApi,
|
|
37
|
+
fireSchedules: () => delivery.fireDueCrons(),
|
|
38
|
+
schedulerIntervalMs: config.schedulerIntervalMs,
|
|
39
|
+
log,
|
|
40
|
+
});
|
|
41
|
+
host.server.listen(config.port, '0.0.0.0', () => log('host.ready', { port: config.port }));
|
|
42
|
+
for (const signal of ['SIGINT', 'SIGTERM']) {
|
|
43
|
+
process.once(signal, () => void host.close().finally(() => process.exit(0)));
|
|
44
|
+
}
|
|
45
|
+
function log(event, details = {}) {
|
|
46
|
+
console.log(JSON.stringify({ service: 'amalgm-automations', event, ...details }));
|
|
47
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type IncomingMessage, type ServerResponse } from 'node:http';
|
|
2
|
+
export declare function createAutomationsHost(options: {
|
|
3
|
+
readonly controlApi: (request: Request) => Promise<Response>;
|
|
4
|
+
readonly machineApi: (request: Request) => Promise<Response>;
|
|
5
|
+
readonly fireSchedules: () => Promise<unknown>;
|
|
6
|
+
readonly schedulerIntervalMs: number;
|
|
7
|
+
readonly log?: (event: string, details?: Readonly<Record<string, unknown>>) => void;
|
|
8
|
+
}): {
|
|
9
|
+
server: import("node:http").Server<typeof IncomingMessage, typeof ServerResponse>;
|
|
10
|
+
close(): Promise<void>;
|
|
11
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
export function createAutomationsHost(options) {
|
|
3
|
+
const log = options.log ?? (() => { });
|
|
4
|
+
let scheduling = null;
|
|
5
|
+
const tick = () => {
|
|
6
|
+
if (scheduling)
|
|
7
|
+
return;
|
|
8
|
+
scheduling = options.fireSchedules()
|
|
9
|
+
.catch((error) => log('scheduler.failed', { error: safe(error) }))
|
|
10
|
+
.finally(() => { scheduling = null; });
|
|
11
|
+
};
|
|
12
|
+
const timer = setInterval(tick, options.schedulerIntervalMs);
|
|
13
|
+
timer.unref();
|
|
14
|
+
tick();
|
|
15
|
+
const server = createServer(async (incoming, outgoing) => {
|
|
16
|
+
try {
|
|
17
|
+
if (incoming.url === '/healthz')
|
|
18
|
+
return send(outgoing, Response.json({ ok: true }));
|
|
19
|
+
const request = await webRequest(incoming);
|
|
20
|
+
const api = new URL(request.url).pathname.startsWith('/v1/machine/')
|
|
21
|
+
? options.machineApi : options.controlApi;
|
|
22
|
+
await send(outgoing, await api(request));
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
log('request.failed', { error: safe(error) });
|
|
26
|
+
await send(outgoing, Response.json({ error: 'Automations service failed' }, { status: 500 }));
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
return {
|
|
30
|
+
server,
|
|
31
|
+
async close() {
|
|
32
|
+
clearInterval(timer);
|
|
33
|
+
await scheduling;
|
|
34
|
+
await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
async function webRequest(request) {
|
|
39
|
+
const chunks = [];
|
|
40
|
+
for await (const chunk of request)
|
|
41
|
+
chunks.push(Buffer.from(chunk));
|
|
42
|
+
const body = Buffer.concat(chunks);
|
|
43
|
+
return new Request(new URL(request.url ?? '/', `http://${request.headers.host ?? '127.0.0.1'}`), {
|
|
44
|
+
method: request.method ?? 'GET',
|
|
45
|
+
headers: request.headers,
|
|
46
|
+
...(body.length ? { body } : {}),
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
async function send(response, source) {
|
|
50
|
+
response.writeHead(source.status, Object.fromEntries(source.headers));
|
|
51
|
+
response.end(Buffer.from(await source.arrayBuffer()));
|
|
52
|
+
}
|
|
53
|
+
const safe = (error) => (error instanceof Error ? error.message : String(error)).slice(0, 500);
|
package/dist/src/automations.js
CHANGED
|
@@ -46,13 +46,16 @@ export class Automations {
|
|
|
46
46
|
const created = [];
|
|
47
47
|
for (const trigger of await this.store.dueCronTriggers(now)) {
|
|
48
48
|
let scheduledFor = trigger.nextRunAt;
|
|
49
|
-
|
|
49
|
+
let remaining = trigger.remainingOccurrences;
|
|
50
|
+
while (new Date(scheduledFor) <= now && (remaining === undefined || remaining > 0)) {
|
|
50
51
|
const nextRunAt = nextCronAt(trigger.cron, trigger.timezone, scheduledFor);
|
|
51
52
|
const run = await this.store.enqueueCron({ ...trigger, nextRunAt: scheduledFor }, nextRunAt, now);
|
|
52
53
|
if (!run)
|
|
53
54
|
break;
|
|
54
55
|
created.push(run);
|
|
55
56
|
scheduledFor = nextRunAt;
|
|
57
|
+
if (remaining !== undefined)
|
|
58
|
+
remaining -= 1;
|
|
56
59
|
}
|
|
57
60
|
}
|
|
58
61
|
await this.#drainCreated(created);
|
package/dist/src/client.d.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import type { AutomationCrud } from './contract.js';
|
|
2
2
|
export type AutomationAuthorization = () => string | undefined | Promise<string | undefined>;
|
|
3
|
+
export type AutomationHeaders = (method: string, url: string) => Readonly<Record<string, string>> | Promise<Readonly<Record<string, string>>>;
|
|
3
4
|
export declare function createAutomationClient(options: {
|
|
4
5
|
baseUrl: string;
|
|
5
|
-
authorization
|
|
6
|
+
authorization?: AutomationAuthorization;
|
|
7
|
+
headers?: AutomationHeaders;
|
|
6
8
|
fetch?: typeof globalThis.fetch;
|
|
7
9
|
}): AutomationCrud;
|
package/dist/src/client.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { AutomationError } from './errors.js';
|
|
2
2
|
export function createAutomationClient(options) {
|
|
3
3
|
const baseUrl = options.baseUrl.replace(/\/$/, '');
|
|
4
|
-
const request = createRequester(baseUrl, options.authorization, options.fetch || globalThis.fetch);
|
|
4
|
+
const request = createRequester(baseUrl, options.authorization, options.headers, options.fetch || globalThis.fetch);
|
|
5
5
|
return {
|
|
6
6
|
automations: {
|
|
7
7
|
create: (input) => request('/v1/automations', 'POST', input),
|
|
@@ -39,15 +39,17 @@ export function createAutomationClient(options) {
|
|
|
39
39
|
},
|
|
40
40
|
};
|
|
41
41
|
}
|
|
42
|
-
function createRequester(baseUrl, authorization, fetch) {
|
|
42
|
+
function createRequester(baseUrl, authorization, additionalHeaders, fetch) {
|
|
43
43
|
return async (path, method, body, nullable = false) => {
|
|
44
|
-
const
|
|
45
|
-
const
|
|
44
|
+
const url = `${baseUrl}${path}`;
|
|
45
|
+
const token = await authorization?.();
|
|
46
|
+
const response = await fetch(url, {
|
|
46
47
|
method,
|
|
47
48
|
headers: {
|
|
48
49
|
accept: 'application/json',
|
|
49
50
|
...(body === undefined ? {} : { 'content-type': 'application/json' }),
|
|
50
51
|
...(token ? { authorization: token } : {}),
|
|
52
|
+
...await additionalHeaders?.(method, url),
|
|
51
53
|
},
|
|
52
54
|
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
53
55
|
});
|
package/dist/src/contract.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export type Json = null | boolean | number | string | Json[] | {
|
|
2
2
|
[key: string]: Json;
|
|
3
3
|
};
|
|
4
|
-
export type AutomationScope = 'automations:read' | 'automations:write' | 'runs:read' | '*';
|
|
4
|
+
export type AutomationScope = 'automations:read' | 'automations:write' | 'runs:read' | 'runs:execute' | '*';
|
|
5
5
|
/** Identity resolved by Amalgm before the SDK is bound to a caller. */
|
|
6
6
|
export interface AutomationPrincipal {
|
|
7
7
|
userId: string;
|
|
@@ -57,6 +57,9 @@ export interface ScheduleTrigger extends TriggerBase {
|
|
|
57
57
|
kind: 'schedule';
|
|
58
58
|
cron: string;
|
|
59
59
|
timezone: string;
|
|
60
|
+
nextRunAt: string;
|
|
61
|
+
maxOccurrences?: number;
|
|
62
|
+
remainingOccurrences?: number;
|
|
60
63
|
}
|
|
61
64
|
export interface WebhookTrigger extends TriggerBase {
|
|
62
65
|
kind: 'webhook';
|
|
@@ -70,11 +73,24 @@ export interface CreateScheduleTrigger {
|
|
|
70
73
|
cron: string;
|
|
71
74
|
timezone?: string;
|
|
72
75
|
enabled?: boolean;
|
|
76
|
+
/** Omit for an unbounded schedule. */
|
|
77
|
+
maxOccurrences?: number;
|
|
73
78
|
}
|
|
74
79
|
export interface UpdateScheduleTrigger {
|
|
75
80
|
cron?: string;
|
|
76
81
|
timezone?: string;
|
|
77
82
|
enabled?: boolean;
|
|
83
|
+
/** Set a new finite budget, or null to make the schedule unbounded. */
|
|
84
|
+
maxOccurrences?: number | null;
|
|
85
|
+
}
|
|
86
|
+
export interface ToolActionStep {
|
|
87
|
+
id: string;
|
|
88
|
+
actionId: string;
|
|
89
|
+
input: Json;
|
|
90
|
+
}
|
|
91
|
+
export interface AutomationPlan {
|
|
92
|
+
version: 1;
|
|
93
|
+
steps: ToolActionStep[];
|
|
78
94
|
}
|
|
79
95
|
export interface CreateWebhookTrigger {
|
|
80
96
|
id?: string;
|