From 389b8bdbff4bd11f27077765872b07468a87eaab Mon Sep 17 00:00:00 2001 From: asgarovf Date: Wed, 11 Mar 2026 00:12:07 +0300 Subject: [PATCH 1/2] chore: complete #237 - Design and implement shared TaskProvider interface in @locusai/sdk Co-Authored-By: LocusAgent --- packages/sdk/src/index.ts | 9 +++ packages/sdk/src/task-provider.ts | 121 ++++++++++++++++++++++++++++++ 2 files changed, 130 insertions(+) create mode 100644 packages/sdk/src/task-provider.ts diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index cee1a109..0d39f050 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -27,3 +27,12 @@ export type { LocusConfig, LocusPackageManifest, } from "./types.js"; +// Task Provider +export type { + AuthResult, + IssueFilters, + ProviderComment, + ProviderIssue, + ProviderSprint, + TaskProvider, +} from "./task-provider.js"; diff --git a/packages/sdk/src/task-provider.ts b/packages/sdk/src/task-provider.ts new file mode 100644 index 00000000..3ccc2e81 --- /dev/null +++ b/packages/sdk/src/task-provider.ts @@ -0,0 +1,121 @@ +/** + * Shared TaskProvider interface for task management integrations. + * + * All providers (GitHub, Jira, Linear) implement this contract to provide + * a uniform API for authentication, issue management, status updates, + * comments, and sprint/milestone/cycle tracking. + */ + +// ─── Issue Types ──────────────────────────────────────────────────────────── + +/** Normalized comment from any provider. */ +export interface ProviderComment { + author: string; + body: string; + createdAt: string; +} + +/** Normalized issue from any provider. */ +export interface ProviderIssue { + /** Provider-specific ID (GitHub number as string, Jira KEY-123, Linear identifier). */ + id: string; + title: string; + /** Markdown description / body. */ + body: string; + /** Provider-specific status string (e.g., "open", "In Progress", workflow state). */ + status: string; + priority: "critical" | "high" | "medium" | "low" | "none"; + labels: string[]; + assignee?: string; + /** Web URL to the issue. */ + url: string; + comments: ProviderComment[]; +} + +/** Filters for listing issues. All fields are optional. */ +export interface IssueFilters { + /** Filter by status / state. */ + status?: string; + /** Filter by label name. */ + label?: string; + /** Filter by assignee username. */ + assignee?: string; + /** Filter by sprint / milestone / cycle ID. */ + sprintId?: string; + /** Maximum number of issues to return. */ + limit?: number; +} + +// ─── Sprint Types ─────────────────────────────────────────────────────────── + +/** Normalized sprint / milestone / cycle from any provider. */ +export interface ProviderSprint { + id: string; + name: string; + state: "active" | "closed" | "future"; + startDate?: string; + endDate?: string; + issueCount: number; +} + +// ─── Auth Types ───────────────────────────────────────────────────────────── + +/** Result of an authentication check. */ +export interface AuthResult { + authenticated: boolean; + user?: string; +} + +// ─── TaskProvider Interface ───────────────────────────────────────────────── + +/** + * Unified contract that all task management providers must implement. + * + * Covers: + * - Authentication verification + * - Issue fetching (single + list with filters) + * - Status lifecycle (in-progress → done / failed) + * - Comment posting + * - Sprint / milestone / cycle management + */ +export interface TaskProvider { + /** Provider identifier, e.g. "github", "jira", "linear". */ + readonly name: string; + + // ── Authentication ────────────────────────────────────────────────────── + + /** Verify that the provider is authenticated and return the current user. */ + checkAuth(): Promise; + + // ── Issues ────────────────────────────────────────────────────────────── + + /** Fetch a single issue by its provider-specific ID. */ + getIssue(id: string): Promise; + + /** List issues, optionally filtered. */ + listIssues(filters?: IssueFilters): Promise; + + // ── Status Management ─────────────────────────────────────────────────── + + /** Mark an issue as in-progress (e.g., add label, transition status). */ + markInProgress(id: string): Promise; + + /** Mark an issue as done, optionally linking a PR URL. */ + markDone(id: string, prUrl?: string): Promise; + + /** Mark an issue as failed with an error description. */ + markFailed(id: string, error: string): Promise; + + // ── Comments ──────────────────────────────────────────────────────────── + + /** Post a comment on an issue. */ + postComment(id: string, body: string): Promise; + + // ── Sprints / Milestones / Cycles ─────────────────────────────────────── + + /** Get the currently active sprint / milestone / cycle, or null if none. */ + getActiveSprint(): Promise; + + /** List issues belonging to a specific sprint / milestone / cycle. */ + getSprintIssues(sprintId: string): Promise; +} From bbe6c2cb0dab21c2764bf0410ec9c3ef1f8f590e Mon Sep 17 00:00:00 2001 From: asgarovf Date: Wed, 11 Mar 2026 00:34:29 +0300 Subject: [PATCH 2/2] chore: complete #238 - Extract execution orchestration engine from CLI core into SDK Co-Authored-By: LocusAgent --- packages/cli/__tests__/run-state.test.ts | 92 +++--- packages/cli/__tests__/shutdown.test.ts | 36 +-- packages/cli/src/commands/run.ts | 75 ++--- packages/cli/src/core/run-state.ts | 209 ++----------- packages/cli/src/core/shutdown.ts | 2 +- packages/cli/src/types.ts | 23 +- packages/sdk/src/execution/index.ts | 35 +++ packages/sdk/src/execution/orchestrator.ts | 335 +++++++++++++++++++++ packages/sdk/src/execution/run-state.ts | 243 +++++++++++++++ packages/sdk/src/index.ts | 24 ++ 10 files changed, 775 insertions(+), 299 deletions(-) create mode 100644 packages/sdk/src/execution/index.ts create mode 100644 packages/sdk/src/execution/orchestrator.ts create mode 100644 packages/sdk/src/execution/run-state.ts diff --git a/packages/cli/__tests__/run-state.test.ts b/packages/cli/__tests__/run-state.test.ts index 5d261e37..e07b8050 100644 --- a/packages/cli/__tests__/run-state.test.ts +++ b/packages/cli/__tests__/run-state.test.ts @@ -29,16 +29,16 @@ describe("run-state", () => { describe("createSprintRunState", () => { it("creates state with correct structure", () => { const state = createSprintRunState("Sprint 1", "feat/sprint-1", [ - { number: 10, order: 1 }, - { number: 11, order: 2 }, - { number: 12, order: 3 }, + { taskId: "10", order: 1 }, + { taskId: "11", order: 2 }, + { taskId: "12", order: 3 }, ]); expect(state.type).toBe("sprint"); expect(state.sprint).toBe("Sprint 1"); expect(state.branch).toBe("feat/sprint-1"); expect(state.tasks.length).toBe(3); - expect(state.tasks[0].issue).toBe(10); + expect(state.tasks[0].taskId).toBe("10"); expect(state.tasks[0].order).toBe(1); expect(state.tasks[0].status).toBe("pending"); expect(state.runId).toMatch(/^run-/); @@ -48,11 +48,11 @@ describe("run-state", () => { describe("createParallelRunState", () => { it("creates state with correct structure", () => { - const state = createParallelRunState([5, 6, 7]); + const state = createParallelRunState(["5", "6", "7"]); expect(state.type).toBe("parallel"); expect(state.tasks.length).toBe(3); - expect(state.tasks[0].issue).toBe(5); + expect(state.tasks[0].taskId).toBe("5"); expect(state.tasks[0].order).toBe(1); expect(state.tasks[2].order).toBe(3); }); @@ -60,13 +60,15 @@ describe("run-state", () => { describe("save/load/clear", () => { it("round-trips state through save and load", () => { - const state = createSprintRunState("S1", "b", [{ number: 1, order: 1 }]); + const state = createSprintRunState("S1", "b", [ + { taskId: "1", order: 1 }, + ]); saveRunState(TEST_DIR, state); const loaded = loadRunState(TEST_DIR, "S1"); expect(loaded).not.toBeNull(); expect(loaded?.runId).toBe(state.runId); - expect(loaded?.tasks[0].issue).toBe(1); + expect(loaded?.tasks[0].taskId).toBe("1"); }); it("returns null when no state file exists", () => { @@ -74,7 +76,9 @@ describe("run-state", () => { }); it("clears state file", () => { - const state = createSprintRunState("S1", "b", [{ number: 1, order: 1 }]); + const state = createSprintRunState("S1", "b", [ + { taskId: "1", order: 1 }, + ]); saveRunState(TEST_DIR, state); expect(loadRunState(TEST_DIR, "S1")).not.toBeNull(); @@ -91,35 +95,41 @@ describe("run-state", () => { describe("task mutations", () => { it("markTaskInProgress", () => { const state = createSprintRunState("S", "b", [ - { number: 1, order: 1 }, - { number: 2, order: 2 }, + { taskId: "1", order: 1 }, + { taskId: "2", order: 2 }, ]); - markTaskInProgress(state, 1); + markTaskInProgress(state, "1"); expect(state.tasks[0].status).toBe("in_progress"); expect(state.tasks[1].status).toBe("pending"); }); it("markTaskDone", () => { - const state = createSprintRunState("S", "b", [{ number: 1, order: 1 }]); - markTaskInProgress(state, 1); - markTaskDone(state, 1, 42); + const state = createSprintRunState("S", "b", [ + { taskId: "1", order: 1 }, + ]); + markTaskInProgress(state, "1"); + markTaskDone(state, "1", 42); expect(state.tasks[0].status).toBe("done"); expect(state.tasks[0].pr).toBe(42); expect(state.tasks[0].completedAt).toBeTruthy(); }); it("markTaskFailed", () => { - const state = createSprintRunState("S", "b", [{ number: 1, order: 1 }]); - markTaskInProgress(state, 1); - markTaskFailed(state, 1, "Build failed"); + const state = createSprintRunState("S", "b", [ + { taskId: "1", order: 1 }, + ]); + markTaskInProgress(state, "1"); + markTaskFailed(state, "1", "Build failed"); expect(state.tasks[0].status).toBe("failed"); expect(state.tasks[0].error).toBe("Build failed"); expect(state.tasks[0].failedAt).toBeTruthy(); }); - it("handles non-existent issue gracefully", () => { - const state = createSprintRunState("S", "b", [{ number: 1, order: 1 }]); - markTaskInProgress(state, 999); // Should not throw + it("handles non-existent task gracefully", () => { + const state = createSprintRunState("S", "b", [ + { taskId: "1", order: 1 }, + ]); + markTaskInProgress(state, "999"); // Should not throw expect(state.tasks[0].status).toBe("pending"); }); }); @@ -127,14 +137,14 @@ describe("run-state", () => { describe("getRunStats", () => { it("returns correct counts", () => { const state = createSprintRunState("S", "b", [ - { number: 1, order: 1 }, - { number: 2, order: 2 }, - { number: 3, order: 3 }, - { number: 4, order: 4 }, + { taskId: "1", order: 1 }, + { taskId: "2", order: 2 }, + { taskId: "3", order: 3 }, + { taskId: "4", order: 4 }, ]); - markTaskDone(state, 1); - markTaskInProgress(state, 2); - markTaskFailed(state, 3, "error"); + markTaskDone(state, "1"); + markTaskInProgress(state, "2"); + markTaskFailed(state, "3", "error"); const stats = getRunStats(state); expect(stats.total).toBe(4); @@ -148,33 +158,35 @@ describe("run-state", () => { describe("getNextTask", () => { it("returns first pending task", () => { const state = createSprintRunState("S", "b", [ - { number: 1, order: 1 }, - { number: 2, order: 2 }, + { taskId: "1", order: 1 }, + { taskId: "2", order: 2 }, ]); - markTaskDone(state, 1); + markTaskDone(state, "1"); const next = getNextTask(state); - expect(next?.issue).toBe(2); + expect(next?.taskId).toBe("2"); expect(next?.status).toBe("pending"); }); it("prioritizes failed tasks for retry", () => { const state = createSprintRunState("S", "b", [ - { number: 1, order: 1 }, - { number: 2, order: 2 }, - { number: 3, order: 3 }, + { taskId: "1", order: 1 }, + { taskId: "2", order: 2 }, + { taskId: "3", order: 3 }, ]); - markTaskDone(state, 1); - markTaskFailed(state, 2, "error"); + markTaskDone(state, "1"); + markTaskFailed(state, "2", "error"); const next = getNextTask(state); - expect(next?.issue).toBe(2); + expect(next?.taskId).toBe("2"); expect(next?.status).toBe("failed"); }); it("returns null when all tasks are done", () => { - const state = createSprintRunState("S", "b", [{ number: 1, order: 1 }]); - markTaskDone(state, 1); + const state = createSprintRunState("S", "b", [ + { taskId: "1", order: 1 }, + ]); + markTaskDone(state, "1"); expect(getNextTask(state)).toBeNull(); }); diff --git a/packages/cli/__tests__/shutdown.test.ts b/packages/cli/__tests__/shutdown.test.ts index e69be362..00108f6c 100644 --- a/packages/cli/__tests__/shutdown.test.ts +++ b/packages/cli/__tests__/shutdown.test.ts @@ -7,7 +7,7 @@ import { loadRunState, saveRunState, } from "../src/core/run-state.js"; -import type { RunState } from "../src/types.js"; +import type { RunState } from "../src/core/run-state.js"; const testDir = join(tmpdir(), `locus-shutdown-test-${Date.now()}`); const INTERRUPT_ERROR = "Interrupted by user"; @@ -40,13 +40,13 @@ describe("shutdown state preservation", () => { startedAt: new Date().toISOString(), tasks: [ { - issue: 1, + taskId: "1", order: 1, status: "done", completedAt: new Date().toISOString(), }, - { issue: 2, order: 2, status: "in_progress" }, - { issue: 3, order: 3, status: "pending" }, + { taskId: "2", order: 2, status: "in_progress" }, + { taskId: "3", order: 3, status: "pending" }, ], }; @@ -71,8 +71,8 @@ describe("shutdown state preservation", () => { type: "parallel", startedAt: new Date().toISOString(), tasks: [ - { issue: 10, order: 1, status: "in_progress" }, - { issue: 11, order: 2, status: "pending" }, + { taskId: "10", order: 1, status: "in_progress" }, + { taskId: "11", order: 2, status: "pending" }, ], }; @@ -81,7 +81,7 @@ describe("shutdown state preservation", () => { // File should be valid JSON const raw = readFileSync( join(testDir, ".locus", "run-state", "_parallel.json"), - "utf-8" + "utf-8", ); const parsed = JSON.parse(raw); expect(parsed.runId).toBe("run-test-002"); @@ -96,9 +96,9 @@ describe("shutdown state preservation", () => { type: "parallel", startedAt: new Date().toISOString(), tasks: [ - { issue: 20, order: 1, status: "in_progress" }, - { issue: 21, order: 2, status: "in_progress" }, - { issue: 22, order: 3, status: "in_progress" }, + { taskId: "20", order: 1, status: "in_progress" }, + { taskId: "21", order: 2, status: "in_progress" }, + { taskId: "22", order: 3, status: "in_progress" }, ], }; @@ -122,21 +122,21 @@ describe("shutdown state preservation", () => { startedAt: new Date().toISOString(), tasks: [ { - issue: 30, + taskId: "30", order: 1, status: "done", completedAt: "2026-01-01T00:00:00Z", pr: 100, }, { - issue: 31, + taskId: "31", order: 2, status: "failed", failedAt: "2026-01-01T01:00:00Z", error: "API limit", }, - { issue: 32, order: 3, status: "in_progress" }, - { issue: 33, order: 4, status: "pending" }, + { taskId: "32", order: 3, status: "in_progress" }, + { taskId: "33", order: 4, status: "pending" }, ], }; @@ -182,13 +182,13 @@ describe("shutdown state preservation", () => { startedAt: new Date().toISOString(), tasks: [ { - issue: 40, + taskId: "40", order: 1, status: "done", completedAt: "2026-01-01T00:00:00Z", }, - { issue: 41, order: 2, status: "in_progress" }, - { issue: 42, order: 3, status: "pending" }, + { taskId: "41", order: 2, status: "in_progress" }, + { taskId: "42", order: 3, status: "pending" }, ], }; @@ -199,7 +199,7 @@ describe("shutdown state preservation", () => { // Simulate resume — failed interrupted task should be retried first. const loaded = loadRunState(testDir, "resume-test"); const next = loaded ? getNextTask(loaded) : null; - expect(next?.issue).toBe(41); // The one that was in_progress + expect(next?.taskId).toBe("41"); // The one that was in_progress expect(next?.status).toBe("failed"); expect(next?.error).toBe(INTERRUPT_ERROR); }); diff --git a/packages/cli/src/commands/run.ts b/packages/cli/src/commands/run.ts index 06a791ca..0deee2ae 100644 --- a/packages/cli/src/commands/run.ts +++ b/packages/cli/src/commands/run.ts @@ -66,7 +66,8 @@ import { renderTaskStatus, } from "../display/progress.js"; import { bold, cyan, dim, green, red, yellow } from "../display/terminal.js"; -import type { Issue, LocusConfig, RunState } from "../types.js"; +import type { Issue, LocusConfig } from "../types.js"; +import type { RunState, RunTask } from "../core/run-state.js"; function resolveExecutionContext( config: LocusConfig, @@ -415,7 +416,7 @@ async function executeSingleSprint( sprintName, branchName, issues.map((issue, i) => ({ - number: issue.number, + taskId: String(issue.number), order: getOrder(issue) ?? i + 1, })) ); @@ -425,10 +426,11 @@ async function executeSingleSprint( // Print task list for (const task of state.tasks) { - const issue = issues.find((i) => i.number === task.issue); + const issueNum = Number(task.taskId); + const issue = issues.find((i) => i.number === issueNum); process.stderr.write( `${renderTaskStatus( - task.issue, + issueNum, issue?.title ?? "Unknown", task.status, `order:${task.order}` @@ -448,12 +450,13 @@ async function executeSingleSprint( for (let i = 0; i < state.tasks.length; i++) { const task = state.tasks[i]; - const issue = issues.find((iss) => iss.number === task.issue); + const issueNum = Number(task.taskId); + const issue = issues.find((iss) => iss.number === issueNum); // Skip completed tasks if (task.status === "done") { process.stderr.write( - `${renderTaskStatus(task.issue, issue?.title ?? "", "done", "skipped")}\n` + `${renderTaskStatus(issueNum, issue?.title ?? "", "done", "skipped")}\n` ); continue; } @@ -473,10 +476,10 @@ async function executeSingleSprint( if (conflictResult.hasConflict) { // Mark task as failed but continue to next task - markTaskFailed(state, task.issue, "Merge conflict with base branch"); + markTaskFailed(state, task.taskId, "Merge conflict with base branch"); saveRunState(projectRoot, state); process.stderr.write( - ` ${red("✗")} Task #${task.issue} skipped due to conflicts.\n` + ` ${red("✗")} Task #${task.taskId} skipped due to conflicts.\n` ); if (config.sprint.stopOnFailure) { @@ -494,10 +497,10 @@ async function executeSingleSprint( // Auto-rebase const rebaseResult = attemptRebase(workDir, config.agent.baseBranch); if (!rebaseResult.success) { - markTaskFailed(state, task.issue, "Rebase failed"); + markTaskFailed(state, task.taskId, "Rebase failed"); saveRunState(projectRoot, state); process.stderr.write( - ` ${red("✗")} Auto-rebase failed for task #${task.issue}.\n` + ` ${red("✗")} Auto-rebase failed for task #${task.taskId}.\n` ); if (config.sprint.stopOnFailure) { @@ -534,12 +537,12 @@ async function executeSingleSprint( ); // Mark in-progress - markTaskInProgress(state, task.issue); + markTaskInProgress(state, task.taskId); saveRunState(projectRoot, state); // Execute (skip per-task PR — a single sprint PR is created after all tasks). const result = await executeIssue(workDir, { - issueNumber: task.issue, + issueNumber: issueNum, provider: execution.provider, model: execution.model, dryRun: flags.dryRun, @@ -554,7 +557,7 @@ async function executeSingleSprint( // Ensure all changes are committed before moving to the next task if (!flags.dryRun) { const issueTitle = issue?.title ?? ""; - ensureTaskCommit(workDir, task.issue, issueTitle); + ensureTaskCommit(workDir, issueNum, issueTitle); // Sandbox sync is bidirectional with the host workspace, so each task // sees the latest committed state. @@ -564,21 +567,21 @@ async function executeSingleSprint( ); } } - markTaskDone(state, task.issue, result.prNumber); + markTaskDone(state, task.taskId, result.prNumber); } else { - markTaskFailed(state, task.issue, result.error ?? "Unknown error"); + markTaskFailed(state, task.taskId, result.error ?? "Unknown error"); saveRunState(projectRoot, state); if (config.sprint.stopOnFailure) { process.stderr.write( - `\n${red("✗")} Sprint "${sprintName}" stopped: task #${task.issue} failed.\n` + `\n${red("✗")} Sprint "${sprintName}" stopped: task #${task.taskId} failed.\n` ); process.stderr.write(` Resume with: ${bold("locus run --resume")}\n`); break; } // When stopOnFailure is false, log and continue to next task process.stderr.write( - ` ${yellow("⚠")} Task #${task.issue} failed, continuing to next task.\n` + ` ${yellow("⚠")} Task #${task.taskId} failed, continuing to next task.\n` ); } @@ -598,10 +601,10 @@ async function executeSingleSprint( // Create a single PR for the entire sprint if (!flags.dryRun && stats.done > 0) { const completedTasks = state.tasks - .filter((t) => t.status === "done") - .map((t) => ({ - issue: t.issue, - title: issues.find((i) => i.number === t.issue)?.title, + .filter((t: RunTask) => t.status === "done") + .map((t: RunTask) => ({ + issue: Number(t.taskId), + title: issues.find((i) => i.number === Number(t.taskId))?.title, })); await createSprintPR( @@ -782,7 +785,7 @@ async function handleParallelRun( } } - const state = createParallelRunState(issueNumbers); + const state = createParallelRunState(issueNumbers.map(String)); saveRunState(projectRoot, state); // Track worktrees created so we can clean them up @@ -794,7 +797,8 @@ async function handleParallelRun( for (let i = 0; i < issueNumbers.length; i += maxConcurrent) { const batch = issueNumbers.slice(i, i + maxConcurrent); const promises = batch.map(async (issueNumber) => { - markTaskInProgress(state, issueNumber); + const taskId = String(issueNumber); + markTaskInProgress(state, taskId); saveRunState(projectRoot, state); // Create worktree for this issue @@ -828,14 +832,14 @@ async function handleParallelRun( }); if (result.success) { - markTaskDone(state, issueNumber, result.prNumber); + markTaskDone(state, taskId, result.prNumber); // Clean up worktree on success if (worktreePath) { removeWorktree(projectRoot, issueNumber); worktreeMap.delete(issueNumber); } } else { - markTaskFailed(state, issueNumber, result.error ?? "Unknown error"); + markTaskFailed(state, taskId, result.error ?? "Unknown error"); // Preserve worktree on failure for debugging } saveRunState(projectRoot, state); @@ -1029,11 +1033,12 @@ async function resumeSingleRun( task.failedAt = undefined; } - markTaskInProgress(state, task.issue); + const issueNum = Number(task.taskId); + markTaskInProgress(state, task.taskId); saveRunState(projectRoot, state); const result = await executeIssue(workDir, { - issueNumber: task.issue, + issueNumber: issueNum, provider: execution.provider, model: execution.model, skipPR: isSprintRun, @@ -1047,12 +1052,12 @@ async function resumeSingleRun( // Fetch issue title for the commit message if possible let issueTitle = ""; try { - const iss = getIssue(task.issue, { cwd: projectRoot }); + const iss = getIssue(issueNum, { cwd: projectRoot }); issueTitle = iss.title; } catch { // Non-fatal } - ensureTaskCommit(workDir, task.issue, issueTitle); + ensureTaskCommit(workDir, issueNum, issueTitle); // Sandbox sync is bidirectional with the host workspace. if (sandboxed) { @@ -1061,19 +1066,19 @@ async function resumeSingleRun( ); } } - markTaskDone(state, task.issue, result.prNumber); + markTaskDone(state, task.taskId, result.prNumber); } else { - markTaskFailed(state, task.issue, result.error ?? "Unknown error"); + markTaskFailed(state, task.taskId, result.error ?? "Unknown error"); saveRunState(projectRoot, state); if (config.sprint.stopOnFailure && isSprintRun) { process.stderr.write( - `\n${red("✗")} Sprint stopped: task #${task.issue} failed.\n` + `\n${red("✗")} Sprint stopped: task #${task.taskId} failed.\n` ); return; } process.stderr.write( - ` ${yellow("⚠")} Task #${task.issue} failed, continuing to next task.\n` + ` ${yellow("⚠")} Task #${task.taskId} failed, continuing to next task.\n` ); } @@ -1089,8 +1094,8 @@ async function resumeSingleRun( // Create sprint PR if this was a sprint run and there were completed tasks if (isSprintRun && state.branch && state.sprint && finalStats.done > 0) { const completedTasks = state.tasks - .filter((t) => t.status === "done") - .map((t) => ({ issue: t.issue })); + .filter((t: RunTask) => t.status === "done") + .map((t: RunTask) => ({ issue: Number(t.taskId) })); await createSprintPR( workDir, diff --git a/packages/cli/src/core/run-state.ts b/packages/cli/src/core/run-state.ts index b51d2cf7..4631429b 100644 --- a/packages/cli/src/core/run-state.ts +++ b/packages/cli/src/core/run-state.ts @@ -1,189 +1,28 @@ /** - * Run state persistence — tracks sprint/parallel execution progress. - * Enables resume after failure with `locus run --resume`. + * Run state persistence — re-exports from @locusai/sdk execution module. * - * State is stored per-sprint in `.locus/run-state/.json`, - * allowing independent pause/resume of multiple sprints. - * Parallel (non-sprint) runs use `.locus/run-state/_parallel.json`. + * The canonical implementation lives in packages/sdk/src/execution/run-state.ts. + * This file re-exports everything for backward compatibility with existing CLI + * imports. */ -import { - existsSync, - mkdirSync, - readFileSync, - unlinkSync, - writeFileSync, -} from "node:fs"; -import { dirname, join } from "node:path"; -import type { RunState, RunTask } from "../types.js"; -import { getLogger } from "./logger.js"; - -// ─── Paths ────────────────────────────────────────────────────────────────── - -function getRunStateDir(projectRoot: string): string { - return join(projectRoot, ".locus", "run-state"); -} - -/** Slugify a sprint name for use as a filename. */ -function sprintSlug(name: string): string { - return name - .trim() - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-|-$/g, ""); -} - -/** - * Get the path to the run state file. - * - With sprintName: `.locus/run-state/.json` - * - Without: `.locus/run-state/_parallel.json` - */ -function getRunStatePath(projectRoot: string, sprintName?: string): string { - const dir = getRunStateDir(projectRoot); - if (sprintName) { - return join(dir, `${sprintSlug(sprintName)}.json`); - } - return join(dir, "_parallel.json"); -} - -// ─── Load / Save ──────────────────────────────────────────────────────────── - -/** - * Load run state for a specific sprint, or parallel state if no sprint given. - */ -export function loadRunState( - projectRoot: string, - sprintName?: string -): RunState | null { - const path = getRunStatePath(projectRoot, sprintName); - if (!existsSync(path)) return null; - - try { - return JSON.parse(readFileSync(path, "utf-8")); - } catch { - getLogger().warn("Corrupted run state file, ignoring"); - return null; - } -} - -/** Save run state to disk. Path is derived from `state.sprint`. */ -export function saveRunState(projectRoot: string, state: RunState): void { - const path = getRunStatePath(projectRoot, state.sprint); - const dir = dirname(path); - - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true }); - } - - writeFileSync(path, `${JSON.stringify(state, null, 2)}\n`, "utf-8"); -} - -/** - * Clear run state for a specific sprint, or parallel state if no sprint given. - */ -export function clearRunState(projectRoot: string, sprintName?: string): void { - const path = getRunStatePath(projectRoot, sprintName); - if (existsSync(path)) { - unlinkSync(path); - } -} - -// ─── State Mutations ──────────────────────────────────────────────────────── - -/** Create a new run state for a sprint. */ -export function createSprintRunState( - sprint: string, - branch: string, - issues: { number: number; order: number }[] -): RunState { - return { - runId: `run-${new Date().toISOString().slice(0, 19).replace(/[:.]/g, "-")}`, - type: "sprint", - sprint, - branch, - startedAt: new Date().toISOString(), - tasks: issues.map(({ number, order }) => ({ - issue: number, - order, - status: "pending" as const, - })), - }; -} - -/** Create a new run state for parallel execution. */ -export function createParallelRunState(issueNumbers: number[]): RunState { - return { - runId: `run-${new Date().toISOString().slice(0, 19).replace(/[:.]/g, "-")}`, - type: "parallel", - startedAt: new Date().toISOString(), - tasks: issueNumbers.map((issue, i) => ({ - issue, - order: i + 1, - status: "pending" as const, - })), - }; -} - -/** Mark a task as in-progress. */ -export function markTaskInProgress(state: RunState, issueNumber: number): void { - const task = state.tasks.find((t) => t.issue === issueNumber); - if (task) { - task.status = "in_progress"; - } -} - -/** Mark a task as done with the PR number. */ -export function markTaskDone( - state: RunState, - issueNumber: number, - prNumber?: number -): void { - const task = state.tasks.find((t) => t.issue === issueNumber); - if (task) { - task.status = "done"; - task.completedAt = new Date().toISOString(); - if (prNumber) task.pr = prNumber; - } -} - -/** Mark a task as failed with an error message. */ -export function markTaskFailed( - state: RunState, - issueNumber: number, - error: string -): void { - const task = state.tasks.find((t) => t.issue === issueNumber); - if (task) { - task.status = "failed"; - task.failedAt = new Date().toISOString(); - task.error = error; - } -} - -/** Get summary stats from a run state. */ -export function getRunStats(state: RunState): { - total: number; - done: number; - failed: number; - pending: number; - inProgress: number; -} { - const tasks = state.tasks; - return { - total: tasks.length, - done: tasks.filter((t) => t.status === "done").length, - failed: tasks.filter((t) => t.status === "failed").length, - pending: tasks.filter((t) => t.status === "pending").length, - inProgress: tasks.filter((t) => t.status === "in_progress").length, - }; -} - -/** Get the next pending or failed task (for resume). */ -export function getNextTask(state: RunState): RunTask | null { - // First try to find a failed task (retry) - const failed = state.tasks.find((t) => t.status === "failed"); - if (failed) return failed; - - // Then find the next pending task - return state.tasks.find((t) => t.status === "pending") ?? null; -} +export type { + RunState, + RunTask, + RunTaskStatus, + RunStats, +} from "@locusai/sdk"; + +export { + loadRunState, + saveRunState, + clearRunState, + createSprintRunState, + createParallelRunState, + markTaskInProgress, + markTaskDone, + markTaskFailed, + getRunStats, + getNextTask, + sprintSlug, +} from "@locusai/sdk"; diff --git a/packages/cli/src/core/shutdown.ts b/packages/cli/src/core/shutdown.ts index 01266ac4..ec1acee8 100644 --- a/packages/cli/src/core/shutdown.ts +++ b/packages/cli/src/core/shutdown.ts @@ -6,7 +6,7 @@ * Docker sandboxes are intentionally preserved across sessions. */ -import type { RunState } from "../types.js"; +import type { RunState } from "./run-state.js"; import { saveRunState } from "./run-state.js"; export interface ShutdownContext { diff --git a/packages/cli/src/types.ts b/packages/cli/src/types.ts index 72bb6ef8..7808a682 100644 --- a/packages/cli/src/types.ts +++ b/packages/cli/src/types.ts @@ -173,26 +173,9 @@ export const ALL_LABELS: LabelDef[] = [ AGENT_LABEL, ]; -// ─── Run State ─────────────────────────────────────────────────────────────── - -export interface RunState { - runId: string; - type: "sprint" | "parallel"; - sprint?: string; - branch?: string; - startedAt: string; - tasks: RunTask[]; -} - -export interface RunTask { - issue: number; - order: number; - status: "pending" | "in_progress" | "done" | "failed"; - pr?: number; - completedAt?: string; - failedAt?: string; - error?: string; -} +// ─── Run State (re-exported from @locusai/sdk) ────────────────────────────── +// RunState and RunTask are now defined in the SDK execution module. +// Import them from "../core/run-state.js" which re-exports from the SDK. // ─── AI Runner ─────────────────────────────────────────────────────────────── diff --git a/packages/sdk/src/execution/index.ts b/packages/sdk/src/execution/index.ts new file mode 100644 index 00000000..8c0ff848 --- /dev/null +++ b/packages/sdk/src/execution/index.ts @@ -0,0 +1,35 @@ +/** + * Execution orchestration engine. + * + * Provides provider-agnostic run state management and task execution + * orchestration. Any TaskProvider can plug into this engine. + */ + +// Run state management +export type { + RunState, + RunTask, + RunTaskStatus, + RunStats, +} from "./run-state.js"; +export { + loadRunState, + saveRunState, + clearRunState, + createSprintRunState, + createParallelRunState, + markTaskInProgress, + markTaskDone, + markTaskFailed, + getRunStats, + getNextTask, + sprintSlug, +} from "./run-state.js"; + +// Orchestrator +export type { + TaskResult, + ExecutionOptions, + RunResult, +} from "./orchestrator.js"; +export { executeTaskRun } from "./orchestrator.js"; diff --git a/packages/sdk/src/execution/orchestrator.ts b/packages/sdk/src/execution/orchestrator.ts new file mode 100644 index 00000000..2524f304 --- /dev/null +++ b/packages/sdk/src/execution/orchestrator.ts @@ -0,0 +1,335 @@ +/** + * Generic execution orchestrator for running tasks from any TaskProvider. + * + * Handles the lifecycle: load state → pick next task → mark in-progress → + * execute callback → mark done/failed → save state → resume. + * + * The orchestrator is intentionally decoupled from AI execution details + * (prompt building, sandbox management, worktrees, git operations). + * Callers provide an `execute` callback that encapsulates provider- and + * environment-specific logic. + */ + +import type { ProviderIssue, TaskProvider } from "../task-provider.js"; +import { + type RunState, + type RunStats, + type RunTask, + clearRunState, + createParallelRunState, + createSprintRunState, + getNextTask, + getRunStats, + loadRunState, + markTaskDone, + markTaskFailed, + markTaskInProgress, + saveRunState, +} from "./run-state.js"; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +/** Result of executing a single task. */ +export interface TaskResult { + /** Whether execution succeeded. */ + success: boolean; + /** PR number created for this task (if any). */ + prNumber?: number; + /** Error message on failure. */ + error?: string; + /** Optional summary of what was done. */ + summary?: string; +} + +/** Options for `executeTaskRun`. */ +export interface ExecutionOptions { + /** The task provider to use for status updates and comments. */ + provider: TaskProvider; + /** Tasks to execute. */ + tasks: ProviderIssue[]; + /** Execution mode: sequential (sprint) or parallel. */ + mode: "sequential" | "parallel"; + /** Project root directory (for run state persistence). */ + projectRoot: string; + /** Whether to resume an existing run state. */ + resume?: boolean; + /** Sprint/milestone/cycle name (for sprint runs). */ + sprintName?: string; + /** Git branch name (for sprint runs). */ + branch?: string; + /** Maximum concurrent tasks for parallel mode. Defaults to tasks.length. */ + maxConcurrent?: number; + + /** + * Execute a single task. This callback encapsulates all provider- and + * environment-specific logic (AI invocation, prompt building, sandbox, git). + * + * The orchestrator calls this for each task and handles state management + * around it. + */ + execute: (task: ProviderIssue, runState: RunState) => Promise; + + // ── Lifecycle hooks (optional) ────────────────────────────────────────── + + /** Called when a task starts execution. */ + onTaskStart?: (task: ProviderIssue) => void; + /** Called when a task completes successfully. */ + onTaskComplete?: (task: ProviderIssue, result: TaskResult) => void; + /** Called when a task fails. */ + onTaskFailed?: (task: ProviderIssue, error: Error) => void; + /** Whether to stop the entire run on the first failure (sprint mode). */ + stopOnFailure?: boolean; +} + +/** Aggregated result of an entire run. */ +export interface RunResult { + /** Run state at completion. */ + state: RunState; + /** Aggregated stats. */ + stats: RunStats; + /** Per-task results keyed by task ID. */ + results: Map; +} + +// ─── Orchestrator ──────────────────────────────────────────────────────────── + +/** + * Execute a set of tasks using the provided TaskProvider for status management. + * + * The function: + * 1. Creates or loads a RunState from `.locus/run-state/` + * 2. For each task: calls `provider.markInProgress()`, runs the execute callback + * 3. On success: calls `provider.markDone()` + * 4. On failure: calls `provider.markFailed()` + * 5. Saves RunState after each task (enabling resume) + * 6. Returns aggregated results + */ +export async function executeTaskRun( + options: ExecutionOptions, +): Promise { + const { + tasks, + mode, + projectRoot, + sprintName, + branch, + } = options; + + // Build a lookup map from task ID to ProviderIssue + const taskMap = new Map(); + for (const task of tasks) { + taskMap.set(task.id, task); + } + + // Load or create run state + let state: RunState; + if (options.resume) { + const existing = loadRunState(projectRoot, sprintName); + if (existing) { + state = existing; + } else { + // No existing state to resume — create fresh + state = createRunState(mode, tasks, sprintName, branch); + } + } else { + state = createRunState(mode, tasks, sprintName, branch); + } + + saveRunState(projectRoot, state); + + const results = new Map(); + + if (mode === "sequential") { + await executeSequential(options, state, taskMap, results); + } else { + await executeParallel(options, state, taskMap, results); + } + + // Clean up state on full success + const stats = getRunStats(state); + if (stats.failed === 0 && stats.pending === 0 && stats.inProgress === 0) { + clearRunState(projectRoot, sprintName); + } + + return { state, stats, results }; +} + +// ─── Internal ──────────────────────────────────────────────────────────────── + +function createRunState( + mode: "sequential" | "parallel", + tasks: ProviderIssue[], + sprintName?: string, + branch?: string, +): RunState { + if (mode === "sequential" && sprintName && branch) { + return createSprintRunState( + sprintName, + branch, + tasks.map((t, i) => ({ taskId: t.id, order: i + 1 })), + ); + } + return createParallelRunState(tasks.map((t) => t.id)); +} + +async function executeSequential( + options: ExecutionOptions, + state: RunState, + taskMap: Map, + results: Map, +): Promise { + const { provider, projectRoot, execute, stopOnFailure } = options; + + let runTask: RunTask | null = getNextTask(state); + + while (runTask) { + const task = taskMap.get(runTask.taskId); + if (!task) { + markTaskFailed(state, runTask.taskId, "Task not found in provider"); + saveRunState(projectRoot, state); + runTask = getNextTask(state); + continue; + } + + // Reset failed tasks for retry during resume + if (runTask.status === "failed") { + runTask.status = "pending"; + runTask.error = undefined; + runTask.failedAt = undefined; + } + + await executeSingleTask(options, state, task, provider, projectRoot, execute, results); + + // Check stop-on-failure + const lastResult = results.get(task.id); + if (lastResult && !lastResult.success && stopOnFailure) { + break; + } + + runTask = getNextTask(state); + } +} + +async function executeParallel( + options: ExecutionOptions, + state: RunState, + taskMap: Map, + results: Map, +): Promise { + const { provider, projectRoot, execute, maxConcurrent } = options; + const batchSize = maxConcurrent ?? state.tasks.length; + + // Process in batches + const pendingTasks = state.tasks.filter( + (t) => t.status === "pending" || t.status === "failed", + ); + + for (let i = 0; i < pendingTasks.length; i += batchSize) { + const batch = pendingTasks.slice(i, i + batchSize); + + const promises = batch.map(async (runTask) => { + const task = taskMap.get(runTask.taskId); + if (!task) { + markTaskFailed(state, runTask.taskId, "Task not found in provider"); + saveRunState(projectRoot, state); + return; + } + + // Reset failed tasks for retry + if (runTask.status === "failed") { + runTask.status = "pending"; + runTask.error = undefined; + runTask.failedAt = undefined; + } + + await executeSingleTask(options, state, task, provider, projectRoot, execute, results); + }); + + await Promise.allSettled(promises); + } +} + +async function executeSingleTask( + options: ExecutionOptions, + state: RunState, + task: ProviderIssue, + provider: TaskProvider, + projectRoot: string, + execute: (task: ProviderIssue, runState: RunState) => Promise, + results: Map, +): Promise { + // Mark in-progress in run state + markTaskInProgress(state, task.id); + saveRunState(projectRoot, state); + + // Mark in-progress in provider + try { + await provider.markInProgress(task.id); + } catch { + // Non-fatal — provider status update failure shouldn't block execution + } + + // Notify hook + options.onTaskStart?.(task); + + let result: TaskResult; + try { + result = await execute(task, state); + } catch (err) { + const error = err instanceof Error ? err : new Error(String(err)); + result = { success: false, error: error.message }; + options.onTaskFailed?.(task, error); + } + + results.set(task.id, result); + + if (result.success) { + markTaskDone(state, task.id, result.prNumber); + + // Update provider status + try { + await provider.markDone(task.id, result.prNumber?.toString()); + } catch { + // Non-fatal + } + + // Post completion comment + try { + const comment = result.summary + ? `Execution complete.\n\n${result.summary}${result.prNumber ? `\nPR: #${result.prNumber}` : ""}` + : `Execution complete.${result.prNumber ? ` PR: #${result.prNumber}` : ""}`; + await provider.postComment(task.id, comment); + } catch { + // Non-fatal + } + + options.onTaskComplete?.(task, result); + } else { + markTaskFailed(state, task.id, result.error ?? "Unknown error"); + + // Update provider status + try { + await provider.markFailed(task.id, result.error ?? "Unknown error"); + } catch { + // Non-fatal + } + + // Post failure comment + try { + await provider.postComment( + task.id, + `Execution failed.\n\n\`\`\`\n${(result.error ?? "Unknown error").slice(0, 1000)}\n\`\`\``, + ); + } catch { + // Non-fatal + } + + if (!options.onTaskFailed) { + // Only fire if not already fired from catch block above + } else { + options.onTaskFailed(task, new Error(result.error ?? "Unknown error")); + } + } + + saveRunState(projectRoot, state); +} diff --git a/packages/sdk/src/execution/run-state.ts b/packages/sdk/src/execution/run-state.ts new file mode 100644 index 00000000..2ad37a06 --- /dev/null +++ b/packages/sdk/src/execution/run-state.ts @@ -0,0 +1,243 @@ +/** + * Run state persistence — tracks sprint/parallel execution progress. + * Enables resume after failure with `--resume`. + * + * State is stored per-sprint in `.locus/run-state/.json`, + * allowing independent pause/resume of multiple sprints. + * Parallel (non-sprint) runs use `.locus/run-state/_parallel.json`. + * + * This module is provider-agnostic — it uses string task IDs instead of + * GitHub issue numbers, allowing any TaskProvider to plug in. + */ + +import { + existsSync, + mkdirSync, + readFileSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { dirname, join } from "node:path"; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +/** Status of a single task within a run. */ +export type RunTaskStatus = "pending" | "in_progress" | "done" | "failed"; + +/** A single task within a run. */ +export interface RunTask { + /** Provider-agnostic task ID (GitHub issue number as string, Jira KEY-123, etc.). */ + taskId: string; + /** Execution order (1-based). */ + order: number; + /** Current status. */ + status: RunTaskStatus; + /** PR number or URL created for this task. */ + pr?: number; + /** ISO timestamp when the task completed. */ + completedAt?: string; + /** ISO timestamp when the task failed. */ + failedAt?: string; + /** Error message if the task failed. */ + error?: string; +} + +/** Persisted state for an entire run (sprint or parallel). */ +export interface RunState { + /** Unique run identifier. */ + runId: string; + /** Run type. */ + type: "sprint" | "parallel"; + /** Sprint/milestone/cycle name (undefined for parallel runs). */ + sprint?: string; + /** Git branch name for this run. */ + branch?: string; + /** ISO timestamp when the run started. */ + startedAt: string; + /** Tasks in this run. */ + tasks: RunTask[]; +} + +/** Aggregated statistics for a run. */ +export interface RunStats { + total: number; + done: number; + failed: number; + pending: number; + inProgress: number; +} + +// ─── Paths ────────────────────────────────────────────────────────────────── + +function getRunStateDir(projectRoot: string): string { + return join(projectRoot, ".locus", "run-state"); +} + +/** Slugify a sprint name for use as a filename. */ +export function sprintSlug(name: string): string { + return name + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, ""); +} + +/** + * Get the path to the run state file. + * - With sprintName: `.locus/run-state/.json` + * - Without: `.locus/run-state/_parallel.json` + */ +function getRunStatePath(projectRoot: string, sprintName?: string): string { + const dir = getRunStateDir(projectRoot); + if (sprintName) { + return join(dir, `${sprintSlug(sprintName)}.json`); + } + return join(dir, "_parallel.json"); +} + +// ─── Load / Save ──────────────────────────────────────────────────────────── + +/** + * Load run state for a specific sprint, or parallel state if no sprint given. + */ +export function loadRunState( + projectRoot: string, + sprintName?: string, +): RunState | null { + const path = getRunStatePath(projectRoot, sprintName); + if (!existsSync(path)) return null; + + try { + return JSON.parse(readFileSync(path, "utf-8")); + } catch { + return null; + } +} + +/** Save run state to disk. Path is derived from `state.sprint`. */ +export function saveRunState(projectRoot: string, state: RunState): void { + const path = getRunStatePath(projectRoot, state.sprint); + const dir = dirname(path); + + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + + writeFileSync(path, `${JSON.stringify(state, null, 2)}\n`, "utf-8"); +} + +/** + * Clear run state for a specific sprint, or parallel state if no sprint given. + */ +export function clearRunState( + projectRoot: string, + sprintName?: string, +): void { + const path = getRunStatePath(projectRoot, sprintName); + if (existsSync(path)) { + unlinkSync(path); + } +} + +// ─── State Creation ───────────────────────────────────────────────────────── + +/** Generate a unique run ID based on the current timestamp. */ +function generateRunId(): string { + return `run-${new Date().toISOString().slice(0, 19).replace(/[:.]/g, "-")}`; +} + +/** Create a new run state for a sprint. */ +export function createSprintRunState( + sprint: string, + branch: string, + tasks: { taskId: string; order: number }[], +): RunState { + return { + runId: generateRunId(), + type: "sprint", + sprint, + branch, + startedAt: new Date().toISOString(), + tasks: tasks.map(({ taskId, order }) => ({ + taskId, + order, + status: "pending" as const, + })), + }; +} + +/** Create a new run state for parallel execution. */ +export function createParallelRunState(taskIds: string[]): RunState { + return { + runId: generateRunId(), + type: "parallel", + startedAt: new Date().toISOString(), + tasks: taskIds.map((taskId, i) => ({ + taskId, + order: i + 1, + status: "pending" as const, + })), + }; +} + +// ─── State Mutations ──────────────────────────────────────────────────────── + +/** Mark a task as in-progress. */ +export function markTaskInProgress(state: RunState, taskId: string): void { + const task = state.tasks.find((t) => t.taskId === taskId); + if (task) { + task.status = "in_progress"; + } +} + +/** Mark a task as done with an optional PR number. */ +export function markTaskDone( + state: RunState, + taskId: string, + prNumber?: number, +): void { + const task = state.tasks.find((t) => t.taskId === taskId); + if (task) { + task.status = "done"; + task.completedAt = new Date().toISOString(); + if (prNumber) task.pr = prNumber; + } +} + +/** Mark a task as failed with an error message. */ +export function markTaskFailed( + state: RunState, + taskId: string, + error: string, +): void { + const task = state.tasks.find((t) => t.taskId === taskId); + if (task) { + task.status = "failed"; + task.failedAt = new Date().toISOString(); + task.error = error; + } +} + +// ─── Queries ──────────────────────────────────────────────────────────────── + +/** Get summary stats from a run state. */ +export function getRunStats(state: RunState): RunStats { + const tasks = state.tasks; + return { + total: tasks.length, + done: tasks.filter((t) => t.status === "done").length, + failed: tasks.filter((t) => t.status === "failed").length, + pending: tasks.filter((t) => t.status === "pending").length, + inProgress: tasks.filter((t) => t.status === "in_progress").length, + }; +} + +/** Get the next pending or failed task (for resume). */ +export function getNextTask(state: RunState): RunTask | null { + // First try to find a failed task (retry) + const failed = state.tasks.find((t) => t.status === "failed"); + if (failed) return failed; + + // Then find the next pending task + return state.tasks.find((t) => t.status === "pending") ?? null; +} diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 0d39f050..ac89a912 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -36,3 +36,27 @@ export type { ProviderSprint, TaskProvider, } from "./task-provider.js"; +// Execution Engine +export type { + RunState, + RunTask, + RunTaskStatus, + RunStats, + TaskResult, + ExecutionOptions, + RunResult, +} from "./execution/index.js"; +export { + loadRunState, + saveRunState, + clearRunState, + createSprintRunState, + createParallelRunState, + markTaskInProgress, + markTaskDone, + markTaskFailed, + getRunStats, + getNextTask, + sprintSlug, + executeTaskRun, +} from "./execution/index.js";