feat(FN-2962): merge fusion/fn-2962
- Add changeset for `@runfusion/fusion` minor release introducing custom provider registration support Commits merged: - feat(FN-2962): complete Step 8 — add changeset and documentation Files changed: .changeset/register-custom-providers.md | 5 +++++ 1 file changed, 5 insertions(+) Fusion-Task-Id: FN-2962
This commit is contained in:
@@ -4229,6 +4229,17 @@ export function updateAgent(agentId: string, updates: AgentUpdateInput, projectI
|
||||
});
|
||||
}
|
||||
|
||||
/** Backfill an existing agent onto the default heartbeat procedure file. */
|
||||
export function upgradeAgentHeartbeatProcedure(
|
||||
agentId: string,
|
||||
projectId?: string,
|
||||
): Promise<{ agent: Agent; heartbeatProcedurePath: string; procedureFileSeeded: boolean }> {
|
||||
return api(
|
||||
withProjectId(`/agents/${encodeURIComponent(agentId)}/upgrade-heartbeat-procedure`, projectId),
|
||||
{ method: "POST" },
|
||||
);
|
||||
}
|
||||
|
||||
/** Update agent custom instructions */
|
||||
export function updateAgentInstructions(
|
||||
agentId: string,
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import type { AgentDetail, AgentState, AgentHeartbeatRun, AgentBudgetStatus, ModelInfo, MemoryFileInfo, AgentCapability, PluginRuntimeInfo } from "../api";
|
||||
import { fetchAgent, updateAgent, updateAgentState, deleteAgent, fetchAgentLogsWithMeta, fetchAgentRunLogs, fetchAgentChildren, fetchAgentRuns, fetchAgentRunDetail, startAgentRun, stopAgentRun, updateAgentInstructions, updateAgentSoul, updateAgentMemory, fetchAgentMemoryFiles, fetchAgentMemoryFile, saveAgentMemoryFile, fetchAgentTasks, fetchChainOfCommand, fetchAgentBudgetStatus, resetAgentBudget, fetchWorkspaceFileContent, saveWorkspaceFileContent, fetchModels, fetchPluginRuntimes, fetchAgents } from "../api";
|
||||
import { fetchAgent, updateAgent, updateAgentState, deleteAgent, fetchAgentLogsWithMeta, fetchAgentRunLogs, fetchAgentChildren, fetchAgentRuns, fetchAgentRunDetail, startAgentRun, stopAgentRun, updateAgentInstructions, updateAgentSoul, updateAgentMemory, fetchAgentMemoryFiles, fetchAgentMemoryFile, saveAgentMemoryFile, fetchAgentTasks, fetchChainOfCommand, fetchAgentBudgetStatus, resetAgentBudget, fetchWorkspaceFileContent, saveWorkspaceFileContent, fetchModels, fetchPluginRuntimes, fetchAgents, upgradeAgentHeartbeatProcedure } from "../api";
|
||||
import type { Agent } from "../api";
|
||||
import type { AgentLogEntry, Task } from "@fusion/core";
|
||||
import { getErrorMessage } from "@fusion/core";
|
||||
@@ -2509,14 +2509,93 @@ function deriveBudgetValues(runtimeConfig: AgentDetail["runtimeConfig"] | undefi
|
||||
return nextValues;
|
||||
}
|
||||
|
||||
function ConfigTab({
|
||||
function HeartbeatProcedureSection({
|
||||
agent,
|
||||
projectId,
|
||||
addToast,
|
||||
onSaved,
|
||||
}: {
|
||||
agent: AgentDetail;
|
||||
projectId?: string;
|
||||
addToast: (message: string, type?: "success" | "error") => void;
|
||||
onSaved: () => Promise<void>;
|
||||
}) {
|
||||
const [isUpgrading, setIsUpgrading] = useState(false);
|
||||
const currentPath = agent.heartbeatProcedurePath?.trim();
|
||||
const onDefault = currentPath === ".fusion/HEARTBEAT.md";
|
||||
|
||||
const handleUpgrade = async () => {
|
||||
setIsUpgrading(true);
|
||||
try {
|
||||
const result = await upgradeAgentHeartbeatProcedure(agent.id, projectId);
|
||||
addToast(
|
||||
result.procedureFileSeeded
|
||||
? `Heartbeat procedure file ready at ${result.heartbeatProcedurePath}`
|
||||
: `Heartbeat procedure path set to ${result.heartbeatProcedurePath}`,
|
||||
"success",
|
||||
);
|
||||
await onSaved();
|
||||
} catch (err) {
|
||||
addToast(`Failed to upgrade heartbeat procedure: ${getErrorMessage(err)}`, "error");
|
||||
} finally {
|
||||
setIsUpgrading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="config-section">
|
||||
<h3>Heartbeat Procedure</h3>
|
||||
<p className="config-description">
|
||||
The per-tick procedure this agent runs every wake. Defaults to a project-level
|
||||
markdown file you can edit. Resets on every tick — no need to restart the agent
|
||||
after editing.
|
||||
</p>
|
||||
<div className="config-fields">
|
||||
<div className="config-field">
|
||||
<span className="config-hint">
|
||||
Current path: <code>{currentPath || "(none — using built-in default)"}</code>
|
||||
</span>
|
||||
</div>
|
||||
<div className="config-field">
|
||||
<button
|
||||
className="btn"
|
||||
disabled={isUpgrading || onDefault}
|
||||
onClick={() => void handleUpgrade()}
|
||||
aria-label="Upgrade agent to default heartbeat procedure file"
|
||||
>
|
||||
{isUpgrading ? (
|
||||
<>
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
Upgrading…
|
||||
</>
|
||||
) : onDefault ? (
|
||||
<>
|
||||
<CheckCircle size={16} />
|
||||
Already on default
|
||||
</>
|
||||
) : (
|
||||
"Upgrade to Default Heartbeat Procedure"
|
||||
)}
|
||||
</button>
|
||||
<span className="config-hint">
|
||||
Sets <code>heartbeatProcedurePath</code> to <code>.fusion/HEARTBEAT.md</code>
|
||||
{" "}and seeds the file from the built-in template if it doesn't exist.
|
||||
Operator edits to the file are preserved.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConfigTab({
|
||||
agent,
|
||||
projectId,
|
||||
addToast,
|
||||
onSaved,
|
||||
onHasChangesChange,
|
||||
onDelete,
|
||||
}: {
|
||||
}: {
|
||||
agent: AgentDetail;
|
||||
projectId?: string;
|
||||
addToast: (message: string, type?: "success" | "error") => void;
|
||||
@@ -3603,6 +3682,13 @@ function ConfigTab({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<HeartbeatProcedureSection
|
||||
agent={agent}
|
||||
projectId={projectId}
|
||||
addToast={addToast}
|
||||
onSaved={onSaved}
|
||||
/>
|
||||
|
||||
<div className="config-section config-section--danger">
|
||||
<h3>Danger Zone</h3>
|
||||
<p className="config-description">
|
||||
|
||||
@@ -746,7 +746,14 @@ export function ModelOnboardingModal({
|
||||
const loadCustomProviders = useCallback(async () => {
|
||||
try {
|
||||
const data = await fetchCustomProviders();
|
||||
setCustomProviders(data.providers ?? []);
|
||||
setCustomProviders((data.providers ?? []).map((provider) => ({
|
||||
id: provider.id,
|
||||
name: provider.name,
|
||||
baseUrl: provider.baseUrl,
|
||||
api: provider.apiType === "anthropic-compatible" ? "anthropic-messages" : "openai-completions",
|
||||
apiKey: provider.apiKey,
|
||||
models: (provider.models ?? []).map((model) => ({ id: model.id, name: model.name })),
|
||||
})));
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
|
||||
@@ -786,7 +786,14 @@ export function SettingsModal({
|
||||
if (activeSection === "authentication") {
|
||||
setAuthLoading(true);
|
||||
loadAuthStatus().finally(() => setAuthLoading(false));
|
||||
void fetchCustomProviders().then((data) => setCustomProviders(data.providers ?? [])).catch(() => undefined);
|
||||
void fetchCustomProviders().then((data) => setCustomProviders((data.providers ?? []).map((provider) => ({
|
||||
id: provider.id,
|
||||
name: provider.name,
|
||||
baseUrl: provider.baseUrl,
|
||||
api: provider.apiType === "anthropic-compatible" ? "anthropic-messages" : "openai-completions",
|
||||
apiKey: provider.apiKey,
|
||||
models: (provider.models ?? []).map((model) => ({ id: model.id, name: model.name })),
|
||||
})))).catch(() => undefined);
|
||||
}
|
||||
// Clean up polling when leaving auth section
|
||||
return () => {
|
||||
@@ -799,7 +806,14 @@ export function SettingsModal({
|
||||
|
||||
const loadCustomProviders = useCallback(async () => {
|
||||
const data = await fetchCustomProviders();
|
||||
setCustomProviders(data.providers ?? []);
|
||||
setCustomProviders((data.providers ?? []).map((provider) => ({
|
||||
id: provider.id,
|
||||
name: provider.name,
|
||||
baseUrl: provider.baseUrl,
|
||||
api: provider.apiType === "anthropic-compatible" ? "anthropic-messages" : "openai-completions",
|
||||
apiKey: provider.apiKey,
|
||||
models: (provider.models ?? []).map((model) => ({ id: model.id, name: model.name })),
|
||||
})));
|
||||
}, []);
|
||||
|
||||
const handleSaveCustomProvider = useCallback(async (config: CustomProviderConfig) => {
|
||||
|
||||
@@ -125,6 +125,21 @@ function getInProgressElapsedMs(task: Task, nowMs: number): number | null {
|
||||
return Math.max(0, nowMs - startedMs);
|
||||
}
|
||||
|
||||
// Wall-clock end-to-end runtime: from when the task first entered in-progress
|
||||
// to when it first entered done (or `now` if not yet done). Preferred over the
|
||||
// instrumented `[timing]` sum on cards in in-progress / in-review / done so the
|
||||
// timer reflects how long the task actually took, not just the time spent
|
||||
// inside instrumented code paths. Returns null on legacy tasks that completed
|
||||
// before `executionStartedAt` was tracked, so callers can fall back.
|
||||
function getEndToEndDurationMs(task: Task, nowMs: number): number | null {
|
||||
const startedMs = parseTimestampToMs(task.executionStartedAt);
|
||||
if (startedMs == null) return null;
|
||||
|
||||
const completedMs = parseTimestampToMs(task.executionCompletedAt);
|
||||
const endMs = completedMs != null && completedMs >= startedMs ? completedMs : nowMs;
|
||||
return Math.max(0, endMs - startedMs);
|
||||
}
|
||||
|
||||
// Mirrors summarizeWorkflowTiming in TaskTokenStatsPanel: completed steps use
|
||||
// completedAt-startedAt; in-progress steps contribute live elapsed (now-startedAt).
|
||||
function getWorkflowRuntimeMs(task: Task, nowMs: number): number | null {
|
||||
@@ -697,16 +712,18 @@ function TaskCardComponent({
|
||||
const merging = task.status != null && ACTIVE_MERGE_STATUSES.has(task.status);
|
||||
|
||||
if (!merging && task.column === "in-progress") {
|
||||
const endToEndMs = getEndToEndDurationMs(task, Date.now());
|
||||
const elapsedMs = getInProgressElapsedMs(task, Date.now());
|
||||
const instrumentedMs = getInstrumentedDurationMs(task, Date.now());
|
||||
if (elapsedMs == null && instrumentedMs == null) {
|
||||
if (endToEndMs == null && elapsedMs == null && instrumentedMs == null) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!merging && task.column === "in-review") {
|
||||
const endToEndMs = getEndToEndDurationMs(task, Date.now());
|
||||
const instrumentedMs = getInstrumentedDurationMs(task, Date.now());
|
||||
if (instrumentedMs == null) {
|
||||
if (endToEndMs == null && instrumentedMs == null) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -717,7 +734,7 @@ function TaskCardComponent({
|
||||
}, LIVE_TIME_INDICATOR_POLL_MS);
|
||||
|
||||
return () => window.clearInterval(interval);
|
||||
}, [task.column, task.status, task.columnMovedAt, task.updatedAt, task.workflowStepResults, task.timedExecutionMs]);
|
||||
}, [task.column, task.status, task.columnMovedAt, task.updatedAt, task.workflowStepResults, task.timedExecutionMs, task.executionStartedAt, task.executionCompletedAt]);
|
||||
|
||||
const timeIndicator = useMemo(() => {
|
||||
if (!TIME_INDICATOR_COLUMNS.has(task.column)) {
|
||||
@@ -744,8 +761,12 @@ function TaskCardComponent({
|
||||
}
|
||||
|
||||
if (task.column === "in-progress") {
|
||||
// Prefer the persistent execution start (set on first transition to
|
||||
// in-progress, never reset on retry-loop bounces). Fall back to the
|
||||
// columnMovedAt heuristic for legacy tasks predating the new field.
|
||||
const elapsedMs =
|
||||
getInProgressElapsedMs(task, timeIndicatorNowMs)
|
||||
getEndToEndDurationMs(task, timeIndicatorNowMs)
|
||||
?? getInProgressElapsedMs(task, timeIndicatorNowMs)
|
||||
?? getInstrumentedDurationMs(task, timeIndicatorNowMs);
|
||||
if (elapsedMs == null) {
|
||||
return null;
|
||||
@@ -756,20 +777,23 @@ function TaskCardComponent({
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasColumnElapsed = getInProgressElapsedMs(task, timeIndicatorNowMs) != null;
|
||||
return {
|
||||
label: elapsedLabel,
|
||||
title: hasColumnElapsed ? `In progress ${elapsedLabel}` : `Execution time ${elapsedLabel}`,
|
||||
ariaLabel: hasColumnElapsed ? `In progress ${elapsedLabel}` : `Execution time ${elapsedLabel}`,
|
||||
title: `In progress ${elapsedLabel}`,
|
||||
ariaLabel: `In progress ${elapsedLabel}`,
|
||||
};
|
||||
}
|
||||
|
||||
const instrumentedMs = getInstrumentedDurationMs(task, timeIndicatorNowMs);
|
||||
if (instrumentedMs == null) {
|
||||
// in-review and done: show wall-clock end-to-end runtime. Falls back to
|
||||
// the instrumented `[timing]` aggregate for tasks completed before
|
||||
// `executionStartedAt`/`executionCompletedAt` were tracked.
|
||||
const endToEndMs = getEndToEndDurationMs(task, timeIndicatorNowMs);
|
||||
const totalMs = endToEndMs ?? getInstrumentedDurationMs(task, timeIndicatorNowMs);
|
||||
if (totalMs == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const elapsedLabel = formatElapsedDurationDone(instrumentedMs);
|
||||
const elapsedLabel = formatElapsedDurationDone(totalMs);
|
||||
if (!elapsedLabel) {
|
||||
return null;
|
||||
}
|
||||
@@ -789,7 +813,7 @@ function TaskCardComponent({
|
||||
title: `Execution time ${elapsedLabel}. Completed ${completedAt}`,
|
||||
ariaLabel: `Execution time ${elapsedLabel}. Completed ${completedAt}`,
|
||||
};
|
||||
}, [task.column, task.status, task.columnMovedAt, task.timedExecutionMs, task.updatedAt, task.workflowStepResults, task.log, timeIndicatorNowMs]);
|
||||
}, [task.column, task.status, task.columnMovedAt, task.timedExecutionMs, task.updatedAt, task.workflowStepResults, task.log, task.executionStartedAt, task.executionCompletedAt, timeIndicatorNowMs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasGitHubBadge || !isInViewport) {
|
||||
|
||||
@@ -8546,6 +8546,40 @@ describe("Git Management endpoints", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /git/changes", () => {
|
||||
const resetGitRepo = () => {
|
||||
const { headSha } = getSharedGitTestRepo();
|
||||
execFileSync("git", ["-C", gitRepoDir, "reset", "--hard", headSha], { stdio: "pipe" });
|
||||
execFileSync("git", ["-C", gitRepoDir, "clean", "-fd"], { stdio: "pipe" });
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
resetGitRepo();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetGitRepo();
|
||||
});
|
||||
|
||||
it("preserves the first unstaged entry instead of misclassifying it as staged", async () => {
|
||||
const readmePath = join(gitRepoDir, "README.md");
|
||||
const original = readFileSync(readmePath, "utf-8");
|
||||
const marker = `\nchanges-first-line-${Date.now()}\n`;
|
||||
writeFileSync(readmePath, `${original}${marker}`);
|
||||
|
||||
const res = await GET(buildApp(), "/api/git/changes");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual([
|
||||
{
|
||||
file: "README.md",
|
||||
status: "modified",
|
||||
staged: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /git/diff/file", () => {
|
||||
const resetGitRepo = () => {
|
||||
const { headSha } = getSharedGitTestRepo();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Request, Response } from "express";
|
||||
import type { Agent, AgentCapability, AgentUpdateInput, TaskStore } from "@fusion/core";
|
||||
import { DEFAULT_HEARTBEAT_PROCEDURE_PATH } from "@fusion/core";
|
||||
import { ApiError, badRequest, notFound } from "../api-error.js";
|
||||
import type { ApiRoutesContext } from "./types.js";
|
||||
|
||||
@@ -66,6 +67,7 @@ export function registerAgentCoreListCreateRoutes(ctx: ApiRoutesContext, deps: A
|
||||
soul,
|
||||
memory,
|
||||
bundleConfig,
|
||||
heartbeatProcedurePath,
|
||||
} = req.body ?? {};
|
||||
|
||||
if (!name || typeof name !== "string") {
|
||||
@@ -107,6 +109,12 @@ export function registerAgentCoreListCreateRoutes(ctx: ApiRoutesContext, deps: A
|
||||
if (typeof memory === "string" && memory.length > 50000) {
|
||||
throw badRequest("memory must be at most 50,000 characters");
|
||||
}
|
||||
if (heartbeatProcedurePath !== undefined && heartbeatProcedurePath !== null && typeof heartbeatProcedurePath !== "string") {
|
||||
throw badRequest("heartbeatProcedurePath must be a string");
|
||||
}
|
||||
if (typeof heartbeatProcedurePath === "string" && heartbeatProcedurePath.length > 500) {
|
||||
throw badRequest("heartbeatProcedurePath must be at most 500 characters");
|
||||
}
|
||||
if (bundleConfig !== undefined && bundleConfig !== null) {
|
||||
if (typeof bundleConfig !== "object" || Array.isArray(bundleConfig)) {
|
||||
throw badRequest("bundleConfig must be an object");
|
||||
@@ -144,7 +152,21 @@ export function registerAgentCoreListCreateRoutes(ctx: ApiRoutesContext, deps: A
|
||||
soul: soul ?? undefined,
|
||||
memory: memory ?? undefined,
|
||||
bundleConfig: bundleConfig ?? undefined,
|
||||
heartbeatProcedurePath: heartbeatProcedurePath ?? undefined,
|
||||
});
|
||||
|
||||
// Seed the default heartbeat procedure file if the new agent landed on
|
||||
// the default path (which createAgent fills in for non-ephemeral agents
|
||||
// when no override is provided). Idempotent — operator edits are kept.
|
||||
if (agent.heartbeatProcedurePath === DEFAULT_HEARTBEAT_PROCEDURE_PATH) {
|
||||
try {
|
||||
const { ensureDefaultHeartbeatProcedureFile, HEARTBEAT_PROCEDURE } = await import("@fusion/engine");
|
||||
await ensureDefaultHeartbeatProcedureFile(scopedStore.getRootDir(), DEFAULT_HEARTBEAT_PROCEDURE_PATH, HEARTBEAT_PROCEDURE);
|
||||
} catch {
|
||||
// Non-fatal — the heartbeat resolver falls back to the in-memory constant.
|
||||
}
|
||||
}
|
||||
|
||||
res.status(201).json(agent);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
@@ -394,6 +416,16 @@ export function registerAgentCoreRoutes(ctx: ApiRoutesContext, deps: AgentCoreRo
|
||||
updates.memory = body.memory ?? undefined;
|
||||
}
|
||||
|
||||
if ("heartbeatProcedurePath" in body) {
|
||||
if (body.heartbeatProcedurePath !== null && typeof body.heartbeatProcedurePath !== "string") {
|
||||
throw badRequest("heartbeatProcedurePath must be a string");
|
||||
}
|
||||
if (typeof body.heartbeatProcedurePath === "string" && body.heartbeatProcedurePath.length > 500) {
|
||||
throw badRequest("heartbeatProcedurePath must be at most 500 characters");
|
||||
}
|
||||
updates.heartbeatProcedurePath = body.heartbeatProcedurePath ?? undefined;
|
||||
}
|
||||
|
||||
if ("bundleConfig" in body) {
|
||||
if (body.bundleConfig !== null) {
|
||||
if (typeof body.bundleConfig !== "object" || Array.isArray(body.bundleConfig)) {
|
||||
@@ -436,6 +468,52 @@ export function registerAgentCoreRoutes(ctx: ApiRoutesContext, deps: AgentCoreRo
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/agents/:id/upgrade-heartbeat-procedure
|
||||
* Backfill an existing agent onto the default heartbeat procedure file.
|
||||
* Sets `heartbeatProcedurePath` to DEFAULT_HEARTBEAT_PROCEDURE_PATH and
|
||||
* seeds the file with the built-in HEARTBEAT_PROCEDURE if it doesn't exist.
|
||||
* Idempotent: existing operator edits to the file are preserved.
|
||||
*/
|
||||
router.post("/agents/:id/upgrade-heartbeat-procedure", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const { AgentStore } = await import("@fusion/core");
|
||||
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
|
||||
await agentStore.init();
|
||||
|
||||
const existing = await agentStore.getAgent(req.params.id);
|
||||
if (!existing) {
|
||||
throw notFound(`agent ${req.params.id} not found`);
|
||||
}
|
||||
|
||||
const { ensureDefaultHeartbeatProcedureFile, HEARTBEAT_PROCEDURE } = await import("@fusion/engine");
|
||||
const filePath = await ensureDefaultHeartbeatProcedureFile(
|
||||
scopedStore.getRootDir(),
|
||||
DEFAULT_HEARTBEAT_PROCEDURE_PATH,
|
||||
HEARTBEAT_PROCEDURE,
|
||||
);
|
||||
|
||||
const updated = await agentStore.updateAgent(req.params.id, {
|
||||
heartbeatProcedurePath: DEFAULT_HEARTBEAT_PROCEDURE_PATH,
|
||||
});
|
||||
|
||||
res.json({
|
||||
agent: updated,
|
||||
heartbeatProcedurePath: DEFAULT_HEARTBEAT_PROCEDURE_PATH,
|
||||
procedureFileSeeded: filePath !== null,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
if ((err instanceof Error ? err.message : String(err)).includes("not found")) {
|
||||
throw notFound(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/agents/:id
|
||||
* Delete an agent.
|
||||
|
||||
@@ -717,15 +717,19 @@ export async function dropGitStash(index: number, cwd?: string): Promise<string>
|
||||
|
||||
export async function getGitFileChanges(cwd?: string): Promise<GitFileChange[]> {
|
||||
try {
|
||||
const output = (await runGitCommand(["status", "--porcelain=v1"], cwd, 5000)).trim();
|
||||
if (!output) return [];
|
||||
const output = await runGitCommand(["status", "--porcelain=v1"], cwd, 5000);
|
||||
if (!output.trim()) return [];
|
||||
|
||||
const changes: GitFileChange[] = [];
|
||||
for (const line of output.split("\n")) {
|
||||
if (line.length < 3) continue;
|
||||
const indexStatus = line[0];
|
||||
const workTreeStatus = line[1];
|
||||
const filePath = line.slice(3).trim();
|
||||
// Preserve leading status spaces from porcelain output. Trimming the
|
||||
// whole command output corrupts the first unstaged entry (`" M foo"` →
|
||||
// `"M foo"`), which misclassifies it as staged and truncates the path.
|
||||
const normalizedLine = line.replace(/\r$/, "");
|
||||
if (normalizedLine.length < 3) continue;
|
||||
const indexStatus = normalizedLine[0];
|
||||
const workTreeStatus = normalizedLine[1];
|
||||
const filePath = normalizedLine.slice(3).trim();
|
||||
|
||||
const mapStatus = (code: string): GitFileChange["status"] => {
|
||||
switch (code) {
|
||||
|
||||
Reference in New Issue
Block a user