diff --git a/src/main/app/startup-recovery.ts b/src/main/app/startup-recovery.ts index 230232d1..3c0a1721 100644 --- a/src/main/app/startup-recovery.ts +++ b/src/main/app/startup-recovery.ts @@ -1,5 +1,6 @@ import type { DatabaseSync } from "node:sqlite"; import { AuditService } from "../services/audit-service"; +import { preserveRecordedFiles } from "../services/files/file-object-store"; const INTERRUPTED_MESSAGE = "Interrupted by application shutdown before completion."; @@ -14,6 +15,7 @@ const INTERRUPTED_MESSAGE = "Interrupted by application shutdown before completi * provider background worker needs to resume them. */ export function recoverInterruptedWork(db: DatabaseSync): void { + preserveRecordedFiles(db); const now = new Date().toISOString(); const audit = new AuditService(db); const runs = db.prepare("select id, org_id, task_id from task_runs where state = 'running'") diff --git a/src/main/database/migrations/089-file-versions.ts b/src/main/database/migrations/089-file-versions.ts new file mode 100644 index 00000000..a3b4c171 --- /dev/null +++ b/src/main/database/migrations/089-file-versions.ts @@ -0,0 +1,9 @@ +export const fileVersionsSchema = ` + alter table file_objects add column source_path text; + alter table file_objects add column superseded_at text; + update file_objects set source_path = storage_path where source_path is null; + create index if not exists file_objects_source on file_objects(org_id, source_path, sha256); + + insert into schema_migrations(version, name, applied_at) + values (89, 'file_versions', datetime('now')); +`; diff --git a/src/main/database/schema.ts b/src/main/database/schema.ts index e03f157f..696fcd4a 100644 --- a/src/main/database/schema.ts +++ b/src/main/database/schema.ts @@ -87,6 +87,7 @@ import { approvalSubjectSnapshotSchema } from "./migrations/085-approval-subject import { heldDeliverablesSchema } from "./migrations/086-held-deliverables"; import { goalAuditorSchema } from "./migrations/087-goal-auditor"; import { hostAccessNoteSchema } from "./migrations/088-host-access-note"; +import { fileVersionsSchema } from "./migrations/089-file-versions"; interface Migration { version: number; @@ -183,6 +184,7 @@ const MIGRATIONS: Migration[] = [ { version: 86, name: "held_deliverables", sql: heldDeliverablesSchema }, { version: 87, name: "goal_auditor", sql: goalAuditorSchema }, { version: 88, name: "host_access_note", sql: hostAccessNoteSchema }, + { version: 89, name: "file_versions", sql: fileVersionsSchema }, ]; const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version; diff --git a/src/main/services/agent-workspace-tools.ts b/src/main/services/agent-workspace-tools.ts index bb874d3a..0fa842a4 100644 --- a/src/main/services/agent-workspace-tools.ts +++ b/src/main/services/agent-workspace-tools.ts @@ -9,7 +9,7 @@ import { PAGE_TOOL_PROPERTIES, pageText, readPageArgs, type PageRequest } from " const LIST_FILES_LIMIT = 400; /** Dependency and VCS internals: huge, machine-managed, never what an agent is looking for. */ -export const LIST_SKIPPED_DIRECTORIES = new Set([".git", "node_modules", ".pnpm-store", "out", "dist"]); +export const LIST_SKIPPED_DIRECTORIES = new Set([".git", "node_modules", ".pnpm-store", "out", "dist", ".agentcompany"]); /** * The workspace tools turn agents from document writers into workers: they can diff --git a/src/main/services/file-service.ts b/src/main/services/file-service.ts index 0a3bf62d..533ba140 100644 --- a/src/main/services/file-service.ts +++ b/src/main/services/file-service.ts @@ -52,6 +52,7 @@ export class FileService { sizeBytes: stats.size, sha256: hash, storagePath: target, + sourcePath: target, ownerType: "system", ownerId: null, scan, diff --git a/src/main/services/files/file-object-store.ts b/src/main/services/files/file-object-store.ts index b06893ab..a5860fa1 100644 --- a/src/main/services/files/file-object-store.ts +++ b/src/main/services/files/file-object-store.ts @@ -1,6 +1,6 @@ import { createHash } from "node:crypto"; -import { readFileSync } from "node:fs"; -import { basename, extname } from "node:path"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { basename, dirname, extname, isAbsolute, join, relative } from "node:path"; import type { DatabaseSync } from "node:sqlite"; import { FILE_SCAN_STATE, type FileScanState } from "../../../shared/domain"; import { createId } from "../../../shared/ids"; @@ -73,27 +73,34 @@ export interface FileObjectInput { kind: string; sizeBytes: number; sha256: string; + /** Where the bytes of this id live, never rewritten: a content-addressed copy, or an imported file's own copy. */ storagePath: string; + /** The path the file came from; one id per content of it, the newest current and older ones superseded. */ + sourcePath: string; ownerType: string; ownerId: string | null; scan: FileScanResult; now: string; } -/** - * Inserts (or refreshes, keyed on org_id + storage_path) a file object and, on - * first insert, grants the workspace org-group read/manage access so the file - * surfaces on the Files page and can be attached in chat. Re-writing the same - * path updates the existing row instead of creating a duplicate. - */ +/** One id per content of a source path, granted to the workspace on first insert; recording it makes it the path's current version. */ export function upsertFileObject(db: DatabaseSync, input: FileObjectInput): string { - const existing = db.prepare("select id from file_objects where org_id = ? and storage_path = ?") - .get(input.orgId, input.storagePath) as { id: string } | undefined; + const id = writeFileObject(db, input); + // Earlier contents of the same path stay readable under their own ids, marked as no longer current. + db.prepare("update file_objects set superseded_at = ? where org_id = ? and source_path = ? and id <> ? and superseded_at is null") + .run(input.now, input.orgId, input.sourcePath, id); + db.prepare("update file_objects set superseded_at = null where id = ?").run(id); + return id; +} + +function writeFileObject(db: DatabaseSync, input: FileObjectInput): string { + const existing = db.prepare("select id from file_objects where org_id = ? and source_path = ? and sha256 = ?") + .get(input.orgId, input.sourcePath, input.sha256) as { id: string } | undefined; const labelsJson = JSON.stringify(input.scan.labels); if (existing) { db.prepare(` update file_objects - set name = ?, kind = ?, size_bytes = ?, sha256 = ?, owner_type = ?, owner_id = ?, + set name = ?, kind = ?, size_bytes = ?, sha256 = ?, storage_path = ?, owner_type = ?, owner_id = ?, scan_state = ?, scan_labels_json = ?, scanned_at = ?, updated_at = ? where id = ? `).run( @@ -101,6 +108,7 @@ export function upsertFileObject(db: DatabaseSync, input: FileObjectInput): stri input.kind, input.sizeBytes, input.sha256, + input.storagePath, input.ownerType, input.ownerId, input.scan.state, @@ -114,10 +122,10 @@ export function upsertFileObject(db: DatabaseSync, input: FileObjectInput): stri const id = input.id ?? createId("fil"); db.prepare(` insert into file_objects( - id, org_id, name, kind, size_bytes, sha256, storage_path, + id, org_id, name, kind, size_bytes, sha256, storage_path, source_path, owner_type, owner_id, space_id, pinned, scan_state, scan_labels_json, scanned_at, created_at, updated_at ) - values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( id, input.orgId, @@ -126,6 +134,7 @@ export function upsertFileObject(db: DatabaseSync, input: FileObjectInput): stri input.sizeBytes, input.sha256, input.storagePath, + input.sourcePath, input.ownerType, input.ownerId, null, @@ -156,14 +165,18 @@ export function recordWorkspaceFile(db: DatabaseSync, input: { const name = basename(input.relativePath); const scan = scanFileContent(name, bytes); const now = new Date().toISOString(); + const sha256 = createHash("sha256").update(bytes).digest("hex"); + // The id keeps exactly these bytes: a content-addressed copy the workspace file can be rewritten without touching. + const copy = keepCopy(input.workspaceRoot, absolute, sha256, bytes); // A savepoint, not a transaction: an approved deliverable is recorded inside the approval's own transaction. return withSavepoint(db, "record_workspace_file", () => upsertFileObject(db, { orgId: input.orgId, name, kind: fileKind(name), sizeBytes: bytes.byteLength, - sha256: createHash("sha256").update(bytes).digest("hex"), - storagePath: absolute, + sha256, + storagePath: copy, + sourcePath: absolute, ownerType: input.ownerType, ownerId: input.ownerId, scan, @@ -171,6 +184,38 @@ export function recordWorkspaceFile(db: DatabaseSync, input: { })); } +/** The content-addressed copy of a workspace file, written once; it keeps the extension so the file still opens with its app. */ +function keepCopy(workspaceRoot: string, source: string, sha256: string, bytes: Buffer): string { + const copy = join(workspaceRoot, ".agentcompany", "files", `${sha256}${extname(source)}`); + if (!existsSync(copy)) { + mkdirSync(dirname(copy), { recursive: true }); + writeFileSync(copy, bytes); + } + return copy; +} + +/** Ids recorded before copies existed still point at the live file; those whose bytes are unchanged get their copy now. */ +export function preserveRecordedFiles(db: DatabaseSync): void { + const workspaces = db.prepare("select org_id, local_path from workspaces").all() as Array<{ org_id: string; local_path: string }>; + const rows = db.prepare("select id, org_id, sha256, storage_path from file_objects where storage_path = source_path") + .all() as Array<{ id: string; org_id: string; sha256: string; storage_path: string }>; + for (const row of rows) { + const workspace = workspaces.find((candidate) => candidate.org_id === row.org_id && isInside(candidate.local_path, row.storage_path)); + if (!workspace || !existsSync(row.storage_path)) continue; + const bytes = readFileSync(row.storage_path); + if (createHash("sha256").update(bytes).digest("hex") !== row.sha256) { + console.warn(`[files] ${row.id} was rewritten before its bytes could be kept; it opens the current file`); + continue; + } + db.prepare("update file_objects set storage_path = ? where id = ?").run(keepCopy(workspace.local_path, row.storage_path, row.sha256, bytes), row.id); + } +} + +function isInside(root: string, path: string): boolean { + const rel = relative(root, path); + return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel); +} + function grantWorkspaceAccess(db: DatabaseSync, orgId: string, fileId: string, now: string): void { db.prepare(` insert into file_access_grants( diff --git a/src/renderer/src/pages/FilesPage.tsx b/src/renderer/src/pages/FilesPage.tsx index e4b2e226..cb7c5ed7 100644 --- a/src/renderer/src/pages/FilesPage.tsx +++ b/src/renderer/src/pages/FilesPage.tsx @@ -24,7 +24,8 @@ export function FilesPage({ snapshot, actions }: PageProps) { const orgId = activeOrgId(snapshot); const employees = snapshot.employees.filter((employee) => employee.orgId === orgId); const agents = snapshot.agents.filter((agent) => agent.orgId === orgId); - const files = useMemo(() => snapshot.files.filter((file) => file.orgId === orgId), [snapshot.files, orgId]); + // Older contents of a rewritten file stay attachable by id but are not listed. + const files = useMemo(() => snapshot.files.filter((file) => file.orgId === orgId && !file.supersededAt), [snapshot.files, orgId]); const fileAccessGrants = useMemo( () => snapshot.fileAccessGrants.filter((grant) => grant.orgId === orgId), [snapshot.fileAccessGrants, orgId], diff --git a/src/shared/domain.ts b/src/shared/domain.ts index 816894c5..e64038c7 100644 --- a/src/shared/domain.ts +++ b/src/shared/domain.ts @@ -229,6 +229,10 @@ export interface FileObjectRecord { sizeBytes: number; sha256: string; storagePath: string; + /** The path the content came from; several ids share it when the file was rewritten. */ + sourcePath: string | null; + /** Set once a newer content of the same path was recorded; the id still opens its own bytes. */ + supersededAt: string | null; ownerType: "employee" | "agent" | "system"; ownerId: string | null; spaceId: string | null; diff --git a/tests/unit/domain-services.test.ts b/tests/unit/domain-services.test.ts index 52cda7dc..d10c9688 100644 --- a/tests/unit/domain-services.test.ts +++ b/tests/unit/domain-services.test.ts @@ -24,7 +24,7 @@ describe("domain services", () => { const connection = createConnection(); const rows = connection.db.prepare("select version from schema_migrations order by version asc").all() as Array<{ version: number }>; - expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88]); + expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89]); connection.close(); }); diff --git a/tests/unit/file-helpers.test.ts b/tests/unit/file-helpers.test.ts index 6d8f3bab..36e362f4 100644 --- a/tests/unit/file-helpers.test.ts +++ b/tests/unit/file-helpers.test.ts @@ -14,7 +14,7 @@ function agent(id: string, department: string): AgentRecord { function file(id: string, ownerType: FileObjectRecord["ownerType"], ownerId: string | null): FileObjectRecord { return { - id, orgId: "org", name: id, kind: "doc", sizeBytes: 1, sha256: "x", storagePath: `/tmp/${id}`, + id, orgId: "org", name: id, kind: "doc", sizeBytes: 1, sha256: "x", storagePath: `/tmp/${id}`, sourcePath: null, supersededAt: null, ownerType, ownerId, spaceId: null, pinned: false, scanState: "approved", scanLabelsJson: "[]", scannedAt: null, createdAt: "2026-01-01", updatedAt: null, }; diff --git a/tests/unit/workspace-file-versions.test.ts b/tests/unit/workspace-file-versions.test.ts new file mode 100644 index 00000000..bec13905 --- /dev/null +++ b/tests/unit/workspace-file-versions.test.ts @@ -0,0 +1,60 @@ +import { createHash, randomUUID } from "node:crypto"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, test } from "vitest"; +import { DatabaseConnection } from "../../src/main/database/connection"; +import { OrganizationService } from "../../src/main/services/organization-service"; +import { preserveRecordedFiles, recordWorkspaceFile } from "../../src/main/services/files/file-object-store"; + +describe("recorded workspace files", () => { + test("a file id keeps the bytes it was recorded with after the workspace file is rewritten", () => { + const connection = new DatabaseConnection(join(tmpdir(), `ac-versions-${randomUUID()}.db`)); + const root = join(tmpdir(), `ac-versions-ws-${randomUUID()}`); + mkdirSync(root, { recursive: true }); + let orgId = ""; + connection.transaction(() => { + orgId = new OrganizationService(connection.db).createOrganization({ organizationName: "Acme", employeeName: "Dana", mission: "m", goal: "g" }); + }); + const record = () => recordWorkspaceFile(connection.db, { orgId, ownerType: "agent", ownerId: null, workspaceRoot: root, relativePath: "plan.md" }); + const file = (id: string) => connection.db.prepare("select storage_path, superseded_at from file_objects where id = ?").get(id) as { storage_path: string; superseded_at: string | null }; + + writeFileSync(join(root, "plan.md"), "handed over"); + const first = record(); + expect(record()).toBe(first); + writeFileSync(join(root, "plan.md"), "rewritten later"); + const second = record(); + + expect(second).not.toBe(first); + expect(readFileSync(file(first).storage_path, "utf8")).toBe("handed over"); + expect(readFileSync(file(second).storage_path, "utf8")).toBe("rewritten later"); + expect(file(first).superseded_at).not.toBeNull(); + expect(file(second).superseded_at).toBeNull(); + connection.close(); + }); + + test("ids recorded before copies existed get their bytes kept at startup, with their extension", () => { + const connection = new DatabaseConnection(join(tmpdir(), `ac-legacy-${randomUUID()}.db`)); + const root = join(tmpdir(), `ac-legacy-ws-${randomUUID()}`); + mkdirSync(root, { recursive: true }); + let orgId = ""; + connection.transaction(() => { + orgId = new OrganizationService(connection.db).createOrganization({ organizationName: "Acme", employeeName: "Dana", mission: "m", goal: "g" }); + new OrganizationService(connection.db).createWorkspace({ orgId, name: "HQ", localPath: root, projectId: null }); + }); + const live = join(root, "report.md"); + writeFileSync(live, "as handed over"); + const sha = createHash("sha256").update("as handed over").digest("hex"); + const now = new Date().toISOString(); + connection.db.prepare(` + insert into file_objects(id, org_id, name, kind, size_bytes, sha256, storage_path, source_path, owner_type, owner_id, pinned, scan_state, scan_labels_json, created_at) + values ('fil_legacy', ?, 'report.md', 'doc', 14, ?, ?, ?, 'agent', null, 0, 'approved', '[]', ?) + `).run(orgId, sha, live, live, now); + preserveRecordedFiles(connection.db); + writeFileSync(live, "rewritten"); + const kept = (connection.db.prepare("select storage_path from file_objects where id = 'fil_legacy'").get() as { storage_path: string }).storage_path; + expect(kept.endsWith(`${sha}.md`)).toBe(true); + expect(readFileSync(kept, "utf8")).toBe("as handed over"); + connection.close(); + }); +});