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:
1 parent
04c8cce0c4
commit
8f192eabe7
11 files changed
+143
-19
No files matched your search
@@ -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'));
|
||||
`;
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -52,6 +52,7 @@ export class FileService {
|
||||
sizeBytes: stats.size,
|
||||
sha256: hash,
|
||||
storagePath: target,
|
||||
sourcePath: target,
|
||||
ownerType: "system",
|
||||
ownerId: null,
|
||||
scan,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user