From fbce59b7075df02fece0f0f8841ac82d1b1583ad Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 21 Jun 2026 04:37:05 -0700 Subject: [PATCH] FN-6777: add artifact registry storage Add a core artifact registry for storing metadata and task-scoped artifact payloads.\n\n- Add artifact types, schema, migrations, store APIs, and exports for registering/listing artifact metadata.\n- Persist binary artifact payloads under task or project artifact directories and hydrate artifacts during worktree acquisition.\n- Cover registry behavior, DB schema, and worktree hydration with tests and document storage semantics.\n\nFiles changed:\n .changeset/fn-6777-artifact-registry.md | 5 +\n docs/storage.md | 8 +\n packages/core/src/__tests__/artifacts.test.ts | 257 +++++++++++++++++++++\n packages/core/src/__tests__/db.test.ts | 5 +\n packages/core/src/db.ts | 59 ++++-\n packages/core/src/index.ts | 2 +-\n packages/core/src/store.ts | 220 +++++++++++++++++-\n packages/core/src/types.ts | 75 ++++++\n .../engine/src/__tests__/executor-test-helpers.ts | 1 +\n .../engine/src/__tests__/executor-worktree.test.ts | 2 +\n .../__tests__/worktree-acquisition-backend.test.ts | 2 +-\n .../worktree-acquisition-secrets-env.test.ts | 2 +-\n .../worktree-acquisition-worktrunk.test.ts | 2 +-\n .../src/__tests__/worktree-acquisition.test.ts | 2 +-\n .../src/__tests__/worktree-db-hydrate.test.ts | 59 +++++\n packages/engine/src/worktree-acquisition.ts | 2 +-\n packages/engine/src/worktree-db-hydrate.ts | 42 +++-\n 17 files changed, 732 insertions(+), 13 deletions(-) Fusion-Task-Id: FN-6777 Fusion-Task-Lineage: 42cdcdcf-6388-42fe-8de1-6857ef20839d --- .changeset/fn-6777-artifact-registry.md | 5 + docs/storage.md | 8 + packages/core/src/__tests__/artifacts.test.ts | 257 ++++++++++++++++++ packages/core/src/__tests__/db.test.ts | 5 + packages/core/src/db.ts | 59 +++- packages/core/src/index.ts | 2 +- packages/core/src/store.ts | 220 ++++++++++++++- packages/core/src/types.ts | 75 +++++ .../src/__tests__/executor-test-helpers.ts | 1 + .../src/__tests__/executor-worktree.test.ts | 2 + .../worktree-acquisition-backend.test.ts | 2 +- .../worktree-acquisition-secrets-env.test.ts | 2 +- .../worktree-acquisition-worktrunk.test.ts | 2 +- .../__tests__/worktree-acquisition.test.ts | 2 +- .../src/__tests__/worktree-db-hydrate.test.ts | 59 ++++ packages/engine/src/worktree-acquisition.ts | 2 +- packages/engine/src/worktree-db-hydrate.ts | 42 ++- 17 files changed, 732 insertions(+), 13 deletions(-) create mode 100644 .changeset/fn-6777-artifact-registry.md create mode 100644 packages/core/src/__tests__/artifacts.test.ts diff --git a/.changeset/fn-6777-artifact-registry.md b/.changeset/fn-6777-artifact-registry.md new file mode 100644 index 0000000000..009349b717 --- /dev/null +++ b/.changeset/fn-6777-artifact-registry.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": minor +--- + +Add a core artifact registry data model and store APIs for persisted artifact metadata with on-disk binary storage. diff --git a/docs/storage.md b/docs/storage.md index 263f226753..b45b74b59a 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -63,6 +63,14 @@ - No public forensic flag is exposed on document read methods or routes. Forensic access remains an internal/operator concern via `readTaskFromDb(id, { includeDeleted: true })` plus direct SQL against the preserved document tables. - Write semantics stay intentionally asymmetric: `upsertTaskDocument` still refuses soft-deleted parents, while `deleteTaskDocument` remains allowed so forensic cleanup can scrub preserved document rows when needed. +### Artifact registry (FN-6777) + +- `artifacts` is the first-class metadata registry for generated or uploaded task artifacts. Rows store title/description, media type, author identity, optional task linkage, metadata JSON, textual content, a relative URI, and size; binary bytes are not stored in SQLite. +- `TaskStore.registerArtifact()` writes task-scoped binary payloads under `/.fusion/tasks/{ID}/artifacts/` and task-less registry payloads under `/.fusion/artifacts/`, then records a relative `artifacts/` URI in SQLite. If the DB insert fails after a binary write, the store removes the orphaned file before surfacing the error. +- `getArtifact(id)` returns metadata by ID, `getArtifacts(taskId)` returns active-task artifacts newest-first, and `listArtifacts(...)` is the cross-agent query path with type/author/task/search filters and pagination. List reads hide artifacts whose parent task is soft-deleted while preserving task-less artifacts. +- Task-linked artifact registration requires an active, non-archived task. Archived tasks are read-only for artifact writes; soft-deleted or missing tasks are rejected. +- Worktree DB hydration copies artifact metadata so isolated agents can query the registry shape locally, but binary payload files remain in the source project storage. + ### Task-ID integrity detection Fusion runs a read-only task-ID integrity detector at startup and on demand to surface allocator regressions before operators lose track of overwritten cards. The detector checks for: diff --git a/packages/core/src/__tests__/artifacts.test.ts b/packages/core/src/__tests__/artifacts.test.ts new file mode 100644 index 0000000000..e8181c4d2d --- /dev/null +++ b/packages/core/src/__tests__/artifacts.test.ts @@ -0,0 +1,257 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { existsSync, mkdtempSync } from "node:fs"; +import { readFile, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { Database } from "../db.js"; +import { TaskStore } from "../store.js"; + +function makeTmpDir(): string { + return mkdtempSync(join(tmpdir(), "kb-artifacts-test-")); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +describe("TaskStore artifacts", () => { + let rootDir: string; + let fusionDir: string; + let db: Database; + let store: TaskStore; + + beforeEach(async () => { + rootDir = makeTmpDir(); + fusionDir = join(rootDir, ".fusion"); + db = new Database(fusionDir); + db.init(); + store = new TaskStore(rootDir, join(rootDir, ".fusion-global-settings")); + await store.init(); + }); + + afterEach(async () => { + try { + store.close(); + } catch { + // ignore + } + try { + db.close(); + } catch { + // ignore + } + await rm(rootDir, { recursive: true, force: true }); + }); + + it("registers inline text artifacts and supports getArtifact hit and miss", async () => { + const task = await store.createTask({ title: "Artifact task", description: "Inline artifact task" }); + + const artifact = await store.registerArtifact({ + type: "document", + title: "Research notes", + description: "Inline evidence", + mimeType: "text/markdown", + content: "# Notes", + authorId: "agent-alpha", + authorType: "agent", + taskId: task.id, + metadata: { source: "test", tags: ["artifact"] }, + }); + + expect(artifact.id).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[0-9a-f]{4}-[0-9a-f]{12}$/i, + ); + expect(artifact.type).toBe("document"); + expect(artifact.title).toBe("Research notes"); + expect(artifact.description).toBe("Inline evidence"); + expect(artifact.mimeType).toBe("text/markdown"); + expect(artifact.content).toBe("# Notes"); + expect(artifact.uri).toBeUndefined(); + expect(artifact.taskId).toBe(task.id); + expect(artifact.metadata).toEqual({ source: "test", tags: ["artifact"] }); + + await expect(store.getArtifact(artifact.id)).resolves.toEqual(artifact); + await expect(store.getArtifact("missing-artifact")).resolves.toBeNull(); + }); + + it("stores binary artifacts on disk under the task artifacts directory", async () => { + const task = await store.createTask({ description: "Binary artifact task" }); + const data = Buffer.from([0, 1, 2, 3, 255]); + + const artifact = await store.registerArtifact({ + type: "image", + title: "diagram image.png", + mimeType: "image/png", + content: "must not be stored with binary data", + data, + authorId: "agent-alpha", + authorType: "agent", + taskId: task.id, + }); + + expect(artifact.uri).toMatch(/^artifacts\//); + expect(artifact.sizeBytes).toBe(data.length); + expect(artifact.content).toBeUndefined(); + + const storedPath = join(store.getTaskDir(task.id), artifact.uri!); + expect(existsSync(storedPath)).toBe(true); + await expect(readFile(storedPath)).resolves.toEqual(data); + + const row = db + .prepare("SELECT content, uri, sizeBytes FROM artifacts WHERE id = ?") + .get(artifact.id) as { content: string | null; uri: string; sizeBytes: number }; + expect(row.content).toBeNull(); + expect(row.uri).toBe(artifact.uri); + expect(row.sizeBytes).toBe(data.length); + }); + + it("returns [] for empty, populated, and soft-deleted task artifact states", async () => { + const task = await store.createTask({ description: "List artifacts task" }); + const emptyTask = await store.createTask({ description: "Empty artifact task" }); + + await expect(store.getArtifacts(emptyTask.id)).resolves.toEqual([]); + + const first = await store.registerArtifact({ + type: "document", + title: "First artifact", + content: "first", + authorId: "agent-alpha", + authorType: "agent", + taskId: task.id, + }); + await sleep(2); + const second = await store.registerArtifact({ + type: "image", + title: "Second artifact", + data: Buffer.from("image"), + authorId: "agent-beta", + authorType: "agent", + taskId: task.id, + }); + + const artifacts = await store.getArtifacts(task.id); + expect(artifacts.map((artifact) => artifact.id)).toEqual([second.id, first.id]); + + await store.deleteTask(task.id); + await expect(store.getArtifacts(task.id)).resolves.toEqual([]); + }); + + it("filters listArtifacts across agents, tasks, types, search, and pagination", async () => { + const taskA = await store.createTask({ title: "Alpha task", description: "Artifact task A" }); + const taskB = await store.createTask({ title: "Beta task", description: "Artifact task B" }); + + const first = await store.registerArtifact({ + type: "document", + title: "Alpha research memo", + description: "contains searchable token", + content: "memo", + authorId: "agent-alpha", + authorType: "agent", + taskId: taskA.id, + }); + await sleep(2); + const second = await store.registerArtifact({ + type: "image", + title: "Beta screenshot", + data: Buffer.from("png"), + authorId: "agent-beta", + authorType: "agent", + taskId: taskB.id, + }); + await sleep(2); + const third = await store.registerArtifact({ + type: "audio", + title: "Gamma narration", + data: Buffer.from("audio"), + authorId: "agent-alpha", + authorType: "agent", + taskId: taskB.id, + }); + + const all = await store.listArtifacts(); + expect(all.map((artifact) => artifact.id)).toEqual([third.id, second.id, first.id]); + expect(all.find((artifact) => artifact.id === first.id)?.taskTitle).toBe("Alpha task"); + expect(all.find((artifact) => artifact.id === second.id)?.taskTitle).toBe("Beta task"); + + await expect(store.listArtifacts({ type: "image" })).resolves.toMatchObject([{ id: second.id }]); + expect((await store.listArtifacts({ authorId: "agent-alpha" })).map((artifact) => artifact.id)).toEqual([ + third.id, + first.id, + ]); + expect((await store.listArtifacts({ taskId: taskB.id })).map((artifact) => artifact.id)).toEqual([ + third.id, + second.id, + ]); + await expect(store.listArtifacts({ search: "searchable token" })).resolves.toMatchObject([{ id: first.id }]); + await expect(store.listArtifacts({ limit: 1, offset: 1 })).resolves.toMatchObject([{ id: second.id }]); + }); + + it("keeps task-less artifacts queryable while hiding artifacts for soft-deleted tasks", async () => { + const liveTask = await store.createTask({ title: "Live artifact task", description: "Live" }); + const deletedTask = await store.createTask({ title: "Deleted artifact task", description: "Deleted" }); + + const live = await store.registerArtifact({ + type: "document", + title: "Live artifact", + content: "live", + authorId: "agent-alpha", + authorType: "agent", + taskId: liveTask.id, + }); + const hidden = await store.registerArtifact({ + type: "document", + title: "Hidden artifact", + content: "hidden", + authorId: "agent-alpha", + authorType: "agent", + taskId: deletedTask.id, + }); + const registry = await store.registerArtifact({ + type: "other", + title: "Registry artifact", + data: Buffer.from("registry"), + authorId: "system", + authorType: "system", + }); + + await store.deleteTask(deletedTask.id); + + const artifacts = await store.listArtifacts(); + expect(artifacts.map((artifact) => artifact.id).sort()).toEqual([live.id, registry.id].sort()); + expect(artifacts.find((artifact) => artifact.id === registry.id)?.taskTitle).toBeUndefined(); + + const hiddenRow = db.prepare("SELECT id FROM artifacts WHERE id = ?").get(hidden.id) as { id: string } | undefined; + expect(hiddenRow?.id).toBe(hidden.id); + }); + + it("rejects registering artifacts for archived or missing tasks", async () => { + const task = await store.createTask({ description: "Archived artifact task" }); + await store.moveTask(task.id, "todo"); + await store.moveTask(task.id, "in-progress"); + await store.moveTask(task.id, "in-review"); + await store.moveTask(task.id, "done"); + await store.archiveTask(task.id, true); + + await expect( + store.registerArtifact({ + type: "document", + title: "Archived artifact", + content: "nope", + authorId: "agent-alpha", + authorType: "agent", + taskId: task.id, + }), + ).rejects.toThrow(/archived/i); + + await expect( + store.registerArtifact({ + type: "document", + title: "Missing artifact", + content: "nope", + authorId: "agent-alpha", + authorType: "agent", + taskId: "FN-DOES-NOT-EXIST", + }), + ).rejects.toThrow("Task FN-DOES-NOT-EXIST not found"); + }); +}); diff --git a/packages/core/src/__tests__/db.test.ts b/packages/core/src/__tests__/db.test.ts index 8046d0c404..fa7ed0da74 100644 --- a/packages/core/src/__tests__/db.test.ts +++ b/packages/core/src/__tests__/db.test.ts @@ -289,6 +289,7 @@ describe("Database", () => { expect(tableNames).toContain("agentRatings"); expect(tableNames).toContain("task_documents"); expect(tableNames).toContain("task_document_revisions"); + expect(tableNames).toContain("artifacts"); // Roadmap tables are plugin-owned (FN-3159) and initialized via plugin schema hooks. // Verification cache (migration 61) expect(tableNames).toContain("verification_cache"); @@ -329,6 +330,10 @@ describe("Database", () => { expect(indexNames).toContain("idxTaskDocumentsTaskKey"); expect(indexNames).toContain("idxTaskDocumentsTaskId"); expect(indexNames).toContain("idxTaskDocumentRevisionsTaskKey"); + expect(indexNames).toContain("idxArtifactsTaskId"); + expect(indexNames).toContain("idxArtifactsAuthorId"); + expect(indexNames).toContain("idxArtifactsType"); + expect(indexNames).toContain("idxArtifactsCreatedAt"); expect(indexNames).toContain("idxAgentRunsAgentIdStartedAt"); expect(indexNames).toContain("idxAgentRunsStatus"); // agentLogEntries indexes removed in migration 102 — now stored in per-task JSONL files diff --git a/packages/core/src/db.ts b/packages/core/src/db.ts index 432ec31099..86c70ede9c 100644 --- a/packages/core/src/db.ts +++ b/packages/core/src/db.ts @@ -162,7 +162,7 @@ export function isFts5CorruptionError(error: unknown): boolean { // ── Schema Definition ──────────────────────────────────────────────── -const SCHEMA_VERSION = 126; +const SCHEMA_VERSION = 127; const TASKS_FTS_AUTOMERGE = 8; const TASKS_FTS_CRISISMERGE = 16; @@ -698,6 +698,31 @@ CREATE TABLE IF NOT EXISTS task_documents ( CREATE UNIQUE INDEX IF NOT EXISTS idxTaskDocumentsTaskKey ON task_documents(taskId, key); CREATE INDEX IF NOT EXISTS idxTaskDocumentsTaskId ON task_documents(taskId); +-- Artifact registry metadata for inline text and on-disk media artifacts. +-- FNXC:ArtifactRegistry 2026-06-19-22:04: +-- Agents register multi-type artifacts that are queryable across agents and tasks. SQLite stores metadata plus optional inline text only; binary media lives on disk under an artifacts/ directory and is referenced by a relative uri. +CREATE TABLE IF NOT EXISTS artifacts ( + id TEXT PRIMARY KEY, + type TEXT NOT NULL, + title TEXT NOT NULL, + description TEXT, + mimeType TEXT, + sizeBytes INTEGER, + uri TEXT, + content TEXT, + authorId TEXT NOT NULL, + authorType TEXT NOT NULL DEFAULT 'agent', + taskId TEXT, + metadata TEXT, + createdAt TEXT NOT NULL, + updatedAt TEXT NOT NULL, + FOREIGN KEY (taskId) REFERENCES tasks(id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idxArtifactsTaskId ON artifacts(taskId); +CREATE INDEX IF NOT EXISTS idxArtifactsAuthorId ON artifacts(authorId); +CREATE INDEX IF NOT EXISTS idxArtifactsType ON artifacts(type); +CREATE INDEX IF NOT EXISTS idxArtifactsCreatedAt ON artifacts(createdAt); + -- Task document revision history (shadow table for archived snapshots) CREATE TABLE IF NOT EXISTS task_document_revisions ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -5198,6 +5223,38 @@ export class Database { }); } + // Migration 127: Artifact registry metadata for inline text and on-disk media. + // Mirrors the SCHEMA_SQL definition above so fresh-init and migrated DBs converge. + // FNXC:ArtifactRegistry 2026-06-19-22:04: + // Agents need queryable cross-task artifact evidence; binary bytes stay out of SQLite and are referenced by relative uri rows. + if (version < 127) { + this.applyMigration(127, () => { + this.db.exec(` + CREATE TABLE IF NOT EXISTS artifacts ( + id TEXT PRIMARY KEY, + type TEXT NOT NULL, + title TEXT NOT NULL, + description TEXT, + mimeType TEXT, + sizeBytes INTEGER, + uri TEXT, + content TEXT, + authorId TEXT NOT NULL, + authorType TEXT NOT NULL DEFAULT 'agent', + taskId TEXT, + metadata TEXT, + createdAt TEXT NOT NULL, + updatedAt TEXT NOT NULL, + FOREIGN KEY (taskId) REFERENCES tasks(id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idxArtifactsTaskId ON artifacts(taskId); + CREATE INDEX IF NOT EXISTS idxArtifactsAuthorId ON artifacts(authorId); + CREATE INDEX IF NOT EXISTS idxArtifactsType ON artifacts(type); + CREATE INDEX IF NOT EXISTS idxArtifactsCreatedAt ON artifacts(createdAt); + `); + }); + } + } /** diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 9520f63f4d..2ec58df343 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,5 +1,5 @@ export { COLUMNS, DEFAULT_COLUMN, isColumn, normalizeColumn, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, isMergeRequestContractShadowEnabled, resolvePersistAgentThinkingLog, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, SUPPORTED_LOCALES, DEFAULT_LOCALE, isLocale, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, PERMANENT_AGENT_ACTION_CATEGORIES, AGENT_PERMISSION_POLICY_ACTION_CATEGORIES, AGENT_PROVISIONING_APPROVAL_MODES, SANDBOX_PROVISIONING_APPROVAL_MODES, AGENT_PERMISSION_POLICY_PRESET_IDS, LEGACY_AGENT_PERMISSION_POLICY_ACTION_CATEGORY_ALIASES, APPROVAL_REQUEST_STATUSES, APPROVAL_REQUEST_AUDIT_EVENT_TYPES, normalizeApprovalRequestActionCategory, isValidApprovalRequestTransition, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, WORKFLOW_WORK_ITEM_KINDS, WORKFLOW_WORK_ITEM_STATES, HIGH_FANOUT_BLOCKER_TODO_THRESHOLD, STALE_HIGH_FANOUT_BLOCKER_AGE_THRESHOLD_MS, DASHBOARD_USER_ID, normalizeMessageParticipant, validateMessageMetadata, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse, normalizeMergeIntegrationWorktreeMode, normalizeMergeAdvanceAutoSyncMode, MERGE_ADVANCE_AUTO_SYNC_MODES, normalizeMergeConflictStrategy, normalizeMergeStrategyOverlapBehavior, normalizePostMergeAuditMode, POST_MERGE_AUDIT_MODES, normalizeMergeAuditAutoRecovery, MERGE_AUDIT_AUTO_RECOVERY_MODES, normalizeMergerMode, MERGER_MODES, normalizeAutoRecovery, AUTO_RECOVERY_MODES, buildResearchDocumentKey, REPO_OVERRIDE_RE, SHARED_STATE_SNAPSHOT_VERSION, sanitizeCliAgentSettings, sanitizeCliAgentsSettings, CLI_AGENT_ADAPTER_IDS, CLI_AGENT_AUTONOMY_MODES } from "./types.js"; -export type { Column, ColumnId, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrConflictState, PrConflictDiagnostics, PrCheckState, PrCheckStatus, PrStatus, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, BranchGroupPrState, Task, TaskTokenUsage, TaskTokenUsagePerModel, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, MeshReplicatedTaskCreatePayload, MeshReplicatedTaskApplyResult, TaskSource, SourceType, TaskDetail, RetrySummary, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOrphanRecord, AutostashOutcome, MergeDetails, MergeResult, MergeIntegrationWorktreeMode, MergeAdvanceAutoSyncMode, MergeConflictStrategy, CanonicalMergeConflictStrategy, MergeStrategyOverlapBehavior, PostMergeAuditMode, MergeAuditAutoRecoveryMode, MergerMode, MergerSettings, AutoRecoveryMode, AutoRecoveryFailureClass, AutoRecoverySettings, DirectMergeCommitStrategy, Settings, GlobalSettings, ProjectSettings, SecretsEnvConfig, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SandboxBackendName, SandboxFailureMode, SandboxPolicy, SandboxProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, Locale, ExecutionMode, TaskPriority, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, MergeRequestState, MergeRequestRecord, MergeRequestWorkflowProjectionOptions, CompletionHandoffMarker, WorkflowWorkItem, WorkflowWorkItemDueFilter, WorkflowWorkItemKind, WorkflowWorkItemState, WorkflowWorkItemTransitionPatch, WorkflowWorkItemUpsertInput, HandoffEvidence, HandoffToReviewOptions, UnavailableNodePolicy, OwningNodeHandoffPolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, GithubIssueAction, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepGateMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyActionCategory, AgentProvisioningApprovalMode, SandboxProvisioningApprovalMode, LegacyAgentPermissionPolicyActionCategory, ApprovalRequestActionCategoryInput, ApprovalRequestActionCategory, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, CheckoutClaimPrecondition, TaskClaimRow, CentralClaimStore, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, AgentMemoryInclusionMode, HeartbeatPromptTemplate, HeartbeatScopeDisciplineMode, WorktrunkSettings, WorktrunkOnFailure, TaskBranchContext, CliAgentSettings } from "./types.js"; +export type { Column, ColumnId, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrConflictState, PrConflictDiagnostics, PrCheckState, PrCheckStatus, PrStatus, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, BranchGroupPrState, Task, TaskTokenUsage, TaskTokenUsagePerModel, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, ArtifactType, Artifact, ArtifactCreateInput, ArtifactWithTask, TaskCreateInput, MeshReplicatedTaskCreatePayload, MeshReplicatedTaskApplyResult, TaskSource, SourceType, TaskDetail, RetrySummary, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOrphanRecord, AutostashOutcome, MergeDetails, MergeResult, MergeIntegrationWorktreeMode, MergeAdvanceAutoSyncMode, MergeConflictStrategy, CanonicalMergeConflictStrategy, MergeStrategyOverlapBehavior, PostMergeAuditMode, MergeAuditAutoRecoveryMode, MergerMode, MergerSettings, AutoRecoveryMode, AutoRecoveryFailureClass, AutoRecoverySettings, DirectMergeCommitStrategy, Settings, GlobalSettings, ProjectSettings, SecretsEnvConfig, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SandboxBackendName, SandboxFailureMode, SandboxPolicy, SandboxProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, Locale, ExecutionMode, TaskPriority, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, MergeRequestState, MergeRequestRecord, MergeRequestWorkflowProjectionOptions, CompletionHandoffMarker, WorkflowWorkItem, WorkflowWorkItemDueFilter, WorkflowWorkItemKind, WorkflowWorkItemState, WorkflowWorkItemTransitionPatch, WorkflowWorkItemUpsertInput, HandoffEvidence, HandoffToReviewOptions, UnavailableNodePolicy, OwningNodeHandoffPolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, GithubIssueAction, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepGateMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyActionCategory, AgentProvisioningApprovalMode, SandboxProvisioningApprovalMode, LegacyAgentPermissionPolicyActionCategory, ApprovalRequestActionCategoryInput, ApprovalRequestActionCategory, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, CheckoutClaimPrecondition, TaskClaimRow, CentralClaimStore, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, AgentMemoryInclusionMode, HeartbeatPromptTemplate, HeartbeatScopeDisciplineMode, WorktrunkSettings, WorktrunkOnFailure, TaskBranchContext, CliAgentSettings } from "./types.js"; export { AGENT_VALID_TRANSITIONS, DUPLICATE_OF_METADATA_KEY } from "./types.js"; export { resolveEntryPointBranchAssignment, diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 30fe55a71f..66822b7f80 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -3,7 +3,7 @@ import { randomUUID } from "node:crypto"; import { mkdir, readdir, readFile, stat, writeFile, rename, unlink } from "node:fs/promises"; import { join } from "node:path"; import { existsSync, watch, type Dirent, type FSWatcher } from "node:fs"; -import type { Task, TaskDetail, TaskCreateInput, TaskAttachment, AgentLogEntry, BoardConfig, Column, ColumnId, CheckoutClaimPrecondition, MergeResult, Settings, GlobalSettings, ProjectSettings, ActivityLogEntry, ActivityEventType, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, InboxTask, TaskLogEntry, RunMutationContext, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, ArchivedTaskEntry, ArchiveAgentLogMode, TaskPriority, SourceType, WorkflowStepTemplate, Agent, AutostashOrphanRecord, TaskCommitAssociation, TaskCommitAssociationMatchSource, TaskCommitAssociationConfidence, GithubIssueAction, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, HandoffToReviewOptions, GoalCitation, GoalCitationFilter, GoalCitationInput, GoalCitationSurface, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, TaskBranchAssignmentMode, MergeRequestRecord, MergeRequestState, MergeRequestWorkflowProjectionOptions, CompletionHandoffMarker, WorkflowWorkItem, WorkflowWorkItemDueFilter, WorkflowWorkItemKind, WorkflowWorkItemState, WorkflowWorkItemTransitionPatch, WorkflowWorkItemUpsertInput, PrEntity, PrEntityCreateInput, PrEntityUpdate, PrEntityState, PrThreadState, PrThreadOutcome, PrConflictState, PrChecksRollup, PrReviewDecision, PluginActivation, PluginActivationInput } from "./types.js"; +import type { Task, TaskDetail, TaskCreateInput, TaskAttachment, AgentLogEntry, BoardConfig, Column, ColumnId, CheckoutClaimPrecondition, MergeResult, Settings, GlobalSettings, ProjectSettings, ActivityLogEntry, ActivityEventType, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, Artifact, ArtifactCreateInput, ArtifactType, ArtifactWithTask, InboxTask, TaskLogEntry, RunMutationContext, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, ArchivedTaskEntry, ArchiveAgentLogMode, TaskPriority, SourceType, WorkflowStepTemplate, Agent, AutostashOrphanRecord, TaskCommitAssociation, TaskCommitAssociationMatchSource, TaskCommitAssociationConfidence, GithubIssueAction, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, HandoffToReviewOptions, GoalCitation, GoalCitationFilter, GoalCitationInput, GoalCitationSurface, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, TaskBranchAssignmentMode, MergeRequestRecord, MergeRequestState, MergeRequestWorkflowProjectionOptions, CompletionHandoffMarker, WorkflowWorkItem, WorkflowWorkItemDueFilter, WorkflowWorkItemKind, WorkflowWorkItemState, WorkflowWorkItemTransitionPatch, WorkflowWorkItemUpsertInput, PrEntity, PrEntityCreateInput, PrEntityUpdate, PrEntityState, PrThreadState, PrThreadOutcome, PrConflictState, PrChecksRollup, PrReviewDecision, PluginActivation, PluginActivationInput } from "./types.js"; import { createActivityLogSnapshot, createRunAuditSnapshot, createTaskMetadataSnapshot, toTaskMetadataRecord, validateSnapshotEnvelope, type ActivityLogSnapshot, type RunAuditSnapshot, type TaskMetadataSnapshot } from "./shared-mesh-state.js"; import { VALID_TRANSITIONS, COLUMNS, DEFAULT_SETTINGS, isColumn, isGlobalOnlySettingsKey, WORKFLOW_STEP_TEMPLATES, validateDocumentKey } from "./types.js"; import { DEFAULT_PROJECT_SETTINGS } from "./settings-schema.js"; @@ -580,6 +580,24 @@ interface TaskDocumentRow { updatedAt: string; } +/** Database row shape for the artifacts table. */ +interface ArtifactRow { + id: string; + type: ArtifactType; + title: string; + description: string | null; + mimeType: string | null; + sizeBytes: number | null; + uri: string | null; + content: string | null; + authorId: string; + authorType: "agent" | "user" | "system"; + taskId: string | null; + metadata: string | null; + createdAt: string; + updatedAt: string; +} + /** Database row shape for the task_document_revisions table. */ interface TaskDocumentRevisionRow { id: number; @@ -2387,6 +2405,28 @@ export class TaskStore extends EventEmitter { }; } + /** + * Convert an artifacts row to an Artifact object. + */ + private rowToArtifact(row: ArtifactRow): Artifact { + return { + id: row.id, + type: row.type, + title: row.title, + ...(row.description !== null ? { description: row.description } : {}), + ...(row.mimeType !== null ? { mimeType: row.mimeType } : {}), + ...(row.sizeBytes !== null ? { sizeBytes: row.sizeBytes } : {}), + ...(row.uri !== null ? { uri: row.uri } : {}), + ...(row.content !== null ? { content: row.content } : {}), + authorId: row.authorId, + authorType: row.authorType, + ...(row.taskId !== null ? { taskId: row.taskId } : {}), + metadata: fromJson>(row.metadata), + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; + } + /** * Convert a task_document_revisions row to a TaskDocumentRevision object. */ @@ -4094,6 +4134,15 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} return join(this.tasksDir, id); } + private artifactRegistryDir(): string { + return join(this.fusionDir, "artifacts"); + } + + private static artifactStoredName(id: string, title: string): string { + const sanitized = (title.trim() || "artifact").replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 120) || "artifact"; + return `${Date.now()}-${id}-${sanitized}`; + } + private getBuiltInWorkflowTemplate(templateId: string): import("./types.js").WorkflowStepTemplate | undefined { return WORKFLOW_STEP_TEMPLATES.find((template) => template.id === templateId); } @@ -12459,6 +12508,175 @@ ${TASK_UPSERT_SQL_ASSIGNMENTS} return Boolean(row); } + private async writeArtifactData(input: ArtifactCreateInput, id: string): Promise<{ uri?: string; sizeBytes?: number; absolutePath?: string }> { + if (!input.data) { + return {}; + } + + const storedName = TaskStore.artifactStoredName(id, input.title); + if (input.taskId) { + const artifactDir = join(this.taskDir(input.taskId), "artifacts"); + await mkdir(artifactDir, { recursive: true }); + const absolutePath = join(artifactDir, storedName); + await writeFile(absolutePath, input.data); + return { uri: `artifacts/${storedName}`, sizeBytes: input.data.length, absolutePath }; + } + + const artifactDir = this.artifactRegistryDir(); + await mkdir(artifactDir, { recursive: true }); + const absolutePath = join(artifactDir, storedName); + await writeFile(absolutePath, input.data); + return { uri: `artifacts/${storedName}`, sizeBytes: input.data.length, absolutePath }; + } + + private insertArtifactRow(input: ArtifactCreateInput, id: string, now: string, stored: { uri?: string; sizeBytes?: number }): Artifact { + this.db.prepare( + `INSERT INTO artifacts ( + id, type, title, description, mimeType, sizeBytes, uri, content, authorId, authorType, taskId, metadata, createdAt, updatedAt + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).run( + id, + input.type, + input.title, + input.description ?? null, + input.mimeType ?? null, + stored.sizeBytes ?? input.sizeBytes ?? null, + stored.uri ?? input.uri ?? null, + input.data ? null : input.content ?? null, + input.authorId, + input.authorType, + input.taskId ?? null, + toJsonNullable(input.metadata), + now, + now, + ); + + const row = this.db.prepare("SELECT * FROM artifacts WHERE id = ?").get(id) as ArtifactRow | undefined; + if (!row) { + throw new Error(`Failed to register artifact ${id}`); + } + return this.rowToArtifact(row); + } + + /** + * FNXC:ArtifactRegistry 2026-06-19-22:04: + * Register multi-type agent/user/system artifacts in SQLite while writing binary payloads to disk. Task-scoped binaries use `.fusion/tasks/{taskId}/artifacts/`; task-less binaries use `.fusion/artifacts/`, and both store only a relative `artifacts/` uri in the row. + */ + async registerArtifact(input: ArtifactCreateInput): Promise { + const id = randomUUID(); + const now = new Date().toISOString(); + + if (input.taskId) { + const taskExists = this.db.prepare(`SELECT id, "column" FROM tasks WHERE id = ? AND ${TaskStore.ACTIVE_TASKS_WHERE}`).get(input.taskId) as + | { id: string; column: Column } + | undefined; + if (taskExists?.column === "archived") { + throw new Error(`Task ${input.taskId} is archived — artifacts are read-only`); + } + if (!taskExists) { + if (this.isTaskArchived(input.taskId)) { + throw new Error(`Task ${input.taskId} is archived — artifacts are read-only`); + } + throw new Error(`Task ${input.taskId} not found`); + } + } + + const register = async (): Promise => { + const stored = await this.writeArtifactData(input, id); + try { + return this.insertArtifactRow(input, id, now, stored); + } catch (error) { + if (stored.absolutePath) { + await unlink(stored.absolutePath).catch(() => undefined); + } + throw error; + } + }; + + return input.taskId ? this.withTaskLock(input.taskId, register) : register(); + } + + /** + * FNXC:ArtifactRegistry 2026-06-19-22:04: + * Fetch a single artifact metadata row by id for downstream tools and UI without reading binary payload bytes from disk. + */ + async getArtifact(id: string): Promise { + const row = this.db.prepare("SELECT * FROM artifacts WHERE id = ?").get(id) as ArtifactRow | undefined; + return row ? this.rowToArtifact(row) : null; + } + + /** + * FNXC:ArtifactRegistry 2026-06-19-22:04: + * List artifacts for an active task newest-first; soft-deleted tasks intentionally return an empty list to mirror task document visibility. + */ + async getArtifacts(taskId: string): Promise { + if (!this.hasActiveTask(taskId)) { + return []; + } + + const rows = this.db + .prepare("SELECT * FROM artifacts WHERE taskId = ? ORDER BY createdAt DESC") + .all(taskId) as unknown as ArtifactRow[]; + return rows.map((row) => this.rowToArtifact(row)); + } + + /** + * FNXC:ArtifactRegistry 2026-06-19-22:04: + * Cross-agent registry query path for filtering artifacts across tasks, authors, and media types. LEFT JOIN keeps task-less registry artifacts visible while excluding artifacts attached to soft-deleted tasks. + */ + async listArtifacts(options?: { + type?: ArtifactType; + authorId?: string; + taskId?: string; + limit?: number; + offset?: number; + search?: string; + }): Promise { + const limit = Math.min(Math.max(1, options?.limit ?? 200), 1000); + const offset = Math.max(0, options?.offset ?? 0); + + let sql = ` + SELECT a.*, t.title as taskTitle, t.description as taskDescription, t.column as taskColumn + FROM artifacts a + LEFT JOIN tasks t ON a.taskId = t.id + WHERE (a.taskId IS NULL OR t.${TaskStore.ACTIVE_TASKS_WHERE}) + `; + const params: (string | number)[] = []; + + if (options?.type) { + sql += " AND a.type = ?"; + params.push(options.type); + } + if (options?.authorId) { + sql += " AND a.authorId = ?"; + params.push(options.authorId); + } + if (options?.taskId) { + sql += " AND a.taskId = ?"; + params.push(options.taskId); + } + if (options?.search && options.search.trim() !== "") { + const query = `%${options.search.trim()}%`; + sql += " AND (a.title LIKE ? OR a.description LIKE ?)"; + params.push(query, query); + } + + sql += " ORDER BY a.createdAt DESC LIMIT ? OFFSET ?"; + params.push(limit, offset); + + const rows = this.db.prepare(sql).all(...params) as unknown as Array; + return rows.map((row) => ({ + ...this.rowToArtifact(row), + ...(row.taskTitle !== null ? { taskTitle: row.taskTitle } : {}), + ...(row.taskDescription !== null ? { taskDescription: row.taskDescription } : {}), + ...(row.taskColumn !== null ? { taskColumn: row.taskColumn } : {}), + })); + } + /** * List all current task documents for a task, ordered by key. */ diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index e8b7720fe0..21a27ce392 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1586,6 +1586,81 @@ export interface TaskDocumentWithTask extends TaskDocument { taskColumn?: string; } +/** Supported artifact media classes for the persisted artifact registry. */ +export type ArtifactType = "document" | "image" | "video" | "audio" | "other"; + +/** + * FNXC:ArtifactRegistry 2026-06-19-22:04: + * Agents need a first-class registry for multi-type artifacts that are visible across agents and tasks. Store binary media on disk and persist only metadata plus relative URIs in SQLite so query paths stay lightweight and never inline binary bytes. + */ +export interface Artifact { + /** UUID primary key */ + id: string; + /** Artifact media class used for filtering and presentation */ + type: ArtifactType; + /** Human-readable artifact title */ + title: string; + /** Optional longer description or caption */ + description?: string; + /** Optional MIME type for inline text or binary media */ + mimeType?: string; + /** Optional content size in bytes, set from binary data when persisted on disk */ + sizeBytes?: number; + /** Relative stored path; task artifacts are anchored at the task dir, while task-less registry artifacts are anchored at `.fusion/` */ + uri?: string; + /** Optional inline text body for text/document artifacts */ + content?: string; + /** Agent, user, or system identifier that registered the artifact */ + authorId: string; + /** Class of actor that registered the artifact */ + authorType: "agent" | "user" | "system"; + /** Optional task this artifact is associated with */ + taskId?: string; + /** Optional extensible metadata (JSON object) */ + metadata?: Record; + /** ISO-8601 creation timestamp */ + createdAt: string; + /** ISO-8601 last-update timestamp */ + updatedAt: string; +} + +export interface ArtifactCreateInput { + /** Artifact media class used for filtering and presentation */ + type: ArtifactType; + /** Human-readable artifact title */ + title: string; + /** Optional longer description or caption */ + description?: string; + /** Optional MIME type for inline text or binary media */ + mimeType?: string; + /** Optional content size in bytes for inline or externally referenced content */ + sizeBytes?: number; + /** Optional relative URI when content is already stored outside SQLite */ + uri?: string; + /** Optional inline text body for text/document artifacts */ + content?: string; + /** Agent, user, or system identifier registering the artifact */ + authorId: string; + /** Class of actor registering the artifact */ + authorType: "agent" | "user" | "system"; + /** Optional task this artifact is associated with */ + taskId?: string; + /** Optional extensible metadata (JSON object) */ + metadata?: Record; + /** Optional binary payload; the store persists it on disk and records a relative URI */ + data?: Buffer; +} + +/** Artifact extended with optional parent task metadata for cross-task registry views. */ +export interface ArtifactWithTask extends Artifact { + /** Title of the parent task */ + taskTitle?: string; + /** Description of the parent task */ + taskDescription?: string; + /** Column of the parent task (e.g., "triage", "todo", "in-progress", "done", "in-review", "archived") */ + taskColumn?: string; +} + /** * Goal-citation Slice 2 success-signal surfaces where goal IDs are extracted. */ diff --git a/packages/engine/src/__tests__/executor-test-helpers.ts b/packages/engine/src/__tests__/executor-test-helpers.ts index 5587ab65d9..f1bb3093ba 100644 --- a/packages/engine/src/__tests__/executor-test-helpers.ts +++ b/packages/engine/src/__tests__/executor-test-helpers.ts @@ -245,6 +245,7 @@ vi.mock("../worktree-db-hydrate.js", () => ({ hydrateWorktreeDb: vi.fn().mockResolvedValue({ tasksCopied: 0, documentsCopied: 0, + artifactsCopied: 0, degraded: false, }), })); diff --git a/packages/engine/src/__tests__/executor-worktree.test.ts b/packages/engine/src/__tests__/executor-worktree.test.ts index fdc659ca14..d521447ac7 100644 --- a/packages/engine/src/__tests__/executor-worktree.test.ts +++ b/packages/engine/src/__tests__/executor-worktree.test.ts @@ -2459,6 +2459,7 @@ describe("worktree DB hydration", () => { mockedHydrateWorktreeDb.mockResolvedValue({ tasksCopied: 1, documentsCopied: 2, + artifactsCopied: 0, degraded: false, }); mockedCreateFnAgent.mockResolvedValue({ @@ -2502,6 +2503,7 @@ describe("worktree DB hydration", () => { mockedHydrateWorktreeDb.mockResolvedValueOnce({ tasksCopied: 0, documentsCopied: 0, + artifactsCopied: 0, degraded: true, reason: "unable to open database file", }); diff --git a/packages/engine/src/__tests__/worktree-acquisition-backend.test.ts b/packages/engine/src/__tests__/worktree-acquisition-backend.test.ts index 542e8ac348..2c1d7f49be 100644 --- a/packages/engine/src/__tests__/worktree-acquisition-backend.test.ts +++ b/packages/engine/src/__tests__/worktree-acquisition-backend.test.ts @@ -13,7 +13,7 @@ vi.mock("../worktree-pool.js", async () => { }); vi.mock("../worktree-db-hydrate.js", () => ({ - hydrateWorktreeDb: vi.fn().mockResolvedValue({ degraded: false, tasksCopied: 1, documentsCopied: 1 }), + hydrateWorktreeDb: vi.fn().mockResolvedValue({ degraded: false, tasksCopied: 1, documentsCopied: 1, artifactsCopied: 0 }), })); const { execMock, existsSyncMock, accessMock } = vi.hoisted(() => { diff --git a/packages/engine/src/__tests__/worktree-acquisition-secrets-env.test.ts b/packages/engine/src/__tests__/worktree-acquisition-secrets-env.test.ts index ec02b74e9c..235ef66ea0 100644 --- a/packages/engine/src/__tests__/worktree-acquisition-secrets-env.test.ts +++ b/packages/engine/src/__tests__/worktree-acquisition-secrets-env.test.ts @@ -16,7 +16,7 @@ vi.mock("../worktree-pool.js", async () => { }); vi.mock("../worktree-db-hydrate.js", () => ({ - hydrateWorktreeDb: vi.fn().mockResolvedValue({ degraded: false, tasksCopied: 0, documentsCopied: 0 }), + hydrateWorktreeDb: vi.fn().mockResolvedValue({ degraded: false, tasksCopied: 0, documentsCopied: 0, artifactsCopied: 0 }), })); import { acquireTaskWorktree } from "../worktree-acquisition.js"; diff --git a/packages/engine/src/__tests__/worktree-acquisition-worktrunk.test.ts b/packages/engine/src/__tests__/worktree-acquisition-worktrunk.test.ts index 2a5f623b60..c17d8f3938 100644 --- a/packages/engine/src/__tests__/worktree-acquisition-worktrunk.test.ts +++ b/packages/engine/src/__tests__/worktree-acquisition-worktrunk.test.ts @@ -17,7 +17,7 @@ vi.mock("../worktree-pool.js", async () => { return { ...actual, isUsableTaskWorktree: vi.fn().mockResolvedValue(true) }; }); vi.mock("../worktree-db-hydrate.js", () => ({ - hydrateWorktreeDb: vi.fn().mockResolvedValue({ degraded: false, tasksCopied: 1, documentsCopied: 1 }), + hydrateWorktreeDb: vi.fn().mockResolvedValue({ degraded: false, tasksCopied: 1, documentsCopied: 1, artifactsCopied: 0 }), })); const task = { diff --git a/packages/engine/src/__tests__/worktree-acquisition.test.ts b/packages/engine/src/__tests__/worktree-acquisition.test.ts index b252f1abaa..0a8ad73f2d 100644 --- a/packages/engine/src/__tests__/worktree-acquisition.test.ts +++ b/packages/engine/src/__tests__/worktree-acquisition.test.ts @@ -29,7 +29,7 @@ vi.mock("../branch-conflicts.js", async () => { }); vi.mock("../worktree-db-hydrate.js", () => ({ - hydrateWorktreeDb: vi.fn().mockResolvedValue({ degraded: false, tasksCopied: 1, documentsCopied: 1 }), + hydrateWorktreeDb: vi.fn().mockResolvedValue({ degraded: false, tasksCopied: 1, documentsCopied: 1, artifactsCopied: 0 }), })); vi.mock("../worktree-desktop-artifacts.js", () => ({ diff --git a/packages/engine/src/__tests__/worktree-db-hydrate.test.ts b/packages/engine/src/__tests__/worktree-db-hydrate.test.ts index d0241e9f3d..81e8442a01 100644 --- a/packages/engine/src/__tests__/worktree-db-hydrate.test.ts +++ b/packages/engine/src/__tests__/worktree-db-hydrate.test.ts @@ -49,6 +49,16 @@ function insertDoc(projectDir: string, taskId: string): void { db.close(); } +function insertArtifact(projectDir: string, id: string, taskId: string | null): void { + ensureProjectFusionDir(projectDir); + const db = new DatabaseSync(join(projectDir, ".fusion", "fusion.db")); + const now = new Date().toISOString(); + db.prepare( + "INSERT OR REPLACE INTO artifacts (id, type, title, description, mimeType, sizeBytes, uri, content, authorId, authorType, taskId, metadata, createdAt, updatedAt) VALUES (?, 'document', ?, 'artifact description', 'text/plain', 5, NULL, 'hello', 'agent-1', 'agent', ?, NULL, ?, ?)", + ).run(id, id, taskId, now, now); + db.close(); +} + function sha(file: string): string { if (!existsSync(file)) return ""; return createHash("sha256").update(readFileSync(file)).digest("hex"); @@ -77,7 +87,9 @@ describe("hydrateWorktreeDb", () => { const first = await hydrateWorktreeDb({ rootDir: root, worktreePath: worktree, taskId: "FN-A", store: store as any, logger: { warn: vi.fn() } }); const second = await hydrateWorktreeDb({ rootDir: root, worktreePath: worktree, taskId: "FN-A", store: store as any, logger: { warn: vi.fn() } }); expect(first.degraded).toBe(false); + expect(first.artifactsCopied).toBe(0); expect(second.degraded).toBe(false); + expect(second.artifactsCopied).toBe(0); const db = new DatabaseSync(join(worktree, ".fusion", "fusion.db")); const tasks = (db.prepare("SELECT COUNT(*) as c FROM tasks WHERE id IN ('FN-A','FN-B','FN-C')").get() as any).c; @@ -131,6 +143,7 @@ describe("hydrateWorktreeDb", () => { expect(result.degraded).toBe(false); expect(result.tasksCopied).toBe(1); expect(result.documentsCopied).toBe(1); + expect(result.artifactsCopied).toBe(0); const db = new DatabaseSync(join(worktree, ".fusion", "fusion.db")); const softDeletedTask = db.prepare("SELECT id FROM tasks WHERE id='FN-B'").get(); @@ -154,6 +167,51 @@ describe("hydrateWorktreeDb", () => { expect(result.degraded).toBe(false); expect(result.tasksCopied).toBe(1); expect(result.documentsCopied).toBe(1); + expect(result.artifactsCopied).toBe(0); + }); + + it("hydrates task-scoped artifact metadata and excludes registry-level artifacts", async () => { + const root = makeProject("h-artifacts-"); + const worktree = makeProject("h-artifacts-dst-"); + cleanup.push(root, worktree); + + insertTask(root, "FN-1", null); + insertArtifact(root, "artifact-task", "FN-1"); + insertArtifact(root, "artifact-registry", null); + const store = { getTask: vi.fn(async () => ({ id: "FN-1", dependencies: [] })) }; + + const result = await hydrateWorktreeDb({ rootDir: root, worktreePath: worktree, taskId: "FN-1", store: store as any, logger: { warn: vi.fn() } }); + + expect(result.degraded).toBe(false); + expect(result.artifactsCopied).toBe(1); + + const db = new DatabaseSync(join(worktree, ".fusion", "fusion.db")); + const taskArtifact = db.prepare("SELECT id, taskId FROM artifacts WHERE id='artifact-task'").get() as { id: string; taskId: string } | undefined; + const registryArtifact = db.prepare("SELECT id FROM artifacts WHERE id='artifact-registry'").get(); + db.close(); + + expect(taskArtifact).toEqual({ id: "artifact-task", taskId: "FN-1" }); + expect(registryArtifact).toBeUndefined(); + }); + + it("skips artifact hydration without degrading when a peer DB predates the artifacts table", async () => { + const root = makeProject("h-no-artifacts-table-"); + const worktree = makeProject("h-no-artifacts-table-dst-"); + cleanup.push(root, worktree); + + insertTask(root, "FN-1", null); + insertDoc(root, "FN-1"); + const srcDb = new DatabaseSync(join(root, ".fusion", "fusion.db")); + srcDb.exec("DROP TABLE IF EXISTS artifacts"); + srcDb.close(); + const store = { getTask: vi.fn(async () => ({ id: "FN-1", dependencies: [] })) }; + + const result = await hydrateWorktreeDb({ rootDir: root, worktreePath: worktree, taskId: "FN-1", store: store as any, logger: { warn: vi.fn() } }); + + expect(result.degraded).toBe(false); + expect(result.tasksCopied).toBe(1); + expect(result.documentsCopied).toBe(1); + expect(result.artifactsCopied).toBe(0); }); it("hydrates when source tasks schema has no deletedAt column", async () => { @@ -175,6 +233,7 @@ describe("hydrateWorktreeDb", () => { expect(result.degraded).toBe(false); expect(result.tasksCopied).toBe(1); + expect(result.artifactsCopied).toBe(0); }); it("handles schema drift by dropping missing destination columns", async () => { diff --git a/packages/engine/src/worktree-acquisition.ts b/packages/engine/src/worktree-acquisition.ts index 0872cad467..11ca2c0850 100644 --- a/packages/engine/src/worktree-acquisition.ts +++ b/packages/engine/src/worktree-acquisition.ts @@ -242,7 +242,7 @@ export async function acquireTaskWorktree(opts: AcquireTaskWorktreeOptions): Pro if (hydration.degraded) { await store.logEntry(task.id, `Worktree DB hydration degraded: ${hydration.reason ?? "unknown"}`, undefined, runContext); } else { - await store.logEntry(task.id, `Hydrated worktree DB: ${hydration.tasksCopied} tasks, ${hydration.documentsCopied} task_documents`, undefined, runContext); + await store.logEntry(task.id, `Hydrated worktree DB: ${hydration.tasksCopied} tasks, ${hydration.documentsCopied} task_documents, ${hydration.artifactsCopied} artifacts`, undefined, runContext); } return true; } catch (error) { diff --git a/packages/engine/src/worktree-db-hydrate.ts b/packages/engine/src/worktree-db-hydrate.ts index 344368bae1..f47449efcc 100644 --- a/packages/engine/src/worktree-db-hydrate.ts +++ b/packages/engine/src/worktree-db-hydrate.ts @@ -13,6 +13,7 @@ export interface HydrateWorktreeDbParams { export interface HydrateWorktreeDbResult { tasksCopied: number; documentsCopied: number; + artifactsCopied: number; degraded: boolean; reason?: string; } @@ -24,7 +25,7 @@ function getDbPath(projectDir: string): string { return join(projectDir, ".fusion", "fusion.db"); } -function getColumns(db: DatabaseSync, table: "tasks" | "task_documents"): string[] { +function getColumns(db: DatabaseSync, table: "tasks" | "task_documents" | "artifacts"): string[] { const rows = db.prepare(`PRAGMA table_info('${table}')`).all() as Array<{ name?: string }>; return rows.map((row) => row.name).filter((name): name is string => typeof name === "string" && name.length > 0); } @@ -89,7 +90,7 @@ export async function hydrateWorktreeDb({ logger, }: HydrateWorktreeDbParams): Promise { if (rootDir === worktreePath) { - return { tasksCopied: 0, documentsCopied: 0, degraded: false, reason: "root_worktree" }; + return { tasksCopied: 0, documentsCopied: 0, artifactsCopied: 0, degraded: false, reason: "root_worktree" }; } let srcDb: DatabaseSync | undefined; @@ -98,14 +99,14 @@ export async function hydrateWorktreeDb({ try { const ids = await resolveDependencyIds(taskId, store); if (ids.length === 0) { - return { tasksCopied: 0, documentsCopied: 0, degraded: false, reason: "no_ids" }; + return { tasksCopied: 0, documentsCopied: 0, artifactsCopied: 0, degraded: false, reason: "no_ids" }; } const srcDbPath = getDbPath(rootDir); const dstDbPath = getDbPath(worktreePath); if (!existsSync(srcDbPath)) { - return { tasksCopied: 0, documentsCopied: 0, degraded: true, reason: "source_db_missing" }; + return { tasksCopied: 0, documentsCopied: 0, artifactsCopied: 0, degraded: true, reason: "source_db_missing" }; } if (!existsSync(dstDbPath)) { @@ -123,15 +124,27 @@ export async function hydrateWorktreeDb({ const dstTaskCols = getColumns(dstDb, "tasks"); const srcDocCols = getColumns(srcDb, "task_documents"); const dstDocCols = getColumns(dstDb, "task_documents"); + const srcArtifactCols = getColumns(srcDb, "artifacts"); + const dstArtifactCols = getColumns(dstDb, "artifacts"); const { shared: taskColumns, dropped: droppedTaskColumns } = intersectColumns(srcTaskCols, dstTaskCols); const { shared: docColumns, dropped: droppedDocColumns } = intersectColumns(srcDocCols, dstDocCols); + const canHydrateArtifacts = srcArtifactCols.length > 0 && dstArtifactCols.length > 0; + const { shared: artifactColumns, dropped: droppedArtifactColumns } = canHydrateArtifacts + ? intersectColumns(srcArtifactCols, dstArtifactCols) + : { shared: [], dropped: [] }; if (taskColumns.length === 0 || docColumns.length === 0) { throw new Error("schema intersection empty"); } - const dropped = [...droppedTaskColumns.map((c) => `tasks.${c}`), ...droppedDocColumns.map((c) => `task_documents.${c}`)]; + // FNXC:ArtifactRegistry 2026-06-19-22:04: + // Artifacts are additive in schema 126, so rolling-upgrade worktree DBs that predate the table must keep hydrating tasks/documents and simply report zero copied artifacts. + const dropped = [ + ...droppedTaskColumns.map((c) => `tasks.${c}`), + ...droppedDocColumns.map((c) => `task_documents.${c}`), + ...droppedArtifactColumns.map((c) => `artifacts.${c}`), + ]; if (dropped.length > 0) { logger.warn(`Worktree DB hydration dropped columns for ${taskId}: ${dropped.join(", ")}`); } @@ -139,8 +152,10 @@ export async function hydrateWorktreeDb({ const placeholders = ids.map(() => "?").join(", "); const taskColumnList = taskColumns.join(", "); const docColumnList = docColumns.join(", "); + const artifactColumnList = artifactColumns.join(", "); const taskValuePlaceholders = taskColumns.map(() => "?").join(", "); const docValuePlaceholders = docColumns.map(() => "?").join(", "); + const artifactValuePlaceholders = artifactColumns.map(() => "?").join(", "); // FN-5105: hydrateWorktreeDb is a live-reader path, so soft-deleted tasks must be excluded. // Only ID allocators/integrity scans are allowed to read deleted rows. @@ -163,12 +178,24 @@ export async function hydrateWorktreeDb({ .all(...hydratedTaskIds) as Array>) : []; + // FNXC:ArtifactRegistry 2026-06-19-22:04: + // Worktree DB hydration carries task-scoped artifact metadata alongside task_documents so executor worktrees can query agent evidence. Registry-level artifacts with null taskId are intentionally excluded because dependency hydration is scoped to the active task graph. + const artifactRows = + canHydrateArtifacts && hydratedTaskIds.length > 0 + ? (srcDb + .prepare(`SELECT ${artifactColumnList} FROM artifacts WHERE taskId IN (${hydratedTaskIds.map(() => "?").join(", ")})`) + .all(...hydratedTaskIds) as Array>) + : []; + const insertTask = dstDb.prepare( `INSERT OR REPLACE INTO tasks (${taskColumnList}) VALUES (${taskValuePlaceholders})`, ); const insertDocument = dstDb.prepare( `INSERT OR REPLACE INTO task_documents (${docColumnList}) VALUES (${docValuePlaceholders})`, ); + const insertArtifact = canHydrateArtifacts + ? dstDb.prepare(`INSERT OR REPLACE INTO artifacts (${artifactColumnList}) VALUES (${artifactValuePlaceholders})`) + : undefined; dstDb.exec("BEGIN IMMEDIATE"); try { @@ -178,6 +205,9 @@ export async function hydrateWorktreeDb({ for (const row of documentRows) { insertDocument.run(...docColumns.map((column) => row[column])); } + for (const row of artifactRows) { + insertArtifact?.run(...artifactColumns.map((column) => row[column])); + } dstDb.exec("COMMIT"); } catch (error) { dstDb.exec("ROLLBACK"); @@ -187,6 +217,7 @@ export async function hydrateWorktreeDb({ return { tasksCopied: taskRows.length, documentsCopied: documentRows.length, + artifactsCopied: artifactRows.length, degraded: false, }; } catch (error) { @@ -195,6 +226,7 @@ export async function hydrateWorktreeDb({ return { tasksCopied: 0, documentsCopied: 0, + artifactsCopied: 0, degraded: true, reason, };