feat(FN-4879): merge fusion/fn-4879
This commit is contained in:
92
packages/cli/src/__tests__/extension-fn-secret-get.test.ts
Normal file
92
packages/cli/src/__tests__/extension-fn-secret-get.test.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
const resolveSecretAccessPolicyMock = vi.hoisted(() => vi.fn());
|
||||||
|
const revealSecretMock = vi.hoisted(() => vi.fn());
|
||||||
|
const listSecretsMock = vi.hoisted(() => vi.fn());
|
||||||
|
const approvalCreateMock = vi.hoisted(() => vi.fn());
|
||||||
|
const approvalFindLatestByDedupeKeyMock = vi.hoisted(() => vi.fn());
|
||||||
|
const recordRunAuditEventMock = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
|
vi.mock("@fusion/dashboard", () => ({ registerGithubTrackingHook: vi.fn() }));
|
||||||
|
vi.mock("@fusion/engine", () => ({ createFnAgent: vi.fn(), fetchWebContent: vi.fn() }));
|
||||||
|
|
||||||
|
vi.mock("@fusion/core", async () => {
|
||||||
|
const actual = await vi.importActual<typeof import("@fusion/core")>("@fusion/core");
|
||||||
|
class MockTaskStore {
|
||||||
|
async init() {}
|
||||||
|
async getSecretsStore() {
|
||||||
|
return { listSecrets: listSecretsMock, revealSecret: revealSecretMock };
|
||||||
|
}
|
||||||
|
getGlobalSettingsStore() {
|
||||||
|
return { getSettings: async () => ({ secretsAccessPolicy: "prompt" }) };
|
||||||
|
}
|
||||||
|
recordRunAuditEvent = recordRunAuditEventMock;
|
||||||
|
getDatabase() {
|
||||||
|
return {} as any;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
class MockApprovalRequestStore {
|
||||||
|
constructor(_db: unknown) {}
|
||||||
|
findLatestByDedupeKey = approvalFindLatestByDedupeKeyMock;
|
||||||
|
create = approvalCreateMock;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
TaskStore: MockTaskStore,
|
||||||
|
ApprovalRequestStore: MockApprovalRequestStore,
|
||||||
|
resolveSecretAccessPolicy: resolveSecretAccessPolicyMock,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import kbExtension from "../extension.js";
|
||||||
|
|
||||||
|
describe("extension fn_secret_get", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
listSecretsMock.mockImplementation((scope?: "project" | "global") => {
|
||||||
|
if (scope === "project") return [{ id: "s1", key: "API_KEY", accessPolicy: "auto" }];
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
revealSecretMock.mockResolvedValue({ key: "API_KEY", plaintextValue: "secret-value" });
|
||||||
|
resolveSecretAccessPolicyMock.mockReturnValue({ policy: "auto", source: "secret" });
|
||||||
|
approvalFindLatestByDedupeKeyMock.mockReturnValue(null);
|
||||||
|
approvalCreateMock.mockReturnValue({ id: "apr-1", status: "pending" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns value for auto policy", async () => {
|
||||||
|
const tools = new Map<string, any>();
|
||||||
|
kbExtension({ registerTool: (d: any) => tools.set(d.name, d), registerCommand: vi.fn(), registerShortcut: vi.fn(), registerFlag: vi.fn(), on: vi.fn() } as any);
|
||||||
|
const tool = tools.get("fn_secret_get");
|
||||||
|
const result = await tool.execute("id", { key: "API_KEY" }, undefined, undefined, { cwd: process.cwd(), agentId: "agent-1", runId: "run-1" });
|
||||||
|
expect(result.details.value).toBe("secret-value");
|
||||||
|
expect(approvalCreateMock).not.toHaveBeenCalled();
|
||||||
|
expect(recordRunAuditEventMock.mock.calls[0][0].mutationType).toBe("secret:read");
|
||||||
|
expect(JSON.stringify(recordRunAuditEventMock.mock.calls[0][0])).not.toContain("secret-value");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns pending_approval for prompt policy", async () => {
|
||||||
|
resolveSecretAccessPolicyMock.mockReturnValue({ policy: "prompt", source: "secret" });
|
||||||
|
const tools = new Map<string, any>();
|
||||||
|
kbExtension({ registerTool: (d: any) => tools.set(d.name, d), registerCommand: vi.fn(), registerShortcut: vi.fn(), registerFlag: vi.fn(), on: vi.fn() } as any);
|
||||||
|
const tool = tools.get("fn_secret_get");
|
||||||
|
const result = await tool.execute("id", { key: "API_KEY" }, undefined, undefined, { cwd: process.cwd(), agentId: "agent-1" });
|
||||||
|
expect(result.details.outcome).toBe("pending_approval");
|
||||||
|
expect(approvalCreateMock).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns denied for deny policy and not found when missing", async () => {
|
||||||
|
const tools = new Map<string, any>();
|
||||||
|
kbExtension({ registerTool: (d: any) => tools.set(d.name, d), registerCommand: vi.fn(), registerShortcut: vi.fn(), registerFlag: vi.fn(), on: vi.fn() } as any);
|
||||||
|
const tool = tools.get("fn_secret_get");
|
||||||
|
|
||||||
|
resolveSecretAccessPolicyMock.mockReturnValue({ policy: "deny", source: "secret" });
|
||||||
|
const denied = await tool.execute("id", { key: "API_KEY" }, undefined, undefined, { cwd: process.cwd(), agentId: "agent-1", runId: "run-1" });
|
||||||
|
expect(denied.details.error).toBe("denied");
|
||||||
|
expect(revealSecretMock).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
listSecretsMock.mockReturnValue([]);
|
||||||
|
const missing = await tool.execute("id", { key: "NOPE" }, undefined, undefined, { cwd: process.cwd(), agentId: "agent-1" });
|
||||||
|
expect(missing.details.error).toBe("not-found");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
formatRoleMismatchReason,
|
formatRoleMismatchReason,
|
||||||
resolveAgentProvisioningPolicy,
|
resolveAgentProvisioningPolicy,
|
||||||
TASK_PRIORITIES,
|
TASK_PRIORITIES,
|
||||||
|
resolveSecretAccessPolicy,
|
||||||
} from "@fusion/core";
|
} from "@fusion/core";
|
||||||
import {
|
import {
|
||||||
getGhErrorMessage,
|
getGhErrorMessage,
|
||||||
@@ -97,6 +98,25 @@ function getFusionDir(cwd: string): string {
|
|||||||
return join(resolveProjectRoot(cwd), ".fusion");
|
return join(resolveProjectRoot(cwd), ".fusion");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function emitSecretAudit(
|
||||||
|
store: TaskStore,
|
||||||
|
ctx: { runId?: string; agentId?: string; taskId?: string },
|
||||||
|
mutationType: string,
|
||||||
|
target: string,
|
||||||
|
metadata?: Record<string, unknown>,
|
||||||
|
): void {
|
||||||
|
if (!ctx.runId || !ctx.agentId) return;
|
||||||
|
store.recordRunAuditEvent({
|
||||||
|
runId: ctx.runId,
|
||||||
|
agentId: ctx.agentId,
|
||||||
|
taskId: ctx.taskId,
|
||||||
|
domain: "filesystem",
|
||||||
|
mutationType,
|
||||||
|
target,
|
||||||
|
metadata,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate an agent id supplied to task create/update tools.
|
* Validate an agent id supplied to task create/update tools.
|
||||||
* Returns null on success, or an error message describing why the id was rejected.
|
* Returns null on success, or an error message describing why the id was rejected.
|
||||||
@@ -1490,6 +1510,82 @@ export default function kbExtension(pi: ExtensionAPI) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
pi.registerTool({
|
||||||
|
name: "fn_secret_get",
|
||||||
|
label: "fn: Secret Get",
|
||||||
|
description: "Read a secret by key using per-secret access policy.",
|
||||||
|
parameters: Type.Object({
|
||||||
|
key: Type.String({ description: "Secret key" }),
|
||||||
|
scope: Type.Optional(Type.Union([Type.Literal("project"), Type.Literal("global")], { description: "Optional scope" })),
|
||||||
|
}),
|
||||||
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||||
|
const store = await getStore(ctx.cwd);
|
||||||
|
const secretsStore = await store.getSecretsStore();
|
||||||
|
const scopes = params.scope ? [params.scope] : ["project", "global"];
|
||||||
|
|
||||||
|
let record: import("@fusion/core").SecretRecord | null = null;
|
||||||
|
let resolvedScope: "project" | "global" | null = null;
|
||||||
|
for (const scope of scopes) {
|
||||||
|
const match = secretsStore.listSecrets(scope).find((candidate) => candidate.key === params.key);
|
||||||
|
if (match) {
|
||||||
|
record = match;
|
||||||
|
resolvedScope = scope;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!record || !resolvedScope) {
|
||||||
|
return { content: [{ type: "text", text: `Secret '${params.key}' not found.` }], details: { error: "not-found", key: params.key, scope: params.scope ?? null } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const globalSettings = await store.getGlobalSettingsStore().getSettings();
|
||||||
|
const decision = resolveSecretAccessPolicy({
|
||||||
|
secretPolicy: record.accessPolicy,
|
||||||
|
settings: { secretsAccessPolicy: globalSettings.secretsAccessPolicy },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (decision.policy === "deny") {
|
||||||
|
emitSecretAudit(store, ctx as { runId?: string; agentId?: string; taskId?: string }, "secret:approval-denied", `${resolvedScope}:${params.key}`);
|
||||||
|
return { content: [{ type: "text", text: "Secret access denied by policy." }], details: { error: "denied", key: params.key, scope: resolvedScope, policySource: decision.source } };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (decision.policy === "prompt") {
|
||||||
|
const { ApprovalRequestStore } = await import("@fusion/core");
|
||||||
|
const approvalStore = new ApprovalRequestStore(store.getDatabase());
|
||||||
|
const dedupeKey = `secret-read:${resolvedScope}:${params.key}:${ctx.agentId ?? "unknown"}`;
|
||||||
|
const existing = approvalStore.findLatestByDedupeKey({ requesterActorId: ctx.agentId ?? "user", taskId: (ctx as { taskId?: string }).taskId, dedupeKey });
|
||||||
|
const request = existing && existing.status === "pending"
|
||||||
|
? existing
|
||||||
|
: approvalStore.create({
|
||||||
|
requester: { actorId: ctx.agentId ?? "user", actorType: "agent", actorName: ctx.agentName ?? ctx.agentId ?? "Agent" },
|
||||||
|
targetAction: {
|
||||||
|
category: "secrets_access",
|
||||||
|
action: "read",
|
||||||
|
summary: `Read secret ${params.key}`,
|
||||||
|
resourceType: "secret",
|
||||||
|
resourceId: record.id,
|
||||||
|
context: { approvalDedupeKey: dedupeKey, key: params.key, scope: resolvedScope },
|
||||||
|
},
|
||||||
|
...(ctx.runId ? { runId: ctx.runId } : {}),
|
||||||
|
...((ctx as { taskId?: string }).taskId ? { taskId: (ctx as { taskId?: string }).taskId } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
emitSecretAudit(store, ctx as { runId?: string; agentId?: string; taskId?: string }, "secret:approval-requested", `${resolvedScope}:${params.key}`);
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: `Secret access requires approval. Request ${request.id} is pending. Approve via POST /api/approvals/:id/decision.` }],
|
||||||
|
details: { outcome: "pending_approval", approvalRequestId: request.id, key: params.key, scope: resolvedScope },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const revealed = await secretsStore.revealSecret(record.id, resolvedScope, { agentId: ctx.agentId ?? null });
|
||||||
|
emitSecretAudit(store, ctx as { runId?: string; agentId?: string; taskId?: string }, "secret:read", `${resolvedScope}:${params.key}`, { key: params.key, scope: resolvedScope });
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: `Loaded secret '${params.key}' from ${resolvedScope} scope.` }],
|
||||||
|
details: { key: params.key, value: revealed.plaintextValue, scope: resolvedScope },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// ── Research Tools ──────────────────────────────────────────────
|
// ── Research Tools ──────────────────────────────────────────────
|
||||||
|
|
||||||
pi.registerTool({
|
pi.registerTool({
|
||||||
|
|||||||
30
packages/core/src/__tests__/secrets-sync.test.ts
Normal file
30
packages/core/src/__tests__/secrets-sync.test.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { SecretsSyncError, unwrapSecretsBundle, wrapSecretsBundle } from "../secrets-sync.js";
|
||||||
|
|
||||||
|
describe("secrets-sync", () => {
|
||||||
|
const records = [{ key: "A", value: "v", scope: "project" as const, accessPolicy: "auto" as const, envExportable: true, envExportKey: null }];
|
||||||
|
|
||||||
|
it("roundtrips", async () => {
|
||||||
|
const envelope = await wrapSecretsBundle(records, "pass");
|
||||||
|
const unwrapped = await unwrapSecretsBundle(envelope, "pass");
|
||||||
|
expect(unwrapped).toEqual(records);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects wrong passphrase", async () => {
|
||||||
|
const envelope = await wrapSecretsBundle(records, "pass");
|
||||||
|
await expect(unwrapSecretsBundle(envelope, "wrong")).rejects.toMatchObject({ code: "wrong-passphrase" } satisfies Partial<SecretsSyncError>);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects version mismatch and malformed payload", async () => {
|
||||||
|
const envelope = await wrapSecretsBundle(records, "pass");
|
||||||
|
await expect(unwrapSecretsBundle({ ...envelope, version: 2 as 1 }, "pass")).rejects.toMatchObject({ code: "version-mismatch" });
|
||||||
|
await expect(unwrapSecretsBundle({ ...envelope, ciphertext: "x" }, "pass")).rejects.toMatchObject({ code: "malformed" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses fresh salt and nonce", async () => {
|
||||||
|
const a = await wrapSecretsBundle(records, "pass");
|
||||||
|
const b = await wrapSecretsBundle(records, "pass");
|
||||||
|
expect(a.salt).not.toBe(b.salt);
|
||||||
|
expect(a.nonce).not.toBe(b.nonce);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1133,3 +1133,11 @@ export type {
|
|||||||
SecretScope,
|
SecretScope,
|
||||||
SecretRecord,
|
SecretRecord,
|
||||||
} from "./secrets-store.js";
|
} from "./secrets-store.js";
|
||||||
|
export {
|
||||||
|
wrapSecretsBundle,
|
||||||
|
unwrapSecretsBundle,
|
||||||
|
SecretsSyncError,
|
||||||
|
} from "./secrets-sync.js";
|
||||||
|
export type {
|
||||||
|
WrappedSecretsBundle,
|
||||||
|
} from "./secrets-sync.js";
|
||||||
|
|||||||
99
packages/core/src/secrets-sync.ts
Normal file
99
packages/core/src/secrets-sync.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { createCipheriv, createDecipheriv, randomBytes, scrypt as scryptCallback } from "node:crypto";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
import type { SecretAccessPolicy } from "./types.js";
|
||||||
|
import type { SecretScope } from "./secrets-store.js";
|
||||||
|
|
||||||
|
const scrypt = promisify(scryptCallback);
|
||||||
|
|
||||||
|
export interface WrappedSecretsBundle {
|
||||||
|
ciphertext: string;
|
||||||
|
salt: string;
|
||||||
|
nonce: string;
|
||||||
|
kdf: "scrypt";
|
||||||
|
kdfParams: { N: number; r: number; p: number; keyLen: number };
|
||||||
|
version: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SecretsSyncError extends Error {
|
||||||
|
constructor(public readonly code: "wrong-passphrase" | "version-mismatch" | "malformed", message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "SecretsSyncError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SecretsSyncRecord {
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
scope: SecretScope;
|
||||||
|
description?: string | null;
|
||||||
|
accessPolicy: SecretAccessPolicy;
|
||||||
|
envExportable: boolean;
|
||||||
|
envExportKey: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_KDF_PARAMS = { N: 32768, r: 8, p: 1, keyLen: 32 } as const;
|
||||||
|
// TODO(FN-4867): migrate to Argon2id when a vetted workspace implementation is available.
|
||||||
|
|
||||||
|
export async function wrapSecretsBundle(records: SecretsSyncRecord[], passphrase: string): Promise<WrappedSecretsBundle> {
|
||||||
|
const salt = randomBytes(16);
|
||||||
|
const nonce = randomBytes(12);
|
||||||
|
const key = await scrypt(passphrase, salt, DEFAULT_KDF_PARAMS.keyLen, {
|
||||||
|
N: DEFAULT_KDF_PARAMS.N,
|
||||||
|
r: DEFAULT_KDF_PARAMS.r,
|
||||||
|
p: DEFAULT_KDF_PARAMS.p,
|
||||||
|
maxmem: 64 * 1024 * 1024,
|
||||||
|
}) as Buffer;
|
||||||
|
|
||||||
|
const cipher = createCipheriv("aes-256-gcm", key, nonce);
|
||||||
|
const payload = Buffer.from(JSON.stringify(records), "utf8");
|
||||||
|
const encrypted = Buffer.concat([cipher.update(payload), cipher.final()]);
|
||||||
|
const authTag = cipher.getAuthTag();
|
||||||
|
const packed = Buffer.concat([encrypted, authTag]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
ciphertext: packed.toString("base64"),
|
||||||
|
salt: salt.toString("base64"),
|
||||||
|
nonce: nonce.toString("base64"),
|
||||||
|
kdf: "scrypt",
|
||||||
|
kdfParams: { ...DEFAULT_KDF_PARAMS },
|
||||||
|
version: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unwrapSecretsBundle(envelope: WrappedSecretsBundle, passphrase: string): Promise<SecretsSyncRecord[]> {
|
||||||
|
if (envelope.version !== 1) {
|
||||||
|
throw new SecretsSyncError("version-mismatch", "Unsupported envelope version");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const salt = Buffer.from(envelope.salt, "base64");
|
||||||
|
const nonce = Buffer.from(envelope.nonce, "base64");
|
||||||
|
const packed = Buffer.from(envelope.ciphertext, "base64");
|
||||||
|
const authTag = packed.subarray(packed.length - 16);
|
||||||
|
const encrypted = packed.subarray(0, packed.length - 16);
|
||||||
|
|
||||||
|
const key = await scrypt(passphrase, salt, envelope.kdfParams.keyLen, {
|
||||||
|
N: envelope.kdfParams.N,
|
||||||
|
r: envelope.kdfParams.r,
|
||||||
|
p: envelope.kdfParams.p,
|
||||||
|
maxmem: 64 * 1024 * 1024,
|
||||||
|
}) as Buffer;
|
||||||
|
|
||||||
|
const decipher = createDecipheriv("aes-256-gcm", key, nonce);
|
||||||
|
decipher.setAuthTag(authTag);
|
||||||
|
const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]).toString("utf8");
|
||||||
|
const parsed = JSON.parse(decrypted);
|
||||||
|
if (!Array.isArray(parsed)) {
|
||||||
|
throw new SecretsSyncError("malformed", "Envelope payload is not a record array");
|
||||||
|
}
|
||||||
|
return parsed as SecretsSyncRecord[];
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof SecretsSyncError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
if (error instanceof Error && /unable to authenticate data/i.test(error.message)) {
|
||||||
|
throw new SecretsSyncError("wrong-passphrase", "Failed to decrypt envelope with supplied passphrase");
|
||||||
|
}
|
||||||
|
throw new SecretsSyncError("malformed", "Malformed secrets envelope");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,8 @@ import { TodoStore } from "./todo-store.js";
|
|||||||
import { EvalStore } from "./eval-store.js";
|
import { EvalStore } from "./eval-store.js";
|
||||||
import { BackwardCompat, ProjectRequiredError } from "./migration.js";
|
import { BackwardCompat, ProjectRequiredError } from "./migration.js";
|
||||||
import { CentralCore } from "./central-core.js";
|
import { CentralCore } from "./central-core.js";
|
||||||
|
import { SecretsStore } from "./secrets-store.js";
|
||||||
|
import { MasterKeyManager } from "./master-key.js";
|
||||||
import { getTaskMergeBlocker, resolveTaskMergeTarget } from "./task-merge.js";
|
import { getTaskMergeBlocker, resolveTaskMergeTarget } from "./task-merge.js";
|
||||||
import { getInReviewStallReason } from "./in-review-stall.js";
|
import { getInReviewStallReason } from "./in-review-stall.js";
|
||||||
import { getStalePausedReviewSignal } from "./stale-paused-review.js";
|
import { getStalePausedReviewSignal } from "./stale-paused-review.js";
|
||||||
@@ -803,6 +805,10 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
private todoStore: TodoStore | null = null;
|
private todoStore: TodoStore | null = null;
|
||||||
/** Cached EvalStore instance */
|
/** Cached EvalStore instance */
|
||||||
private evalStore: EvalStore | null = null;
|
private evalStore: EvalStore | null = null;
|
||||||
|
/** Cached SecretsStore instance */
|
||||||
|
private secretsStore: SecretsStore | null = null;
|
||||||
|
/** Cached central connection for SecretsStore global scope access */
|
||||||
|
private secretsCentralCore: CentralCore | null = null;
|
||||||
/** Cached distributed task-id allocator instance. */
|
/** Cached distributed task-id allocator instance. */
|
||||||
private distributedTaskIdAllocator: DistributedTaskIdAllocator | null = null;
|
private distributedTaskIdAllocator: DistributedTaskIdAllocator | null = null;
|
||||||
|
|
||||||
@@ -8048,6 +8054,11 @@ ${stepsSection}`;
|
|||||||
this._archiveDb.close();
|
this._archiveDb.close();
|
||||||
this._archiveDb = null;
|
this._archiveDb = null;
|
||||||
}
|
}
|
||||||
|
if (this.secretsCentralCore) {
|
||||||
|
void this.secretsCentralCore.close();
|
||||||
|
this.secretsCentralCore = null;
|
||||||
|
}
|
||||||
|
this.secretsStore = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -8080,6 +8091,24 @@ ${stepsSection}`;
|
|||||||
return this.db;
|
return this.db;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getSecretsStore(): Promise<SecretsStore> {
|
||||||
|
if (this.secretsStore) {
|
||||||
|
return this.secretsStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
const central = new CentralCore(this.getFusionDir());
|
||||||
|
await central.init();
|
||||||
|
this.secretsCentralCore = central;
|
||||||
|
const centralDb = (central as unknown as { db: import("./central-db.js").CentralDatabase | null }).db;
|
||||||
|
if (!centralDb) {
|
||||||
|
throw new Error("Central database unavailable for secrets store");
|
||||||
|
}
|
||||||
|
const masterKeyManager = new MasterKeyManager();
|
||||||
|
const masterKeyProvider = () => masterKeyManager.getOrCreateKey();
|
||||||
|
this.secretsStore = new SecretsStore(this.db, centralDb, masterKeyProvider);
|
||||||
|
return this.secretsStore;
|
||||||
|
}
|
||||||
|
|
||||||
getDatabaseHealth(): {
|
getDatabaseHealth(): {
|
||||||
healthy: boolean;
|
healthy: boolean;
|
||||||
lastCheckedAt: Date | null;
|
lastCheckedAt: Date | null;
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ describe("self-healing completion fan-out", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("removes worktree from hint and is idempotent when missing", async () => {
|
it("prefers worktree hint and is idempotent when missing", async () => {
|
||||||
(existsSyncMock as any).mockImplementation((p: string) => p === "/wt/fn-b");
|
(existsSyncMock as any).mockImplementation((p: string) => p === "/wt/fn-b");
|
||||||
const blocker = makeTask("FN-B", { column: "done", branch: "fusion/fn-b" });
|
const blocker = makeTask("FN-B", { column: "done", branch: "fusion/fn-b" });
|
||||||
const store = createStore([blocker]);
|
const store = createStore([blocker]);
|
||||||
@@ -116,32 +116,28 @@ describe("self-healing completion fan-out", () => {
|
|||||||
expect(execMock.mock.calls.some((c) => String(c[0]).includes("git worktree remove --force") && String(c[0]).includes("/wt/fn-b"))).toBe(true);
|
expect(execMock.mock.calls.some((c) => String(c[0]).includes("git worktree remove --force") && String(c[0]).includes("/wt/fn-b"))).toBe(true);
|
||||||
|
|
||||||
existsSyncMock.mockReturnValue(false);
|
existsSyncMock.mockReturnValue(false);
|
||||||
execMock.mockImplementation((cmd: string, _opts: unknown, cb: (err: unknown, stdout: string, stderr: string) => void) => {
|
|
||||||
if (cmd.includes("git worktree list --porcelain")) cb(null, "", "");
|
|
||||||
else cb(null, "", "");
|
|
||||||
});
|
|
||||||
const second = await mgr.reconcileCompletedTask("FN-B");
|
const second = await mgr.reconcileCompletedTask("FN-B");
|
||||||
expect(second.worktreeRemoved).toBe(false);
|
expect(second.worktreeRemoved).toBe(false);
|
||||||
const rmCalls = execMock.mock.calls.filter((c) => String(c[0]).includes("git worktree remove --force") && String(c[0]).includes("/wt/fn-b"));
|
const rmCalls = execMock.mock.calls.filter((c) => String(c[0]).includes("git worktree remove --force") && String(c[0]).includes("/wt/fn-b"));
|
||||||
expect(rmCalls).toHaveLength(1);
|
expect(rmCalls).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("derives worktree from worktree list and skips branch delete when unique commits exist", async () => {
|
it("falls back to task.worktree when hint/branch mapping are unavailable", async () => {
|
||||||
(existsSyncMock as any).mockImplementation((p: string) => String(p).includes("/wt/fn-c"));
|
(existsSyncMock as any).mockImplementation((p: string) => p === "/wt/fn-c");
|
||||||
uniqueCommitsMock.mockResolvedValue({ commits: [{ sha: "abc", subject: "x" }] as any, mainRef: "main", degraded: false });
|
uniqueCommitsMock.mockResolvedValue({ commits: [{ sha: "abc", subject: "x" }] as any, mainRef: "main", degraded: false });
|
||||||
execMock.mockImplementation((cmd: string, _opts: unknown, cb: (err: unknown, stdout: string, stderr: string) => void) => {
|
|
||||||
cb(null, "", "");
|
|
||||||
});
|
|
||||||
|
|
||||||
const blocker = makeTask("FN-C", { column: "done", branch: "fusion/fn-c" });
|
const blocker = makeTask("FN-C", { column: "done", branch: null as any, worktree: "/wt/fn-c" });
|
||||||
const store = createStore([blocker]);
|
const store = createStore([blocker]);
|
||||||
const mgr = new SelfHealingManager(store, { rootDir: "/repo" });
|
const mgr = new SelfHealingManager(store, { rootDir: "/repo" });
|
||||||
vi.spyOn(mgr as any, "findWorktreePathForBranch").mockResolvedValue("/wt/fn-c");
|
const findSpy = vi.spyOn(mgr as any, "findWorktreePathForBranch");
|
||||||
|
|
||||||
const out = await mgr.reconcileCompletedTask("FN-C");
|
const out = await mgr.reconcileCompletedTask("FN-C");
|
||||||
|
expect(out.worktreeRemoved).toBe(true);
|
||||||
|
expect(findSpy).not.toHaveBeenCalled();
|
||||||
expect(execMock.mock.calls.some((c) => String(c[0]).includes("git worktree remove --force") && String(c[0]).includes("/wt/fn-c"))).toBe(true);
|
expect(execMock.mock.calls.some((c) => String(c[0]).includes("git worktree remove --force") && String(c[0]).includes("/wt/fn-c"))).toBe(true);
|
||||||
|
expect((await store.getTask("FN-C"))?.worktree).toBeNull();
|
||||||
|
expect((await store.getTask("FN-C"))?.branch).toBeNull();
|
||||||
expect(out.branchRemoved).toBe(false);
|
expect(out.branchRemoved).toBe(false);
|
||||||
expect(execMock.mock.calls.some((c) => String(c[0]).includes("git branch -D"))).toBe(false);
|
|
||||||
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining("skip deletion"));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("globalPause short-circuits", async () => {
|
it("globalPause short-circuits", async () => {
|
||||||
@@ -176,11 +172,24 @@ describe("self-healing completion fan-out", () => {
|
|||||||
store.emit("task:moved", { task: t, from: "in-review", to: "todo", source: "user" });
|
store.emit("task:moved", { task: t, from: "in-review", to: "todo", source: "user" });
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
expect(spy).toHaveBeenCalledTimes(2);
|
expect(spy).toHaveBeenCalledTimes(2);
|
||||||
expect(spy).toHaveBeenNthCalledWith(1, "FN-L");
|
expect(spy).toHaveBeenNthCalledWith(1, "FN-L", { worktreeHint: undefined });
|
||||||
|
|
||||||
mgr.stop();
|
mgr.stop();
|
||||||
store.emit("task:moved", { task: t, from: "in-review", to: "done", source: "user" });
|
store.emit("task:moved", { task: t, from: "in-review", to: "done", source: "user" });
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
expect(spy).toHaveBeenCalledTimes(2);
|
expect(spy).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("clears task.worktree and matching task.branch after successful removal", async () => {
|
||||||
|
(existsSyncMock as any).mockImplementation((p: string) => p === "/wt/fn-d");
|
||||||
|
uniqueCommitsMock.mockResolvedValue({ commits: [], mainRef: "main", degraded: false });
|
||||||
|
const blocker = makeTask("FN-D", { column: "done", branch: "fusion/fn-d", worktree: "/wt/fn-d" });
|
||||||
|
const store = createStore([blocker]);
|
||||||
|
const mgr = new SelfHealingManager(store, { rootDir: "/repo" });
|
||||||
|
|
||||||
|
const out = await mgr.reconcileCompletedTask("FN-D", { worktreeHint: "/wt/fn-d" });
|
||||||
|
expect(out.worktreeRemoved).toBe(true);
|
||||||
|
expect((await store.getTask("FN-D"))?.worktree).toBeNull();
|
||||||
|
expect((await store.getTask("FN-D"))?.branch).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -128,6 +128,8 @@ function matchesScope(filePath: string, scopePatterns: string[]): boolean {
|
|||||||
export interface SelfHealingOptions {
|
export interface SelfHealingOptions {
|
||||||
/** Project root directory (parent of .worktrees/) */
|
/** Project root directory (parent of .worktrees/) */
|
||||||
rootDir: string;
|
rootDir: string;
|
||||||
|
/** Optional callback to release TaskExecutor in-memory worktree ownership for a task. */
|
||||||
|
releaseExecutorWorktreeOwnership?: (taskId: string) => void;
|
||||||
/** Optional AgentStore for agent-level self-healing checks. */
|
/** Optional AgentStore for agent-level self-healing checks. */
|
||||||
agentStore?: AgentStore;
|
agentStore?: AgentStore;
|
||||||
/** Canonical stale-lease recovery manager. */
|
/** Canonical stale-lease recovery manager. */
|
||||||
@@ -413,7 +415,7 @@ export class SelfHealingManager {
|
|||||||
(from === "in-review" && to === "done") ||
|
(from === "in-review" && to === "done") ||
|
||||||
(from === "done" && to === "archived");
|
(from === "done" && to === "archived");
|
||||||
if (!shouldReconcile) return;
|
if (!shouldReconcile) return;
|
||||||
void this.reconcileCompletedTask(task.id).catch((err: unknown) => {
|
void this.reconcileCompletedTask(task.id, { worktreeHint: task.worktree ?? undefined }).catch((err: unknown) => {
|
||||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||||
log.warn(`[self-healing] task:moved completion fan-out failed for ${task.id}: ${errorMessage}`);
|
log.warn(`[self-healing] task:moved completion fan-out failed for ${task.id}: ${errorMessage}`);
|
||||||
});
|
});
|
||||||
@@ -1935,7 +1937,7 @@ export class SelfHealingManager {
|
|||||||
const settings = await this.store.getSettings();
|
const settings = await this.store.getSettings();
|
||||||
if (settings.globalPause || settings.enginePaused) return result;
|
if (settings.globalPause || settings.enginePaused) return result;
|
||||||
|
|
||||||
const task = await this.store.getTask(taskId);
|
let task = await this.store.getTask(taskId);
|
||||||
const allTasks = await this.store.listTasks({ slim: true, includeArchived: true });
|
const allTasks = await this.store.listTasks({ slim: true, includeArchived: true });
|
||||||
const taskById = new Map(allTasks.map((t) => [t.id, t]));
|
const taskById = new Map(allTasks.map((t) => [t.id, t]));
|
||||||
const todoTasks = await this.store.listTasks({ column: "todo", slim: true });
|
const todoTasks = await this.store.listTasks({ column: "todo", slim: true });
|
||||||
@@ -1998,7 +2000,11 @@ export class SelfHealingManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const branchName = task?.branch || `fusion/${taskId.toLowerCase()}`;
|
const branchName = task?.branch || `fusion/${taskId.toLowerCase()}`;
|
||||||
let worktreePath = options?.worktreeHint;
|
const hintedWorktreePath = options?.worktreeHint;
|
||||||
|
let worktreePath = hintedWorktreePath;
|
||||||
|
if (!worktreePath || !existsSync(worktreePath)) {
|
||||||
|
worktreePath = task?.worktree;
|
||||||
|
}
|
||||||
if (!worktreePath || !existsSync(worktreePath)) {
|
if (!worktreePath || !existsSync(worktreePath)) {
|
||||||
worktreePath = await this.findWorktreePathForBranch(branchName);
|
worktreePath = await this.findWorktreePathForBranch(branchName);
|
||||||
}
|
}
|
||||||
@@ -2013,6 +2019,12 @@ export class SelfHealingManager {
|
|||||||
reason: RemovalReason.SelfHealingStaleActiveBranch,
|
reason: RemovalReason.SelfHealingStaleActiveBranch,
|
||||||
});
|
});
|
||||||
result.worktreeRemoved = true;
|
result.worktreeRemoved = true;
|
||||||
|
if (task) {
|
||||||
|
const patch: Partial<Task> = { worktree: null };
|
||||||
|
if (task.branch === branchName) patch.branch = null;
|
||||||
|
await this.store.updateTask(task.id, patch);
|
||||||
|
task = { ...task, ...patch } as Task;
|
||||||
|
}
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||||
log.warn(`${prefix} failed to remove worktree ${worktreePath}: ${errorMessage}`);
|
log.warn(`${prefix} failed to remove worktree ${worktreePath}: ${errorMessage}`);
|
||||||
@@ -2021,6 +2033,8 @@ export class SelfHealingManager {
|
|||||||
log.log(`${prefix} no live worktree found for branch ${branchName}`);
|
log.log(`${prefix} no live worktree found for branch ${branchName}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.options.releaseExecutorWorktreeOwnership?.(taskId);
|
||||||
|
|
||||||
if (task) {
|
if (task) {
|
||||||
result.branchRemoved = await this.clearCompletionBranchIfSubsumed(task, branchName);
|
result.branchRemoved = await this.clearCompletionBranchIfSubsumed(task, branchName);
|
||||||
}
|
}
|
||||||
@@ -2041,6 +2055,7 @@ export class SelfHealingManager {
|
|||||||
worktreeRemoved: result.worktreeRemoved,
|
worktreeRemoved: result.worktreeRemoved,
|
||||||
branchRemoved: result.branchRemoved,
|
branchRemoved: result.branchRemoved,
|
||||||
branch: branchName,
|
branch: branchName,
|
||||||
|
worktreePath: result.worktreeRemoved ? worktreePath : undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
|
|||||||
Reference in New Issue
Block a user