diff --git a/src/main/agent-runtime/artifact-projector.ts b/src/main/agent-runtime/artifact-projector.ts index 36acf350..f97849f6 100644 --- a/src/main/agent-runtime/artifact-projector.ts +++ b/src/main/agent-runtime/artifact-projector.ts @@ -312,6 +312,10 @@ function projectChangedPaths( * path. `last_touched_by_run_id` carries the retiring run so the * provenance timeline records the deletion alongside the earlier * edits. + * + * Every detected deletion is processed — retirement is a bulk + * `UPDATE … IN (…)` chunked by the DB layer, so a large `rm -r` + * retires the full tail instead of leaving the overflow visible. */ function retireDeletedArtifacts( db: AppDatabase, @@ -319,24 +323,18 @@ function retireDeletedArtifacts( deleted: string[], ): ProjectionOutcome[] { if (deleted.length === 0) return []; - const outcomes: ProjectionOutcome[] = []; - const budget = deleted.slice(0, MAX_BASH_PROJECTED_FILES); - for (const rel of budget) { - const existing = db.findWorkProductByExternalId( - ctx.companyId, - "filesystem", - rel, - ctx.workspaceId, - ); - if (!existing) continue; - db.updateWorkProduct(existing.id, ctx.companyId, { - status: "archived", - lastTouchedByRunId: ctx.runId, - workspaceId: ctx.workspaceId, - }); - outcomes.push({ kind: "retired", workProductId: existing.id, relativePath: rel }); - } - return outcomes; + const archived = db.archiveWorkProductsByExternalPaths({ + companyId: ctx.companyId, + workspaceId: ctx.workspaceId, + provider: "filesystem", + paths: deleted, + lastTouchedByRunId: ctx.runId, + }); + return archived.map(({ id, externalId }) => ({ + kind: "retired", + workProductId: id, + relativePath: externalId, + })); } /** diff --git a/src/main/database.ts b/src/main/database.ts index 1011b3cc..1890a45d 100644 --- a/src/main/database.ts +++ b/src/main/database.ts @@ -100,6 +100,9 @@ export class AppDatabase extends AppDatabaseBase { findWorkProductByExternalId(companyId: string, provider: string, externalId: string, workspaceId: string | null) { return this.documentsMod.findWorkProductByExternalId(companyId, provider, externalId, workspaceId); } resolveWorkProductTarget(workProductId: string, companyId: string) { return this.documentsMod.resolveWorkProductTarget(workProductId, companyId); } deleteWorkProduct(id: string, companyId: string) { return this.documentsMod.deleteWorkProduct(id, companyId); } + archiveWorkProductsByExternalPaths(input: Parameters["archiveWorkProductsByExternalPaths"]>[0]) { + return this.documentsMod.archiveWorkProductsByExternalPaths(input); + } linkDocumentToTask(taskId: string, documentId: string, companyId: string, key: string) { return this.documentsMod.linkDocumentToTask(taskId, documentId, companyId, key); } unlinkDocumentFromTask(taskId: string, key: string, companyId: string) { this.documentsMod.unlinkDocumentFromTask(taskId, key, companyId); } listDocumentsForTask(taskId: string, companyId: string) { return this.documentsMod.listDocumentsForTask(taskId, companyId); } diff --git a/src/main/database/documents.ts b/src/main/database/documents.ts index fe9907db..ac4e13d4 100644 --- a/src/main/database/documents.ts +++ b/src/main/database/documents.ts @@ -451,6 +451,65 @@ export function buildDocumentMethods( return Number(result.changes ?? 0); }, + /** + * Archive every `work_products` row whose natural identity matches + * one of `paths` under the given `(companyId, workspaceId, + * provider)` scope. The change detectors walk the whole workspace + * delta, so every detected deletion has to be retired — previously + * the projector capped the retire list at a hardcoded budget and + * the tail stayed visible in the DocumentCenter. + * + * Runs as a single `UPDATE … WHERE external_id IN (…)` per chunk + * so bulk deletions cost one SQL round-trip per 500 paths instead + * of one per path. `RETURNING` carries the archived row ids back + * to the projector so it can emit `retired` outcomes without a + * follow-up SELECT. + */ + archiveWorkProductsByExternalPaths(input: { + companyId: string; + workspaceId: string | null; + provider: string; + paths: string[]; + lastTouchedByRunId: string | null; + }): Array<{ id: string; externalId: string }> { + if (input.paths.length === 0) return []; + const ts = nowIso(); + const CHUNK = 500; + const workspaceClause = input.workspaceId + ? "AND workspace_id = ?" + : "AND workspace_id IS NULL"; + const results: Array<{ id: string; externalId: string }> = []; + for (let i = 0; i < input.paths.length; i += CHUNK) { + const chunk = input.paths.slice(i, i + CHUNK); + const placeholders = chunk.map(() => "?").join(", "); + const params: SQLInputValue[] = [ + input.lastTouchedByRunId ?? null, + ts, + input.companyId, + input.provider, + ]; + if (input.workspaceId) params.push(input.workspaceId); + params.push(...chunk); + const rows = readAll<{ id: string; external_id: string }>( + `UPDATE work_products + SET status = 'archived', + last_touched_by_run_id = ?, + updated_at = ? + WHERE company_id = ? + AND provider = ? + ${workspaceClause} + AND external_id IN (${placeholders}) + AND status <> 'archived' + RETURNING id, external_id`, + ...params, + ); + for (const row of rows) { + results.push({ id: row.id, externalId: row.external_id }); + } + } + return results; + }, + // ── Task-Document Links ── linkDocumentToTask(taskId: string, documentId: string, companyId: string, key: string): string {