@loucompanion/forge-bridge 0.1.1-dev.242bb53ef13f
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 +63 -0
- package/dist/bridge/bridge.base.d.ts +17 -0
- package/dist/bridge/bridge.base.js +1 -0
- package/dist/bridge/bridge.service.d.ts +45 -0
- package/dist/bridge/bridge.service.js +340 -0
- package/dist/bridge/bridge.types.d.ts +76 -0
- package/dist/bridge/bridge.types.js +1 -0
- package/dist/bridge/index.d.ts +2 -0
- package/dist/bridge/index.js +1 -0
- package/dist/bridge/internals.d.ts +3 -0
- package/dist/bridge/internals.js +1 -0
- package/dist/cleanup/cleanup.base.d.ts +4 -0
- package/dist/cleanup/cleanup.base.js +1 -0
- package/dist/cleanup/cleanup.service.d.ts +5 -0
- package/dist/cleanup/cleanup.service.js +5 -0
- package/dist/cleanup/cleanup.types.d.ts +6 -0
- package/dist/cleanup/cleanup.types.js +1 -0
- package/dist/cleanup/index.d.ts +2 -0
- package/dist/cleanup/index.js +1 -0
- package/dist/cleanup/internals.d.ts +3 -0
- package/dist/cleanup/internals.js +1 -0
- package/dist/cli/bin.d.ts +2 -0
- package/dist/cli/bin.js +4 -0
- package/dist/cli/cli.base.d.ts +7 -0
- package/dist/cli/cli.base.js +1 -0
- package/dist/cli/cli.service.d.ts +45 -0
- package/dist/cli/cli.service.js +400 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +1 -0
- package/dist/cli/internals.d.ts +1 -0
- package/dist/cli/internals.js +1 -0
- package/dist/cli/local-config.d.ts +31 -0
- package/dist/cli/local-config.js +146 -0
- package/dist/config.d.ts +5 -0
- package/dist/config.js +8 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +7 -0
- package/dist/session/index.d.ts +2 -0
- package/dist/session/index.js +1 -0
- package/dist/session/internals.d.ts +7 -0
- package/dist/session/internals.js +2 -0
- package/dist/session/provider-cli/index.d.ts +3 -0
- package/dist/session/provider-cli/index.js +1 -0
- package/dist/session/provider-cli/provider-cli.base.d.ts +6 -0
- package/dist/session/provider-cli/provider-cli.base.js +1 -0
- package/dist/session/provider-cli/provider-cli.service.d.ts +16 -0
- package/dist/session/provider-cli/provider-cli.service.js +111 -0
- package/dist/session/provider-cli/provider-cli.types.d.ts +12 -0
- package/dist/session/provider-cli/provider-cli.types.js +1 -0
- package/dist/session/session.base.d.ts +7 -0
- package/dist/session/session.base.js +1 -0
- package/dist/session/session.service.d.ts +10 -0
- package/dist/session/session.service.js +92 -0
- package/dist/session/session.types.d.ts +107 -0
- package/dist/session/session.types.js +151 -0
- package/dist/shared/git/git.d.ts +6 -0
- package/dist/shared/git/git.js +18 -0
- package/dist/shared/path/path.d.ts +4 -0
- package/dist/shared/path/path.js +29 -0
- package/dist/shared/process/process.d.ts +16 -0
- package/dist/shared/process/process.js +32 -0
- package/dist/shared/redaction/redaction.d.ts +2 -0
- package/dist/shared/redaction/redaction.js +30 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +1 -0
- package/dist/worktree/index.d.ts +2 -0
- package/dist/worktree/index.js +1 -0
- package/dist/worktree/internals.d.ts +3 -0
- package/dist/worktree/internals.js +1 -0
- package/dist/worktree/worktree.base.d.ts +5 -0
- package/dist/worktree/worktree.base.js +1 -0
- package/dist/worktree/worktree.service.d.ts +12 -0
- package/dist/worktree/worktree.service.js +139 -0
- package/dist/worktree/worktree.types.d.ts +15 -0
- package/dist/worktree/worktree.types.js +1 -0
- package/package.json +52 -0
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { parseRunOnceDispatchPacket, failureResult } from "./session.types.js";
|
|
2
|
+
export type { SessionEventSink, SessionService } from "./session.base.js";
|
|
3
|
+
export type { ApiKeySecretGrant, DispatchCycleRun, DispatchExecution, DispatchLease, DispatchSession, DispatchRepo, DispatchServerLocalConfig, DispatchWorkItem, RunOnceDispatchPacket, RunOnceResultPacket, RunnerDispatchArtifact, RunnerDispatchEvent, RunnerDispatchSpec, RunnerEventLine } from "./session.types.js";
|
|
4
|
+
export { DeterministicProviderCliService, buildProviderCliCommand } from "./provider-cli/provider-cli.service.js";
|
|
5
|
+
export type { ProviderCliService } from "./provider-cli/provider-cli.base.js";
|
|
6
|
+
export type { ProviderCliCommand } from "./provider-cli/provider-cli.service.js";
|
|
7
|
+
export type { ProviderCliRunContext, ProviderCliRunResult } from "./provider-cli/provider-cli.types.js";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { DeterministicProviderCliService, buildProviderCliCommand } from "./provider-cli.service.js";
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { ProviderCliRunContext, ProviderCliRunResult } from "./provider-cli.types.js";
|
|
2
|
+
import type { RunOnceDispatchPacket, RunnerDispatchEvent } from "../session.types.js";
|
|
3
|
+
export interface ProviderCliService {
|
|
4
|
+
runImplementation(context: ProviderCliRunContext): Promise<ProviderCliRunResult>;
|
|
5
|
+
runQa(dispatch: RunOnceDispatchPacket, emit: (event: RunnerDispatchEvent) => Promise<void>): Promise<ProviderCliRunResult>;
|
|
6
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { GitClient } from "../../shared/git/git.js";
|
|
2
|
+
import type { ProviderCliService } from "./provider-cli.base.js";
|
|
3
|
+
import type { ProviderCliRunContext, ProviderCliRunResult } from "./provider-cli.types.js";
|
|
4
|
+
import type { RunOnceDispatchPacket, RunnerDispatchEvent } from "../session.types.js";
|
|
5
|
+
export interface ProviderCliCommand {
|
|
6
|
+
readonly command: string;
|
|
7
|
+
readonly args: readonly string[];
|
|
8
|
+
}
|
|
9
|
+
export declare class DeterministicProviderCliService implements ProviderCliService {
|
|
10
|
+
private readonly git;
|
|
11
|
+
constructor(git?: GitClient);
|
|
12
|
+
runImplementation(context: ProviderCliRunContext): Promise<ProviderCliRunResult>;
|
|
13
|
+
runQa(dispatch: RunOnceDispatchPacket, emit: (event: RunnerDispatchEvent) => Promise<void>): Promise<ProviderCliRunResult>;
|
|
14
|
+
}
|
|
15
|
+
export declare function formatProviderCliCommand(command: ProviderCliCommand): string;
|
|
16
|
+
export declare function buildProviderCliCommand(dispatch: RunOnceDispatchPacket): ProviderCliCommand;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { GitClient } from "../../shared/git/git.js";
|
|
5
|
+
export class DeterministicProviderCliService {
|
|
6
|
+
git;
|
|
7
|
+
constructor(git = new GitClient()) {
|
|
8
|
+
this.git = git;
|
|
9
|
+
}
|
|
10
|
+
async runImplementation(context) {
|
|
11
|
+
const { dispatch, worktree } = context;
|
|
12
|
+
const providerCommand = buildProviderCliCommand(dispatch);
|
|
13
|
+
const providerCommandText = formatProviderCliCommand(providerCommand);
|
|
14
|
+
const metadataRoot = path.join(worktree.repoPath, ".forge", "work-items", dispatch.workItem.id);
|
|
15
|
+
const artifactRoot = path.join(metadataRoot, "artifacts");
|
|
16
|
+
await fs.mkdir(artifactRoot, { recursive: true });
|
|
17
|
+
const specBody = `---\nwork_item_id: ${dispatch.workItem.id}\nstatus: approved\nlane: ${dispatch.execution.lane}\nprovider: ${yamlQuotedScalar(dispatch.execution.provider)}\nmodel: ${yamlQuotedScalar(dispatch.execution.model)}\nprovider_command: ${yamlQuotedScalar(providerCommandText)}\n---\n\n# Forge bridge implementation spec\n\nImplement **${dispatch.workItem.title}**.\n\nAcceptance:\n- prepare a task-scoped git worktree\n- emit newline JSON runner events\n- produce Forge phase artifacts\n`;
|
|
18
|
+
const specPath = path.join(metadataRoot, "spec.md");
|
|
19
|
+
await fs.writeFile(specPath, specBody, "utf8");
|
|
20
|
+
await fs.writeFile(path.join(metadataRoot, "spec.meta.json"), JSON.stringify({ schema_version: 1, status: "approved", produced_by: "forge-bridge" }) + "\n", "utf8");
|
|
21
|
+
const changesetPath = path.join(worktree.worktreePath, "FORGE_PHASE5_CHANGESET.md");
|
|
22
|
+
const changesetBody = `# Forge Phase 5 bridge changeset\n\nWork item: ${dispatch.workItem.id}\nTitle: ${dispatch.workItem.title}\nBranch: ${worktree.branchName}\nProvider: ${singleLine(dispatch.execution.provider)}\nModel: ${singleLine(dispatch.execution.model)}\nCommand: ${providerCommandText}\n\nThis deterministic file is written by forge-bridge run-once through the shared session/worktree service path.\n`;
|
|
23
|
+
await fs.writeFile(changesetPath, changesetBody, "utf8");
|
|
24
|
+
await this.git.run(worktree.worktreePath, ["add", "FORGE_PHASE5_CHANGESET.md"]);
|
|
25
|
+
await this.git.run(worktree.worktreePath, ["commit", "--allow-empty", "-m", `forge bridge changeset for ${dispatch.workItem.id.slice(0, 8)}`]);
|
|
26
|
+
await context.emit({
|
|
27
|
+
role: "worker",
|
|
28
|
+
kind: "file_edit",
|
|
29
|
+
content: `Created bridge changeset at ${changesetPath}`,
|
|
30
|
+
eventType: "changeset"
|
|
31
|
+
});
|
|
32
|
+
const verdict = {
|
|
33
|
+
schema_version: 1,
|
|
34
|
+
status: "pass",
|
|
35
|
+
checks: [{ name: "forge_bridge_run_once", status: "pass" }],
|
|
36
|
+
metrics: { files_changed: 1 },
|
|
37
|
+
produced_by: "forge-bridge",
|
|
38
|
+
content_hash: sha256(changesetBody)
|
|
39
|
+
};
|
|
40
|
+
const verdictJson = JSON.stringify(verdict);
|
|
41
|
+
const report = `# Implementation report\n\nforge-bridge prepared \`${worktree.branchName}\` and created \`${changesetPath}\`.\n`;
|
|
42
|
+
const artifactPath = path.join(artifactRoot, "implementation.verdict.json");
|
|
43
|
+
await fs.writeFile(artifactPath, verdictJson + "\n", "utf8");
|
|
44
|
+
await fs.writeFile(path.join(artifactRoot, "implementation.report.md"), report, "utf8");
|
|
45
|
+
await context.emit({
|
|
46
|
+
role: "assistant",
|
|
47
|
+
kind: "message",
|
|
48
|
+
content: "Implementation complete. forge-bridge produced deterministic phase artifacts.",
|
|
49
|
+
eventType: "implementation_complete"
|
|
50
|
+
});
|
|
51
|
+
return {
|
|
52
|
+
summary: `forge-bridge completed ${worktree.branchName}.`,
|
|
53
|
+
spec: {
|
|
54
|
+
repoPath: worktree.repoPath,
|
|
55
|
+
path: specPath,
|
|
56
|
+
body: specBody,
|
|
57
|
+
producedBy: "forge-bridge",
|
|
58
|
+
status: "approved"
|
|
59
|
+
},
|
|
60
|
+
artifact: {
|
|
61
|
+
repoPath: worktree.repoPath,
|
|
62
|
+
path: artifactPath,
|
|
63
|
+
verdictJson,
|
|
64
|
+
report,
|
|
65
|
+
producedBy: "forge-bridge",
|
|
66
|
+
status: "pass"
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
async runQa(dispatch, emit) {
|
|
71
|
+
await emit({
|
|
72
|
+
role: "worker",
|
|
73
|
+
kind: "message",
|
|
74
|
+
content: `Running ${dispatch.execution.provider} QA checks through forge-bridge.`,
|
|
75
|
+
eventType: "qa_analysis"
|
|
76
|
+
});
|
|
77
|
+
await emit({
|
|
78
|
+
role: "assistant",
|
|
79
|
+
kind: "message",
|
|
80
|
+
content: "All checks passed. forge-bridge QA completed.",
|
|
81
|
+
eventType: "qa_complete"
|
|
82
|
+
});
|
|
83
|
+
return {
|
|
84
|
+
summary: "forge-bridge QA passed."
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
function sha256(content) {
|
|
89
|
+
return crypto.createHash("sha256").update(content).digest("hex");
|
|
90
|
+
}
|
|
91
|
+
export function formatProviderCliCommand(command) {
|
|
92
|
+
return [command.command, ...command.args].map(singleLine).join(" ");
|
|
93
|
+
}
|
|
94
|
+
function yamlQuotedScalar(value) {
|
|
95
|
+
return JSON.stringify(singleLine(value));
|
|
96
|
+
}
|
|
97
|
+
function singleLine(value) {
|
|
98
|
+
return value.replace(/\s+/g, " ").trim();
|
|
99
|
+
}
|
|
100
|
+
export function buildProviderCliCommand(dispatch) {
|
|
101
|
+
const provider = dispatch.execution.provider.trim();
|
|
102
|
+
const normalizedProvider = provider.toLowerCase();
|
|
103
|
+
const model = dispatch.execution.model.trim();
|
|
104
|
+
if (normalizedProvider === "codex") {
|
|
105
|
+
return { command: "codex", args: ["exec", "--model", model] };
|
|
106
|
+
}
|
|
107
|
+
if (normalizedProvider === "cursor") {
|
|
108
|
+
return { command: "cursor-agent", args: ["--model", model] };
|
|
109
|
+
}
|
|
110
|
+
return { command: provider, args: ["--model", model] };
|
|
111
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { RunOnceDispatchPacket, RunnerDispatchArtifact, RunnerDispatchEvent, RunnerDispatchSpec } from "../session.types.js";
|
|
2
|
+
import type { WorktreeHandle } from "../../worktree/worktree.types.js";
|
|
3
|
+
export interface ProviderCliRunContext {
|
|
4
|
+
readonly dispatch: RunOnceDispatchPacket;
|
|
5
|
+
readonly worktree: WorktreeHandle;
|
|
6
|
+
emit(event: RunnerDispatchEvent): Promise<void>;
|
|
7
|
+
}
|
|
8
|
+
export interface ProviderCliRunResult {
|
|
9
|
+
readonly summary: string;
|
|
10
|
+
readonly spec?: RunnerDispatchSpec | null;
|
|
11
|
+
readonly artifact?: RunnerDispatchArtifact | null;
|
|
12
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { RunOnceDispatchPacket, RunOnceResultPacket, RunnerDispatchEvent } from "./session.types.js";
|
|
2
|
+
export interface SessionEventSink {
|
|
3
|
+
emit(event: RunnerDispatchEvent): Promise<void>;
|
|
4
|
+
}
|
|
5
|
+
export interface SessionService {
|
|
6
|
+
runOnce(dispatch: RunOnceDispatchPacket, sink: SessionEventSink, signal?: AbortSignal): Promise<RunOnceResultPacket>;
|
|
7
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { ProviderCliService } from "./provider-cli/provider-cli.base.js";
|
|
2
|
+
import type { SessionEventSink, SessionService } from "./session.base.js";
|
|
3
|
+
import type { RunOnceDispatchPacket, RunOnceResultPacket } from "./session.types.js";
|
|
4
|
+
import type { WorktreeService } from "../worktree/worktree.base.js";
|
|
5
|
+
export declare class BridgeSessionService implements SessionService {
|
|
6
|
+
private readonly worktrees;
|
|
7
|
+
private readonly providerCli;
|
|
8
|
+
constructor(worktrees?: WorktreeService, providerCli?: ProviderCliService);
|
|
9
|
+
runOnce(dispatch: RunOnceDispatchPacket, sink: SessionEventSink, signal?: AbortSignal): Promise<RunOnceResultPacket>;
|
|
10
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { redactSecrets } from "../shared/redaction/redaction.js";
|
|
2
|
+
import { DeterministicProviderCliService } from "./provider-cli/provider-cli.service.js";
|
|
3
|
+
import { GitWorktreeService } from "../worktree/worktree.service.js";
|
|
4
|
+
export class BridgeSessionService {
|
|
5
|
+
worktrees;
|
|
6
|
+
providerCli;
|
|
7
|
+
constructor(worktrees = new GitWorktreeService(), providerCli = new DeterministicProviderCliService()) {
|
|
8
|
+
this.worktrees = worktrees;
|
|
9
|
+
this.providerCli = providerCli;
|
|
10
|
+
}
|
|
11
|
+
async runOnce(dispatch, sink, signal) {
|
|
12
|
+
validateApiKeySecretGrant(dispatch);
|
|
13
|
+
const redactionSecrets = exactSecrets(dispatch);
|
|
14
|
+
const events = [];
|
|
15
|
+
const emit = async (event) => {
|
|
16
|
+
throwIfAborted(signal);
|
|
17
|
+
const redacted = redactSecrets(event, redactionSecrets);
|
|
18
|
+
events.push(redacted);
|
|
19
|
+
await sink.emit(redacted);
|
|
20
|
+
};
|
|
21
|
+
throwIfAborted(signal);
|
|
22
|
+
await emit({
|
|
23
|
+
role: "runner",
|
|
24
|
+
kind: "checkpoint",
|
|
25
|
+
content: "forge-bridge accepted run-once dispatch.",
|
|
26
|
+
eventType: "dispatch_accepted"
|
|
27
|
+
});
|
|
28
|
+
const worktree = await this.worktrees.prepare({
|
|
29
|
+
repo: dispatch.repo,
|
|
30
|
+
workItem: dispatch.workItem,
|
|
31
|
+
baseBranch: dispatch.cycleRun.baseBranch || dispatch.cycleRun.requestedBase,
|
|
32
|
+
allowedRepoRoot: dispatch.serverLocal.repoRoot,
|
|
33
|
+
cleanupWorktrees: dispatch.serverLocal.cleanupWorktrees
|
|
34
|
+
});
|
|
35
|
+
throwIfAborted(signal);
|
|
36
|
+
await emit({
|
|
37
|
+
role: "runner",
|
|
38
|
+
kind: "checkpoint",
|
|
39
|
+
content: `Prepared worktree ${worktree.branchName}.`,
|
|
40
|
+
eventType: "worktree_prepared"
|
|
41
|
+
});
|
|
42
|
+
try {
|
|
43
|
+
throwIfAborted(signal);
|
|
44
|
+
const isQa = dispatch.execution.role.toLowerCase() === "qa" || dispatch.execution.lane.toLowerCase() === "qa";
|
|
45
|
+
const run = isQa
|
|
46
|
+
? await this.providerCli.runQa(dispatch, emit)
|
|
47
|
+
: await this.providerCli.runImplementation({
|
|
48
|
+
dispatch,
|
|
49
|
+
worktree,
|
|
50
|
+
emit
|
|
51
|
+
});
|
|
52
|
+
return redactSecrets({
|
|
53
|
+
schemaVersion: 1,
|
|
54
|
+
exitCode: 0,
|
|
55
|
+
summary: run.summary,
|
|
56
|
+
events,
|
|
57
|
+
spec: run.spec ?? null,
|
|
58
|
+
artifact: run.artifact ?? null
|
|
59
|
+
}, redactionSecrets);
|
|
60
|
+
}
|
|
61
|
+
finally {
|
|
62
|
+
await this.worktrees.release(worktree);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
function throwIfAborted(signal) {
|
|
67
|
+
if (signal?.aborted) {
|
|
68
|
+
throw new Error(String(signal.reason ?? "Dispatch cancelled."));
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
function validateApiKeySecretGrant(dispatch) {
|
|
72
|
+
if (!dispatch.execution.requiresApiKeySecretGrant) {
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (dispatch.execution.authKind !== "api_key") {
|
|
76
|
+
throw new Error("API-key secret grant requires api_key execution.");
|
|
77
|
+
}
|
|
78
|
+
const grant = dispatch.apiKeySecretGrant;
|
|
79
|
+
if (!grant?.secret) {
|
|
80
|
+
throw new Error("API-key secret grant is missing.");
|
|
81
|
+
}
|
|
82
|
+
if (grant.provider.toLowerCase() !== dispatch.execution.provider.toLowerCase()) {
|
|
83
|
+
throw new Error("API-key secret grant provider does not match dispatch provider.");
|
|
84
|
+
}
|
|
85
|
+
const expiresAt = Date.parse(grant.expiresAt);
|
|
86
|
+
if (!Number.isFinite(expiresAt) || expiresAt <= Date.now()) {
|
|
87
|
+
throw new Error("API-key secret grant is expired.");
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
function exactSecrets(dispatch) {
|
|
91
|
+
return dispatch.apiKeySecretGrant?.secret ? [dispatch.apiKeySecretGrant.secret] : [];
|
|
92
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
export interface RunOnceDispatchPacket {
|
|
2
|
+
readonly schemaVersion: 1;
|
|
3
|
+
readonly userId: string;
|
|
4
|
+
readonly workItem: DispatchWorkItem;
|
|
5
|
+
readonly repo: DispatchRepo;
|
|
6
|
+
readonly cycleRun: DispatchCycleRun;
|
|
7
|
+
readonly session: DispatchSession;
|
|
8
|
+
readonly lease: DispatchLease;
|
|
9
|
+
readonly execution: DispatchExecution;
|
|
10
|
+
readonly serverLocal: DispatchServerLocalConfig;
|
|
11
|
+
readonly apiKeySecretGrant?: ApiKeySecretGrant | null;
|
|
12
|
+
}
|
|
13
|
+
export interface DispatchWorkItem {
|
|
14
|
+
readonly id: string;
|
|
15
|
+
readonly kind: string;
|
|
16
|
+
readonly title: string;
|
|
17
|
+
readonly description: string;
|
|
18
|
+
readonly acceptanceCriteria: string;
|
|
19
|
+
readonly reproSteps: string;
|
|
20
|
+
readonly systemInfo: string;
|
|
21
|
+
}
|
|
22
|
+
export interface DispatchRepo {
|
|
23
|
+
readonly id: string;
|
|
24
|
+
readonly name: string;
|
|
25
|
+
readonly remoteUrl: string;
|
|
26
|
+
readonly defaultBranch: string;
|
|
27
|
+
readonly cloneOnDemand: boolean;
|
|
28
|
+
readonly serverLocalPath?: string | null;
|
|
29
|
+
}
|
|
30
|
+
export interface DispatchCycleRun {
|
|
31
|
+
readonly id: string;
|
|
32
|
+
readonly kind: string;
|
|
33
|
+
readonly phase: string;
|
|
34
|
+
readonly requestedBase: string;
|
|
35
|
+
readonly baseBranch: string;
|
|
36
|
+
readonly expectedCurrentBranch?: string | null;
|
|
37
|
+
readonly attempt: number;
|
|
38
|
+
readonly model: string;
|
|
39
|
+
}
|
|
40
|
+
export interface DispatchSession {
|
|
41
|
+
readonly id: string;
|
|
42
|
+
readonly model: string;
|
|
43
|
+
readonly lane: string;
|
|
44
|
+
}
|
|
45
|
+
export interface DispatchLease {
|
|
46
|
+
readonly id: string;
|
|
47
|
+
readonly worktreeKey: string;
|
|
48
|
+
readonly lane: string;
|
|
49
|
+
readonly fencingToken: string;
|
|
50
|
+
readonly expiresAt: string;
|
|
51
|
+
}
|
|
52
|
+
export interface DispatchExecution {
|
|
53
|
+
readonly provider: string;
|
|
54
|
+
readonly model: string;
|
|
55
|
+
readonly authKind: string;
|
|
56
|
+
readonly location: string;
|
|
57
|
+
readonly runnerId?: string | null;
|
|
58
|
+
readonly repoId: string;
|
|
59
|
+
readonly role: string;
|
|
60
|
+
readonly lane: string;
|
|
61
|
+
readonly requiresApiKeySecretGrant: boolean;
|
|
62
|
+
}
|
|
63
|
+
export interface ApiKeySecretGrant {
|
|
64
|
+
readonly grantId: string;
|
|
65
|
+
readonly provider: string;
|
|
66
|
+
readonly secret: string;
|
|
67
|
+
readonly expiresAt: string;
|
|
68
|
+
}
|
|
69
|
+
export interface DispatchServerLocalConfig {
|
|
70
|
+
readonly repoRoot: string;
|
|
71
|
+
readonly cleanupWorktrees: boolean;
|
|
72
|
+
}
|
|
73
|
+
export interface RunnerDispatchEvent {
|
|
74
|
+
readonly role: string;
|
|
75
|
+
readonly kind: string;
|
|
76
|
+
readonly content: string;
|
|
77
|
+
readonly eventType: string;
|
|
78
|
+
}
|
|
79
|
+
export interface RunnerDispatchSpec {
|
|
80
|
+
readonly repoPath: string;
|
|
81
|
+
readonly path: string;
|
|
82
|
+
readonly body: string;
|
|
83
|
+
readonly producedBy: string;
|
|
84
|
+
readonly status: string;
|
|
85
|
+
}
|
|
86
|
+
export interface RunnerDispatchArtifact {
|
|
87
|
+
readonly repoPath: string;
|
|
88
|
+
readonly path: string;
|
|
89
|
+
readonly verdictJson: string;
|
|
90
|
+
readonly report: string;
|
|
91
|
+
readonly producedBy: string;
|
|
92
|
+
readonly status: string;
|
|
93
|
+
}
|
|
94
|
+
export interface RunOnceResultPacket {
|
|
95
|
+
readonly schemaVersion: 1;
|
|
96
|
+
readonly exitCode: number;
|
|
97
|
+
readonly summary: string;
|
|
98
|
+
readonly events: readonly RunnerDispatchEvent[];
|
|
99
|
+
readonly spec?: RunnerDispatchSpec | null;
|
|
100
|
+
readonly artifact?: RunnerDispatchArtifact | null;
|
|
101
|
+
}
|
|
102
|
+
export interface RunnerEventLine {
|
|
103
|
+
readonly type: "runner_event";
|
|
104
|
+
readonly event: RunnerDispatchEvent;
|
|
105
|
+
}
|
|
106
|
+
export declare function parseRunOnceDispatchPacket(value: unknown): RunOnceDispatchPacket;
|
|
107
|
+
export declare function failureResult(summary: string, events?: readonly RunnerDispatchEvent[]): RunOnceResultPacket;
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
export function parseRunOnceDispatchPacket(value) {
|
|
2
|
+
const packet = asObject(value, "dispatch packet");
|
|
3
|
+
const schemaVersion = requiredNumber(packet, "schemaVersion");
|
|
4
|
+
if (schemaVersion !== 1) {
|
|
5
|
+
throw new Error(`Unsupported dispatch schemaVersion: ${schemaVersion}`);
|
|
6
|
+
}
|
|
7
|
+
return {
|
|
8
|
+
schemaVersion: 1,
|
|
9
|
+
userId: requiredString(packet, "userId"),
|
|
10
|
+
workItem: parseWorkItem(packet.workItem),
|
|
11
|
+
repo: parseRepo(packet.repo),
|
|
12
|
+
cycleRun: parseCycleRun(packet.cycleRun),
|
|
13
|
+
session: parseSession(packet.session),
|
|
14
|
+
lease: parseLease(packet.lease),
|
|
15
|
+
execution: parseExecution(packet.execution),
|
|
16
|
+
serverLocal: parseServerLocal(packet.serverLocal),
|
|
17
|
+
apiKeySecretGrant: parseApiKeySecretGrant(packet.apiKeySecretGrant)
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
export function failureResult(summary, events = []) {
|
|
21
|
+
return {
|
|
22
|
+
schemaVersion: 1,
|
|
23
|
+
exitCode: 1,
|
|
24
|
+
summary,
|
|
25
|
+
events
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
function parseWorkItem(value) {
|
|
29
|
+
const item = asObject(value, "workItem");
|
|
30
|
+
return {
|
|
31
|
+
id: requiredString(item, "id"),
|
|
32
|
+
kind: requiredString(item, "kind"),
|
|
33
|
+
title: requiredString(item, "title"),
|
|
34
|
+
description: optionalString(item, "description") ?? "",
|
|
35
|
+
acceptanceCriteria: optionalString(item, "acceptanceCriteria") ?? "",
|
|
36
|
+
reproSteps: optionalString(item, "reproSteps") ?? "",
|
|
37
|
+
systemInfo: optionalString(item, "systemInfo") ?? ""
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
function parseRepo(value) {
|
|
41
|
+
const repo = asObject(value, "repo");
|
|
42
|
+
return {
|
|
43
|
+
id: requiredString(repo, "id"),
|
|
44
|
+
name: requiredString(repo, "name"),
|
|
45
|
+
remoteUrl: optionalString(repo, "remoteUrl") ?? "",
|
|
46
|
+
defaultBranch: requiredString(repo, "defaultBranch"),
|
|
47
|
+
cloneOnDemand: requiredBoolean(repo, "cloneOnDemand"),
|
|
48
|
+
serverLocalPath: optionalString(repo, "serverLocalPath")
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
function parseCycleRun(value) {
|
|
52
|
+
const cycle = asObject(value, "cycleRun");
|
|
53
|
+
return {
|
|
54
|
+
id: requiredString(cycle, "id"),
|
|
55
|
+
kind: requiredString(cycle, "kind"),
|
|
56
|
+
phase: requiredString(cycle, "phase"),
|
|
57
|
+
requestedBase: optionalString(cycle, "requestedBase") ?? "develop",
|
|
58
|
+
baseBranch: optionalString(cycle, "baseBranch") ?? "develop",
|
|
59
|
+
expectedCurrentBranch: optionalString(cycle, "expectedCurrentBranch"),
|
|
60
|
+
attempt: requiredNumber(cycle, "attempt"),
|
|
61
|
+
model: requiredString(cycle, "model")
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
function parseSession(value) {
|
|
65
|
+
const session = asObject(value, "session");
|
|
66
|
+
return {
|
|
67
|
+
id: requiredString(session, "id"),
|
|
68
|
+
model: requiredString(session, "model"),
|
|
69
|
+
lane: requiredString(session, "lane")
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
function parseLease(value) {
|
|
73
|
+
const lease = asObject(value, "lease");
|
|
74
|
+
return {
|
|
75
|
+
id: requiredString(lease, "id"),
|
|
76
|
+
worktreeKey: requiredString(lease, "worktreeKey"),
|
|
77
|
+
lane: requiredString(lease, "lane"),
|
|
78
|
+
fencingToken: requiredString(lease, "fencingToken"),
|
|
79
|
+
expiresAt: requiredString(lease, "expiresAt")
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
function parseExecution(value) {
|
|
83
|
+
const execution = asObject(value, "execution");
|
|
84
|
+
return {
|
|
85
|
+
provider: requiredString(execution, "provider"),
|
|
86
|
+
model: requiredString(execution, "model"),
|
|
87
|
+
authKind: requiredString(execution, "authKind"),
|
|
88
|
+
location: requiredString(execution, "location"),
|
|
89
|
+
runnerId: optionalString(execution, "runnerId"),
|
|
90
|
+
repoId: requiredString(execution, "repoId"),
|
|
91
|
+
role: requiredString(execution, "role"),
|
|
92
|
+
lane: requiredString(execution, "lane"),
|
|
93
|
+
requiresApiKeySecretGrant: requiredBoolean(execution, "requiresApiKeySecretGrant")
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
function parseApiKeySecretGrant(value) {
|
|
97
|
+
if (value === undefined || value === null) {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
const grant = asObject(value, "apiKeySecretGrant");
|
|
101
|
+
return {
|
|
102
|
+
grantId: requiredString(grant, "grantId"),
|
|
103
|
+
provider: requiredString(grant, "provider"),
|
|
104
|
+
secret: requiredString(grant, "secret"),
|
|
105
|
+
expiresAt: requiredString(grant, "expiresAt")
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
function parseServerLocal(value) {
|
|
109
|
+
const serverLocal = asObject(value, "serverLocal");
|
|
110
|
+
return {
|
|
111
|
+
repoRoot: requiredString(serverLocal, "repoRoot"),
|
|
112
|
+
cleanupWorktrees: requiredBoolean(serverLocal, "cleanupWorktrees")
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
function asObject(value, label) {
|
|
116
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
117
|
+
throw new Error(`${label} must be an object.`);
|
|
118
|
+
}
|
|
119
|
+
return value;
|
|
120
|
+
}
|
|
121
|
+
function requiredString(source, key) {
|
|
122
|
+
const value = source[key];
|
|
123
|
+
if (typeof value !== "string" || value.trim().length === 0) {
|
|
124
|
+
throw new Error(`${key} must be a non-empty string.`);
|
|
125
|
+
}
|
|
126
|
+
return value;
|
|
127
|
+
}
|
|
128
|
+
function optionalString(source, key) {
|
|
129
|
+
const value = source[key];
|
|
130
|
+
if (value === undefined || value === null) {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
if (typeof value !== "string") {
|
|
134
|
+
throw new Error(`${key} must be a string when provided.`);
|
|
135
|
+
}
|
|
136
|
+
return value;
|
|
137
|
+
}
|
|
138
|
+
function requiredBoolean(source, key) {
|
|
139
|
+
const value = source[key];
|
|
140
|
+
if (typeof value !== "boolean") {
|
|
141
|
+
throw new Error(`${key} must be a boolean.`);
|
|
142
|
+
}
|
|
143
|
+
return value;
|
|
144
|
+
}
|
|
145
|
+
function requiredNumber(source, key) {
|
|
146
|
+
const value = source[key];
|
|
147
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
148
|
+
throw new Error(`${key} must be a finite number.`);
|
|
149
|
+
}
|
|
150
|
+
return value;
|
|
151
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { NodeProcessRunner } from "../process/process.js";
|
|
2
|
+
export class GitClient {
|
|
3
|
+
processRunner;
|
|
4
|
+
constructor(processRunner = new NodeProcessRunner()) {
|
|
5
|
+
this.processRunner = processRunner;
|
|
6
|
+
}
|
|
7
|
+
async run(cwd, args) {
|
|
8
|
+
const result = await this.processRunner.run({
|
|
9
|
+
command: "git",
|
|
10
|
+
args,
|
|
11
|
+
cwd
|
|
12
|
+
});
|
|
13
|
+
if (result.exitCode !== 0) {
|
|
14
|
+
throw new Error(`git ${args.join(" ")} failed: ${result.stderr.trim() || result.stdout.trim()}`);
|
|
15
|
+
}
|
|
16
|
+
return result.stdout;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export declare function isSubPath(root: string, candidate: string): boolean;
|
|
2
|
+
export declare function resolveExistingDirectoryPath(inputPath: string): Promise<string>;
|
|
3
|
+
export declare function ensureDirectory(inputPath: string): Promise<string>;
|
|
4
|
+
export declare function safeSegment(value: string, fallback: string): string;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
export function isSubPath(root, candidate) {
|
|
4
|
+
const normalizedRoot = path.resolve(root);
|
|
5
|
+
const normalizedCandidate = path.resolve(candidate);
|
|
6
|
+
const relative = path.relative(normalizedRoot, normalizedCandidate);
|
|
7
|
+
return relative === "" || (!!relative && !relative.startsWith("..") && !path.isAbsolute(relative));
|
|
8
|
+
}
|
|
9
|
+
export async function resolveExistingDirectoryPath(inputPath) {
|
|
10
|
+
const stat = await fs.stat(inputPath);
|
|
11
|
+
if (!stat.isDirectory()) {
|
|
12
|
+
throw new Error(`Path is not a directory: ${inputPath}`);
|
|
13
|
+
}
|
|
14
|
+
return await fs.realpath(inputPath);
|
|
15
|
+
}
|
|
16
|
+
export async function ensureDirectory(inputPath) {
|
|
17
|
+
await fs.mkdir(inputPath, { recursive: true });
|
|
18
|
+
return await resolveExistingDirectoryPath(inputPath);
|
|
19
|
+
}
|
|
20
|
+
export function safeSegment(value, fallback) {
|
|
21
|
+
const segment = value
|
|
22
|
+
.trim()
|
|
23
|
+
.split("")
|
|
24
|
+
.map((ch) => (/[a-zA-Z0-9_-]/.test(ch) ? ch : "-"))
|
|
25
|
+
.join("")
|
|
26
|
+
.replace(/-+/g, "-")
|
|
27
|
+
.replace(/^-|-$/g, "");
|
|
28
|
+
return segment.length > 0 ? segment : fallback;
|
|
29
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export interface ProcessRunRequest {
|
|
2
|
+
readonly command: string;
|
|
3
|
+
readonly args: readonly string[];
|
|
4
|
+
readonly cwd: string;
|
|
5
|
+
}
|
|
6
|
+
export interface ProcessRunResult {
|
|
7
|
+
readonly exitCode: number;
|
|
8
|
+
readonly stdout: string;
|
|
9
|
+
readonly stderr: string;
|
|
10
|
+
}
|
|
11
|
+
export interface ProcessRunner {
|
|
12
|
+
run(request: ProcessRunRequest): Promise<ProcessRunResult>;
|
|
13
|
+
}
|
|
14
|
+
export declare class NodeProcessRunner implements ProcessRunner {
|
|
15
|
+
run(request: ProcessRunRequest): Promise<ProcessRunResult>;
|
|
16
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import readline from "node:readline";
|
|
3
|
+
export class NodeProcessRunner {
|
|
4
|
+
async run(request) {
|
|
5
|
+
return await new Promise((resolve, reject) => {
|
|
6
|
+
const child = spawn(request.command, request.args, {
|
|
7
|
+
cwd: request.cwd,
|
|
8
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
9
|
+
});
|
|
10
|
+
let stdout = "";
|
|
11
|
+
let stderr = "";
|
|
12
|
+
child.on("error", reject);
|
|
13
|
+
const stdoutReader = readline.createInterface({ input: child.stdout });
|
|
14
|
+
const stderrReader = readline.createInterface({ input: child.stderr });
|
|
15
|
+
stdoutReader.on("line", (line) => {
|
|
16
|
+
stdout += `${line}\n`;
|
|
17
|
+
});
|
|
18
|
+
stderrReader.on("line", (line) => {
|
|
19
|
+
stderr += `${line}\n`;
|
|
20
|
+
});
|
|
21
|
+
child.on("close", (code) => {
|
|
22
|
+
stdoutReader.close();
|
|
23
|
+
stderrReader.close();
|
|
24
|
+
resolve({
|
|
25
|
+
exitCode: code ?? 1,
|
|
26
|
+
stdout,
|
|
27
|
+
stderr
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
}
|