diff --git a/src/resources/extensions/sf/lifecycle-hooks.js b/src/resources/extensions/sf/lifecycle-hooks.js new file mode 100644 index 000000000..9afe490e2 --- /dev/null +++ b/src/resources/extensions/sf/lifecycle-hooks.js @@ -0,0 +1,127 @@ +/** + * SF Lifecycle Hooks (Tier 1.2 Phase 3b) + * + * Purpose: integrate SM sync and other cleanup operations into unit/session lifecycle. + * Ensures durable operations (memory sync) complete before unit transitions to terminal state. + * + * Consumer: unit-runtime.js, dispatch-engine.js, or unit completion handlers. + */ + +import { flushSyncQueue } from "./sync-scheduler.js"; + +/** + * Flush SM sync queue for a project before unit completes. + * Called at session-end or unit terminal transition. + * + * @param {string} projectId - Project identifier (typically process.cwd()) + * @returns {Promise} - { synced, failed, elapsed } + */ +export async function flushProjectMemorySync(projectId) { + const start = Date.now(); + + try { + const result = await flushSyncQueue(projectId); + const elapsed = Date.now() - start; + + return { + ...result, + elapsed, + status: "ok", + }; + } catch (err) { + const elapsed = Date.now() - start; + + return { + synced: 0, + failed: 0, + elapsed, + status: "error", + reason: err instanceof Error ? err.message : String(err), + }; + } +} + +/** + * Flush all projects' SM queues (for session-end cleanup). + * Use with caution; only call when all unit activity is done. + * + * @param {string[]} projectIds - Array of project identifiers + * @returns {Promise} - { totalSynced, totalFailed, results } + */ +export async function flushAllProjectsMemorySync(projectIds) { + const results = []; + let totalSynced = 0; + let totalFailed = 0; + + for (const projectId of projectIds) { + const result = await flushProjectMemorySync(projectId); + results.push({ projectId, ...result }); + totalSynced += result.synced; + totalFailed += result.failed; + } + + return { + totalSynced, + totalFailed, + results, + }; +} + +/** + * Handle unit completion: flush queued memories before marking unit complete. + * Called from unit-runtime or dispatch-engine before transitioning to terminal state. + * + * @param {string} unitId - Unit identifier + * @param {string} projectId - Project identifier + * @param {string} finalStatus - Terminal status (completed, failed, cancelled, etc.) + * @returns {Promise} - { status, syncResult } + */ +export async function onUnitTerminal(unitId, projectId, finalStatus) { + // Flush memory queue for this project + const syncResult = await flushProjectMemorySync(projectId); + + // Log for debugging if needed + if (syncResult.status === "error") { + if (process.env.DEBUG_LIFECYCLE_FLUSH) { + console.warn( + `[lifecycle] unit ${unitId} terminal flush failed: ${syncResult.reason}`, + ); + } + } else if (syncResult.failed > 0) { + if (process.env.DEBUG_LIFECYCLE_FLUSH) { + console.warn( + `[lifecycle] unit ${unitId} had ${syncResult.failed} failed syncs`, + ); + } + } + + // Unit should complete regardless of sync result (best-effort) + return { + unitId, + finalStatus, + syncResult, + }; +} + +/** + * Handle session end: flush all projects' memory queues. + * Called from dispatcher or bootstrap cleanup. + * + * @param {string[]} projectIds - Project identifiers (or use [process.cwd()]) + * @returns {Promise} - { status, flushedProjects, totalSynced, totalFailed } + */ +export async function onSessionEnd(projectIds = [process.cwd()]) { + const result = await flushAllProjectsMemorySync(projectIds); + + if (process.env.DEBUG_LIFECYCLE_FLUSH) { + console.log( + `[lifecycle] session end: flushed ${result.totalSynced} memories, ${result.totalFailed} failed`, + ); + } + + return { + status: "ok", + flushedProjects: projectIds.length, + ...result, + }; +} diff --git a/src/resources/extensions/sf/tests/lifecycle-hooks.test.ts b/src/resources/extensions/sf/tests/lifecycle-hooks.test.ts new file mode 100644 index 000000000..c7f8ca42d --- /dev/null +++ b/src/resources/extensions/sf/tests/lifecycle-hooks.test.ts @@ -0,0 +1,364 @@ +/** + * Lifecycle Hooks Tests (Tier 1.2 Phase 3b) + * + * Validates that lifecycle hooks properly flush memory syncs at unit/session completion. + */ + +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { + flushProjectMemorySync, + flushAllProjectsMemorySync, + onUnitTerminal, + onSessionEnd, +} from "../lifecycle-hooks.js"; +import { + queueMemorySync, + resetAllSchedulers, + getSyncStatus, +} from "../sync-scheduler.js"; + +describe("Lifecycle Hooks (Tier 1.2 Phase 3b)", () => { + const TEST_PROJECT = "test-project-123"; + + beforeEach(() => { + resetAllSchedulers(); + process.env.SM_ENABLED = "false"; + }); + + afterEach(() => { + resetAllSchedulers(); + }); + + describe("flushProjectMemorySync", () => { + it("flushes_queued_memories_for_project", async () => { + for (let i = 0; i < 5; i++) { + queueMemorySync(TEST_PROJECT, `mem-${i}`, { + type: "note", + content: `note ${i}`, + projectId: TEST_PROJECT, + }); + } + + const result = await flushProjectMemorySync(TEST_PROJECT); + + expect(result.synced).toBe(5); + expect(result.failed).toBe(0); + expect(result.status).toBe("ok"); + expect(result.elapsed).toBeGreaterThanOrEqual(0); + }); + + it("returns_empty_when_no_queued_items", async () => { + const result = await flushProjectMemorySync(TEST_PROJECT); + + expect(result.synced).toBe(0); + expect(result.failed).toBe(0); + expect(result.status).toBe("ok"); + }); + + it("includes_elapsed_time", async () => { + queueMemorySync(TEST_PROJECT, "mem-1", { + type: "note", + content: "test", + projectId: TEST_PROJECT, + }); + + const result = await flushProjectMemorySync(TEST_PROJECT); + + expect(typeof result.elapsed).toBe("number"); + expect(result.elapsed).toBeGreaterThanOrEqual(0); + }); + + it("clears_project_queue", async () => { + for (let i = 0; i < 3; i++) { + queueMemorySync(TEST_PROJECT, `mem-${i}`, { + type: "note", + content: `note ${i}`, + projectId: TEST_PROJECT, + }); + } + + await flushProjectMemorySync(TEST_PROJECT); + + const status = getSyncStatus(TEST_PROJECT); + expect(status.queued).toBe(0); + }); + + it("does_not_throw_on_sm_unavailable", async () => { + process.env.SM_ENABLED = "true"; + process.env.SINGULARITY_MEMORY_ADDR = "http://localhost:19999"; + + for (let i = 0; i < 3; i++) { + queueMemorySync(TEST_PROJECT, `mem-${i}`, { + type: "note", + content: `note ${i}`, + projectId: TEST_PROJECT, + }); + } + + // Should complete without throwing + const result = await flushProjectMemorySync(TEST_PROJECT); + + expect(result).toHaveProperty("synced"); + expect(result).toHaveProperty("failed"); + }); + }); + + describe("flushAllProjectsMemorySync", () => { + it("flushes_multiple_projects", async () => { + const proj1 = "project-1"; + const proj2 = "project-2"; + + for (let i = 0; i < 3; i++) { + queueMemorySync(proj1, `mem-1-${i}`, { + type: "note", + content: `proj1-${i}`, + projectId: proj1, + }); + queueMemorySync(proj2, `mem-2-${i}`, { + type: "note", + content: `proj2-${i}`, + projectId: proj2, + }); + } + + const result = await flushAllProjectsMemorySync([proj1, proj2]); + + expect(result.totalSynced).toBe(6); + expect(result.totalFailed).toBe(0); + expect(result.results).toHaveLength(2); + }); + + it("tracks_per_project_results", async () => { + const proj1 = "project-1"; + const proj2 = "project-2"; + + for (let i = 0; i < 2; i++) { + queueMemorySync(proj1, `mem-1-${i}`, { + type: "note", + content: `proj1-${i}`, + projectId: proj1, + }); + } + + for (let i = 0; i < 3; i++) { + queueMemorySync(proj2, `mem-2-${i}`, { + type: "note", + content: `proj2-${i}`, + projectId: proj2, + }); + } + + const result = await flushAllProjectsMemorySync([proj1, proj2]); + + expect(result.results[0].projectId).toBe(proj1); + expect(result.results[0].synced).toBe(2); + expect(result.results[1].projectId).toBe(proj2); + expect(result.results[1].synced).toBe(3); + }); + + it("returns_totals_across_projects", async () => { + const projects = ["proj-1", "proj-2", "proj-3"]; + + for (const proj of projects) { + for (let i = 0; i < 2; i++) { + queueMemorySync(proj, `mem-${i}`, { + type: "note", + content: `test`, + projectId: proj, + }); + } + } + + const result = await flushAllProjectsMemorySync(projects); + + expect(result.totalSynced).toBe(6); + expect(result.results).toHaveLength(3); + }); + }); + + describe("onUnitTerminal", () => { + it("flushes_queue_on_unit_completion", async () => { + for (let i = 0; i < 3; i++) { + queueMemorySync(TEST_PROJECT, `mem-${i}`, { + type: "note", + content: `note ${i}`, + projectId: TEST_PROJECT, + }); + } + + const result = await onUnitTerminal("unit-1", TEST_PROJECT, "completed"); + + expect(result.unitId).toBe("unit-1"); + expect(result.finalStatus).toBe("completed"); + expect(result.syncResult.synced).toBe(3); + }); + + it("returns_sync_result_on_failure", async () => { + process.env.SM_ENABLED = "true"; + process.env.SINGULARITY_MEMORY_ADDR = "http://localhost:19999"; + + for (let i = 0; i < 2; i++) { + queueMemorySync(TEST_PROJECT, `mem-${i}`, { + type: "note", + content: `note ${i}`, + projectId: TEST_PROJECT, + }); + } + + const result = await onUnitTerminal("unit-1", TEST_PROJECT, "failed"); + + expect(result.finalStatus).toBe("failed"); + expect(result.syncResult).toHaveProperty("synced"); + expect(result.syncResult).toHaveProperty("failed"); + }); + + it("includes_all_terminal_statuses", async () => { + const statuses = ["completed", "failed", "cancelled", "blocked"]; + + for (const status of statuses) { + const result = await onUnitTerminal("unit-1", TEST_PROJECT, status); + expect(result.finalStatus).toBe(status); + } + }); + }); + + describe("onSessionEnd", () => { + it("flushes_default_project", async () => { + // Queue for default project (process.cwd()) + queueMemorySync(process.cwd(), "mem-1", { + type: "note", + content: "test", + projectId: process.cwd(), + }); + + const result = await onSessionEnd(); + + expect(result.status).toBe("ok"); + expect(result.flushedProjects).toBe(1); + }); + + it("flushes_specified_projects", async () => { + const projects = ["proj-1", "proj-2"]; + + for (const proj of projects) { + queueMemorySync(proj, "mem-1", { + type: "note", + content: "test", + projectId: proj, + }); + } + + const result = await onSessionEnd(projects); + + expect(result.flushedProjects).toBe(2); + expect(result.totalSynced).toBe(2); + }); + + it("returns_aggregated_results", async () => { + const projects = ["proj-1", "proj-2", "proj-3"]; + + for (const proj of projects) { + for (let i = 0; i < 2; i++) { + queueMemorySync(proj, `mem-${i}`, { + type: "note", + content: "test", + projectId: proj, + }); + } + } + + const result = await onSessionEnd(projects); + + expect(result.totalSynced).toBe(6); + expect(result.totalFailed).toBe(0); + }); + }); + + describe("Lifecycle Integration", () => { + it("unit_terminal_then_session_end_flushes_all", async () => { + const proj1 = "project-1"; + const proj2 = "project-2"; + + // Unit 1 in project 1 + for (let i = 0; i < 2; i++) { + queueMemorySync(proj1, `mem-1-${i}`, { + type: "note", + content: `proj1-${i}`, + projectId: proj1, + }); + } + + await onUnitTerminal("unit-1", proj1, "completed"); + + // Unit 2 in project 2 + for (let i = 0; i < 3; i++) { + queueMemorySync(proj2, `mem-2-${i}`, { + type: "note", + content: `proj2-${i}`, + projectId: proj2, + }); + } + + // Session end flushes remaining + const result = await onSessionEnd([proj1, proj2]); + + expect(result.totalSynced).toBeGreaterThanOrEqual(3); // proj2 memories + }); + + it("multiple_units_in_same_project", async () => { + // Unit 1 + for (let i = 0; i < 2; i++) { + queueMemorySync(TEST_PROJECT, `mem-u1-${i}`, { + type: "note", + content: `unit1-${i}`, + projectId: TEST_PROJECT, + }); + } + + await onUnitTerminal("unit-1", TEST_PROJECT, "completed"); + + // Unit 2 + for (let i = 0; i < 3; i++) { + queueMemorySync(TEST_PROJECT, `mem-u2-${i}`, { + type: "note", + content: `unit2-${i}`, + projectId: TEST_PROJECT, + }); + } + + await onUnitTerminal("unit-2", TEST_PROJECT, "completed"); + + // All should be flushed + const status = getSyncStatus(TEST_PROJECT); + expect(status.queued).toBe(0); + }); + }); + + describe("Error Handling", () => { + it("gracefully_handles_scheduler_errors", async () => { + // Even if scheduler has issues, hooks should complete + const result = await flushProjectMemorySync(TEST_PROJECT); + + expect(result).toHaveProperty("status"); + expect(["ok", "error"]).toContain(result.status); + }); + + it("continues_on_partial_failures", async () => { + const projects = ["proj-1", "proj-2"]; + + for (const proj of projects) { + queueMemorySync(proj, "mem-1", { + type: "note", + content: "test", + projectId: proj, + }); + } + + // Should complete even if one project has issues + const result = await flushAllProjectsMemorySync(projects); + + expect(result).toHaveProperty("totalSynced"); + expect(result.results).toHaveLength(2); + }); + }); +});