Add a thin orchestration layer that drives the existing Orb runtime to progress issue-scoped work. OrbProjectManager creates/resumes Orb runs, assembles context packs, projects OrbEvents into durable project events, detects needs-input and work-complete conditions, forwards follow-up messages, and runs the Git/Gitea lifecycle on completion. Port interfaces (OrbAdapter, OrbRunPort, GitLifecyclePort) make the orchestrator testable with fakes. The real adapters wrap OrbRuntime and runPostRunGiteaLifecycle without reimplementing Docker, AgentOS, or OpenCode. Idempotency: duplicate starts reuse active runs, duplicate completion returns cached results, cancel is idempotent. Never auto-merges. 88 tests pass (19 orchestration + 12 event-mapping + orb suite), 2 live tests skipped; lint and type checks clean.
172 lines
4.8 KiB
TypeScript
172 lines
4.8 KiB
TypeScript
import { Schema } from "effect";
|
|
|
|
import type { OrbEvent } from "./events";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Durable project events — human-meaningful projections of Orb execution
|
|
//
|
|
// These are NOT raw OpenCode events. Each variant maps to a product-level
|
|
// concept the UI and work-graph understand. Raw OrbEvents are preserved for
|
|
// audit; this is the durable projection layer.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export const ProjectRunEventVariant = Schema.Literals([
|
|
"run.started",
|
|
"run.repository_prepared",
|
|
"run.session_opened",
|
|
"run.agent_message",
|
|
"run.agent_progress",
|
|
"run.command_executed",
|
|
"run.needs_input",
|
|
"run.permission_requested",
|
|
"run.completed",
|
|
"run.failed",
|
|
"run.cancelled",
|
|
"run.session_closed",
|
|
]);
|
|
export type ProjectRunEventVariant = typeof ProjectRunEventVariant.Type;
|
|
|
|
export const ProjectRunEvent = Schema.Struct({
|
|
exitCode: Schema.UndefinedOr(Schema.Int),
|
|
issueId: Schema.String,
|
|
runId: Schema.String,
|
|
sequence: Schema.Number,
|
|
text: Schema.UndefinedOr(Schema.String),
|
|
timestamp: Schema.String,
|
|
toolName: Schema.UndefinedOr(Schema.String),
|
|
type: ProjectRunEventVariant,
|
|
});
|
|
export type ProjectRunEvent = typeof ProjectRunEvent.Type;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Marker detection — OpenCode signals product-level conditions via markers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Prefix the agent emits when it cannot proceed without human input. */
|
|
export const NEEDS_INPUT_MARKER = "NEEDS_INPUT:";
|
|
|
|
/**
|
|
* Prefix the agent emits when implementation and verification are complete
|
|
* and the orchestrator should proceed to the Git publish lifecycle.
|
|
*/
|
|
export const WORK_COMPLETE_MARKER = "WORK_COMPLETE:";
|
|
|
|
export const extractNeedsInputQuestion = (text: string): string | undefined => {
|
|
const index = text.indexOf(NEEDS_INPUT_MARKER);
|
|
if (index === -1) {
|
|
return undefined;
|
|
}
|
|
const after = text.slice(index + NEEDS_INPUT_MARKER.length).trim();
|
|
return after.length > 0 ? after.slice(0, 4000) : "Agent requires input";
|
|
};
|
|
|
|
export const isWorkComplete = (text: string): boolean =>
|
|
text.includes(WORK_COMPLETE_MARKER);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// OrbEvent → ProjectRunEvent translation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Translate one normalized OrbEvent into zero or one durable ProjectRunEvent.
|
|
* Returns undefined for OrbEvents that have no product-meaningful projection
|
|
* (e.g. chunked intermediate output that is only useful in the raw audit log).
|
|
*/
|
|
export const mapOrbEvent = (
|
|
orbEvent: OrbEvent,
|
|
issueId: string,
|
|
runId: string
|
|
): ProjectRunEvent | undefined => {
|
|
const base: {
|
|
exitCode: number | undefined;
|
|
issueId: string;
|
|
runId: string;
|
|
sequence: number;
|
|
text: string | undefined;
|
|
timestamp: string;
|
|
toolName: string | undefined;
|
|
} = {
|
|
exitCode: undefined,
|
|
issueId,
|
|
runId,
|
|
sequence: orbEvent.sequence,
|
|
text: undefined,
|
|
timestamp: orbEvent.timestamp,
|
|
toolName: undefined,
|
|
};
|
|
|
|
switch (orbEvent.type) {
|
|
case "session_opened": {
|
|
return {
|
|
...base,
|
|
text: orbEvent.text ?? "Session opened",
|
|
type: "run.session_opened",
|
|
};
|
|
}
|
|
case "vm_booted": {
|
|
return undefined;
|
|
}
|
|
case "agent_message_completed": {
|
|
const text = orbEvent.text ?? "";
|
|
if (extractNeedsInputQuestion(text) !== undefined) {
|
|
return {
|
|
...base,
|
|
text: extractNeedsInputQuestion(text),
|
|
type: "run.needs_input",
|
|
};
|
|
}
|
|
return {
|
|
...base,
|
|
text,
|
|
type: "run.agent_message",
|
|
};
|
|
}
|
|
case "agent_message_chunk":
|
|
case "agent_thought_chunk": {
|
|
return undefined;
|
|
}
|
|
case "tool_call_started":
|
|
case "tool_call_completed": {
|
|
return {
|
|
...base,
|
|
text: undefined,
|
|
toolName: orbEvent.toolName,
|
|
type: "run.agent_progress",
|
|
};
|
|
}
|
|
case "command_executed": {
|
|
return {
|
|
...base,
|
|
exitCode: orbEvent.exitCode,
|
|
text: orbEvent.command,
|
|
type: "run.command_executed",
|
|
};
|
|
}
|
|
case "permission_requested":
|
|
case "permission_denied": {
|
|
return {
|
|
...base,
|
|
text: orbEvent.text ?? "Permission requested",
|
|
type: "run.permission_requested",
|
|
};
|
|
}
|
|
case "session_failed": {
|
|
return {
|
|
...base,
|
|
text: orbEvent.text ?? "Session failed",
|
|
type: "run.failed",
|
|
};
|
|
}
|
|
case "session_closed": {
|
|
return {
|
|
...base,
|
|
text: orbEvent.text ?? "Session closed",
|
|
type: "run.session_closed",
|
|
};
|
|
}
|
|
default: {
|
|
return undefined;
|
|
}
|
|
}
|
|
};
|