mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 21:07:40 +01:00
Merge origin/master and preserve task workspace behavior across service extractions
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
commit
725614f9c4
25 files changed
+6121
-4744
No files matched your search
+20
-2
@@ -1293,10 +1293,28 @@ cleanup. Public-path characterization tests are in
|
||||
browser fixture also checks the persisted prompt variants and description revision
|
||||
from a real process adapter before completing its task.
|
||||
|
||||
Execution configuration is assembled in
|
||||
`server/src/services/heartbeat/execution-configuration.ts`: workspace and issue
|
||||
overrides, scoped credentials, managed AI identity, and runtime skill delivery.
|
||||
The executor receives credential ownership and failure-redaction updates as soon
|
||||
as they are available, so later setup failures still clean up acquired resources.
|
||||
Busy-account deferral returns explicitly before session/workspace preparation.
|
||||
Public-path tests are in `server/src/__tests__/heartbeat-execution-configuration.test.ts`.
|
||||
|
||||
Session preparation is in `server/src/services/heartbeat/session-preparation.ts`.
|
||||
It selects fresh or resumable sessions from effective config and managed-account
|
||||
identity, then resolves adapter session identity and compaction after workspace
|
||||
preparation. Both phases keep their original ordering in `heartbeat.ts`; they
|
||||
acquire no runtime resources. Public-path characterization tests live in
|
||||
`server/src/__tests__/heartbeat-session-preparation.test.ts`.
|
||||
|
||||
Run-log formatting is in `server/src/services/heartbeat/run-log.ts`. It bounds
|
||||
stored event payloads, redacts and shortens log chunks, and caps stdout/stderr
|
||||
excerpts. `heartbeat.ts` keeps event writes, current-user redaction, and live
|
||||
event delivery. Existing public helpers remain available from `heartbeat.ts`.
|
||||
excerpts. `run-output.ts` owns per-attempt log handles, current-user redaction,
|
||||
output progress writes, live log delivery, and adapter event callbacks. The log
|
||||
store remains service-scoped. Completion reads live excerpt getters so output
|
||||
that arrives during finalization is retained. Existing public helpers and flush
|
||||
interval constants remain available from `heartbeat.ts`.
|
||||
|
||||
Workspace preparation is in `server/src/services/heartbeat/workspaces.ts`. It
|
||||
owns managed checkout materialization, workspace validation and reuse, referenced
|
||||
|
||||
@@ -125,3 +125,182 @@ this slice to `paperclipai/paperclip`; subsequent extractions are not part of
|
||||
this PR.
|
||||
|
||||
Review and final verification: [PR #15823](https://github.com/paperclipai/paperclip/pull/15823).
|
||||
|
||||
## Authorized stacked continuation
|
||||
|
||||
The user requested at least five stacked PRs on 2026-10-10. Continue the four
|
||||
remaining boundaries in order, with each branch based on its predecessor. Keep
|
||||
merge and deployment outside this series. Each PR carries its own current-head
|
||||
verification and review results; do not transfer a prior head's pass to a new one.
|
||||
|
||||
### Slice 2: resource reconciliation and availability
|
||||
|
||||
The lifecycle handler also owns credential replacement, delivery claims and
|
||||
runtime shutdown. Keep those responsibilities in the service. Extract the full
|
||||
non-GitHub availability transaction, inventory reconciliation, repository rename
|
||||
handling, provider ordering, resource listing and audited selection together.
|
||||
The factory takes the database, endpoint lookup, existing credential lease
|
||||
function, generation reader and error text limit. It creates no worker or cache.
|
||||
|
||||
- Source: 38,633 lines / 1,491,422 bytes → 37,720 lines / 1,454,762 bytes.
|
||||
- Destination: 947 lines / 39,584 bytes in `resource-reconciliation.ts`.
|
||||
- Mechanical comparison: 14 declarations and the complete availability
|
||||
transaction are unchanged; all other function bodies remain unchanged.
|
||||
- Baseline: all three complete chat CI shards passed on predecessor `cbba30a526`.
|
||||
- Local baseline and after selections could not start PostgreSQL and skipped
|
||||
the suite. They are not passes. Server typecheck and module boundaries pass.
|
||||
- Require complete chat CI on this slice before starting the next extraction.
|
||||
|
||||
### 2026-10-12 channel-folder reassessment: slice 2
|
||||
|
||||
PR #15823 was merged at the user's request. Rebase this slice onto the merged
|
||||
master, then retain the remaining four PRs for review in dependency order.
|
||||
|
||||
- GitHub payload parsing, repository coordinate repair, webhook recovery,
|
||||
repository pagination and bulk-selection entry points live in
|
||||
`chat-channels/github/resources.ts`.
|
||||
- Telegram migration preparation and conversation migration live in
|
||||
`telegram/resources.ts`. They receive the existing transaction and timestamp;
|
||||
the shared coordinator still upserts the destination between those steps.
|
||||
- Slack fallback labels, Teams conversation IDs and Photon participant parsing
|
||||
live in each provider's `resources.ts`.
|
||||
- Shared inventory replacement, availability settlement and audited selection
|
||||
retain their transaction and credential-lease ownership. Small provider
|
||||
dispatch predicates remain next to the shared operation they select.
|
||||
- All eight moved declarations and three transaction blocks retain their logic.
|
||||
The GitHub provider guard stays in the caller. No factory starts background work.
|
||||
- Existing public-service tests: 15 resource cases and the Telegram migration
|
||||
case passed before; the same 16 cases passed after. The name filter excludes
|
||||
unrelated cases. No live third-party provider calls were made.
|
||||
|
||||
### Slice 3: identity linking
|
||||
|
||||
Extract six identity-link operations into `identity-links.ts`. Keep the live
|
||||
public URL getter. Fetch confirmation runtime authority through a callback that
|
||||
reads the existing service map after awaiting runtime creation. Keep the same
|
||||
transaction locks, token consumption, membership checks and post-commit notice.
|
||||
|
||||
- Source: 37,720 lines / 1,454,762 bytes → 37,295 lines / 1,436,867 bytes.
|
||||
- Destination: 459 lines / 19,970 bytes.
|
||||
- Baseline: complete CI and 5/5 review on slice 2 (`c01f34ad2b`, PR #15834).
|
||||
- Server typecheck, module boundaries and mechanical comparison pass. The only
|
||||
body substitution moves the runtime lookup behind the supplied live callback.
|
||||
All remaining statements are unchanged. Local PostgreSQL remains unavailable;
|
||||
record this slice's complete CI and review in its PR before continuing.
|
||||
|
||||
### 2026-10-12 channel-folder reassessment: slice 3
|
||||
|
||||
Keep identity-token consumption, principal scope, membership checks, link locks
|
||||
and revocation in shared `identity-links.ts`. Move Slack receipt queries,
|
||||
self-service access requests and confirmation-notice staging into
|
||||
`slack/identity-links.ts`. GitHub identity fields live in `github/identity-links.ts`.
|
||||
The shared confirmation transaction still stages the Slack notice before commit
|
||||
and schedules it only after commit. The live runtime and public URL callbacks
|
||||
remain live. The Slack factory holds no cache or worker state.
|
||||
|
||||
Four existing public-service cases passed before and after, including the full
|
||||
Slack connect journey: discovery, preview, outsider access request, duplicate
|
||||
request, member confirmation, provider notice, consumed-token rejection and
|
||||
endpoint isolation. Receipt queries, access-request transaction and notice
|
||||
payload retain their statements with explicit parameter substitutions.
|
||||
|
||||
### Slice 4: publication replacement and supersession
|
||||
|
||||
Move 13 functions into `publication-replacement.ts`. The module selects the exact
|
||||
provider message a publication can replace and suppresses obsolete progress. It
|
||||
uses the database, endpoint lookup and retained-source authorization callback.
|
||||
Transport execution, publication claims and receipt reactions remain coordinated
|
||||
by the service. No state or registration moves in this slice.
|
||||
|
||||
- Source: 37,295 lines / 1,436,867 bytes → 36,393 lines / 1,400,636 bytes.
|
||||
- Destination: 963 lines / 38,724 bytes.
|
||||
- Baseline: all 51 checks and 5/5 review passed on slice 3 (`581dd656c1`, #15836).
|
||||
- All 13 bodies and every remaining statement match the predecessor exactly.
|
||||
Server typecheck, module boundaries and whitespace checks pass.
|
||||
- Existing CI fixtures cover exact outbound ownership, interleaved control
|
||||
messages, consumed progress, interactions, committed failure replacement and
|
||||
issue-to-run lock order. Local database capacity remains blocked.
|
||||
|
||||
### 2026-10-12 channel-folder reassessment: slice 4
|
||||
|
||||
Publication replacement is shared ownership logic: exact outbound links, run and
|
||||
wake scope, consumed progress messages, interaction prompts and retained-source
|
||||
authorization. Keep those queries together. The close-message provider allowlist
|
||||
is shared dispatch metadata, not four separate implementations.
|
||||
|
||||
Move the iMessage queued/working suppression policy and explanation to
|
||||
`imessage-photon/publications.ts`. Preserve when endpoint lookup occurs and leave
|
||||
all ownership queries unchanged. Baseline: 49 public-service publication and
|
||||
Photon cases passed, including close-owned progress, interleaved Slack control,
|
||||
Telegram confirmation and FIFO replacement. The same 49 cases passed after. Server typecheck and module boundaries pass.
|
||||
|
||||
|
||||
### Slice 5: failed chat run retry coordination
|
||||
|
||||
Extract source validation, native failure evidence, retry preparation, claims,
|
||||
wake authorization and retry notices into `failed-run-retry.ts`. Keep the
|
||||
in-flight promise map, notice cursor, error class and two authority registrations
|
||||
inside the factory so each service retains its own lifetime. Keep the live
|
||||
shutdown and heartbeat callbacks. The service still owns cleanup order and waits
|
||||
for the module's pending retries before unregistering authorities.
|
||||
|
||||
- Source: 36,393 lines / 1,400,636 bytes → 34,606 lines / 1,336,443 bytes.
|
||||
Net reduction: 1,787 lines / 64,193 bytes.
|
||||
- Destination: 1,936 lines / 68,329 bytes.
|
||||
- Baseline: all 52 checks and 5/5 review passed on slice 4 (`166ea04ac6`, #15844).
|
||||
- Added three tests through the existing public service before the move. All
|
||||
three passed before and after: duplicate calls share a promise, shutdown
|
||||
during a pending read prevents a new claim and waits for completion, rejected
|
||||
work permits a later attempt, and old-service cleanup preserves replacement
|
||||
authority. These tests use controlled reads and do not require PostgreSQL.
|
||||
- All 21 moved declarations and every remaining statement match the predecessor
|
||||
after restoring the documented live callbacks and pending-task accessor.
|
||||
Server typecheck, module boundaries and whitespace checks pass.
|
||||
- Existing CI database fixtures cover corrected/edited sources, lifecycle epochs,
|
||||
native recovery evidence, recreated services and concurrent retries. Local
|
||||
full database execution remains blocked by the host's shared-memory limit.
|
||||
Current-head full CI, broad local gates and review results belong in the PR.
|
||||
|
||||
## Reassessment after five slices
|
||||
|
||||
The service falls from 39,155 lines / 1,509,090 bytes at the rebased baseline to
|
||||
34,606 lines / 1,336,443 bytes: 4,549 lines and 172,647 bytes removed (11.6% of
|
||||
source lines). The five modules total 4,857 lines / 185,635 bytes, including their
|
||||
imports and typed boundaries. This is useful separation, not completion of the
|
||||
larger decomposition.
|
||||
|
||||
The service function still spans 32,254 lines. Its 244 direct nested functions
|
||||
occupy 31,525 lines; 48 other statements occupy 435 lines of state, wiring,
|
||||
registration and public API. The remaining lines are whitespace, comments and
|
||||
syntax. The largest functions are `processMessage` (2,166), `handleAction`
|
||||
(1,074), `configureWithCredentialLease` (868), `processSelectedPublication` (729),
|
||||
`handleModalSubmit` (708), `handleWebhook` (685), `processLifecycleDelivery` (657)
|
||||
and `applyProviderLifecycleEffect` (593).
|
||||
|
||||
The best next planning candidate is inbound message admission and persistence,
|
||||
starting from `processMessage` and its conversation/drain ownership. Define the
|
||||
transaction, live-message retention and scheduling contracts before moving it;
|
||||
do not move that entire function and claim its complexity is resolved. Action
|
||||
resolution and endpoint configuration are later candidates. Keep runtime lease
|
||||
acquisition, partial startup and shutdown together until their ownership contract
|
||||
is explicit. This batch ends with five stacked PRs; merging is separate work.
|
||||
|
||||
### 2026-10-12 channel-folder reassessment: slice 5
|
||||
|
||||
Keep retry admission, complete source-batch authorization, ancestor checks,
|
||||
in-flight deduplication, authority registration and shutdown coordination shared.
|
||||
References to native runner providers describe execution recovery, not chat
|
||||
channel implementations, and remain with that shared proof.
|
||||
|
||||
Move the exact Teams file-transfer continuation proof into
|
||||
`microsoft-teams/file-transfer-source.ts`. It receives the caller's transaction,
|
||||
explicit source identifiers and original denial factory; it returns only the
|
||||
verified publication ID. The shared caller still validates the committed result,
|
||||
all original inputs and other uncertain publications. All provider proof guards,
|
||||
join predicates, row locks and attempt checks retain their statements.
|
||||
|
||||
Baseline: 59 retry cases plus six negative Teams authority cases passed. These
|
||||
include service lifetime checks and transfer ambiguity without duplicate uploads.
|
||||
The same 65 cases passed after. Server typecheck and module boundaries pass.
|
||||
The remaining retry coordinator
|
||||
has 1,825 lines; the Teams proof module has 149 lines.
|
||||
@@ -0,0 +1,83 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { chatActions, type Db } from "@paperclipai/db";
|
||||
import { chatChannelService, type ChatChannelService, type ChatChannelServiceOptions } from "../services/chat-channels.js";
|
||||
import { authorizeCommittedChatResponse, CommittedChatResponseAuthorizationError } from "../services/durable-chat-wakeup.js";
|
||||
|
||||
const services = new Set<ChatChannelService>();
|
||||
afterEach(async () => {
|
||||
for (const service of services) await service.shutdown();
|
||||
services.clear();
|
||||
});
|
||||
|
||||
function createService(db: Db) {
|
||||
const wakeup = vi.fn().mockResolvedValue(undefined);
|
||||
const runtimeShutdown = vi.fn().mockResolvedValue(undefined);
|
||||
const service = chatChannelService(db, {
|
||||
heartbeat: { wakeup },
|
||||
runtime: { shutdown: runtimeShutdown } as unknown as ChatChannelServiceOptions["runtime"],
|
||||
});
|
||||
services.add(service);
|
||||
return { service, wakeup, runtimeShutdown };
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((done) => { resolve = done; });
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
describe("failed chat retry service lifetime", () => {
|
||||
it("shares an in-flight retry and observes shutdown after its candidate read", async () => {
|
||||
const candidate = deferred<unknown[]>();
|
||||
const reads = vi.fn((table: unknown) => table === chatActions ? candidate.promise : Promise.resolve([]));
|
||||
const update = vi.fn(() => { throw new Error("A shutdown retry must not claim work"); });
|
||||
const db = {
|
||||
select: () => ({ from: (table: unknown) => ({ where: () => reads(table) }) }),
|
||||
update,
|
||||
} as unknown as Db;
|
||||
const { service, wakeup, runtimeShutdown } = createService(db);
|
||||
const first = service.processFailedChatRunRetry("retry-action");
|
||||
const second = service.processFailedChatRunRetry("retry-action");
|
||||
expect(second).toBe(first);
|
||||
expect(reads).toHaveBeenCalledTimes(1);
|
||||
const shutdown = service.shutdown();
|
||||
expect(runtimeShutdown).not.toHaveBeenCalled();
|
||||
candidate.resolve([{
|
||||
id: "retry-action", companyId: "company", status: "issued",
|
||||
payload: { issueId: "issue" }, result: {}, updatedAt: new Date(0),
|
||||
}]);
|
||||
await expect(first).resolves.toMatchObject({ actionId: "retry-action", status: "queued" });
|
||||
await shutdown;
|
||||
services.delete(service);
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
expect(wakeup).not.toHaveBeenCalled();
|
||||
expect(runtimeShutdown).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("releases a rejected in-flight retry so a later attempt can read again", async () => {
|
||||
const where = vi.fn().mockResolvedValue([]);
|
||||
const db = { select: () => ({ from: () => ({ where }) }) } as unknown as Db;
|
||||
const { service } = createService(db);
|
||||
const first = service.processFailedChatRunRetry("missing-action");
|
||||
await expect(first).rejects.toMatchObject({ status: 404 });
|
||||
const second = service.processFailedChatRunRetry("missing-action");
|
||||
expect(second).not.toBe(first);
|
||||
await expect(second).rejects.toMatchObject({ status: 404 });
|
||||
expect(where).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not unregister a replacement service's committed-response authority", async () => {
|
||||
const db = {} as Db;
|
||||
const first = createService(db).service;
|
||||
const replacement = createService(db).service;
|
||||
await first.shutdown();
|
||||
services.delete(first);
|
||||
const readFailure = new Error("replacement authority reached current source reads");
|
||||
const tx = { select: () => { throw readFailure; } } as unknown as Db;
|
||||
const input = { companyId: "company", issueId: "issue", agentId: "agent", runId: "run", resultId: "result" };
|
||||
await expect(authorizeCommittedChatResponse(db, tx, input)).rejects.toBe(readFailure);
|
||||
await replacement.shutdown();
|
||||
services.delete(replacement);
|
||||
await expect(authorizeCommittedChatResponse(db, tx, input)).rejects.toBeInstanceOf(CommittedChatResponseAuthorizationError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { access, readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { agents, companies, companyMemberships, createDb, heartbeatRunEvents, heartbeatRuns, issues, projects } from "@paperclipai/db";
|
||||
import { registerServerAdapter, unregisterServerAdapter, type AdapterExecutionContext } from "../adapters/index.js";
|
||||
import { HttpError } from "../errors.js";
|
||||
import { heartbeatService } from "../services/heartbeat.js";
|
||||
import { aiConnectionService } from "../services/ai-connections.js";
|
||||
import * as aiRuntime from "../services/ai-connection-runtime.js";
|
||||
import * as sentry from "../sentry.js";
|
||||
import { waitForPendingRunFailureReports } from "../services/run-failure-report.js";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
|
||||
const skillFailures = vi.hoisted(() => new Map<string, string>());
|
||||
vi.mock("../services/company-skills.js", async importOriginal => {
|
||||
const actual = await importOriginal<typeof import("../services/company-skills.js")>();
|
||||
return { ...actual, companySkillService: (...args: Parameters<typeof actual.companySkillService>) => {
|
||||
const service = actual.companySkillService(...args);
|
||||
return { ...service, listRuntimeSkillEntries: async (...input: Parameters<typeof service.listRuntimeSkillEntries>) => {
|
||||
const failure = skillFailures.get(input[0]);
|
||||
if (failure) throw new Error(failure);
|
||||
return service.listRuntimeSkillEntries(...input);
|
||||
} };
|
||||
} };
|
||||
});
|
||||
|
||||
const support = await getEmbeddedPostgresTestSupport();
|
||||
const describePostgres = support.supported ? describe : describe.skip;
|
||||
if (!support.supported) console.warn(`Skipping execution configuration tests: ${support.reason}`);
|
||||
|
||||
describePostgres("heartbeat execution configuration through adapter dispatch", () => {
|
||||
let temporary: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
let db: ReturnType<typeof createDb>;
|
||||
let heartbeat: ReturnType<typeof heartbeatService>;
|
||||
const captured = new Map<string, AdapterExecutionContext>();
|
||||
const inspect = new Map<string, (input: AdapterExecutionContext) => Promise<void>>();
|
||||
const adapterType = "codex_local";
|
||||
|
||||
beforeAll(async () => {
|
||||
temporary = await startEmbeddedPostgresTestDatabase("heartbeat-execution-config-");
|
||||
db = createDb(temporary.connectionString);
|
||||
heartbeat = heartbeatService(db);
|
||||
// Exercise real preparation for a managed-capable adapter without calling a provider.
|
||||
registerServerAdapter({
|
||||
type: adapterType, supportsLocalAgentJwt: false,
|
||||
execute: async input => {
|
||||
captured.set(input.runId, input);
|
||||
await db.update(issues).set({ status: "in_review" }).where(eq(issues.id, String(input.context.issueId)));
|
||||
await inspect.get(input.agent.id)?.(input);
|
||||
return { exitCode: 0, signal: null, timedOut: false, resultJson: {} };
|
||||
},
|
||||
testEnvironment: async () => ({ adapterType, status: "pass", checks: [], testedAt: new Date().toISOString() }),
|
||||
});
|
||||
}, 30_000);
|
||||
afterEach(() => { vi.restoreAllMocks(); skillFailures.clear(); });
|
||||
afterAll(async () => {
|
||||
await heartbeat?.drainActiveRunExecutions();
|
||||
unregisterServerAdapter(adapterType);
|
||||
await db?.$client.end({ timeout: 0 });
|
||||
await temporary?.cleanup();
|
||||
});
|
||||
|
||||
async function seed(managed = false) {
|
||||
const companyId = randomUUID(), agentId = randomUUID(), issueId = randomUUID(), projectId = randomUUID();
|
||||
const userId = `configuration-user-${companyId}`;
|
||||
const credential = `fixture-api-key-${companyId}`;
|
||||
await db.insert(companies).values({ id: companyId, name: "Execution configuration fixture", issuePrefix: `C${companyId.slice(0, 6)}` });
|
||||
await db.insert(companyMemberships).values({ companyId, principalId: userId, principalType: "user", status: "active", membershipRole: "member" });
|
||||
const account = managed ? await aiConnectionService(db).save(companyId, userId, {
|
||||
provider: "openai", method: "api_key", ownership: "personal", name: "Fixture account", apiKey: credential, agentIds: [], allAgents: true,
|
||||
}, credential) : null;
|
||||
await db.insert(projects).values({ id: projectId, companyId, name: "Config project", env: { SHARED_VALUE: "project", PROJECT_ONLY: "project-only" } });
|
||||
await db.insert(agents).values({ id: agentId, companyId, name: "Configuration reader", adapterType, status: "idle",
|
||||
adapterConfig: { model: "agent-model", env: { SHARED_VALUE: "agent", AGENT_ONLY: "agent-only", ...(managed ? { OPENAI_API_KEY: "stale-agent-key" } : {}) } },
|
||||
runtimeConfig: account ? { aiConnection: { provider: "openai", method: "api_key", mode: "delegated", connectionId: account.connectionId, grantId: account.grantId } } : {},
|
||||
});
|
||||
await db.insert(issues).values({ id: issueId, companyId, projectId, title: "Configuration fixture", status: "todo", assigneeAgentId: agentId, responsibleUserId: userId, issueNumber: 1, assigneeAdapterOverrides: { adapterConfig: { model: "issue-model" } } });
|
||||
return { companyId, agentId, issueId, credential };
|
||||
}
|
||||
|
||||
async function execute(f: Awaited<ReturnType<typeof seed>>) {
|
||||
const run = await heartbeat.wakeup(f.agentId, { source: "on_demand", triggerDetail: "manual", reason: "manual", payload: { issueId: f.issueId }, contextSnapshot: {
|
||||
issueId: f.issueId, paperclipSecrets: { manifest: [{ secretKey: "untrusted-wake-value" }] },
|
||||
paperclipWake: { connectorSkillInstructions: "untrusted wake instructions" },
|
||||
} });
|
||||
expect(run).not.toBeNull();
|
||||
await heartbeat.drainActiveRunExecutions();
|
||||
const [finished] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, run!.id));
|
||||
expect(finished).toBeDefined();
|
||||
return { finished, input: captured.get(run!.id) };
|
||||
}
|
||||
|
||||
it("applies project environment and issue overrides and replaces caller-supplied runtime fields", async () => {
|
||||
const f = await seed();
|
||||
const { finished, input } = await execute(f);
|
||||
expect(finished.status, finished.error ?? undefined).toBe("succeeded");
|
||||
expect(input?.config).toMatchObject({ model: "issue-model", env: { SHARED_VALUE: "project", AGENT_ONLY: "agent-only", PROJECT_ONLY: "project-only" } });
|
||||
expect(input?.context.paperclipSecrets).toBeUndefined();
|
||||
expect(JSON.stringify(input?.context.paperclipWake)).not.toContain("untrusted wake instructions");
|
||||
});
|
||||
|
||||
it("delivers the selected credential in an isolated home and removes it after completion", async () => {
|
||||
const f = await seed(true);
|
||||
let providerHome: string | undefined;
|
||||
inspect.set(f.agentId, async input => {
|
||||
const env = input.config.env as Record<string, string>;
|
||||
providerHome = env.CODEX_HOME;
|
||||
expect(env.OPENAI_API_KEY).toBe(f.credential);
|
||||
expect(env.CODEX_API_KEY).toBe(f.credential);
|
||||
expect(JSON.parse(await readFile(path.join(providerHome, "auth.json"), "utf8"))).toEqual({ OPENAI_API_KEY: f.credential });
|
||||
await input.onMeta?.({ adapterType, command: "fixture", env, prompt: "fixture" });
|
||||
});
|
||||
const { finished, input } = await execute(f);
|
||||
expect(finished.status, finished.error ?? undefined).toBe("succeeded");
|
||||
expect(input?.context.aiConnection).toMatchObject({ accountName: "Fixture account", provider: "openai" });
|
||||
expect(providerHome).toEqual(expect.any(String));
|
||||
await expect(access(providerHome!)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
const events = await db.select().from(heartbeatRunEvents).where(eq(heartbeatRunEvents.runId, finished.id));
|
||||
expect(events.some(event => event.eventType === "adapter.invoke")).toBe(true);
|
||||
expect(JSON.stringify(events)).not.toContain(f.credential);
|
||||
});
|
||||
|
||||
it("cleans acquired credentials and redacts diagnostics when later skill preparation fails", async () => {
|
||||
const f = await seed(true);
|
||||
skillFailures.set(f.companyId, `Invalid fixture skill configuration: ${f.credential}`);
|
||||
const capturedFailure = vi.spyOn(sentry, "captureRunFailure").mockImplementation(() => undefined);
|
||||
const prepare = aiRuntime.prepareManagedAiRuntime;
|
||||
let acquiredHome: string | undefined;
|
||||
vi.spyOn(aiRuntime, "prepareManagedAiRuntime").mockImplementation(async (...args) => {
|
||||
const runtime = await prepare(...args);
|
||||
acquiredHome = runtime.home;
|
||||
await access(acquiredHome!);
|
||||
return runtime;
|
||||
});
|
||||
const { finished, input } = await execute(f);
|
||||
expect(finished.status).toBe("failed");
|
||||
expect(input).toBeUndefined();
|
||||
expect(acquiredHome).toEqual(expect.any(String));
|
||||
await expect(access(acquiredHome!)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
expect(finished.error).toContain("Invalid fixture skill configuration");
|
||||
// The diagnostic reporter redacts runtime values before the external sink.
|
||||
// The legacy local run.error field does not apply this value-based redaction.
|
||||
await waitForPendingRunFailureReports();
|
||||
expect(capturedFailure).toHaveBeenCalled();
|
||||
expect(JSON.stringify(capturedFailure.mock.calls)).toContain("Invalid fixture skill configuration");
|
||||
expect(JSON.stringify(capturedFailure.mock.calls)).not.toContain(f.credential);
|
||||
});
|
||||
|
||||
it("defers a busy account without dispatch and preserves its scheduled retry", async () => {
|
||||
const f = await seed(true);
|
||||
vi.spyOn(aiRuntime, "prepareManagedAiRuntime").mockRejectedValueOnce(new HttpError(422, "AI credentials are being updated. Retry shortly.", { code: "ai_connection_busy" }));
|
||||
const { finished, input } = await execute(f);
|
||||
expect(input).toBeUndefined();
|
||||
expect(finished).toMatchObject({ status: "cancelled", errorCode: "ai_connection_busy", resultJson: { executionRecovery: { kind: "ai_connection_wait", providerWorkStarted: false } }, contextSnapshot: { aiConnectionBusyDeferredWhileAssignee: true } });
|
||||
const runs = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.agentId, f.agentId));
|
||||
expect(runs.filter(run => run.id !== finished.id)).toEqual([expect.objectContaining({ status: "scheduled_retry", scheduledRetryReason: "ai_connection_busy" })]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { agents, companies, createDb, heartbeatRunEvents, heartbeatRuns, issues } from "@paperclipai/db";
|
||||
import { registerServerAdapter, unregisterServerAdapter, type AdapterExecutionContext } from "../adapters/index.js";
|
||||
import { heartbeatService } from "../services/heartbeat.js";
|
||||
import { getRunLogStore } from "../services/run-log-store.js";
|
||||
import { subscribeCompanyLiveEvents } from "../services/live-events.js";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
|
||||
const support = await getEmbeddedPostgresTestSupport();
|
||||
const describePostgres = support.supported ? describe : describe.skip;
|
||||
if (!support.supported) console.warn(`Skipping run output tests: ${support.reason}`);
|
||||
|
||||
describePostgres("heartbeat output through adapter dispatch and finalization", () => {
|
||||
const adapterType = "run_output_test";
|
||||
let temporary: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
let db: ReturnType<typeof createDb>;
|
||||
let heartbeat: ReturnType<typeof heartbeatService>;
|
||||
const executeByAgent = new Map<string, (input: AdapterExecutionContext) => Promise<void>>();
|
||||
|
||||
beforeAll(async () => {
|
||||
temporary = await startEmbeddedPostgresTestDatabase("heartbeat-run-output-");
|
||||
db = createDb(temporary.connectionString);
|
||||
heartbeat = heartbeatService(db);
|
||||
registerServerAdapter({
|
||||
type: adapterType, supportsLocalAgentJwt: false,
|
||||
execute: async (input) => {
|
||||
await db.update(issues).set({ status: "in_review" }).where(eq(issues.id, String(input.context.issueId)));
|
||||
await executeByAgent.get(input.agent.id)!(input);
|
||||
return { exitCode: 0, signal: null, timedOut: false, resultJson: {} };
|
||||
},
|
||||
testEnvironment: async () => ({ adapterType, status: "pass", checks: [], testedAt: new Date().toISOString() }),
|
||||
});
|
||||
}, 30_000);
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
afterAll(async () => {
|
||||
await heartbeat?.drainActiveRunExecutions();
|
||||
unregisterServerAdapter(adapterType);
|
||||
await db?.$client.end({ timeout: 0 });
|
||||
await temporary?.cleanup();
|
||||
});
|
||||
|
||||
async function seed(execute: (input: AdapterExecutionContext) => Promise<void>) {
|
||||
const companyId = randomUUID(), agentId = randomUUID(), issueId = randomUUID();
|
||||
await db.insert(companies).values({ id: companyId, name: "Output fixture", issuePrefix: `O${companyId.slice(0, 6)}` });
|
||||
await db.insert(agents).values({ id: agentId, companyId, name: "Output writer", adapterType, status: "idle" });
|
||||
await db.insert(issues).values({ id: issueId, companyId, title: "Output fixture", status: "todo", assigneeAgentId: agentId, responsibleUserId: "output-user", issueNumber: 1 });
|
||||
executeByAgent.set(agentId, execute);
|
||||
return { companyId, agentId, issueId };
|
||||
}
|
||||
async function execute(f: Awaited<ReturnType<typeof seed>>) {
|
||||
const run = await heartbeat.wakeup(f.agentId, { source: "on_demand", triggerDetail: "manual", reason: "manual", payload: { issueId: f.issueId }, contextSnapshot: { issueId: f.issueId } });
|
||||
expect(run).not.toBeNull();
|
||||
await heartbeat.drainActiveRunExecutions();
|
||||
const finished = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, run!.id)).then(rows => rows[0]);
|
||||
expect(finished).toBeDefined();
|
||||
const log = await heartbeat.readLog(run!.id);
|
||||
const entries = log.content.trim().split("\n").filter(Boolean).map(line => JSON.parse(line));
|
||||
return { finished, entries };
|
||||
}
|
||||
|
||||
it("redacts stored and live chunks, preserves sequence, and flushes the last output at completion", async () => {
|
||||
const secret = "output-test-bearer-canary";
|
||||
const f = await seed(async input => {
|
||||
await input.onLog("stdout", `first: Authorization: Bearer ${secret}\n`);
|
||||
await input.onLog("stdout", `${"x".repeat(10_000)}live-tail\n`);
|
||||
await input.onLog("stderr", "last stderr\n");
|
||||
await input.onMeta?.({ adapterType, command: "fixture", env: { PAPERCLIP_API_KEY: secret }, prompt: "fixture prompt" });
|
||||
await input.onEvent?.({ eventType: " fixture.progress ", stream: "system", level: "info", message: "fixture event", payload: { step: 1 } });
|
||||
await input.onEvent?.({ eventType: " ", stream: "system", level: "info", message: "ignored blank event" });
|
||||
});
|
||||
const live: Record<string, unknown>[] = [];
|
||||
const unsubscribe = subscribeCompanyLiveEvents(f.companyId, event => {
|
||||
if (event.type === "heartbeat.run.log") live.push(event.payload);
|
||||
});
|
||||
try {
|
||||
const { finished, entries } = await execute(f);
|
||||
expect(finished.status, finished.error ?? undefined).toBe("succeeded");
|
||||
const ownLive = live.filter(event => event.runId === finished.id);
|
||||
expect(entries.map(entry => entry.seq)).toEqual(entries.map((_, i) => i + 1));
|
||||
expect(ownLive.map(event => event.seq)).toEqual(entries.map(entry => entry.seq));
|
||||
expect(JSON.stringify(entries)).not.toContain(secret);
|
||||
expect(JSON.stringify(ownLive)).not.toContain(secret);
|
||||
expect(finished.stdoutExcerpt).not.toContain(secret);
|
||||
expect(finished.stderrExcerpt).toContain("last stderr");
|
||||
expect(ownLive.find(event => String(event.chunk).endsWith("live-tail\n"))).toMatchObject({ truncated: true });
|
||||
expect(finished.lastOutputSeq).toBe(entries.at(-1).seq);
|
||||
expect(finished.lastOutputStream).toBe("stderr");
|
||||
expect(finished.lastOutputBytes).toBe(finished.logBytes);
|
||||
expect(finished.lastOutputAt).toEqual(new Date(entries.at(-1).ts));
|
||||
const events = await db.select().from(heartbeatRunEvents).where(eq(heartbeatRunEvents.runId, finished.id));
|
||||
expect(JSON.stringify(events)).not.toContain(secret);
|
||||
expect(events.find(event => event.eventType === "adapter.invoke")?.payload).toMatchObject({ env: { PAPERCLIP_API_KEY: "***REDACTED***" } });
|
||||
expect(events.find(event => event.eventType === "fixture.progress")).toMatchObject({ message: "fixture event", payload: { step: 1 } });
|
||||
expect(events.some(event => event.message === "ignored blank event")).toBe(false);
|
||||
} finally { unsubscribe(); }
|
||||
});
|
||||
|
||||
it.each([false, true])("retains output arriving during finalization (adapter throws: %s)", async fail => {
|
||||
let onLog: AdapterExecutionContext["onLog"];
|
||||
const f = await seed(async input => {
|
||||
onLog = input.onLog;
|
||||
await onLog("stdout", "before finalization\n");
|
||||
if (fail) throw new Error("invalid fixture configuration");
|
||||
});
|
||||
const store = getRunLogStore();
|
||||
const finalize = store.finalize.bind(store);
|
||||
vi.spyOn(store, "finalize").mockImplementationOnce(async handle => {
|
||||
await onLog("stdout", "during finalization\n");
|
||||
return finalize(handle);
|
||||
});
|
||||
const { finished, entries } = await execute(f);
|
||||
expect(finished.status, finished.error ?? undefined).toBe(fail ? "failed" : "succeeded");
|
||||
expect(finished.stdoutExcerpt).toContain("before finalization");
|
||||
expect(finished.stdoutExcerpt).toContain("during finalization");
|
||||
expect(entries.at(-1).chunk).toBe("during finalization\n");
|
||||
expect(finished.lastOutputSeq).toBe(entries.at(-1).seq);
|
||||
expect(finished.lastOutputBytes).toBe(finished.logBytes);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { agents, agentTaskSessions, authUsers, companies, companyMemberships, createDb, heartbeatRuns, issues } from "@paperclipai/db";
|
||||
import { registerServerAdapter, unregisterServerAdapter, type AdapterExecutionContext } from "../adapters/index.js";
|
||||
import { heartbeatService } from "../services/heartbeat.js";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
|
||||
const support = await getEmbeddedPostgresTestSupport();
|
||||
const describePostgres = support.supported ? describe : describe.skip;
|
||||
if (!support.supported) console.warn(`Skipping session preparation tests: ${support.reason}`);
|
||||
|
||||
describePostgres("heartbeat session preparation through adapter dispatch", () => {
|
||||
const adapterType = "session_preparation_test";
|
||||
const captured = new Map<string, AdapterExecutionContext>();
|
||||
let temporary: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
let db: ReturnType<typeof createDb>;
|
||||
let heartbeat: ReturnType<typeof heartbeatService>;
|
||||
|
||||
beforeAll(async () => {
|
||||
temporary = await startEmbeddedPostgresTestDatabase("heartbeat-session-preparation-");
|
||||
db = createDb(temporary.connectionString);
|
||||
heartbeat = heartbeatService(db);
|
||||
registerServerAdapter({
|
||||
type: adapterType,
|
||||
supportsLocalAgentJwt: false,
|
||||
execute: async (input) => {
|
||||
captured.set(input.runId, input);
|
||||
// Settle the fixture's work so no automatic unfinished-task retry
|
||||
// competes with the next explicit session-continuation wake.
|
||||
await db.update(issues).set({ status: "in_review" }).where(eq(issues.id, String(input.context.issueId)));
|
||||
return { exitCode: 0, signal: null, timedOut: false, sessionId: "saved-session", sessionParams: { sessionId: "saved-session", adapterState: "preserved" }, resultJson: {} };
|
||||
},
|
||||
testEnvironment: async () => ({ adapterType, status: "pass", checks: [], testedAt: new Date().toISOString() }),
|
||||
});
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await heartbeat?.drainActiveRunExecutions();
|
||||
unregisterServerAdapter(adapterType);
|
||||
await db?.$client.end({ timeout: 0 });
|
||||
await temporary?.cleanup();
|
||||
});
|
||||
|
||||
async function seed(runtimeConfig: Record<string, unknown> = {}) {
|
||||
const companyId = randomUUID(), agentId = randomUUID(), issueId = randomUUID(), userId = randomUUID();
|
||||
await db.insert(companies).values({ id: companyId, name: "Session fixture", issuePrefix: `S${companyId.slice(0, 6)}` });
|
||||
await db.insert(authUsers).values({ id: userId, name: "Session owner", email: `${userId}@example.test`, createdAt: new Date(), updatedAt: new Date() });
|
||||
await db.insert(companyMemberships).values({ companyId, principalId: userId, principalType: "user", status: "active", membershipRole: "member" });
|
||||
await db.insert(agents).values({ id: agentId, companyId, name: "Session reader", adapterType, status: "idle", adapterConfig: { model: "model-a" }, runtimeConfig });
|
||||
await db.insert(issues).values({ id: issueId, companyId, title: "Session fixture", status: "todo", assigneeAgentId: agentId, responsibleUserId: userId, issueNumber: 1 });
|
||||
return { companyId, agentId, issueId };
|
||||
}
|
||||
|
||||
async function execute(f: Awaited<ReturnType<typeof seed>>, context: Record<string, unknown> = {}) {
|
||||
const run = await heartbeat.wakeup(f.agentId, {
|
||||
source: "on_demand", triggerDetail: "manual", reason: "manual",
|
||||
payload: { issueId: f.issueId }, contextSnapshot: { issueId: f.issueId, ...context },
|
||||
});
|
||||
expect(run).not.toBeNull();
|
||||
await heartbeat.drainActiveRunExecutions();
|
||||
const finished = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, run!.id)).then(rows => rows[0]);
|
||||
expect(finished?.status, finished?.error ?? undefined).toBe("succeeded");
|
||||
expect(captured.has(run!.id)).toBe(true);
|
||||
return { input: captured.get(run!.id)!, finished };
|
||||
}
|
||||
|
||||
it("reuses unchanged task sessions and strips host freshness metadata before dispatch", async () => {
|
||||
const f = await seed();
|
||||
const first = await execute(f);
|
||||
expect(first.input.runtime.sessionId).toBeNull();
|
||||
const stored = await db.select().from(agentTaskSessions).where(eq(agentTaskSessions.agentId, f.agentId)).then(rows => rows[0]);
|
||||
expect(stored?.sessionParamsJson).toHaveProperty("__paperclipConfigFingerprint");
|
||||
const resumed = await execute(f, { paperclipSessionHandoffMarkdown: "stale handoff", paperclipSessionRotationReason: "stale reason", paperclipPreviousSessionId: "stale session" });
|
||||
expect(resumed.input.runtime).toMatchObject({ sessionId: "saved-session", sessionDisplayId: "saved-session", taskKey: f.issueId });
|
||||
expect(resumed.input.runtime.sessionParams).toMatchObject({ sessionId: "saved-session", adapterState: "preserved" });
|
||||
expect(resumed.input.runtime.sessionParams).not.toHaveProperty("__paperclipConfigFingerprint");
|
||||
expect(resumed.input.context.paperclipSessionHandoffMarkdown).toBeUndefined();
|
||||
expect(resumed.input.context.paperclipSessionRotationReason).toBeUndefined();
|
||||
expect(resumed.input.context.paperclipPreviousSessionId).toBeUndefined();
|
||||
expect(resumed.finished.sessionIdBefore).toBe("saved-session");
|
||||
});
|
||||
|
||||
it("starts fresh when effective model configuration changes and persists the reason", async () => {
|
||||
const f = await seed();
|
||||
await execute(f);
|
||||
await db.update(agents).set({ adapterConfig: { model: "model-b" } }).where(eq(agents.id, f.agentId));
|
||||
const next = await execute(f);
|
||||
expect(next.input.runtime.sessionId).toBeNull();
|
||||
expect(next.input.runtime.sessionParams).toBeNull();
|
||||
expect(next.finished.sessionIdBefore).toBeNull();
|
||||
expect(next.finished.resultJson).toMatchObject({ configFreshness: { session: { reset: true, taskSessionAvailable: true, taskSessionReused: false } } });
|
||||
});
|
||||
|
||||
it("refreshes tools with a fresh session when the selected adapter cannot refresh on resume", async () => {
|
||||
const f = await seed();
|
||||
await execute(f);
|
||||
const next = await execute(f, { refreshTools: true });
|
||||
expect(next.input.context.forceFreshSession).toBe(true);
|
||||
expect(next.input.runtime.sessionId).toBeNull();
|
||||
expect(next.finished.resultJson).toMatchObject({ configFreshness: { session: { resetReasons: ["forceFreshSession was requested"] } } });
|
||||
});
|
||||
|
||||
it("rotates a session at the configured run threshold and supplies a handoff", async () => {
|
||||
const f = await seed({ sessionCompaction: { enabled: true, maxSessionRuns: 1 } });
|
||||
await execute(f);
|
||||
await execute(f);
|
||||
const next = await execute(f);
|
||||
expect(next.input.runtime.sessionId).toBeNull();
|
||||
expect(next.input.runtime.sessionParams).toBeNull();
|
||||
expect(next.input.context.paperclipPreviousSessionId).toBe("saved-session");
|
||||
expect(next.input.context.paperclipSessionRotationReason).toBe("session exceeded 1 runs");
|
||||
expect(next.input.context.paperclipSessionHandoffMarkdown).toEqual(expect.any(String));
|
||||
expect(next.finished.sessionIdBefore).toBeNull();
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,7 @@
|
||||
import type { chatExternalPrincipals } from "@paperclipai/db";
|
||||
|
||||
export function githubIdentityFields(
|
||||
principal: Pick<typeof chatExternalPrincipals.$inferSelect, "externalId" | "handle">,
|
||||
) {
|
||||
return { githubUserId: principal.externalId, githubLogin: principal.handle };
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
import { and, asc, eq, sql } from "drizzle-orm";
|
||||
import { chatConversations, chatEndpointResources, chatEndpoints, type Db } from "@paperclipai/db";
|
||||
import { conflict, notFound } from "../../../errors.js";
|
||||
import type { CredentialMutationLeaseGuard } from "../../chat-credential-mutation-lease.js";
|
||||
import type { ChatProviderResourceInventoryItem } from "../../chat-provider-inventory.js";
|
||||
type EndpointRow = typeof chatEndpoints.$inferSelect;
|
||||
type DbTransaction = Parameters<Parameters<Db["transaction"]>[0]>[0];
|
||||
|
||||
export function githubRepositoryInventoryItemFromPayload(
|
||||
payload: unknown,
|
||||
): ChatProviderResourceInventoryItem | null {
|
||||
if (!payload || typeof payload !== "object" || Array.isArray(payload))
|
||||
return null;
|
||||
const repository = (payload as { repository?: unknown }).repository;
|
||||
if (
|
||||
!repository ||
|
||||
typeof repository !== "object" ||
|
||||
Array.isArray(repository)
|
||||
)
|
||||
return null;
|
||||
const value = repository as {
|
||||
id?: unknown;
|
||||
full_name?: unknown;
|
||||
html_url?: unknown;
|
||||
name?: unknown;
|
||||
owner?: { id?: unknown; login?: unknown };
|
||||
private?: unknown;
|
||||
};
|
||||
const repositoryId =
|
||||
typeof value.id === "number" && Number.isSafeInteger(value.id)
|
||||
? String(value.id)
|
||||
: typeof value.id === "string" && /^\d+$/.test(value.id)
|
||||
? value.id
|
||||
: null;
|
||||
const owner =
|
||||
typeof value.owner?.login === "string" ? value.owner.login.trim() : "";
|
||||
const name = typeof value.name === "string" ? value.name.trim() : "";
|
||||
const fullName =
|
||||
typeof value.full_name === "string" && value.full_name.includes("/")
|
||||
? value.full_name.trim()
|
||||
: owner && name
|
||||
? `${owner}/${name}`
|
||||
: "";
|
||||
if (!repositoryId || !fullName) return null;
|
||||
const ownerId =
|
||||
typeof value.owner?.id === "number" && Number.isSafeInteger(value.owner.id)
|
||||
? String(value.owner.id)
|
||||
: typeof value.owner?.id === "string" && /^\d+$/.test(value.owner.id)
|
||||
? value.owner.id
|
||||
: undefined;
|
||||
return {
|
||||
providerResourceId: fullName.toLowerCase(),
|
||||
parentProviderResourceId: ownerId,
|
||||
type: "repository",
|
||||
label: fullName,
|
||||
providerUrl:
|
||||
typeof value.html_url === "string" && value.html_url.length > 0
|
||||
? value.html_url
|
||||
: `https://github.com/${fullName}`,
|
||||
metadata: {
|
||||
providerRepositoryId: repositoryId,
|
||||
fullName,
|
||||
...(owner ? { owner } : {}),
|
||||
private: value.private === true,
|
||||
source: "provider_webhook",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function githubRepositoryStableId(
|
||||
item: ChatProviderResourceInventoryItem,
|
||||
): string | null {
|
||||
const value = item.metadata?.providerRepositoryId;
|
||||
return typeof value === "string" && /^\d+$/.test(value) ? value : null;
|
||||
}
|
||||
|
||||
export async function reconcileGitHubRepositoryCoordinates(tx: DbTransaction, endpoint: EndpointRow, item: ChatProviderResourceInventoryItem) {
|
||||
const stableGitHubId =
|
||||
githubRepositoryStableId(item);
|
||||
if (stableGitHubId) {
|
||||
await tx.execute(
|
||||
sql`select pg_advisory_xact_lock(hashtextextended(${`chat-github-repository:${endpoint.id}:${stableGitHubId}`}, 0))`,
|
||||
);
|
||||
const stableResource = await tx
|
||||
.select()
|
||||
.from(chatEndpointResources)
|
||||
.where(
|
||||
and(
|
||||
eq(chatEndpointResources.companyId, endpoint.companyId),
|
||||
eq(chatEndpointResources.endpointId, endpoint.id),
|
||||
eq(chatEndpointResources.type, "repository"),
|
||||
sql`${chatEndpointResources.metadata}->>'providerRepositoryId' = ${stableGitHubId}`,
|
||||
),
|
||||
)
|
||||
.for("update")
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (
|
||||
stableResource &&
|
||||
stableResource.providerResourceId !== item.providerResourceId
|
||||
) {
|
||||
const coordinateResource = await tx
|
||||
.select()
|
||||
.from(chatEndpointResources)
|
||||
.where(
|
||||
and(
|
||||
eq(chatEndpointResources.companyId, endpoint.companyId),
|
||||
eq(chatEndpointResources.endpointId, endpoint.id),
|
||||
eq(chatEndpointResources.type, "repository"),
|
||||
eq(
|
||||
chatEndpointResources.providerResourceId,
|
||||
item.providerResourceId,
|
||||
),
|
||||
),
|
||||
)
|
||||
.for("update")
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (coordinateResource && coordinateResource.id !== stableResource.id) {
|
||||
const coordinateConversation = await tx
|
||||
.select({ id: chatConversations.id })
|
||||
.from(chatConversations)
|
||||
.where(
|
||||
and(
|
||||
eq(chatConversations.endpointId, endpoint.id),
|
||||
eq(chatConversations.resourceId, coordinateResource.id),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (coordinateConversation) {
|
||||
throw conflict(
|
||||
"GitHub repository identity is already bound to two coordinates",
|
||||
{ code: "chat_github_repository_identity_conflict" },
|
||||
);
|
||||
}
|
||||
await tx
|
||||
.delete(chatEndpointResources)
|
||||
.where(eq(chatEndpointResources.id, coordinateResource.id));
|
||||
}
|
||||
|
||||
const oldRepository = stableResource.providerResourceId;
|
||||
const oldThreadPrefix = `github:${oldRepository}`;
|
||||
const newThreadPrefix = `github:${item.providerResourceId}`;
|
||||
const oldProviderUrl = `https://github.com/${oldRepository}`;
|
||||
const newProviderUrl = `https://github.com/${item.providerResourceId}`;
|
||||
await tx
|
||||
.update(chatEndpointResources)
|
||||
.set({
|
||||
providerResourceId: item.providerResourceId,
|
||||
parentProviderResourceId: item.parentProviderResourceId ?? null,
|
||||
label: item.label,
|
||||
providerUrl: item.providerUrl ?? null,
|
||||
availability: "available",
|
||||
enabled:
|
||||
stableResource.enabled || coordinateResource?.enabled === true,
|
||||
metadata: item.metadata ?? {},
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(chatEndpointResources.id, stableResource.id));
|
||||
await tx
|
||||
.update(chatConversations)
|
||||
.set({
|
||||
externalConversationId: sql<string>`case
|
||||
when lower(${chatConversations.externalConversationId}) = ${oldRepository} then ${item.providerResourceId}
|
||||
when lower(${chatConversations.externalConversationId}) = ${oldThreadPrefix} then ${newThreadPrefix}
|
||||
else ${chatConversations.externalConversationId}
|
||||
end`,
|
||||
externalThreadId: sql<string>`case
|
||||
when lower(${chatConversations.externalThreadId}) = ${oldThreadPrefix} then ${newThreadPrefix}
|
||||
when lower(${chatConversations.externalThreadId}) like ${`${oldThreadPrefix}:%`}
|
||||
then ${newThreadPrefix} || substring(
|
||||
${chatConversations.externalThreadId}
|
||||
from char_length(${oldThreadPrefix}) + 1
|
||||
)
|
||||
else ${chatConversations.externalThreadId}
|
||||
end`,
|
||||
externalLabel: item.label,
|
||||
providerUrl: sql<string | null>`case
|
||||
when lower(${chatConversations.providerUrl}) like ${`${oldProviderUrl}/%`}
|
||||
then ${newProviderUrl} || substring(
|
||||
${chatConversations.providerUrl}
|
||||
from char_length(${oldProviderUrl}) + 1
|
||||
)
|
||||
else ${chatConversations.providerUrl}
|
||||
end`,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(chatConversations.companyId, endpoint.companyId),
|
||||
eq(chatConversations.endpointId, endpoint.id),
|
||||
eq(chatConversations.resourceId, stableResource.id),
|
||||
),
|
||||
);
|
||||
return { id: stableResource.id };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
type GitHubResourceOptions = {
|
||||
endpointRecord: (endpointId: string) => Promise<{ endpoint: EndpointRow } | null>;
|
||||
upsertProviderResourceRow: (tx: DbTransaction, endpoint: EndpointRow, item: ChatProviderResourceInventoryItem) => Promise<{ id: string } | null>;
|
||||
updateResourceSelection: (endpointId: string, updates: Array<{ id: string; enabled: boolean }>, actorUserId?: string | null, options?: { allGitHubRepositoriesEnabled?: boolean }) => Promise<void>;
|
||||
};
|
||||
|
||||
export function createGitHubResources(db: Db, options: GitHubResourceOptions) {
|
||||
const { endpointRecord, upsertProviderResourceRow, updateResourceSelection } = options;
|
||||
async function reconcileGitHubWebhookRepository(
|
||||
endpoint: EndpointRow,
|
||||
payload: unknown,
|
||||
credentialLease: CredentialMutationLeaseGuard,
|
||||
) {
|
||||
const item = githubRepositoryInventoryItemFromPayload(payload);
|
||||
if (!item) return;
|
||||
await db.transaction(async (tx) => {
|
||||
await credentialLease.assertOwned(tx);
|
||||
const resource = await upsertProviderResourceRow(tx, endpoint, item);
|
||||
if (!resource) return;
|
||||
// A correctly signed repository callback is current provider proof. Keep
|
||||
// Paperclip's enabled choice, and reopen only conversations previously
|
||||
// quarantined for provider availability loss.
|
||||
await tx
|
||||
.update(chatConversations)
|
||||
.set({ state: "active", updatedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(chatConversations.companyId, endpoint.companyId),
|
||||
eq(chatConversations.endpointId, endpoint.id),
|
||||
eq(chatConversations.resourceId, resource.id),
|
||||
eq(chatConversations.state, "unavailable"),
|
||||
),
|
||||
);
|
||||
await credentialLease.assertOwned(tx);
|
||||
});
|
||||
}
|
||||
|
||||
async function listGitHubRepositories(
|
||||
endpointId: string,
|
||||
query: { limit: number; offset: number; search: string },
|
||||
) {
|
||||
const record = await endpointRecord(endpointId);
|
||||
if (!record || record.endpoint.provider !== "github") throw notFound("GitHub bot not found");
|
||||
const scope = and(
|
||||
eq(chatEndpointResources.companyId, record.endpoint.companyId),
|
||||
eq(chatEndpointResources.endpointId, endpointId),
|
||||
eq(chatEndpointResources.type, "repository"),
|
||||
);
|
||||
// Literal, case-insensitive substring search (%, _ and backslashes are
|
||||
// ordinary characters). Counts always describe the entire connection.
|
||||
const search = query.search
|
||||
? sql`strpos(lower(${chatEndpointResources.label}), lower(${query.search})) > 0`
|
||||
: undefined;
|
||||
const [rows, [counts]] = await Promise.all([
|
||||
db.select().from(chatEndpointResources).where(and(scope, search))
|
||||
.orderBy(asc(chatEndpointResources.label), asc(chatEndpointResources.id))
|
||||
.offset(query.offset).limit(query.limit + 1),
|
||||
db.select({
|
||||
totalCount: sql<number>`count(*)::integer`,
|
||||
enabledCount: sql<number>`count(*) filter (where ${chatEndpointResources.enabled})::integer`,
|
||||
availableCount: sql<number>`count(*) filter (where ${chatEndpointResources.availability} = 'available')::integer`,
|
||||
}).from(chatEndpointResources).where(scope),
|
||||
]);
|
||||
return {
|
||||
items: rows.slice(0, query.limit),
|
||||
nextOffset: rows.length > query.limit ? query.offset + query.limit : null,
|
||||
totalCount: counts!.totalCount,
|
||||
enabledCount: counts!.enabledCount,
|
||||
availableCount: counts!.availableCount,
|
||||
};
|
||||
}
|
||||
|
||||
async function toggleAllGitHubRepositories(endpointId: string, enabled: boolean, actorUserId?: string | null) {
|
||||
await updateResourceSelection(endpointId, [], actorUserId, { allGitHubRepositoriesEnabled: enabled });
|
||||
return { success: true as const };
|
||||
}
|
||||
return { reconcileGitHubWebhookRepository, listGitHubRepositories, toggleAllGitHubRepositories };
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
import { githubIdentityFields } from "./github/identity-links.js";
|
||||
import { createSlackIdentityLinks, type SlackIdentityEffectStager } from "./slack/identity-links.js";
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { and, asc, eq, gt, inArray, ne, sql } from "drizzle-orm";
|
||||
|
||||
import type { Db } from "@paperclipai/db";
|
||||
|
||||
import { authUsers, chatEndpoints, chatExternalPrincipals, chatIdentityLinks, companies, companyMemberships } from "@paperclipai/db";
|
||||
|
||||
import { conflict, forbidden, notFound, unprocessable } from "../../errors.js";
|
||||
|
||||
type EndpointRow = typeof chatEndpoints.$inferSelect;
|
||||
type ConfirmationRuntime = { credentialFingerprint: string; generation: number };
|
||||
|
||||
type ChatIdentityLinkOptions = {
|
||||
endpointRecord: (endpointId: string) => Promise<{ endpoint: EndpointRow } | null>;
|
||||
getPublicBaseUrl: () => string | null;
|
||||
confirmationRuntimeFor: (endpoint: EndpointRow) => Promise<ConfirmationRuntime | undefined>;
|
||||
stageProviderEffect: SlackIdentityEffectStager;
|
||||
scheduleProviderEffect: (actionId: string) => void;
|
||||
};
|
||||
|
||||
export function createChatIdentityLinks(db: Db, options: ChatIdentityLinkOptions) {
|
||||
const { endpointRecord, getPublicBaseUrl, confirmationRuntimeFor, stageProviderEffect, scheduleProviderEffect } = options;
|
||||
const slackIdentityLinks = createSlackIdentityLinks(db, { previewIdentityLink, stageProviderEffect });
|
||||
|
||||
async function listPrincipals(endpointId: string) {
|
||||
const record = await endpointRecord(endpointId);
|
||||
if (!record) throw notFound("Chat endpoint not found");
|
||||
const principals = await db
|
||||
.select()
|
||||
.from(chatExternalPrincipals)
|
||||
.where(
|
||||
and(
|
||||
eq(chatExternalPrincipals.companyId, record.endpoint.companyId),
|
||||
eq(chatExternalPrincipals.provider, record.endpoint.provider),
|
||||
eq(
|
||||
chatExternalPrincipals.providerAccountId,
|
||||
record.endpoint.providerAccountId ?? "unknown",
|
||||
),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(chatExternalPrincipals.displayName));
|
||||
const links = await db
|
||||
.select()
|
||||
.from(chatIdentityLinks)
|
||||
.where(eq(chatIdentityLinks.endpointId, endpointId));
|
||||
const connects = record.endpoint.provider === "slack" ? await slackIdentityLinks.listConnects(record.endpoint) : [];
|
||||
const connectByPrincipal = new Map(connects.map((row) => [row.principalId, new Date(row.lastConnectAt).toISOString()]));
|
||||
const userIds = links.flatMap((link) =>
|
||||
link.paperclipUserId ? [link.paperclipUserId] : [],
|
||||
);
|
||||
const users = userIds.length
|
||||
? await db
|
||||
.select({
|
||||
id: authUsers.id,
|
||||
name: authUsers.name,
|
||||
email: authUsers.email,
|
||||
})
|
||||
.from(authUsers)
|
||||
.where(inArray(authUsers.id, userIds))
|
||||
: [];
|
||||
const linkByPrincipal = new Map(
|
||||
links.map((link) => [link.principalId, link]),
|
||||
);
|
||||
const userById = new Map(users.map((user) => [user.id, user]));
|
||||
return principals.map((principal) => {
|
||||
const link = linkByPrincipal.get(principal.id);
|
||||
const user = link?.paperclipUserId
|
||||
? userById.get(link.paperclipUserId)
|
||||
: null;
|
||||
return {
|
||||
id: link?.id ?? principal.id,
|
||||
principalId: principal.id,
|
||||
...(record.endpoint.provider === "github" ? githubIdentityFields(principal) : {}),
|
||||
externalLabel:
|
||||
principal.displayName ?? principal.handle ?? principal.externalId,
|
||||
externalDetail: principal.handle
|
||||
? `@${principal.handle.replace(/^@/, "")}`
|
||||
: principal.externalId,
|
||||
paperclipUserId: link?.paperclipUserId ?? null,
|
||||
paperclipUserLabel: user?.name ?? user?.email ?? null,
|
||||
status: link?.status ?? "pending",
|
||||
lastConnectAt: connectByPrincipal.get(principal.id) ?? null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function createLinkIntent(
|
||||
endpointId: string,
|
||||
principalId: string,
|
||||
expiresInSeconds: number,
|
||||
) {
|
||||
const record = await endpointRecord(endpointId);
|
||||
if (!record) throw notFound("Chat endpoint not found");
|
||||
const principal = await db
|
||||
.select()
|
||||
.from(chatExternalPrincipals)
|
||||
.where(
|
||||
and(
|
||||
eq(chatExternalPrincipals.companyId, record.endpoint.companyId),
|
||||
eq(chatExternalPrincipals.id, principalId),
|
||||
eq(chatExternalPrincipals.provider, record.endpoint.provider),
|
||||
eq(
|
||||
chatExternalPrincipals.providerAccountId,
|
||||
record.endpoint.providerAccountId ?? "",
|
||||
),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!principal) throw notFound("External identity not found");
|
||||
if (principal.isBot || principal.kind !== "user") {
|
||||
throw unprocessable("Only a human external identity can be linked");
|
||||
}
|
||||
const token = randomBytes(32).toString("base64url");
|
||||
const tokenHash = createHash("sha256").update(token).digest("hex");
|
||||
const expiresAt = new Date(Date.now() + expiresInSeconds * 1000);
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.execute(
|
||||
sql`select pg_advisory_xact_lock(hashtextextended(${`chat-identity:${record.endpoint.companyId}:${principalId}`}, 0))`,
|
||||
);
|
||||
const existingLink = await tx
|
||||
.select({ status: chatIdentityLinks.status })
|
||||
.from(chatIdentityLinks)
|
||||
.where(
|
||||
and(
|
||||
eq(chatIdentityLinks.endpointId, endpointId),
|
||||
eq(chatIdentityLinks.principalId, principalId),
|
||||
),
|
||||
)
|
||||
.for("update")
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (existingLink?.status === "linked") {
|
||||
throw conflict("This external identity is already linked", {
|
||||
code: "chat_identity_already_linked",
|
||||
});
|
||||
}
|
||||
await tx
|
||||
.insert(chatIdentityLinks)
|
||||
.values({
|
||||
companyId: record.endpoint.companyId,
|
||||
endpointId,
|
||||
principalId,
|
||||
status: "pending",
|
||||
confirmationTokenHash: tokenHash,
|
||||
expiresAt,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [chatIdentityLinks.endpointId, chatIdentityLinks.principalId],
|
||||
set: {
|
||||
paperclipUserId: null,
|
||||
status: "pending",
|
||||
confirmationTokenHash: tokenHash,
|
||||
expiresAt,
|
||||
confirmedAt: null,
|
||||
revokedAt: null,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
});
|
||||
const path = `/chat-identity/confirm?token=${encodeURIComponent(token)}`;
|
||||
return {
|
||||
confirmationUrl: getPublicBaseUrl() ? `${getPublicBaseUrl()}${path}` : path,
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async function previewIdentityLink(token: string, userId?: string | null) {
|
||||
const tokenHash = createHash("sha256").update(token).digest("hex");
|
||||
const row = await db
|
||||
.select({
|
||||
link: chatIdentityLinks,
|
||||
principal: chatExternalPrincipals,
|
||||
endpoint: chatEndpoints,
|
||||
companyName: companies.name,
|
||||
companyPrefix: companies.issuePrefix,
|
||||
})
|
||||
.from(chatIdentityLinks)
|
||||
.innerJoin(
|
||||
chatExternalPrincipals,
|
||||
and(
|
||||
eq(chatExternalPrincipals.companyId, chatIdentityLinks.companyId),
|
||||
eq(chatExternalPrincipals.id, chatIdentityLinks.principalId),
|
||||
),
|
||||
)
|
||||
.innerJoin(
|
||||
chatEndpoints,
|
||||
and(
|
||||
eq(chatEndpoints.companyId, chatIdentityLinks.companyId),
|
||||
eq(chatEndpoints.id, chatIdentityLinks.endpointId),
|
||||
),
|
||||
)
|
||||
.innerJoin(companies, eq(companies.id, chatIdentityLinks.companyId))
|
||||
.where(
|
||||
and(
|
||||
eq(chatIdentityLinks.confirmationTokenHash, tokenHash),
|
||||
eq(chatIdentityLinks.status, "pending"),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!row || !row.link.expiresAt || row.link.expiresAt <= new Date()) {
|
||||
throw unprocessable("This identity-link request is invalid or expired");
|
||||
}
|
||||
if (!["active", "verifying"].includes(row.endpoint.status)) throw unprocessable("This connection is not available");
|
||||
const selfService = await slackIdentityLinks.isSelfService(row.link, tokenHash);
|
||||
const membership = userId ? await db.select({ status: companyMemberships.status }).from(companyMemberships).where(and(
|
||||
eq(companyMemberships.companyId, row.link.companyId), eq(companyMemberships.principalType, "user"), eq(companyMemberships.principalId, userId),
|
||||
)).then((rows) => rows[0]) : null;
|
||||
return {
|
||||
selfService,
|
||||
canConfirm: membership?.status === "active",
|
||||
endpointId: row.endpoint.id,
|
||||
companyId: row.endpoint.companyId,
|
||||
companyName: row.companyName,
|
||||
companyPrefix: row.companyPrefix,
|
||||
provider: row.endpoint.provider,
|
||||
providerAccountLabel: row.endpoint.providerAccountLabel,
|
||||
botLabel: row.endpoint.botDisplayName,
|
||||
externalLabel:
|
||||
row.principal.displayName ??
|
||||
row.principal.handle ??
|
||||
row.principal.externalId,
|
||||
externalDetail: row.principal.handle
|
||||
? `@${row.principal.handle.replace(/^@/, "")}`
|
||||
: row.principal.externalId,
|
||||
expiresAt: row.link.expiresAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async function confirmIdentityLink(token: string, paperclipUserId: string) {
|
||||
const tokenHash = createHash("sha256").update(token).digest("hex");
|
||||
const link = await db
|
||||
.select()
|
||||
.from(chatIdentityLinks)
|
||||
.where(
|
||||
and(
|
||||
eq(chatIdentityLinks.confirmationTokenHash, tokenHash),
|
||||
eq(chatIdentityLinks.status, "pending"),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!link || !link.expiresAt || link.expiresAt <= new Date())
|
||||
throw unprocessable("This identity-link request is invalid or expired");
|
||||
const endpointRecordForLink = await endpointRecord(link.endpointId);
|
||||
if (!endpointRecordForLink || !["active", "verifying"].includes(endpointRecordForLink.endpoint.status)) throw unprocessable("This connection is not available");
|
||||
const connectReceipt = endpointRecordForLink.endpoint.provider === "slack" ? await slackIdentityLinks.latestConnect(link) : null;
|
||||
const confirmationRuntime = connectReceipt ? await confirmationRuntimeFor(endpointRecordForLink.endpoint) : null;
|
||||
let confirmationEffectId: string | null = null;
|
||||
const confirmed = await db.transaction(async (tx) => {
|
||||
await tx.execute(
|
||||
sql`select pg_advisory_xact_lock(hashtextextended(${`chat-identity:${link.companyId}:${link.principalId}`}, 0))`,
|
||||
);
|
||||
const currentLink = await tx
|
||||
.select({
|
||||
id: chatIdentityLinks.id,
|
||||
companyId: chatIdentityLinks.companyId,
|
||||
endpointId: chatIdentityLinks.endpointId,
|
||||
principalId: chatIdentityLinks.principalId,
|
||||
expiresAt: chatIdentityLinks.expiresAt,
|
||||
})
|
||||
.from(chatIdentityLinks)
|
||||
.where(
|
||||
and(
|
||||
eq(chatIdentityLinks.id, link.id),
|
||||
eq(chatIdentityLinks.status, "pending"),
|
||||
eq(chatIdentityLinks.confirmationTokenHash, tokenHash),
|
||||
),
|
||||
)
|
||||
.for("update")
|
||||
.then((rows) => rows[0] ?? null);
|
||||
const now = new Date();
|
||||
if (
|
||||
!currentLink ||
|
||||
!currentLink.expiresAt ||
|
||||
currentLink.expiresAt <= now
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const membership = await tx
|
||||
.select({ status: companyMemberships.status })
|
||||
.from(companyMemberships)
|
||||
.where(
|
||||
and(
|
||||
eq(companyMemberships.companyId, currentLink.companyId),
|
||||
eq(companyMemberships.principalType, "user"),
|
||||
eq(companyMemberships.principalId, paperclipUserId),
|
||||
),
|
||||
)
|
||||
.for("update")
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (membership?.status !== "active") {
|
||||
throw forbidden(
|
||||
"The signed-in Paperclip account is not a member of this company",
|
||||
);
|
||||
}
|
||||
const conflictingLink = await tx
|
||||
.select({
|
||||
id: chatIdentityLinks.id,
|
||||
paperclipUserId: chatIdentityLinks.paperclipUserId,
|
||||
})
|
||||
.from(chatIdentityLinks)
|
||||
.where(
|
||||
and(
|
||||
eq(chatIdentityLinks.companyId, link.companyId),
|
||||
eq(chatIdentityLinks.principalId, link.principalId),
|
||||
eq(chatIdentityLinks.status, "linked"),
|
||||
ne(chatIdentityLinks.paperclipUserId, paperclipUserId),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (conflictingLink) {
|
||||
throw conflict(
|
||||
"This provider identity is linked to a different Paperclip account",
|
||||
{
|
||||
code: "chat_identity_link_conflict",
|
||||
},
|
||||
);
|
||||
}
|
||||
const confirmedLink = await tx
|
||||
.update(chatIdentityLinks)
|
||||
.set({
|
||||
paperclipUserId,
|
||||
status: "linked",
|
||||
confirmationTokenHash: null,
|
||||
confirmedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(chatIdentityLinks.id, link.id),
|
||||
eq(chatIdentityLinks.status, "pending"),
|
||||
eq(chatIdentityLinks.confirmationTokenHash, tokenHash),
|
||||
gt(chatIdentityLinks.expiresAt, now),
|
||||
),
|
||||
)
|
||||
.returning({ endpointId: chatIdentityLinks.endpointId })
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (confirmedLink && connectReceipt && confirmationRuntime &&
|
||||
typeof connectReceipt.payload.channelId === "string" && typeof connectReceipt.payload.userId === "string") {
|
||||
confirmationEffectId = await slackIdentityLinks.stageConfirmation(tx, {
|
||||
endpoint: endpointRecordForLink.endpoint, link, tokenHash,
|
||||
channelId: connectReceipt.payload.channelId, userId: connectReceipt.payload.userId,
|
||||
confirmationRuntime,
|
||||
});
|
||||
}
|
||||
return confirmedLink;
|
||||
});
|
||||
if (!confirmed) {
|
||||
throw conflict("This identity-link request was already used or expired", {
|
||||
code: "chat_identity_link_consumed",
|
||||
});
|
||||
}
|
||||
if (confirmationEffectId) scheduleProviderEffect(confirmationEffectId);
|
||||
return { ok: true, endpointId: confirmed.endpointId };
|
||||
}
|
||||
|
||||
async function revokeLink(endpointId: string, principalId: string) {
|
||||
const link = await db
|
||||
.select({ companyId: chatIdentityLinks.companyId })
|
||||
.from(chatIdentityLinks)
|
||||
.where(
|
||||
and(
|
||||
eq(chatIdentityLinks.endpointId, endpointId),
|
||||
eq(chatIdentityLinks.principalId, principalId),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!link) throw notFound("Identity link not found");
|
||||
const revoked = await db.transaction(async (tx) => {
|
||||
await tx.execute(
|
||||
sql`select pg_advisory_xact_lock(hashtextextended(${`chat-identity:${link.companyId}:${principalId}`}, 0))`,
|
||||
);
|
||||
return tx
|
||||
.update(chatIdentityLinks)
|
||||
.set({
|
||||
paperclipUserId: null,
|
||||
status: "revoked",
|
||||
confirmationTokenHash: null,
|
||||
revokedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(chatIdentityLinks.companyId, link.companyId),
|
||||
eq(chatIdentityLinks.endpointId, endpointId),
|
||||
eq(chatIdentityLinks.principalId, principalId),
|
||||
),
|
||||
)
|
||||
.returning({ id: chatIdentityLinks.id })
|
||||
.then((rows) => rows[0] ?? null);
|
||||
});
|
||||
if (!revoked) throw notFound("Identity link not found");
|
||||
}
|
||||
|
||||
return { listPrincipals, createLinkIntent, previewIdentityLink, requestIdentityAccess: slackIdentityLinks.requestIdentityAccess, confirmIdentityLink, revokeLink };
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { SafeChatPublicationPayload } from "@paperclipai/shared";
|
||||
|
||||
export function imessageProgressSupersessionReason(
|
||||
progress: SafeChatPublicationPayload["progressState"],
|
||||
): string | null {
|
||||
return progress && ["queued", "working"].includes(progress)
|
||||
? "iMessage uses typing instead of progress bubbles"
|
||||
: null;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { chatEndpointResources } from "@paperclipai/db";
|
||||
|
||||
export function imessageResourceParticipants(resource: typeof chatEndpointResources.$inferSelect) {
|
||||
return resource.providerResourceId.startsWith("imessage-photon:") &&
|
||||
Array.isArray(resource.metadata.participants)
|
||||
? resource.metadata.participants.flatMap((participant) => {
|
||||
const address =
|
||||
participant && typeof participant === "object"
|
||||
? (participant as Record<string, unknown>).address
|
||||
: null;
|
||||
return typeof address === "string" ? [address] : [];
|
||||
})
|
||||
: undefined;
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { and, eq, isNull, sql } from "drizzle-orm";
|
||||
import { assets, chatConversations, chatEndpoints, chatPublications, chatTeamsFileTransfers, issueAttachments, issueComments, type Db } from "@paperclipai/db";
|
||||
import { isUuidLike, type ChatProvider } from "@paperclipai/shared";
|
||||
|
||||
type DbOrTransaction = Db | Parameters<Parameters<Db["transaction"]>[0]>[0];
|
||||
|
||||
export type ContinuingTeamsFileTransfer = {
|
||||
transferId: string;
|
||||
publicationId: string;
|
||||
version: number;
|
||||
phase: "consent_unknown" | "file_info_unknown";
|
||||
};
|
||||
|
||||
type TeamsFileTransferSourceInput = {
|
||||
companyId: string;
|
||||
agentId: string;
|
||||
issueId: string;
|
||||
failedRunId: string;
|
||||
provider: ChatProvider;
|
||||
publication?: boolean;
|
||||
retryActionId?: string;
|
||||
runId?: string;
|
||||
continuingTransfer: ContinuingTeamsFileTransfer;
|
||||
};
|
||||
|
||||
// A selector proves only this transfer's source. The caller must still verify
|
||||
// the committed result and complete original source batch in this transaction.
|
||||
export async function continuingTeamsFilePublication(
|
||||
tx: DbOrTransaction,
|
||||
input: TeamsFileTransferSourceInput,
|
||||
failedChatRetryDenied: () => Error,
|
||||
): Promise<string> {
|
||||
const { provider, continuingTransfer } = input;
|
||||
if (
|
||||
input.publication !== true ||
|
||||
input.retryActionId !== undefined ||
|
||||
input.runId !== undefined ||
|
||||
provider !== "microsoft-teams" ||
|
||||
!continuingTransfer ||
|
||||
!isUuidLike(continuingTransfer.transferId) ||
|
||||
!isUuidLike(continuingTransfer.publicationId) ||
|
||||
!Number.isSafeInteger(continuingTransfer.version) ||
|
||||
continuingTransfer.version < 1 ||
|
||||
!["consent_unknown", "file_info_unknown"].includes(
|
||||
continuingTransfer.phase,
|
||||
)
|
||||
)
|
||||
throw failedChatRetryDenied();
|
||||
// The caller supplies a selector only. Independently prove the exact
|
||||
// unknown publication is this native run's own selected file, not a
|
||||
// sibling effect or another actor/generation. All ordinary source and
|
||||
// committed-result authorization below still runs unchanged.
|
||||
const [continuing] = await tx
|
||||
.select({
|
||||
publicationId: chatPublications.id,
|
||||
attemptId: chatTeamsFileTransfers.attemptId,
|
||||
attemptExpiresAt: chatTeamsFileTransfers.attemptExpiresAt,
|
||||
})
|
||||
.from(chatTeamsFileTransfers)
|
||||
.innerJoin(
|
||||
chatPublications,
|
||||
and(
|
||||
eq(chatPublications.id, chatTeamsFileTransfers.publicationId),
|
||||
eq(chatPublications.companyId, chatTeamsFileTransfers.companyId),
|
||||
eq(chatPublications.endpointId, chatTeamsFileTransfers.endpointId),
|
||||
eq(
|
||||
chatPublications.conversationId,
|
||||
chatTeamsFileTransfers.conversationId,
|
||||
),
|
||||
eq(chatPublications.issueId, chatTeamsFileTransfers.issueId),
|
||||
eq(chatPublications.commentId, chatTeamsFileTransfers.commentId),
|
||||
eq(chatPublications.state, "delivery_unknown"),
|
||||
sql`${chatPublications.payload}->'attachmentIds' = jsonb_build_array(${chatTeamsFileTransfers.attachmentId}::text)`,
|
||||
),
|
||||
)
|
||||
.innerJoin(
|
||||
chatEndpoints,
|
||||
and(
|
||||
eq(chatEndpoints.id, chatTeamsFileTransfers.endpointId),
|
||||
eq(chatEndpoints.companyId, input.companyId),
|
||||
eq(chatEndpoints.provider, "microsoft-teams"),
|
||||
),
|
||||
)
|
||||
.innerJoin(
|
||||
chatConversations,
|
||||
and(
|
||||
eq(chatConversations.id, chatTeamsFileTransfers.conversationId),
|
||||
eq(chatConversations.companyId, input.companyId),
|
||||
eq(chatConversations.endpointId, chatTeamsFileTransfers.endpointId),
|
||||
eq(chatConversations.issueId, input.issueId),
|
||||
eq(
|
||||
chatConversations.sessionGeneration,
|
||||
chatTeamsFileTransfers.conversationGeneration,
|
||||
),
|
||||
),
|
||||
)
|
||||
.innerJoin(
|
||||
issueComments,
|
||||
and(
|
||||
eq(issueComments.id, chatTeamsFileTransfers.commentId),
|
||||
eq(issueComments.companyId, input.companyId),
|
||||
eq(issueComments.issueId, input.issueId),
|
||||
eq(issueComments.createdByRunId, input.failedRunId),
|
||||
eq(issueComments.authorAgentId, input.agentId),
|
||||
isNull(issueComments.deletedAt),
|
||||
),
|
||||
)
|
||||
.innerJoin(
|
||||
issueAttachments,
|
||||
and(
|
||||
eq(issueAttachments.id, chatTeamsFileTransfers.attachmentId),
|
||||
eq(issueAttachments.companyId, input.companyId),
|
||||
eq(issueAttachments.issueId, input.issueId),
|
||||
eq(issueAttachments.issueCommentId, issueComments.id),
|
||||
eq(issueAttachments.originatingRunId, input.failedRunId),
|
||||
),
|
||||
)
|
||||
.innerJoin(
|
||||
assets,
|
||||
and(
|
||||
eq(assets.id, issueAttachments.assetId),
|
||||
eq(assets.companyId, input.companyId),
|
||||
eq(assets.sha256, chatTeamsFileTransfers.sha256),
|
||||
eq(assets.byteSize, chatTeamsFileTransfers.byteSize),
|
||||
),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(chatTeamsFileTransfers.companyId, input.companyId),
|
||||
eq(chatTeamsFileTransfers.issueId, input.issueId),
|
||||
eq(chatTeamsFileTransfers.id, continuingTransfer.transferId),
|
||||
eq(
|
||||
chatTeamsFileTransfers.publicationId,
|
||||
continuingTransfer.publicationId,
|
||||
),
|
||||
eq(chatTeamsFileTransfers.version, continuingTransfer.version),
|
||||
eq(chatTeamsFileTransfers.phase, continuingTransfer.phase),
|
||||
),
|
||||
)
|
||||
.for("share", { noWait: true });
|
||||
if (
|
||||
!continuing ||
|
||||
(continuing.attemptId &&
|
||||
(!continuing.attemptExpiresAt ||
|
||||
continuing.attemptExpiresAt > new Date()))
|
||||
)
|
||||
throw failedChatRetryDenied();
|
||||
return continuing.publicationId;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function baseTeamsConversationId(value: string): string {
|
||||
return value.replace(/;messageid=[^;]+/i, "");
|
||||
}
|
||||
@@ -0,0 +1,965 @@
|
||||
import { imessageProgressSupersessionReason } from "./imessage-photon/publications.js";
|
||||
import { fastResponseRequests } from "@paperclipai/db";
|
||||
|
||||
import {
|
||||
and,
|
||||
desc,
|
||||
eq,
|
||||
gt,
|
||||
inArray,
|
||||
isNotNull,
|
||||
isNull,
|
||||
like,
|
||||
ne,
|
||||
or,
|
||||
sql,
|
||||
} from "drizzle-orm";
|
||||
import { alias } from "drizzle-orm/pg-core";
|
||||
|
||||
import type { Db } from "@paperclipai/db";
|
||||
|
||||
import {
|
||||
agentWakeupRequests,
|
||||
chatEndpoints,
|
||||
chatMessageLinks,
|
||||
chatPublications,
|
||||
heartbeatRuns,
|
||||
issueComments,
|
||||
issueThreadInteractions,
|
||||
issues,
|
||||
} from "@paperclipai/db";
|
||||
import type {
|
||||
SafeChatPublicationPayload,
|
||||
} from "@paperclipai/shared";
|
||||
import {
|
||||
isUuidLike,
|
||||
} from "@paperclipai/shared";
|
||||
|
||||
import {
|
||||
NativeChatReviewPresentationContentionError,
|
||||
} from "../native-runtime/native-chat-review-presentation.js";
|
||||
import { isExternalChatWaitAuthorizationContention } from "../native-runtime/chat-attachment-reuse.js";
|
||||
|
||||
import {
|
||||
inboundWakePublicationKey,
|
||||
parseInboundWakePublicationKey,
|
||||
} from "../chat-inbound-wakeup-publications.js";
|
||||
|
||||
type EndpointRow = typeof chatEndpoints.$inferSelect;
|
||||
type DbOrTransaction = Db | Parameters<Parameters<Db["transaction"]>[0]>[0];
|
||||
type ChatPublicationReplacementOptions = {
|
||||
endpointRecord: (endpointId: string) => Promise<{ endpoint: EndpointRow } | null>;
|
||||
authorizeRetainedChatSourcePublication: (database: DbOrTransaction, publication: typeof chatPublications.$inferSelect) => Promise<boolean>;
|
||||
};
|
||||
|
||||
export function createChatPublicationReplacement(db: Db, options: ChatPublicationReplacementOptions) {
|
||||
const { endpointRecord, authorizeRetainedChatSourcePublication } = options;
|
||||
|
||||
|
||||
function runIdFromMilestonePublication(
|
||||
publication: typeof chatPublications.$inferSelect,
|
||||
): string | null {
|
||||
if (!publication.payload.progressState) return null;
|
||||
const match =
|
||||
/^run:([^:]+):(?:queued|working|waiting_for_input|completed|failed):/.exec(
|
||||
publication.idempotencyKey,
|
||||
);
|
||||
const runId = match?.[1] ?? null;
|
||||
// This identifier is also compared against a UUID column below. Old or
|
||||
// manually repaired rows must not be allowed to turn the global
|
||||
// publication sweep into a PostgreSQL cast error.
|
||||
return isUuidLike(runId) ? runId : null;
|
||||
}
|
||||
|
||||
async function runOwnershipMilestoneSupersessionReason(
|
||||
publication: typeof chatPublications.$inferSelect,
|
||||
): Promise<string | null> {
|
||||
const progress = publication.payload.progressState;
|
||||
const typingReason = imessageProgressSupersessionReason(progress);
|
||||
if (typingReason) {
|
||||
const endpoint = await endpointRecord(publication.endpointId);
|
||||
if (endpoint?.endpoint.provider === "imessage-photon") return typingReason;
|
||||
}
|
||||
if (
|
||||
!progress ||
|
||||
!["queued", "working", "waiting_for_input"].includes(progress)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const runId = runIdFromMilestonePublication(publication);
|
||||
if (!runId) return null;
|
||||
const run = await db.transaction(async (tx) => {
|
||||
// Use native finalization's issue -> run lock order. If terminalization
|
||||
// won, the current status suppresses this obsolete provider update. If
|
||||
// this short authorization snapshot wins, the progress was still true
|
||||
// at its durable send boundary and the later final remains authoritative.
|
||||
const currentIssue = await tx
|
||||
.select({ id: issues.id })
|
||||
.from(issues)
|
||||
.where(
|
||||
and(
|
||||
eq(issues.id, publication.issueId),
|
||||
eq(issues.companyId, publication.companyId),
|
||||
),
|
||||
)
|
||||
.for("update")
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!currentIssue) return null;
|
||||
return tx
|
||||
.select({
|
||||
status: heartbeatRuns.status,
|
||||
errorCode: heartbeatRuns.errorCode,
|
||||
})
|
||||
.from(heartbeatRuns)
|
||||
.where(
|
||||
and(
|
||||
eq(heartbeatRuns.id, runId),
|
||||
eq(heartbeatRuns.companyId, publication.companyId),
|
||||
sql`${heartbeatRuns.contextSnapshot} ->> 'issueId' = ${publication.issueId}`,
|
||||
),
|
||||
)
|
||||
.for("update")
|
||||
.then((rows) => rows[0] ?? null);
|
||||
});
|
||||
const baseProgressKey = `run:${runId}:${progress}:${publication.endpointId}`;
|
||||
const nativeProgressPrefix = `run:${runId}:working:${publication.endpointId}:native:`;
|
||||
const isTerminalizableProgress =
|
||||
(progress === "queued" || progress === "working") &&
|
||||
(publication.idempotencyKey === baseProgressKey ||
|
||||
publication.idempotencyKey.startsWith(nativeProgressPrefix));
|
||||
if (
|
||||
isTerminalizableProgress &&
|
||||
run &&
|
||||
["succeeded", "interrupted", "failed", "cancelled", "timed_out"].includes(
|
||||
run.status,
|
||||
)
|
||||
) {
|
||||
return "Run reached a terminal state before progress delivery";
|
||||
}
|
||||
// Match only the closed ownership-attention projection. Historical progress
|
||||
// without an extant run retains its existing delivery semantics.
|
||||
const ownershipBlocked =
|
||||
run?.status === "running" &&
|
||||
[
|
||||
"native_execution_ownership_unverified",
|
||||
"native_adopted_runner_authentication_timeout",
|
||||
].includes(run.errorCode ?? "");
|
||||
if (progress === "waiting_for_input") {
|
||||
return ownershipBlocked
|
||||
? null
|
||||
: "Run ownership attention no longer applies to the bound task";
|
||||
}
|
||||
return ownershipBlocked
|
||||
? "Run progress was superseded by ownership recovery attention"
|
||||
: null;
|
||||
}
|
||||
|
||||
async function providerProgressLaneConsumed(
|
||||
publication: typeof chatPublications.$inferSelect,
|
||||
providerMessageId: string,
|
||||
): Promise<boolean> {
|
||||
// Published progress rows retain their old provider ID after an edit.
|
||||
// Consult that exact message's current outbound link, not the conversation
|
||||
// tail: an authored answer or failure has consumed the lane, while an
|
||||
// interleaved task-status update may still be replaced normally.
|
||||
const [consumed] = await db
|
||||
.select({ id: chatPublications.id })
|
||||
.from(chatMessageLinks)
|
||||
.innerJoin(
|
||||
chatPublications,
|
||||
and(
|
||||
eq(chatPublications.id, chatMessageLinks.publicationId),
|
||||
eq(chatPublications.companyId, publication.companyId),
|
||||
eq(chatPublications.endpointId, publication.endpointId),
|
||||
eq(chatPublications.conversationId, publication.conversationId),
|
||||
eq(chatPublications.issueId, publication.issueId),
|
||||
eq(chatPublications.providerMessageId, providerMessageId),
|
||||
eq(chatPublications.state, "published"),
|
||||
),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(chatMessageLinks.companyId, publication.companyId),
|
||||
eq(chatMessageLinks.endpointId, publication.endpointId),
|
||||
eq(chatMessageLinks.conversationId, publication.conversationId),
|
||||
eq(chatMessageLinks.direction, "outbound"),
|
||||
eq(chatMessageLinks.providerMessageId, providerMessageId),
|
||||
or(
|
||||
and(
|
||||
isNotNull(chatPublications.commentId),
|
||||
sql`${chatPublications.payload}->>'progressState' is null`,
|
||||
),
|
||||
sql`${chatPublications.payload}->>'progressState' = 'failed'`,
|
||||
),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
return Boolean(consumed);
|
||||
}
|
||||
|
||||
async function committedResponseMayReplaceFailure(
|
||||
publication: typeof chatPublications.$inferSelect,
|
||||
runId: string,
|
||||
providerMessageId: string,
|
||||
): Promise<boolean> {
|
||||
if (
|
||||
!publication.commentId ||
|
||||
publication.payload.progressState !== undefined
|
||||
)
|
||||
return false;
|
||||
return db
|
||||
.transaction(async (tx) => {
|
||||
const [run] = await tx
|
||||
.select()
|
||||
.from(heartbeatRuns)
|
||||
.where(
|
||||
and(
|
||||
eq(heartbeatRuns.id, runId),
|
||||
eq(heartbeatRuns.companyId, publication.companyId),
|
||||
eq(heartbeatRuns.nativeIssueId, publication.issueId),
|
||||
eq(heartbeatRuns.runtimeMode, "native"),
|
||||
eq(heartbeatRuns.status, "succeeded"),
|
||||
isNotNull(heartbeatRuns.finishedAt),
|
||||
),
|
||||
)
|
||||
.for("share", { noWait: true });
|
||||
const presentation = run?.resultJson?.presentationDecision as
|
||||
Record<string, unknown> | undefined;
|
||||
if (
|
||||
!run?.resultJson?.nativeCommittedChatResponse ||
|
||||
presentation?.commentId !== publication.commentId
|
||||
)
|
||||
return false;
|
||||
const [comment] = await tx
|
||||
.select({ id: issueComments.id })
|
||||
.from(issueComments)
|
||||
.where(
|
||||
and(
|
||||
eq(issueComments.id, publication.commentId!),
|
||||
eq(issueComments.companyId, publication.companyId),
|
||||
eq(issueComments.issueId, publication.issueId),
|
||||
eq(issueComments.createdByRunId, runId),
|
||||
eq(issueComments.authorAgentId, run.agentId),
|
||||
isNull(issueComments.deletedAt),
|
||||
),
|
||||
)
|
||||
.for("share", { noWait: true });
|
||||
if (!comment) return false;
|
||||
const [priorAnswer] = await tx
|
||||
.select({ id: chatPublications.id })
|
||||
.from(chatPublications)
|
||||
.innerJoin(
|
||||
issueComments,
|
||||
and(
|
||||
eq(issueComments.id, chatPublications.commentId),
|
||||
eq(issueComments.companyId, publication.companyId),
|
||||
eq(issueComments.createdByRunId, runId),
|
||||
),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(chatPublications.companyId, publication.companyId),
|
||||
eq(chatPublications.endpointId, publication.endpointId),
|
||||
eq(chatPublications.conversationId, publication.conversationId),
|
||||
eq(chatPublications.issueId, publication.issueId),
|
||||
ne(chatPublications.id, publication.id),
|
||||
inArray(chatPublications.state, [
|
||||
"published",
|
||||
"streaming",
|
||||
"delivery_unknown",
|
||||
]),
|
||||
sql`${chatPublications.payload}->>'progressState' is null`,
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (priorAnswer) return false;
|
||||
// A historical progress row can retain this ID after a later edit.
|
||||
// Only its CURRENT outbound link may grant this narrow exception; an
|
||||
// authored answer or another run's failure must remain consumed.
|
||||
const [failure] = await tx
|
||||
.select({ id: chatPublications.id })
|
||||
.from(chatMessageLinks)
|
||||
.innerJoin(
|
||||
chatPublications,
|
||||
and(
|
||||
eq(chatPublications.id, chatMessageLinks.publicationId),
|
||||
eq(chatPublications.companyId, publication.companyId),
|
||||
eq(chatPublications.endpointId, publication.endpointId),
|
||||
eq(chatPublications.conversationId, publication.conversationId),
|
||||
eq(chatPublications.issueId, publication.issueId),
|
||||
eq(chatPublications.providerMessageId, providerMessageId),
|
||||
eq(
|
||||
chatPublications.idempotencyKey,
|
||||
`run:${runId}:failed:${publication.endpointId}`,
|
||||
),
|
||||
eq(chatPublications.state, "published"),
|
||||
isNull(chatPublications.commentId),
|
||||
sql`${chatPublications.payload}->>'progressState' = 'failed'`,
|
||||
),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(chatMessageLinks.companyId, publication.companyId),
|
||||
eq(chatMessageLinks.endpointId, publication.endpointId),
|
||||
eq(chatMessageLinks.conversationId, publication.conversationId),
|
||||
eq(chatMessageLinks.providerMessageId, providerMessageId),
|
||||
eq(chatMessageLinks.direction, "outbound"),
|
||||
),
|
||||
);
|
||||
if (!failure) return false;
|
||||
// The marker is read from the authoritative run, never the publication
|
||||
// payload. Recheck accepted result/decision plus the complete current
|
||||
// source batch, actor, access and epoch before borrowing its failure lane.
|
||||
// The transport claim repeats those checks at the I/O boundary.
|
||||
return authorizeRetainedChatSourcePublication(tx, publication);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (isExternalChatWaitAuthorizationContention(error))
|
||||
throw new NativeChatReviewPresentationContentionError();
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
async function runPublicationToReplace(
|
||||
publication: typeof chatPublications.$inferSelect,
|
||||
payload: SafeChatPublicationPayload,
|
||||
): Promise<string | null> {
|
||||
if (payload.attachmentIds?.length) return null;
|
||||
const currentRunId =
|
||||
runIdFromMilestonePublication(publication) ??
|
||||
(publication.commentId
|
||||
? await db
|
||||
.select({ runId: issueComments.createdByRunId })
|
||||
.from(issueComments)
|
||||
.where(eq(issueComments.id, publication.commentId))
|
||||
.then((rows) => rows[0]?.runId ?? null)
|
||||
: null);
|
||||
if (!currentRunId) return null;
|
||||
return db
|
||||
.select({
|
||||
id: chatPublications.id,
|
||||
commentId: chatPublications.commentId,
|
||||
providerMessageId: chatPublications.providerMessageId,
|
||||
payload: chatPublications.payload,
|
||||
})
|
||||
.from(chatPublications)
|
||||
.leftJoin(issueComments, eq(issueComments.id, chatPublications.commentId))
|
||||
.where(
|
||||
and(
|
||||
eq(chatPublications.companyId, publication.companyId),
|
||||
eq(chatPublications.endpointId, publication.endpointId),
|
||||
eq(chatPublications.conversationId, publication.conversationId),
|
||||
eq(chatPublications.state, "published"),
|
||||
isNotNull(chatPublications.providerMessageId),
|
||||
or(
|
||||
like(chatPublications.idempotencyKey, `run:${currentRunId}:%`),
|
||||
eq(issueComments.createdByRunId, currentRunId),
|
||||
),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(chatPublications.createdAt), desc(chatPublications.id))
|
||||
.then(async (rows) => {
|
||||
// Progress updates are one replaceable provider-message lane per run.
|
||||
// The first durable agent comment may turn that placeholder into the
|
||||
// terminal response, but later comments from the same run are distinct
|
||||
// user-visible outputs and must be posted separately. Re-editing the
|
||||
// placeholder for each comment silently erases the earlier replies.
|
||||
if (
|
||||
publication.commentId &&
|
||||
rows.some(
|
||||
(row) =>
|
||||
row.commentId !== null && row.payload.progressState === undefined,
|
||||
)
|
||||
)
|
||||
return null;
|
||||
const replacement = rows.find(
|
||||
(row) =>
|
||||
Boolean(row.providerMessageId) &&
|
||||
row.payload.progressState !== undefined,
|
||||
);
|
||||
if (!replacement?.providerMessageId) return null;
|
||||
if (
|
||||
(await providerProgressLaneConsumed(
|
||||
publication,
|
||||
replacement.providerMessageId,
|
||||
)) &&
|
||||
!(await committedResponseMayReplaceFailure(
|
||||
publication,
|
||||
currentRunId,
|
||||
replacement.providerMessageId,
|
||||
))
|
||||
)
|
||||
return null;
|
||||
// Replacement identity belongs to the run, not to the provider-visible
|
||||
// tail. A status/control reply may legitimately interleave while the run
|
||||
// is active; making the tail the edit candidate would strand this run's
|
||||
// working placeholder forever. The query is bounded by endpoint,
|
||||
// conversation (the task generation), and run id, so an interleaved
|
||||
// control or another run can never donate its provider message here.
|
||||
return replacement.providerMessageId;
|
||||
});
|
||||
}
|
||||
|
||||
async function receiptReactionCompletionRunId(
|
||||
tx: Db,
|
||||
publication: typeof chatPublications.$inferSelect,
|
||||
payload: SafeChatPublicationPayload,
|
||||
): Promise<string | null> {
|
||||
if (
|
||||
payload.progressState === "completed" ||
|
||||
payload.progressState === "failed"
|
||||
) {
|
||||
return runIdFromMilestonePublication(publication);
|
||||
}
|
||||
if (publication.commentId) {
|
||||
const runId = await tx
|
||||
.select({ runId: issueComments.createdByRunId })
|
||||
.from(issueComments)
|
||||
.where(
|
||||
and(
|
||||
eq(issueComments.id, publication.commentId),
|
||||
eq(issueComments.companyId, publication.companyId),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0]?.runId ?? null);
|
||||
return isUuidLike(runId) ? runId : null;
|
||||
}
|
||||
if (
|
||||
payload.interactionId &&
|
||||
publication.idempotencyKey ===
|
||||
`interaction:${payload.interactionId}:${publication.endpointId}`
|
||||
) {
|
||||
const runId = await tx
|
||||
.select({ runId: issueThreadInteractions.sourceRunId })
|
||||
.from(issueThreadInteractions)
|
||||
.where(
|
||||
and(
|
||||
eq(issueThreadInteractions.id, payload.interactionId),
|
||||
eq(issueThreadInteractions.companyId, publication.companyId),
|
||||
eq(issueThreadInteractions.issueId, publication.issueId),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0]?.runId ?? null);
|
||||
return isUuidLike(runId) ? runId : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function inboundWakePublicationToReplace(
|
||||
publication: typeof chatPublications.$inferSelect,
|
||||
payload: SafeChatPublicationPayload,
|
||||
): Promise<string | null> {
|
||||
if (payload.attachmentIds?.length) return null;
|
||||
const notice = parseInboundWakePublicationKey(publication.idempotencyKey);
|
||||
let wakeId: string | null =
|
||||
notice && notice.state !== "queued" ? notice.wakeId : null;
|
||||
let runId = runIdFromMilestonePublication(publication);
|
||||
if (
|
||||
!notice &&
|
||||
!runId &&
|
||||
payload.interactionId &&
|
||||
publication.idempotencyKey ===
|
||||
`interaction:${payload.interactionId}:${publication.endpointId}`
|
||||
) {
|
||||
const [interaction] = await db
|
||||
.select({ runId: issueThreadInteractions.sourceRunId })
|
||||
.from(issueThreadInteractions)
|
||||
.where(
|
||||
and(
|
||||
eq(issueThreadInteractions.id, payload.interactionId),
|
||||
eq(issueThreadInteractions.companyId, publication.companyId),
|
||||
eq(issueThreadInteractions.issueId, publication.issueId),
|
||||
inArray(issueThreadInteractions.kind, [
|
||||
"ask_user_questions",
|
||||
"request_confirmation",
|
||||
]),
|
||||
),
|
||||
);
|
||||
runId = interaction?.runId ?? null;
|
||||
}
|
||||
if (!notice && !runId && publication.commentId) {
|
||||
const [comment] = await db
|
||||
.select({ runId: issueComments.createdByRunId })
|
||||
.from(issueComments)
|
||||
.where(
|
||||
and(
|
||||
eq(issueComments.id, publication.commentId),
|
||||
eq(issueComments.companyId, publication.companyId),
|
||||
eq(issueComments.issueId, publication.issueId),
|
||||
),
|
||||
);
|
||||
runId = comment?.runId ?? null;
|
||||
}
|
||||
let runContext: Record<string, unknown> | null = null;
|
||||
if (runId) {
|
||||
if (publication.commentId) {
|
||||
const [priorAnswer] = await db
|
||||
.select({ id: chatPublications.id })
|
||||
.from(chatPublications)
|
||||
.innerJoin(
|
||||
issueComments,
|
||||
eq(issueComments.id, chatPublications.commentId),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(chatPublications.companyId, publication.companyId),
|
||||
eq(chatPublications.endpointId, publication.endpointId),
|
||||
eq(chatPublications.conversationId, publication.conversationId),
|
||||
eq(chatPublications.state, "published"),
|
||||
eq(issueComments.createdByRunId, runId),
|
||||
sql`${chatPublications.payload}->>'progressState' is null`,
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (priorAnswer) return null;
|
||||
}
|
||||
const [run] = await db
|
||||
.select({
|
||||
wakeId: heartbeatRuns.wakeupRequestId,
|
||||
context: heartbeatRuns.contextSnapshot,
|
||||
})
|
||||
.from(heartbeatRuns)
|
||||
.innerJoin(
|
||||
agentWakeupRequests,
|
||||
and(
|
||||
eq(agentWakeupRequests.id, heartbeatRuns.wakeupRequestId),
|
||||
eq(agentWakeupRequests.runId, heartbeatRuns.id),
|
||||
eq(agentWakeupRequests.companyId, heartbeatRuns.companyId),
|
||||
eq(agentWakeupRequests.agentId, heartbeatRuns.agentId),
|
||||
// Failed/cancelled is the admitted run's outcome, not loss of
|
||||
// its original admission. Its terminal milestone still owns
|
||||
// this exact lane even if no working update was sent first.
|
||||
ne(agentWakeupRequests.status, "skipped"),
|
||||
),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(heartbeatRuns.id, runId),
|
||||
eq(heartbeatRuns.companyId, publication.companyId),
|
||||
sql`${heartbeatRuns.contextSnapshot}->>'issueId' = ${publication.issueId}`,
|
||||
),
|
||||
);
|
||||
wakeId = run?.wakeId ?? null;
|
||||
runContext = run?.context ?? null;
|
||||
}
|
||||
if (!wakeId) return null;
|
||||
const [queued] = await db
|
||||
.select({
|
||||
commentId: chatPublications.commentId,
|
||||
providerMessageId: chatPublications.providerMessageId,
|
||||
})
|
||||
.from(chatPublications)
|
||||
.where(
|
||||
and(
|
||||
eq(chatPublications.companyId, publication.companyId),
|
||||
eq(chatPublications.endpointId, publication.endpointId),
|
||||
eq(chatPublications.conversationId, publication.conversationId),
|
||||
eq(chatPublications.issueId, publication.issueId),
|
||||
eq(chatPublications.state, "published"),
|
||||
isNotNull(chatPublications.providerMessageId),
|
||||
eq(
|
||||
chatPublications.idempotencyKey,
|
||||
inboundWakePublicationKey(
|
||||
wakeId,
|
||||
"queued",
|
||||
publication.endpointId,
|
||||
publication.conversationId,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
if (!queued?.commentId || !queued.providerMessageId) return null;
|
||||
// The run helper may have rejected this same provider ID because its
|
||||
// queued/working placeholder is already an answer or terminal failure.
|
||||
// Falling back to the older wake row must not erase that newer output.
|
||||
if (
|
||||
await providerProgressLaneConsumed(publication, queued.providerMessageId)
|
||||
)
|
||||
return null;
|
||||
if (notice && notice.state !== "queued")
|
||||
return queued.commentId === publication.commentId
|
||||
? queued.providerMessageId
|
||||
: null;
|
||||
// A deferred owner can be a Board wake into which the exact external
|
||||
// comment coalesced. The run must actually contain that source comment;
|
||||
// wake ID alone cannot authorize a different successor's edit lane.
|
||||
return runContext &&
|
||||
(runContext.wakeCommentId === queued.commentId ||
|
||||
runContext.commentId === queued.commentId ||
|
||||
(Array.isArray(runContext.wakeCommentIds) &&
|
||||
runContext.wakeCommentIds.includes(queued.commentId)))
|
||||
? queued.providerMessageId
|
||||
: null;
|
||||
}
|
||||
|
||||
async function interactionResolutionPublicationToReplace(
|
||||
publication: typeof chatPublications.$inferSelect,
|
||||
payload: SafeChatPublicationPayload,
|
||||
): Promise<string | null> {
|
||||
if (
|
||||
!publication.idempotencyKey.startsWith("interaction-resolution:") ||
|
||||
!payload.interactionId
|
||||
)
|
||||
return null;
|
||||
return db
|
||||
.select({ providerMessageId: chatPublications.providerMessageId })
|
||||
.from(chatPublications)
|
||||
.where(
|
||||
and(
|
||||
eq(chatPublications.companyId, publication.companyId),
|
||||
eq(chatPublications.endpointId, publication.endpointId),
|
||||
eq(chatPublications.conversationId, publication.conversationId),
|
||||
eq(chatPublications.issueId, publication.issueId),
|
||||
eq(
|
||||
chatPublications.idempotencyKey,
|
||||
`interaction:${payload.interactionId}:${publication.endpointId}`,
|
||||
),
|
||||
eq(chatPublications.state, "published"),
|
||||
isNotNull(chatPublications.providerMessageId),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0]?.providerMessageId ?? null);
|
||||
}
|
||||
|
||||
async function interactionPromptPublicationToReplace(
|
||||
publication: typeof chatPublications.$inferSelect,
|
||||
payload: SafeChatPublicationPayload,
|
||||
): Promise<string | null> {
|
||||
if (
|
||||
!payload.interactionId ||
|
||||
publication.idempotencyKey !==
|
||||
`interaction:${payload.interactionId}:${publication.endpointId}`
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const interaction = await db
|
||||
.select({
|
||||
kind: issueThreadInteractions.kind,
|
||||
sourceRunId: issueThreadInteractions.sourceRunId,
|
||||
})
|
||||
.from(issueThreadInteractions)
|
||||
.where(
|
||||
and(
|
||||
eq(issueThreadInteractions.id, payload.interactionId),
|
||||
eq(issueThreadInteractions.companyId, publication.companyId),
|
||||
eq(issueThreadInteractions.issueId, publication.issueId),
|
||||
inArray(issueThreadInteractions.kind, [
|
||||
"ask_user_questions",
|
||||
"request_confirmation",
|
||||
]),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!interaction?.sourceRunId) return null;
|
||||
|
||||
// A provider-visible interaction is this run's response, not an additional
|
||||
// message beside its progress indicator. Move the durable interaction
|
||||
// publication onto the exact source run's provider-message lane. This
|
||||
// retires both the normal working placeholder and the queued placeholder
|
||||
// when a very fast run asks before the working update is published.
|
||||
for (const progressState of ["working", "queued"] as const) {
|
||||
const providerMessageId = await db
|
||||
.select({ providerMessageId: chatPublications.providerMessageId })
|
||||
.from(chatPublications)
|
||||
.where(
|
||||
and(
|
||||
eq(chatPublications.companyId, publication.companyId),
|
||||
eq(chatPublications.endpointId, publication.endpointId),
|
||||
eq(chatPublications.conversationId, publication.conversationId),
|
||||
eq(chatPublications.issueId, publication.issueId),
|
||||
eq(
|
||||
chatPublications.idempotencyKey,
|
||||
`run:${interaction.sourceRunId}:${progressState}:${publication.endpointId}`,
|
||||
),
|
||||
eq(chatPublications.state, "published"),
|
||||
isNotNull(chatPublications.providerMessageId),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0]?.providerMessageId ?? null);
|
||||
if (providerMessageId) return providerMessageId;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function progressSupersededByFastResponse(publication: typeof chatPublications.$inferSelect): Promise<boolean> {
|
||||
if (!["queued", "working"].includes(publication.payload.progressState ?? "")) return false;
|
||||
const runId = runIdFromMilestonePublication(publication);
|
||||
const [run] = runId ? await db.select({ context: heartbeatRuns.contextSnapshot }).from(heartbeatRuns).where(and(eq(heartbeatRuns.companyId, publication.companyId), eq(heartbeatRuns.id, runId))) : [];
|
||||
const sourceCommentId = typeof run?.context?.wakeCommentId === "string" ? run.context.wakeCommentId : typeof run?.context?.commentId === "string" ? run.context.commentId : publication.commentId;
|
||||
if (!sourceCommentId) return false;
|
||||
const [receipt] = await db.select({ id: fastResponseRequests.id }).from(fastResponseRequests).innerJoin(chatPublications,
|
||||
and(eq(chatPublications.companyId, publication.companyId), eq(chatPublications.commentId, fastResponseRequests.commentId), eq(chatPublications.state, "published")))
|
||||
.where(and(eq(fastResponseRequests.companyId, publication.companyId), eq(fastResponseRequests.sourceCommentId, sourceCommentId), eq(fastResponseRequests.endpointId, publication.endpointId), eq(fastResponseRequests.conversationId, publication.conversationId))).limit(1);
|
||||
return Boolean(receipt);
|
||||
}
|
||||
|
||||
async function runProgressSupersededByPublishedInteraction(
|
||||
publication: typeof chatPublications.$inferSelect,
|
||||
): Promise<boolean> {
|
||||
if (
|
||||
!["queued", "working"].includes(publication.payload.progressState ?? "")
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const sourceRunId = runIdFromMilestonePublication(publication);
|
||||
if (!sourceRunId) return false;
|
||||
const interactionIds = await db
|
||||
.select({ id: issueThreadInteractions.id })
|
||||
.from(issueThreadInteractions)
|
||||
.where(
|
||||
and(
|
||||
eq(issueThreadInteractions.companyId, publication.companyId),
|
||||
eq(issueThreadInteractions.issueId, publication.issueId),
|
||||
eq(issueThreadInteractions.sourceRunId, sourceRunId),
|
||||
inArray(issueThreadInteractions.kind, [
|
||||
"ask_user_questions",
|
||||
"request_confirmation",
|
||||
]),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows.map((row) => row.id));
|
||||
if (interactionIds.length === 0) return false;
|
||||
return db
|
||||
.select({ id: chatPublications.id })
|
||||
.from(chatPublications)
|
||||
.where(
|
||||
and(
|
||||
eq(chatPublications.companyId, publication.companyId),
|
||||
eq(chatPublications.endpointId, publication.endpointId),
|
||||
eq(chatPublications.conversationId, publication.conversationId),
|
||||
eq(chatPublications.issueId, publication.issueId),
|
||||
eq(chatPublications.state, "published"),
|
||||
inArray(
|
||||
chatPublications.idempotencyKey,
|
||||
interactionIds.map(
|
||||
(interactionId) =>
|
||||
`interaction:${interactionId}:${publication.endpointId}`,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
.then((rows) => rows.length > 0);
|
||||
}
|
||||
|
||||
async function taskStatusPublicationToReplace(
|
||||
publication: typeof chatPublications.$inferSelect,
|
||||
payload: SafeChatPublicationPayload,
|
||||
): Promise<string | null> {
|
||||
if (
|
||||
!publication.idempotencyKey.startsWith("control:status:") ||
|
||||
payload.attachmentIds?.length
|
||||
)
|
||||
return null;
|
||||
const rows = await db
|
||||
.select({
|
||||
commentId: chatPublications.commentId,
|
||||
commentRunId: issueComments.createdByRunId,
|
||||
idempotencyKey: chatPublications.idempotencyKey,
|
||||
payload: chatPublications.payload,
|
||||
providerMessageId: chatPublications.providerMessageId,
|
||||
})
|
||||
.from(chatPublications)
|
||||
.leftJoin(issueComments, eq(issueComments.id, chatPublications.commentId))
|
||||
.where(
|
||||
and(
|
||||
eq(chatPublications.companyId, publication.companyId),
|
||||
eq(chatPublications.endpointId, publication.endpointId),
|
||||
eq(chatPublications.conversationId, publication.conversationId),
|
||||
eq(chatPublications.state, "published"),
|
||||
isNotNull(chatPublications.providerMessageId),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(chatPublications.createdAt), desc(chatPublications.id));
|
||||
const rowRunId = (row: (typeof rows)[number]) => {
|
||||
const milestoneMatch =
|
||||
/^run:([^:]+):(?:queued|working|waiting_for_input|completed|failed):/.exec(
|
||||
row.idempotencyKey,
|
||||
);
|
||||
return milestoneMatch?.[1] ?? row.commentRunId ?? null;
|
||||
};
|
||||
for (const candidate of rows) {
|
||||
if (
|
||||
!candidate.providerMessageId ||
|
||||
!["queued", "working"].includes(candidate.payload.progressState ?? "")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const runId = rowRunId(candidate);
|
||||
if (!runId) continue;
|
||||
const laneClosed = rows.some((row) => {
|
||||
if (rowRunId(row) !== runId) return false;
|
||||
return (
|
||||
["waiting_for_input", "completed", "failed"].includes(
|
||||
row.payload.progressState ?? "",
|
||||
) ||
|
||||
(row.commentId !== null && row.payload.progressState === undefined)
|
||||
);
|
||||
});
|
||||
if (!laneClosed) return candidate.providerMessageId;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function closeProgressToReplace(
|
||||
publication: typeof chatPublications.$inferSelect,
|
||||
endpoint: EndpointRow,
|
||||
): Promise<{ providerMessageId: string; runId: string } | null> {
|
||||
if (
|
||||
!["telegram", "slack", "discord", "microsoft-teams"].includes(
|
||||
endpoint.provider,
|
||||
) ||
|
||||
!publication.idempotencyKey.startsWith("control:close:")
|
||||
)
|
||||
return null;
|
||||
const current = alias(chatPublications, "close_current_progress");
|
||||
const plainText = (table: typeof chatPublications | typeof current) =>
|
||||
and(
|
||||
sql`jsonb_typeof(${table.payload}->'text') = 'string'`,
|
||||
sql`${table.payload}->'card' is null`,
|
||||
sql`${table.payload}->'interactionId' is null`,
|
||||
sql`${table.payload}->'transportPart' is null`,
|
||||
sql`(${table.payload}->'attachmentIds' is null or ${table.payload}->'attachmentIds' = '[]'::jsonb)`,
|
||||
);
|
||||
const ownedMilestone = (table: typeof chatPublications | typeof current) =>
|
||||
or(
|
||||
sql`${table.idempotencyKey} = 'run:' || ${heartbeatRuns.id}::text || ':queued:' || ${endpoint.id}`,
|
||||
sql`${table.idempotencyKey} = 'run:' || ${heartbeatRuns.id}::text || ':working:' || ${endpoint.id}`,
|
||||
sql`${table.idempotencyKey} like 'run:' || ${heartbeatRuns.id}::text || ':working:' || ${endpoint.id} || ':native:%'`,
|
||||
);
|
||||
// A historical progress row alone is not edit authority: its exact current
|
||||
// outbound link must still belong to that run's progress (or an interleaved
|
||||
// status control). Read under the same credential lease as final delivery.
|
||||
const lanes = await db
|
||||
.selectDistinct({
|
||||
providerMessageId: chatMessageLinks.providerMessageId,
|
||||
runId: heartbeatRuns.id,
|
||||
})
|
||||
.from(chatPublications)
|
||||
.innerJoin(
|
||||
heartbeatRuns,
|
||||
and(
|
||||
eq(heartbeatRuns.companyId, publication.companyId),
|
||||
eq(heartbeatRuns.agentId, endpoint.assignedAgentId),
|
||||
sql`${heartbeatRuns.contextSnapshot}->>'issueId' = ${publication.issueId}`,
|
||||
sql`split_part(${chatPublications.idempotencyKey}, ':', 2) = ${heartbeatRuns.id}::text`,
|
||||
),
|
||||
)
|
||||
.innerJoin(
|
||||
chatMessageLinks,
|
||||
and(
|
||||
eq(chatMessageLinks.companyId, publication.companyId),
|
||||
eq(chatMessageLinks.endpointId, publication.endpointId),
|
||||
eq(chatMessageLinks.conversationId, publication.conversationId),
|
||||
eq(chatMessageLinks.direction, "outbound"),
|
||||
eq(
|
||||
chatMessageLinks.providerMessageId,
|
||||
chatPublications.providerMessageId,
|
||||
),
|
||||
),
|
||||
)
|
||||
.innerJoin(
|
||||
current,
|
||||
and(
|
||||
eq(current.id, chatMessageLinks.publicationId),
|
||||
eq(current.companyId, publication.companyId),
|
||||
eq(current.endpointId, publication.endpointId),
|
||||
eq(current.conversationId, publication.conversationId),
|
||||
eq(current.issueId, publication.issueId),
|
||||
eq(current.state, "published"),
|
||||
eq(current.providerMessageId, chatMessageLinks.providerMessageId),
|
||||
isNull(current.commentId),
|
||||
plainText(current),
|
||||
or(
|
||||
and(
|
||||
sql`${current.payload}->>'progressState' in ('queued', 'working')`,
|
||||
ownedMilestone(current),
|
||||
),
|
||||
like(current.idempotencyKey, "control:status:%"),
|
||||
),
|
||||
),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(chatPublications.companyId, publication.companyId),
|
||||
eq(chatPublications.endpointId, publication.endpointId),
|
||||
eq(chatPublications.conversationId, publication.conversationId),
|
||||
eq(chatPublications.issueId, publication.issueId),
|
||||
eq(chatPublications.state, "published"),
|
||||
isNull(chatPublications.commentId),
|
||||
plainText(chatPublications),
|
||||
sql`${chatPublications.payload}->>'progressState' in ('queued', 'working')`,
|
||||
ownedMilestone(chatPublications),
|
||||
),
|
||||
)
|
||||
.limit(2);
|
||||
// One close receipt owns one edit. Multiple independent lanes require a
|
||||
// separate durable multi-effect protocol, never an arbitrary latest guess.
|
||||
if (lanes.length !== 1) return null;
|
||||
const lane = lanes[0]!;
|
||||
const [possiblyConsumed] = await db
|
||||
.select({ id: chatPublications.id })
|
||||
.from(chatPublications)
|
||||
.leftJoin(
|
||||
issueComments,
|
||||
and(
|
||||
eq(issueComments.id, chatPublications.commentId),
|
||||
eq(issueComments.companyId, publication.companyId),
|
||||
eq(issueComments.issueId, publication.issueId),
|
||||
),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(chatPublications.companyId, publication.companyId),
|
||||
eq(chatPublications.endpointId, publication.endpointId),
|
||||
eq(chatPublications.conversationId, publication.conversationId),
|
||||
eq(chatPublications.issueId, publication.issueId),
|
||||
or(
|
||||
inArray(chatPublications.state, [
|
||||
"pending",
|
||||
"retry",
|
||||
"streaming",
|
||||
"delivery_unknown",
|
||||
"published",
|
||||
]),
|
||||
// Stopping retries (including an operator's unknown-delivery
|
||||
// cancellation) does not prove an attempted edit never arrived.
|
||||
and(
|
||||
inArray(chatPublications.state, ["cancelled", "failed"]),
|
||||
gt(chatPublications.attempts, 0),
|
||||
),
|
||||
),
|
||||
or(
|
||||
eq(issueComments.createdByRunId, lane.runId),
|
||||
// Question/confirmation prompts consume this same run's progress
|
||||
// ID through interactionPromptPublicationToReplace, but have no
|
||||
// commentId. A stale working link is not proof that their edit
|
||||
// never reached the provider (including an unconfirmed receipt).
|
||||
sql`exists (
|
||||
select 1 from issue_thread_interactions consuming_interaction
|
||||
where consuming_interaction.company_id = ${publication.companyId}::uuid
|
||||
and consuming_interaction.issue_id = ${publication.issueId}::uuid
|
||||
and consuming_interaction.source_run_id = ${lane.runId}::uuid
|
||||
and consuming_interaction.kind in ('ask_user_questions', 'request_confirmation')
|
||||
and ${chatPublications.payload}->>'interactionId' = consuming_interaction.id::text
|
||||
and ${chatPublications.idempotencyKey} = 'interaction:' || consuming_interaction.id::text || ':' || ${publication.endpointId}
|
||||
)`,
|
||||
eq(
|
||||
chatPublications.idempotencyKey,
|
||||
`run:${lane.runId}:completed:${endpoint.id}`,
|
||||
),
|
||||
eq(
|
||||
chatPublications.idempotencyKey,
|
||||
`run:${lane.runId}:failed:${endpoint.id}`,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
// A final may have reached the provider before its database receipt. Even an
|
||||
// unchanged working link cannot disprove that ambiguous provider effect.
|
||||
return possiblyConsumed ? null : lane;
|
||||
}
|
||||
|
||||
return { runIdFromMilestonePublication, runOwnershipMilestoneSupersessionReason, runPublicationToReplace, receiptReactionCompletionRunId, inboundWakePublicationToReplace, interactionResolutionPublicationToReplace, interactionPromptPublicationToReplace, progressSupersededByFastResponse, runProgressSupersededByPublishedInteraction, taskStatusPublicationToReplace, closeProgressToReplace };
|
||||
}
|
||||
@@ -0,0 +1,613 @@
|
||||
import { imessageResourceParticipants } from "./imessage-photon/resources.js";
|
||||
import { createGitHubResources, reconcileGitHubRepositoryCoordinates } from "./github/resources.js";
|
||||
import { baseTeamsConversationId } from "./microsoft-teams/resources.js";
|
||||
import { slackResourceLabelIsFallback } from "./slack/resources.js";
|
||||
import { telegramResourceLabelIsFallback, prepareTelegramResourceMigration, migrateTelegramResourceConversations } from "./telegram/resources.js";
|
||||
import { and, asc, eq, inArray, ne, sql } from "drizzle-orm";
|
||||
import { chatConversations, chatDeliveries, chatEndpointResources, chatEndpoints, toolConnections, type Db } from "@paperclipai/db";
|
||||
import { assertChatExecutionDefaultsAccess } from "../chat-execution-defaults.js";
|
||||
import type { ChatExecutionDefaults, ChatEndpointSetupState } from "@paperclipai/shared";
|
||||
import { badRequest, conflict, notFound, unprocessable } from "../../errors.js";
|
||||
import { redactSensitiveText } from "../../redaction.js";
|
||||
import { notifyChatDeliveryWork, notifyChatEndpointWork } from "../chat-work-notifications.js";
|
||||
import { logActivity, publishActivity, type ActivityPublication } from "../activity-log.js";
|
||||
import type { CredentialMutationLeaseGuard, chatCredentialMutationLease } from "../chat-credential-mutation-lease.js";
|
||||
import type { ChatProviderInventoryResult, ChatProviderResourceInventoryItem } from "../chat-provider-inventory.js";
|
||||
import type { ChatProviderLifecycleEffect } from "../chat-provider-lifecycle.js";
|
||||
|
||||
type EndpointRow = typeof chatEndpoints.$inferSelect;
|
||||
type DbTransaction = Parameters<Parameters<Db["transaction"]>[0]>[0];
|
||||
type InternalSetupState = ChatEndpointSetupState & { runtimeGeneration?: number };
|
||||
|
||||
type ResourceReconciliationOptions = {
|
||||
endpointRecord: (endpointId: string) => Promise<{ endpoint: EndpointRow } | null>;
|
||||
withCredentialMutationLease: ReturnType<typeof chatCredentialMutationLease>;
|
||||
runtimeGeneration: (setup: ChatEndpointSetupState) => number;
|
||||
maxErrorText: number;
|
||||
};
|
||||
|
||||
export function createChatResourceReconciliation(db: Db, options: ResourceReconciliationOptions) {
|
||||
const { endpointRecord, withCredentialMutationLease, runtimeGeneration, maxErrorText: MAX_ERROR_TEXT } = options;
|
||||
|
||||
async function upsertProviderResourceRow(
|
||||
tx: DbTransaction,
|
||||
endpoint: EndpointRow,
|
||||
item: ChatProviderResourceInventoryItem,
|
||||
) {
|
||||
if (endpoint.provider === "github") {
|
||||
const renamed = await reconcileGitHubRepositoryCoordinates(tx, endpoint, item);
|
||||
if (renamed) return renamed;
|
||||
}
|
||||
|
||||
const [resource] = await tx
|
||||
.insert(chatEndpointResources)
|
||||
.values({
|
||||
companyId: endpoint.companyId,
|
||||
endpointId: endpoint.id,
|
||||
type: item.type,
|
||||
providerResourceId: item.providerResourceId,
|
||||
parentProviderResourceId: item.parentProviderResourceId ?? null,
|
||||
label: item.label,
|
||||
providerUrl: item.providerUrl ?? null,
|
||||
availability: "available",
|
||||
// Slack inventory contains only channels the bot has joined. Apply the
|
||||
// invitation default on insert; conflict updates preserve operator choices.
|
||||
enabled:
|
||||
endpoint.provider === "slack" &&
|
||||
item.type === "channel" &&
|
||||
item.metadata?.creator !== endpoint.botExternalId,
|
||||
metadata: item.metadata ?? {},
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [
|
||||
chatEndpointResources.endpointId,
|
||||
chatEndpointResources.type,
|
||||
chatEndpointResources.providerResourceId,
|
||||
],
|
||||
set: {
|
||||
parentProviderResourceId: item.parentProviderResourceId ?? null,
|
||||
label: item.label,
|
||||
providerUrl: item.providerUrl ?? null,
|
||||
availability: "available",
|
||||
metadata: item.metadata ?? {},
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
})
|
||||
.returning({ id: chatEndpointResources.id });
|
||||
return resource ?? null;
|
||||
}
|
||||
|
||||
async function reconcileProviderResourceRows(
|
||||
endpoint: EndpointRow,
|
||||
inventory: ChatProviderInventoryResult,
|
||||
credentialLease: CredentialMutationLeaseGuard,
|
||||
) {
|
||||
const resourceType =
|
||||
endpoint.provider === "github" ? "repository" : "channel";
|
||||
const discovered = new Set(
|
||||
inventory.resources.map((resource) => resource.providerResourceId),
|
||||
);
|
||||
const absentAvailability =
|
||||
endpoint.provider === "github" ? "removed" : "unavailable";
|
||||
await db.transaction(async (tx) => {
|
||||
await credentialLease.assertOwned(tx);
|
||||
for (const item of inventory.resources) {
|
||||
const resource = await upsertProviderResourceRow(tx, endpoint, item);
|
||||
if (resource) {
|
||||
// Resource recovery reopens only bindings that Paperclip marked
|
||||
// unavailable. Completed historical tasks stay completed.
|
||||
await tx
|
||||
.update(chatConversations)
|
||||
.set({ state: "active", updatedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(chatConversations.companyId, endpoint.companyId),
|
||||
eq(chatConversations.endpointId, endpoint.id),
|
||||
eq(chatConversations.resourceId, resource.id),
|
||||
eq(chatConversations.state, "unavailable"),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const existing = await tx
|
||||
.select({
|
||||
id: chatEndpointResources.id,
|
||||
providerResourceId: chatEndpointResources.providerResourceId,
|
||||
})
|
||||
.from(chatEndpointResources)
|
||||
.where(
|
||||
and(
|
||||
eq(chatEndpointResources.companyId, endpoint.companyId),
|
||||
eq(chatEndpointResources.endpointId, endpoint.id),
|
||||
eq(chatEndpointResources.type, resourceType),
|
||||
),
|
||||
);
|
||||
for (const resource of existing) {
|
||||
if (discovered.has(resource.providerResourceId)) continue;
|
||||
await tx
|
||||
.update(chatEndpointResources)
|
||||
.set({ availability: absentAvailability, updatedAt: new Date() })
|
||||
.where(eq(chatEndpointResources.id, resource.id));
|
||||
await tx
|
||||
.update(chatConversations)
|
||||
.set({ state: "unavailable", updatedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(chatConversations.companyId, endpoint.companyId),
|
||||
eq(chatConversations.endpointId, endpoint.id),
|
||||
eq(chatConversations.resourceId, resource.id),
|
||||
inArray(chatConversations.state, ["active", "waiting"]),
|
||||
),
|
||||
);
|
||||
}
|
||||
await credentialLease.assertOwned(tx);
|
||||
});
|
||||
}
|
||||
|
||||
async function listResources(endpointId: string) {
|
||||
const resources = await db
|
||||
.select()
|
||||
.from(chatEndpointResources)
|
||||
.where(
|
||||
and(
|
||||
eq(chatEndpointResources.endpointId, endpointId),
|
||||
ne(chatEndpointResources.type, "direct_message"),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(chatEndpointResources.label));
|
||||
return resources.map((resource) => ({
|
||||
...resource,
|
||||
participants: imessageResourceParticipants(resource),
|
||||
}));
|
||||
}
|
||||
|
||||
async function replaceResources(
|
||||
endpointId: string,
|
||||
updates: Array<{ id: string; enabled?: boolean; executionDefaults?: ChatExecutionDefaults | null }>,
|
||||
actorUserId?: string | null,
|
||||
options?: { initialGitHubImport?: boolean },
|
||||
) {
|
||||
await updateResourceSelection(endpointId, updates, actorUserId, options);
|
||||
return listResources(endpointId);
|
||||
}
|
||||
|
||||
async function updateResourceSelection(
|
||||
endpointId: string,
|
||||
updates: Array<{ id: string; enabled?: boolean; executionDefaults?: ChatExecutionDefaults | null }>,
|
||||
actorUserId?: string | null,
|
||||
options?: { initialGitHubImport?: boolean; allGitHubRepositoriesEnabled?: boolean },
|
||||
) {
|
||||
const initial = await endpointRecord(endpointId);
|
||||
if (!initial) throw notFound("Chat endpoint not found");
|
||||
const bulk = options?.allGitHubRepositoriesEnabled !== undefined;
|
||||
if (bulk && initial.endpoint.provider !== "github") throw badRequest("Only GitHub repositories support toggle all");
|
||||
if (updates.length === 0 && initial.endpoint.provider !== "github") return;
|
||||
if (initial.endpoint.provider === "imessage-photon" && initial.endpoint.botExternalId?.startsWith("photon-project:") && updates.some((entry) => entry.enabled))
|
||||
throw unprocessable("Photon shared channels support direct messages only; groups cannot be enabled");
|
||||
await withCredentialMutationLease(
|
||||
initial.endpoint,
|
||||
async (credentialLease) => {
|
||||
const ids = updates.map((entry) => entry.id);
|
||||
const publications: ActivityPublication[] = [];
|
||||
await db.transaction(async (tx) => {
|
||||
await credentialLease.assertOwned(tx);
|
||||
const [endpoint] = await tx
|
||||
.select({
|
||||
companyId: chatEndpoints.companyId,
|
||||
assignedAgentId: chatEndpoints.assignedAgentId,
|
||||
connectionId: chatEndpoints.connectionId,
|
||||
provider: chatEndpoints.provider,
|
||||
setup: chatEndpoints.setup,
|
||||
})
|
||||
.from(chatEndpoints)
|
||||
.where(
|
||||
and(
|
||||
eq(chatEndpoints.id, endpointId),
|
||||
eq(chatEndpoints.companyId, initial.endpoint.companyId),
|
||||
),
|
||||
)
|
||||
.for("no key update");
|
||||
if (!endpoint) throw notFound("Chat endpoint not found");
|
||||
if (options?.initialGitHubImport && endpoint.provider === "github" && (endpoint.setup.github?.repositorySelectionSaved || !endpoint.setup.github?.initialRepositoryImportPending)) return;
|
||||
const rows = await tx
|
||||
.select({
|
||||
id: chatEndpointResources.id,
|
||||
availability: chatEndpointResources.availability,
|
||||
enabled: chatEndpointResources.enabled,
|
||||
executionDefaults: chatEndpointResources.executionDefaults,
|
||||
})
|
||||
.from(chatEndpointResources)
|
||||
.where(
|
||||
and(
|
||||
eq(chatEndpointResources.companyId, endpoint.companyId),
|
||||
eq(chatEndpointResources.endpointId, endpointId),
|
||||
bulk
|
||||
? and(
|
||||
eq(chatEndpointResources.type, "repository"),
|
||||
options!.allGitHubRepositoriesEnabled
|
||||
? eq(chatEndpointResources.availability, "available")
|
||||
: undefined,
|
||||
)
|
||||
: inArray(chatEndpointResources.id, ids),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(chatEndpointResources.id))
|
||||
.for("no key update");
|
||||
if (!bulk && rows.length !== new Set(ids).size)
|
||||
throw unprocessable("Every resource must belong to this endpoint");
|
||||
const selectedUpdates: typeof updates = bulk
|
||||
? rows.map((row) => ({ id: row.id, enabled: options!.allGitHubRepositoriesEnabled! }))
|
||||
: updates;
|
||||
const availabilityById = new Map(
|
||||
rows.map((row) => [row.id, row.availability]),
|
||||
);
|
||||
// Validate every submitted grant, including intermediate duplicate
|
||||
// entries. Netting below describes the audit, not new authority.
|
||||
const unavailable = selectedUpdates.find(
|
||||
(entry) =>
|
||||
entry.enabled && availabilityById.get(entry.id) !== "available",
|
||||
);
|
||||
if (unavailable)
|
||||
throw conflict(
|
||||
"A destination must still be available from the provider before it can be enabled",
|
||||
{ code: "chat_resource_unavailable", resourceId: unavailable.id },
|
||||
);
|
||||
const finalEnabled = new Map(rows.map((row) => [row.id, row.enabled]));
|
||||
for (const entry of selectedUpdates) {
|
||||
if (entry.enabled !== undefined) finalEnabled.set(entry.id, entry.enabled);
|
||||
}
|
||||
const finalDefaults = new Map(rows.map((row) => [row.id, row.executionDefaults]));
|
||||
for (const entry of selectedUpdates) {
|
||||
if (entry.executionDefaults === undefined) continue;
|
||||
await assertChatExecutionDefaultsAccess(db, {
|
||||
companyId: endpoint.companyId,
|
||||
assigneeAgentId: endpoint.assignedAgentId,
|
||||
actor: { type: "board", userId: actorUserId, source: actorUserId ? "session" : "local_implicit" },
|
||||
defaults: entry.executionDefaults,
|
||||
}, tx);
|
||||
finalDefaults.set(entry.id, entry.executionDefaults);
|
||||
}
|
||||
const changes = rows
|
||||
.filter((row) => row.enabled !== finalEnabled.get(row.id) || JSON.stringify(row.executionDefaults) !== JSON.stringify(finalDefaults.get(row.id)))
|
||||
.map((row) => ({
|
||||
resourceId: row.id,
|
||||
before: { enabled: row.enabled, ...(JSON.stringify(row.executionDefaults) !== JSON.stringify(finalDefaults.get(row.id)) ? { executionDefaults: row.executionDefaults } : {}) },
|
||||
after: { enabled: finalEnabled.get(row.id)!, ...(JSON.stringify(row.executionDefaults) !== JSON.stringify(finalDefaults.get(row.id)) ? { executionDefaults: finalDefaults.get(row.id) } : {}) },
|
||||
}));
|
||||
if (bulk) {
|
||||
// One atomic database update, independent of pagination/search and
|
||||
// serialized with refresh, reconnect, and individual row changes.
|
||||
await tx.update(chatEndpointResources)
|
||||
.set({ enabled: options!.allGitHubRepositoriesEnabled!, updatedAt: new Date() })
|
||||
.where(and(
|
||||
eq(chatEndpointResources.companyId, endpoint.companyId),
|
||||
eq(chatEndpointResources.endpointId, endpointId),
|
||||
eq(chatEndpointResources.type, "repository"),
|
||||
options!.allGitHubRepositoriesEnabled
|
||||
? eq(chatEndpointResources.availability, "available")
|
||||
: undefined,
|
||||
));
|
||||
} else for (const entry of selectedUpdates)
|
||||
await tx
|
||||
.update(chatEndpointResources)
|
||||
.set({ enabled: entry.enabled, ...(entry.executionDefaults !== undefined ? { executionDefaults: entry.executionDefaults } : {}), updatedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(chatEndpointResources.companyId, endpoint.companyId),
|
||||
eq(chatEndpointResources.endpointId, endpointId),
|
||||
eq(chatEndpointResources.id, entry.id),
|
||||
),
|
||||
);
|
||||
if (endpoint.provider === "github")
|
||||
await tx.update(chatEndpoints).set({ setup: sql`jsonb_set(${chatEndpoints.setup}, '{github}', coalesce(${chatEndpoints.setup}->'github', '{}'::jsonb) || '{"repositorySelectionSaved":true,"initialRepositoryImportPending":false}'::jsonb)`, updatedAt: new Date() }).where(eq(chatEndpoints.id, endpointId));
|
||||
if (changes.length > 0 || (endpoint.provider === "github" && !endpoint.setup.github?.repositorySelectionSaved))
|
||||
await logActivity(
|
||||
tx as unknown as Db,
|
||||
{
|
||||
companyId: endpoint.companyId,
|
||||
actorType: "user",
|
||||
actorId: actorUserId ?? "board",
|
||||
action: "chat_endpoint.resources_updated",
|
||||
entityType: "tool_connection",
|
||||
entityId: endpoint.connectionId,
|
||||
details: { endpointId, provider: endpoint.provider, changes },
|
||||
},
|
||||
publications,
|
||||
);
|
||||
await credentialLease.assertOwned(tx);
|
||||
});
|
||||
// A committed reach change is factual even if the outer lease's last
|
||||
// check subsequently fails. Never publish an uncommitted activity.
|
||||
for (const publication of publications) publishActivity(publication);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function hasNewerProcessedProviderLifecycleEffect(
|
||||
endpoint: EndpointRow,
|
||||
effect: Extract<ChatProviderLifecycleEffect, { kind: "resource" }>,
|
||||
): Promise<boolean> {
|
||||
const sequence = effect.providerOrder?.sequence;
|
||||
const occurredAt = effect.providerOrder?.occurredAt;
|
||||
if (!sequence && !occurredAt) return false;
|
||||
const providerResourceId =
|
||||
endpoint.provider === "microsoft-teams"
|
||||
? baseTeamsConversationId(effect.providerResourceId)
|
||||
: effect.providerResourceId;
|
||||
const newerOrder = sequence
|
||||
? sql`(
|
||||
${chatDeliveries.normalizedEvent}#>>'{lifecycle,providerOrder,sequence}' ~ '^[0-9]+([.][0-9]+)?$'
|
||||
and (${chatDeliveries.normalizedEvent}#>>'{lifecycle,providerOrder,sequence}')::numeric > cast(${sequence} as numeric)
|
||||
)`
|
||||
: sql`(
|
||||
nullif(${chatDeliveries.normalizedEvent}#>>'{lifecycle,providerOrder,occurredAt}', '')::timestamptz > cast(${occurredAt!} as timestamptz)
|
||||
)`;
|
||||
const newer = await db
|
||||
.select({ id: chatDeliveries.id })
|
||||
.from(chatDeliveries)
|
||||
.where(
|
||||
and(
|
||||
eq(chatDeliveries.endpointId, endpoint.id),
|
||||
eq(chatDeliveries.state, "processed"),
|
||||
inArray(chatDeliveries.eventKind, ["installation", "uninstallation"]),
|
||||
sql`${chatDeliveries.normalizedEvent}#>>'{lifecycle,kind}' = 'resource'`,
|
||||
sql`${chatDeliveries.normalizedEvent}#>>'{lifecycle,provider}' = ${effect.provider}`,
|
||||
sql`${chatDeliveries.normalizedEvent}#>>'{lifecycle,providerResourceId}' = ${providerResourceId}`,
|
||||
newerOrder,
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
return newer.length > 0;
|
||||
}
|
||||
|
||||
async function applyProviderAvailability(input: {
|
||||
currentEndpoint: EndpointRow;
|
||||
effect: ChatProviderLifecycleEffect;
|
||||
credentialLease: CredentialMutationLeaseGuard;
|
||||
staleResourceEffect: boolean;
|
||||
claimed: Pick<typeof chatDeliveries.$inferSelect, "id">;
|
||||
candidate: Pick<typeof chatDeliveries.$inferSelect, "attempts">;
|
||||
}): Promise<void> {
|
||||
const { currentEndpoint, effect, credentialLease, staleResourceEffect, claimed, candidate } = input;
|
||||
await db.transaction(async (tx) => {
|
||||
await credentialLease.assertOwned(tx);
|
||||
const now = new Date();
|
||||
if (staleResourceEffect) {
|
||||
// Persisted provider-native ordering wins over request arrival or
|
||||
// lease acquisition order. The older callback remains auditable,
|
||||
// but cannot roll a resource or conversation back.
|
||||
await tx
|
||||
.update(chatEndpoints)
|
||||
.set({ lastEventAt: now, updatedAt: now })
|
||||
.where(eq(chatEndpoints.id, currentEndpoint.id));
|
||||
await notifyChatDeliveryWork(tx);
|
||||
await tx
|
||||
.update(chatDeliveries)
|
||||
.set({
|
||||
state: "processed",
|
||||
processedAt: now,
|
||||
redactedError: null,
|
||||
nextAttemptAt: null,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(chatDeliveries.id, claimed.id));
|
||||
return;
|
||||
}
|
||||
if (effect.kind === "resource") {
|
||||
const providerResourceId =
|
||||
currentEndpoint.provider === "microsoft-teams"
|
||||
? baseTeamsConversationId(effect.providerResourceId)
|
||||
: effect.providerResourceId;
|
||||
const previousProviderResourceId =
|
||||
currentEndpoint.provider === "telegram"
|
||||
? effect.previousProviderResourceId
|
||||
: undefined;
|
||||
const { previousResource, migratedLabel, migratedEnabled } = await prepareTelegramResourceMigration(
|
||||
tx, currentEndpoint, effect, providerResourceId, previousProviderResourceId, now,
|
||||
);
|
||||
const preserveProviderLabel =
|
||||
(currentEndpoint.provider === "slack" &&
|
||||
slackResourceLabelIsFallback(
|
||||
providerResourceId,
|
||||
migratedLabel,
|
||||
)) ||
|
||||
(currentEndpoint.provider === "telegram" &&
|
||||
telegramResourceLabelIsFallback(
|
||||
providerResourceId,
|
||||
migratedLabel,
|
||||
));
|
||||
const [resource] = await tx
|
||||
.insert(chatEndpointResources)
|
||||
.values({
|
||||
companyId: currentEndpoint.companyId,
|
||||
endpointId: currentEndpoint.id,
|
||||
type: effect.resourceType,
|
||||
providerResourceId,
|
||||
parentProviderResourceId:
|
||||
effect.parentProviderResourceId ?? null,
|
||||
label: migratedLabel,
|
||||
providerUrl: effect.providerUrl ?? null,
|
||||
availability: effect.availability,
|
||||
enabled:
|
||||
migratedEnabled ||
|
||||
(currentEndpoint.provider === "slack" &&
|
||||
effect.resourceType === "channel" &&
|
||||
effect.availability === "available"),
|
||||
metadata: effect.metadata ?? {},
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [
|
||||
chatEndpointResources.endpointId,
|
||||
chatEndpointResources.type,
|
||||
chatEndpointResources.providerResourceId,
|
||||
],
|
||||
set: {
|
||||
parentProviderResourceId:
|
||||
effect.parentProviderResourceId ?? null,
|
||||
...(preserveProviderLabel ? {} : { label: migratedLabel }),
|
||||
providerUrl: effect.providerUrl ?? null,
|
||||
availability: effect.availability,
|
||||
...(previousProviderResourceId
|
||||
? {
|
||||
enabled: sql<boolean>`${chatEndpointResources.enabled} or ${migratedEnabled}`,
|
||||
}
|
||||
: {}),
|
||||
metadata: effect.metadata ?? {},
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
.returning({ id: chatEndpointResources.id });
|
||||
if (
|
||||
resource &&
|
||||
previousResource &&
|
||||
previousProviderResourceId !== providerResourceId
|
||||
) {
|
||||
await migrateTelegramResourceConversations(tx, currentEndpoint, previousResource, resource, previousProviderResourceId, providerResourceId, migratedLabel, now);
|
||||
}
|
||||
if (resource) {
|
||||
await tx
|
||||
.update(chatConversations)
|
||||
.set({
|
||||
state:
|
||||
effect.availability === "available"
|
||||
? "active"
|
||||
: "unavailable",
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(chatConversations.companyId, currentEndpoint.companyId),
|
||||
eq(chatConversations.endpointId, currentEndpoint.id),
|
||||
eq(chatConversations.resourceId, resource.id),
|
||||
effect.availability === "available"
|
||||
? eq(chatConversations.state, "unavailable")
|
||||
: inArray(chatConversations.state, ["active", "waiting"]),
|
||||
),
|
||||
);
|
||||
}
|
||||
await tx
|
||||
.update(chatEndpoints)
|
||||
.set({ lastEventAt: now, updatedAt: now })
|
||||
.where(eq(chatEndpoints.id, currentEndpoint.id));
|
||||
} else {
|
||||
const reason = redactSensitiveText(effect.reason).slice(
|
||||
0,
|
||||
MAX_ERROR_TEXT,
|
||||
);
|
||||
if (effect.availability === "available") {
|
||||
if (
|
||||
currentEndpoint.status !== "paused" &&
|
||||
currentEndpoint.status !== "archived"
|
||||
) {
|
||||
const status =
|
||||
currentEndpoint.setup.step === "complete"
|
||||
? "active"
|
||||
: currentEndpoint.status === "attention"
|
||||
? "verifying"
|
||||
: currentEndpoint.status;
|
||||
if (status !== currentEndpoint.status) await notifyChatEndpointWork(tx);
|
||||
await tx
|
||||
.update(chatEndpoints)
|
||||
.set({
|
||||
status,
|
||||
healthMessage:
|
||||
status === "active"
|
||||
? "Connected"
|
||||
: "Waiting for a test conversation",
|
||||
lastError: null,
|
||||
lastEventAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(chatEndpoints.id, currentEndpoint.id));
|
||||
await tx
|
||||
.update(toolConnections)
|
||||
.set({
|
||||
status: "active",
|
||||
enabled: true,
|
||||
healthStatus: "healthy",
|
||||
healthMessage: "Connected",
|
||||
lastError: null,
|
||||
healthCheckedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(toolConnections.id, currentEndpoint.connectionId));
|
||||
}
|
||||
} else {
|
||||
await tx
|
||||
.update(chatEndpoints)
|
||||
.set({
|
||||
status: effect.availability,
|
||||
healthMessage: reason,
|
||||
lastError: reason,
|
||||
lastEventAt: now,
|
||||
setup: {
|
||||
...currentEndpoint.setup,
|
||||
runtimeGeneration:
|
||||
runtimeGeneration(currentEndpoint.setup) + 1,
|
||||
} as InternalSetupState,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(chatEndpoints.id, currentEndpoint.id));
|
||||
await tx
|
||||
.update(toolConnections)
|
||||
.set({
|
||||
status: "disabled",
|
||||
enabled: false,
|
||||
healthStatus:
|
||||
effect.availability === "revoked" ? "failed" : "degraded",
|
||||
healthMessage: reason,
|
||||
lastError: reason,
|
||||
healthCheckedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(toolConnections.id, currentEndpoint.connectionId));
|
||||
await tx
|
||||
.update(chatEndpointResources)
|
||||
.set({ availability: "unavailable", updatedAt: now })
|
||||
.where(
|
||||
and(
|
||||
eq(
|
||||
chatEndpointResources.companyId,
|
||||
currentEndpoint.companyId,
|
||||
),
|
||||
eq(chatEndpointResources.endpointId, currentEndpoint.id),
|
||||
),
|
||||
);
|
||||
await tx
|
||||
.update(chatConversations)
|
||||
.set({ state: "unavailable", updatedAt: now })
|
||||
.where(
|
||||
and(
|
||||
eq(chatConversations.companyId, currentEndpoint.companyId),
|
||||
eq(chatConversations.endpointId, currentEndpoint.id),
|
||||
inArray(chatConversations.state, ["active", "waiting"]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
await notifyChatDeliveryWork(tx);
|
||||
await tx
|
||||
.update(chatDeliveries)
|
||||
.set({
|
||||
state: "processed",
|
||||
processedAt: now,
|
||||
redactedError: null,
|
||||
nextAttemptAt: null,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(chatDeliveries.id, claimed.id),
|
||||
eq(chatDeliveries.state, "processing"),
|
||||
eq(chatDeliveries.attempts, candidate.attempts + 1),
|
||||
),
|
||||
);
|
||||
await credentialLease.assertOwned(tx);
|
||||
});
|
||||
}
|
||||
|
||||
const { reconcileGitHubWebhookRepository, listGitHubRepositories, toggleAllGitHubRepositories } = createGitHubResources(db, { endpointRecord, upsertProviderResourceRow, updateResourceSelection });
|
||||
|
||||
return { reconcileGitHubWebhookRepository, reconcileProviderResourceRows, listResources, listGitHubRepositories, replaceResources, toggleAllGitHubRepositories, hasNewerProcessedProviderLifecycleEffect, applyProviderAvailability };
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { and, desc, eq, gt, or, sql } from "drizzle-orm";
|
||||
|
||||
import type { Db } from "@paperclipai/db";
|
||||
|
||||
import { authUsers, chatActions, chatEndpoints, chatIdentityLinks, invites, joinRequests } from "@paperclipai/db";
|
||||
|
||||
import { conflict, forbidden, unprocessable } from "../../../errors.js";
|
||||
|
||||
import { logActivity } from "../../activity-log.js";
|
||||
|
||||
type EndpointRow = typeof chatEndpoints.$inferSelect;
|
||||
type DbOrTransaction = Db | Parameters<Parameters<Db["transaction"]>[0]>[0];
|
||||
type ConfirmationRuntime = { credentialFingerprint: string; generation: number };
|
||||
export type SlackIdentityEffectStager = (database: DbOrTransaction, input: {
|
||||
endpoint: EndpointRow; principalId: string; providerActionId: string;
|
||||
payload: { version: 1; effect: "ephemeral_message"; authorizationMode: "safe_notice"; threadId: string; userId: string; text: string; settleDelivery: false };
|
||||
runtimeContext: ConfirmationRuntime;
|
||||
}) => Promise<{ id: string } | null>;
|
||||
|
||||
type SlackIdentityLinkOptions = {
|
||||
previewIdentityLink: (token: string, userId: string) => Promise<{ selfService: boolean; canConfirm: boolean; companyId: string; endpointId: string }>;
|
||||
stageProviderEffect: SlackIdentityEffectStager;
|
||||
};
|
||||
|
||||
export function createSlackIdentityLinks(db: Db, { previewIdentityLink, stageProviderEffect }: SlackIdentityLinkOptions) {
|
||||
function listConnects(endpoint: EndpointRow) {
|
||||
const endpointId = endpoint.id;
|
||||
return db
|
||||
.select({ principalId: chatActions.principalId, lastConnectAt: sql<string>`max(${chatActions.createdAt})::text` })
|
||||
.from(chatActions)
|
||||
.where(and(
|
||||
eq(chatActions.companyId, endpoint.companyId),
|
||||
eq(chatActions.endpointId, endpointId),
|
||||
eq(chatActions.kind, "slack_connect"),
|
||||
eq(chatActions.status, "processed"),
|
||||
))
|
||||
.groupBy(chatActions.principalId);
|
||||
}
|
||||
|
||||
async function isSelfService(link: typeof chatIdentityLinks.$inferSelect, tokenHash: string) {
|
||||
const selfService = Boolean(await db.select({ id: chatActions.id }).from(chatActions).where(and(
|
||||
eq(chatActions.companyId, link.companyId), eq(chatActions.endpointId, link.endpointId),
|
||||
eq(chatActions.principalId, link.principalId), eq(chatActions.kind, "slack_connect"),
|
||||
sql`${chatActions.payload}->>'identityLinkHash' = ${tokenHash}`,
|
||||
)).limit(1).then((rows) => rows[0]));
|
||||
return selfService;
|
||||
}
|
||||
|
||||
function latestConnect(link: typeof chatIdentityLinks.$inferSelect) {
|
||||
return db.select({ payload: chatActions.payload }).from(chatActions).where(and(
|
||||
eq(chatActions.endpointId, link.endpointId), eq(chatActions.principalId, link.principalId), eq(chatActions.kind, "slack_connect"),
|
||||
)).orderBy(desc(chatActions.createdAt)).limit(1).then((rows) => rows[0]);
|
||||
}
|
||||
|
||||
async function stageConfirmation(tx: DbOrTransaction, input: {
|
||||
endpoint: EndpointRow; link: typeof chatIdentityLinks.$inferSelect; tokenHash: string;
|
||||
channelId: string; userId: string; confirmationRuntime: ConfirmationRuntime;
|
||||
}) {
|
||||
const { endpoint, link, tokenHash, channelId, userId, confirmationRuntime } = input;
|
||||
const effect = await stageProviderEffect(tx, {
|
||||
endpoint: endpoint, principalId: link.principalId,
|
||||
providerActionId: `provider_effect:identity_linked:${link.id}:${tokenHash}`,
|
||||
payload: {
|
||||
version: 1, effect: "ephemeral_message", authorizationMode: "safe_notice",
|
||||
threadId: channelId, userId: userId,
|
||||
text: "Your Slack account is connected to Paperclip. Future messages use your Paperclip permissions.", settleDelivery: false
|
||||
},
|
||||
runtimeContext: confirmationRuntime,
|
||||
});
|
||||
if (!effect) throw conflict("The Slack connection changed; try confirming again");
|
||||
return effect.id;
|
||||
}
|
||||
|
||||
async function requestIdentityAccess(token: string, userId: string, requestIp: string) {
|
||||
const preview = await previewIdentityLink(token, userId);
|
||||
if (!preview.selfService) throw forbidden("This identity link cannot request access");
|
||||
if (preview.canConfirm) return { status: "member" as const };
|
||||
return db.transaction(async (tx) => {
|
||||
await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`chat-join:${preview.companyId}:${userId}`}, 0))`);
|
||||
const tokenHash = createHash("sha256").update(token).digest("hex");
|
||||
const validLink = await tx.select({ id: chatIdentityLinks.id }).from(chatIdentityLinks).where(and(
|
||||
eq(chatIdentityLinks.companyId, preview.companyId), eq(chatIdentityLinks.endpointId, preview.endpointId),
|
||||
eq(chatIdentityLinks.confirmationTokenHash, tokenHash), eq(chatIdentityLinks.status, "pending"), gt(chatIdentityLinks.expiresAt, new Date()),
|
||||
)).for("update").then((rows) => rows[0]);
|
||||
if (!validLink) throw unprocessable("This identity-link request is invalid or expired");
|
||||
const user = await tx.select({ email: authUsers.email }).from(authUsers).where(eq(authUsers.id, userId)).then((rows) => rows[0]);
|
||||
if (!user) throw forbidden("Sign in to request company access");
|
||||
const existing = await tx.select({ id: joinRequests.id }).from(joinRequests).where(and(
|
||||
eq(joinRequests.companyId, preview.companyId), eq(joinRequests.requestType, "human"), eq(joinRequests.status, "pending_approval"),
|
||||
or(eq(joinRequests.requestingUserId, userId), sql`lower(${joinRequests.requestEmailSnapshot}) = ${user.email.toLowerCase()}`),
|
||||
)).then((rows) => rows[0]);
|
||||
if (existing) return { status: "pending_approval" as const };
|
||||
const now = new Date();
|
||||
const [invite] = await tx.insert(invites).values({
|
||||
companyId: preview.companyId, tokenHash: createHash("sha256").update(randomBytes(32)).digest("hex"),
|
||||
inviteType: "company_join", allowedJoinTypes: "human", acceptedAt: now, expiresAt: now,
|
||||
defaultsPayload: { human: { role: "operator" }, source: "slack_identity_link" },
|
||||
}).returning({ id: invites.id });
|
||||
const [request] = await tx.insert(joinRequests).values({
|
||||
inviteId: invite.id, companyId: preview.companyId, requestType: "human", status: "pending_approval",
|
||||
requestIp, requestingUserId: userId, requestEmailSnapshot: user.email,
|
||||
}).returning({ id: joinRequests.id });
|
||||
await logActivity(tx as unknown as Db, {
|
||||
companyId: preview.companyId, actorType: "user", actorId: userId, action: "join.requested",
|
||||
entityType: "join_request", entityId: request.id, details: { requestType: "human", source: "slack_identity_link", endpointId: preview.endpointId },
|
||||
});
|
||||
return { status: "pending_approval" as const };
|
||||
});
|
||||
}
|
||||
|
||||
return { listConnects, isSelfService, latestConnect, stageConfirmation, requestIdentityAccess };
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export function slackResourceLabelIsFallback(
|
||||
providerResourceId: string,
|
||||
label: string,
|
||||
): boolean {
|
||||
const candidate = label.trim();
|
||||
return (
|
||||
candidate === providerResourceId ||
|
||||
candidate === `slack:${providerResourceId}`
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { chatConversations, chatEndpointResources, chatEndpoints, type Db } from "@paperclipai/db";
|
||||
import type { ChatProviderLifecycleEffect } from "../../chat-provider-lifecycle.js";
|
||||
type DbTransaction = Parameters<Parameters<Db["transaction"]>[0]>[0];
|
||||
type EndpointRow = typeof chatEndpoints.$inferSelect;
|
||||
|
||||
export function telegramResourceLabelIsFallback(
|
||||
providerResourceId: string,
|
||||
label: string,
|
||||
): boolean {
|
||||
const candidate = label.trim();
|
||||
return (
|
||||
candidate === providerResourceId ||
|
||||
candidate === `telegram:${providerResourceId}`
|
||||
);
|
||||
}
|
||||
|
||||
export async function prepareTelegramResourceMigration(
|
||||
tx: DbTransaction, currentEndpoint: EndpointRow,
|
||||
effect: Extract<ChatProviderLifecycleEffect, { kind: "resource" }>,
|
||||
providerResourceId: string, previousProviderResourceId: string | undefined, now: Date,
|
||||
) {
|
||||
const previousResource = previousProviderResourceId
|
||||
? await tx
|
||||
.select()
|
||||
.from(chatEndpointResources)
|
||||
.where(
|
||||
and(
|
||||
eq(chatEndpointResources.endpointId, currentEndpoint.id),
|
||||
eq(chatEndpointResources.type, "chat"),
|
||||
eq(
|
||||
chatEndpointResources.providerResourceId,
|
||||
previousProviderResourceId,
|
||||
),
|
||||
),
|
||||
)
|
||||
.for("update")
|
||||
.then((rows) => rows[0] ?? null)
|
||||
: null;
|
||||
const migratedLabel =
|
||||
previousResource &&
|
||||
telegramResourceLabelIsFallback(providerResourceId, effect.label)
|
||||
? previousResource.label
|
||||
: effect.label;
|
||||
const migratedEnabled = previousResource?.enabled === true;
|
||||
if (
|
||||
previousResource &&
|
||||
previousProviderResourceId !== providerResourceId
|
||||
) {
|
||||
await tx
|
||||
.update(chatEndpointResources)
|
||||
.set({
|
||||
availability: "unavailable",
|
||||
enabled: false,
|
||||
metadata: {
|
||||
...previousResource.metadata,
|
||||
source: "chat_migration",
|
||||
migratedTo: providerResourceId,
|
||||
},
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(chatEndpointResources.id, previousResource.id));
|
||||
}
|
||||
return { previousResource, migratedLabel, migratedEnabled };
|
||||
}
|
||||
|
||||
export async function migrateTelegramResourceConversations(
|
||||
tx: DbTransaction, currentEndpoint: EndpointRow,
|
||||
previousResource: typeof chatEndpointResources.$inferSelect,
|
||||
resource: { id: string }, previousProviderResourceId: string | undefined,
|
||||
providerResourceId: string, migratedLabel: string, now: Date,
|
||||
) {
|
||||
const previousThreadPrefix = `telegram:${previousProviderResourceId}`;
|
||||
const migratedThreadPrefix = `telegram:${providerResourceId}`;
|
||||
await tx
|
||||
.update(chatConversations)
|
||||
.set({
|
||||
resourceId: resource.id,
|
||||
// Telegram SDK threads use the namespaced channel id. Keep
|
||||
// the migrated row in that canonical shape so the first
|
||||
// reply from the replacement supergroup finds this same
|
||||
// task instead of creating a second conversation.
|
||||
externalConversationId: migratedThreadPrefix,
|
||||
externalThreadId: sql<string>`case
|
||||
when ${chatConversations.externalThreadId} = ${previousThreadPrefix} then ${migratedThreadPrefix}
|
||||
when ${chatConversations.externalThreadId} like ${`${previousThreadPrefix}:%`}
|
||||
then ${migratedThreadPrefix} || substring(
|
||||
${chatConversations.externalThreadId}
|
||||
from char_length(${previousThreadPrefix}) + 1
|
||||
)
|
||||
else ${chatConversations.externalThreadId}
|
||||
end`,
|
||||
externalLabel: migratedLabel,
|
||||
state: "active",
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(chatConversations.companyId, currentEndpoint.companyId),
|
||||
eq(chatConversations.endpointId, currentEndpoint.id),
|
||||
eq(chatConversations.resourceId, previousResource.id),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
import { nativeChatWorkspaceCwd } from "./native-runtime/native-chat-workspace.js";
|
||||
import { prepareHeartbeatExecutionConfiguration } from "./heartbeat/execution-configuration.js";
|
||||
import { createHeartbeatRunOutput } from "./heartbeat/run-output.js";
|
||||
import { prepareHeartbeatSession, resolveHeartbeatAdapterSession } from "./heartbeat/session-preparation.js";
|
||||
import { prepareHeartbeatTaskContext } from "./heartbeat/task-context.js";
|
||||
import { selectHeartbeatEnvironment } from "./heartbeat/environment-selection.js";
|
||||
import { prepareHeartbeatWorkspace } from "./heartbeat/workspace-preparation.js";
|
||||
@@ -31,8 +34,6 @@ export {
|
||||
} from "./heartbeat/run-control.js";
|
||||
import {
|
||||
isHeartbeatRunTerminalStatus,
|
||||
isHeartbeatRunRuntimeStatusActive,
|
||||
publishHeartbeatRunRuntimeProgress,
|
||||
decorateHeartbeatRunRuntimeStatus,
|
||||
createHeartbeatLifecycle,
|
||||
} from "./heartbeat/run-lifecycle.js";
|
||||
@@ -82,9 +83,6 @@ import {
|
||||
normalizeResumeParamsForAdapter,
|
||||
truncateDisplayId,
|
||||
resolveLedgerScopeForRun,
|
||||
describeSessionResetReason,
|
||||
isCanonicalSessionIdForAdapter,
|
||||
requiresCanonicalSessionIds,
|
||||
resolveNextSessionState,
|
||||
resolveCacheAdjustedCostUsd,
|
||||
resolveLedgerCostStatus,
|
||||
@@ -118,9 +116,6 @@ import {
|
||||
resolveAcceptedPlanWakeRoutingDecision,
|
||||
clearInteractionContinuationWakeContext,
|
||||
PAPERCLIP_WAKE_PAYLOAD_KEY,
|
||||
resolveRunScopedMentionedSkillKeys,
|
||||
resolveExecutionRunAdapterConfig,
|
||||
applyRunScopedMentionedSkillKeys,
|
||||
MANAGED_GITHUB_TOKEN_KEYS,
|
||||
configuredPaperclipApiBaseUrl,
|
||||
createHeartbeatRunPreparation,
|
||||
@@ -139,18 +134,10 @@ export {
|
||||
buildPaperclipWakePayload,
|
||||
} from "./heartbeat/run-preparation.js";
|
||||
import {
|
||||
buildExecutionWorkspaceConfigSnapshot,
|
||||
stripWorkspaceRuntimeFromExecutionRunConfig,
|
||||
buildEffectiveRunSessionConfigMetadata,
|
||||
readConfigFingerprintFromSessionParams,
|
||||
readConfiguredModelFromAdapterConfig,
|
||||
resolveTaskSessionConfigFreshness,
|
||||
stripPaperclipSessionMetadataFromSessionParams,
|
||||
resolveRuntimeSessionParamsForWorkspace,
|
||||
buildRunWorkspaceHints,
|
||||
buildReferencedProjectRunObservability,
|
||||
assertGitSensitiveAdapterWorkspaceValid,
|
||||
type EffectiveRunSessionConfigMetadata,
|
||||
createHeartbeatWorkspaceResolver,
|
||||
} from "./heartbeat/workspaces.js";
|
||||
export {
|
||||
@@ -212,11 +199,7 @@ export {
|
||||
stripPaperclipSessionMetadataFromSessionParams,
|
||||
resolveTaskSessionConfigFreshness,
|
||||
} from "./heartbeat/workspaces.js";
|
||||
import {
|
||||
appendExcerpt,
|
||||
boundHeartbeatRunEventPayloadForStorage,
|
||||
compactRunLogChunk,
|
||||
} from "./heartbeat/run-log.js";
|
||||
import { boundHeartbeatRunEventPayloadForStorage } from "./heartbeat/run-log.js";
|
||||
export {
|
||||
boundHeartbeatRunEventPayloadForStorage,
|
||||
compactRunLogChunk,
|
||||
@@ -226,10 +209,7 @@ export { buildPaperclipTaskMarkdown } from "./heartbeat/task-markdown.js";
|
||||
import { decisionModelService } from "./decision-models.js";
|
||||
import { TASK_QUESTION_GUIDANCE } from "./issue-question-context.js";
|
||||
import { createAgentIdentityRedactor } from "./agent-identity-redaction.js";
|
||||
import { agentIdentityService, supportsManagedAgentIdentity } from "./agent-identity.js";
|
||||
|
||||
import { prepareConnectionInstructionDelivery } from "./connection-instructions.js";
|
||||
import { resolveAssignedConnectionInstructionsForRun } from "./native-runtime/assigned-mcp-tools.js";
|
||||
|
||||
import {
|
||||
startupCancellationFence,
|
||||
@@ -265,19 +245,13 @@ import {
|
||||
watchLegacyControllerLease,
|
||||
} from "./legacy-controller-lease.js";
|
||||
|
||||
import { applyConnectorSkills, prepareConnectorSkillDelivery, resolveConnectorAssignments } from "./connector-runtime.js";
|
||||
|
||||
import { connectionIntentService } from "./connection-intents.js";
|
||||
import {
|
||||
prepareManagedAiRuntime,
|
||||
assertManagedAiProjectAuth,
|
||||
stripAiAuthBindings,
|
||||
isAiConnectionBusy,
|
||||
AI_AUTH_ENV_KEYS,
|
||||
} from "./ai-connection-runtime.js";
|
||||
import { aiConnectionBindingSchema, aiRuntimeConnectionBindingSchema, type AiConnectionRouterSelection } from "@paperclipai/shared";
|
||||
import { aiConnectionRouterService, AiConnectionPoolExhausted, applyAiConnectionRouterTaskSettings } from "./ai-connection-router.js";
|
||||
import { aiConnectionSessionCompatibilityInputs, managedAiSessionIdentityCompatible } from "./ai-connection-session.js";
|
||||
import {
|
||||
getExecutionBlocker,
|
||||
} from "./execution-blocker.js";
|
||||
@@ -321,7 +295,6 @@ import {
|
||||
} from "node:crypto";
|
||||
import {
|
||||
and,
|
||||
desc,
|
||||
eq,
|
||||
exists,
|
||||
inArray,
|
||||
@@ -338,7 +311,6 @@ import {
|
||||
} from "@paperclipai/shared";
|
||||
import {
|
||||
agents,
|
||||
agentConfigRevisions,
|
||||
agentWakeupRequests,
|
||||
chatActions,
|
||||
heartbeatRuns,
|
||||
@@ -353,7 +325,6 @@ import { HttpError, notFound } from "../errors.js";
|
||||
|
||||
import { logger } from "../middleware/logger.js";
|
||||
import {
|
||||
resolveManagedGitHubIdentitySelection,
|
||||
scrubGitCredentialText,
|
||||
} from "./git-credentials.js";
|
||||
// Re-exported because heartbeat's workspace surface exposed the scrubber before the
|
||||
@@ -377,26 +348,17 @@ import {
|
||||
resolveHeartbeatNativeRuntimeMode,
|
||||
} from "./native-runtime/index.js";
|
||||
|
||||
import {
|
||||
recordFailedSkillPreparation,
|
||||
type NativeRunHistoricalSpan,
|
||||
} from "./native-runtime/native-run-trace.js";
|
||||
import {
|
||||
parseNativeExecutionInput,
|
||||
type NativeExecutionInput,
|
||||
type NativeSessionBackend,
|
||||
} from "../vendor/paperclip-runner/index.js";
|
||||
import { createNativeSessionHandoffLoader } from "./native-runtime/native-session-handoff.js";
|
||||
|
||||
import { getRunLogStore, type RunLogHandle } from "./run-log-store.js";
|
||||
import { getRunLogStore } from "./run-log-store.js";
|
||||
import {
|
||||
providerTraceStore,
|
||||
} from "./provider-trace-store.js";
|
||||
import { getServerAdapter, runningProcesses } from "../adapters/index.js";
|
||||
import type {
|
||||
AdapterInvocationMeta,
|
||||
AdapterRuntimeEvent,
|
||||
} from "../adapters/index.js";
|
||||
|
||||
import { createRuntimeToolsToken } from "../runtime-tools-token.js";
|
||||
import {
|
||||
@@ -418,7 +380,6 @@ import {
|
||||
emitAgentTaskRun,
|
||||
emitAgentTaskRunById,
|
||||
} from "./agent-task-run-telemetry.js";
|
||||
import { readAiConnectionConfigurationFailure, readAiCredentialAccessFailure } from "./ai-connection-configuration-failure.js";
|
||||
import { reportRunFailure } from "./run-failure-report.js";
|
||||
|
||||
import { collectRunFailureSecretValues, type RunFailureReportOptions } from "./run-failure-diagnostics.js";
|
||||
@@ -512,7 +473,6 @@ import {
|
||||
type PostCommitEffect,
|
||||
MAX_TURN_CONTINUATION_RETRY_REASON,
|
||||
WORKSPACE_BUSY_RETRY_REASON,
|
||||
AI_CONNECTION_BUSY_RETRY_REASON,
|
||||
AI_CONNECTION_POOL_WAIT_RETRY_REASON,
|
||||
INTERACTION_CONTINUATION_INFRA_RETRY_REASON,
|
||||
INTERACTION_CONTINUATION_INFRA_WAKE_REASON,
|
||||
@@ -536,15 +496,9 @@ import {
|
||||
DIRECT_NON_INVOKABLE_STATUSES,
|
||||
} from "./agent-invokability.js";
|
||||
|
||||
import {
|
||||
redactCurrentUserText,
|
||||
} from "../log-redaction.js";
|
||||
|
||||
import { createRunSecretRedactionRegistry } from "./run-secret-redaction.js";
|
||||
|
||||
import {
|
||||
readPaperclipSkillSyncPreference,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
|
||||
import { environmentService } from "./environments.js";
|
||||
|
||||
@@ -552,13 +506,11 @@ import {
|
||||
environmentRuntimeService,
|
||||
type ProviderResourceDisposition,
|
||||
} from "./environment-runtime.js";
|
||||
import { skillVersionSelectionMap } from "./runtime-skill-selections.js";
|
||||
import { environmentRunOrchestrator } from "./environment-run-orchestrator.js";
|
||||
|
||||
import {
|
||||
clearHeartbeatRunRuntimeStatus,
|
||||
sweepExpiredHeartbeatRunRuntimeStatuses,
|
||||
touchHeartbeatRunRuntimeStatus,
|
||||
} from "./heartbeat-run-runtime-status.js";
|
||||
|
||||
import {
|
||||
@@ -571,11 +523,6 @@ import type { PluginWorkerManager } from "./plugin-worker-manager.js";
|
||||
|
||||
import { computeTaskDrain, applyTaskDrain, startTaskDrain, stopTaskDrain, readTaskDrain } from "./task-admission.js";
|
||||
|
||||
const MAX_LIVE_LOG_CHUNK_BYTES = 8 * 1024;
|
||||
|
||||
const ACCEPTED_PLAN_CONVERSION_SKILL_KEY =
|
||||
"paperclipai/paperclip/paperclip-converting-plans-to-tasks";
|
||||
|
||||
const execFile = promisify(execFileCallback);
|
||||
const UNSUCCESSFUL_HEARTBEAT_RUN_TERMINAL_STATUSES = [
|
||||
"failed",
|
||||
@@ -588,8 +535,7 @@ export {
|
||||
ACTIVE_RUN_OUTPUT_CRITICAL_THRESHOLD_MS,
|
||||
ACTIVE_RUN_OUTPUT_SUSPICION_THRESHOLD_MS,
|
||||
} from "./recovery/service.js";
|
||||
export const ACTIVE_RUN_OUTPUT_PROGRESS_FLUSH_INTERVAL_MS = 60 * 1000;
|
||||
export const ACTIVE_RUN_LOG_RUNTIME_STATUS_REFRESH_INTERVAL_MS = 5 * 1000;
|
||||
export { ACTIVE_RUN_OUTPUT_PROGRESS_FLUSH_INTERVAL_MS, ACTIVE_RUN_LOG_RUNTIME_STATUS_REFRESH_INTERVAL_MS } from "./heartbeat/run-output.js";
|
||||
|
||||
export {
|
||||
INTERACTION_CONTINUATION_INFRA_RETRY_REASON,
|
||||
@@ -2761,318 +2707,46 @@ export function heartbeatService(
|
||||
services: { executionWorkspacesSvc, environmentsSvc, instanceSettings, findSharedWorkspaceHolder },
|
||||
});
|
||||
let nativeChatExpectedCwd = selectedNativeChatExpectedCwd;
|
||||
const mergedConfig = {
|
||||
...sourceWorkspaceConfig,
|
||||
// The base below is already task-owned. Keep directory transport while
|
||||
// preserving isolated mode and the mandatory sandbox preflight.
|
||||
...((useIsolatedTaskDirectory || explicitTaskDirectory) ? { workspaceStrategy: { type: "project_primary" } } : {}),
|
||||
};
|
||||
const configSnapshot = buildExecutionWorkspaceConfigSnapshot(
|
||||
mergedConfig,
|
||||
selectedEnvironmentId,
|
||||
);
|
||||
const executionRunConfig =
|
||||
stripWorkspaceRuntimeFromExecutionRunConfig(mergedConfig);
|
||||
const runScopedMentionedSkillKeys =
|
||||
await resolveRunScopedMentionedSkillKeys({
|
||||
db,
|
||||
companyId: agent.companyId,
|
||||
issueId,
|
||||
commentIds: extractWakeCommentIds(context),
|
||||
});
|
||||
const runScopedSkillKeys =
|
||||
acceptedPlanContinuationWake &&
|
||||
!acceptedPlanWakeRoutingDecision?.suppressAcceptedContinuation
|
||||
? [...runScopedMentionedSkillKeys, ACCEPTED_PLAN_CONVERSION_SKILL_KEY]
|
||||
: runScopedMentionedSkillKeys;
|
||||
const githubSelection = await resolveManagedGitHubIdentitySelection(
|
||||
db,
|
||||
agent.companyId,
|
||||
{
|
||||
agentId: agent.id,
|
||||
responsibleUserId,
|
||||
allowStandingDelegation: false,
|
||||
},
|
||||
);
|
||||
const useHostGitHub =
|
||||
!githubSelection.configured &&
|
||||
trustPreset.kind === "standard" &&
|
||||
["local", "ssh"].includes(
|
||||
selectedEnvironmentForConfig?.driver ?? "local",
|
||||
);
|
||||
|
||||
const { resolvedConfig, configuredTaskEnvironment, secretKeys, secretManifest } =
|
||||
await resolveExecutionRunAdapterConfig({
|
||||
managedAiCredentials: Boolean(aiBinding),
|
||||
managedGitHubCredentials: !useHostGitHub,
|
||||
companyId: agent.companyId,
|
||||
agentId: agent.id,
|
||||
adapterType: agent.adapterType,
|
||||
issueId,
|
||||
heartbeatRunId: run.id,
|
||||
environmentId: selectedEnvironmentForConfig?.id ?? null,
|
||||
environmentEnv: aiBinding ? stripAiAuthBindings(selectedEnvironmentForConfig?.envVars) : selectedEnvironmentForConfig?.envVars ?? null,
|
||||
environmentDriver: selectedEnvironmentForConfig?.driver ?? null,
|
||||
projectId: taskProjectContext?.id ?? null,
|
||||
routineId: routineEnvContext.routineId,
|
||||
responsibleUserId,
|
||||
executionRunConfig: aiBinding ? { ...executionRunConfig, env: stripAiAuthBindings(executionRunConfig.env) } : executionRunConfig,
|
||||
projectEnv: aiBinding ? stripAiAuthBindings(taskProjectContext?.env) : taskProjectContext?.env ?? null,
|
||||
routineEnv: aiBinding ? stripAiAuthBindings(routineEnvContext.env) : routineEnvContext.env,
|
||||
secretsSvc,
|
||||
trustPreset,
|
||||
});
|
||||
readFailureReportSecrets = () => collectRunFailureSecretValues(resolvedConfig.env, secretKeys);
|
||||
if (aiBinding) {
|
||||
try {
|
||||
managedAiRuntime = await prepareManagedAiRuntime(db, { companyId: agent.companyId, agentId: agent.id, responsibleUserId, adapterType: agent.adapterType, binding: aiBinding, config: resolvedConfig });
|
||||
} catch (error) {
|
||||
// Only fresh executions can receive a pre-provider wait receipt. A
|
||||
// persisted native input may already have provider effects to recover.
|
||||
if (isAiConnectionBusy(error) && !persistedNativeExecutionInput) {
|
||||
// Use the authority recorded by the locked admission gate, never
|
||||
// the issue's mutable assignee observed during runtime preparation.
|
||||
const authorizedNonAssigneeWake =
|
||||
parseObject(run.runnerProfileJson).aiConnectionNonAssigneeCommentWake === true ||
|
||||
(run.scheduledRetryReason === AI_CONNECTION_BUSY_RETRY_REASON &&
|
||||
isNonAssigneeWorkspaceBusyRetry(run.scheduledRetryReason, parseObject(run.contextSnapshot)));
|
||||
await finalizeAiConnectionBusyDeferral(run, error, !authorizedNonAssigneeWake);
|
||||
return;
|
||||
}
|
||||
if (responsibleUserId && issueId) {
|
||||
await connectionIntentService(db).request({ sub: agent.id, company_id: agent.companyId, run_id: run.id, responsible_user_id: responsibleUserId }, aiBinding.provider, { purpose: "ai" }).catch(() => {
|
||||
logger.warn({ runId: run.id, agentId: agent.id }, "Could not attach AI connection request; runtime configuration action remains available");
|
||||
});
|
||||
}
|
||||
throw new ConfigurationIncompleteFailure(error instanceof Error ? error.message : "Configure this agent’s AI connection", {
|
||||
configurationIncomplete: { reason: "ai_connection_unavailable", companyId: agent.companyId, agentId: agent.id, responsibleUserId,
|
||||
...(!persistedNativeExecutionInput && readAiConnectionConfigurationFailure(error)
|
||||
? { selectionFailure: readAiConnectionConfigurationFailure(error) } : {}),
|
||||
...(readAiCredentialAccessFailure(error)
|
||||
? { credentialAccess: readAiCredentialAccessFailure(error) } : {}),
|
||||
provider: aiBinding.provider, method: aiBinding.method, actionUrl: `/agents/${agent.id}/runtime`,
|
||||
fingerprint: `ai:${agent.id}:${responsibleUserId}:${JSON.stringify(aiBinding)}` },
|
||||
});
|
||||
}
|
||||
const savedAiAccount = parseObject(run.contextSnapshot?.aiConnection);
|
||||
if (persistedNativeExecutionInput && !managedAiSessionIdentityCompatible(
|
||||
savedAiAccount.sessionIdentity ?? savedAiAccount.identity,
|
||||
managedAiRuntime.sessionIdentity,
|
||||
managedAiRuntime.identity,
|
||||
)) {
|
||||
throw new ConfigurationIncompleteFailure("The AI account changed while this native run was suspended. Start a new execution.", { configurationIncomplete: { reason: "ai_connection_changed", actionUrl: `/agents/${agent.id}/runtime` } });
|
||||
}
|
||||
Object.assign(resolvedConfig, managedAiRuntime.config);
|
||||
for (const key of AI_AUTH_ENV_KEYS) secretKeys.add(key);
|
||||
context.aiConnection = { ...managedAiRuntime.attribution, accountName: managedAiRuntime.accountName, identity: managedAiRuntime.identity, sessionIdentity: managedAiRuntime.sessionIdentity };
|
||||
await db.update(heartbeatRuns).set({ contextSnapshot: sql`coalesce(${heartbeatRuns.contextSnapshot}, '{}'::jsonb) || ${JSON.stringify({ aiConnection: context.aiConnection, ...(context.aiRouterTaskKey ? { aiRouterTaskKey: context.aiRouterTaskKey } : {}), ...(context.aiRouterSelection ? { aiRouterSelection: context.aiRouterSelection } : {}) })}::jsonb` }).where(eq(heartbeatRuns.id, run.id));
|
||||
}
|
||||
if (secretManifest.length > 0) {
|
||||
context.paperclipSecrets = {
|
||||
manifest: secretManifest,
|
||||
};
|
||||
} else {
|
||||
delete context.paperclipSecrets;
|
||||
}
|
||||
const effectiveResolvedConfig = applyRunScopedMentionedSkillKeys(
|
||||
resolvedConfig,
|
||||
runScopedSkillKeys,
|
||||
);
|
||||
const runtimeSkillPreference = readPaperclipSkillSyncPreference(
|
||||
effectiveResolvedConfig,
|
||||
);
|
||||
const nativeRunnerPreparationSpans: NativeRunHistoricalSpan[] = [];
|
||||
const skillsPrepareStartedAtMs = Date.now();
|
||||
const runtimeSkillEntries = await (async () => {
|
||||
try {
|
||||
return await companySkills.listRuntimeSkillEntries(agent.companyId, {
|
||||
versionSelections: skillVersionSelectionMap(
|
||||
runtimeSkillPreference.desiredSkillEntries,
|
||||
{
|
||||
versionPinsEnabled:
|
||||
resolvedInstanceSettings.experimental.enableBetaSkills ===
|
||||
true,
|
||||
},
|
||||
),
|
||||
});
|
||||
} catch (error) {
|
||||
if (agent.adapterType === "paperclip_runner") {
|
||||
await recordFailedSkillPreparation({
|
||||
runId: run.id,
|
||||
startedAtMs: skillsPrepareStartedAtMs,
|
||||
onEvent: async (event) => {
|
||||
await appendRunEvent(run, event);
|
||||
},
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
})();
|
||||
nativeRunnerPreparationSpans.push({
|
||||
name: "skills.prepare",
|
||||
parentName: "task.prepare",
|
||||
startedAtMs: skillsPrepareStartedAtMs,
|
||||
endedAtMs: Date.now(),
|
||||
const executionConfiguration = await prepareHeartbeatExecutionConfiguration(db, {
|
||||
run, agent,
|
||||
task: { issueId, context, responsibleUserId, acceptedPlanContinuationWake, acceptedPlanWakeRoutingDecision, taskProjectContext, routineEnvContext },
|
||||
config: { sourceWorkspaceConfig, aiBinding },
|
||||
policy: { trustPreset, useIsolatedTaskDirectory, explicitTaskDirectory },
|
||||
environment: { selectedEnvironmentId, selectedEnvironmentForConfig, resolvedInstanceSettings, persistedNativeExecutionInput },
|
||||
services: { secretsSvc, companySkills, appendRunEvent, finalizeAiConnectionBusyDeferral },
|
||||
onFailureSecretsResolved: (readSecrets) => { readFailureReportSecrets = readSecrets; },
|
||||
onManagedAiRuntimeAcquired: (runtime) => { managedAiRuntime = runtime; },
|
||||
onIdentityRedactorPrepared: (redactor) => { identityRedactor = redactor; },
|
||||
});
|
||||
const connectorAssignments = await resolveConnectorAssignments(db, { companyId: agent.companyId, agentId: agent.id, runId: run.id, issueId: typeof context.issueId === "string" ? context.issueId : undefined });
|
||||
const connectorSkillConfig = await applyConnectorSkills(effectiveResolvedConfig, runtimeSkillEntries, connectorAssignments);
|
||||
// Both CLI adapters and native context materialization use the same resolved set.
|
||||
runtimeSkillEntries.splice(0, runtimeSkillEntries.length, ...connectorSkillConfig.paperclipRuntimeSkills);
|
||||
const connectorDelivery = await prepareConnectorSkillDelivery(connectorSkillConfig, agent.adapterType);
|
||||
// Always replace this runtime-only field; caller wake data cannot supply skills.
|
||||
context.paperclipWake = { ...parseObject(context.paperclipWake), connectorSkillInstructions: connectorDelivery.instructions };
|
||||
let runtimeConfig = await prepareConnectionInstructionDelivery({
|
||||
resolve: () => resolveAssignedConnectionInstructionsForRun(db, { companyId: agent.companyId, agentId: agent.id, runId: run.id }),
|
||||
context, config: connectorDelivery.config, native: agent.adapterType === "paperclip_runner",
|
||||
});
|
||||
const agentIdentity = supportsManagedAgentIdentity(agent.adapterType, agent.adapterConfig, run.runtimeMode === "native" ? run.driverKind : undefined)
|
||||
? await agentIdentityService(db).ensureAgentIdentity(agent.companyId, agent.id)
|
||||
: undefined;
|
||||
identityRedactor = createAgentIdentityRedactor(agentIdentity?.privateKeyPem);
|
||||
if (agentIdentity) secretKeys.add("PAPERCLIP_AGENT_PRIVATE_KEY");
|
||||
if (!executionConfiguration.prepared) return;
|
||||
const {
|
||||
mergedConfig, configSnapshot, runScopedMentionedSkillKeys, githubSelection, useHostGitHub,
|
||||
resolvedConfig, configuredTaskEnvironment, secretKeys, secretManifest,
|
||||
runtimeSkillEntries, nativeRunnerPreparationSpans, agentIdentity,
|
||||
} = executionConfiguration;
|
||||
let { runtimeConfig } = executionConfiguration;
|
||||
const resolvedFailureSecrets = readFailureReportSecrets();
|
||||
readFailureReportSecrets = () => [
|
||||
...resolvedFailureSecrets,
|
||||
...identityRedactor.values,
|
||||
...collectRunFailureSecretValues(runtimeConfig.env, secretKeys),
|
||||
];
|
||||
const latestAgentConfigRevision = await getLatestAgentConfigRevision(
|
||||
agent.companyId,
|
||||
agent.id,
|
||||
);
|
||||
const sessionConfigMetadataInput = {
|
||||
agentIdentityKeyId: agentIdentity?.keyId,
|
||||
adapterType: agent.adapterType,
|
||||
effectiveAdapterConfig: runtimeConfig,
|
||||
managedAiHome: managedAiRuntime?.home,
|
||||
agentRuntimeConfig: agent.runtimeConfig,
|
||||
issueOverrides: issueAssigneeOverrides,
|
||||
workspaceConfig: {
|
||||
requestedMode: requestedExecutionWorkspaceMode,
|
||||
effectiveMode: effectiveExecutionWorkspaceMode,
|
||||
issueConfigRevisionAt:
|
||||
issueContext?.updatedAt instanceof Date
|
||||
? issueContext.updatedAt.toISOString()
|
||||
: (issueContext?.updatedAt ?? null),
|
||||
projectConfigRevisionAt:
|
||||
workspaceProjectContext?.updatedAt instanceof Date
|
||||
? workspaceProjectContext.updatedAt.toISOString()
|
||||
: (workspaceProjectContext?.updatedAt ?? null),
|
||||
projectPolicy: projectExecutionWorkspacePolicy,
|
||||
issueSettings: issueExecutionWorkspaceSettings,
|
||||
reusableExecutionWorkspaceConfig:
|
||||
requestedReusableExecutionWorkspaceConfig,
|
||||
existingExecutionWorkspace: reusableExistingExecutionWorkspace
|
||||
? {
|
||||
id: reusableExistingExecutionWorkspace.id,
|
||||
mode: reusableExistingExecutionWorkspace.mode,
|
||||
strategyType: reusableExistingExecutionWorkspace.strategyType,
|
||||
projectWorkspaceId:
|
||||
reusableExistingExecutionWorkspace.projectWorkspaceId,
|
||||
repoUrl: reusableExistingExecutionWorkspace.repoUrl,
|
||||
baseRef: reusableExistingExecutionWorkspace.baseRef,
|
||||
branchName: reusableExistingExecutionWorkspace.branchName,
|
||||
config: reusableExistingExecutionWorkspace.config,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
environment: {
|
||||
selectionSource: environmentResolution.source,
|
||||
selectedEnvironmentId,
|
||||
selectedEnvironment: selectedEnvironmentForConfig
|
||||
? {
|
||||
id: selectedEnvironmentForConfig.id,
|
||||
driver: selectedEnvironmentForConfig.driver,
|
||||
config: selectedEnvironmentForConfig.config,
|
||||
configRevisionAt:
|
||||
selectedEnvironmentForConfig.updatedAt instanceof Date
|
||||
? selectedEnvironmentForConfig.updatedAt.toISOString()
|
||||
: (selectedEnvironmentForConfig.updatedAt ?? null),
|
||||
}
|
||||
: null,
|
||||
executionPolicy,
|
||||
},
|
||||
environmentEnv: selectedEnvironmentForConfig?.envVars ?? null,
|
||||
projectEnv: taskProjectContext?.env ?? null,
|
||||
routineEnv: routineEnvContext.env,
|
||||
secretManifest,
|
||||
runtimeSkills: runtimeSkillEntries,
|
||||
agentConfigRevision: latestAgentConfigRevision
|
||||
? {
|
||||
id: latestAgentConfigRevision.id,
|
||||
changedKeys: latestAgentConfigRevision.changedKeys,
|
||||
configRevisionAt:
|
||||
latestAgentConfigRevision.createdAt.toISOString(),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
const sessionConfigMetadata = await buildEffectiveRunSessionConfigMetadata(sessionConfigMetadataInput);
|
||||
let compatibleConfigMetadata: EffectiveRunSessionConfigMetadata[] = [];
|
||||
if (managedAiRuntime && aiBinding && taskSession &&
|
||||
readConfigFingerprintFromSessionParams(taskSession.sessionParamsJson)?.fingerprint !== sessionConfigMetadata.fingerprint) {
|
||||
const revisions = requestedAiBinding?.mode === "router"
|
||||
? await db.select().from(agentConfigRevisions).where(and(eq(agentConfigRevisions.companyId, agent.companyId), eq(agentConfigRevisions.agentId, agent.id))).orderBy(desc(agentConfigRevisions.createdAt), desc(agentConfigRevisions.id)).limit(21)
|
||||
: [];
|
||||
const candidates = aiConnectionSessionCompatibilityInputs({
|
||||
effectiveAdapterConfig: runtimeConfig, agentRuntimeConfig: agent.runtimeConfig,
|
||||
agentConfigRevision: sessionConfigMetadataInput.agentConfigRevision,
|
||||
issueOverrides: issueAssigneeOverrides, originalIssueOverrides: originalAiIssueOverrides,
|
||||
binding: aiBinding, router: requestedAiBinding?.mode === "router",
|
||||
storedIdentity: taskSession.sessionParamsJson?.paperclipAiCredentialIdentity,
|
||||
sessionIdentity: managedAiRuntime.sessionIdentity, credentialIdentity: managedAiRuntime.identity, revisions,
|
||||
});
|
||||
compatibleConfigMetadata = await Promise.all(candidates.map(candidate => buildEffectiveRunSessionConfigMetadata({ ...sessionConfigMetadataInput, ...candidate })));
|
||||
}
|
||||
const configuredModel =
|
||||
readConfiguredModelFromAdapterConfig(runtimeConfig);
|
||||
if (context.refreshTools === true && agent.adapterType !== "paperclip_runner") {
|
||||
const capability = getServerAdapter(agent.adapterType).supportsToolRefreshOnResume;
|
||||
const canRefresh = typeof capability === "function" ? capability(runtimeConfig) : capability === true;
|
||||
if (!canRefresh) context.forceFreshSession = true;
|
||||
}
|
||||
const wakeSessionResetReason = describeSessionResetReason(context);
|
||||
const sessionConfigFreshness = resolveTaskSessionConfigFreshness({
|
||||
hasTaskSession: taskSession != null,
|
||||
configuredModel,
|
||||
taskSessionParams:
|
||||
taskSession?.sessionParamsJson ?? taskSessionDecodedParams,
|
||||
configMetadata: sessionConfigMetadata,
|
||||
compatibleConfigMetadata,
|
||||
wakeResetReason: wakeSessionResetReason,
|
||||
preserveLegacySessionWithoutConfigMetadata:
|
||||
acceptedPlanContinuationWake && !acceptedPlanWakeRoutingDecision && !agentIdentity,
|
||||
const preparedSession = await prepareHeartbeatSession(db, {
|
||||
run, agent,
|
||||
task: { context, issueRef, issueContext, taskProjectContext, workspaceProjectContext, wakeCommentId, acceptedPlanContinuationWake, acceptedPlanWakeRoutingDecision },
|
||||
session: { taskSession, taskSessionDecodedParams, explicitResumeSessionParams, explicitResumeSessionDisplayId, sessionCodec },
|
||||
config: {
|
||||
runtimeConfig, agentIdentity, issueAssigneeOverrides, originalAiIssueOverrides,
|
||||
managedAiRuntime, aiBinding, requestedAiBinding, secretManifest, runtimeSkillEntries, routineEnvContext,
|
||||
},
|
||||
workspace: {
|
||||
requestedExecutionWorkspaceMode, effectiveExecutionWorkspaceMode, projectExecutionWorkspacePolicy,
|
||||
issueExecutionWorkspaceSettings, executionPolicy, requestedReusableExecutionWorkspaceConfig, reusableExistingExecutionWorkspace,
|
||||
},
|
||||
environment: { environmentResolution, selectedEnvironmentId, selectedEnvironmentForConfig },
|
||||
getLatestAgentConfigRevision,
|
||||
});
|
||||
const resetTaskSession =
|
||||
context.workspaceSelectionChanged === true || shouldResetTaskSessionForWake(context) || sessionConfigFreshness.reset;
|
||||
const sessionResetReason =
|
||||
sessionConfigFreshness.reasons.join("; ") || null;
|
||||
const taskSessionForRun = resetTaskSession ? null : taskSession;
|
||||
const getFreshSessionHandoff = issueRef ? createNativeSessionHandoffLoader({
|
||||
db, companyId: agent.companyId, issueId: issueRef.id, agentId: agent.id, before: run.createdAt,
|
||||
throughCommentId: readNonEmptyString(context.conversationReplayThroughCommentId) ?? (context.interactionKind ? null : wakeCommentId),
|
||||
}) : undefined;
|
||||
const previousSessionParams =
|
||||
explicitResumeSessionParams ??
|
||||
(isCanonicalSessionIdForAdapter(
|
||||
agent.adapterType,
|
||||
explicitResumeSessionDisplayId,
|
||||
)
|
||||
? { sessionId: explicitResumeSessionDisplayId }
|
||||
: null) ??
|
||||
normalizeResumeParamsForAdapter(
|
||||
agent.adapterType,
|
||||
stripPaperclipSessionMetadataFromSessionParams(
|
||||
sessionCodec.deserialize(
|
||||
taskSessionForRun?.sessionParamsJson ?? null,
|
||||
),
|
||||
),
|
||||
);
|
||||
// Legacy plugins can consume the existing context field on a known-fresh
|
||||
// dispatch. Built-ins also load lazily if their resume attempt fails.
|
||||
if (agent.adapterType !== "paperclip_runner" && !previousSessionParams && getFreshSessionHandoff) {
|
||||
const handoff = await getFreshSessionHandoff();
|
||||
if (handoff) context.paperclipFreshSessionHandoffMarkdown = handoff;
|
||||
}
|
||||
const { sessionConfigMetadata, configuredModel, resetTaskSession, sessionResetReason, taskSessionForRun, getFreshSessionHandoff, previousSessionParams } = preparedSession;
|
||||
const {
|
||||
resolvedWorkspace,
|
||||
hostExecutionWorkspaceConfig,
|
||||
@@ -3529,179 +3203,29 @@ export function heartbeatService(
|
||||
) {
|
||||
context.projectId = executionWorkspace.projectId;
|
||||
}
|
||||
const runtimeSessionFallback =
|
||||
taskKey || resetTaskSession
|
||||
? null
|
||||
: isCanonicalSessionIdForAdapter(agent.adapterType, runtime.sessionId)
|
||||
? runtime.sessionId
|
||||
: null;
|
||||
const runtimeSessionDisplayId = truncateDisplayId(
|
||||
explicitResumeSessionDisplayId ??
|
||||
taskSessionForRun?.sessionDisplayId ??
|
||||
(sessionCodec.getDisplayId
|
||||
? sessionCodec.getDisplayId(runtimeSessionParams)
|
||||
: null) ??
|
||||
readNonEmptyString(runtimeSessionParams?.sessionId) ??
|
||||
runtimeSessionFallback,
|
||||
);
|
||||
let previousSessionDisplayId = requiresCanonicalSessionIds(
|
||||
agent.adapterType,
|
||||
)
|
||||
? truncateDisplayId(
|
||||
readNonEmptyString(previousSessionParams?.sessionId) ??
|
||||
(isCanonicalSessionIdForAdapter(
|
||||
agent.adapterType,
|
||||
runtimeSessionDisplayId,
|
||||
)
|
||||
? runtimeSessionDisplayId
|
||||
: null) ??
|
||||
runtimeSessionFallback,
|
||||
)
|
||||
: runtimeSessionDisplayId;
|
||||
let runtimeSessionIdForAdapter =
|
||||
readNonEmptyString(runtimeSessionParams?.sessionId) ??
|
||||
runtimeSessionFallback;
|
||||
let runtimeSessionParamsForAdapter = normalizeSessionParams(
|
||||
stripPaperclipSessionMetadataFromSessionParams(runtimeSessionParams),
|
||||
);
|
||||
|
||||
const sessionCompaction = await evaluateSessionCompaction({
|
||||
const {
|
||||
previousSessionDisplayId, runtimeSessionParamsForAdapter, taskSessionCredentialCompatible, runtimeForAdapter, configFreshnessResultMetadata, sessionCompaction,
|
||||
} = await resolveHeartbeatAdapterSession({
|
||||
agent,
|
||||
sessionId: previousSessionDisplayId ?? runtimeSessionIdForAdapter,
|
||||
issueId,
|
||||
continuationSummaryBody: continuationSummary?.body ?? null,
|
||||
});
|
||||
if (sessionCompaction.rotate) {
|
||||
context.paperclipSessionHandoffMarkdown =
|
||||
sessionCompaction.handoffMarkdown;
|
||||
context.paperclipSessionRotationReason = sessionCompaction.reason;
|
||||
context.paperclipPreviousSessionId =
|
||||
previousSessionDisplayId ?? runtimeSessionIdForAdapter;
|
||||
runtimeSessionIdForAdapter = null;
|
||||
runtimeSessionParamsForAdapter = null;
|
||||
previousSessionDisplayId = null;
|
||||
if (sessionCompaction.reason) {
|
||||
runtimeWorkspaceWarnings.push(
|
||||
`Starting a fresh session because ${sessionCompaction.reason}.`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
delete context.paperclipSessionHandoffMarkdown;
|
||||
delete context.paperclipSessionRotationReason;
|
||||
delete context.paperclipPreviousSessionId;
|
||||
}
|
||||
|
||||
const taskSessionCredentialCompatible = !managedAiRuntime || managedAiSessionIdentityCompatible(
|
||||
taskSession?.sessionParamsJson?.paperclipAiCredentialIdentity,
|
||||
managedAiRuntime.sessionIdentity,
|
||||
managedAiRuntime.identity,
|
||||
);
|
||||
if (managedAiRuntime) {
|
||||
sessionConfigMetadata.aiCredentialIdentity = managedAiRuntime.sessionIdentity;
|
||||
if (!taskSessionCredentialCompatible) {
|
||||
runtimeSessionIdForAdapter = null;
|
||||
runtimeSessionParamsForAdapter = null;
|
||||
previousSessionDisplayId = null;
|
||||
delete executionContinuation?.resumeDelta;
|
||||
}
|
||||
}
|
||||
const runtimeForAdapter = {
|
||||
sessionId: runtimeSessionIdForAdapter,
|
||||
sessionParams: runtimeSessionParamsForAdapter,
|
||||
sessionDisplayId: previousSessionDisplayId,
|
||||
taskKey,
|
||||
};
|
||||
// A delta is safe only when the selected provider session is exactly the
|
||||
// task session whose last dispatch supplied the baseline history.
|
||||
if (
|
||||
executionContinuation?.resumeDelta &&
|
||||
(!taskSessionForRun ||
|
||||
!taskSession?.sessionDisplayId ||
|
||||
runtimeForAdapter.sessionDisplayId !== taskSession.sessionDisplayId ||
|
||||
resetTaskSession ||
|
||||
context.forceFreshSession === true)
|
||||
) {
|
||||
delete executionContinuation.resumeDelta;
|
||||
}
|
||||
const configFreshnessResultMetadata = {
|
||||
version: sessionConfigMetadata.version,
|
||||
session: {
|
||||
fingerprintVersion: sessionConfigMetadata.version,
|
||||
categories: sessionConfigMetadata.categories,
|
||||
reset: resetTaskSession,
|
||||
resetReasons: sessionConfigFreshness.reasons,
|
||||
changedCategories: sessionConfigFreshness.changedCategories,
|
||||
taskSessionAvailable: taskSession != null,
|
||||
taskSessionReused: taskSessionForRun != null,
|
||||
storedFingerprintPresent: Boolean(
|
||||
sessionConfigFreshness.storedFingerprint,
|
||||
),
|
||||
nextFingerprint: sessionConfigFreshness.nextFingerprint,
|
||||
},
|
||||
task: { context, taskKey, issueId, continuationSummary, executionContinuation },
|
||||
session: { runtime, taskSession, sessionCodec, explicitResumeSessionDisplayId, runtimeSessionParams, managedAiRuntime, prepared: preparedSession },
|
||||
workspace: {
|
||||
fingerprintVersion: latestWorkspaceConfigMetadata.version,
|
||||
categories: latestWorkspaceConfigMetadata.categories,
|
||||
action: workspaceConfigFreshness.action,
|
||||
changedCategories: workspaceConfigFreshness.changedCategories,
|
||||
reasons: workspaceConfigFreshness.reasons,
|
||||
reuseRequested: requestedShouldReuseExisting,
|
||||
workspaceReused: Boolean(reusedExecutionWorkspace),
|
||||
configSnapshotRefreshed:
|
||||
resolvedWorkspaceReusePolicy.shouldRefreshWorkspaceConfigSnapshot,
|
||||
storedFingerprintPresent:
|
||||
workspaceConfigFreshness.storedFingerprintPresent,
|
||||
storedFingerprint: workspaceConfigFreshness.storedFingerprint,
|
||||
inferredFingerprint: workspaceConfigFreshness.inferredFingerprint,
|
||||
nextFingerprint: workspaceConfigFreshness.nextFingerprint,
|
||||
previousWorkspaceId:
|
||||
workspaceReuseRequest.requestedExecutionWorkspaceId,
|
||||
activeWorkspaceId: persistedExecutionWorkspace?.id ?? null,
|
||||
latestWorkspaceConfigMetadata, workspaceConfigFreshness, reusedExecutionWorkspace, resolvedWorkspaceReusePolicy,
|
||||
persistedExecutionWorkspace, runtimeWorkspaceWarnings, requestedShouldReuseExisting, workspaceReuseRequest,
|
||||
},
|
||||
};
|
||||
evaluateSessionCompaction,
|
||||
});
|
||||
|
||||
let handle: RunLogHandle | null = null;
|
||||
const runOutput = createHeartbeatRunOutput(db, {
|
||||
run, runId, issueId, runLogStore, identityRedactor, secretKeys,
|
||||
appendRunEvent, getCurrentUserRedactionOptions,
|
||||
});
|
||||
const goalCheckpointSession: {
|
||||
current: {
|
||||
params: Record<string, unknown>;
|
||||
displayId: string;
|
||||
} | null;
|
||||
} = { current: null };
|
||||
let stdoutExcerpt = "";
|
||||
let stderrExcerpt = "";
|
||||
let outputSeq = Number(run.lastOutputSeq ?? 0);
|
||||
let lastOutputFlushAt: Date | null = run.lastOutputAt ?? null;
|
||||
let lastLogRuntimeStatusTouchMs = 0;
|
||||
const outputProgressState: {
|
||||
pending: {
|
||||
at: Date;
|
||||
seq: number;
|
||||
stream: "stdout" | "stderr";
|
||||
bytes: number;
|
||||
} | null;
|
||||
} = { pending: null };
|
||||
let persistedLogBytes = Number(run.logBytes ?? 0);
|
||||
const flushOutputProgress = async (opts?: { force?: boolean }) => {
|
||||
const pendingOutputProgress = outputProgressState.pending;
|
||||
if (!pendingOutputProgress) return;
|
||||
const shouldFlush =
|
||||
opts?.force === true ||
|
||||
!lastOutputFlushAt ||
|
||||
pendingOutputProgress.at.getTime() - lastOutputFlushAt.getTime() >=
|
||||
ACTIVE_RUN_OUTPUT_PROGRESS_FLUSH_INTERVAL_MS;
|
||||
if (!shouldFlush) return;
|
||||
await db
|
||||
.update(heartbeatRuns)
|
||||
.set({
|
||||
lastOutputAt: pendingOutputProgress.at,
|
||||
lastOutputSeq: pendingOutputProgress.seq,
|
||||
lastOutputStream: pendingOutputProgress.stream,
|
||||
lastOutputBytes: pendingOutputProgress.bytes,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(heartbeatRuns.id, run.id));
|
||||
lastOutputFlushAt = pendingOutputProgress.at;
|
||||
outputProgressState.pending = null;
|
||||
};
|
||||
try {
|
||||
const startedAt = run.startedAt ?? new Date();
|
||||
const runningWithSession = await db
|
||||
@@ -3780,100 +3304,8 @@ export function heartbeatService(
|
||||
message: "run started",
|
||||
});
|
||||
|
||||
handle = await runLogStore.begin({
|
||||
companyId: run.companyId,
|
||||
agentId: run.agentId,
|
||||
runId,
|
||||
});
|
||||
|
||||
await db
|
||||
.update(heartbeatRuns)
|
||||
.set({
|
||||
logStore: handle.store,
|
||||
logRef: handle.logRef,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(heartbeatRuns.id, runId));
|
||||
|
||||
const currentUserRedactionOptions =
|
||||
await getCurrentUserRedactionOptions();
|
||||
const appendIdentityRedactedLog = async (stream: "stdout" | "stderr", chunk: string) => {
|
||||
const sanitizedChunk = compactRunLogChunk(
|
||||
redactCurrentUserText(chunk, currentUserRedactionOptions),
|
||||
);
|
||||
if (stream === "stdout")
|
||||
stdoutExcerpt = appendExcerpt(stdoutExcerpt, sanitizedChunk);
|
||||
if (stream === "stderr")
|
||||
stderrExcerpt = appendExcerpt(stderrExcerpt, sanitizedChunk);
|
||||
const ts = new Date().toISOString();
|
||||
|
||||
outputSeq += 1;
|
||||
const chunkSeq = outputSeq;
|
||||
let appendedBytes = 0;
|
||||
if (handle) {
|
||||
appendedBytes = await runLogStore.append(handle, {
|
||||
stream,
|
||||
chunk: sanitizedChunk,
|
||||
ts,
|
||||
seq: chunkSeq,
|
||||
});
|
||||
persistedLogBytes += appendedBytes;
|
||||
}
|
||||
outputProgressState.pending = {
|
||||
at: new Date(ts),
|
||||
seq: chunkSeq,
|
||||
stream,
|
||||
bytes: persistedLogBytes,
|
||||
};
|
||||
await flushOutputProgress();
|
||||
|
||||
// Streamed CLI output is real run activity: keep the in-memory
|
||||
// runtime status ("Working... / X ago") fresh between structured
|
||||
// events so sandbox runs with mid-run log streaming never show a
|
||||
// minutes-stale timestamp. Throttled to avoid churning the live
|
||||
// event stream on every 250ms tail chunk.
|
||||
const logActivityAt = new Date(ts);
|
||||
if (
|
||||
isHeartbeatRunRuntimeStatusActive(run.status) &&
|
||||
logActivityAt.getTime() - lastLogRuntimeStatusTouchMs >=
|
||||
ACTIVE_RUN_LOG_RUNTIME_STATUS_REFRESH_INTERVAL_MS
|
||||
) {
|
||||
lastLogRuntimeStatusTouchMs = logActivityAt.getTime();
|
||||
const touchedStatus = touchHeartbeatRunRuntimeStatus({
|
||||
companyId: run.companyId,
|
||||
issueId,
|
||||
agentId: run.agentId,
|
||||
runId: run.id,
|
||||
at: logActivityAt,
|
||||
});
|
||||
if (touchedStatus)
|
||||
publishHeartbeatRunRuntimeProgress(touchedStatus);
|
||||
}
|
||||
|
||||
const payloadChunk =
|
||||
sanitizedChunk.length > MAX_LIVE_LOG_CHUNK_BYTES
|
||||
? sanitizedChunk.slice(
|
||||
sanitizedChunk.length - MAX_LIVE_LOG_CHUNK_BYTES,
|
||||
)
|
||||
: sanitizedChunk;
|
||||
|
||||
publishLiveEvent({
|
||||
companyId: run.companyId,
|
||||
type: "heartbeat.run.log",
|
||||
payload: {
|
||||
runId: run.id,
|
||||
agentId: run.agentId,
|
||||
issueId,
|
||||
ts,
|
||||
seq: chunkSeq,
|
||||
stream,
|
||||
chunk: payloadChunk,
|
||||
truncated: payloadChunk.length !== sanitizedChunk.length,
|
||||
},
|
||||
});
|
||||
};
|
||||
const onLog = (stream: "stdout" | "stderr", chunk: string) =>
|
||||
appendIdentityRedactedLog(stream, identityRedactor.chunk(stream, chunk));
|
||||
const { currentUserRedactionOptions, appendIdentityRedactedLog, onLog, onAdapterMeta, onAdapterEvent } =
|
||||
await runOutput.begin(currentRun);
|
||||
if (runScopedMentionedSkillKeys.length > 0) {
|
||||
await onLog(
|
||||
"stdout",
|
||||
@@ -3961,36 +3393,6 @@ export function heartbeatService(
|
||||
);
|
||||
}
|
||||
}
|
||||
const onAdapterMeta = async (meta: AdapterInvocationMeta) => {
|
||||
meta = identityRedactor.redact(meta);
|
||||
if (meta.env && secretKeys.size > 0) {
|
||||
for (const key of secretKeys) {
|
||||
if (key in meta.env) meta.env[key] = "***REDACTED***";
|
||||
}
|
||||
}
|
||||
await appendRunEvent(currentRun, {
|
||||
eventType: "adapter.invoke",
|
||||
stream: "system",
|
||||
level: "info",
|
||||
message: "adapter invocation",
|
||||
payload: meta as unknown as Record<string, unknown>,
|
||||
});
|
||||
};
|
||||
|
||||
const onAdapterEvent = async (event: AdapterRuntimeEvent) => {
|
||||
event = identityRedactor.redact(event);
|
||||
const eventType = event.eventType.trim();
|
||||
if (!eventType) return;
|
||||
await appendRunEvent(currentRun, {
|
||||
eventType: eventType.slice(0, 120),
|
||||
stream: event.stream,
|
||||
level: event.level,
|
||||
color: event.color,
|
||||
message: event.message,
|
||||
payload: event.payload,
|
||||
});
|
||||
};
|
||||
|
||||
const adapter = getServerAdapter(agent.adapterType);
|
||||
const durableGoalControlRun =
|
||||
readNonEmptyString(context.goalControlRequestId) !== null ||
|
||||
@@ -4389,11 +3791,7 @@ export function heartbeatService(
|
||||
}
|
||||
await runCompletion.completeRun({
|
||||
run, agent, issueId, issueRef, signal: executionControl.controller.signal,
|
||||
output: {
|
||||
handle, outputProgressState, flushOutputProgress,
|
||||
get stdoutExcerpt() { return stdoutExcerpt; },
|
||||
get stderrExcerpt() { return stderrExcerpt; },
|
||||
},
|
||||
output: runOutput,
|
||||
session: { runtimeForAdapter, previousSessionParams, taskKey, configuredModel, sessionConfigMetadata },
|
||||
readFailureReportSecrets,
|
||||
adapterResult, sessionCodec, taskSessionForRun, sessionCompaction,
|
||||
@@ -4524,11 +3922,7 @@ export function heartbeatService(
|
||||
}
|
||||
await runCompletion.failRun({
|
||||
run, agent, issueId, issueRef, signal: executionControl.controller.signal,
|
||||
output: {
|
||||
handle, outputProgressState, flushOutputProgress,
|
||||
get stdoutExcerpt() { return stdoutExcerpt; },
|
||||
get stderrExcerpt() { return stderrExcerpt; },
|
||||
},
|
||||
output: runOutput,
|
||||
session: { runtimeForAdapter, previousSessionParams, taskKey, configuredModel, sessionConfigMetadata },
|
||||
readFailureReportSecrets,
|
||||
runId, err, identityRedactor, requiredWorkspaceRestoreEvidence, legacyAdapterEntered,
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { heartbeatRuns, type Db, type agents, type projects } from "@paperclipai/db";
|
||||
import type { AiConnectionBinding } from "@paperclipai/shared";
|
||||
import { readPaperclipSkillSyncPreference } from "@paperclipai/adapter-utils/server-utils";
|
||||
import { parseObject } from "../../adapters/utils.js";
|
||||
import { logger } from "../../middleware/logger.js";
|
||||
import { AI_CONNECTION_BUSY_RETRY_REASON, extractWakeCommentIds, isNonAssigneeWorkspaceBusyRetry } from "../../modules/run-dispatch/index.js";
|
||||
import { createAgentIdentityRedactor } from "../agent-identity-redaction.js";
|
||||
import { agentIdentityService, supportsManagedAgentIdentity } from "../agent-identity.js";
|
||||
import { readAiConnectionConfigurationFailure, readAiCredentialAccessFailure } from "../ai-connection-configuration-failure.js";
|
||||
import { AI_AUTH_ENV_KEYS, isAiConnectionBusy, prepareManagedAiRuntime, stripAiAuthBindings } from "../ai-connection-runtime.js";
|
||||
import { managedAiSessionIdentityCompatible } from "../ai-connection-session.js";
|
||||
import type { companySkillService } from "../company-skills.js";
|
||||
import { connectionIntentService } from "../connection-intents.js";
|
||||
import { prepareConnectionInstructionDelivery } from "../connection-instructions.js";
|
||||
import { applyConnectorSkills, prepareConnectorSkillDelivery, resolveConnectorAssignments } from "../connector-runtime.js";
|
||||
import { resolveManagedGitHubIdentitySelection } from "../git-credentials.js";
|
||||
import { resolveAssignedConnectionInstructionsForRun } from "../native-runtime/assigned-mcp-tools.js";
|
||||
import { recordFailedSkillPreparation, type NativeRunHistoricalSpan } from "../native-runtime/native-run-trace.js";
|
||||
import { collectRunFailureSecretValues } from "../run-failure-diagnostics.js";
|
||||
import { skillVersionSelectionMap } from "../runtime-skill-selections.js";
|
||||
import type { selectHeartbeatEnvironment } from "./environment-selection.js";
|
||||
import type { createHeartbeatRetries } from "./retries.js";
|
||||
import type { createHeartbeatLifecycle } from "./run-lifecycle.js";
|
||||
import {
|
||||
ConfigurationIncompleteFailure, applyRunScopedMentionedSkillKeys, resolveExecutionRunAdapterConfig,
|
||||
resolveRunScopedMentionedSkillKeys, type createHeartbeatRunPreparation, type resolveAcceptedPlanWakeRoutingDecision,
|
||||
} from "./run-preparation.js";
|
||||
import { buildExecutionWorkspaceConfigSnapshot, stripWorkspaceRuntimeFromExecutionRunConfig } from "./workspaces.js";
|
||||
import type { HeartbeatWorkspacePreparationInput } from "./workspace-preparation.js";
|
||||
|
||||
const ACCEPTED_PLAN_CONVERSION_SKILL_KEY =
|
||||
"paperclipai/paperclip/paperclip-converting-plans-to-tasks";
|
||||
|
||||
type ManagedAiRuntime = Awaited<ReturnType<typeof prepareManagedAiRuntime>>;
|
||||
type EnvironmentSelection = Awaited<ReturnType<typeof selectHeartbeatEnvironment>>;
|
||||
|
||||
export interface HeartbeatExecutionConfigurationInput {
|
||||
run: typeof heartbeatRuns.$inferSelect;
|
||||
agent: typeof agents.$inferSelect;
|
||||
task: {
|
||||
issueId: string | null;
|
||||
context: Record<string, unknown>;
|
||||
responsibleUserId: string | null;
|
||||
acceptedPlanContinuationWake: boolean;
|
||||
acceptedPlanWakeRoutingDecision: Awaited<ReturnType<typeof resolveAcceptedPlanWakeRoutingDecision>> | null;
|
||||
taskProjectContext: Pick<typeof projects.$inferSelect, "id" | "env"> | null;
|
||||
routineEnvContext: Awaited<ReturnType<ReturnType<typeof createHeartbeatRunPreparation>["getRoutineEnvForExecutionIssue"]>>;
|
||||
};
|
||||
config: {
|
||||
sourceWorkspaceConfig: Record<string, unknown>;
|
||||
aiBinding: AiConnectionBinding | undefined;
|
||||
};
|
||||
policy: Pick<HeartbeatWorkspacePreparationInput["policy"],
|
||||
"trustPreset" | "useIsolatedTaskDirectory"> & { explicitTaskDirectory: boolean };
|
||||
environment: Pick<EnvironmentSelection,
|
||||
"selectedEnvironmentId" | "selectedEnvironmentForConfig" | "resolvedInstanceSettings" | "persistedNativeExecutionInput">;
|
||||
services: {
|
||||
secretsSvc: Parameters<typeof resolveExecutionRunAdapterConfig>[0]["secretsSvc"];
|
||||
companySkills: Pick<ReturnType<typeof companySkillService>, "listRuntimeSkillEntries">;
|
||||
appendRunEvent: ReturnType<typeof createHeartbeatLifecycle>["appendRunEvent"];
|
||||
finalizeAiConnectionBusyDeferral: ReturnType<typeof createHeartbeatRetries>["finalizeAiConnectionBusyDeferral"];
|
||||
};
|
||||
// The executor must observe these before a later preparation step can throw.
|
||||
onFailureSecretsResolved: (readSecrets: () => string[]) => void;
|
||||
onManagedAiRuntimeAcquired: (runtime: ManagedAiRuntime) => void;
|
||||
onIdentityRedactorPrepared: (redactor: ReturnType<typeof createAgentIdentityRedactor>) => void;
|
||||
}
|
||||
|
||||
/** Assembles effective run configuration; the executor retains cleanup ownership. */
|
||||
export async function prepareHeartbeatExecutionConfiguration(db: Db, input: HeartbeatExecutionConfigurationInput) {
|
||||
const { run, agent, onFailureSecretsResolved, onManagedAiRuntimeAcquired, onIdentityRedactorPrepared } = input;
|
||||
const { issueId, context, responsibleUserId, acceptedPlanContinuationWake, acceptedPlanWakeRoutingDecision, taskProjectContext, routineEnvContext } = input.task;
|
||||
const { sourceWorkspaceConfig, aiBinding } = input.config;
|
||||
const { trustPreset, useIsolatedTaskDirectory, explicitTaskDirectory } = input.policy;
|
||||
const { selectedEnvironmentId, selectedEnvironmentForConfig, resolvedInstanceSettings, persistedNativeExecutionInput } = input.environment;
|
||||
const { secretsSvc, companySkills, appendRunEvent, finalizeAiConnectionBusyDeferral } = input.services;
|
||||
let managedAiRuntime: ManagedAiRuntime | undefined;
|
||||
const mergedConfig = {
|
||||
...sourceWorkspaceConfig,
|
||||
// The base below is already task-owned. Keep directory transport while
|
||||
// preserving isolated mode and the mandatory sandbox preflight.
|
||||
...((useIsolatedTaskDirectory || explicitTaskDirectory) ? { workspaceStrategy: { type: "project_primary" } } : {}),
|
||||
};
|
||||
const configSnapshot = buildExecutionWorkspaceConfigSnapshot(
|
||||
mergedConfig,
|
||||
selectedEnvironmentId,
|
||||
);
|
||||
const executionRunConfig =
|
||||
stripWorkspaceRuntimeFromExecutionRunConfig(mergedConfig);
|
||||
const runScopedMentionedSkillKeys =
|
||||
await resolveRunScopedMentionedSkillKeys({
|
||||
db,
|
||||
companyId: agent.companyId,
|
||||
issueId,
|
||||
commentIds: extractWakeCommentIds(context),
|
||||
});
|
||||
const runScopedSkillKeys =
|
||||
acceptedPlanContinuationWake &&
|
||||
!acceptedPlanWakeRoutingDecision?.suppressAcceptedContinuation
|
||||
? [...runScopedMentionedSkillKeys, ACCEPTED_PLAN_CONVERSION_SKILL_KEY]
|
||||
: runScopedMentionedSkillKeys;
|
||||
const githubSelection = await resolveManagedGitHubIdentitySelection(
|
||||
db,
|
||||
agent.companyId,
|
||||
{
|
||||
agentId: agent.id,
|
||||
responsibleUserId,
|
||||
allowStandingDelegation: false,
|
||||
},
|
||||
);
|
||||
const useHostGitHub =
|
||||
!githubSelection.configured &&
|
||||
trustPreset.kind === "standard" &&
|
||||
["local", "ssh"].includes(
|
||||
selectedEnvironmentForConfig?.driver ?? "local",
|
||||
);
|
||||
|
||||
const { resolvedConfig, configuredTaskEnvironment, secretKeys, secretManifest } =
|
||||
await resolveExecutionRunAdapterConfig({
|
||||
managedAiCredentials: Boolean(aiBinding),
|
||||
managedGitHubCredentials: !useHostGitHub,
|
||||
companyId: agent.companyId,
|
||||
agentId: agent.id,
|
||||
adapterType: agent.adapterType,
|
||||
issueId,
|
||||
heartbeatRunId: run.id,
|
||||
environmentId: selectedEnvironmentForConfig?.id ?? null,
|
||||
environmentEnv: aiBinding ? stripAiAuthBindings(selectedEnvironmentForConfig?.envVars) : selectedEnvironmentForConfig?.envVars ?? null,
|
||||
environmentDriver: selectedEnvironmentForConfig?.driver ?? null,
|
||||
projectId: taskProjectContext?.id ?? null,
|
||||
routineId: routineEnvContext.routineId,
|
||||
responsibleUserId,
|
||||
executionRunConfig: aiBinding ? { ...executionRunConfig, env: stripAiAuthBindings(executionRunConfig.env) } : executionRunConfig,
|
||||
projectEnv: aiBinding ? stripAiAuthBindings(taskProjectContext?.env) : taskProjectContext?.env ?? null,
|
||||
routineEnv: aiBinding ? stripAiAuthBindings(routineEnvContext.env) : routineEnvContext.env,
|
||||
secretsSvc,
|
||||
trustPreset,
|
||||
});
|
||||
onFailureSecretsResolved(() => collectRunFailureSecretValues(resolvedConfig.env, secretKeys));
|
||||
if (aiBinding) {
|
||||
try {
|
||||
managedAiRuntime = await prepareManagedAiRuntime(db, { companyId: agent.companyId, agentId: agent.id, responsibleUserId, adapterType: agent.adapterType, binding: aiBinding, config: resolvedConfig });
|
||||
onManagedAiRuntimeAcquired(managedAiRuntime);
|
||||
} catch (error) {
|
||||
// Only fresh executions can receive a pre-provider wait receipt. A
|
||||
// persisted native input may already have provider effects to recover.
|
||||
if (isAiConnectionBusy(error) && !persistedNativeExecutionInput) {
|
||||
// Use the authority recorded by the locked admission gate, never
|
||||
// the issue's mutable assignee observed during runtime preparation.
|
||||
const authorizedNonAssigneeWake =
|
||||
parseObject(run.runnerProfileJson).aiConnectionNonAssigneeCommentWake === true ||
|
||||
(run.scheduledRetryReason === AI_CONNECTION_BUSY_RETRY_REASON &&
|
||||
isNonAssigneeWorkspaceBusyRetry(run.scheduledRetryReason, parseObject(run.contextSnapshot)));
|
||||
await finalizeAiConnectionBusyDeferral(run, error, !authorizedNonAssigneeWake);
|
||||
return { prepared: false as const };
|
||||
}
|
||||
if (responsibleUserId && issueId) {
|
||||
await connectionIntentService(db).request({ sub: agent.id, company_id: agent.companyId, run_id: run.id, responsible_user_id: responsibleUserId }, aiBinding.provider, { purpose: "ai" }).catch(() => {
|
||||
logger.warn({ runId: run.id, agentId: agent.id }, "Could not attach AI connection request; runtime configuration action remains available");
|
||||
});
|
||||
}
|
||||
throw new ConfigurationIncompleteFailure(error instanceof Error ? error.message : "Configure this agent’s AI connection", {
|
||||
configurationIncomplete: { reason: "ai_connection_unavailable", companyId: agent.companyId, agentId: agent.id, responsibleUserId,
|
||||
...(!persistedNativeExecutionInput && readAiConnectionConfigurationFailure(error)
|
||||
? { selectionFailure: readAiConnectionConfigurationFailure(error) } : {}),
|
||||
...(readAiCredentialAccessFailure(error)
|
||||
? { credentialAccess: readAiCredentialAccessFailure(error) } : {}),
|
||||
provider: aiBinding.provider, method: aiBinding.method, actionUrl: `/agents/${agent.id}/runtime`,
|
||||
fingerprint: `ai:${agent.id}:${responsibleUserId}:${JSON.stringify(aiBinding)}` },
|
||||
});
|
||||
}
|
||||
const savedAiAccount = parseObject(run.contextSnapshot?.aiConnection);
|
||||
if (persistedNativeExecutionInput && !managedAiSessionIdentityCompatible(
|
||||
savedAiAccount.sessionIdentity ?? savedAiAccount.identity,
|
||||
managedAiRuntime.sessionIdentity,
|
||||
managedAiRuntime.identity,
|
||||
)) {
|
||||
throw new ConfigurationIncompleteFailure("The AI account changed while this native run was suspended. Start a new execution.", { configurationIncomplete: { reason: "ai_connection_changed", actionUrl: `/agents/${agent.id}/runtime` } });
|
||||
}
|
||||
Object.assign(resolvedConfig, managedAiRuntime.config);
|
||||
for (const key of AI_AUTH_ENV_KEYS) secretKeys.add(key);
|
||||
context.aiConnection = { ...managedAiRuntime.attribution, accountName: managedAiRuntime.accountName, identity: managedAiRuntime.identity, sessionIdentity: managedAiRuntime.sessionIdentity };
|
||||
await db.update(heartbeatRuns).set({ contextSnapshot: sql`coalesce(${heartbeatRuns.contextSnapshot}, '{}'::jsonb) || ${JSON.stringify({ aiConnection: context.aiConnection, ...(context.aiRouterTaskKey ? { aiRouterTaskKey: context.aiRouterTaskKey } : {}), ...(context.aiRouterSelection ? { aiRouterSelection: context.aiRouterSelection } : {}) })}::jsonb` }).where(eq(heartbeatRuns.id, run.id));
|
||||
}
|
||||
if (secretManifest.length > 0) {
|
||||
context.paperclipSecrets = {
|
||||
manifest: secretManifest,
|
||||
};
|
||||
} else {
|
||||
delete context.paperclipSecrets;
|
||||
}
|
||||
const effectiveResolvedConfig = applyRunScopedMentionedSkillKeys(
|
||||
resolvedConfig,
|
||||
runScopedSkillKeys,
|
||||
);
|
||||
const runtimeSkillPreference = readPaperclipSkillSyncPreference(
|
||||
effectiveResolvedConfig,
|
||||
);
|
||||
const nativeRunnerPreparationSpans: NativeRunHistoricalSpan[] = [];
|
||||
const skillsPrepareStartedAtMs = Date.now();
|
||||
const runtimeSkillEntries = await (async () => {
|
||||
try {
|
||||
return await companySkills.listRuntimeSkillEntries(agent.companyId, {
|
||||
versionSelections: skillVersionSelectionMap(
|
||||
runtimeSkillPreference.desiredSkillEntries,
|
||||
{
|
||||
versionPinsEnabled:
|
||||
resolvedInstanceSettings.experimental.enableBetaSkills ===
|
||||
true,
|
||||
},
|
||||
),
|
||||
});
|
||||
} catch (error) {
|
||||
if (agent.adapterType === "paperclip_runner") {
|
||||
await recordFailedSkillPreparation({
|
||||
runId: run.id,
|
||||
startedAtMs: skillsPrepareStartedAtMs,
|
||||
onEvent: async (event) => {
|
||||
await appendRunEvent(run, event);
|
||||
},
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
})();
|
||||
nativeRunnerPreparationSpans.push({
|
||||
name: "skills.prepare",
|
||||
parentName: "task.prepare",
|
||||
startedAtMs: skillsPrepareStartedAtMs,
|
||||
endedAtMs: Date.now(),
|
||||
});
|
||||
const connectorAssignments = await resolveConnectorAssignments(db, { companyId: agent.companyId, agentId: agent.id, runId: run.id, issueId: typeof context.issueId === "string" ? context.issueId : undefined });
|
||||
const connectorSkillConfig = await applyConnectorSkills(effectiveResolvedConfig, runtimeSkillEntries, connectorAssignments);
|
||||
// Both CLI adapters and native context materialization use the same resolved set.
|
||||
runtimeSkillEntries.splice(0, runtimeSkillEntries.length, ...connectorSkillConfig.paperclipRuntimeSkills);
|
||||
const connectorDelivery = await prepareConnectorSkillDelivery(connectorSkillConfig, agent.adapterType);
|
||||
// Always replace this runtime-only field; caller wake data cannot supply skills.
|
||||
context.paperclipWake = { ...parseObject(context.paperclipWake), connectorSkillInstructions: connectorDelivery.instructions };
|
||||
let runtimeConfig = await prepareConnectionInstructionDelivery({
|
||||
resolve: () => resolveAssignedConnectionInstructionsForRun(db, { companyId: agent.companyId, agentId: agent.id, runId: run.id }),
|
||||
context, config: connectorDelivery.config, native: agent.adapterType === "paperclip_runner",
|
||||
});
|
||||
const agentIdentity = supportsManagedAgentIdentity(agent.adapterType, agent.adapterConfig, run.runtimeMode === "native" ? run.driverKind : undefined)
|
||||
? await agentIdentityService(db).ensureAgentIdentity(agent.companyId, agent.id)
|
||||
: undefined;
|
||||
const identityRedactor = createAgentIdentityRedactor(agentIdentity?.privateKeyPem);
|
||||
onIdentityRedactorPrepared(identityRedactor);
|
||||
if (agentIdentity) secretKeys.add("PAPERCLIP_AGENT_PRIVATE_KEY");
|
||||
return {
|
||||
prepared: true as const,
|
||||
mergedConfig, configSnapshot, runScopedMentionedSkillKeys, githubSelection, useHostGitHub,
|
||||
resolvedConfig, configuredTaskEnvironment, secretKeys, secretManifest,
|
||||
runtimeSkillEntries, nativeRunnerPreparationSpans, runtimeConfig, agentIdentity,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { heartbeatRuns, type Db } from "@paperclipai/db";
|
||||
import type { AdapterInvocationMeta, AdapterRuntimeEvent } from "../../adapters/index.js";
|
||||
import { redactCurrentUserText, type CurrentUserRedactionOptions } from "../../log-redaction.js";
|
||||
import type { createAgentIdentityRedactor } from "../agent-identity-redaction.js";
|
||||
import { touchHeartbeatRunRuntimeStatus } from "../heartbeat-run-runtime-status.js";
|
||||
import { publishLiveEvent } from "../live-events.js";
|
||||
import type { RunLogHandle, RunLogStore } from "../run-log-store.js";
|
||||
import { appendExcerpt, compactRunLogChunk } from "./run-log.js";
|
||||
import { isHeartbeatRunRuntimeStatusActive, publishHeartbeatRunRuntimeProgress, type createHeartbeatLifecycle } from "./run-lifecycle.js";
|
||||
|
||||
const MAX_LIVE_LOG_CHUNK_BYTES = 8 * 1024;
|
||||
export const ACTIVE_RUN_OUTPUT_PROGRESS_FLUSH_INTERVAL_MS = 60 * 1000;
|
||||
export const ACTIVE_RUN_LOG_RUNTIME_STATUS_REFRESH_INTERVAL_MS = 5 * 1000;
|
||||
|
||||
type Run = typeof heartbeatRuns.$inferSelect;
|
||||
|
||||
export interface HeartbeatRunOutputInput {
|
||||
run: Run;
|
||||
runId: string;
|
||||
issueId: string | null;
|
||||
runLogStore: Pick<RunLogStore, "begin" | "append">;
|
||||
identityRedactor: Pick<ReturnType<typeof createAgentIdentityRedactor>, "chunk" | "redact">;
|
||||
secretKeys: Set<string>;
|
||||
appendRunEvent: ReturnType<typeof createHeartbeatLifecycle>["appendRunEvent"];
|
||||
getCurrentUserRedactionOptions: () => Promise<CurrentUserRedactionOptions>;
|
||||
}
|
||||
|
||||
/** One recorder per execution attempt; the log store itself stays service-scoped. */
|
||||
export function createHeartbeatRunOutput(db: Db, input: HeartbeatRunOutputInput) {
|
||||
const { run, runId, issueId, runLogStore, identityRedactor, secretKeys, appendRunEvent, getCurrentUserRedactionOptions } = input;
|
||||
let handle: RunLogHandle | null = null;
|
||||
let stdoutExcerpt = "";
|
||||
let stderrExcerpt = "";
|
||||
let outputSeq = Number(run.lastOutputSeq ?? 0);
|
||||
let lastOutputFlushAt: Date | null = run.lastOutputAt ?? null;
|
||||
let lastLogRuntimeStatusTouchMs = 0;
|
||||
const outputProgressState: {
|
||||
pending: {
|
||||
at: Date;
|
||||
seq: number;
|
||||
stream: "stdout" | "stderr";
|
||||
bytes: number;
|
||||
} | null;
|
||||
} = { pending: null };
|
||||
let persistedLogBytes = Number(run.logBytes ?? 0);
|
||||
const flushOutputProgress = async (opts?: { force?: boolean }) => {
|
||||
const pendingOutputProgress = outputProgressState.pending;
|
||||
if (!pendingOutputProgress) return;
|
||||
const shouldFlush =
|
||||
opts?.force === true ||
|
||||
!lastOutputFlushAt ||
|
||||
pendingOutputProgress.at.getTime() - lastOutputFlushAt.getTime() >=
|
||||
ACTIVE_RUN_OUTPUT_PROGRESS_FLUSH_INTERVAL_MS;
|
||||
if (!shouldFlush) return;
|
||||
await db
|
||||
.update(heartbeatRuns)
|
||||
.set({
|
||||
lastOutputAt: pendingOutputProgress.at,
|
||||
lastOutputSeq: pendingOutputProgress.seq,
|
||||
lastOutputStream: pendingOutputProgress.stream,
|
||||
lastOutputBytes: pendingOutputProgress.bytes,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(heartbeatRuns.id, run.id));
|
||||
lastOutputFlushAt = pendingOutputProgress.at;
|
||||
outputProgressState.pending = null;
|
||||
};
|
||||
// Bind handlers to the run row persisted at execution start. Progress writes
|
||||
// above use only the immutable run ID from the initial row.
|
||||
async function begin(currentRun: Run) {
|
||||
const run = currentRun;
|
||||
handle = await runLogStore.begin({
|
||||
companyId: run.companyId,
|
||||
agentId: run.agentId,
|
||||
runId,
|
||||
});
|
||||
|
||||
await db
|
||||
.update(heartbeatRuns)
|
||||
.set({
|
||||
logStore: handle.store,
|
||||
logRef: handle.logRef,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(heartbeatRuns.id, runId));
|
||||
|
||||
const currentUserRedactionOptions =
|
||||
await getCurrentUserRedactionOptions();
|
||||
const appendIdentityRedactedLog = async (stream: "stdout" | "stderr", chunk: string) => {
|
||||
const sanitizedChunk = compactRunLogChunk(
|
||||
redactCurrentUserText(chunk, currentUserRedactionOptions),
|
||||
);
|
||||
if (stream === "stdout")
|
||||
stdoutExcerpt = appendExcerpt(stdoutExcerpt, sanitizedChunk);
|
||||
if (stream === "stderr")
|
||||
stderrExcerpt = appendExcerpt(stderrExcerpt, sanitizedChunk);
|
||||
const ts = new Date().toISOString();
|
||||
|
||||
outputSeq += 1;
|
||||
const chunkSeq = outputSeq;
|
||||
let appendedBytes = 0;
|
||||
if (handle) {
|
||||
appendedBytes = await runLogStore.append(handle, {
|
||||
stream,
|
||||
chunk: sanitizedChunk,
|
||||
ts,
|
||||
seq: chunkSeq,
|
||||
});
|
||||
persistedLogBytes += appendedBytes;
|
||||
}
|
||||
outputProgressState.pending = {
|
||||
at: new Date(ts),
|
||||
seq: chunkSeq,
|
||||
stream,
|
||||
bytes: persistedLogBytes,
|
||||
};
|
||||
await flushOutputProgress();
|
||||
|
||||
// Streamed CLI output is real run activity: keep the in-memory
|
||||
// runtime status ("Working... / X ago") fresh between structured
|
||||
// events so sandbox runs with mid-run log streaming never show a
|
||||
// minutes-stale timestamp. Throttled to avoid churning the live
|
||||
// event stream on every 250ms tail chunk.
|
||||
const logActivityAt = new Date(ts);
|
||||
if (
|
||||
isHeartbeatRunRuntimeStatusActive(run.status) &&
|
||||
logActivityAt.getTime() - lastLogRuntimeStatusTouchMs >=
|
||||
ACTIVE_RUN_LOG_RUNTIME_STATUS_REFRESH_INTERVAL_MS
|
||||
) {
|
||||
lastLogRuntimeStatusTouchMs = logActivityAt.getTime();
|
||||
const touchedStatus = touchHeartbeatRunRuntimeStatus({
|
||||
companyId: run.companyId,
|
||||
issueId,
|
||||
agentId: run.agentId,
|
||||
runId: run.id,
|
||||
at: logActivityAt,
|
||||
});
|
||||
if (touchedStatus)
|
||||
publishHeartbeatRunRuntimeProgress(touchedStatus);
|
||||
}
|
||||
|
||||
const payloadChunk =
|
||||
sanitizedChunk.length > MAX_LIVE_LOG_CHUNK_BYTES
|
||||
? sanitizedChunk.slice(
|
||||
sanitizedChunk.length - MAX_LIVE_LOG_CHUNK_BYTES,
|
||||
)
|
||||
: sanitizedChunk;
|
||||
|
||||
publishLiveEvent({
|
||||
companyId: run.companyId,
|
||||
type: "heartbeat.run.log",
|
||||
payload: {
|
||||
runId: run.id,
|
||||
agentId: run.agentId,
|
||||
issueId,
|
||||
ts,
|
||||
seq: chunkSeq,
|
||||
stream,
|
||||
chunk: payloadChunk,
|
||||
truncated: payloadChunk.length !== sanitizedChunk.length,
|
||||
},
|
||||
});
|
||||
};
|
||||
const onLog = (stream: "stdout" | "stderr", chunk: string) =>
|
||||
appendIdentityRedactedLog(stream, identityRedactor.chunk(stream, chunk));
|
||||
const onAdapterMeta = async (meta: AdapterInvocationMeta) => {
|
||||
meta = identityRedactor.redact(meta);
|
||||
if (meta.env && secretKeys.size > 0) {
|
||||
for (const key of secretKeys) {
|
||||
if (key in meta.env) meta.env[key] = "***REDACTED***";
|
||||
}
|
||||
}
|
||||
await appendRunEvent(currentRun, {
|
||||
eventType: "adapter.invoke",
|
||||
stream: "system",
|
||||
level: "info",
|
||||
message: "adapter invocation",
|
||||
payload: meta as unknown as Record<string, unknown>,
|
||||
});
|
||||
};
|
||||
|
||||
const onAdapterEvent = async (event: AdapterRuntimeEvent) => {
|
||||
event = identityRedactor.redact(event);
|
||||
const eventType = event.eventType.trim();
|
||||
if (!eventType) return;
|
||||
await appendRunEvent(currentRun, {
|
||||
eventType: eventType.slice(0, 120),
|
||||
stream: event.stream,
|
||||
level: event.level,
|
||||
color: event.color,
|
||||
message: event.message,
|
||||
payload: event.payload,
|
||||
});
|
||||
};
|
||||
|
||||
return { currentUserRedactionOptions, appendIdentityRedactedLog, onLog, onAdapterMeta, onAdapterEvent };
|
||||
}
|
||||
|
||||
return {
|
||||
begin,
|
||||
get handle() { return handle; },
|
||||
outputProgressState,
|
||||
flushOutputProgress,
|
||||
// Finalization awaits I/O and can receive more output before persisting excerpts.
|
||||
get stdoutExcerpt() { return stdoutExcerpt; },
|
||||
get stderrExcerpt() { return stderrExcerpt; },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
import { and, desc, eq } from "drizzle-orm";
|
||||
import { agentConfigRevisions, type Db, type agents, type agentTaskSessions, type heartbeatRuns, type issues, type projects } from "@paperclipai/db";
|
||||
import type { AiConnectionBinding, AiRuntimeConnectionBinding } from "@paperclipai/shared";
|
||||
import { getServerAdapter } from "../../adapters/index.js";
|
||||
import type { prepareManagedAiRuntime } from "../ai-connection-runtime.js";
|
||||
import { aiConnectionSessionCompatibilityInputs, managedAiSessionIdentityCompatible } from "../ai-connection-session.js";
|
||||
import { createNativeSessionHandoffLoader } from "../native-runtime/native-session-handoff.js";
|
||||
import {
|
||||
describeSessionResetReason, shouldResetTaskSessionForWake, isCanonicalSessionIdForAdapter,
|
||||
normalizeResumeParamsForAdapter, normalizeSessionParams, requiresCanonicalSessionIds,
|
||||
truncateDisplayId, type createHeartbeatRunState, type getAdapterSessionCodec,
|
||||
} from "./run-state.js";
|
||||
import {
|
||||
buildEffectiveRunSessionConfigMetadata, readConfigFingerprintFromSessionParams,
|
||||
readConfiguredModelFromAdapterConfig, resolveTaskSessionConfigFreshness,
|
||||
stripPaperclipSessionMetadataFromSessionParams, type EffectiveRunSessionConfigMetadata,
|
||||
} from "./workspaces.js";
|
||||
import type { HeartbeatWorkspacePreparationInput, prepareHeartbeatWorkspace } from "./workspace-preparation.js";
|
||||
import type { prepareHeartbeatTaskContext } from "./task-context.js";
|
||||
|
||||
type RunState = ReturnType<typeof createHeartbeatRunState>;
|
||||
type WorkspaceInput = HeartbeatWorkspacePreparationInput;
|
||||
type PreparedWorkspace = Awaited<ReturnType<typeof prepareHeartbeatWorkspace>>;
|
||||
type TaskContext = Awaited<ReturnType<typeof prepareHeartbeatTaskContext>>;
|
||||
type SessionMetadataInput = Parameters<typeof buildEffectiveRunSessionConfigMetadata>[0];
|
||||
type ManagedAiRuntime = Awaited<ReturnType<typeof prepareManagedAiRuntime>>;
|
||||
type TaskSession = typeof agentTaskSessions.$inferSelect | null;
|
||||
type SessionCodec = ReturnType<typeof getAdapterSessionCodec>;
|
||||
|
||||
export interface HeartbeatSessionPreparationInput {
|
||||
run: Pick<typeof heartbeatRuns.$inferSelect, "createdAt">;
|
||||
agent: typeof agents.$inferSelect;
|
||||
task: {
|
||||
context: Record<string, unknown>;
|
||||
issueRef: Pick<typeof issues.$inferSelect, "id"> | null;
|
||||
issueContext: Pick<typeof issues.$inferSelect, "updatedAt"> | null;
|
||||
taskProjectContext: Pick<typeof projects.$inferSelect, "env"> | null;
|
||||
workspaceProjectContext: Pick<typeof projects.$inferSelect, "updatedAt"> | null;
|
||||
wakeCommentId: string | null;
|
||||
acceptedPlanContinuationWake: boolean;
|
||||
acceptedPlanWakeRoutingDecision: unknown;
|
||||
};
|
||||
session: {
|
||||
taskSession: TaskSession;
|
||||
taskSessionDecodedParams: Record<string, unknown> | null;
|
||||
explicitResumeSessionParams: Record<string, unknown> | null;
|
||||
explicitResumeSessionDisplayId: string | null;
|
||||
sessionCodec: SessionCodec;
|
||||
};
|
||||
config: {
|
||||
runtimeConfig: Record<string, unknown>;
|
||||
agentIdentity: { keyId: string } | undefined;
|
||||
issueAssigneeOverrides: unknown;
|
||||
originalAiIssueOverrides: unknown;
|
||||
managedAiRuntime: ManagedAiRuntime | undefined;
|
||||
aiBinding: AiConnectionBinding | undefined;
|
||||
requestedAiBinding: AiRuntimeConnectionBinding | undefined;
|
||||
secretManifest: SessionMetadataInput["secretManifest"];
|
||||
runtimeSkillEntries: SessionMetadataInput["runtimeSkills"];
|
||||
routineEnvContext: { env: unknown };
|
||||
};
|
||||
workspace: Pick<WorkspaceInput["policy"], "requestedExecutionWorkspaceMode" | "effectiveExecutionWorkspaceMode" | "projectExecutionWorkspacePolicy" | "issueExecutionWorkspaceSettings" | "executionPolicy"> & {
|
||||
requestedReusableExecutionWorkspaceConfig: NonNullable<WorkspaceInput["reuse"]["reusableExistingExecutionWorkspace"]>["config"] | null;
|
||||
reusableExistingExecutionWorkspace: WorkspaceInput["reuse"]["reusableExistingExecutionWorkspace"];
|
||||
};
|
||||
environment: Pick<WorkspaceInput["environment"], "environmentResolution" | "selectedEnvironmentId" | "selectedEnvironmentForConfig">;
|
||||
getLatestAgentConfigRevision: RunState["getLatestAgentConfigRevision"];
|
||||
}
|
||||
|
||||
function readNonEmptyString(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value : null;
|
||||
}
|
||||
|
||||
/** Selects a resumable session before workspace preparation; acquires no runtime resources. */
|
||||
export async function prepareHeartbeatSession(db: Db, input: HeartbeatSessionPreparationInput) {
|
||||
const { run, agent, getLatestAgentConfigRevision } = input;
|
||||
const { context, issueRef, issueContext, taskProjectContext, workspaceProjectContext, wakeCommentId, acceptedPlanContinuationWake, acceptedPlanWakeRoutingDecision } = input.task;
|
||||
const { taskSession, taskSessionDecodedParams, explicitResumeSessionParams, explicitResumeSessionDisplayId, sessionCodec } = input.session;
|
||||
const { runtimeConfig, agentIdentity, issueAssigneeOverrides, originalAiIssueOverrides, managedAiRuntime, aiBinding, requestedAiBinding, secretManifest, runtimeSkillEntries, routineEnvContext } = input.config;
|
||||
const { requestedExecutionWorkspaceMode, effectiveExecutionWorkspaceMode, projectExecutionWorkspacePolicy, issueExecutionWorkspaceSettings, executionPolicy, requestedReusableExecutionWorkspaceConfig, reusableExistingExecutionWorkspace } = input.workspace;
|
||||
const { environmentResolution, selectedEnvironmentId, selectedEnvironmentForConfig } = input.environment;
|
||||
const latestAgentConfigRevision = await getLatestAgentConfigRevision(
|
||||
agent.companyId,
|
||||
agent.id,
|
||||
);
|
||||
const sessionConfigMetadataInput = {
|
||||
agentIdentityKeyId: agentIdentity?.keyId,
|
||||
adapterType: agent.adapterType,
|
||||
effectiveAdapterConfig: runtimeConfig,
|
||||
managedAiHome: managedAiRuntime?.home,
|
||||
agentRuntimeConfig: agent.runtimeConfig,
|
||||
issueOverrides: issueAssigneeOverrides,
|
||||
workspaceConfig: {
|
||||
requestedMode: requestedExecutionWorkspaceMode,
|
||||
effectiveMode: effectiveExecutionWorkspaceMode,
|
||||
issueConfigRevisionAt:
|
||||
issueContext?.updatedAt instanceof Date
|
||||
? issueContext.updatedAt.toISOString()
|
||||
: (issueContext?.updatedAt ?? null),
|
||||
projectConfigRevisionAt:
|
||||
workspaceProjectContext?.updatedAt instanceof Date
|
||||
? workspaceProjectContext.updatedAt.toISOString()
|
||||
: (workspaceProjectContext?.updatedAt ?? null),
|
||||
projectPolicy: projectExecutionWorkspacePolicy,
|
||||
issueSettings: issueExecutionWorkspaceSettings,
|
||||
reusableExecutionWorkspaceConfig:
|
||||
requestedReusableExecutionWorkspaceConfig,
|
||||
existingExecutionWorkspace: reusableExistingExecutionWorkspace
|
||||
? {
|
||||
id: reusableExistingExecutionWorkspace.id,
|
||||
mode: reusableExistingExecutionWorkspace.mode,
|
||||
strategyType: reusableExistingExecutionWorkspace.strategyType,
|
||||
projectWorkspaceId:
|
||||
reusableExistingExecutionWorkspace.projectWorkspaceId,
|
||||
repoUrl: reusableExistingExecutionWorkspace.repoUrl,
|
||||
baseRef: reusableExistingExecutionWorkspace.baseRef,
|
||||
branchName: reusableExistingExecutionWorkspace.branchName,
|
||||
config: reusableExistingExecutionWorkspace.config,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
environment: {
|
||||
selectionSource: environmentResolution.source,
|
||||
selectedEnvironmentId,
|
||||
selectedEnvironment: selectedEnvironmentForConfig
|
||||
? {
|
||||
id: selectedEnvironmentForConfig.id,
|
||||
driver: selectedEnvironmentForConfig.driver,
|
||||
config: selectedEnvironmentForConfig.config,
|
||||
configRevisionAt:
|
||||
selectedEnvironmentForConfig.updatedAt instanceof Date
|
||||
? selectedEnvironmentForConfig.updatedAt.toISOString()
|
||||
: (selectedEnvironmentForConfig.updatedAt ?? null),
|
||||
}
|
||||
: null,
|
||||
executionPolicy,
|
||||
},
|
||||
environmentEnv: selectedEnvironmentForConfig?.envVars ?? null,
|
||||
projectEnv: taskProjectContext?.env ?? null,
|
||||
routineEnv: routineEnvContext.env,
|
||||
secretManifest,
|
||||
runtimeSkills: runtimeSkillEntries,
|
||||
agentConfigRevision: latestAgentConfigRevision
|
||||
? {
|
||||
id: latestAgentConfigRevision.id,
|
||||
changedKeys: latestAgentConfigRevision.changedKeys,
|
||||
configRevisionAt:
|
||||
latestAgentConfigRevision.createdAt.toISOString(),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
const sessionConfigMetadata = await buildEffectiveRunSessionConfigMetadata(sessionConfigMetadataInput);
|
||||
let compatibleConfigMetadata: EffectiveRunSessionConfigMetadata[] = [];
|
||||
if (managedAiRuntime && aiBinding && taskSession &&
|
||||
readConfigFingerprintFromSessionParams(taskSession.sessionParamsJson)?.fingerprint !== sessionConfigMetadata.fingerprint) {
|
||||
const revisions = requestedAiBinding?.mode === "router"
|
||||
? await db.select().from(agentConfigRevisions).where(and(eq(agentConfigRevisions.companyId, agent.companyId), eq(agentConfigRevisions.agentId, agent.id))).orderBy(desc(agentConfigRevisions.createdAt), desc(agentConfigRevisions.id)).limit(21)
|
||||
: [];
|
||||
const candidates = aiConnectionSessionCompatibilityInputs({
|
||||
effectiveAdapterConfig: runtimeConfig, agentRuntimeConfig: agent.runtimeConfig,
|
||||
agentConfigRevision: sessionConfigMetadataInput.agentConfigRevision,
|
||||
issueOverrides: issueAssigneeOverrides, originalIssueOverrides: originalAiIssueOverrides,
|
||||
binding: aiBinding, router: requestedAiBinding?.mode === "router",
|
||||
storedIdentity: taskSession.sessionParamsJson?.paperclipAiCredentialIdentity,
|
||||
sessionIdentity: managedAiRuntime.sessionIdentity, credentialIdentity: managedAiRuntime.identity, revisions,
|
||||
});
|
||||
compatibleConfigMetadata = await Promise.all(candidates.map(candidate => buildEffectiveRunSessionConfigMetadata({ ...sessionConfigMetadataInput, ...candidate })));
|
||||
}
|
||||
const configuredModel =
|
||||
readConfiguredModelFromAdapterConfig(runtimeConfig);
|
||||
if (context.refreshTools === true && agent.adapterType !== "paperclip_runner") {
|
||||
const capability = getServerAdapter(agent.adapterType).supportsToolRefreshOnResume;
|
||||
const canRefresh = typeof capability === "function" ? capability(runtimeConfig) : capability === true;
|
||||
if (!canRefresh) context.forceFreshSession = true;
|
||||
}
|
||||
const wakeSessionResetReason = describeSessionResetReason(context);
|
||||
const sessionConfigFreshness = resolveTaskSessionConfigFreshness({
|
||||
hasTaskSession: taskSession != null,
|
||||
configuredModel,
|
||||
taskSessionParams:
|
||||
taskSession?.sessionParamsJson ?? taskSessionDecodedParams,
|
||||
configMetadata: sessionConfigMetadata,
|
||||
compatibleConfigMetadata,
|
||||
wakeResetReason: wakeSessionResetReason,
|
||||
preserveLegacySessionWithoutConfigMetadata:
|
||||
acceptedPlanContinuationWake && !acceptedPlanWakeRoutingDecision && !agentIdentity,
|
||||
});
|
||||
const resetTaskSession =
|
||||
context.workspaceSelectionChanged === true || shouldResetTaskSessionForWake(context) || sessionConfigFreshness.reset;
|
||||
const sessionResetReason =
|
||||
sessionConfigFreshness.reasons.join("; ") || null;
|
||||
const taskSessionForRun = resetTaskSession ? null : taskSession;
|
||||
const getFreshSessionHandoff = issueRef ? createNativeSessionHandoffLoader({
|
||||
db, companyId: agent.companyId, issueId: issueRef.id, agentId: agent.id, before: run.createdAt,
|
||||
throughCommentId: readNonEmptyString(context.conversationReplayThroughCommentId) ?? (context.interactionKind ? null : wakeCommentId),
|
||||
}) : undefined;
|
||||
const previousSessionParams =
|
||||
explicitResumeSessionParams ??
|
||||
(isCanonicalSessionIdForAdapter(
|
||||
agent.adapterType,
|
||||
explicitResumeSessionDisplayId,
|
||||
)
|
||||
? { sessionId: explicitResumeSessionDisplayId }
|
||||
: null) ??
|
||||
normalizeResumeParamsForAdapter(
|
||||
agent.adapterType,
|
||||
stripPaperclipSessionMetadataFromSessionParams(
|
||||
sessionCodec.deserialize(
|
||||
taskSessionForRun?.sessionParamsJson ?? null,
|
||||
),
|
||||
),
|
||||
);
|
||||
// Legacy plugins can consume the existing context field on a known-fresh
|
||||
// dispatch. Built-ins also load lazily if their resume attempt fails.
|
||||
if (agent.adapterType !== "paperclip_runner" && !previousSessionParams && getFreshSessionHandoff) {
|
||||
const handoff = await getFreshSessionHandoff();
|
||||
if (handoff) context.paperclipFreshSessionHandoffMarkdown = handoff;
|
||||
}
|
||||
return { sessionConfigMetadata, configuredModel, sessionConfigFreshness, resetTaskSession, sessionResetReason, taskSessionForRun, getFreshSessionHandoff, previousSessionParams };
|
||||
}
|
||||
|
||||
export interface HeartbeatAdapterSessionInput {
|
||||
agent: typeof agents.$inferSelect;
|
||||
task: {
|
||||
context: Record<string, unknown>;
|
||||
taskKey: string | null;
|
||||
issueId: string | null;
|
||||
continuationSummary: TaskContext["continuationSummary"];
|
||||
executionContinuation: TaskContext["executionContinuation"];
|
||||
};
|
||||
session: {
|
||||
runtime: { sessionId: string | null };
|
||||
taskSession: TaskSession;
|
||||
sessionCodec: SessionCodec;
|
||||
explicitResumeSessionDisplayId: string | null;
|
||||
runtimeSessionParams: Record<string, unknown> | null;
|
||||
managedAiRuntime: ManagedAiRuntime | undefined;
|
||||
prepared: Awaited<ReturnType<typeof prepareHeartbeatSession>>;
|
||||
};
|
||||
workspace: Pick<PreparedWorkspace, "latestWorkspaceConfigMetadata" | "workspaceConfigFreshness" | "reusedExecutionWorkspace" | "resolvedWorkspaceReusePolicy" | "persistedExecutionWorkspace"> & {
|
||||
runtimeWorkspaceWarnings: string[];
|
||||
requestedShouldReuseExisting: boolean;
|
||||
workspaceReuseRequest: WorkspaceInput["reuse"]["workspaceReuseRequest"];
|
||||
};
|
||||
evaluateSessionCompaction: RunState["evaluateSessionCompaction"];
|
||||
}
|
||||
|
||||
/** Resolves adapter identity after workspace preparation, retaining in-place context and delta updates. */
|
||||
export async function resolveHeartbeatAdapterSession(input: HeartbeatAdapterSessionInput) {
|
||||
const { agent, evaluateSessionCompaction } = input;
|
||||
const { context, taskKey, issueId, continuationSummary, executionContinuation } = input.task;
|
||||
const { runtime, taskSession, sessionCodec, explicitResumeSessionDisplayId, runtimeSessionParams, managedAiRuntime, prepared } = input.session;
|
||||
const { sessionConfigMetadata, sessionConfigFreshness, resetTaskSession, taskSessionForRun, previousSessionParams } = prepared;
|
||||
const { latestWorkspaceConfigMetadata, workspaceConfigFreshness, reusedExecutionWorkspace, resolvedWorkspaceReusePolicy, persistedExecutionWorkspace, runtimeWorkspaceWarnings, requestedShouldReuseExisting, workspaceReuseRequest } = input.workspace;
|
||||
const runtimeSessionFallback =
|
||||
taskKey || resetTaskSession
|
||||
? null
|
||||
: isCanonicalSessionIdForAdapter(agent.adapterType, runtime.sessionId)
|
||||
? runtime.sessionId
|
||||
: null;
|
||||
const runtimeSessionDisplayId = truncateDisplayId(
|
||||
explicitResumeSessionDisplayId ??
|
||||
taskSessionForRun?.sessionDisplayId ??
|
||||
(sessionCodec.getDisplayId
|
||||
? sessionCodec.getDisplayId(runtimeSessionParams)
|
||||
: null) ??
|
||||
readNonEmptyString(runtimeSessionParams?.sessionId) ??
|
||||
runtimeSessionFallback,
|
||||
);
|
||||
let previousSessionDisplayId = requiresCanonicalSessionIds(
|
||||
agent.adapterType,
|
||||
)
|
||||
? truncateDisplayId(
|
||||
readNonEmptyString(previousSessionParams?.sessionId) ??
|
||||
(isCanonicalSessionIdForAdapter(
|
||||
agent.adapterType,
|
||||
runtimeSessionDisplayId,
|
||||
)
|
||||
? runtimeSessionDisplayId
|
||||
: null) ??
|
||||
runtimeSessionFallback,
|
||||
)
|
||||
: runtimeSessionDisplayId;
|
||||
let runtimeSessionIdForAdapter =
|
||||
readNonEmptyString(runtimeSessionParams?.sessionId) ??
|
||||
runtimeSessionFallback;
|
||||
let runtimeSessionParamsForAdapter = normalizeSessionParams(
|
||||
stripPaperclipSessionMetadataFromSessionParams(runtimeSessionParams),
|
||||
);
|
||||
|
||||
const sessionCompaction = await evaluateSessionCompaction({
|
||||
agent,
|
||||
sessionId: previousSessionDisplayId ?? runtimeSessionIdForAdapter,
|
||||
issueId,
|
||||
continuationSummaryBody: continuationSummary?.body ?? null,
|
||||
});
|
||||
if (sessionCompaction.rotate) {
|
||||
context.paperclipSessionHandoffMarkdown =
|
||||
sessionCompaction.handoffMarkdown;
|
||||
context.paperclipSessionRotationReason = sessionCompaction.reason;
|
||||
context.paperclipPreviousSessionId =
|
||||
previousSessionDisplayId ?? runtimeSessionIdForAdapter;
|
||||
runtimeSessionIdForAdapter = null;
|
||||
runtimeSessionParamsForAdapter = null;
|
||||
previousSessionDisplayId = null;
|
||||
if (sessionCompaction.reason) {
|
||||
runtimeWorkspaceWarnings.push(
|
||||
`Starting a fresh session because ${sessionCompaction.reason}.`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
delete context.paperclipSessionHandoffMarkdown;
|
||||
delete context.paperclipSessionRotationReason;
|
||||
delete context.paperclipPreviousSessionId;
|
||||
}
|
||||
|
||||
const taskSessionCredentialCompatible = !managedAiRuntime || managedAiSessionIdentityCompatible(
|
||||
taskSession?.sessionParamsJson?.paperclipAiCredentialIdentity,
|
||||
managedAiRuntime.sessionIdentity,
|
||||
managedAiRuntime.identity,
|
||||
);
|
||||
if (managedAiRuntime) {
|
||||
sessionConfigMetadata.aiCredentialIdentity = managedAiRuntime.sessionIdentity;
|
||||
if (!taskSessionCredentialCompatible) {
|
||||
runtimeSessionIdForAdapter = null;
|
||||
runtimeSessionParamsForAdapter = null;
|
||||
previousSessionDisplayId = null;
|
||||
delete executionContinuation?.resumeDelta;
|
||||
}
|
||||
}
|
||||
const runtimeForAdapter = {
|
||||
sessionId: runtimeSessionIdForAdapter,
|
||||
sessionParams: runtimeSessionParamsForAdapter,
|
||||
sessionDisplayId: previousSessionDisplayId,
|
||||
taskKey,
|
||||
};
|
||||
// A delta is safe only when the selected provider session is exactly the
|
||||
// task session whose last dispatch supplied the baseline history.
|
||||
if (
|
||||
executionContinuation?.resumeDelta &&
|
||||
(!taskSessionForRun ||
|
||||
!taskSession?.sessionDisplayId ||
|
||||
runtimeForAdapter.sessionDisplayId !== taskSession.sessionDisplayId ||
|
||||
resetTaskSession ||
|
||||
context.forceFreshSession === true)
|
||||
) {
|
||||
delete executionContinuation.resumeDelta;
|
||||
}
|
||||
const configFreshnessResultMetadata = {
|
||||
version: sessionConfigMetadata.version,
|
||||
session: {
|
||||
fingerprintVersion: sessionConfigMetadata.version,
|
||||
categories: sessionConfigMetadata.categories,
|
||||
reset: resetTaskSession,
|
||||
resetReasons: sessionConfigFreshness.reasons,
|
||||
changedCategories: sessionConfigFreshness.changedCategories,
|
||||
taskSessionAvailable: taskSession != null,
|
||||
taskSessionReused: taskSessionForRun != null,
|
||||
storedFingerprintPresent: Boolean(
|
||||
sessionConfigFreshness.storedFingerprint,
|
||||
),
|
||||
nextFingerprint: sessionConfigFreshness.nextFingerprint,
|
||||
},
|
||||
workspace: {
|
||||
fingerprintVersion: latestWorkspaceConfigMetadata.version,
|
||||
categories: latestWorkspaceConfigMetadata.categories,
|
||||
action: workspaceConfigFreshness.action,
|
||||
changedCategories: workspaceConfigFreshness.changedCategories,
|
||||
reasons: workspaceConfigFreshness.reasons,
|
||||
reuseRequested: requestedShouldReuseExisting,
|
||||
workspaceReused: Boolean(reusedExecutionWorkspace),
|
||||
configSnapshotRefreshed:
|
||||
resolvedWorkspaceReusePolicy.shouldRefreshWorkspaceConfigSnapshot,
|
||||
storedFingerprintPresent:
|
||||
workspaceConfigFreshness.storedFingerprintPresent,
|
||||
storedFingerprint: workspaceConfigFreshness.storedFingerprint,
|
||||
inferredFingerprint: workspaceConfigFreshness.inferredFingerprint,
|
||||
nextFingerprint: workspaceConfigFreshness.nextFingerprint,
|
||||
previousWorkspaceId:
|
||||
workspaceReuseRequest.requestedExecutionWorkspaceId,
|
||||
activeWorkspaceId: persistedExecutionWorkspace?.id ?? null,
|
||||
},
|
||||
};
|
||||
|
||||
return { previousSessionDisplayId, runtimeSessionParamsForAdapter, taskSessionCredentialCompatible, runtimeForAdapter, configFreshnessResultMetadata, sessionCompaction };
|
||||
}
|
||||
@@ -75,6 +75,12 @@ for (const persistent of [false, true]) {
|
||||
// Independently confirm the configured source was never overwritten.
|
||||
expect(sourceCopy).toBeTruthy();
|
||||
expect(completed.scheduledRetryAttempt).toBe(1);
|
||||
const completedRun = await api(`/heartbeat-runs/${completed.id}`);
|
||||
const completedLog = await api(`/heartbeat-runs/${completed.id}/log`);
|
||||
expect(completedRun.stdoutExcerpt).toContain("Workspace recovery verification complete.");
|
||||
expect(completedRun.lastOutputSeq).toBeGreaterThan(0);
|
||||
expect(completedRun.lastOutputBytes).toBe(completedRun.logBytes);
|
||||
expect(completedLog.content).toContain("Workspace recovery verification complete.");
|
||||
expect(await readFile(path.join(fixture.source, "README.md"), "utf8")).toBe("Existing uncommitted work\n");
|
||||
}
|
||||
await page.reload();
|
||||
|
||||
Reference in new issue
Block a user