Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions src/Core.TypeScript/observe/kiro-executor-v2.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { describe, expect, test } from "bun:test";
import { portExecuteItem } from "./kiro-executor-v2";
import { simulatedWorkspacePort, emptySimulatedState } from "./workspace-port";
import type { BacklogItem } from "./observe";

const ITEM: BacklogItem = {
id: "081KTEST000000001",
title: "Test item for port executor",
ready: true,
ambiguous: false,
};

describe("kiro-executor-v2 (WorkspacePort-based, no bash/git)", () => {
test("creates a claim branch via port.branch", async () => {
const state = emptySimulatedState();
const port = simulatedWorkspacePort(state);
await portExecuteItem(port, ITEM, "alexa");
expect(state.branch).toContain("claim/");
expect(state.branch).toContain("alexa");
});

test("writes a claim file via port.writeFile", async () => {
const state = emptySimulatedState();
const port = simulatedWorkspacePort(state);
await portExecuteItem(port, ITEM, "alexa");
expect(port.exists("docs/claims/081ktest000000001.md")).toBe(true);
});

test("claim file contains item id and agent", async () => {
const state = emptySimulatedState();
const port = simulatedWorkspacePort(state);
await portExecuteItem(port, ITEM, "alexa");
const content = port.readFile("docs/claims/081ktest000000001.md");
expect(content.ok).toBe(true);
if (content.ok) {
expect(content.value).toContain("081KTEST000000001");
expect(content.value).toContain("alexa");
expect(content.value).toContain("in-progress");
}
});

test("commits via port.commit", async () => {
const state = emptySimulatedState();
const port = simulatedWorkspacePort(state);
await portExecuteItem(port, ITEM, "alexa");
expect(state.commits.length).toBeGreaterThan(0);
expect(state.commits[0]!.message).toContain("claim(alexa)");
});

test("pushes via port.push", async () => {
const state = emptySimulatedState();
const port = simulatedWorkspacePort(state);
await portExecuteItem(port, ITEM, "alexa");
expect(state.pushed.size).toBeGreaterThan(0);
});

test("returns ok with stdout on success", async () => {
const state = emptySimulatedState();
const port = simulatedWorkspacePort(state);
const result = await portExecuteItem(port, ITEM, "alexa");
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.stdout).toContain("Claimed 081KTEST000000001");
}
});

test("handles push failure gracefully (local commit still valid)", async () => {
const state = emptySimulatedState();
const port = simulatedWorkspacePort(state);
// Override push to fail (simulating offline / no remote)
(port as any).push = () => ({ ok: false, reason: "no remote" });

const result = await portExecuteItem(port, ITEM, "alexa");
expect(result.ok).toBe(true); // push failure is non-fatal
if (result.ok) {
expect(result.stdout).toContain("push failed");
expect(result.stdout).toContain("local commit exists");
}
});

test("full cycle: NO bash, NO git CLI, purely port operations", async () => {
const state = emptySimulatedState();
const port = simulatedWorkspacePort(state);

// Pre-seed a backlog item so findItemFile works
port.writeFile("docs/backlog/P1/081KTEST000000001-test.md",
"---\nzetaid: 081KTEST000000001\ntitle: Test\n---\n# Test item");

const result = await portExecuteItem(port, ITEM, "alexa");
expect(result.ok).toBe(true);
// The entire execution happened via port operations — no shell spawned
expect(state.branch).toContain("claim/");
expect(state.commits.length).toBe(1);
expect(port.exists("docs/claims/081ktest000000001.md")).toBe(true);
});
});
190 changes: 190 additions & 0 deletions src/Core.TypeScript/observe/kiro-executor-v2.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
/**
* src/Core.TypeScript/observe/kiro-executor-v2.ts — WorkspacePort-based executor.
*
* The executor that uses WorkspacePort instead of raw git/bash. This is the
* "eat our own cooking" version: all persistence goes through the port interface.
* Testable with the simulated port (no I/O). Production uses realWorkspacePort
* (which delegates to git underneath via the polyfill).
*
* NO raw git commands. NO bash scripts. NO child_process.spawnSync.
* Just typed WorkspacePort operations: branch, writeFile, stage, commit, push.
*
* Composes with:
* - src/Core.TypeScript/observe/workspace-port.ts (the DI-injectable interface)
* - src/Core.TypeScript/observe/do-item.ts (CommandExecutor / RunOutcome)
* - src/Core.TypeScript/observe/simulate-tick.ts (injects simulated port for testing)
*/

import type { CommandExecutor, RunSpec, RunOutcome, ExecutorTier } from "./do-item";
import type { BacklogItem } from "./observe";
import type { WorkspacePort } from "./workspace-port";

export interface PortExecutorOptions {
/** The workspace port (real or simulated). */
readonly port: WorkspacePort;
/** Agent identity for branch naming. */
readonly agentId?: string;
/** Max execution time in ms. */
readonly timeoutMs?: number;
}

/**
* Generate a claim branch name from a backlog item id + agent + date.
*/
function claimBranchName(item: BacklogItem, agentId: string): string {
const slug = item.id.toLowerCase().replace(/\./g, "-").slice(0, 20);
const date = new Date().toISOString().slice(0, 10);
return `claim/${slug}-${agentId}-${date}`;
}

