Merge branch 'main' into feature/refactor-apptsx
This commit is contained in:
5
.changeset/multiworkspace-worktree-persistence.md
Normal file
5
.changeset/multiworkspace-worktree-persistence.md
Normal 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 …".
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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];
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user