@fungi.computer/braid 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +260 -2
- package/dist/cloudflare/graph-engine.d.ts +25 -0
- package/dist/cloudflare/graph-engine.js +46 -0
- package/dist/cloudflare/graph-program.d.ts +6 -0
- package/dist/cloudflare/graph-program.js +550 -0
- package/dist/cloudflare/index.d.ts +1 -0
- package/dist/cloudflare/index.js +1 -0
- package/dist/code-execution.d.ts +16 -0
- package/dist/code-execution.js +50 -0
- package/dist/code-result.d.ts +11 -0
- package/dist/code-result.js +19 -0
- package/dist/code-sandbox.d.ts +9 -0
- package/dist/code-sandbox.js +13 -0
- package/dist/compile.d.ts +43 -0
- package/dist/compile.js +248 -0
- package/dist/execution.d.ts +194 -0
- package/dist/execution.js +130 -0
- package/dist/expressions.d.ts +16 -0
- package/dist/expressions.js +322 -0
- package/dist/git-access.d.ts +59 -0
- package/dist/git-access.js +106 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +9 -0
- package/dist/ports.d.ts +58 -0
- package/dist/ports.js +256 -0
- package/dist/review-text.d.ts +15 -0
- package/dist/review-text.js +40 -0
- package/dist/runtime/arming-authority.d.ts +83 -0
- package/dist/runtime/arming-authority.js +90 -0
- package/dist/runtime/bindings.d.ts +25 -0
- package/dist/runtime/bindings.js +41 -0
- package/dist/runtime/capture.d.ts +18 -0
- package/dist/runtime/capture.js +99 -0
- package/dist/runtime/git-pass-contract.d.ts +63 -0
- package/dist/runtime/git-pass-contract.js +36 -0
- package/dist/runtime/git-pass-store.d.ts +32 -0
- package/dist/runtime/git-pass-store.js +82 -0
- package/dist/runtime/git-pass.d.ts +41 -0
- package/dist/runtime/git-pass.js +72 -0
- package/dist/runtime/graph-contract.d.ts +788 -0
- package/dist/runtime/graph-contract.js +122 -0
- package/dist/runtime/graph-dispatch.d.ts +31 -0
- package/dist/runtime/graph-dispatch.js +111 -0
- package/dist/runtime/graph-run-store.d.ts +79 -0
- package/dist/runtime/graph-run-store.js +267 -0
- package/dist/runtime/graph-start.d.ts +90 -0
- package/dist/runtime/graph-start.js +124 -0
- package/dist/runtime/index.d.ts +20 -0
- package/dist/runtime/index.js +20 -0
- package/dist/runtime/ports.d.ts +156 -0
- package/dist/runtime/ports.js +1 -0
- package/dist/runtime/run-authority.d.ts +60 -0
- package/dist/runtime/run-authority.js +145 -0
- package/dist/runtime/run-ceiling.d.ts +106 -0
- package/dist/runtime/run-ceiling.js +23 -0
- package/dist/runtime/run-member.d.ts +3 -0
- package/dist/runtime/run-member.js +12 -0
- package/dist/runtime/run-owner.d.ts +1650 -0
- package/dist/runtime/run-owner.js +513 -0
- package/dist/runtime/run-report.d.ts +14 -0
- package/dist/runtime/run-report.js +23 -0
- package/dist/runtime/schedules.d.ts +39 -0
- package/dist/runtime/schedules.js +149 -0
- package/dist/runtime/stored-trigger-rows.d.ts +12 -0
- package/dist/runtime/stored-trigger-rows.js +17 -0
- package/dist/runtime/trigger-owner.d.ts +594 -0
- package/dist/runtime/trigger-owner.js +280 -0
- package/dist/runtime/trigger-plans.d.ts +774 -0
- package/dist/runtime/trigger-plans.js +62 -0
- package/dist/runtime/trigger-router.d.ts +34 -0
- package/dist/runtime/trigger-router.js +143 -0
- package/dist/runtime/trigger-run-reservations.d.ts +28 -0
- package/dist/runtime/trigger-run-reservations.js +114 -0
- package/dist/schema.d.ts +1183 -0
- package/dist/schema.js +239 -0
- package/dist/triggers.d.ts +46 -0
- package/dist/triggers.js +75 -0
- package/dist/validate.d.ts +25 -0
- package/dist/validate.js +686 -0
- package/dist/workflow-id.d.ts +3 -0
- package/dist/workflow-id.js +3 -0
- package/package.json +96 -3
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { nodeGitAccessListSchema, runCeilingSchema } from "./run-ceiling.js";
|
|
2
|
+
import { hostActorSchema, } from "@fungi.computer/puffball/app-method-runtime";
|
|
3
|
+
import * as S from "effect/Schema";
|
|
4
|
+
import { sha256DigestSchema } from "@fungi.computer/puffball/effect";
|
|
5
|
+
import { resolvedGitAccessSchema, triggerRepositorySchema, } from "../git-access.js";
|
|
6
|
+
import { gitSha1Schema } from "@fungi.computer/puffball/git-representation";
|
|
7
|
+
import { workflowIdSchema } from "../workflow-id.js";
|
|
8
|
+
import { executionPlanSchema } from "../index.js";
|
|
9
|
+
const id = S.String.pipe(S.check(S.isMinLength(1)), S.check(S.isMaxLength(256)));
|
|
10
|
+
const natural = S.Number.pipe(S.check(S.isInt()), S.check(S.isGreaterThanOrEqualTo(0)));
|
|
11
|
+
const resolvedGitAccess = S.declare((value) => resolvedGitAccessSchema.safeParse(value).success);
|
|
12
|
+
/** Provenance supplied only by the admitted published-Workflow trigger owner. */
|
|
13
|
+
export const graphTriggerProvenanceSchema = S.Struct({
|
|
14
|
+
libraryInstallationId: id,
|
|
15
|
+
workflowId: S.declare((value) => workflowIdSchema.safeParse(value).success),
|
|
16
|
+
commit: S.declare((value) => gitSha1Schema.safeParse(value).success),
|
|
17
|
+
triggerId: id,
|
|
18
|
+
armedBy: id,
|
|
19
|
+
nodeGitAccess: S.optionalKey(nodeGitAccessListSchema),
|
|
20
|
+
/** The firing event's repository, resolving `$trigger.repository`. */
|
|
21
|
+
repository: S.optionalKey(S.declare((value) => triggerRepositorySchema.safeParse(value).success)),
|
|
22
|
+
});
|
|
23
|
+
const graphPublicationSchema = S.Struct({
|
|
24
|
+
installationId: id,
|
|
25
|
+
workflowId: graphTriggerProvenanceSchema.fields.workflowId,
|
|
26
|
+
commit: graphTriggerProvenanceSchema.fields.commit,
|
|
27
|
+
digest: S.declare((value) => S.is(sha256DigestSchema)(value)),
|
|
28
|
+
armedBy: S.declare((value) => hostActorSchema.safeParse(value).success),
|
|
29
|
+
});
|
|
30
|
+
/**
|
|
31
|
+
* The run's declared delivery: the Agent Session that receives its ending,
|
|
32
|
+
* whoever started it. Declared at start or arming, never by a node.
|
|
33
|
+
*/
|
|
34
|
+
export const workflowRunDeliverySchema = S.Struct({
|
|
35
|
+
agentId: id,
|
|
36
|
+
sessionId: id,
|
|
37
|
+
});
|
|
38
|
+
/** The member who started or armed the run, or the Agent Session that did. */
|
|
39
|
+
export const workflowMemberInitiatorSchema = S.Struct({
|
|
40
|
+
kind: S.Literal("member"),
|
|
41
|
+
memberId: id,
|
|
42
|
+
});
|
|
43
|
+
/** Trusted acquisition identity, never Workflow arguments or an Agent creator. */
|
|
44
|
+
export const workflowAgentInitiatorSchema = S.Struct({
|
|
45
|
+
kind: S.Literal("agent"),
|
|
46
|
+
agentId: id,
|
|
47
|
+
sessionId: id,
|
|
48
|
+
grantorMemberId: id,
|
|
49
|
+
});
|
|
50
|
+
/** A key never becomes a principal of its own; the run always acts as its grantor. */
|
|
51
|
+
export const workflowKeyInitiatorSchema = S.Struct({
|
|
52
|
+
kind: S.Literal("key"),
|
|
53
|
+
keyId: id,
|
|
54
|
+
grantorMemberId: id,
|
|
55
|
+
});
|
|
56
|
+
export const workflowRunInitiatorSchema = S.Union([
|
|
57
|
+
workflowMemberInitiatorSchema,
|
|
58
|
+
workflowAgentInitiatorSchema,
|
|
59
|
+
workflowKeyInitiatorSchema,
|
|
60
|
+
]);
|
|
61
|
+
/** The Agent Session stays host-side; a listing names only the Agent/key and its grantor. */
|
|
62
|
+
export function wireRunInitiator(initiatedBy) {
|
|
63
|
+
if (initiatedBy.kind === "member")
|
|
64
|
+
return initiatedBy;
|
|
65
|
+
if (initiatedBy.kind === "agent")
|
|
66
|
+
return {
|
|
67
|
+
kind: "agent",
|
|
68
|
+
agentId: initiatedBy.agentId,
|
|
69
|
+
grantorMemberId: initiatedBy.grantorMemberId,
|
|
70
|
+
};
|
|
71
|
+
return {
|
|
72
|
+
kind: "key",
|
|
73
|
+
keyId: initiatedBy.keyId,
|
|
74
|
+
grantorMemberId: initiatedBy.grantorMemberId,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
export const workflowRunSourceValues = [
|
|
78
|
+
"manual",
|
|
79
|
+
"schedule",
|
|
80
|
+
"event",
|
|
81
|
+
"agent",
|
|
82
|
+
"key",
|
|
83
|
+
];
|
|
84
|
+
export const workflowRunSourceSchema = S.Literals(workflowRunSourceValues);
|
|
85
|
+
export const graphRunSchema = S.Struct({
|
|
86
|
+
teamId: id,
|
|
87
|
+
runId: id,
|
|
88
|
+
requestId: id,
|
|
89
|
+
actorId: id,
|
|
90
|
+
/** Host-bound library for raw as well as published App starts; never guest input. */
|
|
91
|
+
libraryInstallationId: S.optionalKey(id),
|
|
92
|
+
/** The Computer the starting member pinned; `computer.*` work runs only there. */
|
|
93
|
+
computerId: S.NullOr(id),
|
|
94
|
+
plan: executionPlanSchema,
|
|
95
|
+
input: S.JsonObject,
|
|
96
|
+
ceiling: runCeilingSchema,
|
|
97
|
+
trigger: S.optionalKey(graphTriggerProvenanceSchema),
|
|
98
|
+
publication: S.optionalKey(graphPublicationSchema),
|
|
99
|
+
delivery: S.optionalKey(workflowRunDeliverySchema),
|
|
100
|
+
/** Recorded once, from the admitted preparation; never recomputed later. */
|
|
101
|
+
source: workflowRunSourceSchema,
|
|
102
|
+
initiatedBy: workflowRunInitiatorSchema,
|
|
103
|
+
});
|
|
104
|
+
/** Host-owned identity of one App node execution within a graph run. */
|
|
105
|
+
export const graphNodeContextSchema = S.Struct({
|
|
106
|
+
runId: id,
|
|
107
|
+
graphId: S.optionalKey(id),
|
|
108
|
+
nodeId: id,
|
|
109
|
+
iteration: id,
|
|
110
|
+
/** Pending-call poll identity, separate from retries of the same poll. */
|
|
111
|
+
poll: S.optionalKey(natural),
|
|
112
|
+
/** Opaque engine retry identity, never parsed as authority. */
|
|
113
|
+
idempotencyKey: S.String,
|
|
114
|
+
trigger: S.optionalKey(graphTriggerProvenanceSchema),
|
|
115
|
+
gitAccess: S.optionalKey(resolvedGitAccess),
|
|
116
|
+
});
|
|
117
|
+
/** Publication attribution from an admitted publisher App or trigger owner. */
|
|
118
|
+
export const workflowRunContextSchema = S.Struct({
|
|
119
|
+
...graphNodeContextSchema.fields,
|
|
120
|
+
workflowId: graphPublicationSchema.fields.workflowId,
|
|
121
|
+
armedBy: graphPublicationSchema.fields.armedBy,
|
|
122
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { WorkflowAppCall } from "./ports.js";
|
|
2
|
+
import type { GraphRun } from "./graph-contract.js";
|
|
3
|
+
import type { NodeCall, Node, Catalog } from "../index.js";
|
|
4
|
+
import type { RunCeiling } from "./run-ceiling.js";
|
|
5
|
+
import { type GraphNodeContext, type WorkflowRunContext } from "./graph-contract.js";
|
|
6
|
+
/**
|
|
7
|
+
* Resolve an engine node call against the run's own frozen plan. The engine's
|
|
8
|
+
* claims (installation, method) are accepted only when the plan bound exactly
|
|
9
|
+
* that pair, and the App id and method digest come from the plan, never from
|
|
10
|
+
* the call; the Computer is the one the run pinned at start. Null means the
|
|
11
|
+
* call names something the plan never allowed.
|
|
12
|
+
*/
|
|
13
|
+
/** Canonical installed identity and context of a stored App node, without method input. */
|
|
14
|
+
export type StoredWorkflowNode = Omit<WorkflowAppCall, "input"> & (GraphNodeContext | WorkflowRunContext);
|
|
15
|
+
/** Identity comes only from the frozen compiled plan, never the engine's claimed digest. */
|
|
16
|
+
export type WorkflowNodeTarget = Pick<WorkflowAppCall, "installationId" | "appId" | "method" | "methodDigest" | "computerId">;
|
|
17
|
+
export type WorkflowNodeCeiling = Pick<RunCeiling["nodes"][number], "level" | "capabilities" | "bindings">;
|
|
18
|
+
export type ResolvedWorkflowNodeTarget = Readonly<{
|
|
19
|
+
node: Extract<Node, {
|
|
20
|
+
kind: "app";
|
|
21
|
+
}>;
|
|
22
|
+
method: Catalog[string]["methods"][string];
|
|
23
|
+
target: WorkflowNodeTarget;
|
|
24
|
+
}>;
|
|
25
|
+
/** Capture and execution share the same immutable node/installation/method match. */
|
|
26
|
+
export declare function resolveWorkflowNodeTarget(run: Pick<GraphRun, "plan" | "computerId">, call: Pick<NodeCall, "graphId" | "nodeId" | "installationId" | "method">): ResolvedWorkflowNodeTarget | null;
|
|
27
|
+
/** A captured node must cover every required capability and the method's effect. */
|
|
28
|
+
export declare function workflowNodeWithinCeiling(method: Pick<Catalog[string]["methods"][string], "requires" | "effect">, ceiling: WorkflowNodeCeiling): boolean;
|
|
29
|
+
export declare function resolveWorkflowNode(run: Pick<GraphRun, "plan" | "computerId" | "runId" | "trigger" | "publication" | "ceiling">, call: Pick<NodeCall, "graphId" | "nodeId" | "iteration" | "poll" | "idempotencyKey" | "installationId" | "method">): StoredWorkflowNode | null;
|
|
30
|
+
/** Resolve the stored node once, then project its admitted scope into method input. */
|
|
31
|
+
export declare function planNodeCall(run: Pick<GraphRun, "plan" | "computerId" | "runId" | "trigger" | "publication" | "ceiling">, call: NodeCall): WorkflowAppCall | null;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { appCapabilityIdSchema } from "@fungi.computer/puffball/app";
|
|
2
|
+
import { resolveGitAccess, resolvedGitAccessEqual } from "../git-access.js";
|
|
3
|
+
import { workflowRunContextSchema, graphNodeContextSchema, } from "./graph-contract.js";
|
|
4
|
+
import * as S from "effect/Schema";
|
|
5
|
+
import * as Option from "effect/Option";
|
|
6
|
+
/** Capture and execution share the same immutable node/installation/method match. */
|
|
7
|
+
export function resolveWorkflowNodeTarget(run, call) {
|
|
8
|
+
const { plan } = run;
|
|
9
|
+
const graph = call.graphId === undefined
|
|
10
|
+
? plan.definition
|
|
11
|
+
: plan.definition.subgraphs?.[call.graphId];
|
|
12
|
+
const node = graph?.nodes.find((candidate) => {
|
|
13
|
+
if (candidate.kind !== "app" ||
|
|
14
|
+
candidate.id !== call.nodeId ||
|
|
15
|
+
candidate.method !== call.method)
|
|
16
|
+
return false;
|
|
17
|
+
const binding = plan.bindings[candidate.app];
|
|
18
|
+
return (binding !== undefined &&
|
|
19
|
+
"installationId" in binding &&
|
|
20
|
+
binding.installationId === call.installationId);
|
|
21
|
+
});
|
|
22
|
+
if (node?.kind !== "app")
|
|
23
|
+
return null;
|
|
24
|
+
const required = plan.requiredBindings.find((binding) => binding.app === node.app &&
|
|
25
|
+
binding.installationId === call.installationId &&
|
|
26
|
+
binding.method === call.method);
|
|
27
|
+
if (required === undefined)
|
|
28
|
+
return null;
|
|
29
|
+
const app = plan.definition.apps[required.app];
|
|
30
|
+
const method = app === undefined
|
|
31
|
+
? undefined
|
|
32
|
+
: plan.catalog[app.appId]?.methods[call.method];
|
|
33
|
+
if (app === undefined || method === undefined)
|
|
34
|
+
return null;
|
|
35
|
+
return {
|
|
36
|
+
node,
|
|
37
|
+
method,
|
|
38
|
+
target: {
|
|
39
|
+
installationId: call.installationId,
|
|
40
|
+
appId: app.appId,
|
|
41
|
+
method: call.method,
|
|
42
|
+
methodDigest: method.digest,
|
|
43
|
+
computerId: run.computerId,
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/** A captured node must cover every required capability and the method's effect. */
|
|
48
|
+
export function workflowNodeWithinCeiling(method, ceiling) {
|
|
49
|
+
const requires = S.decodeUnknownOption(S.Array(appCapabilityIdSchema))(method.requires);
|
|
50
|
+
return (Option.isSome(requires) &&
|
|
51
|
+
requires.value.every((capability) => ceiling.capabilities.includes(capability)) &&
|
|
52
|
+
(method.effect === "read" || ceiling.level === "write"));
|
|
53
|
+
}
|
|
54
|
+
export function resolveWorkflowNode(run, call) {
|
|
55
|
+
const resolved = resolveWorkflowNodeTarget(run, call);
|
|
56
|
+
if (resolved === null)
|
|
57
|
+
return null;
|
|
58
|
+
const { node, method, target } = resolved;
|
|
59
|
+
const { plan } = run;
|
|
60
|
+
const ceiling = run.ceiling.nodes.find((candidate) => candidate.graphId === call.graphId && candidate.nodeId === node.id);
|
|
61
|
+
if (ceiling === undefined || !workflowNodeWithinCeiling(method, ceiling))
|
|
62
|
+
return null;
|
|
63
|
+
let context = {
|
|
64
|
+
runId: run.runId,
|
|
65
|
+
nodeId: call.nodeId,
|
|
66
|
+
iteration: call.iteration,
|
|
67
|
+
idempotencyKey: call.idempotencyKey,
|
|
68
|
+
};
|
|
69
|
+
if (run.publication !== undefined)
|
|
70
|
+
context = {
|
|
71
|
+
...context,
|
|
72
|
+
workflowId: run.publication.workflowId,
|
|
73
|
+
armedBy: run.publication.armedBy,
|
|
74
|
+
};
|
|
75
|
+
if (run.trigger !== undefined)
|
|
76
|
+
context = { ...context, trigger: run.trigger };
|
|
77
|
+
if (call.graphId !== undefined)
|
|
78
|
+
context = { ...context, graphId: call.graphId };
|
|
79
|
+
if (call.poll !== undefined)
|
|
80
|
+
context = { ...context, poll: call.poll };
|
|
81
|
+
if (node.gitAccess !== undefined) {
|
|
82
|
+
const scope = run.ceiling.nodeGitAccess.find((candidate) => candidate.graphId === call.graphId && candidate.nodeId === node.id);
|
|
83
|
+
const expected = resolveGitAccess(plan.bindings, node.gitAccess, run.trigger?.repository);
|
|
84
|
+
if (scope === undefined ||
|
|
85
|
+
expected === null ||
|
|
86
|
+
!resolvedGitAccessEqual(scope.gitAccess, expected))
|
|
87
|
+
return null;
|
|
88
|
+
context = { ...context, gitAccess: scope.gitAccess };
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
...("workflowId" in context
|
|
92
|
+
? S.decodeUnknownSync(workflowRunContextSchema)(context)
|
|
93
|
+
: S.decodeUnknownSync(graphNodeContextSchema)(context)),
|
|
94
|
+
...target,
|
|
95
|
+
bindingCeiling: ceiling.bindings,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
/** Resolve the stored node once, then project its admitted scope into method input. */
|
|
99
|
+
export function planNodeCall(run, call) {
|
|
100
|
+
const resolved = resolveWorkflowNode(run, call);
|
|
101
|
+
if (resolved === null)
|
|
102
|
+
return null;
|
|
103
|
+
let input = call.input;
|
|
104
|
+
if (resolved.gitAccess !== undefined) {
|
|
105
|
+
const object = S.decodeUnknownOption(S.JsonObject)(input);
|
|
106
|
+
if (Option.isNone(object))
|
|
107
|
+
return null;
|
|
108
|
+
input = { ...object.value, gitAccess: resolved.gitAccess };
|
|
109
|
+
}
|
|
110
|
+
return { ...resolved, input };
|
|
111
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import * as S from "effect/Schema";
|
|
2
|
+
import type { WatchdogSqliteOwner } from "@fungi.computer/watchdog";
|
|
3
|
+
import { type GraphRun } from "./graph-contract.js";
|
|
4
|
+
import { reviewAnswerSchema, type NodeEvent, type GraphReview } from "../index.js";
|
|
5
|
+
declare const deliverySchema: S.Struct<{
|
|
6
|
+
readonly runId: S.String;
|
|
7
|
+
readonly nodeId: S.String;
|
|
8
|
+
readonly iteration: S.String;
|
|
9
|
+
readonly instanceId: S.String;
|
|
10
|
+
readonly eventType: S.String;
|
|
11
|
+
readonly answer: S.Union<readonly [S.Struct<{
|
|
12
|
+
readonly approved: S.Boolean;
|
|
13
|
+
}>, S.Struct<{
|
|
14
|
+
readonly choice: S.String;
|
|
15
|
+
readonly note: S.optionalKey<S.String>;
|
|
16
|
+
}>]>;
|
|
17
|
+
}>;
|
|
18
|
+
export type ReviewDelivery = typeof deliverySchema.Type;
|
|
19
|
+
export type GraphReviewer = {
|
|
20
|
+
readonly kind: "member" | "agent";
|
|
21
|
+
readonly actorId: string;
|
|
22
|
+
readonly isOwner: boolean;
|
|
23
|
+
};
|
|
24
|
+
/** The durable state of every Workflow run: runs, node events, reviews and outcomes. */
|
|
25
|
+
export declare function createGraphWorkflowRuns(options: {
|
|
26
|
+
sql: WatchdogSqliteOwner["sql"];
|
|
27
|
+
transactionSync: <T>(body: () => T) => T;
|
|
28
|
+
}): {
|
|
29
|
+
read: (runId: string) => GraphRun | null;
|
|
30
|
+
/**
|
|
31
|
+
* Up to 50 runs for one installation, newest first; a viewer narrows
|
|
32
|
+
* further to their own. `started_at` is the host clock at insert, kept
|
|
33
|
+
* out of the record so a replay still compares equal.
|
|
34
|
+
*/
|
|
35
|
+
list(filter: {
|
|
36
|
+
installationId: string;
|
|
37
|
+
actorId?: string;
|
|
38
|
+
workflowId?: string;
|
|
39
|
+
}): readonly (GraphRun & {
|
|
40
|
+
startedAt: number;
|
|
41
|
+
})[];
|
|
42
|
+
/** Runs with no recorded outcome, oldest first. */
|
|
43
|
+
active(): readonly GraphRun[];
|
|
44
|
+
outcome(runId: string): "active" | "terminal" | "cancelled" | null;
|
|
45
|
+
markTerminal(runId: string, outcome?: "terminal" | "cancelled"): void;
|
|
46
|
+
startCompiled: (input: GraphRun) => "started" | "replay" | "conflict";
|
|
47
|
+
start(input: GraphRun): Promise<"started" | "replay" | "conflict">;
|
|
48
|
+
recordEvent(input: NodeEvent): void;
|
|
49
|
+
events(runId: string): readonly NodeEvent[];
|
|
50
|
+
openReview(input: GraphReview): number;
|
|
51
|
+
reviews(runId: string): {
|
|
52
|
+
review: {
|
|
53
|
+
readonly show: readonly S.Json[];
|
|
54
|
+
readonly title: string;
|
|
55
|
+
readonly timeout: number;
|
|
56
|
+
readonly runId: string;
|
|
57
|
+
readonly nodeId: string;
|
|
58
|
+
readonly reviewers: "runner" | "owners";
|
|
59
|
+
readonly iteration: string;
|
|
60
|
+
readonly instanceId: string;
|
|
61
|
+
readonly eventType: string;
|
|
62
|
+
readonly position?: readonly {
|
|
63
|
+
readonly node: string;
|
|
64
|
+
readonly loop: "repeat" | "map";
|
|
65
|
+
readonly index: number;
|
|
66
|
+
readonly count: number;
|
|
67
|
+
}[];
|
|
68
|
+
readonly choices?: readonly string[];
|
|
69
|
+
};
|
|
70
|
+
status: "closed" | "pending" | "answered" | "delivered" | "timed_out";
|
|
71
|
+
openedAt: number;
|
|
72
|
+
expiresAt: number;
|
|
73
|
+
}[];
|
|
74
|
+
answerReview(key: Pick<GraphReview, "runId" | "nodeId" | "iteration">, answer: typeof reviewAnswerSchema.Type, reviewer: GraphReviewer): "accepted" | "denied" | "not_waiting" | "invalid";
|
|
75
|
+
/** Durable pending delivery survives a sender restart; native event sends may be repeated. */
|
|
76
|
+
pendingReviewDeliveries(runId: string): readonly ReviewDelivery[];
|
|
77
|
+
markReviewDelivered(delivery: ReviewDelivery): void;
|
|
78
|
+
};
|
|
79
|
+
export {};
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import * as S from "effect/Schema";
|
|
2
|
+
import { admitRunInput, decodeExecutionPlan, canonicalJson } from "../index.js";
|
|
3
|
+
import { graphRunSchema } from "./graph-contract.js";
|
|
4
|
+
import { nodeEventSchema, graphReviewSchema, reviewAnswerSchema, } from "../index.js";
|
|
5
|
+
const rowSchema = S.Struct({ record: S.String });
|
|
6
|
+
const deliverySchema = S.Struct({
|
|
7
|
+
runId: S.String,
|
|
8
|
+
nodeId: S.String,
|
|
9
|
+
iteration: S.String,
|
|
10
|
+
instanceId: S.String,
|
|
11
|
+
eventType: S.String,
|
|
12
|
+
answer: reviewAnswerSchema,
|
|
13
|
+
});
|
|
14
|
+
/** The durable state of every Workflow run: runs, node events, reviews and outcomes. */
|
|
15
|
+
export function createGraphWorkflowRuns(options) {
|
|
16
|
+
const { sql } = options;
|
|
17
|
+
sql.exec(`CREATE TABLE IF NOT EXISTS hub_graph_runs (run_id TEXT PRIMARY KEY,request_id TEXT NOT NULL UNIQUE,installation_id TEXT,workflow_id TEXT,actor_id TEXT NOT NULL,started_at INTEGER NOT NULL,record TEXT NOT NULL) STRICT`);
|
|
18
|
+
sql.exec(`CREATE INDEX IF NOT EXISTS hub_graph_runs_listing ON hub_graph_runs(installation_id, actor_id, started_at DESC, run_id DESC)`);
|
|
19
|
+
sql.exec(`CREATE TABLE IF NOT EXISTS hub_graph_terminal_runs (run_id TEXT PRIMARY KEY REFERENCES hub_graph_runs(run_id), outcome TEXT NOT NULL CHECK(outcome IN ('terminal','cancelled'))) STRICT`);
|
|
20
|
+
// The decoder forbids empty named graph ids; the empty SQL key names only the root.
|
|
21
|
+
sql.exec(`CREATE TABLE IF NOT EXISTS hub_graph_node_events (run_id TEXT NOT NULL,graph_id TEXT NOT NULL,node_id TEXT NOT NULL,iteration TEXT NOT NULL,poll INTEGER NOT NULL,state TEXT NOT NULL,attempt INTEGER NOT NULL,record TEXT NOT NULL,PRIMARY KEY(run_id,graph_id,node_id,iteration,poll,state,attempt)) STRICT`);
|
|
22
|
+
sql.exec(`CREATE TABLE IF NOT EXISTS workflow_graph_reviews (run_id TEXT NOT NULL,node_id TEXT NOT NULL,iteration TEXT NOT NULL,status TEXT NOT NULL CHECK(status IN ('pending','answered','delivered','timed_out','closed')),record TEXT NOT NULL,delivery TEXT,opened_at INTEGER NOT NULL,expires_at INTEGER NOT NULL,PRIMARY KEY(run_id,node_id,iteration)) STRICT`);
|
|
23
|
+
const read = (runId) => {
|
|
24
|
+
const row = sql
|
|
25
|
+
.exec("SELECT record FROM hub_graph_runs WHERE run_id=?", runId)
|
|
26
|
+
.toArray()[0];
|
|
27
|
+
return row === undefined
|
|
28
|
+
? null
|
|
29
|
+
: S.decodeUnknownSync(graphRunSchema)(JSON.parse(S.decodeUnknownSync(rowSchema)(row).record));
|
|
30
|
+
};
|
|
31
|
+
const requireRun = (runId) => {
|
|
32
|
+
const run = read(runId);
|
|
33
|
+
if (!run)
|
|
34
|
+
throw new Error("Unknown graph run");
|
|
35
|
+
return run;
|
|
36
|
+
};
|
|
37
|
+
const requireActive = (runId) => {
|
|
38
|
+
const run = read(runId);
|
|
39
|
+
if (run === null ||
|
|
40
|
+
sql
|
|
41
|
+
.exec("SELECT run_id FROM hub_graph_terminal_runs WHERE run_id=?", runId)
|
|
42
|
+
.toArray().length !== 0)
|
|
43
|
+
throw new Error("Workflow run is not active");
|
|
44
|
+
return run;
|
|
45
|
+
};
|
|
46
|
+
/** Only the Team run owner supplies already compiled, stored plans here. */
|
|
47
|
+
const startCompiled = (input) => {
|
|
48
|
+
const decoded = S.decodeUnknownSync(graphRunSchema)(input, {
|
|
49
|
+
onExcessProperty: "error",
|
|
50
|
+
});
|
|
51
|
+
const admitted = admitRunInput(decoded.plan.definition, decoded.input);
|
|
52
|
+
if (admitted.kind === "invalid")
|
|
53
|
+
throw new Error("Invalid graph run input");
|
|
54
|
+
const run = { ...decoded, input: admitted.input };
|
|
55
|
+
return options.transactionSync(() => {
|
|
56
|
+
const row = sql
|
|
57
|
+
.exec("SELECT record FROM hub_graph_runs WHERE request_id=? OR run_id=?", run.requestId, run.runId)
|
|
58
|
+
.toArray()[0];
|
|
59
|
+
if (row !== undefined) {
|
|
60
|
+
const prior = S.decodeUnknownSync(graphRunSchema)(JSON.parse(S.decodeUnknownSync(rowSchema)(row).record));
|
|
61
|
+
return canonicalJson(prior) === canonicalJson(run)
|
|
62
|
+
? "replay"
|
|
63
|
+
: "conflict";
|
|
64
|
+
}
|
|
65
|
+
sql.exec("INSERT INTO hub_graph_runs(run_id,request_id,installation_id,workflow_id,actor_id,started_at,record) VALUES(?,?,?,?,?,?,?)", run.runId, run.requestId, run.publication?.installationId ??
|
|
66
|
+
run.libraryInstallationId ??
|
|
67
|
+
run.trigger?.libraryInstallationId ??
|
|
68
|
+
null, run.publication?.workflowId ?? run.trigger?.workflowId ?? null, run.actorId, Date.now(), JSON.stringify(run));
|
|
69
|
+
return "started";
|
|
70
|
+
});
|
|
71
|
+
};
|
|
72
|
+
return {
|
|
73
|
+
read,
|
|
74
|
+
/**
|
|
75
|
+
* Up to 50 runs for one installation, newest first; a viewer narrows
|
|
76
|
+
* further to their own. `started_at` is the host clock at insert, kept
|
|
77
|
+
* out of the record so a replay still compares equal.
|
|
78
|
+
*/
|
|
79
|
+
list(filter) {
|
|
80
|
+
const conditions = ["installation_id=?"];
|
|
81
|
+
const params = [filter.installationId];
|
|
82
|
+
if (filter.actorId !== undefined) {
|
|
83
|
+
conditions.push("actor_id=?");
|
|
84
|
+
params.push(filter.actorId);
|
|
85
|
+
}
|
|
86
|
+
if (filter.workflowId !== undefined) {
|
|
87
|
+
conditions.push("workflow_id=?");
|
|
88
|
+
params.push(filter.workflowId);
|
|
89
|
+
}
|
|
90
|
+
return sql
|
|
91
|
+
.exec(`SELECT record,started_at FROM hub_graph_runs WHERE ${conditions.join(" AND ")} ORDER BY started_at DESC, run_id DESC LIMIT 50`, ...params)
|
|
92
|
+
.toArray()
|
|
93
|
+
.map((row) => {
|
|
94
|
+
const parsed = S.decodeUnknownSync(S.Struct({ record: S.String, started_at: S.Number }))(row);
|
|
95
|
+
return {
|
|
96
|
+
...S.decodeUnknownSync(graphRunSchema)(JSON.parse(parsed.record)),
|
|
97
|
+
startedAt: parsed.started_at,
|
|
98
|
+
};
|
|
99
|
+
});
|
|
100
|
+
},
|
|
101
|
+
/** Runs with no recorded outcome, oldest first. */
|
|
102
|
+
active() {
|
|
103
|
+
return sql
|
|
104
|
+
.exec("SELECT record FROM hub_graph_runs WHERE run_id NOT IN (SELECT run_id FROM hub_graph_terminal_runs) ORDER BY rowid")
|
|
105
|
+
.toArray()
|
|
106
|
+
.map((row) => S.decodeUnknownSync(graphRunSchema)(JSON.parse(S.decodeUnknownSync(rowSchema)(row).record)));
|
|
107
|
+
},
|
|
108
|
+
outcome(runId) {
|
|
109
|
+
if (read(runId) === null)
|
|
110
|
+
return null;
|
|
111
|
+
const row = sql
|
|
112
|
+
.exec("SELECT outcome FROM hub_graph_terminal_runs WHERE run_id=?", runId)
|
|
113
|
+
.toArray()[0];
|
|
114
|
+
return row === undefined
|
|
115
|
+
? "active"
|
|
116
|
+
: S.decodeUnknownSync(S.Struct({ outcome: S.Literals(["terminal", "cancelled"]) }))(row).outcome;
|
|
117
|
+
},
|
|
118
|
+
markTerminal(runId, outcome = "terminal") {
|
|
119
|
+
requireRun(runId);
|
|
120
|
+
options.transactionSync(() => {
|
|
121
|
+
sql.exec("INSERT OR IGNORE INTO hub_graph_terminal_runs(run_id,outcome) VALUES(?,?)", runId, outcome);
|
|
122
|
+
sql.exec("UPDATE workflow_graph_reviews SET status='closed',delivery=NULL WHERE run_id=? AND status IN ('pending','answered')", runId);
|
|
123
|
+
});
|
|
124
|
+
},
|
|
125
|
+
startCompiled,
|
|
126
|
+
async start(input) {
|
|
127
|
+
const run = S.decodeUnknownSync(graphRunSchema)(input, {
|
|
128
|
+
onExcessProperty: "error",
|
|
129
|
+
});
|
|
130
|
+
await decodeExecutionPlan(run.plan);
|
|
131
|
+
return startCompiled(run);
|
|
132
|
+
},
|
|
133
|
+
recordEvent(input) {
|
|
134
|
+
const event = S.decodeUnknownSync(nodeEventSchema)(input, {
|
|
135
|
+
onExcessProperty: "error",
|
|
136
|
+
});
|
|
137
|
+
requireActive(event.runId);
|
|
138
|
+
options.transactionSync(() => {
|
|
139
|
+
const prior = sql
|
|
140
|
+
.exec("SELECT record FROM hub_graph_node_events WHERE run_id=? AND graph_id=? AND node_id=? AND iteration=? AND poll=? AND state=? AND attempt=?", event.runId, event.graphId ?? "", event.nodeId, event.iteration, event.poll ?? -1, event.state, event.attempt)
|
|
141
|
+
.toArray()[0];
|
|
142
|
+
if (prior !== undefined)
|
|
143
|
+
return;
|
|
144
|
+
const count = S.decodeUnknownSync(S.Struct({ count: S.Number }))(sql
|
|
145
|
+
.exec("SELECT count(*) AS count FROM hub_graph_node_events WHERE run_id=?", event.runId)
|
|
146
|
+
.toArray()[0]).count;
|
|
147
|
+
if (count >= 4096)
|
|
148
|
+
throw new Error("Graph event bound exceeded");
|
|
149
|
+
sql.exec("INSERT OR IGNORE INTO hub_graph_node_events(run_id,graph_id,node_id,iteration,poll,state,attempt,record) VALUES(?,?,?,?,?,?,?,?)", event.runId, event.graphId ?? "", event.nodeId, event.iteration, event.poll ?? -1, event.state, event.attempt, JSON.stringify({ ...event, recordedAt: Date.now() }));
|
|
150
|
+
if (event.state === "failed" && event.refusal === "review_timeout")
|
|
151
|
+
sql.exec("UPDATE workflow_graph_reviews SET status='timed_out' WHERE run_id=? AND node_id=? AND iteration=? AND status IN ('pending','answered')", event.runId, event.nodeId, event.iteration);
|
|
152
|
+
});
|
|
153
|
+
},
|
|
154
|
+
events(runId) {
|
|
155
|
+
requireRun(runId);
|
|
156
|
+
return sql
|
|
157
|
+
.exec("SELECT record FROM hub_graph_node_events WHERE run_id=? ORDER BY rowid", runId)
|
|
158
|
+
.toArray()
|
|
159
|
+
.map((row) => S.decodeUnknownSync(nodeEventSchema)(JSON.parse(S.decodeUnknownSync(rowSchema)(row).record)));
|
|
160
|
+
},
|
|
161
|
+
openReview(input) {
|
|
162
|
+
const review = S.decodeUnknownSync(graphReviewSchema)(input, {
|
|
163
|
+
onExcessProperty: "error",
|
|
164
|
+
});
|
|
165
|
+
requireActive(review.runId);
|
|
166
|
+
return options.transactionSync(() => {
|
|
167
|
+
const row = sql
|
|
168
|
+
.exec("SELECT record,expires_at FROM workflow_graph_reviews WHERE run_id=? AND node_id=? AND iteration=?", review.runId, review.nodeId, review.iteration)
|
|
169
|
+
.toArray()[0];
|
|
170
|
+
if (row !== undefined) {
|
|
171
|
+
const stored = S.decodeUnknownSync(S.Struct({ record: S.String, expires_at: S.Number }))(row);
|
|
172
|
+
if (stored.record !== JSON.stringify(review))
|
|
173
|
+
throw new Error("Conflicting graph review");
|
|
174
|
+
return stored.expires_at;
|
|
175
|
+
}
|
|
176
|
+
const openedAt = Date.now();
|
|
177
|
+
const expiresAt = openedAt + review.timeout;
|
|
178
|
+
sql.exec("INSERT INTO workflow_graph_reviews(run_id,node_id,iteration,status,record,opened_at,expires_at) VALUES(?,?,?,'pending',?,?,?)", review.runId, review.nodeId, review.iteration, JSON.stringify(review), openedAt, expiresAt);
|
|
179
|
+
return expiresAt;
|
|
180
|
+
});
|
|
181
|
+
},
|
|
182
|
+
reviews(runId) {
|
|
183
|
+
requireRun(runId);
|
|
184
|
+
return sql
|
|
185
|
+
.exec("SELECT record,status,opened_at,expires_at FROM workflow_graph_reviews WHERE run_id=? ORDER BY rowid", runId)
|
|
186
|
+
.toArray()
|
|
187
|
+
.map((row) => {
|
|
188
|
+
const parsed = S.decodeUnknownSync(S.Struct({
|
|
189
|
+
record: S.String,
|
|
190
|
+
status: S.Literals([
|
|
191
|
+
"pending",
|
|
192
|
+
"answered",
|
|
193
|
+
"delivered",
|
|
194
|
+
"timed_out",
|
|
195
|
+
"closed",
|
|
196
|
+
]),
|
|
197
|
+
opened_at: S.Number,
|
|
198
|
+
expires_at: S.Number,
|
|
199
|
+
}))(row);
|
|
200
|
+
return {
|
|
201
|
+
review: S.decodeUnknownSync(graphReviewSchema)(JSON.parse(parsed.record)),
|
|
202
|
+
status: parsed.status,
|
|
203
|
+
openedAt: parsed.opened_at,
|
|
204
|
+
expiresAt: parsed.expires_at,
|
|
205
|
+
};
|
|
206
|
+
});
|
|
207
|
+
},
|
|
208
|
+
answerReview(key, answer, reviewer) {
|
|
209
|
+
const run = requireRun(key.runId);
|
|
210
|
+
if (sql
|
|
211
|
+
.exec("SELECT run_id FROM hub_graph_terminal_runs WHERE run_id=?", key.runId)
|
|
212
|
+
.toArray().length !== 0)
|
|
213
|
+
return "not_waiting";
|
|
214
|
+
const checked = S.decodeUnknownSync(reviewAnswerSchema)(answer, {
|
|
215
|
+
onExcessProperty: "error",
|
|
216
|
+
});
|
|
217
|
+
return options.transactionSync(() => {
|
|
218
|
+
const row = sql
|
|
219
|
+
.exec("SELECT record,status,expires_at FROM workflow_graph_reviews WHERE run_id=? AND node_id=? AND iteration=?", key.runId, key.nodeId, key.iteration)
|
|
220
|
+
.toArray()[0];
|
|
221
|
+
if (row === undefined)
|
|
222
|
+
return "not_waiting";
|
|
223
|
+
const parsed = S.decodeUnknownSync(S.Struct({
|
|
224
|
+
record: S.String,
|
|
225
|
+
status: S.String,
|
|
226
|
+
expires_at: S.Number,
|
|
227
|
+
}))(row);
|
|
228
|
+
const review = S.decodeUnknownSync(graphReviewSchema)(JSON.parse(parsed.record));
|
|
229
|
+
if (reviewer.kind === "agent" ||
|
|
230
|
+
(!reviewer.isOwner &&
|
|
231
|
+
(review.reviewers === "owners" || reviewer.actorId !== run.actorId)))
|
|
232
|
+
return "denied";
|
|
233
|
+
if (parsed.status !== "pending")
|
|
234
|
+
return "not_waiting";
|
|
235
|
+
if (Date.now() >= parsed.expires_at) {
|
|
236
|
+
sql.exec("UPDATE workflow_graph_reviews SET status='timed_out' WHERE run_id=? AND node_id=? AND iteration=? AND status='pending'", key.runId, key.nodeId, key.iteration);
|
|
237
|
+
return "not_waiting";
|
|
238
|
+
}
|
|
239
|
+
// The answer must fit the review: a choice it offers, or approve/reject.
|
|
240
|
+
if ("choice" in checked
|
|
241
|
+
? !review.choices?.includes(checked.choice)
|
|
242
|
+
: review.choices !== undefined)
|
|
243
|
+
return "invalid";
|
|
244
|
+
const delivery = {
|
|
245
|
+
...key,
|
|
246
|
+
instanceId: review.instanceId,
|
|
247
|
+
eventType: review.eventType,
|
|
248
|
+
answer: checked,
|
|
249
|
+
};
|
|
250
|
+
sql.exec("UPDATE workflow_graph_reviews SET status='answered',delivery=? WHERE run_id=? AND node_id=? AND iteration=? AND status='pending'", JSON.stringify(delivery), key.runId, key.nodeId, key.iteration);
|
|
251
|
+
return "accepted";
|
|
252
|
+
});
|
|
253
|
+
},
|
|
254
|
+
/** Durable pending delivery survives a sender restart; native event sends may be repeated. */
|
|
255
|
+
pendingReviewDeliveries(runId) {
|
|
256
|
+
requireRun(runId);
|
|
257
|
+
return sql
|
|
258
|
+
.exec("SELECT delivery FROM workflow_graph_reviews WHERE run_id=? AND status='answered'", runId)
|
|
259
|
+
.toArray()
|
|
260
|
+
.map((row) => S.decodeUnknownSync(deliverySchema)(JSON.parse(S.decodeUnknownSync(S.Struct({ delivery: S.String }))(row)
|
|
261
|
+
.delivery)));
|
|
262
|
+
},
|
|
263
|
+
markReviewDelivered(delivery) {
|
|
264
|
+
sql.exec("UPDATE workflow_graph_reviews SET status='delivered' WHERE run_id=? AND node_id=? AND iteration=? AND status='answered'", delivery.runId, delivery.nodeId, delivery.iteration);
|
|
265
|
+
},
|
|
266
|
+
};
|
|
267
|
+
}
|