@workflow-manager/runner 0.1.0 → 0.3.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/README.md +123 -12
- package/dist/adapters.d.ts +4 -0
- package/dist/adapters.js +11 -0
- package/dist/claudeCodeExecutor.d.ts +4 -0
- package/dist/claudeCodeExecutor.js +173 -0
- package/dist/cliRunRenderer.d.ts +36 -0
- package/dist/cliRunRenderer.js +286 -0
- package/dist/engine.d.ts +4 -2
- package/dist/engine.js +622 -44
- package/dist/events.d.ts +1 -1
- package/dist/events.js +4 -2
- package/dist/index.js +371 -41
- package/dist/manPage.d.ts +1 -0
- package/dist/manPage.js +147 -0
- package/dist/mockExecutor.d.ts +2 -2
- package/dist/mockExecutor.js +62 -13
- package/dist/opencodeExecutor.d.ts +2 -2
- package/dist/opencodeExecutor.js +101 -138
- package/dist/parser.js +98 -2
- package/dist/piAgentExecutor.d.ts +4 -0
- package/dist/piAgentExecutor.js +298 -0
- package/dist/remote/api.d.ts +1 -0
- package/dist/remote/commands.d.ts +2 -0
- package/dist/remote/commands.js +76 -4
- package/dist/runnerApi.d.ts +7 -0
- package/dist/runnerApi.js +221 -0
- package/dist/runnerSession.d.ts +62 -0
- package/dist/runnerSession.js +260 -0
- package/dist/runtimePreflight.d.ts +16 -0
- package/dist/runtimePreflight.js +189 -0
- package/dist/skillResolver.d.ts +6 -0
- package/dist/skillResolver.js +75 -0
- package/dist/types.d.ts +148 -2
- package/man/wfm.1 +54 -4
- package/package.json +28 -4
- package/skills/commit-discipline/SKILL.md +109 -0
- package/skills/doc-sync/SKILL.md +83 -0
- package/skills/repo-hygiene/SKILL.md +70 -0
- package/skills/spec-driven-development/SKILL.md +33 -0
- package/skills/workflow-manager-cli/SKILL.md +14 -9
package/dist/parser.js
CHANGED
|
@@ -1,7 +1,57 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import fs from "node:fs";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
import matter from "gray-matter";
|
|
4
|
-
|
|
5
|
+
import { SUPPORTED_ADAPTERS, resolveTaskAdapter } from "./adapters.js";
|
|
6
|
+
const SKILL_NAME_PATTERN = /^[a-zA-Z0-9_.-]+$/;
|
|
7
|
+
function hashContentSha256(content) {
|
|
8
|
+
return createHash("sha256").update(content).digest("hex");
|
|
9
|
+
}
|
|
10
|
+
function isSafeSkillName(name) {
|
|
11
|
+
return !!name && SKILL_NAME_PATTERN.test(name);
|
|
12
|
+
}
|
|
13
|
+
function isAllowedLocalSkillSourcePath(source) {
|
|
14
|
+
if (!source || path.isAbsolute(source) || source.includes("\\") || source.includes(".."))
|
|
15
|
+
return false;
|
|
16
|
+
const normalized = path.posix.normalize(source);
|
|
17
|
+
const withoutDot = normalized.startsWith("./") ? normalized.slice(2) : normalized;
|
|
18
|
+
if (!withoutDot.startsWith("skills/"))
|
|
19
|
+
return false;
|
|
20
|
+
return withoutDot.endsWith("/SKILL.md");
|
|
21
|
+
}
|
|
22
|
+
function validateSkillEntry(name, entry, errors) {
|
|
23
|
+
if (!isSafeSkillName(name)) {
|
|
24
|
+
errors.push(`Invalid skill name: ${name}`);
|
|
25
|
+
}
|
|
26
|
+
if (!entry.content?.trim() && !entry.source?.trim()) {
|
|
27
|
+
errors.push(`Skill "${name}" must define content or source`);
|
|
28
|
+
}
|
|
29
|
+
if (entry.source && !isAllowedLocalSkillSourcePath(entry.source)) {
|
|
30
|
+
errors.push(`Skill "${name}" source must be under ./skills/**/SKILL.md`);
|
|
31
|
+
}
|
|
32
|
+
if (entry.contentSha256) {
|
|
33
|
+
if (!/^[a-f0-9]{64}$/.test(entry.contentSha256)) {
|
|
34
|
+
errors.push(`Skill "${name}" contentSha256 must be a 64-char lowercase hex SHA-256`);
|
|
35
|
+
}
|
|
36
|
+
else if (!entry.content?.trim()) {
|
|
37
|
+
errors.push(`Skill "${name}" defines contentSha256 but has no content`);
|
|
38
|
+
}
|
|
39
|
+
else if (hashContentSha256(entry.content) !== entry.contentSha256) {
|
|
40
|
+
errors.push(`Skill "${name}" contentSha256 does not match content`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (entry.upstream) {
|
|
44
|
+
if (entry.upstream.repo !== undefined && (!entry.upstream.repo || typeof entry.upstream.repo !== "string")) {
|
|
45
|
+
errors.push(`Skill "${name}" upstream.repo must be a non-empty string when present`);
|
|
46
|
+
}
|
|
47
|
+
if (entry.upstream.ref !== undefined && (!entry.upstream.ref || typeof entry.upstream.ref !== "string")) {
|
|
48
|
+
errors.push(`Skill "${name}" upstream.ref must be a non-empty string when present`);
|
|
49
|
+
}
|
|
50
|
+
if (entry.upstream.path !== undefined && (!entry.upstream.path || typeof entry.upstream.path !== "string")) {
|
|
51
|
+
errors.push(`Skill "${name}" upstream.path must be a non-empty string when present`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
5
55
|
function normalizeWorkflow(data, source) {
|
|
6
56
|
if (!data.key || !data.title || !Array.isArray(data.steps)) {
|
|
7
57
|
throw new Error(`Invalid workflow ${source}: key, title, and steps are required`);
|
|
@@ -14,6 +64,7 @@ function normalizeWorkflow(data, source) {
|
|
|
14
64
|
inputSchema: data.inputSchema ?? {},
|
|
15
65
|
outputSchema: data.outputSchema ?? {},
|
|
16
66
|
defaultRetryPolicy: data.defaultRetryPolicy ?? { maxAttempts: 1 },
|
|
67
|
+
skills: data.skills,
|
|
17
68
|
steps: data.steps.map((s) => ({
|
|
18
69
|
...s,
|
|
19
70
|
dependsOn: s.dependsOn ?? [],
|
|
@@ -22,7 +73,7 @@ function normalizeWorkflow(data, source) {
|
|
|
22
73
|
taskSpec: s.taskSpec
|
|
23
74
|
? {
|
|
24
75
|
...s.taskSpec,
|
|
25
|
-
adapterKey: (s.taskSpec.adapterKey
|
|
76
|
+
adapterKey: resolveTaskAdapter(s.taskSpec.adapterKey),
|
|
26
77
|
init: {
|
|
27
78
|
context: s.taskSpec.init?.context ?? {},
|
|
28
79
|
skills: s.taskSpec.init?.skills ?? [],
|
|
@@ -35,6 +86,44 @@ function normalizeWorkflow(data, source) {
|
|
|
35
86
|
})),
|
|
36
87
|
};
|
|
37
88
|
}
|
|
89
|
+
function findDependencyCycle(def) {
|
|
90
|
+
const stepsByKey = new Map(def.steps.map((step) => [step.key, step]));
|
|
91
|
+
const visiting = new Set();
|
|
92
|
+
const visited = new Set();
|
|
93
|
+
const path = [];
|
|
94
|
+
const visit = (stepKey) => {
|
|
95
|
+
if (visited.has(stepKey)) {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
const pathIndex = path.indexOf(stepKey);
|
|
99
|
+
if (visiting.has(stepKey) && pathIndex >= 0) {
|
|
100
|
+
return [...path.slice(pathIndex), stepKey];
|
|
101
|
+
}
|
|
102
|
+
const step = stepsByKey.get(stepKey);
|
|
103
|
+
if (!step) {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
visiting.add(stepKey);
|
|
107
|
+
path.push(stepKey);
|
|
108
|
+
for (const dependency of step.dependsOn ?? []) {
|
|
109
|
+
const cycle = visit(dependency);
|
|
110
|
+
if (cycle) {
|
|
111
|
+
return cycle;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
path.pop();
|
|
115
|
+
visiting.delete(stepKey);
|
|
116
|
+
visited.add(stepKey);
|
|
117
|
+
return null;
|
|
118
|
+
};
|
|
119
|
+
for (const step of def.steps) {
|
|
120
|
+
const cycle = visit(step.key);
|
|
121
|
+
if (cycle) {
|
|
122
|
+
return cycle;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
38
127
|
export function parseWorkflowMarkdown(filePath) {
|
|
39
128
|
const raw = fs.readFileSync(filePath, "utf-8");
|
|
40
129
|
const parsed = matter(raw);
|
|
@@ -66,6 +155,9 @@ export function validateWorkflow(def) {
|
|
|
66
155
|
errors.push("Workflow must define at least one step");
|
|
67
156
|
return errors;
|
|
68
157
|
}
|
|
158
|
+
for (const [name, entry] of Object.entries(def.skills ?? {})) {
|
|
159
|
+
validateSkillEntry(name, entry, errors);
|
|
160
|
+
}
|
|
69
161
|
for (const step of def.steps) {
|
|
70
162
|
if (!step.key || !step.key.trim()) {
|
|
71
163
|
errors.push("Each step must define a non-empty key");
|
|
@@ -97,5 +189,9 @@ export function validateWorkflow(def) {
|
|
|
97
189
|
errors.push(`Invalid validation mode for ${step.key}: ${mode}`);
|
|
98
190
|
}
|
|
99
191
|
}
|
|
192
|
+
const cycle = findDependencyCycle(def);
|
|
193
|
+
if (cycle) {
|
|
194
|
+
errors.push(`Circular dependency detected: ${cycle.join(" -> ")}`);
|
|
195
|
+
}
|
|
100
196
|
return errors;
|
|
101
197
|
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { InputEnvelope, OutputEnvelope, StepDefinition, StepExecutionHooks, WorkflowDefinition } from "./types.js";
|
|
2
|
+
export declare const DEFAULT_PI_COMMAND = "pi";
|
|
3
|
+
export declare function normalizeTimeout(value: unknown, fallbackMs?: number): number;
|
|
4
|
+
export declare function executePiAgentStep(step: StepDefinition, input: InputEnvelope, attempt: number, workflow?: WorkflowDefinition, workflowFilePath?: string, hooks?: StepExecutionHooks): Promise<OutputEnvelope>;
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { resolveSkill } from "./skillResolver.js";
|
|
6
|
+
export const DEFAULT_PI_COMMAND = "pi";
|
|
7
|
+
function asRecord(value) {
|
|
8
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
9
|
+
}
|
|
10
|
+
export function normalizeTimeout(value, fallbackMs = 600000) {
|
|
11
|
+
const timeout = Number(value ?? fallbackMs);
|
|
12
|
+
if (!Number.isFinite(timeout) || timeout <= 0)
|
|
13
|
+
return fallbackMs;
|
|
14
|
+
return Math.floor(timeout);
|
|
15
|
+
}
|
|
16
|
+
function makeResult(step, input, attempt, startedAt, status, reason, extra = {}, action = "PROCEED") {
|
|
17
|
+
return {
|
|
18
|
+
step_id: step.key,
|
|
19
|
+
execution_status: status,
|
|
20
|
+
qa_routing: { action, feedback_reason: reason },
|
|
21
|
+
mutated_payload: {
|
|
22
|
+
stepKey: step.key,
|
|
23
|
+
attempt,
|
|
24
|
+
adapter: input.priming_configuration.adapter ?? "pi-agent",
|
|
25
|
+
...extra,
|
|
26
|
+
},
|
|
27
|
+
metadata: {
|
|
28
|
+
execution_time_ms: Date.now() - startedAt,
|
|
29
|
+
external_intervention_required: false,
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
function resolveCommand(payload) {
|
|
34
|
+
if (typeof payload.command === "string" && payload.command.trim())
|
|
35
|
+
return payload.command;
|
|
36
|
+
if (process.env.WFM_PI_AGENT_COMMAND?.trim())
|
|
37
|
+
return process.env.WFM_PI_AGENT_COMMAND;
|
|
38
|
+
return DEFAULT_PI_COMMAND;
|
|
39
|
+
}
|
|
40
|
+
function resolveArgs(payload) {
|
|
41
|
+
return Array.isArray(payload.args) ? payload.args.map((arg) => String(arg)) : [];
|
|
42
|
+
}
|
|
43
|
+
function resolveRunDir(payload) {
|
|
44
|
+
if (typeof payload.runDir === "string" && payload.runDir.trim()) {
|
|
45
|
+
fs.mkdirSync(payload.runDir, { recursive: true });
|
|
46
|
+
return payload.runDir;
|
|
47
|
+
}
|
|
48
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), "wfm-pi-agent-"));
|
|
49
|
+
}
|
|
50
|
+
function buildInputFile(step, input, attempt, runDir, workflow, workflowFilePath) {
|
|
51
|
+
const resolvedSkills = [];
|
|
52
|
+
if (workflow && workflowFilePath) {
|
|
53
|
+
for (const name of input.priming_configuration.required_skills) {
|
|
54
|
+
const resolved = resolveSkill(name, workflow, workflowFilePath);
|
|
55
|
+
if (resolved) {
|
|
56
|
+
resolvedSkills.push({ name, origin: resolved.origin, content: resolved.content });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
input_envelope: input,
|
|
62
|
+
step,
|
|
63
|
+
workflow: workflow
|
|
64
|
+
? {
|
|
65
|
+
key: workflow.key,
|
|
66
|
+
title: workflow.title,
|
|
67
|
+
description: workflow.description,
|
|
68
|
+
objectives: workflow.objectives,
|
|
69
|
+
inputSchema: workflow.inputSchema,
|
|
70
|
+
outputSchema: workflow.outputSchema,
|
|
71
|
+
}
|
|
72
|
+
: undefined,
|
|
73
|
+
resolved_skills: resolvedSkills,
|
|
74
|
+
run: { attempt, runDir },
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
function sanitizeSkillName(name) {
|
|
78
|
+
const safe = name.replace(/[^a-zA-Z0-9_.-]/g, "-");
|
|
79
|
+
return safe || "skill";
|
|
80
|
+
}
|
|
81
|
+
function writeSkillFiles(runDir, skills) {
|
|
82
|
+
const skillPaths = [];
|
|
83
|
+
skills.forEach((skill, index) => {
|
|
84
|
+
const skillDir = path.join(runDir, "skills", `${index}-${sanitizeSkillName(skill.name)}`);
|
|
85
|
+
fs.mkdirSync(skillDir, { recursive: true });
|
|
86
|
+
const skillPath = path.join(skillDir, "SKILL.md");
|
|
87
|
+
fs.writeFileSync(skillPath, skill.content, "utf-8");
|
|
88
|
+
skillPaths.push(skillPath);
|
|
89
|
+
});
|
|
90
|
+
return skillPaths;
|
|
91
|
+
}
|
|
92
|
+
function describeContext(context) {
|
|
93
|
+
if (typeof context === "string") {
|
|
94
|
+
return context.trim() ? context : null;
|
|
95
|
+
}
|
|
96
|
+
if (context && Object.keys(context).length > 0) {
|
|
97
|
+
return JSON.stringify(context, null, 2);
|
|
98
|
+
}
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
function buildPrompt(step, input, inputPath, outputPath) {
|
|
102
|
+
const lines = ["You are executing a single step of an orchestrated workflow run."];
|
|
103
|
+
if (input.global_context.primary_objective.trim()) {
|
|
104
|
+
lines.push("", `Workflow objective: ${input.global_context.primary_objective}`);
|
|
105
|
+
}
|
|
106
|
+
if (input.global_context.workflow_objectives.length > 0) {
|
|
107
|
+
lines.push(`Workflow goals: ${input.global_context.workflow_objectives.join("; ")}`);
|
|
108
|
+
}
|
|
109
|
+
lines.push("", `Step key: ${step.key}`);
|
|
110
|
+
const objective = step.objective ?? input.step_context.step_objective;
|
|
111
|
+
if (objective.trim()) {
|
|
112
|
+
lines.push(`Step objective: ${objective}`);
|
|
113
|
+
}
|
|
114
|
+
if (Object.keys(input.step_context.previous_output).length > 0) {
|
|
115
|
+
lines.push("", "Previous step output:", JSON.stringify(input.step_context.previous_output, null, 2));
|
|
116
|
+
}
|
|
117
|
+
const context = describeContext(input.priming_configuration.context);
|
|
118
|
+
if (context) {
|
|
119
|
+
lines.push("", "Additional context:", context);
|
|
120
|
+
}
|
|
121
|
+
if (input.priming_configuration.mcp_endpoints.length > 0) {
|
|
122
|
+
lines.push("", `MCP endpoints declared for this step: ${input.priming_configuration.mcp_endpoints.join(", ")}`);
|
|
123
|
+
}
|
|
124
|
+
lines.push("", `The full structured step input is available at ${inputPath}.`, "", "Complete the step objective now.", `When you are done, write a JSON result envelope to ${outputPath} with this shape:`, `{"step_id":"${step.key}","execution_status":"SUCCESS"|"FAILED"|"QA_REJECTED","qa_routing":{"action":"PROCEED"|"RETRY_CURRENT"|"ROLLBACK_PREVIOUS"|"RESTART_ALL","feedback_reason":"<short reason>"},"mutated_payload":{<step results>},"metadata":{"execution_time_ms":0,"external_intervention_required":false}}`, "If you cannot write that file, simply finish with a clear final response; a successful exit is recorded as step success and your response text becomes the step output.");
|
|
125
|
+
return lines.join("\n");
|
|
126
|
+
}
|
|
127
|
+
function buildPiArgs(payload, input, skillPaths, prompt) {
|
|
128
|
+
const args = resolveArgs(payload);
|
|
129
|
+
args.push("--print", "--no-session");
|
|
130
|
+
if (input.priming_configuration.model) {
|
|
131
|
+
args.push("--model", input.priming_configuration.model);
|
|
132
|
+
}
|
|
133
|
+
for (const systemPrompt of input.priming_configuration.system_prompts) {
|
|
134
|
+
args.push("--append-system-prompt", systemPrompt);
|
|
135
|
+
}
|
|
136
|
+
for (const skillPath of skillPaths) {
|
|
137
|
+
args.push("--skill", skillPath);
|
|
138
|
+
}
|
|
139
|
+
args.push(prompt);
|
|
140
|
+
return args;
|
|
141
|
+
}
|
|
142
|
+
function normalizeOutputEnvelope(raw, step, input, attempt, startedAt) {
|
|
143
|
+
const record = asRecord(raw);
|
|
144
|
+
const metadata = asRecord(record.metadata);
|
|
145
|
+
const qaRouting = asRecord(record.qa_routing);
|
|
146
|
+
const status = String(record.execution_status ?? "SUCCESS");
|
|
147
|
+
const action = String(qaRouting.action ?? "PROCEED");
|
|
148
|
+
return {
|
|
149
|
+
step_id: typeof record.step_id === "string" && record.step_id.trim() ? record.step_id : step.key,
|
|
150
|
+
execution_status: status,
|
|
151
|
+
qa_routing: {
|
|
152
|
+
action,
|
|
153
|
+
feedback_reason: typeof qaRouting.feedback_reason === "string" ? qaRouting.feedback_reason : "",
|
|
154
|
+
},
|
|
155
|
+
mutated_payload: {
|
|
156
|
+
stepKey: step.key,
|
|
157
|
+
attempt,
|
|
158
|
+
adapter: input.priming_configuration.adapter ?? "pi-agent",
|
|
159
|
+
...asRecord(record.mutated_payload),
|
|
160
|
+
},
|
|
161
|
+
metadata: {
|
|
162
|
+
execution_time_ms: typeof metadata.execution_time_ms === "number" ? metadata.execution_time_ms : Date.now() - startedAt,
|
|
163
|
+
external_intervention_required: metadata.external_intervention_required === true,
|
|
164
|
+
intervention_details: asRecord(metadata.intervention_details),
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
export function executePiAgentStep(step, input, attempt, workflow, workflowFilePath, hooks) {
|
|
169
|
+
const startedAt = Date.now();
|
|
170
|
+
let payload;
|
|
171
|
+
let timeoutMs;
|
|
172
|
+
let runDir;
|
|
173
|
+
let inputPath;
|
|
174
|
+
let outputPath;
|
|
175
|
+
let command;
|
|
176
|
+
let args;
|
|
177
|
+
try {
|
|
178
|
+
payload = asRecord(step.taskSpec?.payload);
|
|
179
|
+
timeoutMs = normalizeTimeout(payload.timeoutMs);
|
|
180
|
+
runDir = resolveRunDir(payload);
|
|
181
|
+
inputPath = path.join(runDir, "input.json");
|
|
182
|
+
outputPath = path.join(runDir, "output.json");
|
|
183
|
+
// A fixed runDir is reused across attempts; a leftover output.json would be
|
|
184
|
+
// read as this attempt's result if the agent exits 0 without writing one.
|
|
185
|
+
fs.rmSync(outputPath, { force: true });
|
|
186
|
+
command = resolveCommand(payload);
|
|
187
|
+
const inputFile = buildInputFile(step, input, attempt, runDir, workflow, workflowFilePath);
|
|
188
|
+
fs.writeFileSync(inputPath, JSON.stringify(inputFile, null, 2), "utf-8");
|
|
189
|
+
const skillPaths = writeSkillFiles(runDir, inputFile.resolved_skills);
|
|
190
|
+
const prompt = buildPrompt(step, input, inputPath, outputPath);
|
|
191
|
+
fs.writeFileSync(path.join(runDir, "prompt.txt"), prompt, "utf-8");
|
|
192
|
+
args = buildPiArgs(payload, input, skillPaths, prompt);
|
|
193
|
+
}
|
|
194
|
+
catch (err) {
|
|
195
|
+
const result = makeResult(step, input, attempt, startedAt, "FAILED", `Pi setup failed: ${err.message}`);
|
|
196
|
+
hooks?.onFinished?.({ executionStatus: result.execution_status });
|
|
197
|
+
return Promise.resolve(result);
|
|
198
|
+
}
|
|
199
|
+
return new Promise((resolve) => {
|
|
200
|
+
let child;
|
|
201
|
+
try {
|
|
202
|
+
child = spawn(command, args, {
|
|
203
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
204
|
+
env: { ...process.env, WFM_PI_INPUT_FILE: inputPath, WFM_PI_OUTPUT_FILE: outputPath },
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
catch (err) {
|
|
208
|
+
resolve(makeResult(step, input, attempt, startedAt, "FAILED", err.message, {
|
|
209
|
+
command,
|
|
210
|
+
inputPath,
|
|
211
|
+
outputPath,
|
|
212
|
+
timeoutMs,
|
|
213
|
+
}));
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
hooks?.onStarted?.({ command, inputPath, outputPath, timeoutMs });
|
|
217
|
+
const outChunks = [];
|
|
218
|
+
const errChunks = [];
|
|
219
|
+
child.stdout?.setEncoding("utf-8");
|
|
220
|
+
child.stderr?.setEncoding("utf-8");
|
|
221
|
+
child.stdout?.on("data", (chunk) => {
|
|
222
|
+
outChunks.push(chunk);
|
|
223
|
+
hooks?.onStdout?.(chunk);
|
|
224
|
+
});
|
|
225
|
+
child.stderr?.on("data", (chunk) => {
|
|
226
|
+
errChunks.push(chunk);
|
|
227
|
+
hooks?.onStderr?.(chunk);
|
|
228
|
+
});
|
|
229
|
+
let timedOut = false;
|
|
230
|
+
const timer = setTimeout(() => {
|
|
231
|
+
timedOut = true;
|
|
232
|
+
child.kill("SIGTERM");
|
|
233
|
+
const result = makeResult(step, input, attempt, startedAt, "FAILED", `timed out after ${timeoutMs}ms`, { command, inputPath, outputPath, timeoutMs, stdout: outChunks.join(""), stderr: errChunks.join("") });
|
|
234
|
+
hooks?.onFinished?.({ executionStatus: result.execution_status, timedOut: true });
|
|
235
|
+
resolve(result);
|
|
236
|
+
}, timeoutMs);
|
|
237
|
+
child.on("error", (err) => {
|
|
238
|
+
clearTimeout(timer);
|
|
239
|
+
if (timedOut)
|
|
240
|
+
return;
|
|
241
|
+
const result = makeResult(step, input, attempt, startedAt, "FAILED", err.message, {
|
|
242
|
+
command,
|
|
243
|
+
inputPath,
|
|
244
|
+
outputPath,
|
|
245
|
+
timeoutMs,
|
|
246
|
+
stdout: outChunks.join(""),
|
|
247
|
+
stderr: errChunks.join(""),
|
|
248
|
+
});
|
|
249
|
+
hooks?.onFinished?.({ executionStatus: result.execution_status });
|
|
250
|
+
resolve(result);
|
|
251
|
+
});
|
|
252
|
+
child.on("close", (code) => {
|
|
253
|
+
clearTimeout(timer);
|
|
254
|
+
if (timedOut)
|
|
255
|
+
return;
|
|
256
|
+
const stdout = outChunks.join("");
|
|
257
|
+
const stderr = errChunks.join("");
|
|
258
|
+
const exitStatus = code ?? 1;
|
|
259
|
+
if (exitStatus !== 0) {
|
|
260
|
+
const result = makeResult(step, input, attempt, startedAt, "FAILED", `${command} exited with status ${exitStatus}`, {
|
|
261
|
+
command,
|
|
262
|
+
inputPath,
|
|
263
|
+
outputPath,
|
|
264
|
+
exitStatus,
|
|
265
|
+
stdout,
|
|
266
|
+
stderr,
|
|
267
|
+
});
|
|
268
|
+
hooks?.onFinished?.({ executionStatus: result.execution_status, exitStatus });
|
|
269
|
+
resolve(result);
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
if (!fs.existsSync(outputPath)) {
|
|
273
|
+
const result = makeResult(step, input, attempt, startedAt, "SUCCESS", "pi completed without a result envelope; using response text", { command, inputPath, outputPath, exitStatus, response: stdout.trim() });
|
|
274
|
+
hooks?.onFinished?.({ executionStatus: result.execution_status, exitStatus });
|
|
275
|
+
resolve(result);
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
try {
|
|
279
|
+
const rawOutput = JSON.parse(fs.readFileSync(outputPath, "utf-8"));
|
|
280
|
+
const result = normalizeOutputEnvelope(rawOutput, step, input, attempt, startedAt);
|
|
281
|
+
hooks?.onFinished?.({ executionStatus: result.execution_status, exitStatus });
|
|
282
|
+
resolve(result);
|
|
283
|
+
}
|
|
284
|
+
catch (err) {
|
|
285
|
+
const result = makeResult(step, input, attempt, startedAt, "FAILED", `Invalid Pi result envelope: ${err.message}`, {
|
|
286
|
+
command,
|
|
287
|
+
inputPath,
|
|
288
|
+
outputPath,
|
|
289
|
+
exitStatus,
|
|
290
|
+
stdout,
|
|
291
|
+
stderr,
|
|
292
|
+
});
|
|
293
|
+
hooks?.onFinished?.({ executionStatus: result.execution_status, exitStatus });
|
|
294
|
+
resolve(result);
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
}
|
package/dist/remote/api.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { WorkflowDefinition } from "../types.js";
|
|
2
|
+
export declare function bundleSkills(workflow: WorkflowDefinition, workflowFilePath: string): WorkflowDefinition;
|
|
1
3
|
export declare function cmdAuth(args: string[]): Promise<number>;
|
|
2
4
|
export declare function cmdSearch(args: string[]): Promise<number>;
|
|
3
5
|
export declare function cmdPublish(filePath: string, args: string[]): Promise<number>;
|
package/dist/remote/commands.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import fs from "node:fs";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
import { parseWorkflowFile, validateWorkflow } from "../parser.js";
|
|
@@ -31,6 +32,64 @@ function splitOwnerSlug(value) {
|
|
|
31
32
|
function sourceFormatFromPath(filePath) {
|
|
32
33
|
return path.extname(filePath).toLowerCase() === ".json" ? "json" : "markdown";
|
|
33
34
|
}
|
|
35
|
+
function hashContentSha256(content) {
|
|
36
|
+
return createHash("sha256").update(content).digest("hex");
|
|
37
|
+
}
|
|
38
|
+
function isAllowedLocalSkillSourcePath(source) {
|
|
39
|
+
if (!source || path.isAbsolute(source) || source.includes("\\") || source.includes(".."))
|
|
40
|
+
return false;
|
|
41
|
+
const normalized = path.posix.normalize(source);
|
|
42
|
+
const withoutDot = normalized.startsWith("./") ? normalized.slice(2) : normalized;
|
|
43
|
+
if (!withoutDot.startsWith("skills/"))
|
|
44
|
+
return false;
|
|
45
|
+
return withoutDot.endsWith("/SKILL.md");
|
|
46
|
+
}
|
|
47
|
+
function resolveAllowedLocalSkillSourcePath(workflowDir, source) {
|
|
48
|
+
if (!isAllowedLocalSkillSourcePath(source)) {
|
|
49
|
+
throw new Error(`Skill source must be under ./skills/**/SKILL.md: ${source}`);
|
|
50
|
+
}
|
|
51
|
+
const sourcePath = path.resolve(workflowDir, source);
|
|
52
|
+
const allowedRoot = path.resolve(workflowDir, "skills");
|
|
53
|
+
if (!sourcePath.startsWith(`${allowedRoot}${path.sep}`)) {
|
|
54
|
+
throw new Error(`Skill source escapes ./skills directory: ${source}`);
|
|
55
|
+
}
|
|
56
|
+
if (path.basename(sourcePath) !== "SKILL.md") {
|
|
57
|
+
throw new Error(`Skill source must point to SKILL.md: ${source}`);
|
|
58
|
+
}
|
|
59
|
+
return sourcePath;
|
|
60
|
+
}
|
|
61
|
+
export function bundleSkills(workflow, workflowFilePath) {
|
|
62
|
+
if (!workflow.skills)
|
|
63
|
+
return workflow;
|
|
64
|
+
const workflowDir = path.dirname(path.resolve(workflowFilePath));
|
|
65
|
+
const bundled = {};
|
|
66
|
+
for (const [name, entry] of Object.entries(workflow.skills)) {
|
|
67
|
+
if (entry.content && entry.content.trim()) {
|
|
68
|
+
const content = entry.content;
|
|
69
|
+
bundled[name] = {
|
|
70
|
+
...entry,
|
|
71
|
+
content,
|
|
72
|
+
contentSha256: hashContentSha256(content),
|
|
73
|
+
};
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (!entry.source) {
|
|
77
|
+
throw new Error(`Skill "${name}" has neither content nor source`);
|
|
78
|
+
}
|
|
79
|
+
const sourcePath = resolveAllowedLocalSkillSourcePath(workflowDir, entry.source);
|
|
80
|
+
if (!fs.existsSync(sourcePath)) {
|
|
81
|
+
throw new Error(`Skill "${name}" source file not found: ${sourcePath}`);
|
|
82
|
+
}
|
|
83
|
+
const content = fs.readFileSync(sourcePath, "utf-8");
|
|
84
|
+
bundled[name] = {
|
|
85
|
+
source: entry.source,
|
|
86
|
+
upstream: entry.upstream,
|
|
87
|
+
content,
|
|
88
|
+
contentSha256: hashContentSha256(content),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
return { ...workflow, skills: bundled };
|
|
92
|
+
}
|
|
34
93
|
function normalizeTags(raw) {
|
|
35
94
|
if (!raw) {
|
|
36
95
|
return [];
|
|
@@ -116,15 +175,19 @@ export async function cmdPublish(filePath, args) {
|
|
|
116
175
|
const publishedState = hasFlag(args, "--draft") ? "draft" : "published";
|
|
117
176
|
const tags = normalizeTags(getFlag(args, "--tag"));
|
|
118
177
|
const changelog = getFlag(args, "--changelog")?.trim() || null;
|
|
178
|
+
const bundled = bundleSkills(workflow, resolvedPath);
|
|
179
|
+
const hasSkills = Object.keys(bundled.skills ?? {}).length > 0;
|
|
180
|
+
const sourceFormat = hasSkills ? "json" : sourceFormatFromPath(resolvedPath);
|
|
181
|
+
const publishSource = hasSkills ? JSON.stringify(bundled, null, 2) : rawSource;
|
|
119
182
|
const result = await publishRemoteWorkflow({
|
|
120
183
|
slug,
|
|
121
184
|
title,
|
|
122
185
|
description,
|
|
123
186
|
visibility,
|
|
124
187
|
versionLabel,
|
|
125
|
-
sourceFormat
|
|
126
|
-
rawSource,
|
|
127
|
-
definition:
|
|
188
|
+
sourceFormat,
|
|
189
|
+
rawSource: publishSource,
|
|
190
|
+
definition: bundled,
|
|
128
191
|
tags,
|
|
129
192
|
changelog,
|
|
130
193
|
publishedState,
|
|
@@ -145,12 +208,21 @@ export async function cmdPull(reference, args) {
|
|
|
145
208
|
const outputPath = getFlag(args, "--output") ??
|
|
146
209
|
path.resolve(`${slug}.${pulled.sourceFormat === "json" ? "json" : "md"}`);
|
|
147
210
|
fs.writeFileSync(outputPath, pulled.rawSource, "utf-8");
|
|
148
|
-
const
|
|
211
|
+
const parsed = parseWorkflowFile(outputPath);
|
|
212
|
+
const validationErrors = validateWorkflow(parsed);
|
|
149
213
|
if (validationErrors.length > 0) {
|
|
150
214
|
fs.rmSync(outputPath);
|
|
151
215
|
console.error(`Pulled workflow failed local validation: ${validationErrors.join("; ")}`);
|
|
152
216
|
return 1;
|
|
153
217
|
}
|
|
218
|
+
const missingEmbeddedSkills = Object.entries(parsed.skills ?? {})
|
|
219
|
+
.filter(([, entry]) => !entry.content?.trim())
|
|
220
|
+
.map(([name]) => name);
|
|
221
|
+
if (missingEmbeddedSkills.length > 0) {
|
|
222
|
+
fs.rmSync(outputPath);
|
|
223
|
+
console.error(`Pulled workflow is missing embedded content for skills: ${missingEmbeddedSkills.join(", ")}`);
|
|
224
|
+
return 1;
|
|
225
|
+
}
|
|
154
226
|
console.log(`Pulled ${owner}/${slug}@${pulled.version} -> ${path.resolve(outputPath)}`);
|
|
155
227
|
return 0;
|
|
156
228
|
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { RunnerSessionStore } from "./runnerSession.js";
|
|
2
|
+
interface RunnerApiServer {
|
|
3
|
+
port: number;
|
|
4
|
+
close: () => Promise<void>;
|
|
5
|
+
}
|
|
6
|
+
export declare function startRunnerApiServer(store: RunnerSessionStore, requestedPort: number): Promise<RunnerApiServer>;
|
|
7
|
+
export {};
|