pipeline-sdk 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 +146 -0
- package/package.json +63 -0
- package/schemas/pipeline.schema.json +158 -0
- package/src/adapters/claude-code.ts +112 -0
- package/src/adapters/detector.ts +26 -0
- package/src/adapters/generic.ts +30 -0
- package/src/adapters/interface.ts +7 -0
- package/src/cli/advance.ts +27 -0
- package/src/cli/cleanup.ts +55 -0
- package/src/cli/helpers.ts +52 -0
- package/src/cli/index.ts +92 -0
- package/src/cli/init.ts +248 -0
- package/src/cli/resume.ts +45 -0
- package/src/cli/signal.ts +21 -0
- package/src/cli/start.ts +33 -0
- package/src/cli/status.ts +24 -0
- package/src/cli/template.ts +28 -0
- package/src/cli/validate.ts +21 -0
- package/src/cli/verify.ts +33 -0
- package/src/cli/visualize.ts +36 -0
- package/src/core/cleanup.ts +75 -0
- package/src/core/evidence.ts +144 -0
- package/src/core/gate-runner.ts +109 -0
- package/src/core/loader.ts +125 -0
- package/src/core/state-machine.ts +119 -0
- package/src/daemon/ipc.ts +56 -0
- package/src/daemon/server.ts +144 -0
- package/src/daemon/state-file.ts +65 -0
- package/src/gates/async.ts +60 -0
- package/src/gates/builtin.ts +40 -0
- package/src/gates/custom.ts +71 -0
- package/src/index.ts +20 -0
- package/src/mcp/prompts.ts +40 -0
- package/src/mcp/resources.ts +71 -0
- package/src/mcp/server.ts +211 -0
- package/src/mcp/tools.ts +52 -0
- package/src/templates/infra-gitops.yaml +37 -0
- package/src/templates/sdlc-full.yaml +69 -0
- package/src/templates/static-site.yaml +45 -0
- package/src/templates/zship.yaml +224 -0
- package/src/types.ts +210 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import type { CleanupHandler } from "../types";
|
|
3
|
+
|
|
4
|
+
interface CleanupEntry {
|
|
5
|
+
stage: string;
|
|
6
|
+
handlers: CleanupHandler[];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface CleanupResult {
|
|
10
|
+
stage: string;
|
|
11
|
+
script: string;
|
|
12
|
+
success: boolean;
|
|
13
|
+
error?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class CleanupManager {
|
|
17
|
+
private registry: CleanupEntry[] = [];
|
|
18
|
+
|
|
19
|
+
register(stage: string, handlers: CleanupHandler[]): void {
|
|
20
|
+
this.registry.push({ stage, handlers });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async runAll(workspaceRoot: string): Promise<CleanupResult[]> {
|
|
24
|
+
const results: CleanupResult[] = [];
|
|
25
|
+
|
|
26
|
+
// LIFO — reverse registration order
|
|
27
|
+
const reversed = [...this.registry].reverse();
|
|
28
|
+
|
|
29
|
+
for (const entry of reversed) {
|
|
30
|
+
for (const handler of entry.handlers) {
|
|
31
|
+
const result = await this.runHandler(entry.stage, handler, workspaceRoot);
|
|
32
|
+
results.push(result);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return results;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
private runHandler(stage: string, handler: CleanupHandler, cwd: string): Promise<CleanupResult> {
|
|
40
|
+
return new Promise((resolve) => {
|
|
41
|
+
const child = spawn(handler.script, {
|
|
42
|
+
cwd,
|
|
43
|
+
shell: false,
|
|
44
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
let stderr = "";
|
|
48
|
+
child.stderr?.on("data", (chunk: Buffer) => {
|
|
49
|
+
stderr += chunk.toString();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
child.on("error", (err) => {
|
|
53
|
+
resolve({
|
|
54
|
+
stage,
|
|
55
|
+
script: handler.script,
|
|
56
|
+
success: false,
|
|
57
|
+
error: err.message,
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
child.on("close", (code) => {
|
|
62
|
+
if (code === 0) {
|
|
63
|
+
resolve({ stage, script: handler.script, success: true });
|
|
64
|
+
} else {
|
|
65
|
+
resolve({
|
|
66
|
+
stage,
|
|
67
|
+
script: handler.script,
|
|
68
|
+
success: false,
|
|
69
|
+
error: stderr.trim() || `exited with code ${code}`,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import type { ArtifactRef, EvidenceRecord, GateResult } from "../types";
|
|
5
|
+
|
|
6
|
+
export interface RecordInput {
|
|
7
|
+
stage_id: string;
|
|
8
|
+
pipeline_id: string;
|
|
9
|
+
duration_ms: number;
|
|
10
|
+
agent: { provider: string; model?: string; session_id?: string };
|
|
11
|
+
gates_passed: GateResult[];
|
|
12
|
+
artifacts: ArtifactRef[];
|
|
13
|
+
git: { tree_sha: string; branch?: string; files_changed?: number };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface VerificationResult {
|
|
17
|
+
valid: boolean;
|
|
18
|
+
recordCount: number;
|
|
19
|
+
errors: string[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class EvidenceStore {
|
|
23
|
+
private readonly dir: string;
|
|
24
|
+
private _head: string | null = null;
|
|
25
|
+
|
|
26
|
+
constructor(dir: string) {
|
|
27
|
+
this.dir = dir;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async record(input: RecordInput): Promise<EvidenceRecord> {
|
|
31
|
+
await mkdir(this.dir, { recursive: true });
|
|
32
|
+
|
|
33
|
+
const partial: Omit<EvidenceRecord, "evidence_hash"> = {
|
|
34
|
+
stage_id: input.stage_id,
|
|
35
|
+
pipeline_id: input.pipeline_id,
|
|
36
|
+
timestamp: new Date().toISOString(),
|
|
37
|
+
duration_ms: input.duration_ms,
|
|
38
|
+
agent: input.agent,
|
|
39
|
+
gates_passed: input.gates_passed,
|
|
40
|
+
artifacts: input.artifacts,
|
|
41
|
+
git: input.git,
|
|
42
|
+
previous_evidence_hash: this._head,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const evidence_hash = computeHash(partial);
|
|
46
|
+
|
|
47
|
+
const evidenceRecord: EvidenceRecord = { ...partial, evidence_hash };
|
|
48
|
+
|
|
49
|
+
const fileName = `${evidence_hash.replace(":", "-")}.json`;
|
|
50
|
+
await writeFile(join(this.dir, fileName), JSON.stringify(evidenceRecord, null, 2));
|
|
51
|
+
|
|
52
|
+
this._head = evidence_hash;
|
|
53
|
+
return evidenceRecord;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async verify(): Promise<VerificationResult> {
|
|
57
|
+
const errors: string[] = [];
|
|
58
|
+
|
|
59
|
+
let files: string[];
|
|
60
|
+
try {
|
|
61
|
+
files = (await readdir(this.dir)).filter((f) => f.endsWith(".json"));
|
|
62
|
+
} catch {
|
|
63
|
+
return { valid: true, recordCount: 0, errors: [] };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (files.length === 0) {
|
|
67
|
+
return { valid: true, recordCount: 0, errors: [] };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const records: EvidenceRecord[] = [];
|
|
71
|
+
for (const file of files) {
|
|
72
|
+
const raw = await readFile(join(this.dir, file), "utf-8");
|
|
73
|
+
records.push(JSON.parse(raw) as EvidenceRecord);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Sort topologically by following the chain: null previous_evidence_hash = genesis
|
|
77
|
+
const sorted: EvidenceRecord[] = [];
|
|
78
|
+
let current = records.find((r) => r.previous_evidence_hash === null);
|
|
79
|
+
while (current) {
|
|
80
|
+
sorted.push(current);
|
|
81
|
+
const next = records.find((r) => r.previous_evidence_hash === current?.evidence_hash);
|
|
82
|
+
current = next;
|
|
83
|
+
}
|
|
84
|
+
// If chain is broken or forked, fall back to original order for remaining records
|
|
85
|
+
if (sorted.length !== records.length) {
|
|
86
|
+
const inSorted = new Set(sorted.map((r) => r.evidence_hash));
|
|
87
|
+
for (const r of records) {
|
|
88
|
+
if (!inSorted.has(r.evidence_hash)) sorted.push(r);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
records.length = 0;
|
|
92
|
+
records.push(...sorted);
|
|
93
|
+
|
|
94
|
+
for (let i = 0; i < records.length; i++) {
|
|
95
|
+
const rec = records[i];
|
|
96
|
+
const { evidence_hash, ...rest } = rec;
|
|
97
|
+
|
|
98
|
+
const recomputed = computeHash(rest);
|
|
99
|
+
if (recomputed !== evidence_hash) {
|
|
100
|
+
errors.push(`Record ${evidence_hash}: hash mismatch (expected ${recomputed})`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (i === 0) {
|
|
104
|
+
if (rec.previous_evidence_hash !== null) {
|
|
105
|
+
errors.push(
|
|
106
|
+
`Record ${evidence_hash}: first record should have null previous_evidence_hash`,
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
} else {
|
|
110
|
+
const expected = records[i - 1].evidence_hash;
|
|
111
|
+
if (rec.previous_evidence_hash !== expected) {
|
|
112
|
+
errors.push(
|
|
113
|
+
`Record ${evidence_hash}: previous_evidence_hash mismatch (expected ${expected}, got ${rec.previous_evidence_hash})`,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
valid: errors.length === 0,
|
|
121
|
+
recordCount: records.length,
|
|
122
|
+
errors,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
get head(): string | null {
|
|
127
|
+
return this._head;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
static async hashFile(filePath: string): Promise<string> {
|
|
131
|
+
const contents = await readFile(filePath);
|
|
132
|
+
return `sha256:${createHash("sha256").update(contents).digest("hex")}`;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function computeHash(record: Omit<EvidenceRecord, "evidence_hash">): string {
|
|
137
|
+
const sortedKeys = Object.keys(record).sort() as Array<keyof typeof record>;
|
|
138
|
+
const canonical: Record<string, unknown> = {};
|
|
139
|
+
for (const key of sortedKeys) {
|
|
140
|
+
canonical[key] = record[key];
|
|
141
|
+
}
|
|
142
|
+
const json = JSON.stringify(canonical);
|
|
143
|
+
return `sha256:${createHash("sha256").update(json).digest("hex")}`;
|
|
144
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import type { GateInput, GateResult, GateVerdict } from "../types";
|
|
2
|
+
|
|
3
|
+
export type GateEvaluator = (input: GateInput) => Promise<GateVerdict>;
|
|
4
|
+
|
|
5
|
+
export interface GateRunnerOptions {
|
|
6
|
+
failFast?: boolean;
|
|
7
|
+
timeoutMs?: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface GateRunResult {
|
|
11
|
+
passed: boolean;
|
|
12
|
+
results: Array<GateResult & { status: string; message: string }>;
|
|
13
|
+
firstFailure?: GateResult & { status: string; message: string };
|
|
14
|
+
duration_ms: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const DEFAULT_TIMEOUT_MS = 300_000;
|
|
18
|
+
|
|
19
|
+
export class GateRunner {
|
|
20
|
+
private readonly evaluators = new Map<string, GateEvaluator>();
|
|
21
|
+
private readonly failFast: boolean;
|
|
22
|
+
private readonly timeoutMs: number;
|
|
23
|
+
|
|
24
|
+
constructor(options?: GateRunnerOptions) {
|
|
25
|
+
this.failFast = options?.failFast ?? false;
|
|
26
|
+
this.timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
register(name: string, evaluator: GateEvaluator): void {
|
|
30
|
+
this.evaluators.set(name, evaluator);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async evaluateAll(gateNames: string[], input: GateInput): Promise<GateRunResult> {
|
|
34
|
+
const started = Date.now();
|
|
35
|
+
const results: Array<GateResult & { status: string; message: string }> = [];
|
|
36
|
+
let firstFailure: (GateResult & { status: string; message: string }) | undefined;
|
|
37
|
+
|
|
38
|
+
for (const gateName of gateNames) {
|
|
39
|
+
const evaluator = this.evaluators.get(gateName);
|
|
40
|
+
|
|
41
|
+
if (!evaluator) {
|
|
42
|
+
const entry: GateResult & { status: string; message: string } = {
|
|
43
|
+
gate: gateName,
|
|
44
|
+
exit_code: 1,
|
|
45
|
+
duration_ms: 0,
|
|
46
|
+
status: "fail",
|
|
47
|
+
message: `Gate "${gateName}" is not registered`,
|
|
48
|
+
};
|
|
49
|
+
results.push(entry);
|
|
50
|
+
if (!firstFailure) firstFailure = entry;
|
|
51
|
+
if (this.failFast) break;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const gateStart = Date.now();
|
|
56
|
+
|
|
57
|
+
let verdict: GateVerdict;
|
|
58
|
+
try {
|
|
59
|
+
verdict = await Promise.race([
|
|
60
|
+
evaluator({ ...input, gate_id: gateName }),
|
|
61
|
+
new Promise<never>((_, reject) =>
|
|
62
|
+
setTimeout(
|
|
63
|
+
() => reject(new Error(`Gate "${gateName}" timed out after ${this.timeoutMs}ms`)),
|
|
64
|
+
this.timeoutMs,
|
|
65
|
+
),
|
|
66
|
+
),
|
|
67
|
+
]);
|
|
68
|
+
} catch (err: unknown) {
|
|
69
|
+
const message = err instanceof Error ? err.message : "Unknown error";
|
|
70
|
+
const entry: GateResult & { status: string; message: string } = {
|
|
71
|
+
gate: gateName,
|
|
72
|
+
exit_code: 1,
|
|
73
|
+
duration_ms: Date.now() - gateStart,
|
|
74
|
+
status: "fail",
|
|
75
|
+
message,
|
|
76
|
+
};
|
|
77
|
+
results.push(entry);
|
|
78
|
+
if (!firstFailure) firstFailure = entry;
|
|
79
|
+
if (this.failFast) break;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const isFail = verdict.status === "fail";
|
|
84
|
+
const entry: GateResult & { status: string; message: string } = {
|
|
85
|
+
gate: gateName,
|
|
86
|
+
exit_code: isFail ? 1 : 0,
|
|
87
|
+
duration_ms: Date.now() - gateStart,
|
|
88
|
+
status: verdict.status,
|
|
89
|
+
message: verdict.message,
|
|
90
|
+
...(verdict.evidence
|
|
91
|
+
? { evidence: { type: verdict.evidence.type, hash: verdict.evidence.summary } }
|
|
92
|
+
: {}),
|
|
93
|
+
};
|
|
94
|
+
results.push(entry);
|
|
95
|
+
|
|
96
|
+
if (isFail) {
|
|
97
|
+
if (!firstFailure) firstFailure = entry;
|
|
98
|
+
if (this.failFast) break;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
passed: !firstFailure,
|
|
104
|
+
results,
|
|
105
|
+
...(firstFailure ? { firstFailure } : {}),
|
|
106
|
+
duration_ms: Date.now() - started,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import Ajv from "ajv";
|
|
3
|
+
import { parse as parseYaml } from "yaml";
|
|
4
|
+
import schema from "../../schemas/pipeline.schema.json";
|
|
5
|
+
import type { PipelineDefinition } from "../types";
|
|
6
|
+
|
|
7
|
+
const ajv = new Ajv({ allErrors: true });
|
|
8
|
+
const validate = ajv.compile(schema);
|
|
9
|
+
|
|
10
|
+
export async function loadPipeline(filePath: string): Promise<PipelineDefinition> {
|
|
11
|
+
const raw = await readFile(filePath, "utf-8");
|
|
12
|
+
const doc = parseYaml(raw) as { pipeline?: unknown };
|
|
13
|
+
|
|
14
|
+
const valid = validate(doc);
|
|
15
|
+
if (!valid) {
|
|
16
|
+
const messages = (validate.errors ?? [])
|
|
17
|
+
.map((e) => `${e.instancePath || "root"} ${e.message}`)
|
|
18
|
+
.join("; ");
|
|
19
|
+
throw new Error(`Schema validation failed: ${messages}`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const pipeline = (doc as { pipeline: PipelineDefinition }).pipeline;
|
|
23
|
+
|
|
24
|
+
const errors = validatePipeline(pipeline);
|
|
25
|
+
if (errors.length > 0) {
|
|
26
|
+
throw new Error(`Semantic validation failed:\n${errors.map((e) => ` - ${e}`).join("\n")}`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return pipeline;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function validatePipeline(pipeline: PipelineDefinition): string[] {
|
|
33
|
+
const errors: string[] = [];
|
|
34
|
+
const stageIds = new Set(Object.keys(pipeline.stages));
|
|
35
|
+
const conditionIds = new Set(Object.keys(pipeline.conditions ?? {}));
|
|
36
|
+
const gateIds = new Set(Object.keys(pipeline.gates ?? {}));
|
|
37
|
+
|
|
38
|
+
// Initial stage must exist
|
|
39
|
+
if (!stageIds.has(pipeline.initial)) {
|
|
40
|
+
errors.push(`Initial stage "${pipeline.initial}" does not exist in stages`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
for (const [stageId, stage] of Object.entries(pipeline.stages)) {
|
|
44
|
+
// Transition targets must exist
|
|
45
|
+
if (stage.on) {
|
|
46
|
+
for (const [event, transition] of Object.entries(stage.on)) {
|
|
47
|
+
if (!stageIds.has(transition.target)) {
|
|
48
|
+
errors.push(
|
|
49
|
+
`Stage "${stageId}" event "${event}" targets unknown stage "${transition.target}"`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
// Guard references must exist in conditions
|
|
53
|
+
if (transition.guard !== undefined && !conditionIds.has(transition.guard)) {
|
|
54
|
+
errors.push(
|
|
55
|
+
`Stage "${stageId}" event "${event}" guard "${transition.guard}" not found in conditions`,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Gate references must exist in top-level gates
|
|
62
|
+
if (stage.gates) {
|
|
63
|
+
for (const ref of [...(stage.gates.entry ?? []), ...(stage.gates.exit ?? [])]) {
|
|
64
|
+
if (!gateIds.has(ref)) {
|
|
65
|
+
errors.push(`Stage "${stageId}" references unknown gate "${ref}"`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Parallel children must exist and have type "parallel-child"
|
|
71
|
+
if (stage.parallel) {
|
|
72
|
+
for (const childId of stage.parallel) {
|
|
73
|
+
if (!stageIds.has(childId)) {
|
|
74
|
+
errors.push(`Stage "${stageId}" parallel child "${childId}" does not exist`);
|
|
75
|
+
} else if (pipeline.stages[childId]?.type !== "parallel-child") {
|
|
76
|
+
errors.push(
|
|
77
|
+
`Stage "${stageId}" parallel child "${childId}" must have type "parallel-child"`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// `when` conditions must exist in conditions
|
|
84
|
+
if (stage.when !== undefined && !conditionIds.has(stage.when)) {
|
|
85
|
+
errors.push(`Stage "${stageId}" when condition "${stage.when}" not found in conditions`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Unreachable stages via BFS from initial
|
|
90
|
+
if (stageIds.has(pipeline.initial)) {
|
|
91
|
+
const reachable = new Set<string>();
|
|
92
|
+
const queue: string[] = [pipeline.initial];
|
|
93
|
+
while (queue.length > 0) {
|
|
94
|
+
const current = queue.shift()!;
|
|
95
|
+
if (reachable.has(current)) continue;
|
|
96
|
+
reachable.add(current);
|
|
97
|
+
const stage = pipeline.stages[current];
|
|
98
|
+
if (!stage) continue;
|
|
99
|
+
// Follow transitions
|
|
100
|
+
if (stage.on) {
|
|
101
|
+
for (const transition of Object.values(stage.on)) {
|
|
102
|
+
if (!reachable.has(transition.target)) {
|
|
103
|
+
queue.push(transition.target);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
// Follow parallel children
|
|
108
|
+
if (stage.parallel) {
|
|
109
|
+
for (const childId of stage.parallel) {
|
|
110
|
+
if (!reachable.has(childId)) {
|
|
111
|
+
queue.push(childId);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
for (const stageId of stageIds) {
|
|
118
|
+
if (!reachable.has(stageId)) {
|
|
119
|
+
errors.push(`Stage "${stageId}" is unreachable from initial stage "${pipeline.initial}"`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return errors;
|
|
125
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import type { PipelineDefinition, PipelineState, StageDefinition } from "../types";
|
|
2
|
+
|
|
3
|
+
export interface TransitionResult {
|
|
4
|
+
success: boolean;
|
|
5
|
+
newStage?: string;
|
|
6
|
+
previousStage?: string;
|
|
7
|
+
error?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class StateMachine {
|
|
11
|
+
private readonly pipeline: PipelineDefinition;
|
|
12
|
+
private _currentStage: string;
|
|
13
|
+
private _completedStages: string[];
|
|
14
|
+
private _retryCounts: Record<string, number>;
|
|
15
|
+
private _evidenceHead: string | null;
|
|
16
|
+
private _startedAt: string;
|
|
17
|
+
|
|
18
|
+
constructor(pipeline: PipelineDefinition) {
|
|
19
|
+
this.pipeline = pipeline;
|
|
20
|
+
this._currentStage = pipeline.initial;
|
|
21
|
+
this._completedStages = [];
|
|
22
|
+
this._retryCounts = {};
|
|
23
|
+
this._evidenceHead = null;
|
|
24
|
+
this._startedAt = new Date().toISOString();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
static fromState(pipeline: PipelineDefinition, state: PipelineState): StateMachine {
|
|
28
|
+
const sm = new StateMachine(pipeline);
|
|
29
|
+
sm._currentStage = state.current_stage;
|
|
30
|
+
sm._completedStages = [...state.stages_completed];
|
|
31
|
+
sm._retryCounts = { ...state.retry_count };
|
|
32
|
+
sm._evidenceHead = state.evidence_chain_head;
|
|
33
|
+
sm._startedAt = state.started_at;
|
|
34
|
+
return sm;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
get currentStage(): string {
|
|
38
|
+
return this._currentStage;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
get isTerminal(): boolean {
|
|
42
|
+
const stageDef = this.pipeline.stages[this._currentStage];
|
|
43
|
+
return stageDef?.type === "terminal";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
get completedStages(): string[] {
|
|
47
|
+
return [...this._completedStages];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
get allowedEvents(): string[] {
|
|
51
|
+
const stageDef = this.pipeline.stages[this._currentStage];
|
|
52
|
+
if (!stageDef?.on) return [];
|
|
53
|
+
return Object.keys(stageDef.on);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
get allowedTools(): string[] {
|
|
57
|
+
const stageDef = this.pipeline.stages[this._currentStage];
|
|
58
|
+
return stageDef?.agent?.tools ?? [];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
get currentStageDefinition(): StageDefinition {
|
|
62
|
+
return this.pipeline.stages[this._currentStage];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
getRetryCount(stageName: string): number {
|
|
66
|
+
return this._retryCounts[stageName] ?? 0;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
transition(event: string): TransitionResult {
|
|
70
|
+
if (this.isTerminal) {
|
|
71
|
+
return {
|
|
72
|
+
success: false,
|
|
73
|
+
error: `Cannot transition from terminal stage "${this._currentStage}"`,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const stageDef = this.pipeline.stages[this._currentStage];
|
|
78
|
+
const transitionDef = stageDef?.on?.[event];
|
|
79
|
+
|
|
80
|
+
if (!transitionDef) {
|
|
81
|
+
return {
|
|
82
|
+
success: false,
|
|
83
|
+
error: `Event "${event}" is not valid in stage "${this._currentStage}". Allowed: [${this.allowedEvents.join(", ")}]`,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const previousStage = this._currentStage;
|
|
88
|
+
const newStage = transitionDef.target;
|
|
89
|
+
|
|
90
|
+
this._completedStages.push(previousStage);
|
|
91
|
+
this._currentStage = newStage;
|
|
92
|
+
this._incrementRetryCount(newStage);
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
success: true,
|
|
96
|
+
newStage,
|
|
97
|
+
previousStage,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
exportState(): PipelineState {
|
|
102
|
+
return {
|
|
103
|
+
pipeline_id: this.pipeline.id,
|
|
104
|
+
current_stage: this._currentStage,
|
|
105
|
+
started_at: this._startedAt,
|
|
106
|
+
stages_completed: [...this._completedStages],
|
|
107
|
+
retry_count: { ...this._retryCounts },
|
|
108
|
+
evidence_chain_head: this._evidenceHead,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
updateEvidenceHead(hash: string): void {
|
|
113
|
+
this._evidenceHead = hash;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private _incrementRetryCount(stageName: string): void {
|
|
117
|
+
this._retryCounts[stageName] = (this._retryCounts[stageName] ?? 0) + 1;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { IpcResponse } from "../types";
|
|
2
|
+
|
|
3
|
+
export function createCheckResponse(
|
|
4
|
+
allowed: boolean,
|
|
5
|
+
currentStage: string,
|
|
6
|
+
allowedTools: string[],
|
|
7
|
+
reason?: string,
|
|
8
|
+
): IpcResponse {
|
|
9
|
+
return {
|
|
10
|
+
verdict: allowed ? "allow" : "deny",
|
|
11
|
+
allowed,
|
|
12
|
+
current_stage: currentStage,
|
|
13
|
+
allowed_tools: allowedTools,
|
|
14
|
+
...(reason !== undefined ? { reason } : {}),
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function createAdvanceResponse(
|
|
19
|
+
success: boolean,
|
|
20
|
+
newStage?: string,
|
|
21
|
+
error?: string,
|
|
22
|
+
): IpcResponse {
|
|
23
|
+
return {
|
|
24
|
+
verdict: "transition",
|
|
25
|
+
transitioned: success,
|
|
26
|
+
...(newStage !== undefined ? { new_stage: newStage } : {}),
|
|
27
|
+
...(error !== undefined ? { error } : {}),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function createStatusResponse(
|
|
32
|
+
stage: string,
|
|
33
|
+
gatesRemaining: string[],
|
|
34
|
+
startedAt: string,
|
|
35
|
+
completedStages: string[] = [],
|
|
36
|
+
allowedEvents: string[] = [],
|
|
37
|
+
allowedTools: string[] = [],
|
|
38
|
+
): IpcResponse {
|
|
39
|
+
const elapsedMs = Date.now() - new Date(startedAt).getTime();
|
|
40
|
+
const elapsedSec = Math.floor(elapsedMs / 1000);
|
|
41
|
+
return {
|
|
42
|
+
stage,
|
|
43
|
+
gates_remaining: gatesRemaining,
|
|
44
|
+
elapsed: `${elapsedSec}s`,
|
|
45
|
+
completed_stages: completedStages,
|
|
46
|
+
allowed_events: allowedEvents,
|
|
47
|
+
allowed_tools: allowedTools,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function createSignalResponse(received: boolean, gateClearedId?: string): IpcResponse {
|
|
52
|
+
return {
|
|
53
|
+
received,
|
|
54
|
+
...(gateClearedId !== undefined ? { gate_cleared: gateClearedId } : {}),
|
|
55
|
+
};
|
|
56
|
+
}
|