feat(FN-4649): complete Step 1 — add core task-created hook
Fusion-Task-Id: FN-4649 Fusion-Task-Lineage: 98ab6ec3-5253-4db6-93f0-de4ab7792002
This commit is contained in:
committed by
gsxdsm
parent
8b371bae04
commit
4c3cd0b406
107
packages/core/src/__tests__/task-creation-hook.test.ts
Normal file
107
packages/core/src/__tests__/task-creation-hook.test.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { setTaskCreatedHook } from "../task-creation-hooks.js";
|
||||
import { createTaskStoreTestHarness } from "./store-test-helpers.js";
|
||||
|
||||
describe("task creation hook", () => {
|
||||
const harness = createTaskStoreTestHarness();
|
||||
|
||||
beforeEach(async () => {
|
||||
setTaskCreatedHook(undefined);
|
||||
await harness.beforeEach();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
setTaskCreatedHook(undefined);
|
||||
await harness.afterEach();
|
||||
});
|
||||
|
||||
it("fires once for createTask and createTaskWithReservedId", async () => {
|
||||
const store = harness.store();
|
||||
const hook = vi.fn();
|
||||
setTaskCreatedHook(hook);
|
||||
|
||||
const created = await store.createTask({ description: "a" });
|
||||
const reserved = await store.createTaskWithReservedId({ description: "b" }, { taskId: "FN-9101" });
|
||||
|
||||
expect(hook).toHaveBeenCalledTimes(2);
|
||||
expect(hook).toHaveBeenNthCalledWith(1, expect.objectContaining({ id: created.id }), store);
|
||||
expect(hook).toHaveBeenNthCalledWith(2, expect.objectContaining({ id: reserved.id }), store);
|
||||
});
|
||||
|
||||
async function moveToDone(taskId: string): Promise<void> {
|
||||
const store = harness.store();
|
||||
await store.moveTask(taskId, "todo");
|
||||
await store.moveTask(taskId, "in-progress");
|
||||
await store.moveTask(taskId, "in-review");
|
||||
await store.moveTask(taskId, "done");
|
||||
}
|
||||
|
||||
it("fires for duplicateTask and refineTask", async () => {
|
||||
const store = harness.store();
|
||||
const source = await store.createTask({ description: "source", title: "Source" });
|
||||
await moveToDone(source.id);
|
||||
|
||||
const hook = vi.fn();
|
||||
setTaskCreatedHook(hook);
|
||||
|
||||
const duplicated = await store.duplicateTask(source.id);
|
||||
const refined = await store.refineTask(source.id, "please refine");
|
||||
|
||||
expect(hook).toHaveBeenCalledTimes(2);
|
||||
expect(hook).toHaveBeenNthCalledWith(1, expect.objectContaining({ id: duplicated.id }), store);
|
||||
expect(hook).toHaveBeenNthCalledWith(2, expect.objectContaining({ id: refined.id }), store);
|
||||
});
|
||||
|
||||
it("does not fire for applyReplicatedTaskCreate", async () => {
|
||||
const store = harness.store();
|
||||
const hook = vi.fn();
|
||||
setTaskCreatedHook(hook);
|
||||
|
||||
await store.applyReplicatedTaskCreate({
|
||||
replicationVersion: 1,
|
||||
reservationId: "res-1",
|
||||
taskId: "FN-9102",
|
||||
sourceNodeId: "node-a",
|
||||
createdAt: "2026-05-05T00:00:00.000Z",
|
||||
updatedAt: "2026-05-05T00:00:00.000Z",
|
||||
prompt: "# FN-9102\n\nreplicated\n",
|
||||
input: { description: "replicated", column: "triage" },
|
||||
});
|
||||
|
||||
expect(hook).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("swallows sync and async hook failures and still returns tasks", async () => {
|
||||
const store = harness.store();
|
||||
setTaskCreatedHook(() => {
|
||||
throw new Error("boom");
|
||||
});
|
||||
|
||||
const created = await store.createTask({ description: "a" });
|
||||
const duplicated = await store.duplicateTask(created.id);
|
||||
await moveToDone(created.id);
|
||||
const refined = await store.refineTask(created.id, "feedback");
|
||||
|
||||
expect(created.id).toMatch(/^FN-/);
|
||||
expect(duplicated.id).toMatch(/^FN-/);
|
||||
expect(refined.id).toMatch(/^FN-/);
|
||||
|
||||
setTaskCreatedHook(async () => {
|
||||
throw new Error("async boom");
|
||||
});
|
||||
|
||||
const created2 = await store.createTask({ description: "b" });
|
||||
expect(created2.id).toMatch(/^FN-/);
|
||||
});
|
||||
|
||||
it("can clear hook with undefined", async () => {
|
||||
const store = harness.store();
|
||||
const hook = vi.fn();
|
||||
setTaskCreatedHook(hook);
|
||||
setTaskCreatedHook(undefined);
|
||||
|
||||
await store.createTask({ description: "a" });
|
||||
expect(hook).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -32,6 +32,11 @@ export {
|
||||
getCreateAiSessionFactory,
|
||||
type AgentMessage,
|
||||
} from "./ai-engine-loader.js";
|
||||
export {
|
||||
setTaskCreatedHook,
|
||||
getTaskCreatedHook,
|
||||
type TaskCreatedHook,
|
||||
} from "./task-creation-hooks.js";
|
||||
|
||||
// ── Prompt Overrides ─────────────────────────────────────────────────
|
||||
export {
|
||||
|
||||
@@ -32,6 +32,8 @@ import { runCommandAsync } from "./run-command.js";
|
||||
import { createLogger } from "./logger.js";
|
||||
import { validateNodeOverrideChange } from "./node-override-guard.js";
|
||||
import { sanitizeTitle } from "./ai-summarize.js";
|
||||
import { getErrorMessage } from "./error-message.js";
|
||||
import { getTaskCreatedHook } from "./task-creation-hooks.js";
|
||||
import { assertProjectRootDir } from "./project-root-guard.js";
|
||||
import { generateTaskLineageId, normalizeTaskCommitAssociation } from "./task-lineage.js";
|
||||
import { createDistributedTaskIdAllocator, reconcileTaskIdState, resolveLocalNodeId, type DistributedTaskIdAllocator } from "./distributed-task-id.js";
|
||||
@@ -2904,6 +2906,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
updatedAt?: string;
|
||||
prompt?: string;
|
||||
applyDefaultWorkflowSteps?: boolean;
|
||||
invokeTaskCreatedHook?: boolean;
|
||||
},
|
||||
): Promise<Task> {
|
||||
if (!input.description?.trim()) {
|
||||
@@ -2951,10 +2954,14 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
createdAt: options.createdAt,
|
||||
updatedAt: options.updatedAt,
|
||||
promptOverride: options.prompt,
|
||||
invokeTaskCreatedHook: options.invokeTaskCreatedHook,
|
||||
});
|
||||
}
|
||||
|
||||
async applyReplicatedTaskCreate(payload: MeshReplicatedTaskCreatePayload): Promise<MeshReplicatedTaskApplyResult> {
|
||||
// Intentionally does not invoke the post-create hook. Replicated tasks mirror
|
||||
// state from an origin node; rerunning side effects here (e.g. GitHub issue
|
||||
// creation) would duplicate external artifacts.
|
||||
const existing = this.readTaskFromDb(payload.taskId);
|
||||
if (existing) {
|
||||
const existingDetail = await this.getTask(payload.taskId);
|
||||
@@ -2970,6 +2977,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
updatedAt: payload.updatedAt,
|
||||
prompt: payload.prompt,
|
||||
applyDefaultWorkflowSteps: false,
|
||||
invokeTaskCreatedHook: false,
|
||||
});
|
||||
|
||||
return { task, applied: true };
|
||||
@@ -2988,6 +2996,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
promptOverride?: string;
|
||||
invokeTaskCreatedHook?: boolean;
|
||||
},
|
||||
): Promise<Task> {
|
||||
const now = options?.createdAt ?? new Date().toISOString();
|
||||
@@ -3066,9 +3075,22 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
await writeFile(join(dir, "PROMPT.md"), prompt);
|
||||
|
||||
this.emit("task:created", task);
|
||||
if (options?.invokeTaskCreatedHook !== false) {
|
||||
await this.invokeTaskCreatedHook(task);
|
||||
}
|
||||
return task;
|
||||
}
|
||||
|
||||
private async invokeTaskCreatedHook(task: Task): Promise<void> {
|
||||
const taskCreatedHook = getTaskCreatedHook();
|
||||
if (!taskCreatedHook) return;
|
||||
try {
|
||||
await taskCreatedHook(task, this);
|
||||
} catch (error) {
|
||||
storeLog.warn(`[task-created-hook] ${task.id}: ${getErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplicate an existing task, creating a fresh copy in triage.
|
||||
* Copies title and description with source reference, but resets all
|
||||
@@ -3118,6 +3140,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
|
||||
if (this.isWatching) this.taskCache.set(newId, { ...newTask });
|
||||
this.emit("task:created", newTask);
|
||||
await this.invokeTaskCreatedHook(newTask);
|
||||
return newTask;
|
||||
},
|
||||
});
|
||||
@@ -3208,6 +3231,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
|
||||
if (this.isWatching) this.taskCache.set(newId, { ...newTask });
|
||||
this.emit("task:created", newTask);
|
||||
await this.invokeTaskCreatedHook(newTask);
|
||||
return newTask;
|
||||
},
|
||||
});
|
||||
|
||||
14
packages/core/src/task-creation-hooks.ts
Normal file
14
packages/core/src/task-creation-hooks.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { Task } from "./types.js";
|
||||
import type { TaskStore } from "./store.js";
|
||||
|
||||
export type TaskCreatedHook = (task: Task, store: TaskStore) => Promise<void> | void;
|
||||
|
||||
let taskCreatedHook: TaskCreatedHook | undefined;
|
||||
|
||||
export function setTaskCreatedHook(fn: TaskCreatedHook | undefined): void {
|
||||
taskCreatedHook = fn;
|
||||
}
|
||||
|
||||
export function getTaskCreatedHook(): TaskCreatedHook | undefined {
|
||||
return taskCreatedHook;
|
||||
}
|
||||
Reference in New Issue
Block a user