mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-31 14:16:39 +01:00
## Thinking Path > - Paperclip coordinates agent work and preserves its results. > - Remote execution copies completed workspace changes back to the host. > - An agent can fetch a published branch with shallow Git history. > - A full Git bundle omits the shallow boundary metadata and can fail host connectivity checks. > - This pull request carries the boundaries through the full-bundle fallback and imports through normal Git fetch. > - Recovery must also recognize completed native work on a reusable lease without treating it as ordinary executor reuse. > - Completed work can be saved without another provider turn, while existing host history remains connected. ## Linked Issues or Issue Description **What happened?** Sandbox finalization can fail with a missing parent commit after an agent checks out a separately fetched shallow branch. Re-exporting a full bundle repeats the failure because Git bundles do not encode shallow boundaries. **Expected behavior** Import the completed branch and saved files. Preserve existing host ancestry and concurrent host edits. Keep normal Git connectivity and workspace merge checks. **Steps to reproduce** 1. Prepare a sandbox from one host commit. 2. Fetch a different published branch with depth one and check it out. 3. Save a result file and restore the sandbox workspace. **Paperclip version or commit** Reproduced against master before this repair. The regression fails with the missing-parent error before the production change. Related public context: #15268 covers rewritten history integration. This repair handles shallow bundle transport before integration. Searches found no equivalent open repair. ## What Changed - Export shallow boundaries beside the full bundle when the delta import lacks history. - Unpack into a temporary shared bare repository. Keep parent connections already available on the host and import with Git's shallow fetch protocol. - Reject malformed boundary values and retain existing connectivity, branch, and concurrent-work merge guards. Keep boundary probes within 15 seconds without consuming the separate 60-second Git import budget. - Let source-bound preservation recover reusable leases only for the finalizer-selected, accepted successful native result and matching completion contract. Retain provider-stop, physical ownership and host-writer checks; ordinary reusable sources remain blocked. - Classify invalid Git metadata separately from omittable unsafe archives. Keep native finalization failed and preserve service-owned file recovery; carry the sender's dedicated rejection across the command boundary without transient retries. - Share the failure allowlist across bounded run/activity projections and cancelled-preservation selection. Migration `0416` rebuilds the matching partial index; task chat retains the non-retryable recovery notice. - Add real Git, filesystem sandbox and database recovery regressions. Update execution semantics and affected verification maps. ## Verification - Red: real filesystem sandbox restore failed with the same missing-parent/revision-walk error before the export repair. - Green: 240 focused Git, workspace restore, sandbox transport and native-finalization tests pass. Coverage includes both transfer paths, malformed/oversized metadata, a genuinely shallow host with its original boundary intact, and concurrent host work with both merge parents verified. - Red/green: a 14-second simulated probe left the final real Git fetch only 45,893 ms before the budget split. It now retains over 55 seconds after the probes; a 16-second probe exhausts its own budget before host import. - Red/green: an oversized read progress report was retried three times before its distinct metadata classification. It now aborts the first read before payload consumption, leaves the host tip unchanged and does not retry. - Red/green: the real metadata error was swallowed by best-effort export and native finalization incorrectly returned success. Both regressions now pass; all 240 focused Git/restore/native-finalization tests pass. A PostgreSQL/filesystem fault-injection test also verifies retained files, failed run, service-owned recovery barrier, no preservation receipt and no new wake. - Red/green: duplicate metadata produced 47 Git process starts before deduplication; the bounded-work regression now passes. Oversized byte and boundary inputs fail before Git probes. - Recovery red: both reusable lease policies exhausted recovery despite a selected accepted successful result. The assembled PostgreSQL/filesystem path now passes for retained and both reusable policies, including idempotent re-admission after exhausted automatic recovery, saved files, no agent wake, and once-only existing-result finalization. - Source verification controls reject ordinary reusable leases, rejected/failed/unselected results, another physical allocation owner, and an active host writer without changing recorded state. - The 78-test PostgreSQL/filesystem recovery/source suite passed before the metadata classification correction. Its new persisted metadata-failure regression also passes. Full workspace typecheck and build pass at final head `69f10daab2`. - `node scripts/check-maps.mjs` and `git diff --check` pass. - Full local `pnpm test:run` attempts did not finish. They are not counted as passes. Early focused runs exceeded 5-second and 30-second fixture deadlines; the final serial suite passed all 176 tests with a 30-second per-test budget in 56.8 seconds. - Red/green: both bounded PostgreSQL projections dropped the new code in oversized results. All 19 source/projection cases now pass, plus an actual index-predicate test that excludes a metadata repair hold and still selects ordinary cancelled work. - Red/green: an oversized sender `.git/shallow` lost its error type before host validation. The real transport test now verifies one typed rejection, no shallow-payload read, and an unchanged host tip. All 465 command/sandbox/task-chat tests pass, and token/migration gates pass. - Full typecheck/build pass after these final contract corrections at `69f10daab2`; all 20 PostgreSQL source/projection/index contracts pass. All current-head CI checks passed; Greptile is 5/5 with no unresolved review threads. Merged #18230 supplies the CI-cache and Linux shell-fixture baseline repairs; their duplicate changes have been removed from this PR. The final Runner lane passed after one justified rerun of an unchanged authentication fixture timeout (all 11 fixture tests also pass locally). Previous CI failures and incomplete local runs remain recorded; live deployment and retained-run recovery remain separate verification. ## Risks - The fallback creates a temporary shared bare clone. Metadata is limited to 64 KiB and 256 unique boundaries, duplicate/parent probes are cached, and Git import has a 60-second command budget plus a separate 15-second boundary-probe budget. It runs only after a missing-history import failure. - Incorrect boundaries could hide real host ancestry. The importer retains available parent connections, and regressions cover that case. - The full-bundle fallback can transfer a bundle twice. This preserves the existing fast delta path; removing that extra transfer is outside this correctness repair. - Preservation eligibility expands only for completed native exports on reusable leases. It creates no provider turn and leaves ordinary reusable leases ineligible. Database/filesystem regressions cover result selection and competing physical/host ownership. - The migration rebuilds one partial index; no tables, columns or user records change. There are no agent execution admission, model, trust, recovery permission, workflow or lockfile changes. ## Model Used OpenAI Codex, GPT-6, with reasoning, code execution, and browser tools. The exact serving variant 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 - [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>
680 lines
28 KiB
TypeScript
680 lines
28 KiB
TypeScript
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
|
|
import { skillAccessService } from "./skill-access.js";
|
|
import { credentialAccessConnectionNameSql } from "./credential-access-visibility.js";
|
|
import { executionProjectionsForRuns } from "./execution-projection.js";
|
|
import { and, asc, desc, eq, inArray, isNull, or, sql, type SQL } from "drizzle-orm";
|
|
import type { Db } from "@paperclipai/db";
|
|
import {
|
|
activityLog,
|
|
agents,
|
|
documentRevisions,
|
|
documents,
|
|
environmentLeases,
|
|
environments,
|
|
heartbeatRunEvents,
|
|
heartbeatRuns,
|
|
workspaceRestoreFailureCodesSql,
|
|
issueComments,
|
|
issueDocuments,
|
|
issues,
|
|
issueWorkProducts,
|
|
workspaceOperations,
|
|
} from "@paperclipai/db";
|
|
import { hasWorkspaceRestoreFailure, safeWorkspaceRestorePath, ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY } from "@paperclipai/shared";
|
|
import { logger } from "../middleware/logger.js";
|
|
import { visibleIssueCondition } from "./issue-visibility.js";
|
|
import { classifyRunLiveness } from "./run-liveness.js";
|
|
|
|
export interface ActivityFilters {
|
|
companyId: string;
|
|
agentId?: string;
|
|
entityType?: string;
|
|
entityId?: string;
|
|
limit?: number;
|
|
readCondition?: SQL<boolean>;
|
|
caseReadCondition?: SQL<boolean>;
|
|
}
|
|
|
|
const DEFAULT_ACTIVITY_LIMIT = 100;
|
|
const MAX_ACTIVITY_LIMIT = 500;
|
|
|
|
export function normalizeActivityLimit(limit: number | undefined) {
|
|
if (!Number.isFinite(limit)) return DEFAULT_ACTIVITY_LIMIT;
|
|
return Math.max(1, Math.min(MAX_ACTIVITY_LIMIT, Math.floor(limit ?? DEFAULT_ACTIVITY_LIMIT)));
|
|
}
|
|
|
|
export function activityService(db: Db) {
|
|
const scheduledLivenessBackfills = new Set<string>();
|
|
const issueIdAsText = sql<string>`${issues.id}::text`;
|
|
const summarizedUsageJson = sql<Record<string, unknown> | null>`
|
|
case
|
|
when ${heartbeatRuns.usageJson} is null then null
|
|
else jsonb_strip_nulls(jsonb_build_object(
|
|
'inputTokens', coalesce(${heartbeatRuns.usageJson} -> 'inputTokens', ${heartbeatRuns.usageJson} -> 'input_tokens'),
|
|
'input_tokens', coalesce(${heartbeatRuns.usageJson} -> 'input_tokens', ${heartbeatRuns.usageJson} -> 'inputTokens'),
|
|
'outputTokens', coalesce(${heartbeatRuns.usageJson} -> 'outputTokens', ${heartbeatRuns.usageJson} -> 'output_tokens'),
|
|
'output_tokens', coalesce(${heartbeatRuns.usageJson} -> 'output_tokens', ${heartbeatRuns.usageJson} -> 'outputTokens'),
|
|
'cachedInputTokens', coalesce(
|
|
${heartbeatRuns.usageJson} -> 'cachedInputTokens',
|
|
${heartbeatRuns.usageJson} -> 'cached_input_tokens',
|
|
${heartbeatRuns.usageJson} -> 'cache_read_input_tokens'
|
|
),
|
|
'cached_input_tokens', coalesce(
|
|
${heartbeatRuns.usageJson} -> 'cached_input_tokens',
|
|
${heartbeatRuns.usageJson} -> 'cachedInputTokens',
|
|
${heartbeatRuns.usageJson} -> 'cache_read_input_tokens'
|
|
),
|
|
'cache_read_input_tokens', coalesce(
|
|
${heartbeatRuns.usageJson} -> 'cache_read_input_tokens',
|
|
${heartbeatRuns.usageJson} -> 'cached_input_tokens',
|
|
${heartbeatRuns.usageJson} -> 'cachedInputTokens'
|
|
),
|
|
'billingType', coalesce(${heartbeatRuns.usageJson} -> 'billingType', ${heartbeatRuns.usageJson} -> 'billing_type'),
|
|
'billing_type', coalesce(${heartbeatRuns.usageJson} -> 'billing_type', ${heartbeatRuns.usageJson} -> 'billingType'),
|
|
'costUsd', coalesce(
|
|
${heartbeatRuns.usageJson} -> 'costUsd',
|
|
${heartbeatRuns.usageJson} -> 'cost_usd',
|
|
${heartbeatRuns.usageJson} -> 'total_cost_usd'
|
|
),
|
|
'cost_usd', coalesce(
|
|
${heartbeatRuns.usageJson} -> 'cost_usd',
|
|
${heartbeatRuns.usageJson} -> 'costUsd',
|
|
${heartbeatRuns.usageJson} -> 'total_cost_usd'
|
|
),
|
|
'total_cost_usd', coalesce(
|
|
${heartbeatRuns.usageJson} -> 'total_cost_usd',
|
|
${heartbeatRuns.usageJson} -> 'cost_usd',
|
|
${heartbeatRuns.usageJson} -> 'costUsd'
|
|
)
|
|
))
|
|
end
|
|
`.as("usageJson");
|
|
// A denied user needs the selected connection's name to repair their task.
|
|
// Other readers need the connection's existing human sharing permission.
|
|
const summarizedResultJson = (viewerUserId: string | null) => sql<Record<string, unknown> | null>`
|
|
case
|
|
when ${heartbeatRuns.resultJson} is null then null
|
|
else jsonb_strip_nulls(jsonb_build_object(
|
|
'conversationReset', ${heartbeatRuns.resultJson} -> 'conversationReset',
|
|
'configurationIncomplete', case
|
|
when ${heartbeatRuns.resultJson} -> 'configurationIncomplete' ->> 'reason' = 'workspace_base_ref_unresolved'
|
|
then jsonb_build_object('reason', 'workspace_base_ref_unresolved')
|
|
when ${heartbeatRuns.errorCode} = 'configuration_incomplete'
|
|
and (${heartbeatRuns.resultJson} #>> '{configurationIncomplete,selectionFailure}' = 'ai_connection_credential_not_shared'
|
|
or ${heartbeatRuns.error} = 'This credential is not shared with the responsible user')
|
|
then jsonb_build_object(
|
|
'selectionFailure', case when ${heartbeatRuns.resultJson} #>> '{configurationIncomplete,selectionFailure}' = 'ai_connection_credential_not_shared'
|
|
then 'ai_connection_credential_not_shared' end,
|
|
'credentialAccess', jsonb_build_object(
|
|
'connectionName', ${credentialAccessConnectionNameSql(viewerUserId)}
|
|
)
|
|
) end,
|
|
'workspaceRestoreFailure', case when ${heartbeatRuns.resultJson} ->> 'workspaceRestoreFailure'
|
|
in (${workspaceRestoreFailureCodesSql})
|
|
then ${heartbeatRuns.resultJson} -> 'workspaceRestoreFailure' end,
|
|
'workspaceRestorePath', case when length(${heartbeatRuns.resultJson} ->> 'workspaceRestorePath') <= 180
|
|
then ${heartbeatRuns.resultJson} -> 'workspaceRestorePath' end,
|
|
'finalResponseRecorded', case when jsonb_typeof(${heartbeatRuns.resultJson} -> 'finalResponseRecorded') = 'boolean'
|
|
then ${heartbeatRuns.resultJson} -> 'finalResponseRecorded' end,
|
|
'billingType', coalesce(${heartbeatRuns.resultJson} -> 'billingType', ${heartbeatRuns.resultJson} -> 'billing_type'),
|
|
'billing_type', coalesce(${heartbeatRuns.resultJson} -> 'billing_type', ${heartbeatRuns.resultJson} -> 'billingType'),
|
|
'costUsd', coalesce(
|
|
${heartbeatRuns.resultJson} -> 'costUsd',
|
|
${heartbeatRuns.resultJson} -> 'cost_usd',
|
|
${heartbeatRuns.resultJson} -> 'total_cost_usd'
|
|
),
|
|
'cost_usd', coalesce(
|
|
${heartbeatRuns.resultJson} -> 'cost_usd',
|
|
${heartbeatRuns.resultJson} -> 'costUsd',
|
|
${heartbeatRuns.resultJson} -> 'total_cost_usd'
|
|
),
|
|
'total_cost_usd', coalesce(
|
|
${heartbeatRuns.resultJson} -> 'total_cost_usd',
|
|
${heartbeatRuns.resultJson} -> 'cost_usd',
|
|
${heartbeatRuns.resultJson} -> 'costUsd'
|
|
),
|
|
'stopReason', ${heartbeatRuns.resultJson} -> 'stopReason',
|
|
'effectiveTimeoutSec', ${heartbeatRuns.resultJson} -> 'effectiveTimeoutSec',
|
|
'effectiveTimeoutMs', ${heartbeatRuns.resultJson} -> 'effectiveTimeoutMs',
|
|
'timeoutConfigured', ${heartbeatRuns.resultJson} -> 'timeoutConfigured',
|
|
'timeoutSource', ${heartbeatRuns.resultJson} -> 'timeoutSource',
|
|
'timeoutFired', ${heartbeatRuns.resultJson} -> 'timeoutFired'
|
|
))
|
|
end
|
|
`.as("resultJson");
|
|
|
|
function countValue(value: unknown) {
|
|
const parsed = Number(value ?? 0);
|
|
return Number.isFinite(parsed) ? Math.max(0, Math.floor(parsed)) : 0;
|
|
}
|
|
|
|
function dateValue(value: unknown) {
|
|
if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value;
|
|
if (typeof value === "string" || typeof value === "number") {
|
|
const parsed = new Date(value);
|
|
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function latestDate(...values: unknown[]) {
|
|
let latest: Date | null = null;
|
|
for (const value of values) {
|
|
const parsed = dateValue(value);
|
|
if (!parsed) continue;
|
|
if (!latest || parsed.getTime() > latest.getTime()) latest = parsed;
|
|
}
|
|
return latest;
|
|
}
|
|
|
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
return value as Record<string, unknown>;
|
|
}
|
|
|
|
function readNumber(value: unknown) {
|
|
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
}
|
|
|
|
async function backfillMissingRunLivenessForIssue(companyId: string, issueId: string) {
|
|
const runs = await db
|
|
.select({
|
|
id: heartbeatRuns.id,
|
|
companyId: heartbeatRuns.companyId,
|
|
status: heartbeatRuns.status,
|
|
contextSnapshot: heartbeatRuns.contextSnapshot,
|
|
resultJson: heartbeatRuns.resultJson,
|
|
stdoutExcerpt: heartbeatRuns.stdoutExcerpt,
|
|
stderrExcerpt: heartbeatRuns.stderrExcerpt,
|
|
error: heartbeatRuns.error,
|
|
errorCode: heartbeatRuns.errorCode,
|
|
continuationAttempt: heartbeatRuns.continuationAttempt,
|
|
})
|
|
.from(heartbeatRuns)
|
|
.where(
|
|
and(
|
|
eq(heartbeatRuns.companyId, companyId),
|
|
isNull(heartbeatRuns.livenessState),
|
|
sql`${heartbeatRuns.status} not in ('queued', 'running')`,
|
|
or(
|
|
sql`${heartbeatRuns.contextSnapshot} ->> 'issueId' = ${issueId}`,
|
|
sql`exists (
|
|
select 1
|
|
from ${activityLog}
|
|
where ${activityLog.companyId} = ${companyId}
|
|
and ${activityLog.entityType} = 'issue'
|
|
and ${activityLog.entityId} = ${issueId}
|
|
and ${activityLog.runId} = ${heartbeatRuns.id}
|
|
)`,
|
|
),
|
|
),
|
|
)
|
|
.limit(20);
|
|
|
|
if (runs.length === 0) return;
|
|
|
|
const issue = await db
|
|
.select({
|
|
status: issues.status,
|
|
title: issues.title,
|
|
description: issues.description,
|
|
workMode: issues.workMode,
|
|
})
|
|
.from(issues)
|
|
.where(and(eq(issues.companyId, companyId), eq(issues.id, issueId)))
|
|
.then((rows) => rows[0] ?? null);
|
|
|
|
for (const run of runs) {
|
|
const context = asRecord(run.contextSnapshot);
|
|
const continuationAttempt =
|
|
readNumber(context?.continuationAttempt) ??
|
|
readNumber(context?.livenessContinuationAttempt) ??
|
|
run.continuationAttempt ??
|
|
0;
|
|
|
|
const [commentStats] = await db
|
|
.select({
|
|
count: sql<number>`count(*)::int`,
|
|
latestAt: sql<Date | null>`max(${issueComments.createdAt})`,
|
|
})
|
|
.from(issueComments)
|
|
.where(
|
|
and(
|
|
eq(issueComments.companyId, companyId),
|
|
eq(issueComments.issueId, issueId),
|
|
eq(issueComments.createdByRunId, run.id),
|
|
),
|
|
);
|
|
|
|
const [documentStats] = await db
|
|
.select({
|
|
count: sql<number>`count(*)::int`,
|
|
planCount: sql<number>`count(*) filter (where ${issueDocuments.key} = 'plan')::int`,
|
|
latestAt: sql<Date | null>`max(${documentRevisions.createdAt})`,
|
|
})
|
|
.from(documentRevisions)
|
|
.innerJoin(issueDocuments, eq(documentRevisions.documentId, issueDocuments.documentId))
|
|
.where(
|
|
and(
|
|
eq(documentRevisions.companyId, companyId),
|
|
eq(documentRevisions.createdByRunId, run.id),
|
|
eq(issueDocuments.companyId, companyId),
|
|
eq(issueDocuments.issueId, issueId),
|
|
sql`${issueDocuments.key} != ${ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY}`,
|
|
),
|
|
);
|
|
|
|
const [workProductStats] = await db
|
|
.select({
|
|
count: sql<number>`count(*)::int`,
|
|
latestAt: sql<Date | null>`max(${issueWorkProducts.createdAt})`,
|
|
})
|
|
.from(issueWorkProducts)
|
|
.where(
|
|
and(
|
|
eq(issueWorkProducts.companyId, companyId),
|
|
eq(issueWorkProducts.issueId, issueId),
|
|
eq(issueWorkProducts.createdByRunId, run.id),
|
|
),
|
|
);
|
|
|
|
const [workspaceOperationStats] = await db
|
|
.select({
|
|
count: sql<number>`count(*)::int`,
|
|
latestAt: sql<Date | null>`max(${workspaceOperations.startedAt})`,
|
|
})
|
|
.from(workspaceOperations)
|
|
.where(and(eq(workspaceOperations.companyId, companyId), eq(workspaceOperations.heartbeatRunId, run.id)));
|
|
|
|
const [activityStats] = await db
|
|
.select({
|
|
count: sql<number>`count(*)::int`,
|
|
latestAt: sql<Date | null>`max(${activityLog.createdAt})`,
|
|
})
|
|
.from(activityLog)
|
|
.where(and(eq(activityLog.companyId, companyId), eq(activityLog.runId, run.id)));
|
|
|
|
const [eventStats] = await db
|
|
.select({
|
|
count: sql<number>`count(*) filter (where ${heartbeatRunEvents.eventType} not in ('lifecycle', 'adapter.invoke', 'error'))::int`,
|
|
latestAt: sql<Date | null>`max(${heartbeatRunEvents.createdAt}) filter (where ${heartbeatRunEvents.eventType} not in ('lifecycle', 'adapter.invoke', 'error'))`,
|
|
})
|
|
.from(heartbeatRunEvents)
|
|
.where(and(eq(heartbeatRunEvents.companyId, companyId), eq(heartbeatRunEvents.runId, run.id)));
|
|
|
|
const classification = classifyRunLiveness({
|
|
runStatus: run.status,
|
|
issue,
|
|
resultJson: asRecord(run.resultJson),
|
|
stdoutExcerpt: run.stdoutExcerpt,
|
|
stderrExcerpt: run.stderrExcerpt,
|
|
error: run.error,
|
|
errorCode: run.errorCode,
|
|
continuationAttempt,
|
|
evidence: {
|
|
issueCommentsCreated: countValue(commentStats?.count),
|
|
documentRevisionsCreated: countValue(documentStats?.count),
|
|
planDocumentRevisionsCreated: countValue(documentStats?.planCount),
|
|
workProductsCreated: countValue(workProductStats?.count),
|
|
workspaceOperationsCreated: countValue(workspaceOperationStats?.count),
|
|
activityEventsCreated: countValue(activityStats?.count),
|
|
toolOrActionEventsCreated: countValue(eventStats?.count),
|
|
latestEvidenceAt: latestDate(
|
|
commentStats?.latestAt,
|
|
documentStats?.latestAt,
|
|
workProductStats?.latestAt,
|
|
workspaceOperationStats?.latestAt,
|
|
activityStats?.latestAt,
|
|
eventStats?.latestAt,
|
|
),
|
|
},
|
|
});
|
|
|
|
await db
|
|
.update(heartbeatRuns)
|
|
.set({
|
|
livenessState: classification.livenessState,
|
|
livenessReason: classification.livenessReason,
|
|
continuationAttempt: classification.continuationAttempt,
|
|
lastUsefulActionAt: classification.lastUsefulActionAt,
|
|
nextAction: classification.nextAction,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(and(eq(heartbeatRuns.id, run.id), isNull(heartbeatRuns.livenessState)));
|
|
}
|
|
}
|
|
|
|
function scheduleRunLivenessBackfill(companyId: string, issueId: string) {
|
|
const key = `${companyId}:${issueId}`;
|
|
if (scheduledLivenessBackfills.has(key)) return;
|
|
scheduledLivenessBackfills.add(key);
|
|
void backfillMissingRunLivenessForIssue(companyId, issueId)
|
|
.catch((err: unknown) => {
|
|
logger.warn({ err, companyId, issueId }, "run liveness backfill failed");
|
|
})
|
|
.finally(() => {
|
|
scheduledLivenessBackfills.delete(key);
|
|
});
|
|
}
|
|
|
|
return {
|
|
list: async (filters: ActivityFilters) => {
|
|
const conditions = [eq(activityLog.companyId, filters.companyId), await skillAccessService(db).activityCondition(filters.companyId),
|
|
filters.caseReadCondition ?? sql<boolean>`${activityLog.entityType} <> 'case'`];
|
|
const limit = normalizeActivityLimit(filters.limit);
|
|
|
|
if (filters.agentId) {
|
|
conditions.push(eq(activityLog.agentId, filters.agentId));
|
|
}
|
|
if (filters.entityType) {
|
|
conditions.push(eq(activityLog.entityType, filters.entityType));
|
|
}
|
|
if (filters.entityId) {
|
|
conditions.push(eq(activityLog.entityId, filters.entityId));
|
|
}
|
|
|
|
return db
|
|
.select({ activityLog })
|
|
.from(activityLog)
|
|
.leftJoin(
|
|
issues,
|
|
and(
|
|
eq(activityLog.entityType, sql`'issue'`),
|
|
eq(activityLog.entityId, issueIdAsText),
|
|
),
|
|
)
|
|
.where(
|
|
and(
|
|
...conditions,
|
|
or(
|
|
sql`${activityLog.entityType} != 'issue'`,
|
|
and(visibleIssueCondition(), filters.readCondition ?? sql<boolean>`true`),
|
|
),
|
|
),
|
|
)
|
|
.orderBy(desc(activityLog.createdAt))
|
|
.limit(limit)
|
|
.then((rows) => rows.map((r) => r.activityLog));
|
|
},
|
|
|
|
inputFactsForIssue: async (companyId: string, issueId: string, commentIds: string[]) => {
|
|
if (!commentIds.length) return [];
|
|
const commentId = sql<string>`${activityLog.details}->>'commentId'`;
|
|
return db.selectDistinctOn([commentId, activityLog.action])
|
|
.from(activityLog)
|
|
.where(and(
|
|
eq(activityLog.companyId, companyId),
|
|
eq(activityLog.entityType, "issue"),
|
|
eq(activityLog.entityId, issueId),
|
|
inArray(commentId, commentIds),
|
|
inArray(activityLog.action, ["issue.queued_comment_steered", "issue.comment_added"]),
|
|
sql`${activityLog.details}->>'duplicate' is distinct from 'true'`,
|
|
))
|
|
.orderBy(commentId, activityLog.action, asc(activityLog.createdAt), asc(activityLog.id));
|
|
},
|
|
|
|
forIssue: (issueId: string, limit?: number) => {
|
|
const query = db
|
|
.select()
|
|
.from(activityLog)
|
|
.where(
|
|
or(
|
|
and(eq(activityLog.entityType, "issue"), eq(activityLog.entityId, issueId)),
|
|
and(or(eq(activityLog.action, "project.created"), eq(activityLog.action, "company.skill_created")), sql`${activityLog.details}->>'sourceIssueId' = ${issueId}`,
|
|
sql`${activityLog.companyId} = (select company_id from issues where id = ${issueId})`),
|
|
),
|
|
)
|
|
.orderBy(desc(activityLog.createdAt), desc(activityLog.id));
|
|
return limit === undefined ? query : query.limit(limit);
|
|
},
|
|
|
|
runsForIssue: async (companyId: string, issueId: string, viewerUserId: string | null = null, limit?: number) => {
|
|
scheduleRunLivenessBackfill(companyId, issueId);
|
|
const runsQuery = db
|
|
.select({
|
|
runId: heartbeatRuns.id,
|
|
runtimeMode: heartbeatRuns.runtimeMode,
|
|
wakeupRequestId: heartbeatRuns.wakeupRequestId,
|
|
status: heartbeatRuns.status,
|
|
agentId: heartbeatRuns.agentId,
|
|
adapterType: agents.adapterType,
|
|
startedAt: heartbeatRuns.startedAt,
|
|
finishedAt: heartbeatRuns.finishedAt,
|
|
createdAt: heartbeatRuns.createdAt,
|
|
invocationSource: heartbeatRuns.invocationSource,
|
|
responsibleUserId: heartbeatRuns.responsibleUserId,
|
|
errorCode: heartbeatRuns.errorCode,
|
|
error: sql<string | null>`case when ${heartbeatRuns.status} = 'failed'
|
|
and ${heartbeatRuns.errorCode} = 'native_provider_model_rejected'
|
|
then left(${heartbeatRuns.error}, 2000)
|
|
when ${heartbeatRuns.status} = 'failed'
|
|
and ${heartbeatRuns.error} = 'This credential is not shared with the responsible user'
|
|
then 'This credential is not shared with the responsible user' else null end`,
|
|
usageJson: summarizedUsageJson,
|
|
resultJson: summarizedResultJson(viewerUserId),
|
|
logBytes: heartbeatRuns.logBytes,
|
|
retryOfRunId: heartbeatRuns.retryOfRunId,
|
|
scheduledRetryAt: heartbeatRuns.scheduledRetryAt,
|
|
scheduledRetryAttempt: heartbeatRuns.scheduledRetryAttempt,
|
|
scheduledRetryReason: heartbeatRuns.scheduledRetryReason,
|
|
livenessState: heartbeatRuns.livenessState,
|
|
livenessReason: heartbeatRuns.livenessReason,
|
|
continuationAttempt: heartbeatRuns.continuationAttempt,
|
|
lastUsefulActionAt: heartbeatRuns.lastUsefulActionAt,
|
|
nextAction: heartbeatRuns.nextAction,
|
|
wakeCommentIds: sql<string[] | null>`${heartbeatRuns.contextSnapshot} -> 'wakeCommentIds'`,
|
|
queuedCommentSourceWakeIds: sql<string[] | null>`${heartbeatRuns.contextSnapshot} -> 'queuedCommentSourceWakeIds'`,
|
|
wakeCommentId: sql<string | null>`${heartbeatRuns.contextSnapshot} ->> 'wakeCommentId'`,
|
|
contextCommentId: sql<string | null>`${heartbeatRuns.contextSnapshot} ->> 'commentId'`,
|
|
contextIssueId: sql<string | null>`${heartbeatRuns.contextSnapshot} ->> 'issueId'`,
|
|
})
|
|
.from(heartbeatRuns)
|
|
.innerJoin(
|
|
agents,
|
|
and(
|
|
eq(agents.id, heartbeatRuns.agentId),
|
|
eq(agents.companyId, heartbeatRuns.companyId),
|
|
),
|
|
)
|
|
.where(
|
|
and(
|
|
eq(heartbeatRuns.companyId, companyId),
|
|
or(
|
|
eq(heartbeatRuns.issueId, issueId),
|
|
sql`exists (
|
|
select 1
|
|
from ${activityLog}
|
|
where ${activityLog.companyId} = ${companyId}
|
|
and ${activityLog.entityType} = 'issue'
|
|
and ${activityLog.entityId} = ${issueId}
|
|
and ${activityLog.runId} = ${heartbeatRuns.id}
|
|
)`,
|
|
),
|
|
),
|
|
)
|
|
.orderBy(desc(heartbeatRuns.createdAt), desc(heartbeatRuns.id));
|
|
const runs = await (limit === undefined ? runsQuery : runsQuery.limit(limit));
|
|
|
|
if (runs.length === 0) return runs;
|
|
const runIds = runs.map((run) => run.runId);
|
|
if (runIds.length === 0) return runs;
|
|
|
|
const exhaustionRowsQuery = db
|
|
.select({
|
|
runId: heartbeatRunEvents.runId,
|
|
message: heartbeatRunEvents.message,
|
|
})
|
|
.from(heartbeatRunEvents)
|
|
.where(
|
|
and(
|
|
inArray(heartbeatRunEvents.runId, runIds),
|
|
eq(heartbeatRunEvents.eventType, "lifecycle"),
|
|
sql`${heartbeatRunEvents.message} like 'Bounded retry exhausted%'`,
|
|
),
|
|
)
|
|
.orderBy(asc(heartbeatRunEvents.runId), desc(heartbeatRunEvents.id));
|
|
|
|
const leaseRowsQuery = db
|
|
.select({
|
|
lease: environmentLeases,
|
|
environment: {
|
|
id: environments.id,
|
|
name: environments.name,
|
|
driver: environments.driver,
|
|
},
|
|
})
|
|
.from(environmentLeases)
|
|
.innerJoin(environments, eq(environmentLeases.environmentId, environments.id))
|
|
.where(
|
|
and(
|
|
eq(environmentLeases.companyId, companyId),
|
|
inArray(environmentLeases.heartbeatRunId, runIds),
|
|
),
|
|
)
|
|
.orderBy(desc(environmentLeases.lastUsedAt), desc(environmentLeases.createdAt));
|
|
|
|
// Only stored, current plan revisions can support a saved-plan link.
|
|
// Do not trust an adapter's claim that it wrote a document.
|
|
const savedPlanQuery = runs.some((run) => hasWorkspaceRestoreFailure(run.resultJson))
|
|
? db.select({ revisionId: documentRevisions.id, runId: documentRevisions.createdByRunId })
|
|
.from(issueDocuments)
|
|
.innerJoin(documents, and(eq(documents.id, issueDocuments.documentId), eq(documents.companyId, companyId)))
|
|
.innerJoin(documentRevisions, and(eq(documentRevisions.id, documents.latestRevisionId), eq(documentRevisions.documentId, documents.id), eq(documentRevisions.companyId, companyId)))
|
|
.where(and(eq(issueDocuments.companyId, companyId), eq(issueDocuments.issueId, issueId), eq(issueDocuments.key, "plan")))
|
|
.limit(1)
|
|
: Promise.resolve([]);
|
|
const [exhaustionRows, leaseRows, executionByRunId, [savedPlan]] = await Promise.all([
|
|
exhaustionRowsQuery,
|
|
leaseRowsQuery,
|
|
executionProjectionsForRuns(db, companyId, runIds, new Date(), { retryDatabaseReads: true }),
|
|
savedPlanQuery,
|
|
]);
|
|
const retryExhaustedReasonByRunId = new Map<string, string>();
|
|
for (const row of exhaustionRows) {
|
|
if (!row.message || retryExhaustedReasonByRunId.has(row.runId)) continue;
|
|
retryExhaustedReasonByRunId.set(row.runId, row.message);
|
|
}
|
|
|
|
const leaseByRunId = new Map<string, (typeof leaseRows)[number]>();
|
|
for (const row of leaseRows) {
|
|
if (row.lease.heartbeatRunId && !leaseByRunId.has(row.lease.heartbeatRunId)) {
|
|
leaseByRunId.set(row.lease.heartbeatRunId, row);
|
|
}
|
|
}
|
|
|
|
return runs.map((run) => {
|
|
const leaseRow = leaseByRunId.get(run.runId);
|
|
const leaseMetadata = leaseRow?.lease.metadata ?? null;
|
|
const workspacePath =
|
|
typeof leaseMetadata?.remoteCwd === "string" && leaseMetadata.remoteCwd.trim().length > 0
|
|
? leaseMetadata.remoteCwd
|
|
: typeof leaseMetadata?.remoteWorkspacePath === "string" && leaseMetadata.remoteWorkspacePath.trim().length > 0
|
|
? leaseMetadata.remoteWorkspacePath
|
|
: null;
|
|
return {
|
|
...run,
|
|
resultJson: run.resultJson ? {
|
|
...run.resultJson,
|
|
...(Object.hasOwn(run.resultJson, "workspaceRestorePath") ? {
|
|
workspaceRestorePath: safeWorkspaceRestorePath(run.resultJson.workspaceRestorePath),
|
|
} : {}),
|
|
...(hasWorkspaceRestoreFailure(run.resultJson) ? {
|
|
...(savedPlan?.runId === run.runId ? { savedPlanRevisionId: savedPlan.revisionId } : {}),
|
|
} : {}),
|
|
} : null,
|
|
execution: executionByRunId.get(run.runId) ?? null,
|
|
environment: leaseRow
|
|
? {
|
|
id: leaseRow.environment.id,
|
|
name: leaseRow.environment.name,
|
|
driver: leaseRow.environment.driver,
|
|
}
|
|
: null,
|
|
environmentLease: leaseRow
|
|
? {
|
|
id: leaseRow.lease.id,
|
|
status: leaseRow.lease.status,
|
|
leasePolicy: leaseRow.lease.leasePolicy,
|
|
provider: leaseRow.lease.provider,
|
|
providerLeaseId: leaseRow.lease.providerLeaseId,
|
|
executionWorkspaceId: leaseRow.lease.executionWorkspaceId,
|
|
workspacePath,
|
|
failureReason: leaseRow.lease.failureReason,
|
|
cleanupStatus: leaseRow.lease.cleanupStatus,
|
|
acquiredAt: leaseRow.lease.acquiredAt,
|
|
releasedAt: leaseRow.lease.releasedAt,
|
|
}
|
|
: null,
|
|
retryExhaustedReason: retryExhaustedReasonByRunId.get(run.runId) ?? null,
|
|
};
|
|
});
|
|
},
|
|
|
|
issuesForRun: async (runId: string, readCondition: SQL<boolean>) => {
|
|
const run = await db
|
|
.select({
|
|
companyId: heartbeatRuns.companyId,
|
|
issueId: heartbeatRuns.issueId,
|
|
})
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.id, runId))
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!run) return [];
|
|
|
|
const fromActivity = await db
|
|
.selectDistinctOn([issueIdAsText], {
|
|
issueId: issues.id,
|
|
identifier: issues.identifier,
|
|
title: issues.title,
|
|
status: issues.status,
|
|
priority: issues.priority,
|
|
})
|
|
.from(activityLog)
|
|
.innerJoin(issues, eq(activityLog.entityId, issueIdAsText))
|
|
.where(
|
|
and(
|
|
eq(activityLog.companyId, run.companyId),
|
|
eq(activityLog.runId, runId),
|
|
eq(activityLog.entityType, "issue"),
|
|
eq(issues.companyId, run.companyId),
|
|
visibleIssueCondition(),
|
|
readCondition,
|
|
),
|
|
)
|
|
.orderBy(issueIdAsText);
|
|
|
|
const contextIssueId = run.issueId;
|
|
if (!contextIssueId) return fromActivity;
|
|
if (fromActivity.some((issue) => issue.issueId === contextIssueId)) return fromActivity;
|
|
|
|
const fromContext = await db
|
|
.select({
|
|
issueId: issues.id,
|
|
identifier: issues.identifier,
|
|
title: issues.title,
|
|
status: issues.status,
|
|
priority: issues.priority,
|
|
})
|
|
.from(issues)
|
|
.where(
|
|
and(
|
|
eq(issues.companyId, run.companyId),
|
|
eq(issues.id, contextIssueId),
|
|
visibleIssueCondition(),
|
|
readCondition,
|
|
),
|
|
)
|
|
.then((rows) => rows[0] ?? null);
|
|
|
|
if (!fromContext) return fromActivity;
|
|
return [fromContext, ...fromActivity];
|
|
},
|
|
|
|
create: (data: typeof activityLog.$inferInsert) => db.transaction(async tx => {
|
|
if (data.entityType === "issue") await notifyDeliveryWork(tx, DELIVERY_QUEUES.mcpEvents, `task:${data.entityId}`);
|
|
const [row] = await tx.insert(activityLog).values(data).returning();
|
|
return row;
|
|
}),
|
|
};
|
|
}
|