forgeos 0.1.0-alpha.70 → 0.1.0-alpha.71
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/.agents/skills/forge-agent-fabric/SKILL.md +10 -1
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +6 -0
- package/docs/agent-fabric-programs.md +151 -168
- package/docs/agent-fabric.md +4 -6
- package/docs/architecture/agent-fabric/WORKFLOW_PROGRAM_V2_ADR.md +81 -41
- package/docs/architecture/agent-fabric/WORKFLOW_PROGRAM_V2_CORRECTIONS.md +39 -0
- package/docs/architecture/agent-fabric/WORKFLOW_PROGRAM_V2_CORRECTIONS_EVIDENCE.md +53 -0
- package/docs/architecture/agent-fabric/WORKFLOW_PROGRAM_V2_EVIDENCE.md +49 -109
- package/docs/architecture/agent-fabric/WORKFLOW_PROGRAM_V2_PLAN.md +463 -423
- package/examples/agent-fabric-v2/README.md +16 -0
- package/examples/agent-fabric-v2/item-child.workflow.ts +11 -0
- package/examples/agent-fabric-v2/map-child.workflow.ts +10 -0
- package/examples/agent-fabric-v2/migrate.workflow.ts +1 -1
- package/examples/agent-fabric-v2/run-ui-fixture.ts +48 -0
- package/examples/agent-fabric-v2/ui-audit.workflow.ts +28 -0
- package/examples/agent-fabric-v2/ui-fixture-worker.cjs +36 -0
- package/examples/agent-fabric-v2/visual-population.json +13 -0
- package/package.json +3 -1
- package/src/forge/_generated/releaseManifest.json +1 -1
- package/src/forge/_generated/releaseManifest.ts +3 -3
- package/src/forge/agent-fabric/codex-sdk-worker.ts +2 -0
- package/src/forge/agent-fabric/program-api.ts +37 -0
- package/src/forge/agent-fabric/program-contract.ts +89 -35
- package/src/forge/agent-fabric/program-dsl.ts +45 -31
- package/src/forge/agent-fabric/program-evidence.ts +22 -0
- package/src/forge/agent-fabric/program-image.ts +15 -0
- package/src/forge/agent-fabric/program-lock.ts +50 -0
- package/src/forge/agent-fabric/program-owner.ts +16 -0
- package/src/forge/agent-fabric/program-scheduler.ts +38 -0
- package/src/forge/agent-fabric/program-service.ts +275 -101
- package/src/forge/agent-fabric/program-store.ts +80 -55
- package/src/forge/agent-fabric/program-structure.ts +65 -0
- package/src/forge/agent-fabric/program-types.ts +21 -9
- package/src/forge/agent-fabric/program-view.ts +12 -0
- package/src/forge/agent-fabric/program-worker.ts +24 -7
- package/src/forge/agent-memory/mcp.ts +3 -3
- package/src/forge/cli/fabric.ts +2 -2
- package/src/forge/cli/main.ts +2 -0
- package/src/forge/cli/parse.ts +1 -1
- package/src/forge/version.ts +1 -1
|
@@ -1,22 +1,33 @@
|
|
|
1
|
+
import type { WorkflowRef, WorkflowOptions, WorkflowOperation, WorkflowDefinition, WorkflowExpression, WorkflowValue, WorkflowResolved, WorkflowActivityOptions, WorkflowBody, WorkflowCollection } from "./program-api.ts";
|
|
2
|
+
export type { WorkflowRef, WorkflowOptions, WorkflowOperation, WorkflowDefinition, WorkflowExpression, WorkflowValue, WorkflowResolved, WorkflowActivityOptions, WorkflowBody, WorkflowCollection } from "./program-api.ts";
|
|
1
3
|
import ts from "typescript";
|
|
2
|
-
import { programAssert, programValueBytes, type ProgramExpr, type ProgramOperation, type
|
|
4
|
+
import { programAssert, programValueBytes, type ProgramExpr, type ProgramOperation, type WorkflowProgramV2 } from "./program-contract.ts";
|
|
3
5
|
|
|
4
|
-
export const PROGRAM_DSL_VERSION = "forge-workflow-dsl/
|
|
5
|
-
const expressionNames = ["workflowInput", "item", "loopState", "output", "field", "literal", "object", "array", "eq", "and", "or", "not", "concat", "filter", "unique", "sort", "take", "length", "population", "acceptance", "acceptedCandidate", "acceptedCandidates", "outputCandidate", "candidateFromBaseline", "coverageFor", "coverageReceipt", "acceptanceWriteScope", "approvedChecksFor", "acceptanceChecks"];
|
|
6
|
-
const referenceNames = ["schemaRef", "executorRef", "policyRef", "acceptanceRef", "populationRef", "recipeRef", "programRef"];
|
|
7
|
-
const operationNames = ["agent", "command", "map", "branch", "loop", "compose", "gate", "repair", "subworkflow", "waitEvent", "value"];
|
|
6
|
+
export const PROGRAM_DSL_VERSION = "forge-workflow-dsl/2";
|
|
7
|
+
export const expressionNames = ["workflowInput", "item", "loopState", "output", "field", "literal", "object", "array", "eq", "and", "or", "not", "concat", "filter", "unique", "sort", "take", "length", "population", "acceptance", "acceptedCandidate", "acceptedCandidates", "outputCandidate", "candidateFromBaseline", "coverageFor", "coverageReceipt", "acceptanceWriteScope", "approvedChecksFor", "acceptanceChecks"];
|
|
8
|
+
export const referenceNames = ["schemaRef", "executorRef", "policyRef", "acceptanceRef", "populationRef", "recipeRef", "programRef"];
|
|
9
|
+
export const operationNames = ["agent", "command", "map", "branch", "loop", "compose", "gate", "repair", "subworkflow", "waitEvent", "value", "sequence", "parallel"];
|
|
8
10
|
export function workflowExpression(name: string, ...args: unknown[]): ProgramExpr { return { $expr: name, args }; }
|
|
9
11
|
export function workflowOperation(kind: string, id: string, options: Record<string, unknown>): ProgramOperation { return { kind, id, options }; }
|
|
10
|
-
export function schemaRef(id: string, version: string):
|
|
11
|
-
export const executorRef =
|
|
12
|
-
export const
|
|
13
|
-
export const
|
|
14
|
-
export const
|
|
15
|
-
export const
|
|
16
|
-
export const
|
|
17
|
-
export const
|
|
18
|
-
export const
|
|
19
|
-
export const
|
|
12
|
+
export function schemaRef<T = unknown>(id: string, version: string): WorkflowRef<"schema", unknown, T> { return { id, version } as WorkflowRef<"schema", unknown, T>; }
|
|
13
|
+
export const executorRef = <Input = unknown, Output = unknown>(id: string, version: string) => ({ id, version } as WorkflowRef<"executor", Input, Output>);
|
|
14
|
+
export const policyRef = (id: string, version: string) => ({ id, version } as WorkflowRef<"policy">);
|
|
15
|
+
export const acceptanceRef = (id: string, version: string) => ({ id, version } as WorkflowRef<"acceptance">);
|
|
16
|
+
export const populationRef = (id: string, version: string) => ({ id, version } as WorkflowRef<"population">);
|
|
17
|
+
export const recipeRef = (id: string, version: string) => ({ id, version } as WorkflowRef<"recipe">);
|
|
18
|
+
export const programRef = <Input = unknown, Output = unknown>(id: string, version: string) => ({ id, version } as WorkflowRef<"program", Input, Output>);
|
|
19
|
+
export const workflowInput = <T = unknown>() => workflowExpression("workflowInput") as WorkflowExpression<T>;
|
|
20
|
+
export const item = <T = unknown>() => workflowExpression("item") as WorkflowExpression<T>;
|
|
21
|
+
export const loopState = <T = unknown>() => workflowExpression("loopState") as WorkflowExpression<T>;
|
|
22
|
+
export function output<T>(operation: WorkflowOperation<keyof WorkflowOptions, T>): WorkflowExpression<T>;
|
|
23
|
+
export function output(id: string): WorkflowExpression;
|
|
24
|
+
export function output(operation: string | WorkflowOperation): WorkflowExpression { return workflowExpression("output", typeof operation === "string" ? operation : operation.id); }
|
|
25
|
+
export function field<T, K extends (unknown extends T ? string : keyof T & string)>(value: WorkflowExpression<T>, key: K): WorkflowExpression<unknown extends T ? unknown : T[K & keyof T]>;
|
|
26
|
+
export function field<T, K extends keyof T & string>(value: T, key: K): WorkflowExpression<T[K]>;
|
|
27
|
+
export function field(value: unknown, key: string): WorkflowExpression { return workflowExpression("field", value, key); }
|
|
28
|
+
export const literal = <T>(value: T) => workflowExpression("literal", value) as WorkflowExpression<T>;
|
|
29
|
+
export const object = <T extends Record<string, unknown>>(value: T) => workflowExpression("object", value) as WorkflowExpression<WorkflowResolved<T>>;
|
|
30
|
+
export const array = <T extends unknown[]>(...values: T) => workflowExpression("array", ...values) as WorkflowExpression<WorkflowResolved<T>>;
|
|
20
31
|
export const eq = (left: unknown, right: unknown) => workflowExpression("eq", left, right);
|
|
21
32
|
export const and = (...values: unknown[]) => workflowExpression("and", ...values);
|
|
22
33
|
export const or = (...values: unknown[]) => workflowExpression("or", ...values);
|
|
@@ -32,26 +43,28 @@ export const acceptance = () => workflowExpression("acceptance");
|
|
|
32
43
|
export const acceptedCandidate = (id: string) => workflowExpression("acceptedCandidate", id);
|
|
33
44
|
export const acceptedCandidates = (id: string) => workflowExpression("acceptedCandidates", id);
|
|
34
45
|
export const outputCandidate = (id: string) => workflowExpression("outputCandidate", id);
|
|
35
|
-
export const candidateFromBaseline = (entry
|
|
46
|
+
export const candidateFromBaseline = (entry?: unknown) => entry === undefined ? workflowExpression("candidateFromBaseline") : workflowExpression("candidateFromBaseline", entry);
|
|
36
47
|
export const coverageFor = (contract: unknown, discovered: unknown) => workflowExpression("coverageFor", contract, discovered);
|
|
37
48
|
export const coverageReceipt = (id: string) => workflowExpression("coverageReceipt", id);
|
|
38
49
|
export const acceptanceWriteScope = (id: string) => workflowExpression("acceptanceWriteScope", id);
|
|
39
50
|
export const approvedChecksFor = (entry: unknown) => workflowExpression("approvedChecksFor", entry);
|
|
40
51
|
export const acceptanceChecks = (id: string) => workflowExpression("acceptanceChecks", id);
|
|
41
|
-
export const agent = (id: string, options:
|
|
42
|
-
export const command = (id: string, options:
|
|
43
|
-
export const map = (id: string, options:
|
|
44
|
-
export const branch = (id: string, options:
|
|
45
|
-
export const loop = (id: string, options:
|
|
46
|
-
export const compose = (id: string, options:
|
|
47
|
-
export const gate = (id: string, options:
|
|
48
|
-
export const repair = (id: string, options:
|
|
49
|
-
export const subworkflow = (id: string, options:
|
|
50
|
-
export const waitEvent = (id: string, options:
|
|
51
|
-
export const value = (id: string, options:
|
|
52
|
-
export function defineWorkflow(program:
|
|
52
|
+
export const agent = <Input = unknown, Output = unknown>(id: string, options: WorkflowActivityOptions<Input, Output>) => workflowOperation("agent", id, options as unknown as Record<string, unknown>) as WorkflowOperation<"agent", Output>;
|
|
53
|
+
export const command = <Input = unknown, Output = unknown>(id: string, options: WorkflowActivityOptions<Input, Output>) => workflowOperation("command", id, options as unknown as Record<string, unknown>) as WorkflowOperation<"command", Output>;
|
|
54
|
+
export const map = <T = unknown>(id: string, options: Omit<WorkflowOptions["map"], "body"> & { body: WorkflowBody<T> }) => workflowOperation("map", id, options as unknown as Record<string, unknown>) as WorkflowOperation<"map", WorkflowCollection<T>>;
|
|
55
|
+
export const branch = (id: string, options: WorkflowOptions["branch"]) => workflowOperation("branch", id, options as unknown as Record<string, unknown>) as WorkflowOperation<"branch">;
|
|
56
|
+
export const loop = (id: string, options: WorkflowOptions["loop"]) => workflowOperation("loop", id, options as unknown as Record<string, unknown>) as WorkflowOperation<"loop">;
|
|
57
|
+
export const compose = (id: string, options: WorkflowOptions["compose"]) => workflowOperation("compose", id, options as unknown as Record<string, unknown>) as WorkflowOperation<"compose">;
|
|
58
|
+
export const gate = (id: string, options: WorkflowOptions["gate"]) => workflowOperation("gate", id, options as unknown as Record<string, unknown>) as WorkflowOperation<"gate">;
|
|
59
|
+
export const repair = (id: string, options: WorkflowOptions["repair"]) => workflowOperation("repair", id, options as unknown as Record<string, unknown>) as WorkflowOperation<"repair">;
|
|
60
|
+
export const subworkflow = <Input = unknown, Output = unknown>(id: string, options: Omit<WorkflowOptions["subworkflow"], "program" | "input"> & { program: WorkflowRef<"program", Input, Output>; input: WorkflowValue<NoInfer<Input>> }) => workflowOperation("subworkflow", id, options as unknown as Record<string, unknown>) as WorkflowOperation<"subworkflow", Output>;
|
|
61
|
+
export const waitEvent = <T = unknown>(id: string, options: Omit<WorkflowOptions["waitEvent"], "schema"> & { schema: WorkflowRef<"schema", unknown, T> }) => workflowOperation("waitEvent", id, options as unknown as Record<string, unknown>) as WorkflowOperation<"waitEvent", T>;
|
|
62
|
+
export const value = <T>(id: string, options: Omit<WorkflowOptions["value"], "value"> & { value: T }) => workflowOperation("value", id, options as unknown as Record<string, unknown>) as WorkflowOperation<"value", WorkflowResolved<T>>;
|
|
63
|
+
export function defineWorkflow(program: WorkflowDefinition): WorkflowProgramV2 { return { schemaVersion: 2, operatorVersion: 2, ...program }; }
|
|
53
64
|
|
|
54
65
|
/** Parse a finite AST. Never transpile, import, evaluate, or call authored JavaScript. */
|
|
66
|
+
export const sequence = (id: string, options: WorkflowOptions["sequence"]) => workflowOperation("sequence", id, options as unknown as Record<string, unknown>) as WorkflowOperation<"sequence">;
|
|
67
|
+
export const parallel = (id: string, options: WorkflowOptions["parallel"]) => workflowOperation("parallel", id, options as unknown as Record<string, unknown>) as WorkflowOperation<"parallel">;
|
|
55
68
|
export function lowerWorkflowSource(source: string): WorkflowProgramV2 {
|
|
56
69
|
programAssert(Buffer.byteLength(source) <= 256 * 1024, "DSL source too large");
|
|
57
70
|
const file = ts.createSourceFile("workflow.ts", source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
|
|
@@ -67,7 +80,7 @@ export function lowerWorkflowSource(source: string): WorkflowProgramV2 {
|
|
|
67
80
|
if (node.kind === ts.SyntaxKind.FalseKeyword) return false;
|
|
68
81
|
if (node.kind === ts.SyntaxKind.NullKeyword) return null;
|
|
69
82
|
if (ts.isPrefixUnaryExpression(node) && node.operator === ts.SyntaxKind.MinusToken && ts.isNumericLiteral(node.operand)) return -Number(node.operand.text);
|
|
70
|
-
if (ts.isParenthesizedExpression(node)) return next(node.expression);
|
|
83
|
+
if (ts.isParenthesizedExpression(node) || ts.isAsExpression(node) || ts.isSatisfiesExpression(node)) return next(node.expression);
|
|
71
84
|
if (ts.isIdentifier(node)) { programAssert(bindings.has(node.text), `Unknown binding ${node.text}`); programValueBytes(bindings.get(node.text), 4 * 1024 * 1024); return structuredClone(bindings.get(node.text)); }
|
|
72
85
|
if (ts.isArrayLiteralExpression(node)) { programAssert(node.elements.every(element => !ts.isSpreadElement(element)), "Spread forbidden"); return evaluateList(node.elements, depth + 1); }
|
|
73
86
|
if (ts.isObjectLiteralExpression(node)) {
|
|
@@ -87,10 +100,10 @@ export function lowerWorkflowSource(source: string): WorkflowProgramV2 {
|
|
|
87
100
|
programAssert(ts.isIdentifier(node.expression), "Only DSL constructors allowed");
|
|
88
101
|
const name = imports.get(node.expression.text); programAssert(name, `Unimported constructor ${node.expression.text}`);
|
|
89
102
|
const args = evaluateList(node.arguments, depth + 1);
|
|
90
|
-
if (expressionNames.includes(name)) return { $expr: name, args };
|
|
103
|
+
if (expressionNames.includes(name)) { if (name === "output" && args[0] && typeof args[0] === "object" && "kind" in args[0]) args[0] = (args[0] as ProgramOperation).id; return { $expr: name, args }; }
|
|
91
104
|
if (referenceNames.includes(name)) { programAssert(args.length === 2 && args.every(arg => typeof arg === "string"), "Versioned registry reference required"); return { id: args[0], version: args[1] }; }
|
|
92
105
|
if (operationNames.includes(name)) { programAssert(args.length === 2 && typeof args[0] === "string" && args[1] && typeof args[1] === "object", "Operation ID and options required"); return { kind: name, id: args[0], options: args[1] }; }
|
|
93
|
-
programAssert(name === "defineWorkflow" && args.length === 1 && args[0] && typeof args[0] === "object", "Invalid workflow constructor"); return { schemaVersion: 2, ...args[0] };
|
|
106
|
+
programAssert(name === "defineWorkflow" && args.length === 1 && args[0] && typeof args[0] === "object", "Invalid workflow constructor"); return { schemaVersion: 2, operatorVersion: 2, ...args[0] };
|
|
94
107
|
}
|
|
95
108
|
programAssert(false, `Unsupported authored syntax ${ts.SyntaxKind[node.kind]}`);
|
|
96
109
|
}
|
|
@@ -99,6 +112,7 @@ export function lowerWorkflowSource(source: string): WorkflowProgramV2 {
|
|
|
99
112
|
programAssert(ts.isStringLiteral(statement.moduleSpecifier) && statement.moduleSpecifier.text === "forgeos/agent-fabric/workflows" && statement.importClause?.namedBindings && ts.isNamedImports(statement.importClause.namedBindings), "Only versioned Forge DSL named imports allowed");
|
|
100
113
|
for (const element of statement.importClause.namedBindings.elements) {
|
|
101
114
|
const name = element.propertyName?.text ?? element.name.text;
|
|
115
|
+
if (statement.importClause.isTypeOnly || element.isTypeOnly) { programAssert(["WorkflowOptions", "WorkflowRef", "WorkflowOperation", "WorkflowDefinition", "WorkflowExpression", "WorkflowValue", "WorkflowResolved", "WorkflowActivityOptions", "WorkflowBody", "WorkflowCollection"].includes(name), "Unknown DSL author type"); continue; }
|
|
102
116
|
programAssert(["defineWorkflow", ...expressionNames, ...referenceNames, ...operationNames].includes(name) && !imports.has(element.name.text), "Unknown/duplicate DSL import"); imports.set(element.name.text, name);
|
|
103
117
|
}
|
|
104
118
|
} else if (ts.isVariableStatement(statement)) {
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { programAssert, programDigest, type ProgramAcceptance, type ProgramCaptureReceipt, type ProgramPopulation, type ProgramVisualCase } from "./program-contract.ts";
|
|
2
|
+
import { canonicalItemKey } from "./program-structure.ts";
|
|
3
|
+
|
|
4
|
+
export function validateVisualPopulation(population: ProgramPopulation): void {
|
|
5
|
+
if (!population.visualCases) return;
|
|
6
|
+
const keys = population.members.map(canonicalItemKey), cases = population.visualCases;
|
|
7
|
+
programAssert(cases.length === keys.length && new Set(cases.map(entry => canonicalItemKey(entry.itemKey))).size === keys.length && cases.every(entry => keys.includes(canonicalItemKey(entry.itemKey))), "Visual cases must cover exactly owner population");
|
|
8
|
+
const tuples = cases.map(entry => programDigest([entry.route, entry.viewport, entry.state]));
|
|
9
|
+
programAssert(new Set(tuples).size === tuples.length, "Duplicate visual route/viewport/state case");
|
|
10
|
+
for (const entry of cases) programAssert([entry.route, entry.viewport, entry.state].every(value => typeof value === "string" && value.length > 0 && value.length <= 2048) && [entry.width, entry.height].every(value => Number.isSafeInteger(value) && value > 0 && value <= 16384) && entry.width * entry.height <= 64 * 1024 * 1024, "Invalid expected visual case");
|
|
11
|
+
}
|
|
12
|
+
export function captureKeys(acceptance: ProgramAcceptance, phase: "item" | "final", members: string[], itemKey?: string): string[] {
|
|
13
|
+
const required = (acceptance.obligations ?? []).some(obligation => (phase === "final" || obligation.scope === "item") && obligation.requiredEvidence?.includes("capture"));
|
|
14
|
+
if (!required) return [];
|
|
15
|
+
programAssert(members.length > 0 && (phase !== "item" || itemKey && members.includes(itemKey)), "Capture obligations require an owner population");
|
|
16
|
+
return phase === "item" ? [itemKey!] : members;
|
|
17
|
+
}
|
|
18
|
+
export function validateVisualCapture(capture: Pick<ProgramCaptureReceipt, "itemKey" | "route" | "viewport" | "state" | "width" | "height">, cases?: ProgramVisualCase[]): void {
|
|
19
|
+
if (!cases) return;
|
|
20
|
+
const expected = cases.find(entry => canonicalItemKey(entry.itemKey) === canonicalItemKey(capture.itemKey));
|
|
21
|
+
programAssert(expected && ["route", "viewport", "state", "width", "height"].every(key => expected[key as keyof ProgramVisualCase] === capture[key as keyof typeof capture]), "Capture does not match owner visual case");
|
|
22
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { programAssert } from "./program-contract.ts";
|
|
2
|
+
|
|
3
|
+
/** Bounded image header validation. Pixel decoding/render acceptance belongs to the registered checker. */
|
|
4
|
+
export function programImage(bytes: Uint8Array): { mime: string; width: number; height: number } {
|
|
5
|
+
const data = Buffer.from(bytes); programAssert(data.length >= 24 && data.length <= 8 * 1024 * 1024, "Invalid image byte size");
|
|
6
|
+
let mime = "", width = 0, height = 0;
|
|
7
|
+
if (data.subarray(0,8).equals(Buffer.from([137,80,78,71,13,10,26,10])) && data.toString("ascii",12,16) === "IHDR") { mime = "image/png"; width = data.readUInt32BE(16); height = data.readUInt32BE(20); }
|
|
8
|
+
else if (data[0] === 255 && data[1] === 216) {
|
|
9
|
+
mime = "image/jpeg"; let offset = 2;
|
|
10
|
+
while (offset + 9 < data.length) { if (data[offset++] !== 255) break; const marker = data[offset++], size = data.readUInt16BE(offset); if ([192,193,194].includes(marker)) { height = data.readUInt16BE(offset+3); width = data.readUInt16BE(offset+5); break; } if (size < 2) break; offset += size; }
|
|
11
|
+
} else if (data.toString("ascii",0,4) === "RIFF" && data.toString("ascii",8,12) === "WEBP" && data.toString("ascii",12,16) === "VP8X" && data.length >= 30) {
|
|
12
|
+
mime = "image/webp"; width = 1 + data.readUIntLE(24,3); height = 1 + data.readUIntLE(27,3);
|
|
13
|
+
}
|
|
14
|
+
programAssert(width > 0 && height > 0 && width <= 16384 && height <= 16384 && width * height <= 64 * 1024 * 1024, "Unsupported/invalid image header or pixel budget"); return { mime, width, height };
|
|
15
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { link, open, readFile, unlink } from "node:fs/promises";
|
|
2
|
+
import { readFileSync, unlinkSync } from "node:fs";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { programAssert } from "./program-contract.ts";
|
|
5
|
+
|
|
6
|
+
const code = (error: unknown) => (error as NodeJS.ErrnoException).code;
|
|
7
|
+
const transient = (error: unknown) => ["ENOENT", "EEXIST", "EPERM", "EACCES", "EBUSY"].includes(code(error) ?? "");
|
|
8
|
+
function dead(bytes: string): boolean {
|
|
9
|
+
const holder = JSON.parse(bytes) as { pid: number };
|
|
10
|
+
if (!Number.isSafeInteger(holder.pid) || holder.pid <= 0) return false;
|
|
11
|
+
try { process.kill(holder.pid, 0); return false; } catch (error) { return code(error) === "ESRCH"; }
|
|
12
|
+
}
|
|
13
|
+
/** Shared local hardlink protocol. Only a confirmed dead holder may be reclaimed. */
|
|
14
|
+
export class ProgramFileLease {
|
|
15
|
+
private constructor(readonly path: string, readonly token: string, private bytes: string, private lostMessage: string) {}
|
|
16
|
+
static async acquire(path: string, options: { waitMs?: number; busyCode?: string; busyMessage?: string; lostMessage?: string } = {}): Promise<ProgramFileLease> {
|
|
17
|
+
const token = randomUUID(), bytes = JSON.stringify({ pid: process.pid, token });
|
|
18
|
+
const candidate = `${path}.${token}.candidate`, reclaim = `${path}.reclaim`, deadline = Date.now() + (options.waitMs ?? 0);
|
|
19
|
+
const handle = await open(candidate, "wx", 0o600);
|
|
20
|
+
try { await handle.writeFile(bytes); await handle.sync(); } finally { await handle.close(); }
|
|
21
|
+
try {
|
|
22
|
+
for (;;) {
|
|
23
|
+
try { await link(candidate, path); return new ProgramFileLease(path, token, bytes, options.lostMessage ?? "Program lock ownership changed"); }
|
|
24
|
+
catch (error) { if (!transient(error)) throw error; }
|
|
25
|
+
let reclaimed = false;
|
|
26
|
+
try {
|
|
27
|
+
const old = await readFile(path, "utf8");
|
|
28
|
+
if (dead(old)) {
|
|
29
|
+
try { const guard = readFileSync(reclaim, "utf8"); if (dead(guard) && readFileSync(reclaim, "utf8") === guard) unlinkSync(reclaim); }
|
|
30
|
+
catch (error) { if (!transient(error) && !(error instanceof SyntaxError)) throw error; }
|
|
31
|
+
let claimed = false;
|
|
32
|
+
try {
|
|
33
|
+
await link(candidate, reclaim); claimed = true;
|
|
34
|
+
if (readFileSync(path, "utf8") === old) { unlinkSync(path); reclaimed = true; }
|
|
35
|
+
} finally {
|
|
36
|
+
if (claimed) { programAssert(readFileSync(reclaim, "utf8") === bytes, "Reclaim identity changed"); unlinkSync(reclaim); }
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
} catch (error) { if (!transient(error) && !(error instanceof SyntaxError)) throw error; }
|
|
40
|
+
if (reclaimed) continue;
|
|
41
|
+
programAssert(Date.now() < deadline, options.busyMessage ?? "Program lock busy", options.busyCode ?? "AF_PROGRAM_CONFLICT");
|
|
42
|
+
await new Promise(resolve => setTimeout(resolve, 20));
|
|
43
|
+
}
|
|
44
|
+
} finally { await unlink(candidate).catch(() => {}); }
|
|
45
|
+
}
|
|
46
|
+
async assert(): Promise<void> { programAssert(await readFile(this.path, "utf8") === this.bytes, this.lostMessage); }
|
|
47
|
+
async close(): Promise<void> {
|
|
48
|
+
programAssert(readFileSync(this.path, "utf8") === this.bytes, this.lostMessage); unlinkSync(this.path);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { ProgramFileLease } from "./program-lock.ts";
|
|
3
|
+
|
|
4
|
+
/** Owner and transactions use the same crash-recoverable admission protocol. */
|
|
5
|
+
export class ProgramOwnerLease {
|
|
6
|
+
private constructor(private lease: ProgramFileLease) {}
|
|
7
|
+
get path(): string { return this.lease.path; }
|
|
8
|
+
get epoch(): string { return this.lease.token; }
|
|
9
|
+
static async acquire(directory: string): Promise<ProgramOwnerLease> {
|
|
10
|
+
return new ProgramOwnerLease(await ProgramFileLease.acquire(join(directory, "owner.lock"), {
|
|
11
|
+
busyCode: "AF_PROGRAM_OWNER_BUSY", busyMessage: "Another program owner is active or reclaiming", lostMessage: "Owner admission lease lost",
|
|
12
|
+
}));
|
|
13
|
+
}
|
|
14
|
+
async assert(): Promise<void> { await this.lease.assert(); }
|
|
15
|
+
async close(): Promise<void> { await this.lease.close(); }
|
|
16
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { programAssert } from "./program-contract.ts";
|
|
2
|
+
|
|
3
|
+
export interface ActivityScope { id: string; limit: number }
|
|
4
|
+
interface Ticket { runId: string; runLimit: number; scopes: ActivityScope[]; resolve: (release: () => void) => void; reject: (error: Error) => void; signal: AbortSignal; abort: () => void }
|
|
5
|
+
/** Round robin between eligible runs; parent controls never hold activity capacity. */
|
|
6
|
+
export class ProgramActivityScheduler {
|
|
7
|
+
private tickets: Ticket[] = [];
|
|
8
|
+
private active = new Map<string, number>();
|
|
9
|
+
private scopes = new Map<string, number>();
|
|
10
|
+
private count = 0;
|
|
11
|
+
private lastRun = "";
|
|
12
|
+
constructor(readonly ownerLimit = 4) { programAssert(Number.isSafeInteger(ownerLimit) && ownerLimit > 0 && ownerLimit <= 32, "Invalid owner capacity"); }
|
|
13
|
+
restore(runId: string, scopes: ActivityScope[]): () => void {
|
|
14
|
+
this.count++; this.active.set(runId, (this.active.get(runId) ?? 0) + 1);
|
|
15
|
+
for (const scope of scopes) this.scopes.set(scope.id, (this.scopes.get(scope.id) ?? 0) + 1);
|
|
16
|
+
let released = false;
|
|
17
|
+
return () => { if (released) return; released = true; this.count--; this.active.set(runId, this.active.get(runId)! - 1); for (const scope of scopes) this.scopes.set(scope.id, this.scopes.get(scope.id)! - 1); this.drain(); };
|
|
18
|
+
}
|
|
19
|
+
acquire(runId: string, runLimit: number, scopes: ActivityScope[], signal: AbortSignal): Promise<() => void> {
|
|
20
|
+
return new Promise((resolve, reject) => {
|
|
21
|
+
const ticket: Ticket = { runId, runLimit, scopes, resolve, reject, signal, abort: () => { this.tickets = this.tickets.filter(entry => entry !== ticket); reject(new Error("Activity admission canceled")); this.drain(); } };
|
|
22
|
+
if (signal.aborted) { ticket.abort(); return; }
|
|
23
|
+
signal.addEventListener("abort", ticket.abort, { once: true }); this.tickets.push(ticket); this.drain();
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
private drain(): void {
|
|
27
|
+
while (this.count < this.ownerLimit) {
|
|
28
|
+
const eligible = this.tickets.filter(ticket => !ticket.signal.aborted && (this.active.get(ticket.runId) ?? 0) < ticket.runLimit && ticket.scopes.every(scope => (this.scopes.get(scope.id) ?? 0) < scope.limit));
|
|
29
|
+
const ticket = eligible.find(entry => entry.runId !== this.lastRun) ?? eligible[0]; if (!ticket) return;
|
|
30
|
+
this.tickets.splice(this.tickets.indexOf(ticket), 1); ticket.signal.removeEventListener("abort", ticket.abort);
|
|
31
|
+
this.lastRun = ticket.runId; this.count++; this.active.set(ticket.runId, (this.active.get(ticket.runId) ?? 0) + 1);
|
|
32
|
+
for (const scope of ticket.scopes) this.scopes.set(scope.id, (this.scopes.get(scope.id) ?? 0) + 1);
|
|
33
|
+
let released = false;
|
|
34
|
+
ticket.resolve(() => { if (released) return; released = true; this.count--; this.active.set(ticket.runId, this.active.get(ticket.runId)! - 1); for (const scope of ticket.scopes) this.scopes.set(scope.id, this.scopes.get(scope.id)! - 1); this.drain(); });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
snapshot(): { active: number; queued: number; ownerLimit: number } { return { active: this.count, queued: this.tickets.length, ownerLimit: this.ownerLimit }; }
|
|
38
|
+
}
|