@fungi.computer/braid 0.1.0 → 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/dist/code-execution.js +2 -0
- package/dist/git-access.d.ts +1 -1
- package/dist/git-access.js +1 -1
- package/dist/runtime/git-pass-contract.d.ts +21 -0
- package/dist/runtime/git-pass-contract.js +24 -0
- package/dist/runtime/git-pass-store.d.ts +16 -13
- package/dist/runtime/git-pass-store.js +126 -50
- package/dist/runtime/git-pass.d.ts +3 -4
- package/dist/runtime/git-pass.js +3 -17
- package/dist/runtime/graph-contract.d.ts +1 -1
- package/dist/runtime/ports.d.ts +8 -1
- package/dist/runtime/run-ceiling.d.ts +1 -1
- package/dist/runtime/run-owner.d.ts +67 -4
- package/dist/runtime/run-owner.js +25 -10
- package/dist/runtime/schedules.d.ts +1 -0
- package/dist/runtime/schedules.js +1 -0
- package/dist/runtime/trigger-owner.d.ts +2 -2
- package/dist/runtime/trigger-owner.js +2 -2
- package/dist/runtime/trigger-run-reservations.d.ts +65 -1
- package/dist/runtime/trigger-run-reservations.js +119 -0
- package/dist/workflow-id.d.ts +1 -1
- package/dist/workflow-id.js +1 -1
- package/package.json +8 -8
package/dist/code-execution.js
CHANGED
package/dist/git-access.d.ts
CHANGED
package/dist/git-access.js
CHANGED
|
@@ -1,5 +1,26 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import type { GitWriter } from "@fungi.computer/agar/git";
|
|
3
|
+
/** One credential grammar for both hosts; the owner is public, the token is not. */
|
|
4
|
+
export declare const workflowGitPassPrefix = "wgp.";
|
|
5
|
+
export declare const workflowGitPassTokenSchema: z.ZodPipe<z.ZodString, z.ZodTransform<{
|
|
6
|
+
ownerId: string;
|
|
7
|
+
token: string;
|
|
8
|
+
}, string>>;
|
|
9
|
+
/** Format the existing standalone bearer shape, never a credential-bearing URL. */
|
|
10
|
+
export declare function formatWorkflowGitPassToken(ownerId: string, token: string): string;
|
|
11
|
+
/** Returned once to the native dispatcher, never to an App or a durable record. */
|
|
12
|
+
export type WorkflowGitCredential = Readonly<{
|
|
13
|
+
bearer: string;
|
|
14
|
+
remote: string;
|
|
15
|
+
expiresAt: number;
|
|
16
|
+
}>;
|
|
17
|
+
/** Git reads this command-local config without writing a credential or repository config. */
|
|
18
|
+
export declare function workflowGitExecutionEnvironment(credential: WorkflowGitCredential): {
|
|
19
|
+
FUNGI_GIT_REMOTE: string;
|
|
20
|
+
GIT_CONFIG_COUNT: string;
|
|
21
|
+
GIT_CONFIG_KEY_0: string;
|
|
22
|
+
GIT_CONFIG_VALUE_0: string;
|
|
23
|
+
};
|
|
3
24
|
/** Non-secret Team-issued authority for one stored Workflow step on its pinned Computer. */
|
|
4
25
|
export declare const workflowGitPassGrantSchema: z.ZodLazy<z.ZodObject<{
|
|
5
26
|
teamId: z.ZodString;
|
|
@@ -2,6 +2,30 @@ import { z } from "zod";
|
|
|
2
2
|
import { workflowAttestationSchema } from "@fungi.computer/puffball/app-method-runtime";
|
|
3
3
|
import { gitAccessScope, resolvedGitAccessSchema } from "../git-access.js";
|
|
4
4
|
const id = z.string().min(1).max(256);
|
|
5
|
+
/** One credential grammar for both hosts; the owner is public, the token is not. */
|
|
6
|
+
export const workflowGitPassPrefix = "wgp.";
|
|
7
|
+
export const workflowGitPassTokenSchema = z
|
|
8
|
+
.string()
|
|
9
|
+
.regex(/^wgp\.([a-zA-Z0-9_-]{1,256})\.([0-9a-f]{64})$/u)
|
|
10
|
+
.transform((bearer) => {
|
|
11
|
+
const [, ownerId = "", token = ""] = bearer.split(".");
|
|
12
|
+
return { ownerId, token };
|
|
13
|
+
});
|
|
14
|
+
/** Format the existing standalone bearer shape, never a credential-bearing URL. */
|
|
15
|
+
export function formatWorkflowGitPassToken(ownerId, token) {
|
|
16
|
+
const bearer = `${workflowGitPassPrefix}${ownerId}.${token}`;
|
|
17
|
+
workflowGitPassTokenSchema.parse(bearer);
|
|
18
|
+
return bearer;
|
|
19
|
+
}
|
|
20
|
+
/** Git reads this command-local config without writing a credential or repository config. */
|
|
21
|
+
export function workflowGitExecutionEnvironment(credential) {
|
|
22
|
+
return {
|
|
23
|
+
FUNGI_GIT_REMOTE: credential.remote,
|
|
24
|
+
GIT_CONFIG_COUNT: "1",
|
|
25
|
+
GIT_CONFIG_KEY_0: `http.${credential.remote}.extraHeader`,
|
|
26
|
+
GIT_CONFIG_VALUE_0: `Authorization: Bearer ${credential.bearer}`,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
5
29
|
/** Non-secret Team-issued authority for one stored Workflow step on its pinned Computer. */
|
|
6
30
|
export const workflowGitPassGrantSchema = z.lazy(() => z
|
|
7
31
|
.strictObject({
|
|
@@ -1,32 +1,35 @@
|
|
|
1
1
|
import { type RepositoryRef } from "@fungi.computer/agar/git";
|
|
2
2
|
import type { WatchdogSqliteOwner } from "@fungi.computer/watchdog";
|
|
3
|
-
import { type WorkflowGitPassGrant } from "./git-pass-contract.js";
|
|
4
|
-
/**
|
|
3
|
+
import { type WorkflowGitCredential, type WorkflowGitPassGrant } from "./git-pass-contract.js";
|
|
4
|
+
/** One fresh credential per newly admitted native invocation; only its digest is durable. */
|
|
5
5
|
export declare function createWorkflowGitPassStore(options: Readonly<{
|
|
6
6
|
sql: WatchdogSqliteOwner["sql"];
|
|
7
7
|
transactionSync: <T>(body: () => T) => T;
|
|
8
8
|
ownerId(): string | null;
|
|
9
9
|
computerId(): string | undefined;
|
|
10
|
-
|
|
11
|
-
remote(
|
|
10
|
+
mintToken?: () => string;
|
|
11
|
+
remote(repository: RepositoryRef): string;
|
|
12
12
|
denied(): Error;
|
|
13
13
|
now?: () => number;
|
|
14
14
|
}>): Readonly<{
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
15
|
+
/** Only the NEW native-admission path calls this; replay never obtains another secret. */
|
|
16
|
+
issue(rawInvocationId: string, rawGrant: WorkflowGitPassGrant): Promise<WorkflowGitCredential>;
|
|
17
|
+
/** A live native replay can extend its existing lease, never issue or resurrect it. */
|
|
18
|
+
renew(rawInvocationId: string, rawGrant: WorkflowGitPassGrant): void;
|
|
19
|
+
/** Keep the public receipt so a delayed issuer cannot reopen the same invocation. */
|
|
20
|
+
close(rawInvocationId: string, rawGrant: WorkflowGitPassGrant): void;
|
|
21
21
|
/** Retain a tombstone so a delayed mint cannot resurrect a terminal run. */
|
|
22
22
|
revokeWorkflowRunGitPasses(input: {
|
|
23
23
|
teamId: string;
|
|
24
24
|
runId: string;
|
|
25
25
|
}): void;
|
|
26
|
-
/**
|
|
26
|
+
/** Every request also needs the host's live native-invocation and domain admission. */
|
|
27
27
|
admit(input: {
|
|
28
|
-
|
|
28
|
+
bearer: string;
|
|
29
29
|
repository: RepositoryRef;
|
|
30
30
|
scope: "read" | "write";
|
|
31
|
-
}):
|
|
31
|
+
}): Promise<{
|
|
32
|
+
invocationId: string;
|
|
33
|
+
grant: WorkflowGitPassGrant;
|
|
34
|
+
} | null>;
|
|
32
35
|
}>;
|
|
@@ -1,55 +1,124 @@
|
|
|
1
1
|
import { repositoryRefsEqual, } from "@fungi.computer/agar/git";
|
|
2
|
-
import { workflowGitPassResultSchema } from "@fungi.computer/agar/computer-contracts";
|
|
3
2
|
import { z } from "zod";
|
|
4
|
-
import {
|
|
5
|
-
|
|
3
|
+
import { canonicalJson } from "../index.js";
|
|
4
|
+
import { formatWorkflowGitPassToken, workflowGitPassTokenSchema, workflowGitPassGrantSchema, workflowGitPassRevocationSchema, } from "./git-pass-contract.js";
|
|
5
|
+
const invocationIdSchema = z.string().min(1).max(256);
|
|
6
|
+
const tokenSchema = z.string().regex(/^[0-9a-f]{64}$/u);
|
|
7
|
+
const leaseSchema = z.strictObject({
|
|
8
|
+
invocation_id: invocationIdSchema,
|
|
9
|
+
run_id: z.string(),
|
|
10
|
+
grant_json: z.string(),
|
|
11
|
+
token_digest: z.string().nullable(),
|
|
12
|
+
expires_at: z.number().int(),
|
|
13
|
+
state: z.enum(["open", "closed"]),
|
|
14
|
+
});
|
|
15
|
+
const remoteSchema = z
|
|
16
|
+
.url({ protocol: /^https$/, abort: true })
|
|
17
|
+
.refine((value) => {
|
|
18
|
+
const url = new URL(value);
|
|
19
|
+
return url.username === "" && url.password === "";
|
|
20
|
+
}, "Workflow Git remotes must not contain credentials");
|
|
21
|
+
function mintToken() {
|
|
22
|
+
return Array.from(crypto.getRandomValues(new Uint8Array(32)), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
23
|
+
}
|
|
24
|
+
/** A verifier digest, never a native invocation identity or replay hash. */
|
|
25
|
+
async function tokenDigest(token) {
|
|
26
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(token));
|
|
27
|
+
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
28
|
+
}
|
|
29
|
+
/** Optional internal fields use JSON's omission semantics at the storage boundary. */
|
|
30
|
+
function serializeGrant(grant) {
|
|
31
|
+
return canonicalJson(z.json().parse(JSON.parse(JSON.stringify(grant))));
|
|
32
|
+
}
|
|
33
|
+
/** One fresh credential per newly admitted native invocation; only its digest is durable. */
|
|
6
34
|
export function createWorkflowGitPassStore(options) {
|
|
7
35
|
const { sql } = options;
|
|
8
36
|
const now = options.now ?? Date.now;
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
37
|
+
// Pre-release reusable, plaintext pass state is disposable, not another reader.
|
|
38
|
+
sql.exec("DROP TABLE IF EXISTS computer_workflow_git_passes");
|
|
39
|
+
sql.exec(`CREATE TABLE IF NOT EXISTS computer_workflow_git_invocations (
|
|
40
|
+
invocation_id TEXT PRIMARY KEY, run_id TEXT NOT NULL,
|
|
41
|
+
grant_json TEXT NOT NULL, token_digest TEXT UNIQUE,
|
|
42
|
+
expires_at INTEGER NOT NULL,
|
|
43
|
+
state TEXT NOT NULL CHECK (state IN ('open', 'closed'))
|
|
12
44
|
) STRICT`);
|
|
13
45
|
sql.exec(`CREATE TABLE IF NOT EXISTS computer_workflow_git_ended_runs (run_id TEXT PRIMARY KEY) STRICT`);
|
|
46
|
+
function admittedGrant(raw) {
|
|
47
|
+
const grant = workflowGitPassGrantSchema.parse(raw);
|
|
48
|
+
if (grant.teamId !== options.ownerId() ||
|
|
49
|
+
grant.computerId !== options.computerId() ||
|
|
50
|
+
grant.workflow.runId !== grant.runId ||
|
|
51
|
+
grant.workflow.nodeId !== grant.nodeId ||
|
|
52
|
+
grant.workflow.armedBy.kind !== "member" ||
|
|
53
|
+
grant.workflow.armedBy.memberId !== grant.memberId)
|
|
54
|
+
throw options.denied();
|
|
55
|
+
return grant;
|
|
56
|
+
}
|
|
57
|
+
function ended(runId) {
|
|
58
|
+
return (sql
|
|
59
|
+
.exec("SELECT 1 FROM computer_workflow_git_ended_runs WHERE run_id=?", runId)
|
|
60
|
+
.toArray().length !== 0);
|
|
61
|
+
}
|
|
62
|
+
function read(invocationId) {
|
|
63
|
+
const row = sql
|
|
64
|
+
.exec("SELECT * FROM computer_workflow_git_invocations WHERE invocation_id=?", invocationId)
|
|
65
|
+
.toArray()[0];
|
|
66
|
+
return row === undefined ? undefined : leaseSchema.parse(row);
|
|
67
|
+
}
|
|
14
68
|
return Object.freeze({
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
grant.workflow.nodeId !== grant.nodeId ||
|
|
21
|
-
grant.workflow.armedBy.kind !== "member" ||
|
|
22
|
-
grant.workflow.armedBy.memberId !== grant.memberId)
|
|
69
|
+
/** Only the NEW native-admission path calls this; replay never obtains another secret. */
|
|
70
|
+
async issue(rawInvocationId, rawGrant) {
|
|
71
|
+
const invocationId = invocationIdSchema.parse(rawInvocationId);
|
|
72
|
+
const grant = admittedGrant(rawGrant);
|
|
73
|
+
if (ended(grant.runId) || read(invocationId) !== undefined)
|
|
23
74
|
throw options.denied();
|
|
75
|
+
const remote = remoteSchema.parse(options.remote(grant.gitAccess.repository));
|
|
76
|
+
const token = tokenSchema.parse((options.mintToken ?? mintToken)());
|
|
77
|
+
const bearer = formatWorkflowGitPassToken(grant.teamId, token);
|
|
78
|
+
const digest = await tokenDigest(token);
|
|
24
79
|
return options.transactionSync(() => {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
throw options.denied();
|
|
29
|
-
const key = JSON.stringify([
|
|
30
|
-
grant.runId,
|
|
31
|
-
grant.graphId ?? null,
|
|
32
|
-
grant.nodeId,
|
|
33
|
-
grant.iteration,
|
|
34
|
-
grant.scope,
|
|
35
|
-
]);
|
|
36
|
-
const row = sql
|
|
37
|
-
.exec("SELECT pass_id,grant_json,expires_at FROM computer_workflow_git_passes WHERE node_key=?", key)
|
|
38
|
-
.toArray()[0];
|
|
39
|
-
if (row !== undefined &&
|
|
40
|
-
z.string().parse(row.grant_json) !== JSON.stringify(grant))
|
|
80
|
+
// The digest await can race a terminal revocation or another issuer.
|
|
81
|
+
admittedGrant(grant);
|
|
82
|
+
if (ended(grant.runId) || read(invocationId) !== undefined)
|
|
41
83
|
throw options.denied();
|
|
42
|
-
const passId = row === undefined
|
|
43
|
-
? options.mintPassId()
|
|
44
|
-
: z.string().parse(row.pass_id);
|
|
45
84
|
const expiresAt = now() + 60_000;
|
|
46
|
-
sql.exec(
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
85
|
+
sql.exec("INSERT INTO computer_workflow_git_invocations VALUES(?,?,?,?,?,'open')", invocationId, grant.runId, serializeGrant(grant), digest, expiresAt);
|
|
86
|
+
return { bearer, remote, expiresAt };
|
|
87
|
+
});
|
|
88
|
+
},
|
|
89
|
+
/** A live native replay can extend its existing lease, never issue or resurrect it. */
|
|
90
|
+
renew(rawInvocationId, rawGrant) {
|
|
91
|
+
const invocationId = invocationIdSchema.parse(rawInvocationId);
|
|
92
|
+
const grant = admittedGrant(rawGrant);
|
|
93
|
+
options.transactionSync(() => {
|
|
94
|
+
const row = read(invocationId);
|
|
95
|
+
if (row?.state !== "open" ||
|
|
96
|
+
row.expires_at <= now() ||
|
|
97
|
+
row.grant_json !== serializeGrant(grant) ||
|
|
98
|
+
ended(grant.runId))
|
|
99
|
+
throw options.denied();
|
|
100
|
+
sql.exec("UPDATE computer_workflow_git_invocations SET expires_at=? WHERE invocation_id=?", now() + 60_000, invocationId);
|
|
101
|
+
});
|
|
102
|
+
},
|
|
103
|
+
/** Keep the public receipt so a delayed issuer cannot reopen the same invocation. */
|
|
104
|
+
close(rawInvocationId, rawGrant) {
|
|
105
|
+
const invocationId = invocationIdSchema.parse(rawInvocationId);
|
|
106
|
+
options.transactionSync(() => {
|
|
107
|
+
const row = read(invocationId);
|
|
108
|
+
if (row !== undefined) {
|
|
109
|
+
// Revocation never depends on current authority or reconstructed
|
|
110
|
+
// metadata still being sufficient to admit a new command.
|
|
111
|
+
sql.exec("UPDATE computer_workflow_git_invocations SET state='closed',token_digest=NULL WHERE invocation_id=?", invocationId);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
const grant = workflowGitPassGrantSchema.parse(rawGrant);
|
|
115
|
+
// The run tombstone already prevents delayed issuance. Do not recreate
|
|
116
|
+
// receipts after terminal cleanup has deleted the run's invocation rows.
|
|
117
|
+
if (ended(grant.runId))
|
|
118
|
+
return;
|
|
119
|
+
// Close can beat the issue RPC or its digest await. Retain the same
|
|
120
|
+
// public identity either way, so neither can publish a later credential.
|
|
121
|
+
sql.exec("INSERT INTO computer_workflow_git_invocations VALUES(?,?,?,NULL,0,'closed')", invocationId, grant.runId, serializeGrant(grant));
|
|
53
122
|
});
|
|
54
123
|
},
|
|
55
124
|
/** Retain a tombstone so a delayed mint cannot resurrect a terminal run. */
|
|
@@ -59,24 +128,31 @@ export function createWorkflowGitPassStore(options) {
|
|
|
59
128
|
throw options.denied();
|
|
60
129
|
options.transactionSync(() => {
|
|
61
130
|
sql.exec("INSERT OR IGNORE INTO computer_workflow_git_ended_runs VALUES(?)", runId);
|
|
62
|
-
sql.exec("DELETE FROM
|
|
131
|
+
sql.exec("DELETE FROM computer_workflow_git_invocations WHERE run_id=?", runId);
|
|
63
132
|
});
|
|
64
133
|
},
|
|
65
|
-
/**
|
|
66
|
-
admit(input) {
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
134
|
+
/** Every request also needs the host's live native-invocation and domain admission. */
|
|
135
|
+
async admit(input) {
|
|
136
|
+
const parsed = workflowGitPassTokenSchema.safeParse(input.bearer);
|
|
137
|
+
if (!parsed.success || parsed.data.ownerId !== options.ownerId())
|
|
138
|
+
return null;
|
|
139
|
+
const digest = await tokenDigest(parsed.data.token);
|
|
140
|
+
const raw = sql
|
|
141
|
+
.exec("SELECT * FROM computer_workflow_git_invocations WHERE token_digest=?", digest)
|
|
70
142
|
.toArray()[0];
|
|
71
|
-
if (
|
|
143
|
+
if (raw === undefined)
|
|
144
|
+
return null;
|
|
145
|
+
const row = leaseSchema.parse(raw);
|
|
146
|
+
if (row.state !== "open" || row.expires_at <= now())
|
|
72
147
|
return null;
|
|
73
|
-
const grant = workflowGitPassGrantSchema.parse(JSON.parse(
|
|
148
|
+
const grant = workflowGitPassGrantSchema.parse(JSON.parse(row.grant_json));
|
|
74
149
|
if (grant.teamId !== options.ownerId() ||
|
|
75
150
|
grant.computerId !== options.computerId() ||
|
|
76
151
|
!repositoryRefsEqual(grant.gitAccess.repository, input.repository) ||
|
|
77
|
-
(input.scope === "write" && grant.scope !== "write")
|
|
152
|
+
(input.scope === "write" && grant.scope !== "write") ||
|
|
153
|
+
ended(grant.runId))
|
|
78
154
|
return null;
|
|
79
|
-
return grant;
|
|
155
|
+
return { invocationId: row.invocation_id, grant };
|
|
80
156
|
},
|
|
81
157
|
});
|
|
82
158
|
}
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import type { RepositoryRef } from "@fungi.computer/agar/git";
|
|
2
|
-
import { type WorkflowGitPassResult } from "@fungi.computer/agar/computer-contracts";
|
|
3
2
|
import type { WorkflowRunContext } from "./graph-contract.js";
|
|
4
3
|
import type { StoredWorkflowNode } from "./graph-dispatch.js";
|
|
5
4
|
import { type WorkflowGitPassGrant } from "./git-pass-contract.js";
|
|
@@ -19,7 +18,6 @@ export declare function createWorkflowGitPasses(ports: Readonly<{
|
|
|
19
18
|
method: string;
|
|
20
19
|
}): WorkflowGitPassNode | null;
|
|
21
20
|
admitMemberGitAccess(memberId: string, repository: RepositoryRef, scope: WorkflowGitScope): Promise<void>;
|
|
22
|
-
open(computerId: string, grant: WorkflowGitPassGrant): Promise<WorkflowGitPassResult>;
|
|
23
21
|
revoke(computerId: string, input: {
|
|
24
22
|
teamId: string;
|
|
25
23
|
runId: string;
|
|
@@ -33,9 +31,10 @@ export declare function createWorkflowGitPasses(ports: Readonly<{
|
|
|
33
31
|
wake?(due: number | null): Promise<void>;
|
|
34
32
|
}>): {
|
|
35
33
|
cleanupWake: () => number | null;
|
|
36
|
-
|
|
34
|
+
/** Returns only public authority; native NEW admission owns credential issuance. */
|
|
35
|
+
admitWorkflowGit(context: WorkflowRunContext, target: {
|
|
37
36
|
installationId: string;
|
|
38
37
|
method: string;
|
|
39
|
-
}): Promise<
|
|
38
|
+
}): Promise<WorkflowGitPassGrant>;
|
|
40
39
|
deliverCleanups(): Promise<void>;
|
|
41
40
|
};
|
package/dist/runtime/git-pass.js
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { workflowGitPassResultSchema, } from "@fungi.computer/agar/computer-contracts";
|
|
2
1
|
import { canonicalJson } from "../index.js";
|
|
3
2
|
import { gitAccessScope } from "../git-access.js";
|
|
4
3
|
import { workflowGitPassGrantSchema, } from "./git-pass-contract.js";
|
|
@@ -7,7 +6,8 @@ export function createWorkflowGitPasses(ports) {
|
|
|
7
6
|
const cleanupWake = () => ports.pending().length === 0 ? null : Date.now() + 5000;
|
|
8
7
|
return {
|
|
9
8
|
cleanupWake,
|
|
10
|
-
|
|
9
|
+
/** Returns only public authority; native NEW admission owns credential issuance. */
|
|
10
|
+
async admitWorkflowGit(context, target) {
|
|
11
11
|
const admitted = ports.admitNode(context, target);
|
|
12
12
|
if (admitted?.computerId == null ||
|
|
13
13
|
admitted.context.gitAccess === undefined ||
|
|
@@ -38,21 +38,7 @@ export function createWorkflowGitPasses(ports) {
|
|
|
38
38
|
if (canonicalJson(ports.admitNode(context, target)) !==
|
|
39
39
|
canonicalJson(admitted))
|
|
40
40
|
throw ports.denied();
|
|
41
|
-
|
|
42
|
-
try {
|
|
43
|
-
await ports.admitMemberGitAccess(grant.memberId, grant.gitAccess.repository, scope);
|
|
44
|
-
if (canonicalJson(ports.admitNode(context, target)) !==
|
|
45
|
-
canonicalJson(admitted))
|
|
46
|
-
throw ports.denied();
|
|
47
|
-
}
|
|
48
|
-
catch (cause) {
|
|
49
|
-
await ports.revoke(grant.computerId, {
|
|
50
|
-
teamId: grant.teamId,
|
|
51
|
-
runId: grant.runId,
|
|
52
|
-
});
|
|
53
|
-
throw cause;
|
|
54
|
-
}
|
|
55
|
-
return pass;
|
|
41
|
+
return grant;
|
|
56
42
|
},
|
|
57
43
|
async deliverCleanups() {
|
|
58
44
|
try {
|
|
@@ -416,7 +416,7 @@ export declare const graphRunSchema: S.Struct<{
|
|
|
416
416
|
readonly graphId: S.optionalKey<S.String>;
|
|
417
417
|
readonly nodeId: S.String;
|
|
418
418
|
readonly level: S.Literals<readonly ["read", "write"]>;
|
|
419
|
-
readonly capabilities: S.$Array<S.Literals<readonly ["agent.list", "agent.manage", "agent.rename", "agent.avatar", "agent.toolPolicy", "agent.read", "agent.send", "computer.list", "computer.readFile", "computer.writeFile", "computer.execute", "llm.generation", "media.generation", "games:servers", "git.repository", "apps.open", "apps.call", "app.storage", "app.events.emit", "team.workbenchHost", "marketplace.browse", "marketplace.purchase", "workflow.run", "workflow.publish", "workflow.arm", "world", "channels.read", "channels.personal.manage"]>>;
|
|
419
|
+
readonly capabilities: S.$Array<S.Literals<readonly ["approvals", "agent.list", "agent.manage", "agent.rename", "agent.avatar", "agent.toolPolicy", "agent.read", "agent.send", "computer.list", "computer.readFile", "computer.writeFile", "computer.execute", "llm.generation", "media.generation", "games:servers", "git.repository", "apps.open", "apps.call", "app.storage", "app.events.emit", "team.workbenchHost", "marketplace.browse", "marketplace.purchase", "workflow.run", "workflow.publish", "workflow.arm", "world", "channels.read", "channels.personal.manage"]>>;
|
|
420
420
|
readonly bindings: S.$Array<S.Union<S.Struct<{
|
|
421
421
|
readonly name: S.Literal<"BACKGROUND" | "GIT" | "EVENTS" | "APPS" | "WORKFLOWS" | "APP_STORAGE" | "MEDIA" | "COMPUTER" | "INBOX" | "AGENTS">;
|
|
422
422
|
readonly operations: S.$Array<S.Literals<string[]>>;
|
package/dist/runtime/ports.d.ts
CHANGED
|
@@ -145,10 +145,17 @@ export type WorkflowTriggerRunPorts = Readonly<{
|
|
|
145
145
|
startCachedGraph(binding: import("./trigger-owner.js").ArmedWorkflowTrigger, request: Pick<import("./graph-start.js").GraphStartRequest, "requestId" | "input"> & {
|
|
146
146
|
/** The firing event's repository, evaluated from the armed trigger. */
|
|
147
147
|
triggerRepository?: string;
|
|
148
|
-
}
|
|
148
|
+
},
|
|
149
|
+
/** Host-private binding check, evaluated with the durable run write. */
|
|
150
|
+
admission?: WorkflowTriggerAdmission): Promise<Exclude<GraphStartReply, {
|
|
149
151
|
kind: "invalid";
|
|
150
152
|
}>>;
|
|
151
153
|
}>;
|
|
154
|
+
/** Host-private checks and reservation identity, never supplied by a sender. */
|
|
155
|
+
export type WorkflowTriggerAdmission = Readonly<{
|
|
156
|
+
admitBinding(): boolean;
|
|
157
|
+
reservationId?: string;
|
|
158
|
+
}>;
|
|
152
159
|
/** Hosts retain their existing error vocabulary and denial classification. */
|
|
153
160
|
export type WorkflowErrorPort = Readonly<{
|
|
154
161
|
denied(): Error;
|
|
@@ -51,7 +51,7 @@ export declare const runCeilingSchema: S.Struct<{
|
|
|
51
51
|
readonly graphId: S.optionalKey<S.String>;
|
|
52
52
|
readonly nodeId: S.String;
|
|
53
53
|
readonly level: S.Literals<readonly ["read", "write"]>;
|
|
54
|
-
readonly capabilities: S.$Array<S.Literals<readonly ["agent.list", "agent.manage", "agent.rename", "agent.avatar", "agent.toolPolicy", "agent.read", "agent.send", "computer.list", "computer.readFile", "computer.writeFile", "computer.execute", "llm.generation", "media.generation", "games:servers", "git.repository", "apps.open", "apps.call", "app.storage", "app.events.emit", "team.workbenchHost", "marketplace.browse", "marketplace.purchase", "workflow.run", "workflow.publish", "workflow.arm", "world", "channels.read", "channels.personal.manage"]>>;
|
|
54
|
+
readonly capabilities: S.$Array<S.Literals<readonly ["approvals", "agent.list", "agent.manage", "agent.rename", "agent.avatar", "agent.toolPolicy", "agent.read", "agent.send", "computer.list", "computer.readFile", "computer.writeFile", "computer.execute", "llm.generation", "media.generation", "games:servers", "git.repository", "apps.open", "apps.call", "app.storage", "app.events.emit", "team.workbenchHost", "marketplace.browse", "marketplace.purchase", "workflow.run", "workflow.publish", "workflow.arm", "world", "channels.read", "channels.personal.manage"]>>;
|
|
55
55
|
readonly bindings: S.$Array<S.Union<S.Struct<{
|
|
56
56
|
readonly name: S.Literal<"BACKGROUND" | "GIT" | "EVENTS" | "APPS" | "WORKFLOWS" | "APP_STORAGE" | "MEDIA" | "COMPUTER" | "INBOX" | "AGENTS">;
|
|
57
57
|
readonly operations: S.$Array<S.Literals<string[]>>;
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import * as S from "effect/Schema";
|
|
2
2
|
import { type NodeEvent, type NodeAdmissionResult } from "../execution.js";
|
|
3
|
+
import { z } from "zod";
|
|
3
4
|
import type { WatchdogSqliteOwner } from "@fungi.computer/watchdog";
|
|
4
5
|
import { type WorkflowRunAuthority } from "./run-authority.js";
|
|
5
6
|
import type { ArmedWorkflowTrigger } from "./trigger-owner.js";
|
|
6
7
|
import { type WorkflowRunContext, type GraphRun, type WorkflowRunDelivery } from "./graph-contract.js";
|
|
7
8
|
import { type StoredWorkflowNode } from "./graph-dispatch.js";
|
|
8
|
-
import type { WorkflowAppCall, WorkflowAppCallResult, WorkflowCatalogEntry } from "./ports.js";
|
|
9
|
+
import type { WorkflowAppCall, WorkflowAppCallResult, WorkflowCatalogEntry, WorkflowTriggerAdmission } from "./ports.js";
|
|
9
10
|
import { type GraphStartRequest } from "./graph-start.js";
|
|
10
11
|
/** The member can supply a plan, never stored authority or a captured ceiling. */
|
|
11
12
|
export declare const memberCompiledGraphStartSchema: S.Struct<{
|
|
@@ -301,6 +302,8 @@ export declare function createTeamWorkflowRuns(options: {
|
|
|
301
302
|
sql: WatchdogSqliteOwner["sql"];
|
|
302
303
|
transactionSync: <T>(body: () => T) => T;
|
|
303
304
|
teamId: () => string;
|
|
305
|
+
/** Host-owned wall clock; deterministic native journeys may supply their clock. */
|
|
306
|
+
now?: () => number;
|
|
304
307
|
admission: () => "active" | "suspended";
|
|
305
308
|
errors: Readonly<{
|
|
306
309
|
app(code: "denied" | "invalid" | "unavailable"): Error;
|
|
@@ -655,7 +658,7 @@ export declare function createTeamWorkflowRuns(options: {
|
|
|
655
658
|
readonly operations: readonly string[];
|
|
656
659
|
}[];
|
|
657
660
|
readonly nodeId: string;
|
|
658
|
-
readonly capabilities: readonly ("agent.list" | "agent.manage" | "agent.rename" | "agent.avatar" | "agent.toolPolicy" | "agent.read" | "agent.send" | "computer.list" | "computer.readFile" | "computer.writeFile" | "computer.execute" | "llm.generation" | "media.generation" | "games:servers" | "git.repository" | "apps.open" | "apps.call" | "app.storage" | "app.events.emit" | "team.workbenchHost" | "marketplace.browse" | "marketplace.purchase" | "workflow.run" | "workflow.publish" | "workflow.arm" | "world" | "channels.read" | "channels.personal.manage")[];
|
|
661
|
+
readonly capabilities: readonly ("approvals" | "agent.list" | "agent.manage" | "agent.rename" | "agent.avatar" | "agent.toolPolicy" | "agent.read" | "agent.send" | "computer.list" | "computer.readFile" | "computer.writeFile" | "computer.execute" | "llm.generation" | "media.generation" | "games:servers" | "git.repository" | "apps.open" | "apps.call" | "app.storage" | "app.events.emit" | "team.workbenchHost" | "marketplace.browse" | "marketplace.purchase" | "workflow.run" | "workflow.publish" | "workflow.arm" | "world" | "channels.read" | "channels.personal.manage")[];
|
|
659
662
|
readonly graphId?: string;
|
|
660
663
|
}[];
|
|
661
664
|
readonly nodeGitAccess: readonly {
|
|
@@ -1555,7 +1558,7 @@ export declare function createTeamWorkflowRuns(options: {
|
|
|
1555
1558
|
}>;
|
|
1556
1559
|
startCachedGraph(binding: ArmedWorkflowTrigger, started: Pick<GraphStartRequest, "requestId" | "input"> & {
|
|
1557
1560
|
triggerRepository?: string;
|
|
1558
|
-
}): Promise<{
|
|
1561
|
+
}, admission?: WorkflowTriggerAdmission): Promise<{
|
|
1559
1562
|
kind: "conflict" | "started" | "replay";
|
|
1560
1563
|
runId: string;
|
|
1561
1564
|
planDigest: string;
|
|
@@ -1581,6 +1584,66 @@ export declare function createTeamWorkflowRuns(options: {
|
|
|
1581
1584
|
revokeReleaseInMutation(appIds: readonly string[]): void;
|
|
1582
1585
|
/** The kernel's lifecycle caller owns the transaction. Suspension ends every run. */
|
|
1583
1586
|
onLifecycle(status: "active" | "suspended"): void;
|
|
1587
|
+
reserveWebhookDelivery(raw: z.input<z.ZodObject<{
|
|
1588
|
+
scope: z.ZodLazy<z.ZodObject<{
|
|
1589
|
+
installationId: z.ZodString;
|
|
1590
|
+
workflowId: z.ZodString;
|
|
1591
|
+
commit: z.ZodString;
|
|
1592
|
+
triggerId: z.ZodString;
|
|
1593
|
+
}, z.core.$strict>>;
|
|
1594
|
+
delivery: z.ZodObject<{
|
|
1595
|
+
host: z.ZodString;
|
|
1596
|
+
tenant: z.ZodString;
|
|
1597
|
+
bindingId: z.ZodString;
|
|
1598
|
+
eventId: z.ZodString;
|
|
1599
|
+
}, z.core.$strict>;
|
|
1600
|
+
cargo: z.ZodJSONSchema;
|
|
1601
|
+
limits: z.ZodObject<{
|
|
1602
|
+
hostConcurrency: z.ZodNumber;
|
|
1603
|
+
bindingConcurrency: z.ZodNumber;
|
|
1604
|
+
}, z.core.$strict>;
|
|
1605
|
+
}, z.core.$strict>>): {
|
|
1606
|
+
kind: "reserved" | "replay";
|
|
1607
|
+
reservationId: string;
|
|
1608
|
+
runId: string | null;
|
|
1609
|
+
} | {
|
|
1610
|
+
kind: "refused";
|
|
1611
|
+
reason: "limit";
|
|
1612
|
+
};
|
|
1613
|
+
pendingWebhookDeliveries(rawOwner: z.input<z.ZodObject<{
|
|
1614
|
+
host: z.ZodString;
|
|
1615
|
+
tenant: z.ZodString;
|
|
1616
|
+
}, z.core.$strict>>): ({
|
|
1617
|
+
kind: "launch";
|
|
1618
|
+
reservationId: string;
|
|
1619
|
+
runId: string;
|
|
1620
|
+
} | {
|
|
1621
|
+
scope: {
|
|
1622
|
+
installationId: string;
|
|
1623
|
+
workflowId: string;
|
|
1624
|
+
commit: string;
|
|
1625
|
+
triggerId: string;
|
|
1626
|
+
};
|
|
1627
|
+
delivery: {
|
|
1628
|
+
host: string;
|
|
1629
|
+
tenant: string;
|
|
1630
|
+
bindingId: string;
|
|
1631
|
+
eventId: string;
|
|
1632
|
+
};
|
|
1633
|
+
cargo: z.core.util.JSONType;
|
|
1634
|
+
limits: {
|
|
1635
|
+
hostConcurrency: number;
|
|
1636
|
+
bindingConcurrency: number;
|
|
1637
|
+
};
|
|
1638
|
+
kind: "start";
|
|
1639
|
+
reservationId: string;
|
|
1640
|
+
runId?: never;
|
|
1641
|
+
})[];
|
|
1642
|
+
nextWebhookWake(rawOwner: z.input<z.ZodObject<{
|
|
1643
|
+
host: z.ZodString;
|
|
1644
|
+
tenant: z.ZodString;
|
|
1645
|
+
}, z.core.$strict>>): number | null;
|
|
1646
|
+
completeWebhookDelivery(rawReservationId: string): void;
|
|
1584
1647
|
reserveTriggerRun(raw: {
|
|
1585
1648
|
scope: import("./trigger-run-reservations.js").TriggerRunScope;
|
|
1586
1649
|
requestId: string;
|
|
@@ -1599,7 +1662,7 @@ export declare function createTeamWorkflowRuns(options: {
|
|
|
1599
1662
|
list(filter: {
|
|
1600
1663
|
installationId: string;
|
|
1601
1664
|
actorId?: string;
|
|
1602
|
-
workflowId
|
|
1665
|
+
workflowId?: string;
|
|
1603
1666
|
}): readonly (GraphRun & {
|
|
1604
1667
|
startedAt: number;
|
|
1605
1668
|
})[];
|
|
@@ -384,7 +384,7 @@ export function createTeamWorkflowRuns(options) {
|
|
|
384
384
|
throw options.errors.app("denied");
|
|
385
385
|
return startCompiled(preparation, request, identity, ceiling, undefined, publication, libraryInstallationId);
|
|
386
386
|
},
|
|
387
|
-
async startCachedGraph(binding, started) {
|
|
387
|
+
async startCachedGraph(binding, started, admission) {
|
|
388
388
|
const { triggerRepository, ...request } = started;
|
|
389
389
|
await admitGraphMember(binding.armedBy, binding.computerId);
|
|
390
390
|
const stored = triggerPlans.read(binding);
|
|
@@ -467,15 +467,30 @@ export function createTeamWorkflowRuns(options) {
|
|
|
467
467
|
trigger = { ...trigger, nodeGitAccess: binding.nodeGitAccess };
|
|
468
468
|
if (triggerRepository !== undefined)
|
|
469
469
|
trigger = { ...trigger, repository: triggerRepository };
|
|
470
|
-
return
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
470
|
+
return options.transactionSync(() => {
|
|
471
|
+
// Different external bindings may share the compiled publication.
|
|
472
|
+
// Check the binding's owning store after all capture I/O, in the same
|
|
473
|
+
// transaction as the durable start, rather than invalidating its peers.
|
|
474
|
+
if (admission !== undefined && !admission.admitBinding())
|
|
475
|
+
return {
|
|
476
|
+
kind: "refused",
|
|
477
|
+
reason: "Trigger binding is no longer armed",
|
|
478
|
+
};
|
|
479
|
+
const result = startCompiled(preparation, binding.delivery === undefined
|
|
480
|
+
? request
|
|
481
|
+
: { ...request, delivery: binding.delivery }, identity, ceiling, trigger, {
|
|
482
|
+
installationId: binding.installationId,
|
|
483
|
+
workflowId: binding.workflowId,
|
|
484
|
+
commit: binding.commit,
|
|
485
|
+
digest: binding.digest,
|
|
486
|
+
armedBy: { kind: "member", memberId: binding.armedBy },
|
|
487
|
+
}, binding.installationId);
|
|
488
|
+
if (admission?.reservationId !== undefined &&
|
|
489
|
+
(result.kind === "started" || result.kind === "replay") &&
|
|
490
|
+
triggerReservations.bindTriggerRun(admission.reservationId, result.runId) === "conflict")
|
|
491
|
+
throw new Error("Trigger reservation binding conflict");
|
|
492
|
+
return result;
|
|
493
|
+
});
|
|
479
494
|
},
|
|
480
495
|
callWorkflowApp: options.callWorkflowApp,
|
|
481
496
|
admitDelivery: options.admitDelivery,
|
|
@@ -24,6 +24,7 @@ export declare function createWorkflowSchedules(options: {
|
|
|
24
24
|
}>;
|
|
25
25
|
list(principal: WorkflowTriggerPrincipal, installationId: string): Promise<{
|
|
26
26
|
scheduleId: string;
|
|
27
|
+
installationId: string;
|
|
27
28
|
workflowId: string;
|
|
28
29
|
triggerId: string;
|
|
29
30
|
nextDueAt: number;
|
|
@@ -95,6 +95,7 @@ export function createWorkflowSchedules(options) {
|
|
|
95
95
|
.filter((record) => record.installationId === installationId)
|
|
96
96
|
.map((record) => ({
|
|
97
97
|
scheduleId: record.id,
|
|
98
|
+
installationId: record.installationId,
|
|
98
99
|
workflowId: record.workflowId,
|
|
99
100
|
triggerId: record.triggerId,
|
|
100
101
|
nextDueAt: record.nextDueAt,
|
|
@@ -3,7 +3,7 @@ import * as S from "effect/Schema";
|
|
|
3
3
|
import { type GitBinding, type RepositoryRef } from "@fungi.computer/agar/git";
|
|
4
4
|
import { type TriggerEvent } from "../index.js";
|
|
5
5
|
import { type WorkflowArmingAuthorityPorts, type WorkflowArmingReceipt } from "./arming-authority.js";
|
|
6
|
-
import type { PublishedDefinitionPort, WorkflowPublicationContext, WorkflowTriggerRunPorts, WorkflowErrorPort } from "./ports.js";
|
|
6
|
+
import type { PublishedDefinitionPort, WorkflowPublicationContext, WorkflowTriggerRunPorts, WorkflowErrorPort, WorkflowTriggerAdmission } from "./ports.js";
|
|
7
7
|
export declare const workflowTriggerTargetSchema: z.ZodObject<{
|
|
8
8
|
installationId: z.ZodString;
|
|
9
9
|
workflowId: z.ZodString;
|
|
@@ -581,7 +581,7 @@ export declare function createTeamWorkflowTriggers(options: {
|
|
|
581
581
|
invalidateInstallation: (installationId: string) => void;
|
|
582
582
|
/** The single native push owner supplies host-issued Git, never a fire caller. */
|
|
583
583
|
revalidatePublished(installationId: string, git: GitBinding): Promise<void>;
|
|
584
|
-
fire(raw: ArmedWorkflowTrigger, scope: string, event: TriggerEvent): Promise<Readonly<{
|
|
584
|
+
fire(raw: ArmedWorkflowTrigger, scope: string, event: TriggerEvent, admission?: WorkflowTriggerAdmission): Promise<Readonly<{
|
|
585
585
|
kind: "ignored";
|
|
586
586
|
}> | Readonly<{
|
|
587
587
|
kind: "started" | "replay" | "conflict";
|
|
@@ -213,7 +213,7 @@ export function createTeamWorkflowTriggers(options) {
|
|
|
213
213
|
options.runs.triggerPlans.refuse(binding, "commit no longer published");
|
|
214
214
|
}
|
|
215
215
|
},
|
|
216
|
-
async fire(raw, scope, event) {
|
|
216
|
+
async fire(raw, scope, event, admission) {
|
|
217
217
|
const binding = armedWorkflowTriggerSchema.parse(raw);
|
|
218
218
|
try {
|
|
219
219
|
const current = await options.admit(binding.armedBy, binding.installationId, binding.computerId);
|
|
@@ -256,7 +256,7 @@ export function createTeamWorkflowTriggers(options) {
|
|
|
256
256
|
};
|
|
257
257
|
const result = await options.runs.startCachedGraph(binding, mapped.repository === undefined
|
|
258
258
|
? request
|
|
259
|
-
: { ...request, triggerRepository: mapped.repository });
|
|
259
|
+
: { ...request, triggerRepository: mapped.repository }, admission);
|
|
260
260
|
if (result.kind === "refused")
|
|
261
261
|
return result;
|
|
262
262
|
if (result.kind === "conflict")
|
|
@@ -7,8 +7,72 @@ declare const triggerRunScopeSchema: z.ZodLazy<z.ZodObject<{
|
|
|
7
7
|
triggerId: z.ZodString;
|
|
8
8
|
}, z.core.$strict>>;
|
|
9
9
|
export type TriggerRunScope = z.output<typeof triggerRunScopeSchema>;
|
|
10
|
+
declare const deliveryInputSchema: z.ZodObject<{
|
|
11
|
+
scope: z.ZodLazy<z.ZodObject<{
|
|
12
|
+
installationId: z.ZodString;
|
|
13
|
+
workflowId: z.ZodString;
|
|
14
|
+
commit: z.ZodString;
|
|
15
|
+
triggerId: z.ZodString;
|
|
16
|
+
}, z.core.$strict>>;
|
|
17
|
+
delivery: z.ZodObject<{
|
|
18
|
+
host: z.ZodString;
|
|
19
|
+
tenant: z.ZodString;
|
|
20
|
+
bindingId: z.ZodString;
|
|
21
|
+
eventId: z.ZodString;
|
|
22
|
+
}, z.core.$strict>;
|
|
23
|
+
cargo: z.ZodJSONSchema;
|
|
24
|
+
limits: z.ZodObject<{
|
|
25
|
+
hostConcurrency: z.ZodNumber;
|
|
26
|
+
bindingConcurrency: z.ZodNumber;
|
|
27
|
+
}, z.core.$strict>;
|
|
28
|
+
}, z.core.$strict>;
|
|
29
|
+
declare const deliveryOwnerSchema: z.ZodObject<{
|
|
30
|
+
host: z.ZodString;
|
|
31
|
+
tenant: z.ZodString;
|
|
32
|
+
}, z.core.$strict>;
|
|
10
33
|
/** The enclosing Team owner supplies SQLite; callers never select the Team. */
|
|
11
|
-
export declare function createTriggerRunReservations(options: Pick<WatchdogSqliteOwner, "sql" | "transactionSync">
|
|
34
|
+
export declare function createTriggerRunReservations(options: Pick<WatchdogSqliteOwner, "sql" | "transactionSync"> & {
|
|
35
|
+
now?: () => number;
|
|
36
|
+
}): {
|
|
37
|
+
/** Opaque verified cargo and delivery identity live with the run reservation. */
|
|
38
|
+
reserveWebhookDelivery(raw: z.input<typeof deliveryInputSchema>): {
|
|
39
|
+
kind: "reserved" | "replay";
|
|
40
|
+
reservationId: string;
|
|
41
|
+
runId: string | null;
|
|
42
|
+
} | {
|
|
43
|
+
kind: "refused";
|
|
44
|
+
reason: "limit";
|
|
45
|
+
};
|
|
46
|
+
/** Bounded restart/lost-launch-reply recovery over the SAME reservations. */
|
|
47
|
+
pendingWebhookDeliveries(rawOwner: z.input<typeof deliveryOwnerSchema>): ({
|
|
48
|
+
kind: "launch";
|
|
49
|
+
reservationId: string;
|
|
50
|
+
runId: string;
|
|
51
|
+
} | {
|
|
52
|
+
scope: {
|
|
53
|
+
installationId: string;
|
|
54
|
+
workflowId: string;
|
|
55
|
+
commit: string;
|
|
56
|
+
triggerId: string;
|
|
57
|
+
};
|
|
58
|
+
delivery: {
|
|
59
|
+
host: string;
|
|
60
|
+
tenant: string;
|
|
61
|
+
bindingId: string;
|
|
62
|
+
eventId: string;
|
|
63
|
+
};
|
|
64
|
+
cargo: z.core.util.JSONType;
|
|
65
|
+
limits: {
|
|
66
|
+
hostConcurrency: number;
|
|
67
|
+
bindingConcurrency: number;
|
|
68
|
+
};
|
|
69
|
+
kind: "start";
|
|
70
|
+
reservationId: string;
|
|
71
|
+
runId?: never;
|
|
72
|
+
})[];
|
|
73
|
+
/** Native alarms share this deadline with schedules, including idle TTL cleanup. */
|
|
74
|
+
nextWebhookWake(rawOwner: z.input<typeof deliveryOwnerSchema>): number | null;
|
|
75
|
+
completeWebhookDelivery(rawReservationId: string): void;
|
|
12
76
|
reserveTriggerRun(raw: {
|
|
13
77
|
scope: TriggerRunScope;
|
|
14
78
|
requestId: string;
|
|
@@ -33,15 +33,134 @@ const scopeKey = (scope) => {
|
|
|
33
33
|
parsed.triggerId,
|
|
34
34
|
]);
|
|
35
35
|
};
|
|
36
|
+
const deliveryIdentitySchema = z.strictObject({
|
|
37
|
+
host: identity,
|
|
38
|
+
tenant: identity,
|
|
39
|
+
bindingId: identity,
|
|
40
|
+
eventId: identity,
|
|
41
|
+
});
|
|
42
|
+
const deliveryInputSchema = z.strictObject({
|
|
43
|
+
scope: triggerRunScopeSchema,
|
|
44
|
+
delivery: deliveryIdentitySchema,
|
|
45
|
+
cargo: z.json(),
|
|
46
|
+
limits: z.strictObject({
|
|
47
|
+
hostConcurrency: z.number().int().positive().max(20),
|
|
48
|
+
bindingConcurrency: z.number().int().positive().max(20),
|
|
49
|
+
}),
|
|
50
|
+
});
|
|
51
|
+
const deliveryOwnerSchema = deliveryIdentitySchema.pick({
|
|
52
|
+
host: true,
|
|
53
|
+
tenant: true,
|
|
54
|
+
});
|
|
55
|
+
const pendingDeliverySchema = rowSchema.and(z.object({
|
|
56
|
+
scope: z.string(),
|
|
57
|
+
delivery_record: z.string().nullable(),
|
|
58
|
+
}));
|
|
59
|
+
const DAY = 24 * 60 * 60 * 1000;
|
|
36
60
|
/** The enclosing Team owner supplies SQLite; callers never select the Team. */
|
|
37
61
|
export function createTriggerRunReservations(options) {
|
|
38
62
|
const { sql } = options;
|
|
63
|
+
const now = options.now ?? Date.now;
|
|
39
64
|
sql.exec(`CREATE TABLE IF NOT EXISTS hub_trigger_run_reservations (
|
|
40
65
|
reservation_id TEXT PRIMARY KEY, scope TEXT NOT NULL, request_id TEXT NOT NULL,
|
|
41
66
|
run_id TEXT UNIQUE, status TEXT NOT NULL CHECK(status IN ('active','released','terminal')),
|
|
67
|
+
delivery_key TEXT, delivery_record TEXT, delivery_expires_at INTEGER,
|
|
68
|
+
delivery_host TEXT, delivery_tenant TEXT, delivery_binding TEXT,
|
|
69
|
+
delivery_pending INTEGER NOT NULL DEFAULT 0 CHECK(delivery_pending IN (0,1)),
|
|
42
70
|
UNIQUE(scope,request_id)
|
|
43
71
|
) STRICT`);
|
|
72
|
+
sql.exec(`CREATE INDEX IF NOT EXISTS hub_trigger_delivery_identity
|
|
73
|
+
ON hub_trigger_run_reservations(delivery_key,delivery_expires_at)`);
|
|
74
|
+
sql.exec(`CREATE INDEX IF NOT EXISTS hub_trigger_delivery_expiry
|
|
75
|
+
ON hub_trigger_run_reservations(delivery_expires_at)`);
|
|
76
|
+
const expireDeliveries = () => {
|
|
77
|
+
const at = now();
|
|
78
|
+
// An expired unstarted intent cannot wake forever. A recorded active run
|
|
79
|
+
// still owns its concurrency slot even after delivery deduplication expires.
|
|
80
|
+
sql.exec("UPDATE hub_trigger_run_reservations SET status='terminal',delivery_pending=0 WHERE reservation_id IN (SELECT reservation_id FROM hub_trigger_run_reservations WHERE delivery_expires_at<=? AND run_id IS NULL LIMIT 100)", at);
|
|
81
|
+
sql.exec("DELETE FROM hub_trigger_run_reservations WHERE reservation_id IN (SELECT reservation_id FROM hub_trigger_run_reservations WHERE delivery_expires_at<=? AND status!='active' AND delivery_pending=0 LIMIT 100)", at);
|
|
82
|
+
sql.exec("UPDATE hub_trigger_run_reservations SET delivery_key=NULL,delivery_record=NULL,delivery_expires_at=NULL WHERE reservation_id IN (SELECT reservation_id FROM hub_trigger_run_reservations WHERE delivery_expires_at<=? AND run_id IS NOT NULL LIMIT 100)", at);
|
|
83
|
+
sql.exec("DELETE FROM hub_trigger_run_reservations WHERE reservation_id IN (SELECT reservation_id FROM hub_trigger_run_reservations WHERE delivery_host IS NOT NULL AND delivery_expires_at IS NULL AND status!='active' AND delivery_pending=0 LIMIT 100)");
|
|
84
|
+
};
|
|
44
85
|
return {
|
|
86
|
+
/** Opaque verified cargo and delivery identity live with the run reservation. */
|
|
87
|
+
reserveWebhookDelivery(raw) {
|
|
88
|
+
const input = deliveryInputSchema.parse(raw);
|
|
89
|
+
const key = JSON.stringify(input.delivery);
|
|
90
|
+
return options.transactionSync(() => {
|
|
91
|
+
expireDeliveries();
|
|
92
|
+
const prior = sql
|
|
93
|
+
.exec("SELECT reservation_id,run_id,status FROM hub_trigger_run_reservations WHERE delivery_key=? AND delivery_expires_at>? ORDER BY delivery_expires_at DESC LIMIT 1", key, now())
|
|
94
|
+
.toArray()[0];
|
|
95
|
+
if (prior !== undefined) {
|
|
96
|
+
const row = rowSchema.parse(prior);
|
|
97
|
+
return {
|
|
98
|
+
kind: "replay",
|
|
99
|
+
reservationId: row.reservation_id,
|
|
100
|
+
runId: row.run_id,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
const active = z
|
|
104
|
+
.object({
|
|
105
|
+
count: z.number(),
|
|
106
|
+
binding_count: z.number(),
|
|
107
|
+
})
|
|
108
|
+
.parse(sql
|
|
109
|
+
.exec("SELECT count(*) AS count,coalesce(sum(delivery_host=? AND delivery_tenant=? AND delivery_binding=?),0) AS binding_count FROM hub_trigger_run_reservations WHERE status='active'", input.delivery.host, input.delivery.tenant, input.delivery.bindingId)
|
|
110
|
+
.toArray()[0]);
|
|
111
|
+
if (active.count >= input.limits.hostConcurrency ||
|
|
112
|
+
active.binding_count >= input.limits.bindingConcurrency)
|
|
113
|
+
return { kind: "refused", reason: "limit" };
|
|
114
|
+
const reservationId = reservationIds.reservation.generate();
|
|
115
|
+
sql.exec("INSERT INTO hub_trigger_run_reservations(reservation_id,scope,request_id,run_id,status,delivery_key,delivery_record,delivery_expires_at,delivery_pending,delivery_host,delivery_tenant,delivery_binding) VALUES(?,?,?,NULL,'active',?,?,?,1,?,?,?)", reservationId, scopeKey(input.scope), reservationId, key, JSON.stringify(input), now() + DAY, input.delivery.host, input.delivery.tenant, input.delivery.bindingId);
|
|
116
|
+
return { kind: "reserved", reservationId, runId: null };
|
|
117
|
+
});
|
|
118
|
+
},
|
|
119
|
+
/** Bounded restart/lost-launch-reply recovery over the SAME reservations. */
|
|
120
|
+
pendingWebhookDeliveries(rawOwner) {
|
|
121
|
+
const owner = deliveryOwnerSchema.parse(rawOwner);
|
|
122
|
+
return options.transactionSync(() => {
|
|
123
|
+
expireDeliveries();
|
|
124
|
+
return sql
|
|
125
|
+
.exec("SELECT reservation_id,run_id,status,scope,delivery_record FROM hub_trigger_run_reservations WHERE delivery_host=? AND delivery_tenant=? AND delivery_pending=1 ORDER BY delivery_expires_at LIMIT 32", owner.host, owner.tenant)
|
|
126
|
+
.toArray()
|
|
127
|
+
.map((raw) => {
|
|
128
|
+
const row = pendingDeliverySchema.parse(raw);
|
|
129
|
+
if (row.run_id !== null)
|
|
130
|
+
return {
|
|
131
|
+
kind: "launch",
|
|
132
|
+
reservationId: row.reservation_id,
|
|
133
|
+
runId: row.run_id,
|
|
134
|
+
};
|
|
135
|
+
const record = deliveryInputSchema.parse(JSON.parse(z.string().parse(row.delivery_record)));
|
|
136
|
+
return {
|
|
137
|
+
kind: "start",
|
|
138
|
+
reservationId: row.reservation_id,
|
|
139
|
+
...record,
|
|
140
|
+
};
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
},
|
|
144
|
+
/** Native alarms share this deadline with schedules, including idle TTL cleanup. */
|
|
145
|
+
nextWebhookWake(rawOwner) {
|
|
146
|
+
const owner = deliveryOwnerSchema.parse(rawOwner);
|
|
147
|
+
return options.transactionSync(() => {
|
|
148
|
+
expireDeliveries();
|
|
149
|
+
const row = z
|
|
150
|
+
.object({
|
|
151
|
+
pending: z.number(),
|
|
152
|
+
expiry: z.number().nullable(),
|
|
153
|
+
})
|
|
154
|
+
.parse(sql
|
|
155
|
+
.exec("SELECT coalesce(max(delivery_pending),0) AS pending,min(delivery_expires_at) AS expiry FROM hub_trigger_run_reservations WHERE delivery_host=? AND delivery_tenant=?", owner.host, owner.tenant)
|
|
156
|
+
.toArray()[0]);
|
|
157
|
+
return row.pending === 1 ? now() + 1000 : row.expiry;
|
|
158
|
+
});
|
|
159
|
+
},
|
|
160
|
+
completeWebhookDelivery(rawReservationId) {
|
|
161
|
+
const reservationId = identity.parse(rawReservationId);
|
|
162
|
+
sql.exec("UPDATE hub_trigger_run_reservations SET delivery_pending=0,delivery_record=NULL,status=CASE WHEN run_id IS NULL THEN 'terminal' ELSE status END WHERE reservation_id=? AND delivery_pending=1", reservationId);
|
|
163
|
+
},
|
|
45
164
|
reserveTriggerRun(raw) {
|
|
46
165
|
const input = reservationInputSchema.parse(raw);
|
|
47
166
|
const scope = scopeKey(input.scope);
|
package/dist/workflow-id.d.ts
CHANGED
package/dist/workflow-id.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fungi.computer/braid",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"author": "Levi <levi@technomancy.dev>",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Pure versioned Workflow graph validation and content-addressed compilation",
|
|
@@ -29,17 +29,17 @@
|
|
|
29
29
|
],
|
|
30
30
|
"dependencies": {
|
|
31
31
|
"@cfworker/json-schema": "4.1.1",
|
|
32
|
-
"@fungi.computer/almanac": "0.
|
|
33
|
-
"@fungi.computer/mycelium": "0.
|
|
32
|
+
"@fungi.computer/almanac": "0.2.0",
|
|
33
|
+
"@fungi.computer/mycelium": "0.2.0",
|
|
34
34
|
"effect": "4.0.2",
|
|
35
35
|
"jsonpointer": "5.0.1",
|
|
36
36
|
"mustache": "4.2.0",
|
|
37
37
|
"zod": "4.4.3",
|
|
38
|
-
"@fungi.computer/agar": "0.
|
|
39
|
-
"@fungi.computer/gills": "0.
|
|
40
|
-
"@fungi.computer/watchdog": "0.0
|
|
41
|
-
"@fungi.computer/puffball": "0.
|
|
42
|
-
"@fungi.computer/cairn": "0.0
|
|
38
|
+
"@fungi.computer/agar": "0.3.0",
|
|
39
|
+
"@fungi.computer/gills": "0.2.0",
|
|
40
|
+
"@fungi.computer/watchdog": "0.1.0",
|
|
41
|
+
"@fungi.computer/puffball": "0.2.0",
|
|
42
|
+
"@fungi.computer/cairn": "0.1.0"
|
|
43
43
|
},
|
|
44
44
|
"devDependencies": {
|
|
45
45
|
"@cloudflare/workers-types": "5.20260828.1",
|