/**
* Find and read a backlog item's file via the workspace port.
*/
function findItemFile(port: WorkspacePort, item: BacklogItem): string | null {
const priorities = ["P0", "P1", "P2", "P3"];
for (const p of priorities) {
const dirPath = `docs/backlog/${p}`;
const dirResult = port.readDir(dirPath);
if (!dirResult.ok) continue;

for (const entry of dirResult.value) {
if (!entry.endsWith(".md")) continue;
const filePath = `${dirPath}/${entry}`;
const content = port.readFile(filePath);
if (!content.ok) continue;
// Check if zetaid matches
const zetaMatch = content.value.match(/^zetaid:\s*(.+)$/m);
if (zetaMatch && zetaMatch[1]?.trim() === item.id) {
return content.value;
}
}
}
return null;
}

/**
* Execute a do_item via WorkspacePort operations.
* NO bash. NO git CLI. Just typed port operations.
*/
async function executeViaPort(
port: WorkspacePort,
item: BacklogItem,
agentId: string,
): Promise<RunOutcome> {
const branch = claimBranchName(item, agentId);

// 1. Pull latest. Pull can fail (no remote, offline) — continue anyway for
// local work; we intentionally do not branch on the result here.
void port.pull("origin", "main");

// 2. Create claim branch
const branchResult = port.branch(branch, "origin/main");
if (!branchResult.ok) {
// Fallback: branch from current HEAD
const fallback = port.branch(branch);
if (!fallback.ok) {
return { ok: false, reason: `branch failed: ${fallback.reason}`, exitCode: 1, stderr: fallback.reason };
}
}

// 3. Read the item for context
const itemContent = findItemFile(port, item);

// 4. Write a claim file (proof of execution)
const claimPath = `docs/claims/${item.id.toLowerCase().replace(/\./g, "-")}.md`;
const claimContent = [
"---",
`id: ${item.id}`,
`claimed_by: ${agentId}`,
`claimed_at: ${new Date().toISOString()}`,
`title: "${item.title}"`,
`status: in-progress`,
`branch: ${branch}`,
"---",
"",
`# Claim: ${item.id}`,
"",
`Claimed by the observe-inline executor (${agentId}).`,
`Work in progress on branch \`${branch}\`.`,
itemContent ? `\nItem has ${itemContent.split("\n").length} lines of context.` : "",
].join("\n");

const writeResult = port.writeFile(claimPath, claimContent);
if (!writeResult.ok) {
return { ok: false, reason: `writeFile failed: ${writeResult.reason}`, exitCode: 1, stderr: writeResult.reason };
}

// 5. Stage + commit
const stageResult = port.stage([claimPath]);
if (!stageResult.ok) {
return { ok: false, reason: `stage failed: ${stageResult.reason}`, exitCode: 1, stderr: stageResult.reason };
}

const commitMsg = `claim(${agentId}): ${item.id} — ${item.title.slice(0, 60)}\n\nObserve-inline executor.\n\nCo-Authored-By: Kiro <noreply@kiro.dev>`;
const commitResult = port.commit(commitMsg);
if (!commitResult.ok) {
return { ok: false, reason: `commit failed: ${commitResult.reason}`, exitCode: 1, stderr: commitResult.reason };
}

// 6. Push
const pushResult = port.push("origin", branch);
if (!pushResult.ok) {
// Push failure is non-fatal (offline work is valid)
return {
ok: true,
stdout: `Claimed ${item.id} on ${branch} (push failed: ${pushResult.reason} — local commit exists)`,
exitCode: 0,
};
}

return {
ok: true,
stdout: `Claimed ${item.id} on ${branch} (pushed to origin)`,
exitCode: 0,
};
}

/**
* The WorkspacePort-based executor. No bash, no raw git.
* Testable with simulatedWorkspacePort (no I/O, deterministic).
*/
export function portExecutor(options: PortExecutorOptions): CommandExecutor {
const agentId = options.agentId ?? process.env.ZETA_AGENT_ID ?? "alexa";

return {
tier: "just-bash" as ExecutorTier, // same tier label (compatible with do-item)

run: async (_spec: RunSpec): Promise<RunOutcome> => {
// The spec carries the item info in a structured way, but the new path
// bypasses RunSpec/bash entirely: callers pass the item directly via
// portExecuteItem. This RunSpec entry point is a compatibility shim that
// directs callers to the typed path; agentId is surfaced in stdout below.
return {
ok: true,
stdout: `port-executor (${agentId}): use portExecuteItem directly`,
exitCode: 0,
};
},
};
}

/**
* Execute a backlog item directly via the port (the preferred path).
* This bypasses RunSpec/bash entirely — pure WorkspacePort operations.
*/
export async function portExecuteItem(
port: WorkspacePort,
item: BacklogItem,
agentId: string = "alexa",
): Promise<RunOutcome> {
try {
return await executeViaPort(port, item, agentId);
} catch (err) {
return {
ok: false,
reason: `executor error: ${err instanceof Error ? err.message : String(err)}`,
exitCode: -1,
stderr: "",
};
}
}
Loading