Q121: a file id keeps the bytes it was recorded with; rewrites get new ids

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-10-08 19:34:44 -04:00
1 parent 04c8cce0c4
commit 8f192eabe7
11 files changed
+143 -19

No files matched your search

+2
View File
@@ -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'")
@@ -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'));
`;
+2
View File
@@ -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;
+1 -1
View File
@@ -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
+1
View File
@@ -52,6 +52,7 @@ export class FileService {
sizeBytes: stats.size,
sha256: hash,
storagePath: target,
sourcePath: target,
ownerType: "system",
ownerId: null,
scan,
+60 -15
View File
@@ -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(
+2 -1
View File
@@ -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],
+4
View File
@@ -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;
+1 -1
View File
@@ -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();
});
+1 -1
View File
@@ -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,
};
@@ -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();
});
});