From 616c56cea22dcf717caaf6c07e9d9a0ab567c080 Mon Sep 17 00:00:00 2001 From: Harsh Batheja <40922251+harsh-batheja@users.noreply.github.com> Date: Wed, 22 Apr 2026 22:18:39 +0530 Subject: [PATCH] fix(cli): derive projectId from prefixed issue id on spawn (#1330) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(cli): derive projectId from prefixed issue id on spawn When `ao spawn /` is used in a multi-project config, route the spawn to the prefixed project and strip the prefix from the issue id. Previously the projectId fell back to whichever project `ao start` was running for, tagging cross-project sessions with the wrong project (and session prefix). Applies to both `ao spawn` and `ao batch-spawn`; batch-spawn errors out if issues mix multiple project prefixes. Fixes #1329 * refactor(core,cli): lift spawn target resolution into core Move the issue-prefix → project routing from the CLI into a reusable core utility, `resolveSpawnTarget(projects, issueRef, fallback?)`. - Exposes routing to any spawn entry point (CLI, web API, programmatic). - Accepts either a project id or sessionPrefix as the prefix; project id wins on collision. - `ao batch-spawn` now groups issues by resolved project and preflights once per group instead of erroring on mixed prefixes. Tests: - 8 unit tests for `resolveSpawnTarget` in core. - CLI spawn.test.ts: adds a sessionPrefix routing test (23 tests total). Refs #1329 * test(cli): cover batch-spawn grouping; tighten resolveSpawnTarget API Self-review found three gaps: - `resolveSpawnTarget` accepted `undefined` issueRef and returned `{ issueId: "" }` with a fallback project. Dead path — both callers guard against undefined. Drop it and make issueRef required. - No tests for `batch-spawn`. Added two: - routes cross-project prefixed issues to the correct project and lists each project's sessions separately - skips a prefixed issue when the target project already has an active session for it - `spawn` and `batch-spawn` help text didn't document the `/` or `/` forms. * fix(core): guard resolveSpawnTarget against prototype-key matches Second review pass found a latent bug: Plain JS objects inherit `__proto__`, `constructor`, `toString`, `hasOwnProperty`, etc. from Object.prototype. The previous `if (projects[prefix])` check entered the routing branch for any of these keys, mis-routing a user typing `ao spawn __proto__/42` with `projectId: "__proto__"`. Downstream session-manager would then receive a junk project object (Object.prototype itself) and fail with a non-obvious error. Fix: use `Object.prototype.hasOwnProperty.call(projects, prefix)` so only actual configured project ids match. Also: - Document the case-sensitive matching semantic explicitly. - Lock in the batch-spawn grouping contract by asserting exact `list()` and `spawn()` call counts in the cross-project test. --- packages/cli/__tests__/commands/spawn.test.ts | 275 +++++++++++++++++- packages/cli/src/commands/spawn.ts | 195 ++++++++----- .../core/src/__tests__/spawn-target.test.ts | 89 ++++++ packages/core/src/index.ts | 2 + packages/core/src/spawn-target.ts | 48 +++ 5 files changed, 529 insertions(+), 80 deletions(-) create mode 100644 packages/core/src/__tests__/spawn-target.test.ts create mode 100644 packages/core/src/spawn-target.ts diff --git a/packages/cli/__tests__/commands/spawn.test.ts b/packages/cli/__tests__/commands/spawn.test.ts index 42a1930b3..bfca2d0bb 100644 --- a/packages/cli/__tests__/commands/spawn.test.ts +++ b/packages/cli/__tests__/commands/spawn.test.ts @@ -71,7 +71,7 @@ let cwdSpy: ReturnType | undefined; const STORAGE_KEY = "111111111113"; import { Command } from "commander"; -import { registerSpawn } from "../../src/commands/spawn.js"; +import { registerSpawn, registerBatchSpawn } from "../../src/commands/spawn.js"; let program: Command; let consoleSpy: ReturnType; @@ -245,6 +245,140 @@ describe("spawn command", () => { }); }); + it("routes a / identifier to the prefixed project", async () => { + // Multi-project config where AO is running for the default project + // but the issue belongs to a different project. + (mockConfigRef.current as Record).projects = { + "agent-orchestrator": { + name: "Agent Orchestrator", + repo: "org/agent-orchestrator", + path: join(tmpDir, "agent-orchestrator"), + defaultBranch: "main", + sessionPrefix: "ao", + }, + "x402-identity": { + name: "x402 Identity", + repo: "harsh-batheja/x402-identity", + path: join(tmpDir, "x402-identity"), + defaultBranch: "main", + sessionPrefix: "xid", + }, + }; + mkdirSync(join(tmpDir, "agent-orchestrator"), { recursive: true }); + mkdirSync(join(tmpDir, "x402-identity"), { recursive: true }); + + // The cwd shouldn't change the result — prefix takes priority. + cwdSpy = vi.spyOn(process, "cwd").mockReturnValue(join(tmpDir, "agent-orchestrator")); + mockGetRunning.mockResolvedValue({ + pid: 1234, + port: 3000, + startedAt: "", + projects: ["agent-orchestrator"], + }); + + const fakeSession: Session = { + id: "xid-1", + projectId: "x402-identity", + status: "spawning", + activity: null, + branch: "feat/issue-1", + issueId: "1", + pr: null, + workspacePath: "/tmp/wt", + runtimeHandle: { id: "hash-xid-1", runtimeName: "tmux", data: {} }, + agentInfo: null, + createdAt: new Date(), + lastActivityAt: new Date(), + metadata: {}, + }; + mockSessionManager.spawn.mockResolvedValue(fakeSession); + + await program.parseAsync(["node", "test", "spawn", "x402-identity/1"]); + + expect(mockSessionManager.spawn).toHaveBeenCalledWith({ + projectId: "x402-identity", + issueId: "1", + }); + }); + + it("routes via sessionPrefix when that matches instead of project id", async () => { + (mockConfigRef.current as Record).projects = { + "agent-orchestrator": { + name: "Agent Orchestrator", + repo: "org/agent-orchestrator", + path: join(tmpDir, "agent-orchestrator"), + defaultBranch: "main", + sessionPrefix: "ao", + }, + "x402-identity": { + name: "x402 Identity", + repo: "harsh-batheja/x402-identity", + path: join(tmpDir, "x402-identity"), + defaultBranch: "main", + sessionPrefix: "xid", + }, + }; + mkdirSync(join(tmpDir, "agent-orchestrator"), { recursive: true }); + mkdirSync(join(tmpDir, "x402-identity"), { recursive: true }); + cwdSpy = vi.spyOn(process, "cwd").mockReturnValue(join(tmpDir, "agent-orchestrator")); + mockGetRunning.mockResolvedValue({ + pid: 1234, + port: 3000, + startedAt: "", + projects: ["agent-orchestrator"], + }); + + const fakeSession: Session = { + id: "xid-2", + projectId: "x402-identity", + status: "spawning", + activity: null, + branch: "feat/issue-7", + issueId: "7", + pr: null, + workspacePath: "/tmp/wt", + runtimeHandle: { id: "hash-xid-2", runtimeName: "tmux", data: {} }, + agentInfo: null, + createdAt: new Date(), + lastActivityAt: new Date(), + metadata: {}, + }; + mockSessionManager.spawn.mockResolvedValue(fakeSession); + + await program.parseAsync(["node", "test", "spawn", "xid/7"]); + + expect(mockSessionManager.spawn).toHaveBeenCalledWith({ + projectId: "x402-identity", + issueId: "7", + }); + }); + + it("leaves the issueId untouched when the prefix is not a configured project", async () => { + const fakeSession: Session = { + id: "app-1", + projectId: "my-app", + status: "spawning", + activity: null, + branch: "feat/some-org-42", + issueId: "some-org/42", + pr: null, + workspacePath: "/tmp/wt", + runtimeHandle: { id: "hash-app-1", runtimeName: "tmux", data: {} }, + agentInfo: null, + createdAt: new Date(), + lastActivityAt: new Date(), + metadata: {}, + }; + mockSessionManager.spawn.mockResolvedValue(fakeSession); + + await program.parseAsync(["node", "test", "spawn", "some-org/42"]); + + expect(mockSessionManager.spawn).toHaveBeenCalledWith({ + projectId: "my-app", + issueId: "some-org/42", + }); + }); + it("spawns without issueId when none provided", async () => { const fakeSession: Session = { id: "app-1", @@ -746,3 +880,142 @@ describe("spawn pre-flight checks", () => { expect(errors).not.toContain("not authenticated"); }); }); + +describe("batch-spawn command", () => { + function setupBatch(): Command { + const cmd = new Command(); + cmd.exitOverride(); + registerBatchSpawn(cmd); + return cmd; + } + + function makeFakeSession(overrides: Partial & Pick): Session { + return { + status: "spawning", + activity: null, + branch: null, + issueId: null, + pr: null, + workspacePath: "/tmp/wt", + runtimeHandle: { id: `hash-${overrides.id}`, runtimeName: "tmux", data: {} }, + agentInfo: null, + createdAt: new Date(), + lastActivityAt: new Date(), + metadata: {}, + ...overrides, + } as Session; + } + + beforeEach(() => { + mockSessionManager.list.mockResolvedValue([]); + }); + + it("groups cross-project issues and routes each to the correct project", async () => { + (mockConfigRef.current as Record).projects = { + "agent-orchestrator": { + name: "Agent Orchestrator", + repo: "org/agent-orchestrator", + path: join(tmpDir, "agent-orchestrator"), + defaultBranch: "main", + sessionPrefix: "ao", + }, + "x402-identity": { + name: "x402 Identity", + repo: "harsh-batheja/x402-identity", + path: join(tmpDir, "x402-identity"), + defaultBranch: "main", + sessionPrefix: "xid", + }, + }; + mkdirSync(join(tmpDir, "agent-orchestrator"), { recursive: true }); + mkdirSync(join(tmpDir, "x402-identity"), { recursive: true }); + mockGetRunning.mockResolvedValue({ + pid: 1234, + port: 3000, + startedAt: "", + projects: ["agent-orchestrator", "x402-identity"], + }); + + mockSessionManager.spawn + .mockResolvedValueOnce(makeFakeSession({ id: "ao-1", projectId: "agent-orchestrator" })) + .mockResolvedValueOnce(makeFakeSession({ id: "xid-1", projectId: "x402-identity" })); + + const program = setupBatch(); + await program.parseAsync([ + "node", + "test", + "batch-spawn", + "agent-orchestrator/10", + "x402-identity/20", + ]); + + const spawnCalls = mockSessionManager.spawn.mock.calls.map((call) => call[0]); + expect(spawnCalls).toEqual( + expect.arrayContaining([ + { projectId: "agent-orchestrator", issueId: "10" }, + { projectId: "x402-identity", issueId: "20" }, + ]), + ); + expect(mockSessionManager.list).toHaveBeenCalledWith("agent-orchestrator"); + expect(mockSessionManager.list).toHaveBeenCalledWith("x402-identity"); + // Exactly one list() per project group — locks the grouping contract so a + // regression that lists every project for every issue is caught. + expect(mockSessionManager.list).toHaveBeenCalledTimes(2); + expect(mockSessionManager.spawn).toHaveBeenCalledTimes(2); + }); + + it("skips a prefixed issue that already has an active session in the target project", async () => { + (mockConfigRef.current as Record).projects = { + "agent-orchestrator": { + name: "Agent Orchestrator", + repo: "org/agent-orchestrator", + path: join(tmpDir, "agent-orchestrator"), + defaultBranch: "main", + sessionPrefix: "ao", + }, + "x402-identity": { + name: "x402 Identity", + repo: "harsh-batheja/x402-identity", + path: join(tmpDir, "x402-identity"), + defaultBranch: "main", + sessionPrefix: "xid", + }, + }; + mkdirSync(join(tmpDir, "agent-orchestrator"), { recursive: true }); + mkdirSync(join(tmpDir, "x402-identity"), { recursive: true }); + + // Pre-existing active session in x402-identity for issue 20 + mockSessionManager.list.mockImplementation(async (pid: string) => { + if (pid === "x402-identity") { + return [ + makeFakeSession({ + id: "xid-9", + projectId: "x402-identity", + status: "working", + issueId: "20", + }), + ]; + } + return []; + }); + + mockSessionManager.spawn.mockResolvedValueOnce( + makeFakeSession({ id: "ao-2", projectId: "agent-orchestrator" }), + ); + + const program = setupBatch(); + await program.parseAsync([ + "node", + "test", + "batch-spawn", + "agent-orchestrator/10", + "x402-identity/20", + ]); + + expect(mockSessionManager.spawn).toHaveBeenCalledTimes(1); + expect(mockSessionManager.spawn).toHaveBeenCalledWith({ + projectId: "agent-orchestrator", + issueId: "10", + }); + }); +}); diff --git a/packages/cli/src/commands/spawn.ts b/packages/cli/src/commands/spawn.ts index 7dcd999f7..6ee69b039 100644 --- a/packages/cli/src/commands/spawn.ts +++ b/packages/cli/src/commands/spawn.ts @@ -4,6 +4,7 @@ import type { Command } from "commander"; import { resolve } from "node:path"; import { loadConfig, + resolveSpawnTarget, TERMINAL_STATUSES, type OrchestratorConfig, } from "@aoagents/ao-core"; @@ -191,7 +192,10 @@ export function registerSpawn(program: Command): void { program .command("spawn") .description("Spawn a single agent session") - .argument("[first]", "Issue identifier (project is auto-detected)") + .argument( + "[first]", + "Issue identifier. Accepts bare ids (42, INT-100) or prefixed forms (x402-identity/42, xid/42) to target a specific project by id or sessionPrefix.", + ) .argument("[second]", "" /* hidden second arg to catch old two-arg usage */) .option("--open", "Open session in terminal tab") .option("--agent ", "Override the agent plugin (e.g. codex, claude-code)") @@ -228,12 +232,18 @@ export function registerSpawn(program: Command): void { let issueId: string | undefined; if (first) { - issueId = first; - try { - projectId = autoDetectProject(config); - } catch (err) { - console.error(chalk.red(err instanceof Error ? err.message : String(err))); - process.exit(1); + const prefixed = resolveSpawnTarget(config.projects, first); + if (prefixed) { + projectId = prefixed.projectId; + issueId = prefixed.issueId; + } else { + issueId = first; + try { + projectId = autoDetectProject(config); + } catch (err) { + console.error(chalk.red(err instanceof Error ? err.message : String(err))); + process.exit(1); + } } } else { // No args: auto-detect project, no issue @@ -272,97 +282,124 @@ export function registerBatchSpawn(program: Command): void { program .command("batch-spawn") .description("Spawn sessions for multiple issues with duplicate detection") - .argument("", "Issue identifiers (project is auto-detected)") + .argument( + "", + "Issue identifiers. Accepts bare ids or prefixed forms (x402-identity/42, xid/42); mixed projects are grouped automatically.", + ) .option("--open", "Open sessions in terminal tabs") .action(async (issues: string[], opts: { open?: boolean }) => { const config = loadConfig(); - let projectId: string; - try { - projectId = autoDetectProject(config); - } catch (err) { - console.error(chalk.red(err instanceof Error ? err.message : String(err))); - process.exit(1); + // Resolve each issue to its target project. Issues without a prefix fall + // back to auto-detection; prefixed issues route to the matched project. + let fallbackProjectId: string | null = null; + const needsFallback = issues.some( + (issue) => resolveSpawnTarget(config.projects, issue) === null, + ); + if (needsFallback) { + try { + fallbackProjectId = autoDetectProject(config); + } catch (err) { + console.error(chalk.red(err instanceof Error ? err.message : String(err))); + process.exit(1); + } } - if (!config.projects[projectId]) { - console.error( - chalk.red( - `Unknown project: ${projectId}\nAvailable: ${Object.keys(config.projects).join(", ")}`, - ), - ); - process.exit(1); + // Group issues by resolved project so each group preflights once. + const groups = new Map>(); + for (const issue of issues) { + const target = resolveSpawnTarget(config.projects, issue, fallbackProjectId ?? undefined); + if (!target) { + console.error(chalk.red(`Could not resolve project for issue: ${issue}`)); + process.exit(1); + } + if (!config.projects[target.projectId]) { + console.error( + chalk.red( + `Unknown project: ${target.projectId}\nAvailable: ${Object.keys(config.projects).join(", ")}`, + ), + ); + process.exit(1); + } + if (!groups.has(target.projectId)) groups.set(target.projectId, []); + groups.get(target.projectId)!.push({ original: issue, resolved: target.issueId }); } console.log(banner("BATCH SESSION SPAWNER")); console.log(); - console.log(` Project: ${chalk.bold(projectId)}`); - console.log(` Issues: ${issues.join(", ")}`); + for (const [pid, items] of groups) { + console.log( + ` ${chalk.bold(pid)}: ${items.map((i) => i.original).join(", ")}`, + ); + } console.log(); - // Pre-flight once before the loop so a missing prerequisite fails fast - try { - await runSpawnPreflight(config, projectId); - await warnIfAONotRunning(projectId); - } catch (err) { - console.error(chalk.red(`✗ ${err instanceof Error ? err.message : String(err)}`)); - process.exit(1); - } - - const sm = await getSessionManager(config); const created: Array<{ session: string; issue: string }> = []; const skipped: Array<{ issue: string; existing: string }> = []; const failed: Array<{ issue: string; error: string }> = []; - const spawnedIssues = new Set(); - // Load existing sessions once before the loop to avoid repeated reads + enrichment. - // Exclude terminal sessions so completed/merged sessions don't block respawning - // (e.g. when an issue is reopened after its PR was merged). - const existingSessions = await sm.list(projectId); - const existingIssueMap = new Map( - existingSessions - .filter((s) => s.issueId && !TERMINAL_STATUSES.has(s.status)) - .map((s) => [s.issueId!.toLowerCase(), s.id]), - ); - - for (const issue of issues) { - // Duplicate detection — check both existing sessions and same-run duplicates - if (spawnedIssues.has(issue.toLowerCase())) { - console.log(chalk.yellow(` Skip ${issue} — duplicate in this batch`)); - skipped.push({ issue, existing: "(this batch)" }); - continue; - } - - // Check existing sessions (pre-loaded before loop) - const existingSessionId = existingIssueMap.get(issue.toLowerCase()); - if (existingSessionId) { - console.log(chalk.yellow(` Skip ${issue} — already has session ${existingSessionId}`)); - skipped.push({ issue, existing: existingSessionId }); - continue; - } + const sm = await getSessionManager(config); + for (const [groupProjectId, items] of groups) { + // Pre-flight once per project group so a missing prerequisite fails fast. try { - const session = await sm.spawn({ projectId, issueId: issue }); - created.push({ session: session.id, issue }); - spawnedIssues.add(issue.toLowerCase()); - console.log(chalk.green(` Created ${session.id} for ${issue}`)); - - if (opts.open) { - try { - const tmuxTarget = session.runtimeHandle?.id ?? session.id; - await exec("open-iterm-tab", [tmuxTarget]); - } catch { - // best effort - } - } + await runSpawnPreflight(config, groupProjectId); + await warnIfAONotRunning(groupProjectId); } catch (err) { - failed.push({ - issue, - error: err instanceof Error ? err.message : String(err), - }); - console.log( - chalk.red(` Failed ${issue} — ${err instanceof Error ? err.message : String(err)}`), - ); + console.error(chalk.red(`✗ ${err instanceof Error ? err.message : String(err)}`)); + process.exit(1); + } + + // Load existing sessions once per group (exclude terminal sessions so + // merged/completed sessions don't block respawning a reopened issue). + const existingSessions = await sm.list(groupProjectId); + const existingIssueMap = new Map( + existingSessions + .filter((s) => s.issueId && !TERMINAL_STATUSES.has(s.status)) + .map((s) => [s.issueId!.toLowerCase(), s.id]), + ); + const spawnedIssues = new Set(); + + for (const { original, resolved } of items) { + if (spawnedIssues.has(resolved.toLowerCase())) { + console.log(chalk.yellow(` Skip ${original} — duplicate in this batch`)); + skipped.push({ issue: original, existing: "(this batch)" }); + continue; + } + const existingSessionId = existingIssueMap.get(resolved.toLowerCase()); + if (existingSessionId) { + console.log( + chalk.yellow(` Skip ${original} — already has session ${existingSessionId}`), + ); + skipped.push({ issue: original, existing: existingSessionId }); + continue; + } + + try { + const session = await sm.spawn({ projectId: groupProjectId, issueId: resolved }); + created.push({ session: session.id, issue: original }); + spawnedIssues.add(resolved.toLowerCase()); + console.log(chalk.green(` Created ${session.id} for ${original}`)); + + if (opts.open) { + try { + const tmuxTarget = session.runtimeHandle?.id ?? session.id; + await exec("open-iterm-tab", [tmuxTarget]); + } catch { + // best effort + } + } + } catch (err) { + failed.push({ + issue: original, + error: err instanceof Error ? err.message : String(err), + }); + console.log( + chalk.red( + ` Failed ${original} — ${err instanceof Error ? err.message : String(err)}`, + ), + ); + } } } diff --git a/packages/core/src/__tests__/spawn-target.test.ts b/packages/core/src/__tests__/spawn-target.test.ts new file mode 100644 index 000000000..ccc4614f1 --- /dev/null +++ b/packages/core/src/__tests__/spawn-target.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vitest"; +import { resolveSpawnTarget } from "../spawn-target.js"; +import type { ProjectConfig } from "../types.js"; + +const baseProject: Omit = { + defaultBranch: "main", +}; + +function makeProjects(): Record { + return { + "agent-orchestrator": { + ...baseProject, + name: "Agent Orchestrator", + path: "/tmp/ao", + sessionPrefix: "ao", + }, + "x402-identity": { + ...baseProject, + name: "x402 Identity", + path: "/tmp/xid", + sessionPrefix: "xid", + }, + }; +} + +describe("resolveSpawnTarget", () => { + it("routes to the project matched by id prefix", () => { + const target = resolveSpawnTarget(makeProjects(), "x402-identity/1"); + expect(target).toEqual({ projectId: "x402-identity", issueId: "1" }); + }); + + it("routes to the project matched by sessionPrefix", () => { + const target = resolveSpawnTarget(makeProjects(), "xid/42"); + expect(target).toEqual({ projectId: "x402-identity", issueId: "42" }); + }); + + it("prefers project-id match over sessionPrefix collision", () => { + const projects = makeProjects(); + // Give the other project a sessionPrefix that collides with the first project's id + projects["x402-identity"].sessionPrefix = "agent-orchestrator"; + const target = resolveSpawnTarget(projects, "agent-orchestrator/9"); + expect(target).toEqual({ projectId: "agent-orchestrator", issueId: "9" }); + }); + + it("falls back to the fallback project when the prefix doesn't match", () => { + const target = resolveSpawnTarget(makeProjects(), "some-org/42", "agent-orchestrator"); + expect(target).toEqual({ projectId: "agent-orchestrator", issueId: "some-org/42" }); + }); + + it("returns null without a fallback when nothing matches", () => { + expect(resolveSpawnTarget(makeProjects(), "some-org/42")).toBeNull(); + }); + + it("treats a bare issue id as plain identifier", () => { + const target = resolveSpawnTarget(makeProjects(), "INT-100", "x402-identity"); + expect(target).toEqual({ projectId: "x402-identity", issueId: "INT-100" }); + }); + + it("does not match inherited prototype keys", () => { + // Regular plain objects inherit `__proto__`, `constructor`, `toString`, etc. + // from Object.prototype — a truthy `projects[prefix]` check without hasOwn + // would mis-route these. + const projects = makeProjects(); + expect(resolveSpawnTarget(projects, "__proto__/42", "agent-orchestrator")).toEqual({ + projectId: "agent-orchestrator", + issueId: "__proto__/42", + }); + expect(resolveSpawnTarget(projects, "constructor/42", "agent-orchestrator")).toEqual({ + projectId: "agent-orchestrator", + issueId: "constructor/42", + }); + expect(resolveSpawnTarget(projects, "toString/42", "agent-orchestrator")).toEqual({ + projectId: "agent-orchestrator", + issueId: "toString/42", + }); + }); + + it("ignores leading-slash and trailing-slash inputs", () => { + expect(resolveSpawnTarget(makeProjects(), "/42", "agent-orchestrator")).toEqual({ + projectId: "agent-orchestrator", + issueId: "/42", + }); + expect(resolveSpawnTarget(makeProjects(), "x402-identity/", "agent-orchestrator")).toEqual({ + projectId: "agent-orchestrator", + issueId: "x402-identity/", + }); + }); + +}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 8281c181c..67891f5ee 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -156,6 +156,8 @@ export { writeWorkspaceOpenCodeAgentsMd, } from "./opencode-agents-md.js"; export { normalizeOrchestratorSessionStrategy } from "./orchestrator-session-strategy.js"; +export { resolveSpawnTarget } from "./spawn-target.js"; +export type { SpawnTarget } from "./spawn-target.js"; // Activity log — JSONL activity tracking for agents without native JSONL export { diff --git a/packages/core/src/spawn-target.ts b/packages/core/src/spawn-target.ts new file mode 100644 index 000000000..9fcee8248 --- /dev/null +++ b/packages/core/src/spawn-target.ts @@ -0,0 +1,48 @@ +import type { ProjectConfig } from "./types.js"; + +export interface SpawnTarget { + projectId: string; + issueId: string; +} + +/** + * Parse a possibly-prefixed issue reference into a `{ projectId, issueId }` pair. + * + * When the reference is of the form `/` and `` matches a + * configured project id or `sessionPrefix`, the spawn is routed to that project + * with `` as the issue id. Otherwise the reference is treated as a plain + * issue id and the fallback project is used. + * + * - Matching is case-sensitive — yaml keys and `sessionPrefix` values are + * compared literally. + * - Exact project-id match wins over `sessionPrefix` match (yaml keys are + * unique; `sessionPrefix` values are not guaranteed to be). + * - Returns `null` when no prefix routing matches and no fallback is provided. + * - Empty `` (trailing slash) is treated as no prefix — the full string + * is passed through as the issue id. + */ +export function resolveSpawnTarget( + projects: Record, + issueRef: string, + fallbackProjectId?: string, +): SpawnTarget | null { + const slashIdx = issueRef.indexOf("/"); + if (slashIdx > 0 && slashIdx < issueRef.length - 1) { + const prefix = issueRef.slice(0, slashIdx); + const rest = issueRef.slice(slashIdx + 1); + + // hasOwn guards against prototype keys (`__proto__`, `constructor`, …) + // incorrectly matching via inheritance from Object.prototype. + if (Object.prototype.hasOwnProperty.call(projects, prefix)) { + return { projectId: prefix, issueId: rest }; + } + for (const [pid, project] of Object.entries(projects)) { + if (project.sessionPrefix === prefix) { + return { projectId: pid, issueId: rest }; + } + } + } + + if (!fallbackProjectId) return null; + return { projectId: fallbackProjectId, issueId: issueRef }; +}