From cd0a3b3a061102449bb3e3810f874723bc3f44ed Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 17 Jul 2026 02:34:07 +0000 Subject: [PATCH] Add runtime and wake audit attribution Co-Authored-By: Paperclip --- .../0178_runtime_wake_attribution.sql | 75 ++++++ packages/db/src/migrations/meta/_journal.json | 7 + .../db/src/schema/agent_wakeup_requests.ts | 15 ++ packages/db/src/schema/environment_leases.ts | 14 + .../db/src/schema/workspace_operations.ts | 24 ++ .../src/schema/workspace_runtime_services.ts | 20 ++ packages/shared/src/types/environment.ts | 4 + packages/shared/src/types/heartbeat.ts | 4 + packages/shared/src/types/issue.ts | 4 + .../shared/src/types/workspace-operation.ts | 4 + .../shared/src/types/workspace-runtime.ts | 7 + .../src/validators/execution-workspace.ts | 7 + ...artbeat-responsible-user-invariant.test.ts | 73 ++++++ .../runtime-audit-attribution.test.ts | 247 ++++++++++++++++++ server/src/routes/agents.ts | 20 ++ server/src/routes/execution-workspaces.ts | 39 ++- server/src/routes/issues.ts | 12 + server/src/routes/projects.ts | 20 +- .../services/environment-run-orchestrator.ts | 11 +- server/src/services/environment-runtime.ts | 36 +++ server/src/services/environments.ts | 12 + server/src/services/execution-workspaces.ts | 7 + server/src/services/heartbeat.ts | 79 +++--- server/src/services/issues.ts | 12 + server/src/services/projects.ts | 7 + server/src/services/workspace-operations.ts | 17 +- server/src/services/workspace-runtime.ts | 148 ++++++++++- 27 files changed, 887 insertions(+), 38 deletions(-) create mode 100644 packages/db/src/migrations/0178_runtime_wake_attribution.sql create mode 100644 server/src/__tests__/runtime-audit-attribution.test.ts diff --git a/packages/db/src/migrations/0178_runtime_wake_attribution.sql b/packages/db/src/migrations/0178_runtime_wake_attribution.sql new file mode 100644 index 00000000000..ec2b4933986 --- /dev/null +++ b/packages/db/src/migrations/0178_runtime_wake_attribution.sql @@ -0,0 +1,75 @@ +ALTER TABLE "workspace_operations" + ADD COLUMN IF NOT EXISTS "actor_agent_id" uuid, + ADD COLUMN IF NOT EXISTS "actor_user_id" text, + ADD COLUMN IF NOT EXISTS "actor_run_id" uuid, + ADD COLUMN IF NOT EXISTS "responsible_user_id" text; + +ALTER TABLE "workspace_runtime_services" + ADD COLUMN IF NOT EXISTS "started_by_agent_id" uuid, + ADD COLUMN IF NOT EXISTS "started_by_user_id" text, + ADD COLUMN IF NOT EXISTS "responsible_user_id" text, + ADD COLUMN IF NOT EXISTS "last_controlled_by_agent_id" uuid, + ADD COLUMN IF NOT EXISTS "last_controlled_by_user_id" text, + ADD COLUMN IF NOT EXISTS "last_controlled_by_run_id" uuid, + ADD COLUMN IF NOT EXISTS "last_controlled_at" timestamp with time zone; + +ALTER TABLE "environment_leases" + ADD COLUMN IF NOT EXISTS "actor_agent_id" uuid, + ADD COLUMN IF NOT EXISTS "actor_user_id" text, + ADD COLUMN IF NOT EXISTS "actor_run_id" uuid, + ADD COLUMN IF NOT EXISTS "responsible_user_id" text; + +ALTER TABLE "agent_wakeup_requests" + ADD COLUMN IF NOT EXISTS "requested_by_agent_id" uuid, + ADD COLUMN IF NOT EXISTS "requested_by_user_id" text, + ADD COLUMN IF NOT EXISTS "requested_by_run_id" uuid, + ADD COLUMN IF NOT EXISTS "responsible_user_id" text; + +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM "pg_constraint" WHERE "conname" = 'workspace_operations_actor_agent_id_agents_id_fk') THEN + ALTER TABLE "workspace_operations" ADD CONSTRAINT "workspace_operations_actor_agent_id_agents_id_fk" FOREIGN KEY ("actor_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action; + END IF; + IF NOT EXISTS (SELECT 1 FROM "pg_constraint" WHERE "conname" = 'workspace_operations_actor_run_id_heartbeat_runs_id_fk') THEN + ALTER TABLE "workspace_operations" ADD CONSTRAINT "workspace_operations_actor_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("actor_run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE set null ON UPDATE no action; + END IF; + IF NOT EXISTS (SELECT 1 FROM "pg_constraint" WHERE "conname" = 'workspace_runtime_services_started_by_agent_id_agents_id_fk') THEN + ALTER TABLE "workspace_runtime_services" ADD CONSTRAINT "workspace_runtime_services_started_by_agent_id_agents_id_fk" FOREIGN KEY ("started_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action; + END IF; + IF NOT EXISTS (SELECT 1 FROM "pg_constraint" WHERE "conname" = 'workspace_runtime_services_last_controlled_by_agent_id_agents_id_fk') THEN + ALTER TABLE "workspace_runtime_services" ADD CONSTRAINT "workspace_runtime_services_last_controlled_by_agent_id_agents_id_fk" FOREIGN KEY ("last_controlled_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action; + END IF; + IF NOT EXISTS (SELECT 1 FROM "pg_constraint" WHERE "conname" = 'workspace_runtime_services_last_controlled_by_run_id_heartbeat_runs_id_fk') THEN + ALTER TABLE "workspace_runtime_services" ADD CONSTRAINT "workspace_runtime_services_last_controlled_by_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("last_controlled_by_run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE set null ON UPDATE no action; + END IF; + IF NOT EXISTS (SELECT 1 FROM "pg_constraint" WHERE "conname" = 'environment_leases_actor_agent_id_agents_id_fk') THEN + ALTER TABLE "environment_leases" ADD CONSTRAINT "environment_leases_actor_agent_id_agents_id_fk" FOREIGN KEY ("actor_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action; + END IF; + IF NOT EXISTS (SELECT 1 FROM "pg_constraint" WHERE "conname" = 'environment_leases_actor_run_id_heartbeat_runs_id_fk') THEN + ALTER TABLE "environment_leases" ADD CONSTRAINT "environment_leases_actor_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("actor_run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE set null ON UPDATE no action; + END IF; + IF NOT EXISTS (SELECT 1 FROM "pg_constraint" WHERE "conname" = 'agent_wakeup_requests_requested_by_agent_id_agents_id_fk') THEN + ALTER TABLE "agent_wakeup_requests" ADD CONSTRAINT "agent_wakeup_requests_requested_by_agent_id_agents_id_fk" FOREIGN KEY ("requested_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action; + END IF; +END $$; + +-- paperclip:migration-safety-ignore large-create-index-not-concurrently: Drizzle migrations run transactionally, so CONCURRENTLY is unavailable because these indexes back audit lookups by natural runtime and wake attribution rows. +CREATE INDEX IF NOT EXISTS "workspace_operations_company_actor_agent_started_idx" ON "workspace_operations" USING btree ("company_id", "actor_agent_id", "started_at"); +CREATE INDEX IF NOT EXISTS "workspace_operations_company_actor_run_started_idx" ON "workspace_operations" USING btree ("company_id", "actor_run_id", "started_at"); +CREATE INDEX IF NOT EXISTS "workspace_operations_company_responsible_user_started_idx" ON "workspace_operations" USING btree ("company_id", "responsible_user_id", "started_at"); + +-- paperclip:migration-safety-ignore large-create-index-not-concurrently: Drizzle migrations run transactionally, so CONCURRENTLY is unavailable because these indexes back audit lookups by natural runtime service attribution rows. +CREATE INDEX IF NOT EXISTS "workspace_runtime_services_company_started_by_agent_idx" ON "workspace_runtime_services" USING btree ("company_id", "started_by_agent_id", "started_at"); +CREATE INDEX IF NOT EXISTS "workspace_runtime_services_company_responsible_user_idx" ON "workspace_runtime_services" USING btree ("company_id", "responsible_user_id", "started_at"); +CREATE INDEX IF NOT EXISTS "workspace_runtime_services_last_controlled_by_run_idx" ON "workspace_runtime_services" USING btree ("last_controlled_by_run_id"); + +-- paperclip:migration-safety-ignore large-create-index-not-concurrently: Drizzle migrations run transactionally, so CONCURRENTLY is unavailable because these indexes back audit lookups by natural environment lease attribution rows. +CREATE INDEX IF NOT EXISTS "environment_leases_actor_run_idx" ON "environment_leases" USING btree ("actor_run_id"); +CREATE INDEX IF NOT EXISTS "environment_leases_company_actor_agent_idx" ON "environment_leases" USING btree ("company_id", "actor_agent_id"); +CREATE INDEX IF NOT EXISTS "environment_leases_company_responsible_user_idx" ON "environment_leases" USING btree ("company_id", "responsible_user_id"); + +-- paperclip:migration-safety-ignore large-create-index-not-concurrently: Drizzle migrations run transactionally, so CONCURRENTLY is unavailable because these indexes back audit lookups by natural wake request attribution rows. +CREATE INDEX IF NOT EXISTS "agent_wakeup_requests_requested_by_run_idx" ON "agent_wakeup_requests" USING btree ("requested_by_run_id"); +-- paperclip:migration-safety-ignore large-create-index-not-concurrently: Drizzle migrations run transactionally, so CONCURRENTLY is unavailable because this index backs audit lookups by natural wake request attribution rows. +CREATE INDEX IF NOT EXISTS "agent_wakeup_requests_company_requested_by_agent_idx" ON "agent_wakeup_requests" USING btree ("company_id", "requested_by_agent_id", "requested_at"); +-- paperclip:migration-safety-ignore large-create-index-not-concurrently: Drizzle migrations run transactionally, so CONCURRENTLY is unavailable because this index backs audit lookups by natural wake request attribution rows. +CREATE INDEX IF NOT EXISTS "agent_wakeup_requests_company_responsible_user_requested_idx" ON "agent_wakeup_requests" USING btree ("company_id", "responsible_user_id", "requested_at"); diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index 197e97daded..a52bc8fd1e5 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -1233,6 +1233,13 @@ "when": 1784241826832, "tag": "0177_activity_log_responsible_user", "breakpoints": true + }, + { + "idx": 178, + "version": "7", + "when": 1784254315259, + "tag": "0178_runtime_wake_attribution", + "breakpoints": true } ] } diff --git a/packages/db/src/schema/agent_wakeup_requests.ts b/packages/db/src/schema/agent_wakeup_requests.ts index 0d019446bc3..52071dea0b4 100644 --- a/packages/db/src/schema/agent_wakeup_requests.ts +++ b/packages/db/src/schema/agent_wakeup_requests.ts @@ -16,6 +16,10 @@ export const agentWakeupRequests = pgTable( coalescedCount: integer("coalesced_count").notNull().default(0), requestedByActorType: text("requested_by_actor_type"), requestedByActorId: text("requested_by_actor_id"), + requestedByAgentId: uuid("requested_by_agent_id").references(() => agents.id, { onDelete: "set null" }), + requestedByUserId: text("requested_by_user_id"), + requestedByRunId: uuid("requested_by_run_id"), + responsibleUserId: text("responsible_user_id"), idempotencyKey: text("idempotency_key"), runId: uuid("run_id"), requestedAt: timestamp("requested_at", { withTimezone: true }).notNull().defaultNow(), @@ -36,5 +40,16 @@ export const agentWakeupRequests = pgTable( table.requestedAt, ), agentRequestedIdx: index("agent_wakeup_requests_agent_requested_idx").on(table.agentId, table.requestedAt), + requestedByRunIdx: index("agent_wakeup_requests_requested_by_run_idx").on(table.requestedByRunId), + companyRequestedByAgentIdx: index("agent_wakeup_requests_company_requested_by_agent_idx").on( + table.companyId, + table.requestedByAgentId, + table.requestedAt, + ), + companyResponsibleUserRequestedIdx: index("agent_wakeup_requests_company_responsible_user_requested_idx").on( + table.companyId, + table.responsibleUserId, + table.requestedAt, + ), }), ); diff --git a/packages/db/src/schema/environment_leases.ts b/packages/db/src/schema/environment_leases.ts index ea01db2bd66..b29ccbfc48e 100644 --- a/packages/db/src/schema/environment_leases.ts +++ b/packages/db/src/schema/environment_leases.ts @@ -1,5 +1,6 @@ import { index, jsonb, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core"; import { companies } from "./companies.js"; +import { agents } from "./agents.js"; import { environments } from "./environments.js"; import { executionWorkspaces } from "./execution_workspaces.js"; import { heartbeatRuns } from "./heartbeat_runs.js"; @@ -14,6 +15,10 @@ export const environmentLeases = pgTable( executionWorkspaceId: uuid("execution_workspace_id").references(() => executionWorkspaces.id, { onDelete: "set null" }), issueId: uuid("issue_id").references(() => issues.id, { onDelete: "set null" }), heartbeatRunId: uuid("heartbeat_run_id").references(() => heartbeatRuns.id, { onDelete: "set null" }), + actorAgentId: uuid("actor_agent_id").references(() => agents.id, { onDelete: "set null" }), + actorUserId: text("actor_user_id"), + actorRunId: uuid("actor_run_id").references(() => heartbeatRuns.id, { onDelete: "set null" }), + responsibleUserId: text("responsible_user_id"), status: text("status").notNull().default("active"), leasePolicy: text("lease_policy").notNull().default("ephemeral"), provider: text("provider"), @@ -42,5 +47,14 @@ export const environmentLeases = pgTable( heartbeatRunIdx: index("environment_leases_heartbeat_run_idx").on(table.heartbeatRunId), companyLastUsedIdx: index("environment_leases_company_last_used_idx").on(table.companyId, table.lastUsedAt), providerLeaseIdx: index("environment_leases_provider_lease_idx").on(table.providerLeaseId), + actorRunIdx: index("environment_leases_actor_run_idx").on(table.actorRunId), + companyActorAgentIdx: index("environment_leases_company_actor_agent_idx").on( + table.companyId, + table.actorAgentId, + ), + companyResponsibleUserIdx: index("environment_leases_company_responsible_user_idx").on( + table.companyId, + table.responsibleUserId, + ), }), ); diff --git a/packages/db/src/schema/workspace_operations.ts b/packages/db/src/schema/workspace_operations.ts index 8924d5e7b33..30666bcb2d3 100644 --- a/packages/db/src/schema/workspace_operations.ts +++ b/packages/db/src/schema/workspace_operations.ts @@ -10,6 +10,7 @@ import { uuid, } from "drizzle-orm/pg-core"; import { companies } from "./companies.js"; +import { agents } from "./agents.js"; import { executionWorkspaces } from "./execution_workspaces.js"; import { heartbeatRuns } from "./heartbeat_runs.js"; import { issues } from "./issues.js"; @@ -25,6 +26,14 @@ export const workspaceOperations = pgTable( heartbeatRunId: uuid("heartbeat_run_id").references(() => heartbeatRuns.id, { onDelete: "set null", }), + actorAgentId: uuid("actor_agent_id").references(() => agents.id, { + onDelete: "set null", + }), + actorUserId: text("actor_user_id"), + actorRunId: uuid("actor_run_id").references(() => heartbeatRuns.id, { + onDelete: "set null", + }), + responsibleUserId: text("responsible_user_id"), issueId: uuid("issue_id").references(() => issues.id, { onDelete: "set null", }), @@ -63,5 +72,20 @@ export const workspaceOperations = pgTable( table.issueId, table.startedAt, ), + companyActorAgentStartedIdx: index("workspace_operations_company_actor_agent_started_idx").on( + table.companyId, + table.actorAgentId, + table.startedAt, + ), + companyActorRunStartedIdx: index("workspace_operations_company_actor_run_started_idx").on( + table.companyId, + table.actorRunId, + table.startedAt, + ), + companyResponsibleUserStartedIdx: index("workspace_operations_company_responsible_user_started_idx").on( + table.companyId, + table.responsibleUserId, + table.startedAt, + ), }), ); diff --git a/packages/db/src/schema/workspace_runtime_services.ts b/packages/db/src/schema/workspace_runtime_services.ts index 150c332dec8..5060af203d1 100644 --- a/packages/db/src/schema/workspace_runtime_services.ts +++ b/packages/db/src/schema/workspace_runtime_services.ts @@ -38,6 +38,13 @@ export const workspaceRuntimeServices = pgTable( providerRef: text("provider_ref"), ownerAgentId: uuid("owner_agent_id").references(() => agents.id, { onDelete: "set null" }), startedByRunId: uuid("started_by_run_id").references(() => heartbeatRuns.id, { onDelete: "set null" }), + startedByAgentId: uuid("started_by_agent_id").references(() => agents.id, { onDelete: "set null" }), + startedByUserId: text("started_by_user_id"), + responsibleUserId: text("responsible_user_id"), + lastControlledByAgentId: uuid("last_controlled_by_agent_id").references(() => agents.id, { onDelete: "set null" }), + lastControlledByUserId: text("last_controlled_by_user_id"), + lastControlledByRunId: uuid("last_controlled_by_run_id").references(() => heartbeatRuns.id, { onDelete: "set null" }), + lastControlledAt: timestamp("last_controlled_at", { withTimezone: true }), lastUsedAt: timestamp("last_used_at", { withTimezone: true }).notNull().defaultNow(), startedAt: timestamp("started_at", { withTimezone: true }).notNull().defaultNow(), stoppedAt: timestamp("stopped_at", { withTimezone: true }), @@ -63,6 +70,19 @@ export const workspaceRuntimeServices = pgTable( table.status, ), runIdx: index("workspace_runtime_services_run_idx").on(table.startedByRunId), + companyStartedByAgentIdx: index("workspace_runtime_services_company_started_by_agent_idx").on( + table.companyId, + table.startedByAgentId, + table.startedAt, + ), + companyResponsibleUserIdx: index("workspace_runtime_services_company_responsible_user_idx").on( + table.companyId, + table.responsibleUserId, + table.startedAt, + ), + lastControlledByRunIdx: index("workspace_runtime_services_last_controlled_by_run_idx").on( + table.lastControlledByRunId, + ), companyUpdatedIdx: index("workspace_runtime_services_company_updated_idx").on( table.companyId, table.updatedAt, diff --git a/packages/shared/src/types/environment.ts b/packages/shared/src/types/environment.ts index 2a2867ffc1c..222fc74c186 100644 --- a/packages/shared/src/types/environment.ts +++ b/packages/shared/src/types/environment.ts @@ -112,6 +112,10 @@ export interface EnvironmentLease { executionWorkspaceId: string | null; issueId: string | null; heartbeatRunId: string | null; + actorAgentId: string | null; + actorUserId: string | null; + actorRunId: string | null; + responsibleUserId: string | null; status: EnvironmentLeaseStatus; leasePolicy: EnvironmentLeasePolicy; provider: string | null; diff --git a/packages/shared/src/types/heartbeat.ts b/packages/shared/src/types/heartbeat.ts index 1c2592edd06..b458b79e493 100644 --- a/packages/shared/src/types/heartbeat.ts +++ b/packages/shared/src/types/heartbeat.ts @@ -234,6 +234,10 @@ export interface AgentWakeupRequest { coalescedCount: number; requestedByActorType: "user" | "agent" | "system" | null; requestedByActorId: string | null; + requestedByAgentId: string | null; + requestedByUserId: string | null; + requestedByRunId: string | null; + responsibleUserId: string | null; idempotencyKey: string | null; runId: string | null; requestedAt: Date; diff --git a/packages/shared/src/types/issue.ts b/packages/shared/src/types/issue.ts index 7fe47abe89a..08dd0fb1fb4 100644 --- a/packages/shared/src/types/issue.ts +++ b/packages/shared/src/types/issue.ts @@ -265,6 +265,10 @@ export interface IssueWakeDiagnosticWakeRequest { status: string; coalescedCount: number; runId: string | null; + requestedByAgentId: string | null; + requestedByUserId: string | null; + requestedByRunId: string | null; + responsibleUserId: string | null; requestedAt: string; claimedAt: string | null; finishedAt: string | null; diff --git a/packages/shared/src/types/workspace-operation.ts b/packages/shared/src/types/workspace-operation.ts index 18a3788e028..6bc28cf540f 100644 --- a/packages/shared/src/types/workspace-operation.ts +++ b/packages/shared/src/types/workspace-operation.ts @@ -13,6 +13,10 @@ export interface WorkspaceOperation { companyId: string; executionWorkspaceId: string | null; heartbeatRunId: string | null; + actorAgentId: string | null; + actorUserId: string | null; + actorRunId: string | null; + responsibleUserId: string | null; issueId: string | null; phase: WorkspaceOperationPhase; command: string | null; diff --git a/packages/shared/src/types/workspace-runtime.ts b/packages/shared/src/types/workspace-runtime.ts index 4e7fdcc2d2c..bffff506c3d 100644 --- a/packages/shared/src/types/workspace-runtime.ts +++ b/packages/shared/src/types/workspace-runtime.ts @@ -282,6 +282,13 @@ export interface WorkspaceRuntimeService { providerRef: string | null; ownerAgentId: string | null; startedByRunId: string | null; + startedByAgentId: string | null; + startedByUserId: string | null; + responsibleUserId: string | null; + lastControlledByAgentId: string | null; + lastControlledByUserId: string | null; + lastControlledByRunId: string | null; + lastControlledAt: Date | null; lastUsedAt: Date; startedAt: Date; stoppedAt: Date | null; diff --git a/packages/shared/src/validators/execution-workspace.ts b/packages/shared/src/validators/execution-workspace.ts index 4e3e1148c51..aba61700d5a 100644 --- a/packages/shared/src/validators/execution-workspace.ts +++ b/packages/shared/src/validators/execution-workspace.ts @@ -112,6 +112,13 @@ export const workspaceRuntimeServiceSchema = z.object({ providerRef: z.string().nullable(), ownerAgentId: z.string().uuid().nullable(), startedByRunId: z.string().uuid().nullable(), + startedByAgentId: z.string().uuid().nullable(), + startedByUserId: z.string().nullable(), + responsibleUserId: z.string().nullable(), + lastControlledByAgentId: z.string().uuid().nullable(), + lastControlledByUserId: z.string().nullable(), + lastControlledByRunId: z.string().uuid().nullable(), + lastControlledAt: z.coerce.date().nullable(), lastUsedAt: z.coerce.date(), startedAt: z.coerce.date(), stoppedAt: z.coerce.date().nullable(), diff --git a/server/src/__tests__/heartbeat-responsible-user-invariant.test.ts b/server/src/__tests__/heartbeat-responsible-user-invariant.test.ts index 32a1ed3a886..f80f912e891 100644 --- a/server/src/__tests__/heartbeat-responsible-user-invariant.test.ts +++ b/server/src/__tests__/heartbeat-responsible-user-invariant.test.ts @@ -194,6 +194,79 @@ describeEmbeddedPostgres("heartbeat responsible-user invariant", () => { expect(completed?.responsibleUserId).toBe(triggeringUserId); }); + it("stores requester run and responsible user on queued and skipped wakeup requests", async () => { + const { companyId, agentId } = await seedCompany(); + const requesterRunId = randomUUID(); + const responsibleUserId = `responsible-${randomUUID()}`; + await db.insert(heartbeatRuns).values({ + id: requesterRunId, + companyId, + agentId, + invocationSource: "on_demand", + triggerDetail: "manual", + status: "completed", + responsibleUserId, + }); + + const run = await heartbeat.wakeup(agentId, { + source: "on_demand", + triggerDetail: "manual", + requestedByActorType: "agent", + requestedByActorId: agentId, + requestedByRunId: requesterRunId, + }); + + expect(run).not.toBeNull(); + const queuedWake = await db + .select() + .from(agentWakeupRequests) + .where(eq(agentWakeupRequests.runId, run!.id)) + .then((rows) => rows[0] ?? null); + expect(queuedWake?.requestedByActorType).toBe("agent"); + expect(queuedWake?.requestedByActorId).toBe(agentId); + expect(queuedWake?.requestedByAgentId).toBe(agentId); + expect(queuedWake?.requestedByRunId).toBe(requesterRunId); + expect(queuedWake?.responsibleUserId).toBe(responsibleUserId); + const completed = await waitForRun(db, run!.id); + expect(completed?.responsibleUserId).toBe(responsibleUserId); + + const skippedAgentId = randomUUID(); + await db.insert(agents).values({ + id: skippedAgentId, + companyId, + name: "SleepingAgent", + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: { heartbeat: { wakeOnDemand: false } }, + permissions: {}, + }); + const skippedRequesterRunId = randomUUID(); + const skippedResponsibleUserId = `responsible-${randomUUID()}`; + const skipped = await heartbeat.wakeup(skippedAgentId, { + source: "on_demand", + triggerDetail: "manual", + requestedByActorType: "agent", + requestedByActorId: agentId, + requestedByRunId: skippedRequesterRunId, + responsibleUserId: skippedResponsibleUserId, + }); + + expect(skipped).toBeNull(); + const skippedWake = await db + .select() + .from(agentWakeupRequests) + .where(and(eq(agentWakeupRequests.agentId, skippedAgentId), eq(agentWakeupRequests.status, "skipped"))) + .then((rows) => rows[0] ?? null); + expect(skippedWake?.reason).toBe("heartbeat.wakeOnDemand.disabled"); + expect(skippedWake?.requestedByActorType).toBe("agent"); + expect(skippedWake?.requestedByActorId).toBe(agentId); + expect(skippedWake?.requestedByAgentId).toBe(agentId); + expect(skippedWake?.requestedByRunId).toBe(skippedRequesterRunId); + expect(skippedWake?.responsibleUserId).toBe(skippedResponsibleUserId); + }); + it("falls back to the company default for system-originated runs without an issue", async () => { const { agentId, ownerUserId } = await seedCompany(); const run = await heartbeat.wakeup(agentId, { diff --git a/server/src/__tests__/runtime-audit-attribution.test.ts b/server/src/__tests__/runtime-audit-attribution.test.ts new file mode 100644 index 00000000000..b4b5ec8fb92 --- /dev/null +++ b/server/src/__tests__/runtime-audit-attribution.test.ts @@ -0,0 +1,247 @@ +import { randomUUID } from "node:crypto"; +import type { AdapterRuntimeServiceReport } from "@paperclipai/adapter-utils"; +import { + agents, + companies, + createDb, + environmentLeases, + environments, + heartbeatRuns, + workspaceOperations, + workspaceRuntimeServices, +} from "@paperclipai/db"; +import { eq } from "drizzle-orm"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; +import { environmentService } from "../services/environments.ts"; +import { + persistAdapterManagedRuntimeServices, + stopRuntimeServicesForExecutionWorkspace, + type RealizedExecutionWorkspace, +} from "../services/workspace-runtime.ts"; +import { workspaceOperationService } from "../services/workspace-operations.ts"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +describeEmbeddedPostgres("runtime audit attribution", () => { + let db!: ReturnType; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-runtime-audit-attribution-"); + db = createDb(tempDb.connectionString); + }, 20_000); + + afterEach(async () => { + await db.delete(workspaceOperations); + await db.delete(workspaceRuntimeServices); + await db.delete(environmentLeases); + await db.delete(heartbeatRuns); + await db.delete(agents); + await db.delete(companies); + await db.delete(environments); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }, 60_000); + + async function seedRun() { + const companyId = randomUUID(); + const agentId = randomUUID(); + const runId = randomUUID(); + const responsibleUserId = `responsible-${randomUUID()}`; + + await db.insert(companies).values({ + id: companyId, + name: "Audit attribution", + issuePrefix: `A${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + defaultResponsibleUserId: responsibleUserId, + }); + await db.insert(agents).values({ + id: agentId, + companyId, + name: "CloudOps", + role: "operations", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }); + await db.insert(heartbeatRuns).values({ + id: runId, + companyId, + agentId, + invocationSource: "on_demand", + triggerDetail: "manual", + status: "running", + responsibleUserId, + }); + + return { companyId, agentId, runId, responsibleUserId }; + } + + it("stores actor and responsible-user attribution on workspace operation rows", async () => { + const { companyId, agentId, runId, responsibleUserId } = await seedRun(); + const recorder = workspaceOperationService(db).createRecorder({ + companyId, + heartbeatRunId: runId, + actorAgentId: agentId, + actorRunId: runId, + responsibleUserId, + }); + + const operation = await recorder.recordOperation({ + phase: "workspace_provision", + command: "git status --short", + cwd: "/workspace/audit", + run: async () => ({ + status: "succeeded", + exitCode: 0, + stdout: "clean", + }), + }); + + expect(operation.heartbeatRunId).toBe(runId); + expect(operation.actorAgentId).toBe(agentId); + expect(operation.actorRunId).toBe(runId); + expect(operation.responsibleUserId).toBe(responsibleUserId); + + const row = await db + .select() + .from(workspaceOperations) + .where(eq(workspaceOperations.id, operation.id)) + .then((rows) => rows[0] ?? null); + expect(row?.actorAgentId).toBe(agentId); + expect(row?.actorRunId).toBe(runId); + expect(row?.responsibleUserId).toBe(responsibleUserId); + }); + + it("stores actor and responsible-user attribution on environment test leases", async () => { + const { companyId, agentId, runId, responsibleUserId } = await seedRun(); + const environmentsSvc = environmentService(db); + const environment = await environmentsSvc.ensureLocalEnvironment(companyId); + + const lease = await environmentsSvc.acquireLease({ + companyId, + environmentId: environment.id, + heartbeatRunId: runId, + actorAgentId: agentId, + actorRunId: runId, + responsibleUserId, + provider: "local", + }); + + expect(lease.heartbeatRunId).toBe(runId); + expect(lease.actorAgentId).toBe(agentId); + expect(lease.actorRunId).toBe(runId); + expect(lease.responsibleUserId).toBe(responsibleUserId); + + const row = await db + .select() + .from(environmentLeases) + .where(eq(environmentLeases.id, lease.id)) + .then((rows) => rows[0] ?? null); + expect(row?.actorAgentId).toBe(agentId); + expect(row?.actorRunId).toBe(runId); + expect(row?.responsibleUserId).toBe(responsibleUserId); + }); + + it("stores start and manual control attribution on workspace runtime service rows", async () => { + const { companyId, agentId, runId, responsibleUserId } = await seedRun(); + const workspace: RealizedExecutionWorkspace = { + baseCwd: "/workspace/audit", + source: "task_session", + projectId: null, + workspaceId: null, + repoUrl: null, + repoRef: null, + strategy: "git_worktree", + cwd: "/workspace/audit", + branchName: "audit-runtime-attribution", + worktreePath: "/workspace/audit", + warnings: [], + created: false, + }; + const runtimeServiceId = randomUUID(); + const reports: AdapterRuntimeServiceReport[] = [ + { + id: runtimeServiceId, + serviceName: "web", + status: "running", + lifecycle: "ephemeral", + scopeType: "run", + providerRef: "adapter-web-ref", + }, + ]; + + const refs = await persistAdapterManagedRuntimeServices({ + db, + adapterType: "codex_local", + runId, + agent: { id: agentId, name: "CloudOps", companyId }, + issue: null, + workspace, + responsibleUserId, + reports, + }); + expect(refs).toHaveLength(1); + expect(refs[0]!.startedByAgentId).toBe(agentId); + expect(refs[0]!.startedByRunId).toBe(runId); + expect(refs[0]!.responsibleUserId).toBe(responsibleUserId); + expect(refs[0]!.lastControlledByAgentId).toBe(agentId); + expect(refs[0]!.lastControlledByRunId).toBe(runId); + + const persisted = await db + .select() + .from(workspaceRuntimeServices) + .where(eq(workspaceRuntimeServices.id, refs[0]!.id)) + .then((rows) => rows[0] ?? null); + expect(persisted?.startedByAgentId).toBe(agentId); + expect(persisted?.startedByRunId).toBe(runId); + expect(persisted?.responsibleUserId).toBe(responsibleUserId); + expect(persisted?.lastControlledByAgentId).toBe(agentId); + expect(persisted?.lastControlledByRunId).toBe(runId); + + const controllerRunId = randomUUID(); + const controllerResponsibleUserId = `controller-${randomUUID()}`; + await db.insert(heartbeatRuns).values({ + id: controllerRunId, + companyId, + agentId, + invocationSource: "on_demand", + triggerDetail: "manual-runtime-stop", + status: "running", + responsibleUserId: controllerResponsibleUserId, + }); + + await stopRuntimeServicesForExecutionWorkspace({ + db, + executionWorkspaceId: randomUUID(), + runtimeServiceId: refs[0]!.id, + controlAttribution: { + actorAgentId: agentId, + actorRunId: controllerRunId, + responsibleUserId: controllerResponsibleUserId, + }, + }); + + const stopped = await db + .select() + .from(workspaceRuntimeServices) + .where(eq(workspaceRuntimeServices.id, refs[0]!.id)) + .then((rows) => rows[0] ?? null); + expect(stopped?.status).toBe("stopped"); + expect(stopped?.startedByAgentId).toBe(agentId); + expect(stopped?.startedByRunId).toBe(runId); + expect(stopped?.lastControlledByAgentId).toBe(agentId); + expect(stopped?.lastControlledByRunId).toBe(controllerRunId); + expect(stopped?.responsibleUserId).toBe(controllerResponsibleUserId); + expect(stopped?.lastControlledAt).toBeInstanceOf(Date); + }); +}); diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index cff27b96c04..2965d36f603 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -187,6 +187,11 @@ export function agentRoutes( const environmentRuntime = environmentRuntimeService(db, { pluginWorkerManager: options.pluginWorkerManager, }); + function routeResponsibleUserId(req: Request) { + if (req.actor.type === "agent") return req.actor.onBehalfOfUserId?.trim() || null; + if (req.actor.type === "board") return req.actor.userId?.trim() || null; + return null; + } const heartbeat = heartbeatService(db, { pluginWorkerManager: options.pluginWorkerManager, }); @@ -259,6 +264,10 @@ export function agentRoutes( companyId: string; adapterType: string; environmentId: string | null; + actorAgentId?: string | null; + actorUserId?: string | null; + actorRunId?: string | null; + responsibleUserId?: string | null; }): Promise<{ executionTarget: AdapterExecutionTarget | null; environmentName: string | null; @@ -386,6 +395,10 @@ export function agentRoutes( issueId: null, heartbeatRunId: null, persistedExecutionWorkspace: null, + actorAgentId: input.actorAgentId ?? null, + actorUserId: input.actorUserId ?? null, + actorRunId: input.actorRunId ?? null, + responsibleUserId: input.responsibleUserId ?? null, // Apply the active custom-image template so the Test boots with the // operator's captured sandbox customizations and prepared image state, // matching what real agent runs use. Without this the test would @@ -1776,11 +1789,16 @@ export function agentRoutes( { adapterType: type }, ); + const actor = getActorInfo(req); const { executionTarget, environmentName, fallbackChecks, sandboxIdentityCheck, release } = await resolveAdapterTestExecutionContext({ companyId, adapterType: type, environmentId: requestedEnvironmentId, + actorAgentId: actor.agentId, + actorUserId: actor.actorType === "user" ? actor.actorId : null, + actorRunId: actor.runId, + responsibleUserId: routeResponsibleUserId(req), }); let releaseStatus: "released" | "failed" = "released"; @@ -3440,6 +3458,8 @@ export function agentRoutes( idempotencyKey: req.body.idempotencyKey ?? null, requestedByActorType: req.actor.type === "agent" ? "agent" : "user", requestedByActorId: req.actor.type === "agent" ? req.actor.agentId ?? null : req.actor.userId ?? null, + requestedByRunId: req.actor.runId ?? null, + responsibleUserId: routeResponsibleUserId(req), contextSnapshot: { triggeredBy: req.actor.type, actorId: req.actor.type === "agent" ? req.actor.agentId : req.actor.userId, diff --git a/server/src/routes/execution-workspaces.ts b/server/src/routes/execution-workspaces.ts index a8a59130fbf..4b4de6f5083 100644 --- a/server/src/routes/execution-workspaces.ts +++ b/server/src/routes/execution-workspaces.ts @@ -249,9 +249,23 @@ export function executionWorkspaceRoutes(db: Db, opts: { pluginWorkerManager?: P } const actor = getActorInfo(req); + const controlAttribution = { + actorAgentId: actor.agentId, + actorUserId: actor.actorType === "user" ? actor.actorId : null, + actorRunId: actor.runId, + responsibleUserId: req.actor.type === "agent" + ? req.actor.onBehalfOfUserId?.trim() || null + : actor.actorType === "user" + ? actor.actorId + : null, + }; const recorder = workspaceOperationsSvc.createRecorder({ companyId: existing.companyId, executionWorkspaceId: existing.id, + actorAgentId: controlAttribution.actorAgentId, + actorUserId: controlAttribution.actorUserId, + actorRunId: controlAttribution.actorRunId, + responsibleUserId: controlAttribution.responsibleUserId, }); let runtimeServiceCount = existing.runtimeServices?.length ?? 0; let stdout = ""; @@ -366,6 +380,7 @@ export function executionWorkspaceRoutes(db: Db, opts: { pluginWorkerManager?: P executionWorkspaceId: existing.id, workspaceCwd, runtimeServiceId: selectedRuntimeServiceId, + controlAttribution, }); } @@ -394,6 +409,7 @@ export function executionWorkspaceRoutes(db: Db, opts: { pluginWorkerManager?: P adapterEnv: {}, onLog, serviceIndex: selectedServiceIndex, + controlAttribution, }); runtimeServiceCount = startedServices.length; } else { @@ -551,6 +567,12 @@ export function executionWorkspaceRoutes(db: Db, opts: { pluginWorkerManager?: P }, requestedByActorType: actor.actorType, requestedByActorId: actor.actorId, + requestedByRunId: actor.runId, + responsibleUserId: req.actor.type === "agent" + ? req.actor.onBehalfOfUserId?.trim() || null + : actor.actorType === "user" + ? actor.actorId + : null, contextSnapshot: { issueId: result.restoredSourceIssue.id, taskId: result.restoredSourceIssue.id, @@ -608,6 +630,17 @@ export function executionWorkspaceRoutes(db: Db, opts: { pluginWorkerManager?: P const configForCleanup = readExecutionWorkspaceConfig( ((patch.metadata as Record | null | undefined) ?? (existing.metadata as Record | null)) ?? null, ); + const actor = getActorInfo(req); + const controlAttribution = { + actorAgentId: actor.agentId, + actorUserId: actor.actorType === "user" ? actor.actorId : null, + actorRunId: actor.runId, + responsibleUserId: req.actor.type === "agent" + ? req.actor.onBehalfOfUserId?.trim() || null + : actor.actorType === "user" + ? actor.actorId + : null, + }; if (req.body.status === "archived" && existing.status !== "archived") { const readiness = await svc.getCloseReadiness(existing.id); @@ -663,6 +696,7 @@ export function executionWorkspaceRoutes(db: Db, opts: { pluginWorkerManager?: P db, executionWorkspaceId: existing.id, workspaceCwd: existing.cwd, + controlAttribution, }); const projectWorkspace = existing.projectWorkspaceId ? await db @@ -696,6 +730,10 @@ export function executionWorkspaceRoutes(db: Db, opts: { pluginWorkerManager?: P recorder: workspaceOperationsSvc.createRecorder({ companyId: existing.companyId, executionWorkspaceId: existing.id, + actorAgentId: controlAttribution.actorAgentId, + actorUserId: controlAttribution.actorUserId, + actorRunId: controlAttribution.actorRunId, + responsibleUserId: controlAttribution.responsibleUserId, }), }); cleanupWarnings = cleanupResult.warnings; @@ -730,7 +768,6 @@ export function executionWorkspaceRoutes(db: Db, opts: { pluginWorkerManager?: P } workspace = updatedWorkspace; } - const actor = getActorInfo(req); await logActivity(db, { companyId: existing.companyId, actorType: actor.actorType, diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 932254e8cb8..aef0b9a7f9c 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -1029,6 +1029,10 @@ function projectIssueWakeRequest(row: { status: string; coalescedCount: number; runId: string | null; + requestedByAgentId?: string | null; + requestedByUserId?: string | null; + requestedByRunId?: string | null; + responsibleUserId?: string | null; requestedAt: Date | string; claimedAt: Date | string | null; finishedAt: Date | string | null; @@ -1043,6 +1047,10 @@ function projectIssueWakeRequest(row: { status, coalescedCount: row.coalescedCount, runId: options.includeInternalIds ? row.runId : null, + requestedByAgentId: options.includeInternalIds ? row.requestedByAgentId ?? null : null, + requestedByUserId: options.includeInternalIds ? row.requestedByUserId ?? null : null, + requestedByRunId: options.includeInternalIds ? row.requestedByRunId ?? null : null, + responsibleUserId: options.includeInternalIds ? row.responsibleUserId ?? null : null, requestedAt: dateToIso(row.requestedAt)!, claimedAt: dateToIso(row.claimedAt), finishedAt: dateToIso(row.finishedAt), @@ -1214,6 +1222,10 @@ function buildIssueWakeDiagnosticsResponse(input: { status: string; coalescedCount: number; runId: string | null; + requestedByAgentId?: string | null; + requestedByUserId?: string | null; + requestedByRunId?: string | null; + responsibleUserId?: string | null; requestedAt: Date | string; claimedAt: Date | string | null; finishedAt: Date | string | null; diff --git a/server/src/routes/projects.ts b/server/src/routes/projects.ts index 7d472c36d89..042d29cdc53 100644 --- a/server/src/routes/projects.ts +++ b/server/src/routes/projects.ts @@ -444,7 +444,23 @@ export function projectRoutes(db: Db) { } const actor = getActorInfo(req); - const recorder = workspaceOperations.createRecorder({ companyId: project.companyId }); + const controlAttribution = { + actorAgentId: actor.agentId, + actorUserId: actor.actorType === "user" ? actor.actorId : null, + actorRunId: actor.runId, + responsibleUserId: req.actor.type === "agent" + ? req.actor.onBehalfOfUserId?.trim() || null + : actor.actorType === "user" + ? actor.actorId + : null, + }; + const recorder = workspaceOperations.createRecorder({ + companyId: project.companyId, + actorAgentId: controlAttribution.actorAgentId, + actorUserId: controlAttribution.actorUserId, + actorRunId: controlAttribution.actorRunId, + responsibleUserId: controlAttribution.responsibleUserId, + }); let runtimeServiceCount = workspace.runtimeServices?.length ?? 0; let stdout = ""; let stderr = ""; @@ -518,6 +534,7 @@ export function projectRoutes(db: Db) { db, projectWorkspaceId: workspace.id, runtimeServiceId: selectedRuntimeServiceId, + controlAttribution, }); } @@ -548,6 +565,7 @@ export function projectRoutes(db: Db) { adapterEnv: {}, onLog, serviceIndex: selectedServiceIndex, + controlAttribution, }); runtimeServiceCount = startedServices.length; } else { diff --git a/server/src/services/environment-run-orchestrator.ts b/server/src/services/environment-run-orchestrator.ts index e69d9ef6699..a288ab267a6 100644 --- a/server/src/services/environment-run-orchestrator.ts +++ b/server/src/services/environment-run-orchestrator.ts @@ -203,9 +203,16 @@ export function environmentRunOrchestrator( heartbeatRunId: string; persistedExecutionWorkspace: Pick | null; adapterType: string | null; + responsibleUserId?: string | null; }): Promise { try { - return await environmentRuntime.acquireRunLease(input); + return await environmentRuntime.acquireRunLease({ + ...input, + actorAgentId: input.agentId, + actorUserId: null, + actorRunId: input.heartbeatRunId, + responsibleUserId: input.responsibleUserId ?? null, + }); } catch (err) { throw new EnvironmentRunError( "lease_acquire_failed", @@ -262,6 +269,7 @@ export function environmentRunOrchestrator( heartbeatRunId: string; agentId: string; persistedExecutionWorkspace: Pick | null; + responsibleUserId?: string | null; }): Promise { // Step 1: Resolve environment const environment = await resolveEnvironment({ @@ -279,6 +287,7 @@ export function environmentRunOrchestrator( heartbeatRunId: input.heartbeatRunId, persistedExecutionWorkspace: input.persistedExecutionWorkspace, adapterType: input.adapterType ?? null, + responsibleUserId: input.responsibleUserId ?? null, }); // Step 3: Log lease acquisition activity diff --git a/server/src/services/environment-runtime.ts b/server/src/services/environment-runtime.ts index 151b181a7c6..1c90b6cd7fe 100644 --- a/server/src/services/environment-runtime.ts +++ b/server/src/services/environment-runtime.ts @@ -135,6 +135,10 @@ export interface EnvironmentDriverAcquireInput { * of the base image, matching what real agent runs do. */ applyCustomImageTemplate?: boolean; + actorAgentId?: string | null; + actorUserId?: string | null; + actorRunId?: string | null; + responsibleUserId?: string | null; } export interface EnvironmentDriverReleaseInput { @@ -221,6 +225,10 @@ function toEnvironmentLeaseSnapshot(row: typeof environmentLeases.$inferSelect): executionWorkspaceId: row.executionWorkspaceId ?? null, issueId: row.issueId ?? null, heartbeatRunId: row.heartbeatRunId ?? null, + actorAgentId: row.actorAgentId ?? null, + actorUserId: row.actorUserId ?? null, + actorRunId: row.actorRunId ?? null, + responsibleUserId: row.responsibleUserId ?? null, status: row.status as EnvironmentLease["status"], leasePolicy: row.leasePolicy as EnvironmentLease["leasePolicy"], provider: row.provider ?? null, @@ -513,6 +521,10 @@ function createLocalEnvironmentDriver(db: Db): EnvironmentRuntimeDriver { executionWorkspaceId: input.executionWorkspaceId, issueId: input.issueId, heartbeatRunId: input.heartbeatRunId, + actorAgentId: input.actorAgentId ?? input.agentId ?? null, + actorUserId: input.actorUserId ?? null, + actorRunId: input.actorRunId ?? input.heartbeatRunId ?? null, + responsibleUserId: input.responsibleUserId ?? null, leasePolicy: "ephemeral", provider: "local", metadata: { @@ -567,6 +579,10 @@ function createSshEnvironmentDriver(db: Db): EnvironmentRuntimeDriver { executionWorkspaceId: input.executionWorkspaceId, issueId: input.issueId, heartbeatRunId: input.heartbeatRunId, + actorAgentId: input.actorAgentId ?? input.agentId ?? null, + actorUserId: input.actorUserId ?? null, + actorRunId: input.actorRunId ?? input.heartbeatRunId ?? null, + responsibleUserId: input.responsibleUserId ?? null, leasePolicy: "ephemeral", provider: "ssh", providerLeaseId: `ssh://${parsed.config.username}@${parsed.config.host}:${parsed.config.port}${remoteCwd}`, @@ -935,6 +951,10 @@ function createSandboxEnvironmentDriver( executionWorkspaceId: input.executionWorkspaceId, issueId: input.issueId, heartbeatRunId: input.heartbeatRunId, + actorAgentId: input.actorAgentId ?? input.agentId ?? null, + actorUserId: input.actorUserId ?? null, + actorRunId: input.actorRunId ?? input.heartbeatRunId ?? null, + responsibleUserId: input.responsibleUserId ?? null, leasePolicy: resolvedLeasePolicy, provider: parsed.config.provider, providerLeaseId: acquiredLease.providerLeaseId, @@ -1082,6 +1102,10 @@ function createSandboxEnvironmentDriver( executionWorkspaceId: input.executionWorkspaceId, issueId: input.issueId, heartbeatRunId: input.heartbeatRunId, + actorAgentId: input.actorAgentId ?? input.agentId ?? null, + actorUserId: input.actorUserId ?? null, + actorRunId: input.actorRunId ?? input.heartbeatRunId ?? null, + responsibleUserId: input.responsibleUserId ?? null, leasePolicy: resolvedLeasePolicy, provider: parsed.config.provider, providerLeaseId: providerLease.providerLeaseId, @@ -1509,6 +1533,10 @@ function createPluginEnvironmentDriver( executionWorkspaceId: input.executionWorkspaceId, issueId: input.issueId, heartbeatRunId: input.heartbeatRunId, + actorAgentId: input.actorAgentId ?? input.agentId ?? null, + actorUserId: input.actorUserId ?? null, + actorRunId: input.actorRunId ?? input.heartbeatRunId ?? null, + responsibleUserId: input.responsibleUserId ?? null, leasePolicy: "ephemeral", provider: `plugin:${parsed.config.pluginKey}:${parsed.config.driverKey}`, providerLeaseId: providerLease.providerLeaseId, @@ -1728,6 +1756,10 @@ export function environmentRuntimeService( * lease uses the operator-prepared custom image. */ applyCustomImageTemplate?: boolean; + actorAgentId?: string | null; + actorUserId?: string | null; + actorRunId?: string | null; + responsibleUserId?: string | null; }): Promise { if (input.environment.status !== "active") { throw new Error(`Environment "${input.environment.name}" is not active.`); @@ -1747,6 +1779,10 @@ export function environmentRuntimeService( executionWorkspaceMode: leaseContext.executionWorkspaceMode, adapterType: input.adapterType ?? null, applyCustomImageTemplate: input.applyCustomImageTemplate ?? false, + actorAgentId: input.actorAgentId ?? null, + actorUserId: input.actorUserId ?? null, + actorRunId: input.actorRunId ?? null, + responsibleUserId: input.responsibleUserId ?? null, }); return { diff --git a/server/src/services/environments.ts b/server/src/services/environments.ts index 6487f7a1b9e..fe0c7be93d1 100644 --- a/server/src/services/environments.ts +++ b/server/src/services/environments.ts @@ -156,6 +156,10 @@ function toEnvironmentLease(row: EnvironmentLeaseRow): EnvironmentLease { executionWorkspaceId: row.executionWorkspaceId ?? null, issueId: row.issueId ?? null, heartbeatRunId: row.heartbeatRunId ?? null, + actorAgentId: row.actorAgentId ?? null, + actorUserId: row.actorUserId ?? null, + actorRunId: row.actorRunId ?? null, + responsibleUserId: row.responsibleUserId ?? null, status: readEnum(row.status, ENVIRONMENT_LEASE_STATUSES, "environment lease status") ?? "active", leasePolicy: readEnum(row.leasePolicy, ENVIRONMENT_LEASE_POLICIES, "environment lease policy") ?? "ephemeral", provider: row.provider ?? null, @@ -601,6 +605,10 @@ export function environmentService(db: Db) { executionWorkspaceId?: string | null; issueId?: string | null; heartbeatRunId?: string | null; + actorAgentId?: string | null; + actorUserId?: string | null; + actorRunId?: string | null; + responsibleUserId?: string | null; leasePolicy?: EnvironmentLeasePolicy; provider?: string | null; providerLeaseId?: string | null; @@ -616,6 +624,10 @@ export function environmentService(db: Db) { executionWorkspaceId: input.executionWorkspaceId ?? null, issueId: input.issueId ?? null, heartbeatRunId: input.heartbeatRunId ?? null, + actorAgentId: input.actorAgentId ?? null, + actorUserId: input.actorUserId ?? null, + actorRunId: input.actorRunId ?? null, + responsibleUserId: input.responsibleUserId ?? null, status: "active", leasePolicy: input.leasePolicy ?? "ephemeral", provider: input.provider ?? null, diff --git a/server/src/services/execution-workspaces.ts b/server/src/services/execution-workspaces.ts index d47d81801ee..ace9b6e6ff8 100644 --- a/server/src/services/execution-workspaces.ts +++ b/server/src/services/execution-workspaces.ts @@ -749,6 +749,13 @@ function toRuntimeService(row: WorkspaceRuntimeServiceRow): WorkspaceRuntimeServ providerRef: row.providerRef ?? null, ownerAgentId: row.ownerAgentId ?? null, startedByRunId: row.startedByRunId ?? null, + startedByAgentId: row.startedByAgentId ?? null, + startedByUserId: row.startedByUserId ?? null, + responsibleUserId: row.responsibleUserId ?? null, + lastControlledByAgentId: row.lastControlledByAgentId ?? null, + lastControlledByUserId: row.lastControlledByUserId ?? null, + lastControlledByRunId: row.lastControlledByRunId ?? null, + lastControlledAt: row.lastControlledAt ?? null, lastUsedAt: row.lastUsedAt, startedAt: row.startedAt, stoppedAt: row.stoppedAt ?? null, diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 5cefb7a0ddb..9e3c93ecb15 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -2027,6 +2027,8 @@ interface WakeupOptions { idempotencyKey?: string | null; requestedByActorType?: "user" | "agent" | "system"; requestedByActorId?: string | null; + requestedByRunId?: string | null; + responsibleUserId?: string | null; contextSnapshot?: Record; } @@ -6104,14 +6106,23 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) requestedByActorId?: string | null; source?: WakeupOptions["source"] | null; triggerDetail?: WakeupOptions["triggerDetail"] | null; + requestedByRunId?: string | null; existingRunResponsibleUserId?: string | null; }) { const contextResponsibleUserId = readNonEmptyString(input.contextSnapshot.responsibleUserId); const requestedUserId = input.requestedByActorType === "user" ? readNonEmptyString(input.requestedByActorId) : null; + const requesterRunResponsibleUserId = input.requestedByRunId && isUuidLike(input.requestedByRunId) + ? await db + .select({ responsibleUserId: heartbeatRuns.responsibleUserId }) + .from(heartbeatRuns) + .where(and(eq(heartbeatRuns.id, input.requestedByRunId), eq(heartbeatRuns.companyId, input.companyId))) + .then((rows) => readNonEmptyString(rows[0]?.responsibleUserId)) + : null; if (contextResponsibleUserId) return contextResponsibleUserId; if (input.existingRunResponsibleUserId) return input.existingRunResponsibleUserId; + if (requesterRunResponsibleUserId) return requesterRunResponsibleUserId; if (input.routineEnvContext.responsibleUserId) return input.routineEnvContext.responsibleUserId; if (isManualUserRun(input) && requestedUserId) return requestedUserId; if (input.issueContext?.responsibleUserId) return input.issueContext.responsibleUserId; @@ -12497,6 +12508,10 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) const workspaceOperationRecorder = workspaceOperationsSvc.createRecorder({ companyId: agent.companyId, heartbeatRunId: run.id, + actorAgentId: agent.id, + actorUserId: null, + actorRunId: run.id, + responsibleUserId: run.responsibleUserId ?? null, executionWorkspaceId: workspaceReuseProvisioningPolicy.shouldRestoreExistingWorkspace ? workspaceReuseRequest.requestedExecutionWorkspaceId : null, @@ -12738,6 +12753,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) heartbeatRunId: run.id, agentId: agent.id, persistedExecutionWorkspace, + responsibleUserId: run.responsibleUserId ?? null, }); const selectedEnvironment = acquiredEnvironment.environment; // Defense-in-depth: re-check the actually-acquired environment against the @@ -13268,6 +13284,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) executionWorkspaceId: persistedExecutionWorkspace?.id ?? issueRef?.executionWorkspaceId ?? null, config: hostExecutionWorkspaceConfig, adapterEnv, + responsibleUserId: run.responsibleUserId ?? null, onLog, }); if (runtimeServices.length > 0) { @@ -13635,6 +13652,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) }, issue: issueRef, workspace: executionWorkspace, + responsibleUserId: run.responsibleUserId ?? null, reports: adapterResult.runtimeServices, }) : []; @@ -14672,7 +14690,8 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) requestedByActorId: deferred.requestedByActorId, source: promotedSource, triggerDetail: promotedTriggerDetail, - existingRunResponsibleUserId: run.responsibleUserId, + requestedByRunId: deferred.requestedByRunId, + existingRunResponsibleUserId: deferred.responsibleUserId ?? run.responsibleUserId, }); if (!promotedResponsibleUserId) { throw new HttpError(422, "Unable to resolve responsible user for promoted heartbeat run", { @@ -15120,6 +15139,16 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) const agent = await getAgent(agentId); if (!agent) throw notFound("Agent not found"); + const wakeupRequestAttribution = { + requestedByActorType: opts.requestedByActorType ?? null, + requestedByActorId: opts.requestedByActorId ?? null, + requestedByAgentId: opts.requestedByActorType === "agent" ? opts.requestedByActorId ?? null : null, + requestedByUserId: opts.requestedByActorType === "user" ? opts.requestedByActorId ?? null : null, + requestedByRunId: opts.requestedByRunId ?? null, + responsibleUserId: opts.responsibleUserId + ?? (opts.requestedByActorType === "user" ? opts.requestedByActorId ?? null : null), + }; + const writeSkippedRequest = async ( skipReason: string, patch: Partial = {}, @@ -15132,8 +15161,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) reason: skipReason, payload, status: "skipped", - requestedByActorType: opts.requestedByActorType ?? null, - requestedByActorId: opts.requestedByActorId ?? null, + ...wakeupRequestAttribution, idempotencyKey: opts.idempotencyKey ?? null, finishedAt: new Date(), ...patch, @@ -15267,10 +15295,10 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) contextSnapshot: enrichedContextSnapshot, issueContext: queuedIssueContext, routineEnvContext: queuedRoutineEnvContext, - requestedByActorType: opts.requestedByActorType ?? null, - requestedByActorId: opts.requestedByActorId ?? null, + ...wakeupRequestAttribution, source, triggerDetail, + existingRunResponsibleUserId: opts.responsibleUserId ?? null, }); if (!queuedResponsibleUserId) { throw new HttpError(422, "Unable to resolve responsible user for heartbeat run dispatch", { @@ -15348,8 +15376,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) issueId, agentId, contextSnapshot: enrichedContextSnapshot, - requestedByActorType: opts.requestedByActorType, - requestedByActorId: opts.requestedByActorId, + ...wakeupRequestAttribution, }); if (!treeHoldInteractionWake) { @@ -15427,8 +15454,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) reason: "issue_execution_issue_not_found", payload, status: "skipped", - requestedByActorType: opts.requestedByActorType ?? null, - requestedByActorId: opts.requestedByActorId ?? null, + ...wakeupRequestAttribution, idempotencyKey: opts.idempotencyKey ?? null, finishedAt: new Date(), }); @@ -15451,8 +15477,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) }, }, status: "skipped", - requestedByActorType: opts.requestedByActorType ?? null, - requestedByActorId: opts.requestedByActorId ?? null, + ...wakeupRequestAttribution, idempotencyKey: opts.idempotencyKey ?? null, finishedAt: new Date(), }); @@ -15707,8 +15732,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) unresolvedBlockerIssueIds: dependencyReadiness.unresolvedBlockerIssueIds, }, status: "skipped", - requestedByActorType: opts.requestedByActorType ?? null, - requestedByActorId: opts.requestedByActorId ?? null, + ...wakeupRequestAttribution, idempotencyKey: opts.idempotencyKey ?? null, finishedAt: new Date(), }); @@ -15804,8 +15828,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) }, }, status: "skipped", - requestedByActorType: opts.requestedByActorType ?? null, - requestedByActorId: opts.requestedByActorId ?? null, + ...wakeupRequestAttribution, idempotencyKey: opts.idempotencyKey ?? null, finishedAt: now, }); @@ -15888,8 +15911,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) payload, status: "coalesced", coalescedCount: 1, - requestedByActorType: opts.requestedByActorType ?? null, - requestedByActorId: opts.requestedByActorId ?? null, + ...wakeupRequestAttribution, idempotencyKey: opts.idempotencyKey ?? null, runId: mergedRun.id, finishedAt: new Date(), @@ -15954,8 +15976,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) reason: "issue_execution_deferred", payload: deferredPayload, status: "deferred_issue_execution", - requestedByActorType: opts.requestedByActorType ?? null, - requestedByActorId: opts.requestedByActorId ?? null, + ...wakeupRequestAttribution, idempotencyKey: opts.idempotencyKey ?? null, }); @@ -16060,8 +16081,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) }, }, status: "skipped", - requestedByActorType: opts.requestedByActorType ?? null, - requestedByActorId: opts.requestedByActorId ?? null, + ...wakeupRequestAttribution, idempotencyKey: opts.idempotencyKey ?? null, finishedAt: throttleNow, }); @@ -16088,8 +16108,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) }, }, status: "skipped", - requestedByActorType: opts.requestedByActorType ?? null, - requestedByActorId: opts.requestedByActorId ?? null, + ...wakeupRequestAttribution, idempotencyKey: opts.idempotencyKey ?? null, finishedAt: now, }); @@ -16115,8 +16134,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) reason, payload, status: "queued", - requestedByActorType: opts.requestedByActorType ?? null, - requestedByActorId: opts.requestedByActorId ?? null, + ...wakeupRequestAttribution, idempotencyKey: opts.idempotencyKey ?? null, }) .returning() @@ -16143,6 +16161,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) .update(agentWakeupRequests) .set({ runId: newRun.id, + responsibleUserId: newRun.responsibleUserId, updatedAt: new Date(), }) .where(eq(agentWakeupRequests.id, wakeupRequest.id)); @@ -16230,8 +16249,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) payload, status: "coalesced", coalescedCount: 1, - requestedByActorType: opts.requestedByActorType ?? null, - requestedByActorId: opts.requestedByActorId ?? null, + ...wakeupRequestAttribution, idempotencyKey: opts.idempotencyKey ?? null, runId: mergedRun.id, finishedAt: new Date(), @@ -16262,8 +16280,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) }, }, status: "skipped", - requestedByActorType: opts.requestedByActorType ?? null, - requestedByActorId: opts.requestedByActorId ?? null, + ...wakeupRequestAttribution, idempotencyKey: opts.idempotencyKey ?? null, finishedAt: now, }); @@ -16289,8 +16306,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) reason, payload, status: "queued", - requestedByActorType: opts.requestedByActorType ?? null, - requestedByActorId: opts.requestedByActorId ?? null, + ...wakeupRequestAttribution, idempotencyKey: opts.idempotencyKey ?? null, }) .returning() @@ -16317,6 +16333,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) .update(agentWakeupRequests) .set({ runId: newRun.id, + responsibleUserId: newRun.responsibleUserId, updatedAt: new Date(), }) .where(eq(agentWakeupRequests.id, wakeupRequest.id)); diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 3130570c1cc..ab23b0d617a 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -627,6 +627,10 @@ type IssueWakeDiagnosticsWakeRequestRow = { status: string; coalescedCount: number; runId: string | null; + requestedByAgentId: string | null; + requestedByUserId: string | null; + requestedByRunId: string | null; + responsibleUserId: string | null; requestedAt: Date; claimedAt: Date | null; finishedAt: Date | null; @@ -5245,6 +5249,10 @@ export function issueService(db: Db) { status: agentWakeupRequests.status, coalescedCount: agentWakeupRequests.coalescedCount, runId: agentWakeupRequests.runId, + requestedByAgentId: agentWakeupRequests.requestedByAgentId, + requestedByUserId: agentWakeupRequests.requestedByUserId, + requestedByRunId: agentWakeupRequests.requestedByRunId, + responsibleUserId: agentWakeupRequests.responsibleUserId, requestedAt: agentWakeupRequests.requestedAt, claimedAt: agentWakeupRequests.claimedAt, finishedAt: agentWakeupRequests.finishedAt, @@ -5496,6 +5504,10 @@ export function issueService(db: Db) { wake.status, wake.coalesced_count AS "coalescedCount", wake.run_id AS "runId", + wake.requested_by_agent_id AS "requestedByAgentId", + wake.requested_by_user_id AS "requestedByUserId", + wake.requested_by_run_id AS "requestedByRunId", + wake.responsible_user_id AS "responsibleUserId", wake.requested_at AS "requestedAt", wake.claimed_at AS "claimedAt", wake.finished_at AS "finishedAt", diff --git a/server/src/services/projects.ts b/server/src/services/projects.ts index 7cb38f676d2..d0204b8987b 100644 --- a/server/src/services/projects.ts +++ b/server/src/services/projects.ts @@ -139,6 +139,13 @@ function toRuntimeService(row: WorkspaceRuntimeServiceRow): WorkspaceRuntimeServ providerRef: row.providerRef ?? null, ownerAgentId: row.ownerAgentId ?? null, startedByRunId: row.startedByRunId ?? null, + startedByAgentId: row.startedByAgentId ?? null, + startedByUserId: row.startedByUserId ?? null, + responsibleUserId: row.responsibleUserId ?? null, + lastControlledByAgentId: row.lastControlledByAgentId ?? null, + lastControlledByUserId: row.lastControlledByUserId ?? null, + lastControlledByRunId: row.lastControlledByRunId ?? null, + lastControlledAt: row.lastControlledAt ?? null, lastUsedAt: row.lastUsedAt, startedAt: row.startedAt, stoppedAt: row.stoppedAt ?? null, diff --git a/server/src/services/workspace-operations.ts b/server/src/services/workspace-operations.ts index 1c0c13a824f..6abf29948f4 100644 --- a/server/src/services/workspace-operations.ts +++ b/server/src/services/workspace-operations.ts @@ -16,6 +16,10 @@ function toWorkspaceOperation(row: WorkspaceOperationRow): WorkspaceOperation { companyId: row.companyId, executionWorkspaceId: row.executionWorkspaceId ?? null, heartbeatRunId: row.heartbeatRunId ?? null, + actorAgentId: row.actorAgentId ?? null, + actorUserId: row.actorUserId ?? null, + actorRunId: row.actorRunId ?? null, + responsibleUserId: row.responsibleUserId ?? null, issueId: row.issueId ?? null, phase: row.phase as WorkspaceOperationPhase, command: row.command ?? null, @@ -52,6 +56,13 @@ function combineMetadata( }; } +export interface WorkspaceOperationAttribution { + actorAgentId?: string | null; + actorUserId?: string | null; + actorRunId?: string | null; + responsibleUserId?: string | null; +} + export interface WorkspaceOperationRecorder { attachExecutionWorkspaceId(executionWorkspaceId: string | null): Promise; recordOperation(input: { @@ -91,7 +102,7 @@ export function workspaceOperationService(db: Db) { heartbeatRunId?: string | null; executionWorkspaceId?: string | null; issueId?: string | null; - }): WorkspaceOperationRecorder { + } & WorkspaceOperationAttribution): WorkspaceOperationRecorder { let executionWorkspaceId = input.executionWorkspaceId ?? null; const createdIds: string[] = []; @@ -138,6 +149,10 @@ export function workspaceOperationService(db: Db) { companyId: input.companyId, executionWorkspaceId, heartbeatRunId: input.heartbeatRunId ?? null, + actorAgentId: input.actorAgentId ?? null, + actorUserId: input.actorUserId ?? null, + actorRunId: input.actorRunId ?? null, + responsibleUserId: input.responsibleUserId ?? null, issueId: input.issueId ?? null, phase: recordInput.phase, command: recordInput.command ?? null, diff --git a/server/src/services/workspace-runtime.ts b/server/src/services/workspace-runtime.ts index 44ff8cfd7cf..ba27c834be0 100644 --- a/server/src/services/workspace-runtime.ts +++ b/server/src/services/workspace-runtime.ts @@ -110,6 +110,13 @@ export interface RuntimeServiceRef { providerRef: string | null; ownerAgentId: string | null; startedByRunId: string | null; + startedByAgentId: string | null; + startedByUserId: string | null; + responsibleUserId: string | null; + lastControlledByAgentId: string | null; + lastControlledByUserId: string | null; + lastControlledByRunId: string | null; + lastControlledAt: string | null; lastUsedAt: string; startedAt: string; stoppedAt: string | null; @@ -118,6 +125,13 @@ export interface RuntimeServiceRef { reused: boolean; } +interface RuntimeServiceAttribution { + actorAgentId?: string | null; + actorUserId?: string | null; + actorRunId?: string | null; + responsibleUserId?: string | null; +} + interface RuntimeServiceRecord extends RuntimeServiceRef { db?: Db; child: ChildProcess | null; @@ -371,6 +385,13 @@ function toRuntimeServiceRef(record: RuntimeServiceRecord, overrides?: Partial { + if (!attribution) return {}; + return { + lastControlledByAgentId: attribution.actorAgentId ?? null, + lastControlledByUserId: attribution.actorUserId ?? null, + lastControlledByRunId: attribution.actorRunId ?? null, + lastControlledAt: controlledAt, + ...(attribution.responsibleUserId ? { responsibleUserId: attribution.responsibleUserId } : {}), + }; +} + function sanitizeSlugPart(value: string | null | undefined, fallback: string): string { const raw = (value ?? "").trim().toLowerCase(); const normalized = raw @@ -3646,6 +3706,13 @@ function toPersistedWorkspaceRuntimeService(record: RuntimeServiceRecord): typeo providerRef: record.providerRef, ownerAgentId: record.ownerAgentId, startedByRunId: record.startedByRunId, + startedByAgentId: record.startedByAgentId, + startedByUserId: record.startedByUserId, + responsibleUserId: record.responsibleUserId, + lastControlledByAgentId: record.lastControlledByAgentId, + lastControlledByUserId: record.lastControlledByUserId, + lastControlledByRunId: record.lastControlledByRunId, + lastControlledAt: record.lastControlledAt ? new Date(record.lastControlledAt) : null, lastUsedAt: new Date(record.lastUsedAt), startedAt: new Date(record.startedAt), stoppedAt: record.stoppedAt ? new Date(record.stoppedAt) : null, @@ -3682,6 +3749,13 @@ async function persistRuntimeServiceRecord(db: Db | undefined, record: RuntimeSe providerRef: values.providerRef, ownerAgentId: values.ownerAgentId, startedByRunId: values.startedByRunId, + startedByAgentId: values.startedByAgentId, + startedByUserId: values.startedByUserId, + responsibleUserId: values.responsibleUserId, + lastControlledByAgentId: values.lastControlledByAgentId, + lastControlledByUserId: values.lastControlledByUserId, + lastControlledByRunId: values.lastControlledByRunId, + lastControlledAt: values.lastControlledAt, lastUsedAt: values.lastUsedAt, startedAt: values.startedAt, stoppedAt: values.stoppedAt, @@ -3765,6 +3839,7 @@ export function normalizeAdapterManagedRuntimeServices(input: { issue: ExecutionWorkspaceIssueRef | null; workspace: RealizedExecutionWorkspace; executionWorkspaceId?: string | null; + responsibleUserId?: string | null; reports: AdapterRuntimeServiceReport[]; now?: Date; }): RuntimeServiceRef[] { @@ -3817,6 +3892,13 @@ export function normalizeAdapterManagedRuntimeServices(input: { providerRef: report.providerRef ?? null, ownerAgentId: report.ownerAgentId ?? input.agent.id ?? null, startedByRunId: input.runId, + startedByAgentId: input.agent.id ?? null, + startedByUserId: null, + responsibleUserId: input.responsibleUserId ?? null, + lastControlledByAgentId: input.agent.id ?? null, + lastControlledByUserId: null, + lastControlledByRunId: input.runId, + lastControlledAt: nowIso, lastUsedAt: nowIso, startedAt: nowIso, stoppedAt: status === "running" || status === "starting" ? null : nowIso, @@ -3832,6 +3914,7 @@ type StartLocalRuntimeServiceInput = { runId: string; leaseRunId?: string | null; startedByRunId?: string | null; + startAttribution?: RuntimeServiceAttribution | null; agent: ExecutionWorkspaceAgentRef; issue: ExecutionWorkspaceIssueRef | null; workspace: RealizedExecutionWorkspace; @@ -3847,6 +3930,10 @@ type StartLocalRuntimeServiceInput = { async function spawnLocalRuntimeService(input: StartLocalRuntimeServiceInput): Promise { const leaseRunId = input.leaseRunId === undefined ? input.runId : input.leaseRunId; const startedByRunId = input.startedByRunId === undefined ? input.runId : input.startedByRunId; + const startAttribution = runtimeServiceAttributionFields(input.startAttribution, { + actorAgentId: input.agent.id ?? null, + actorRunId: startedByRunId ?? null, + }); const identity = resolveRuntimeServiceReuseIdentity({ service: input.service, workspace: input.workspace, @@ -3971,6 +4058,13 @@ async function spawnLocalRuntimeService(input: StartLocalRuntimeServiceInput): P providerRef: String(adoptedRecord.pid), ownerAgentId: input.agent.id ?? null, startedByRunId, + startedByAgentId: startAttribution.actorAgentId, + startedByUserId: startAttribution.actorUserId, + responsibleUserId: startAttribution.responsibleUserId, + lastControlledByAgentId: startAttribution.actorAgentId, + lastControlledByUserId: startAttribution.actorUserId, + lastControlledByRunId: startAttribution.actorRunId, + lastControlledAt: new Date().toISOString(), lastUsedAt: new Date().toISOString(), startedAt: adoptedRecord.startedAt, stoppedAt: null, @@ -4060,6 +4154,13 @@ async function spawnLocalRuntimeService(input: StartLocalRuntimeServiceInput): P providerRef: child.pid ? String(child.pid) : null, ownerAgentId: input.agent.id ?? null, startedByRunId, + startedByAgentId: startAttribution.actorAgentId, + startedByUserId: startAttribution.actorUserId, + responsibleUserId: startAttribution.responsibleUserId, + lastControlledByAgentId: startAttribution.actorAgentId, + lastControlledByUserId: startAttribution.actorUserId, + lastControlledByRunId: startAttribution.actorRunId, + lastControlledAt: nowIso, lastUsedAt: nowIso, startedAt: nowIso, stoppedAt: null, @@ -4148,7 +4249,7 @@ function scheduleIdleStop(record: RuntimeServiceRecord) { }, idleSeconds * 1000); } -async function stopRuntimeService(serviceId: string) { +async function stopRuntimeService(serviceId: string, controlAttribution?: RuntimeServiceAttribution | null) { const record = runtimeServicesById.get(serviceId); if (!record) return; clearIdleTimer(record); @@ -4156,6 +4257,7 @@ async function stopRuntimeService(serviceId: string) { record.healthStatus = "unknown"; record.lastUsedAt = new Date().toISOString(); record.stoppedAt = new Date().toISOString(); + applyRuntimeServiceControlAttribution(record, controlAttribution ?? null, record.stoppedAt); runtimeServicesById.delete(serviceId); if (record.reuseKey && runtimeServicesByReuseKey.get(record.reuseKey) === record.id) { runtimeServicesByReuseKey.delete(record.reuseKey); @@ -4181,6 +4283,7 @@ async function stopRuntimeService(serviceId: string) { async function markPersistedRuntimeServicesStoppedForExecutionWorkspace(input: { db: Db; executionWorkspaceId: string; + controlAttribution?: RuntimeServiceAttribution | null; }) { const now = new Date(); await input.db @@ -4190,6 +4293,7 @@ async function markPersistedRuntimeServicesStoppedForExecutionWorkspace(input: { healthStatus: "unknown", stoppedAt: now, lastUsedAt: now, + ...runtimeServiceControlUpdate(input.controlAttribution ?? null, now), updatedAt: now, }) .where( @@ -4323,6 +4427,7 @@ export async function ensureRuntimeServicesForRun(input: { executionWorkspaceId?: string | null; config: Record; adapterEnv: Record; + responsibleUserId?: string | null; onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise; }): Promise { const rawServices = selectRuntimeServiceEntries({ @@ -4361,6 +4466,7 @@ export async function ensureRuntimeServicesForRun(input: { if (existing && existing.status === "running") { existing.leaseRunIds.add(input.runId); existing.lastUsedAt = new Date().toISOString(); + existing.responsibleUserId = existing.responsibleUserId ?? input.responsibleUserId ?? null; existing.stoppedAt = null; clearIdleTimer(existing); void touchLocalServiceRegistryRecord(existing.serviceKey, { @@ -4384,6 +4490,12 @@ export async function ensureRuntimeServicesForRun(input: { adapterEnv: input.adapterEnv, service, onLog: input.onLog, + startAttribution: { + actorAgentId: input.agent.id ?? null, + actorUserId: null, + actorRunId: input.runId, + responsibleUserId: input.responsibleUserId ?? null, + }, reuseKey, scopeType, scopeId, @@ -4413,6 +4525,7 @@ type StartRuntimeServicesForWorkspaceControlInput = { onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise; serviceIndex?: number | null; respectDesiredStates?: boolean; + controlAttribution?: RuntimeServiceAttribution | null; }; type WorkspaceControlStartBatch = { @@ -4457,6 +4570,7 @@ async function startRuntimeServicesForWorkspaceControlUnlocked( const existing = existingId ? runtimeServicesById.get(existingId) : null; if (existing && existing.status === "running") { existing.lastUsedAt = new Date().toISOString(); + applyRuntimeServiceControlAttribution(existing, input.controlAttribution ?? null, existing.lastUsedAt); existing.stoppedAt = null; clearIdleTimer(existing); void touchLocalServiceRegistryRecord(existing.serviceKey, { @@ -4474,6 +4588,7 @@ async function startRuntimeServicesForWorkspaceControlUnlocked( runId: invocationId, leaseRunId: null, startedByRunId: null, + startAttribution: input.controlAttribution ?? null, agent: input.actor, issue: input.issue, workspace: input.workspace, @@ -4624,6 +4739,7 @@ export async function stopRuntimeServicesForExecutionWorkspace(input: { executionWorkspaceId: string; workspaceCwd?: string | null; runtimeServiceId?: string | null; + controlAttribution?: RuntimeServiceAttribution | null; }) { const normalizedWorkspaceCwd = input.workspaceCwd ? path.resolve(input.workspaceCwd) : null; const matchingServiceIds = Array.from(runtimeServicesById.values()) @@ -4640,7 +4756,7 @@ export async function stopRuntimeServicesForExecutionWorkspace(input: { .map((record) => record.id); for (const serviceId of matchingServiceIds) { - await stopRuntimeService(serviceId); + await stopRuntimeService(serviceId, input.controlAttribution ?? null); } if (input.db) { @@ -4653,6 +4769,7 @@ export async function stopRuntimeServicesForExecutionWorkspace(input: { healthStatus: "unknown", stoppedAt: now, lastUsedAt: now, + ...runtimeServiceControlUpdate(input.controlAttribution ?? null, now), updatedAt: now, }) .where(eq(workspaceRuntimeServices.id, input.runtimeServiceId)); @@ -4660,6 +4777,7 @@ export async function stopRuntimeServicesForExecutionWorkspace(input: { await markPersistedRuntimeServicesStoppedForExecutionWorkspace({ db: input.db, executionWorkspaceId: input.executionWorkspaceId, + controlAttribution: input.controlAttribution ?? null, }); } } @@ -4669,6 +4787,7 @@ export async function stopRuntimeServicesForProjectWorkspace(input: { db?: Db; projectWorkspaceId: string; runtimeServiceId?: string | null; + controlAttribution?: RuntimeServiceAttribution | null; }) { const matchingServiceIds = Array.from(runtimeServicesById.values()) .filter((record) => { @@ -4678,7 +4797,7 @@ export async function stopRuntimeServicesForProjectWorkspace(input: { .map((record) => record.id); for (const serviceId of matchingServiceIds) { - await stopRuntimeService(serviceId); + await stopRuntimeService(serviceId, input.controlAttribution ?? null); } if (input.db) { @@ -4690,6 +4809,7 @@ export async function stopRuntimeServicesForProjectWorkspace(input: { healthStatus: "unknown", stoppedAt: now, lastUsedAt: now, + ...runtimeServiceControlUpdate(input.controlAttribution ?? null, now), updatedAt: now, }) .where( @@ -4817,6 +4937,13 @@ export async function reconcilePersistedRuntimeServicesOnStartup(db: Db) { providerRef: String(adoptedRecord.pid), ownerAgentId: row.ownerAgentId ?? null, startedByRunId: row.startedByRunId ?? null, + startedByAgentId: row.startedByAgentId ?? null, + startedByUserId: row.startedByUserId ?? null, + responsibleUserId: row.responsibleUserId ?? null, + lastControlledByAgentId: row.lastControlledByAgentId ?? null, + lastControlledByUserId: row.lastControlledByUserId ?? null, + lastControlledByRunId: row.lastControlledByRunId ?? null, + lastControlledAt: row.lastControlledAt?.toISOString() ?? null, lastUsedAt: new Date().toISOString(), startedAt: row.startedAt.toISOString(), stoppedAt: null, @@ -4985,6 +5112,7 @@ export async function persistAdapterManagedRuntimeServices(input: { issue: ExecutionWorkspaceIssueRef | null; workspace: RealizedExecutionWorkspace; executionWorkspaceId?: string | null; + responsibleUserId?: string | null; reports: AdapterRuntimeServiceReport[]; }) { const refs = normalizeAdapterManagedRuntimeServices(input); @@ -5023,6 +5151,13 @@ export async function persistAdapterManagedRuntimeServices(input: { providerRef: ref.providerRef, ownerAgentId: ref.ownerAgentId, startedByRunId: ref.startedByRunId, + startedByAgentId: ref.startedByAgentId, + startedByUserId: ref.startedByUserId, + responsibleUserId: ref.responsibleUserId, + lastControlledByAgentId: ref.lastControlledByAgentId, + lastControlledByUserId: ref.lastControlledByUserId, + lastControlledByRunId: ref.lastControlledByRunId, + lastControlledAt: ref.lastControlledAt ? new Date(ref.lastControlledAt) : null, lastUsedAt: new Date(ref.lastUsedAt), startedAt, stoppedAt: ref.stoppedAt ? new Date(ref.stoppedAt) : null, @@ -5052,6 +5187,13 @@ export async function persistAdapterManagedRuntimeServices(input: { providerRef: ref.providerRef, ownerAgentId: ref.ownerAgentId, startedByRunId: ref.startedByRunId, + startedByAgentId: ref.startedByAgentId, + startedByUserId: ref.startedByUserId, + responsibleUserId: ref.responsibleUserId, + lastControlledByAgentId: ref.lastControlledByAgentId, + lastControlledByUserId: ref.lastControlledByUserId, + lastControlledByRunId: ref.lastControlledByRunId, + lastControlledAt: ref.lastControlledAt ? new Date(ref.lastControlledAt) : null, lastUsedAt: new Date(ref.lastUsedAt), startedAt, stoppedAt: ref.stoppedAt ? new Date(ref.stoppedAt) : null,