Merge reviewed master203 into incoming sleep safety

This commit is contained in:
Dotta committed 2026-11-03 13:58:17 -06:00
commit 5d0716e3e7
31 files changed
+2215 -44

No files matched your search

+37
View File
@@ -746,6 +746,43 @@ blocks concurrent task or run updates. It allows audit inserts to retain their
foreign-key `KEY SHARE` locks without waiting on identity acquisition. The audit
foreign keys and their deletion behavior remain enforced.
## Execution admission accounting
The execution-admission module (`server/src/modules/execution-admission/`) owns
three tables. Nothing writes admissions or caps in production yet, and no claim
path enforces a project maximum.
- `project_task_execution_policies` stores a project's maximum concurrent tasks
apart from `projects`, so project readers never return an unenforced cap. No
row means never configured. A row with a null maximum means an explicit "no
limit", which a later legacy migration must not overwrite. Maximums are
positive. The row is deleted with its project.
- `execution_admissions` stores one admission attempt of a run. A run can be
released before dispatch and admitted again, so `(run_id, attempt)` is unique
and a partial unique index allows one unsettled attempt per run. The row keeps
the context revision, the nullable primary project as provenance (no foreign
key) and the settlement identity, which is set together with `settled_at`.
- `execution_admission_project_charges` stores one row per distinct admitted
project, including unlimited projects. An admission without projects has no
rows. Composite `(company_id, …)` foreign keys keep admissions and projects in
one company. Rows exist only while the admission is unsettled: settlement
deletes the whole set in one transaction, and `(company_id, project_id)` is
indexed for active counts, so lookups scale with active work, not history.
Archiving a project changes no charge. Deleting a project with a charge returns
`409 project_execution_admission_pending`; the foreign key is the backstop.
Deleting an agent refuses unsettled admissions on its runs and removes settled
ones. Company deletion removes the company's admissions before its runs and
projects.
The proposed global lock order for the admission transaction is: company row
(`FOR NO KEY UPDATE`), native finalization recovery owner, the whole project set
in ascending ID order (`FOR NO KEY UPDATE`, one statement), issues, wake
requests, the run, the external-admission advisory lock, the admission row and
its charges, then budget reservations. Policy updates take the same project
lock. Settlement takes no project lock. Only the project-set step is implemented
so far; claim paths keep their current locks until they are integrated.
## Switching between modes
The database mode is controlled by `DATABASE_URL`:
+16
View File
@@ -1878,6 +1878,22 @@ or recovery, unsafe paths, and explicitly concurrent shared writers block
import. An interrupted apply retains its journal and admission hold; retry the
same command to reconcile it before starting another writer.
An import preserves the workspace's lifecycle state and the task's current
workspace binding. It can import an archived or superseded workspace without
reopening the task or selecting that older workspace for future turns. A changed
task binding during the import blocks publication.
If an archived folder has already been removed, the importer can reconstruct it
in private scratch space from its encrypted task-directory restore point or
validated Git restoration recipe. For older Git workspaces without a recipe,
it requires a successful branch-deletion receipt and the exact saved commit in
the verified source repository. It never substitutes today's default branch.
Git restore points retain unpushed history and staged and unstaged edits, even
when the source repository has pruned the saved branch. Missing, expired,
corrupt, or unbound sources block the import. Publication still requires an
independent download and restore of the new encrypted snapshot. The old folder
remains absent, and the importer does not change the original restore point.
Supported sources include empty task folders, repository URLs, and managed
mutable repositories. Authorized mentioned projects attach all their configured
Git repositories as writable copies under `.paperclip-repositories/` in the task
+4
View File
@@ -63,6 +63,10 @@ never persisted or published on the shared company event bus.
Authorized events still reach cache handling and component subscribers when
eligibility is false. Lookup failure is logged but does not discard the authorized
event. Existing task/run privacy checks and payload redaction remain in force.
Task activity titles, fallback text, and links use the authorized task identifier.
If the optional identifier lookup fails, the event still arrives and uses `Task`
unless the browser already has the identifier. Permission-check failures do not
use this fallback.
When an authenticated comment event omits the comment ID, the visible task thread
refreshes through its authorized HTTP read instead of trying to hydrate the comment.
Closed sockets cannot deliver stale notifications after account/company changes.
@@ -0,0 +1,45 @@
# Preserve existing workspaces during ephemeral enrollment
The deployment endpoint remains a volume-free Cloud tenant, followed by a real
rolling deployment while an agent works and new work is admitted. Enrollment must
preserve existing task files and historical recovery sources. It must not reset
task workspaces, manufacture successful copyback, or weaken migration guards.
## Evidence and remaining work
| Required outcome | Current evidence | Remaining gap | Owner / next action |
| --- | --- | --- | --- |
| Existing active task retains files | The current import encrypts, uploads, independently restores, compares the source again and publishes under the lifecycle lock. A live completed task imported successfully with its original directory retained. | The importer only accepts an active workspace with the current task binding. | Workspace migration owner: support historical records without changing the task binding or lifecycle state. |
| Archived task retains its saved files | Retention records include encrypted task-directory restore points, Git restore points and verified Git reconstruction recipes. | The importer rejects missing local folders even when an existing restore point can reconstruct them. | Restore-point owners: reconstruct into migration scratch space, reject expired/missing/corrupt sources, and feed the verified result into the normal import publication. |
| Shared project roots preserve all task files | Legacy roots contain nested task worktrees and multiple records can reference one physical folder. | A naive root capture recursively imports those other workspaces; the current per-record writer check does not fence aliases. | Workspace migration owner: inventory physical overlaps, fence all writers, and preserve nested workspace coverage before excluding any directory. |
| Failed runs retain unsaved files | Provider inspection distinguishes surviving archived sandboxes from missing allocations. Existing recovery validation accepts some sources and rejects others for missing provenance or ambiguous allocation ownership. | Recovery must settle or durably preserve each exact source. A successful old preservation action does not necessarily update the legacy sync descriptor. | Workspace recovery owner: verify existing preservation receipts, recover eligible sources, and retain explicit unresolved obligations for unavailable/ambiguous sources. Do not replay agent work. |
| Tenant is eligible without its volume | External DB and S3 are configured; the original volume remains attached. | Workspaces, local run logs and remaining owner-maintained data must pass the packaged maintenance receipt. | Cloud enrollment owner, after workspace migration is complete. |
| Ordinary upgrades remain available | The no-drain Cloud deployment path is merged and qualified on another enrolled tenant. | Exact final app image qualification and a live deployment on this tenant. | Cloud deployment owner: qualify, enroll, then prove reads, submissions and active-run adoption through an actual replacement. |
## Acceptance boundaries
The first implementation slice supports archived and historical records,
encrypted task-directory restoration, Git restoration recipes, and legacy
branch-deletion receipts. PostgreSQL and real-file tests prove cold restoration
of saved bytes, index state, and unpushed commits, including after pruning the
old Git object store. Invalid or unavailable retained data still blocks import.
Shared-root migration and recovery-obligation reconciliation remain separate
gaps; this slice does not make a whole tenant eligible for enrollment.
- Import existing files or a verified retained source. A missing folder alone is
not permission to publish an empty workspace or choose a newer Git branch.
- Preserve archived status, task bindings, task history, unpushed Git history,
staged and unstaged edits, and saved recovery copies.
- Preserve original files and the detached original volume until verified
migration receipts and the retention interval permit removal.
- Parent roots may omit a nested workspace only with durable, inspectable
coverage of that exact nested directory. Arbitrary exclusion globs are not an
acceptable data migration.
- Resume interrupted imports with their original identity; cancellation may
release an empty reservation only after the producer is stopped and no upload
was journaled. Preserve an audit receipt.
- Verify restored file bytes independently, including after making the old
source unavailable in an isolated test. Exercise current and historical
bindings, archived sources, overlap/writer races, and unavailable sources.
- Unrelated tenants, production services, and agent task execution remain outside
the migration's mutation scope.
@@ -0,0 +1,53 @@
import { createHash, randomUUID } from "node:crypto";
import { readFile } from "node:fs/promises";
import postgres from "postgres";
import { describe, expect, it } from "vitest";
import { applyPendingMigrations, inspectMigrations } from "./client.js";
import { EMBEDDED_POSTGRES_TEST_TIMEOUT_MS, getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./test-embedded-postgres.js";
const migrationName = "0426_absent_guardian.sql";
const migrationHash = createHash("sha256")
.update(await readFile(new URL(`./migrations/${migrationName}`, import.meta.url), "utf8")).digest("hex");
const support = await getEmbeddedPostgresTestSupport();
const describePostgres = support.supported ? describe : describe.skip;
describePostgres("execution admission migration", () => {
it("adds empty admission storage without changing existing projects or runs", async () => {
const database = await startEmbeddedPostgresTestDatabase("paperclip-execution-admission-migration-");
const sql = postgres(database.connectionString, { max: 1, onnotice: () => {} });
try {
await sql`DROP TABLE execution_admission_project_charges, execution_admissions, project_task_execution_policies`;
await sql`DELETE FROM drizzle.__drizzle_migrations WHERE hash = ${migrationHash}`;
const companyId = randomUUID(), agentId = randomUUID(), projectId = randomUUID();
await sql`INSERT INTO companies (id, name, issue_prefix) VALUES (${companyId}, 'Upgrade', 'UPG')`;
await sql`INSERT INTO agents (id, company_id, name, adapter_type) VALUES (${agentId}, ${companyId}, 'Agent', 'process')`;
await sql`INSERT INTO projects (id, company_id, name, execution_workspace_policy, archived_at) VALUES
(${projectId}, ${companyId}, 'Legacy', '{"enabled":true,"sharedWorkspaceConcurrency":"serialize"}', now())`;
await sql`INSERT INTO heartbeat_runs (company_id, agent_id, status, context_snapshot) VALUES
(${companyId}, ${agentId}, 'running', ${sql.json({ projectId })})`;
const contents = async () => ({
projects: await sql`SELECT * FROM projects ORDER BY id`,
runs: await sql`SELECT * FROM heartbeat_runs ORDER BY id`,
});
const before = await contents();
expect(await inspectMigrations(database.connectionString))
.toMatchObject({ status: "needsMigrations", pendingMigrations: [migrationName] });
await applyPendingMigrations(database.connectionString);
expect(await contents()).toEqual(before);
for (const table of ["project_task_execution_policies", "execution_admissions", "execution_admission_project_charges"]) {
expect(await sql.unsafe(`SELECT count(*)::int AS count FROM "${table}"`)).toEqual([{ count: 0 }]);
}
expect((await sql`SELECT conname FROM pg_constraint WHERE conrelid = 'execution_admission_project_charges'::regclass
AND contype = 'f' ORDER BY conname`).map(row => row.conname)).toEqual([
"execution_admission_project_charges_company_admission_fk",
"execution_admission_project_charges_company_project_fk",
]);
expect(await inspectMigrations(database.connectionString)).toMatchObject({ status: "upToDate" });
} finally {
await sql.end();
await database.cleanup();
}
}, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS);
});
@@ -0,0 +1,39 @@
CREATE TABLE "execution_admission_project_charges" (
"admission_id" uuid NOT NULL,
"company_id" uuid NOT NULL,
"project_id" uuid NOT NULL,
CONSTRAINT "execution_admission_project_charges_admission_id_project_id_pk" PRIMARY KEY("admission_id","project_id")
);
--> statement-breakpoint
CREATE TABLE "execution_admissions" (
"id" uuid PRIMARY KEY NOT NULL,
"company_id" uuid NOT NULL,
"run_id" uuid NOT NULL,
"attempt" integer NOT NULL,
"primary_project_id" uuid,
"context_revision" text NOT NULL,
"settlement_id" uuid,
"settled_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "execution_admissions_attempt_check" CHECK ("execution_admissions"."attempt" > 0),
CONSTRAINT "execution_admissions_settlement_check" CHECK (("execution_admissions"."settlement_id" is null) = ("execution_admissions"."settled_at" is null))
);
--> statement-breakpoint
CREATE TABLE "project_task_execution_policies" (
"project_id" uuid PRIMARY KEY NOT NULL,
"company_id" uuid NOT NULL,
"max_concurrent_tasks" integer,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "project_task_execution_policies_maximum_check" CHECK ("project_task_execution_policies"."max_concurrent_tasks" is null or "project_task_execution_policies"."max_concurrent_tasks" > 0)
);
--> statement-breakpoint
CREATE INDEX "execution_admission_project_charges_project_idx" ON "execution_admission_project_charges" USING btree ("company_id","project_id");--> statement-breakpoint
CREATE UNIQUE INDEX "execution_admissions_company_id_uq" ON "execution_admissions" USING btree ("company_id","id");--> statement-breakpoint
CREATE UNIQUE INDEX "execution_admissions_run_attempt_uq" ON "execution_admissions" USING btree ("run_id","attempt");--> statement-breakpoint
CREATE UNIQUE INDEX "execution_admissions_live_run_uq" ON "execution_admissions" USING btree ("run_id") WHERE "execution_admissions"."settled_at" is null;--> statement-breakpoint
ALTER TABLE "execution_admission_project_charges" ADD CONSTRAINT "execution_admission_project_charges_company_admission_fk" FOREIGN KEY ("company_id","admission_id") REFERENCES "public"."execution_admissions"("company_id","id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "execution_admission_project_charges" ADD CONSTRAINT "execution_admission_project_charges_company_project_fk" FOREIGN KEY ("company_id","project_id") REFERENCES "public"."projects"("company_id","id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "execution_admissions" ADD CONSTRAINT "execution_admissions_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "execution_admissions" ADD CONSTRAINT "execution_admissions_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "project_task_execution_policies" ADD CONSTRAINT "project_task_execution_policies_company_project_fk" FOREIGN KEY ("company_id","project_id") REFERENCES "public"."projects"("company_id","id") ON DELETE cascade ON UPDATE no action;
@@ -1,6 +1,6 @@
{
"id": "8f75a1bb-32d9-4aed-9f93-134a6ae71f79",
"prevId": "52cf22f3-3bd9-4b0f-8d23-b422bb0d2540",
"id": "a1fcb4c6-40e7-47a6-b3f2-c43cd9bf332b",
"prevId": "8d9f4abf-57c7-443a-aa8b-3e3fd4f09dae",
"version": "7",
"dialect": "postgresql",
"tables": {
@@ -2233,6 +2233,74 @@
"checkConstraints": {},
"isRLSEnabled": false
},
"public.agent_file_roots": {
"name": "agent_file_roots",
"schema": "",
"columns": {
"company_id": {
"name": "company_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"agent_id": {
"name": "agent_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"generation": {
"name": "generation",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"entries": {
"name": "entries",
"type": "jsonb",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"agent_file_roots_company_id_agent_id_agents_company_id_id_fk": {
"name": "agent_file_roots_company_id_agent_id_agents_company_id_id_fk",
"tableFrom": "agent_file_roots",
"tableTo": "agents",
"columnsFrom": [
"company_id",
"agent_id"
],
"columnsTo": [
"company_id",
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"agent_file_roots_company_id_agent_id_pk": {
"name": "agent_file_roots_company_id_agent_id_pk",
"columns": [
"company_id",
"agent_id"
]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.agent_identity_keys": {
"name": "agent_identity_keys",
"schema": "",
@@ -28757,6 +28825,327 @@
"checkConstraints": {},
"isRLSEnabled": false
},
"public.execution_admission_project_charges": {
"name": "execution_admission_project_charges",
"schema": "",
"columns": {
"admission_id": {
"name": "admission_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"company_id": {
"name": "company_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"project_id": {
"name": "project_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"execution_admission_project_charges_project_idx": {
"name": "execution_admission_project_charges_project_idx",
"columns": [
{
"expression": "company_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "project_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"execution_admission_project_charges_company_admission_fk": {
"name": "execution_admission_project_charges_company_admission_fk",
"tableFrom": "execution_admission_project_charges",
"tableTo": "execution_admissions",
"columnsFrom": [
"company_id",
"admission_id"
],
"columnsTo": [
"company_id",
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"execution_admission_project_charges_company_project_fk": {
"name": "execution_admission_project_charges_company_project_fk",
"tableFrom": "execution_admission_project_charges",
"tableTo": "projects",
"columnsFrom": [
"company_id",
"project_id"
],
"columnsTo": [
"company_id",
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"execution_admission_project_charges_admission_id_project_id_pk": {
"name": "execution_admission_project_charges_admission_id_project_id_pk",
"columns": [
"admission_id",
"project_id"
]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.execution_admissions": {
"name": "execution_admissions",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true
},
"company_id": {
"name": "company_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"run_id": {
"name": "run_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"attempt": {
"name": "attempt",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"primary_project_id": {
"name": "primary_project_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"context_revision": {
"name": "context_revision",
"type": "text",
"primaryKey": false,
"notNull": true
},
"settlement_id": {
"name": "settlement_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"settled_at": {
"name": "settled_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"execution_admissions_company_id_uq": {
"name": "execution_admissions_company_id_uq",
"columns": [
{
"expression": "company_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
},
"execution_admissions_run_attempt_uq": {
"name": "execution_admissions_run_attempt_uq",
"columns": [
{
"expression": "run_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "attempt",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
},
"execution_admissions_live_run_uq": {
"name": "execution_admissions_live_run_uq",
"columns": [
{
"expression": "run_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"where": "\"execution_admissions\".\"settled_at\" is null",
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"execution_admissions_company_id_companies_id_fk": {
"name": "execution_admissions_company_id_companies_id_fk",
"tableFrom": "execution_admissions",
"tableTo": "companies",
"columnsFrom": [
"company_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"execution_admissions_run_id_heartbeat_runs_id_fk": {
"name": "execution_admissions_run_id_heartbeat_runs_id_fk",
"tableFrom": "execution_admissions",
"tableTo": "heartbeat_runs",
"columnsFrom": [
"run_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"execution_admissions_attempt_check": {
"name": "execution_admissions_attempt_check",
"value": "\"execution_admissions\".\"attempt\" > 0"
},
"execution_admissions_settlement_check": {
"name": "execution_admissions_settlement_check",
"value": "(\"execution_admissions\".\"settlement_id\" is null) = (\"execution_admissions\".\"settled_at\" is null)"
}
},
"isRLSEnabled": false
},
"public.project_task_execution_policies": {
"name": "project_task_execution_policies",
"schema": "",
"columns": {
"project_id": {
"name": "project_id",
"type": "uuid",
"primaryKey": true,
"notNull": true
},
"company_id": {
"name": "company_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"max_concurrent_tasks": {
"name": "max_concurrent_tasks",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"project_task_execution_policies_company_project_fk": {
"name": "project_task_execution_policies_company_project_fk",
"tableFrom": "project_task_execution_policies",
"tableTo": "projects",
"columnsFrom": [
"company_id",
"project_id"
],
"columnsTo": [
"company_id",
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"project_task_execution_policies_maximum_check": {
"name": "project_task_execution_policies_maximum_check",
"value": "\"project_task_execution_policies\".\"max_concurrent_tasks\" is null or \"project_task_execution_policies\".\"max_concurrent_tasks\" > 0"
}
},
"isRLSEnabled": false
},
"public.execution_workspace_repositories": {
"name": "execution_workspace_repositories",
"schema": "",
@@ -29705,6 +30094,241 @@
"checkConstraints": {},
"isRLSEnabled": false
},
"public.execution_workspace_snapshots": {
"name": "execution_workspace_snapshots",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"company_id": {
"name": "company_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"execution_workspace_id": {
"name": "execution_workspace_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"source_operation_id": {
"name": "source_operation_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"source_run_id": {
"name": "source_run_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"parent_snapshot_id": {
"name": "parent_snapshot_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"generation": {
"name": "generation",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"epoch": {
"name": "epoch",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'pending'"
},
"format": {
"name": "format",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'paperclip.remote-workspace-archive.v1'"
},
"object_key": {
"name": "object_key",
"type": "text",
"primaryKey": false,
"notNull": false
},
"sha256": {
"name": "sha256",
"type": "text",
"primaryKey": false,
"notNull": false
},
"byte_size": {
"name": "byte_size",
"type": "bigint",
"primaryKey": false,
"notNull": false
},
"key": {
"name": "key",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"manifest_digest": {
"name": "manifest_digest",
"type": "text",
"primaryKey": false,
"notNull": false
},
"metadata": {
"name": "metadata",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"delete_after": {
"name": "delete_after",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"last_error": {
"name": "last_error",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"execution_workspace_snapshots_operation_uniq": {
"name": "execution_workspace_snapshots_operation_uniq",
"columns": [
{
"expression": "company_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "execution_workspace_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "source_operation_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
},
"execution_workspace_snapshots_generation_uniq": {
"name": "execution_workspace_snapshots_generation_uniq",
"columns": [
{
"expression": "company_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "execution_workspace_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "generation",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
},
"execution_workspace_snapshots_workspace_idx": {
"name": "execution_workspace_snapshots_workspace_idx",
"columns": [
{
"expression": "company_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "execution_workspace_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"execution_workspace_snapshots_cleanup_idx": {
"name": "execution_workspace_snapshots_cleanup_idx",
"columns": [
{
"expression": "status",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "delete_after",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.execution_workspaces": {
"name": "execution_workspaces",
"schema": "",
@@ -50177,6 +50801,12 @@
"primaryKey": false,
"notNull": false
},
"created_by_run_id": {
"name": "created_by_run_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"dispatch_fingerprint": {
"name": "dispatch_fingerprint",
"type": "text",
@@ -62139,6 +62769,12 @@
"primaryKey": false,
"notNull": false
},
"cleanup_verified_at": {
"name": "cleanup_verified_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
+7
View File
@@ -2969,6 +2969,13 @@
"when": 1793724587619,
"tag": "0425_awesome_living_lightning",
"breakpoints": true
},
{
"idx": 426,
"version": "7",
"when": 1793733446322,
"tag": "0426_absent_guardian",
"breakpoints": true
}
]
}
@@ -0,0 +1,59 @@
import { sql } from "drizzle-orm";
import { check, foreignKey, index, integer, pgTable, primaryKey, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core";
import { companies } from "./companies.js";
import { heartbeatRuns } from "./heartbeat_runs.js";
import { projects } from "./projects.js";
/**
* Project task-execution policy, kept apart from `projects` so unenforced caps
* never reach project readers. No row: never configured. Null maximum: explicit
* unlimited. Owned by the execution-admission module.
*/
export const projectTaskExecutionPolicies = pgTable("project_task_execution_policies", {
projectId: uuid("project_id").primaryKey(),
companyId: uuid("company_id").notNull(),
maxConcurrentTasks: integer("max_concurrent_tasks"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
}, t => ({
project: foreignKey({ columns: [t.companyId, t.projectId], foreignColumns: [projects.companyId, projects.id],
name: "project_task_execution_policies_company_project_fk" }).onDelete("cascade"),
maximum: check("project_task_execution_policies_maximum_check", sql`${t.maxConcurrentTasks} is null or ${t.maxConcurrentTasks} > 0`),
}));
/**
* One admission attempt of a run. A run can be admitted again after a settled
* attempt, so the run ID is not the admission identity. Primary project is
* provenance only; the charges below are the admitted project set.
*/
export const executionAdmissions = pgTable("execution_admissions", {
id: uuid("id").primaryKey(),
companyId: uuid("company_id").notNull().references(() => companies.id),
runId: uuid("run_id").notNull().references(() => heartbeatRuns.id),
attempt: integer("attempt").notNull(),
primaryProjectId: uuid("primary_project_id"),
contextRevision: text("context_revision").notNull(),
settlementId: uuid("settlement_id"),
settledAt: timestamp("settled_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
}, t => ({
companyIdentity: uniqueIndex("execution_admissions_company_id_uq").on(t.companyId, t.id),
runAttempt: uniqueIndex("execution_admissions_run_attempt_uq").on(t.runId, t.attempt),
liveRun: uniqueIndex("execution_admissions_live_run_uq").on(t.runId).where(sql`${t.settledAt} is null`),
attemptCheck: check("execution_admissions_attempt_check", sql`${t.attempt} > 0`),
settlementCheck: check("execution_admissions_settlement_check", sql`(${t.settlementId} is null) = (${t.settledAt} is null)`),
}));
/** One active charge per distinct admitted project. Settlement deletes the whole set; a charged project cannot be deleted. */
export const executionAdmissionProjectCharges = pgTable("execution_admission_project_charges", {
admissionId: uuid("admission_id").notNull(),
companyId: uuid("company_id").notNull(),
projectId: uuid("project_id").notNull(),
}, t => ({
identity: primaryKey({ columns: [t.admissionId, t.projectId] }),
admission: foreignKey({ columns: [t.companyId, t.admissionId], foreignColumns: [executionAdmissions.companyId, executionAdmissions.id],
name: "execution_admission_project_charges_company_admission_fk" }).onDelete("cascade"),
project: foreignKey({ columns: [t.companyId, t.projectId], foreignColumns: [projects.companyId, projects.id],
name: "execution_admission_project_charges_company_project_fk" }),
activeProject: index("execution_admission_project_charges_project_idx").on(t.companyId, t.projectId),
}));
+2
View File
@@ -274,3 +274,5 @@ export { workflows } from "./workflows.js";
export { userWorkspacePreferences } from "./user_workspace_preferences.js";
export { issueThreadInteractionIdempotencyKeys } from "./issue_thread_interaction_idempotency_keys.js";
export { projectTaskExecutionPolicies, executionAdmissions, executionAdmissionProjectCharges } from "./execution_admissions.js";
+80 -7
View File
@@ -29,7 +29,7 @@ function database(onPredicate: (kind: "where" | "join", condition: SQL) => void
innerJoin: (_table: unknown, condition: SQL) => { onPredicate("join", condition); return query; },
where: (condition: SQL) => { onPredicate("where", condition); return query; },
limit: async () => {
if (table === "issues") return [{ id: "task" }];
if (table === "issues") return [{ id: "task", identifier: "PAP-1234" }];
if (table === "company_memberships") return fields.name ? [{ name: "Alex Example", image: "/alex.png" }] : [{ id: "membership" }];
if (table === "agents") return [{ name: "Coder", appearance: { schemaVersion: 1, characterVersion: "cap-v1", paletteId: "bubblegum-sky" } }];
return [];
@@ -39,6 +39,60 @@ function database(onPredicate: (kind: "where" | "join", condition: SQL) => void
} };
}
describe("authenticated activity notifications", () => {
it.each(["local_trusted", "authenticated"] as const)("keeps authorized notifications when optional identifier reads fail (%s)", async deploymentMode => {
const db = database();
const select = db.select;
const identifierRead = vi.fn();
db.select = fields => {
if (fields.identifier) {
identifierRead();
if (identifierRead.mock.calls.length <= 3) throw new Error("Identifier lookup unavailable");
}
return select(fields);
};
const server = createServer();
setupLiveEventsWebSocketServer(server, db as never, { deploymentMode,
resolveCloudActor: async () => ({ userId: "viewer", companyIds: ["company"] }) });
server.listen(0, "127.0.0.1");
await once(server, "listening");
const socket = new WebSocket(`ws://127.0.0.1:${(server.address() as { port: number }).port}/api/companies/company/events/ws`);
try {
await once(socket, "open");
const messages: Array<{ payload: Record<string, unknown> }> = [];
const delivered = new Promise<void>(resolve => socket.on("message", data => {
const event = JSON.parse(data.toString());
messages.push(event);
if (event.payload.entityId === "after-failure") resolve();
}));
const activity = (entityId: string, actorType: string) => publishLiveEvent({
companyId: "company", type: "activity.logged", payload: {
action: "issue.updated", entityType: "issue", entityId, actorType,
actorId: actorType === "user" ? "viewer" : actorType === "agent" ? "agent" : null,
issueIdentifier: "SPOOF-1", details: { secret: "private task details" },
},
});
if (deploymentMode === "authenticated") activity("hidden", "system");
for (const actorType of ["system", "agent", "user"]) {
activity("visible", actorType);
}
activity("after-failure", "system");
await delivered;
expect(messages).toHaveLength(4);
expect(identifierRead).toHaveBeenCalledTimes(4);
for (const [index, actorType] of ["system", "agent", "user"].entries()) {
const event = messages[index];
expect(event.payload).toMatchObject({ entityId: "visible", actorType, issueIdentifier: null });
if (actorType === "agent") expect(event.payload.actorName).toBe("Coder");
if (actorType === "user") expect(event.payload.actorName).toBe("Alex Example");
if (deploymentMode === "authenticated") expect(event.payload).not.toHaveProperty("details");
}
expect(messages[3].payload.issueIdentifier).toBe("PAP-1234");
} finally {
socket.terminate();
await new Promise<void>(resolve => server.close(() => resolve()));
}
});
it("preserves only an authorized handoff task identity for empty case-panel refresh", async () => {
const server = createServer();
setupLiveEventsWebSocketServer(server, database() as never, { deploymentMode: "authenticated",
@@ -88,6 +142,7 @@ describe("authenticated activity notifications", () => {
} });
const [data] = await delivery;
expect(JSON.parse(data.toString()).toastEligible).toBe(true);
expect(JSON.parse(data.toString()).payload.issueIdentifier).toBe("PAP-1234");
expect(taskToastEligible).toHaveBeenLastCalledWith(expect.anything(), expect.anything(), "local-board");
} finally {
socket.terminate();
@@ -123,6 +178,8 @@ describe("authenticated activity notifications", () => {
expect(b.toastEligible).toBe(false);
expect(a.payload.entityId).toBe(entityId);
expect(b.payload.entityId).toBe(entityId);
expect(a.payload.issueIdentifier).toBe("PAP-1234");
expect(b.payload.issueIdentifier).toBe("PAP-1234");
expect(original).not.toHaveProperty("toastEligible");
}
} finally {
@@ -132,9 +189,9 @@ describe("authenticated activity notifications", () => {
}
});
it("keeps actor identity after authorization and excludes hidden tasks and private details", async () => {
it.each(["authenticated", "local_trusted"] as const)("keeps task and actor identity for %s without exposing hidden tasks or copying private details", async (deploymentMode) => {
const server = createServer();
setupLiveEventsWebSocketServer(server, database() as never, { deploymentMode: "authenticated",
setupLiveEventsWebSocketServer(server, database() as never, { deploymentMode,
resolveCloudActor: async () => ({ userId: "viewer", companyIds: ["company"] }) });
server.listen(0, "127.0.0.1");
await once(server, "listening");
@@ -145,19 +202,21 @@ describe("authenticated activity notifications", () => {
socket.on("message", (data) => messages.push(JSON.parse(data.toString())));
const activity = (entityId: string, actorType = "user") => publishLiveEvent({ companyId: "company", type: "activity.logged", payload: {
action: "issue.created", entityType: "issue", entityId, actorType, actorId: actorType === "user" ? "viewer" : "agent",
issueIdentifier: "SPOOF-1",
details: { bodySnippet: "private comment", secret: "not for the socket" },
} });
activity("hidden");
if (deploymentMode === "authenticated") activity("hidden");
const first = once(socket, "message");
activity("visible");
await first;
expect(messages).toHaveLength(1);
expect(messages[0].payload).toEqual({ action: "issue.created", entityType: "issue", entityId: "visible", actorType: "user", actorId: "viewer", actorName: "Alex Example", actorImage: "/alex.png" });
expect(messages[0].payload).toMatchObject({ action: "issue.created", entityType: "issue", entityId: "visible", issueIdentifier: "PAP-1234", actorType: "user", actorId: "viewer", actorName: "Alex Example", actorImage: "/alex.png" });
if (deploymentMode === "authenticated") expect(messages[0].payload).not.toHaveProperty("details");
const second = once(socket, "message");
activity("visible", "agent");
await second;
expect(messages[1].payload).toMatchObject({ actorName: "Coder", actorType: "agent", actorId: "agent", actorAppearance: { paletteId: "bubblegum-sky" } });
expect(messages[1].payload).not.toHaveProperty("details");
expect(messages[1].payload).toMatchObject({ issueIdentifier: "PAP-1234", actorName: "Coder", actorType: "agent", actorId: "agent", actorAppearance: { paletteId: "bubblegum-sky" } });
if (deploymentMode === "authenticated") expect(messages[1].payload).not.toHaveProperty("details");
} finally {
socket.terminate();
await new Promise<void>((resolve) => server.close(() => resolve()));
@@ -167,6 +226,20 @@ describe("authenticated activity notifications", () => {
describe("activity profile query boundaries", () => {
it("reads the task identifier within its company even when there is no actor", async () => {
const predicates: { sql: string; params: unknown[] }[] = [];
const db = database((_kind, condition) => {
const { sql, params } = new PgDialect().sqlToQuery(condition);
predicates.push({ sql, params });
});
expect(await activityToastIdentity(db as never, "company-A", {
action: "issue.updated", entityType: "issue", entityId: "task-A", actorType: "system",
})).toMatchObject({ issueIdentifier: "PAP-1234" });
expect(predicates).toEqual([
{ sql: '("issues"."company_id" = $1 and "issues"."id" = $2)', params: ["company-A", "task-A"] },
]);
});
it("requires the exact company, human actor, and active membership before reading a person profile", async () => {
const predicates: { kind: string; sql: string; params: unknown[] }[] = [];
const db = database((kind, condition) => {
+6
View File
@@ -56,3 +56,9 @@ Ordinary configuration updates use the root lifecycle command.
The [agent lifecycle module](../../../doc/AGENT-LIFECYCLE.md) owns agent creation,
pause, resume, and termination. Use its commands for these changes.
The execution admission module owns project task-execution policy and the
durable admission records that charge project capacity. Today it exposes only
internal policy commands and the deletion guards that project, agent, and
company deletion call. Execution claims still belong to `services/heartbeat/queue.ts`
and native review dispatch. See [execution admission accounting](../../../doc/DATABASE.md#execution-admission-accounting).
@@ -0,0 +1,11 @@
import { executionAdmissions } from "@paperclipai/db";
import { eq } from "drizzle-orm";
import type { CompanyDeletionParticipant } from "../../../lib/company-deletion.js";
/** Removes every admission, settled or not, before the company's runs and projects are deleted. */
export const executionAdmissionCompanyDeletion: CompanyDeletionParticipant = {
async deleteCompanyData(tx, companyId) {
if (!("nestedIndex" in tx)) throw new Error("Company deletion requires a database transaction");
await tx.delete(executionAdmissions).where(eq(executionAdmissions.companyId, companyId));
},
};
@@ -0,0 +1,239 @@
import { randomUUID } from "node:crypto";
import {
agents,
companies,
createDb,
executionAdmissionProjectCharges as charges,
executionAdmissions as admissions,
heartbeatRuns,
projectTaskExecutionPolicies as policies,
projects,
} from "@paperclipai/db";
import { asc, eq, sql } from "drizzle-orm";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "../../../__tests__/helpers/embedded-postgres.js";
import { withAccountingTransaction } from "../../../services/accounting-transaction.js";
import { deleteAgentDependencies } from "../../../services/agent-deletion.js";
import { deleteCompany } from "../../../services/company-deletion.js";
import { projectService } from "../../../services/projects.js";
import { prepareAdmissionRecord, type AdmissionRecord } from "../domain/admission.js";
import { createExecutionAdmission } from "../index.js";
import { countActiveProjectCharges, recordAdmission, settleAdmission } from "./postgres.js";
const support = await getEmbeddedPostgresTestSupport();
type TestDb = ReturnType<typeof createDb>;
function record(primaryProjectId: string | null, referencedProjectIds: string[], contextRevision = "issue:1") {
const result = prepareAdmissionRecord({ primaryProjectId, referencedProjectIds, contextRevision });
if (result.kind !== "ok") throw new Error(result.kind);
return result.record;
}
describe.skipIf(!support.supported)("execution admission storage", () => {
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
let db: TestDb;
beforeAll(async () => {
database = await startEmbeddedPostgresTestDatabase("execution-admission-");
db = createDb(database.connectionString);
}, 60_000);
afterAll(async () => {
await db?.$client.end();
await database?.cleanup();
});
async function fixture() {
const [company] = await db.insert(companies).values({ name: "Admission fixture", issuePrefix: `A${randomUUID().slice(0, 6)}` }).returning();
const [agent] = await db.insert(agents).values({ companyId: company.id, name: "Admission agent", adapterType: "process" }).returning();
const projectRows = await db.insert(projects).values(["Design", "Docs", "Unlimited"].map(name => ({ companyId: company.id, name })))
.returning();
const [run] = await db.insert(heartbeatRuns).values({ companyId: company.id, agentId: agent.id }).returning();
return { companyId: company.id, agentId: agent.id, projectIds: projectRows.map(row => row.id).sort(), runId: run.id };
}
const inTransaction = <T>(work: (tx: TestDb) => Promise<T>) => db.transaction(tx => work(tx as unknown as TestDb));
const admit = (input: { companyId: string; runId: string; admissionId?: string; record: AdmissionRecord }) =>
inTransaction(tx => recordAdmission(tx, { ...input, admissionId: input.admissionId ?? randomUUID() }));
const settle = (companyId: string, admissionId: string, settlementId = randomUUID()) =>
inTransaction(tx => settleAdmission(tx, { companyId, admissionId, settlementId }));
const chargedProjects = async (admissionId: string) => (await db.select({ projectId: charges.projectId }).from(charges)
.where(eq(charges.admissionId, admissionId)).orderBy(asc(charges.projectId))).map(row => row.projectId);
it("stores absent, explicit unlimited and positive maximums with partial updates", async () => {
const { companyId, projectIds: [projectId] } = await fixture();
const policy = createExecutionAdmission(db);
const scope = { companyId, projectId };
expect(await policy.readTaskExecutionPolicy(scope)).toEqual({ kind: "found", policy: { state: "absent", maxConcurrentTasks: null } });
expect(await policy.updateTaskExecutionPolicy(scope, {})).toMatchObject({ kind: "unchanged", policy: { state: "absent" } });
expect(await db.select().from(policies)).not.toContainEqual(expect.objectContaining({ projectId }));
expect(await policy.updateTaskExecutionPolicy(scope, { maxConcurrentTasks: 2 }))
.toEqual({ kind: "updated", policy: { state: "configured", maxConcurrentTasks: 2 } });
expect(await policy.updateTaskExecutionPolicy(scope, undefined)).toMatchObject({ kind: "unchanged", policy: { maxConcurrentTasks: 2 } });
expect(await policy.updateTaskExecutionPolicy(scope, { maxConcurrentTasks: 0 })).toMatchObject({ kind: "invalid" });
expect(await policy.updateTaskExecutionPolicy(scope, null))
.toEqual({ kind: "updated", policy: { state: "configured", maxConcurrentTasks: null } });
expect(await policy.readTaskExecutionPolicy(scope)).toEqual({ kind: "found", policy: { state: "configured", maxConcurrentTasks: null } });
const foreign = { companyId: (await fixture()).companyId, projectId };
expect(await policy.readTaskExecutionPolicy(foreign)).toEqual({ kind: "project_not_found" });
expect(await policy.updateTaskExecutionPolicy(foreign, { maxConcurrentTasks: 1 })).toEqual({ kind: "project_not_found" });
expect(await policy.readTaskExecutionPolicy(scope)).toMatchObject({ policy: { maxConcurrentTasks: null } });
});
it("enforces policy constraints and company scope in the database", async () => {
const { companyId, projectIds: [projectId] } = await fixture();
const other = await fixture();
await expect(db.insert(policies).values({ companyId, projectId, maxConcurrentTasks: 0 }))
.rejects.toMatchObject({ cause: { constraint_name: "project_task_execution_policies_maximum_check" } });
await expect(db.insert(policies).values({ companyId: other.companyId, projectId, maxConcurrentTasks: 1 }))
.rejects.toMatchObject({ cause: { constraint_name: "project_task_execution_policies_company_project_fk" } });
});
it("charges every distinct project, including unlimited and primaryless sets", async () => {
const { companyId, runId, projectIds: [a, b, c] } = await fixture();
await createExecutionAdmission(db).updateTaskExecutionPolicy({ companyId, projectId: c }, null);
const admissionId = randomUUID();
const admitted = record(a, [c, b, a]);
expect(await admit({ companyId, runId, admissionId, record: admitted })).toEqual({ kind: "recorded", attempt: 1 });
expect(await chargedProjects(admissionId)).toEqual([a, b, c]);
const [epoch] = await db.select().from(admissions).where(eq(admissions.id, admissionId));
expect(epoch).toMatchObject({ companyId, runId, attempt: 1, primaryProjectId: a, contextRevision: "issue:1", settlementId: null });
const other = await fixture();
const primaryless = randomUUID();
expect(await admit({ ...other, admissionId: primaryless, record: record(null, [other.projectIds[1]]) }))
.toMatchObject({ kind: "recorded" });
expect(await chargedProjects(primaryless)).toEqual([other.projectIds[1]]);
const empty = await fixture();
const emptyId = randomUUID();
expect(await admit({ ...empty, admissionId: emptyId, record: record(null, []) })).toMatchObject({ kind: "recorded" });
expect(await chargedProjects(emptyId)).toEqual([]);
expect([...(await countActiveProjectCharges(db, empty.companyId, empty.projectIds)).values()]).toEqual([0, 0, 0]);
});
it("replays one admission identity and refuses a second live epoch for the run", async () => {
const { companyId, runId, projectIds: [a, b] } = await fixture();
const admissionId = randomUUID();
const admitted = record(a, [b]);
await admit({ companyId, runId, admissionId, record: admitted });
expect(await admit({ companyId, runId, admissionId, record: admitted })).toEqual({ kind: "replayed", attempt: 1 });
expect(await admit({ companyId, runId, admissionId, record: record(a, []) })).toEqual({ kind: "admission_conflict" });
expect(await admit({ companyId, runId, admissionId, record: record(a, [b], "issue:2") })).toEqual({ kind: "admission_conflict" });
expect(await admit({ companyId, runId, record: admitted })).toEqual({ kind: "run_already_admitted", admissionId });
expect(await db.select({ id: admissions.id }).from(admissions).where(eq(admissions.runId, runId))).toHaveLength(1);
await expect(db.insert(admissions).values({ id: randomUUID(), companyId, runId, attempt: 2, contextRevision: "x" }))
.rejects.toMatchObject({ cause: { constraint_name: "execution_admissions_live_run_uq" } });
});
it("settles the whole set once and starts a new attempt for the same run", async () => {
const { companyId, runId, projectIds: [a, b] } = await fixture();
const first = randomUUID();
await admit({ companyId, runId, admissionId: first, record: record(a, [b]) });
const settlementId = randomUUID();
expect(await settle(companyId, first, settlementId)).toEqual({ kind: "settled", releasedProjectIds: [a, b] });
expect(await settle(companyId, first, settlementId)).toEqual({ kind: "already_settled" });
expect(await settle(companyId, first)).toEqual({ kind: "settled_by_other" });
expect(await settle(randomUUID(), first)).toEqual({ kind: "not_found" });
expect(await chargedProjects(first)).toEqual([]);
expect(await admit({ companyId, runId, admissionId: first, record: record(a, [b]) })).toEqual({ kind: "admission_settled" });
const second = randomUUID();
expect(await admit({ companyId, runId, admissionId: second, record: record(a, [], "issue:2") })).toEqual({ kind: "recorded", attempt: 2 });
expect(await db.select({ attempt: admissions.attempt, settled: sql<boolean>`${admissions.settledAt} is not null` })
.from(admissions).where(eq(admissions.runId, runId)).orderBy(asc(admissions.attempt)))
.toEqual([{ attempt: 1, settled: true }, { attempt: 2, settled: false }]);
await expect(db.update(admissions).set({ settlementId: randomUUID() }).where(eq(admissions.id, second)))
.rejects.toMatchObject({ cause: { constraint_name: "execution_admissions_settlement_check" } });
});
it("keeps company isolation and rolls back the whole record", async () => {
const { companyId, runId, projectIds: [a] } = await fixture();
const other = await fixture();
expect(await admit({ companyId, runId, record: record(a, [other.projectIds[0]]) }))
.toEqual({ kind: "projects_unavailable", projectIds: [other.projectIds[0]] });
expect(await admit({ companyId, runId: other.runId, record: record(a, []) })).toEqual({ kind: "run_not_found" });
const admissionId = randomUUID();
await admit({ companyId, runId, admissionId, record: record(a, []) });
expect(await admit({ companyId: other.companyId, runId: other.runId, admissionId, record: record(null, []) }))
.toEqual({ kind: "admission_conflict" });
await expect(db.insert(charges).values({ admissionId, companyId, projectId: other.projectIds[0] }))
.rejects.toMatchObject({ cause: { constraint_name: "execution_admission_project_charges_company_project_fk" } });
await expect(db.insert(charges).values({ admissionId, companyId, projectId: a }))
.rejects.toMatchObject({ cause: { constraint_name: "execution_admission_project_charges_admission_id_project_id_pk" } });
const rolledBack = randomUUID();
await expect(inTransaction(async tx => {
await recordAdmission(tx, { companyId: other.companyId, runId: other.runId, admissionId: rolledBack, record: record(null, other.projectIds) });
throw new Error("claim lost");
})).rejects.toThrow("claim lost");
expect(await db.select().from(admissions).where(eq(admissions.id, rolledBack))).toEqual([]);
expect(await chargedProjects(rolledBack)).toEqual([]);
await expect(recordAdmission(db, { companyId, runId, admissionId: randomUUID(), record: record(a, []) }))
.rejects.toThrow("requires a database transaction");
});
it("counts active charges per project through the project index", async () => {
const first = await fixture();
const [a, b, c] = first.projectIds;
const [secondRun] = await db.insert(heartbeatRuns).values({ companyId: first.companyId, agentId: first.agentId }).returning();
const held = randomUUID();
await admit({ ...first, admissionId: held, record: record(a, [b]) });
await admit({ companyId: first.companyId, runId: secondRun.id, record: record(null, [a]) });
expect(Object.fromEntries(await countActiveProjectCharges(db, first.companyId, [a, b, c]))).toEqual({ [a]: 2, [b]: 1, [c]: 0 });
await settle(first.companyId, held);
expect(Object.fromEntries(await countActiveProjectCharges(db, first.companyId, [a, b, c]))).toEqual({ [a]: 1, [b]: 0, [c]: 0 });
expect(Object.fromEntries(await countActiveProjectCharges(db, randomUUID(), [a]))).toEqual({ [a]: 0 });
const plan = await inTransaction(async tx => {
await tx.execute(sql`set local enable_seqscan = off`);
return tx.execute(sql`explain select project_id, count(*) from execution_admission_project_charges
where company_id = ${first.companyId} and project_id in (${a}, ${b}) group by project_id`);
});
expect(JSON.stringify(plan)).toContain("execution_admission_project_charges_project_idx");
});
it("preserves unsettled charges through project archive and delete", async () => {
const { companyId, runId, projectIds: [a, b] } = await fixture();
const admissionId = randomUUID();
await admit({ companyId, runId, admissionId, record: record(a, [b]) });
const projectsService = projectService(db);
await db.update(projects).set({ archivedAt: new Date() }).where(eq(projects.id, b));
expect(await chargedProjects(admissionId)).toEqual([a, b]);
await expect(projectsService.remove(a)).rejects.toMatchObject({ status: 409, details: { code: "project_execution_admission_pending" } });
await expect(db.delete(projects).where(eq(projects.id, b)))
.rejects.toMatchObject({ cause: { constraint_name: "execution_admission_project_charges_company_project_fk" } });
expect(await chargedProjects(admissionId)).toEqual([a, b]);
await createExecutionAdmission(db).updateTaskExecutionPolicy({ companyId, projectId: a }, { maxConcurrentTasks: 1 });
await settle(companyId, admissionId);
expect(await projectsService.remove(a)).toMatchObject({ id: a });
expect(await db.select().from(policies).where(eq(policies.projectId, a))).toEqual([]);
expect(await db.select({ primaryProjectId: admissions.primaryProjectId }).from(admissions).where(eq(admissions.id, admissionId)))
.toEqual([{ primaryProjectId: a }]);
});
it("guards agent deletion while unsettled and removes admissions with the company", async () => {
const { companyId, agentId, runId, projectIds: [a] } = await fixture();
const admissionId = randomUUID();
await admit({ companyId, runId, admissionId, record: record(a, []) });
await expect(withAccountingTransaction(db, companyId, tx => deleteAgentDependencies(tx, companyId, agentId)))
.rejects.toMatchObject({ status: 409, details: { code: "agent_execution_admission_pending" } });
expect(await chargedProjects(admissionId)).toEqual([a]);
await settle(companyId, admissionId);
await withAccountingTransaction(db, companyId, tx => deleteAgentDependencies(tx, companyId, agentId));
expect(await db.select().from(admissions).where(eq(admissions.runId, runId))).toEqual([]);
const deleted = await fixture();
const retained = await fixture();
const retainedAdmission = randomUUID();
await admit({ ...retained, admissionId: retainedAdmission, record: record(retained.projectIds[0], []) });
await admit({ ...deleted, record: record(deleted.projectIds[0], deleted.projectIds) });
await db.update(agents).set({ lifecycleState: "terminated", status: "terminated" }).where(eq(agents.id, deleted.agentId));
expect(await deleteCompany(db, deleted.companyId)).toMatchObject({ id: deleted.companyId });
expect(await db.select().from(admissions).where(eq(admissions.companyId, deleted.companyId))).toEqual([]);
expect(await chargedProjects(retainedAdmission)).toEqual([retained.projectIds[0]]);
}, 60_000);
});
@@ -0,0 +1,165 @@
import {
executionAdmissionProjectCharges as charges,
executionAdmissions as admissions,
heartbeatRuns,
projectTaskExecutionPolicies as policies,
projects,
type Db,
} from "@paperclipai/db";
import { and, asc, eq, inArray, isNull, sql } from "drizzle-orm";
import type { TaskExecutionPolicyStore } from "../application/policy.js";
import { decideSettlement, sameAdmissionRecord, type AdmissionRecord } from "../domain/admission.js";
import { ABSENT_TASK_EXECUTION_POLICY, type TaskExecutionPolicy } from "../domain/policy.js";
function assertTransaction(tx: Db) {
if (!("nestedIndex" in tx)) throw new Error("Execution admission storage requires a database transaction");
}
/**
* Global lock order step 3: locks the whole project set in ascending ID order
* in one statement. Returns the IDs that are not projects of the company.
*/
export async function lockAdmissionProjects(tx: Db, companyId: string, projectIds: readonly string[]) {
assertTransaction(tx);
if (projectIds.length === 0) return [];
const rows = await tx.select({ id: projects.id }).from(projects)
.where(and(eq(projects.companyId, companyId), inArray(projects.id, [...projectIds])))
.orderBy(asc(projects.id)).for("no key update");
const found = new Set(rows.map(row => row.id));
return projectIds.filter(id => !found.has(id));
}
function toPolicy(row: { maxConcurrentTasks: number | null } | undefined): TaskExecutionPolicy {
return row ? { state: "configured", maxConcurrentTasks: row.maxConcurrentTasks } : ABSENT_TASK_EXECUTION_POLICY;
}
export function createPostgresTaskExecutionPolicyStore(db: Db): TaskExecutionPolicyStore {
return {
async read({ companyId, projectId }) {
const [row] = await db.select({ configured: policies.projectId, maxConcurrentTasks: policies.maxConcurrentTasks })
.from(projects).leftJoin(policies, eq(policies.projectId, projects.id))
.where(and(eq(projects.companyId, companyId), eq(projects.id, projectId)));
if (!row) return null;
return row.configured ? toPolicy(row) : ABSENT_TASK_EXECUTION_POLICY;
},
update: ({ companyId, projectId }, change) => db.transaction(async transaction => {
const tx = transaction as unknown as Db;
if ((await lockAdmissionProjects(tx, companyId, [projectId])).length > 0) return null;
const [row] = await tx.select({ maxConcurrentTasks: policies.maxConcurrentTasks }).from(policies)
.where(eq(policies.projectId, projectId));
const current = toPolicy(row);
const next = change(current);
if (next === current) return current;
if (next.state === "absent") throw new Error("A stored task execution policy cannot become absent");
await tx.insert(policies).values({ projectId, companyId, maxConcurrentTasks: next.maxConcurrentTasks })
.onConflictDoUpdate({ target: policies.projectId, set: { maxConcurrentTasks: next.maxConcurrentTasks, updatedAt: sql`now()` } });
return next;
}),
};
}
export type RecordAdmissionInput = { companyId: string; runId: string; admissionId: string; record: AdmissionRecord };
export type RecordAdmissionOutcome =
| { kind: "recorded" | "replayed"; attempt: number }
| { kind: "projects_unavailable"; projectIds: string[] }
| { kind: "run_not_found" }
| { kind: "run_already_admitted"; admissionId: string }
| { kind: "admission_settled" }
| { kind: "admission_conflict" };
/**
* Storage transition only: persists one admission attempt and its charges in
* the caller's transaction. It neither checks capacity nor claims work; the
* composed admission command must do both under the same locks. Takes lock
* steps 3 (projects) and 6 (run); composed callers take them earlier.
*/
export async function recordAdmission(tx: Db, input: RecordAdmissionInput): Promise<RecordAdmissionOutcome> {
assertTransaction(tx);
const { companyId, runId, admissionId, record } = input;
const missing = await lockAdmissionProjects(tx, companyId, record.projectIds);
if (missing.length > 0) return { kind: "projects_unavailable", projectIds: missing };
const [run] = await tx.select({ id: heartbeatRuns.id }).from(heartbeatRuns)
.where(and(eq(heartbeatRuns.id, runId), eq(heartbeatRuns.companyId, companyId))).for("no key update");
if (!run) return { kind: "run_not_found" };
const [existing] = await tx.select().from(admissions).where(eq(admissions.id, admissionId));
if (existing) {
if (existing.companyId !== companyId || existing.runId !== runId) return { kind: "admission_conflict" };
if (existing.settledAt) return { kind: "admission_settled" };
const charged = await tx.select({ projectId: charges.projectId }).from(charges)
.where(and(eq(charges.companyId, companyId), eq(charges.admissionId, admissionId))).orderBy(asc(charges.projectId));
const stored = { primaryProjectId: existing.primaryProjectId, contextRevision: existing.contextRevision,
projectIds: charged.map(row => row.projectId) };
return sameAdmissionRecord(stored, record) ? { kind: "replayed", attempt: existing.attempt } : { kind: "admission_conflict" };
}
const [live] = await tx.select({ id: admissions.id }).from(admissions)
.where(and(eq(admissions.runId, runId), isNull(admissions.settledAt)));
if (live) return { kind: "run_already_admitted", admissionId: live.id };
const [{ attempt }] = await tx.select({ attempt: sql<number>`coalesce(max(${admissions.attempt}), 0)::int + 1` })
.from(admissions).where(eq(admissions.runId, runId));
await tx.insert(admissions).values({ id: admissionId, companyId, runId, attempt,
primaryProjectId: record.primaryProjectId, contextRevision: record.contextRevision });
if (record.projectIds.length > 0) {
await tx.insert(charges).values(record.projectIds.map(projectId => ({ admissionId, companyId, projectId })));
}
return { kind: "recorded", attempt };
}
export type SettleAdmissionOutcome =
| { kind: "settled"; releasedProjectIds: string[] }
| { kind: "already_settled" | "settled_by_other" | "not_found" };
/**
* Storage transition only: releases the admission's whole charge set once.
* Callers must first verify authoritative lifecycle facts. Takes no project lock.
*/
export async function settleAdmission(tx: Db, input: { companyId: string; admissionId: string; settlementId: string }):
Promise<SettleAdmissionOutcome> {
assertTransaction(tx);
const { companyId, admissionId, settlementId } = input;
const [row] = await tx.select({ settlementId: admissions.settlementId }).from(admissions)
.where(and(eq(admissions.companyId, companyId), eq(admissions.id, admissionId))).for("no key update");
if (!row) return { kind: "not_found" };
const decision = decideSettlement(row.settlementId, settlementId);
if (decision !== "settle") return { kind: decision };
const released = await tx.delete(charges)
.where(and(eq(charges.companyId, companyId), eq(charges.admissionId, admissionId)))
.returning({ projectId: charges.projectId });
await tx.update(admissions).set({ settlementId, settledAt: sql`now()` }).where(eq(admissions.id, admissionId));
return { kind: "settled", releasedProjectIds: released.map(charge => charge.projectId).sort() };
}
/** Active charge counts per project. Every stored charge is unsettled. */
export async function countActiveProjectCharges(db: Db, companyId: string, projectIds: readonly string[]) {
const counts = new Map(projectIds.map(id => [id, 0]));
if (projectIds.length === 0) return counts;
const rows = await db.select({ projectId: charges.projectId, active: sql<number>`count(*)::int` }).from(charges)
.where(and(eq(charges.companyId, companyId), inArray(charges.projectId, [...projectIds])))
.groupBy(charges.projectId);
for (const row of rows) counts.set(row.projectId, row.active);
return counts;
}
/** Project deletion holds the project row lock, so no charge can be added concurrently. */
export async function hasUnsettledProjectCharges(db: Db, scope: { companyId: string; projectId: string }) {
const [row] = await db.select({ admissionId: charges.admissionId }).from(charges)
.where(and(eq(charges.companyId, scope.companyId), eq(charges.projectId, scope.projectId))).limit(1);
return Boolean(row);
}
/** Removes settled history of an agent's runs before they are deleted; refuses while any admission is unsettled. */
export async function deleteAgentAdmissionHistory(tx: Db, scope: { companyId: string; agentId: string }) {
assertTransaction(tx);
const agentRuns = tx.select({ id: heartbeatRuns.id }).from(heartbeatRuns)
.where(and(eq(heartbeatRuns.companyId, scope.companyId), eq(heartbeatRuns.agentId, scope.agentId)));
const [live] = await tx.select({ id: admissions.id }).from(admissions)
.where(and(eq(admissions.companyId, scope.companyId), inArray(admissions.runId, agentRuns), isNull(admissions.settledAt)))
.limit(1);
if (live) return { kind: "unsettled" as const };
await tx.delete(admissions).where(and(eq(admissions.companyId, scope.companyId), inArray(admissions.runId, agentRuns)));
return { kind: "deleted" as const };
}
@@ -0,0 +1,50 @@
import {
applyTaskExecutionPolicyPatch,
parseTaskExecutionPolicyPatch,
type TaskExecutionPolicy,
} from "../domain/policy.js";
export type ProjectScope = { companyId: string; projectId: string };
export interface TaskExecutionPolicyStore {
/** Null when the project is not in the company. */
read(scope: ProjectScope): Promise<TaskExecutionPolicy | null>;
/**
* Locks the project in admission lock order and stores `change(current)`.
* Returning `current` itself writes nothing. Null when the project is not in the company.
*/
update(scope: ProjectScope, change: (current: TaskExecutionPolicy) => TaskExecutionPolicy): Promise<TaskExecutionPolicy | null>;
}
export type PolicyReadResult = { kind: "found"; policy: TaskExecutionPolicy } | { kind: "project_not_found" };
export type PolicyUpdateResult =
| { kind: "updated" | "unchanged"; policy: TaskExecutionPolicy }
| { kind: "invalid"; field: "policy" | "maxConcurrentTasks"; reason: string }
| { kind: "project_not_found" };
/**
* Internal policy commands. No route, tool or import calls `update` until
* admission enforces the maximum; a cap written earlier would mislead users.
*/
export function createTaskExecutionPolicyCommands(store: TaskExecutionPolicyStore) {
return {
async readTaskExecutionPolicy(scope: ProjectScope): Promise<PolicyReadResult> {
const policy = await store.read(scope);
return policy ? { kind: "found", policy } : { kind: "project_not_found" };
},
async updateTaskExecutionPolicy(scope: ProjectScope, input: unknown): Promise<PolicyUpdateResult> {
const parsed = parseTaskExecutionPolicyPatch(input);
if (parsed.kind === "invalid") return parsed;
let changed = false;
const policy = await store.update(scope, current => {
const next = applyTaskExecutionPolicyPatch(current, parsed.patch);
changed = next !== current;
return next;
});
if (!policy) return { kind: "project_not_found" };
return { kind: changed ? "updated" : "unchanged", policy };
},
};
}
@@ -0,0 +1 @@
export { executionAdmissionCompanyDeletion } from "./adapters/company-deletion.js";
@@ -0,0 +1,53 @@
/** Referenced projects are rejected beyond this bound instead of admitting a subset. */
export const MAX_ADMISSION_PROJECTS = 100;
const MAX_CONTEXT_REVISION_LENGTH = 256;
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
export type AdmissionRecordInput = {
primaryProjectId: string | null;
referencedProjectIds: readonly string[];
contextRevision: string;
};
/**
* `projectIds` is the complete distinct set in canonical lock order. Lowercase
* UUID text order matches PostgreSQL uuid order. An empty set is valid and
* charges no project.
*/
export type AdmissionRecord = {
primaryProjectId: string | null;
projectIds: string[];
contextRevision: string;
};
export type AdmissionRecordResult =
| { kind: "ok"; record: AdmissionRecord }
| { kind: "invalid_project_id" }
| { kind: "too_many_projects"; count: number; limit: number }
| { kind: "invalid_context_revision" };
export function prepareAdmissionRecord(input: AdmissionRecordInput): AdmissionRecordResult {
const contextRevision = input.contextRevision.trim();
if (!contextRevision || contextRevision.length > MAX_CONTEXT_REVISION_LENGTH) return { kind: "invalid_context_revision" };
const primaryProjectId = input.primaryProjectId?.toLowerCase() ?? null;
const candidates = [...input.referencedProjectIds, ...(primaryProjectId === null ? [] : [primaryProjectId])].map(id => id.toLowerCase());
if (candidates.some(id => !UUID.test(id))) return { kind: "invalid_project_id" };
const projectIds = [...new Set(candidates)].sort();
if (projectIds.length > MAX_ADMISSION_PROJECTS) {
return { kind: "too_many_projects", count: projectIds.length, limit: MAX_ADMISSION_PROJECTS };
}
return { kind: "ok", record: { primaryProjectId, projectIds, contextRevision } };
}
export function sameAdmissionRecord(left: AdmissionRecord, right: AdmissionRecord) {
return left.primaryProjectId === right.primaryProjectId && left.contextRevision === right.contextRevision
&& left.projectIds.length === right.projectIds.length && left.projectIds.every((id, index) => id === right.projectIds[index]);
}
export type SettlementDecision = "settle" | "already_settled" | "settled_by_other";
/** A settlement identity can be replayed; a second, different settlement cannot take over. */
export function decideSettlement(existingSettlementId: string | null, settlementId: string): SettlementDecision {
if (existingSettlementId === null) return "settle";
return existingSettlementId === settlementId ? "already_settled" : "settled_by_other";
}
@@ -0,0 +1,88 @@
import { describe, expect, it } from "vitest";
import { decideSettlement, MAX_ADMISSION_PROJECTS, prepareAdmissionRecord } from "./admission.js";
import { ABSENT_TASK_EXECUTION_POLICY, applyTaskExecutionPolicyPatch, parseTaskExecutionPolicyPatch, type TaskExecutionPolicy } from "./policy.js";
const configured = (maxConcurrentTasks: number | null): TaskExecutionPolicy => ({ state: "configured", maxConcurrentTasks });
const apply = (current: TaskExecutionPolicy, input: unknown) => {
const parsed = parseTaskExecutionPolicyPatch(input);
if (parsed.kind !== "ok") throw new Error(parsed.reason);
return applyTaskExecutionPolicyPatch(current, parsed.patch);
};
describe("task execution policy", () => {
it("keeps never-configured distinct from explicit unlimited", () => {
expect(apply(ABSENT_TASK_EXECUTION_POLICY, null)).toEqual(configured(null));
expect(apply(ABSENT_TASK_EXECUTION_POLICY, { maxConcurrentTasks: null })).toEqual(configured(null));
expect(apply(ABSENT_TASK_EXECUTION_POLICY, { maxConcurrentTasks: 3 })).toEqual(configured(3));
});
it("preserves the current value for omitted policies and fields", () => {
for (const current of [ABSENT_TASK_EXECUTION_POLICY, configured(null), configured(2)]) {
expect(apply(current, undefined)).toBe(current);
expect(apply(current, {})).toBe(current);
expect(apply(current, { maxConcurrentTasks: undefined })).toBe(current);
}
const two = configured(2);
expect(apply(two, { maxConcurrentTasks: 2 })).toBe(two);
const unlimited = configured(null);
expect(apply(unlimited, null)).toBe(unlimited);
});
it("clears or replaces a configured maximum", () => {
expect(apply(configured(2), null)).toEqual(configured(null));
expect(apply(configured(2), { maxConcurrentTasks: null })).toEqual(configured(null));
expect(apply(configured(null), { maxConcurrentTasks: 1 })).toEqual(configured(1));
});
it.each([0, -1, 1.5, Number.NaN, Number.POSITIVE_INFINITY, 2_147_483_648, "2", true, {}])(
"rejects maximum %s", value => {
expect(parseTaskExecutionPolicyPatch({ maxConcurrentTasks: value })).toMatchObject({ kind: "invalid", field: "maxConcurrentTasks" });
},
);
it("rejects malformed policies and unknown fields", () => {
for (const input of [1, "unlimited", [], { maxConcurrentTasks: 1, perTaskOverride: 3 }]) {
expect(parseTaskExecutionPolicyPatch(input)).toMatchObject({ kind: "invalid", field: "policy" });
}
expect(parseTaskExecutionPolicyPatch({ maxConcurrentTasks: 2_147_483_647 })).toEqual({ kind: "ok", patch: { maxConcurrentTasks: 2_147_483_647 } });
});
});
describe("admission record", () => {
const a = "0b9d8e52-6f2e-4bb0-9a4a-1f0a7f2e0a01";
const b = "7c1f3d9a-2b4e-4d8f-8a6b-3e5c7d9f1b02";
const c = "f3a2b1c0-9d8e-4f7a-8b6c-5d4e3f2a1b03";
it("unions the primary with referenced projects in canonical order", () => {
expect(prepareAdmissionRecord({ primaryProjectId: c, referencedProjectIds: [b, a, b.toUpperCase(), c], contextRevision: " r1 " }))
.toEqual({ kind: "ok", record: { primaryProjectId: c, projectIds: [a, b, c], contextRevision: "r1" } });
});
it("keeps a primaryless set and an empty set without a project bucket", () => {
expect(prepareAdmissionRecord({ primaryProjectId: null, referencedProjectIds: [b], contextRevision: "r" }))
.toEqual({ kind: "ok", record: { primaryProjectId: null, projectIds: [b], contextRevision: "r" } });
expect(prepareAdmissionRecord({ primaryProjectId: null, referencedProjectIds: [], contextRevision: "r" }))
.toEqual({ kind: "ok", record: { primaryProjectId: null, projectIds: [], contextRevision: "r" } });
});
it("rejects invalid references, oversized sets and missing context identity instead of narrowing", () => {
expect(prepareAdmissionRecord({ primaryProjectId: null, referencedProjectIds: [a, "Design"], contextRevision: "r" }))
.toEqual({ kind: "invalid_project_id" });
expect(prepareAdmissionRecord({ primaryProjectId: "", referencedProjectIds: [a], contextRevision: "r" }))
.toEqual({ kind: "invalid_project_id" });
const many = Array.from({ length: MAX_ADMISSION_PROJECTS + 1 }, (_, index) =>
`00000000-0000-4000-8000-${index.toString(16).padStart(12, "0")}`);
expect(prepareAdmissionRecord({ primaryProjectId: null, referencedProjectIds: many, contextRevision: "r" }))
.toEqual({ kind: "too_many_projects", count: MAX_ADMISSION_PROJECTS + 1, limit: MAX_ADMISSION_PROJECTS });
expect(prepareAdmissionRecord({ primaryProjectId: a, referencedProjectIds: [], contextRevision: " " }))
.toEqual({ kind: "invalid_context_revision" });
expect(prepareAdmissionRecord({ primaryProjectId: a, referencedProjectIds: [], contextRevision: "x".repeat(257) }))
.toEqual({ kind: "invalid_context_revision" });
});
it("settles once and replays only the same settlement", () => {
expect(decideSettlement(null, a)).toBe("settle");
expect(decideSettlement(a, a)).toBe("already_settled");
expect(decideSettlement(a, b)).toBe("settled_by_other");
});
});
@@ -0,0 +1,47 @@
/**
* A project's task-execution policy. `absent` was never configured, so a later
* legacy migration may still choose a value. `configured` with a null maximum
* is an explicit "no limit" that such a migration must preserve.
*/
export type TaskExecutionPolicy =
| { state: "absent"; maxConcurrentTasks: null }
| { state: "configured"; maxConcurrentTasks: number | null };
/** Null clears the cap explicitly. An omitted field preserves its value. */
export type TaskExecutionPolicyPatch = { maxConcurrentTasks?: number | null } | null;
export type PolicyPatchResult =
| { kind: "ok"; patch: TaskExecutionPolicyPatch }
| { kind: "invalid"; field: "policy" | "maxConcurrentTasks"; reason: string };
export const ABSENT_TASK_EXECUTION_POLICY: TaskExecutionPolicy = { state: "absent", maxConcurrentTasks: null };
// The column is a PostgreSQL integer.
const MAX_STORED_MAXIMUM = 2_147_483_647;
/** Undefined (an omitted policy) preserves everything; null clears the cap. */
export function parseTaskExecutionPolicyPatch(input: unknown): PolicyPatchResult {
if (input === undefined) return { kind: "ok", patch: {} };
if (input === null) return { kind: "ok", patch: null };
if (typeof input !== "object" || Array.isArray(input)) {
return { kind: "invalid", field: "policy", reason: "Policy must be an object or null" };
}
const unknownKey = Object.keys(input).find(key => key !== "maxConcurrentTasks");
if (unknownKey) return { kind: "invalid", field: "policy", reason: `Unknown policy field ${unknownKey}` };
if (!("maxConcurrentTasks" in input)) return { kind: "ok", patch: {} };
const value = (input as { maxConcurrentTasks: unknown }).maxConcurrentTasks;
if (value === undefined) return { kind: "ok", patch: {} };
if (value === null) return { kind: "ok", patch: { maxConcurrentTasks: null } };
if (typeof value !== "number" || !Number.isInteger(value) || value < 1 || value > MAX_STORED_MAXIMUM) {
return { kind: "invalid", field: "maxConcurrentTasks", reason: "Maximum concurrent tasks must be a positive whole number" };
}
return { kind: "ok", patch: { maxConcurrentTasks: value } };
}
/** Applies a validated patch. Returns the current policy when nothing changes. */
export function applyTaskExecutionPolicyPatch(current: TaskExecutionPolicy, patch: TaskExecutionPolicyPatch): TaskExecutionPolicy {
if (patch !== null && patch.maxConcurrentTasks === undefined) return current;
const maxConcurrentTasks = patch === null ? null : patch.maxConcurrentTasks ?? null;
if (current.state === "configured" && current.maxConcurrentTasks === maxConcurrentTasks) return current;
return { state: "configured", maxConcurrentTasks };
}
@@ -0,0 +1,17 @@
import type { Db } from "@paperclipai/db";
import { createPostgresTaskExecutionPolicyStore } from "./adapters/postgres.js";
import { createTaskExecutionPolicyCommands } from "./application/policy.js";
export type { TaskExecutionPolicy, TaskExecutionPolicyPatch } from "./domain/policy.js";
export type { PolicyReadResult, PolicyUpdateResult, ProjectScope } from "./application/policy.js";
// Lifecycle guards run inside the caller's deletion transaction.
export { deleteAgentAdmissionHistory, hasUnsettledProjectCharges } from "./adapters/postgres.js";
/**
* Execution admission owns project task-execution policy and the durable
* admission epochs that charge project capacity. Admission and settlement
* commands are not composed yet; nothing enforces the maximum.
*/
export function createExecutionAdmission(db: Db) {
return createTaskExecutionPolicyCommands(createPostgresTaskExecutionPolicyStore(db));
}
+22 -3
View File
@@ -1,5 +1,6 @@
import { and, eq } from "drizzle-orm";
import { agents, authUsers, companyMemberships, type Db } from "@paperclipai/db";
import { agents, authUsers, companyMemberships, issues, type Db } from "@paperclipai/db";
import { logger } from "../middleware/logger.js";
const TOAST_ACTIONS = new Set(["issue.created", "issue.updated", "issue.comment_added"]);
@@ -11,8 +12,26 @@ export async function activityToastIdentity(
) {
const actorType = typeof payload.actorType === "string" ? payload.actorType : null;
const actorId = typeof payload.actorId === "string" ? payload.actorId : null;
const identity = { actorType, actorId };
if (!actorId || !TOAST_ACTIONS.has(String(payload.action))) return identity;
const actorIdentity = { actorType, actorId };
if (!TOAST_ACTIONS.has(String(payload.action))) return actorIdentity;
const issueId = payload.entityType === "issue" && typeof payload.entityId === "string"
? payload.entityId : null;
let issueIdentifier: string | null = null;
if (issueId) {
try {
const [issue] = await db
.select({ identifier: issues.identifier })
.from(issues)
.where(and(eq(issues.companyId, companyId), eq(issues.id, issueId)))
.limit(1);
issueIdentifier = issue?.identifier ?? null;
} catch (err) {
logger.warn({ err, companyId, issueId }, "task toast identifier lookup failed");
}
}
const identity = { ...actorIdentity, ...(issueId ? { issueIdentifier } : {}) };
if (!actorId) return identity;
if (actorType === "agent") {
const [agent] = await db
+6
View File
@@ -14,6 +14,7 @@ import { agents, agentApiKeys, agentRuntimeState, agentTaskSessions, agentWakeup
caseHandoffs, principalPermissionGrants, type Db } from "@paperclipai/db";
import { conflict } from "../errors.js";
import { issueThreadInteractionService } from "./issue-thread-interactions.js";
import { deleteAgentAdmissionHistory } from "../modules/execution-admission/index.js";
export async function deleteAgentDependencies(tx: Db, companyId: string, id: string) {
const [retainedHandoff] = await tx.select({ id: caseHandoffs.id }).from(caseHandoffs).where(and(
@@ -63,6 +64,11 @@ export async function deleteAgentDependencies(tx: Db, companyId: string, id: str
await notifyInstructionCollection(tx, { companyId: companyId });
await notifyCancelledWorkspacePreservation(tx, { companyId: companyId });
await notifyUnsafeWorkspaceExportRecovery(tx, { companyId });
if ((await deleteAgentAdmissionHistory(tx, { companyId, agentId: id })).kind === "unsettled") {
throw conflict("Wait for this agent's admitted task executions to settle before deleting it", {
code: "agent_execution_admission_pending",
});
}
await tx.delete(heartbeatRuns).where(eq(heartbeatRuns.agentId, id));
await tx.delete(agentWakeupRequests).where(eq(agentWakeupRequests.agentId, id));
await tx.delete(agentApiKeys).where(eq(agentApiKeys.agentId, id));
+2
View File
@@ -20,6 +20,7 @@ import { eq, inArray } from "drizzle-orm";
import { createAgentLifecycle, processAgentLifecycle } from "./agent-lifecycle.js";
import { requestRestorePointDeletion } from "./git-restore-point-ledger.js";
import { agentLifecycleCompanyDeletion } from "../modules/agent-lifecycle/company-deletion.js";
import { executionAdmissionCompanyDeletion } from "../modules/execution-admission/company-deletion.js";
import {
companies,
agents,
@@ -117,6 +118,7 @@ export async function deleteCompany(db: Db, id: string) {
await notifyInstructionCollection(tx, { companyId: id });
await notifyCancelledWorkspacePreservation(tx, { companyId: id });
await notifyUnsafeWorkspaceExportRecovery(tx, { companyId: id });
await executionAdmissionCompanyDeletion.deleteCompanyData(tx, id);
await tx.delete(heartbeatRuns).where(eq(heartbeatRuns.companyId, id));
await tx.delete(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, id));
await tx.delete(agentApiKeys).where(eq(agentApiKeys.companyId, id));
@@ -124,7 +124,7 @@ function restorePointObjectKey(companyId: string, workspaceId: string, generatio
return `${companyId}/${NAMESPACE}/${workspaceId}/${generation}/task-directory/${id}.pcrp`;
}
function validateRestorePointRef(ref: TaskDirectoryRestorePointRef | null | undefined, workspace: { companyId: string; id: string }) {
export function validateTaskDirectoryRestorePointRef(ref: TaskDirectoryRestorePointRef | null | undefined, workspace: { companyId: string; id: string }) {
if (!ref || ref.schema !== TASK_DIRECTORY_RESTORE_POINT_SCHEMA || typeof ref.id !== "string" || !UUID.test(ref.id)
|| !Number.isSafeInteger(ref.generation) || ref.generation < 1
|| ref.objectKey !== restorePointObjectKey(workspace.companyId, workspace.id, ref.generation, ref.id)
@@ -136,6 +136,25 @@ function validateRestorePointRef(ref: TaskDirectoryRestorePointRef | null | unde
return ref;
}
/** Read a retained directory without reopening its workspace or replacing its
* original path. Administrative migration uses the same identity and byte checks
* as ordinary restore, and never treats an expired object as an empty folder. */
export async function loadTaskDirectoryRestorePoint(input: {
storage: StorageService;
workspace: { companyId: string; id: string };
ref: TaskDirectoryRestorePointRef;
signal?: AbortSignal;
}) {
const ref = validateTaskDirectoryRestorePointRef(input.ref, input.workspace);
if (ref.expiredAt) throw new TaskDirectoryRestoreFailure("restore_point_expired");
const ciphertext = await readRestorePointObject(input.storage, input.workspace.companyId, ref.objectKey, input.signal).catch(() => {
throw new TaskDirectoryRestoreFailure("restore_point_unavailable");
});
if (ciphertext.length !== ref.byteSize) throw new TaskDirectoryRestoreFailure("ciphertext_size_mismatch");
return openRestorePoint({ ciphertext, sha256: ref.sha256, key: ref.key,
identity: { companyId: input.workspace.companyId, workspaceId: input.workspace.id, restorePointId: ref.id, generation: ref.generation } });
}
function skipReason(error: unknown) {
if (error instanceof TaskDirectorySnapshotSkip) return error.reason;
if (error instanceof IsolatedRepositorySourceBusy) return "source_busy";
@@ -341,7 +360,7 @@ export async function restoreIsolatedTaskDirectory(input: {
const parent = path.dirname(cwd);
await fs.mkdir(parent, { recursive: true, mode: 0o700 });
if (await fs.realpath(parent) !== parent) throw new TaskDirectoryRestoreFailure("task_directory_identity_changed");
const ref = validateRestorePointRef(state.restorePoint, input.workspace);
const ref = validateTaskDirectoryRestorePointRef(state.restorePoint, input.workspace);
if (await directoryState(cwd) === "present") {
if (!state.removed) return { restored: false, expired: false };
if ((await fs.readdir(cwd)).length === 0) await fs.rmdir(cwd);
@@ -355,12 +374,7 @@ export async function restoreIsolatedTaskDirectory(input: {
await fs.mkdir(cwd, { mode: 0o700 });
return { restored: false, expired: true };
}
const ciphertext = await readRestorePointObject(input.storage, input.workspace.companyId, ref.objectKey, input.signal).catch(() => {
throw new TaskDirectoryRestoreFailure("restore_point_unavailable");
});
if (ciphertext.length !== ref.byteSize) throw new TaskDirectoryRestoreFailure("ciphertext_size_mismatch");
const payload = await openRestorePoint({ ciphertext, sha256: ref.sha256, key: ref.key,
identity: { companyId: input.workspace.companyId, workspaceId: input.workspace.id, restorePointId: ref.id, generation: ref.generation } });
const payload = await loadTaskDirectoryRestorePoint({ ...input, ref });
const staging = await materializeTaskDirectorySnapshot({ payload, target: cwd, expectedDigest: ref.digest, signal: input.signal });
try {
await fs.rename(staging, cwd);
+6
View File
@@ -43,6 +43,7 @@ import { parseProjectExecutionWorkspacePolicy } from "./execution-workspace-poli
import { mergeProjectWorkspaceRuntimeConfig, readProjectWorkspaceRuntimeConfig } from "./project-workspace-runtime-config.js";
import { resolveManagedProjectWorkspaceDir } from "../home-paths.js";
import { recordProjectLifecycleEvent, recordResourceCreationEvent } from "./resource-lifecycle-events.js";
import { hasUnsettledProjectCharges } from "../modules/execution-admission/index.js";
type ProjectTransaction = Parameters<Parameters<Db["transaction"]>[0]>[0];
@@ -1085,6 +1086,11 @@ export function projectService(db: Db, options: { captureWorkspaceUpdates?: bool
const [retained] = await tx.select({ id: executionWorkspaces.id }).from(executionWorkspaces)
.where(eq(executionWorkspaces.projectId, id)).limit(1);
if (retained) throw conflict("This project still supplies access policy for retained task workspaces. Archive the project or explicitly clean up its workspaces before deleting it.");
if (await hasUnsettledProjectCharges(tx as unknown as Db, { companyId: project.companyId, projectId: id })) {
throw conflict("Task executions admitted to this project have not finished saving. Wait for them to settle before deleting the project.", {
code: "project_execution_admission_pending",
});
}
await tx.select({ id: projectWorkspaces.id }).from(projectWorkspaces).where(eq(projectWorkspaces.projectId, id)).for("update");
await recordProjectLifecycleEvent(tx as unknown as Db, project.companyId, id, "delete");
await notifyDeliveryWork(tx, DELIVERY_QUEUES.skillSourceSync);
@@ -0,0 +1,142 @@
import { execFile } from "node:child_process";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { promisify } from "node:util";
import { and, desc, eq, gte, or, sql } from "drizzle-orm";
import { executionWorkspaces, projectWorkspaces, workspaceOperations, type Db } from "@paperclipai/db";
import { PROJECT_REPOSITORIES_DIR } from "@paperclipai/adapter-utils/git-workspace-sync";
import type { StorageService } from "../storage/types.js";
import { createIssueGitRemoteAuthProvider } from "./git-credentials.js";
import { applyGitRestorePointToWorktree, ensureGitRestorePointCommit, loadGitRestorePoint } from "./git-restore-point-storage.js";
import { restoreGitWorkspaceContents } from "./git-workspace-contents.js";
import { ensureSavedGitCommit, gitRestorePointExpired, managedSourcePaths, readGitCacheRestoration,
verifiedGitCacheSource, type GitCacheRestoration } from "./git-workspace-restoration.js";
import { localRepositoryPath } from "./local-repository-path.js";
import { materializeManagedProjectWorkspace } from "./managed-repository-checkout.js";
type Workspace = typeof executionWorkspaces.$inferSelect;
export type ArchivedGitMigrationSource = {
commit: string;
sourcePath: string;
cleanupOperationId: string | null;
recipe: GitCacheRestoration | null;
};
const execute = promisify(execFile);
const unavailable = () => new Error("remote_workspace_migration_archived_git_source_unavailable");
async function git(cwd: string, args: string[]) {
return (await execute("git", ["-c", "core.fsmonitor=false", "-c", `core.hooksPath=${os.devNull}`, "-C", cwd, ...args], {
timeout: 120_000, maxBuffer: 1024 * 1024,
env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: os.devNull,
GIT_TERMINAL_PROMPT: "0", GIT_OPTIONAL_LOCKS: "0" },
})).stdout.trim();
}
/** A removed branch must be reconstructed from its saved commit or its exact
* successful deletion receipt. Today's default branch is never a substitute. */
export async function inspectArchivedGitMigrationSource(db: Pick<Db, "select">, workspace: Workspace): Promise<ArchivedGitMigrationSource | null> {
if (workspace.status !== "archived" || !workspace.repoUrl || !workspace.branchName || !workspace.sourceIssueId) return null;
const recipe = readGitCacheRestoration(workspace);
if (recipe) {
if (gitRestorePointExpired(recipe)) throw new Error("remote_workspace_migration_restore_point_expired");
return { commit: recipe.commit, sourcePath: recipe.sourcePath, recipe, cleanupOperationId: null };
}
if (workspace.providerType !== "git_worktree" || !workspace.closedAt || workspace.metadata?.gitBranchOwnershipVersion !== 1) return null;
const [operation] = await db.select().from(workspaceOperations).where(and(
eq(workspaceOperations.companyId, workspace.companyId), eq(workspaceOperations.executionWorkspaceId, workspace.id),
eq(workspaceOperations.phase, "worktree_cleanup"), eq(workspaceOperations.status, "succeeded"),
or(eq(workspaceOperations.command, `git branch -d ${workspace.branchName}`), and(
sql`${workspaceOperations.metadata}->>'cleanupAction' = 'branch_delete'`,
sql`${workspaceOperations.metadata}->>'workspaceId' = ${workspace.id}`,
sql`${workspaceOperations.metadata}->>'branchName' = ${workspace.branchName}`,
sql`${workspaceOperations.metadata}->>'workspacePath' = ${workspace.providerRef ?? workspace.cwd}`,
)), gte(workspaceOperations.startedAt, workspace.closedAt),
)).orderBy(desc(workspaceOperations.startedAt), desc(workspaceOperations.id)).limit(1);
const receipt = operation?.stdoutExcerpt?.trim(), prefix = `Deleted branch ${workspace.branchName} (was `;
if (!operation || !receipt?.startsWith(prefix) || !receipt.endsWith(").")) return null;
const abbreviation = receipt.slice(prefix.length, -2);
if (!/^[a-f0-9]{7,40}$/.test(abbreviation)) throw unavailable();
const [projectWorkspace] = workspace.projectWorkspaceId ? await db.select({ cwd: projectWorkspaces.cwd, repoUrl: projectWorkspaces.repoUrl })
.from(projectWorkspaces).where(and(eq(projectWorkspaces.id, workspace.projectWorkspaceId),
eq(projectWorkspaces.companyId, workspace.companyId), workspace.projectId ? eq(projectWorkspaces.projectId, workspace.projectId) : undefined)) : [];
const sourcePath = projectWorkspace?.cwd ?? managedSourcePaths(workspace)[0];
if (!sourcePath || projectWorkspace?.repoUrl && projectWorkspace.repoUrl !== workspace.repoUrl) throw unavailable();
await verifiedGitCacheSource(workspace, sourcePath);
const commit = await git(sourcePath, ["rev-parse", "--verify", `${abbreviation}^{commit}`]);
if (!/^[a-f0-9]{40}$/.test(commit)) throw unavailable();
return { commit, sourcePath, cleanupOperationId: operation.id, recipe: null };
}
/** Restore only into private migration scratch. Original repositories, task
* folders, lifecycle state and restore-point metadata remain unchanged. */
export async function materializeArchivedGitMigrationSource(input: {
db: Db; storage: StorageService; workspace: Workspace; source: ArchivedGitMigrationSource; scratch: string;
}) {
const { workspace, source } = input, cwd = path.join(input.scratch, "retained-git");
if (!workspace.sourceIssueId || !workspace.repoUrl || !workspace.branchName) throw unavailable();
const resolveGitAuth = createIssueGitRemoteAuthProvider(input.db, workspace.companyId, workspace.sourceIssueId);
const present = await fs.lstat(source.sourcePath).catch((error: NodeJS.ErrnoException) => {
if (error.code === "ENOENT") return null;
throw error;
});
if (present) {
const verified = await verifiedGitCacheSource(workspace, source.sourcePath);
await fs.mkdir(cwd, { mode: 0o700 }); await git(cwd, ["init", "--quiet"]);
// Fetch objects, never the current working tree of a shared project root.
const presentCommit = await git(verified, ["cat-file", "-e", `${source.commit}^{commit}`]).then(() => true, () => false);
if (presentCommit) {
await git(cwd, ["fetch", "--no-tags", "--no-recurse-submodules", "--", verified, source.commit]);
} else if (source.recipe?.restorePoint && !source.recipe.restorePoint.headOnRemote) {
// The archived branch can have been pruned from the base while its
// encrypted bundle still owns the unpushed commits. Import only that
// bundle's explicit prerequisites, then let the restore-point owner
// verify and import the saved history below.
const { manifest } = await loadGitRestorePoint({ storage: input.storage, workspace, ref: source.recipe.restorePoint });
for (const commit of manifest.prerequisites) {
const local = await git(verified, ["cat-file", "-e", `${commit}^{commit}`]).then(() => true, () => false);
if (local) await git(cwd, ["fetch", "--no-tags", "--no-recurse-submodules", "--", verified, commit]);
else await ensureSavedGitCommit(cwd, workspace.repoUrl, commit, resolveGitAuth);
}
}
} else {
if (!source.recipe) throw unavailable();
await materializeManagedProjectWorkspace(cwd, { repoUrl: workspace.repoUrl, resolveGitAuth });
}
if (source.recipe?.restorePoint) await ensureGitRestorePointCommit({ storage: input.storage, workspace,
ref: source.recipe.restorePoint, repositoryCwd: cwd });
await ensureSavedGitCommit(cwd, workspace.repoUrl, source.commit, resolveGitAuth);
await git(cwd, ["check-ref-format", `refs/heads/${workspace.branchName}`]);
await git(cwd, ["checkout", "--detach", source.commit]);
await git(cwd, ["update-ref", `refs/heads/${workspace.branchName}`, source.commit]);
await git(cwd, ["symbolic-ref", "HEAD", `refs/heads/${workspace.branchName}`]);
await git(cwd, ["config", "remote.origin.url", workspace.repoUrl]);
if (source.recipe?.restorePoint) await applyGitRestorePointToWorktree({ storage: input.storage, workspace,
ref: source.recipe.restorePoint, repositoryCwd: cwd, worktreePath: cwd });
const approvedLocalSources = new Set<string>();
await restoreGitWorkspaceContents({ cwd, companyId: workspace.companyId, workspaceId: workspace.id,
projectId: workspace.projectId, contents: source.recipe?.contents, storage: input.storage, resolveGitAuth,
authorizeSource: async saved => {
if (!workspace.projectId) throw unavailable();
const [row] = await input.db.select().from(projectWorkspaces).where(and(eq(projectWorkspaces.id, saved.id),
eq(projectWorkspaces.companyId, workspace.companyId), eq(projectWorkspaces.projectId, workspace.projectId)));
if (!row || row.repoUrl !== saved.repoUrl) throw unavailable();
const local = localRepositoryPath(saved.repoUrl); if (local) approvedLocalSources.add(local);
},
prepareLocalSource: async local => {
if (!approvedLocalSources.has(local)) throw unavailable();
const identity = await fs.realpath(local);
return async () => { if (await fs.realpath(local) !== identity) throw unavailable(); };
},
});
// A checkout records scratch-file inode and time fields in its index. Those
// fields must not change the saved archive identity after an upload reply is
// lost. Rebuild only these reconstructed indexes from their staged trees;
// the original repository and unstaged files remain untouched.
for (const repository of [cwd, ...(source.recipe?.contents?.checkouts ?? [])
.map(checkout => path.join(cwd, PROJECT_REPOSITORIES_DIR, checkout.name))]) {
const tree = await git(repository, ["write-tree"]);
await git(repository, ["read-tree", "--empty"]);
await git(repository, ["read-tree", tree]);
}
return cwd;
}
@@ -4,6 +4,8 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { promisify } from "node:util";
import { createServer, type Server } from "node:http";
import { pathToFileURL } from "node:url";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { eq } from "drizzle-orm";
import { activityLog, applyPendingMigrations, closeRegisteredClients, companies, createDb, executionWorkspaceSnapshots,
@@ -16,7 +18,14 @@ import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } fro
import { inventoryRemoteWorkspaceMigration, migrateRemoteWorkspace } from "./remote-workspace-migration.js";
import { getWorkspaceSnapshot, loadWorkspaceSnapshotArchive } from "./remote-workspace-snapshot-storage.js";
import { restoreRemoteWorkspaceArchive } from "./remote-workspace-snapshot-archive.js";
import { removeTaskDirectoryTree } from "./isolated-task-directory-snapshot.js";
import { captureTaskDirectorySnapshot, removeTaskDirectoryTree } from "./isolated-task-directory-snapshot.js";
import { captureRestorePoint, sealRestorePoint } from "./git-restore-point.js";
import type { TaskDirectoryRestorePointRef } from "./isolated-task-directory-retention.js";
import { resolveManagedProjectWorkspaceDir } from "../home-paths.js";
import { cleanupExecutionWorkspaceArtifacts } from "./workspace-runtime.js";
import { workspaceOperationService } from "./workspace-operations.js";
import { managedProjectCheckoutName } from "./managed-project-checkout-name.js";
import { captureGitWorkspaceContents, readManagedCheckoutContents } from "./git-workspace-contents.js";
const execute = promisify(execFile);
const git = async (cwd: string, ...args: string[]) => (await execute("git", ["-C", cwd, ...args], { env: { ...process.env,
@@ -28,6 +37,7 @@ if (url && !/^remote_workspace_[a-z_]*test$/.test(new URL(url).pathname.slice(1)
const support = url ? { supported: true } : await getEmbeddedPostgresTestSupport();
describe.skipIf(!support.supported)("remote workspace migration", () => {
let db: ReturnType<typeof createDb>, root: string, storage: StorageService;
const servers: Server[] = [];
let temporary: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | undefined;
beforeAll(async () => {
if (url) { await applyPendingMigrations(url); db = createDb(url); }
@@ -37,6 +47,7 @@ describe.skipIf(!support.supported)("remote workspace migration", () => {
beforeEach(async () => { vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY", "61".repeat(32));
root = await fs.mkdtemp(path.join(os.tmpdir(), "pc-migration-test-")); storage = createStorageService(createLocalDiskStorageProvider(path.join(root, "objects"))); });
afterEach(async () => {
await Promise.all(servers.splice(0).map(server => new Promise<void>((resolve, reject) => server.close(error => error ? reject(error) : resolve()))));
vi.unstubAllEnvs(); await db.delete(activityLog); await db.delete(workspaceOperations); await db.delete(executionWorkspaceSnapshots);
await db.delete(workspaceRuntimeServices); await db.delete(executionWorkspaceRuntimeLeases);
await db.delete(issues); await db.delete(executionWorkspaces); await db.delete(projectWorkspaces); await db.delete(projects);
@@ -53,6 +64,256 @@ describe.skipIf(!support.supported)("remote workspace migration", () => {
return { companyId, workspaceId, issueId: issue!.id, cwd };
}
async function workspace(id: string) { return (await db.select().from(executionWorkspaces).where(eq(executionWorkspaces.id, id)))[0]!; }
async function gitArchiveFixture(repoUrl = "https://example.invalid/repository.git") {
const f = await seed(), projectId = randomUUID(), projectWorkspaceId = randomUUID(), branchName = "saved-task";
vi.stubEnv("PAPERCLIP_HOME", root); vi.stubEnv("PAPERCLIP_INSTANCE_ID", "default");
const base = resolveManagedProjectWorkspaceDir({ companyId: f.companyId, projectId, repoName: "repository" });
await fs.mkdir(base, { recursive: true }); await git(base, "init", "-q", "-b", "main");
await fs.writeFile(path.join(base, "base.txt"), "base\n"); await git(base, "add", "."); await git(base, "commit", "-qm", "base");
await git(base, "remote", "add", "origin", repoUrl); const upstream = await git(base, "rev-parse", "HEAD");
const cwd = path.join(base, ".paperclip", "worktrees", branchName);
await git(base, "worktree", "add", "-b", branchName, cwd);
await fs.copyFile(path.join(f.cwd, "saved.txt"), path.join(cwd, "saved.txt")); await fs.rm(f.cwd, { recursive: true });
await git(cwd, "add", "."); await git(cwd, "commit", "-qm", "saved task commit"); const commit = await git(cwd, "rev-parse", "HEAD");
await db.insert(projects).values({ id: projectId, companyId: f.companyId, name: "Archived project" });
await db.insert(projectWorkspaces).values({ id: projectWorkspaceId, companyId: f.companyId, projectId, name: "Source", cwd: base, repoUrl });
await db.update(executionWorkspaces).set({ cwd, providerRef: cwd, projectId, projectWorkspaceId, repoUrl, baseRef: "main",
branchName, strategyType: "git_worktree", providerType: "git_worktree", metadata: { createdByRuntime: true, gitBranchOwnershipVersion: 1 } }).where(eq(executionWorkspaces.id, f.workspaceId));
return { ...f, cwd, base, upstream, commit, projectId, projectWorkspaceId, repoUrl, branchName };
}
it.each(["legacy", "verified", "retry"])("recovers an archived Git workspace from its exact branch-deletion receipt (%s)", async receiptKind => {
const f = await gitArchiveFixture(), closedAt = new Date();
await db.update(executionWorkspaces).set({ status: "archived", closedAt }).where(eq(executionWorkspaces.id, f.workspaceId));
if (receiptKind !== "verified") {
await git(f.base, "merge", "--ff-only", f.branchName);
await git(f.base, "worktree", "remove", f.cwd);
const receipt = await git(f.base, "branch", "-d", f.branchName);
await db.insert(workspaceOperations).values({ companyId: f.companyId, executionWorkspaceId: f.workspaceId,
phase: "worktree_cleanup", status: "succeeded", command: `git branch -d ${f.branchName}`,
stdoutExcerpt: receipt, startedAt: new Date(), finishedAt: new Date() });
} else {
const cleaned = await cleanupExecutionWorkspaceArtifacts({ workspace: await workspace(f.workspaceId),
projectWorkspace: { cwd: f.base, cleanupCommand: null }, expectedBranchHeadSha: f.commit, runCleanupCommands: false,
recorder: workspaceOperationService(db).createRecorder({ companyId: f.companyId, executionWorkspaceId: f.workspaceId, issueId: f.issueId }) });
expect(cleaned.warnings).toEqual([]);
}
await fs.writeFile(path.join(f.base, "base.txt"), "newer default branch\n");
await git(f.base, "add", "base.txt"); await git(f.base, "commit", "-qm", "new default branch work");
if (receiptKind === "retry") {
const put = storage.putFile.bind(storage); let fail = true;
storage.putFile = async input => { const result = await put(input); if (fail) { fail = false; throw new Error("upload reply lost"); } return result; };
await expect(migrateRemoteWorkspace({ db, storage, ...f, apply: true })).rejects.toThrow("upload reply lost");
}
await migrateRemoteWorkspace({ db, storage, ...f, apply: true });
const row = await workspace(f.workspaceId), authority = readRemoteWorkspaceAuthority(row.metadata)!;
expect(row.status).toBe("archived"); expect(authority.source).toMatchObject({ kind: "git", ref: f.commit });
await fs.rm(f.base, { recursive: true });
const ref = await getWorkspaceSnapshot(db, { ...f, snapshotId: authority.snapshotId! });
const archivePath = path.join(root, "legacy-git.pcws"), targetDir = path.join(root, "restored-legacy-git");
await loadWorkspaceSnapshotArchive({ storage, reference: ref!, targetPath: archivePath });
await restoreRemoteWorkspaceArchive({ archivePath, targetDir, expectedManifestDigest: ref!.manifestDigest });
expect(await git(targetDir, "rev-parse", "HEAD")).toBe(f.commit);
expect(await fs.readFile(path.join(targetDir, "base.txt"), "utf8")).toBe("base\n");
expect(await fs.readFile(path.join(targetDir, "saved.txt"), "utf8")).toBe("original saved work\n");
});
async function serveRepository() {
const directory = path.join(root, "http"), bare = path.join(directory, "repository.git");
await fs.mkdir(bare, { recursive: true }); await git(bare, "init", "--bare", "--initial-branch=main");
const server = createServer(async (request, response) => {
const relative = new URL(request.url!, "http://localhost").pathname.slice(1);
const target = path.resolve(directory, relative);
if (!target.startsWith(`${directory}/`)) { response.writeHead(403).end(); return; }
try { response.end(await fs.readFile(target)); } catch { response.writeHead(404).end(); }
});
await new Promise<void>(resolve => server.listen(0, "127.0.0.1", resolve)); servers.push(server);
const address = server.address(); if (!address || typeof address === "string") throw new Error("test server not bound");
return { bare, repoUrl: `http://127.0.0.1:${address.port}/repository.git` };
}
it.each(["present", "pruned", "retry", "missing-base", "nested", "unapproved-nested", "missing", "expired", "corrupt"])("preserves archived Git edits or refuses an unavailable restore point (%s)", async state => {
const remote = state === "missing-base" ? await serveRepository() : null;
const f = await gitArchiveFixture(remote?.repoUrl);
if (remote) { await git(remote.bare, "fetch", f.base, "main:refs/heads/main"); await git(remote.bare, "update-server-info"); }
await fs.writeFile(path.join(f.cwd, "saved.txt"), "staged edit\n"); await git(f.cwd, "add", "saved.txt");
await fs.writeFile(path.join(f.cwd, "saved.txt"), "unstaged edit\n");
const captured = (await captureRestorePoint({ cwd: f.cwd, upstreamCommits: [f.upstream] }))!;
const id = randomUUID(), generation = 1, sealed = await sealRestorePoint(captured.payload,
{ companyId: f.companyId, workspaceId: f.workspaceId, restorePointId: id, generation });
const objectKey = `${f.companyId}/git-restore-points/${f.workspaceId}/${generation}/${id}.pcrp`;
await storage.putFile({ companyId: f.companyId, namespace: "git-restore-points", objectKey,
originalFilename: "git.pcrp", contentType: "application/octet-stream", body: sealed.ciphertext });
const restorePoint = { schema: "paperclip.git-restore-point.v1", id, generation, objectKey, byteSize: sealed.ciphertext.length,
sha256: sealed.sha256, key: sealed.key, head: captured.manifest.head, index: captured.manifest.index,
worktree: captured.manifest.worktree, ignored: captured.manifest.ignored, modes: captured.manifest.modes, headOnRemote: false };
let contents;
if (state === "nested" || state === "unapproved-nested") {
const sourceCwd = path.join(root, "nested-source");
await fs.mkdir(sourceCwd); await git(sourceCwd, "init", "-q", "-b", "main");
await fs.writeFile(path.join(sourceCwd, "nested.txt"), "nested source work\n");
await git(sourceCwd, "add", "."); await git(sourceCwd, "commit", "-qm", "nested source");
const source = { id: randomUUID(), repoUrl: pathToFileURL(sourceCwd).href, repoRef: "main", cwd: sourceCwd };
const name = managedProjectCheckoutName(source), nestedCwd = path.join(f.cwd, ".paperclip-repositories", name);
await git(f.cwd, "clone", source.repoUrl, nestedCwd); await git(nestedCwd, "checkout", "--detach");
await fs.mkdir(path.join(nestedCwd, ".claude"));
await fs.writeFile(path.join(nestedCwd, ".claude", "settings.local.json"), '{"permissions":{"allow":["Read"]}}');
contents = await captureGitWorkspaceContents(f.cwd, { companyId: f.companyId, workspaceId: f.workspaceId },
[await readManagedCheckoutContents(nestedCwd, name, source)]);
if (state === "nested") await db.insert(projectWorkspaces).values({ ...source, companyId: f.companyId, projectId: f.projectId, name: "Nested source" });
}
await db.update(executionWorkspaces).set({ status: "archived", closedAt: new Date(), metadata: {
createdByRuntime: true, gitBranchOwnershipVersion: 1, gitCacheRestoration: { schema: "paperclip.git-cache-restoration.v2", companyId: f.companyId,
workspaceId: f.workspaceId, sourceIssueId: f.issueId, repoUrl: f.repoUrl, commit: f.commit, branchName: f.branchName,
sourceKind: "managed_project", projectId: f.projectId, sourcePath: f.base, worktreePath: f.cwd,
gitBranchOwnershipVersion: 1, verifiedAt: new Date().toISOString(), restorePoint, contents },
} }).where(eq(executionWorkspaces.id, f.workspaceId));
await git(f.base, "worktree", "remove", "--force", f.cwd); await git(f.base, "branch", "-D", f.branchName);
if (state === "missing-base") await fs.rm(f.base, { recursive: true });
if (state === "unapproved-nested") {
await expect(migrateRemoteWorkspace({ db, storage, ...f, apply: true })).rejects.toThrow();
expect(readRemoteWorkspaceAuthority((await workspace(f.workspaceId)).metadata)).toBeNull();
expect((await workspace(f.workspaceId)).status).toBe("archived");
return;
}
if (state === "pruned") {
await git(f.base, "reflog", "expire", "--expire=now", "--all");
await git(f.base, "gc", "--prune=now");
await expect(git(f.base, "cat-file", "-e", `${f.commit}^{commit}`)).rejects.toThrow();
}
if (["missing", "expired", "corrupt"].includes(state)) {
if (state === "missing") await storage.deleteObject(f.companyId, objectKey);
else {
const row = await workspace(f.workspaceId);
const recipe = row.metadata!.gitCacheRestoration as Record<string, unknown>;
await db.update(executionWorkspaces).set({ metadata: { ...row.metadata, gitCacheRestoration: { ...recipe,
restorePoint: { ...restorePoint, ...(state === "expired" ? { expiredAt: new Date().toISOString() } : { sha256: "00".repeat(32) }) },
} } }).where(eq(executionWorkspaces.id, f.workspaceId));
}
await expect(migrateRemoteWorkspace({ db, storage, ...f, apply: true })).rejects.toThrow();
const row = await workspace(f.workspaceId);
expect(row).toMatchObject({ status: "archived", providerType: "git_worktree", cwd: f.cwd });
expect(readRemoteWorkspaceAuthority(row.metadata)).toBeNull();
expect(await fs.stat(f.cwd).catch(() => null)).toBeNull();
return;
}
if (state === "retry") {
const put = storage.putFile.bind(storage); let fail = true;
storage.putFile = async input => { const result = await put(input); if (fail) { fail = false; throw new Error("upload reply lost"); } return result; };
await expect(migrateRemoteWorkspace({ db, storage, ...f, apply: true })).rejects.toThrow("upload reply lost");
}
await migrateRemoteWorkspace({ db, storage, ...f, apply: true });
const authority = readRemoteWorkspaceAuthority((await workspace(f.workspaceId)).metadata)!;
await fs.rm(f.base, { recursive: true, force: true }); await fs.rm(path.join(root, "nested-source"), { recursive: true, force: true }); await storage.deleteObject(f.companyId, objectKey);
const ref = await getWorkspaceSnapshot(db, { ...f, snapshotId: authority.snapshotId! });
const archivePath = path.join(root, "retained-git.pcws"), targetDir = path.join(root, "restored-retained-git");
await loadWorkspaceSnapshotArchive({ storage, reference: ref!, targetPath: archivePath });
await restoreRemoteWorkspaceArchive({ archivePath, targetDir, expectedManifestDigest: ref!.manifestDigest });
expect(await git(targetDir, "rev-parse", "HEAD")).toBe(f.commit);
if (state === "nested") {
expect(await fs.readFile(path.join(targetDir, ".paperclip-repositories", contents!.checkouts[0]!.name, "nested.txt"), "utf8")).toBe("nested source work\n");
expect(JSON.parse(await fs.readFile(path.join(targetDir, ".paperclip-repositories", contents!.checkouts[0]!.name, ".claude/settings.local.json"), "utf8")))
.toEqual({ permissions: { allow: ["Read"] } });
}
expect(await git(targetDir, "show", ":saved.txt")).toBe("staged edit");
expect(await fs.readFile(path.join(targetDir, "saved.txt"), "utf8")).toBe("unstaged edit\n");
});
async function retainDirectory(f: Awaited<ReturnType<typeof seed>>) {
const captured = await captureTaskDirectorySnapshot(f.cwd), id = randomUUID(), generation = 1;
const sealed = await sealRestorePoint(captured.payload, { companyId: f.companyId, workspaceId: f.workspaceId, restorePointId: id, generation });
const objectKey = `${f.companyId}/git-restore-points/${f.workspaceId}/${generation}/task-directory/${id}.pcrp`;
await storage.putFile({ companyId: f.companyId, namespace: "git-restore-points", objectKey,
originalFilename: "retained.pcrp", contentType: "application/octet-stream", body: sealed.ciphertext });
const ref: TaskDirectoryRestorePointRef = { schema: "paperclip.task-directory-restore-point.v1", id, generation,
objectKey, sha256: sealed.sha256, byteSize: sealed.ciphertext.length, key: sealed.key,
digest: captured.digest, files: captured.files, excluded: captured.excluded };
await db.update(executionWorkspaces).set({ status: "archived", closedAt: new Date(), metadata: {
isolatedTaskDirectoryRetention: { schema: "paperclip.task-directory-retention.v1", generation, removed: true,
trash: path.join(root, "removed-trash"), restorePoint: ref },
} }).where(eq(executionWorkspaces.id, f.workspaceId));
await fs.rm(f.cwd, { recursive: true });
return ref;
}
it("imports an archived directory from its encrypted restore point after the old folder is gone", async () => {
const f = await seed(), retained = await retainDirectory(f);
expect(await inventoryRemoteWorkspaceMigration(db, f)).toMatchObject({ eligible: true });
await migrateRemoteWorkspace({ db, storage, ...f, apply: true });
const saved = await workspace(f.workspaceId), authority = readRemoteWorkspaceAuthority(saved.metadata)!;
expect(saved.status).toBe("archived");
expect(await fs.stat(f.cwd).catch(() => null)).toBeNull();
await storage.deleteObject(f.companyId, retained.objectKey);
const ref = await getWorkspaceSnapshot(db, { ...f, snapshotId: authority.snapshotId! });
const archivePath = path.join(root, "retained.pcws"), targetDir = path.join(root, "restored-retained");
await loadWorkspaceSnapshotArchive({ storage, reference: ref!, targetPath: archivePath });
await restoreRemoteWorkspaceArchive({ archivePath, targetDir, expectedManifestDigest: ref!.manifestDigest });
expect(await fs.readFile(path.join(targetDir, "saved.txt"), "utf8")).toBe("original saved work\n");
});
it.each(["missing", "expired", "corrupt"])("does not replace an unavailable retained directory with an empty workspace (%s)", async failure => {
const f = await seed(), retained = await retainDirectory(f);
if (failure === "missing") await storage.deleteObject(f.companyId, retained.objectKey);
else {
const row = await workspace(f.workspaceId);
const retention = row.metadata!.isolatedTaskDirectoryRetention as Record<string, unknown>;
await db.update(executionWorkspaces).set({ metadata: { ...row.metadata, isolatedTaskDirectoryRetention: {
...retention, restorePoint: { ...retained, ...(failure === "expired" ? { expiredAt: new Date().toISOString() } : { sha256: "00".repeat(32) }) },
} } }).where(eq(executionWorkspaces.id, f.workspaceId));
}
await expect(migrateRemoteWorkspace({ db, storage, ...f, apply: true })).rejects.toThrow();
const row = await workspace(f.workspaceId);
expect(row).toMatchObject({ status: "archived", providerType: "local_fs", cwd: f.cwd });
expect(readRemoteWorkspaceAuthority(row.metadata)).toBeNull();
expect(await fs.stat(f.cwd).catch(() => null)).toBeNull();
});
it("keeps newly restored local files when they appear during an archived import", async () => {
const f = await seed(); await retainDirectory(f);
const put = storage.putFile.bind(storage);
storage.putFile = async input => {
const result = await put(input);
await fs.mkdir(f.cwd); await fs.writeFile(path.join(f.cwd, "saved.txt"), "new local edits\n");
return result;
};
await expect(migrateRemoteWorkspace({ db, storage, ...f, apply: true })).rejects.toThrow("remote_workspace_migration_source_changed");
expect(readRemoteWorkspaceAuthority((await workspace(f.workspaceId)).metadata)).toBeNull();
expect(await fs.readFile(path.join(f.cwd, "saved.txt"), "utf8")).toBe("new local edits\n");
});
it("preserves an archived workspace without reopening its task or losing its files", async () => {
const f = await seed(), closedAt = new Date("2026-10-20T12:00:00Z");
await db.update(executionWorkspaces).set({ status: "archived", closedAt }).where(eq(executionWorkspaces.id, f.workspaceId));
await db.update(issues).set({ status: "done" }).where(eq(issues.id, f.issueId));
await expect(migrateRemoteWorkspace({ db, storage, ...f, apply: true })).resolves.toMatchObject({ applied: true });
const saved = await workspace(f.workspaceId), authority = readRemoteWorkspaceAuthority(saved.metadata)!;
expect(saved.status).toBe("archived"); expect(saved.closedAt).toEqual(closedAt);
const [issue] = await db.select().from(issues).where(eq(issues.id, f.issueId));
expect(issue).toMatchObject({ status: "done", executionWorkspaceId: f.workspaceId });
const ref = await getWorkspaceSnapshot(db, { ...f, snapshotId: authority.snapshotId! });
await fs.rm(f.cwd, { recursive: true });
const archivePath = path.join(root, "archived.pcws"), targetDir = path.join(root, "restored-archived");
await loadWorkspaceSnapshotArchive({ storage, reference: ref!, targetPath: archivePath });
await restoreRemoteWorkspaceArchive({ archivePath, targetDir, expectedManifestDigest: ref!.manifestDigest });
expect(await fs.readFile(path.join(targetDir, "saved.txt"), "utf8")).toBe("original saved work\n");
});
it("imports a historical workspace without replacing the task's current workspace", async () => {
const f = await seed(), currentId = randomUUID(), currentCwd = path.join(root, "current");
await fs.mkdir(currentCwd); await fs.writeFile(path.join(currentCwd, "saved.txt"), "newer current work\n");
await db.insert(executionWorkspaces).values({ id: currentId, companyId: f.companyId, sourceIssueId: f.issueId,
mode: "isolated_workspace", strategyType: "project_primary", providerType: "local_fs", name: "Current workspace", cwd: currentCwd });
await db.update(issues).set({ executionWorkspaceId: currentId }).where(eq(issues.id, f.issueId));
await expect(migrateRemoteWorkspace({ db, storage, ...f, apply: true })).resolves.toMatchObject({ applied: true });
const [issue] = await db.select().from(issues).where(eq(issues.id, f.issueId));
expect(issue?.executionWorkspaceId).toBe(currentId);
expect(await fs.readFile(path.join(currentCwd, "saved.txt"), "utf8")).toBe("newer current work\n");
expect(readRemoteWorkspaceAuthority((await workspace(currentId)).metadata)).toBeNull();
expect(readRemoteWorkspaceAuthority((await workspace(f.workspaceId)).metadata)?.snapshotId).toBeTruthy();
});
it("refuses publication when the task changes its workspace during import", async () => {
const f = await seed(), put = storage.putFile.bind(storage);
storage.putFile = async input => {
const result = await put(input);
await db.update(issues).set({ executionWorkspaceId: null }).where(eq(issues.id, f.issueId));
return result;
};
await expect(migrateRemoteWorkspace({ db, storage, ...f, apply: true })).rejects.toThrow("remote_workspace_migration_source_changed");
expect(readRemoteWorkspaceAuthority((await workspace(f.workspaceId)).metadata)).toBeNull();
expect(await fs.readFile(path.join(f.cwd, "saved.txt"), "utf8")).toBe("original saved work\n");
await expect(migrateRemoteWorkspace({ db, storage, ...f, apply: true })).rejects.toThrow("remote_workspace_migration_source_changed");
});
it("defaults to dry run and preserves both database authority and local files", async () => {
const f = await seed(); expect(await migrateRemoteWorkspace({ db, storage, ...f })).toMatchObject({ eligible: true, applied: false, alreadyMigrated: false });
expect((await workspace(f.workspaceId)).providerType).toBe("local_fs");
@@ -14,7 +14,9 @@ import { withExecutionWorkspaceLifecycleLock } from "./execution-workspace-lifec
import { captureRemoteWorkspaceDirectory, restoreRemoteWorkspaceArchive } from "./remote-workspace-snapshot-archive.js";
import { beginWorkspaceSnapshot, loadWorkspaceSnapshotArchive, uploadWorkspaceSnapshot } from "./remote-workspace-snapshot-storage.js";
import { notifyRemoteWorkspaceSnapshots } from "./remote-workspace-snapshot-retention.js";
import { removeTaskDirectoryTree } from "./isolated-task-directory-snapshot.js";
import { materializeTaskDirectorySnapshot, removeTaskDirectoryTree } from "./isolated-task-directory-snapshot.js";
import { loadTaskDirectoryRestorePoint, mayBeIsolatedTaskDirectoryWorkspace, readTaskDirectoryRetention, validateTaskDirectoryRestorePointRef } from "./isolated-task-directory-retention.js";
import { inspectArchivedGitMigrationSource, materializeArchivedGitMigrationSource, type ArchivedGitMigrationSource } from "./remote-workspace-migration-git.js";
const SCHEMA = "paperclip.remote-workspace-migration.v1";
type Workspace = typeof executionWorkspaces.$inferSelect;
@@ -32,6 +34,9 @@ function fingerprint(workspace: Workspace) {
providerType: workspace.providerType, cwd: workspace.cwd, providerRef: workspace.providerRef, repoUrl: workspace.repoUrl,
baseRef: workspace.baseRef, branchName: workspace.branchName, status: workspace.status, closedAt: workspace.closedAt?.toISOString(), metadata })).digest("hex");
}
const gitSourceProof = (source: ArchivedGitMigrationSource | null) => source ? {
commit: source.commit, sourcePath: source.sourcePath, cleanupOperationId: source.cleanupOperationId,
} : null;
async function inspect(db: ReadDb, input: { companyId: string; workspaceId: string; operationId?: string }) {
const [workspace] = await db.select().from(executionWorkspaces).where(and(eq(executionWorkspaces.companyId, input.companyId), eq(executionWorkspaces.id, input.workspaceId)));
@@ -40,21 +45,43 @@ async function inspect(db: ReadDb, input: { companyId: string; workspaceId: stri
const marker = object(workspace.metadata?.remoteWorkspaceMigration);
const allowedOperation = input.operationId ?? (marker.schema === SCHEMA && typeof marker.operationId === "string" ? marker.operationId : undefined);
const reasons: string[] = [];
if (authority) return { workspace, reasons, authority, marker, source: authority.source };
if (authority) return { workspace, reasons, authority, marker, source: authority.source, issueBinding: null, retainedDirectory: null, archivedGitSource: null };
if (!["local_fs", "git_worktree"].includes(workspace.providerType)) reasons.push("workspace_provider_unsupported");
if (!["active", "idle", "in_review"].includes(workspace.status)) reasons.push("workspace_not_active");
if (!["active", "idle", "in_review", "archived"].includes(workspace.status)) reasons.push("workspace_not_active");
if (!workspace.sourceIssueId) reasons.push("task_binding_required");
const nestedMigration = workspace.metadata?.nestedCloneMigrationPending;
if (nestedMigration != null && (typeof nestedMigration !== "object" || Array.isArray(nestedMigration)
|| Object.keys(nestedMigration).length > 0)) reasons.push("nested_clone_migration_unresolved");
const cwd = workspace.cwd ?? workspace.providerRef;
let retainedDirectory = null as ReturnType<typeof readTaskDirectoryRetention>;
let archivedGitSource: ArchivedGitMigrationSource | null = null;
if (!cwd || !path.isAbsolute(cwd)) reasons.push("local_source_missing");
else {
const entry = await fs.lstat(cwd).catch(() => null);
if (!entry?.isDirectory() || entry.isSymbolicLink()) reasons.push("local_source_unsafe_or_missing");
const entry = await fs.lstat(cwd).catch((error: NodeJS.ErrnoException) => {
if (error.code === "ENOENT") return null;
throw error;
});
if (!entry && workspace.status === "archived" && mayBeIsolatedTaskDirectoryWorkspace(workspace)) {
const retained = readTaskDirectoryRetention(workspace);
if (retained?.removed === true && retained.restorePoint && retained.generation === retained.restorePoint.generation) {
if (retained.restorePoint.expiredAt) reasons.push("restore_point_expired");
else {
try { validateTaskDirectoryRestorePointRef(retained.restorePoint, workspace); retainedDirectory = retained; }
catch { reasons.push("restore_point_invalid"); }
}
}
}
if (!entry && workspace.status === "archived" && !retainedDirectory) {
try { archivedGitSource = await inspectArchivedGitMigrationSource(db, workspace); }
catch (error) { reasons.push(error instanceof Error && error.message === "remote_workspace_migration_restore_point_expired"
? "restore_point_expired" : "archived_git_source_unavailable"); }
}
if (!retainedDirectory && !archivedGitSource && (!entry?.isDirectory() || entry.isSymbolicLink())) reasons.push("local_source_unsafe_or_missing");
}
const [issue] = workspace.sourceIssueId ? await db.select().from(issues).where(and(eq(issues.companyId, workspace.companyId), eq(issues.id, workspace.sourceIssueId))) : [];
if (workspace.sourceIssueId && (!issue || issue.executionWorkspaceId !== workspace.id)) reasons.push("task_binding_changed");
// Historical records still own their saved files after the task selects a
// different workspace. Importing them must not reopen or rebind that task.
if (workspace.sourceIssueId && !issue) reasons.push("task_binding_changed");
const [project] = workspace.projectId ? await db.select({ policy: projects.executionWorkspacePolicy }).from(projects)
.where(and(eq(projects.companyId, workspace.companyId), eq(projects.id, workspace.projectId))) : [];
const issuePolicy = object(issue?.executionWorkspaceSettings), projectPolicy = object(project?.policy);
@@ -109,10 +136,10 @@ async function inspect(db: ReadDb, input: { companyId: string; workspaceId: stri
eq(executionWorkspaceRepositories.executionWorkspaceId, workspace.id)));
if (repositories.some(repository => repository.state !== "ready")) reasons.push("repository_preparation_unresolved");
const source: RemoteWorkspaceSource = workspace.repoUrl ? { kind: "git", repoUrl: workspace.repoUrl,
ref: workspace.baseRef, branchName: workspace.branchName } : { kind: "empty" };
ref: archivedGitSource?.commit ?? workspace.baseRef, branchName: workspace.branchName } : { kind: "empty" };
if (repositories.length) source.repositories = repositories.map(repository => ({ id: repository.id, relativePath: repository.relativePath,
repoUrl: repository.repoUrl, ref: repository.requestedRef, ...(repository.pinnedCommit ? { pinnedCommit: repository.pinnedCommit } : {}) }));
return { workspace, reasons: [...new Set(reasons)], authority, marker, source };
return { workspace, reasons: [...new Set(reasons)], authority, marker, source, issueBinding: issue?.executionWorkspaceId ?? null, retainedDirectory, archivedGitSource };
}
export async function inventoryRemoteWorkspaceMigration(db: Db, input: { companyId: string; workspaceId: string }) {
@@ -134,7 +161,7 @@ export async function migrateRemoteWorkspace(input: {
if (!workspace) throw new Error("remote_workspace_migration_workspace_missing");
if (workspace.sourceIssueId) await tx.select({ id: issues.id }).from(issues).where(and(eq(issues.companyId, workspace.companyId), eq(issues.id, workspace.sourceIssueId))).for("update");
const current = await inspect(tx as unknown as Db, input);
if (current.authority) return { workspace, authority: current.authority, operation: null, source: current.source, sourceFingerprint: "" };
if (current.authority) return { workspace, authority: current.authority, operation: null, source: current.source, sourceFingerprint: "", issueBinding: current.issueBinding, retainedDirectory: null, archivedGitSource: null };
if (current.reasons.length) throw new Error(`remote_workspace_migration_blocked:${current.reasons.join(",")}`);
const source = remoteWorkspaceSourceSchema.parse(input.source ?? current.source);
const sourceFingerprint = fingerprint(workspace);
@@ -144,16 +171,26 @@ export async function migrateRemoteWorkspace(input: {
const [operation] = await tx.select().from(workspaceOperations).where(and(eq(workspaceOperations.companyId, workspace.companyId),
eq(workspaceOperations.executionWorkspaceId, workspace.id), eq(workspaceOperations.id, marker.operationId), eq(workspaceOperations.phase, "remote_workspace_migration")));
if (!operation) throw new Error("remote_workspace_migration_operation_missing");
return { workspace, authority: null, operation, source, sourceFingerprint };
// Older operations only admitted the task's current workspace. Preserve
// that binding when resuming an import journaled before this field existed.
const issueBinding = Object.hasOwn(operation.metadata ?? {}, "sourceIssueBinding")
? operation.metadata!.sourceIssueBinding : workspace.id;
if (issueBinding !== current.issueBinding) throw new Error("remote_workspace_migration_source_changed");
if (Boolean(operation.metadata?.retainedDirectory) !== Boolean(current.retainedDirectory)) throw new Error("remote_workspace_migration_source_changed");
if (canonical(operation.metadata?.archivedGitSource ?? null) !== canonical(gitSourceProof(current.archivedGitSource))) throw new Error("remote_workspace_migration_source_changed");
return { workspace, authority: null, operation, source, sourceFingerprint, issueBinding, retainedDirectory: current.retainedDirectory, archivedGitSource: current.archivedGitSource };
}
const [operation] = await tx.insert(workspaceOperations).values({ companyId: workspace.companyId, executionWorkspaceId: workspace.id,
issueId: workspace.sourceIssueId, cwd: workspace.cwd ?? workspace.providerRef, phase: "remote_workspace_migration", status: "running",
metadata: { schema: SCHEMA, sourceFingerprint, source } }).returning();
metadata: { schema: SCHEMA, sourceFingerprint, source, sourceIssueBinding: current.issueBinding,
...(current.archivedGitSource ? { archivedGitSource: gitSourceProof(current.archivedGitSource) } : {}),
...(current.retainedDirectory ? { retainedDirectory: { restorePointId: current.retainedDirectory.restorePoint.id,
generation: current.retainedDirectory.generation, digest: current.retainedDirectory.restorePoint.digest } } : {}) } }).returning();
await notifyRemoteWorkspaceSnapshots(tx, `workspace:${workspace.id}`);
await tx.update(executionWorkspaces).set({ metadata: { ...workspace.metadata, remoteWorkspaceMigration: {
schema: SCHEMA, operationId: operation!.id, state: "running", sourceFingerprint, source,
} }, updatedAt: new Date() }).where(eq(executionWorkspaces.id, workspace.id));
return { workspace, authority: null, operation: operation!, source, sourceFingerprint };
return { workspace, authority: null, operation: operation!, source, sourceFingerprint, issueBinding: current.issueBinding, retainedDirectory: current.retainedDirectory, archivedGitSource: current.archivedGitSource };
});
if (reserved.authority) return { ...await inventoryRemoteWorkspaceMigration(input.db, input), applied: true, replayed: true };
const operation = reserved.operation!;
@@ -161,15 +198,21 @@ export async function migrateRemoteWorkspace(input: {
operationId: operation.id, generation: 1, epoch: 1 });
const scratch = await fs.mkdtemp(path.join(os.tmpdir(), "pc-workspace-migration-"));
try {
const retained = reserved.retainedDirectory?.restorePoint;
const sourceRoot = retained ? await materializeTaskDirectorySnapshot({
payload: await loadTaskDirectoryRestorePoint({ storage: input.storage, workspace: reserved.workspace, ref: retained }),
target: path.join(scratch, "retained-source"), expectedDigest: retained.digest,
}) : reserved.archivedGitSource ? await materializeArchivedGitMigrationSource({ db: input.db, storage: input.storage,
workspace: reserved.workspace, source: reserved.archivedGitSource, scratch }) : reserved.workspace.cwd ?? reserved.workspace.providerRef!;
const archivePath = path.join(scratch, "workspace.pcws");
const captured = await captureRemoteWorkspaceDirectory({ cwd: reserved.workspace.cwd ?? reserved.workspace.providerRef!, archivePath });
const captured = await captureRemoteWorkspaceDirectory({ cwd: sourceRoot, archivePath });
const ref = await uploadWorkspaceSnapshot({ db: input.db, storage: input.storage, snapshotId: snapshot.id,
companyId: input.companyId, workspaceId: input.workspaceId, archivePath, manifestDigest: captured.manifestDigest });
const restoredArchive = path.join(scratch, "verified.pcws"), restored = path.join(scratch, "restored");
await loadWorkspaceSnapshotArchive({ storage: input.storage, reference: ref, targetPath: restoredArchive });
await restoreRemoteWorkspaceArchive({ archivePath: restoredArchive, targetDir: restored, expectedManifestDigest: ref.manifestDigest });
const currentArchive = path.join(scratch, "current.pcws");
const current = await captureRemoteWorkspaceDirectory({ cwd: reserved.workspace.cwd ?? reserved.workspace.providerRef!, archivePath: currentArchive });
const current = await captureRemoteWorkspaceDirectory({ cwd: sourceRoot, archivePath: currentArchive });
if (current.manifestDigest !== captured.manifestDigest) throw new Error("remote_workspace_migration_source_changed");
await lock.execute(sql`select 1`);
const publication = await input.db.transaction(async tx => {
@@ -181,6 +224,9 @@ export async function migrateRemoteWorkspace(input: {
|| object(workspace.metadata?.remoteWorkspaceMigration).operationId !== operation.id) throw new Error("remote_workspace_migration_publication_changed");
if (workspace.sourceIssueId) await tx.select({ id: issues.id }).from(issues).where(eq(issues.id, workspace.sourceIssueId)).for("update");
const validation = await inspect(tx as unknown as Db, { ...input, operationId: operation.id });
if (validation.issueBinding !== reserved.issueBinding) throw new Error("remote_workspace_migration_source_changed");
if (Boolean(validation.retainedDirectory) !== Boolean(reserved.retainedDirectory)) throw new Error("remote_workspace_migration_source_changed");
if (canonical(gitSourceProof(validation.archivedGitSource)) !== canonical(gitSourceProof(reserved.archivedGitSource))) throw new Error("remote_workspace_migration_source_changed");
if (validation.reasons.length) throw new Error(`remote_workspace_migration_blocked:${validation.reasons.join(",")}`);
const [uploaded] = await tx.select().from(executionWorkspaceSnapshots).where(and(eq(executionWorkspaceSnapshots.companyId, workspace.companyId),
eq(executionWorkspaceSnapshots.executionWorkspaceId, workspace.id), eq(executionWorkspaceSnapshots.id, ref.id))).for("update");
+28 -4
View File
@@ -2085,7 +2085,31 @@ describe("task subtree notification context", () => {
describe("activity toast attribution", () => {
const build = __liveUpdatesTestUtils.buildActivityToast;
const payload = { action: "issue.created", entityType: "issue", entityId: "task-1", actorType: "user", actorId: "user-1", actorName: "Alex Example", actorImage: "/alex.png" };
const payload = { action: "issue.created", entityType: "issue", entityId: "task-1", issueIdentifier: "PAP-1234", actorType: "user", actorId: "user-1", actorName: "Alex Example", actorImage: "/alex.png" };
it.each([
["issue.created", "Alex Example created PAP-1234"],
["issue.updated", "Alex Example updated PAP-1234"],
["issue.comment_added", "Alex Example commented on PAP-1234"],
])("uses the server task identifier for %s with an empty cache and no activity details", (action, title) => {
const toast = build(new QueryClient(), "company", { ...payload, action }, { userId: "other-user", agentId: null });
expect(toast?.title).toBe(title);
expect(toast?.action).toEqual({ label: "View PAP-1234", href: "/issues/PAP-1234" });
if (action === "issue.updated") expect(toast?.body).toBe("PAP-1234");
expect(JSON.stringify(toast)).not.toContain("Task task-1");
});
it("uses a generic task label when the identifier is unavailable", () => {
const toast = build(new QueryClient(), "company", { ...payload, action: "issue.updated", issueIdentifier: undefined }, { userId: "other-user", agentId: null });
expect(toast?.title).toBe("Alex Example updated Task");
expect(toast?.body).toBe("Task");
expect(toast?.action).toEqual({ label: "View Task", href: "/issues/task-1" });
});
it("resolves cached task titles by the server identifier", () => {
const queryClient = new QueryClient();
queryClient.setQueryData(queryKeys.issues.detail("PAP-1234"), { id: "task-1", identifier: "PAP-1234", title: "Fix task toasts" });
const toast = build(queryClient, "company", { ...payload, action: "issue.updated" }, { userId: "other-user", agentId: null });
expect(toast?.title).toBe("Alex Example updated PAP-1234");
expect(toast?.body).toBe("Fix task toasts");
});
it.each(["issue.created", "issue.updated", "issue.comment_added"])("hides the viewer's own %s", (action) => {
expect(build(new QueryClient(), "company", { ...payload, action }, { userId: "user-1", agentId: null })).toBeNull();
});
@@ -2094,18 +2118,18 @@ describe("activity toast attribution", () => {
});
it("uses the real name and photo with an empty directory cache", () => {
const toast = build(new QueryClient(), "company", payload, { userId: "other-user", agentId: null });
expect(toast?.title).toBe("Alex Example created Task task-1");
expect(toast?.title).toBe("Alex Example created PAP-1234");
expect(toast?.actor).toMatchObject({ type: "user", name: "Alex Example", image: "/alex.png" });
});
it("uses the agent's custom avatar with an empty agent cache", () => {
const appearance = { schemaVersion: 1, characterVersion: "cap-v1", paletteId: "bubblegum-sky", customAvatarAssetId: "11111111-1111-4111-8111-111111111111" };
const toast = build(new QueryClient(), "company", { ...payload, actorType: "agent", actorId: "agent-1", actorName: "Coder", actorAppearance: appearance }, { userId: "user-1", agentId: null });
expect(toast?.title).toBe("Coder created Task task-1");
expect(toast?.title).toBe("Coder created PAP-1234");
expect(toast?.actor?.appearance).toEqual(appearance);
});
it("keeps system activity distinct from people", () => {
const toast = build(new QueryClient(), "company", { ...payload, actorType: "system", actorName: undefined }, { userId: "user-1", agentId: null });
expect(toast?.title).toBe("System created Task task-1");
expect(toast?.title).toBe("System created PAP-1234");
expect(toast?.actor).toBeUndefined();
});
});
+8 -5
View File
@@ -375,11 +375,11 @@ function resolveIssueToastContext(
issueRefs.some((ref) => issue.id === ref || issue.identifier === ref),
);
const cachedIssue = detailIssue ?? listIssue ?? null;
const ref =
const identifier =
readString(details?.identifier) ??
readString(details?.issueIdentifier) ??
cachedIssue?.identifier ??
`Task ${shortId(issueId)}`;
cachedIssue?.identifier;
const ref = identifier ?? "Task";
const title =
readString(details?.title) ??
readString(details?.issueTitle) ??
@@ -389,7 +389,7 @@ function resolveIssueToastContext(
ref,
title,
label: title ? `${ref} - ${truncate(title, 72)}` : ref,
href: `/issues/${cachedIssue?.identifier ?? issueId}`,
href: `/issues/${identifier ?? issueId}`,
};
}
@@ -1064,7 +1064,10 @@ function buildActivityToast(
queryClient,
companyId,
entityId,
details,
{
...details,
identifier: readString(payload.issueIdentifier) ?? readString(details?.identifier),
},
);
const actor = readString(readString(payload.actorName)?.trim()) ?? resolveActorLabel(queryClient, companyId, actorType, actorId);
const cachedAgent = actorType === "agent" && actorId