mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 19:28:46 +01:00
Merge reviewed master203 into incoming sleep safety
This commit is contained in:
commit
5d0716e3e7
31 files changed
+2215
-44
No files matched your search
@@ -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`:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
+638
-2
@@ -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
@@ -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),
|
||||
}));
|
||||
@@ -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";
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user