diff --git a/.changeset/multiworkspace-worktree-persistence.md b/.changeset/multiworkspace-worktree-persistence.md new file mode 100644 index 0000000000..2179e7a766 --- /dev/null +++ b/.changeset/multiworkspace-worktree-persistence.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Fix multiworkspace tasks failing to complete. `task.workspaceWorktrees` is now durably persisted (it previously had no SQLite column, so `fn_acquire_repo_worktree`'s write was dropped on every persist and `fn_task_done` always reported "acquired no sub-repo worktrees"). Concurrent workspace tasks no longer collide on the shared browse-root active-session path — each task gets a task-scoped session key, so a second workspace task no longer fails with "active-session path … is held by …". diff --git a/CONCEPTS.md b/CONCEPTS.md index 2afdad7e97..9b952ac8b2 100644 --- a/CONCEPTS.md +++ b/CONCEPTS.md @@ -96,6 +96,9 @@ The authoritative task lifecycle runtime. It resolves a Task to workflow IR, wal ### Engine Singleton Lock A per-machine mutual-exclusion guard ensuring only one fusion process runs the engine for a given project, combining a lockfile in the project's `.fusion/` directory with a per-project loopback socket. Failure to acquire it (`EngineAlreadyRunningError`) is **positive proof an engine is already running** for that project elsewhere on the machine — not an error to swallow and not "no engine." A process refused the lock keeps that as a fact: it reports the engine as available (so UI surfaces don't claim it's down) while reconciliation keeps retrying, so it takes over if the current owner exits. +### Active-session lease +A path-keyed, in-memory claim that a given worktree path is held by a specific Task's running session (executor, step, workflow-step, AI-merge, or a workspace sub-repo acquire/land). It serves two jobs at once: mutual exclusion (a second Task may not register a path already held by a different Task — the foreign-task guard) and liveness (self-healing treats a held path as proof the Task is actively running and must not be rebounded). The key is the path, so the registry is only as correct as the path chosen: a path uniquely owned by one Task gives real exclusivity, but a path shared across Tasks (e.g. a workspace's browse-only root) must be made Task-scoped before registration or the guard will reject every concurrent sibling. Re-registration by the same Task is idempotent; cleanup must unregister the exact key that was registered. + ### ACP Ask Path A one-turn read-only model ask routed through the ACP runtime rather than a CLI print mode. The runner accumulates streamed prose, may recover a trailing JSON object for structured seams, and treats abnormal ACP stop reasons as incomplete answers for validator use. diff --git a/docs/solutions/database-issues/task-field-silently-dropped-without-sqlite-column-mapping.md b/docs/solutions/database-issues/task-field-silently-dropped-without-sqlite-column-mapping.md new file mode 100644 index 0000000000..bbf425e56d --- /dev/null +++ b/docs/solutions/database-issues/task-field-silently-dropped-without-sqlite-column-mapping.md @@ -0,0 +1,72 @@ +--- +title: A Task field is silently dropped on persist unless it has a SQLite column + rowToTask mapping +date: 2026-06-24 +category: database-issues +module: core-task-store +problem_type: database_issue +component: database +symptoms: + - "fn_task_done on a workspace task fails: workspace task declares File Scope but acquired no sub-repo worktrees — cannot verify scope" + - "A field set via store.updateTask(...) is present on the returned task but undefined on the next store.getTask(...)" + - "task.json on disk never contains the field even though the update succeeded" +root_cause: incomplete_setup +resolution_type: code_fix +severity: high +tags: [task-store, sqlite, persistence, rowtotask, workspaceworktrees, silent-data-loss, workspace, multiworkspace] +related_components: [engine-executor, active-session-registry] +--- + +# A Task field is silently dropped on persist unless it has a SQLite column + rowToTask mapping + +## Problem +Adding a field to the `Task` TypeScript type and mutating it in `TaskStore.updateTask` is **not** enough to persist it. If the field has no matching SQLite column, no `defineTaskColumn` descriptor, and no `rowToTask` deserialization, the value is silently dropped on the very next read — with no error. This broke all multiworkspace task completion: `task.workspaceWorktrees` (the per-sub-repo worktree map written by `fn_acquire_repo_worktree`) was such a phantom field. + +## Symptoms +- `fn_task_done` on a workspace task always blocked with: *"workspace task declares File Scope but acquired no sub-repo worktrees — cannot verify scope"* (`executor.ts` scope verifier reading `task.workspaceWorktrees ?? {}` → `{}`). +- A peer workspace task separately failed with *"active-session path … is held by task … may not overwrite it"* (a second, independent bug fixed alongside — see Related). +- Ground truth: the live failing tasks' `task.json` files contained **zero** occurrences of `workspaceWorktrees`, even though the agent logs showed the sub-repo worktree was acquired and the acquire tool returned its path. + +## What Didn't Work +- Treating it as a race / stale-read between the acquire write and the `fn_task_done` read. The field was not racing — it was never persisted at all, so no retry or ordering change would help. +- Inspecting only the executor/scope-verifier side. The verifier read the field correctly; the value was already gone before it ran. The bug was one layer down in the store. + +## Solution +Persist the field by mirroring an existing JSON-object column (`mergeDetails` is the canonical example). All of these are required — adding only some leaves the field still broken: + +1. **SCHEMA_SQL** — add the column to `CREATE TABLE tasks` in `db.ts` (this feeds `getSchemaCompatibilityTableSchemas()`, so existing DBs get backfilled by `ensureSchemaCompatibility()` at boot). +2. **Versioned migration** — `addColumnIfMissing("tasks", "", "TEXT")` in a new `if (version < N)` block, and bump `SCHEMA_VERSION` to `N`. +3. **db-migrate.ts** — add the column to the legacy `task.json → SQLite` rebuild INSERT (column list, one `?`, and the `toJsonNullable(task.)` value). Keep column/placeholder/arg counts equal. +4. **store.ts descriptor** — `defineTaskColumn("", (task) => toJsonNullable(task.))`. This is what `getChangedTaskColumns` uses to detect the field changed and emit it in the UPDATE. +5. **store.ts TaskRow** — add `: string | null;` to the `TaskRow` interface. +6. **store.ts rowToTask** — deserialize: `: fromJson<...>(row.)`. + +```ts +// store.ts — descriptor (drives both the write AND change-detection) +defineTaskColumn("workspaceWorktrees", (task) => toJsonNullable(task.workspaceWorktrees)), + +// store.ts — rowToTask (the read side that was missing → undefined on every getTask) +workspaceWorktrees: (() => { + const w = fromJson(row.workspaceWorktrees); + return w && Object.keys(w).length > 0 ? w : undefined; +})(), +``` + +## Why This Works +The trap is in the persist path. `TaskStore.updateTask` mutates the in-memory task, but `applyTaskPatch` writes **`result.current`** to `task.json` — and `result.current` comes from `readTaskFromDb()` → `rowToTask()`, i.e. a fresh round-trip *through SQLite*. A field with no column never makes it into the row, so `rowToTask` reconstructs the task **without** it, and that stripped object is what gets written back to `task.json`. The in-memory mutation is overwritten by the DB's view on the same call. Every later `getTask` reads from SQLite and returns `undefined`. SQLite — not `task.json` — is the source of truth; `task.json` is a debug mirror that is itself rebuilt from the DB round-trip. + +## Prevention +- When adding a persisted `Task` field, treat the six edit sites above as one atomic change. The `Task` type compiling is **not** evidence the field persists — TypeScript never sees the SQLite layer. +- Always write a round-trip regression test that asserts the field survives `getTask`, `listTasks`, **and** a full store reopen (`reopenDiskBackedStore` in `store-test-helpers.ts`). An in-memory-only assertion would pass even with the bug, because the bug lives in the SQLite round-trip: + +```ts +const updated = await store.updateTask(id, { workspaceWorktrees: map }); +expect(updated.workspaceWorktrees).toEqual(map); // passes even when broken +const detail = await store.getTask(id); +expect(detail.workspaceWorktrees).toEqual(map); // FAILS when broken — the real check +``` + +- The `architecture-schema-compat` test enforces that fresh-from-SCHEMA_SQL and migrated DBs converge, so the SCHEMA_SQL column and the migration must both be added (see Related). + +## Related Issues +- `docs/solutions/database-issues/schema-version-constant-must-equal-highest-migration.md` — the companion rule for the `SCHEMA_VERSION` bump that accompanies any new migration. +- Same fix (PR #1747) also resolved a second multiworkspace bug: concurrent workspace tasks collided on the shared browse-only workspace root in the path-keyed `activeSessionRegistry` (every task registered `this.rootDir` as its executor session, so the foreign-task guard rejected the second). Fixed by giving each workspace task a task-scoped synthetic session key (`sessionRegistryPath` in `executor.ts`), applied symmetrically at all register/unregister sites. diff --git a/packages/cli/src/project-resolver.ts b/packages/cli/src/project-resolver.ts index 044a718867..cd171d135a 100644 --- a/packages/cli/src/project-resolver.ts +++ b/packages/cli/src/project-resolver.ts @@ -680,14 +680,17 @@ export async function registerProjectInteractive( // Initialize the project (create .fusion/) const { TaskStore } = await import("@fusion/core"); const store = new TaskStore(absPath); - await store.init(); - if (detectedSubRepos) { - await saveWorkspaceConfig(absPath, { repos: detectedSubRepos }); - // Persist workspaceMode in config.json so it's visible/toggleable in the dashboard - await store.updateSettings({ workspaceMode: true }); + try { + await store.init(); + if (detectedSubRepos) { + await saveWorkspaceConfig(absPath, { repos: detectedSubRepos }); + // Persist workspaceMode in config.json so it's visible/toggleable in the dashboard + await store.updateSettings({ workspaceMode: true }); + } + console.log(` ✓ Initialized fn at ${absPath}`); + } finally { + await store.close(); } - await store.close(); - console.log(` ✓ Initialized fn at ${absPath}`); } else { throw new ProjectResolutionError( "Cannot register project without .fusion/ directory. Run `fn init` first.", @@ -747,29 +750,32 @@ export async function registerProjectInteractive( /* FNXC:Onboarding 2026-06-24-18:00: - After registration, prompt the user to confirm a task prefix and default workflow. - The prefix defaults to the first 2-4 chars of the project name so each project gets - recognizable task IDs (e.g., "MYPR" for "my-project"). The workflow defaults to coding. - Both are persisted to config.json via the TaskStore. + After registration, set a task prefix and default workflow. The prefix defaults to + the first 2-4 chars of the project name so each project gets recognizable task IDs + (e.g., "MYPR" for "my-project"). The workflow defaults to coding. Both are persisted + to config.json via the TaskStore. */ - if (interactive) { + { const { TaskStore } = await import("@fusion/core"); const store = new TaskStore(absPath); - await store.init(); - - const suggestedPrefix = suggestTaskPrefix(name); - const rl = createInterface({ input: process.stdin, output: process.stdout }); - const prefixInput = await rl.question(`\n Task prefix [${suggestedPrefix}]: `); - rl.close(); - const rawPrefix = prefixInput.trim().toUpperCase().replace(/[^A-Z]/g, ""); - const prefix = rawPrefix.length >= 2 && rawPrefix.length <= 5 ? rawPrefix : suggestedPrefix; - - await store.updateSettings({ - taskPrefix: prefix, - defaultWorkflowId: "builtin:coding", - }); - await store.close(); - console.log(` ✓ Task prefix set to "${prefix}", default workflow: coding`); + try { + await store.init(); + let prefix = suggestTaskPrefix(name); + if (interactive) { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + const prefixInput = await rl.question(`\n Task prefix [${prefix}]: `); + rl.close(); + const rawPrefix = prefixInput.trim().toUpperCase().replace(/[^A-Z]/g, ""); + if (rawPrefix.length >= 1 && rawPrefix.length <= 5) prefix = rawPrefix; + } + await store.updateSettings({ + taskPrefix: prefix, + defaultWorkflowId: "builtin:coding", + }); + console.log(` ✓ Task prefix set to "${prefix}", default workflow: coding`); + } finally { + await store.close(); + } } return createResolvedProject(project); diff --git a/packages/core/src/__tests__/store-persistence.test.ts b/packages/core/src/__tests__/store-persistence.test.ts index 54f4670338..c54be7ee39 100644 --- a/packages/core/src/__tests__/store-persistence.test.ts +++ b/packages/core/src/__tests__/store-persistence.test.ts @@ -59,6 +59,78 @@ describe("TaskStore", () => { }); }); + // FNXC:Workspace 2026-06-24-15:30 (multiworkspace fn_task_done regression): + // task.workspaceWorktrees previously had NO SQLite column / no rowToTask mapping, so + // fn_acquire_repo_worktree's updateTask({workspaceWorktrees}) set it only in memory and the very + // next getTask (SQLite round-trip) dropped it. fn_task_done's scope verifier then read `{}` and + // refused with "acquired no sub-repo worktrees", and every isWorkspaceTask() consumer misfired. + // The invariant: the per-sub-repo worktree map survives write→read across ALL surfaces — + // getTask, listTasks, AND a full store reopen (SQLite + task.json + reconcile). + describe("workspaceWorktrees persistence", () => { + const sampleMap = { + swarmclaw: { worktreePath: "/ws/swarmclaw/.worktrees/ivory-raven", branch: "fusion/mult-002", baseCommitSha: "a327402" }, + OpenVide: { worktreePath: "/ws/OpenVide/.worktrees/light-ember", branch: "fusion/mult-001" }, + }; + + it("round-trips the per-sub-repo worktree map through write and getTask", async () => { + const task = await harness.store().createTask({ description: "Workspace task" }); + + const updated = await harness.store().updateTask(task.id, { workspaceWorktrees: sampleMap }); + expect(updated.workspaceWorktrees).toEqual(sampleMap); + + // The smoking-gun assertion: getTask reads back from SQLite (rowToTask), not the in-memory + // mutation. Before the fix this returned undefined because no column persisted the map. + const detail = await harness.store().getTask(task.id); + expect(detail.workspaceWorktrees).toEqual(sampleMap); + }); + + it("returns the map from listTasks", async () => { + const task = await harness.store().createTask({ description: "Workspace task in list" }); + await harness.store().updateTask(task.id, { workspaceWorktrees: sampleMap }); + + const listed = (await harness.store().listTasks()).find((t) => t.id === task.id); + expect(listed?.workspaceWorktrees).toEqual(sampleMap); + }); + + it("survives a full store reopen (SQLite + task.json + reconcile)", async () => { + const task = await harness.store().createTask({ description: "Workspace task across reopen" }); + await harness.store().updateTask(task.id, { workspaceWorktrees: sampleMap }); + + await harness.reopenDiskBackedStore(); + + const detail = await harness.store().getTask(task.id); + expect(detail.workspaceWorktrees).toEqual(sampleMap); + }); + + // Surface enumeration (PR #1747 review): rowToTask reads row.workspaceWorktrees, but the + // explicit slim and activity-log-limited SELECT lists are separate from `*` — if the column is + // omitted there, slim/limited reads silently drop the field even though getTask("*") works. + it("survives the activity-log-limited read (explicit limited SELECT clause)", async () => { + const task = await harness.store().createTask({ description: "Workspace task limited read" }); + await harness.store().updateTask(task.id, { workspaceWorktrees: sampleMap }); + + const detail = await harness.store().getTask(task.id, { activityLogLimit: 1 }); + expect(detail.workspaceWorktrees).toEqual(sampleMap); + }); + + it("survives the slim search read (explicit slim SELECT clause)", async () => { + const task = await harness.store().createTask({ description: "Workspace slimsearchmarker task" }); + await harness.store().updateTask(task.id, { workspaceWorktrees: sampleMap }); + + const found = (await harness.store().searchTasks("slimsearchmarker", { slim: true })).find((t) => t.id === task.id); + expect(found?.workspaceWorktrees).toEqual(sampleMap); + }); + + it("normalizes an empty map to undefined so isWorkspaceTask stays false", async () => { + const task = await harness.store().createTask({ description: "Empty workspace map" }); + const updated = await harness.store().updateTask(task.id, { workspaceWorktrees: {} }); + expect(updated.workspaceWorktrees ?? {}).toEqual({}); + + const detail = await harness.store().getTask(task.id); + expect(detail.workspaceWorktrees).toBeUndefined(); + }); + }); + describe("tokenUsage persistence", () => { it("round-trips per-model token buckets through write and read", async () => { const task = await harness.store().createTask({ description: "Per-model token task" }); diff --git a/packages/core/src/db-migrate.ts b/packages/core/src/db-migrate.ts index 30d10cab8e..6322a87e22 100644 --- a/packages/core/src/db-migrate.ts +++ b/packages/core/src/db-migrate.ts @@ -226,10 +226,11 @@ async function migrateTasks(fusionDir: string, db: Database): Promise { columnMovedAt, dependencies, steps, log, attachments, steeringComments, comments, workflowStepResults, prInfo, issueInfo, sourceIssueProvider, sourceIssueRepository, sourceIssueExternalIssueId, sourceIssueNumber, sourceIssueUrl, sourceIssueClosedAt, - mergeDetails, breakIntoSubtasks, noCommitsExpected, enabledWorkflowSteps, modifiedFiles, sliceId + mergeDetails, breakIntoSubtasks, noCommitsExpected, enabledWorkflowSteps, modifiedFiles, sliceId, + workspaceWorktrees ) VALUES ( ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, - ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ) `); @@ -300,6 +301,9 @@ async function migrateTasks(fusionDir: string, db: Database): Promise { toJson(task.enabledWorkflowSteps || []), toJson(task.modifiedFiles || []), task.sliceId ?? null, + // FNXC:Workspace 2026-06-24-15:30: carry the per-sub-repo worktree map through the legacy + // task.json→SQLite rebuild so a workspace task migrated from disk keeps its acquired worktrees. + toJsonNullable(task.workspaceWorktrees), ); migrated++; } catch (err) { diff --git a/packages/core/src/db.ts b/packages/core/src/db.ts index 2ef6ca39b4..f84d0c1e59 100644 --- a/packages/core/src/db.ts +++ b/packages/core/src/db.ts @@ -162,7 +162,7 @@ export function isFts5CorruptionError(error: unknown): boolean { // ── Schema Definition ──────────────────────────────────────────────── -const SCHEMA_VERSION = 128; +const SCHEMA_VERSION = 129; const TASKS_FTS_AUTOMERGE = 8; const TASKS_FTS_CRISISMERGE = 16; @@ -347,7 +347,11 @@ CREATE TABLE IF NOT EXISTS tasks ( deletedAt TEXT, allowResurrection INTEGER DEFAULT 0, transitionPending TEXT, - customFields TEXT DEFAULT '{}' + customFields TEXT DEFAULT '{}', + -- FNXC:Workspace 2026-06-24-15:30: per-sub-repo worktree map (JSON) for workspace-mode tasks. + -- Source of truth for getSchemaCompatibilityTableSchemas(), so existing DBs are backfilled by + -- ensureSchemaCompatibility() at boot and fresh DBs get it here. See store.ts TaskRow note. + workspaceWorktrees TEXT ); -- Config table (single row with project settings) @@ -5289,6 +5293,16 @@ export class Database { }); } + if (version < 129) { + // FNXC:Workspace 2026-06-24-15:30: add the workspaceWorktrees column so workspace-mode tasks + // can durably persist their per-sub-repo worktree map. Backfill is also covered by + // ensureSchemaCompatibility() (SCHEMA_SQL is its source of truth); this versioned migration keeps + // migrated and fresh-from-SCHEMA_SQL DBs converged. + this.applyMigration(129, () => { + this.addColumnIfMissing("tasks", "workspaceWorktrees", "TEXT"); + }); + } + } /** diff --git a/packages/core/src/distributed-task-id.ts b/packages/core/src/distributed-task-id.ts index 04ba930b00..98a5aed311 100644 --- a/packages/core/src/distributed-task-id.ts +++ b/packages/core/src/distributed-task-id.ts @@ -77,16 +77,16 @@ function getConfiguredPrefixAndLegacyNextId(db: Database): { prefix: string; nex .prepare("SELECT nextId, settings FROM config WHERE id = 1") .get() as { nextId: number | null; settings: string | null } | undefined; if (!row) { - return { prefix: "KB", nextId: null }; + return { prefix: "FN", nextId: null }; } const settings = row.settings ? (JSON.parse(row.settings) as { taskPrefix?: string }) : null; return { - prefix: (settings?.taskPrefix ?? "KB").trim().toUpperCase(), + prefix: (settings?.taskPrefix ?? "FN").trim().toUpperCase(), nextId: typeof row.nextId === "number" ? row.nextId : null, }; } catch { - return { prefix: "KB", nextId: null }; + return { prefix: "FN", nextId: null }; } } diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index b5e85e2411..64d26fca25 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -277,6 +277,13 @@ interface TaskRow { sourceIssueUrl: string | null; sourceIssueClosedAt: string | null; mergeDetails: string | null; + // FNXC:Workspace 2026-06-24-15:30 (FN-multiworkspace persistence): the per-sub-repo worktree + // map MUST have its own SQLite column. Before this it was a Task field with NO column/rowToTask + // mapping, so updateTask set it only in-memory and applyTaskPatch wrote the DB-round-tripped + // task (without it) back to task.json — the map was silently dropped on every persist. That made + // fn_task_done's scope verifier always read `{}` ("acquired no sub-repo worktrees") and broke + // every isWorkspaceTask() consumer. Stored as JSON text, same shape as mergeDetails. + workspaceWorktrees: string | null; breakIntoSubtasks: number | null; noCommitsExpected: number | null; enabledWorkflowSteps: string | null; @@ -429,6 +436,10 @@ const TASK_COLUMN_DESCRIPTORS: TaskColumnDescriptor[] = [ defineTaskColumn("sourceIssueUrl", (task) => task.sourceIssue?.url ?? null), defineTaskColumn("sourceIssueClosedAt", (task) => task.sourceIssue?.closedAt ?? null), defineTaskColumn("mergeDetails", (task) => toJsonNullable(task.mergeDetails)), + // FNXC:Workspace 2026-06-24-15:30: persist the per-sub-repo worktree map so fn_acquire_repo_worktree's + // write survives the SQLite round-trip getChangedTaskColumns/rowToTask use. Without this descriptor the + // column diff never sees a change and the field never reaches the DB. + defineTaskColumn("workspaceWorktrees", (task) => toJsonNullable(task.workspaceWorktrees)), defineTaskColumn("breakIntoSubtasks", (task) => task.breakIntoSubtasks ? 1 : 0), defineTaskColumn("noCommitsExpected", (task) => task.noCommitsExpected ? 1 : 0), defineTaskColumn("enabledWorkflowSteps", (task) => toJson(task.enabledWorkflowSteps || [])), @@ -2150,6 +2161,13 @@ export class TaskStore extends EventEmitter { }; })(), mergeDetails: fromJson(row.mergeDetails), + // FNXC:Workspace 2026-06-24-15:30: deserialize the per-sub-repo worktree map. An empty/null map + // normalizes to undefined so isWorkspaceTask() (keys-length>0) and the scope verifier behave the + // same as a task that never acquired a sub-repo. + workspaceWorktrees: (() => { + const w = fromJson(row.workspaceWorktrees); + return w && Object.keys(w).length > 0 ? w : undefined; + })(), breakIntoSubtasks: row.breakIntoSubtasks ? true : undefined, noCommitsExpected: row.noCommitsExpected ? true : undefined, enabledWorkflowSteps: (() => { const e = fromJson(row.enabledWorkflowSteps); return e && e.length > 0 ? e : undefined; })(), @@ -2589,7 +2607,7 @@ export class TaskStore extends EventEmitter { "tokenUsageInputTokens", "tokenUsageOutputTokens", "tokenUsageCachedTokens", "tokenUsageCacheWriteTokens", "tokenUsageTotalTokens", "tokenUsageFirstUsedAt", "tokenUsageLastUsedAt", "tokenUsageModelProvider", "tokenUsageModelId", "tokenUsagePerModel", "tokenBudgetSoftAlertedAt", "tokenBudgetHardAlertedAt", "tokenBudgetOverride", "createdAt", "updatedAt", "columnMovedAt", "firstExecutionAt", "cumulativeActiveMs", "executionStartedAt", "executionCompletedAt", "dependencies", "steps", "customFields", "comments", "review", "reviewState", "workflowStepResults", "steeringComments", - "attachments", "prInfo", "prInfos", "issueInfo", "githubTracking", "sourceIssueProvider", "sourceIssueRepository", "sourceIssueExternalIssueId", "sourceIssueNumber", "sourceIssueUrl", "sourceIssueClosedAt", "mergeDetails", + "attachments", "prInfo", "prInfos", "issueInfo", "githubTracking", "sourceIssueProvider", "sourceIssueRepository", "sourceIssueExternalIssueId", "sourceIssueNumber", "sourceIssueUrl", "sourceIssueClosedAt", "mergeDetails", "workspaceWorktrees", "breakIntoSubtasks", "noCommitsExpected", "enabledWorkflowSteps", "modifiedFiles", "missionId", "sliceId", "scopeOverride", "scopeOverrideReason", "scopeAutoWiden", "assignedAgentId", "pausedByAgentId", "assigneeUserId", "nodeId", "effectiveNodeId", "effectiveNodeSource", "sourceType", "sourceAgentId", "sourceRunId", "sourceSessionId", "sourceMessageId", "sourceParentTaskId", "sourceMetadata", @@ -2638,7 +2656,7 @@ export class TaskStore extends EventEmitter { "tokenUsageInputTokens", "tokenUsageOutputTokens", "tokenUsageCachedTokens", "tokenUsageCacheWriteTokens", "tokenUsageTotalTokens", "tokenUsageFirstUsedAt", "tokenUsageLastUsedAt", "tokenUsageModelProvider", "tokenUsageModelId", "tokenUsagePerModel", "tokenBudgetSoftAlertedAt", "tokenBudgetHardAlertedAt", "tokenBudgetOverride", "createdAt", "updatedAt", "columnMovedAt", "firstExecutionAt", "cumulativeActiveMs", "executionStartedAt", "executionCompletedAt", "dependencies", "steps", "customFields", "attachments", "steeringComments", - "comments", "review", "reviewState", "workflowStepResults", "prInfo", "prInfos", "issueInfo", "githubTracking", "sourceIssueProvider", "sourceIssueRepository", "sourceIssueExternalIssueId", "sourceIssueNumber", "sourceIssueUrl", "sourceIssueClosedAt", "mergeDetails", + "comments", "review", "reviewState", "workflowStepResults", "prInfo", "prInfos", "issueInfo", "githubTracking", "sourceIssueProvider", "sourceIssueRepository", "sourceIssueExternalIssueId", "sourceIssueNumber", "sourceIssueUrl", "sourceIssueClosedAt", "mergeDetails", "workspaceWorktrees", "breakIntoSubtasks", "noCommitsExpected", "enabledWorkflowSteps", "modifiedFiles", "missionId", "sliceId", "scopeOverride", "scopeOverrideReason", "scopeAutoWiden", "assignedAgentId", "pausedByAgentId", "assigneeUserId", "nodeId", "effectiveNodeId", "effectiveNodeSource", "sourceType", "sourceAgentId", "sourceRunId", "sourceSessionId", "sourceMessageId", "sourceParentTaskId", "sourceMetadata", diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index b4dfa9d33a..08d78d6d76 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -2502,8 +2502,8 @@ export interface GitRemote { } /** Fetch GitHub remotes from the current git repository */ -export function fetchGitRemotes(projectId?: string): Promise { - return api(withProjectId("/git/remotes", projectId)); +export function fetchGitRemotes(projectId?: string, repoPath?: string): Promise { + return api(withRepoPath(withProjectId("/git/remotes", projectId), repoPath)); } /** Detailed git remote info with fetch and push URLs */ @@ -2514,36 +2514,36 @@ export interface GitRemoteDetailed { } /** Fetch all git remotes with their fetch and push URLs */ -export function fetchGitRemotesDetailed(projectId?: string): Promise { - return api(withProjectId("/git/remotes/detailed", projectId)); +export function fetchGitRemotesDetailed(projectId?: string, repoPath?: string): Promise { + return api(withRepoPath(withProjectId("/git/remotes/detailed", projectId), repoPath)); } /** Add a new git remote */ -export function addGitRemote(name: string, url: string, projectId?: string): Promise { - return api(withProjectId("/git/remotes", projectId), { +export function addGitRemote(name: string, url: string, projectId?: string, repoPath?: string): Promise { + return api(withRepoPath(withProjectId("/git/remotes", projectId), repoPath), { method: "POST", body: JSON.stringify({ name, url }), }); } /** Remove a git remote */ -export function removeGitRemote(name: string, projectId?: string): Promise { - return api(withProjectId(`/git/remotes/${encodeURIComponent(name)}`, projectId), { +export function removeGitRemote(name: string, projectId?: string, repoPath?: string): Promise { + return api(withRepoPath(withProjectId(`/git/remotes/${encodeURIComponent(name)}`, projectId), repoPath), { method: "DELETE", }); } /** Rename a git remote */ -export function renameGitRemote(name: string, newName: string, projectId?: string): Promise { - return api(withProjectId(`/git/remotes/${encodeURIComponent(name)}`, projectId), { +export function renameGitRemote(name: string, newName: string, projectId?: string, repoPath?: string): Promise { + return api(withRepoPath(withProjectId(`/git/remotes/${encodeURIComponent(name)}`, projectId), repoPath), { method: "PATCH", body: JSON.stringify({ newName }), }); } /** Update the URL for a git remote */ -export function updateGitRemoteUrl(name: string, url: string, projectId?: string): Promise { - return api(withProjectId(`/git/remotes/${encodeURIComponent(name)}/url`, projectId), { +export function updateGitRemoteUrl(name: string, url: string, projectId?: string, repoPath?: string): Promise { + return api(withRepoPath(withProjectId(`/git/remotes/${encodeURIComponent(name)}/url`, projectId), repoPath), { method: "PUT", body: JSON.stringify({ url }), }); @@ -3041,104 +3041,111 @@ export interface GitPushResult { * resolution, ahead/behind vs both local and origin integration tip, dirty * breakdown, stash count, index-stale detection, and recent merge-advance * audit events for the project-root worktree. */ -export function fetchGitStatus(projectId?: string, opts?: { extended?: boolean }): Promise { - const base = withProjectId("/git/status", projectId); +export function fetchGitStatus(projectId?: string, opts?: { extended?: boolean }, repoPath?: string): Promise { + const base = withRepoPath(withProjectId("/git/status", projectId), repoPath); if (!opts?.extended) return api(base); const sep = base.includes("?") ? "&" : "?"; return api(`${base}${sep}extended=1`); } /** Fetch recent commits */ -export function fetchGitCommits(limit?: number, projectId?: string): Promise { +export function fetchGitCommits(limit?: number, projectId?: string, repoPath?: string): Promise { const query = limit ? `?limit=${limit}` : ""; - return api(withProjectId(`/git/commits${query}`, projectId)); + return api(withRepoPath(withProjectId(`/git/commits${query}`, projectId), repoPath)); } /** Fetch diff for a specific commit */ -export function fetchCommitDiff(hash: string, projectId?: string): Promise<{ stat: string; patch: string }> { - return api<{ stat: string; patch: string }>(withProjectId(`/git/commits/${hash}/diff`, projectId)); +export function fetchCommitDiff(hash: string, projectId?: string, repoPath?: string): Promise<{ stat: string; patch: string }> { + return api<{ stat: string; patch: string }>(withRepoPath(withProjectId(`/git/commits/${hash}/diff`, projectId), repoPath)); } /** Fetch local commits ahead of the upstream tracking branch (commits to push) */ -export function fetchAheadCommits(projectId?: string): Promise { - return api(withProjectId("/git/commits/ahead", projectId)); +export function fetchAheadCommits(projectId?: string, repoPath?: string): Promise { + return api(withRepoPath(withProjectId("/git/commits/ahead", projectId), repoPath)); } /** Fetch recent commits for a specific remote */ -export function fetchRemoteCommits(remote: string, ref?: string, limit?: number, projectId?: string): Promise { +export function fetchRemoteCommits(remote: string, ref?: string, limit?: number, projectId?: string, repoPath?: string): Promise { const params = new URLSearchParams(); if (ref) params.set("ref", ref); if (limit) params.set("limit", String(limit)); const query = params.size > 0 ? `?${params.toString()}` : ""; - return api(withProjectId(`/git/remotes/${encodeURIComponent(remote)}/commits${query}`, projectId)); + return api(withRepoPath(withProjectId(`/git/remotes/${encodeURIComponent(remote)}/commits${query}`, projectId), repoPath)); } /** Fetch all local branches */ -export function fetchGitBranches(projectId?: string): Promise { - return api(withProjectId("/git/branches", projectId)); +export function fetchGitBranches(projectId?: string, repoPath?: string): Promise { + return api(withRepoPath(withProjectId("/git/branches", projectId), repoPath)); } /** Fetch recent commits for a specific branch */ -export function fetchBranchCommits(branchName: string, limit?: number, projectId?: string): Promise { +export function fetchBranchCommits(branchName: string, limit?: number, projectId?: string, repoPath?: string): Promise { const query = limit ? `?limit=${limit}` : ""; - return api(withProjectId(`/git/branches/${encodeURIComponent(branchName)}/commits${query}`, projectId)); + return api(withRepoPath(withProjectId(`/git/branches/${encodeURIComponent(branchName)}/commits${query}`, projectId), repoPath)); } /** Fetch all worktrees */ -export function fetchGitWorktrees(projectId?: string): Promise { - return api(withProjectId("/git/worktrees", projectId)); +export function fetchGitWorktrees(projectId?: string, repoPath?: string): Promise { + return api(withRepoPath(withProjectId("/git/worktrees", projectId), repoPath)); } /** Create a new branch */ -export function createBranch(name: string, base?: string, projectId?: string): Promise { - return api(withProjectId("/git/branches", projectId), { +export function createBranch(name: string, base?: string, projectId?: string, repoPath?: string): Promise { + return api(withRepoPath(withProjectId("/git/branches", projectId), repoPath), { method: "POST", body: JSON.stringify({ name, base }), }); } /** Checkout an existing branch */ -export function checkoutBranch(name: string, projectId?: string): Promise { - return api(withProjectId(`/git/branches/${encodeURIComponent(name)}/checkout`, projectId), { +export function checkoutBranch(name: string, projectId?: string, repoPath?: string): Promise { + return api(withRepoPath(withProjectId(`/git/branches/${encodeURIComponent(name)}/checkout`, projectId), repoPath), { method: "POST", }); } /** Delete a branch */ -export function deleteBranch(name: string, force?: boolean, projectId?: string): Promise { +export function deleteBranch(name: string, force?: boolean, projectId?: string, repoPath?: string): Promise { const query = force ? "?force=true" : ""; - return api(withProjectId(`/git/branches/${encodeURIComponent(name)}${query}`, projectId), { + return api(withRepoPath(withProjectId(`/git/branches/${encodeURIComponent(name)}${query}`, projectId), repoPath), { method: "DELETE", }); } /** Fetch from remote */ -export function fetchRemote(remote?: string, projectId?: string): Promise { - return api(withProjectId("/git/fetch", projectId), { +export function fetchRemote(remote?: string, projectId?: string, repoPath?: string): Promise { + return api(withRepoPath(withProjectId("/git/fetch", projectId), repoPath), { method: "POST", body: JSON.stringify({ remote }), }); } /** Pull current branch */ -export function pullBranch(options?: { rebase?: boolean }, projectId?: string): Promise; -export function pullBranch(projectId?: string): Promise; +export function pullBranch(options?: { rebase?: boolean }, projectId?: string, repoPath?: string): Promise; +export function pullBranch(projectId?: string, repoPath?: string): Promise; export function pullBranch( optionsOrProjectId?: { rebase?: boolean } | string, projectId?: string, + repoPath?: string, ): Promise { - const options = typeof optionsOrProjectId === "string" ? undefined : optionsOrProjectId; - const resolvedProjectId = typeof optionsOrProjectId === "string" ? optionsOrProjectId : projectId; + // FNXC:DashboardGitApi 2026-06-24-00:00: + // pullBranch has two overloads. In the string-arg style pullBranch(projectId, repoPath), + // the second positional carries repoPath (not the 3rd parameter), so resolve it from `projectId` + // to avoid dropping repoPath; otherwise multi-repo workspace pulls hit the wrong repo. + const isStringForm = typeof optionsOrProjectId === "string"; + const options = isStringForm ? undefined : optionsOrProjectId; + const resolvedProjectId = isStringForm ? optionsOrProjectId : projectId; + const resolvedRepoPath = isStringForm ? projectId : repoPath; - return api(withProjectId("/git/pull", resolvedProjectId), { + return api(withRepoPath(withProjectId("/git/pull", resolvedProjectId), resolvedRepoPath), { method: "POST", body: JSON.stringify({ rebase: options?.rebase ?? false }), }); } /** Push current branch */ -export function pushBranch(projectId?: string): Promise { - return api(withProjectId("/git/push", projectId), { +export function pushBranch(projectId?: string, repoPath?: string): Promise { + return api(withRepoPath(withProjectId("/git/push", projectId), repoPath), { method: "POST", }); } @@ -3160,83 +3167,83 @@ export interface GitFileChange { } /** Fetch stash list */ -export function fetchGitStashList(projectId?: string): Promise { - return api(withProjectId("/git/stashes", projectId)); +export function fetchGitStashList(projectId?: string, repoPath?: string): Promise { + return api(withRepoPath(withProjectId("/git/stashes", projectId), repoPath)); } /** Create a new stash */ -export function createStash(message?: string, projectId?: string): Promise<{ message: string }> { - return api<{ message: string }>(withProjectId("/git/stashes", projectId), { +export function createStash(message?: string, projectId?: string, repoPath?: string): Promise<{ message: string }> { + return api<{ message: string }>(withRepoPath(withProjectId("/git/stashes", projectId), repoPath), { method: "POST", body: JSON.stringify({ message }), }); } /** Apply a stash entry */ -export function applyStash(index: number, drop?: boolean, projectId?: string): Promise<{ message: string }> { - return api<{ message: string }>(withProjectId(`/git/stashes/${index}/apply`, projectId), { +export function applyStash(index: number, drop?: boolean, projectId?: string, repoPath?: string): Promise<{ message: string }> { + return api<{ message: string }>(withRepoPath(withProjectId(`/git/stashes/${index}/apply`, projectId), repoPath), { method: "POST", body: JSON.stringify({ drop }), }); } /** Drop a stash entry */ -export function dropStash(index: number, projectId?: string): Promise<{ message: string }> { - return api<{ message: string }>(withProjectId(`/git/stashes/${index}`, projectId), { +export function dropStash(index: number, projectId?: string, repoPath?: string): Promise<{ message: string }> { + return api<{ message: string }>(withRepoPath(withProjectId(`/git/stashes/${index}`, projectId), repoPath), { method: "DELETE", }); } /** Fetch stash diff (stat + patch) */ -export function fetchStashDiff(index: number, projectId?: string): Promise<{ stat: string; patch: string }> { - return api<{ stat: string; patch: string }>(withProjectId(`/git/stashes/${index}/diff`, projectId)); +export function fetchStashDiff(index: number, projectId?: string, repoPath?: string): Promise<{ stat: string; patch: string }> { + return api<{ stat: string; patch: string }>(withRepoPath(withProjectId(`/git/stashes/${index}/diff`, projectId), repoPath)); } /** Fetch unstaged diff (working directory changes) */ -export function fetchUnstagedDiff(projectId?: string): Promise<{ stat: string; patch: string }> { - return api<{ stat: string; patch: string }>(withProjectId("/git/diff", projectId)); +export function fetchUnstagedDiff(projectId?: string, repoPath?: string): Promise<{ stat: string; patch: string }> { + return api<{ stat: string; patch: string }>(withRepoPath(withProjectId("/git/diff", projectId), repoPath)); } /** Fetch diff for a specific file in staged or unstaged mode */ -export function fetchGitFileDiff(path: string, staged: boolean, projectId?: string): Promise<{ stat: string; patch: string }> { +export function fetchGitFileDiff(path: string, staged: boolean, projectId?: string, repoPath?: string): Promise<{ stat: string; patch: string }> { const params = new URLSearchParams(); params.set("path", path); params.set("staged", String(staged)); - return api<{ stat: string; patch: string }>(withProjectId(`/git/diff/file?${params.toString()}`, projectId)); + return api<{ stat: string; patch: string }>(withRepoPath(withProjectId(`/git/diff/file?${params.toString()}`, projectId), repoPath)); } /** Fetch file changes (staged and unstaged) */ -export function fetchFileChanges(projectId?: string): Promise { - return api(withProjectId("/git/changes", projectId)); +export function fetchFileChanges(projectId?: string, repoPath?: string): Promise { + return api(withRepoPath(withProjectId("/git/changes", projectId), repoPath)); } /** Stage specific files */ -export function stageFiles(files: string[], projectId?: string): Promise<{ staged: string[] }> { - return api<{ staged: string[] }>(withProjectId("/git/stage", projectId), { +export function stageFiles(files: string[], projectId?: string, repoPath?: string): Promise<{ staged: string[] }> { + return api<{ staged: string[] }>(withRepoPath(withProjectId("/git/stage", projectId), repoPath), { method: "POST", body: JSON.stringify({ files }), }); } /** Unstage specific files */ -export function unstageFiles(files: string[], projectId?: string): Promise<{ unstaged: string[] }> { - return api<{ unstaged: string[] }>(withProjectId("/git/unstage", projectId), { +export function unstageFiles(files: string[], projectId?: string, repoPath?: string): Promise<{ unstaged: string[] }> { + return api<{ unstaged: string[] }>(withRepoPath(withProjectId("/git/unstage", projectId), repoPath), { method: "POST", body: JSON.stringify({ files }), }); } /** Create a commit */ -export function createCommit(message: string, projectId?: string): Promise<{ hash: string; message: string }> { - return api<{ hash: string; message: string }>(withProjectId("/git/commit", projectId), { +export function createCommit(message: string, projectId?: string, repoPath?: string): Promise<{ hash: string; message: string }> { + return api<{ hash: string; message: string }>(withRepoPath(withProjectId("/git/commit", projectId), repoPath), { method: "POST", body: JSON.stringify({ message }), }); } /** Discard changes in working directory for specific files */ -export function discardChanges(files: string[], projectId?: string): Promise<{ discarded: string[] }> { - return api<{ discarded: string[] }>(withProjectId("/git/discard", projectId), { +export function discardChanges(files: string[], projectId?: string, repoPath?: string): Promise<{ discarded: string[] }> { + return api<{ discarded: string[] }>(withRepoPath(withProjectId("/git/discard", projectId), repoPath), { method: "POST", body: JSON.stringify({ files }), }); @@ -5881,6 +5888,18 @@ function withProjectId(path: string, projectId?: string): string { return `${path}${separator}projectId=${encodeURIComponent(projectId)}`; } +/** Append repoPath query param for workspace-mode sub-repo targeting */ +function withRepoPath(path: string, repoPath?: string): string { + if (!repoPath) return path; + const separator = path.includes("?") ? "&" : "?"; + return `${path}${separator}repoPath=${encodeURIComponent(repoPath)}`; +} + +/** Fetch workspace sub-repos for a project */ +export function fetchWorkspaceRepos(projectId?: string): Promise<{ repos: string[] }> { + return api<{ repos: string[] }>(withProjectId("/git/workspace-repos", projectId)); +} + /** * Rewrite a path to route through the node proxy when viewing a remote node. * When nodeId is provided and differs from localNodeId (i.e., it's a remote node), @@ -6770,6 +6789,8 @@ export interface ProjectCreateInput { isolationMode?: "in-process" | "child-process"; nodeId?: string; cloneUrl?: string; + workspaceMode?: boolean; + taskPrefix?: string; } export type DockerNodeConfigInfo = DockerNodeConfig; @@ -7236,6 +7257,13 @@ export function registerProject(input: ProjectCreateInput): Promise body: JSON.stringify(input), }); } +/** Detect git sub-repos in a directory (workspace mode detection) */ +export function detectWorkspace(path: string): Promise<{ repos: string[]; isWorkspace: boolean }> { + return api<{ repos: string[]; isWorkspace: boolean }>("/projects/detect-workspace", { + method: "POST", + body: JSON.stringify({ path }), + }); +} /** Unregister a project */ export function unregisterProject(id: string): Promise { diff --git a/packages/dashboard/app/components/GitManagerModal.tsx b/packages/dashboard/app/components/GitManagerModal.tsx index eb2e62a5ee..e50f21bb78 100644 --- a/packages/dashboard/app/components/GitManagerModal.tsx +++ b/packages/dashboard/app/components/GitManagerModal.tsx @@ -58,6 +58,7 @@ import { fetchAheadCommits, fetchRemoteCommits, fetchBranchCommits, + fetchWorkspaceRepos, } from "../api"; import { StashRecoveryView } from "./StashRecoveryView"; import { @@ -256,6 +257,17 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj const [rootDir, setRootDir] = useState(null); + // ── Workspace repo selector state + /* + FNXC:Workspace 2026-06-24-21:00: + In workspace mode (multi-repo), the git manager shows a repo selector so the + user can pick which sub-repo to inspect. selectedRepo is the relative path + (e.g. "openvide"); gitRepoPath is passed as repoPath to all git API calls. + */ + const [workspaceRepos, setWorkspaceRepos] = useState([]); + const [selectedRepo, setSelectedRepo] = useState(null); + const gitRepoPath = selectedRepo ?? undefined; + // ── Changes state const [fileChanges, setFileChanges] = useState([]); const [selectedFiles, setSelectedFiles] = useState>(new Set()); @@ -313,12 +325,12 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj try { switch (activeSection) { case "status": { - const statusData = await fetchGitStatus(projectId, { extended: true }); + const statusData = await fetchGitStatus(projectId, { extended: true }, gitRepoPath); setStatus(statusData); break; } case "changes": { - const [statusData, changes] = await Promise.all([fetchGitStatus(projectId, { extended: true }), fetchFileChanges(projectId)]); + const [statusData, changes] = await Promise.all([fetchGitStatus(projectId, { extended: true }, gitRepoPath), fetchFileChanges(projectId, gitRepoPath)]); setStatus(statusData); setFileChanges(changes); setSelectedFiles(new Set()); @@ -328,23 +340,23 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj break; } case "commits": { - const commitsData = await fetchGitCommits(commitsLimit, projectId); + const commitsData = await fetchGitCommits(commitsLimit, projectId, gitRepoPath); setCommits(commitsData); break; } case "branches": { - const [branchesData, statusForBranch] = await Promise.all([fetchGitBranches(projectId), fetchGitStatus(projectId, { extended: true })]); + const [branchesData, statusForBranch] = await Promise.all([fetchGitBranches(projectId, gitRepoPath), fetchGitStatus(projectId, { extended: true }, gitRepoPath)]); setBranches(branchesData); setStatus(statusForBranch); break; } case "worktrees": { - const worktreesData = await fetchGitWorktrees(projectId); + const worktreesData = await fetchGitWorktrees(projectId, gitRepoPath); setWorktrees(worktreesData); break; } case "stashes": { - const stashesData = await fetchGitStashList(projectId); + const stashesData = await fetchGitStashList(projectId, gitRepoPath); setStashes(stashesData); setExpandedStashIndex(null); setStashDiff(null); @@ -357,7 +369,7 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj break; } case "remotes": { - const remoteStatus = await fetchGitStatus(projectId, { extended: true }); + const remoteStatus = await fetchGitStatus(projectId, { extended: true }, gitRepoPath); setStatus(remoteStatus); break; } @@ -368,7 +380,7 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj } finally { setLoading(false); } - }, [activeSection, isOpen, commitsLimit, addToast, projectId]); + }, [activeSection, isOpen, commitsLimit, addToast, projectId, gitRepoPath]); useEffect(() => { if (isOpen) { @@ -405,9 +417,9 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj const handleStageFiles = useCallback(async (files: string[]) => { try { - await stageFiles(files, projectId); + await stageFiles(files, projectId, gitRepoPath); addToast(t("git.stagedFiles", "Staged {{count}} file(s)", { count: files.length }), "success"); - const changes = await fetchFileChanges(projectId); + const changes = await fetchFileChanges(projectId, gitRepoPath); setFileChanges(changes); setSelectedFiles(new Set()); setSelectedDiffTarget(null); @@ -420,9 +432,9 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj const handleUnstageFiles = useCallback(async (files: string[]) => { try { - await unstageFiles(files, projectId); + await unstageFiles(files, projectId, gitRepoPath); addToast(t("git.unstagedFiles", "Unstaged {{count}} file(s)", { count: files.length }), "success"); - const changes = await fetchFileChanges(projectId); + const changes = await fetchFileChanges(projectId, gitRepoPath); setFileChanges(changes); setSelectedFiles(new Set()); setSelectedDiffTarget(null); @@ -441,9 +453,9 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj }); if (!shouldDiscard) return; try { - await discardChanges(files, projectId); + await discardChanges(files, projectId, gitRepoPath); addToast(t("git.discardedFiles", "Discarded changes to {{count}} file(s)", { count: files.length }), "success"); - const [changes, statusData] = await Promise.all([fetchFileChanges(projectId), fetchGitStatus(projectId, { extended: true })]); + const [changes, statusData] = await Promise.all([fetchFileChanges(projectId, gitRepoPath), fetchGitStatus(projectId, { extended: true }, gitRepoPath)]); setFileChanges(changes); setStatus(statusData); setSelectedFiles(new Set()); @@ -460,11 +472,11 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj if (!commitMessage.trim()) return; setCommitting(true); try { - const result = await createCommit(commitMessage.trim(), projectId); + const result = await createCommit(commitMessage.trim(), projectId, gitRepoPath); addToast(t("git.committedHash", "Committed: {{hash}}", { hash: result.hash }), "success"); setCommitMessage(""); // Refresh changes and status - const [changes, statusData] = await Promise.all([fetchFileChanges(projectId), fetchGitStatus(projectId, { extended: true })]); + const [changes, statusData] = await Promise.all([fetchFileChanges(projectId, gitRepoPath), fetchGitStatus(projectId, { extended: true }, gitRepoPath)]); setFileChanges(changes); setStatus(statusData); setSelectedDiffTarget(null); @@ -483,12 +495,12 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj try { const unstaged = fileChanges.filter((f) => !f.staged).map((f) => f.file); if (unstaged.length > 0) { - await stageFiles(unstaged, projectId); + await stageFiles(unstaged, projectId, gitRepoPath); } - const result = await createCommit(commitMessage.trim(), projectId); + const result = await createCommit(commitMessage.trim(), projectId, gitRepoPath); addToast(t("git.committedHash", "Committed: {{hash}}", { hash: result.hash }), "success"); setCommitMessage(""); - const [changes, statusData] = await Promise.all([fetchFileChanges(projectId), fetchGitStatus(projectId, { extended: true })]); + const [changes, statusData] = await Promise.all([fetchFileChanges(projectId, gitRepoPath), fetchGitStatus(projectId, { extended: true }, gitRepoPath)]); setFileChanges(changes); setStatus(statusData); setSelectedDiffTarget(null); @@ -509,7 +521,7 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj changeDiffRequestIdRef.current = requestId; try { - const diff = await fetchGitFileDiff(file, staged, projectId); + const diff = await fetchGitFileDiff(file, staged, projectId, gitRepoPath); if (changeDiffRequestIdRef.current !== requestId) { return; } @@ -552,7 +564,7 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj setSelectedCommit(hash); setLoadingDiff(true); try { - const diff = await fetchCommitDiff(hash, projectId); + const diff = await fetchCommitDiff(hash, projectId, gitRepoPath); setCommitDiff(diff); } catch (err) { addToast(getErrorMessage(err) || t("git.failedToLoadDiff", "Failed to load diff"), "error"); @@ -584,11 +596,11 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj if (!newBranchName.trim()) return; setLoading(true); try { - await createBranch(newBranchName.trim(), branchBase.trim() || undefined, projectId); + await createBranch(newBranchName.trim(), branchBase.trim() || undefined, projectId, gitRepoPath); addToast(t("git.createdBranch", "Created branch {{name}}", { name: newBranchName }), "success"); setNewBranchName(""); setBranchBase(""); - const branchesData = await fetchGitBranches(projectId); + const branchesData = await fetchGitBranches(projectId, gitRepoPath); setBranches(branchesData); } catch (err) { addToast(getErrorMessage(err) || t("git.failedToCreateBranch", "Failed to create branch"), "error"); @@ -600,9 +612,9 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj const handleCheckoutBranch = useCallback(async (name: string) => { setLoading(true); try { - await checkoutBranch(name, projectId); + await checkoutBranch(name, projectId, gitRepoPath); addToast(t("git.switchedToBranch", "Switched to {{name}}", { name }), "success"); - const [statusData, branchesData] = await Promise.all([fetchGitStatus(projectId, { extended: true }), fetchGitBranches(projectId)]); + const [statusData, branchesData] = await Promise.all([fetchGitStatus(projectId, { extended: true }, gitRepoPath), fetchGitBranches(projectId, gitRepoPath)]); setStatus(statusData); setBranches(branchesData); } catch (err) { @@ -621,9 +633,9 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj if (!shouldDelete) return; setLoading(true); try { - await deleteBranch(name, undefined, projectId); + await deleteBranch(name, undefined, projectId, gitRepoPath); addToast(t("git.deletedBranch", "Deleted branch {{name}}", { name }), "success"); - const branchesData = await fetchGitBranches(projectId); + const branchesData = await fetchGitBranches(projectId, gitRepoPath); setBranches(branchesData); } catch (err) { if (getErrorMessage(err).includes("not fully merged")) { @@ -634,9 +646,9 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj }); if (shouldForceDelete) { try { - await deleteBranch(name, true, projectId); + await deleteBranch(name, true, projectId, gitRepoPath); addToast(t("git.forceDeletedBranch", "Force deleted branch {{name}}", { name }), "success"); - const branchesData = await fetchGitBranches(projectId); + const branchesData = await fetchGitBranches(projectId, gitRepoPath); setBranches(branchesData); } catch (forceErr) { addToast(getErrorMessage(forceErr) || t("git.failedToDeleteBranch", "Failed to delete branch"), "error"); @@ -674,7 +686,7 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj setBranchCommitDiff(null); setLoadingBranchCommits(true); try { - const data = await fetchBranchCommits(name, 10, projectId); + const data = await fetchBranchCommits(name, 10, projectId, gitRepoPath); setBranchCommits(data); } catch { setBranchCommits([]); @@ -694,7 +706,7 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj setBranchCommitDiff(null); setLoadingBranchCommitDiff(true); try { - const diff = await fetchCommitDiff(hash, projectId); + const diff = await fetchCommitDiff(hash, projectId, gitRepoPath); setBranchCommitDiff(diff); } catch { setBranchCommitDiff(null); @@ -726,10 +738,10 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj setStashLoading("create"); resetStashDiffState(); try { - await createStash(stashMessage.trim() || undefined, projectId); + await createStash(stashMessage.trim() || undefined, projectId, gitRepoPath); addToast(t("git.changesStashed", "Changes stashed"), "success"); setStashMessage(""); - const stashesData = await fetchGitStashList(projectId); + const stashesData = await fetchGitStashList(projectId, gitRepoPath); setStashes(stashesData); } catch (err) { addToast(getErrorMessage(err) || t("git.failedToStashChanges", "Failed to stash changes"), "error"); @@ -742,9 +754,9 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj setStashLoading(`apply-${index}`); resetStashDiffState(); try { - await applyStash(index, drop, projectId); + await applyStash(index, drop, projectId, gitRepoPath); addToast(drop ? t("git.stashPopped", "Stash popped") : t("git.stashApplied", "Stash applied"), "success"); - const stashesData = await fetchGitStashList(projectId); + const stashesData = await fetchGitStashList(projectId, gitRepoPath); setStashes(stashesData); } catch (err) { addToast(getErrorMessage(err) || t("git.failedToApplyStash", "Failed to apply stash"), "error"); @@ -763,9 +775,9 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj setStashLoading(`drop-${index}`); resetStashDiffState(); try { - await dropStash(index, projectId); + await dropStash(index, projectId, gitRepoPath); addToast(t("git.stashDropped", "Stash dropped"), "success"); - const stashesData = await fetchGitStashList(projectId); + const stashesData = await fetchGitStashList(projectId, gitRepoPath); setStashes(stashesData); } catch (err) { addToast(getErrorMessage(err) || t("git.failedToDropStash", "Failed to drop stash"), "error"); @@ -787,7 +799,7 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj setStashDiffError(null); setLoadingStashDiff(true); try { - const diff = await fetchStashDiff(index, projectId); + const diff = await fetchStashDiff(index, projectId, gitRepoPath); if (stashDiffRequestIdRef.current !== requestId) { return; } @@ -810,10 +822,10 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj const handleFetch = useCallback(async () => { setRemoteLoading("fetch"); try { - const result = await fetchRemote(undefined, projectId); + const result = await fetchRemote(undefined, projectId, gitRepoPath); setLastRemoteResult(result); addToast(result.message || t("git.fetchCompleted", "Fetch completed"), result.fetched ? "success" : "info"); - const statusData = await fetchGitStatus(projectId, { extended: true }); + const statusData = await fetchGitStatus(projectId, { extended: true }, gitRepoPath); setStatus(statusData); } catch (err) { addToast(getErrorMessage(err) || t("git.fetchFailed", "Fetch failed"), "error"); @@ -825,7 +837,7 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj const handlePull = useCallback(async (options?: { rebase?: boolean }) => { setRemoteLoading("pull"); try { - const result = await pullBranch(options, projectId); + const result = await pullBranch(options, projectId, gitRepoPath); setLastRemoteResult(result); if (result.conflict) { addToast(t("git.mergeConflictDetected", "Merge conflict detected. Resolve manually."), "error"); @@ -833,7 +845,7 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj const fallbackMessage = options?.rebase ? t("git.pullRebaseCompleted", "Pull --rebase completed") : t("git.pullCompleted", "Pull completed"); addToast(result.message || fallbackMessage, "success"); } - const statusData = await fetchGitStatus(projectId, { extended: true }); + const statusData = await fetchGitStatus(projectId, { extended: true }, gitRepoPath); setStatus(statusData); } catch (err) { addToast(getErrorMessage(err) || t("git.pullFailed", "Pull failed"), "error"); @@ -845,10 +857,10 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj const handlePush = useCallback(async () => { setRemoteLoading("push"); try { - const result = await pushBranch(projectId); + const result = await pushBranch(projectId, gitRepoPath); setLastRemoteResult(result); addToast(result.message || t("git.pushCompleted", "Push completed"), "success"); - const statusData = await fetchGitStatus(projectId, { extended: true }); + const statusData = await fetchGitStatus(projectId, { extended: true }, gitRepoPath); setStatus(statusData); } catch (err) { addToast(getErrorMessage(err) || t("git.pushFailed", "Push failed"), "error"); @@ -860,17 +872,17 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj const handleSyncWithOrigin = useCallback(async () => { setRemoteLoading("sync"); try { - const pullResult = await pullBranch({ rebase: true }, projectId); + const pullResult = await pullBranch({ rebase: true }, projectId, gitRepoPath); setLastRemoteResult(pullResult); if (pullResult.conflict) { addToast(t("git.mergeConflictDetected", "Merge conflict detected. Resolve manually."), "error"); return; } - const pushResult = await pushBranch(projectId); + const pushResult = await pushBranch(projectId, gitRepoPath); setLastRemoteResult(pushResult); addToast(t("git.syncedWithOrigin", "Synced with origin (pull --rebase + push)"), "success"); - const statusData = await fetchGitStatus(projectId, { extended: true }); + const statusData = await fetchGitStatus(projectId, { extended: true }, gitRepoPath); setStatus(statusData); } catch (err) { addToast(getErrorMessage(err) || t("git.syncWithOriginFailed", "Sync with origin failed"), "error"); @@ -885,6 +897,32 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj fetchConfig(projectId).then((cfg) => setRootDir(cfg.rootDir)).catch(() => setRootDir(null)); }, [projectId]); + // Fetch workspace repos on mount to determine if this is a multi-repo project. + /* + FNXC:Workspace 2026-06-24-21:30: + Revalidate selectedRepo against the freshly fetched repo list. When projectId + changes (or a project has no workspace repos), a stale selection from the prior + project would otherwise persist and keep sending a stale repoPath to git + endpoints. Keep the current selection only if it still exists in the new list; + otherwise fall back to repos[0], or clear to null when the list is empty (and on + fetch error). The functional updater lets us revalidate without depending on + selectedRepo in the effect deps, preserving the projectId-keyed intent. + */ + useEffect(() => { + fetchWorkspaceRepos(projectId) + .then((result) => { + const repos = result.repos; + setWorkspaceRepos(repos); + setSelectedRepo((current) => + current && repos.includes(current) ? current : (repos[0] ?? null), + ); + }) + .catch(() => { + setWorkspaceRepos([]); + setSelectedRepo(null); + }); + }, [projectId]); // keyed on projectId; selectedRepo is revalidated via the functional updater + const handleSyncIntegrationTip = useCallback(async () => { if (!status?.integrationBranch || status.isOnIntegrationBranch === false) return; const worktreePath = rootDir; @@ -909,7 +947,7 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj }), }); addToast(t("git.syncedWorktreeToIntegrationTip", "Synced worktree to local integration tip"), "success"); - const statusData = await fetchGitStatus(projectId, { extended: true }); + const statusData = await fetchGitStatus(projectId, { extended: true }, gitRepoPath); setStatus(statusData); } catch (err) { addToast(getErrorMessage(err) || t("git.syncFailed", "Sync failed"), "error"); @@ -932,6 +970,29 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj <> {/* Sidebar Navigation */}