mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-30 13:20:54 +01:00
<!-- entire-trail-link-start --> https://entire.com/gh/paperclipai/-/trails/206/changes/paperclip/206 <!-- entire-trail-link-end --> ## Thinking Path > - Paperclip coordinates agent work and presents its progress in chat. > - GitHub deliveries save acknowledgement actions and provider receipts. > - Periodic maintenance currently searches for unfinished acknowledgements even when idle. > - Committed delivery, lease, response and presentation changes identify when work can advance. > - The existing owner should wake on those changes and recover only at saved deadlines. > - Completed history should become quiet without losing unfinished provider effects. ## Linked Issues or Issue Description Refs #17653, which is merged. This existing GitHub acknowledgement slice targets master. **Problem:** A saved GitHub acknowledgement can wait for a publication lease, fast-response result or native presentation decision. Polling empty or unchanged work keeps the tenant active. Removing maintenance without covering these producers can strand the acknowledgement. **Expected behavior:** Committed changes wake the scoped owner. It reconstructs existing actions on startup, retries at saved deadlines, parks unchanged presentation waits and becomes quiet when settled. ## What Changed - Register GitHub acknowledgement work with the shared scoped deadline coordinator. Reuse existing action, delivery, lease and fast-response rows. - Add transaction-aware response-availability notifications at supported writes. Keep GitHub protocol details in its platform owner and shared notifications limited to IDs. - Share the bounded latest-request/run projection between scheduling and direct response authorization. An absent native presentation decision parks the action until its real writer commits. - Retain publication leases, source/principal authority, version fencing, response markers and final receipt precedence. Check admission before each new provider request; let in-flight work settle. Stop does not consume the provider retry budget. - Remove only recurring acknowledgement recovery and keep its manual path. Add no work table or scheduler service. - Regenerate the three original indexes as `0400_absent_phantom_reporter.sql`: pending acknowledgement IDs, fast-response delivery lookup and publication comment/destination lookup. Each uses concurrent/idempotent creation and the existing top-level migration runner. Preserve all landed SQL/journal entries and the five newest snapshots. ## Verification Published head: `09ca00b9f029838434ec042cda691345d67b493e`, on landed continuation parent `063cfd43813cabf18417605fb9eefb9aacf95c8d`. - **87 distinct current cases pass:** 18 real PostgreSQL acknowledgement lifecycle/presentation cases, all 26 fast-response service cases, all 30 heartbeat completion cases and all 13 startup cases. Database build, server typechecking/build, module boundaries and three fresh migrations through 0400 pass. The current Runner bundle rebuild also passes after upstream provider-event changes. - The lifecycle matrix covers empty/history quiet, lease release, restart, drain/standby, fast-response failure/expiry/publication/unknown outcome, streaming quarantine, outer rollback/commit, saved provider retry, stop between requests and receipt state. The real presentation writer proves quiet while pending, rollback, committed reactivation and final quiet. - The earlier **305-case** matrix (236 full GitHub workflow cases, 26 fast-response service cases, 30 completion cases and 13 startup cases) passes at `337f272853d4f8a51b5b9300900a46eb4e13f698`. It retains its prepared-parent scope. Repeated cases are not added to current 87. - A populated 0399 database upgrades to 0400 with all 398 prior migration receipts unchanged, one new receipt and no additional receipt on replay. All three indexes are valid/ready on fresh and upgraded databases. Their SQL definitions match the original draft after whitespace normalization; migration history and retained snapshot bytes are preserved. - Normal integration `9b3607e159153e00ccdfe538d52b591bd38d6499` preserves the full 28-path owned binary patch and all 20 unrelated master paths, including native retry, provider-event and evaluation workflow changes. Both normal merge parents and the exact tree are checked. No force push or rebase occurs. - [The owner audit](https://github.com/paperclipai/paperclip/blob/09ca00b9f029838434ec042cda691345d67b493e/doc/plans/2026-10-25-github-chat-maintenance-sleep-audit.md#acceptance-on-the-actual-landed-continuation-parent) retains the original missing-notification, JSON precedence, receipt lifecycle, external-database fixture and migration-registry failures. Earlier 69/228/broad counts keep their original scope. Skips and failed revisions are not relabelled as current passes. - The final audit-only edit leaves all owned source/test/migration hashes unchanged. **Fresh exact-head CI and Greptile 5/5 with no actionable findings remain required.** No full local repository, live GitHub tenant or deployed sleep result is claimed. ## Risks - Process-local transaction hints cover supported application writers and shared pools. Independent SQL writers and other replicas do not gain a new wake guarantee. - Provider effects remain guarded by the existing publication lease and current source/principal authority. Unknown outcomes retain their existing recovery semantics. - Presentation waits depend on their supported milestone/transaction producers. A missing producer can defer recovery until another supported change or restart. - Index creation can take time on large histories and runs concurrently outside a transaction. Existing migration recovery handles replay; prior SQL is unchanged. - Worker quiet is distinct from host/database suspension. Cloud wake coverage and sleep admission remain unchanged; later maintenance duties are separate slices. ## Model Used OpenAI Codex, using reasoning, repository inspection, code execution and real PostgreSQL tests. The exact model identifier and context-window size are not exposed in this session. ## 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 - [ ] All Paperclip CI gates are green - [ ] 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>
1265 lines
40 KiB
TypeScript
1265 lines
40 KiB
TypeScript
import { notifyChatResponseAvailability } from "./chat-response-availability.js";
|
|
import { insertIssueComment } from "./issue-comment-writes.js";
|
|
import { randomUUID } from "node:crypto";
|
|
import { sanitizeQuarantinedCommentForHigherTrust } from "./source-trust.js";
|
|
import {
|
|
and,
|
|
desc,
|
|
eq,
|
|
gt,
|
|
gte,
|
|
inArray,
|
|
isNull,
|
|
lt,
|
|
lte,
|
|
or,
|
|
sql,
|
|
} from "drizzle-orm";
|
|
import {
|
|
agents,
|
|
toolConnections,
|
|
assets,
|
|
issueAttachments,
|
|
projects,
|
|
authUsers,
|
|
budgetReservations,
|
|
companies,
|
|
companyFastResponses,
|
|
companyMemberships,
|
|
companySecrets,
|
|
costEvents,
|
|
fastResponseRequests,
|
|
heartbeatRunEvents,
|
|
heartbeatRuns,
|
|
issueComments,
|
|
issues,
|
|
chatPublications,
|
|
type Db,
|
|
} from "@paperclipai/db";
|
|
import {
|
|
aiConnectionCatalogSlug,
|
|
aiConnectionMetadataSchema,
|
|
centsToUnits,
|
|
unitsToCents,
|
|
updateFastResponseSchema,
|
|
FAST_RESPONSE_DEADLINE_MS,
|
|
type AiProvider,
|
|
type FastResponseSettings,
|
|
type FastResponseHistoryEntry,
|
|
type UpdateFastResponse,
|
|
type FastResponseTestResult,
|
|
} from "@paperclipai/shared";
|
|
import { aiConnectionService } from "./ai-connections.js";
|
|
import { instanceSettingsService } from "./instance-settings.js";
|
|
import { isAutomaticRecoverySuppressedByPauseHold } from "./recovery/pause-hold-guard.js";
|
|
import { accessService } from "./access.js";
|
|
import type { AuthorizationActor } from "./authorization.js";
|
|
import { forbidden, unprocessable } from "../errors.js";
|
|
import {
|
|
logActivity,
|
|
publishActivity,
|
|
type ActivityPublication,
|
|
} from "./activity-log.js";
|
|
import { withAccountingTransaction } from "./accounting-transaction.js";
|
|
import { withBudgetSpan } from "./budget-tracing.js";
|
|
import {
|
|
budgetServiceInTransaction,
|
|
type BudgetServiceHooks,
|
|
} from "./budgets.js";
|
|
import { createCostEventInTransaction } from "./costs.js";
|
|
import {
|
|
notifyDeliveryWork,
|
|
DELIVERY_QUEUES,
|
|
} from "./delivery-work-notifications.js";
|
|
import { projectSafeChatPublication } from "./chat-publication-projection.js";
|
|
import {
|
|
fastResponsePrompt,
|
|
fastResponseReceipt,
|
|
runFastResponseProvider,
|
|
type FastResponseOutcome,
|
|
type FastResponsePromptInput,
|
|
} from "./fast-response-provider.js";
|
|
|
|
type Request = typeof fastResponseRequests.$inferSelect;
|
|
export type FastResponseSource = Pick<
|
|
Request,
|
|
"companyId" | "issueId" | "agentId" | "responsibleUserId"
|
|
> &
|
|
Partial<
|
|
Pick<
|
|
Request,
|
|
| "sourceCommentId"
|
|
| "endpointId"
|
|
| "conversationId"
|
|
| "deliveryId"
|
|
| "sponsored"
|
|
| "sessionGeneration"
|
|
>
|
|
> & { sourceKey: string; acceptedAt: Date };
|
|
export type FastResponseExternalAuthorization = (
|
|
tx: Db,
|
|
request: Request,
|
|
) => Promise<FastResponsePromptInput>;
|
|
|
|
/** A run created for this turn is not older work that this turn is waiting on. */
|
|
export async function fastResponseTurnQueued(
|
|
db: Db,
|
|
request: Pick<
|
|
Request,
|
|
"companyId" | "agentId" | "issueId" | "sourceCommentId" | "acceptedAt"
|
|
>,
|
|
) {
|
|
if (!request.agentId) return false;
|
|
const runs = await db
|
|
.select({
|
|
status: heartbeatRuns.status,
|
|
createdAt: heartbeatRuns.createdAt,
|
|
context: heartbeatRuns.contextSnapshot,
|
|
})
|
|
.from(heartbeatRuns)
|
|
.where(
|
|
and(
|
|
eq(heartbeatRuns.companyId, request.companyId),
|
|
eq(heartbeatRuns.agentId, request.agentId),
|
|
inArray(heartbeatRuns.status, ["queued", "running"]),
|
|
),
|
|
);
|
|
return runs.some((run) => {
|
|
const sourceCommentId =
|
|
run.context?.wakeCommentId ?? run.context?.commentId;
|
|
const ownsTurn =
|
|
run.context?.issueId === request.issueId &&
|
|
(request.sourceCommentId
|
|
? sourceCommentId === request.sourceCommentId
|
|
: run.createdAt >= request.acceptedAt);
|
|
return (
|
|
run.status === "queued" ||
|
|
(!ownsTurn && run.createdAt < request.acceptedAt)
|
|
);
|
|
});
|
|
}
|
|
|
|
/** Board chat streams without a heartbeat run; fence its receipt when real text begins. */
|
|
export async function supersedeFastResponse(
|
|
db: Db,
|
|
companyId: string,
|
|
sourceCommentId: string,
|
|
) {
|
|
await db
|
|
.update(fastResponseRequests)
|
|
.set({
|
|
errorCode: "real_reply_started",
|
|
publicationStatus: "suppressed",
|
|
status: sql`case when ${fastResponseRequests.status} = 'pending' then 'skipped' else ${fastResponseRequests.status} end`,
|
|
})
|
|
.where(
|
|
and(
|
|
eq(fastResponseRequests.companyId, companyId),
|
|
eq(fastResponseRequests.sourceCommentId, sourceCommentId),
|
|
isNull(fastResponseRequests.commentId),
|
|
inArray(fastResponseRequests.status, ["pending", "running"]),
|
|
),
|
|
);
|
|
}
|
|
|
|
/** Called only at an authenticated, accepted human-turn boundary, on its transaction. */
|
|
export async function enqueueFastResponse(tx: Db, source: FastResponseSource) {
|
|
if (!(await instanceSettingsService(tx).getExperimental()).enableFastResponses) return;
|
|
const [config] = await tx
|
|
.select({ enabled: companyFastResponses.enabled })
|
|
.from(companyFastResponses)
|
|
.where(eq(companyFastResponses.companyId, source.companyId));
|
|
if (
|
|
!config?.enabled ||
|
|
!source.issueId ||
|
|
(!source.agentId && !source.sourceKey.startsWith("board:"))
|
|
)
|
|
return;
|
|
const [comment] = source.sourceCommentId
|
|
? await tx
|
|
.select()
|
|
.from(issueComments)
|
|
.where(
|
|
and(
|
|
eq(issueComments.companyId, source.companyId),
|
|
eq(issueComments.id, source.sourceCommentId),
|
|
),
|
|
)
|
|
: [];
|
|
if (
|
|
comment &&
|
|
(comment.origin === "fast_response" ||
|
|
/^\s*\//.test(comment.body) ||
|
|
comment.deletedAt)
|
|
)
|
|
return;
|
|
if (source.acceptedAt.getTime() + FAST_RESPONSE_DEADLINE_MS <= Date.now())
|
|
return;
|
|
await notifyDeliveryWork(tx, DELIVERY_QUEUES.fastResponse);
|
|
if (source.endpointId && source.deliveryId) await notifyChatResponseAvailability(tx, { companyId: source.companyId, deliveryId: source.deliveryId });
|
|
await tx
|
|
.insert(fastResponseRequests)
|
|
.values({
|
|
...source,
|
|
expiresAt: new Date(
|
|
source.acceptedAt.getTime() + FAST_RESPONSE_DEADLINE_MS,
|
|
),
|
|
})
|
|
.onConflictDoNothing();
|
|
}
|
|
|
|
/** Shared with external transport admission. Caller already checks destination authority. */
|
|
export async function fastResponseSourceCurrent(tx: Db, request: Request) {
|
|
if (!(await instanceSettingsService(tx).getExperimental()).enableFastResponses) return false;
|
|
if (!request.issueId || request.expiresAt.getTime() <= Date.now())
|
|
return false;
|
|
const [company] = await tx
|
|
.select({ status: companies.status })
|
|
.from(companies)
|
|
.where(eq(companies.id, request.companyId));
|
|
const [agent] = request.agentId
|
|
? await tx
|
|
.select({ status: agents.status })
|
|
.from(agents)
|
|
.where(
|
|
and(
|
|
eq(agents.id, request.agentId),
|
|
eq(agents.companyId, request.companyId),
|
|
),
|
|
)
|
|
: [];
|
|
if (
|
|
company?.status !== "active" ||
|
|
(request.agentId &&
|
|
(!agent || ["paused", "terminated"].includes(agent.status)))
|
|
)
|
|
return false;
|
|
const [issue] = await tx
|
|
.select()
|
|
.from(issues)
|
|
.where(
|
|
and(
|
|
eq(issues.id, request.issueId),
|
|
eq(issues.companyId, request.companyId),
|
|
),
|
|
)
|
|
.for("update");
|
|
if (
|
|
!issue ||
|
|
issue.hiddenAt ||
|
|
(request.agentId && issue.assigneeAgentId !== request.agentId) ||
|
|
["done", "cancelled"].includes(issue.status) ||
|
|
(request.sessionGeneration !== null &&
|
|
issue.conversationSessionGeneration !== request.sessionGeneration)
|
|
)
|
|
return false;
|
|
if (
|
|
await isAutomaticRecoverySuppressedByPauseHold(
|
|
tx,
|
|
request.companyId,
|
|
request.issueId,
|
|
)
|
|
)
|
|
return false;
|
|
const [source] = request.sourceCommentId
|
|
? await tx
|
|
.select()
|
|
.from(issueComments)
|
|
.where(
|
|
and(
|
|
eq(issueComments.companyId, request.companyId),
|
|
eq(issueComments.id, request.sourceCommentId),
|
|
isNull(issueComments.deletedAt),
|
|
),
|
|
)
|
|
: [];
|
|
if (
|
|
request.sourceCommentId &&
|
|
(!source || source.updatedAt.getTime() > request.acceptedAt.getTime() + 10)
|
|
)
|
|
return false;
|
|
const [answer] = await tx
|
|
.select({ id: issueComments.id })
|
|
.from(issueComments)
|
|
.where(
|
|
and(
|
|
eq(issueComments.companyId, request.companyId),
|
|
eq(issueComments.issueId, request.issueId),
|
|
eq(issueComments.origin, "comment"),
|
|
isNull(issueComments.deletedAt),
|
|
gt(issueComments.createdAt, source?.createdAt ?? request.acceptedAt),
|
|
request.agentId
|
|
? eq(issueComments.authorAgentId, request.agentId)
|
|
: eq(issueComments.authorUserId, "board-concierge"),
|
|
),
|
|
)
|
|
.limit(1);
|
|
const [stopped] = await tx
|
|
.select({ id: heartbeatRuns.id })
|
|
.from(heartbeatRuns)
|
|
.where(
|
|
and(
|
|
eq(heartbeatRuns.companyId, request.companyId),
|
|
sql`${heartbeatRuns.contextSnapshot}->>'issueId' = ${request.issueId}`,
|
|
inArray(heartbeatRuns.status, ["cancelled", "failed", "succeeded"]),
|
|
request.sourceCommentId
|
|
? sql`coalesce(${heartbeatRuns.contextSnapshot}->>'wakeCommentId', ${heartbeatRuns.contextSnapshot}->>'commentId') = ${request.sourceCommentId}`
|
|
: gte(heartbeatRuns.createdAt, request.acceptedAt),
|
|
),
|
|
)
|
|
.limit(1);
|
|
const [streamed] = await tx
|
|
.select({ id: heartbeatRunEvents.id })
|
|
.from(heartbeatRunEvents)
|
|
.innerJoin(heartbeatRuns, eq(heartbeatRuns.id, heartbeatRunEvents.runId))
|
|
.where(
|
|
and(
|
|
eq(heartbeatRunEvents.companyId, request.companyId),
|
|
gt(heartbeatRunEvents.createdAt, request.acceptedAt),
|
|
sql`${heartbeatRuns.contextSnapshot}->>'issueId' = ${request.issueId}`,
|
|
inArray(heartbeatRunEvents.eventType, ["item.delta", "item.completed"]),
|
|
sql`${heartbeatRunEvents.payload}->'prpEvent'->'payload'->>'kind' = 'agentMessage'`,
|
|
sql`length(coalesce(${heartbeatRunEvents.payload}->'prpEvent'->'payload'->>'text', '')) > 0`,
|
|
),
|
|
)
|
|
.limit(1);
|
|
return !answer && !stopped && !streamed;
|
|
}
|
|
|
|
export function fastResponseService(
|
|
db: Db,
|
|
options: {
|
|
provider?: typeof runFastResponseProvider;
|
|
budgetHooks?: BudgetServiceHooks;
|
|
authorizeExternal?: FastResponseExternalAuthorization;
|
|
publishExternal?: (
|
|
tx: Db,
|
|
request: Request,
|
|
commentId: string,
|
|
text: string,
|
|
) => Promise<boolean>;
|
|
} = {},
|
|
) {
|
|
const providerCall = options.provider ?? runFastResponseProvider;
|
|
async function settings(companyId: string): Promise<FastResponseSettings> {
|
|
const [row] = await db
|
|
.select()
|
|
.from(companyFastResponses)
|
|
.where(eq(companyFastResponses.companyId, companyId));
|
|
return row
|
|
? { ...row, provider: row.provider as AiProvider }
|
|
: {
|
|
companyId,
|
|
enabled: false,
|
|
connectionId: null,
|
|
grantId: null,
|
|
provider: null,
|
|
model: null,
|
|
allowSponsored: true,
|
|
};
|
|
}
|
|
async function choices(companyId: string, userId: string) {
|
|
return (await aiConnectionService(db).list(companyId, userId)).filter(
|
|
(c) => c.ownership === "shared" && c.method === "api_key",
|
|
);
|
|
}
|
|
async function configure(
|
|
companyId: string,
|
|
userId: string,
|
|
input: UpdateFastResponse,
|
|
) {
|
|
const data = updateFastResponseSchema.parse(input);
|
|
return withAccountingTransaction(
|
|
db,
|
|
companyId,
|
|
async (tx, publications) => {
|
|
const [previous] = await tx
|
|
.select()
|
|
.from(companyFastResponses)
|
|
.where(eq(companyFastResponses.companyId, companyId));
|
|
const disabling =
|
|
!data.enabled &&
|
|
previous?.connectionId === data.connectionId &&
|
|
previous?.grantId === data.grantId;
|
|
const connection =
|
|
!disabling && data.connectionId && data.grantId
|
|
? await aiConnectionService(tx).selectFastResponse({
|
|
companyId,
|
|
userId,
|
|
connectionId: data.connectionId,
|
|
grantId: data.grantId,
|
|
})
|
|
: null;
|
|
const values = {
|
|
...data,
|
|
companyId,
|
|
provider: disabling
|
|
? previous.provider
|
|
: (connection?.provider ?? null),
|
|
updatedAt: new Date(),
|
|
};
|
|
await tx
|
|
.insert(companyFastResponses)
|
|
.values(values)
|
|
.onConflictDoUpdate({
|
|
target: companyFastResponses.companyId,
|
|
set: values,
|
|
});
|
|
await logActivity(
|
|
tx,
|
|
{
|
|
companyId,
|
|
actorType: "user",
|
|
actorId: userId,
|
|
action: "fast_response.configured",
|
|
entityType: "company",
|
|
entityId: companyId,
|
|
details: data,
|
|
},
|
|
publications,
|
|
);
|
|
return { ...values, provider: values.provider as AiProvider | null };
|
|
},
|
|
);
|
|
}
|
|
async function resolve(
|
|
tx: Db,
|
|
request: Pick<
|
|
Request,
|
|
| "companyId"
|
|
| "issueId"
|
|
| "responsibleUserId"
|
|
| "agentId"
|
|
| "sponsored"
|
|
| "endpointId"
|
|
> &
|
|
Partial<Pick<Request, "connectionId" | "grantId" | "model">>,
|
|
) {
|
|
if (!(await instanceSettingsService(tx).getExperimental()).enableFastResponses)
|
|
throw unprocessable("disabled");
|
|
const [config] = await tx
|
|
.select()
|
|
.from(companyFastResponses)
|
|
.where(eq(companyFastResponses.companyId, request.companyId));
|
|
if (
|
|
!config?.enabled ||
|
|
!config.connectionId ||
|
|
!config.grantId ||
|
|
!config.model
|
|
)
|
|
throw unprocessable("disabled");
|
|
if (
|
|
request.connectionId &&
|
|
(request.connectionId !== config.connectionId ||
|
|
request.grantId !== config.grantId ||
|
|
request.model !== config.model)
|
|
)
|
|
throw forbidden("configuration_changed");
|
|
if (
|
|
request.sponsored &&
|
|
(!config.allowSponsored ||
|
|
request.responsibleUserId ||
|
|
!request.endpointId)
|
|
)
|
|
throw forbidden("sponsorship_disabled");
|
|
const memberships = request.responsibleUserId
|
|
? await tx
|
|
.select({
|
|
companyId: companyMemberships.companyId,
|
|
membershipRole: companyMemberships.membershipRole,
|
|
status: companyMemberships.status,
|
|
})
|
|
.from(companyMemberships)
|
|
.where(
|
|
and(
|
|
eq(companyMemberships.companyId, request.companyId),
|
|
eq(companyMemberships.principalType, "user"),
|
|
eq(companyMemberships.principalId, request.responsibleUserId),
|
|
),
|
|
)
|
|
: [];
|
|
if (!request.sponsored && !request.responsibleUserId)
|
|
throw forbidden("responsible_user_missing");
|
|
const [issue] = request.issueId
|
|
? await tx
|
|
.select()
|
|
.from(issues)
|
|
.where(
|
|
and(
|
|
eq(issues.companyId, request.companyId),
|
|
eq(issues.id, request.issueId),
|
|
),
|
|
)
|
|
: [];
|
|
if (request.issueId && !issue) throw forbidden();
|
|
if (!request.sponsored && issue) {
|
|
const actor: AuthorizationActor = {
|
|
type: "board",
|
|
userId: request.responsibleUserId!,
|
|
source:
|
|
request.responsibleUserId === "local-board"
|
|
? "local_implicit"
|
|
: "session",
|
|
companyIds: memberships.map((m) => m.companyId),
|
|
memberships,
|
|
};
|
|
if (
|
|
!(
|
|
await accessService(tx).decide({
|
|
actor,
|
|
action: "issue:read",
|
|
resource: {
|
|
type: "issue",
|
|
companyId: request.companyId,
|
|
issueId: issue.id,
|
|
projectId: issue.projectId,
|
|
parentIssueId: issue.parentId,
|
|
assigneeAgentId: issue.assigneeAgentId,
|
|
assigneeUserId: issue.assigneeUserId,
|
|
status: issue.status,
|
|
},
|
|
})
|
|
).allowed
|
|
)
|
|
throw forbidden();
|
|
}
|
|
const connection = await aiConnectionService(tx).selectFastResponse({
|
|
companyId: request.companyId,
|
|
connectionId: config.connectionId,
|
|
grantId: config.grantId,
|
|
userId: request.responsibleUserId,
|
|
agentId: request.agentId,
|
|
sponsoredBackground: request.sponsored,
|
|
});
|
|
return { config, connection, issue };
|
|
}
|
|
async function availability(companyId: string, userId: string) {
|
|
if (!(await instanceSettingsService(db).getExperimental()).enableFastResponses)
|
|
return { available: false as const, reason: "disabled" };
|
|
try {
|
|
await resolve(db, {
|
|
companyId,
|
|
responsibleUserId: userId,
|
|
issueId: null,
|
|
agentId: null,
|
|
sponsored: false,
|
|
endpointId: null,
|
|
});
|
|
return { available: true as const };
|
|
} catch {
|
|
return { available: false as const, reason: "connection_unavailable" };
|
|
}
|
|
}
|
|
async function skip(request: Request, reason: string) {
|
|
await db.transaction(async tx => {
|
|
if (request.deliveryId) await notifyChatResponseAvailability(tx, { companyId: request.companyId, deliveryId: request.deliveryId });
|
|
await tx
|
|
.update(fastResponseRequests)
|
|
.set({
|
|
status: "skipped",
|
|
publicationStatus: "suppressed",
|
|
errorCode: reason,
|
|
finishedAt: new Date(),
|
|
})
|
|
.where(
|
|
and(
|
|
eq(fastResponseRequests.id, request.id),
|
|
eq(fastResponseRequests.status, "pending"),
|
|
),
|
|
);
|
|
});
|
|
}
|
|
async function settle(request: Request, outcome: FastResponseOutcome) {
|
|
await withBudgetSpan("budget.accounting.settlement", () => withAccountingTransaction(
|
|
db,
|
|
request.companyId,
|
|
async (tx, publications) => {
|
|
const [current] = await tx
|
|
.select()
|
|
.from(fastResponseRequests)
|
|
.where(eq(fastResponseRequests.id, request.id))
|
|
.for("update");
|
|
if (!current || current.status !== "running") return;
|
|
const receipt = outcome.receipt;
|
|
const links: {
|
|
agentId: string | null;
|
|
issueId: string | null;
|
|
projectId: string | null;
|
|
} = { agentId: null, issueId: null, projectId: null };
|
|
for (const [key, table] of [
|
|
["agentId", agents],
|
|
["issueId", issues],
|
|
["projectId", projects],
|
|
] as const) {
|
|
const id = request[key];
|
|
if (!id) continue;
|
|
const [row] = await tx
|
|
.select({ id: table.id })
|
|
.from(table)
|
|
.where(
|
|
and(eq(table.id, id), eq(table.companyId, request.companyId)),
|
|
)
|
|
.for("key share");
|
|
if (row) links[key] = row.id;
|
|
}
|
|
const event = await createCostEventInTransaction(
|
|
tx,
|
|
request.companyId,
|
|
{
|
|
usageKind: "fast_response",
|
|
responsibleUserId: request.responsibleUserId,
|
|
...links,
|
|
heartbeatRunId: null,
|
|
idempotencyKey: `fast-response:${request.id}`,
|
|
provider: request.provider!,
|
|
biller: request.provider!,
|
|
model: request.model!,
|
|
billingType: "metered_api",
|
|
costStatus: receipt.costStatus,
|
|
inputTokens: receipt.inputTokens ?? 0,
|
|
outputTokens: receipt.outputTokens ?? 0,
|
|
costCents: receipt.costCents ?? "0",
|
|
providerRequestId: receipt.providerRequestId,
|
|
pricingProvenance: receipt.pricingProvenance,
|
|
occurredAt: request.startedAt!,
|
|
},
|
|
publications,
|
|
{
|
|
actorType: request.responsibleUserId ? "user" : "system",
|
|
actorId: request.responsibleUserId ?? "paperclip-fast-response",
|
|
},
|
|
);
|
|
const finishedAt = new Date();
|
|
if (request.deliveryId) await notifyChatResponseAvailability(tx, { companyId: request.companyId, deliveryId: request.deliveryId });
|
|
await tx
|
|
.update(fastResponseRequests)
|
|
.set({
|
|
status: outcome.text
|
|
? "succeeded"
|
|
: receipt.costStatus === "unpriced"
|
|
? "unknown"
|
|
: "failed",
|
|
errorCode: outcome.errorCode ?? null,
|
|
costEventId: event.id,
|
|
providerRequestId: receipt.providerRequestId,
|
|
inputTokens: receipt.inputTokens,
|
|
outputTokens: receipt.outputTokens,
|
|
finishedAt,
|
|
durationMs: finishedAt.getTime() - request.acceptedAt.getTime(),
|
|
...(outcome.text ? {} : { publicationStatus: "suppressed" }),
|
|
})
|
|
.where(eq(fastResponseRequests.id, request.id));
|
|
if (receipt.costStatus !== "unpriced")
|
|
await tx
|
|
.update(budgetReservations)
|
|
.set({
|
|
state: outcome.noProviderWork ? "released" : "settled",
|
|
settledAt: finishedAt,
|
|
})
|
|
.where(
|
|
and(
|
|
eq(budgetReservations.companyId, request.companyId),
|
|
eq(budgetReservations.fastResponseRequestId, request.id),
|
|
eq(budgetReservations.state, "held"),
|
|
),
|
|
);
|
|
},
|
|
));
|
|
|
|
}
|
|
async function process(
|
|
request: Request,
|
|
signal?: AbortSignal,
|
|
): Promise<FastResponseTestResult> {
|
|
let selected: Awaited<ReturnType<typeof resolve>>,
|
|
credential: string,
|
|
prompt: string;
|
|
let secretId: string | undefined,
|
|
secret: typeof companySecrets.$inferSelect | undefined;
|
|
try {
|
|
selected = await resolve(db, request);
|
|
const [agent] = request.agentId
|
|
? await db
|
|
.select({ name: agents.name })
|
|
.from(agents)
|
|
.where(
|
|
and(
|
|
eq(agents.companyId, request.companyId),
|
|
eq(agents.id, request.agentId),
|
|
),
|
|
)
|
|
: [];
|
|
if (request.endpointId) {
|
|
if (!options.authorizeExternal) throw forbidden();
|
|
const context = await db.transaction((tx) =>
|
|
options.authorizeExternal!(tx as unknown as Db, request),
|
|
);
|
|
prompt = fastResponsePrompt(context);
|
|
} else if (request.issueId) {
|
|
const [source] = request.sourceCommentId
|
|
? await db
|
|
.select()
|
|
.from(issueComments)
|
|
.where(
|
|
and(
|
|
eq(issueComments.companyId, request.companyId),
|
|
eq(issueComments.id, request.sourceCommentId),
|
|
),
|
|
)
|
|
: [];
|
|
const recent = await db
|
|
.select({ body: issueComments.body, sourceTrust: issueComments.sourceTrust })
|
|
.from(issueComments)
|
|
.where(
|
|
and(
|
|
eq(issueComments.companyId, request.companyId),
|
|
eq(issueComments.issueId, request.issueId),
|
|
isNull(issueComments.deletedAt),
|
|
eq(issueComments.origin, "comment"),
|
|
lt(
|
|
issueComments.createdAt,
|
|
source?.createdAt ?? request.acceptedAt,
|
|
),
|
|
),
|
|
)
|
|
.orderBy(desc(issueComments.createdAt))
|
|
.limit(2);
|
|
const attachments = request.sourceCommentId
|
|
? await db
|
|
.select({
|
|
name: assets.originalFilename,
|
|
type: assets.contentType,
|
|
})
|
|
.from(issueAttachments)
|
|
.innerJoin(
|
|
assets,
|
|
and(
|
|
eq(assets.id, issueAttachments.assetId),
|
|
eq(assets.companyId, request.companyId),
|
|
),
|
|
)
|
|
.where(
|
|
and(
|
|
eq(issueAttachments.companyId, request.companyId),
|
|
eq(issueAttachments.issueCommentId, request.sourceCommentId),
|
|
),
|
|
)
|
|
.limit(5)
|
|
: [];
|
|
prompt = fastResponsePrompt({
|
|
attachments: attachments.map((a) => `${a.name} (${a.type})`),
|
|
agentName: agent?.name ?? "Board assistant",
|
|
message:
|
|
(source ? sanitizeQuarantinedCommentForHigherTrust(source).body : undefined) ??
|
|
selected.issue?.description ??
|
|
selected.issue?.title ??
|
|
"",
|
|
title: selected.issue?.title,
|
|
recent: recent.reverse().map((c) => sanitizeQuarantinedCommentForHigherTrust(c).body),
|
|
queued: await fastResponseTurnQueued(db, request),
|
|
});
|
|
} else
|
|
prompt = fastResponsePrompt({
|
|
agentName: "Assistant",
|
|
message: "Fix the border around the settings panel.",
|
|
});
|
|
secretId = selected.connection.grant.credentialSecretRefs.find(
|
|
(r) => r.configPath === "ai.credential",
|
|
)?.secretId;
|
|
[secret] = secretId
|
|
? await db
|
|
.select()
|
|
.from(companySecrets)
|
|
.where(
|
|
and(
|
|
eq(companySecrets.id, secretId),
|
|
eq(companySecrets.companyId, request.companyId),
|
|
),
|
|
)
|
|
: [];
|
|
credential = await aiConnectionService(db).credential(
|
|
selected.connection,
|
|
0,
|
|
{
|
|
responsibleUserId: request.responsibleUserId,
|
|
actorType: request.responsibleUserId ? "user" : "system",
|
|
actorId: request.responsibleUserId ?? "paperclip-fast-response",
|
|
issueId: request.issueId,
|
|
},
|
|
);
|
|
} catch (error) {
|
|
const reason = error instanceof Error && error.message === "disabled"
|
|
? "disabled"
|
|
: "connection_unavailable";
|
|
await skip(request, reason);
|
|
return { status: "unavailable", reason };
|
|
}
|
|
const metadata = aiConnectionMetadataSchema.parse(
|
|
selected.connection.connection.config.ai,
|
|
);
|
|
let admitted: Request | null = null;
|
|
try {
|
|
admitted = await withAccountingTransaction(
|
|
db,
|
|
request.companyId,
|
|
async (tx) => {
|
|
if (request.expiresAt.getTime() <= Date.now() || signal?.aborted)
|
|
return null;
|
|
const current = await resolve(tx, request);
|
|
if (
|
|
current.config.updatedAt.getTime() !==
|
|
selected.config.updatedAt.getTime() ||
|
|
current.connection.connection.updatedAt.getTime() !==
|
|
selected.connection.connection.updatedAt.getTime() ||
|
|
current.connection.grant.updatedAt.getTime() !==
|
|
selected.connection.grant.updatedAt.getTime()
|
|
)
|
|
return null;
|
|
if (secretId) {
|
|
const [now] = await tx
|
|
.select()
|
|
.from(companySecrets)
|
|
.where(eq(companySecrets.id, secretId))
|
|
.for("share");
|
|
if (
|
|
!now ||
|
|
now.status !== "active" ||
|
|
now.latestVersion !== secret?.latestVersion
|
|
)
|
|
return null;
|
|
}
|
|
if (
|
|
request.issueId &&
|
|
!(await fastResponseSourceCurrent(tx, request))
|
|
)
|
|
return null;
|
|
if (request.endpointId) await options.authorizeExternal!(tx, request);
|
|
const [company] = await tx
|
|
.select()
|
|
.from(companies)
|
|
.where(eq(companies.id, request.companyId));
|
|
const [agent] = request.agentId
|
|
? await tx
|
|
.select()
|
|
.from(agents)
|
|
.where(
|
|
and(
|
|
eq(agents.id, request.agentId),
|
|
eq(agents.companyId, request.companyId),
|
|
),
|
|
)
|
|
: [];
|
|
if (
|
|
company?.status !== "active" ||
|
|
(request.agentId &&
|
|
(!agent || ["paused", "terminated"].includes(agent.status)))
|
|
)
|
|
return null;
|
|
const { block, policies, observations } = await budgetServiceInTransaction(tx, []).evaluateAdmission(
|
|
request.companyId, request.agentId, { projectId: current.issue?.projectId });
|
|
if (block) return null;
|
|
const amount = policies.reduce((max, p) => {
|
|
const n = centsToUnits(p.reservationCents);
|
|
return n > max ? n : max;
|
|
}, 0n);
|
|
for (const [index, policy] of policies.entries()) {
|
|
const committed = centsToUnits(observations[index].ledgerCents) + centsToUnits(observations[index].effectiveHeldCents);
|
|
if (
|
|
committed >= centsToUnits(policy.amount) ||
|
|
committed + amount > centsToUnits(policy.amount)
|
|
)
|
|
return null;
|
|
}
|
|
const [claimed] = await tx
|
|
.update(fastResponseRequests)
|
|
.set({
|
|
status: "running",
|
|
startedAt: new Date(),
|
|
connectionId: current.config.connectionId,
|
|
grantId: current.config.grantId,
|
|
model: current.config.model,
|
|
provider: aiConnectionCatalogSlug(
|
|
current.connection.provider,
|
|
metadata.routing,
|
|
),
|
|
projectId: current.issue?.projectId ?? null,
|
|
})
|
|
.where(
|
|
and(
|
|
eq(fastResponseRequests.id, request.id),
|
|
eq(fastResponseRequests.status, "pending"),
|
|
),
|
|
)
|
|
.returning();
|
|
if (!claimed) return null;
|
|
await tx.insert(budgetReservations).values({
|
|
companyId: request.companyId,
|
|
fastResponseRequestId: request.id,
|
|
agentId: request.agentId,
|
|
projectId: current.issue?.projectId,
|
|
amountCents: unitsToCents(amount),
|
|
providerStartedAt: claimed.startedAt,
|
|
});
|
|
return claimed;
|
|
},
|
|
);
|
|
} catch {
|
|
/* An optional receipt must never fail the accepted turn. */
|
|
}
|
|
if (!admitted) {
|
|
await skip(request, "not_admitted");
|
|
return { status: "unavailable", reason: "not_admitted" };
|
|
}
|
|
const timeout = AbortSignal.timeout(
|
|
Math.max(1, request.expiresAt.getTime() - Date.now()),
|
|
);
|
|
let outcome: FastResponseOutcome;
|
|
try {
|
|
outcome = await providerCall({
|
|
metadata,
|
|
credential,
|
|
model: admitted.model!,
|
|
prompt,
|
|
signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
|
|
});
|
|
} catch {
|
|
outcome = {
|
|
errorCode: "provider_failed",
|
|
receipt: fastResponseReceipt(),
|
|
};
|
|
}
|
|
// Commit the visible receipt first: accounting settlement must not spend its display deadline.
|
|
if (outcome.text && request.issueId) {
|
|
const text = outcome.text;
|
|
try {
|
|
const publications: ActivityPublication[] = [];
|
|
await db.transaction(async (tx) => {
|
|
if (!(await fastResponseSourceCurrent(tx as unknown as Db, request)))
|
|
return;
|
|
await resolve(tx as unknown as Db, admitted!);
|
|
if (request.endpointId)
|
|
await options.authorizeExternal!(tx as unknown as Db, request);
|
|
const [locked] = await tx
|
|
.select()
|
|
.from(fastResponseRequests)
|
|
.where(eq(fastResponseRequests.id, request.id))
|
|
.for("update");
|
|
if (
|
|
!locked ||
|
|
locked.commentId ||
|
|
locked.status !== "running" ||
|
|
locked.errorCode === "real_reply_started"
|
|
)
|
|
return;
|
|
if (request.expiresAt.getTime() <= Date.now()) return;
|
|
const safe = projectSafeChatPublication({
|
|
classification: "external",
|
|
source: "agent_comment",
|
|
text,
|
|
});
|
|
const [source] = request.sourceCommentId
|
|
? await tx
|
|
.select({ sourceTrust: issueComments.sourceTrust })
|
|
.from(issueComments)
|
|
.where(
|
|
and(
|
|
eq(issueComments.companyId, request.companyId),
|
|
eq(issueComments.id, request.sourceCommentId),
|
|
),
|
|
)
|
|
: [];
|
|
const [comment] = await insertIssueComment(tx, {
|
|
companyId: request.companyId,
|
|
issueId: request.issueId!,
|
|
authorAgentId: request.agentId,
|
|
authorUserId: request.agentId ? null : "board-concierge",
|
|
authorType: request.agentId ? "agent" : "system",
|
|
body: safe.text,
|
|
sourceTrust: source?.sourceTrust ?? null,
|
|
conversationSessionGeneration: request.sessionGeneration,
|
|
origin: "fast_response",
|
|
fastResponseRequestId: request.id,
|
|
});
|
|
if (
|
|
request.endpointId &&
|
|
request.conversationId &&
|
|
!(await options.publishExternal?.(
|
|
tx as unknown as Db,
|
|
request,
|
|
comment.id,
|
|
safe.text,
|
|
))
|
|
)
|
|
await tx.insert(chatPublications).values({
|
|
companyId: request.companyId,
|
|
issueId: request.issueId!,
|
|
endpointId: request.endpointId,
|
|
conversationId: request.conversationId,
|
|
commentId: comment.id,
|
|
idempotencyKey: `fast-response:${request.id}`,
|
|
payload: safe,
|
|
});
|
|
if (request.deliveryId) await notifyChatResponseAvailability(tx, { companyId: request.companyId, deliveryId: request.deliveryId });
|
|
await tx
|
|
.update(fastResponseRequests)
|
|
.set({
|
|
commentId: comment.id,
|
|
publicationStatus: request.endpointId ? "pending" : "published",
|
|
})
|
|
.where(eq(fastResponseRequests.id, request.id));
|
|
await logActivity(
|
|
tx as unknown as Db,
|
|
{
|
|
companyId: request.companyId,
|
|
actorType: "system",
|
|
actorId: "paperclip-fast-response",
|
|
action: "issue.comment_added",
|
|
entityType: "issue",
|
|
entityId: request.issueId!,
|
|
details: { commentId: comment.id, origin: "fast_response" },
|
|
},
|
|
publications,
|
|
);
|
|
});
|
|
for (const publication of publications) publishActivity(publication);
|
|
} catch {
|
|
/* Never substitute an unsafe or obsolete acknowledgement. */
|
|
}
|
|
}
|
|
await settle(admitted, outcome);
|
|
await db
|
|
.update(fastResponseRequests)
|
|
.set({ publicationStatus: request.issueId ? "suppressed" : "test" })
|
|
.where(
|
|
and(
|
|
eq(fastResponseRequests.id, request.id),
|
|
isNull(fastResponseRequests.commentId),
|
|
),
|
|
);
|
|
return outcome.text
|
|
? {
|
|
status: "succeeded",
|
|
text: outcome.text,
|
|
durationMs: Date.now() - request.acceptedAt.getTime(),
|
|
invocationId: request.id,
|
|
usage: outcome.receipt,
|
|
}
|
|
: {
|
|
status: "failed",
|
|
reason: outcome.errorCode ?? "provider_failed",
|
|
invocationId: request.id,
|
|
};
|
|
}
|
|
async function test(companyId: string, userId: string) {
|
|
const now = new Date();
|
|
const [request] = await db
|
|
.insert(fastResponseRequests)
|
|
.values({
|
|
companyId,
|
|
sourceKey: `test:${randomUUID()}`,
|
|
responsibleUserId: userId,
|
|
acceptedAt: now,
|
|
expiresAt: new Date(now.getTime() + FAST_RESPONSE_DEADLINE_MS),
|
|
})
|
|
.returning();
|
|
return process(request);
|
|
}
|
|
async function sweepPending(signal?: AbortSignal) {
|
|
const stale = await db
|
|
.select()
|
|
.from(fastResponseRequests)
|
|
.where(
|
|
and(
|
|
eq(fastResponseRequests.status, "running"),
|
|
lt(fastResponseRequests.expiresAt, new Date(Date.now() - 30_000)),
|
|
),
|
|
)
|
|
.limit(50);
|
|
for (const row of stale)
|
|
await settle(row, {
|
|
errorCode: "interrupted",
|
|
receipt: fastResponseReceipt(),
|
|
});
|
|
await db
|
|
.update(fastResponseRequests)
|
|
.set({
|
|
status: "skipped",
|
|
publicationStatus: "suppressed",
|
|
errorCode: "expired",
|
|
finishedAt: new Date(),
|
|
})
|
|
.where(
|
|
and(
|
|
eq(fastResponseRequests.status, "pending"),
|
|
lte(fastResponseRequests.expiresAt, new Date()),
|
|
),
|
|
);
|
|
const pending = await db
|
|
.select()
|
|
.from(fastResponseRequests)
|
|
.where(
|
|
and(
|
|
eq(fastResponseRequests.status, "pending"),
|
|
sql`${fastResponseRequests.sourceKey} not like 'test:%'`,
|
|
gt(fastResponseRequests.expiresAt, new Date()),
|
|
),
|
|
)
|
|
.orderBy(fastResponseRequests.acceptedAt)
|
|
.limit(8);
|
|
await Promise.allSettled(pending.map((row) => process(row, signal)));
|
|
}
|
|
async function hasPending() {
|
|
const [row] = await db
|
|
.select({ id: fastResponseRequests.id })
|
|
.from(fastResponseRequests)
|
|
.where(inArray(fastResponseRequests.status, ["pending", "running"]))
|
|
.limit(1);
|
|
return Boolean(row);
|
|
}
|
|
async function history(
|
|
companyId: string,
|
|
actor: AuthorizationActor,
|
|
range: { from?: Date; to?: Date; limit?: number } = {},
|
|
): Promise<FastResponseHistoryEntry[]> {
|
|
if (
|
|
!(
|
|
await accessService(db).decide({
|
|
actor,
|
|
action: "company_scope:read",
|
|
resource: { type: "company", companyId },
|
|
})
|
|
).allowed
|
|
)
|
|
throw forbidden();
|
|
const rows = await db
|
|
.select({
|
|
request: fastResponseRequests,
|
|
userName: authUsers.name,
|
|
cost: costEvents,
|
|
issue: issues,
|
|
publicationState: chatPublications.state,
|
|
connectionName: toolConnections.name,
|
|
})
|
|
.from(fastResponseRequests)
|
|
.leftJoin(
|
|
costEvents,
|
|
and(
|
|
eq(costEvents.companyId, companyId),
|
|
eq(costEvents.id, fastResponseRequests.costEventId),
|
|
),
|
|
)
|
|
.leftJoin(
|
|
authUsers,
|
|
eq(authUsers.id, fastResponseRequests.responsibleUserId),
|
|
)
|
|
.leftJoin(
|
|
toolConnections,
|
|
and(
|
|
eq(toolConnections.companyId, companyId),
|
|
eq(toolConnections.id, fastResponseRequests.connectionId),
|
|
),
|
|
)
|
|
.leftJoin(
|
|
chatPublications,
|
|
and(
|
|
eq(chatPublications.companyId, companyId),
|
|
eq(
|
|
chatPublications.idempotencyKey,
|
|
sql`'fast-response:' || ${fastResponseRequests.id}::text`,
|
|
),
|
|
),
|
|
)
|
|
.leftJoin(
|
|
issues,
|
|
and(
|
|
eq(issues.companyId, companyId),
|
|
eq(issues.id, fastResponseRequests.issueId),
|
|
),
|
|
)
|
|
.where(
|
|
and(
|
|
eq(fastResponseRequests.companyId, companyId),
|
|
range.from
|
|
? gte(fastResponseRequests.acceptedAt, range.from)
|
|
: undefined,
|
|
range.to ? lte(fastResponseRequests.acceptedAt, range.to) : undefined,
|
|
),
|
|
)
|
|
.orderBy(desc(fastResponseRequests.acceptedAt))
|
|
.limit(Math.min(range.limit ?? 100, 500));
|
|
return Promise.all(
|
|
rows.map(
|
|
async ({
|
|
request: r,
|
|
userName,
|
|
cost,
|
|
issue,
|
|
publicationState,
|
|
connectionName,
|
|
}) => {
|
|
const visible =
|
|
issue &&
|
|
(
|
|
await accessService(db).decide({
|
|
actor,
|
|
action: "issue:read",
|
|
resource: {
|
|
type: "issue",
|
|
companyId,
|
|
issueId: issue.id,
|
|
projectId: issue.projectId,
|
|
parentIssueId: issue.parentId,
|
|
assigneeAgentId: issue.assigneeAgentId,
|
|
assigneeUserId: issue.assigneeUserId,
|
|
status: issue.status,
|
|
},
|
|
})
|
|
).allowed;
|
|
return {
|
|
id: r.id,
|
|
feature: r.sourceKey.startsWith("test:")
|
|
? "settings.test"
|
|
: "message",
|
|
actorType: r.sponsored ? "system" : "user",
|
|
responsibleUserId: r.responsibleUserId,
|
|
userName,
|
|
issueId: visible ? issue!.id : null,
|
|
issueIdentifier: visible ? issue!.identifier : null,
|
|
agentId: r.agentId,
|
|
runId: null,
|
|
connectionId: r.connectionId ?? "",
|
|
provider: r.provider ?? "",
|
|
model: r.model ?? "",
|
|
status: r.status,
|
|
connectionName,
|
|
publicationStatus: publicationState ?? r.publicationStatus,
|
|
errorCode: r.errorCode,
|
|
startedAt: r.acceptedAt.toISOString(),
|
|
finishedAt: r.finishedAt?.toISOString() ?? null,
|
|
durationMs: r.durationMs,
|
|
inputTokens: r.inputTokens,
|
|
outputTokens: r.outputTokens,
|
|
costCents:
|
|
cost && cost.costStatus !== "unpriced"
|
|
? String(cost.costCents)
|
|
: null,
|
|
costStatus: cost?.costStatus ?? null,
|
|
};
|
|
},
|
|
),
|
|
);
|
|
}
|
|
return {
|
|
settings,
|
|
choices,
|
|
configure,
|
|
availability,
|
|
test,
|
|
history,
|
|
process,
|
|
sweepPending,
|
|
hasPending,
|
|
authorize: (tx: Db, request: Request) => resolve(tx, request),
|
|
};
|
|
}
|