@agentic-sdlc/dispatch 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/bin/dispatch.ts +245 -0
- package/package.json +35 -0
- package/src/index.ts +8 -0
- package/src/orchestrator.ts +273 -0
- package/src/providers/daytona.ts +157 -0
- package/src/providers/direct-runner.ts +91 -0
- package/src/sandbox-provider.ts +58 -0
- package/src/types.ts +40 -0
- package/src/workflow-reader.ts +99 -0
package/bin/dispatch.ts
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { orchestrateStage } from "../src/orchestrator.js";
|
|
5
|
+
import { readWorkflow } from "../src/workflow-reader.js";
|
|
6
|
+
import { createSandboxProvider } from "../src/sandbox-provider.js";
|
|
7
|
+
import type { RpcCommand } from "../src/sandbox-provider.js";
|
|
8
|
+
|
|
9
|
+
interface CliArgs {
|
|
10
|
+
issue: number;
|
|
11
|
+
repo: string;
|
|
12
|
+
label?: string;
|
|
13
|
+
comment?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function parseArgs(argv: string[]): CliArgs {
|
|
17
|
+
const args: Partial<CliArgs> = {};
|
|
18
|
+
|
|
19
|
+
for (let i = 0; i < argv.length; i++) {
|
|
20
|
+
const key = argv[i];
|
|
21
|
+
const value = argv[i + 1];
|
|
22
|
+
switch (key) {
|
|
23
|
+
case "--issue":
|
|
24
|
+
args.issue = Number(value);
|
|
25
|
+
i++;
|
|
26
|
+
break;
|
|
27
|
+
case "--repo":
|
|
28
|
+
args.repo = value;
|
|
29
|
+
i++;
|
|
30
|
+
break;
|
|
31
|
+
case "--label":
|
|
32
|
+
args.label = value;
|
|
33
|
+
i++;
|
|
34
|
+
break;
|
|
35
|
+
case "--comment":
|
|
36
|
+
args.comment = value;
|
|
37
|
+
i++;
|
|
38
|
+
break;
|
|
39
|
+
case "--help":
|
|
40
|
+
printUsage();
|
|
41
|
+
process.exit(0);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (!args.issue) args.issue = parseInt(process.env.ISSUE_NUMBER ?? "", 10);
|
|
46
|
+
if (!args.repo) args.repo = process.env.REPO_NAME ?? process.env.GITHUB_REPOSITORY ?? "";
|
|
47
|
+
if (!args.label && !args.comment) args.label = process.env.LABEL_NAME ?? "";
|
|
48
|
+
|
|
49
|
+
if (!args.issue || isNaN(args.issue)) {
|
|
50
|
+
console.error("[dispatch] Missing required: --issue <number> or ISSUE_NUMBER env");
|
|
51
|
+
process.exit(1);
|
|
52
|
+
}
|
|
53
|
+
if (!args.repo) {
|
|
54
|
+
console.error("[dispatch] Missing required: --repo <owner/repo> or REPO_NAME env");
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}
|
|
57
|
+
if (!args.label && !args.comment) {
|
|
58
|
+
console.error("[dispatch] Missing required: --label or --comment (or LABEL_NAME env)");
|
|
59
|
+
process.exit(1);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return args as CliArgs;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function printUsage(): void {
|
|
66
|
+
console.log(`Usage: harness-dispatch [options]
|
|
67
|
+
|
|
68
|
+
Options:
|
|
69
|
+
--issue <number> Issue number (or ISSUE_NUMBER env)
|
|
70
|
+
--repo <owner/repo> Repository (or REPO_NAME / GITHUB_REPOSITORY env)
|
|
71
|
+
--label <name> Label that triggered the pipeline (or LABEL_NAME env)
|
|
72
|
+
--comment <text> Forward a comment to the agent
|
|
73
|
+
--help Show this help
|
|
74
|
+
|
|
75
|
+
Environment variables:
|
|
76
|
+
DAYTONA_API_KEY Sandbox provider auth
|
|
77
|
+
DAYTONA_API_URL Sandbox API endpoint (default: https://app.daytona.io/api)
|
|
78
|
+
GH_TOKEN GitHub token for API access
|
|
79
|
+
HARNESS_MODEL_API_KEY Model provider API key
|
|
80
|
+
MODEL Model identifier
|
|
81
|
+
AWS_REGION AWS region (default: us-east-1)
|
|
82
|
+
SNAPSHOT_NAME Daytona snapshot name (default: pi-agent-v1)
|
|
83
|
+
HARNESS_SANDBOX_PROVIDER Provider override: daytona | direct
|
|
84
|
+
`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
interface DispatchConfig {
|
|
88
|
+
ghToken: string;
|
|
89
|
+
modelApiKey: string;
|
|
90
|
+
modelRegion: string;
|
|
91
|
+
model: string;
|
|
92
|
+
snapshotName: string;
|
|
93
|
+
daytonaApiKey?: string;
|
|
94
|
+
daytonaApiUrl?: string;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function loadConfig(): DispatchConfig {
|
|
98
|
+
const ghToken = process.env.GH_TOKEN ?? "";
|
|
99
|
+
if (!ghToken) {
|
|
100
|
+
console.error("[dispatch] Missing required: GH_TOKEN env");
|
|
101
|
+
process.exit(1);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
ghToken,
|
|
106
|
+
modelApiKey: process.env.HARNESS_MODEL_API_KEY ?? process.env.MODEL_API_KEY ?? "",
|
|
107
|
+
modelRegion: process.env.AWS_REGION ?? "us-east-1",
|
|
108
|
+
model: process.env.MODEL ?? "us.anthropic.claude-sonnet-4-20250514-v1:0",
|
|
109
|
+
snapshotName: process.env.SNAPSHOT_NAME ?? "pi-agent-v1",
|
|
110
|
+
daytonaApiKey: process.env.DAYTONA_API_KEY,
|
|
111
|
+
daytonaApiUrl: process.env.DAYTONA_API_URL,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function main(): Promise<void> {
|
|
116
|
+
const args = parseArgs(process.argv.slice(2));
|
|
117
|
+
const config = loadConfig();
|
|
118
|
+
|
|
119
|
+
const workflowPath = resolve("WORKFLOW.md");
|
|
120
|
+
if (!existsSync(workflowPath)) {
|
|
121
|
+
console.error("[dispatch] WORKFLOW.md not found in current directory");
|
|
122
|
+
process.exit(1);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const workflowConfig = readWorkflow(workflowPath);
|
|
126
|
+
console.log(`[dispatch] Issue #${args.issue} | repo: ${args.repo} | ${args.label ? `label: ${args.label}` : `comment mode`}`);
|
|
127
|
+
|
|
128
|
+
const provider = createSandboxProvider({
|
|
129
|
+
apiKey: config.daytonaApiKey ?? "",
|
|
130
|
+
apiUrl: config.daytonaApiUrl ?? "",
|
|
131
|
+
ghToken: config.ghToken,
|
|
132
|
+
snapshotName: config.snapshotName,
|
|
133
|
+
modelApiKey: config.modelApiKey,
|
|
134
|
+
modelRegion: config.modelRegion,
|
|
135
|
+
model: config.model,
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const sandbox = await provider.create({
|
|
139
|
+
issue: args.issue,
|
|
140
|
+
repo: args.repo,
|
|
141
|
+
profile: workflowConfig.sandbox.default_profile,
|
|
142
|
+
});
|
|
143
|
+
console.log(`[dispatch] Sandbox created: ${sandbox.id}`);
|
|
144
|
+
|
|
145
|
+
try {
|
|
146
|
+
await provider.rpc(sandbox.id, {
|
|
147
|
+
type: "prompt",
|
|
148
|
+
text: [
|
|
149
|
+
'git config user.email "bot@harness.dev"',
|
|
150
|
+
'git config user.name "Harness Bot"',
|
|
151
|
+
].join(" && "),
|
|
152
|
+
issueNumber: args.issue,
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
if (args.label) {
|
|
156
|
+
await runLabelDispatch(args, config, provider, sandbox.id, workflowConfig);
|
|
157
|
+
} else if (args.comment) {
|
|
158
|
+
await runCommentDispatch(args, config, provider, sandbox.id);
|
|
159
|
+
}
|
|
160
|
+
} finally {
|
|
161
|
+
console.log(`[dispatch] Destroying sandbox ${sandbox.id}...`);
|
|
162
|
+
await provider.destroy(sandbox.id);
|
|
163
|
+
console.log("[dispatch] Done.");
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function runLabelDispatch(
|
|
168
|
+
args: CliArgs,
|
|
169
|
+
config: DispatchConfig,
|
|
170
|
+
provider: Awaited<ReturnType<typeof createSandboxProvider>>,
|
|
171
|
+
sandboxId: string,
|
|
172
|
+
workflowConfig: ReturnType<typeof readWorkflow>,
|
|
173
|
+
): Promise<void> {
|
|
174
|
+
const piCommand = [
|
|
175
|
+
`ISSUE_NUMBER=${args.issue}`,
|
|
176
|
+
`AWS_BEARER_TOKEN_BEDROCK=$HARNESS_MODEL_API_KEY`,
|
|
177
|
+
`AWS_REGION=${config.modelRegion}`,
|
|
178
|
+
`GH_TOKEN=$GH_TOKEN`,
|
|
179
|
+
`pi -p "Process issue #${args.issue}. Current label: ${args.label}."`,
|
|
180
|
+
`-e node_modules/@agentic-sdlc/extension/src/index.ts`,
|
|
181
|
+
`--provider amazon-bedrock`,
|
|
182
|
+
`--model ${config.model}`,
|
|
183
|
+
].join(" ");
|
|
184
|
+
|
|
185
|
+
console.log("[dispatch] Starting Pi agent...");
|
|
186
|
+
try {
|
|
187
|
+
const output = await provider.rpc(sandboxId, {
|
|
188
|
+
type: "prompt",
|
|
189
|
+
text: piCommand,
|
|
190
|
+
issueNumber: args.issue,
|
|
191
|
+
});
|
|
192
|
+
if (output) console.log(`[dispatch] Pi output: ${output.slice(0, 500)}`);
|
|
193
|
+
} catch (err) {
|
|
194
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
195
|
+
console.log(`[dispatch] Pi exited: ${message.slice(0, 500)}`);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
console.log("[dispatch] Running post-agent orchestration...");
|
|
199
|
+
const artifactsDir = resolve(`artifacts/issue-${args.issue}`);
|
|
200
|
+
const result = orchestrateStage({
|
|
201
|
+
issueNumber: args.issue,
|
|
202
|
+
repo: args.repo,
|
|
203
|
+
label: args.label!,
|
|
204
|
+
workflowConfig,
|
|
205
|
+
artifactsDir,
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
console.log(`[dispatch] Orchestration: stage=${result.stage} action=${result.action} nextLabel=${result.nextLabel ?? "none"}`);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function runCommentDispatch(
|
|
212
|
+
args: CliArgs,
|
|
213
|
+
config: DispatchConfig,
|
|
214
|
+
provider: Awaited<ReturnType<typeof createSandboxProvider>>,
|
|
215
|
+
sandboxId: string,
|
|
216
|
+
): Promise<void> {
|
|
217
|
+
const escapedComment = (args.comment ?? "").replace(/'/g, "'\\''");
|
|
218
|
+
const piCommand = [
|
|
219
|
+
`ISSUE_NUMBER=${args.issue}`,
|
|
220
|
+
`AWS_BEARER_TOKEN_BEDROCK=$HARNESS_MODEL_API_KEY`,
|
|
221
|
+
`AWS_REGION=${config.modelRegion}`,
|
|
222
|
+
`GH_TOKEN=$GH_TOKEN`,
|
|
223
|
+
`pi -p 'Human commented on issue #${args.issue}: ${escapedComment}'`,
|
|
224
|
+
`-e node_modules/@agentic-sdlc/extension/src/index.ts`,
|
|
225
|
+
`--provider amazon-bedrock`,
|
|
226
|
+
`--model ${config.model}`,
|
|
227
|
+
].join(" ");
|
|
228
|
+
|
|
229
|
+
console.log("[dispatch] Forwarding comment to Pi agent...");
|
|
230
|
+
try {
|
|
231
|
+
await provider.rpc(sandboxId, {
|
|
232
|
+
type: "follow_up",
|
|
233
|
+
text: piCommand,
|
|
234
|
+
issueNumber: args.issue,
|
|
235
|
+
});
|
|
236
|
+
} catch (err) {
|
|
237
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
238
|
+
console.log(`[dispatch] Pi exited: ${message.slice(0, 500)}`);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
main().catch((err) => {
|
|
243
|
+
console.error(`[dispatch] Fatal: ${err instanceof Error ? err.message : String(err)}`);
|
|
244
|
+
process.exit(1);
|
|
245
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@agentic-sdlc/dispatch",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "CI-side dispatch and orchestration for the agentic SDLC harness",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/hjgraca/agentic-sdlc-harness.git",
|
|
9
|
+
"directory": "packages/dispatch"
|
|
10
|
+
},
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"import": "./src/index.ts",
|
|
14
|
+
"types": "./src/index.ts"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"bin": {
|
|
18
|
+
"harness-dispatch": "./bin/dispatch.ts"
|
|
19
|
+
},
|
|
20
|
+
"files": ["src", "bin", "dist"],
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"@daytona/sdk": "^0.175.0",
|
|
23
|
+
"yaml": "^2.8.0"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"vitest": "^3.2.0",
|
|
27
|
+
"@types/node": "^22.0.0",
|
|
28
|
+
"typescript": "^5.8.0"
|
|
29
|
+
},
|
|
30
|
+
"scripts": {
|
|
31
|
+
"test": "vitest run",
|
|
32
|
+
"test:watch": "vitest",
|
|
33
|
+
"build": "tsc"
|
|
34
|
+
}
|
|
35
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { orchestrateStage } from "./orchestrator.js";
|
|
2
|
+
export { readWorkflow } from "./workflow-reader.js";
|
|
3
|
+
export type { StageConfig, PipelineConfig, WorkflowConfig, OrchestrationContext, OrchestrationResult } from "./types.js";
|
|
4
|
+
|
|
5
|
+
export { createSandboxProvider } from "./sandbox-provider.js";
|
|
6
|
+
export type { SandboxProvider, SandboxProviderConfig, RpcCommand, SandboxStatus } from "./sandbox-provider.js";
|
|
7
|
+
export { DaytonaSandboxProvider } from "./providers/daytona.js";
|
|
8
|
+
export { DirectRunnerProvider } from "./providers/direct-runner.js";
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import type { OrchestrationContext, OrchestrationResult, StageConfig } from "./types.js";
|
|
5
|
+
|
|
6
|
+
export function orchestrateStage(ctx: OrchestrationContext): OrchestrationResult {
|
|
7
|
+
const { issueNumber, repo, label, workflowConfig, artifactsDir } = ctx;
|
|
8
|
+
const { pipeline } = workflowConfig;
|
|
9
|
+
|
|
10
|
+
// 1. Find the stage from the label
|
|
11
|
+
const stageName = pipeline.labels[label];
|
|
12
|
+
if (!stageName) {
|
|
13
|
+
console.error(`[dispatch] No stage mapped for label "${label}"`);
|
|
14
|
+
return { stage: "unknown", action: "escalated", nextLabel: "escalated" };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const stageIndex = pipeline.stages.findIndex((s) => s.name === stageName);
|
|
18
|
+
if (stageIndex === -1) {
|
|
19
|
+
console.error(`[dispatch] Stage "${stageName}" not found in pipeline.stages`);
|
|
20
|
+
return { stage: stageName, action: "escalated", nextLabel: "escalated" };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const stage = pipeline.stages[stageIndex];
|
|
24
|
+
|
|
25
|
+
// 2. Check artifact exists (if stage produces one)
|
|
26
|
+
if (stage.produces) {
|
|
27
|
+
const artifactPath = join(artifactsDir, stage.produces);
|
|
28
|
+
if (!existsSync(artifactPath)) {
|
|
29
|
+
console.error(`[dispatch] Artifact not found: ${artifactPath}`);
|
|
30
|
+
applyLabel(repo, issueNumber, "escalated", label);
|
|
31
|
+
return { stage: stage.name, action: "no-artifact", nextLabel: "escalated" };
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// 3. Handle by gate type
|
|
36
|
+
switch (stage.gate) {
|
|
37
|
+
case "none":
|
|
38
|
+
return handleGateNone(ctx, stage, stageIndex);
|
|
39
|
+
case "pr-review":
|
|
40
|
+
return handleGatePrReview(ctx, stage, stageIndex);
|
|
41
|
+
case "retry":
|
|
42
|
+
return handleGateRetry(ctx, stage, stageIndex);
|
|
43
|
+
default:
|
|
44
|
+
console.error(`[dispatch] Unknown gate type: ${stage.gate}`);
|
|
45
|
+
return { stage: stage.name, action: "escalated", nextLabel: "escalated" };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function handleGateNone(
|
|
50
|
+
ctx: OrchestrationContext,
|
|
51
|
+
stage: StageConfig,
|
|
52
|
+
stageIndex: number
|
|
53
|
+
): OrchestrationResult {
|
|
54
|
+
const { issueNumber, repo, label, workflowConfig, artifactsDir } = ctx;
|
|
55
|
+
|
|
56
|
+
// Commit artifact to current branch and push
|
|
57
|
+
try {
|
|
58
|
+
execFileSync("git", ["add", artifactsDir]);
|
|
59
|
+
execFileSync("git", ["commit", "-m", `chore: add ${stage.produces} for issue #${issueNumber}`]);
|
|
60
|
+
execFileSync("git", ["push"]);
|
|
61
|
+
} catch (err) {
|
|
62
|
+
console.error(`[dispatch] Git commit/push failed for gate=none:`, err);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Advance to next stage label
|
|
66
|
+
const nextLabel = findNextStageLabel(workflowConfig.pipeline, stageIndex);
|
|
67
|
+
if (nextLabel) {
|
|
68
|
+
applyLabel(repo, issueNumber, nextLabel, label);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return { stage: stage.name, action: "advanced", nextLabel };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function handleGatePrReview(
|
|
75
|
+
ctx: OrchestrationContext,
|
|
76
|
+
stage: StageConfig,
|
|
77
|
+
stageIndex: number
|
|
78
|
+
): OrchestrationResult {
|
|
79
|
+
const { issueNumber, repo, label, workflowConfig, artifactsDir } = ctx;
|
|
80
|
+
|
|
81
|
+
const branchName = renderTemplate(stage.branch_pattern ?? `${stage.name}/issue-{{ issue.number }}`, issueNumber);
|
|
82
|
+
|
|
83
|
+
// Create or checkout branch
|
|
84
|
+
try {
|
|
85
|
+
execFileSync("git", ["checkout", "-b", branchName]);
|
|
86
|
+
} catch {
|
|
87
|
+
// Branch may already exist — try checking it out
|
|
88
|
+
try {
|
|
89
|
+
execFileSync("git", ["checkout", branchName]);
|
|
90
|
+
} catch (err) {
|
|
91
|
+
console.error(`[dispatch] Failed to checkout branch ${branchName}:`, err);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Commit artifacts
|
|
96
|
+
try {
|
|
97
|
+
execFileSync("git", ["add", artifactsDir]);
|
|
98
|
+
execFileSync("git", ["commit", "-m", `feat: add ${stage.produces} for issue #${issueNumber}`]);
|
|
99
|
+
} catch (err) {
|
|
100
|
+
console.error(`[dispatch] Git commit failed for pr-review gate:`, err);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Push branch
|
|
104
|
+
try {
|
|
105
|
+
execFileSync("git", ["push", "-u", "origin", branchName]);
|
|
106
|
+
} catch (err) {
|
|
107
|
+
console.error(`[dispatch] Git push failed:`, err);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Open PR if one does not already exist
|
|
111
|
+
let prCreated = false;
|
|
112
|
+
try {
|
|
113
|
+
const existing = execFileSync("gh", [
|
|
114
|
+
"pr", "list",
|
|
115
|
+
"--repo", repo,
|
|
116
|
+
"--head", branchName,
|
|
117
|
+
"--json", "number",
|
|
118
|
+
]).toString().trim();
|
|
119
|
+
|
|
120
|
+
const prs = JSON.parse(existing || "[]");
|
|
121
|
+
if (prs.length === 0) {
|
|
122
|
+
execFileSync("gh", [
|
|
123
|
+
"pr", "create",
|
|
124
|
+
"--repo", repo,
|
|
125
|
+
"--head", branchName,
|
|
126
|
+
"--title", `[#${issueNumber}] ${stage.name}: ${stage.produces}`,
|
|
127
|
+
"--body", `Automated PR for issue #${issueNumber} — stage: ${stage.name}`,
|
|
128
|
+
]);
|
|
129
|
+
prCreated = true;
|
|
130
|
+
}
|
|
131
|
+
} catch (err) {
|
|
132
|
+
console.error(`[dispatch] PR creation failed:`, err);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Find the "ready" label for this stage (convention: stage's own ready label)
|
|
136
|
+
const readyLabel = findReadyLabel(workflowConfig.pipeline, stage.name);
|
|
137
|
+
const nextLabel = readyLabel ?? findNextStageLabel(workflowConfig.pipeline, stageIndex);
|
|
138
|
+
|
|
139
|
+
if (nextLabel) {
|
|
140
|
+
applyLabel(repo, issueNumber, nextLabel, label);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
stage: stage.name,
|
|
145
|
+
action: prCreated ? "opened-pr" : "advanced",
|
|
146
|
+
nextLabel,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function handleGateRetry(
|
|
151
|
+
ctx: OrchestrationContext,
|
|
152
|
+
stage: StageConfig,
|
|
153
|
+
stageIndex: number
|
|
154
|
+
): OrchestrationResult {
|
|
155
|
+
const { issueNumber, repo, label, workflowConfig, artifactsDir } = ctx;
|
|
156
|
+
|
|
157
|
+
// Read verdict from artifact frontmatter
|
|
158
|
+
const verdict = readVerdictFromArtifact(artifactsDir, stage.produces ?? "");
|
|
159
|
+
const maxRetries = stage.max_retries ?? 3;
|
|
160
|
+
|
|
161
|
+
if (verdict === "pass") {
|
|
162
|
+
// Advance to next stage
|
|
163
|
+
const nextLabel = findNextStageLabel(workflowConfig.pipeline, stageIndex);
|
|
164
|
+
if (nextLabel) {
|
|
165
|
+
applyLabel(repo, issueNumber, nextLabel, label);
|
|
166
|
+
}
|
|
167
|
+
return { stage: stage.name, action: "advanced", nextLabel };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// verdict is "fail" or unknown — check retry count
|
|
171
|
+
const attemptCount = getAttemptCount(artifactsDir, stage.name);
|
|
172
|
+
|
|
173
|
+
if (attemptCount >= maxRetries) {
|
|
174
|
+
applyLabel(repo, issueNumber, "escalated", label);
|
|
175
|
+
return { stage: stage.name, action: "escalated", nextLabel: "escalated" };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Apply retry label (needs-fix)
|
|
179
|
+
const retryLabel = findRetryLabel(workflowConfig.pipeline, stage.name);
|
|
180
|
+
if (retryLabel) {
|
|
181
|
+
applyLabel(repo, issueNumber, retryLabel, label);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return { stage: stage.name, action: "retried", nextLabel: retryLabel };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// --- Helpers ---
|
|
188
|
+
|
|
189
|
+
function renderTemplate(pattern: string, issueNumber: number): string {
|
|
190
|
+
return pattern.replace(/\{\{\s*issue\.number\s*\}\}/g, String(issueNumber));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function applyLabel(repo: string, issueNumber: number, addLabel: string, removeLabel: string): void {
|
|
194
|
+
try {
|
|
195
|
+
execFileSync("gh", [
|
|
196
|
+
"issue", "edit", String(issueNumber),
|
|
197
|
+
"--repo", repo,
|
|
198
|
+
"--add-label", addLabel,
|
|
199
|
+
"--remove-label", removeLabel,
|
|
200
|
+
]);
|
|
201
|
+
} catch (err) {
|
|
202
|
+
console.error(`[dispatch] Label transition failed (add=${addLabel}, remove=${removeLabel}):`, err);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function findNextStageLabel(
|
|
207
|
+
pipeline: OrchestrationContext["workflowConfig"]["pipeline"],
|
|
208
|
+
currentStageIndex: number
|
|
209
|
+
): string | undefined {
|
|
210
|
+
const nextStage = pipeline.stages[currentStageIndex + 1];
|
|
211
|
+
if (!nextStage) return undefined;
|
|
212
|
+
|
|
213
|
+
// Find the label that maps to the next stage's name
|
|
214
|
+
for (const [lbl, stageName] of Object.entries(pipeline.labels)) {
|
|
215
|
+
if (stageName === nextStage.name) {
|
|
216
|
+
return lbl;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return undefined;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function findReadyLabel(
|
|
223
|
+
pipeline: OrchestrationContext["workflowConfig"]["pipeline"],
|
|
224
|
+
stageName: string
|
|
225
|
+
): string | undefined {
|
|
226
|
+
// Convention: look for a label like "{stage}-ready" that maps to this stage
|
|
227
|
+
for (const [lbl, mapped] of Object.entries(pipeline.labels)) {
|
|
228
|
+
if (mapped === stageName && lbl.endsWith("-ready")) {
|
|
229
|
+
return lbl;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return undefined;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function findRetryLabel(
|
|
236
|
+
pipeline: OrchestrationContext["workflowConfig"]["pipeline"],
|
|
237
|
+
stageName: string
|
|
238
|
+
): string | undefined {
|
|
239
|
+
// Look for a label that triggers the preceding stage (for retry, we go back)
|
|
240
|
+
// Convention: "needs-fix" maps to the build stage for retries
|
|
241
|
+
for (const [lbl, mapped] of Object.entries(pipeline.labels)) {
|
|
242
|
+
if (lbl === "needs-fix" || lbl.includes("retry") || lbl.includes("fix")) {
|
|
243
|
+
return lbl;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return undefined;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function readVerdictFromArtifact(artifactsDir: string, artifactName: string): string {
|
|
250
|
+
try {
|
|
251
|
+
const content = readFileSync(join(artifactsDir, artifactName), "utf-8");
|
|
252
|
+
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
|
|
253
|
+
if (!frontmatterMatch) return "unknown";
|
|
254
|
+
|
|
255
|
+
const verdictMatch = frontmatterMatch[1].match(/^verdict:\s*(.+)$/m);
|
|
256
|
+
return verdictMatch ? verdictMatch[1].trim().toLowerCase() : "unknown";
|
|
257
|
+
} catch {
|
|
258
|
+
return "unknown";
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function getAttemptCount(artifactsDir: string, stageName: string): number {
|
|
263
|
+
try {
|
|
264
|
+
const attemptFile = join(artifactsDir, `.${stageName}-attempts`);
|
|
265
|
+
if (existsSync(attemptFile)) {
|
|
266
|
+
const count = parseInt(readFileSync(attemptFile, "utf-8").trim(), 10);
|
|
267
|
+
return isNaN(count) ? 1 : count;
|
|
268
|
+
}
|
|
269
|
+
return 1;
|
|
270
|
+
} catch {
|
|
271
|
+
return 1;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { Daytona, Sandbox } from "@daytona/sdk";
|
|
2
|
+
import type { CreateSandboxFromSnapshotParams } from "@daytona/sdk";
|
|
3
|
+
import type { SandboxProvider, SandboxProviderConfig, RpcCommand, SandboxStatus } from "../sandbox-provider.js";
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Daytona state mapping
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
export function mapDaytonaState(daytonaState: string): SandboxStatus["state"] {
|
|
10
|
+
switch (daytonaState) {
|
|
11
|
+
case "started":
|
|
12
|
+
return "running";
|
|
13
|
+
case "stopped":
|
|
14
|
+
case "archived":
|
|
15
|
+
return "paused";
|
|
16
|
+
default:
|
|
17
|
+
return "stopped";
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// DaytonaSandboxProvider
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
export interface DaytonaSandboxProviderOptions {
|
|
26
|
+
apiKey: string;
|
|
27
|
+
apiUrl: string;
|
|
28
|
+
ghToken: string;
|
|
29
|
+
snapshotName?: string;
|
|
30
|
+
modelApiKey?: string;
|
|
31
|
+
modelRegion?: string;
|
|
32
|
+
model?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export class DaytonaSandboxProvider implements SandboxProvider {
|
|
36
|
+
private daytona: Daytona;
|
|
37
|
+
private ghToken: string;
|
|
38
|
+
private snapshotName: string | undefined;
|
|
39
|
+
private modelApiKey: string;
|
|
40
|
+
private modelRegion: string;
|
|
41
|
+
private model: string;
|
|
42
|
+
private sandboxCache: Map<string, Sandbox> = new Map();
|
|
43
|
+
|
|
44
|
+
constructor(options: DaytonaSandboxProviderOptions) {
|
|
45
|
+
if (!options.apiKey) {
|
|
46
|
+
throw new Error("DaytonaSandboxProvider: apiKey is required");
|
|
47
|
+
}
|
|
48
|
+
if (!options.apiUrl) {
|
|
49
|
+
options.apiUrl = "https://app.daytona.io/api";
|
|
50
|
+
}
|
|
51
|
+
if (!options.ghToken) {
|
|
52
|
+
throw new Error("DaytonaSandboxProvider: ghToken is required");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
this.daytona = new Daytona({
|
|
56
|
+
apiKey: options.apiKey,
|
|
57
|
+
apiUrl: options.apiUrl,
|
|
58
|
+
});
|
|
59
|
+
this.ghToken = options.ghToken;
|
|
60
|
+
this.snapshotName = options.snapshotName;
|
|
61
|
+
this.modelApiKey = options.modelApiKey ?? "";
|
|
62
|
+
this.modelRegion = options.modelRegion ?? "us-east-1";
|
|
63
|
+
this.model = options.model ?? "bedrock:us.anthropic.claude-sonnet-4-20250514-v1:0";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async create(config: SandboxProviderConfig): Promise<{ id: string }> {
|
|
67
|
+
const createOptions: CreateSandboxFromSnapshotParams = {
|
|
68
|
+
language: "typescript",
|
|
69
|
+
labels: {
|
|
70
|
+
"github-issue": String(config.issue),
|
|
71
|
+
"github-repo": config.repo,
|
|
72
|
+
profile: config.profile,
|
|
73
|
+
},
|
|
74
|
+
autoStopInterval: 30,
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
if (this.snapshotName) {
|
|
78
|
+
createOptions.snapshot = this.snapshotName;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const sandbox = await this.daytona.create(createOptions);
|
|
82
|
+
const id = sandbox.id;
|
|
83
|
+
|
|
84
|
+
this.sandboxCache.set(id, sandbox);
|
|
85
|
+
|
|
86
|
+
// Use token-authenticated URL for private repos
|
|
87
|
+
const repoUrl = `https://x-access-token:${this.ghToken}@github.com/${config.repo}.git`;
|
|
88
|
+
await sandbox.git.clone(repoUrl, "/home/daytona/project");
|
|
89
|
+
|
|
90
|
+
// Configure gh CLI auth and strip token from git remote
|
|
91
|
+
await sandbox.process.executeCommand(
|
|
92
|
+
"gh auth setup-git",
|
|
93
|
+
"/home/daytona/project",
|
|
94
|
+
{ GH_TOKEN: this.ghToken }
|
|
95
|
+
);
|
|
96
|
+
await sandbox.process.executeCommand(
|
|
97
|
+
`git remote set-url origin https://github.com/${config.repo}.git`,
|
|
98
|
+
"/home/daytona/project"
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
return { id };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async resume(id: string): Promise<void> {
|
|
105
|
+
const sandbox = await this.getSandbox(id);
|
|
106
|
+
await sandbox.start();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async pause(id: string): Promise<void> {
|
|
110
|
+
const sandbox = await this.getSandbox(id);
|
|
111
|
+
await sandbox.stop();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async destroy(id: string): Promise<void> {
|
|
115
|
+
const sandbox = await this.getSandbox(id);
|
|
116
|
+
await sandbox.delete();
|
|
117
|
+
this.sandboxCache.delete(id);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async rpc(id: string, command: RpcCommand): Promise<string> {
|
|
121
|
+
const sandbox = await this.getSandbox(id);
|
|
122
|
+
|
|
123
|
+
if (command.text) {
|
|
124
|
+
const result = await sandbox.process.executeCommand(
|
|
125
|
+
command.text,
|
|
126
|
+
"/home/daytona/project",
|
|
127
|
+
{
|
|
128
|
+
GH_TOKEN: this.ghToken,
|
|
129
|
+
MODEL_API_KEY: this.modelApiKey,
|
|
130
|
+
AWS_REGION: this.modelRegion,
|
|
131
|
+
MODEL: this.model,
|
|
132
|
+
ISSUE_NUMBER: String(command.issueNumber ?? ""),
|
|
133
|
+
}
|
|
134
|
+
);
|
|
135
|
+
if (result.exitCode !== 0) {
|
|
136
|
+
throw new Error(`RPC command failed (exit ${result.exitCode}): ${result.result}`);
|
|
137
|
+
}
|
|
138
|
+
return result.result ?? "";
|
|
139
|
+
}
|
|
140
|
+
return "";
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async status(id: string): Promise<SandboxStatus> {
|
|
144
|
+
const sandbox = await this.getSandbox(id);
|
|
145
|
+
const daytonaState: string = sandbox.state ?? "unknown";
|
|
146
|
+
return { state: mapDaytonaState(daytonaState) };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private async getSandbox(id: string): Promise<Sandbox> {
|
|
150
|
+
const cached = this.sandboxCache.get(id);
|
|
151
|
+
if (cached) return cached;
|
|
152
|
+
|
|
153
|
+
const sandbox = await this.daytona.get(id);
|
|
154
|
+
this.sandboxCache.set(id, sandbox);
|
|
155
|
+
return sandbox;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync, existsSync } from "node:fs";
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
6
|
+
import type { SandboxProvider, SandboxProviderConfig, RpcCommand, SandboxStatus } from "../sandbox-provider.js";
|
|
7
|
+
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// DirectRunnerProvider — lightweight local execution without remote sandboxes
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
export class DirectRunnerProvider implements SandboxProvider {
|
|
13
|
+
private sandboxes: Map<string, string> = new Map();
|
|
14
|
+
|
|
15
|
+
async create(config: SandboxProviderConfig): Promise<{ id: string }> {
|
|
16
|
+
const id = randomUUID();
|
|
17
|
+
const dir = mkdtempSync(join(tmpdir(), `harness-sandbox-${id.slice(0, 8)}-`));
|
|
18
|
+
|
|
19
|
+
// Clone the repo into the temp directory
|
|
20
|
+
const repoUrl = `https://github.com/${config.repo}.git`;
|
|
21
|
+
try {
|
|
22
|
+
execFileSync("git", ["clone", repoUrl, dir], {
|
|
23
|
+
stdio: "pipe",
|
|
24
|
+
env: { ...process.env },
|
|
25
|
+
});
|
|
26
|
+
} catch (err: unknown) {
|
|
27
|
+
// Clean up on clone failure
|
|
28
|
+
rmSync(dir, { recursive: true, force: true });
|
|
29
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
30
|
+
throw new Error(`DirectRunnerProvider: failed to clone ${config.repo}: ${message}`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
this.sandboxes.set(id, dir);
|
|
34
|
+
return { id };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async resume(_id: string): Promise<void> {
|
|
38
|
+
// no-op for local execution
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async pause(_id: string): Promise<void> {
|
|
42
|
+
// no-op for local execution
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async destroy(id: string): Promise<void> {
|
|
46
|
+
const dir = this.sandboxes.get(id);
|
|
47
|
+
if (dir && existsSync(dir)) {
|
|
48
|
+
rmSync(dir, { recursive: true, force: true });
|
|
49
|
+
}
|
|
50
|
+
this.sandboxes.delete(id);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async rpc(id: string, command: RpcCommand): Promise<string> {
|
|
54
|
+
const dir = this.sandboxes.get(id);
|
|
55
|
+
if (!dir) {
|
|
56
|
+
throw new Error(`DirectRunnerProvider: sandbox ${id} not found`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (!command.text) {
|
|
60
|
+
return "";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
const result = execFileSync("bash", ["-c", command.text], {
|
|
65
|
+
cwd: dir,
|
|
66
|
+
stdio: "pipe",
|
|
67
|
+
env: {
|
|
68
|
+
...process.env,
|
|
69
|
+
ISSUE_NUMBER: String(command.issueNumber ?? ""),
|
|
70
|
+
},
|
|
71
|
+
maxBuffer: 10 * 1024 * 1024, // 10MB
|
|
72
|
+
});
|
|
73
|
+
return result.toString();
|
|
74
|
+
} catch (err: unknown) {
|
|
75
|
+
const exitCode = (err as { status?: number }).status ?? 1;
|
|
76
|
+
const stderr = (err as { stderr?: Buffer }).stderr?.toString() ?? "";
|
|
77
|
+
throw new Error(
|
|
78
|
+
`DirectRunnerProvider: command failed (exit ${exitCode}): ${stderr || (err instanceof Error ? err.message : String(err))}`
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async status(_id: string): Promise<SandboxStatus> {
|
|
84
|
+
return { state: "running" };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Visible for testing: get the working directory for a sandbox */
|
|
88
|
+
getWorkDir(id: string): string | undefined {
|
|
89
|
+
return this.sandboxes.get(id);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Sandbox provider interface and factory
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
|
|
5
|
+
export interface SandboxProviderConfig {
|
|
6
|
+
issue: number;
|
|
7
|
+
repo: string;
|
|
8
|
+
profile: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface RpcCommand {
|
|
12
|
+
type: "prompt" | "follow_up" | "steer" | "abort" | "new_session";
|
|
13
|
+
text?: string;
|
|
14
|
+
issueNumber?: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface SandboxStatus {
|
|
18
|
+
state: "running" | "paused" | "stopped";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface SandboxProvider {
|
|
22
|
+
create(config: SandboxProviderConfig): Promise<{ id: string }>;
|
|
23
|
+
resume(id: string): Promise<void>;
|
|
24
|
+
pause(id: string): Promise<void>;
|
|
25
|
+
destroy(id: string): Promise<void>;
|
|
26
|
+
rpc(id: string, command: RpcCommand): Promise<string>;
|
|
27
|
+
status(id: string): Promise<SandboxStatus>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// Factory: auto-detect which provider to use
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
import { DirectRunnerProvider } from "./providers/direct-runner.js";
|
|
35
|
+
import { DaytonaSandboxProvider } from "./providers/daytona.js";
|
|
36
|
+
|
|
37
|
+
export function createSandboxProvider(options: Record<string, string> = {}): SandboxProvider {
|
|
38
|
+
const daytonaKey = options["DAYTONA_API_KEY"] ?? process.env["DAYTONA_API_KEY"];
|
|
39
|
+
const providerOverride = options["HARNESS_SANDBOX_PROVIDER"] ?? process.env["HARNESS_SANDBOX_PROVIDER"];
|
|
40
|
+
|
|
41
|
+
if (providerOverride === "direct") {
|
|
42
|
+
return new DirectRunnerProvider();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (daytonaKey) {
|
|
46
|
+
return new DaytonaSandboxProvider({
|
|
47
|
+
apiKey: daytonaKey,
|
|
48
|
+
apiUrl: options["DAYTONA_API_URL"] ?? process.env["DAYTONA_API_URL"] ?? "https://app.daytona.io/api",
|
|
49
|
+
ghToken: options["GH_TOKEN"] ?? process.env["GH_TOKEN"] ?? "",
|
|
50
|
+
modelApiKey: options["MODEL_API_KEY"] ?? process.env["MODEL_API_KEY"],
|
|
51
|
+
modelRegion: options["MODEL_REGION"] ?? process.env["AWS_REGION"],
|
|
52
|
+
model: options["MODEL"] ?? process.env["MODEL"],
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Default: fall back to DirectRunnerProvider
|
|
57
|
+
return new DirectRunnerProvider();
|
|
58
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export interface StageConfig {
|
|
2
|
+
name: string;
|
|
3
|
+
role: string;
|
|
4
|
+
skill: string;
|
|
5
|
+
tools: string[];
|
|
6
|
+
gate: "none" | "pr-review" | "retry";
|
|
7
|
+
produces?: string;
|
|
8
|
+
branch_pattern?: string;
|
|
9
|
+
hooks?: Record<string, string>;
|
|
10
|
+
reads_only?: string[];
|
|
11
|
+
max_retries?: number;
|
|
12
|
+
trigger?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface PipelineConfig {
|
|
16
|
+
stages: StageConfig[];
|
|
17
|
+
labels: Record<string, string>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface WorkflowConfig {
|
|
21
|
+
pipeline: PipelineConfig;
|
|
22
|
+
sandbox: { default_profile: string; profiles: Record<string, string[]> };
|
|
23
|
+
agent: { max_concurrent_issues: number; cost_ceiling: number; stale_threshold_minutes: number };
|
|
24
|
+
validation: Record<string, string>;
|
|
25
|
+
prompts: Record<string, string>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface OrchestrationContext {
|
|
29
|
+
issueNumber: number;
|
|
30
|
+
repo: string;
|
|
31
|
+
label: string;
|
|
32
|
+
workflowConfig: WorkflowConfig;
|
|
33
|
+
artifactsDir: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface OrchestrationResult {
|
|
37
|
+
stage: string;
|
|
38
|
+
action: "advanced" | "opened-pr" | "retried" | "escalated" | "no-artifact";
|
|
39
|
+
nextLabel?: string;
|
|
40
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { parse } from "yaml";
|
|
3
|
+
import type { WorkflowConfig } from "./types.js";
|
|
4
|
+
|
|
5
|
+
export function readWorkflow(filePath: string): WorkflowConfig {
|
|
6
|
+
const content = readFileSync(filePath, "utf-8");
|
|
7
|
+
return parseWorkflow(content);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function parseWorkflow(content: string): WorkflowConfig {
|
|
11
|
+
if (!content || content.trim().length === 0) {
|
|
12
|
+
throw new Error(
|
|
13
|
+
"WORKFLOW.md is empty. Expected YAML frontmatter between --- delimiters."
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const frontmatter = extractFrontmatter(content);
|
|
18
|
+
const body = extractBody(content);
|
|
19
|
+
const config = parseYaml(frontmatter);
|
|
20
|
+
const prompts = extractPrompts(body);
|
|
21
|
+
|
|
22
|
+
return {
|
|
23
|
+
pipeline: config.pipeline as WorkflowConfig["pipeline"],
|
|
24
|
+
sandbox: config.sandbox as WorkflowConfig["sandbox"],
|
|
25
|
+
agent: config.agent as WorkflowConfig["agent"],
|
|
26
|
+
validation: (config.validation ?? {}) as WorkflowConfig["validation"],
|
|
27
|
+
prompts,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function extractFrontmatter(content: string): string {
|
|
32
|
+
const trimmed = content.trimStart();
|
|
33
|
+
if (!trimmed.startsWith("---")) {
|
|
34
|
+
throw new Error(
|
|
35
|
+
"WORKFLOW.md missing frontmatter. Expected file to start with --- delimiters."
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const afterOpening = trimmed.slice(3);
|
|
40
|
+
const closingIndex = afterOpening.indexOf("\n---");
|
|
41
|
+
if (closingIndex === -1) {
|
|
42
|
+
throw new Error(
|
|
43
|
+
"WORKFLOW.md missing closing --- delimiter for frontmatter."
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return afterOpening.slice(0, closingIndex);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function extractBody(content: string): string {
|
|
51
|
+
const trimmed = content.trimStart();
|
|
52
|
+
const afterOpening = trimmed.slice(3);
|
|
53
|
+
const closingIndex = afterOpening.indexOf("\n---");
|
|
54
|
+
if (closingIndex === -1) {
|
|
55
|
+
return "";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return afterOpening.slice(closingIndex + 4);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function parseYaml(frontmatter: string): Record<string, unknown> {
|
|
62
|
+
try {
|
|
63
|
+
const parsed = parse(frontmatter);
|
|
64
|
+
if (!parsed || typeof parsed !== "object") {
|
|
65
|
+
throw new Error(
|
|
66
|
+
"WORKFLOW.md frontmatter is not a valid YAML object."
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
return parsed as Record<string, unknown>;
|
|
70
|
+
} catch (err) {
|
|
71
|
+
if (err instanceof Error && err.message.startsWith("WORKFLOW.md")) {
|
|
72
|
+
throw err;
|
|
73
|
+
}
|
|
74
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
75
|
+
throw new Error(`WORKFLOW.md contains malformed YAML: ${message}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function extractPrompts(body: string): Record<string, string> {
|
|
80
|
+
const prompts: Record<string, string> = {};
|
|
81
|
+
|
|
82
|
+
const sections = body.split(/^### /m);
|
|
83
|
+
|
|
84
|
+
for (const section of sections) {
|
|
85
|
+
if (!section.trim()) continue;
|
|
86
|
+
|
|
87
|
+
const newlineIndex = section.indexOf("\n");
|
|
88
|
+
if (newlineIndex === -1) continue;
|
|
89
|
+
|
|
90
|
+
const name = section.slice(0, newlineIndex).trim();
|
|
91
|
+
const template = section.slice(newlineIndex + 1).trimEnd();
|
|
92
|
+
|
|
93
|
+
if (name && template.trim()) {
|
|
94
|
+
prompts[name] = template.trimStart();
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return prompts;
|
|
99
|
+
}
|