mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 17:33:51 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Chat channels give an agent tools for its assigned task. > - Each later run must resolve the task and its accepted requester again. > - Some native runs store the task in a database column without the older JSON fields. > - Tool discovery read only the JSON fields. GitHub calls also required a new inbound comment. > - This pull request uses the durable task binding and accepted request history. > - Later runs retain their channel tools while current access checks still apply. ## Linked Issues or Issue Description **What happened?** A GitHub task can find `begin_review` on its first run, then receive an empty tool list on a later native run. If discovery is repaired alone, a call can still fail because the later run has no new inbound comment. Slack and voice tool access also read the older JSON task fields. **Expected behavior** A later run for the same task can find and call its assigned channel tools. The current requester, task, company, agent, endpoint, and access rules must still allow the call. Revoked access must fail. **Steps to reproduce** 1. Start a GitHub task and accept its linked requester. 2. Confirm that the runtime can find `begin_review`. 3. Start a later native run with `nativeIssueId`, accepted parent identity, and no JSON task ID or new wake comment. 4. Search for `begin_review` again. Before this change, the search returns an empty list. 5. Repair discovery alone and call `read_pull_request`. Before the request-history fix, the call fails with no active conversation capability. **Paperclip version or commit** Reproduced in source tests before this patch. The branch includes master at `fa50cab5685b9cdbb26ad9c610cc5b6c373d3ff4`. **Additional context** This is a core run-binding bug. The tests use PostgreSQL and synthetic provider responses. They do not send live reviews, Slack messages, or phone calls. ## What Changed - Resolve run task IDs from durable columns, with legacy JSON fields as a fallback. - Use the same binding in the gateway, access policy, identity service, and channel runtime preparation. - Recover accepted channel request history across later runs. Keep company, task, agent, and requester boundaries. - Recover older wake sources through verified parent runs, including after same-user board follow-ups. Clear the board message’s own legacy wake fallback. Keep cycle checks without a fixed limit on valid task history. - Retain the GitHub origin after a board comment from the same accepted user. Skip GitHub origin lookup results from other providers. - Keep ordinary board tools available when the requester changes. Accept legacy user comments without an author-type field. Still reject explicit malformed task IDs. - Prevent a later failure reply from replacing an earlier final GitHub answer. Recheck this rule under the publication lease. - Rotate the native tool contract to replace retained sessions with an older tool list. - Add continuation, publication, revocation, and boundary tests. Update the access and feature maps. ## Verification - Red: the later GitHub assigned-tool search returned an empty list before the binding fix. - Red: the later GitHub read failed with no active conversation capability before the request-history fix. - Green: runtime MCP discovery, GitHub read/begin/submit review, and response publication pass for monitor and same-user board continuations. - Green: Slack history, retained access controls, revocation, and voice gateway continuation pass. - Green: 35 channel, failure-publication, legacy-origin, long-history, and provenance tests pass. Both review findings had failing tests before their fixes. - Green: 107 gateway, operation-credential, identity, and provenance tests pass. These cover both first-run CI failures without changing their tests. - Green: 122 runtime, discovery-policy, and native-session tests pass; 2 unrelated tests are skipped. - Map checks and whitespace checks pass. - Updated to master in `cfcaba22628772d7fe1fb32b57ff7031c7072781`. The sole conflict was two tests added at the same location. Both the continuation test and the publication 403/401 test are retained. - Current local verification: all 179 gateway, credential, requester-identity, access-policy, runtime-context, and session-resume tests passed. Repository typecheck and build passed. Map and whitespace checks passed. - The new review regression first returned no source for a same-user board follow-up over legacy wake metadata. After the repair, all 27 provenance and channel cases passed. The combined GitHub discovery/read/begin/submit path and revoked access are covered. - The broader local channel run passed 176 cases and failed four timing/setup cases: a 15-second Slack authorization timeout, webhook-verification polling, a Slack acknowledgement deadline, and Discord setup polling. All four passed in isolation without source, assertion, or deadline changes. The earlier failure record is retained; the exact load/order cause is not established. Full CI must pass on the latest commit. - Current head: `7b9a08dcbc7b8140b589742f136944270b0496bb`. Repository typecheck and build pass on this commit. Greptile completed on this exact commit with 5/5; all four review threads are resolved. The final-head [CI run](https://github.com/paperclipai/paperclip/actions/runs/39278921653) passed: 55 checks passed and 2 intentionally skipped, with no failures or pending checks. This includes the full sharded test matrix, browser tests, typecheck, build, and Runner verification. Results were delayed during the [GitHub Actions incident](https://www.githubstatus.com/incidents/8tzbpcnl75jp). An additional unsharded local full test run has not completed; the completed full-suite evidence is CI. - Earlier verification: the first PR commit failed two server suites. Those failures were fixed and verified before this update. The original 4 GiB sandbox could not complete full local typechecking or tests; the current checkout completed typechecking and build. The full test suite runs through the CI shards. No full local test-suite pass or live provider delivery is claimed. ## Risks - Request history can restore the wrong authority if its scope is too broad. The resolver checks each accepted parent against the task, company, agent, and requester. Tests cover invalid and cyclic history. - The native contract change starts a new provider session for older contracts. - No database migration or public API schema changes are required. Each tool call still checks live access. ## Model Used - OpenAI Codex, GPT-6 family. The runtime does not expose the exact deployment model ID or context-window size. The agent used reasoning, repository tools, code edits, and local test execution. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
549 lines
18 KiB
TypeScript
549 lines
18 KiB
TypeScript
import { and, asc, desc, eq, inArray, sql } from "drizzle-orm";
|
|
import {
|
|
agentWakeupRequests,
|
|
heartbeatRuns,
|
|
heartbeatRunEvents,
|
|
issueComments,
|
|
issueThreadInteractions,
|
|
issues,
|
|
runIdentityContexts,
|
|
type Db,
|
|
} from "@paperclipai/db";
|
|
import { conflict, forbidden } from "../errors.js";
|
|
import { boundRunIssueId } from "./run-task-binding.js";
|
|
import { isUuidLike } from "@paperclipai/shared";
|
|
import { queuedCommentIdsFromRunContext, queuedCommentIdsFromWakePayload } from "./issue-queued-comment-queue.js";
|
|
|
|
/** Resolve an explicit click from persisted receipts, never caller context or message authors. */
|
|
export async function explicitOperatorRunIdentity(
|
|
executor: Pick<Db, "select">,
|
|
run: Pick<typeof heartbeatRuns.$inferSelect, "id" | "companyId" | "agentId" | "contextSnapshot" | "wakeupRequestId">,
|
|
) {
|
|
const [request] = run.wakeupRequestId ? await executor.select().from(agentWakeupRequests).where(and(
|
|
eq(agentWakeupRequests.id, run.wakeupRequestId), eq(agentWakeupRequests.companyId, run.companyId),
|
|
eq(agentWakeupRequests.agentId, run.agentId), eq(agentWakeupRequests.runId, run.id),
|
|
)) : [];
|
|
if (request?.payload?.manualUserWake === true) {
|
|
if (request.requestedByActorType !== "user" || !request.requestedByActorId) {
|
|
throw forbidden("Manual wake requires an authenticated user");
|
|
}
|
|
return { actorId: request.requestedByActorId, cause: "manual_user_wake" };
|
|
}
|
|
const prefix = "queued-comment-interrupt:";
|
|
if (!request?.idempotencyKey?.startsWith(prefix)) return null;
|
|
const queueId = request.idempotencyKey.slice(prefix.length);
|
|
// The key only locates a candidate. The consumed queue, actor, run, company,
|
|
// agent, task, and delivered messages must all independently agree.
|
|
if (!isUuidLike(queueId)) {
|
|
throw forbidden("Queued-message interrupt authority is unavailable");
|
|
}
|
|
const [receipt] = await executor.select().from(agentWakeupRequests).where(and(
|
|
eq(agentWakeupRequests.id, queueId), eq(agentWakeupRequests.companyId, run.companyId),
|
|
eq(agentWakeupRequests.agentId, run.agentId), eq(agentWakeupRequests.runId, run.id),
|
|
eq(agentWakeupRequests.status, "coalesced"),
|
|
));
|
|
const marker = receipt?.payload?.queuedCommentInterrupt;
|
|
const actorId = marker && typeof marker === "object" && "actorId" in marker ? marker.actorId : null;
|
|
const ids = queuedCommentIdsFromWakePayload(receipt?.payload);
|
|
const deliveredIds = queuedCommentIdsFromRunContext(run.contextSnapshot);
|
|
if (typeof actorId !== "string" || !actorId || !ids.length ||
|
|
receipt?.payload?.issueId !== run.contextSnapshot?.issueId ||
|
|
!ids.every(id => deliveredIds.includes(id)) ||
|
|
request.requestedByActorType !== "user" || request.requestedByActorId !== actorId) {
|
|
throw forbidden("Queued-message interrupt authority is unavailable");
|
|
}
|
|
return { actorId, cause: "queued_comment_interrupt" };
|
|
}
|
|
|
|
export type RunIdentityContext = typeof runIdentityContexts.$inferSelect;
|
|
type Executor = Pick<Db, "select" | "insert" | "update">;
|
|
|
|
/**
|
|
* Lock the task before the run, matching task mutation ordering. Identity
|
|
* operations do not change parent keys: NO KEY UPDATE still serializes writers
|
|
* and steering, while allowing audit inserts to check their foreign keys.
|
|
* FOR UPDATE can deadlock with an append that holds KEY SHARE on the run and
|
|
* then checks the task while identity capture holds the task and waits on the run.
|
|
*/
|
|
async function lockIdentityTask(
|
|
executor: Pick<Db, "select">,
|
|
companyId: string,
|
|
runId: string,
|
|
) {
|
|
const [run] = await executor
|
|
.select({
|
|
contextSnapshot: heartbeatRuns.contextSnapshot,
|
|
nativeIssueId: heartbeatRuns.nativeIssueId,
|
|
issueId: heartbeatRuns.issueId,
|
|
})
|
|
.from(heartbeatRuns)
|
|
.where(
|
|
and(eq(heartbeatRuns.id, runId), eq(heartbeatRuns.companyId, companyId)),
|
|
);
|
|
const issueId = run?.nativeIssueId ?? run?.issueId ?? run?.contextSnapshot?.issueId ?? run?.contextSnapshot?.taskId;
|
|
const legacyIssueId = run?.contextSnapshot?.issueId ?? run?.contextSnapshot?.taskId;
|
|
if ([issueId, legacyIssueId].some(id => id !== undefined && id !== null &&
|
|
(typeof id !== "string" || !isUuidLike(id)))) {
|
|
throw forbidden("Run task identity is invalid");
|
|
}
|
|
if (typeof issueId === "string")
|
|
await executor
|
|
.select({ id: issues.id })
|
|
.from(issues)
|
|
.where(and(eq(issues.id, issueId), eq(issues.companyId, companyId)))
|
|
.for("no key update");
|
|
}
|
|
|
|
async function append(
|
|
executor: Executor,
|
|
input: {
|
|
companyId: string;
|
|
runId: string;
|
|
responsibleUserId: string | null;
|
|
messageId?: string | null;
|
|
parentContextId?: string | null;
|
|
cause: string;
|
|
correlationId: string;
|
|
status?: "accepted" | "pending";
|
|
},
|
|
) {
|
|
const [existing] = await executor
|
|
.select()
|
|
.from(runIdentityContexts)
|
|
.where(
|
|
and(
|
|
eq(runIdentityContexts.runId, input.runId),
|
|
eq(runIdentityContexts.correlationId, input.correlationId),
|
|
),
|
|
);
|
|
if (existing) return existing;
|
|
const [last] = await executor
|
|
.select()
|
|
.from(runIdentityContexts)
|
|
.where(eq(runIdentityContexts.runId, input.runId))
|
|
.orderBy(desc(runIdentityContexts.revision))
|
|
.limit(1);
|
|
const [created] = await executor
|
|
.insert(runIdentityContexts)
|
|
.values({
|
|
...input,
|
|
revision: (last?.revision ?? 0) + 1,
|
|
acceptedAt: input.status === "pending" ? null : new Date(),
|
|
})
|
|
.returning();
|
|
if (!created) throw new Error("Failed to persist run identity");
|
|
if (created.status === "accepted") await activate(executor, created);
|
|
return created;
|
|
}
|
|
|
|
async function activate(executor: Executor, context: RunIdentityContext) {
|
|
const [run] = await executor
|
|
.update(heartbeatRuns)
|
|
.set({
|
|
responsibleUserId: context.responsibleUserId,
|
|
activeIdentityContextId: context.id,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(
|
|
and(
|
|
eq(heartbeatRuns.id, context.runId),
|
|
eq(heartbeatRuns.companyId, context.companyId),
|
|
),
|
|
)
|
|
.returning();
|
|
const issueId = run ? boundRunIssueId(run) : null;
|
|
if (typeof issueId === "string")
|
|
await executor
|
|
.update(issues)
|
|
.set({ continuationIdentityContextId: context.id })
|
|
.where(
|
|
and(eq(issues.id, issueId), eq(issues.companyId, context.companyId)),
|
|
);
|
|
}
|
|
|
|
/** Called only for newly dispatched runs. Historical rows are never backfilled. */
|
|
export async function initializeRunIdentity(
|
|
db: Db,
|
|
input: {
|
|
companyId: string;
|
|
runId: string;
|
|
responsibleUserId: string | null;
|
|
messageIds?: string[];
|
|
issueId?: string | null;
|
|
interactionId?: string | null;
|
|
parentRunId?: string | null;
|
|
parentContextId?: string | null;
|
|
cause: string;
|
|
},
|
|
) {
|
|
return db.transaction(async (tx) => {
|
|
await lockIdentityTask(tx, input.companyId, input.runId);
|
|
const [run] = await tx
|
|
.select()
|
|
.from(heartbeatRuns)
|
|
.where(
|
|
and(
|
|
eq(heartbeatRuns.id, input.runId),
|
|
eq(heartbeatRuns.companyId, input.companyId),
|
|
),
|
|
)
|
|
.for("no key update");
|
|
if (!run) throw forbidden("Run identity does not belong to this company");
|
|
if (run.activeIdentityContextId) {
|
|
const [current] = await tx
|
|
.select()
|
|
.from(runIdentityContexts)
|
|
.where(eq(runIdentityContexts.id, run.activeIdentityContextId));
|
|
return current!;
|
|
}
|
|
const operatorIdentity = await explicitOperatorRunIdentity(tx, run);
|
|
const [parent] = input.parentRunId
|
|
? await tx
|
|
.select()
|
|
.from(heartbeatRuns)
|
|
.where(
|
|
and(
|
|
eq(heartbeatRuns.id, input.parentRunId),
|
|
eq(heartbeatRuns.companyId, input.companyId),
|
|
),
|
|
)
|
|
: [];
|
|
const [interaction] =
|
|
input.interactionId && input.issueId
|
|
? await tx
|
|
.select()
|
|
.from(issueThreadInteractions)
|
|
.where(
|
|
and(
|
|
eq(issueThreadInteractions.id, input.interactionId),
|
|
eq(issueThreadInteractions.companyId, input.companyId),
|
|
eq(issueThreadInteractions.issueId, input.issueId),
|
|
),
|
|
)
|
|
: [];
|
|
const parentId = operatorIdentity ? null : (
|
|
interaction?.sourceIdentityContextId ?? input.parentContextId ?? parent?.activeIdentityContextId
|
|
);
|
|
const [origin] = parentId
|
|
? await tx
|
|
.select()
|
|
.from(runIdentityContexts)
|
|
.where(
|
|
and(
|
|
eq(runIdentityContexts.id, parentId),
|
|
eq(runIdentityContexts.companyId, input.companyId),
|
|
eq(runIdentityContexts.status, "accepted"),
|
|
),
|
|
)
|
|
: [];
|
|
if (parentId && !origin)
|
|
throw forbidden("Originating execution identity is unavailable");
|
|
let current = await append(tx, {
|
|
companyId: input.companyId,
|
|
runId: input.runId,
|
|
responsibleUserId: operatorIdentity?.actorId ?? (origin ? origin.responsibleUserId : input.responsibleUserId),
|
|
parentContextId: origin?.id ?? null,
|
|
cause:
|
|
operatorIdentity ? operatorIdentity.cause : origin?.cause === "company_default" ? "company_default" : input.cause,
|
|
correlationId: "dispatch",
|
|
});
|
|
const ids = [...new Set(input.messageIds ?? [])];
|
|
const comments =
|
|
ids.length && input.issueId
|
|
? await tx
|
|
.select()
|
|
.from(issueComments)
|
|
.where(
|
|
and(
|
|
eq(issueComments.companyId, input.companyId),
|
|
eq(issueComments.issueId, input.issueId),
|
|
inArray(issueComments.id, ids),
|
|
),
|
|
)
|
|
: [];
|
|
for (const id of ids) {
|
|
const comment = comments.find((c) => c.id === id);
|
|
if (!comment?.authorUserId) continue;
|
|
current = await append(tx, {
|
|
companyId: input.companyId,
|
|
runId: input.runId,
|
|
responsibleUserId: operatorIdentity?.actorId ?? comment.authorUserId,
|
|
messageId: id,
|
|
parentContextId: current.id,
|
|
cause: operatorIdentity?.cause ?? "instruction",
|
|
correlationId: `message:${id}`,
|
|
});
|
|
}
|
|
return current;
|
|
});
|
|
}
|
|
|
|
/** Caller holds the task and run row locks, in that order. Reserve before delivery so acquisitions cannot guess. */
|
|
export async function prepareSteeredIdentity(
|
|
executor: Executor,
|
|
input: {
|
|
companyId: string;
|
|
runId: string;
|
|
messageId: string;
|
|
issueId: string;
|
|
source?: "comment" | "interaction";
|
|
},
|
|
) {
|
|
const [run] = await executor
|
|
.select()
|
|
.from(heartbeatRuns)
|
|
.where(
|
|
and(
|
|
eq(heartbeatRuns.id, input.runId),
|
|
eq(heartbeatRuns.companyId, input.companyId),
|
|
),
|
|
);
|
|
const [comment] = input.source === "interaction" ? [] : await executor
|
|
.select()
|
|
.from(issueComments)
|
|
.where(
|
|
and(
|
|
eq(issueComments.id, input.messageId),
|
|
eq(issueComments.companyId, input.companyId),
|
|
eq(issueComments.issueId, input.issueId),
|
|
),
|
|
);
|
|
const [interaction] = input.source === "interaction" ? await executor.select().from(issueThreadInteractions).where(and(
|
|
eq(issueThreadInteractions.id, input.messageId),
|
|
eq(issueThreadInteractions.companyId, input.companyId),
|
|
eq(issueThreadInteractions.issueId, input.issueId),
|
|
inArray(issueThreadInteractions.status, ["accepted", "answered", "rejected"]),
|
|
)) : [];
|
|
const responsibleUserId = interaction?.resolvedByUserId ?? comment?.authorUserId;
|
|
if (!run || !responsibleUserId)
|
|
throw forbidden("Steering requires an authenticated message author");
|
|
if (
|
|
run.status !== "running" ||
|
|
boundRunIssueId(run) !== input.issueId
|
|
) {
|
|
throw conflict("Steering targets a different or inactive execution");
|
|
}
|
|
return append(executor, {
|
|
companyId: input.companyId,
|
|
runId: input.runId,
|
|
responsibleUserId,
|
|
messageId: input.messageId,
|
|
parentContextId: run.activeIdentityContextId,
|
|
cause: "steering",
|
|
correlationId: `${input.source === "interaction" ? "interaction" : "message"}:${input.messageId}`,
|
|
status: "pending",
|
|
});
|
|
}
|
|
|
|
export async function reserveSteeredIdentity(
|
|
db: Db,
|
|
input: Parameters<typeof prepareSteeredIdentity>[1],
|
|
) {
|
|
return db.transaction(async (tx) => {
|
|
await lockIdentityTask(tx, input.companyId, input.runId);
|
|
const [run] = await tx
|
|
.select()
|
|
.from(heartbeatRuns)
|
|
.where(
|
|
and(
|
|
eq(heartbeatRuns.id, input.runId),
|
|
eq(heartbeatRuns.companyId, input.companyId),
|
|
),
|
|
)
|
|
.for("no key update");
|
|
// Processes started before the broker rollout keep their original environment.
|
|
if (!run?.activeIdentityContextId) return null;
|
|
const [pending] = await tx
|
|
.select()
|
|
.from(runIdentityContexts)
|
|
.where(
|
|
and(
|
|
eq(runIdentityContexts.runId, run.id),
|
|
eq(runIdentityContexts.status, "pending"),
|
|
),
|
|
)
|
|
.limit(1);
|
|
if (pending && pending.messageId !== input.messageId) {
|
|
if (await storedSteeringAcknowledgement(tx, pending))
|
|
await acceptSteeredIdentity(tx, pending);
|
|
else
|
|
throw conflict(
|
|
"A prior steering acknowledgement must be reconciled first",
|
|
);
|
|
}
|
|
const [existing] = await tx
|
|
.select()
|
|
.from(runIdentityContexts)
|
|
.where(
|
|
and(
|
|
eq(runIdentityContexts.runId, run.id),
|
|
eq(runIdentityContexts.messageId, input.messageId),
|
|
eq(runIdentityContexts.status, "accepted"),
|
|
),
|
|
);
|
|
if (existing) return existing;
|
|
const context = await prepareSteeredIdentity(tx, input);
|
|
if (context.status === "rejected") {
|
|
await tx
|
|
.update(runIdentityContexts)
|
|
.set({ status: "pending" })
|
|
.where(eq(runIdentityContexts.id, context.id));
|
|
return { ...context, status: "pending" };
|
|
}
|
|
return context;
|
|
});
|
|
}
|
|
|
|
export async function rejectSteeredIdentity(
|
|
db: Db,
|
|
context: RunIdentityContext,
|
|
) {
|
|
await db
|
|
.update(runIdentityContexts)
|
|
.set({ status: "rejected" })
|
|
.where(
|
|
and(
|
|
eq(runIdentityContexts.id, context.id),
|
|
eq(runIdentityContexts.status, "pending"),
|
|
),
|
|
);
|
|
}
|
|
|
|
export async function acceptSteeredIdentity(
|
|
executor: Executor,
|
|
context: RunIdentityContext,
|
|
) {
|
|
// A replay must not reactivate a historical identity after later instructions.
|
|
const [accepted] = await executor
|
|
.update(runIdentityContexts)
|
|
.set({ status: "accepted", acceptedAt: new Date() })
|
|
.where(
|
|
and(
|
|
eq(runIdentityContexts.id, context.id),
|
|
eq(runIdentityContexts.status, "pending"),
|
|
),
|
|
)
|
|
.returning();
|
|
if (accepted) await activate(executor, accepted);
|
|
}
|
|
|
|
export async function captureRunIdentity(
|
|
db: Db,
|
|
input: { companyId: string; runId: string; agentId: string },
|
|
) {
|
|
// Lock acquisition serializes with steering delivery and its durable acknowledgement.
|
|
return db.transaction(async (tx) => {
|
|
await lockIdentityTask(tx, input.companyId, input.runId);
|
|
const [run] = await tx
|
|
.select()
|
|
.from(heartbeatRuns)
|
|
.where(
|
|
and(
|
|
eq(heartbeatRuns.id, input.runId),
|
|
eq(heartbeatRuns.companyId, input.companyId),
|
|
eq(heartbeatRuns.agentId, input.agentId),
|
|
),
|
|
)
|
|
.for("no key update");
|
|
if (!run || run.status !== "running")
|
|
throw forbidden(
|
|
"Credential acquisition requires this agent's active run",
|
|
);
|
|
const [pending] = await tx
|
|
.select()
|
|
.from(runIdentityContexts)
|
|
.where(
|
|
and(
|
|
eq(runIdentityContexts.runId, run.id),
|
|
eq(runIdentityContexts.status, "pending"),
|
|
),
|
|
)
|
|
.limit(1);
|
|
if (pending) {
|
|
const acknowledgement = await storedSteeringAcknowledgement(tx, pending);
|
|
if (!acknowledgement)
|
|
throw conflict(
|
|
"Message acceptance is being reconciled; retry credential acquisition",
|
|
);
|
|
await acceptSteeredIdentity(tx, pending);
|
|
run.activeIdentityContextId = pending.id;
|
|
run.responsibleUserId = pending.responsibleUserId;
|
|
}
|
|
const [context] = run.activeIdentityContextId
|
|
? await tx
|
|
.select()
|
|
.from(runIdentityContexts)
|
|
.where(
|
|
and(
|
|
eq(runIdentityContexts.id, run.activeIdentityContextId),
|
|
eq(runIdentityContexts.runId, run.id),
|
|
),
|
|
)
|
|
: [];
|
|
return { run, context: context ?? null };
|
|
});
|
|
}
|
|
|
|
export async function listRunIdentityContexts(
|
|
db: Db,
|
|
companyId: string,
|
|
runId: string,
|
|
) {
|
|
return db
|
|
.select()
|
|
.from(runIdentityContexts)
|
|
.where(
|
|
and(
|
|
eq(runIdentityContexts.companyId, companyId),
|
|
eq(runIdentityContexts.runId, runId),
|
|
),
|
|
)
|
|
.orderBy(asc(runIdentityContexts.revision));
|
|
}
|
|
|
|
/** Late native acknowledgements settle reservations even when the HTTP caller timed out. */
|
|
export async function reconcileSteeredIdentity(
|
|
db: Db,
|
|
context: RunIdentityContext,
|
|
) {
|
|
await db.transaction(async (tx) => {
|
|
await lockIdentityTask(tx, context.companyId, context.runId);
|
|
const [run] = await tx
|
|
.select()
|
|
.from(heartbeatRuns)
|
|
.where(
|
|
and(
|
|
eq(heartbeatRuns.id, context.runId),
|
|
eq(heartbeatRuns.companyId, context.companyId),
|
|
),
|
|
)
|
|
.for("no key update");
|
|
if (!run) return;
|
|
await acceptSteeredIdentity(tx, context);
|
|
});
|
|
}
|
|
|
|
/** Only events validated and persisted by the native control-plane transport count. */
|
|
export async function storedSteeringAcknowledgement(
|
|
executor: Pick<Db, "select">,
|
|
context: Pick<RunIdentityContext, "companyId" | "runId" | "messageId">,
|
|
) {
|
|
if (!context.messageId) return null;
|
|
const [receipt] = await executor
|
|
.select({ payload: heartbeatRunEvents.payload })
|
|
.from(heartbeatRunEvents)
|
|
.where(
|
|
and(
|
|
eq(heartbeatRunEvents.companyId, context.companyId),
|
|
eq(heartbeatRunEvents.runId, context.runId),
|
|
eq(heartbeatRunEvents.eventType, "item.completed"),
|
|
sql`${heartbeatRunEvents.sourceEventId} is not null`,
|
|
sql`${heartbeatRunEvents.payload}->'prpEvent'->'payload'->>'kind' = 'steering_acknowledgement'`,
|
|
sql`${heartbeatRunEvents.payload}->'prpEvent'->>'itemId' like ${`%:steer:${context.messageId}`}`,
|
|
),
|
|
)
|
|
.limit(1);
|
|
const event = receipt?.payload?.prpEvent as { turnId?: string } | undefined;
|
|
return typeof event?.turnId === "string" ? { turnId: event.turnId } : null;
|
|
}
|