Merge branch 'main' into feature/refactor-apptsx

This commit is contained in:
gsxdsm
2026-06-24 17:07:22 -07:00
committed by GitHub
18 changed files with 857 additions and 210 deletions

View File

@@ -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 …".

View File

@@ -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.

View File

@@ -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", "<col>", "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.<field>)` value). Keep column/placeholder/arg counts equal.
4. **store.ts descriptor** — `defineTaskColumn("<field>", (task) => toJsonNullable(task.<field>))`. This is what `getChangedTaskColumns` uses to detect the field changed and emit it in the UPDATE.
5. **store.ts TaskRow** — add `<field>: string | null;` to the `TaskRow` interface.
6. **store.ts rowToTask** — deserialize: `<field>: fromJson<...>(row.<field>)`.
```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<Task["workspaceWorktrees"]>(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.

View File

@@ -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);

View File

@@ -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" });

View File

@@ -226,10 +226,11 @@ async function migrateTasks(fusionDir: string, db: Database): Promise<void> {
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<void> {
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) {

View File

@@ -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");
});
}
}
/**

View File

@@ -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 };
}
}

View File

@@ -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<TaskStoreEvents> {
};
})(),
mergeDetails: fromJson<import("./types.js").MergeDetails>(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<import("./types.js").Task["workspaceWorktrees"]>(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<string[]>(row.enabledWorkflowSteps); return e && e.length > 0 ? e : undefined; })(),
@@ -2589,7 +2607,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
"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<TaskStoreEvents> {
"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",

View File

@@ -2502,8 +2502,8 @@ export interface GitRemote {
}
/** Fetch GitHub remotes from the current git repository */
export function fetchGitRemotes(projectId?: string): Promise<GitRemote[]> {
return api<GitRemote[]>(withProjectId("/git/remotes", projectId));
export function fetchGitRemotes(projectId?: string, repoPath?: string): Promise<GitRemote[]> {
return api<GitRemote[]>(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<GitRemoteDetailed[]> {
return api<GitRemoteDetailed[]>(withProjectId("/git/remotes/detailed", projectId));
export function fetchGitRemotesDetailed(projectId?: string, repoPath?: string): Promise<GitRemoteDetailed[]> {
return api<GitRemoteDetailed[]>(withRepoPath(withProjectId("/git/remotes/detailed", projectId), repoPath));
}
/** Add a new git remote */
export function addGitRemote(name: string, url: string, projectId?: string): Promise<void> {
return api<void>(withProjectId("/git/remotes", projectId), {
export function addGitRemote(name: string, url: string, projectId?: string, repoPath?: string): Promise<void> {
return api<void>(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<void> {
return api<void>(withProjectId(`/git/remotes/${encodeURIComponent(name)}`, projectId), {
export function removeGitRemote(name: string, projectId?: string, repoPath?: string): Promise<void> {
return api<void>(withRepoPath(withProjectId(`/git/remotes/${encodeURIComponent(name)}`, projectId), repoPath), {
method: "DELETE",
});
}
/** Rename a git remote */
export function renameGitRemote(name: string, newName: string, projectId?: string): Promise<void> {
return api<void>(withProjectId(`/git/remotes/${encodeURIComponent(name)}`, projectId), {
export function renameGitRemote(name: string, newName: string, projectId?: string, repoPath?: string): Promise<void> {
return api<void>(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<void> {
return api<void>(withProjectId(`/git/remotes/${encodeURIComponent(name)}/url`, projectId), {
export function updateGitRemoteUrl(name: string, url: string, projectId?: string, repoPath?: string): Promise<void> {
return api<void>(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<GitStatus> {
const base = withProjectId("/git/status", projectId);
export function fetchGitStatus(projectId?: string, opts?: { extended?: boolean }, repoPath?: string): Promise<GitStatus> {
const base = withRepoPath(withProjectId("/git/status", projectId), repoPath);
if (!opts?.extended) return api<GitStatus>(base);
const sep = base.includes("?") ? "&" : "?";
return api<GitStatus>(`${base}${sep}extended=1`);
}
/** Fetch recent commits */
export function fetchGitCommits(limit?: number, projectId?: string): Promise<GitCommit[]> {
export function fetchGitCommits(limit?: number, projectId?: string, repoPath?: string): Promise<GitCommit[]> {
const query = limit ? `?limit=${limit}` : "";
return api<GitCommit[]>(withProjectId(`/git/commits${query}`, projectId));
return api<GitCommit[]>(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<GitCommit[]> {
return api<GitCommit[]>(withProjectId("/git/commits/ahead", projectId));
export function fetchAheadCommits(projectId?: string, repoPath?: string): Promise<GitCommit[]> {
return api<GitCommit[]>(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<GitCommit[]> {
export function fetchRemoteCommits(remote: string, ref?: string, limit?: number, projectId?: string, repoPath?: string): Promise<GitCommit[]> {
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<GitCommit[]>(withProjectId(`/git/remotes/${encodeURIComponent(remote)}/commits${query}`, projectId));
return api<GitCommit[]>(withRepoPath(withProjectId(`/git/remotes/${encodeURIComponent(remote)}/commits${query}`, projectId), repoPath));
}
/** Fetch all local branches */
export function fetchGitBranches(projectId?: string): Promise<GitBranch[]> {
return api<GitBranch[]>(withProjectId("/git/branches", projectId));
export function fetchGitBranches(projectId?: string, repoPath?: string): Promise<GitBranch[]> {
return api<GitBranch[]>(withRepoPath(withProjectId("/git/branches", projectId), repoPath));
}
/** Fetch recent commits for a specific branch */
export function fetchBranchCommits(branchName: string, limit?: number, projectId?: string): Promise<GitCommit[]> {
export function fetchBranchCommits(branchName: string, limit?: number, projectId?: string, repoPath?: string): Promise<GitCommit[]> {
const query = limit ? `?limit=${limit}` : "";
return api<GitCommit[]>(withProjectId(`/git/branches/${encodeURIComponent(branchName)}/commits${query}`, projectId));
return api<GitCommit[]>(withRepoPath(withProjectId(`/git/branches/${encodeURIComponent(branchName)}/commits${query}`, projectId), repoPath));
}
/** Fetch all worktrees */
export function fetchGitWorktrees(projectId?: string): Promise<GitWorktree[]> {
return api<GitWorktree[]>(withProjectId("/git/worktrees", projectId));
export function fetchGitWorktrees(projectId?: string, repoPath?: string): Promise<GitWorktree[]> {
return api<GitWorktree[]>(withRepoPath(withProjectId("/git/worktrees", projectId), repoPath));
}
/** Create a new branch */
export function createBranch(name: string, base?: string, projectId?: string): Promise<void> {
return api<void>(withProjectId("/git/branches", projectId), {
export function createBranch(name: string, base?: string, projectId?: string, repoPath?: string): Promise<void> {
return api<void>(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<void> {
return api<void>(withProjectId(`/git/branches/${encodeURIComponent(name)}/checkout`, projectId), {
export function checkoutBranch(name: string, projectId?: string, repoPath?: string): Promise<void> {
return api<void>(withRepoPath(withProjectId(`/git/branches/${encodeURIComponent(name)}/checkout`, projectId), repoPath), {
method: "POST",
});
}
/** Delete a branch */
export function deleteBranch(name: string, force?: boolean, projectId?: string): Promise<void> {
export function deleteBranch(name: string, force?: boolean, projectId?: string, repoPath?: string): Promise<void> {
const query = force ? "?force=true" : "";
return api<void>(withProjectId(`/git/branches/${encodeURIComponent(name)}${query}`, projectId), {
return api<void>(withRepoPath(withProjectId(`/git/branches/${encodeURIComponent(name)}${query}`, projectId), repoPath), {
method: "DELETE",
});
}
/** Fetch from remote */
export function fetchRemote(remote?: string, projectId?: string): Promise<GitFetchResult> {
return api<GitFetchResult>(withProjectId("/git/fetch", projectId), {
export function fetchRemote(remote?: string, projectId?: string, repoPath?: string): Promise<GitFetchResult> {
return api<GitFetchResult>(withRepoPath(withProjectId("/git/fetch", projectId), repoPath), {
method: "POST",
body: JSON.stringify({ remote }),
});
}
/** Pull current branch */
export function pullBranch(options?: { rebase?: boolean }, projectId?: string): Promise<GitPullResult>;
export function pullBranch(projectId?: string): Promise<GitPullResult>;
export function pullBranch(options?: { rebase?: boolean }, projectId?: string, repoPath?: string): Promise<GitPullResult>;
export function pullBranch(projectId?: string, repoPath?: string): Promise<GitPullResult>;
export function pullBranch(
optionsOrProjectId?: { rebase?: boolean } | string,
projectId?: string,
repoPath?: string,
): Promise<GitPullResult> {
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<GitPullResult>(withProjectId("/git/pull", resolvedProjectId), {
return api<GitPullResult>(withRepoPath(withProjectId("/git/pull", resolvedProjectId), resolvedRepoPath), {
method: "POST",
body: JSON.stringify({ rebase: options?.rebase ?? false }),
});
}
/** Push current branch */
export function pushBranch(projectId?: string): Promise<GitPushResult> {
return api<GitPushResult>(withProjectId("/git/push", projectId), {
export function pushBranch(projectId?: string, repoPath?: string): Promise<GitPushResult> {
return api<GitPushResult>(withRepoPath(withProjectId("/git/push", projectId), repoPath), {
method: "POST",
});
}
@@ -3160,83 +3167,83 @@ export interface GitFileChange {
}
/** Fetch stash list */
export function fetchGitStashList(projectId?: string): Promise<GitStash[]> {
return api<GitStash[]>(withProjectId("/git/stashes", projectId));
export function fetchGitStashList(projectId?: string, repoPath?: string): Promise<GitStash[]> {
return api<GitStash[]>(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<GitFileChange[]> {
return api<GitFileChange[]>(withProjectId("/git/changes", projectId));
export function fetchFileChanges(projectId?: string, repoPath?: string): Promise<GitFileChange[]> {
return api<GitFileChange[]>(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<ProjectInfo>
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<void> {

View File

@@ -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<string | null>(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<string[]>([]);
const [selectedRepo, setSelectedRepo] = useState<string | null>(null);
const gitRepoPath = selectedRepo ?? undefined;
// ── Changes state
const [fileChanges, setFileChanges] = useState<GitFileChange[]>([]);
const [selectedFiles, setSelectedFiles] = useState<Set<string>>(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 */}
<nav className="gm-sidebar" role="tablist" aria-label={t("git.sidebarAriaLabel", "Git Manager Sections")}>
{/*
FNXC:Workspace 2026-06-24-21:00:
Repo selector for workspace-mode (multi-repo) projects. Placed at the top
of the sidebar so it's visible in both modal and embedded presentations.
*/}
{workspaceRepos.length > 0 && (
<div className="gm-repo-selector-wrap">
<FolderGit2 size={14} />
<select
className="gm-repo-selector"
value={selectedRepo ?? ""}
onChange={(e) => {
setSelectedRepo(e.target.value || null);
}}
title={t("git.selectRepo", "Select repository")}
aria-label={t("git.selectRepo", "Select repository")}
>
{workspaceRepos.map((repo) => (
<option key={repo} value={repo}>{repo}</option>
))}
</select>
</div>
)}
{SECTIONS.map((section) => {
const Icon = section.icon;
const sectionLabel = {

View File

@@ -2297,6 +2297,27 @@ The previous bespoke rules here hid the tab labels (icon-only) and used a crampe
overflow-y: auto;
}
/* ── Workspace repo selector ── */
.gm-repo-selector-wrap {
display: flex;
align-items: center;
gap: var(--space-xs);
padding: var(--space-sm) var(--space-lg);
border-bottom: 1px solid var(--border);
color: var(--text-muted);
}
.gm-repo-selector {
flex: 1;
min-width: 0;
background: var(--bg-input);
color: var(--text-primary);
border: 1px solid var(--border);
border-radius: 4px;
padding: 2px 4px;
font-size: 12px;
cursor: pointer;
}
.gm-nav-item {
display: flex;
align-items: center;

View File

@@ -3,9 +3,22 @@ import { lazy, Suspense, useState, useCallback, useMemo, useRef, useEffect, type
import { X, Loader2, CheckCircle, ChevronRight, Sparkles } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { AgentOnboardingSummary, ProjectInfo, ProjectCreateInput } from "../api";
import { createAgent, registerProject } from "../api";
import { createAgent, registerProject, detectWorkspace } from "../api";
import { DirectoryPicker } from "./DirectoryPicker";
import { suggestProjectName } from "../utils/projectDetection";
/*
FNXC:TaskPrefix 2026-06-24-19:00:
Derive a task prefix from a project name in the browser. Mirrors the logic in
@fusion/core's suggestTaskPrefix: strip non-alpha, uppercase, take 2-4 chars,
fall back to "FN". Duplicated because @fusion/core is server-only.
*/
function suggestTaskPrefixFromName(name: string): string {
const cleaned = name.replace(/[^a-zA-Z]/g, "").toUpperCase();
if (cleaned.length >= 2 && cleaned.length <= 4) return cleaned;
if (cleaned.length > 4) return cleaned.slice(0, 4);
return "FN";
}
import { useNodes } from "../hooks/useNodes";
import { AgentAvatar } from "./AgentAvatar";
import { ErrorBoundary } from "./ErrorBoundary";
@@ -44,6 +57,10 @@ interface WizardState {
manualName: string;
manualIsolationMode: "in-process" | "child-process";
manualNodeId: string;
manualTaskPrefix: string;
detectedRepos: string[];
workspaceMode: boolean;
isDetectingWorkspace: boolean;
registeredProject: ProjectInfo | null;
selectedPresetId: string;
agentDraft: AgentDraftValues;
@@ -90,6 +107,10 @@ export function SetupWizardModal({
manualName: "",
manualIsolationMode: "in-process",
manualNodeId: "",
manualTaskPrefix: "",
detectedRepos: [],
workspaceMode: false,
isDetectingWorkspace: false,
registeredProject: null,
selectedPresetId: ceoPreset.id,
agentDraft: mapPresetToAgentDraft(ceoPreset),
@@ -125,16 +146,48 @@ export function SetupWizardModal({
}
}, [state.agentError]);
const detectWorkspaceRequestId = useRef(0);
const handlePathChange = useCallback((path: string) => {
setState((prev) => {
const updates: Partial<WizardState> = { manualPath: path };
const updates: Partial<WizardState> = { manualPath: path, detectedRepos: [], workspaceMode: false };
// Auto-suggest name when path changes and name is empty or was previously auto-suggested
if (path && (!prev.manualName || prev.manualName === suggestProjectName(prev.manualPath))) {
updates.manualName = suggestProjectName(path);
}
// Auto-suggest prefix when name changes and prefix is empty or was previously auto-suggested
const suggestedName = updates.manualName ?? prev.manualName;
if (suggestedName && (!prev.manualTaskPrefix || prev.manualTaskPrefix === suggestTaskPrefixFromName(suggestProjectName(prev.manualPath)))) {
updates.manualTaskPrefix = suggestTaskPrefixFromName(suggestedName);
}
return { ...prev, ...updates };
});
}, []);
/*
FNXC:Workspace 2026-06-24-21:00:
Detect workspace sub-repos only in existing-directory mode (clone mode creates a fresh
directory with a single repo). A monotonic request ID guards against stale responses
overwriting state from a newer path entry (race condition on rapid typing).
*/
if (state.manualMode === "existing" && path.trim() && path.trim() !== "/") {
const requestId = ++detectWorkspaceRequestId.current;
setState((prev) => ({ ...prev, isDetectingWorkspace: true }));
detectWorkspace(path.trim())
.then((result) => {
if (requestId !== detectWorkspaceRequestId.current) return;
setState((prev) => ({
...prev,
isDetectingWorkspace: false,
detectedRepos: result.repos,
workspaceMode: result.isWorkspace,
}));
})
.catch(() => {
if (requestId !== detectWorkspaceRequestId.current) return;
setState((prev) => ({ ...prev, isDetectingWorkspace: false }));
});
}
}, [state.manualMode]);
const handleManualRegister = useCallback(async () => {
const trimmedPath = state.manualPath.trim();
@@ -153,6 +206,8 @@ export function SetupWizardModal({
isolationMode: state.manualIsolationMode,
nodeId: state.manualNodeId || undefined,
cloneUrl: state.manualMode === "clone" ? trimmedCloneUrl : undefined,
workspaceMode: state.workspaceMode,
taskPrefix: state.manualTaskPrefix.trim() || undefined,
};
const result = await registerProject(input);
@@ -179,7 +234,7 @@ export function SetupWizardModal({
error: err instanceof Error ? err.message : "Failed to register project",
}));
}
}, [includeAgentStep, onProjectRegistered, state.manualPath, state.manualName, state.manualCloneUrl, state.manualMode, state.manualIsolationMode, state.manualNodeId]);
}, [includeAgentStep, onProjectRegistered, state.manualPath, state.manualName, state.manualCloneUrl, state.manualMode, state.manualIsolationMode, state.manualNodeId, state.workspaceMode, state.manualTaskPrefix]);
const handlePresetSelect = useCallback((presetId: string) => {
const preset = getPresetById(presetId);
@@ -369,6 +424,64 @@ export function SetupWizardModal({
</p>
</div>
{/*
FNXC:Workspace 2026-06-24-19:00:
Workspace mode detection: when the selected directory contains git sub-repos,
show a checkbox letting the user opt into workspace mode. In workspace mode,
tasks run per-sub-repo and no git repo is created at the root.
*/}
{isExistingMode && state.manualPath.trim() && (
<div className="form-group">
<label htmlFor="workspace-mode" className="checkbox-label">
<input
id="workspace-mode"
type="checkbox"
checked={state.workspaceMode}
onChange={(e) => setState((prev) => ({ ...prev, workspaceMode: e.target.checked }))}
/>
{t("setup.workspaceMode", "Workspace mode (multi-repo)")}
</label>
{state.isDetectingWorkspace && (
<p className="form-hint">
<Loader2 size={12} className="animate-spin" style={{ display: "inline-block", verticalAlign: "middle", marginRight: 4 }} />
{t("setup.detectingWorkspace", "Detecting sub-repositories...")}
</p>
)}
{!state.isDetectingWorkspace && state.detectedRepos.length > 0 && (
<p className="form-hint">
{t("setup.detectedRepos", "Found {{count}} repositories:", { count: state.detectedRepos.length })}
{" "}
{state.detectedRepos.join(", ")}
</p>
)}
{!state.isDetectingWorkspace && state.detectedRepos.length === 0 && state.workspaceMode === false && state.manualPath.trim() && (
<p className="form-hint">
{t("setup.noSubReposDetected", "No sub-repositories detected. Enable if this is a multi-repo workspace.")}
</p>
)}
</div>
)}
{/*
FNXC:TaskPrefix 2026-06-24-19:00:
Task prefix field: auto-derived from the project name. The prefix is used
for task IDs (e.g. "MYPR-1"). Users can override it.
*/}
<div className="form-group">
<label htmlFor="task-prefix">{t("setup.taskPrefix", "Task Prefix")}</label>
<input
id="task-prefix"
type="text"
value={state.manualTaskPrefix}
onChange={(e) => setState((prev) => ({ ...prev, manualTaskPrefix: e.target.value.toUpperCase() }))}
placeholder={suggestTaskPrefixFromName(state.manualName || "FN")}
maxLength={5}
/>
<p className="form-hint">
{t("setup.taskPrefixHint", "Used for task IDs (e.g. \"{{prefix}}-1\"). Derived from project name.", { prefix: state.manualTaskPrefix || "FN" })}
</p>
</div>
<div className="setup-wizard-advanced">
<button
type="button"

View File

@@ -65,8 +65,8 @@ export function GeneralSection({ scopeBanner, form, setForm, projectId, addToast
<input id="taskPrefix" type="text" placeholder={t("settings.general.fN", "FN")} value={form.taskPrefix || ""} onChange={(e) => {
const val = e.target.value;
setForm((f) => ({ ...f, taskPrefix: val || undefined }));
if (val && !/^[A-Z]{1,10}$/.test(val)) {
setPrefixError(t("settings.general.prefixMustBe110UppercaseLetters", "Prefix must be 1–10 uppercase letters"));
if (val && !/^[A-Z]{1,5}$/.test(val)) {
setPrefixError(t("settings.general.prefixMustBe15UppercaseLetters", "Prefix must be 1–5 uppercase letters"));
}
else {
setPrefixError(null);

View File

@@ -17,7 +17,7 @@ import type {
Task,
TaskStore,
} from "@fusion/core";
import { classifyGhError, getCurrentRepo, isGhAuthenticated } from "@fusion/core";
import { classifyGhError, getCurrentRepo, isGhAuthenticated, loadWorkspaceConfig } from "@fusion/core";
import {
dropAutostashHandle,
generateSyntheticRunId,
@@ -2468,6 +2468,52 @@ export async function refreshIssueInBackground(
export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
const { router, getProjectContext, rethrowAsApiError, store } = ctx;
/*
FNXC:Workspace 2026-06-24-21:00:
In workspace mode (multi-repo), git operations target a specific sub-repo.
The `repoPath` query param selects which sub-repo. When absent, the project
root directory is used (existing single-repo behavior).
FNXC:Workspace 2026-06-24-22:30:
`repoPath` is caller-supplied and untrusted. It must resolve to a directory
contained within the project root; a `../`-prefixed or absolute value would
otherwise redirect every git endpoint (read remote URLs, commit/push/discard)
at an arbitrary repo on disk. Resolve to an absolute path and reject anything
that escapes `projectRoot` via the shared `isPathWithin` containment check
(the empty / `.` / exact-root case stays allowed — that is the root itself).
*/
function resolveGitDir(req: Request, projectRoot: string): string {
const repoPath = req.query.repoPath;
if (typeof repoPath === "string" && repoPath.trim()) {
const resolved = resolve(projectRoot, repoPath.trim());
if (!isPathWithin(projectRoot, resolved)) {
throw new ApiError(400, "Invalid repoPath: resolves outside the project root", {
reason: "repo-path-escape",
});
}
return resolved;
}
return projectRoot;
}
/**
* GET /api/git/workspace-repos
* Returns the list of sub-repos for a workspace-mode project.
* Non-workspace projects return an empty array.
*/
router.get("/git/workspace-repos", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const rootDir = resolveGitDir(req, scopedStore.getRootDir());
const config = await loadWorkspaceConfig(rootDir);
res.json({ repos: config?.repos ?? [] });
} catch (err: unknown) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err);
}
});
const githubToken = ctx.options?.githubToken ?? process.env.GITHUB_TOKEN;
if (typeof (store as Partial<{ on: unknown; off: unknown }>).on === "function" &&
typeof (store as Partial<{ off: unknown }>).off === "function") {
@@ -2649,7 +2695,7 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
router.get("/git/remotes", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const rootDir = scopedStore.getRootDir();
const rootDir = resolveGitDir(req, scopedStore.getRootDir());
const remotes = await getGitHubRemotes(rootDir);
res.json(remotes);
} catch (err: unknown) {
@@ -2668,7 +2714,7 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
router.get("/git/remotes/detailed", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const rootDir = scopedStore.getRootDir();
const rootDir = resolveGitDir(req, scopedStore.getRootDir());
if (!(await isGitRepo(rootDir))) {
throw badRequest("Not a git repository");
}
@@ -2690,7 +2736,7 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
router.post("/git/remotes", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const rootDir = scopedStore.getRootDir();
const rootDir = resolveGitDir(req, scopedStore.getRootDir());
const { name, url } = req.body;
if (!name || typeof name !== "string") {
throw badRequest("name is required");
@@ -2732,7 +2778,7 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
router.delete("/git/remotes/:name", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const rootDir = scopedStore.getRootDir();
const rootDir = resolveGitDir(req, scopedStore.getRootDir());
if (!(await isGitRepo(rootDir))) {
throw badRequest("Not a git repository");
}
@@ -2761,7 +2807,7 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
router.patch("/git/remotes/:name", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const rootDir = scopedStore.getRootDir();
const rootDir = resolveGitDir(req, scopedStore.getRootDir());
if (!(await isGitRepo(rootDir))) {
throw badRequest("Not a git repository");
}
@@ -2796,7 +2842,7 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
router.put("/git/remotes/:name/url", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const rootDir = scopedStore.getRootDir();
const rootDir = resolveGitDir(req, scopedStore.getRootDir());
if (!(await isGitRepo(rootDir))) {
throw badRequest("Not a git repository");
}
@@ -2834,7 +2880,7 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
router.get("/git/status", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const rootDir = scopedStore.getRootDir();
const rootDir = resolveGitDir(req, scopedStore.getRootDir());
if (!(await isGitRepo(rootDir))) {
throw badRequest("Not a git repository");
}
@@ -2878,7 +2924,7 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
router.get("/git/commits", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const rootDir = scopedStore.getRootDir();
const rootDir = resolveGitDir(req, scopedStore.getRootDir());
if (!(await isGitRepo(rootDir))) {
throw badRequest("Not a git repository");
}
@@ -2901,7 +2947,7 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
router.get("/git/commits/:hash/diff", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const rootDir = scopedStore.getRootDir();
const rootDir = resolveGitDir(req, scopedStore.getRootDir());
if (!(await isGitRepo(rootDir))) {
throw badRequest("Not a git repository");
}
@@ -2931,7 +2977,7 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
router.get("/git/commits/ahead", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const rootDir = scopedStore.getRootDir();
const rootDir = resolveGitDir(req, scopedStore.getRootDir());
if (!(await isGitRepo(rootDir))) {
throw badRequest("Not a git repository");
}
@@ -2955,7 +3001,7 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
router.get("/git/remotes/:name/commits", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const rootDir = scopedStore.getRootDir();
const rootDir = resolveGitDir(req, scopedStore.getRootDir());
if (!(await isGitRepo(rootDir))) {
throw badRequest("Not a git repository");
}
@@ -3024,7 +3070,7 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
router.get("/git/branches", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const rootDir = scopedStore.getRootDir();
const rootDir = resolveGitDir(req, scopedStore.getRootDir());
if (!(await isGitRepo(rootDir))) {
throw badRequest("Not a git repository");
}
@@ -3047,7 +3093,7 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
router.get("/git/branches/:name/commits", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const rootDir = scopedStore.getRootDir();
const rootDir = resolveGitDir(req, scopedStore.getRootDir());
if (!(await isGitRepo(rootDir))) {
throw badRequest("Not a git repository");
}
@@ -3074,7 +3120,7 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
router.get("/git/worktrees", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const rootDir = scopedStore.getRootDir();
const rootDir = resolveGitDir(req, scopedStore.getRootDir());
if (!(await isGitRepo(rootDir))) {
throw badRequest("Not a git repository");
}
@@ -3100,7 +3146,7 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
router.post("/git/branches", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const rootDir = scopedStore.getRootDir();
const rootDir = resolveGitDir(req, scopedStore.getRootDir());
if (!(await isGitRepo(rootDir))) {
throw badRequest("Not a git repository");
}
@@ -3131,7 +3177,7 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
router.post("/git/branches/:name/checkout", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const rootDir = scopedStore.getRootDir();
const rootDir = resolveGitDir(req, scopedStore.getRootDir());
if (!(await isGitRepo(rootDir))) {
throw badRequest("Not a git repository");
}
@@ -3160,7 +3206,7 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
router.delete("/git/branches/:name", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const rootDir = scopedStore.getRootDir();
const rootDir = resolveGitDir(req, scopedStore.getRootDir());
if (!(await isGitRepo(rootDir))) {
throw badRequest("Not a git repository");
}
@@ -3192,7 +3238,7 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
router.post("/git/fetch", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const rootDir = scopedStore.getRootDir();
const rootDir = resolveGitDir(req, scopedStore.getRootDir());
if (!(await isGitRepo(rootDir))) {
throw badRequest("Not a git repository");
}
@@ -3220,7 +3266,7 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
router.post("/git/pull", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const rootDir = scopedStore.getRootDir();
const rootDir = resolveGitDir(req, scopedStore.getRootDir());
if (!(await isGitRepo(rootDir))) {
throw badRequest("Not a git repository");
}
@@ -3416,7 +3462,7 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
router.post("/git/push", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const rootDir = scopedStore.getRootDir();
const rootDir = resolveGitDir(req, scopedStore.getRootDir());
if (!(await isGitRepo(rootDir))) {
throw badRequest("Not a git repository");
}
@@ -3445,7 +3491,7 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
router.get("/git/stashes", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const rootDir = scopedStore.getRootDir();
const rootDir = resolveGitDir(req, scopedStore.getRootDir());
if (!(await isGitRepo(rootDir))) {
throw badRequest("Not a git repository");
}
@@ -3467,7 +3513,7 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
router.post("/git/stashes", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const rootDir = scopedStore.getRootDir();
const rootDir = resolveGitDir(req, scopedStore.getRootDir());
if (!(await isGitRepo(rootDir))) {
throw badRequest("Not a git repository");
}
@@ -3494,7 +3540,7 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
router.post("/git/stashes/:index/apply", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const rootDir = scopedStore.getRootDir();
const rootDir = resolveGitDir(req, scopedStore.getRootDir());
if (!(await isGitRepo(rootDir))) {
throw badRequest("Not a git repository");
}
@@ -3520,7 +3566,7 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
router.get("/git/stashes/:index/diff", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const rootDir = scopedStore.getRootDir();
const rootDir = resolveGitDir(req, scopedStore.getRootDir());
if (!(await isGitRepo(rootDir))) {
throw badRequest("Not a git repository");
}
@@ -3551,7 +3597,7 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
router.delete("/git/stashes/:index", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const rootDir = scopedStore.getRootDir();
const rootDir = resolveGitDir(req, scopedStore.getRootDir());
if (!(await isGitRepo(rootDir))) {
throw badRequest("Not a git repository");
}
@@ -3576,7 +3622,7 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
router.get("/git/diff", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const rootDir = scopedStore.getRootDir();
const rootDir = resolveGitDir(req, scopedStore.getRootDir());
if (!(await isGitRepo(rootDir))) {
throw badRequest("Not a git repository");
}
@@ -3598,7 +3644,7 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
router.get("/git/diff/file", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const rootDir = scopedStore.getRootDir();
const rootDir = resolveGitDir(req, scopedStore.getRootDir());
if (!(await isGitRepo(rootDir))) {
throw badRequest("Not a git repository");
}
@@ -3633,7 +3679,7 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
router.get("/git/changes", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const rootDir = scopedStore.getRootDir();
const rootDir = resolveGitDir(req, scopedStore.getRootDir());
if (!(await isGitRepo(rootDir))) {
throw badRequest("Not a git repository");
}
@@ -3655,7 +3701,7 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
router.post("/git/stage", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const rootDir = scopedStore.getRootDir();
const rootDir = resolveGitDir(req, scopedStore.getRootDir());
if (!(await isGitRepo(rootDir))) {
throw badRequest("Not a git repository");
}
@@ -3681,7 +3727,7 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
router.post("/git/unstage", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const rootDir = scopedStore.getRootDir();
const rootDir = resolveGitDir(req, scopedStore.getRootDir());
if (!(await isGitRepo(rootDir))) {
throw badRequest("Not a git repository");
}
@@ -3707,7 +3753,7 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
router.post("/git/commit", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const rootDir = scopedStore.getRootDir();
const rootDir = resolveGitDir(req, scopedStore.getRootDir());
if (!(await isGitRepo(rootDir))) {
throw badRequest("Not a git repository");
}
@@ -3737,7 +3783,7 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
router.post("/git/discard", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const rootDir = scopedStore.getRootDir();
const rootDir = resolveGitDir(req, scopedStore.getRootDir());
if (!(await isGitRepo(rootDir))) {
throw badRequest("Not a git repository");
}
@@ -3764,7 +3810,7 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
router.get("/github/issues/recent", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const rootDir = scopedStore.getRootDir();
const rootDir = resolveGitDir(req, scopedStore.getRootDir());
const remotes = await getGitHubRemotes(rootDir);
const remote = remotes.find((item) => item.name === "origin") ?? remotes[0];

View File

@@ -210,6 +210,39 @@ export const registerProjectRoutes: ApiRouteRegistrar = (ctx) => {
}
});
/**
* POST /api/projects/detect-workspace
* Probe a directory for git sub-repos (workspace mode detection).
* Body: { path: string }
* Returns: { repos: string[], isWorkspace: boolean }
*/
router.post("/projects/detect-workspace", async (req, res) => {
try {
const { path } = req.body;
if (!path || typeof path !== "string" || !path.trim()) {
throw badRequest("path is required");
}
const normalizedPath = path.trim();
if (!isAbsolute(normalizedPath)) {
throw badRequest("path must be an absolute path");
}
try {
await access(normalizedPath);
} catch {
throw badRequest("Project path does not exist");
}
const { detectWorkspaceRepos } = await import("@fusion/core");
const repos = await detectWorkspaceRepos(normalizedPath);
res.json({ repos, isWorkspace: repos.length > 0 });
} catch (err: unknown) {
if (err instanceof ApiError) throw err;
throw new ApiError(500, err instanceof Error ? err.message : String(err));
}
});
/**
* POST /api/projects
* Register a new project.
@@ -218,13 +251,15 @@ export const registerProjectRoutes: ApiRouteRegistrar = (ctx) => {
* path: string,
* isolationMode?: "in-process" | "child-process",
* nodeId?: string,
* cloneUrl?: string
* cloneUrl?: string,
* workspaceMode?: boolean,
* taskPrefix?: string
* }
* Returns: RegisteredProject
*/
router.post("/projects", async (req, res) => {
try {
const { name, path, isolationMode = "in-process", nodeId, cloneUrl } = req.body;
const { name, path, isolationMode = "in-process", nodeId, cloneUrl, workspaceMode, taskPrefix } = req.body;
if (!name || typeof name !== "string" || !name.trim()) {
throw badRequest("name is required and must be a non-empty string");
@@ -376,21 +411,47 @@ export const registerProjectRoutes: ApiRouteRegistrar = (ctx) => {
/*
FNXC:Onboarding 2026-06-24-18:00:
For new registrations (not reattachments), set a derived task prefix and default
workflow via the per-project TaskStore config.json so the project is immediately
usable without manual settings configuration.
For new registrations (not reattachments), configure workspace mode (if specified or
auto-detected), set a task prefix, and default workflow via the per-project TaskStore
config.json so the project is immediately usable without manual settings configuration.
*/
if (activeProjectWithOutcome.outcome === "registered") {
try {
const { TaskStore, suggestTaskPrefix } = await import("@fusion/core");
const { TaskStore, suggestTaskPrefix, detectWorkspaceRepos, saveWorkspaceConfig } = await import("@fusion/core");
const store = new TaskStore(normalizedPath);
await store.init();
const prefix = suggestTaskPrefix(normalizedName);
await store.updateSettings({
taskPrefix: prefix,
defaultWorkflowId: "builtin:coding",
});
await store.close();
try {
await store.init();
/*
FNXC:Workspace 2026-06-24-19:00:
Workspace mode: if the client explicitly requested it (workspaceMode: true from the
wizard checkbox), detect and persist sub-repos. If the client didn't specify and
auto-detection finds sub-repos, also apply it. This mirrors the CLI interactive flow.
*/
if (workspaceMode === true) {
const repos = await detectWorkspaceRepos(normalizedPath);
if (repos.length > 0) {
await saveWorkspaceConfig(normalizedPath, { repos });
await store.updateSettings({ workspaceMode: true });
}
} else if (workspaceMode === undefined) {
const repos = await detectWorkspaceRepos(normalizedPath);
if (repos.length > 0) {
await saveWorkspaceConfig(normalizedPath, { repos });
await store.updateSettings({ workspaceMode: true });
}
}
const rawPrefix = typeof taskPrefix === "string" ? taskPrefix.trim().toUpperCase() : "";
const validPrefix = /^[A-Z]{1,5}$/.test(rawPrefix) ? rawPrefix : "";
const prefix = validPrefix || suggestTaskPrefix(normalizedName);
await store.updateSettings({
taskPrefix: prefix,
defaultWorkflowId: "builtin:coding",
});
} finally {
await store.close();
}
} catch {
// Non-fatal: project registration succeeded; settings can be configured later
}

View File

@@ -0,0 +1,92 @@
/*
FNXC:Workspace 2026-06-24-15:45 (concurrent workspace tasks — shared browse-root collision regression):
In workspace mode every task runs its agent session rooted at the SHARED browse-only workspace root
(`this.rootDir`); per-sub-repo worktrees are acquired on demand. The session registrations
(executor / step-session / workflow-step) are keyed in the GLOBAL path-keyed activeSessionRegistry,
whose foreign-task guard rejects a second task registering a path already held by a different task.
With the bare root as the key, the SECOND concurrent workspace task failed with
"active-session path <root> is held by task <other>; task <self> may not overwrite it" — so only ONE
task per workspace could ever run (the reported MULT-001 vs MULT-002 failure).
Invariant under test (across ALL session-registration surfaces): two different workspace tasks sharing
the browse-root register concurrently WITHOUT collision, each remains discoverable by liveness
(pathsForTask returns a task-scoped key), and cleanup leaves no leaked entry. Negative control: a
NON-workspace executor (unique worktree path) still rejects a foreign-task overwrite, so the
cross-phase-clobber guard is preserved.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { EventEmitter } from "node:events";
import type { TaskStore } from "@fusion/core";
import { TaskExecutor } from "../executor.js";
import { activeSessionRegistry, ActiveSessionPathHeldByForeignTaskError } from "../active-session-registry.js";
const WORKSPACE_ROOT = "/tmp/fusion-test-workspace-root";
function createStore(): TaskStore & EventEmitter {
const emitter = new EventEmitter();
return Object.assign(emitter, {
logEntry: vi.fn().mockResolvedValue(undefined),
getRunContextFor: vi.fn(),
getSettings: vi.fn().mockResolvedValue({}),
}) as unknown as TaskStore & EventEmitter;
}
function makeWorkspaceExecutor(): TaskExecutor {
const executor = new TaskExecutor(createStore(), WORKSPACE_ROOT);
(executor as any).workspaceConfig = { repos: ["swarmclaw", "OpenVide"] };
return executor;
}
describe("workspace concurrent session registration", () => {
beforeEach(() => activeSessionRegistry.clear());
afterEach(() => activeSessionRegistry.clear());
it("lets two workspace tasks register executor sessions on the shared browse-root without collision", () => {
const executor = makeWorkspaceExecutor();
// Both tasks pass the SAME shared workspace root as worktreePath — the pre-fix collision point.
expect(() => (executor as any).setActiveSession("MULT-001", {}, WORKSPACE_ROOT)).not.toThrow();
expect(() => (executor as any).setActiveSession("MULT-002", {}, WORKSPACE_ROOT)).not.toThrow();
// Each task stays discoverable by liveness via a DISTINCT task-scoped registry key.
const a = activeSessionRegistry.pathsForTask("MULT-001");
const b = activeSessionRegistry.pathsForTask("MULT-002");
expect(a).toHaveLength(1);
expect(b).toHaveLength(1);
expect(a[0]).not.toEqual(b[0]);
expect(a[0]).toContain("MULT-001");
expect(b[0]).toContain("MULT-002");
});
it("cleans up the task-scoped session key on deleteActiveSession (no leak)", () => {
const executor = makeWorkspaceExecutor();
// The in-memory activeWorktrees Set holds the REAL root; deleteActiveSession must still map it
// back to the synthetic key it registered.
(executor as any).addActiveWorktree("MULT-001", WORKSPACE_ROOT);
(executor as any).setActiveSession("MULT-001", {}, WORKSPACE_ROOT);
expect(activeSessionRegistry.pathsForTask("MULT-001")).toHaveLength(1);
(executor as any).deleteActiveSession("MULT-001");
expect(activeSessionRegistry.pathsForTask("MULT-001")).toHaveLength(0);
});
it("does not collide across the step-session and workflow-step surfaces either", () => {
const executor = makeWorkspaceExecutor();
expect(() => (executor as any).setActiveStepExecutor("MULT-001", {}, WORKSPACE_ROOT)).not.toThrow();
expect(() => (executor as any).setActiveStepExecutor("MULT-002", {}, WORKSPACE_ROOT)).not.toThrow();
expect(() => (executor as any).setActiveWorkflowStepSession("MULT-001", {}, WORKSPACE_ROOT)).not.toThrow();
expect(() => (executor as any).setActiveWorkflowStepSession("MULT-002", {}, WORKSPACE_ROOT)).not.toThrow();
});
it("still rejects a foreign-task overwrite for NON-workspace tasks (clobber guard preserved)", () => {
const sharedWorktree = "/tmp/fusion-test-single-repo-worktree";
const executor = new TaskExecutor(createStore(), sharedWorktree); // no workspaceConfig → singular path
(executor as any).setActiveSession("FN-A", {}, sharedWorktree);
// A second, different task on the identical real worktree path must still be rejected — this is the
// cross-phase-clobber protection the workspace fix must not weaken.
expect(() => (executor as any).setActiveSession("FN-B", {}, sharedWorktree)).toThrow(
ActiveSessionPathHeldByForeignTaskError,
);
});
});

View File

@@ -1620,9 +1620,31 @@ export class TaskExecutor {
this.completionFinalizedTaskIds.delete(taskId);
}
/*
FNXC:Workspace 2026-06-24-15:45 (concurrent workspace tasks — shared browse-root collision):
In workspace mode `this.rootDir` is the SHARED browse-only (non-git) workspace root, and EVERY
workspace task runs its agent session rooted there (per-sub-repo worktrees are acquired on demand).
The session registrations below are keyed in the GLOBAL path-keyed activeSessionRegistry, whose
foreign-task guard rejects a second task registering a path already held by a different task. With
the bare root as the key, the second concurrent workspace task fails with "active-session path
<root> is held by task <other>; task <self> may not overwrite it" — so only ONE task per workspace
could ever run. Per-task session liveness does NOT require path-exclusivity on the shared root
(real per-sub-repo exclusivity is enforced separately by the workspace-repo-acquire lease in
worktree-acquisition.ts, keyed by sub-repo path). Give each task a task-scoped synthetic session
key so the registry stays per-task. The in-memory activeWorktrees Set still holds the REAL root, so
getActiveWorktreePaths() consumers that cd into a path are unaffected; only the registry key changes.
Non-workspace tasks (unique worktree path != rootDir) are returned unchanged.
*/
private sessionRegistryPath(taskId: string, worktreePath: string): string {
if (this.workspaceConfig && worktreePath === this.rootDir) {
return `${worktreePath}#session:${taskId}`;
}
return worktreePath;
}
private setActiveSession(taskId: string, sessionState: ActiveExecutorSessionState, worktreePath: string): void {
this.activeSessions.set(taskId, sessionState);
activeSessionRegistry.registerPath(worktreePath, { taskId, kind: "executor", ownerKey: taskId });
activeSessionRegistry.registerPath(this.sessionRegistryPath(taskId, worktreePath), { taskId, kind: "executor", ownerKey: taskId });
}
private markGraphExecuteSelfRequeued(taskId: string): void {
@@ -1638,14 +1660,17 @@ export class TaskExecutor {
// FNXC:Workspace 2026-06-21-12:00: KTD2 — when no explicit path is given, unregister EVERY worktree path the task holds (a workspace task holds N sub-repo paths); single-repo tasks resolve a one-element set.
const resolvedWorktreePaths = worktreePath ? [worktreePath] : this.getActiveWorktreePaths(taskId);
for (const path of resolvedWorktreePaths) {
activeSessionRegistry.unregisterPath(path);
// FNXC:Workspace 2026-06-24-15:45: map through sessionRegistryPath so the task-scoped synthetic
// session key registered for the shared workspace browse-root is the one we unregister (the
// in-memory Set holds the REAL root). Non-workspace/sub-repo paths pass through unchanged.
activeSessionRegistry.unregisterPath(this.sessionRegistryPath(taskId, path));
}
}
private setActiveStepExecutor(taskId: string, stepExecutor: StepSessionExecutor, worktreePath: string, seenSteeringIds = new Set<string>()): void {
this.activeStepExecutors.set(taskId, stepExecutor);
this.activeStepExecutorSeenSteeringIds.set(taskId, seenSteeringIds);
activeSessionRegistry.registerPath(worktreePath, { taskId, kind: "step-session", ownerKey: `${taskId}#step-session` });
activeSessionRegistry.registerPath(this.sessionRegistryPath(taskId, worktreePath), { taskId, kind: "step-session", ownerKey: `${taskId}#step-session` });
}
private deleteActiveStepExecutor(taskId: string, worktreePath?: string): void {
@@ -1656,14 +1681,17 @@ export class TaskExecutor {
// FNXC:Workspace 2026-06-21-12:00: KTD2 — unregister every held worktree path (Set), not one.
const resolvedWorktreePaths = worktreePath ? [worktreePath] : this.getActiveWorktreePaths(taskId);
for (const path of resolvedWorktreePaths) {
activeSessionRegistry.unregisterPath(path);
// FNXC:Workspace 2026-06-24-15:45: map through sessionRegistryPath so the task-scoped synthetic
// session key registered for the shared workspace browse-root is the one we unregister (the
// in-memory Set holds the REAL root). Non-workspace/sub-repo paths pass through unchanged.
activeSessionRegistry.unregisterPath(this.sessionRegistryPath(taskId, path));
}
}
private setActiveWorkflowStepSession(taskId: string, session: AgentSession, worktreePath: string, seenSteeringIds = new Set<string>()): void {
this.activeWorkflowStepSessions.set(taskId, session);
this.activeWorkflowStepSessionSeenSteeringIds.set(taskId, seenSteeringIds);
activeSessionRegistry.registerPath(worktreePath, { taskId, kind: "workflow-step", ownerKey: `${taskId}#workflow-step` });
activeSessionRegistry.registerPath(this.sessionRegistryPath(taskId, worktreePath), { taskId, kind: "workflow-step", ownerKey: `${taskId}#workflow-step` });
}
private deleteActiveWorkflowStepSession(taskId: string, worktreePath?: string): void {
@@ -1672,7 +1700,10 @@ export class TaskExecutor {
// FNXC:Workspace 2026-06-21-12:00: KTD2 — unregister every held worktree path (Set), not one.
const resolvedWorktreePaths = worktreePath ? [worktreePath] : this.getActiveWorktreePaths(taskId);
for (const path of resolvedWorktreePaths) {
activeSessionRegistry.unregisterPath(path);
// FNXC:Workspace 2026-06-24-15:45: map through sessionRegistryPath so the task-scoped synthetic
// session key registered for the shared workspace browse-root is the one we unregister (the
// in-memory Set holds the REAL root). Non-workspace/sub-repo paths pass through unchanged.
activeSessionRegistry.unregisterPath(this.sessionRegistryPath(taskId, path));
}
}