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:
Fusion
2026-04-29 21:42:44 -07:00
committed by gsxdsm
parent 6c051b1851
commit 64b5f677ff
33 changed files with 1048 additions and 60 deletions

View File

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

View File

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