Merge origin/master into custom image terminal expiry branch

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Devin FoleyandPaperclip committed 2026-07-02 20:18:43 -07:00
commit 99c305d7ae
102 files changed
+8663 -494

No files matched your search

+379
View File
@@ -0,0 +1,379 @@
---
name: check-pr
description: >
Checks a GitHub, GitLab, or Perforce (p4) pull request (or merge request, or shelved changelist)
for unresolved review comments, failing status checks, and incomplete PR descriptions. Waits for
pending checks to complete, categorizes issues as actionable or informational, and optionally fixes
and resolves them. Use when the user wants to check a PR/MR/CL, address review feedback, or prepare
a change for submission.
license: MIT
compatibility: Requires git and gh (GitHub CLI), glab (GitLab CLI), or p4 (Perforce CLI) installed and authenticated.
metadata:
author: greptileai
version: "1.3"
allowed-tools: Bash(gh:*) Bash(glab:*) Bash(git:*) Bash(p4:*)
---
# Check PR
Analyze a pull request (GitHub), merge request (GitLab), or shelved changelist (Perforce) for review comments, status checks, and description completeness, then help address any issues found.
## Inputs
- **PR/MR/CL number** (optional): If not provided, detect the PR/MR for the current branch, or the default pending changelist for p4.
## Instructions
### 0. Detect platform
First check if the user is working in a Perforce depot by looking for a `.p4config` file or `P4CLIENT`/`P4PORT` environment variables:
```bash
# Check for Perforce environment
if p4 info >/dev/null 2>&1; then
VCS="perforce"
else
# Fall back to git remote detection
REMOTE_URL=$(git remote get-url origin)
if echo "$REMOTE_URL" | grep -qi "gitlab"; then
VCS="gitlab"
else
VCS="github"
fi
fi
```
For self-hosted GitLab instances whose hostname doesn't contain "gitlab", the user can override by passing `--vcs gitlab` as an input. For Perforce, the user can override by passing `--vcs perforce`.
### 1. Identify the PR/MR/CL
If a number was provided, use it. Otherwise, detect it:
**GitHub:**
```bash
gh pr view --json number,headRefName,headRefOid -q '{number: .number, branch: .headRefName, head: .headRefOid}'
```
**GitLab:**
```bash
glab mr view --output json | jq '.iid'
```
**Perforce:**
```bash
# List pending changelists for the current user/client
p4 changes -s pending -u $P4USER -c $P4CLIENT
```
Key field differences between platforms:
- GitHub: `number`, `headRefName`, `headRefOid`
- GitLab: `iid`, `source_branch`, `sha`
- Perforce: changelist number (CL), `shelved` files for in-review CLs
### 2. Fetch PR/MR/CL details
**GitHub:**
```bash
gh pr view <PR_NUMBER> --json title,body,state,reviews,comments,headRefName,headRefOid,statusCheckRollup
OWNER_REPO=$(gh repo view --json nameWithOwner -q .nameWithOwner)
gh api "repos/$OWNER_REPO/pulls/<PR_NUMBER>/comments"
gh api --paginate "repos/$OWNER_REPO/issues/<PR_NUMBER>/comments?per_page=100"
```
GitHub PRs are also issues, so general PR comments live on the issue comments endpoint. Greptile may edit a single general PR comment on each review cycle instead of creating a new review or comment. Always inspect the latest Greptile-authored general comment by `updated_at`, including any "Prompt to fix all with AI" section, before concluding that the PR is clear.
**GitLab:**
```bash
glab mr view <MR_IID> --output json
# Fetch discussions (inline diff comments are type "DiffNote"; general comments have null type)
glab api "projects/:fullpath/merge_requests/<MR_IID>/discussions"
```
For GitLab, paginate discussions if needed (add `?per_page=100&page=N`).
**Perforce:**
```bash
# Get changelist description, files, and status
p4 describe -s <CL_NUMBER>
# Get shelved files (for in-review CLs)
p4 describe -S <CL_NUMBER>
# Get the diff of the shelved changelist
p4 diff2 //...@=<CL_NUMBER> //...@=<CL_NUMBER>
# List review comments (if using p4 review workflow)
p4 review -c <CL_NUMBER>
```
Key Perforce CL fields:
- `Change`: changelist number
- `Status`: `pending`, `submitted`, `shelved`
- `Description`: the CL description / commit message
- `Files`: list of files in the CL
### 3. Wait for pending checks
Before analyzing, ensure all status checks have completed. If any checks are `PENDING` or `IN_PROGRESS` (GitHub) / `running` or `pending` (GitLab), poll every 30 seconds until all checks reach a terminal state.
**GitHub:** poll `statusCheckRollup` from `gh pr view`.
**GitLab:**
```bash
glab api "projects/:fullpath/merge_requests/<MR_IID>/pipelines"
```
Pipeline statuses: `running`, `pending`, `success`, `failed`, `canceled`, `skipped`. Poll until no pipeline has `running` or `pending` status.
**Perforce:** Perforce doesn't have built-in CI checks natively. If the team uses a review tool (Swarm, etc.) or an external CI triggered by shelve events, check the relevant system. Otherwise, proceed to analysis immediately.
### 4. Require a fresh Greptile review for the current head
For GitHub PRs, do not treat an existing Greptile review, comment, or summary as current unless it is tied to the PR's exact current `headRefOid`. This is especially important after pushing a new commit to an existing PR: a Greptile review on an older commit is stale, even if the PR still has a Greptile comment or prior review.
Fetch the current head SHA immediately before the Greptile gate:
```bash
HEAD_SHA=$(gh pr view <PR_NUMBER> --json headRefOid -q .headRefOid)
```
Then inspect check-runs for that commit and require a completed Greptile run:
```bash
OWNER_REPO=$(gh repo view --json nameWithOwner -q .nameWithOwner)
GREPTILE_CHECKS=$(gh api "repos/$OWNER_REPO/commits/$HEAD_SHA/check-runs?per_page=100" \
--jq '[.check_runs[] | select(.name | test("greptile"; "i"))]')
# A run only counts as a valid fresh pass when it has completed AND concluded cleanly.
# GitHub check-run conclusions: success, neutral, skipped, failure, timed_out,
# cancelled, action_required, stale. Treat success/neutral as clean;
# everything else (especially failure and action_required) must block.
FRESH_GREPTILE_COMPLETED=$(echo "$GREPTILE_CHECKS" \
| jq '[.[] | select(.status == "completed")] | length')
FRESH_GREPTILE_CLEAN=$(echo "$GREPTILE_CHECKS" \
| jq '[.[] | select(.status == "completed" and (.conclusion | IN("success","neutral")))] | length')
FRESH_GREPTILE_BLOCKING=$(echo "$GREPTILE_CHECKS" \
| jq '[.[] | select(.status == "completed" and ((.conclusion | IN("success","neutral")) | not))] | length')
if [ "$FRESH_GREPTILE_COMPLETED" = "0" ]; then
echo "Blocked: no completed Greptile review/check is tied to current PR head $HEAD_SHA."
echo "Request a fresh Greptile review against this head before marking the PR check done."
echo "Suggested trigger: gh pr comment <PR_NUMBER> --body \"@greptile review\""
exit 1
fi
if [ "$FRESH_GREPTILE_BLOCKING" != "0" ] || [ "$FRESH_GREPTILE_CLEAN" = "0" ]; then
echo "Blocked: Greptile completed on head $HEAD_SHA but did not conclude clean"
echo "(conclusion was failure/action_required/timed_out/cancelled/stale, or no clean run exists)."
echo "Address the findings, push, and re-run Greptile until it concludes success/clean on the new head."
exit 1
fi
```
If a Greptile check exists for the current head but is still pending or in progress, wait for it with the same polling pattern used by `greploop` rather than proceeding from older review material. If no Greptile check appears for the current head after a reasonable wait, report the PR as blocked on a fresh Greptile review for `HEAD_SHA` and stop. Do not mark the check complete from PR comments, PR reviews, or Greptile summaries that cannot be associated with the current head SHA.
For GitLab installations with Greptile integration, apply the same freshness rule against the MR's current head SHA:
```bash
# 1. Get the MR's current head SHA
MR_SHA=$(glab mr view <MR_IID> --output json | jq -r '.sha // .diff_refs.head_sha')
# 2. Find the latest pipeline for that EXACT sha, then the Greptile job within it.
LATEST_PIPELINE_ID=$(glab api "projects/:fullpath/merge_requests/<MR_IID>/pipelines" \
| jq -r --arg sha "$MR_SHA" '[.[] | select(.sha == $sha)] | sort_by(.id) | last | .id // empty')
if [ -n "$LATEST_PIPELINE_ID" ]; then
GREPTILE_JOBS=$(glab api "projects/:fullpath/pipelines/$LATEST_PIPELINE_ID/jobs" \
| jq --arg sha "$MR_SHA" '[.[] | select(.name | test("greptile"; "i"))
| {name, status, pipeline_sha: $sha}]')
else
GREPTILE_JOBS='[]'
fi
GREPTILE_JOB_SUCCESS=$(echo "$GREPTILE_JOBS" \
| jq '[.[] | select(.status == "success")] | length')
GREPTILE_JOB_BLOCKING=$(echo "$GREPTILE_JOBS" \
| jq '[.[] | select(.status != "success")] | length')
# 3. If Greptile integrates via MR notes instead of a CI job, require the newest
# Greptile note to reference the current head sha and report a clean review.
GREPTILE_NOTES=$(glab api "projects/:fullpath/merge_requests/<MR_IID>/discussions?per_page=100" \
| jq --arg sha "$MR_SHA" '[.[].notes[]
| select(.author.username | test("greptile"; "i"))]
| sort_by(.updated_at // .created_at)')
GREPTILE_NOTE_CLEAN=$(echo "$GREPTILE_NOTES" \
| jq --arg sha "$MR_SHA" 'if length == 0 then 0
elif ((last.body // "") | contains($sha) and test("Confidence Score:[[:space:]]*5/5|Confidence:[[:space:]]*5/5|\\b5/5\\b"; "i") and (test("Prompt To Fix|blocking issue|failed|action required"; "i") | not)) then 1
else 0 end')
if [ "$GREPTILE_JOB_SUCCESS" = "0" ] && [ "$GREPTILE_NOTE_CLEAN" = "0" ]; then
echo "Blocked: no successful Greptile job or completed-clean current-head Greptile note is tied to MR head $MR_SHA."
exit 1
fi
if [ "$GREPTILE_JOB_BLOCKING" != "0" ]; then
echo "Blocked: at least one Greptile job for MR head $MR_SHA did not succeed."
exit 1
fi
```
Block completion if, for `MR_SHA`, there is (a) no Greptile job or note at all (missing), (b) the newest Greptile job/note is tied to a different sha (stale), or (c) the Greptile job status is not `success`. A completed Greptile result for a different SHA is stale and must block completion.
For Perforce installations with Greptile integration, apply the same rule using the CL's current shelved-revision identity and the Greptile webhook/review artifact tied to it; a Greptile result for an earlier shelf is stale and must block completion.
### 5. Analyze the PR/MR
Once all checks are complete, evaluate these areas:
#### A. Status Checks
- Are all CI checks passing?
- If any are failing, identify which ones and the failure reason.
#### B. PR/MR Description
- Is the description complete and follows team conventions?
- Are all required sections filled in?
- Are there TODOs or placeholders that need updating?
#### C. Review Comments
- Inline code review comments that need addressing
- Look for bot review comments (e.g. from `greptile-apps[bot]` on GitHub, or the Greptile bot user on GitLab, linters, etc.)
- Human reviewer comments
- **Perforce:** review comments from `p4 review` or external review tools
#### D. General Comments
- Discussion comments on the PR/MR
- For GitHub, check the issue comments endpoint and use `updated_at` to catch bot comments edited in place. Greptile's latest edited summary can contain actionable items even when there are no new inline comments.
- Bot comments (deploy previews, etc.) - usually informational
- **Perforce:** CL description should include a clear summary, affected files rationale, and testing notes
### 6. Categorize issues
For each issue found, categorize as:
| Category | Meaning |
|---|---|
| **Actionable** | Code changes, test improvements, or fixes needed |
| **Informational** | Verification notes, questions, or FYIs that don't require changes |
| **Already addressed** | Issues that appear to be resolved by subsequent commits |
### 7. Report findings
Present a summary table:
| Area | Issue | Status | Action Needed |
|------|-------|--------|---------------|
| Status Checks | CI build failing | Failing | Fix type error in `src/api.ts` |
| Review | "Add null check" - @reviewer | Actionable | Add guard clause |
| Description | TODO placeholder in test plan | Actionable | Fill in test plan |
| Review | "Looks good" - @teammate | Informational | None |
### 8. Fix issues (if requested)
If there are actionable items:
1. Switch to the PR/MR's branch (git) or ensure files are open in the correct CL (Perforce) if not already.
2. Ask the user if they want to fix the issues.
3. If yes, make the fixes, then:
**GitHub/GitLab:** commit and push:
```bash
git add <files>
git commit -m "address review feedback"
git push
```
**Perforce:** open files for edit, make changes, and re-shelve:
```bash
p4 edit <file>
# make changes
p4 shelve -f -c <CL_NUMBER>
```
### 9. Resolve review threads
After addressing comments, resolve the corresponding review threads.
**Perforce** - Perforce does not have a native "resolve thread" concept. Instead, mark comments as addressed by updating the CL description or by responding in the review tool being used (Swarm, etc.). If using `p4 review`:
```bash
# Mark files as reviewed after addressing feedback
p4 review -c <CL_NUMBER>
```
**GitHub** - fetch unresolved thread IDs (paginate if needed - see [the GraphQL reference](references/graphql-queries.md)):
```bash
gh api graphql -f query='
query($cursor: String) {
repository(owner: "OWNER", name: "REPO") {
pullRequest(number: PR_NUMBER) {
reviewThreads(first: 100, after: $cursor) {
pageInfo { hasNextPage endCursor }
nodes {
id
isResolved
comments(first: 1) {
nodes { body path }
}
}
}
}
}
}'
```
If `hasNextPage` is true, repeat with `-f cursor=ENDCURSOR` to get remaining threads.
Then resolve threads that have been addressed or are informational:
```bash
gh api graphql -f query='
mutation {
resolveReviewThread(input: {threadId: "THREAD_ID"}) {
thread { isResolved }
}
}'
```
Batch multiple resolutions into a single mutation using aliases (`t1`, `t2`, etc.).
**GitLab** - fetch unresolved discussions (see [the GitLab API reference](references/gitlab-api.md)):
```bash
glab api "projects/:fullpath/merge_requests/<MR_IID>/discussions?per_page=100"
```
Filter for discussions where `"resolved": false`. Collect each discussion's `id`.
Resolve each discussion individually (GitLab has no batch resolution):
```bash
glab api --method PUT \
"projects/:fullpath/merge_requests/<MR_IID>/discussions/<DISCUSSION_ID>" \
--field resolved=true
```
Repeat for each unresolved discussion ID.
### 10. Multiple PRs/MRs/CLs
If checking a chain of PRs/MRs/CLs, process them sequentially.
**Perforce** - to check multiple changelists at once:
```bash
p4 changes -s pending -u $P4USER -c $P4CLIENT -l
```
## Output format
Summarize:
- PR/MR/CL title or description and current state
- Platform detected (GitHub / GitLab / Perforce)
- Status checks summary (passing/failing/pending) - or N/A for Perforce
- Total issues found
- Actionable items with descriptions
- Items that can be ignored with reasons
- Recommended next steps
@@ -0,0 +1,91 @@
# GitLab API Reference
Useful GitLab REST API calls for working with merge request discussions, using `glab api`.
`glab api` automatically resolves `:fullpath` to the URL-encoded project path from the local git remote.
## Fetch MR details
```bash
glab mr view <MR_IID> --output json
```
Key fields (compared to GitHub equivalents):
- `iid` - internal MR number (use this, not `id`)
- `source_branch` - equivalent to GitHub's `headRefName`
- `sha` - HEAD commit SHA, equivalent to GitHub's `headRefOid`
- `description` - equivalent to GitHub's `body`
## Fetch all discussions (inline + general comments)
```bash
glab api "projects/:fullpath/merge_requests/<MR_IID>/discussions?per_page=100"
```
Paginate with `&page=2`, `&page=3`, etc. until the response array length is less than `per_page`.
Each discussion object:
- `id` - discussion ID (used for resolution)
- `resolved` - `true` or `false`
- `notes` - array of note objects
Each note object:
- `type` - `"DiffNote"` for inline diff comments, `null` for general comments
- `author.username` - author's username
- `body` - comment text
- `position.new_path` - file path (for `DiffNote` type)
## Filter for unresolved inline diff comments
```bash
glab api "projects/:fullpath/merge_requests/<MR_IID>/discussions?per_page=100" | \
jq '[.[] | select(.resolved == false and (.notes[0].type == "DiffNote"))]'
```
## Resolve a single discussion
```bash
glab api --method PUT \
"projects/:fullpath/merge_requests/<MR_IID>/discussions/<DISCUSSION_ID>" \
--field resolved=true
```
There is no batch resolution in GitLab - issue one PUT per discussion.
## Fetch pipeline status for an MR
```bash
glab api "projects/:fullpath/merge_requests/<MR_IID>/pipelines"
```
Pipeline statuses: `running`, `pending`, `success`, `failed`, `canceled`, `skipped`.
## Fetch jobs for a specific pipeline
```bash
glab api "projects/:fullpath/pipelines/<PIPELINE_ID>/jobs"
```
Each job has `name`, `status`, `stage`, and `web_url`.
## Fetch MR notes (general comments and bot reviews)
```bash
glab api "projects/:fullpath/merge_requests/<MR_IID>/notes?per_page=100"
```
Filter by `author.username` to find Greptile bot comments. The exact bot username depends on the Greptile installation - check the first Greptile comment to identify it.
## Post a comment on an MR
```bash
glab mr note <MR_IID> --message "your message here"
```
Or via API:
```bash
glab api --method POST \
"projects/:fullpath/merge_requests/<MR_IID>/notes" \
--field body="your message here"
```
@@ -0,0 +1,89 @@
# GraphQL Queries Reference
Useful GitHub GraphQL queries for working with PR review threads.
## Fetch unresolved review threads (with pagination)
```graphql
query($cursor: String) {
repository(owner: "OWNER", name: "REPO") {
pullRequest(number: PR_NUMBER) {
reviewThreads(first: 100, after: $cursor) {
pageInfo {
hasNextPage
endCursor
}
nodes {
id
isResolved
comments(first: 3) {
nodes {
body
path
author { login }
createdAt
}
}
}
}
}
}
}
```
Pass `-f cursor=ENDCURSOR` on subsequent requests if `hasNextPage` is `true`.
## Resolve a single review thread
```graphql
mutation {
resolveReviewThread(input: {threadId: "THREAD_ID"}) {
thread { isResolved }
}
}
```
## Batch-resolve multiple threads
Use GraphQL aliases to resolve several threads in one request:
```graphql
mutation {
t1: resolveReviewThread(input: {threadId: "THREAD_ID_1"}) {
thread { isResolved }
}
t2: resolveReviewThread(input: {threadId: "THREAD_ID_2"}) {
thread { isResolved }
}
t3: resolveReviewThread(input: {threadId: "THREAD_ID_3"}) {
thread { isResolved }
}
}
```
## Fetch PR details (REST)
```bash
gh pr view <PR_NUMBER> --json title,body,state,reviews,comments,headRefName,statusCheckRollup
```
## Fetch inline review comments (REST)
```bash
OWNER_REPO=$(gh repo view --json nameWithOwner -q .nameWithOwner)
gh api "repos/$OWNER_REPO/pulls/<PR_NUMBER>/comments"
```
## Fetch general PR comments edited in place (REST)
General PR comments are issue comments. Greptile may update one summary comment repeatedly, so select by `updated_at` instead of `created_at`:
```bash
OWNER_REPO=$(gh repo view --json nameWithOwner -q .nameWithOwner)
gh api --paginate "repos/$OWNER_REPO/issues/<PR_NUMBER>/comments?per_page=100" \
| jq -s 'add
| map(select(.user.login | test("greptile"; "i")))
| sort_by(.updated_at)
| last
| {author: .user.login, updated_at, body}'
```
+4
View File
@@ -102,6 +102,10 @@ Every PR must include a **Model Used** section specifying which AI model produce
All tests must pass before a PR can be merged. Run them locally first and verify CI is green after pushing.
### Telemetry Changes
If your change adds, removes, or modifies emitted telemetry events, update the [Telemetry Data Contract](packages/shared/src/telemetry/README.md) in the same PR. Keep clients emitting raw dimension values and avoid documenting or relying on private delivery details.
### Paperclip Gates Must Pass
All Paperclip CI gates (lint, typecheck, tests, build, and any other required checks) must be satisfied before a PR can be merged. Don't ask for a merge while gates are red — fix them first.
+2
View File
@@ -409,6 +409,8 @@ Paperclip ships with opt-in OpenTelemetry auto-instrumentation for the server (t
Paperclip collects anonymous usage telemetry to help us understand how the product is used and improve it. No personal information, issue content, prompts, file paths, or secrets are ever collected. Private repository references are hashed with a per-install salt before being sent.
Contributors changing emitted telemetry events should follow the [Telemetry Data Contract](packages/shared/src/telemetry/README.md).
Telemetry is **enabled by default** and can be disabled with any of the following:
| Method | How |
+8
View File
@@ -307,10 +307,16 @@ A workspace-coherent adapter path means:
- the effective cwd exists or is provider-reachable, according to the workspace provider
- when the adapter or workspace strategy relies on git state, the cwd is git-valid for the selected workspace: it resolves to the expected repository root, required base refs or branch metadata can be resolved, and runtime-created worktrees are still registered or explicitly recoverable
Adapter-backed liveness also requires control-plane reachability from the agent's actual mutation surface, not just from the host adapter process. If the agent is expected to use Bash, shell tools, runtime helpers, or in-sandbox command execution to update issues, create comments, upload artifacts, or submit review decisions, the `PAPERCLIP_API_URL` and `PAPERCLIP_API_KEY` visible to that surface must route to Paperclip successfully.
For sandbox-backed local adapters, Paperclip may satisfy that contract with a run-scoped in-sandbox bridge. The host adapter keeps the real run JWT on the host side, injects only the bridge URL/token into the sandbox tool environment, and forwards allowed Paperclip API requests with the run id attached. The bridge credentials are execution plumbing, not user-facing context: they must not be written into prompts, visible comments, issue documents, restored workspace files, or durable logs. Agents and skills must use the env vars available in Bash/curl rather than assuming that the host's localhost API URL is reachable from browser or web-extraction tools inside the sandbox.
The state `projectWorkspaceId` plus `executionWorkspaceId` without `projectId` is invalid for project-scoped execution. Paperclip may treat it as recoverable only when it can derive exactly one owning project from the execution workspace, project workspace, or source issue in the same company and then repair the persisted state before delivery. If the owning project is missing, ambiguous, or cross-company, the queued adapter run must not be counted as a live path.
Workspace incoherence feeds into the same non-terminal liveness and stranded assigned-work model as a disappeared run. The recovery path should first fail or reject the incoherent wake, then either repair and requeue one bounded continuation for the same assignee or surface an explicit recovery action. It must not leave an agent-owned `in_progress` issue healthy solely because a wake record exists that would invoke the adapter in the wrong cwd, a non-git directory where git is required, an unrelated project workspace, or an unrecoverable missing worktree.
For runtime-created `git_worktree` execution workspaces, branch coherence is part of workspace coherence. The persisted execution workspace branch is the recorded branch for future dispatch. Reusing that workspace must verify that the worktree is still registered and that `HEAD` is on the recorded branch. Successful run finalization must perform the same check before recording `workspace_finalize=succeeded`; if the run switched to a publishing/PR branch without updating the execution workspace record, finalization records a failed workspace finalize and the run fails with the expected and actual branch. A branch change is sanctioned only when a control-plane path updates the execution workspace record before finalization, or when publishing work happens in a separate worktree and the managed issue worktree remains on its recorded branch.
### Explicit recovery actions
An explicit recovery action is a typed liveness repair path for a source issue. It is the recovery primitive; the action can be rendered directly on the source issue or backed by a separate recovery issue when the repair needs its own work item.
@@ -396,6 +402,8 @@ Agent-assigned `in_review` with no typed participant is only healthy when one of
An `in_review` issue is stalled when it has no typed participant, no pending interaction or approval, no user owner, no active monitor, no active run, no queued wake, and no explicit recovery action. Paperclip should surface that state as recovery work rather than silently completing the issue or leaving blocker chains parked indefinitely.
When an execution-policy review stage has a pending agent participant, the participant's run is part of the review path only while it is live or queued. If that participant run reaches a terminal state while `executionState.status` remains `pending`, no decision has been recorded. Paperclip should queue one bounded normal-model recovery wake for the same participant when the agent is invokable and no other review path exists. If that recovery run also finishes while the stage remains pending, or the participant cannot be invoked, Paperclip must move the source issue to an explicit blocked/recovery path instead of leaving `in_review` to drift silently.
### Issue monitors
An issue monitor is a one-shot deferred action path for agent-owned issues in `in_progress` or `in_review`.
@@ -395,4 +395,24 @@ describe("command managed runtime", () => {
expect(progress.every((entry) => entry.total === payload.length)).toBe(true);
expect(progress.at(-1)?.done).toBe(payload.length);
});
it("includes stdout diagnostics when a managed runtime command fails", async () => {
const startedAt = new Date().toISOString();
const runner: CommandManagedRuntimeRunner = {
execute: async () => ({
exitCode: 2,
signal: null,
timedOut: false,
stdout: "tar: workspace-download.tar: Cannot open: Permission denied\n",
stderr: "",
pid: null,
startedAt,
}),
};
const client = createCommandManagedRuntimeClient({ runner, commandCwd: "/", timeoutMs: 30_000 });
await expect(client.run("tar -cf workspace-download.tar .", { timeoutMs: 30_000 })).rejects.toThrow(
/stdout: tar: workspace-download\.tar: Cannot open: Permission denied/,
);
});
});
@@ -69,10 +69,25 @@ function toBuffer(bytes: Buffer | Uint8Array | ArrayBuffer): Buffer {
return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
}
const FAILED_COMMAND_OUTPUT_TAIL_CHARS = 4_000;
function formatFailedCommandOutput(result: RunProcessResult): string {
const tail = (text: string): string => {
const trimmed = text.trim();
if (trimmed.length <= FAILED_COMMAND_OUTPUT_TAIL_CHARS) return trimmed;
return `...[truncated]\n${trimmed.slice(-FAILED_COMMAND_OUTPUT_TAIL_CHARS)}`;
};
const stderr = tail(result.stderr);
const stdout = tail(result.stdout);
const parts: string[] = [];
if (stderr.length > 0) parts.push(`stderr: ${stderr}`);
if (stdout.length > 0) parts.push(`stdout: ${stdout}`);
return parts.length > 0 ? `:\n${parts.join("\n")}` : "";
}
function requireSuccessfulResult(result: RunProcessResult, action: string): void {
if (result.exitCode === 0 && !result.timedOut) return;
const stderr = result.stderr.trim();
const detail = stderr.length > 0 ? `: ${stderr}` : "";
const detail = formatFailedCommandOutput(result);
throw new Error(`${action} failed with exit code ${result.exitCode ?? "null"}${detail}`);
}
@@ -1,5 +1,5 @@
import { createServer } from "node:http";
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { mkdir, mkdtemp, readdir, readFile, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
@@ -17,6 +17,7 @@ import {
type AdapterSandboxExecutionTarget,
} from "./execution-target.js";
import { runChildProcess } from "./server-utils.js";
import { shellQuote } from "./ssh.js";
describe("sandbox adapter execution targets", () => {
const cleanupDirs: string[] = [];
@@ -60,6 +61,20 @@ describe("sandbox adapter execution targets", () => {
};
}
async function readRuntimeTextFiles(rootDir: string): Promise<string[]> {
const entries = await readdir(rootDir, { withFileTypes: true }).catch(() => []);
const contents: string[] = [];
for (const entry of entries) {
const entryPath = path.join(rootDir, entry.name);
if (entry.isDirectory()) {
contents.push(...await readRuntimeTextFiles(entryPath));
} else if (entry.isFile()) {
contents.push(await readFile(entryPath, "utf8").catch(() => ""));
}
}
return contents;
}
it("executes through the provider-neutral runner without a remote spec", async () => {
const runner = {
execute: vi.fn(async () => ({
@@ -449,6 +464,120 @@ describe("sandbox adapter execution targets", () => {
}
});
it("exposes the Paperclip bridge to the sandbox shell surface", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-execution-target-bridge-shell-"));
cleanupDirs.push(rootDir);
const remoteCwd = path.join(rootDir, "workspace");
const runtimeRootDir = path.join(remoteCwd, ".paperclip-runtime", "claude");
await mkdir(runtimeRootDir, { recursive: true });
const requests: Array<{ method: string; url: string; auth: string | null; runId: string | null }> = [];
const apiServer = createServer((req, res) => {
requests.push({
method: req.method ?? "GET",
url: req.url ?? "/",
auth: req.headers.authorization ?? null,
runId: typeof req.headers["x-paperclip-run-id"] === "string" ? req.headers["x-paperclip-run-id"] : null,
});
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ ok: true }));
});
await new Promise<void>((resolve, reject) => {
apiServer.once("error", reject);
apiServer.listen(0, "127.0.0.1", () => resolve());
});
const address = apiServer.address();
if (!address || typeof address === "string") {
throw new Error("Expected the bridge shell test API server to listen on a TCP port.");
}
const delegateRunner = createLocalSandboxRunner();
const runner = {
execute: vi.fn(async (input: Parameters<typeof delegateRunner.execute>[0]) => delegateRunner.execute(input)),
};
const target: AdapterSandboxExecutionTarget = {
kind: "remote",
transport: "sandbox",
providerKey: "daytona",
environmentId: "env-1",
leaseId: "lease-1",
remoteCwd,
runner,
timeoutMs: 30_000,
};
const bridge = await startAdapterExecutionTargetPaperclipBridge({
runId: "run-bridge-shell",
target,
runtimeRootDir,
adapterKey: "claude",
hostApiToken: "real-run-jwt",
hostApiUrl: `http://127.0.0.1:${address.port}`,
});
try {
expect(bridge).not.toBeNull();
const shellProbe = [
"const url = `${process.env.PAPERCLIP_API_URL}/api/agents/me`;",
"fetch(url, { headers: { authorization: `Bearer ${process.env.PAPERCLIP_API_KEY}`, accept: 'application/json' } })",
" .then(async (response) => {",
" const body = await response.json();",
" process.stdout.write(JSON.stringify({",
" status: response.status,",
" body,",
" bridgeMode: process.env.PAPERCLIP_API_BRIDGE_MODE,",
" }));",
" })",
" .catch((error) => {",
" console.error(error instanceof Error ? error.stack : String(error));",
" process.exit(1);",
" });",
].join("\n");
const result = await runAdapterExecutionTargetShellCommand(
"run-bridge-shell",
target,
`${shellQuote(process.execPath)} -e ${shellQuote(shellProbe)}`,
{
cwd: remoteCwd,
env: bridge!.env,
timeoutSec: 15,
graceSec: 5,
onLog: async () => {},
},
);
expect(result.exitCode).toBe(0);
expect(result.stderr).toBe("");
expect(JSON.parse(result.stdout)).toEqual({
status: 200,
body: { ok: true },
bridgeMode: "queue_v1",
});
expect(`${result.stdout}\n${result.stderr}`).not.toContain("real-run-jwt");
expect(`${result.stdout}\n${result.stderr}`).not.toContain(bridge!.env.PAPERCLIP_API_KEY);
const runnerCommandText = JSON.stringify(
runner.execute.mock.calls.map(([call]) => ({
command: call.command,
args: call.args,
})),
);
expect(runnerCommandText).not.toContain("real-run-jwt");
expect(runnerCommandText).not.toContain(bridge!.env.PAPERCLIP_API_KEY);
const runtimeFiles = (await readRuntimeTextFiles(runtimeRootDir)).join("\n");
expect(runtimeFiles).not.toContain("real-run-jwt");
expect(runtimeFiles).not.toContain(bridge!.env.PAPERCLIP_API_KEY);
expect(requests).toEqual([{
method: "GET",
url: "/api/agents/me",
auth: "Bearer real-run-jwt",
runId: "run-bridge-shell",
}]);
} finally {
await bridge?.stop();
await new Promise<void>((resolve) => apiServer.close(() => resolve()));
}
});
it("uses the effective adapter timeout when starting the sandbox callback bridge", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-execution-target-bridge-timeout-"));
cleanupDirs.push(rootDir);
@@ -914,8 +914,7 @@ export async function startSandboxCallbackBridgeServer(input: {
[
`mkdir -p ${shellQuote(directories.requestsDir)} ${shellQuote(directories.responsesDir)} ${shellQuote(directories.logsDir)}`,
`rm -f ${shellQuote(directories.readyFile)} ${shellQuote(directories.pidFile)}`,
`nohup env ${Object.entries(env).map(([key, value]) => `${key}=${shellQuote(value)}`).join(" ")} ` +
`${shellQuote(nodeCommand)} ${shellQuote(remoteEntrypoint)} ` +
`nohup ${shellQuote(nodeCommand)} ${shellQuote(remoteEntrypoint)} ` +
`>> ${shellQuote(directories.logFile)} 2>&1 < /dev/null &`,
"pid=$!",
`printf '%s\\n' \"$pid\" > ${shellQuote(directories.pidFile)}`,
@@ -925,6 +924,7 @@ export async function startSandboxCallbackBridgeServer(input: {
cwd: input.remoteCwd,
env: {
[SANDBOX_EXEC_CHANNEL_ENV]: SANDBOX_EXEC_CHANNEL_BRIDGE,
...env,
},
timeoutMs,
});
@@ -507,8 +507,9 @@ describe("sandbox managed runtime", () => {
await writeFile(path.join(localWorkspaceDir, "src", "main.ts"), "x\n", "utf8");
await writeFile(path.join(localAssetsDir, "asset.txt"), "a\n", "utf8");
// Capture every tar uploaded to the sandbox so we can inspect its members.
// Capture every tar uploaded/downloaded through the sandbox so we can inspect its members.
const uploadedTars: { remotePath: string; bytes: Buffer }[] = [];
const downloadedTars: { remotePath: string; bytes: Buffer }[] = [];
const client: SandboxManagedRuntimeClient = {
makeDir: async (remotePath) => {
await mkdir(remotePath, { recursive: true });
@@ -519,7 +520,11 @@ describe("sandbox managed runtime", () => {
if (remotePath.endsWith("-upload.tar")) uploadedTars.push({ remotePath, bytes: buffer });
await writeFile(remotePath, buffer);
},
readFile: async (remotePath) => await readFile(remotePath),
readFile: async (remotePath) => {
const buffer = await readFile(remotePath);
if (remotePath.endsWith("workspace-download.tar")) downloadedTars.push({ remotePath, bytes: buffer });
return buffer;
},
listFiles: async () => [],
remove: async (remotePath) => {
await rm(remotePath, { recursive: true, force: true });
@@ -529,7 +534,7 @@ describe("sandbox managed runtime", () => {
},
};
await prepareSandboxManagedRuntime({
const prepared = await prepareSandboxManagedRuntime({
spec: {
transport: "sandbox",
provider: "test",
@@ -559,6 +564,12 @@ describe("sandbox managed runtime", () => {
// And the workspace still extracts correctly into an existing target dir.
await expect(readFile(path.join(remoteWorkspaceDir, "README.md"), "utf8")).resolves.toBe("ws\n");
await expect(readFile(path.join(remoteWorkspaceDir, "src", "main.ts"), "utf8")).resolves.toBe("x\n");
await prepared.restoreWorkspace();
expect(downloadedTars).toHaveLength(1);
const downloadMembers = await listTarMembers(rootDir, "workspace-download-list.tar", downloadedTars[0]!.bytes);
expect(downloadMembers).not.toContain(".");
expect(downloadMembers).not.toContain("./");
});
it("excludes transient symlinked home dirs from the asset tar while keeping required content", async () => {
@@ -735,13 +746,15 @@ describe("sandbox managed runtime", () => {
expect(restoreLines.some((line) => line.includes("100%"))).toBe(true);
});
it("creates a valid empty workspace tarball when the local workspace is empty", async () => {
it("creates valid empty workspace tarballs when the workspace is empty", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-sandbox-empty-"));
cleanupDirs.push(rootDir);
const localWorkspaceDir = path.join(rootDir, "local-workspace");
const remoteWorkspaceDir = path.join(rootDir, "remote-workspace");
await mkdir(localWorkspaceDir, { recursive: true });
const downloadedTars: { remotePath: string; bytes: Buffer }[] = [];
const runCommands: string[] = [];
const client: SandboxManagedRuntimeClient = {
makeDir: async (remotePath) => {
await mkdir(remotePath, { recursive: true });
@@ -750,30 +763,41 @@ describe("sandbox managed runtime", () => {
await mkdir(path.dirname(remotePath), { recursive: true });
await writeFile(remotePath, Buffer.from(bytes));
},
readFile: async (remotePath) => await readFile(remotePath),
readFile: async (remotePath) => {
const buffer = await readFile(remotePath);
if (remotePath.endsWith("workspace-download.tar")) downloadedTars.push({ remotePath, bytes: buffer });
return buffer;
},
listFiles: async () => [],
remove: async (remotePath) => {
await rm(remotePath, { recursive: true, force: true });
},
run: async (command) => {
runCommands.push(command);
await execFile("sh", ["-c", command], { maxBuffer: 32 * 1024 * 1024 });
},
};
await expect(
prepareSandboxManagedRuntime({
spec: {
transport: "sandbox",
provider: "test",
sandboxId: "sandbox-1",
remoteCwd: remoteWorkspaceDir,
timeoutMs: 30_000,
apiKey: null,
},
adapterKey: "test-adapter",
client,
workspaceLocalDir: localWorkspaceDir,
}),
).resolves.toBeDefined();
const prepared = await prepareSandboxManagedRuntime({
spec: {
transport: "sandbox",
provider: "test",
sandboxId: "sandbox-1",
remoteCwd: remoteWorkspaceDir,
timeoutMs: 30_000,
apiKey: null,
},
adapterKey: "test-adapter",
client,
workspaceLocalDir: localWorkspaceDir,
});
await prepared.restoreWorkspace();
expect(downloadedTars).toHaveLength(1);
const members = await listTarMembers(rootDir, "empty-workspace-download.tar", downloadedTars[0]!.bytes);
expect(members).toEqual([]);
const emptyArchiveCommand = runCommands.find((command) => command.includes("dd if=/dev/zero"));
expect(emptyArchiveCommand).toBeDefined();
expect(emptyArchiveCommand).not.toContain("/dev/null");
});
});
@@ -323,6 +323,25 @@ function tarExcludeFlags(exclude: string[] | undefined): string {
return ["._*", ...(exclude ?? [])].map((entry) => `--exclude ${shellQuote(entry)}`).join(" ");
}
function createRemoteTarballFromDirectoryCommand(input: {
remoteDir: string;
archivePath: string;
exclude?: string[];
}): string {
// Match the local archive path: name top-level entries explicitly so tar
// does not include a "." self-entry that it later tries to chmod/utime.
return [
`mkdir -p ${shellQuote(path.posix.dirname(input.archivePath))}`,
`cd ${shellQuote(input.remoteDir)}`,
"set -- *",
`if [ "$#" -eq 1 ] && [ "$1" = "*" ] && [ ! -e "$1" ] && [ ! -L "$1" ]; then set --; fi`,
`for entry in .[!.]* ..?*; do [ -e "$entry" ] || [ -L "$entry" ] || continue; set -- "$@" "$entry"; done`,
`if [ "$#" -eq 0 ]; then ` +
`dd if=/dev/zero of=${shellQuote(input.archivePath)} bs=1024 count=1; ` +
`else tar -cf ${shellQuote(input.archivePath)} ${tarExcludeFlags(input.exclude)} -- "$@"; fi`,
].join(" && ");
}
async function emitRuntimeStatus(
sink: RuntimeStatusSink | undefined,
phase: RuntimeStatusPhase,
@@ -586,11 +605,11 @@ export async function prepareSandboxManagedRuntime(input: {
const remoteWorkspaceTar = path.posix.join(runtimeRootDir, "workspace-download.tar");
await emitRuntimeStatus(input.onRuntimeProgress, "restore", "Restoring workspace from sandbox");
await input.client.run(
`sh -c ${shellQuote(
`mkdir -p ${shellQuote(runtimeRootDir)} && ` +
`tar -cf ${shellQuote(remoteWorkspaceTar)} -C ${shellQuote(workspaceRemoteDir)} ` +
`${tarExcludeFlags(restoreExclude)} .`,
)}`,
`sh -c ${shellQuote(createRemoteTarballFromDirectoryCommand({
remoteDir: workspaceRemoteDir,
archivePath: remoteWorkspaceTar,
exclude: restoreExclude,
}))}`,
{ timeoutMs: input.spec.timeoutMs },
);
const workspaceRestore = makeTransferProgress(restoreSink, "Restoring", "from", "workspace");
@@ -643,6 +643,82 @@ describe("renderPaperclipWakePrompt", () => {
expect(prompt).toContain("named unblock owner/action");
});
it("renders resolved checkbox selections in scoped wake prompts", () => {
const payload = {
reason: "issue_commented",
issue: {
id: "issue-1",
identifier: "PAP-1581",
title: "Delete selected files",
status: "in_progress",
},
interactionKind: "request_checkbox_confirmation",
interactionStatus: "accepted",
checkboxSelection: {
prompt: "Delete selected files?",
selectedOptionIds: ["file-b"],
selectedOptions: [{ id: "file-b", label: "b.txt", description: "Generated build output" }],
},
commentWindow: {
requestedCount: 0,
includedCount: 0,
missingCount: 0,
},
comments: [],
fallbackFetchNeeded: false,
};
const prompt = renderPaperclipWakePrompt(payload);
expect(prompt).toContain("- checkbox prompt: Delete selected files?");
expect(prompt).toContain("- checkbox selection ids: file-b");
expect(prompt).toContain("- checkbox selection options: file-b (b.txt) - Generated build output");
expect(JSON.parse(stringifyPaperclipWakePayload(payload) ?? "{}")).toMatchObject({
checkboxSelection: {
prompt: "Delete selected files?",
selectedOptionIds: ["file-b"],
selectedOptions: [{ id: "file-b", label: "b.txt", description: "Generated build output" }],
},
});
});
it("renders accepted empty checkbox selections explicitly", () => {
const payload = {
reason: "issue_commented",
issue: {
id: "issue-1",
identifier: "PAP-1581",
title: "Delete selected files",
status: "in_progress",
},
interactionKind: "request_checkbox_confirmation",
interactionStatus: "accepted",
checkboxSelection: {
prompt: "Delete selected files?",
selectedOptionIds: [],
selectedOptions: [],
},
commentWindow: {
requestedCount: 0,
includedCount: 0,
missingCount: 0,
},
comments: [],
fallbackFetchNeeded: false,
};
const prompt = renderPaperclipWakePrompt(payload);
expect(prompt).toContain("- checkbox prompt: Delete selected files?");
expect(prompt).toContain("- checkbox selection ids: (none)");
expect(prompt).toContain("- checkbox selection options: (none)");
expect(JSON.parse(stringifyPaperclipWakePayload(payload) ?? "{}")).toMatchObject({
checkboxSelection: {
prompt: "Delete selected files?",
selectedOptionIds: [],
selectedOptions: [],
},
});
});
it("preserves Chinese, Japanese, and Hindi issue and comment text in scoped wake prompts", () => {
const title = "验证中文任务";
const commentBody = [
+65 -1
View File
@@ -581,6 +581,16 @@ type PaperclipWakeTreeHoldSummary = {
reason: string | null;
};
type PaperclipWakeCheckboxSelection = {
prompt: string | null;
selectedOptionIds: string[];
selectedOptions: Array<{
id: string;
label: string;
description: string | null;
}>;
};
type PaperclipWakePayload = {
reason: string | null;
issue: PaperclipWakeIssue | null;
@@ -597,6 +607,7 @@ type PaperclipWakePayload = {
taskWatchdog: PaperclipWakeTaskWatchdogContext | null;
interactionKind: string | null;
interactionStatus: string | null;
checkboxSelection: PaperclipWakeCheckboxSelection | null;
annotationDeltas: PaperclipWakeAnnotationDelta[];
childIssueSummaries: PaperclipWakeChildIssueSummary[];
childIssueSummaryTruncated: boolean;
@@ -929,6 +940,42 @@ function normalizePaperclipWakeTreeHoldSummary(value: unknown): PaperclipWakeTre
return { holdId, rootIssueId, mode, reason };
}
function normalizePaperclipWakeCheckboxSelection(value: unknown): PaperclipWakeCheckboxSelection | null {
const selection = parseObject(value);
const hasExplicitSelection =
Object.prototype.hasOwnProperty.call(selection, "prompt") ||
Object.prototype.hasOwnProperty.call(selection, "selectedOptionIds") ||
Object.prototype.hasOwnProperty.call(selection, "selectedOptions");
const prompt = asString(selection.prompt, "").trim() || null;
const selectedOptionIds = Array.isArray(selection.selectedOptionIds)
? selection.selectedOptionIds
.map((entry) => asString(entry, "").trim())
.filter(Boolean)
: [];
const selectedOptions = Array.isArray(selection.selectedOptions)
? selection.selectedOptions
.map((entry) => {
const option = parseObject(entry);
const id = asString(option.id, "").trim();
if (!id) return null;
return {
id,
label: asString(option.label, id).trim() || id,
description: asString(option.description, "").trim() || null,
};
})
.filter((entry): entry is { id: string; label: string; description: string | null } => Boolean(entry))
: [];
if (!hasExplicitSelection && selectedOptionIds.length === 0 && selectedOptions.length === 0 && !prompt) return null;
const optionById = new Map(selectedOptions.map((option) => [option.id, option]));
return {
prompt,
selectedOptionIds,
selectedOptions: selectedOptionIds.map((id) => optionById.get(id) ?? { id, label: id, description: null }),
};
}
function normalizePaperclipWakeExecutionPrincipal(value: unknown): PaperclipWakeExecutionPrincipal | null {
const principal = parseObject(value);
const typeRaw = asString(principal.type, "").trim().toLowerCase();
@@ -1113,7 +1160,8 @@ export function normalizePaperclipWakePayload(value: unknown): PaperclipWakePayl
: [];
const activeTreeHold = normalizePaperclipWakeTreeHoldSummary(payload.activeTreeHold);
if (comments.length === 0 && commentIds.length === 0 && annotationDeltas.length === 0 && childIssueSummaries.length === 0 && unresolvedBlockerIssueIds.length === 0 && unresolvedBlockerSummaries.length === 0 && !activeTreeHold && !executionStage && !continuationSummary && !planReviewContext && !livenessContinuation && !taskWatchdog && !normalizePaperclipWakeIssue(payload.issue)) {
const checkboxSelection = normalizePaperclipWakeCheckboxSelection(payload.checkboxSelection);
if (comments.length === 0 && commentIds.length === 0 && annotationDeltas.length === 0 && childIssueSummaries.length === 0 && unresolvedBlockerIssueIds.length === 0 && unresolvedBlockerSummaries.length === 0 && !activeTreeHold && !executionStage && !continuationSummary && !planReviewContext && !livenessContinuation && !taskWatchdog && !checkboxSelection && !normalizePaperclipWakeIssue(payload.issue)) {
return null;
}
@@ -1134,6 +1182,7 @@ export function normalizePaperclipWakePayload(value: unknown): PaperclipWakePayl
taskWatchdog,
interactionKind: asString(payload.interactionKind, "").trim() || null,
interactionStatus: asString(payload.interactionStatus, "").trim() || null,
checkboxSelection,
childIssueSummaries,
childIssueSummaryTruncated: asBoolean(payload.childIssueSummaryTruncated, false),
commentIds,
@@ -1239,6 +1288,21 @@ export function renderPaperclipWakePrompt(
if (normalized.issue?.priority) {
lines.push(`- issue priority: ${normalized.issue.priority}`);
}
if (normalized.checkboxSelection) {
if (normalized.checkboxSelection.prompt) {
lines.push(`- checkbox prompt: ${normalized.checkboxSelection.prompt}`);
}
const selectedOptionIds = normalized.checkboxSelection.selectedOptionIds.join(", ") || "(none)";
const selectedOptions = normalized.checkboxSelection.selectedOptions
.map((option) => {
const label = option.label && option.label !== option.id ? ` (${option.label})` : "";
const description = option.description ? ` - ${option.description}` : "";
return `${option.id}${label}${description}`;
})
.join(", ") || "(none)";
lines.push(`- checkbox selection ids: ${selectedOptionIds}`);
lines.push(`- checkbox selection options: ${selectedOptions}`);
}
if (normalized.issue?.workMode === "planning" && !normalized.taskWatchdog) {
const hasWakeComments = normalized.comments.length > 0;
const acceptedPlanContinuation =
@@ -12,6 +12,8 @@ export const DEFAULT_GEMINI_LOCAL_MODEL = "auto";
export const models = [
{ id: DEFAULT_GEMINI_LOCAL_MODEL, label: "Auto" },
{ id: "gemini-3.1-pro-preview", label: "Gemini 3.1 Pro Preview" },
{ id: "gemini-3.1-pro-preview-customtools", label: "Gemini 3.1 Pro Preview (Custom Tools)" },
{ id: "gemini-2.5-pro", label: "Gemini 2.5 Pro" },
{ id: "gemini-2.5-flash", label: "Gemini 2.5 Flash" },
{ id: "gemini-2.5-flash-lite", label: "Gemini 2.5 Flash Lite" },
@@ -14,33 +14,33 @@ function indexWhere(table: Parameters<typeof getTableConfig>[0], indexName: stri
}
describe("environment customImage schema", () => {
it("scopes template lookup and active uniqueness by company and environment", () => {
it("scopes template lookup and active uniqueness by environment", () => {
expect(indexColumns(
environmentCustomImageTemplates,
"environment_custom_image_templates_company_environment_status_idx",
)).toEqual(["company_id", "environment_id", "status"]);
"environment_custom_image_templates_environment_status_idx",
)).toEqual(["environment_id", "status"]);
expect(indexColumns(
environmentCustomImageTemplates,
"environment_custom_image_templates_company_environment_active_uq",
)).toEqual(["company_id", "environment_id"]);
"environment_custom_image_templates_environment_active_uq",
)).toEqual(["environment_id"]);
expect(indexWhere(
environmentCustomImageTemplates,
"environment_custom_image_templates_company_environment_active_uq",
"environment_custom_image_templates_environment_active_uq",
)).toBeDefined();
});
it("scopes setup-session lookup and active uniqueness by company and environment", () => {
it("scopes setup-session lookup and active uniqueness by environment", () => {
expect(indexColumns(
environmentCustomImageSetupSessions,
"environment_custom_image_setup_sessions_company_environment_status_idx",
)).toEqual(["company_id", "environment_id", "status"]);
"environment_custom_image_setup_sessions_environment_status_idx",
)).toEqual(["environment_id", "status"]);
expect(indexColumns(
environmentCustomImageSetupSessions,
"environment_custom_image_setup_sessions_company_environment_active_uq",
)).toEqual(["company_id", "environment_id"]);
"environment_custom_image_setup_sessions_environment_active_uq",
)).toEqual(["environment_id"]);
expect(indexWhere(
environmentCustomImageSetupSessions,
"environment_custom_image_setup_sessions_company_environment_active_uq",
"environment_custom_image_setup_sessions_environment_active_uq",
)).toBeDefined();
});
});
@@ -0,0 +1,69 @@
ALTER TABLE "issue_comments" ADD COLUMN IF NOT EXISTS "derived_author_agent_id" uuid;--> statement-breakpoint
ALTER TABLE "issue_comments" ADD COLUMN IF NOT EXISTS "derived_created_by_run_id" uuid;--> statement-breakpoint
ALTER TABLE "issue_comments" ADD COLUMN IF NOT EXISTS "derived_author_source" text;--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM "pg_constraint" WHERE "conname" = 'issue_comments_derived_author_agent_id_agents_id_fk'
) THEN
ALTER TABLE "issue_comments" ADD CONSTRAINT "issue_comments_derived_author_agent_id_agents_id_fk" FOREIGN KEY ("derived_author_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;
END IF;
END $$;--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM "pg_constraint" WHERE "conname" = 'issue_comments_derived_created_by_run_id_heartbeat_runs_id_fk'
) THEN
ALTER TABLE "issue_comments" ADD CONSTRAINT "issue_comments_derived_created_by_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("derived_created_by_run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE set null ON UPDATE no action;
END IF;
END $$;--> statement-breakpoint
-- Backfill agent attribution for historical non-human ("Board") comments so old
-- threads stop rendering as blue board bubbles and the read path stops
-- re-scanning run logs. Two SQL-computable tiers, both guarded to NEVER touch a
-- comment whose author maps to a genuine user profile. The log-marker tier is
-- handled lazily on read (it needs object-storage log bodies).
--
-- Tier `run_id`: the comment's own authoring run resolves to an agent (lossless).
-- Batched to keep lock/WAL footprint bounded on large histories.
DO $$
DECLARE
affected integer;
BEGIN
LOOP
WITH batch AS (
SELECT c.id AS comment_id, hr.agent_id, hr.id AS run_id
FROM issue_comments c
JOIN heartbeat_runs hr ON hr.id = c.created_by_run_id
WHERE c.author_agent_id IS NULL
AND c.derived_author_agent_id IS NULL
AND c.author_user_id IS NOT NULL
-- Only the non-human board sentinel or non-`user` authors are
-- eligible. `local-board` IS a row in "user" (the implicit board
-- admin), so it must be allowed explicitly; genuine signups are not.
AND (c.author_user_id = 'local-board'
OR NOT EXISTS (SELECT 1 FROM "user" u WHERE u.id = c.author_user_id))
LIMIT 5000
)
UPDATE issue_comments c
SET derived_author_agent_id = b.agent_id,
derived_created_by_run_id = b.run_id,
derived_author_source = 'run_id'
FROM batch b
WHERE c.id = b.comment_id;
GET DIAGNOSTICS affected = ROW_COUNT;
EXIT WHEN affected = 0;
END LOOP;
END $$;--> statement-breakpoint
-- Option A: the pure run-window TIMING tiers are intentionally NOT applied.
-- Because agents post through the `local-board` subprocess, an agent comment and
-- a genuine human board comment are indistinguishable rows, so any timing-overlap
-- guess mis-attributes human board comments that merely coincided with an agent
-- run (e.g. a human board reply typed while an agent run was in flight).
-- Only the lossless `run_id` backfill above (and the read-path `run_log_comment_post`
-- tier) attribute history; everything else stays "Board".
--
-- This statement also reverts any attribution a PRIOR revision of this migration
-- persisted via the timing tiers, so re-applying / upgrading is idempotent.
UPDATE issue_comments
SET derived_author_agent_id = NULL,
derived_created_by_run_id = NULL,
derived_author_source = NULL
WHERE derived_author_source IN ('run_window_unique', 'run_window_agent_unique');
@@ -0,0 +1,106 @@
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM "environment_custom_image_templates"
WHERE "status" = 'active'
GROUP BY "environment_id"
HAVING count(*) > 1
) THEN
RAISE EXCEPTION 'Cannot migrate environment custom image templates to environment scope while multiple active templates exist for the same environment. Revoke or supersede the extra active templates before retrying.';
END IF;
END $$;
--> statement-breakpoint
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM "environment_custom_image_setup_sessions"
WHERE "status" IN ('starting', 'waiting_for_user', 'capturing')
GROUP BY "environment_id"
HAVING count(*) > 1
) THEN
RAISE EXCEPTION 'Cannot migrate environment custom image setup sessions to environment scope while multiple active sessions exist for the same environment. Finish or cancel the extra sessions before retrying.';
END IF;
END $$;
--> statement-breakpoint
UPDATE "environment_custom_image_templates"
SET
"metadata" = jsonb_set(
COALESCE("metadata", '{}'::jsonb),
'{setupRpcCompanyId}',
to_jsonb("company_id"::text),
true
),
"updated_at" = now()
WHERE "company_id" IS NOT NULL
AND COALESCE("metadata" ->> 'setupRpcCompanyId', '') = '';
--> statement-breakpoint
UPDATE "environment_custom_image_setup_sessions"
SET
"metadata" = jsonb_set(
COALESCE("metadata", '{}'::jsonb),
'{setupRpcCompanyId}',
to_jsonb("company_id"::text),
true
),
"updated_at" = now()
WHERE "company_id" IS NOT NULL
AND COALESCE("metadata" ->> 'setupRpcCompanyId', '') = '';
--> statement-breakpoint
DROP INDEX IF EXISTS "environment_custom_image_templates_company_environment_status_idx";
--> statement-breakpoint
DROP INDEX IF EXISTS "environment_custom_image_templates_company_provider_status_idx";
--> statement-breakpoint
DROP INDEX IF EXISTS "environment_custom_image_templates_company_environment_active_uq";
--> statement-breakpoint
DROP INDEX IF EXISTS "environment_custom_image_templates_company_last_used_idx";
--> statement-breakpoint
DROP INDEX IF EXISTS "environment_custom_image_setup_sessions_company_environment_status_idx";
--> statement-breakpoint
DROP INDEX IF EXISTS "environment_custom_image_setup_sessions_company_environment_active_uq";
--> statement-breakpoint
DROP INDEX IF EXISTS "environment_custom_image_setup_sessions_company_template_idx";
--> statement-breakpoint
DROP INDEX IF EXISTS "environment_custom_image_setup_sessions_company_promoted_template_idx";
--> statement-breakpoint
DROP INDEX IF EXISTS "environment_custom_image_setup_sessions_company_expires_idx";
--> statement-breakpoint
ALTER TABLE "environment_custom_image_templates"
DROP CONSTRAINT IF EXISTS "environment_custom_image_templates_company_id_companies_id_fk";
--> statement-breakpoint
ALTER TABLE "environment_custom_image_setup_sessions"
DROP CONSTRAINT IF EXISTS "environment_custom_image_setup_sessions_company_id_companies_id_fk";
--> statement-breakpoint
ALTER TABLE "environment_custom_image_templates" DROP COLUMN IF EXISTS "company_id";
--> statement-breakpoint
ALTER TABLE "environment_custom_image_setup_sessions" DROP COLUMN IF EXISTS "company_id";
--> statement-breakpoint
CREATE INDEX "environment_custom_image_templates_environment_status_idx"
ON "environment_custom_image_templates" USING btree ("environment_id", "status");
--> statement-breakpoint
CREATE INDEX "environment_custom_image_templates_environment_provider_status_idx"
ON "environment_custom_image_templates" USING btree ("environment_id", "provider", "status");
--> statement-breakpoint
CREATE UNIQUE INDEX "environment_custom_image_templates_environment_active_uq"
ON "environment_custom_image_templates" USING btree ("environment_id")
WHERE "status" = 'active';
--> statement-breakpoint
CREATE INDEX "environment_custom_image_templates_last_used_idx"
ON "environment_custom_image_templates" USING btree ("last_used_at");
--> statement-breakpoint
CREATE INDEX "environment_custom_image_setup_sessions_environment_status_idx"
ON "environment_custom_image_setup_sessions" USING btree ("environment_id", "status");
--> statement-breakpoint
CREATE UNIQUE INDEX "environment_custom_image_setup_sessions_environment_active_uq"
ON "environment_custom_image_setup_sessions" USING btree ("environment_id")
WHERE "status" IN ('starting', 'waiting_for_user', 'capturing');
--> statement-breakpoint
CREATE INDEX "environment_custom_image_setup_sessions_template_idx"
ON "environment_custom_image_setup_sessions" USING btree ("template_id");
--> statement-breakpoint
CREATE INDEX "environment_custom_image_setup_sessions_promoted_template_idx"
ON "environment_custom_image_setup_sessions" USING btree ("promoted_template_id");
--> statement-breakpoint
CREATE INDEX "environment_custom_image_setup_sessions_expires_idx"
ON "environment_custom_image_setup_sessions" USING btree ("expires_at");
@@ -883,6 +883,20 @@
"when": 1782440100000,
"tag": "0125_environment_custom_image_templates",
"breakpoints": true
},
{
"idx": 126,
"version": "7",
"when": 1782526400000,
"tag": "0126_issue_comment_derived_attribution",
"breakpoints": true
},
{
"idx": 127,
"version": "7",
"when": 1782526500000,
"tag": "0127_environment_custom_images_instance_scoped",
"breakpoints": true
}
]
}
@@ -5,7 +5,6 @@ import type {
EnvironmentCustomImageSetupSessionStatus,
} from "@paperclipai/shared";
import { agents } from "./agents.js";
import { companies } from "./companies.js";
import { environmentLeases } from "./environment_leases.js";
import { environmentCustomImageTemplates } from "./environment_custom_image_templates.js";
import { environments } from "./environments.js";
@@ -14,7 +13,6 @@ export const environmentCustomImageSetupSessions = pgTable(
"environment_custom_image_setup_sessions",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
environmentId: uuid("environment_id").notNull().references(() => environments.id, { onDelete: "cascade" }),
templateId: uuid("template_id").references(() => environmentCustomImageTemplates.id, { onDelete: "set null" }),
promotedTemplateId: uuid("promoted_template_id")
@@ -36,26 +34,17 @@ export const environmentCustomImageSetupSessions = pgTable(
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
companyEnvironmentStatusIdx: index("environment_custom_image_setup_sessions_company_environment_status_idx").on(
table.companyId,
environmentStatusIdx: index("environment_custom_image_setup_sessions_environment_status_idx").on(
table.environmentId,
table.status,
),
companyEnvironmentActiveUq: uniqueIndex("environment_custom_image_setup_sessions_company_environment_active_uq")
.on(table.companyId, table.environmentId)
environmentActiveUq: uniqueIndex("environment_custom_image_setup_sessions_environment_active_uq")
.on(table.environmentId)
.where(sql`${table.status} IN ('starting', 'waiting_for_user', 'capturing')`),
companyTemplateIdx: index("environment_custom_image_setup_sessions_company_template_idx").on(
table.companyId,
table.templateId,
),
companyPromotedTemplateIdx: index("environment_custom_image_setup_sessions_company_promoted_template_idx").on(
table.companyId,
table.promotedTemplateId,
),
companyExpiresIdx: index("environment_custom_image_setup_sessions_company_expires_idx").on(
table.companyId,
table.expiresAt,
),
templateIdx: index("environment_custom_image_setup_sessions_template_idx").on(table.templateId),
promotedTemplateIdx: index("environment_custom_image_setup_sessions_promoted_template_idx")
.on(table.promotedTemplateId),
expiresIdx: index("environment_custom_image_setup_sessions_expires_idx").on(table.expiresAt),
providerLeaseIdx: index("environment_custom_image_setup_sessions_provider_lease_idx").on(
table.provider,
table.providerLeaseId,
@@ -11,14 +11,12 @@ import {
} from "drizzle-orm/pg-core";
import type { EnvironmentCustomImageTemplateStatus } from "@paperclipai/shared";
import { agents } from "./agents.js";
import { companies } from "./companies.js";
import { environments } from "./environments.js";
export const environmentCustomImageTemplates = pgTable(
"environment_custom_image_templates",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
environmentId: uuid("environment_id").notNull().references(() => environments.id, { onDelete: "cascade" }),
provider: text("provider").notNull(),
templateKind: text("template_kind").notNull().default("unknown"),
@@ -37,23 +35,19 @@ export const environmentCustomImageTemplates = pgTable(
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
companyEnvironmentStatusIdx: index("environment_custom_image_templates_company_environment_status_idx").on(
table.companyId,
environmentStatusIdx: index("environment_custom_image_templates_environment_status_idx").on(
table.environmentId,
table.status,
),
companyProviderStatusIdx: index("environment_custom_image_templates_company_provider_status_idx").on(
table.companyId,
environmentProviderStatusIdx: index("environment_custom_image_templates_environment_provider_status_idx").on(
table.environmentId,
table.provider,
table.status,
),
companyEnvironmentActiveUq: uniqueIndex("environment_custom_image_templates_company_environment_active_uq")
.on(table.companyId, table.environmentId)
environmentActiveUq: uniqueIndex("environment_custom_image_templates_environment_active_uq")
.on(table.environmentId)
.where(sql`${table.status} = 'active'`),
supersededByIdx: index("environment_custom_image_templates_superseded_by_idx").on(table.supersededByTemplateId),
companyLastUsedIdx: index("environment_custom_image_templates_company_last_used_idx").on(
table.companyId,
table.lastUsedAt,
),
lastUsedIdx: index("environment_custom_image_templates_last_used_idx").on(table.lastUsedAt),
}),
);
+8
View File
@@ -1,5 +1,6 @@
import type {
IssueCommentAuthorType,
IssueCommentDerivedAuthorSource,
IssueCommentMetadata,
IssueCommentPresentation,
SourceTrustMetadata,
@@ -20,6 +21,13 @@ export const issueComments = pgTable(
authorUserId: text("author_user_id"),
authorType: text("author_type").$type<IssueCommentAuthorType>(),
createdByRunId: uuid("created_by_run_id").references(() => heartbeatRuns.id, { onDelete: "set null" }),
// Persisted result of best-effort agent-attribution derivation for comments
// authored by a non-human sentinel (e.g. `local-board`). Populated once by a
// backfill migration and lazily on read so the load path stops re-scanning
// run logs.
derivedAuthorAgentId: uuid("derived_author_agent_id").references(() => agents.id, { onDelete: "set null" }),
derivedCreatedByRunId: uuid("derived_created_by_run_id").references(() => heartbeatRuns.id, { onDelete: "set null" }),
derivedAuthorSource: text("derived_author_source").$type<IssueCommentDerivedAuthorSource>(),
body: text("body").notNull(),
presentation: jsonb("presentation").$type<IssueCommentPresentation | null>(),
metadata: jsonb("metadata").$type<IssueCommentMetadata | null>(),
+1
View File
@@ -74,6 +74,7 @@ const listIssuesSchema = z.object({
originKind: z.string().optional(),
originId: z.string().optional(),
includeRoutineExecutions: z.boolean().optional(),
includeLiveDescendantSummary: z.boolean().optional(),
q: z.string().optional(),
});
@@ -338,6 +338,7 @@ export interface IssuesListFilters {
originId?: string;
descendantOf?: string;
includeRoutineExecutions?: boolean;
includeLiveDescendantSummary?: boolean;
}
export interface IssuesListProps {
@@ -11,16 +11,14 @@ import {
startEnvironmentCustomImageSetupSessionSchema,
} from "./validators/environment-custom-images.js";
const companyId = "11111111-1111-4111-8111-111111111111";
const environmentId = "22222222-2222-4222-8222-222222222222";
const templateId = "33333333-3333-4333-8333-333333333333";
const sessionId = "44444444-4444-4444-8444-444444444444";
describe("environment customImage validators", () => {
it("requires company and environment scope on templates and setup sessions", () => {
it("requires environment scope on templates and setup sessions", () => {
const template = environmentCustomImageTemplateSchema.parse({
id: templateId,
companyId,
environmentId,
provider: "daytona",
templateKind: "snapshot",
@@ -38,12 +36,10 @@ describe("environment customImage validators", () => {
updatedAt: "2026-06-25T12:00:00.000Z",
});
expect(template.companyId).toBe(companyId);
expect(template.environmentId).toBe(environmentId);
const session = environmentCustomImageSetupSessionSchema.parse({
id: sessionId,
companyId,
environmentId,
templateId,
promotedTemplateId: null,
@@ -69,7 +65,6 @@ describe("environment customImage validators", () => {
updatedAt: "2026-06-25T12:00:00.000Z",
});
expect(session.companyId).toBe(companyId);
expect(session.environmentId).toBe(environmentId);
});
+9
View File
@@ -659,6 +659,7 @@ export type {
IssueExecutionStagePrincipal,
IssueExecutionDecision,
IssueComment,
IssueCommentDerivedAuthorSource,
IssueCommentMetadata,
IssueCommentMetadataSection,
IssueCommentMetadataRow,
@@ -751,6 +752,14 @@ export type {
LiveEvent,
DashboardRunActivityDay,
DashboardSummary,
TimelineActorType,
TimelineEventKind,
TimelineEdgeKind,
WorkTimelineActor,
WorkTimelineSpan,
WorkTimelineEvent,
WorkTimelineEdge,
WorkTimelineResult,
ActivityEvent,
UserProfileActivitySummary,
UserProfileAgentUsage,
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import {
acceptIssueThreadInteractionSchema,
askUserQuestionsResultSchema,
createIssueThreadInteractionSchema,
} from "./validators/issue.js";
@@ -103,6 +104,44 @@ describe("issue thread interaction schemas", () => {
}
});
it("parses ask_user_questions supersede flags and expired results", () => {
const parsed = createIssueThreadInteractionSchema.parse({
kind: "ask_user_questions",
payload: {
version: 1,
title: "Choose scope",
supersedeOnUserComment: false,
questions: [
{
id: "scope",
prompt: "Which scope should I use?",
selectionMode: "single",
options: [{ id: "small", label: "Small" }],
},
],
},
});
expect(parsed).toMatchObject({
kind: "ask_user_questions",
continuationPolicy: "wake_assignee",
payload: {
supersedeOnUserComment: false,
},
});
expect(askUserQuestionsResultSchema.parse({
version: 1,
answers: [],
expirationReason: "superseded_by_comment",
commentId: "11111111-1111-4111-8111-111111111111",
summaryMarkdown: null,
})).toMatchObject({
expirationReason: "superseded_by_comment",
commentId: "11111111-1111-4111-8111-111111111111",
});
});
it("rejects unsafe request_confirmation target hrefs", () => {
const base = {
kind: "request_confirmation",
+116
View File
@@ -0,0 +1,116 @@
# Telemetry Data Contract
This document explains how contributors should use Paperclip's public telemetry
contract. It intentionally does not list individual events or dimensions.
The canonical source for first-party event names, dimensions, optionality,
allowed primitive value types, and enum descriptions is
`packages/shared/src/telemetry/generated/paperclip-telemetry.ts`.
Shared enum constants live in `packages/shared/src/constants.ts`. Use those
constants when code needs a reusable domain, but treat the generated telemetry
types as the final authority for emitted first-party telemetry shapes.
## Public Sources
Use these files when reviewing or changing telemetry code:
| Contract item | Public source |
| --- | --- |
| First-party event names | `PaperclipEventName` in `generated/paperclip-telemetry.ts` |
| Per-event dimensions and optionality | `EventDimensionsMap` in `generated/paperclip-telemetry.ts` |
| Enum descriptions for telemetry dimensions | `PAPERCLIP_ENUM_DESCRIPTIONS` in `generated/paperclip-telemetry.ts` |
| Schema version and event envelope helpers | `SCHEMA_VERSION`, `makeEvent()`, and `makeBatch()` in `generated/paperclip-telemetry.ts` |
| Runtime-safe event names and dimensions | `TelemetryEventName` and `TelemetryEventDimensions` in `types.ts` |
| Allowed primitive dimension values | `TelemetryDimensionValue` in `types.ts` |
| Shared reusable enum domains | Named exports in `constants.ts` |
| First-party typed emit helpers | `events.ts` |
| Generic client behavior | `client.ts` |
Do not copy generated event lists or dimension tables into this README. They
will drift as the generated contract changes.
## Emission Boundary
Paperclip telemetry uses named events with explicit dimension fields. Treat
open-ended string dimensions as public contract values, not as a place for user
content or private operational data. Do not send PII, secrets, credentials,
private paths, prompts, model output, or other sensitive values through
telemetry dimensions.
Telemetry emitters send raw dimension values. They must not pre-normalize
enum-like values into a reporting form just to match today's known domain.
The receiving layer owns canonicalization. Keeping canonicalization in one place
means emitters can stay simple and accurate: emit what the product observed, use
the generated contract for required and optional fields, and let the receiving
layer decide how legacy spellings, aliases, unknown names, and future values map
to a stable reporting shape.
Do not add client-side lowercasing, alias mapping, or fallback mapping unless
the generated telemetry contract specifically requires that emitted value.
If a dimension is privacy-protected before emission, emit only the protected
value and its matching public marker as defined by the typed helper or generated
contract. Do not emit private source material in telemetry dimensions.
## Dimension Values
Telemetry dimension values must be primitives. Use only the value types allowed
by `TelemetryDimensionValue`:
- `string`
- `number`
- `boolean`
Do not emit `null`, `undefined`, arrays, or objects as dimension values. Optional
dimensions should be omitted when absent.
When a dimension is enum-like, use the shared constant from `constants.ts` when
one exists. If no shared constant exists, use the generated telemetry type as the
domain. In all cases, the generated telemetry type remains the source of truth
for the emitted value.
## Required, Optional, And Sentinel Values
Required and optional dimensions are defined by `EventDimensionsMap`.
Required dimensions must be present for every event of that name. Optional
dimensions should be emitted only when the value is known and useful.
Sentinel values are only for required fields that have no observed raw value at
the emitting layer. Do not use a sentinel to hide a concrete value that is new,
custom, or not yet represented by a shared constant. Emit the concrete raw value
and let the receiving layer canonicalize it.
## Adding Or Changing Telemetry
Client code is responsible for emitting approved telemetry events at the right
place in the product. It is not responsible for deciding which new events should
exist. Do not introduce ad hoc event names, dimensions, or enum domains in client
code; they must exist in the generated telemetry contract before emitters use
them.
1. Start from `generated/paperclip-telemetry.ts`. The generated types are what
reviewers use to verify event names, dimensions, optionality, value types,
enum descriptions, and schema version.
2. Choose stable event and dimension names. Do not include user content, local
machine details, secrets, credentials, private paths, or values that are not
part of the public event contract.
3. Use only `string`, `number`, or `boolean` dimension values.
4. Reuse a shared constant from `constants.ts` for enum-like dimensions when one
exists. If the generated telemetry domain has values beyond a shared constant,
keep the emitter aligned with the generated telemetry type.
5. Keep emitters raw. Do not normalize, alias-map, or lowercase enum-like values
in the client unless the generated contract explicitly calls for that emitted
value.
6. Add or update a typed helper in `events.ts` when the event is first-party and
should have a stable helper API.
7. Update tests for helper behavior, including raw pass-through for enum-like
values when that is the intended boundary.
8. Update this README only when the contributor workflow, source-of-truth
pointers, or durable invariants change. Do not add an event catalog here.
Before opening a pull request, verify that the emitted code, typed helpers, and
generated telemetry contract agree. If they disagree, fix the contract or code
rather than documenting around the mismatch in this README.
@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import { TelemetryClient } from "./client.js";
describe("TelemetryClient event-name types", () => {
it("keeps dynamic telemetry separate from registered events", () => {
const client = new TelemetryClient(
{ enabled: false },
() => ({
installId: "test-install",
salt: "test-salt",
createdAt: "2026-01-01T00:00:00Z",
firstSeenVersion: "0.0.0",
}),
"0.0.0-test",
);
client.track("install.started", {});
client.trackDynamic("plugin.linear.sync_completed", {});
// @ts-expect-error plugin events are intentionally not registered first-party events.
client.track("plugin.linear.sync_completed", {});
expect(client).toBeInstanceOf(TelemetryClient);
});
});
+27 -2
View File
@@ -1,7 +1,9 @@
import { createHash } from "node:crypto";
import type {
TelemetryConfig,
TelemetryDimensions,
TelemetryEvent,
TelemetryEventDimensions,
TelemetryEventName,
TelemetryState,
} from "./types.js";
@@ -13,6 +15,11 @@ const DEFAULT_ENDPOINTS = [
const BATCH_SIZE = 50;
const SEND_TIMEOUT_MS = 5_000;
type TrackArgs<K extends TelemetryEventName> =
keyof TelemetryEventDimensions<K> extends never
? [dimensions?: TelemetryEventDimensions<K>]
: [dimensions: TelemetryEventDimensions<K>];
export class TelemetryClient {
private queue: TelemetryEvent[] = [];
private readonly config: TelemetryConfig;
@@ -27,14 +34,32 @@ export class TelemetryClient {
this.version = version;
}
track(eventName: TelemetryEventName, dimensions?: Record<string, string | number | boolean>): void {
/**
* Tracks first-party Paperclip telemetry events registered in the generated
* backend event schema.
*/
track<K extends TelemetryEventName>(eventName: K, ...args: TrackArgs<K>): void {
const [dimensions] = args;
this.enqueue(eventName, dimensions);
}
/**
* Tracks plugin telemetry bridge events whose names are built dynamically
* from third-party plugin input. The backend accepts only explicitly
* registered plugin events.
*/
trackDynamic(eventName: string, dimensions?: TelemetryDimensions): void {
this.enqueue(eventName, dimensions);
}
private enqueue(eventName: string, dimensions?: object): void {
if (!this.config.enabled) return;
this.getState(); // ensure state is initialised (side-effect: creates state file on first call)
this.queue.push({
name: eventName,
occurredAt: new Date().toISOString(),
dimensions: dimensions ?? {},
dimensions: { ...dimensions } as TelemetryDimensions,
});
if (this.queue.length >= BATCH_SIZE) {
+93 -24
View File
@@ -1,91 +1,123 @@
import type { TelemetryClient } from "./client.js";
import type { EventDimensionsMap } from "./generated/paperclip-telemetry.js";
type RawDimension<T extends string | undefined> = T | (string & {});
function asEventDimension<T extends string>(value: RawDimension<T>): T {
return value as T;
}
export function trackInstallStarted(client: TelemetryClient): void {
client.track("install.started");
client.track("install.started", {});
}
export function trackInstallCompleted(
client: TelemetryClient,
dims: { adapterType: string },
dims: { adapterType: RawDimension<EventDimensionsMap["install.completed"]["adapter_type"]> },
): void {
client.track("install.completed", { adapter_type: dims.adapterType });
client.track("install.completed", {
adapter_type: asEventDimension(dims.adapterType),
});
}
export function trackCompanyImported(
client: TelemetryClient,
dims: { sourceType: string; sourceRef: string; isPrivate: boolean },
dims: {
sourceType: RawDimension<EventDimensionsMap["company.imported"]["source_type"]>;
sourceRef: string;
isPrivate: boolean;
},
): void {
const ref = dims.isPrivate ? client.hashPrivateRef(dims.sourceRef) : dims.sourceRef;
client.track("company.imported", {
source_type: dims.sourceType,
source_type: asEventDimension(dims.sourceType),
source_ref: ref,
source_ref_hashed: dims.isPrivate,
});
}
export function trackProjectCreated(client: TelemetryClient): void {
client.track("project.created");
client.track("project.created", {});
}
export function trackRoutineCreated(client: TelemetryClient): void {
client.track("routine.created");
client.track("routine.created", {});
}
export function trackRoutineRun(
client: TelemetryClient,
dims: { source: string; status: string },
dims: {
source: RawDimension<EventDimensionsMap["routine.run"]["source"]>;
status: RawDimension<EventDimensionsMap["routine.run"]["status"]>;
},
): void {
client.track("routine.run", {
source: dims.source,
status: dims.status,
source: asEventDimension(dims.source),
status: asEventDimension(dims.status),
});
}
export function trackGoalCreated(
client: TelemetryClient,
dims?: { goalLevel?: string | null },
dims?: { goalLevel?: RawDimension<EventDimensionsMap["goal.created"]["goal_level"]> | null },
): void {
client.track("goal.created", dims?.goalLevel ? { goal_level: dims.goalLevel } : undefined);
client.track("goal.created", {
goal_level: dims?.goalLevel ? asEventDimension(dims.goalLevel) : "other",
});
}
export function trackAgentCreated(
client: TelemetryClient,
dims: { agentRole: string; agentId?: string },
dims: {
agentRole: RawDimension<EventDimensionsMap["agent.created"]["agent_role"]>;
agentId: string;
},
): void {
client.track("agent.created", {
agent_role: dims.agentRole,
...(dims.agentId ? { agent_id: dims.agentId } : {}),
agent_role: asEventDimension(dims.agentRole),
agent_id: dims.agentId,
});
}
export function trackSkillImported(
client: TelemetryClient,
dims: { sourceType: string; skillRef?: string | null },
dims: {
sourceType: RawDimension<EventDimensionsMap["skill.imported"]["source_type"]>;
skillRef?: string | null;
},
): void {
client.track("skill.imported", {
source_type: dims.sourceType,
source_type: asEventDimension(dims.sourceType),
...(dims.skillRef ? { skill_ref: dims.skillRef } : {}),
});
}
export function trackAgentFirstHeartbeat(
client: TelemetryClient,
dims: { agentRole: string; agentId?: string },
dims: {
agentRole: RawDimension<EventDimensionsMap["agent.first_heartbeat"]["agent_role"]>;
agentId: string;
},
): void {
client.track("agent.first_heartbeat", {
agent_role: dims.agentRole,
...(dims.agentId ? { agent_id: dims.agentId } : {}),
agent_role: asEventDimension(dims.agentRole),
agent_id: dims.agentId,
});
}
export function trackAgentTaskCompleted(
client: TelemetryClient,
dims: { agentRole: string; agentId?: string; adapterType?: string; model?: string },
dims: {
agentRole: RawDimension<EventDimensionsMap["agent.task_completed"]["agent_role"]>;
agentId: string;
adapterType: RawDimension<EventDimensionsMap["agent.task_completed"]["adapter_type"]>;
model?: string;
},
): void {
client.track("agent.task_completed", {
agent_role: dims.agentRole,
...(dims.agentId ? { agent_id: dims.agentId } : {}),
...(dims.adapterType ? { adapter_type: dims.adapterType } : {}),
agent_role: asEventDimension(dims.agentRole),
agent_id: dims.agentId,
adapter_type: asEventDimension(dims.adapterType),
...(dims.model ? { model: dims.model } : {}),
});
}
@@ -96,3 +128,40 @@ export function trackErrorHandlerCrash(
): void {
client.track("error.handler_crash", { error_code: dims.errorCode });
}
export function trackInteractionResolved(
client: TelemetryClient,
dims: {
interactionKind: RawDimension<EventDimensionsMap["interaction.resolved"]["interaction_kind"]>;
status: RawDimension<EventDimensionsMap["interaction.resolved"]["status"]>;
resolvedByKind: RawDimension<EventDimensionsMap["interaction.resolved"]["resolved_by_kind"]>;
resolutionReason?: RawDimension<EventDimensionsMap["interaction.resolved"]["resolution_reason"]> | null;
createdByKind?: RawDimension<EventDimensionsMap["interaction.resolved"]["created_by_kind"]> | null;
creatorAgentRole?: RawDimension<EventDimensionsMap["interaction.resolved"]["creator_agent_role"]> | null;
continuationPolicy?: RawDimension<EventDimensionsMap["interaction.resolved"]["continuation_policy"]> | null;
targetType?: RawDimension<EventDimensionsMap["interaction.resolved"]["target_type"]> | null;
optionCount?: number;
selectedOptionCount?: number;
questionCount?: number;
answeredQuestionCount?: number;
createdTaskCount?: number;
skippedTaskCount?: number;
},
): void {
client.track("interaction.resolved", {
interaction_kind: asEventDimension(dims.interactionKind),
status: asEventDimension(dims.status),
resolved_by_kind: asEventDimension(dims.resolvedByKind),
...(dims.resolutionReason ? { resolution_reason: asEventDimension(dims.resolutionReason) } : {}),
...(dims.createdByKind ? { created_by_kind: asEventDimension(dims.createdByKind) } : {}),
...(dims.creatorAgentRole ? { creator_agent_role: asEventDimension(dims.creatorAgentRole) } : {}),
...(dims.continuationPolicy ? { continuation_policy: asEventDimension(dims.continuationPolicy) } : {}),
...(dims.targetType ? { target_type: asEventDimension(dims.targetType) } : {}),
...(dims.optionCount === undefined ? {} : { option_count: dims.optionCount }),
...(dims.selectedOptionCount === undefined ? {} : { selected_option_count: dims.selectedOptionCount }),
...(dims.questionCount === undefined ? {} : { question_count: dims.questionCount }),
...(dims.answeredQuestionCount === undefined ? {} : { answered_question_count: dims.answeredQuestionCount }),
...(dims.createdTaskCount === undefined ? {} : { created_task_count: dims.createdTaskCount }),
...(dims.skippedTaskCount === undefined ? {} : { skipped_task_count: dims.skippedTaskCount }),
});
}
@@ -0,0 +1,371 @@
// GENERATED — DO NOT EDIT.
export interface PaperclipAgentCreatedDimensions {
agent_id: string
agent_role: ("ceo" | "cto" | "cmo" | "cfo" | "security" | "engineer" | "designer" | "pm" | "qa" | "devops" | "researcher" | "general" | "other")
}
export interface PaperclipAgentFirstHeartbeatDimensions {
agent_id: string
agent_role: ("ceo" | "cto" | "cmo" | "cfo" | "security" | "engineer" | "designer" | "pm" | "qa" | "devops" | "researcher" | "general" | "other")
}
export interface PaperclipAgentTaskCompletedDimensions {
adapter_type: ("process" | "http" | "acpx_local" | "claude_local" | "codex_local" | "cursor_cloud" | "gemini_local" | "hermes_gateway" | "hermes_local" | "opencode_local" | "pi_local" | "cursor" | "openclaw_gateway" | "grok_local" | "other")
agent_id: string
agent_role: ("ceo" | "cto" | "cmo" | "cfo" | "security" | "engineer" | "designer" | "pm" | "qa" | "devops" | "researcher" | "general" | "other")
model?: string
}
export interface PaperclipCompanyImportedDimensions {
source_type: ("local_path" | "github" | "url" | "catalog" | "skills_sh" | "unknown")
source_ref?: string
source_ref_hashed?: boolean
}
export interface PaperclipErrorHandlerCrashDimensions {
error_code: string
}
export interface PaperclipGoalCreatedDimensions {
goal_level: ("company" | "team" | "agent" | "task" | "other")
}
export interface PaperclipInstallCompletedDimensions {
adapter_type: ("process" | "http" | "acpx_local" | "claude_local" | "codex_local" | "cursor_cloud" | "gemini_local" | "hermes_gateway" | "hermes_local" | "opencode_local" | "pi_local" | "cursor" | "openclaw_gateway" | "grok_local" | "other")
}
export interface PaperclipInstallStartedDimensions {
}
export interface PaperclipInteractionResolvedDimensions {
interaction_kind: ("suggest_tasks" | "ask_user_questions" | "request_confirmation" | "request_checkbox_confirmation" | "other")
status: ("accepted" | "rejected" | "answered" | "cancelled" | "expired" | "failed" | "other")
resolution_reason?: ("accepted" | "rejected" | "stale_target" | "superseded_by_comment" | "expired" | "cancelled" | "other")
resolved_by_kind: ("user" | "agent" | "system" | "other")
created_by_kind?: ("agent" | "user" | "other")
creator_agent_role?: ("ceo" | "cto" | "cmo" | "cfo" | "security" | "engineer" | "designer" | "pm" | "qa" | "devops" | "researcher" | "general" | "other")
continuation_policy?: ("none" | "wake_assignee" | "wake_assignee_on_accept" | "other")
target_type?: ("issue_document" | "custom" | "none" | "other")
option_count?: number
selected_option_count?: number
question_count?: number
answered_question_count?: number
created_task_count?: number
skipped_task_count?: number
has_reason?: boolean
resolution_latency_seconds?: number
interaction_id?: string
created_by_agent_id?: string
source_run_id?: string
}
export interface PaperclipProjectCreatedDimensions {
}
export interface PaperclipRoutineCreatedDimensions {
}
export interface PaperclipRoutineRunDimensions {
source: ("schedule" | "manual" | "api" | "webhook" | "other")
status: ("received" | "coalesced" | "skipped" | "issue_created" | "completed" | "failed" | "other")
}
export interface PaperclipSkillImportedDimensions {
source_type: ("local_path" | "github" | "url" | "catalog" | "skills_sh" | "unknown")
skill_ref?: string
}
export type PaperclipEventName =
| "agent.created"
| "agent.first_heartbeat"
| "agent.task_completed"
| "company.imported"
| "error.handler_crash"
| "goal.created"
| "install.completed"
| "install.started"
| "interaction.resolved"
| "project.created"
| "routine.created"
| "routine.run"
| "skill.imported";
export interface EventDimensionsMap {
"agent.created": PaperclipAgentCreatedDimensions;
"agent.first_heartbeat": PaperclipAgentFirstHeartbeatDimensions;
"agent.task_completed": PaperclipAgentTaskCompletedDimensions;
"company.imported": PaperclipCompanyImportedDimensions;
"error.handler_crash": PaperclipErrorHandlerCrashDimensions;
"goal.created": PaperclipGoalCreatedDimensions;
"install.completed": PaperclipInstallCompletedDimensions;
"install.started": PaperclipInstallStartedDimensions;
"interaction.resolved": PaperclipInteractionResolvedDimensions;
"project.created": PaperclipProjectCreatedDimensions;
"routine.created": PaperclipRoutineCreatedDimensions;
"routine.run": PaperclipRoutineRunDimensions;
"skill.imported": PaperclipSkillImportedDimensions;
}
export const PAPERCLIP_EVENTS = {
"agent.created": "agent.created",
"agent.first_heartbeat": "agent.first_heartbeat",
"agent.task_completed": "agent.task_completed",
"company.imported": "company.imported",
"error.handler_crash": "error.handler_crash",
"goal.created": "goal.created",
"install.completed": "install.completed",
"install.started": "install.started",
"interaction.resolved": "interaction.resolved",
"project.created": "project.created",
"routine.created": "routine.created",
"routine.run": "routine.run",
"skill.imported": "skill.imported",
} as const;
export const PAPERCLIP_ENUM_DESCRIPTIONS = {
"agent.created": {
"agent_role": {
"ceo": "Agent configured for company leadership and board coordination work.",
"cto": "Agent configured for technical leadership, architecture, and engineering coordination.",
"cmo": "Agent configured for marketing leadership work.",
"cfo": "Agent configured for finance leadership work.",
"security": "Agent configured for security review, risk, or policy work.",
"engineer": "Agent configured for general software engineering work.",
"designer": "Agent configured for product, visual, or experience design work.",
"pm": "Agent configured for product management or planning work.",
"qa": "Agent configured for quality assurance, testing, or validation work.",
"devops": "Agent configured for infrastructure, deployment, or operations work.",
"researcher": "Agent configured for research and information-gathering work.",
"general": "Agent configured as a general-purpose worker without a more specific role.",
"other": "Fallback when the agent role is unknown or not represented by the tracked enum."
}
},
"agent.first_heartbeat": {
"agent_role": {
"ceo": "Agent configured for company leadership and board coordination work.",
"cto": "Agent configured for technical leadership, architecture, and engineering coordination.",
"cmo": "Agent configured for marketing leadership work.",
"cfo": "Agent configured for finance leadership work.",
"security": "Agent configured for security review, risk, or policy work.",
"engineer": "Agent configured for general software engineering work.",
"designer": "Agent configured for product, visual, or experience design work.",
"pm": "Agent configured for product management or planning work.",
"qa": "Agent configured for quality assurance, testing, or validation work.",
"devops": "Agent configured for infrastructure, deployment, or operations work.",
"researcher": "Agent configured for research and information-gathering work.",
"general": "Agent configured as a general-purpose worker without a more specific role.",
"other": "Fallback when the agent role is unknown or not represented by the tracked enum."
}
},
"agent.task_completed": {
"adapter_type": {
"process": "Agent runtime uses a local process adapter.",
"http": "Agent runtime uses a generic HTTP adapter.",
"acpx_local": "Agent runtime uses the local ACPX adapter.",
"claude_local": "Agent runtime uses the local Claude adapter.",
"codex_local": "Agent runtime uses the local Codex adapter.",
"cursor_cloud": "Agent runtime uses the Cursor cloud adapter.",
"gemini_local": "Agent runtime uses the local Gemini adapter.",
"hermes_gateway": "Agent runtime uses the Hermes gateway adapter.",
"hermes_local": "Agent runtime uses the local Hermes adapter.",
"opencode_local": "Agent runtime uses the local OpenCode adapter.",
"pi_local": "Agent runtime uses the local Pi adapter.",
"cursor": "Agent runtime uses the Cursor adapter.",
"openclaw_gateway": "Agent runtime uses the OpenClaw gateway adapter.",
"grok_local": "Agent runtime uses the local Grok adapter.",
"other": "Fallback when the adapter type is unknown or not represented by the tracked enum."
},
"agent_role": {
"ceo": "Agent configured for company leadership and board coordination work.",
"cto": "Agent configured for technical leadership, architecture, and engineering coordination.",
"cmo": "Agent configured for marketing leadership work.",
"cfo": "Agent configured for finance leadership work.",
"security": "Agent configured for security review, risk, or policy work.",
"engineer": "Agent configured for general software engineering work.",
"designer": "Agent configured for product, visual, or experience design work.",
"pm": "Agent configured for product management or planning work.",
"qa": "Agent configured for quality assurance, testing, or validation work.",
"devops": "Agent configured for infrastructure, deployment, or operations work.",
"researcher": "Agent configured for research and information-gathering work.",
"general": "Agent configured as a general-purpose worker without a more specific role.",
"other": "Fallback when the agent role is unknown or not represented by the tracked enum."
}
},
"company.imported": {
"source_type": {
"local_path": "Import source came from a filesystem path on the operator's machine.",
"github": "Import source came from a GitHub repository or GitHub-backed reference.",
"url": "Import source came from a direct URL.",
"catalog": "Import source came from a Paperclip catalog entry.",
"skills_sh": "Import source came from a Skills.sh-compatible source.",
"unknown": "Source type could not be classified."
}
},
"goal.created": {
"goal_level": {
"company": "Goal applies at company scope.",
"team": "Goal applies at team or group scope.",
"agent": "Goal applies to a specific agent.",
"task": "Goal applies to task-level work.",
"other": "Fallback when the goal level is unknown or not represented by the tracked enum."
}
},
"install.completed": {
"adapter_type": {
"process": "Agent runtime uses a local process adapter.",
"http": "Agent runtime uses a generic HTTP adapter.",
"acpx_local": "Agent runtime uses the local ACPX adapter.",
"claude_local": "Agent runtime uses the local Claude adapter.",
"codex_local": "Agent runtime uses the local Codex adapter.",
"cursor_cloud": "Agent runtime uses the Cursor cloud adapter.",
"gemini_local": "Agent runtime uses the local Gemini adapter.",
"hermes_gateway": "Agent runtime uses the Hermes gateway adapter.",
"hermes_local": "Agent runtime uses the local Hermes adapter.",
"opencode_local": "Agent runtime uses the local OpenCode adapter.",
"pi_local": "Agent runtime uses the local Pi adapter.",
"cursor": "Agent runtime uses the Cursor adapter.",
"openclaw_gateway": "Agent runtime uses the OpenClaw gateway adapter.",
"grok_local": "Agent runtime uses the local Grok adapter.",
"other": "Fallback when the adapter type is unknown or not represented by the tracked enum."
}
},
"interaction.resolved": {
"interaction_kind": {
"suggest_tasks": "Board-facing interaction that proposes concrete subtasks for acceptance.",
"ask_user_questions": "Board-facing interaction that asks structured questions and stores answers.",
"request_confirmation": "Board-facing interaction that asks for a single accept or reject decision.",
"request_checkbox_confirmation": "Board-facing interaction that asks the board to select options and confirm.",
"other": "Fallback when the interaction kind is unknown or not represented by the tracked enum."
},
"status": {
"accepted": "Interaction was accepted.",
"rejected": "Interaction was rejected.",
"answered": "Ask-user-questions interaction was answered.",
"cancelled": "Interaction was cancelled before acceptance or answer.",
"expired": "Interaction expired because the bound target became stale or was superseded.",
"failed": "Interaction resolution attempted but failed.",
"other": "Fallback when the terminal status is unknown or not represented by the tracked enum."
},
"resolution_reason": {
"accepted": "Stored result outcome says the interaction was accepted.",
"rejected": "Stored result outcome says the interaction was rejected.",
"stale_target": "Bound target, such as an issue document revision, was no longer current.",
"superseded_by_comment": "A later user or board comment superseded the pending confirmation.",
"expired": "Interaction expired for a generic expiration reason.",
"cancelled": "Interaction was explicitly cancelled.",
"other": "Fallback when the resolution reason is unknown or not represented by the tracked enum."
},
"resolved_by_kind": {
"user": "A board or human user resolved the interaction.",
"agent": "An agent resolved the interaction.",
"system": "Paperclip resolved the interaction automatically.",
"other": "Fallback when the resolver kind is unknown or not represented by the tracked enum."
},
"created_by_kind": {
"agent": "Interaction was created by an agent.",
"user": "Interaction was created by a board or human user.",
"other": "Fallback when the creator kind is unknown or not represented by the tracked enum."
},
"creator_agent_role": {
"ceo": "Agent configured for company leadership and board coordination work.",
"cto": "Agent configured for technical leadership, architecture, and engineering coordination.",
"cmo": "Agent configured for marketing leadership work.",
"cfo": "Agent configured for finance leadership work.",
"security": "Agent configured for security review, risk, or policy work.",
"engineer": "Agent configured for general software engineering work.",
"designer": "Agent configured for product, visual, or experience design work.",
"pm": "Agent configured for product management or planning work.",
"qa": "Agent configured for quality assurance, testing, or validation work.",
"devops": "Agent configured for infrastructure, deployment, or operations work.",
"researcher": "Agent configured for research and information-gathering work.",
"general": "Agent configured as a general-purpose worker without a more specific role.",
"other": "Fallback when the agent role is unknown or not represented by the tracked enum."
},
"continuation_policy": {
"none": "Resolving the interaction does not automatically wake the issue assignee.",
"wake_assignee": "Resolving the interaction wakes or returns the issue to the assignee.",
"wake_assignee_on_accept": "Accepting the interaction wakes or returns the issue to the assignee.",
"other": "Fallback when the continuation policy is unknown or not represented by the tracked enum."
},
"target_type": {
"issue_document": "Interaction is bound to a specific issue document revision.",
"custom": "Interaction is bound to a custom target.",
"none": "Interaction has no bound target.",
"other": "Fallback when the target type is unknown or not represented by the tracked enum."
}
},
"routine.run": {
"source": {
"schedule": "Routine was triggered by a scheduled trigger.",
"manual": "Routine was triggered manually by a user or agent action.",
"api": "Routine was triggered through an API request.",
"webhook": "Routine was triggered by a webhook.",
"other": "Fallback when the source is unknown or not represented by the tracked enum."
},
"status": {
"received": "Routine run was accepted for processing.",
"coalesced": "A live execution already existed and the run was coalesced into it.",
"skipped": "A live execution already existed and concurrency policy skipped the run.",
"issue_created": "Routine dispatch created a new issue and queued the agent wakeup.",
"completed": "Routine run completed without needing a new issue.",
"failed": "Routine dispatch failed and the run was finalized as failed.",
"other": "Fallback when the status is unknown or not represented by the tracked enum."
}
},
"skill.imported": {
"source_type": {
"local_path": "Import source came from a filesystem path on the operator's machine.",
"github": "Import source came from a GitHub repository or GitHub-backed reference.",
"url": "Import source came from a direct URL.",
"catalog": "Import source came from a Paperclip catalog entry.",
"skills_sh": "Import source came from a Skills.sh-compatible source.",
"unknown": "Source type could not be classified."
}
}
} as const;
export const SCHEMA_VERSION = "1" as const;
export interface PaperclipTelemetryEvent<K extends PaperclipEventName = PaperclipEventName> {
name: K
occurredAt: string
dimensions: EventDimensionsMap[K]
}
export type AnyPaperclipTelemetryEvent = {
[K in PaperclipEventName]: PaperclipTelemetryEvent<K>
}[PaperclipEventName];
export interface PaperclipTelemetryBatch {
app: "paperclip"
schemaVersion: typeof SCHEMA_VERSION
installId: string
version?: string
events: AnyPaperclipTelemetryEvent[]
}
export function makeEvent<K extends PaperclipEventName>(
name: K,
dimensions: EventDimensionsMap[K],
occurredAt: string
): PaperclipTelemetryEvent<K> {
return { name, occurredAt, dimensions };
}
export function makeBatch(
installId: string,
events: readonly AnyPaperclipTelemetryEvent[],
version?: string
): PaperclipTelemetryBatch {
return {
app: "paperclip",
schemaVersion: SCHEMA_VERSION,
installId,
...(version === undefined ? {} : { version }),
events: [...events]
};
}
+10
View File
@@ -14,11 +14,21 @@ export {
trackAgentFirstHeartbeat,
trackAgentTaskCompleted,
trackErrorHandlerCrash,
trackInteractionResolved,
} from "./events.js";
export type {
TelemetryConfig,
TelemetryState,
TelemetryEvent,
TelemetryEventEnvelope,
TelemetryDimensions,
TelemetryDimensionValue,
TelemetryEventDimensions,
TelemetryEventName,
RegisteredPluginEventName,
} from "./types.js";
export type {
AnyPaperclipTelemetryEvent,
EventDimensionsMap,
PaperclipEventName,
} from "./generated/paperclip-telemetry.js";
@@ -0,0 +1,19 @@
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import * as generatedTelemetry from "./generated/paperclip-telemetry.js";
const readmePath = fileURLToPath(new URL("./README.md", import.meta.url));
describe("telemetry README contract", () => {
it("documents generated event helper exports that exist", () => {
const readme = readFileSync(readmePath, "utf8");
expect(generatedTelemetry.SCHEMA_VERSION).toBeDefined();
expect(generatedTelemetry.makeEvent).toBeTypeOf("function");
expect(generatedTelemetry.makeBatch).toBeTypeOf("function");
expect(readme).toContain("`SCHEMA_VERSION`, `makeEvent()`, and `makeBatch()`");
expect(readme).not.toContain("createPaperclipTelemetryEvent");
expect(readme).not.toContain("createPaperclipTelemetryEnvelope");
});
});
+14 -15
View File
@@ -1,3 +1,8 @@
import type {
EventDimensionsMap,
PaperclipEventName,
} from "./generated/paperclip-telemetry.js";
export interface TelemetryState {
installId: string;
salt: string;
@@ -12,11 +17,14 @@ export interface TelemetryConfig {
schemaVersion?: string;
}
export type TelemetryDimensionValue = string | number | boolean;
export type TelemetryDimensions = Record<string, TelemetryDimensionValue>;
/** Per-event object inside the backend envelope */
export interface TelemetryEvent {
name: string;
occurredAt: string;
dimensions: Record<string, string | number | boolean>;
dimensions: TelemetryDimensions;
}
/** Full payload sent to the backend ingest endpoint */
@@ -28,17 +36,8 @@ export interface TelemetryEventEnvelope {
events: TelemetryEvent[];
}
export type TelemetryEventName =
| "install.started"
| "install.completed"
| "company.imported"
| "project.created"
| "routine.created"
| "routine.run"
| "goal.created"
| "agent.created"
| "skill.imported"
| "agent.first_heartbeat"
| "agent.task_completed"
| "error.handler_crash"
| `plugin.${string}`;
export type RegisteredPluginEventName = never;
export type TelemetryEventName = PaperclipEventName | RegisteredPluginEventName;
export type TelemetryEventDimensions<K extends TelemetryEventName> =
K extends keyof EventDimensionsMap ? EventDimensionsMap[K] : never;
+11
View File
@@ -324,6 +324,7 @@ export type {
IssueReviewRequest,
IssueExecutionDecision,
IssueComment,
IssueCommentDerivedAuthorSource,
IssueCommentMetadata,
IssueCommentMetadataSection,
IssueCommentMetadataRow,
@@ -471,6 +472,16 @@ export type {
} from "./heartbeat.js";
export type { LiveEvent } from "./live.js";
export type { DashboardRunActivityDay, DashboardSummary } from "./dashboard.js";
export type {
TimelineActorType,
TimelineEventKind,
TimelineEdgeKind,
WorkTimelineActor,
WorkTimelineSpan,
WorkTimelineEvent,
WorkTimelineEdge,
WorkTimelineResult,
} from "./work-timeline.js";
export type { ActivityEvent } from "./activity.js";
export type {
UserProfileActivitySummary,
+21 -1
View File
@@ -585,6 +585,7 @@ export interface Issue {
successfulRunHandoff?: SuccessfulRunHandoffState | null;
watchdog?: IssueWatchdogSummary | null;
scheduledRetry?: IssueScheduledRetry | null;
liveDescendantCount?: number;
relatedWork?: IssueRelatedWorkSummary;
referencedIssueIdentifiers?: string[];
planDocument?: IssueDocument | null;
@@ -603,6 +604,22 @@ export interface Issue {
updatedAt: Date;
}
/**
* Where a comment's derived (non-stored-author) agent attribution came from,
* in descending confidence:
* - `run_id`: comment carries a `createdByRunId`/`derivedCreatedByRunId` whose
* run resolves directly to an agent (lossless).
* - `run_log_comment_post`: a run log within the comment window contains the
* `comment id: {id}` post marker (lossless: the run recorded posting it).
*
* Only lossless signals are used. Pure run-window timing overlap is NOT a
* source — it cannot distinguish an agent comment from a human board comment
* that coincided with a run (Option A).
*/
export type IssueCommentDerivedAuthorSource =
| "run_id"
| "run_log_comment_post";
export interface IssueComment {
id: string;
companyId: string;
@@ -613,7 +630,7 @@ export interface IssueComment {
createdByRunId?: string | null;
derivedAuthorAgentId?: string | null;
derivedCreatedByRunId?: string | null;
derivedAuthorSource?: "run_log_comment_post" | null;
derivedAuthorSource?: IssueCommentDerivedAuthorSource | null;
body: string;
presentation: IssueCommentPresentation | null;
metadata: IssueCommentMetadata | null;
@@ -760,6 +777,7 @@ export interface AskUserQuestionsPayload {
version: 1;
title?: string | null;
submitLabel?: string | null;
supersedeOnUserComment?: boolean;
questions: AskUserQuestionsQuestion[];
}
@@ -774,6 +792,8 @@ export interface AskUserQuestionsResult {
answers: AskUserQuestionsAnswer[];
cancelled?: true;
cancellationReason?: string | null;
expirationReason?: "superseded_by_comment";
commentId?: string | null;
summaryMarkdown?: string | null;
}
@@ -0,0 +1,73 @@
/**
* Work Timeline (Gantt) types — shared between the aggregation service
* (`server/src/services/work-timeline.ts`) and the UI page
* (`ui/src/pages/Timeline.tsx`). Defined here so both sides consume one contract
* without redefining DTOs. Returned by `GET /api/companies/:companyId/timeline`.
*/
export type TimelineActorType = "agent" | "user" | "system" | "plugin";
export type TimelineEventKind = "created" | "commented" | "approved" | "delegated" | "assigned";
export type TimelineEdgeKind = "delegation" | "assignment" | "mention";
export interface WorkTimelineActor {
/** Namespaced id, e.g. `agent:<id>`, `user:<id>`, `system:<id>`. */
id: string;
type: TimelineActorType;
name: string;
avatar?: string | null;
}
export interface WorkTimelineSpan {
actorId: string;
laneHint: string | null;
runId: string;
issueId: string;
issueIdentifier: string | null;
/** Human-readable issue title, shown truncated in the hover tooltip (bars carry no ID). */
issueTitle: string | null;
/** ISO timestamp of run start. */
start: string;
/** ISO timestamp of run finish, or null when the run is still in progress. */
end: string | null;
status: string;
retryOfRunId?: string | null;
continuationAttempt?: number;
invocationSource?: string | null;
}
export interface WorkTimelineEvent {
actorId: string;
kind: TimelineEventKind;
issueId: string;
/** ISO timestamp. */
at: string;
}
export interface WorkTimelineEdge {
fromActorId: string;
toActorId: string;
issueId: string;
/** ISO timestamp. */
at: string;
kind: TimelineEdgeKind;
}
export interface WorkTimelineResult {
actors: WorkTimelineActor[];
spans: WorkTimelineSpan[];
events: WorkTimelineEvent[];
edges: WorkTimelineEdge[];
pagination: {
limit: number;
offset: number;
totalIssues: number;
hasMore: boolean;
};
window: {
/** ISO timestamp of the window start. */
from: string;
/** ISO timestamp of the window end. */
to: string;
capped: boolean;
};
}
@@ -32,7 +32,6 @@ export type EnvironmentCustomImageSetupConnectionSummary =
export const environmentCustomImageTemplateSchema = z.object({
id: z.string().uuid(),
companyId: z.string().uuid(),
environmentId: z.string().uuid(),
provider: providerKeySchema,
templateKind: environmentCustomImageTemplateKindSchema,
@@ -54,7 +53,6 @@ export type EnvironmentCustomImageTemplate =
export const environmentCustomImageSetupSessionSchema = z.object({
id: z.string().uuid(),
companyId: z.string().uuid(),
environmentId: z.string().uuid(),
templateId: z.string().uuid().nullable(),
promotedTemplateId: z.string().uuid().nullable(),
+3
View File
@@ -657,6 +657,7 @@ export const askUserQuestionsPayloadSchema = z.object({
version: z.literal(1),
title: z.string().trim().max(240).nullable().optional(),
submitLabel: z.string().trim().max(120).nullable().optional(),
supersedeOnUserComment: z.boolean().optional(),
questions: z.array(askUserQuestionsQuestionSchema).min(1).max(10),
}).superRefine((value, ctx) => {
const seenQuestionIds = new Set<string>();
@@ -695,6 +696,8 @@ export const askUserQuestionsResultSchema = z.object({
answers: z.array(askUserQuestionsAnswerSchema).max(20),
cancelled: z.literal(true).optional(),
cancellationReason: z.string().trim().max(4000).nullable().optional(),
expirationReason: z.literal("superseded_by_comment").optional(),
commentId: z.string().uuid().nullable().optional(),
summaryMarkdown: z.string().max(20000).nullable().optional(),
});
@@ -157,12 +157,10 @@ describeEmbeddedPostgres("environmentCustomImageService", () => {
async function seed() {
const companyId = randomUUID();
const otherCompanyId = randomUUID();
const environmentId = randomUUID();
await db.insert(companies).values([
await db.insert(companies).values(
{ id: companyId, name: "Acme", issuePrefix: `A${companyId.slice(0, 4)}` },
{ id: otherCompanyId, name: "Other", issuePrefix: `B${otherCompanyId.slice(0, 4)}` },
]);
);
await db.insert(environments).values({
id: environmentId,
name: `Fake ${environmentId.slice(0, 8)}`,
@@ -184,16 +182,15 @@ describeEmbeddedPostgres("environmentCustomImageService", () => {
manifestJson: pluginManifest(),
status: "ready",
});
return { companyId, otherCompanyId, environmentId };
return { companyId, environmentId };
}
it("starts, refreshes, finishes, refreshes again, and rolls back setup sessions", async () => {
const { companyId, environmentId } = await seed();
const { environmentId } = await seed();
const workerManager = createWorkerManager();
const service = environmentCustomImageService(db, { pluginWorkerManager: workerManager });
const started = await service.startSetupSession({
companyId,
environmentId,
actor: { userId: "user-1" },
ttlSeconds: 600,
@@ -221,25 +218,69 @@ describeEmbeddedPostgres("environmentCustomImageService", () => {
});
const refresh = await service.startSetupSession({
companyId,
environmentId,
actor: { userId: "user-1" },
});
const replacement = await service.finishSetupSession({ sessionId: refresh.session.id });
expect(replacement.template.id).not.toBe(promoted.template.id);
const rollback = await service.rollbackTemplate({ companyId, environmentId });
const rollback = await service.rollbackTemplate({ environmentId });
expect(rollback.activeTemplate.id).toBe(promoted.template.id);
expect(rollback.supersededTemplate.id).toBe(replacement.template.id);
});
it("revokes the active template before deleting the provider template", async () => {
it("reuses the setup provider company context across lifecycle calls", async () => {
const { companyId, environmentId } = await seed();
const workerManager = createWorkerManager();
const service = environmentCustomImageService(db, { pluginWorkerManager: workerManager });
const started = await service.startSetupSession({
companyId,
environmentId,
actor: { userId: "user-1" },
secretContextCompanyId: companyId,
});
expect(started.session.metadata).toMatchObject({
setupRpcCompanyId: companyId,
});
await service.refreshSetupSession({
sessionId: started.session.id,
includeConnectionPayload: true,
});
const promoted = await service.finishSetupSession({ sessionId: started.session.id });
const lifecycleCalls = workerManager.call.mock.calls
.filter(([, method]) => [
"environmentStartInteractiveSetup",
"environmentGetInteractiveSetup",
"environmentCaptureTemplate",
"environmentCancelInteractiveSetup",
].includes(method))
.map(([, method, params]) => ({
method,
companyId: (params as Record<string, unknown>).companyId,
}));
expect(lifecycleCalls).toEqual([
{ method: "environmentStartInteractiveSetup", companyId },
{ method: "environmentGetInteractiveSetup", companyId },
{ method: "environmentCaptureTemplate", companyId },
{ method: "environmentCancelInteractiveSetup", companyId },
]);
expect(promoted.session.metadata).toMatchObject({
setupRpcCompanyId: companyId,
});
expect(promoted.template.metadata).toMatchObject({
setupRpcCompanyId: companyId,
});
});
it("revokes the active template before deleting the provider template", async () => {
const { environmentId } = await seed();
const workerManager = createWorkerManager();
const service = environmentCustomImageService(db, { pluginWorkerManager: workerManager });
const started = await service.startSetupSession({
environmentId,
actor: { userId: "user-1" },
});
@@ -258,7 +299,6 @@ describeEmbeddedPostgres("environmentCustomImageService", () => {
});
const disabled = await service.disableTemplate({
companyId,
environmentId,
deleteProviderTemplate: true,
});
@@ -276,13 +316,53 @@ describeEmbeddedPostgres("environmentCustomImageService", () => {
);
});
it("does not send provider template refs to a different current provider", async () => {
const { environmentId } = await seed();
const workerManager = createWorkerManager();
const service = environmentCustomImageService(db, { pluginWorkerManager: workerManager });
const started = await service.startSetupSession({
environmentId,
actor: { userId: "user-1" },
});
const promoted = await service.finishSetupSession({ sessionId: started.session.id });
const deleteCallsBefore = workerManager.call.mock.calls
.filter(([, method]) => method === "environmentDeleteTemplate")
.length;
await db.update(environments)
.set({
config: {
provider: "other-plugin",
image: "other:base",
reuseLease: false,
},
})
.where(eq(environments.id, environmentId));
await expect(service.disableTemplate({
environmentId,
deleteProviderTemplate: true,
})).rejects.toThrow("Environment customImage provider changed");
const deleteCallsAfter = workerManager.call.mock.calls
.filter(([, method]) => method === "environmentDeleteTemplate")
.length;
const [templateRow] = await db
.select({ status: environmentCustomImageTemplates.status })
.from(environmentCustomImageTemplates)
.where(eq(environmentCustomImageTemplates.id, promoted.template.id));
expect(deleteCallsAfter).toBe(deleteCallsBefore);
expect(templateRow?.status).toBe("active");
});
it("cancels and times out setup sessions without changing the active template", async () => {
const { companyId, environmentId } = await seed();
const { environmentId } = await seed();
const workerManager = createWorkerManager();
const service = environmentCustomImageService(db, { pluginWorkerManager: workerManager });
const started = await service.startSetupSession({
companyId,
environmentId,
actor: { userId: "user-1" },
});
@@ -291,10 +371,9 @@ describeEmbeddedPostgres("environmentCustomImageService", () => {
reason: "user_cancelled",
});
expect(cancelled.status).toBe("cancelled");
expect(await service.getActiveTemplate({ companyId, environmentId })).toBeNull();
expect(await service.getActiveTemplate({ environmentId })).toBeNull();
const expired = await service.startSetupSession({
companyId,
environmentId,
actor: { userId: "user-1" },
ttlSeconds: 60,
@@ -308,21 +387,32 @@ describeEmbeddedPostgres("environmentCustomImageService", () => {
expect(timedOut?.status).toBe("timed_out");
});
it("rejects templates from another company or environment", async () => {
const { companyId, otherCompanyId, environmentId } = await seed();
it("rejects templates from another environment", async () => {
const { environmentId } = await seed();
const otherEnvironmentId = randomUUID();
await db.insert(environments).values({
id: otherEnvironmentId,
name: `Other ${otherEnvironmentId.slice(0, 8)}`,
driver: "sandbox",
status: "active",
config: {
provider: "fake-plugin",
image: "fake:base",
reuseLease: false,
},
envVars: {},
});
const workerManager = createWorkerManager();
const service = environmentCustomImageService(db, { pluginWorkerManager: workerManager });
const [otherTemplate] = await db.insert(environmentCustomImageTemplates).values({
companyId: otherCompanyId,
environmentId,
environmentId: otherEnvironmentId,
provider: "fake-plugin",
templateKind: "snapshot",
templateRef: "snapshot-other-company",
templateRef: "snapshot-other-environment",
status: "active",
}).returning();
await expect(service.startSetupSession({
companyId,
environmentId,
templateId: otherTemplate!.id,
actor: { userId: "user-1" },
@@ -342,7 +432,6 @@ describeEmbeddedPostgres("environmentCustomImageService", () => {
expect(fallback.config).toMatchObject({ image: "fake:base" });
await db.insert(environmentCustomImageTemplates).values({
companyId,
environmentId,
provider: "fake-plugin",
templateKind: "snapshot",
@@ -380,7 +469,6 @@ describeEmbeddedPostgres("environmentCustomImageService", () => {
const environment = await db.select().from(environments).where(eq(environments.id, environmentId)).then((rows) => rows[0]!);
await db.insert(environmentCustomImageTemplates).values({
companyId,
environmentId,
provider: "fake-plugin",
templateKind: "snapshot",
@@ -414,7 +502,6 @@ describeEmbeddedPostgres("environmentCustomImageService", () => {
const service = environmentCustomImageService(db, { pluginWorkerManager: workerManager });
const started = await service.startSetupSession({
companyId,
environmentId,
actor: { userId: "user-1" },
});
@@ -1346,6 +1346,7 @@ describe("environment routes", () => {
expect(mockProbeEnvironment).toHaveBeenCalledWith(expect.anything(), environment, {
companyId: null,
pluginWorkerManager: undefined,
applyCustomImageTemplate: false,
});
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
@@ -1444,6 +1445,7 @@ describe("environment routes", () => {
expect(mockProbeEnvironment).toHaveBeenCalledWith(expect.anything(), environment, {
companyId: null,
pluginWorkerManager: undefined,
applyCustomImageTemplate: true,
});
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
@@ -1460,6 +1462,48 @@ describe("environment routes", () => {
);
});
it("probes saved sandbox environments with the active custom image template without company context", async () => {
const environment = {
...createEnvironment(),
id: "env-sandbox",
name: "Daytona Sandbox",
driver: "sandbox" as const,
config: {
provider: "daytona",
image: "ubuntu:24.04",
reuseLease: true,
},
};
mockEnvironmentService.getById.mockResolvedValue(environment);
mockProbeEnvironment.mockResolvedValue({
ok: true,
driver: "sandbox",
summary: "Connected to Daytona sandbox.",
details: {
provider: "daytona",
snapshot: "captured-template",
},
});
const app = createApp({
type: "board",
userId: "user-1",
source: "local_implicit",
runId: "run-1",
});
const res = await request(app)
.post(`/api/environments/${environment.id}/probe`)
.send({});
expect(res.status).toBe(200);
expect(res.body.driver).toBe("sandbox");
expect(mockProbeEnvironment).toHaveBeenCalledWith(expect.anything(), environment, {
companyId: null,
pluginWorkerManager: undefined,
applyCustomImageTemplate: true,
});
});
it("probes unsaved provider config without persisting secrets", async () => {
mockProbeEnvironment.mockResolvedValue({
ok: true,
+4 -3
View File
@@ -63,7 +63,8 @@ async function seedGraph(db: Db, input: {
projectSourceType?: string;
targetProjectSourceType?: string;
}): Promise<TestGraph> {
const suffix = crypto.randomUUID().slice(0, 8);
const suffix = crypto.randomUUID().replace(/-/g, "").slice(0, 12);
const prefixSuffix = suffix.toUpperCase();
const companyId = crypto.randomUUID();
const otherCompanyId = crypto.randomUUID();
const goalId = crypto.randomUUID();
@@ -79,8 +80,8 @@ async function seedGraph(db: Db, input: {
const otherIssueId = crypto.randomUUID();
await db.insert(companies).values([
{ id: companyId, name: `Company ${suffix}`, issuePrefix: `F${suffix.slice(0, 4).toUpperCase()}` },
{ id: otherCompanyId, name: `Other ${suffix}`, issuePrefix: `G${suffix.slice(0, 4).toUpperCase()}` },
{ id: companyId, name: `Company ${suffix}`, issuePrefix: `F${prefixSuffix}` },
{ id: otherCompanyId, name: `Other ${suffix}`, issuePrefix: `G${prefixSuffix}` },
]);
await db.insert(goals).values([
{ id: goalId, companyId, title: "Goal", level: "company", status: "active" },
@@ -123,6 +123,11 @@ describe("mergeCoalescedContextSnapshot", () => {
interactionKind: "request_confirmation",
interactionStatus: "accepted",
continuationPolicy: "wake_assignee_on_accept",
checkboxSelection: {
prompt: "Delete selected files?",
selectedOptionIds: ["file-b"],
selectedOptions: [{ id: "file-b", label: "b.txt", description: "Generated build output" }],
},
wakeReason: "issue_commented",
},
{
@@ -137,11 +142,12 @@ describe("mergeCoalescedContextSnapshot", () => {
expect(merged.interactionKind).toBeUndefined();
expect(merged.interactionStatus).toBeUndefined();
expect(merged.continuationPolicy).toBeUndefined();
expect(merged.checkboxSelection).toBeUndefined();
expect(merged.commentId).toBe("comment-1");
expect(merged.wakeCommentId).toBe("comment-1");
});
it("preserves accepted-plan interaction state for the interaction wake itself", () => {
it("preserves resolved interaction state for the interaction wake itself", () => {
const merged = mergeCoalescedContextSnapshot(
{
issueId: "issue-1",
@@ -152,6 +158,11 @@ describe("mergeCoalescedContextSnapshot", () => {
interactionKind: "request_confirmation",
interactionStatus: "accepted",
continuationPolicy: "wake_assignee_on_accept",
checkboxSelection: {
prompt: "Delete selected files?",
selectedOptionIds: ["file-b"],
selectedOptions: [{ id: "file-b", label: "b.txt", description: "Generated build output" }],
},
wakeReason: "issue_commented",
},
);
@@ -160,6 +171,11 @@ describe("mergeCoalescedContextSnapshot", () => {
expect(merged.interactionKind).toBe("request_confirmation");
expect(merged.interactionStatus).toBe("accepted");
expect(merged.continuationPolicy).toBe("wake_assignee_on_accept");
expect(merged.checkboxSelection).toEqual({
prompt: "Delete selected files?",
selectedOptionIds: ["file-b"],
selectedOptions: [{ id: "file-b", label: "b.txt", description: "Generated build output" }],
});
});
});
@@ -644,7 +644,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
async function seedStrandedIssueFixture(input: {
status: "todo" | "in_progress";
runStatus: "failed" | "timed_out" | "cancelled" | "succeeded";
retryReason?: "assignment_recovery" | "issue_continuation_needed" | null;
retryReason?: "assignment_recovery" | "issue_continuation_needed" | "execution_review_participant_recovery" | null;
runSource?: string | null;
assignToUser?: boolean;
activePauseHold?: boolean;
@@ -769,6 +769,103 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
return { companyId, agentId, runId, wakeupRequestId, issueId, rootIssueId };
}
async function seedInReviewParticipantRunFixture(input?: {
wakeReason?: string;
retryReason?: string | null;
}) {
const companyId = randomUUID();
const agentId = randomUUID();
const runId = randomUUID();
const wakeupRequestId = randomUUID();
const issueId = randomUUID();
const stageId = randomUUID();
const now = new Date("2026-03-19T00:00:00.000Z");
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
const wakeReason = input?.wakeReason ?? "execution_review_requested";
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix,
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "CodexReviewer",
role: "engineer",
status: "idle",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
await db.insert(agentWakeupRequests).values({
id: wakeupRequestId,
companyId,
agentId,
source: "automation",
triggerDetail: "system",
reason: wakeReason,
payload: {
issueId,
...(input?.retryReason ? { retryReason: input.retryReason } : {}),
},
status: "queued",
runId,
requestedAt: now,
updatedAt: now,
});
await db.insert(heartbeatRuns).values({
id: runId,
companyId,
agentId,
invocationSource: "automation",
triggerDetail: "system",
status: "queued",
wakeupRequestId,
contextSnapshot: {
issueId,
taskId: issueId,
wakeReason,
...(input?.retryReason ? { retryReason: input.retryReason } : {}),
},
updatedAt: now,
});
await db.insert(issues).values({
id: issueId,
companyId,
title: "Review participant stayed pending",
status: "in_review",
priority: "medium",
assigneeAgentId: agentId,
assigneeUserId: null,
executionRunId: runId,
executionAgentNameKey: "codexreviewer",
executionLockedAt: now,
issueNumber: 1,
identifier: `${issuePrefix}-1`,
executionState: {
status: "pending",
currentStageId: stageId,
currentStageIndex: 0,
currentStageType: "review",
currentParticipant: { type: "agent", agentId, userId: null },
returnAssignee: { type: "agent", agentId, userId: null },
reviewRequest: null,
completedStageIds: [],
lastDecisionId: null,
lastDecisionOutcome: null,
},
});
return { companyId, agentId, runId, wakeupRequestId, issueId, stageId };
}
async function seedAssignedTodoNoRunFixture(input?: {
agentStatus?: "paused" | "idle" | "running";
}) {
@@ -816,10 +913,12 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
agentId: string;
issueId: string;
runId: string;
previousStatus: "todo" | "in_progress";
retryReason?: "assignment_recovery" | "issue_continuation_needed" | null;
previousStatus: "todo" | "in_progress" | "in_review";
retryReason?: "assignment_recovery" | "issue_continuation_needed" | "execution_review_participant_recovery" | null;
cause?: string;
kind?: string;
previousOwnerAgentId?: string | null;
returnOwnerAgentId?: string | null;
}) {
const action = await waitForValue(async () =>
db.select().from(issueRecoveryActions).where(
@@ -839,8 +938,8 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
status: "active",
ownerType: "agent",
ownerAgentId: input.agentId,
previousOwnerAgentId: input.agentId,
returnOwnerAgentId: input.agentId,
previousOwnerAgentId: input.previousOwnerAgentId ?? input.agentId,
returnOwnerAgentId: input.returnOwnerAgentId ?? input.agentId,
cause: input.cause ?? "stranded_assigned_issue",
attemptCount: 1,
maxAttempts: null,
@@ -851,9 +950,13 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
latestRunId: input.runId,
retryReason: input.retryReason ?? null,
});
expect(action.nextAction).toContain(
input.kind === "missing_disposition" ? "valid issue disposition" : "Restore a live execution path",
);
if (input.cause === "execution_review_participant_recovery") {
expect(action.nextAction).toContain("failed review participant path");
} else {
expect(action.nextAction).toContain(
input.kind === "missing_disposition" ? "valid issue disposition" : "Restore a live execution path",
);
}
const recoveryIssues = await db
.select()
@@ -2554,6 +2657,458 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
}
});
it("re-enqueues an already stranded execution-review participant during reconciliation", async () => {
const { agentId, issueId, runId, wakeupRequestId, stageId } = await seedInReviewParticipantRunFixture();
const finishedAt = new Date("2026-03-19T00:05:00.000Z");
await db
.update(heartbeatRuns)
.set({
status: "succeeded",
startedAt: new Date("2026-03-19T00:00:00.000Z"),
finishedAt,
updatedAt: finishedAt,
})
.where(eq(heartbeatRuns.id, runId));
await db
.update(agentWakeupRequests)
.set({
status: "completed",
finishedAt,
updatedAt: finishedAt,
})
.where(eq(agentWakeupRequests.id, wakeupRequestId));
const heartbeat = heartbeatService(db);
const result = await heartbeat.reconcileStrandedAssignedIssues();
expect(result.reviewParticipantRequeued).toBe(1);
expect(result.escalated).toBe(0);
expect(result.issueIds).toEqual([issueId]);
const runs = await db
.select()
.from(heartbeatRuns)
.where(eq(heartbeatRuns.agentId, agentId));
const retryRun = runs.find((row) => row.id !== runId);
expect(["queued", "running"]).toContain(retryRun?.status);
expect(retryRun).toMatchObject({
retryOfRunId: runId,
});
expect(retryRun?.contextSnapshot).toMatchObject({
issueId,
taskId: issueId,
wakeReason: "execution_review_participant_recovery",
retryReason: "execution_review_participant_recovery",
source: "issue.execution_review_recovery",
retryOfRunId: runId,
currentStageId: stageId,
currentStageType: "review",
reviewRecoveryInstruction: expect.stringContaining("Submit the review decision now"),
});
expect(retryRun?.contextSnapshot as Record<string, unknown>).not.toHaveProperty("modelProfile");
});
it("re-enqueues a stranded execution-review participant when another agent has the latest issue run", async () => {
const { companyId, agentId, issueId, runId, wakeupRequestId, stageId } =
await seedInReviewParticipantRunFixture();
const otherAgentId = randomUUID();
const otherRunId = randomUUID();
const finishedAt = new Date("2026-03-19T00:05:00.000Z");
await db
.update(heartbeatRuns)
.set({
status: "succeeded",
startedAt: new Date("2026-03-19T00:00:00.000Z"),
finishedAt,
updatedAt: finishedAt,
})
.where(eq(heartbeatRuns.id, runId));
await db
.update(agentWakeupRequests)
.set({
status: "completed",
finishedAt,
updatedAt: finishedAt,
})
.where(eq(agentWakeupRequests.id, wakeupRequestId));
await db.insert(agents).values({
id: otherAgentId,
companyId,
name: "CodexImplementor",
role: "engineer",
status: "idle",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
await db.insert(heartbeatRuns).values({
id: otherRunId,
companyId,
agentId: otherAgentId,
invocationSource: "automation",
triggerDetail: "system",
status: "succeeded",
contextSnapshot: {
issueId,
taskId: issueId,
wakeReason: "issue_commented",
},
startedAt: new Date("2026-03-19T00:10:00.000Z"),
finishedAt: new Date("2026-03-19T00:15:00.000Z"),
createdAt: new Date(Date.now() + 1_000),
updatedAt: new Date("2026-03-19T00:15:00.000Z"),
});
const heartbeat = heartbeatService(db);
const result = await heartbeat.reconcileStrandedAssignedIssues();
expect(result.reviewParticipantRequeued).toBe(1);
expect(result.escalated).toBe(0);
expect(result.issueIds).toEqual([issueId]);
const retryRun = await db
.select()
.from(heartbeatRuns)
.where(eq(heartbeatRuns.agentId, agentId))
.then((runs) =>
runs.find((row) =>
row.id !== runId &&
(row.contextSnapshot as Record<string, unknown> | null)?.retryReason ===
"execution_review_participant_recovery"
) ?? null
);
expect(retryRun).toMatchObject({
retryOfRunId: runId,
});
expect(retryRun?.contextSnapshot).toMatchObject({
issueId,
currentStageId: stageId,
currentStageType: "review",
});
});
it("re-enqueues a stranded execution-review participant when another agent has a queued issue wake", async () => {
const { companyId, agentId, issueId, runId, wakeupRequestId } =
await seedInReviewParticipantRunFixture();
const otherAgentId = randomUUID();
const otherWakeId = randomUUID();
const finishedAt = new Date("2026-03-19T00:05:00.000Z");
await db
.update(heartbeatRuns)
.set({
status: "succeeded",
startedAt: new Date("2026-03-19T00:00:00.000Z"),
finishedAt,
updatedAt: finishedAt,
})
.where(eq(heartbeatRuns.id, runId));
await db
.update(agentWakeupRequests)
.set({
status: "completed",
finishedAt,
updatedAt: finishedAt,
})
.where(eq(agentWakeupRequests.id, wakeupRequestId));
await db.insert(agents).values({
id: otherAgentId,
companyId,
name: "CodexImplementor",
role: "engineer",
status: "idle",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
await db.insert(agentWakeupRequests).values({
id: otherWakeId,
companyId,
agentId: otherAgentId,
source: "automation",
triggerDetail: "system",
reason: "issue_commented",
payload: { issueId },
status: "queued",
});
const heartbeat = heartbeatService(db);
const result = await heartbeat.reconcileStrandedAssignedIssues();
expect(result.reviewParticipantRequeued).toBe(1);
expect(result.escalated).toBe(0);
expect(result.issueIds).toEqual([issueId]);
const wakeups = await db
.select()
.from(agentWakeupRequests)
.where(eq(agentWakeupRequests.agentId, agentId));
expect(wakeups.some((wakeup) =>
wakeup.reason === "execution_review_participant_recovery" &&
wakeup.status !== "skipped"
)).toBe(true);
});
it("retries a pending execution-review participant when another agent has an active issue run", async () => {
const { companyId, agentId, issueId, runId } = await seedInReviewParticipantRunFixture();
const otherAgentId = randomUUID();
const otherRunId = randomUUID();
await db.insert(agents).values({
id: otherAgentId,
companyId,
name: "CodexImplementor",
role: "engineer",
status: "idle",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
await db.insert(heartbeatRuns).values({
id: otherRunId,
companyId,
agentId: otherAgentId,
invocationSource: "automation",
triggerDetail: "system",
status: "running",
contextSnapshot: {
issueId,
taskId: issueId,
wakeReason: "issue_commented",
},
startedAt: new Date("2026-03-19T00:01:00.000Z"),
updatedAt: new Date("2026-03-19T00:01:00.000Z"),
});
const heartbeat = heartbeatService(db);
await heartbeat.resumeQueuedRuns();
const reviewRecoveryRun = await waitForValue(async () => {
const runs = await db
.select()
.from(heartbeatRuns)
.where(eq(heartbeatRuns.agentId, agentId));
return runs.find((row) =>
(row.contextSnapshot as Record<string, unknown> | null)?.retryReason ===
"execution_review_participant_recovery"
) ?? null;
}, 8_000);
expect(reviewRecoveryRun).toMatchObject({
companyId,
agentId,
retryOfRunId: runId,
});
});
it("does not immediately recover a generic on-demand run used for an in-review agent API update", async () => {
const { agentId, issueId, runId } = await seedInReviewParticipantRunFixture({
wakeReason: "manual",
});
const heartbeat = heartbeatService(db);
await heartbeat.resumeQueuedRuns();
const settledRun = await waitForRunToSettle(heartbeat, runId, 8_000);
expect(settledRun?.status).toBe("succeeded");
const runs = await db
.select()
.from(heartbeatRuns)
.where(eq(heartbeatRuns.agentId, agentId));
expect(runs.some((row) =>
(row.contextSnapshot as Record<string, unknown> | null)?.retryReason ===
"execution_review_participant_recovery"
)).toBe(false);
const issue = await db
.select()
.from(issues)
.where(eq(issues.id, issueId))
.then((rows) => rows[0] ?? null);
expect(issue?.status).toBe("in_review");
expect(issue?.assigneeAgentId).toBe(agentId);
});
it("retries a pending execution-review participant once before blocking with a recovery action", async () => {
const { companyId, agentId, issueId, runId, stageId } = await seedInReviewParticipantRunFixture();
const heartbeat = heartbeatService(db);
await heartbeat.resumeQueuedRuns();
const reviewRecoveryRun = await waitForValue(async () => {
const runs = await db
.select()
.from(heartbeatRuns)
.where(eq(heartbeatRuns.agentId, agentId));
return runs.find((row) =>
(row.contextSnapshot as Record<string, unknown> | null)?.retryReason ===
"execution_review_participant_recovery" &&
row.status !== "queued" &&
row.status !== "running"
) ?? null;
}, 8_000);
expect(reviewRecoveryRun).toBeTruthy();
expect(reviewRecoveryRun).toMatchObject({
companyId,
agentId,
retryOfRunId: runId,
status: "succeeded",
});
expect(reviewRecoveryRun?.contextSnapshot).toMatchObject({
issueId,
taskId: issueId,
wakeReason: "execution_review_participant_recovery",
retryReason: "execution_review_participant_recovery",
source: "issue.execution_review_recovery",
retryOfRunId: runId,
currentStageId: stageId,
currentStageType: "review",
reviewRecoveryInstruction: expect.stringContaining("Submit the review decision now"),
});
expect(reviewRecoveryRun?.contextSnapshot as Record<string, unknown>).not.toHaveProperty("modelProfile");
const sourceIssue = await waitForValue(async () => {
const row = await db
.select()
.from(issues)
.where(eq(issues.id, issueId))
.then((rows) => rows[0] ?? null);
return row?.status === "blocked" ? row : null;
}, 8_000);
expect(sourceIssue).toMatchObject({
status: "blocked",
assigneeAgentId: agentId,
executionRunId: null,
});
const recoveryAction = await expectSourceScopedStrandedRecoveryAction({
companyId,
agentId,
issueId,
runId: reviewRecoveryRun!.id,
previousStatus: "in_review",
retryReason: "execution_review_participant_recovery",
cause: "execution_review_participant_recovery",
});
expect(recoveryAction.evidence).toMatchObject({
latestRunId: reviewRecoveryRun?.id,
latestRunStatus: "succeeded",
latestRunErrorCode: null,
recoveryCause: "execution_review_participant_recovery",
});
const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId));
const recoveryComment = comments.find((comment) =>
comment.body.includes("pending execution-review participant once") &&
comment.body.includes(`Recovery action: \`${recoveryAction.id}\``),
);
expect(recoveryComment).toBeTruthy();
const activity = await db.select().from(activityLog).where(eq(activityLog.entityId, issueId));
expect(activity.some((event) =>
(event.details as Record<string, unknown> | null)?.source ===
"recovery.reconcile_execution_review_participant",
)).toBe(true);
});
it("blocks failed execution-review recovery under the reviewer when the source assignee differs", async () => {
const { companyId, agentId, issueId, runId, wakeupRequestId, stageId } =
await seedInReviewParticipantRunFixture({
wakeReason: "execution_review_participant_recovery",
retryReason: "execution_review_participant_recovery",
});
const sourceAssigneeAgentId = randomUUID();
const finishedAt = new Date("2026-03-19T00:05:00.000Z");
await db.insert(agents).values({
id: sourceAssigneeAgentId,
companyId,
name: "CodexImplementor",
role: "engineer",
status: "idle",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
await db
.update(issues)
.set({
assigneeAgentId: sourceAssigneeAgentId,
executionState: {
status: "pending",
currentStageId: stageId,
currentStageIndex: 0,
currentStageType: "review",
currentParticipant: { type: "agent", agentId, userId: null },
returnAssignee: { type: "agent", agentId: sourceAssigneeAgentId, userId: null },
reviewRequest: null,
completedStageIds: [],
lastDecisionId: null,
lastDecisionOutcome: null,
},
})
.where(eq(issues.id, issueId));
await db
.update(heartbeatRuns)
.set({
status: "failed",
startedAt: new Date("2026-03-19T00:00:00.000Z"),
finishedAt,
updatedAt: finishedAt,
errorCode: "adapter_failed",
error: "review recovery failed before submitting a decision",
})
.where(eq(heartbeatRuns.id, runId));
await db
.update(agentWakeupRequests)
.set({
status: "failed",
claimedAt: new Date("2026-03-19T00:00:00.000Z"),
finishedAt,
updatedAt: finishedAt,
error: "review recovery failed before submitting a decision",
})
.where(eq(agentWakeupRequests.id, wakeupRequestId));
const heartbeat = heartbeatService(db);
const result = await heartbeat.reconcileStrandedAssignedIssues();
expect(result.reviewParticipantRequeued).toBe(0);
expect(result.escalated).toBe(1);
expect(result.issueIds).toEqual([issueId]);
const sourceIssue = await waitForValue(async () => {
const row = await db
.select()
.from(issues)
.where(eq(issues.id, issueId))
.then((rows) => rows[0] ?? null);
return row?.status === "blocked" ? row : null;
});
expect(sourceIssue).toMatchObject({
status: "blocked",
assigneeAgentId: agentId,
});
const recoveryAction = await expectSourceScopedStrandedRecoveryAction({
companyId,
agentId,
issueId,
runId,
previousStatus: "in_review",
retryReason: "execution_review_participant_recovery",
cause: "execution_review_participant_recovery",
previousOwnerAgentId: sourceAssigneeAgentId,
returnOwnerAgentId: sourceAssigneeAgentId,
});
expect(recoveryAction.evidence).toMatchObject({
latestRunId: runId,
latestRunStatus: "failed",
latestRunErrorCode: "adapter_failed",
recoveryCause: "execution_review_participant_recovery",
});
});
it.each([
["failed", "adapter_failed"],
["failed", "process_lost"],
@@ -0,0 +1,427 @@
import { execFile } from "node:child_process";
import { randomUUID } from "node:crypto";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { promisify } from "node:util";
import { and, asc, eq } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import {
activityLog,
agentRuntimeState,
agentTaskSessions,
agentWakeupRequests,
agents,
companies,
companySkills,
createDb,
documentRevisions,
documents,
environmentLeases,
environments,
executionWorkspaces,
heartbeatRunEvents,
heartbeatRuns,
issueComments,
issueDocuments,
issuePlanDecompositions,
issues,
projects,
projectWorkspaces,
workspaceOperations,
} from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { heartbeatService } from "../services/heartbeat.ts";
import { instanceSettingsService } from "../services/instance-settings.ts";
const execFileAsync = promisify(execFile);
const adapterExecute = vi.hoisted(() =>
vi.fn(async () => ({
exitCode: 0,
signal: null,
timedOut: false,
summary: "Finalization branch guard test run.",
provider: "test",
model: "test-model",
})),
);
vi.mock("../adapters/index.js", () => ({
getServerAdapter: () => ({
type: "codex_local",
execute: adapterExecute,
supportsLocalAgentJwt: false,
}),
findActiveServerAdapter: () => ({
type: "codex_local",
execute: adapterExecute,
supportsLocalAgentJwt: false,
}),
listAdapterModelProfiles: async () => [],
runningProcesses: new Map(),
}));
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres heartbeat workspace finalize branch tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
type Db = ReturnType<typeof createDb>;
type Heartbeat = ReturnType<typeof heartbeatService>;
async function runGit(cwd: string, args: string[]) {
await execFileAsync("git", args, { cwd });
}
async function createGitRepo() {
const repoRoot = await mkdtemp(path.join(os.tmpdir(), "paperclip-finalize-branch-repo-"));
await runGit(repoRoot, ["init"]);
await runGit(repoRoot, ["config", "user.email", "paperclip-test@example.com"]);
await runGit(repoRoot, ["config", "user.name", "Paperclip Test"]);
await writeFile(path.join(repoRoot, "README.md"), "finalization branch guard\n", "utf8");
await runGit(repoRoot, ["add", "README.md"]);
await runGit(repoRoot, ["commit", "-m", "initial"]);
return repoRoot;
}
async function waitForRunToFinish(heartbeat: Heartbeat, runId: string, timeoutMs = 10_000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const run = await heartbeat.getRun(runId);
if (run && run.status !== "queued" && run.status !== "running") return run;
await new Promise((resolve) => setTimeout(resolve, 50));
}
return heartbeat.getRun(runId);
}
async function waitForHeartbeatIdle(db: Db, timeoutMs = 5_000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const runs = await db.select({ status: heartbeatRuns.status }).from(heartbeatRuns);
if (!runs.some((run) => run.status === "queued" || run.status === "running")) return;
await new Promise((resolve) => setTimeout(resolve, 50));
}
}
async function waitForRuntimeStateLastRun(db: Db, agentId: string, runId: string, timeoutMs = 5_000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const state = await db
.select({ lastRunId: agentRuntimeState.lastRunId })
.from(agentRuntimeState)
.where(eq(agentRuntimeState.agentId, agentId))
.then((rows) => rows[0] ?? null);
if (state?.lastRunId === runId) return;
await new Promise((resolve) => setTimeout(resolve, 50));
}
}
function readAdapterWorkspace(input: unknown) {
const context = (input as { context?: Record<string, unknown> }).context ?? {};
const workspace = context.paperclipWorkspace as Record<string, unknown> | undefined;
const cwd = typeof workspace?.cwd === "string" ? workspace.cwd : null;
const branchName = typeof workspace?.branchName === "string" ? workspace.branchName : null;
const executionWorkspaceId =
typeof context.executionWorkspaceId === "string" ? context.executionWorkspaceId : null;
if (!cwd || !branchName || !executionWorkspaceId) {
throw new Error("Adapter input is missing the realized execution workspace context");
}
return { cwd, branchName, executionWorkspaceId };
}
async function seedRunTarget(db: Db, repoRoot: string) {
const companyId = randomUUID();
const projectId = randomUUID();
const projectWorkspaceId = randomUUID();
const issueId = randomUUID();
const agentId = randomUUID();
await instanceSettingsService(db).updateExperimental({
enableIsolatedWorkspaces: true,
});
await db.insert(companies).values({
id: companyId,
name: "Acme",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
status: "active",
createdAt: new Date(),
updatedAt: new Date(),
});
await db.insert(projects).values({
id: projectId,
companyId,
name: "Workspace Finalize Branch Guard",
status: "active",
createdAt: new Date(),
updatedAt: new Date(),
});
await db.insert(projectWorkspaces).values({
id: projectWorkspaceId,
companyId,
projectId,
name: "Primary",
cwd: repoRoot,
isPrimary: true,
createdAt: new Date(),
updatedAt: new Date(),
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "CodexCoder",
role: "engineer",
status: "idle",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {
heartbeat: {
wakeOnDemand: true,
maxConcurrentRuns: 1,
},
},
permissions: {},
createdAt: new Date(),
updatedAt: new Date(),
});
await db.insert(issues).values({
id: issueId,
companyId,
projectId,
projectWorkspaceId,
title: "Publish without drifting managed workspace",
status: "in_progress",
workMode: "standard",
priority: "medium",
assigneeAgentId: agentId,
identifier: `PAP-${issueId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
executionWorkspaceSettings: {
mode: "isolated_workspace",
},
createdAt: new Date(),
updatedAt: new Date(),
});
return { companyId, projectId, projectWorkspaceId, issueId, agentId };
}
async function wakeIssue(heartbeat: Heartbeat, agentId: string, issueId: string) {
return heartbeat.wakeup(agentId, {
source: "automation",
triggerDetail: "system",
reason: "issue_commented",
payload: { issueId },
contextSnapshot: {
issueId,
taskId: issueId,
wakeReason: "issue_commented",
skipIssueComment: true,
},
});
}
async function listFinalizeOperations(db: Db, runId: string) {
return db
.select()
.from(workspaceOperations)
.where(and(
eq(workspaceOperations.heartbeatRunId, runId),
eq(workspaceOperations.phase, "workspace_finalize"),
))
.orderBy(asc(workspaceOperations.startedAt), asc(workspaceOperations.createdAt));
}
describeEmbeddedPostgres("heartbeat workspace finalization branch guard", () => {
let db!: Db;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
const tempRoots: string[] = [];
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-finalize-branch-");
db = createDb(tempDb.connectionString);
}, 20_000);
afterEach(async () => {
await waitForHeartbeatIdle(db);
adapterExecute.mockReset();
adapterExecute.mockImplementation(async () => ({
exitCode: 0,
signal: null,
timedOut: false,
summary: "Finalization branch guard test run.",
provider: "test",
model: "test-model",
}));
while (tempRoots.length > 0) {
const root = tempRoots.pop();
if (root) await rm(root, { recursive: true, force: true }).catch(() => undefined);
}
await db.delete(issuePlanDecompositions);
await db.delete(issueDocuments);
await db.delete(documentRevisions);
await db.delete(documents);
await db.delete(agentTaskSessions);
await db.delete(environmentLeases);
await db.delete(activityLog);
await db.delete(heartbeatRunEvents);
await db.delete(heartbeatRuns);
await db.delete(issueComments);
await db.delete(issues);
await db.delete(projectWorkspaces);
await db.delete(projects);
await db.delete(agentWakeupRequests);
await db.delete(agentRuntimeState);
await db.delete(agents);
await db.delete(workspaceOperations);
await db.delete(executionWorkspaces);
await db.delete(environments);
await db.delete(companySkills);
await db.delete(companies);
});
afterAll(async () => {
await db.$client.end();
await tempDb?.cleanup();
});
it("fails a successful adapter run when the managed worktree branch drift was not recorded", async () => {
const repoRoot = await createGitRepo();
tempRoots.push(repoRoot);
const { agentId, issueId } = await seedRunTarget(db, repoRoot);
const publishBranch = `publish-${issueId.slice(0, 8)}`;
let recordedBranch: string | null = null;
let executionWorkspaceId: string | null = null;
adapterExecute.mockImplementationOnce(async (input) => {
const workspace = readAdapterWorkspace(input);
recordedBranch = workspace.branchName;
executionWorkspaceId = workspace.executionWorkspaceId;
await runGit(workspace.cwd, ["checkout", "-b", publishBranch]);
await db.update(issues).set({ status: "done", updatedAt: new Date() }).where(eq(issues.id, issueId));
return {
exitCode: 0,
signal: null,
timedOut: false,
summary: "Adapter completed after switching to a publish branch.",
provider: "test",
model: "test-model",
};
});
const heartbeat = heartbeatService(db);
const run = await wakeIssue(heartbeat, agentId, issueId);
expect(run).not.toBeNull();
const finishedRun = await waitForRunToFinish(heartbeat, run!.id);
expect(finishedRun).toMatchObject({
status: "failed",
errorCode: "workspace_validation_failed",
error: expect.stringContaining("Record a sanctioned execution-workspace branch transition"),
});
const workspaceValidation = (finishedRun?.resultJson as Record<string, unknown> | null)?.workspaceValidation;
expect(workspaceValidation).toMatchObject({
reason: "git_worktree_branch_mismatch_after_run",
fingerprint: expect.stringMatching(/^workspace_finalize_branch_mismatch:v1:sha256:/),
issueId,
persistedExecutionWorkspaceId: executionWorkspaceId,
managedGitWorktreeBranch: expect.objectContaining({
executionWorkspaceId,
reasonCode: "branch_mismatch",
expectedBranchName: recordedBranch,
actualBranchName: publishBranch,
}),
});
await waitForRuntimeStateLastRun(db, agentId, run!.id);
expect(adapterExecute).toHaveBeenCalledTimes(1);
const finalizeOps = await listFinalizeOperations(db, run!.id);
expect(finalizeOps).toHaveLength(1);
expect(finalizeOps[0]).toMatchObject({
status: "failed",
executionWorkspaceId,
stderrExcerpt: expect.stringContaining("Managed git worktree branch check failed"),
});
expect(finalizeOps[0]?.metadata).toMatchObject({
managedGitWorktreeBranch: {
executionWorkspaceId,
valid: false,
reasonCode: "branch_mismatch",
expectedBranchName: recordedBranch,
actualBranchName: publishBranch,
},
});
}, 20_000);
it("allows a successful adapter run when the branch transition is recorded before finalization", async () => {
const repoRoot = await createGitRepo();
tempRoots.push(repoRoot);
const { agentId, issueId } = await seedRunTarget(db, repoRoot);
const publishBranch = `publish-${issueId.slice(0, 8)}`;
let executionWorkspaceId: string | null = null;
adapterExecute.mockImplementationOnce(async (input) => {
const workspace = readAdapterWorkspace(input);
executionWorkspaceId = workspace.executionWorkspaceId;
await runGit(workspace.cwd, ["checkout", "-b", publishBranch]);
await db.update(issues).set({ status: "done", updatedAt: new Date() }).where(eq(issues.id, issueId));
await db
.update(executionWorkspaces)
.set({
branchName: publishBranch,
updatedAt: new Date(),
})
.where(eq(executionWorkspaces.id, workspace.executionWorkspaceId));
return {
exitCode: 0,
signal: null,
timedOut: false,
summary: "Adapter completed after recording a branch transition.",
provider: "test",
model: "test-model",
};
});
const heartbeat = heartbeatService(db);
const run = await wakeIssue(heartbeat, agentId, issueId);
expect(run).not.toBeNull();
const finishedRun = await waitForRunToFinish(heartbeat, run!.id);
expect(finishedRun).toMatchObject({
status: "succeeded",
errorCode: null,
error: null,
});
await waitForRuntimeStateLastRun(db, agentId, run!.id);
expect(adapterExecute).toHaveBeenCalledTimes(1);
const finalizedWorkspace = await db
.select({ branchName: executionWorkspaces.branchName })
.from(executionWorkspaces)
.where(eq(executionWorkspaces.id, executionWorkspaceId!))
.then((rows) => rows[0] ?? null);
expect(finalizedWorkspace?.branchName).toBe(publishBranch);
const finalizeOps = await listFinalizeOperations(db, run!.id);
expect(finalizeOps).toHaveLength(1);
expect(finalizeOps[0]).toMatchObject({
status: "succeeded",
executionWorkspaceId,
});
expect(finalizeOps[0]?.metadata).toMatchObject({
managedGitWorktreeBranch: {
executionWorkspaceId,
valid: true,
reasonCode: null,
expectedBranchName: publishBranch,
actualBranchName: publishBranch,
},
});
}, 20_000);
});
@@ -365,6 +365,51 @@ describe("assertGitSensitiveAdapterWorkspaceValid", () => {
);
});
it("rejects a git worktree persisted workspace when the checked-out branch differs from the recorded branch", async () => {
const repoRoot = await createGitCheckout({ withRemote: false });
const worktreeParent = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-branch-worktree-"));
const worktreePath = path.join(worktreeParent, "workspace");
const recordedBranch = "PAP-1-recorded-branch";
const actualBranch = "PAP-1-push-pr-head";
try {
await runGit(repoRoot, ["config", "user.email", "test@example.com"]);
await runGit(repoRoot, ["config", "user.name", "Paperclip Test"]);
await fs.writeFile(path.join(repoRoot, "README.md"), "initial\n", "utf8");
await runGit(repoRoot, ["add", "README.md"]);
await runGit(repoRoot, ["commit", "-m", "Initial commit"]);
await runGit(repoRoot, ["worktree", "add", "-b", recordedBranch, worktreePath, "HEAD"]);
await runGit(worktreePath, ["checkout", "-b", actualBranch]);
const input = buildWorkspaceValidationInput();
await expectWorkspaceValidationFailure(
buildWorkspaceValidationInput({
resolvedWorkspace: buildResolvedWorkspace({ cwd: worktreePath }),
executionWorkspace: {
...input.executionWorkspace,
strategy: "git_worktree",
baseCwd: repoRoot,
cwd: worktreePath,
branchName: recordedBranch,
worktreePath,
},
persistedExecutionWorkspace: {
...input.persistedExecutionWorkspace!,
strategyType: "git_worktree",
cwd: worktreePath,
providerType: "git_worktree",
providerRef: worktreePath,
branchName: recordedBranch,
},
}),
"git_worktree_branch_mismatch",
`expected git worktree branch "${recordedBranch}"`,
);
} finally {
await fs.rm(repoRoot, { recursive: true, force: true });
await fs.rm(worktreeParent, { recursive: true, force: true });
}
});
it("rejects a workspace-linked issue when adapter cwd has no git metadata", async () => {
const input = buildWorkspaceValidationInput();
const cwd = "/tmp/paperclip-workspace-without-git-metadata";
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
import express from "express";
import request from "supertest";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import { agents, companies, companyMemberships, createDb, issues, principalPermissionGrants } from "@paperclipai/db";
import { agents, companies, companyMemberships, createDb, heartbeatRuns, issues, principalPermissionGrants } from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
@@ -31,6 +31,7 @@ describeEmbeddedPostgres("issue list routes assigneeAgentId filter", () => {
afterEach(async () => {
await db.delete(issues);
await db.delete(heartbeatRuns);
await db.delete(agents);
await db.delete(principalPermissionGrants);
await db.delete(companyMemberships);
@@ -220,4 +221,229 @@ describeEmbeddedPostgres("issue list routes assigneeAgentId filter", () => {
error: "assigneeAgentId must be a UUID or 'null'",
});
});
it("returns opt-in live descendant counts for offscreen live descendants only", async () => {
const companyId = randomUUID();
const otherCompanyId = randomUUID();
const agentId = randomUUID();
const otherAgentId = randomUUID();
const rootIssueId = randomUUID();
const childIssueId = randomUUID();
const grandchildIssueId = randomUUID();
const hiddenChildIssueId = randomUUID();
const crossCompanyChildIssueId = randomUUID();
const rootRunId = randomUUID();
const grandchildRunId = randomUUID();
const hiddenRunId = randomUUID();
const crossCompanyRunId = randomUUID();
await db.insert(companies).values([
{
id: companyId,
name: "Paperclip",
issuePrefix: uniqueIssuePrefix(),
requireBoardApprovalForNewAgents: false,
},
{
id: otherCompanyId,
name: "Other Company",
issuePrefix: uniqueIssuePrefix(),
requireBoardApprovalForNewAgents: false,
},
]);
await seedCloudTenantMember(companyId);
await db.insert(agents).values([
{
id: agentId,
companyId,
name: "Assignee",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
},
{
id: otherAgentId,
companyId: otherCompanyId,
name: "Other",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
},
]);
await db.insert(heartbeatRuns).values([
{
id: rootRunId,
companyId,
agentId,
status: "running",
contextSnapshot: { issueId: rootIssueId },
},
{
id: grandchildRunId,
companyId,
agentId,
status: "queued",
contextSnapshot: { issueId: grandchildIssueId },
},
{
id: hiddenRunId,
companyId,
agentId,
status: "running",
contextSnapshot: { issueId: hiddenChildIssueId },
},
{
id: crossCompanyRunId,
companyId: otherCompanyId,
agentId: otherAgentId,
status: "running",
contextSnapshot: { issueId: crossCompanyChildIssueId },
},
]);
await db.insert(issues).values([
{
id: rootIssueId,
companyId,
title: "Blocked parent",
status: "blocked",
priority: "critical",
executionRunId: rootRunId,
assigneeAgentId: agentId,
},
{
id: childIssueId,
companyId,
title: "Offscreen child",
status: "todo",
priority: "medium",
parentId: rootIssueId,
assigneeAgentId: agentId,
},
{
id: grandchildIssueId,
companyId,
title: "Offscreen live grandchild",
status: "todo",
priority: "medium",
parentId: childIssueId,
executionRunId: grandchildRunId,
assigneeAgentId: agentId,
},
{
id: hiddenChildIssueId,
companyId,
title: "Hidden live child",
status: "todo",
priority: "medium",
parentId: rootIssueId,
executionRunId: hiddenRunId,
hiddenAt: new Date("2026-07-02T00:00:00.000Z"),
assigneeAgentId: agentId,
},
{
id: crossCompanyChildIssueId,
companyId: otherCompanyId,
title: "Cross-company live child",
status: "todo",
priority: "medium",
parentId: rootIssueId,
executionRunId: crossCompanyRunId,
assigneeAgentId: otherAgentId,
},
]);
const app = createApp(companyId);
const withoutSummary = await request(app)
.get(`/api/companies/${companyId}/issues`)
.query({ status: "blocked", limit: "20" });
expect(withoutSummary.status, JSON.stringify(withoutSummary.body)).toBe(200);
expect(withoutSummary.body).toHaveLength(1);
expect(withoutSummary.body[0].id).toBe(rootIssueId);
expect(withoutSummary.body[0].liveDescendantCount).toBeUndefined();
const withSummary = await request(app)
.get(`/api/companies/${companyId}/issues`)
.query({ status: "blocked", includeLiveDescendantSummary: "true", limit: "20" });
expect(withSummary.status, JSON.stringify(withSummary.body)).toBe(200);
expect(withSummary.body).toHaveLength(1);
expect(withSummary.body[0]).toMatchObject({
id: rootIssueId,
liveDescendantCount: 1,
});
});
it("does not recurse forever when live descendant summaries encounter a parent cycle", async () => {
const companyId = randomUUID();
const agentId = randomUUID();
const parentIssueId = randomUUID();
const childIssueId = randomUUID();
const runId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: uniqueIssuePrefix(),
requireBoardApprovalForNewAgents: false,
});
await seedCloudTenantMember(companyId);
await db.insert(agents).values({
id: agentId,
companyId,
name: "Assignee",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
await db.insert(heartbeatRuns).values({
id: runId,
companyId,
agentId,
status: "running",
contextSnapshot: { issueId: childIssueId },
});
await db.insert(issues).values([
{
id: parentIssueId,
companyId,
title: "Cycle parent",
status: "blocked",
priority: "medium",
parentId: childIssueId,
assigneeAgentId: agentId,
},
{
id: childIssueId,
companyId,
title: "Cycle live child",
status: "in_progress",
priority: "medium",
parentId: parentIssueId,
executionRunId: runId,
assigneeAgentId: agentId,
},
]);
const app = createApp(companyId);
const res = await request(app)
.get(`/api/companies/${companyId}/issues`)
.query({ status: "blocked", includeLiveDescendantSummary: "true", limit: "20" });
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(res.body).toHaveLength(1);
expect(res.body[0]).toMatchObject({
id: parentIssueId,
liveDescendantCount: 1,
});
});
});
@@ -379,6 +379,101 @@ describeEmbeddedPostgres("issue recovery actions", () => {
});
});
it("deduplicates workspace-incoherence recovery actions by the typed workspace fingerprint", async () => {
const { companyId, coderId, sourceIssue } = await seedCompany();
const enqueueWakeup = vi.fn(async () => null);
const recovery = recoveryService(db, { enqueueWakeup });
const workspaceFingerprint = `workspace_incoherence:v1:sha256:${"a".repeat(64)}`;
const workspaceValidation = {
reason: "git_worktree_branch_incoherence",
fingerprint: workspaceFingerprint,
sourceIssueId: sourceIssue.id,
sourceIdentifier: sourceIssue.identifier,
executionWorkspaceId: "execution-workspace-1",
expectedBranch: "PAP-1-expected",
actualBranch: "PAP-1-publish",
cleanliness: "dirty",
provenance: {
expectedBranchExists: true,
actualBranchExists: true,
expectedHeadSha: "1111111111111111111111111111111111111111",
actualHeadSha: "2222222222222222222222222222222222222222",
sameHead: false,
},
safeRepair: {
eligible: false,
attempted: false,
succeeded: false,
reason: "worktree is not clean",
},
};
const firstLatestRun = {
id: randomUUID(),
agentId: coderId,
status: "failed",
error: "workspace branch mismatch",
errorCode: "workspace_validation_failed",
contextSnapshot: {},
livenessState: "failed",
resultJson: { workspaceValidation },
} as const;
const secondLatestRun = {
...firstLatestRun,
id: randomUUID(),
};
await recovery.escalateStrandedAssignedIssue({
issue: sourceIssue,
previousStatus: "in_progress",
latestRun: firstLatestRun,
comment: "Workspace failed validation.",
recoveryCause: "workspace_validation_failed",
});
await recovery.escalateStrandedAssignedIssue({
issue: sourceIssue,
previousStatus: "in_progress",
latestRun: secondLatestRun,
comment: "Workspace failed validation.",
recoveryCause: "workspace_validation_failed",
});
const actionRows = await db
.select()
.from(issueRecoveryActions)
.where(eq(issueRecoveryActions.sourceIssueId, sourceIssue.id));
expect(actionRows).toHaveLength(1);
expect(actionRows[0]).toMatchObject({
companyId,
kind: "workspace_validation",
cause: "workspace_validation_failed",
status: "active",
attemptCount: 2,
fingerprint: expect.stringContaining(workspaceFingerprint),
evidence: expect.objectContaining({
latestRunId: secondLatestRun.id,
latestRunErrorCode: "workspace_validation_failed",
workspaceValidation: expect.objectContaining({
reason: "git_worktree_branch_incoherence",
fingerprint: workspaceFingerprint,
sourceIssueId: sourceIssue.id,
executionWorkspaceId: "execution-workspace-1",
expectedBranch: "PAP-1-expected",
actualBranch: "PAP-1-publish",
cleanliness: "dirty",
}),
}),
nextAction: expect.stringContaining("git worktree branch incoherence"),
wakePolicy: expect.objectContaining({
type: "manual_repair_required",
reason: "workspace_validation_failed",
}),
});
const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, sourceIssue.id));
expect(comments.filter((comment) => comment.body.includes(`Recovery action: \`${actionRows[0]?.id}\``))).toHaveLength(1);
expect(enqueueWakeup).not.toHaveBeenCalled();
});
it("keeps the source issue blocked when source-scoped wakeup is claimed synchronously", async () => {
const { companyId, managerId, coderId, sourceIssue } = await seedCompany();
await db.update(agents).set({ status: "paused" }).where(eq(agents.id, managerId));
@@ -326,12 +326,14 @@ describe.sequential("issue thread interaction routes", () => {
mockInteractionService.expireRequestConfirmationsSupersededByHistoricalComments.mockResolvedValueOnce([
{
id: "interaction-expired",
kind: "request_confirmation",
kind: "ask_user_questions",
status: "expired",
result: {
version: 1,
outcome: "superseded_by_comment",
answers: [],
expirationReason: "superseded_by_comment",
commentId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
summaryMarkdown: null,
},
},
]);
@@ -354,10 +356,10 @@ describe.sequential("issue thread interaction routes", () => {
action: "issue.thread_interaction_expired",
details: expect.objectContaining({
interactionId: "interaction-expired",
interactionKind: "request_confirmation",
interactionKind: "ask_user_questions",
source: "issue.interactions.catchup_superseded_by_comment",
result: expect.objectContaining({
outcome: "superseded_by_comment",
expirationReason: "superseded_by_comment",
commentId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
}),
}),
@@ -564,7 +566,7 @@ describe.sequential("issue thread interaction routes", () => {
prompt: "Delete selected files?",
options: [
{ id: "file-a", label: "a.txt" },
{ id: "file-b", label: "b.txt" },
{ id: "file-b", label: "b.txt", description: "Generated build output" },
],
},
result: {
@@ -600,6 +602,18 @@ describe.sequential("issue thread interaction routes", () => {
interactionId: "interaction-checkbox",
interactionKind: "request_checkbox_confirmation",
interactionStatus: "accepted",
checkboxSelection: {
prompt: "Delete selected files?",
selectedOptionIds: ["file-b"],
selectedOptions: [{ id: "file-b", label: "b.txt", description: "Generated build output" }],
},
}),
contextSnapshot: expect.objectContaining({
checkboxSelection: {
prompt: "Delete selected files?",
selectedOptionIds: ["file-b"],
selectedOptions: [{ id: "file-b", label: "b.txt", description: "Generated build output" }],
},
}),
}),
);
@@ -615,6 +629,66 @@ describe.sequential("issue thread interaction routes", () => {
);
});
it("preserves accepted empty checkbox selections in assignee wake context", async () => {
mockInteractionService.acceptInteraction.mockResolvedValueOnce({
interaction: {
id: "interaction-checkbox-empty",
companyId: "company-1",
issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
kind: "request_checkbox_confirmation",
status: "accepted",
continuationPolicy: "wake_assignee",
idempotencyKey: null,
sourceCommentId: null,
sourceRunId: "run-checkbox",
payload: {
version: 1,
prompt: "Delete selected files?",
options: [
{ id: "file-a", label: "a.txt", description: "Temporary export" },
{ id: "file-b", label: "b.txt", description: "Generated build output" },
],
},
result: {
version: 1,
outcome: "accepted",
selectedOptionIds: [],
},
createdAt: "2026-04-20T12:00:00.000Z",
updatedAt: "2026-04-20T12:05:00.000Z",
resolvedAt: "2026-04-20T12:05:00.000Z",
},
createdIssues: [],
});
const app = await createApp();
const res = await request(app)
.post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-checkbox-empty/accept")
.send({ selectedOptionIds: [] });
expect(res.status).toBe(200);
expect(mockHeartbeatService.wakeup).toHaveBeenCalledTimes(1);
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
ASSIGNEE_AGENT_ID,
expect.objectContaining({
payload: expect.objectContaining({
checkboxSelection: {
prompt: "Delete selected files?",
selectedOptionIds: [],
selectedOptions: [],
},
}),
contextSnapshot: expect.objectContaining({
checkboxSelection: {
prompt: "Delete selected files?",
selectedOptionIds: [],
selectedOptions: [],
},
}),
}),
);
});
it("forces a fresh workspace-aware session when accepting a planning confirmation", async () => {
mockIssueService.getById.mockResolvedValueOnce(createIssue({ workMode: "planning" }));
mockInteractionService.acceptInteraction.mockResolvedValueOnce({
@@ -566,6 +566,233 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
})).rejects.toThrow("Interaction has already been resolved");
});
it("expires ask_user_questions interactions by default when a user comments after creation", async () => {
const { companyId, issueId } = await seedConfirmationIssue("Question supersede");
const commentId = randomUUID();
const created = await interactionsSvc.create({
id: issueId,
companyId,
}, {
kind: "ask_user_questions",
payload: {
version: 1,
questions: [{
id: "scope",
prompt: "Choose the scope",
selectionMode: "single",
options: [{ id: "phase-1", label: "Phase 1" }],
}],
},
}, {
userId: "local-board",
});
expect(created).toMatchObject({
kind: "ask_user_questions",
payload: {
supersedeOnUserComment: true,
},
});
const expired = await interactionsSvc.expireRequestConfirmationsSupersededByComment({
id: issueId,
companyId,
}, {
id: commentId,
createdAt: new Date(new Date(created.createdAt).getTime() + 1_000),
authorUserId: "local-board",
}, {
userId: "local-board",
});
expect(expired).toHaveLength(1);
expect(expired[0]).toMatchObject({
id: created.id,
kind: "ask_user_questions",
status: "expired",
result: {
version: 1,
answers: [],
expirationReason: "superseded_by_comment",
commentId,
summaryMarkdown: null,
},
resolvedByUserId: "local-board",
});
});
it("keeps ask_user_questions pending when user-comment supersede is explicitly disabled", async () => {
const { companyId, issueId } = await seedConfirmationIssue("Question supersede opt-out");
await interactionsSvc.create({
id: issueId,
companyId,
}, {
kind: "ask_user_questions",
payload: {
version: 1,
supersedeOnUserComment: false,
questions: [{
id: "scope",
prompt: "Choose the scope",
selectionMode: "single",
options: [{ id: "phase-1", label: "Phase 1" }],
}],
},
}, {
userId: "local-board",
});
const expired = await interactionsSvc.expireRequestConfirmationsSupersededByComment({
id: issueId,
companyId,
}, {
id: randomUUID(),
createdAt: new Date(Date.now() + 1_000),
authorUserId: "local-board",
}, {
userId: "local-board",
});
expect(expired).toHaveLength(0);
const rows = await db.select().from(issueThreadInteractions);
expect(rows).toHaveLength(1);
expect(rows[0]?.status).toBe("pending");
});
it("does not supersede ask_user_questions for agent, system, or older user comments", async () => {
const { companyId, issueId } = await seedConfirmationIssue("Question supersede exclusions");
const created = await interactionsSvc.create({
id: issueId,
companyId,
}, {
kind: "ask_user_questions",
payload: {
version: 1,
questions: [{
id: "scope",
prompt: "Choose the scope",
selectionMode: "single",
options: [{ id: "phase-1", label: "Phase 1" }],
}],
},
}, {
userId: "local-board",
});
const createdAtMs = new Date(created.createdAt).getTime();
await expect(interactionsSvc.expireRequestConfirmationsSupersededByComment({
id: issueId,
companyId,
}, {
id: randomUUID(),
createdAt: new Date(createdAtMs + 1_000),
authorUserId: null,
}, {
agentId: randomUUID(),
})).resolves.toHaveLength(0);
await expect(interactionsSvc.expireRequestConfirmationsSupersededByComment({
id: issueId,
companyId,
}, {
id: randomUUID(),
createdAt: new Date(createdAtMs + 1_000),
authorUserId: null,
}, {})).resolves.toHaveLength(0);
await expect(interactionsSvc.expireRequestConfirmationsSupersededByComment({
id: issueId,
companyId,
}, {
id: randomUUID(),
createdAt: new Date(createdAtMs - 1_000),
authorUserId: "local-board",
}, {
userId: "local-board",
})).resolves.toHaveLength(0);
const rows = await db.select().from(issueThreadInteractions);
expect(rows).toHaveLength(1);
expect(rows[0]?.status).toBe("pending");
});
it("repairs historical ask_user_questions superseded by later user comments idempotently", async () => {
const { companyId, issueId } = await seedConfirmationIssue("Historical question supersede");
const commentId = randomUUID();
const createdAt = new Date("2026-05-18T12:00:00.000Z");
const created = await interactionsSvc.create({
id: issueId,
companyId,
}, {
kind: "ask_user_questions",
payload: {
version: 1,
questions: [{
id: "scope",
prompt: "Choose the scope",
selectionMode: "single",
options: [{ id: "phase-1", label: "Phase 1" }],
}],
},
}, {
userId: "local-board",
});
await db
.update(issueThreadInteractions)
.set({ createdAt, updatedAt: createdAt })
.where(eq(issueThreadInteractions.id, created.id));
await db.insert(issueComments).values({
id: randomUUID(),
companyId,
issueId,
authorType: "system",
body: "System-side progress note.",
createdAt: new Date("2026-05-18T12:00:30.000Z"),
updatedAt: new Date("2026-05-18T12:00:30.000Z"),
});
await db.insert(issueComments).values({
id: commentId,
companyId,
issueId,
authorUserId: "local-board",
authorType: "user",
body: "Please revise this first.",
createdAt: new Date("2026-05-18T12:01:00.000Z"),
updatedAt: new Date("2026-05-18T12:01:00.000Z"),
});
const expired = await interactionsSvc.expireRequestConfirmationsSupersededByHistoricalComments({
id: issueId,
companyId,
});
expect(expired).toHaveLength(1);
expect(expired[0]).toMatchObject({
id: created.id,
kind: "ask_user_questions",
status: "expired",
result: {
version: 1,
answers: [],
expirationReason: "superseded_by_comment",
commentId,
summaryMarkdown: null,
},
resolvedByAgentId: null,
resolvedByUserId: "local-board",
});
await expect(interactionsSvc.expireRequestConfirmationsSupersededByHistoricalComments({
id: issueId,
companyId,
})).resolves.toEqual([]);
});
it("reuses the existing interaction when the same idempotency key is submitted twice", async () => {
const companyId = randomUUID();
const goalId = randomUUID();
@@ -0,0 +1,505 @@
import { randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import {
agents,
companies,
createDb,
documentRevisions,
documents,
goals,
issueComments,
issueDocuments,
issueThreadInteractions,
issues,
} from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { issueThreadInteractionService } from "../services/issue-thread-interactions.js";
const telemetryMocks = vi.hoisted(() => ({
track: vi.fn(),
getTelemetryClient: vi.fn(),
}));
vi.mock("../telemetry.js", () => ({
getTelemetryClient: telemetryMocks.getTelemetryClient,
}));
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
describeEmbeddedPostgres("issueThreadInteractionService telemetry", () => {
let db!: ReturnType<typeof createDb>;
let interactionsSvc!: ReturnType<typeof issueThreadInteractionService>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issue-interaction-telemetry-");
db = createDb(tempDb.connectionString);
interactionsSvc = issueThreadInteractionService(db);
}, 20_000);
beforeEach(() => {
telemetryMocks.track.mockClear();
telemetryMocks.getTelemetryClient.mockReturnValue({
track: telemetryMocks.track,
hashPrivateRef: vi.fn((value: string) => `hashed:${value}`),
});
});
afterEach(async () => {
await db.delete(issueThreadInteractions);
await db.delete(issueComments);
await db.delete(issueDocuments);
await db.delete(documentRevisions);
await db.delete(documents);
await db.delete(issues);
await db.delete(goals);
await db.delete(agents);
await db.delete(companies);
});
afterAll(async () => {
await tempDb?.cleanup();
});
async function seedIssue(title = "Interaction telemetry") {
const companyId = randomUUID();
const goalId = randomUUID();
const issueId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(goals).values({
id: goalId,
companyId,
title,
level: "task",
status: "active",
});
await db.insert(issues).values({
id: issueId,
companyId,
goalId,
title,
status: "in_progress",
priority: "medium",
});
return { companyId, goalId, issueId };
}
async function seedAgent(companyId: string, role: string) {
const agentId = randomUUID();
await db.insert(agents).values({
id: agentId,
companyId,
name: role,
role,
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
return agentId;
}
function expectNoRawInteractionIds(dimensions: Record<string, unknown>) {
expect(dimensions).not.toHaveProperty("interaction_id");
expect(dimensions).not.toHaveProperty("created_by_agent_id");
expect(dimensions).not.toHaveProperty("source_run_id");
}
function lastInteractionResolvedDimensions() {
expect(telemetryMocks.track).toHaveBeenCalledWith("interaction.resolved", expect.any(Object));
const calls = telemetryMocks.track.mock.calls.filter((call) => call[0] === "interaction.resolved");
return calls.at(-1)?.[1] as Record<string, unknown>;
}
it("emits accepted suggested-task telemetry with created and skipped task counts", async () => {
const { companyId, goalId, issueId } = await seedIssue("Accept suggested tasks telemetry");
const created = await interactionsSvc.create({
id: issueId,
companyId,
}, {
kind: "suggest_tasks",
continuationPolicy: "wake_assignee",
payload: {
version: 1,
tasks: [
{
clientKey: "root",
title: "Create the root follow-up",
},
{
clientKey: "child",
parentClientKey: "root",
title: "Create the nested follow-up",
},
{
clientKey: "sibling",
title: "Create the sibling follow-up",
},
],
},
}, {
userId: "local-board",
});
await interactionsSvc.acceptInteraction({
id: issueId,
companyId,
goalId,
projectId: null,
}, created.id, {
selectedClientKeys: ["root"],
}, {
userId: "local-board",
});
const dimensions = lastInteractionResolvedDimensions();
expect(dimensions).toMatchObject({
interaction_kind: "suggest_tasks",
status: "accepted",
resolved_by_kind: "user",
resolution_reason: "accepted",
created_by_kind: "user",
continuation_policy: "wake_assignee",
target_type: "none",
created_task_count: 1,
skipped_task_count: 2,
});
expectNoRawInteractionIds(dimensions);
});
it("emits accepted checkbox telemetry with raw role, target, and counts", async () => {
const { companyId, goalId, issueId } = await seedIssue("Accept checkbox telemetry");
const creatorAgentId = await seedAgent(companyId, "Backend Engineer");
const created = await interactionsSvc.create({
id: issueId,
companyId,
}, {
kind: "request_checkbox_confirmation",
continuationPolicy: "wake_assignee",
payload: {
version: 1,
prompt: "Which files should be deleted?",
options: [
{ id: "file-a", label: "a.txt" },
{ id: "file-b", label: "b.txt" },
],
target: {
type: "custom",
key: "cleanup-plan",
},
},
}, {
agentId: creatorAgentId,
});
await interactionsSvc.acceptInteraction({
id: issueId,
companyId,
goalId,
projectId: null,
}, created.id, {
selectedOptionIds: ["file-b"],
}, {
userId: "local-board",
});
const dimensions = lastInteractionResolvedDimensions();
expect(dimensions).toMatchObject({
interaction_kind: "request_checkbox_confirmation",
status: "accepted",
resolved_by_kind: "user",
resolution_reason: "accepted",
created_by_kind: "agent",
creator_agent_role: "Backend Engineer",
continuation_policy: "wake_assignee",
target_type: "custom",
option_count: 2,
selected_option_count: 1,
});
expectNoRawInteractionIds(dimensions);
});
it("emits rejected confirmation telemetry and omits creator_agent_role for user-created interactions", async () => {
const { companyId, issueId } = await seedIssue("Reject confirmation telemetry");
const resolverAgentId = await seedAgent(companyId, "SecurityEngineer");
const created = await interactionsSvc.create({
id: issueId,
companyId,
}, {
kind: "request_confirmation",
payload: {
version: 1,
prompt: "Approve this?",
},
}, {
userId: "local-board",
});
await interactionsSvc.rejectInteraction({
id: issueId,
companyId,
}, created.id, {
reason: "Needs edits before approval.",
}, {
agentId: resolverAgentId,
});
const dimensions = lastInteractionResolvedDimensions();
expect(dimensions).toMatchObject({
interaction_kind: "request_confirmation",
status: "rejected",
resolved_by_kind: "agent",
resolution_reason: "rejected",
created_by_kind: "user",
continuation_policy: "none",
target_type: "none",
});
expect(dimensions).not.toHaveProperty("creator_agent_role");
expect(dimensions).not.toHaveProperty("reason");
expectNoRawInteractionIds(dimensions);
});
it("emits answered question telemetry with system resolver and raw creator role", async () => {
const { companyId, issueId } = await seedIssue("Answer question telemetry");
const creatorAgentId = await seedAgent(companyId, "Wizard");
const created = await interactionsSvc.create({
id: issueId,
companyId,
}, {
kind: "ask_user_questions",
continuationPolicy: "wake_assignee",
payload: {
version: 1,
questions: [
{
id: "scope",
prompt: "Choose the scope",
selectionMode: "single",
required: true,
options: [{ id: "phase-1", label: "Phase 1" }],
},
{
id: "extras",
prompt: "Optional extras",
selectionMode: "multi",
options: [{ id: "docs", label: "Docs" }],
},
],
},
}, {
agentId: creatorAgentId,
});
await db
.update(issueThreadInteractions)
.set({ continuationPolicy: "" })
.where(eq(issueThreadInteractions.id, created.id));
await interactionsSvc.answerQuestions({
id: issueId,
companyId,
}, created.id, {
answers: [{ questionId: "scope", optionIds: ["phase-1"] }],
summaryMarkdown: "Do not emit this free text.",
}, {});
const dimensions = lastInteractionResolvedDimensions();
expect(dimensions).toMatchObject({
interaction_kind: "ask_user_questions",
status: "answered",
resolved_by_kind: "system",
created_by_kind: "agent",
creator_agent_role: "Wizard",
target_type: "none",
question_count: 2,
answered_question_count: 1,
});
expect(dimensions).not.toHaveProperty("continuation_policy");
expect(dimensions).not.toHaveProperty("summaryMarkdown");
expect(dimensions).not.toHaveProperty("answers");
expectNoRawInteractionIds(dimensions);
});
it("emits expired question telemetry with zero answered question count", async () => {
const { companyId, issueId } = await seedIssue("Expired question telemetry");
const commentId = randomUUID();
const created = await interactionsSvc.create({
id: issueId,
companyId,
}, {
kind: "ask_user_questions",
continuationPolicy: "wake_assignee",
payload: {
version: 1,
questions: [
{
id: "scope",
prompt: "Choose the scope",
selectionMode: "single",
required: true,
options: [{ id: "phase-1", label: "Phase 1" }],
},
],
},
}, {
userId: "local-board",
});
const expired = await interactionsSvc.expireRequestConfirmationsSupersededByComment({
id: issueId,
companyId,
}, {
id: commentId,
createdAt: new Date(new Date(created.createdAt).getTime() + 1_000),
authorUserId: "local-board",
}, {
userId: "local-board",
});
expect(expired).toHaveLength(1);
const dimensions = lastInteractionResolvedDimensions();
expect(dimensions).toMatchObject({
interaction_kind: "ask_user_questions",
status: "expired",
resolved_by_kind: "user",
resolution_reason: "superseded_by_comment",
created_by_kind: "user",
continuation_policy: "wake_assignee",
target_type: "none",
question_count: 1,
answered_question_count: 0,
});
expectNoRawInteractionIds(dimensions);
});
it("emits expired stale-target telemetry without stale target identifiers", async () => {
const { companyId, issueId } = await seedIssue("Stale target telemetry");
const documentId = randomUUID();
const revisionId = randomUUID();
await db.insert(documents).values({
id: documentId,
companyId,
title: "Plan",
format: "markdown",
latestBody: "v1",
latestRevisionId: revisionId,
latestRevisionNumber: 1,
});
await db.insert(issueDocuments).values({
companyId,
issueId,
documentId,
key: "plan",
});
await db.insert(documentRevisions).values({
id: revisionId,
companyId,
documentId,
revisionNumber: 1,
title: "Plan",
format: "markdown",
body: "v1",
});
await interactionsSvc.create({
id: issueId,
companyId,
}, {
kind: "request_confirmation",
payload: {
version: 1,
prompt: "Approve the plan?",
target: {
type: "issue_document",
issueId,
documentId,
key: "plan",
revisionId,
revisionNumber: 1,
},
},
}, {
userId: "local-board",
});
const expired = await interactionsSvc.expireStaleRequestConfirmationsForIssueDocument({
id: issueId,
companyId,
}, null, {});
expect(expired).toHaveLength(1);
const dimensions = lastInteractionResolvedDimensions();
expect(dimensions).toMatchObject({
interaction_kind: "request_confirmation",
status: "expired",
resolved_by_kind: "system",
resolution_reason: "stale_target",
created_by_kind: "user",
continuation_policy: "none",
target_type: "issue_document",
});
expect(dimensions).not.toHaveProperty("staleTarget");
expect(dimensions).not.toHaveProperty("revisionId");
expectNoRawInteractionIds(dimensions);
});
it("emits superseded expiration telemetry without comment identifiers", async () => {
const { companyId, issueId } = await seedIssue("Superseded telemetry");
const commentId = randomUUID();
const created = await interactionsSvc.create({
id: issueId,
companyId,
}, {
kind: "request_confirmation",
payload: {
version: 1,
prompt: "Approve this plan?",
},
}, {
userId: "local-board",
});
const expired = await interactionsSvc.expireRequestConfirmationsSupersededByComment({
id: issueId,
companyId,
}, {
id: commentId,
createdAt: new Date(new Date(created.createdAt).getTime() + 1_000),
authorUserId: "local-board",
}, {
userId: "local-board",
});
expect(expired).toHaveLength(1);
const dimensions = lastInteractionResolvedDimensions();
expect(dimensions).toMatchObject({
interaction_kind: "request_confirmation",
status: "expired",
resolved_by_kind: "user",
resolution_reason: "superseded_by_comment",
created_by_kind: "user",
target_type: "none",
});
expect(dimensions).not.toHaveProperty("commentId");
expectNoRawInteractionIds(dimensions);
});
});
+252 -3
View File
@@ -94,14 +94,81 @@ describe("deriveIssueCommentRunLogAttribution", () => {
});
});
it("does not rewrite comments without exact run-log proof", () => {
it("resolves directly from the comment's own run id without reading logs", () => {
const commentId = randomUUID();
const runId = randomUUID();
const agentId = randomUUID();
const derived = deriveIssueCommentRunLogAttribution(
[
{
id: commentId,
authorAgentId: null,
authorUserId: "local-board",
createdByRunId: runId,
createdAt: new Date("2026-05-11T18:55:40.090Z"),
},
],
[
{
runId,
agentId,
createdAt: new Date("2026-05-11T18:51:56.246Z"),
startedAt: new Date("2026-05-11T18:51:56.257Z"),
finishedAt: new Date("2026-05-11T18:55:45.600Z"),
logContent: "",
},
],
);
expect(derived.get(commentId)).toEqual({
derivedAuthorAgentId: agentId,
derivedCreatedByRunId: runId,
derivedAuthorSource: "run_id",
});
});
it("does NOT attribute on run-window overlap alone — timing is not a lossless signal (option A)", () => {
// A human board comment can land inside an agent's run window; since both are
// stored as `local-board`, a timing-only guess would mis-attribute it. So a
// single overlapping run with no run-id and no log marker stays unresolved.
const commentId = randomUUID();
const runId = randomUUID();
const agentId = randomUUID();
const derived = deriveIssueCommentRunLogAttribution(
[
{
id: commentId,
authorAgentId: null,
authorUserId: "local-board",
createdByRunId: null,
createdAt: new Date("2026-05-11T18:55:40.090Z"),
},
],
[
{
runId,
agentId,
createdAt: new Date("2026-05-11T18:51:56.246Z"),
startedAt: new Date("2026-05-11T18:51:56.257Z"),
finishedAt: new Date("2026-05-11T18:55:45.600Z"),
logContent: "posted results without echoing the comment id",
},
],
);
expect(derived.has(commentId)).toBe(false);
});
it("does not guess when multiple agent runs overlap and no log proves the author", () => {
const commentId = randomUUID();
const derived = deriveIssueCommentRunLogAttribution(
[
{
id: commentId,
authorAgentId: null,
authorUserId: "user-1",
authorUserId: "local-board",
createdByRunId: null,
createdAt: new Date("2026-05-11T18:55:40.090Z"),
},
@@ -113,7 +180,81 @@ describe("deriveIssueCommentRunLogAttribution", () => {
createdAt: new Date("2026-05-11T18:51:56.246Z"),
startedAt: new Date("2026-05-11T18:51:56.257Z"),
finishedAt: new Date("2026-05-11T18:55:45.600Z"),
logContent: "posted results without echoing the comment id",
logContent: "no comment id here",
},
{
runId: randomUUID(),
agentId: randomUUID(),
createdAt: new Date("2026-05-11T18:54:00.000Z"),
startedAt: new Date("2026-05-11T18:54:00.000Z"),
finishedAt: new Date("2026-05-11T18:56:00.000Z"),
logContent: "also nothing",
},
],
);
expect(derived.has(commentId)).toBe(false);
});
it("does NOT attribute on same-agent run-window overlap alone (option A)", () => {
// Even when every overlapping run is the same agent, timing alone cannot
// prove the comment was the agent's vs a human board comment during the run.
const commentId = randomUUID();
const agentId = randomUUID();
const derived = deriveIssueCommentRunLogAttribution(
[
{
id: commentId,
authorAgentId: null,
authorUserId: "local-board",
createdByRunId: null,
createdAt: new Date("2026-06-29T17:41:59.916Z"),
},
],
[
{
runId: randomUUID(),
agentId,
createdAt: new Date("2026-06-29T17:41:26.116Z"),
startedAt: new Date("2026-06-29T17:41:26.116Z"),
finishedAt: new Date("2026-06-29T17:46:33.794Z"),
logContent: "no comment id here",
},
{
runId: randomUUID(),
agentId,
createdAt: new Date("2026-06-29T17:40:09.531Z"),
startedAt: new Date("2026-06-29T17:40:09.531Z"),
finishedAt: new Date("2026-06-29T17:46:33.794Z"),
logContent: "also nothing",
},
],
);
expect(derived.has(commentId)).toBe(false);
});
it("never reattributes a comment that already has a stored agent author", () => {
const commentId = randomUUID();
const derived = deriveIssueCommentRunLogAttribution(
[
{
id: commentId,
authorAgentId: randomUUID(),
authorUserId: null,
createdByRunId: null,
createdAt: new Date("2026-05-11T18:55:40.090Z"),
},
],
[
{
runId: randomUUID(),
agentId: randomUUID(),
createdAt: new Date("2026-05-11T18:51:56.246Z"),
startedAt: new Date("2026-05-11T18:51:56.257Z"),
finishedAt: new Date("2026-05-11T18:55:45.600Z"),
logContent: "",
},
],
);
@@ -3059,6 +3200,114 @@ describeEmbeddedPostgres("issueService blockers and dependency wake readiness",
expect(blockedRelations.blockedBy.map((relation) => relation.id)).toEqual([blockerId]);
});
it("returns blocked-by summaries on newly created issues", async () => {
const companyId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
const blockerId = randomUUID();
await db.insert(issues).values({
id: blockerId,
companyId,
title: "Blocker",
status: "todo",
priority: "high",
});
const created = await svc.create(companyId, {
title: "Blocked issue",
status: "blocked",
priority: "medium",
blockedByIssueIds: [blockerId],
});
expect(created.blockedBy.map((relation) => relation.id)).toEqual([blockerId]);
expect(created.blockedBy[0]).toEqual(expect.objectContaining({
title: "Blocker",
status: "todo",
priority: "high",
}));
expect(created.blocks).toEqual([]);
});
it("returns blocked-by summaries on newly created child issues", async () => {
const companyId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
const parentId = randomUUID();
const blockerId = randomUUID();
await db.insert(issues).values([
{
id: parentId,
companyId,
title: "Parent",
status: "todo",
priority: "medium",
},
{
id: blockerId,
companyId,
title: "Blocker",
status: "todo",
priority: "high",
},
]);
const { issue: child } = await svc.createChild(parentId, {
title: "Blocked child issue",
status: "blocked",
priority: "medium",
blockedByIssueIds: [blockerId],
});
expect(child.parentId).toBe(parentId);
expect(child.blockedBy.map((relation) => relation.id)).toEqual([blockerId]);
expect(child.blocks).toEqual([]);
});
it("returns blocks summaries when child creation blocks the parent", async () => {
const companyId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
const parentId = randomUUID();
await db.insert(issues).values({
id: parentId,
companyId,
title: "Parent",
status: "todo",
priority: "medium",
});
const { issue: child } = await svc.createChild(parentId, {
title: "Parent-blocking child",
status: "todo",
priority: "medium",
blockParentUntilDone: true,
});
expect(child.blocks.map((relation) => relation.id)).toEqual([parentId]);
expect(child.blocks[0]).toEqual(expect.objectContaining({
title: "Parent",
status: "todo",
priority: "medium",
}));
expect(child.blockedBy).toEqual([]);
});
it("adds terminal blockers to immediate blocked-by summaries", async () => {
const companyId = randomUUID();
await db.insert(companies).values({
@@ -26,8 +26,8 @@ describe("plugin telemetry bridge", () => {
});
it("prefixes plugin telemetry events before forwarding them to the telemetry client", async () => {
const track = vi.fn();
mockGetTelemetryClient.mockReturnValue({ track });
const trackDynamic = vi.fn();
mockGetTelemetryClient.mockReturnValue({ trackDynamic });
const services = buildHostServices(
{} as never,
@@ -46,14 +46,14 @@ describe("plugin telemetry bridge", () => {
dimensions: { attempts: 2, success: true },
});
expect(track).toHaveBeenCalledWith("plugin.linear.sync_completed", {
expect(trackDynamic).toHaveBeenCalledWith("plugin.linear.sync_completed", {
attempts: 2,
success: true,
});
});
it("rejects invalid bare telemetry event names before prefixing", async () => {
mockGetTelemetryClient.mockReturnValue({ track: vi.fn() });
mockGetTelemetryClient.mockReturnValue({ trackDynamic: vi.fn() });
const services = buildHostServices(
{} as never,
@@ -92,6 +92,9 @@ describe("plugin telemetry bridge", () => {
});
it("passes telemetry requests through when the plugin declares the capability", async () => {
const trackDynamic = vi.fn();
mockGetTelemetryClient.mockReturnValue({ trackDynamic });
const services = buildHostServices(
{} as never,
"plugin-record-id",
@@ -110,5 +113,8 @@ describe("plugin telemetry bridge", () => {
});
expect(mockGetTelemetryClient).toHaveBeenCalledTimes(1);
expect(trackDynamic).toHaveBeenCalledWith("plugin.linear.sync_completed", {
source: "manual",
});
});
});
@@ -150,7 +150,7 @@ describe("project and goal telemetry routes", () => {
.send({ name: "Telemetry project" });
expect([200, 201], JSON.stringify(res.body)).toContain(res.status);
expect(mockTelemetryTrack).toHaveBeenCalledWith("project.created");
expect(mockTelemetryTrack).toHaveBeenCalledWith("project.created", {});
});
it("emits telemetry when a goal is created", async () => {
@@ -3,9 +3,10 @@ import {
trackAgentCreated,
trackAgentFirstHeartbeat,
trackAgentTaskCompleted,
trackInteractionResolved,
trackInstallCompleted,
} from "@paperclipai/shared/telemetry";
import type { TelemetryClient } from "@paperclipai/shared/telemetry";
import type { EventDimensionsMap, TelemetryClient } from "@paperclipai/shared/telemetry";
function createClient(): TelemetryClient {
return {
@@ -14,6 +15,10 @@ function createClient(): TelemetryClient {
} as unknown as TelemetryClient;
}
function runtimeValue<T>(value: string): T {
return value as T;
}
describe("shared telemetry agent events", () => {
it("includes agent_id for agent.created", () => {
const client = createClient();
@@ -29,16 +34,30 @@ describe("shared telemetry agent events", () => {
});
});
it("passes an unrecognized agent role through for backend normalization", () => {
const client = createClient();
trackAgentCreated(client, {
agentRole: runtimeValue<EventDimensionsMap["agent.created"]["agent_role"]>("coder"),
agentId: "44444444-4444-4444-8444-444444444444",
});
expect(client.track).toHaveBeenCalledWith("agent.created", {
agent_role: "coder",
agent_id: "44444444-4444-4444-8444-444444444444",
});
});
it("includes agent_id for agent.first_heartbeat", () => {
const client = createClient();
trackAgentFirstHeartbeat(client, {
agentRole: "coder",
agentRole: "engineer",
agentId: "22222222-2222-4222-8222-222222222222",
});
expect(client.track).toHaveBeenCalledWith("agent.first_heartbeat", {
agent_role: "coder",
agent_role: "engineer",
agent_id: "22222222-2222-4222-8222-222222222222",
});
});
@@ -49,11 +68,13 @@ describe("shared telemetry agent events", () => {
trackAgentTaskCompleted(client, {
agentRole: "qa",
agentId: "33333333-3333-4333-8333-333333333333",
adapterType: "codex_local",
});
expect(client.track).toHaveBeenCalledWith("agent.task_completed", {
agent_role: "qa",
agent_id: "33333333-3333-4333-8333-333333333333",
adapter_type: "codex_local",
});
});
@@ -70,4 +91,40 @@ describe("shared telemetry agent events", () => {
expect.objectContaining({ agent_id: expect.any(String) }),
);
});
it("passes interaction.resolved enum dimensions through for backend normalization", () => {
const client = createClient();
trackInteractionResolved(client, {
interactionKind: runtimeValue<EventDimensionsMap["interaction.resolved"]["interaction_kind"]>(
"single_confirmation",
),
status: "accepted",
resolvedByKind: runtimeValue<EventDimensionsMap["interaction.resolved"]["resolved_by_kind"]>("operator"),
resolutionReason: "accepted",
createdByKind: "agent",
creatorAgentRole: runtimeValue<EventDimensionsMap["interaction.resolved"]["creator_agent_role"]>("coder"),
continuationPolicy: runtimeValue<EventDimensionsMap["interaction.resolved"]["continuation_policy"]>(
"wake_everyone",
),
targetType: "issue_document",
optionCount: 2,
selectedOptionCount: 1,
skippedTaskCount: 3,
});
expect(client.track).toHaveBeenCalledWith("interaction.resolved", {
interaction_kind: "single_confirmation",
status: "accepted",
resolved_by_kind: "operator",
resolution_reason: "accepted",
created_by_kind: "agent",
creator_agent_role: "coder",
continuation_policy: "wake_everyone",
target_type: "issue_document",
option_count: 2,
selected_option_count: 1,
skipped_task_count: 3,
});
});
});
@@ -0,0 +1,378 @@
import { randomUUID } from "node:crypto";
import express from "express";
import request from "supertest";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
activityLog,
agents,
approvals,
authUsers,
companies,
createDb,
heartbeatRuns,
issueApprovals,
issueComments,
issues,
} from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { errorHandler } from "../middleware/index.js";
import { companyRoutes } from "../routes/companies.js";
import { normalizeTimelineWindow, workTimelineService } from "../services/work-timeline.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres work timeline tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
describeEmbeddedPostgres("work timeline aggregation", () => {
let db!: ReturnType<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-work-timeline-");
db = createDb(tempDb.connectionString);
}, 20_000);
afterEach(async () => {
await db.delete(activityLog);
await db.delete(issueComments);
await db.delete(issueApprovals);
await db.delete(approvals);
await db.delete(heartbeatRuns);
await db.delete(issues);
await db.delete(agents);
await db.delete(authUsers);
await db.delete(companies);
});
afterAll(async () => {
await tempDb?.cleanup();
});
function createApp() {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).actor = {
type: "board",
userId: "local-board",
companyIds: [],
memberships: [],
source: "local_implicit",
isInstanceAdmin: true,
};
next();
});
app.use("/api/companies", companyRoutes(db, {} as any));
app.use(errorHandler);
return app;
}
async function seedBase() {
const companyId = randomUUID();
const userId = "user-1";
const agentAId = randomUUID();
const agentBId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Timeline Co",
issuePrefix: `T${randomUUID().replace(/-/g, "").slice(0, 4).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(authUsers).values({
id: userId,
name: "User One",
email: "user@example.com",
emailVerified: true,
createdAt: new Date("2026-01-01T00:00:00Z"),
updatedAt: new Date("2026-01-01T00:00:00Z"),
});
await db.insert(agents).values([
{
id: agentAId,
companyId,
name: "Coder",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
},
{
id: agentBId,
companyId,
name: "QA",
role: "qa",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
},
]);
return { companyId, userId, agentAId, agentBId };
}
it("normalizes timeline windows with a 31 day cap", () => {
const result = normalizeTimelineWindow({
from: new Date("2026-01-01T00:00:00Z"),
to: new Date("2026-03-15T00:00:00Z"),
}, new Date("2026-03-15T00:00:00Z"));
expect(result.capped).toBe(true);
expect(result.from.toISOString()).toBe("2026-02-12T00:00:00.000Z");
});
it("aggregates runs, human events, approvals, and delegation edges", async () => {
const { companyId, userId, agentAId, agentBId } = await seedBase();
const parentIssueId = randomUUID();
const childIssueId = randomUUID();
const contextRunId = randomUUID();
const activityRunId = randomUUID();
const approvalId = randomUUID();
await db.insert(issues).values([
{
id: parentIssueId,
companyId,
title: "Parent",
identifier: "TL-1",
status: "in_progress",
priority: "medium",
createdByUserId: userId,
assigneeAgentId: agentAId,
createdAt: new Date("2026-03-01T10:00:00Z"),
updatedAt: new Date("2026-03-01T10:00:00Z"),
},
{
id: childIssueId,
companyId,
title: "Child",
identifier: "TL-2",
status: "in_progress",
priority: "medium",
parentId: parentIssueId,
createdByAgentId: agentAId,
assigneeAgentId: agentBId,
createdAt: new Date("2026-03-01T11:00:00Z"),
updatedAt: new Date("2026-03-01T11:00:00Z"),
},
]);
await db.insert(heartbeatRuns).values([
{
id: contextRunId,
companyId,
agentId: agentBId,
status: "running",
invocationSource: "issue_assigned",
startedAt: new Date("2026-03-01T12:00:00Z"),
finishedAt: null,
contextSnapshot: { issueId: childIssueId },
},
{
id: activityRunId,
companyId,
agentId: agentAId,
status: "completed",
invocationSource: "manual",
startedAt: new Date("2026-03-01T12:30:00Z"),
finishedAt: new Date("2026-03-01T12:45:00Z"),
contextSnapshot: {},
},
]);
await db.insert(activityLog).values([
{
companyId,
actorType: "agent",
actorId: agentAId,
action: "issue.updated",
entityType: "issue",
entityId: parentIssueId,
agentId: agentAId,
runId: activityRunId,
createdAt: new Date("2026-03-01T12:35:00Z"),
},
{
companyId,
actorType: "user",
actorId: userId,
action: "issue.assigned",
entityType: "issue",
entityId: childIssueId,
details: { assigneeAgentId: agentBId },
createdAt: new Date("2026-03-01T13:00:00Z"),
},
]);
await db.insert(issueComments).values({
companyId,
issueId: childIssueId,
authorUserId: userId,
body: "Looks good",
createdAt: new Date("2026-03-01T13:30:00Z"),
});
await db.insert(approvals).values({
id: approvalId,
companyId,
type: "request_board_approval",
status: "approved",
payload: {},
decidedByUserId: userId,
decidedAt: new Date("2026-03-01T14:00:00Z"),
});
await db.insert(issueApprovals).values({ companyId, issueId: childIssueId, approvalId });
const result = await workTimelineService(db).getTimeline({
companyId,
from: new Date("2026-03-01T00:00:00Z"),
to: new Date("2026-03-02T00:00:00Z"),
});
expect(result.actors.map((actor) => actor.name)).toEqual(expect.arrayContaining(["Coder", "QA", "User One"]));
expect(result.spans).toEqual(expect.arrayContaining([
expect.objectContaining({ runId: contextRunId, issueId: childIssueId, end: null, status: "running" }),
expect.objectContaining({ runId: activityRunId, issueId: parentIssueId, status: "completed" }),
]));
expect(result.events.map((event) => event.kind)).toEqual(expect.arrayContaining([
"created",
"commented",
"approved",
"delegated",
"assigned",
]));
expect(result.edges).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "delegation", issueId: childIssueId }),
expect.objectContaining({ kind: "assignment", issueId: childIssueId }),
]));
});
it("applies the user lens as a transitive issue subtree", async () => {
const { companyId, userId, agentAId, agentBId } = await seedBase();
const rootIssueId = randomUUID();
const childIssueId = randomUUID();
const unrelatedIssueId = randomUUID();
await db.insert(issues).values([
{
id: rootIssueId,
companyId,
title: "User root",
status: "todo",
priority: "medium",
createdByUserId: userId,
assigneeAgentId: agentAId,
createdAt: new Date("2026-03-03T10:00:00Z"),
updatedAt: new Date("2026-03-03T10:00:00Z"),
},
{
id: childIssueId,
companyId,
title: "Delegated child",
status: "todo",
priority: "medium",
parentId: rootIssueId,
createdByAgentId: agentAId,
assigneeAgentId: agentBId,
createdAt: new Date("2026-03-03T11:00:00Z"),
updatedAt: new Date("2026-03-03T11:00:00Z"),
},
{
id: unrelatedIssueId,
companyId,
title: "Unrelated",
status: "todo",
priority: "medium",
assigneeAgentId: agentBId,
createdAt: new Date("2026-03-03T12:00:00Z"),
updatedAt: new Date("2026-03-03T12:00:00Z"),
},
]);
const result = await workTimelineService(db).getTimeline({
companyId,
userId,
from: new Date("2026-03-03T00:00:00Z"),
to: new Date("2026-03-04T00:00:00Z"),
});
expect(result.events.map((event) => event.issueId)).toEqual(expect.arrayContaining([rootIssueId, childIssueId]));
expect(result.events.map((event) => event.issueId)).not.toContain(unrelatedIssueId);
});
it("filters unreadable issues before emitting timeline rows", async () => {
const { companyId, agentAId } = await seedBase();
const visibleIssueId = randomUUID();
const hiddenIssueId = randomUUID();
await db.insert(issues).values([
{
id: visibleIssueId,
companyId,
title: "Visible",
status: "todo",
priority: "medium",
assigneeAgentId: agentAId,
createdAt: new Date("2026-03-04T10:00:00Z"),
updatedAt: new Date("2026-03-04T10:00:00Z"),
},
{
id: hiddenIssueId,
companyId,
title: "Denied",
status: "todo",
priority: "medium",
assigneeAgentId: agentAId,
createdAt: new Date("2026-03-04T11:00:00Z"),
updatedAt: new Date("2026-03-04T11:00:00Z"),
},
]);
const result = await workTimelineService(db).getTimeline({
companyId,
from: new Date("2026-03-04T00:00:00Z"),
to: new Date("2026-03-05T00:00:00Z"),
canReadIssue: async (issue) => issue.id !== hiddenIssueId,
});
expect(result.events.map((event) => event.issueId)).toContain(visibleIssueId);
expect(result.events.map((event) => event.issueId)).not.toContain(hiddenIssueId);
expect(result.pagination.totalIssues).toBe(1);
});
it("serves GET /api/companies/:companyId/timeline", async () => {
const { companyId, agentAId } = await seedBase();
const issueId = randomUUID();
await db.insert(issues).values({
id: issueId,
companyId,
title: "Route issue",
identifier: "TL-9",
status: "todo",
priority: "medium",
assigneeAgentId: agentAId,
createdAt: new Date("2026-03-05T10:00:00Z"),
updatedAt: new Date("2026-03-05T10:00:00Z"),
});
const res = await request(createApp())
.get(`/api/companies/${companyId}/timeline`)
.query({ from: "2026-03-05T00:00:00Z", to: "2026-03-06T00:00:00Z" });
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(res.body.events).toEqual([
expect.objectContaining({ kind: "created", issueId }),
]);
expect(res.body).toEqual(expect.objectContaining({
actors: expect.any(Array),
spans: expect.any(Array),
edges: expect.any(Array),
pagination: expect.objectContaining({ totalIssues: 1 }),
}));
});
});
+331 -28
View File
@@ -754,8 +754,9 @@ describe("realizeExecutionWorkspace", () => {
await expect(fs.realpath(realized.worktreePath ?? "")).resolves.toBe(expectedWorktreePath);
});
it("rejects reusing a linked worktree whose branch drifted from the expected issue branch", async () => {
it("repairs a clean linked worktree whose branch drifted from the expected issue branch", async () => {
const repoRoot = await createTempRepo();
const { recorder, operations } = createWorkspaceOperationRecorderDouble();
const initial = await realizeExecutionWorkspace({
base: {
@@ -786,34 +787,52 @@ describe("realizeExecutionWorkspace", () => {
await runGit(initial.cwd, ["checkout", "-b", "unexpected-branch"]);
await expect(
realizeExecutionWorkspace({
base: {
baseCwd: repoRoot,
source: "project_primary",
projectId: "project-1",
workspaceId: "workspace-1",
repoUrl: null,
repoRef: "HEAD",
const repaired = await realizeExecutionWorkspace({
base: {
baseCwd: repoRoot,
source: "project_primary",
projectId: "project-1",
workspaceId: "workspace-1",
repoUrl: null,
repoRef: "HEAD",
},
config: {
workspaceStrategy: {
type: "git_worktree",
branchTemplate: "{{issue.identifier}}-{{slug}}",
},
config: {
workspaceStrategy: {
type: "git_worktree",
branchTemplate: "{{issue.identifier}}-{{slug}}",
},
},
issue: {
id: "issue-1",
identifier: "PAP-447",
title: "Add Worktree Support",
},
agent: {
id: "agent-1",
name: "Codex Coder",
companyId: "company-1",
},
}),
).rejects.toThrow(/not a reusable git worktree \(worktree HEAD is on "unexpected-branch" instead of "PAP-447-add-worktree-support"\)\./);
},
issue: {
id: "issue-1",
identifier: "PAP-447",
title: "Add Worktree Support",
},
agent: {
id: "agent-1",
name: "Codex Coder",
companyId: "company-1",
},
recorder,
});
expect(repaired.created).toBe(false);
expect(repaired.cwd).toBe(initial.cwd);
await expect(readGit(initial.cwd, ["branch", "--show-current"])).resolves.toBe("PAP-447-add-worktree-support");
expect(operations).toEqual(
expect.arrayContaining([
expect.objectContaining({
phase: "worktree_prepare",
command: "git checkout PAP-447-add-worktree-support",
metadata: expect.objectContaining({
branchIncoherenceRepair: true,
expectedBranchName: "PAP-447-add-worktree-support",
actualBranchName: "unexpected-branch",
sourceIssueId: "issue-1",
fingerprint: expect.stringMatching(/^workspace_incoherence:v1:sha256:/),
}),
}),
]),
);
});
it("reuses an already checked out branch from git worktree metadata even when the target path differs", async () => {
@@ -2057,6 +2076,290 @@ describe("realizeExecutionWorkspace", () => {
expect(actualHead).toBe(expectedHead);
}, 15_000);
it("repairs a clean persisted git worktree branch mismatch when both branches point at the same commit", async () => {
const repoRoot = await createTempRepo();
const expectedBranch = "PAP-454-repair-clean-branch-mismatch";
const actualBranch = "PAP-454-publish-head";
const realWorktreeRoot = path.join(repoRoot, ".paperclip", "real-worktrees");
const symlinkedWorktreeRoot = path.join(repoRoot, ".paperclip", "worktrees");
const realWorktreePath = path.join(realWorktreeRoot, expectedBranch);
const worktreePath = path.join(symlinkedWorktreeRoot, expectedBranch);
await fs.mkdir(realWorktreeRoot, { recursive: true });
await runGit(repoRoot, ["branch", expectedBranch]);
await runGit(repoRoot, ["worktree", "add", "-b", actualBranch, realWorktreePath, "HEAD"]);
await fs.symlink(realWorktreeRoot, symlinkedWorktreeRoot, "dir");
const { recorder, operations } = createWorkspaceOperationRecorderDouble();
const restored = await ensurePersistedExecutionWorkspaceAvailable({
base: {
baseCwd: repoRoot,
source: "project_primary",
projectId: "project-1",
workspaceId: "workspace-1",
repoUrl: null,
repoRef: "HEAD",
},
workspace: {
id: "execution-workspace-1",
mode: "isolated_workspace",
strategyType: "git_worktree",
cwd: worktreePath,
providerRef: worktreePath,
projectId: "project-1",
projectWorkspaceId: "workspace-1",
repoUrl: null,
baseRef: "HEAD",
branchName: expectedBranch,
},
issue: {
id: "issue-1",
identifier: "PAP-454",
title: "Repair clean branch mismatch",
},
agent: {
id: "agent-1",
name: "Codex Coder",
companyId: "company-1",
},
recorder,
});
expect(restored?.cwd).toBe(worktreePath);
await expect(readGit(worktreePath, ["branch", "--show-current"])).resolves.toBe(expectedBranch);
expect(operations).toEqual(
expect.arrayContaining([
expect.objectContaining({
phase: "worktree_prepare",
command: "git checkout PAP-454-repair-clean-branch-mismatch",
metadata: expect.objectContaining({
branchIncoherenceRepair: true,
expectedBranchName: expectedBranch,
actualBranchName: actualBranch,
sourceIssueId: "issue-1",
executionWorkspaceId: "execution-workspace-1",
fingerprint: expect.stringMatching(/^workspace_incoherence:v1:sha256:/),
}),
}),
]),
);
}, 15_000);
it("rejects dirty persisted git worktree branch incoherence with bounded recovery evidence", async () => {
const repoRoot = await createTempRepo();
const expectedBranch = "PAP-455-reject-dirty-branch-mismatch";
const actualBranch = "PAP-455-publish-head";
const worktreePath = path.join(repoRoot, ".paperclip", "worktrees", expectedBranch);
await fs.mkdir(path.dirname(worktreePath), { recursive: true });
await runGit(repoRoot, ["branch", expectedBranch]);
await runGit(repoRoot, ["worktree", "add", "-b", actualBranch, worktreePath, "HEAD"]);
await fs.writeFile(path.join(worktreePath, "untracked.txt"), "not safe to switch\n", "utf8");
await expect(ensurePersistedExecutionWorkspaceAvailable({
base: {
baseCwd: repoRoot,
source: "project_primary",
projectId: "project-1",
workspaceId: "workspace-1",
repoUrl: null,
repoRef: "HEAD",
},
workspace: {
id: "execution-workspace-2",
mode: "isolated_workspace",
strategyType: "git_worktree",
cwd: worktreePath,
providerRef: worktreePath,
projectId: "project-1",
projectWorkspaceId: "workspace-1",
repoUrl: null,
baseRef: "HEAD",
branchName: expectedBranch,
},
issue: {
id: "issue-2",
identifier: "PAP-455",
title: "Reject dirty branch mismatch",
},
agent: {
id: "agent-1",
name: "Codex Coder",
companyId: "company-1",
},
})).rejects.toMatchObject({
code: "workspace_validation_failed",
resultJson: {
workspaceValidation: expect.objectContaining({
reason: "git_worktree_branch_incoherence",
fingerprint: expect.stringMatching(/^workspace_incoherence:v1:sha256:/),
sourceIssueId: "issue-2",
sourceIdentifier: "PAP-455",
executionWorkspaceId: "execution-workspace-2",
expectedBranch,
actualBranch,
cleanliness: "dirty",
provenance: expect.objectContaining({
expectedBranchExists: true,
actualBranchExists: true,
sameHead: true,
}),
safeRepair: expect.objectContaining({
eligible: false,
attempted: false,
succeeded: false,
reason: "worktree is not clean",
}),
}),
},
});
}, 15_000);
it("routes non-reusable persisted git worktrees through workspace validation recovery", async () => {
const repoRoot = await createTempRepo();
const expectedBranch = "PAP-455-not-registered-worktree";
const detachedWorktreePath = path.join(repoRoot, ".paperclip", "worktrees", expectedBranch);
await fs.mkdir(path.dirname(detachedWorktreePath), { recursive: true });
await execFileAsync("git", ["clone", repoRoot, detachedWorktreePath]);
await runGit(detachedWorktreePath, ["checkout", "-B", expectedBranch]);
await expect(ensurePersistedExecutionWorkspaceAvailable({
base: {
baseCwd: repoRoot,
source: "project_primary",
projectId: "project-1",
workspaceId: "workspace-1",
repoUrl: null,
repoRef: "HEAD",
},
workspace: {
id: "execution-workspace-not-registered",
mode: "isolated_workspace",
strategyType: "git_worktree",
cwd: detachedWorktreePath,
providerRef: detachedWorktreePath,
projectId: "project-1",
projectWorkspaceId: "workspace-1",
repoUrl: null,
baseRef: "HEAD",
branchName: expectedBranch,
},
issue: {
id: "issue-not-registered",
identifier: "PAP-455",
title: "Reject unregistered persisted worktree",
},
agent: {
id: "agent-1",
name: "Codex Coder",
companyId: "company-1",
},
})).rejects.toMatchObject({
code: "workspace_validation_failed",
resultJson: {
workspaceValidation: {
reason: "git_worktree_not_reusable",
reasonCode: "not_registered",
worktreePath: detachedWorktreePath,
executionWorkspaceId: "execution-workspace-not-registered",
},
},
});
}, 15_000);
it("rejects an existing persisted git worktree when the checked-out branch changed to a different commit", async () => {
const repoRoot = await createTempRepo();
const initial = await realizeExecutionWorkspace({
base: {
baseCwd: repoRoot,
source: "project_primary",
projectId: "project-1",
workspaceId: "workspace-1",
repoUrl: null,
repoRef: "HEAD",
},
config: {
workspaceStrategy: {
type: "git_worktree",
branchTemplate: "{{issue.identifier}}-{{slug}}",
},
},
issue: {
id: "issue-1",
identifier: "PAP-456",
title: "Keep persisted branch coherent",
},
agent: {
id: "agent-1",
name: "Codex Coder",
companyId: "company-1",
},
});
const actualBranch = "PAP-456-push-pr-head";
await runGit(initial.cwd, ["checkout", "-b", actualBranch]);
await fs.writeFile(path.join(initial.cwd, "publish.txt"), "publish\n", "utf8");
await runGit(initial.cwd, ["add", "publish.txt"]);
await runGit(initial.cwd, ["commit", "-m", "Add publish branch work"]);
await expect(ensurePersistedExecutionWorkspaceAvailable({
base: {
baseCwd: repoRoot,
source: "project_primary",
projectId: "project-1",
workspaceId: "workspace-1",
repoUrl: null,
repoRef: "HEAD",
},
workspace: {
id: "execution-workspace-3",
mode: "isolated_workspace",
strategyType: "git_worktree",
cwd: initial.cwd,
providerRef: initial.worktreePath,
projectId: "project-1",
projectWorkspaceId: "workspace-1",
repoUrl: null,
baseRef: "HEAD",
branchName: initial.branchName,
},
issue: {
id: "issue-3",
identifier: "PAP-456",
title: "Keep persisted branch coherent",
},
agent: {
id: "agent-1",
name: "Codex Coder",
companyId: "company-1",
},
})).rejects.toMatchObject({
code: "workspace_validation_failed",
resultJson: {
workspaceValidation: expect.objectContaining({
reason: "git_worktree_branch_incoherence",
fingerprint: expect.stringMatching(/^workspace_incoherence:v1:sha256:/),
sourceIssueId: "issue-3",
sourceIdentifier: "PAP-456",
executionWorkspaceId: "execution-workspace-3",
expectedBranch: initial.branchName,
actualBranch,
cleanliness: "clean",
provenance: expect.objectContaining({
expectedBranchExists: true,
actualBranchExists: true,
sameHead: false,
}),
safeRepair: expect.objectContaining({
eligible: false,
attempted: false,
succeeded: false,
reason: "expected branch and current HEAD differ",
}),
}),
},
});
}, 15_000);
it("does not reuse a missing persisted local filesystem workspace", async () => {
const baseCwd = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-workspace-base-"));
const missingCwd = path.join(baseCwd, "missing-workspace");
@@ -50,7 +50,7 @@ Status quick guide:
- Create subtasks with `POST /api/companies/{companyId}/issues`. Always set `parentId` and `goalId`. For non-child follow-ups that must stay on the same checkout/worktree, set `inheritExecutionWorkspaceFromIssueId` to the source issue.
- When you know the needed work and owner, create those subtasks directly. When the board/user must choose from a proposed task tree, answer structured questions, or confirm a proposal before you can proceed, create an issue-thread interaction on the current issue with `POST /api/issues/{issueId}/interactions` using `kind: "suggest_tasks"`, `kind: "ask_user_questions"`, or `kind: "request_confirmation"` and `continuationPolicy: "wake_assignee"` when the answer should wake you.
- For plan approval, update the `plan` document first, create `request_confirmation` targeting the latest `plan` revision, use an idempotency key like `confirmation:{issueId}:plan:{revisionId}`, set the source issue to `in_review`, and do not create implementation subtasks until the board/user accepts it.
- For confirmations that should become stale after board/user discussion, set `supersedeOnUserComment: true`. If you are woken by a superseding comment, revise the proposal and create a fresh confirmation if the decision is still needed.
- `ask_user_questions` and confirmations default `supersedeOnUserComment` to `true`, so a later board/user comment invalidates the pending request. Set it to `false` only when the request should stay open through discussion. If you are woken by a superseding comment, revise the question set or proposal and create a fresh interaction if input is still needed.
- Use `paperclip-create-agent` skill when hiring new agents.
- Assign work to the right agent for the job.
@@ -11,7 +11,7 @@ You are an agent at Paperclip company.
- Use child issues for parallel or long delegated work instead of polling agents, sessions, or processes.
- Create child issues directly when you know what needs to be done. If the board/user needs to choose suggested tasks, answer structured questions, or confirm a proposal first, create an issue-thread interaction on the current issue with `POST /api/issues/{issueId}/interactions` using `kind: "suggest_tasks"`, `kind: "ask_user_questions"`, or `kind: "request_confirmation"`.
- Use `request_confirmation` instead of asking for yes/no decisions in markdown. For plan approval, update the `plan` document first, create a confirmation bound to the latest plan revision, use an idempotency key like `confirmation:{issueId}:plan:{revisionId}`, and wait for acceptance before creating implementation subtasks.
- Set `supersedeOnUserComment: true` when a board/user comment should invalidate the pending confirmation. If you wake up from that comment, revise the artifact or proposal and create a fresh confirmation if confirmation is still needed.
- `ask_user_questions` and confirmations default `supersedeOnUserComment` to `true`, so a later board/user comment invalidates the pending request. Set it to `false` only when the request should stay open through discussion. If you wake up from a superseding comment, revise the artifact, question set, or proposal and create a fresh interaction if input is still needed.
- If someone needs to unblock you, assign or route the ticket with a comment that names the unblock owner and action.
- Respect budget, pause/cancel, approval gates, and company boundaries.
+76
View File
@@ -1,6 +1,7 @@
import { randomUUID } from "node:crypto";
import { Router, type Request } from "express";
import { and, count as countFn, eq } from "drizzle-orm";
import { z } from "zod";
import type { Db } from "@paperclipai/db";
import { agents as agentsTable } from "@paperclipai/db";
import {
@@ -27,6 +28,7 @@ import {
companyService,
feedbackService,
logActivity,
workTimelineService,
} from "../services/index.js";
import type { StorageService } from "../storage/types.js";
import { assertBoard, assertCompanyAccess, assertInstanceAdmin, getActorInfo } from "./authz.js";
@@ -57,6 +59,26 @@ export function companyRoutes(db: Db, storage?: StorageService) {
return parsed;
}
function parseIntegerQuery(value: unknown, field: string) {
if (value === undefined || value === null || value === "") return undefined;
const parsed = typeof value === "string" ? Number(value) : NaN;
if (!Number.isFinite(parsed)) {
throw badRequest(`Invalid ${field} query value`);
}
return Math.floor(parsed);
}
const timelineQuerySchema = z.object({
from: z.string().optional(),
to: z.string().optional(),
userId: z.string().min(1).optional(),
goalId: z.string().uuid().optional(),
projectId: z.string().uuid().optional(),
issueId: z.string().uuid().optional(),
limit: z.string().optional(),
offset: z.string().optional(),
}).passthrough();
function assertImportTargetAccess(
req: Request,
target: { mode: "new_company" } | { mode: "existing_company"; companyId: string },
@@ -123,6 +145,60 @@ export function companyRoutes(db: Db, storage?: StorageService) {
res.json(await artifacts.list(companyId, query));
});
router.get("/:companyId/timeline", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const companyScopeDecision = await access.decide({
actor: req.actor,
action: "company_scope:read",
resource: { type: "company", companyId },
});
if (!companyScopeDecision.allowed) {
res.status(403).json({ error: "Timeline is outside this actor's authorization boundary" });
return;
}
const query = timelineQuerySchema.parse(req.query);
const timeline = workTimelineService(db);
const result = await timeline.getTimeline({
companyId,
from: parseDateQuery(query.from, "from"),
to: parseDateQuery(query.to, "to"),
userId: query.userId,
goalId: query.goalId,
projectId: query.projectId,
issueId: query.issueId,
limit: parseIntegerQuery(query.limit, "limit"),
offset: parseIntegerQuery(query.offset, "offset"),
canReadIssue: async (issue) => {
const decision = await access.decide({
actor: req.actor,
action: "issue:read",
resource: {
type: "issue",
companyId: issue.companyId,
issueId: issue.id,
projectId: issue.projectId,
parentIssueId: issue.parentId,
assigneeAgentId: issue.assigneeAgentId,
assigneeUserId: issue.assigneeUserId,
status: issue.status,
},
scope: {
issueId: issue.id,
projectId: issue.projectId,
parentIssueId: issue.parentId,
assigneeAgentId: issue.assigneeAgentId,
assigneeUserId: issue.assigneeUserId,
},
});
return decision.allowed;
},
});
res.json(result);
});
router.get("/:companyId", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
+1
View File
@@ -814,6 +814,7 @@ export function environmentRoutes(
const probe = await probeEnvironment(db, environment, {
companyId: companyIdForSecrets,
pluginWorkerManager: options.pluginWorkerManager,
applyCustomImageTemplate: environment.driver === "sandbox",
});
await logInstanceEnvironmentActivity({
actor,
+44
View File
@@ -967,6 +967,7 @@ function queueResolvedInteractionContinuationWakeup(input: {
const workspaceRefreshReason = readNonEmptyString(input.workspaceRefreshReason);
const planTarget = readPlanConfirmationTargetForIssue(input.interaction.payload, input.issue.id);
const interactionResult = readConfirmationResultForWake(input.interaction.result);
const checkboxSelection = readCheckboxSelectionForWake(input.interaction);
const planReviewInteraction =
planTarget && input.interaction.kind === "request_confirmation"
? {
@@ -990,6 +991,7 @@ function queueResolvedInteractionContinuationWakeup(input: {
sourceCommentId: input.interaction.sourceCommentId ?? null,
sourceRunId: input.interaction.sourceRunId ?? null,
...(planReviewInteraction ? { planReviewInteraction } : {}),
...(checkboxSelection ? { checkboxSelection } : {}),
mutation: "interaction",
},
requestedByActorType: input.actor.actorType,
@@ -1003,6 +1005,7 @@ function queueResolvedInteractionContinuationWakeup(input: {
sourceCommentId: input.interaction.sourceCommentId ?? null,
sourceRunId: input.interaction.sourceRunId ?? null,
...(planReviewInteraction ? { planReviewInteraction } : {}),
...(checkboxSelection ? { checkboxSelection } : {}),
wakeReason: "issue_commented",
source: input.source,
...(forceFreshSession ? { forceFreshSession: true } : {}),
@@ -1016,6 +1019,41 @@ function queueResolvedInteractionContinuationWakeup(input: {
}, "failed to wake assignee on issue interaction resolution"));
}
function readCheckboxSelectionForWake(input: {
kind: string;
payload?: unknown;
result?: unknown;
}) {
if (input.kind !== "request_checkbox_confirmation") return null;
const result = readObject(input.result);
if (result.outcome !== "accepted") return null;
const selectedOptionIds = Array.isArray(result.selectedOptionIds)
? result.selectedOptionIds.filter((value): value is string => typeof value === "string" && value.length > 0)
: [];
const payload = readObject(input.payload);
const options = Array.isArray(payload.options)
? payload.options
.map((value) => {
const option = readObject(value);
const id = readNonEmptyString(option.id);
if (!id) return null;
return {
id,
label: readNonEmptyString(option.label) ?? id,
description: readNonEmptyString(option.description),
};
})
.filter((value): value is { id: string; label: string; description: string | null } => Boolean(value))
: [];
const optionById = new Map(options.map((option) => [option.id, option]));
return {
prompt: readNonEmptyString(payload.prompt),
selectedOptionIds,
selectedOptions: selectedOptionIds.map((id) => optionById.get(id) ?? { id, label: id, description: null }),
};
}
function diffExecutionParticipants(
previousPolicy: NormalizedExecutionPolicy | null,
nextPolicy: NormalizedExecutionPolicy | null,
@@ -3067,6 +3105,7 @@ export function issueRoutes(
const sortField = req.query.sortField as string | undefined;
const sortDir = req.query.sortDir as string | undefined;
const hasPlanDocument = parseOptionalBooleanQuery(req.query.hasPlanDocument);
const includeLiveDescendantSummary = parseOptionalBooleanQuery(req.query.includeLiveDescendantSummary);
const assigneeAgentFilterRaw = req.query.assigneeAgentId;
let assigneeAgentId: string | null | undefined;
@@ -3110,6 +3149,10 @@ export function issueRoutes(
res.status(400).json({ error: "hasPlanDocument must be true or false when provided" });
return;
}
if (includeLiveDescendantSummary === null) {
res.status(400).json({ error: "includeLiveDescendantSummary must be true or false when provided" });
return;
}
if (assigneeAgentFilterRaw !== undefined) {
if (typeof assigneeAgentFilterRaw !== "string") {
res.status(422).json({ error: "assigneeAgentId must be a UUID or 'null'" });
@@ -3156,6 +3199,7 @@ export function issueRoutes(
includeBlockedBy: req.query.includeBlockedBy === "true" || req.query.includeBlockedBy === "1",
includeBlockedInboxAttention:
req.query.includeBlockedInboxAttention === "true" || req.query.includeBlockedInboxAttention === "1",
includeLiveDescendantSummary: includeLiveDescendantSummary === true,
hasPlanDocument,
q: req.query.q as string | undefined,
limit,
+74
View File
@@ -493,6 +493,63 @@ const environmentCustomImageTemplateRollbackResultSchema = z.object({
supersededTemplate: environmentCustomImageTemplateSchema,
}).strict();
const workTimelineQuerySchema = z.object({
from: z.string().optional(),
to: z.string().optional(),
userId: z.string().optional(),
goalId: z.string().uuid().optional(),
projectId: z.string().uuid().optional(),
issueId: z.string().uuid().optional(),
limit: z.string().optional(),
offset: z.string().optional(),
}).strict();
const workTimelineResponseSchema = z.object({
actors: z.array(z.object({
id: z.string(),
type: z.enum(["agent", "user", "system", "plugin"]),
name: z.string(),
avatar: z.string().nullable().optional(),
}).strict()),
spans: z.array(z.object({
actorId: z.string(),
laneHint: z.string().nullable(),
runId: z.string(),
issueId: z.string(),
issueIdentifier: z.string().nullable(),
start: z.string(),
end: z.string().nullable(),
status: z.string(),
retryOfRunId: z.string().nullable().optional(),
continuationAttempt: z.number().optional(),
invocationSource: z.string().nullable().optional(),
}).strict()),
events: z.array(z.object({
actorId: z.string(),
kind: z.enum(["created", "commented", "approved", "delegated", "assigned"]),
issueId: z.string(),
at: z.string(),
}).strict()),
edges: z.array(z.object({
fromActorId: z.string(),
toActorId: z.string(),
issueId: z.string(),
at: z.string(),
kind: z.enum(["delegation", "assignment", "mention"]),
}).strict()),
pagination: z.object({
limit: z.number().int().positive(),
offset: z.number().int().nonnegative(),
totalIssues: z.number().int().nonnegative(),
hasMore: z.boolean(),
}).strict(),
window: z.object({
from: z.string(),
to: z.string(),
capped: z.boolean(),
}).strict(),
}).strict();
function paramsSchemaFromPath(routePath: string): z.ZodObject<z.ZodRawShape> | undefined {
const names = [...routePath.matchAll(/\{([A-Za-z0-9_]+)\}/g)].map((match) => match[1]);
if (names.length === 0) return undefined;
@@ -906,6 +963,23 @@ registry.registerPath({
},
});
registry.registerPath({
method: "get",
path: "/api/companies/{companyId}/timeline",
tags: ["companies"],
summary: "Get company work timeline",
request: {
params: z.object({ companyId: z.string() }),
query: workTimelineQuerySchema,
},
responses: {
200: r.ok(workTimelineResponseSchema),
400: r.badRequest,
401: r.unauthorized,
403: r.forbidden,
},
});
registry.registerPath({
method: "patch",
path: "/api/companies/{companyId}",
+25 -13
View File
@@ -556,7 +556,7 @@ export async function normalizeEnvironmentConfigForPersistence(input: {
export async function resolveEnvironmentDriverConfigForRuntime(
db: Db,
companyId: string,
companyId: string | null,
environment: Pick<Environment, "driver" | "config"> & Partial<Pick<Environment, "id">>,
context?: {
issueId?: string | null;
@@ -576,6 +576,9 @@ export async function resolveEnvironmentDriverConfigForRuntime(
}
if (parsed.driver === "ssh" && parsed.config.privateKeySecretRef) {
if (!companyId) {
throw unprocessable("Runtime secret resolution requires a companyId context");
}
return {
driver: "ssh",
config: {
@@ -600,22 +603,31 @@ export async function resolveEnvironmentDriverConfigForRuntime(
if (parsed.driver === "sandbox" && parsed.config.provider !== "fake") {
const schema = await getSandboxProviderConfigSchema(db, parsed.config.provider);
const runtimeConfig = await resolveConfigSecretRefsForRuntime({
db,
companyId,
config: parsed.config as Record<string, unknown>,
schema,
context: {
consumerId: environmentId!,
issueId: context?.issueId ?? null,
heartbeatRunId: context?.heartbeatRunId ?? null,
},
}) as SandboxEnvironmentConfig;
let runtimeConfig = parsed.config;
if (companyId) {
runtimeConfig = await resolveConfigSecretRefsForRuntime({
db,
companyId,
config: parsed.config as Record<string, unknown>,
schema,
context: {
consumerId: environmentId!,
issueId: context?.issueId ?? null,
heartbeatRunId: context?.heartbeatRunId ?? null,
},
}) as SandboxEnvironmentConfig;
} else {
for (const path of collectSecretRefPaths(schema)) {
const current = readConfigValueAtPath(parsed.config as Record<string, unknown>, path);
if (typeof current === "string" && isUuidSecretRef(current.trim())) {
throw unprocessable("Runtime secret resolution requires a companyId context");
}
}
}
return {
driver: "sandbox",
config: environmentId && (context?.issueId || context?.heartbeatRunId || context?.applyCustomImageTemplate)
? await resolveActiveEnvironmentCustomImageTemplateForRuntime(db, {
companyId,
environmentId,
baseConfig: parsed.config,
runtimeConfig,
@@ -114,7 +114,6 @@ export function applyCustomImageTemplateToSandboxConfig(
export function environmentCustomImageTemplateFromRow(row: TemplateRow): EnvironmentCustomImageTemplate {
return {
id: row.id,
companyId: row.companyId,
environmentId: row.environmentId,
provider: row.provider,
templateKind: readEnvironmentCustomImageTemplateKind(row.templateKind),
@@ -136,7 +135,6 @@ export function environmentCustomImageTemplateFromRow(row: TemplateRow): Environ
export async function resolveActiveEnvironmentCustomImageTemplateForRuntime(
db: Db,
input: {
companyId: string;
environmentId: string;
baseConfig: SandboxEnvironmentConfig;
runtimeConfig: SandboxEnvironmentConfig;
@@ -147,7 +145,6 @@ export async function resolveActiveEnvironmentCustomImageTemplateForRuntime(
.select()
.from(environmentCustomImageTemplates)
.where(and(
eq(environmentCustomImageTemplates.companyId, input.companyId),
eq(environmentCustomImageTemplates.environmentId, input.environmentId),
eq(environmentCustomImageTemplates.provider, input.baseConfig.provider),
eq(environmentCustomImageTemplates.status, "active"),
+139 -52
View File
@@ -31,6 +31,7 @@ import {
resolveSandboxProviderSecretRefPaths,
stripSandboxProviderEnvelope,
} from "./environment-config.js";
import { secretService } from "./secrets.js";
import {
resolvePluginExecuteRpcTimeoutMs,
resolvePluginSandboxProviderDriverByKey,
@@ -49,6 +50,8 @@ import type { PluginWorkerManager } from "./plugin-worker-manager.js";
const ACTIVE_SETUP_STATUSES = ["starting", "waiting_for_user", "capturing"] as const;
const DEFAULT_SETUP_TTL_SECONDS = 60 * 60;
const DEFAULT_CONNECTION_EXPIRES_IN_MINUTES = 15;
const SETUP_RPC_COMPANY_ID_METADATA_KEY = "setupRpcCompanyId";
const SOURCE_ENVIRONMENT_CONFIG_FINGERPRINT_METADATA_KEY = "sourceEnvironmentConfigFingerprint";
type SetupSessionRow = typeof environmentCustomImageSetupSessions.$inferSelect;
@@ -72,7 +75,6 @@ export interface EnvironmentCustomImageSetupCleanupResult {
function toSession(row: SetupSessionRow): EnvironmentCustomImageSetupSession {
return {
id: row.id,
companyId: row.companyId,
environmentId: row.environmentId,
templateId: row.templateId ?? null,
promotedTemplateId: row.promotedTemplateId ?? null,
@@ -130,6 +132,42 @@ function normalizeProviderMetadata(metadata: Record<string, unknown> | null | un
return redactEnvironmentCustomImageValue(metadata);
}
function metadataRecord(metadata: Record<string, unknown> | null | undefined): Record<string, unknown> {
return metadata && typeof metadata === "object" && !Array.isArray(metadata) ? metadata : {};
}
function normalizeSetupRpcCompanyId(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function readSetupRpcCompanyId(metadata: Record<string, unknown> | null | undefined): string | null {
return normalizeSetupRpcCompanyId(metadataRecord(metadata)[SETUP_RPC_COMPANY_ID_METADATA_KEY]);
}
function persistedSetupMetadata(metadata: Record<string, unknown> | null | undefined): Record<string, unknown> {
const record = metadataRecord(metadata);
const result: Record<string, unknown> = {};
const setupRpcCompanyId = normalizeSetupRpcCompanyId(record[SETUP_RPC_COMPANY_ID_METADATA_KEY]);
if (setupRpcCompanyId) {
result[SETUP_RPC_COMPANY_ID_METADATA_KEY] = setupRpcCompanyId;
}
const fingerprint = readString(record[SOURCE_ENVIRONMENT_CONFIG_FINGERPRINT_METADATA_KEY]);
if (fingerprint) {
result[SOURCE_ENVIRONMENT_CONFIG_FINGERPRINT_METADATA_KEY] = fingerprint;
}
return result;
}
function mergeSetupSessionMetadata(
existing: Record<string, unknown> | null | undefined,
providerMetadata: Record<string, unknown> | null | undefined,
): Record<string, unknown> | null {
const provider = normalizeProviderMetadata(providerMetadata) ?? {};
const persisted = persistedSetupMetadata(existing);
const merged = { ...provider, ...persisted };
return Object.keys(merged).length > 0 ? merged : null;
}
function normalizePersistedStatus(
status: string,
fallback: EnvironmentCustomImageSetupSessionStatus = "failed",
@@ -179,10 +217,9 @@ function sourceTemplateFromConfig(
async function resolveActiveTemplate(
db: Db,
input: { companyId: string; environmentId: string; provider?: string | null },
input: { environmentId: string; provider?: string | null },
): Promise<EnvironmentCustomImageTemplate | null> {
const conditions = [
eq(environmentCustomImageTemplates.companyId, input.companyId),
eq(environmentCustomImageTemplates.environmentId, input.environmentId),
eq(environmentCustomImageTemplates.status, "active"),
];
@@ -203,6 +240,7 @@ export function environmentCustomImageService(
options: { pluginWorkerManager?: PluginWorkerManager } = {},
) {
const environments = environmentService(db);
const secrets = secretService(db);
async function getTemplateById(id: string): Promise<EnvironmentCustomImageTemplate | null> {
const row = await db
@@ -223,14 +261,12 @@ export function environmentCustomImageService(
}
async function getActiveSetupSession(input: {
companyId: string;
environmentId: string;
}): Promise<EnvironmentCustomImageSetupSession | null> {
const row = await db
.select()
.from(environmentCustomImageSetupSessions)
.where(and(
eq(environmentCustomImageSetupSessions.companyId, input.companyId),
eq(environmentCustomImageSetupSessions.environmentId, input.environmentId),
inArray(environmentCustomImageSetupSessions.status, [...ACTIVE_SETUP_STATUSES]),
))
@@ -240,14 +276,12 @@ export function environmentCustomImageService(
}
async function getLatestSetupSession(input: {
companyId: string;
environmentId: string;
}): Promise<EnvironmentCustomImageSetupSession | null> {
const row = await db
.select()
.from(environmentCustomImageSetupSessions)
.where(and(
eq(environmentCustomImageSetupSessions.companyId, input.companyId),
eq(environmentCustomImageSetupSessions.environmentId, input.environmentId),
))
.orderBy(desc(environmentCustomImageSetupSessions.createdAt))
@@ -261,8 +295,25 @@ export function environmentCustomImageService(
return environment;
}
async function resolveSecretContextCompanyId(
environmentId: string,
explicitCompanyId?: string | null,
): Promise<string | null> {
if (explicitCompanyId) return explicitCompanyId;
const bindingCompanyIds = await secrets.listBindingCompanyIdsForTarget({
targetType: "environment",
targetId: environmentId,
});
if (bindingCompanyIds.length > 1) {
throw conflict("Environment secret bindings span multiple companies and require explicit companyId context.");
}
return bindingCompanyIds[0] ?? null;
}
async function resolveSetupProvider(input: {
companyId: string;
secretContextCompanyId?: string | null;
storedRpcCompanyId?: string | null;
storedProvider?: string | null;
environment: Environment;
requireCapture?: boolean;
requireDelete?: boolean;
@@ -270,9 +321,16 @@ export function environmentCustomImageService(
if (!options.pluginWorkerManager) {
throw unprocessable("Environment customImage setup requires a running plugin worker manager.");
}
const storedRpcCompanyId = normalizeSetupRpcCompanyId(input.storedRpcCompanyId);
const secretContextCompanyId = storedRpcCompanyId
? (storedRpcCompanyId === "instance" ? null : storedRpcCompanyId)
: await resolveSecretContextCompanyId(
input.environment.id,
input.secretContextCompanyId,
);
const parsed = await resolveEnvironmentDriverConfigForRuntime(
db,
input.companyId,
secretContextCompanyId,
input.environment,
{ issueId: null, heartbeatRunId: null },
);
@@ -280,6 +338,12 @@ export function environmentCustomImageService(
throw unprocessable("Environment customImage setup is only supported for sandbox environments.");
}
const provider = parsed.config.provider;
const storedProvider = readString(input.storedProvider);
if (storedProvider && provider !== storedProvider) {
throw conflict(
`Environment customImage provider changed from "${storedProvider}" to "${provider}". Switch the environment back before continuing this customImage lifecycle operation.`,
);
}
const resolved = await resolvePluginSandboxProviderDriverByKey({
db,
driverKey: provider,
@@ -300,6 +364,7 @@ export function environmentCustomImageService(
}
return {
provider,
rpcCompanyId: storedRpcCompanyId ?? secretContextCompanyId ?? "instance",
pluginId: resolved.plugin.id,
driver: resolved.driver,
runtimeConfig: parsed.config,
@@ -308,17 +373,20 @@ export function environmentCustomImageService(
}
async function callProviderStart(input: {
companyId: string;
environment: Environment;
sessionId: string;
expiresAt: Date;
sourceTemplateRef: string | null;
sourceTemplateKind: EnvironmentCustomImageTemplateKind | null;
secretContextCompanyId?: string | null;
}): Promise<PluginEnvironmentInteractiveSetupSession> {
const provider = await resolveSetupProvider({ companyId: input.companyId, environment: input.environment });
const provider = await resolveSetupProvider({
secretContextCompanyId: input.secretContextCompanyId,
environment: input.environment,
});
return await options.pluginWorkerManager!.call(provider.pluginId, "environmentStartInteractiveSetup", {
driverKey: provider.provider,
companyId: input.companyId,
companyId: provider.rpcCompanyId,
environmentId: input.environment.id,
issueId: null,
config: provider.driverConfig,
@@ -338,10 +406,14 @@ export function environmentCustomImageService(
includeConnectionPayload: boolean;
}): Promise<PluginEnvironmentInteractiveSetupSession> {
const environment = await requireEnvironment(input.session.environmentId);
const provider = await resolveSetupProvider({ companyId: input.session.companyId, environment });
const provider = await resolveSetupProvider({
environment,
storedProvider: input.session.provider,
storedRpcCompanyId: readSetupRpcCompanyId(input.session.metadata),
});
return await options.pluginWorkerManager!.call(provider.pluginId, "environmentGetInteractiveSetup", {
driverKey: provider.provider,
companyId: input.session.companyId,
companyId: provider.rpcCompanyId,
environmentId: environment.id,
issueId: null,
config: provider.driverConfig,
@@ -361,13 +433,14 @@ export function environmentCustomImageService(
}): Promise<PluginEnvironmentCaptureTemplateResult> {
const environment = await requireEnvironment(input.session.environmentId);
const provider = await resolveSetupProvider({
companyId: input.session.companyId,
environment,
storedProvider: input.session.provider,
storedRpcCompanyId: readSetupRpcCompanyId(input.session.metadata),
requireCapture: true,
});
return await options.pluginWorkerManager!.call(provider.pluginId, "environmentCaptureTemplate", {
driverKey: provider.provider,
companyId: input.session.companyId,
companyId: provider.rpcCompanyId,
environmentId: environment.id,
issueId: null,
config: provider.driverConfig,
@@ -388,10 +461,14 @@ export function environmentCustomImageService(
reason: string | null;
}): Promise<PluginEnvironmentCancelInteractiveSetupResult> {
const environment = await requireEnvironment(input.session.environmentId);
const provider = await resolveSetupProvider({ companyId: input.session.companyId, environment });
const provider = await resolveSetupProvider({
environment,
storedProvider: input.session.provider,
storedRpcCompanyId: readSetupRpcCompanyId(input.session.metadata),
});
return await options.pluginWorkerManager!.call(provider.pluginId, "environmentCancelInteractiveSetup", {
driverKey: provider.provider,
companyId: input.session.companyId,
companyId: provider.rpcCompanyId,
environmentId: environment.id,
issueId: null,
config: provider.driverConfig,
@@ -404,26 +481,37 @@ export function environmentCustomImageService(
}));
}
async function resolveTemplateDeleteProvider(template: EnvironmentCustomImageTemplate) {
if (!template.templateRef) {
throw unprocessable("Cannot delete an environment customImage template without a provider template ref.");
}
const environment = await requireEnvironment(template.environmentId);
return await resolveSetupProvider({
environment,
storedProvider: template.provider,
storedRpcCompanyId: readSetupRpcCompanyId(template.metadata),
requireDelete: true,
});
}
async function callProviderDeleteTemplate(input: {
template: EnvironmentCustomImageTemplate;
provider: Awaited<ReturnType<typeof resolveTemplateDeleteProvider>>;
reason: string | null;
}) {
if (!input.template.templateRef) {
const templateRef = input.template.templateRef;
if (!templateRef) {
throw unprocessable("Cannot delete an environment customImage template without a provider template ref.");
}
const environment = await requireEnvironment(input.template.environmentId);
const provider = await resolveSetupProvider({
companyId: input.template.companyId,
environment,
requireDelete: true,
});
const provider = input.provider;
return await options.pluginWorkerManager!.call(provider.pluginId, "environmentDeleteTemplate", {
driverKey: provider.provider,
companyId: input.template.companyId,
companyId: provider.rpcCompanyId,
environmentId: environment.id,
issueId: null,
config: provider.driverConfig,
templateRef: input.template.templateRef,
templateRef,
templateKind: input.template.templateKind as PluginEnvironmentTemplateRefKind,
metadata: input.template.metadata ?? undefined,
reason: input.reason,
@@ -434,7 +522,7 @@ export function environmentCustomImageService(
}
async function updateSessionFromProvider(
sessionId: string,
session: Pick<EnvironmentCustomImageSetupSession, "id" | "metadata">,
providerSession: PluginEnvironmentInteractiveSetupSession,
fallbackStatus: EnvironmentCustomImageSetupSessionStatus = "failed",
): Promise<EnvironmentCustomImageSetupSession> {
@@ -447,11 +535,11 @@ export function environmentCustomImageService(
status,
connectionSummary: normalizeConnectionSummary(providerSession.connectionSummary),
expiresAt: providerSession.expiresAt ? new Date(providerSession.expiresAt) : undefined,
metadata: normalizeProviderMetadata(providerSession.metadata),
metadata: mergeSetupSessionMetadata(session.metadata, providerSession.metadata),
failureReason: status === "failed" ? "Provider setup session failed or is missing." : null,
updatedAt: now,
})
.where(eq(environmentCustomImageSetupSessions.id, sessionId))
.where(eq(environmentCustomImageSetupSessions.id, session.id))
.returning()
.then((rows) => rows[0] ?? null);
if (!row) throw notFound("Environment customImage setup session not found");
@@ -509,7 +597,6 @@ export function environmentCustomImageService(
return {
getOverview: async (input: {
companyId: string;
environmentId: string;
}): Promise<EnvironmentCustomImageOverview> => {
await requireEnvironment(input.environmentId);
@@ -522,7 +609,6 @@ export function environmentCustomImageService(
},
getActiveTemplate: async (input: {
companyId: string;
environmentId: string;
provider?: string | null;
}): Promise<EnvironmentCustomImageTemplate | null> => resolveActiveTemplate(db, input),
@@ -530,15 +616,18 @@ export function environmentCustomImageService(
getSessionById,
startSetupSession: async (input: {
companyId: string;
environmentId: string;
templateId?: string | null;
ttlSeconds?: number | null;
actor: { userId?: string | null; agentId?: string | null };
secretContextCompanyId?: string | null;
now?: Date;
}): Promise<EnvironmentCustomImageSetupSessionResult> => {
const environment = await requireEnvironment(input.environmentId);
const provider = await resolveSetupProvider({ companyId: input.companyId, environment });
const provider = await resolveSetupProvider({
secretContextCompanyId: input.secretContextCompanyId,
environment,
});
const activeSession = await getActiveSetupSession(input);
if (activeSession) {
throw conflict("An environment customImage setup session is already active for this environment.");
@@ -546,7 +635,6 @@ export function environmentCustomImageService(
const selectedTemplate = input.templateId
? await getTemplateById(input.templateId)
: await resolveActiveTemplate(db, {
companyId: input.companyId,
environmentId: input.environmentId,
provider: provider.provider,
});
@@ -556,13 +644,12 @@ export function environmentCustomImageService(
if (
selectedTemplate &&
(
selectedTemplate.companyId !== input.companyId ||
selectedTemplate.environmentId !== input.environmentId ||
selectedTemplate.provider !== provider.provider ||
selectedTemplate.status !== "active"
)
) {
throw unprocessable("Setup template must be the active template for this company and environment.");
throw unprocessable("Setup template must be the active template for this environment.");
}
const source = selectedTemplate
? {
@@ -579,9 +666,12 @@ export function environmentCustomImageService(
const expiresAt = addSeconds(now, ttlSeconds);
const sessionId = randomUUID();
const fingerprint = fingerprintEnvironmentSandboxProviderConfig(provider.runtimeConfig);
const setupMetadata = {
[SOURCE_ENVIRONMENT_CONFIG_FINGERPRINT_METADATA_KEY]: fingerprint,
[SETUP_RPC_COMPANY_ID_METADATA_KEY]: provider.rpcCompanyId,
};
await db.insert(environmentCustomImageSetupSessions).values({
id: sessionId,
companyId: input.companyId,
environmentId: input.environmentId,
templateId: selectedTemplate?.id ?? null,
provider: provider.provider,
@@ -590,23 +680,21 @@ export function environmentCustomImageService(
startedByAgentId: input.actor.agentId ?? null,
baseTemplateRef: source.sourceTemplateRef,
expiresAt,
metadata: {
sourceEnvironmentConfigFingerprint: fingerprint,
},
metadata: setupMetadata,
createdAt: now,
updatedAt: now,
});
try {
const providerSession = await callProviderStart({
companyId: input.companyId,
environment,
sessionId,
expiresAt,
sourceTemplateRef: source.sourceTemplateRef,
sourceTemplateKind: source.sourceTemplateKind,
secretContextCompanyId: input.secretContextCompanyId,
});
const session = await updateSessionFromProvider(sessionId, providerSession);
const session = await updateSessionFromProvider({ id: sessionId, metadata: setupMetadata }, providerSession);
return {
session,
connectionPayload: providerSession.connectionPayload ?? null,
@@ -644,7 +732,7 @@ export function environmentCustomImageService(
includeConnectionPayload: input.includeConnectionPayload ?? false,
});
return {
session: await updateSessionFromProvider(session.id, providerSession),
session: await updateSessionFromProvider(session, providerSession),
connectionPayload: providerSession.connectionPayload ?? null,
};
},
@@ -666,7 +754,6 @@ export function environmentCustomImageService(
}
await markSessionStatus({ sessionId: session.id, status: "capturing" });
const currentActive = await resolveActiveTemplate(db, {
companyId: session.companyId,
environmentId: session.environmentId,
provider: session.provider,
});
@@ -680,8 +767,9 @@ export function environmentCustomImageService(
})
: null;
const provider = await resolveSetupProvider({
companyId: session.companyId,
environment,
storedProvider: session.provider,
storedRpcCompanyId: readSetupRpcCompanyId(session.metadata),
requireCapture: true,
});
const runtimeConfigBinding = templateConfigBindingFromDriver({
@@ -699,7 +787,6 @@ export function environmentCustomImageService(
updatedAt: now,
})
.where(and(
eq(environmentCustomImageTemplates.companyId, session.companyId),
eq(environmentCustomImageTemplates.environmentId, session.environmentId),
eq(environmentCustomImageTemplates.provider, session.provider),
eq(environmentCustomImageTemplates.status, "active"),
@@ -709,7 +796,6 @@ export function environmentCustomImageService(
.insert(environmentCustomImageTemplates)
.values({
id: templateId,
companyId: session.companyId,
environmentId: session.environmentId,
provider: session.provider,
templateKind: readTemplateKind(captured.templateKind),
@@ -723,6 +809,7 @@ export function environmentCustomImageService(
metadata: normalizeProviderMetadata({
...(captured.metadata ?? {}),
...(input.metadata ? { userMetadata: input.metadata } : {}),
...persistedSetupMetadata(session.metadata),
[ENVIRONMENT_CUSTOM_IMAGE_RUNTIME_CONFIG_BINDING_METADATA_KEY]: runtimeConfigBinding,
}),
createdAt: now,
@@ -790,7 +877,6 @@ export function environmentCustomImageService(
},
rollbackTemplate: async (input: {
companyId: string;
environmentId: string;
now?: Date;
}): Promise<{
@@ -804,7 +890,6 @@ export function environmentCustomImageService(
.select()
.from(environmentCustomImageTemplates)
.where(and(
eq(environmentCustomImageTemplates.companyId, input.companyId),
eq(environmentCustomImageTemplates.environmentId, input.environmentId),
eq(environmentCustomImageTemplates.provider, active.provider),
eq(environmentCustomImageTemplates.status, "superseded"),
@@ -843,7 +928,6 @@ export function environmentCustomImageService(
},
disableTemplate: async (input: {
companyId: string;
environmentId: string;
deleteProviderTemplate?: boolean;
now?: Date;
@@ -851,6 +935,9 @@ export function environmentCustomImageService(
await requireEnvironment(input.environmentId);
const active = await resolveActiveTemplate(db, input);
if (!active) throw notFound("Active environment customImage template not found");
const deleteProvider = input.deleteProviderTemplate
? await resolveTemplateDeleteProvider(active)
: null;
const now = input.now ?? new Date();
const row = await db
.update(environmentCustomImageTemplates)
@@ -860,8 +947,8 @@ export function environmentCustomImageService(
.then((rows) => rows[0] ?? null);
if (!row) throw notFound("Active environment customImage template not found");
const template = environmentCustomImageTemplateFromRow(row);
if (input.deleteProviderTemplate) {
await callProviderDeleteTemplate({ template, reason: "disabled" });
if (deleteProvider) {
await callProviderDeleteTemplate({ template, provider: deleteProvider, reason: "disabled" });
}
return template;
},
+5 -2
View File
@@ -18,12 +18,15 @@ export async function probeEnvironment(
companyId?: string | null;
pluginWorkerManager?: PluginWorkerManager;
resolvedConfig?: ParsedEnvironmentConfig;
applyCustomImageTemplate?: boolean;
} = {},
): Promise<EnvironmentProbeResult> {
const resolvedCompanyId = options.companyId ?? null;
const parsed = options.resolvedConfig ?? (
resolvedCompanyId
? await resolveEnvironmentDriverConfigForRuntime(db, resolvedCompanyId, environment)
resolvedCompanyId || options.applyCustomImageTemplate === true
? await resolveEnvironmentDriverConfigForRuntime(db, resolvedCompanyId, environment, {
applyCustomImageTemplate: options.applyCustomImageTemplate === true,
})
: parseEnvironmentDriverConfig(environment)
);
+343 -20
View File
@@ -98,6 +98,8 @@ import {
cleanupExecutionWorkspaceArtifacts,
ensurePersistedExecutionWorkspaceAvailable,
ensureRuntimeServicesForRun,
formatManagedGitWorktreeBranchInspection,
inspectManagedGitWorktreeBranch,
persistAdapterManagedRuntimeServices,
realizeExecutionWorkspace,
releaseRuntimeServicesForRun,
@@ -277,6 +279,9 @@ const WORKSPACE_VALIDATION_FAILURE_CODE = "workspace_validation_failed";
const WORKSPACE_VALIDATION_RECOVERY_CAUSE = "workspace_validation_failed";
const CONFIGURATION_INCOMPLETE_FAILURE_CODE = "configuration_incomplete";
const CONFIGURATION_INCOMPLETE_RECOVERY_CAUSE = "configuration_incomplete";
const EXECUTION_REVIEW_PARTICIPANT_RECOVERY_RETRY_REASON = "execution_review_participant_recovery";
const EXECUTION_REVIEW_PARTICIPANT_RECOVERY_WAKE_REASON = "execution_review_participant_recovery";
const EXECUTION_REVIEW_PARTICIPANT_RECOVERY_CAUSE = "execution_review_participant_recovery";
const GITHUB_PR_WORKFLOW_SKILL_KEY = "paperclipai/bundled/software-development/github-pr-workflow";
const GITHUB_PR_WORKFLOW_SKILL_SLUG = "github-pr-workflow";
const PUSH_CAPABILITY_ENV_KEYS = ["GH_TOKEN", "GITHUB_TOKEN"] as const;
@@ -1153,8 +1158,21 @@ async function ensureManagedProjectWorkspace(input: {
}
}
function isWorkspaceValidationFailure(error: unknown): error is WorkspaceValidationFailure {
return error instanceof WorkspaceValidationFailure;
type WorkspaceValidationFailureLike = WorkspaceValidationFailure | {
code: typeof WORKSPACE_VALIDATION_FAILURE_CODE;
resultJson: Record<string, unknown>;
};
function isWorkspaceValidationFailure(error: unknown): error is WorkspaceValidationFailureLike {
if (error instanceof WorkspaceValidationFailure) return true;
const maybe = error as { code?: unknown; resultJson?: unknown } | null;
return Boolean(
maybe &&
maybe.code === WORKSPACE_VALIDATION_FAILURE_CODE &&
maybe.resultJson &&
typeof maybe.resultJson === "object" &&
!Array.isArray(maybe.resultJson),
);
}
function isWorkspaceValidationFailedRun(
@@ -1163,6 +1181,38 @@ function isWorkspaceValidationFailedRun(
return run?.errorCode === WORKSPACE_VALIDATION_FAILURE_CODE;
}
function stableStringifyForFingerprint(value: unknown): string {
if (Array.isArray(value)) {
return `[${value.map((entry) => stableStringifyForFingerprint(entry)).join(",")}]`;
}
if (value && typeof value === "object") {
const rec = value as Record<string, unknown>;
return `{${Object.keys(rec).sort().map((key) => `${JSON.stringify(key)}:${stableStringifyForFingerprint(rec[key])}`).join(",")}}`;
}
return JSON.stringify(value);
}
function fingerprintFinalizeWorkspaceBranchValidation(input: {
issueId: string | null;
executionWorkspaceId: string;
inspection: ReturnType<typeof formatManagedGitWorktreeBranchInspection>;
}) {
const digest = createHash("sha256")
.update(stableStringifyForFingerprint({
version: 1,
reason: "git_worktree_branch_mismatch_after_run",
issueId: input.issueId,
executionWorkspaceId: input.executionWorkspaceId,
worktreePath: input.inspection.worktreePath ? path.resolve(input.inspection.worktreePath) : null,
repoRoot: input.inspection.repoRoot ? path.resolve(input.inspection.repoRoot) : null,
expectedBranchName: input.inspection.expectedBranchName,
actualBranchName: input.inspection.actualBranchName,
reasonCode: input.inspection.reasonCode,
}))
.digest("hex");
return `workspace_finalize_branch_mismatch:v1:sha256:${digest}`;
}
function isConfigurationIncompleteFailure(error: unknown): error is ConfigurationIncompleteFailure {
return error instanceof ConfigurationIncompleteFailure;
}
@@ -1386,6 +1436,27 @@ export async function assertGitSensitiveAdapterWorkspaceValid(input: {
`Issue ${issue.identifier ?? issue.id} expected a git workspace for ${input.adapterType}, but "${effectiveCwd}" has no .git metadata.`,
);
}
const expectedManagedBranchName =
readNonEmptyString(input.persistedExecutionWorkspace?.branchName) ??
readNonEmptyString(input.executionWorkspace.branchName);
if (
input.persistedExecutionWorkspace?.strategyType === "git_worktree" &&
effectiveCwd &&
expectedManagedBranchName
) {
const inspection = await inspectManagedGitWorktreeBranch({
worktreePath: effectiveCwd,
expectedBranchName: expectedManagedBranchName,
});
if (!inspection.valid) {
fail(
"git_worktree_branch_mismatch",
`Issue ${issue.identifier ?? issue.id} expected git worktree branch "${expectedManagedBranchName}" at "${effectiveCwd}", but ${inspection.reason ?? "the checked-out branch could not be verified"}.`,
{ managedGitWorktreeBranch: formatManagedGitWorktreeBranchInspection(inspection) },
);
}
}
}
const heartbeatRunProcessGroupIdColumn =
@@ -1967,7 +2038,10 @@ function summarizeRunFailureForIssueComment(
function didAutomaticRecoveryFail(
latestRun: Pick<typeof heartbeatRuns.$inferSelect, "status" | "contextSnapshot"> | null,
expectedRetryReason: "assignment_recovery" | "issue_continuation_needed",
expectedRetryReason:
| "assignment_recovery"
| "issue_continuation_needed"
| typeof EXECUTION_REVIEW_PARTICIPANT_RECOVERY_RETRY_REASON,
) {
if (!latestRun) return false;
@@ -1981,6 +2055,27 @@ function didAutomaticRecoveryFail(
);
}
function isExecutionReviewParticipantRecoveryRun(
run: Pick<typeof heartbeatRuns.$inferSelect, "contextSnapshot"> | null,
) {
if (!run) return false;
const context = parseObject(run.contextSnapshot);
return readNonEmptyString(context.retryReason) === EXECUTION_REVIEW_PARTICIPANT_RECOVERY_RETRY_REASON;
}
function isExecutionReviewParticipantRecoveryEligibleRun(
run: Pick<typeof heartbeatRuns.$inferSelect, "contextSnapshot"> | null,
) {
if (!run) return false;
const context = parseObject(run.contextSnapshot);
const wakeReason = readNonEmptyString(context.wakeReason);
return (
wakeReason === "execution_review_requested" ||
wakeReason === "execution_approval_requested" ||
isExecutionReviewParticipantRecoveryRun(run)
);
}
function normalizeLedgerBillingType(value: unknown): BillingType {
const raw = readNonEmptyString(value);
switch (raw) {
@@ -2329,6 +2424,7 @@ export function shouldResetTaskSessionForWake(
if (
wakeReason === "issue_assigned" ||
wakeReason === "execution_review_requested" ||
wakeReason === EXECUTION_REVIEW_PARTICIPANT_RECOVERY_WAKE_REASON ||
wakeReason === "execution_approval_requested" ||
wakeReason === "execution_changes_requested" ||
// PF-4: timer-driven wakes are exploratory ("any new work?"). They do not
@@ -2441,6 +2537,9 @@ export function describeSessionResetReason(
const wakeReason = readNonEmptyString(contextSnapshot?.wakeReason);
if (wakeReason === "issue_assigned") return "wake reason is issue_assigned";
if (wakeReason === "execution_review_requested") return "wake reason is execution_review_requested";
if (wakeReason === EXECUTION_REVIEW_PARTICIPANT_RECOVERY_WAKE_REASON) {
return `wake reason is ${EXECUTION_REVIEW_PARTICIPANT_RECOVERY_WAKE_REASON}`;
}
if (wakeReason === "execution_approval_requested") return "wake reason is execution_approval_requested";
if (wakeReason === "execution_changes_requested") return "wake reason is execution_changes_requested";
// PF-4: paired with shouldResetTaskSessionForWake — keep the reason wording
@@ -3446,6 +3545,7 @@ const INTERACTION_CONTINUATION_CONTEXT_KEYS = [
"interactionKind",
"interactionStatus",
"continuationPolicy",
"checkboxSelection",
] as const;
function isInteractionResolutionWakePayload(payload: Record<string, unknown> | null | undefined) {
@@ -3745,6 +3845,7 @@ export async function buildPaperclipWakePayload(input: {
const interactionId = readNonEmptyString(input.contextSnapshot.interactionId);
const interactionKind = readNonEmptyString(input.contextSnapshot.interactionKind);
const interactionStatus = readNonEmptyString(input.contextSnapshot.interactionStatus);
const checkboxSelection = parseObject(input.contextSnapshot.checkboxSelection);
const planReviewContext = issueId
? await buildPlanReviewContext({
db: input.db,
@@ -3789,6 +3890,7 @@ export async function buildPaperclipWakePayload(input: {
: null,
interactionKind,
interactionStatus,
checkboxSelection: Object.keys(checkboxSelection).length > 0 ? checkboxSelection : null,
checkedOutByHarness: input.contextSnapshot[PAPERCLIP_HARNESS_CHECKOUT_KEY] === true,
dependencyBlockedInteraction: input.contextSnapshot.dependencyBlockedInteraction === true,
treeHoldInteraction: input.contextSnapshot.treeHoldInteraction === true,
@@ -9946,6 +10048,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
? await ensurePersistedExecutionWorkspaceAvailable({
base: executionWorkspaceBase,
workspace: {
id: existingExecutionWorkspace.id,
mode: existingExecutionWorkspace.mode,
strategyType: existingExecutionWorkspace.strategyType,
cwd: existingExecutionWorkspace.cwd,
@@ -10665,11 +10768,81 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
);
}
let adapterFinalizeOutcome: "succeeded" | "failed" | null = null;
const inspectFinalizeWorkspaceBranch = async () => {
const workspaceRecord = persistedExecutionWorkspace?.id
? await executionWorkspacesSvc.getById(persistedExecutionWorkspace.id)
: persistedExecutionWorkspace;
if (workspaceRecord?.strategyType !== "git_worktree") return null;
const worktreePath =
readNonEmptyString(workspaceRecord.providerRef) ??
readNonEmptyString(workspaceRecord.cwd) ??
readNonEmptyString(executionWorkspace.worktreePath) ??
readNonEmptyString(executionWorkspace.cwd);
const expectedBranchName =
readNonEmptyString(workspaceRecord.branchName) ??
readNonEmptyString(executionWorkspace.branchName);
if (!worktreePath || !expectedBranchName) return null;
const inspection = await inspectManagedGitWorktreeBranch({
worktreePath,
expectedBranchName,
});
return { workspaceRecord, inspection };
};
const recordWorkspaceFinalize = async (
status: "succeeded" | "failed",
metadata?: Record<string, unknown>,
) => {
if (adapterFinalizeOutcome) return;
let finalizeBranchMetadata: Record<string, unknown> | null = null;
if (status === "succeeded") {
const branchInspection = await inspectFinalizeWorkspaceBranch();
if (branchInspection) {
const managedGitWorktreeBranch = formatManagedGitWorktreeBranchInspection(branchInspection.inspection);
finalizeBranchMetadata = {
executionWorkspaceId: branchInspection.workspaceRecord.id,
...managedGitWorktreeBranch,
};
if (!branchInspection.inspection.valid) {
const workspaceValidationFingerprint = fingerprintFinalizeWorkspaceBranchValidation({
issueId: issueRef?.id ?? null,
executionWorkspaceId: branchInspection.workspaceRecord.id,
inspection: managedGitWorktreeBranch,
});
await workspaceOperationRecorder.recordOperation({
phase: "workspace_finalize",
cwd: executionWorkspace.cwd,
metadata: {
adapterType: agent.adapterType,
executionTargetKind: executionTarget?.kind ?? "local",
...metadata,
managedGitWorktreeBranch: finalizeBranchMetadata,
},
run: async () => ({
status: "failed",
stderr: `Managed git worktree branch check failed: ${branchInspection.inspection.reason ?? "unknown branch mismatch"}\n`,
}),
});
adapterFinalizeOutcome = "failed";
throw new WorkspaceValidationFailure(
`Execution workspace ${branchInspection.workspaceRecord.id} expected git worktree branch "${branchInspection.inspection.expectedBranchName}" at "${branchInspection.inspection.worktreePath}", but ${branchInspection.inspection.reason ?? "the checked-out branch could not be verified"}. Record a sanctioned execution-workspace branch transition or restore the workspace branch before completing the run.`,
{
workspaceValidation: {
reason: "git_worktree_branch_mismatch_after_run",
fingerprint: workspaceValidationFingerprint,
adapterType: agent.adapterType,
issueId: issueRef?.id ?? null,
issueIdentifier: issueRef?.identifier ?? null,
persistedExecutionWorkspaceId: branchInspection.workspaceRecord.id,
executionWorkspaceCwd: executionWorkspace.cwd,
managedGitWorktreeBranch: finalizeBranchMetadata,
},
},
);
}
}
}
await workspaceOperationRecorder.recordOperation({
phase: "workspace_finalize",
cwd: executionWorkspace.cwd,
@@ -10677,6 +10850,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
adapterType: agent.adapterType,
executionTargetKind: executionTarget?.kind ?? "local",
...metadata,
...(finalizeBranchMetadata ? { managedGitWorktreeBranch: finalizeBranchMetadata } : {}),
},
run: async () => ({ status }),
});
@@ -11201,8 +11375,10 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
// A missing secret/env binding is a known pre-dispatch configuration gap,
// not an opaque setup crash. Surface it with its own errorCode so the
// recovery path routes it to a human owner instead of looping retries.
const workspaceValidationSetupFailure = isWorkspaceValidationFailure(outerErr) ? outerErr : null;
const configurationIncompleteSetupFailure = isConfigurationIncompleteFailure(outerErr) ? outerErr : null;
const setupFailureErrorCode = configurationIncompleteSetupFailure?.code ?? "setup_failed";
const setupFailureErrorCode =
workspaceValidationSetupFailure?.code ?? configurationIncompleteSetupFailure?.code ?? "setup_failed";
logger.error({ err: outerErr, runId }, "heartbeat execution setup failed");
const setupFailureAgent = await getAgent(run.agentId).catch(() => null);
const setupFailureWrite = await setRunStatusIfRunning(runId, "failed", {
@@ -11213,7 +11389,8 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
resultJson: mergeRunStopMetadataForAgent(setupFailureAgent, "failed", {
errorCode: setupFailureErrorCode,
errorMessage: message,
resultJson: configurationIncompleteSetupFailure?.resultJson ?? null,
resultJson:
workspaceValidationSetupFailure?.resultJson ?? configurationIncompleteSetupFailure?.resultJson ?? null,
}),
} : {}),
}).catch(() => ({ run: null, updated: false as const }));
@@ -11315,6 +11492,18 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
);
}
function buildExecutionReviewParticipantRecoveryComment(input: {
latestRun: Pick<typeof heartbeatRuns.$inferSelect, "error" | "errorCode"> | null | undefined;
}) {
const failureSummary = summarizeRunFailureForIssueComment(input.latestRun);
return (
"Paperclip retried the pending execution-review participant once, but the review stage still has no completed decision " +
`or live reviewer run.${failureSummary ?? ""} ` +
"Moving it to `blocked` with a source-scoped recovery action so the recovery owner can repair the reviewer runtime, " +
"restore the review stage, or record an intentional manual resolution."
);
}
async function releaseIssueExecutionAndPromote(
run: typeof heartbeatRuns.$inferSelect,
options: { suppressImmediateRecovery?: boolean } = {},
@@ -11696,6 +11885,149 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
};
}
const findExistingExecutionPath = (agentId?: string | null) =>
tx
.select({ id: heartbeatRuns.id })
.from(heartbeatRuns)
.where(
and(
eq(heartbeatRuns.companyId, issue.companyId),
inArray(heartbeatRuns.status, [...EXECUTION_PATH_HEARTBEAT_RUN_STATUSES]),
sql`${heartbeatRuns.contextSnapshot} ->> 'issueId' = ${issue.id}`,
sql`${heartbeatRuns.id} <> ${run.id}`,
agentId ? eq(heartbeatRuns.agentId, agentId) : sql`true`,
),
)
.limit(1)
.then((rows) => rows[0] ?? null);
const issueHasScheduledMonitor =
issue.monitorNextCheckAt instanceof Date &&
issue.monitorNextCheckAt.getTime() > Date.now();
const executionState = parseIssueExecutionState(issue.executionState);
const currentParticipant = executionState?.status === "pending"
? executionState.currentParticipant
: null;
const issueNeedsReviewParticipantRecovery =
issue.status === "in_review" &&
!issue.assigneeUserId &&
currentParticipant?.type === "agent" &&
currentParticipant.agentId === run.agentId &&
isExecutionReviewParticipantRecoveryEligibleRun(run) &&
HEARTBEAT_RUN_TERMINAL_STATUSES.includes(
run.status as (typeof HEARTBEAT_RUN_TERMINAL_STATUSES)[number],
);
if (issueNeedsReviewParticipantRecovery) {
const existingReviewParticipantExecutionPath = await findExistingExecutionPath(currentParticipant.agentId);
if (
options.suppressImmediateRecovery ||
existingReviewParticipantExecutionPath ||
issueHasScheduledMonitor ||
await isAutomaticRecoverySuppressedByPauseHold(db, issue.companyId, issue.id, treeControlSvc)
) {
return { kind: "released" as const };
}
if (issue.originKind === RECOVERY_ORIGIN_KINDS.strandedIssueRecovery) {
return {
kind: "blocked_recovery_in_place" as const,
issue,
previousStatus: issue.status,
};
}
const shouldBlockReviewRecovery =
!recoveryAgentInvokable ||
!recoveryAgent ||
isExecutionReviewParticipantRecoveryRun(run);
if (shouldBlockReviewRecovery) {
return {
kind: "blocked" as const,
issue,
previousStatus: issue.status,
comment: buildExecutionReviewParticipantRecoveryComment({ latestRun: run }),
recoveryCause: EXECUTION_REVIEW_PARTICIPANT_RECOVERY_CAUSE,
recoveryOwnerAgentId: currentParticipant.agentId,
};
}
const now = new Date();
const wakeupRequest = await tx
.insert(agentWakeupRequests)
.values({
companyId: issue.companyId,
agentId: recoveryAgent.id,
source: "automation",
triggerDetail: "system",
reason: EXECUTION_REVIEW_PARTICIPANT_RECOVERY_WAKE_REASON,
payload: withRecoveryModelProfileHint({
issueId: issue.id,
retryOfRunId: run.id,
retryReason: EXECUTION_REVIEW_PARTICIPANT_RECOVERY_RETRY_REASON,
currentStageId: executionState?.currentStageId ?? null,
currentStageType: executionState?.currentStageType ?? null,
}, "normal_model"),
status: "queued",
requestedByActorType: "system",
requestedByActorId: null,
updatedAt: now,
})
.returning()
.then((rows) => rows[0]);
const queuedRun = await tx
.insert(heartbeatRuns)
.values({
companyId: issue.companyId,
agentId: recoveryAgent.id,
invocationSource: "automation",
triggerDetail: "system",
status: "queued",
wakeupRequestId: wakeupRequest.id,
contextSnapshot: withRecoveryModelProfileHint({
issueId: issue.id,
taskId: issue.id,
wakeReason: EXECUTION_REVIEW_PARTICIPANT_RECOVERY_WAKE_REASON,
retryReason: EXECUTION_REVIEW_PARTICIPANT_RECOVERY_RETRY_REASON,
source: "issue.execution_review_recovery",
retryOfRunId: run.id,
currentStageId: executionState?.currentStageId ?? null,
currentStageType: executionState?.currentStageType ?? null,
reviewRecoveryInstruction:
"The previous reviewer run ended while this execution-review stage was still pending. Submit the review decision now, or mark the issue blocked with the exact unblock action.",
}, "normal_model"),
sessionIdBefore: recoverySessionBefore,
retryOfRunId: run.id,
updatedAt: now,
})
.returning()
.then((rows) => rows[0]);
await tx
.update(agentWakeupRequests)
.set({
runId: queuedRun.id,
updatedAt: now,
})
.where(eq(agentWakeupRequests.id, wakeupRequest.id));
await tx
.update(issues)
.set({
executionRunId: queuedRun.id,
executionAgentNameKey: recoveryAgentNameKey,
executionLockedAt: now,
updatedAt: now,
})
.where(eq(issues.id, issue.id));
return {
kind: "queued_recovery" as const,
run: queuedRun,
};
}
const issueNeedsImmediateRecovery =
(issue.status === "todo" || issue.status === "in_progress") &&
!issue.assigneeUserId &&
@@ -11709,19 +12041,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
return { kind: "released" as const };
}
const existingExecutionPath = await tx
.select({ id: heartbeatRuns.id })
.from(heartbeatRuns)
.where(
and(
eq(heartbeatRuns.companyId, issue.companyId),
inArray(heartbeatRuns.status, [...EXECUTION_PATH_HEARTBEAT_RUN_STATUSES]),
sql`${heartbeatRuns.contextSnapshot} ->> 'issueId' = ${issue.id}`,
sql`${heartbeatRuns.id} <> ${run.id}`,
),
)
.limit(1)
.then((rows) => rows[0] ?? null);
const existingExecutionPath = await findExistingExecutionPath();
if (existingExecutionPath) {
return { kind: "released" as const };
}
@@ -11844,7 +12164,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
if (promotionResult?.kind === "blocked") {
await recovery.escalateStrandedAssignedIssue({
issue: promotionResult.issue,
previousStatus: promotionResult.previousStatus as "todo" | "in_progress",
previousStatus: promotionResult.previousStatus as "todo" | "in_progress" | "in_review",
latestRun: run,
comment: promotionResult.comment,
recoveryCause:
@@ -11852,7 +12172,10 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
? WORKSPACE_VALIDATION_RECOVERY_CAUSE
: promotionResult.recoveryCause === CONFIGURATION_INCOMPLETE_RECOVERY_CAUSE
? CONFIGURATION_INCOMPLETE_RECOVERY_CAUSE
: promotionResult.recoveryCause === EXECUTION_REVIEW_PARTICIPANT_RECOVERY_CAUSE
? EXECUTION_REVIEW_PARTICIPANT_RECOVERY_CAUSE
: undefined,
recoveryOwnerAgentId: promotionResult.recoveryOwnerAgentId,
});
return;
}
@@ -11860,7 +12183,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
if (promotionResult?.kind === "blocked_recovery_in_place") {
await recovery.escalateStrandedRecoveryIssueInPlace({
issue: promotionResult.issue,
previousStatus: promotionResult.previousStatus as "todo" | "in_progress",
previousStatus: promotionResult.previousStatus as "todo" | "in_progress" | "in_review",
latestRun: run,
});
return;
+9
View File
@@ -44,6 +44,15 @@ export {
} from "./external-objects.js";
export { goalService } from "./goals.js";
export { activityService, type ActivityFilters } from "./activity.js";
export { workTimelineService, normalizeTimelineWindow } from "./work-timeline.js";
export type {
WorkTimelineActor,
WorkTimelineEdge,
WorkTimelineEvent,
WorkTimelineQuery,
WorkTimelineResult,
WorkTimelineSpan,
} from "./work-timeline.js";
export { approvalService } from "./approvals.js";
export { budgetService } from "./budgets.js";
export { secretService } from "./secrets.js";
+275 -39
View File
@@ -2,6 +2,7 @@ import { isDeepStrictEqual } from "node:util";
import { and, asc, eq, inArray, isNotNull } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
agents,
documents,
heartbeatRuns,
issueComments,
@@ -9,6 +10,7 @@ import {
issueThreadInteractions,
issues,
} from "@paperclipai/db";
import { trackInteractionResolved } from "@paperclipai/shared/telemetry";
import type {
AcceptIssueThreadInteraction,
AskUserQuestionsAnswer,
@@ -39,6 +41,7 @@ import {
suggestTasksResultSchema,
} from "@paperclipai/shared";
import { conflict, notFound, unprocessable } from "../errors.js";
import { getTelemetryClient } from "../telemetry.js";
import { issueService, runWorkspaceIsFinalized } from "./issues.js";
type InteractionActor = {
@@ -82,10 +85,23 @@ type RequestConfirmationLikeInteraction =
| RequestConfirmationInteraction
| RequestCheckboxConfirmationInteraction;
const USER_COMMENT_SUPERSEDABLE_INTERACTION_KINDS = [
...REQUEST_CONFIRMATION_INTERACTION_KINDS,
"ask_user_questions",
] as const;
type UserCommentSupersedableKind = (typeof USER_COMMENT_SUPERSEDABLE_INTERACTION_KINDS)[number];
type UserCommentSupersedableInteraction =
| RequestConfirmationLikeInteraction
| AskUserQuestionsInteraction;
function isRequestConfirmationLikeKind(kind: string): kind is RequestConfirmationLikeKind {
return (REQUEST_CONFIRMATION_INTERACTION_KINDS as readonly string[]).includes(kind);
}
function isUserCommentSupersedableKind(kind: string): kind is UserCommentSupersedableKind {
return (USER_COMMENT_SUPERSEDABLE_INTERACTION_KINDS as readonly string[]).includes(kind);
}
function isIssueThreadInteractionIdempotencyConflict(error: unknown): boolean {
if (typeof error !== "object" || error === null) return false;
const err = error as { code?: string; constraint?: string; constraint_name?: string };
@@ -181,12 +197,20 @@ function shouldReturnAcceptedConfirmationToCreatorAgent(args: {
return true;
}
function shouldSupersedeRequestConfirmationOnUserComment(interaction: RequestConfirmationLikeInteraction) {
function shouldSupersedeInteractionOnUserComment(interaction: UserCommentSupersedableInteraction) {
return interaction.payload.supersedeOnUserComment === true;
}
function normalizeCreateInteractionInput(input: CreateIssueThreadInteraction): CreateIssueThreadInteraction {
switch (input.kind) {
case "ask_user_questions":
return {
...input,
payload: {
...input.payload,
supersedeOnUserComment: input.payload.supersedeOnUserComment ?? true,
},
};
case "request_confirmation":
return {
...input,
@@ -208,6 +232,169 @@ function normalizeCreateInteractionInput(input: CreateIssueThreadInteraction): C
}
}
function buildSupersededByCommentResult(row: IssueThreadInteractionRow, commentId: string) {
if (row.kind === "ask_user_questions") {
return {
version: 1,
answers: [],
expirationReason: "superseded_by_comment",
commentId,
summaryMarkdown: null,
} as const;
}
return {
version: 1,
outcome: "superseded_by_comment",
commentId,
} as const;
}
function resolveActorKind(interaction: Pick<IssueThreadInteraction, "resolvedByAgentId" | "resolvedByUserId">) {
if (interaction.resolvedByAgentId) return "agent";
if (interaction.resolvedByUserId) return "user";
return "system";
}
function resolveCreatorKind(interaction: Pick<IssueThreadInteraction, "createdByAgentId" | "createdByUserId">) {
if (interaction.createdByAgentId) return "agent";
if (interaction.createdByUserId) return "user";
return undefined;
}
function deriveTargetType(interaction: IssueThreadInteraction) {
if (interaction.kind !== "request_confirmation" && interaction.kind !== "request_checkbox_confirmation") {
return "none";
}
return interaction.payload.target?.type ?? "none";
}
function deriveResolutionReason(interaction: IssueThreadInteraction) {
switch (interaction.status) {
case "accepted":
return "accepted";
case "rejected":
return "rejected";
case "cancelled":
return "cancelled";
case "expired": {
if (interaction.kind === "ask_user_questions") {
return interaction.result?.expirationReason ?? "expired";
}
if (interaction.kind === "request_confirmation" || interaction.kind === "request_checkbox_confirmation") {
return interaction.result?.outcome ?? "expired";
}
return "expired";
}
default:
return undefined;
}
}
function nonNegativeInteger(value: number) {
if (!Number.isFinite(value)) return 0;
return Math.max(0, Math.trunc(value));
}
function buildInteractionResolvedCounts(interaction: IssueThreadInteraction, args?: {
createdTaskCount?: number;
}) {
switch (interaction.kind) {
case "suggest_tasks":
return {
createdTaskCount: nonNegativeInteger(args?.createdTaskCount ?? 0),
skippedTaskCount: nonNegativeInteger(interaction.result?.skippedClientKeys?.length ?? 0),
};
case "request_checkbox_confirmation":
return {
optionCount: nonNegativeInteger(interaction.payload.options.length),
selectedOptionCount: nonNegativeInteger(interaction.result?.selectedOptionIds?.length ?? 0),
};
case "ask_user_questions":
return {
questionCount: nonNegativeInteger(interaction.payload.questions.length),
answeredQuestionCount: nonNegativeInteger(interaction.result?.answers?.length ?? 0),
};
default:
return {};
}
}
async function fetchCreatorAgentRoleById(
db: Pick<Db, "select">,
interactions: readonly IssueThreadInteraction[],
) {
const creatorAgentIds = [...new Set(interactions
.map((interaction) => interaction.createdByAgentId)
.filter((value): value is string => Boolean(value)))];
if (creatorAgentIds.length === 0) return new Map<string, string | null>();
const rows = await db
.select({
id: agents.id,
role: agents.role,
})
.from(agents)
.where(inArray(agents.id, creatorAgentIds));
return new Map(rows.map((row) => [row.id, row.role] as const));
}
async function emitInteractionResolvedTelemetry(
db: Pick<Db, "select">,
interaction: IssueThreadInteraction,
args?: { createdTaskCount?: number; creatorRoleByAgentId?: ReadonlyMap<string, string | null> },
) {
const telemetryClient = getTelemetryClient();
if (!telemetryClient) return;
try {
let roleByAgentId = args?.creatorRoleByAgentId ?? new Map<string, string | null>();
if (!args?.creatorRoleByAgentId) {
try {
roleByAgentId = await fetchCreatorAgentRoleById(db, [interaction]);
} catch (error) {
console.error("[paperclip] Failed to load interaction.resolved creator role", error);
}
}
const creatorAgentRole = interaction.createdByAgentId
? roleByAgentId.get(interaction.createdByAgentId) ?? undefined
: undefined;
trackInteractionResolved(telemetryClient, {
interactionKind: interaction.kind,
status: interaction.status,
resolvedByKind: resolveActorKind(interaction),
resolutionReason: deriveResolutionReason(interaction),
createdByKind: resolveCreatorKind(interaction),
creatorAgentRole,
continuationPolicy: interaction.continuationPolicy,
targetType: deriveTargetType(interaction),
...buildInteractionResolvedCounts(interaction, {
createdTaskCount: args?.createdTaskCount,
}),
});
} catch (error) {
console.error("[paperclip] Failed to emit interaction.resolved telemetry", error);
}
}
async function emitResolvedInteractionsTelemetry(
db: Pick<Db, "select">,
interactions: readonly IssueThreadInteraction[],
) {
if (interactions.length === 0 || !getTelemetryClient()) return;
let roleByAgentId = new Map<string, string | null>();
try {
roleByAgentId = await fetchCreatorAgentRoleById(db, interactions);
} catch (error) {
console.error("[paperclip] Failed to load interaction.resolved creator roles", error);
}
await Promise.all(interactions.map((interaction) =>
emitInteractionResolvedTelemetry(db, interaction, { creatorRoleByAgentId: roleByAgentId })
));
}
function isCommentAtOrAfterInteraction(args: {
commentCreatedAt: Date | string;
interactionCreatedAt: Date | string;
@@ -518,7 +705,9 @@ async function expireStaleRequestConfirmationTarget(db: Db | any, args: {
throw conflict("Interaction has already been resolved");
}
await touchIssue(db, args.row.issueId);
return hydrateInteraction(updated);
const expired = hydrateInteraction(updated);
await emitInteractionResolvedTelemetry(db, expired);
return expired;
}
export function issueThreadInteractionService(db: Db) {
@@ -615,7 +804,7 @@ export function issueThreadInteractionService(db: Db) {
: undefined;
const now = new Date();
return db.transaction(async (tx) => {
const result = await db.transaction(async (tx) => {
const [updated] = await tx
.update(issueThreadInteractions)
.set({
@@ -688,6 +877,8 @@ export function issueThreadInteractionService(db: Db) {
continuationIssue,
};
});
await emitInteractionResolvedTelemetry(db, result.interaction);
return result;
}
async function rejectRequestConfirmation(args: {
@@ -735,7 +926,9 @@ export function issueThreadInteractionService(db: Db) {
throw conflict("Interaction has already been resolved");
}
await touchIssue(db, args.issue.id);
return hydrateInteraction(updated);
const rejected = hydrateInteraction(updated);
await emitInteractionResolvedTelemetry(db, rejected);
return rejected;
}
return {
@@ -1047,8 +1240,12 @@ export function issueThreadInteractionService(db: Db) {
current.updatedAt = updated.updatedAt;
});
const accepted = hydrateInteraction(current);
await emitInteractionResolvedTelemetry(db, accepted, {
createdTaskCount: createdWakeTargets.length,
});
return {
interaction: hydrateInteraction(current),
interaction: accepted,
createdIssues: createdWakeTargets,
};
},
@@ -1118,7 +1315,9 @@ export function issueThreadInteractionService(db: Db) {
}
await touchIssue(db, issue.id);
return hydrateInteraction(updated);
const rejected = hydrateInteraction(updated);
await emitInteractionResolvedTelemetry(db, rejected);
return rejected;
},
expireRequestConfirmationsSupersededByComment: async (
@@ -1134,14 +1333,15 @@ export function issueThreadInteractionService(db: Db) {
.where(and(
eq(issueThreadInteractions.companyId, issue.companyId),
eq(issueThreadInteractions.issueId, issue.id),
inArray(issueThreadInteractions.kind, [...REQUEST_CONFIRMATION_INTERACTION_KINDS]),
inArray(issueThreadInteractions.kind, [...USER_COMMENT_SUPERSEDABLE_INTERACTION_KINDS]),
eq(issueThreadInteractions.status, "pending"),
));
const superseded = rows.filter((row) => {
const interaction = hydrateInteraction(row) as RequestConfirmationLikeInteraction;
if (!isUserCommentSupersedableKind(row.kind)) return false;
const interaction = hydrateInteraction(row) as UserCommentSupersedableInteraction;
return (
shouldSupersedeRequestConfirmationOnUserComment(interaction)
shouldSupersedeInteractionOnUserComment(interaction)
&& isCommentAtOrAfterInteraction({
commentCreatedAt: comment.createdAt,
interactionCreatedAt: row.createdAt,
@@ -1158,11 +1358,7 @@ export function issueThreadInteractionService(db: Db) {
.update(issueThreadInteractions)
.set({
status: "expired",
result: {
version: 1,
outcome: "superseded_by_comment",
commentId: comment.id,
},
result: buildSupersededByCommentResult(row, comment.id),
resolvedByAgentId: actor.agentId ?? null,
resolvedByUserId: actor.userId ?? null,
resolvedAt: now,
@@ -1178,6 +1374,7 @@ export function issueThreadInteractionService(db: Db) {
if (expired.length > 0) {
await touchIssue(db, issue.id);
await emitResolvedInteractionsTelemetry(db, expired);
}
return expired;
},
@@ -1192,7 +1389,7 @@ export function issueThreadInteractionService(db: Db) {
.where(and(
eq(issueThreadInteractions.companyId, issue.companyId),
eq(issueThreadInteractions.issueId, issue.id),
inArray(issueThreadInteractions.kind, [...REQUEST_CONFIRMATION_INTERACTION_KINDS]),
inArray(issueThreadInteractions.kind, [...USER_COMMENT_SUPERSEDABLE_INTERACTION_KINDS]),
eq(issueThreadInteractions.status, "pending"),
)),
db
@@ -1218,8 +1415,9 @@ export function issueThreadInteractionService(db: Db) {
}
>();
for (const row of rows) {
const interaction = hydrateInteraction(row) as RequestConfirmationLikeInteraction;
if (!shouldSupersedeRequestConfirmationOnUserComment(interaction)) continue;
if (!isUserCommentSupersedableKind(row.kind)) continue;
const interaction = hydrateInteraction(row) as UserCommentSupersedableInteraction;
if (!shouldSupersedeInteractionOnUserComment(interaction)) continue;
const supersedingComment = comments.find((comment) => isCommentAtOrAfterInteraction({
commentCreatedAt: comment.createdAt,
@@ -1238,31 +1436,64 @@ export function issueThreadInteractionService(db: Db) {
}
}
const rowById = new Map(rows.map((row) => [row.id, row] as const));
for (const { comment, rowIds } of supersededByComment.values()) {
const updatedRows = await db
.update(issueThreadInteractions)
.set({
status: "expired",
result: {
version: 1,
outcome: "superseded_by_comment",
commentId: comment.id,
},
resolvedByAgentId: null,
resolvedByUserId: comment.authorUserId,
resolvedAt: now,
updatedAt: now,
})
.where(and(
inArray(issueThreadInteractions.id, rowIds),
eq(issueThreadInteractions.status, "pending"),
))
.returning();
expired.push(...updatedRows.map(hydrateInteraction));
const commentRows = rowIds
.map((rowId) => rowById.get(rowId))
.filter((row): row is IssueThreadInteractionRow => Boolean(row));
const questionRowIds = commentRows
.filter((row) => row.kind === "ask_user_questions")
.map((row) => row.id);
const confirmationRowIds = commentRows
.filter((row) => isRequestConfirmationLikeKind(row.kind))
.map((row) => row.id);
if (questionRowIds.length > 0) {
const sampleQuestionRow = commentRows.find((row) => row.kind === "ask_user_questions");
if (!sampleQuestionRow) continue;
const updatedRows = await db
.update(issueThreadInteractions)
.set({
status: "expired",
result: buildSupersededByCommentResult(sampleQuestionRow, comment.id),
resolvedByAgentId: null,
resolvedByUserId: comment.authorUserId,
resolvedAt: now,
updatedAt: now,
})
.where(and(
inArray(issueThreadInteractions.id, questionRowIds),
eq(issueThreadInteractions.status, "pending"),
))
.returning();
expired.push(...updatedRows.map(hydrateInteraction));
}
if (confirmationRowIds.length > 0) {
const sampleConfirmationRow = commentRows.find((row) => isRequestConfirmationLikeKind(row.kind));
if (!sampleConfirmationRow) continue;
const updatedRows = await db
.update(issueThreadInteractions)
.set({
status: "expired",
result: buildSupersededByCommentResult(sampleConfirmationRow, comment.id),
resolvedByAgentId: null,
resolvedByUserId: comment.authorUserId,
resolvedAt: now,
updatedAt: now,
})
.where(and(
inArray(issueThreadInteractions.id, confirmationRowIds),
eq(issueThreadInteractions.status, "pending"),
))
.returning();
expired.push(...updatedRows.map(hydrateInteraction));
}
}
if (expired.length > 0) {
await touchIssue(db, issue.id);
await emitResolvedInteractionsTelemetry(db, expired);
}
return expired;
},
@@ -1338,6 +1569,7 @@ export function issueThreadInteractionService(db: Db) {
if (expired.length > 0) {
await touchIssue(db, issue.id);
await emitResolvedInteractionsTelemetry(db, expired);
}
return expired;
},
@@ -1396,7 +1628,9 @@ export function issueThreadInteractionService(db: Db) {
}
await touchIssue(db, issue.id);
return hydrateInteraction(updated);
const answered = hydrateInteraction(updated);
await emitInteractionResolvedTelemetry(db, answered);
return answered;
},
cancelQuestions: async (
@@ -1451,7 +1685,9 @@ export function issueThreadInteractionService(db: Db) {
}
await touchIssue(db, issue.id);
return hydrateInteraction(updated);
const cancelled = hydrateInteraction(updated);
await emitInteractionResolvedTelemetry(db, cancelled);
return cancelled;
},
};
}
+335 -56
View File
@@ -6,6 +6,7 @@ import {
activityLog,
agentWakeupRequests,
agents,
authUsers,
approvals,
assets,
companies,
@@ -37,6 +38,7 @@ import type {
AcceptedPlanDecomposition,
IssueComment,
IssueCommentAuthorType,
IssueCommentDerivedAuthorSource,
IssueCommentMetadata,
IssueCommentPresentation,
IssueBlockerAttention,
@@ -101,6 +103,11 @@ const ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE = 500;
export const MAX_CHILD_ISSUES_CREATED_BY_HELPER = 25;
const MAX_CHILD_COMPLETION_SUMMARIES = 20;
const CHILD_COMPLETION_SUMMARY_BODY_MAX_CHARS = 500;
// Non-human author sentinels that agents post under. These ARE eligible for
// agent-attribution derivation even though `local-board` is also materialized
// as a row in the `user` table (it is the implicit board admin). Genuine human
// users — real signups with their own ids — are never reattributed.
const NON_HUMAN_SENTINEL_AUTHOR_USER_IDS = new Set<string>(["local-board"]);
const ISSUE_COMMENT_RUN_LOG_DERIVATION_MAX_LOG_BYTES = 2_000_000;
const ISSUE_COMMENT_RUN_LOG_DERIVATION_CHUNK_BYTES = 256_000;
const ISSUE_COMMENT_RUN_LOG_DERIVATION_END_SLACK_MS = 60_000;
@@ -169,32 +176,65 @@ type IssueCommentRunLogAttributionRun = {
createdAt: Date | string;
startedAt?: Date | string | null;
finishedAt?: Date | string | null;
// Best-effort run log text. May be empty when logs were not read for a tier
// that does not need them (run-id / run-window-unique); only the
// `run_log_comment_post` tier consults this.
logContent: string;
};
type DerivedIssueCommentAttribution = {
derivedAuthorAgentId: string;
derivedCreatedByRunId: string;
derivedAuthorSource: IssueCommentDerivedAuthorSource;
};
/**
* Best-effort agent attribution for comments whose stored author is a non-human
* sentinel (e.g. `local-board`). Callers MUST pre-filter `comments` to drop any
* comment whose `authorUserId` maps to a genuine user profile so a real board /
* user comment is never reattributed.
*
* Only LOSSLESS signals are used — a comment is reattributed solely when a run
* provably authored it. Pure run-window timing overlap is intentionally NOT a
* signal: because agents post through the `local-board` subprocess, an agent
* comment and a genuine human board comment are indistinguishable rows, so any
* timing-based guess mis-attributes human board comments that merely coincided
* with an agent run (Option A).
*
* Tiers, in descending confidence (first match wins per comment):
* 1. `run_id` — the comment's own `createdByRunId` resolves to an agent run
* (lossless: that run authored the comment).
* 2. `run_log_comment_post` — an overlapping run log contains the explicit
* `comment id: {id}` post marker (lossless: the run recorded posting it).
*/
export function deriveIssueCommentRunLogAttribution(
comments: readonly IssueCommentRunLogAttributionCandidate[],
runs: readonly IssueCommentRunLogAttributionRun[],
) {
const derivedByCommentId = new Map<string, {
derivedAuthorAgentId: string;
derivedCreatedByRunId: string;
derivedAuthorSource: "run_log_comment_post";
}>();
const derivedByCommentId = new Map<string, DerivedIssueCommentAttribution>();
const runById = new Map(runs.map((run) => [run.runId, run] as const));
for (const comment of comments) {
if (comment.authorAgentId || !comment.authorUserId || comment.createdByRunId) continue;
if (comment.authorAgentId || !comment.authorUserId) continue;
// Tier 1: the comment carries the run that authored it. Lossless even when
// the author was recorded as the `local-board` sentinel.
if (comment.createdByRunId) {
const ownRun = runById.get(comment.createdByRunId);
if (ownRun?.agentId) {
derivedByCommentId.set(comment.id, {
derivedAuthorAgentId: ownRun.agentId,
derivedCreatedByRunId: ownRun.runId,
derivedAuthorSource: "run_id",
});
continue;
}
}
const commentCreatedAtMs = toTimestampMs(comment.createdAt);
if (commentCreatedAtMs === null) continue;
let bestMatch:
| {
runId: string;
agentId: string;
distanceMs: number;
}
| null = null;
const overlappingRuns: Array<{ run: IssueCommentRunLogAttributionRun; runEndMs: number }> = [];
for (const run of runs) {
const runStartMs = toTimestampMs(run.startedAt ?? run.createdAt);
const runEndMs = toTimestampMs(run.finishedAt ?? run.createdAt);
@@ -205,24 +245,30 @@ export function deriveIssueCommentRunLogAttribution(
) {
continue;
}
if (!run.logContent.includes(`comment id: ${comment.id}`)) continue;
const distanceMs = Math.abs(runEndMs - commentCreatedAtMs);
if (!bestMatch || distanceMs < bestMatch.distanceMs) {
bestMatch = {
runId: run.runId,
agentId: run.agentId,
distanceMs,
};
}
overlappingRuns.push({ run, runEndMs });
}
if (!bestMatch) continue;
derivedByCommentId.set(comment.id, {
derivedAuthorAgentId: bestMatch.agentId,
derivedCreatedByRunId: bestMatch.runId,
derivedAuthorSource: "run_log_comment_post",
});
// Tier 2: an overlapping run log explicitly recorded posting this comment.
let bestLogMatch: { runId: string; agentId: string; distanceMs: number } | null = null;
for (const { run, runEndMs } of overlappingRuns) {
if (!run.logContent.includes(`comment id: ${comment.id}`)) continue;
const distanceMs = Math.abs(runEndMs - commentCreatedAtMs);
if (!bestLogMatch || distanceMs < bestLogMatch.distanceMs) {
bestLogMatch = { runId: run.runId, agentId: run.agentId, distanceMs };
}
}
if (bestLogMatch) {
derivedByCommentId.set(comment.id, {
derivedAuthorAgentId: bestLogMatch.agentId,
derivedCreatedByRunId: bestLogMatch.runId,
derivedAuthorSource: "run_log_comment_post",
});
continue;
}
// No lossless signal — leave unresolved. A pure run-window timing overlap is
// deliberately NOT enough to reattribute (it cannot tell an agent comment
// from a human board comment that happened during the run).
}
return derivedByCommentId;
@@ -273,6 +319,7 @@ export interface IssueFilters {
includePluginOperations?: boolean;
includeBlockedBy?: boolean;
includeBlockedInboxAttention?: boolean;
includeLiveDescendantSummary?: boolean;
hasPlanDocument?: boolean;
lowTrustBoundary?: LowTrustBoundary & { companyId: string };
q?: string;
@@ -1435,6 +1482,85 @@ async function activeRunMapForIssues(
return map;
}
async function liveDescendantCountMapForIssues(
dbOrTx: any,
companyId: string,
issueIds: string[],
): Promise<Map<string, number>> {
const uniqueIssueIds = [...new Set(issueIds)];
const map = new Map<string, number>();
if (uniqueIssueIds.length === 0) return map;
for (const issueIdChunk of chunkList(uniqueIssueIds, ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE)) {
const targetRows = issueIdChunk.map((issueId) => sql`(${issueId}::uuid)`);
const rows = await dbOrTx.execute(sql<{
issueId: string;
liveDescendantCount: number;
}>`
WITH RECURSIVE
target_issues(issue_id) AS (
VALUES ${sql.join(targetRows, sql`, `)}
),
live_issues(live_issue_id, parent_id) AS (
SELECT DISTINCT live_issue.id, live_issue.parent_id
FROM issues live_issue
JOIN heartbeat_runs live_run ON live_run.id = live_issue.execution_run_id
WHERE live_issue.company_id = ${companyId}
AND live_issue.hidden_at IS NULL
AND live_run.company_id = ${companyId}
AND live_run.status IN ('queued', 'running')
UNION
SELECT DISTINCT live_issue.id, live_issue.parent_id
FROM heartbeat_runs live_run
JOIN issues live_issue ON live_issue.id::text = (live_run.context_snapshot ->> 'issueId')
WHERE live_issue.company_id = ${companyId}
AND live_issue.hidden_at IS NULL
AND live_run.company_id = ${companyId}
AND live_run.status IN ('queued', 'running')
),
live_ancestors(live_issue_id, ancestor_id, next_parent_id, visited_issue_ids) AS (
SELECT live_issues.live_issue_id, parent.id, parent.parent_id, ARRAY[live_issues.live_issue_id, parent.id]
FROM live_issues
JOIN issues parent ON parent.id = live_issues.parent_id
WHERE parent.company_id = ${companyId}
AND parent.hidden_at IS NULL
UNION ALL
SELECT
live_ancestors.live_issue_id,
parent.id,
parent.parent_id,
live_ancestors.visited_issue_ids || parent.id
FROM live_ancestors
JOIN issues parent ON parent.id = live_ancestors.next_parent_id
WHERE parent.company_id = ${companyId}
AND parent.hidden_at IS NULL
AND NOT parent.id = ANY(live_ancestors.visited_issue_ids)
)
SELECT
live_ancestors.ancestor_id::text AS "issueId",
count(DISTINCT live_ancestors.live_issue_id)::int AS "liveDescendantCount"
FROM live_ancestors
JOIN target_issues ON target_issues.issue_id = live_ancestors.ancestor_id
WHERE live_ancestors.ancestor_id <> live_ancestors.live_issue_id
GROUP BY live_ancestors.ancestor_id
`);
const resultRows = Array.isArray(rows) ? rows : Array.from(rows as Iterable<unknown>);
for (const row of resultRows) {
if (typeof row !== "object" || row === null) continue;
const issueId = (row as { issueId?: unknown }).issueId;
const liveDescendantCount = (row as { liveDescendantCount?: unknown }).liveDescendantCount;
if (typeof issueId !== "string") continue;
const count = typeof liveDescendantCount === "number"
? liveDescendantCount
: Number(liveDescendantCount);
if (Number.isFinite(count)) map.set(issueId, count);
}
}
return map;
}
function createIssueBlockerAttention(input: Partial<IssueBlockerAttention> = {}): IssueBlockerAttention {
return {
state: input.state ?? "none",
@@ -3124,6 +3250,7 @@ async function listBlockedInboxIssues(
blockerAttention?: IssueBlockerAttention;
blockedInboxAttention: IssueBlockedInboxAttention;
productivityReview?: IssueProductivityReview | null;
liveDescendantCount?: number;
lastActivityAt: Date;
myLastTouchAt?: Date | null;
lastExternalCommentAt?: Date | null;
@@ -3145,6 +3272,7 @@ async function listBlockedInboxIssues(
if (withRuns.length === 0) return [];
const issueIds = withRuns.map((row) => row.id);
const includeLiveDescendantSummary = filters?.includeLiveDescendantSummary === true;
const [
statsRows,
readRows,
@@ -3153,6 +3281,7 @@ async function listBlockedInboxIssues(
blockerAttentionByIssueId,
productivityReviewByIssueId,
blockedInboxAttentionByIssueId,
liveDescendantCountByIssueId,
] = await Promise.all([
contextUserId ? userCommentStatsForIssues(dbOrTx, companyId, contextUserId, issueIds) : Promise.resolve([]),
contextUserId ? userReadStatsForIssues(dbOrTx, companyId, contextUserId, issueIds) : Promise.resolve([]),
@@ -3161,6 +3290,9 @@ async function listBlockedInboxIssues(
listIssueBlockerAttentionMap(dbOrTx, companyId, withRuns),
listIssueProductivityReviewMap(dbOrTx, companyId, issueIds),
listIssueBlockedInboxAttentionMap(dbOrTx, companyId, withRuns),
includeLiveDescendantSummary
? liveDescendantCountMapForIssues(dbOrTx, companyId, issueIds)
: Promise.resolve(new Map<string, number>()),
]);
const rawSearchInput = filters?.q?.trim() ?? "";
@@ -3210,6 +3342,7 @@ async function listBlockedInboxIssues(
...(productivityReviewByIssueId.has(row.id)
? { productivityReview: productivityReviewByIssueId.get(row.id) }
: {}),
...(includeLiveDescendantSummary ? { liveDescendantCount: liveDescendantCountByIssueId.get(row.id) ?? 0 } : {}),
...(contextUserId
? deriveIssueUserContext(row, contextUserId, {
myLastCommentAt: statsByIssueId.get(row.id)?.myLastCommentAt ?? null,
@@ -3442,6 +3575,39 @@ export function issueService(db: Db) {
return content;
}
// Persist a resolved attribution so subsequent reads stop re-scanning run
// logs (and old "Board" threads stay fixed durably). Best-effort: a write
// failure must never break the read path. The `IS NULL` guard keeps this
// idempotent and avoids clobbering a value another reader just stored.
async function persistDerivedIssueCommentAttribution(
derivedByCommentId: ReadonlyMap<string, DerivedIssueCommentAttribution>,
) {
if (derivedByCommentId.size === 0) return;
// One bulk `UPDATE ... FROM (VALUES ...)` so the read path is never blocked
// on N sequential round-trips for a large legacy thread. The `IS NULL` guard
// keeps this idempotent and avoids clobbering a value another reader just
// stored. Best-effort: a write failure must never break the read path.
const rows = [...derivedByCommentId].map(
([commentId, derived]) =>
sql`(${commentId}::uuid, ${derived.derivedAuthorAgentId}::uuid, ${derived.derivedCreatedByRunId}::uuid, ${derived.derivedAuthorSource}::text)`,
);
try {
await db.execute(sql`
UPDATE ${issueComments} AS c
SET derived_author_agent_id = v.agent_id,
derived_created_by_run_id = v.run_id,
derived_author_source = v.source
FROM (VALUES ${sql.join(rows, sql`, `)}) AS v(comment_id, agent_id, run_id, source)
WHERE c.id = v.comment_id AND c.derived_author_agent_id IS NULL
`);
} catch (err) {
logger.warn(
{ err, commentIds: [...derivedByCommentId.keys()] },
"failed to persist derived issue-comment attribution",
);
}
}
async function enrichCommentsWithDerivedAgentAttribution<
T extends {
id: string;
@@ -3450,20 +3616,55 @@ export function issueService(db: Db) {
authorAgentId?: string | null;
authorUserId?: string | null;
createdByRunId?: string | null;
derivedAuthorAgentId?: string | null;
createdAt: Date | string;
},
>(comments: readonly T[]) {
const candidates = comments.filter((comment) =>
// Candidates: a non-human author, no stored agent, and not already resolved
// by a previous read / the backfill migration.
const preliminary = comments.filter((comment) =>
!comment.authorAgentId
&& !!comment.authorUserId
&& !comment.createdByRunId,
&& !comment.derivedAuthorAgentId,
);
if (candidates.length === 0) return comments;
if (preliminary.length === 0) return comments;
const companyId = comments[0]?.companyId ?? null;
const issueId = comments[0]?.issueId ?? null;
if (!companyId || !issueId) return comments;
// Guard: never reattribute a comment whose author maps to a genuine user
// profile. Only the non-human sentinels agents post under (e.g.
// `local-board`) are eligible — even though `local-board` is itself a row in
// the `user` table, so a plain "exists in user table" check would wrongly
// exclude every mis-attributed agent comment.
const nonSentinelAuthorUserIds = [
...new Set(
preliminary
.map((comment) => comment.authorUserId)
.filter((id): id is string => !!id && !NON_HUMAN_SENTINEL_AUTHOR_USER_IDS.has(id)),
),
];
const genuineUserIds = nonSentinelAuthorUserIds.length
? new Set(
(
await db
.select({ id: authUsers.id })
.from(authUsers)
.where(inArray(authUsers.id, nonSentinelAuthorUserIds))
).map((row) => row.id),
)
: new Set<string>();
// `preliminary` already guarantees a truthy `authorUserId`, so only the two
// "not a genuine user" arms are live: the explicit non-human sentinel, or an
// author id absent from the `user` table.
const candidates = preliminary.filter(
(comment) =>
NON_HUMAN_SENTINEL_AUTHOR_USER_IDS.has(comment.authorUserId!)
|| !genuineUserIds.has(comment.authorUserId!),
);
if (candidates.length === 0) return comments;
const minCommentCreatedAtMs = candidates.reduce<number | null>((min, comment) => {
const timestamp = toTimestampMs(comment.createdAt);
if (timestamp === null) return min;
@@ -3481,6 +3682,13 @@ export function issueService(db: Db) {
maxCommentCreatedAtMs + ISSUE_COMMENT_RUN_LOG_DERIVATION_END_SLACK_MS,
).toISOString();
// The runs the comments' own `createdByRunId` point at — fetched
// unconditionally so the lossless run-id tier resolves even when a run is
// not otherwise associated with the issue.
const ownRunIds = [
...new Set(candidates.map((comment) => comment.createdByRunId).filter((id): id is string => !!id)),
];
const runs = await db
.select({
runId: heartbeatRuns.id,
@@ -3497,36 +3705,82 @@ export function issueService(db: Db) {
and(
eq(heartbeatRuns.companyId, companyId),
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}
)`,
and(
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}
)`,
),
sql`coalesce(${heartbeatRuns.finishedAt}, ${heartbeatRuns.createdAt}) >= ${minCommentCreatedAt}::timestamptz`,
sql`coalesce(${heartbeatRuns.startedAt}, ${heartbeatRuns.createdAt}) <= ${maxCommentCreatedAt}::timestamptz`,
),
ownRunIds.length > 0 ? inArray(heartbeatRuns.id, ownRunIds) : sql`false`,
),
sql`coalesce(${heartbeatRuns.finishedAt}, ${heartbeatRuns.createdAt}) >= ${minCommentCreatedAt}::timestamptz`,
sql`coalesce(${heartbeatRuns.startedAt}, ${heartbeatRuns.createdAt}) <= ${maxCommentCreatedAt}::timestamptz`,
),
)
.orderBy(desc(heartbeatRuns.createdAt));
if (runs.length === 0) return comments;
const runsWithLogs: Array<(typeof runs)[number] & { logContent: string }> = [];
for (let index = 0; index < runs.length; index += ISSUE_COMMENT_RUN_LOG_DERIVATION_MAX_PARALLEL_READS) {
const batch = runs.slice(index, index + ISSUE_COMMENT_RUN_LOG_DERIVATION_MAX_PARALLEL_READS);
const batchWithLogs = await Promise.all(batch.map(async (run) => ({
...run,
logContent: await readRunLogText(run),
})));
runsWithLogs.push(...batchWithLogs);
// Pass 1: resolve the run-id tier, which never reads log bodies. Most
// comments resolve here, so we avoid object-storage reads entirely.
const runsWithoutLogs = runs.map((run) => ({ ...run, logContent: "" }));
const derivedByCommentId = new Map<string, DerivedIssueCommentAttribution>(
deriveIssueCommentRunLogAttribution(candidates, runsWithoutLogs),
);
// Pass 2: for comments still unresolved after the run-id tier, read the logs
// of any run whose window overlaps such a comment, to look for the explicit
// `comment id:` post marker. The marker is a lossless signal regardless of
// how many runs overlap, so we do not short-circuit on the single-run case.
const unresolved = candidates.filter((comment) => !derivedByCommentId.has(comment.id));
if (unresolved.length > 0) {
const runIdsToRead = new Set<string>();
for (const run of runs) {
const runStartMs = toTimestampMs(run.startedAt ?? run.createdAt);
const runEndMs = toTimestampMs(run.finishedAt ?? run.createdAt);
if (runStartMs === null || runEndMs === null) continue;
for (const comment of unresolved) {
const commentCreatedAtMs = toTimestampMs(comment.createdAt);
if (commentCreatedAtMs === null) continue;
if (
commentCreatedAtMs >= runStartMs
&& commentCreatedAtMs <= runEndMs + ISSUE_COMMENT_RUN_LOG_DERIVATION_END_SLACK_MS
) {
runIdsToRead.add(run.runId);
break;
}
}
}
if (runIdsToRead.size > 0) {
const runsToRead = runs.filter((run) => runIdsToRead.has(run.runId));
const logByRunId = new Map<string, string>();
for (let index = 0; index < runsToRead.length; index += ISSUE_COMMENT_RUN_LOG_DERIVATION_MAX_PARALLEL_READS) {
const batch = runsToRead.slice(index, index + ISSUE_COMMENT_RUN_LOG_DERIVATION_MAX_PARALLEL_READS);
await Promise.all(
batch.map(async (run) => {
logByRunId.set(run.runId, await readRunLogText(run));
}),
);
}
const runsWithLogs = runs.map((run) => ({ ...run, logContent: logByRunId.get(run.runId) ?? "" }));
for (const [commentId, derived] of deriveIssueCommentRunLogAttribution(unresolved, runsWithLogs)) {
derivedByCommentId.set(commentId, derived);
}
}
}
const derivedByCommentId = deriveIssueCommentRunLogAttribution(candidates, runsWithLogs);
if (derivedByCommentId.size === 0) return comments;
await persistDerivedIssueCommentAttribution(derivedByCommentId);
return comments.map((comment) => {
const derived = derivedByCommentId.get(comment.id);
return derived ? { ...comment, ...derived } : comment;
@@ -3737,6 +3991,23 @@ export function issueService(db: Db) {
return empty;
}
async function withIssueRelationSummaries<T extends { id: string }>(
companyId: string,
rows: T[],
dbOrTx: DbReader = db,
): Promise<Array<T & IssueRelationSummaryMap>> {
if (rows.length === 0) return [];
const relationMap = await getIssueRelationSummaryMap(
companyId,
rows.map((row) => row.id),
dbOrTx,
);
return rows.map((row) => ({
...row,
...(relationMap.get(row.id) ?? { blockedBy: [], blocks: [] }),
}));
}
async function assertNoBlockingCycles(
companyId: string,
issueId: string,
@@ -4122,6 +4393,7 @@ export function issueService(db: Db) {
const contextUserId = unreadForUserId ?? touchedByUserId ?? inboxArchivedByUserId;
const includeBlockedBy = filters?.includeBlockedBy === true;
const includeBlockedInboxAttention = filters?.includeBlockedInboxAttention === true;
const includeLiveDescendantSummary = filters?.includeLiveDescendantSummary === true;
const rawSearch = filters?.q?.trim() ?? "";
const hasSearch = rawSearch.length > 0;
const escapedSearch = hasSearch ? escapeLikePattern(rawSearch) : "";
@@ -4269,7 +4541,7 @@ export function issueService(db: Db) {
}
const issueIds = withRuns.map((row) => row.id);
const [statsRows, readRows, lastActivityRows, blockedByMap] = await Promise.all([
const [statsRows, readRows, lastActivityRows, blockedByMap, liveDescendantCountByIssueId] = await Promise.all([
contextUserId
? userCommentStatsForIssues(db, companyId, contextUserId, issueIds)
: Promise.resolve([]),
@@ -4280,6 +4552,9 @@ export function issueService(db: Db) {
includeBlockedBy
? blockedByMapForIssues(db, companyId, issueIds)
: Promise.resolve(new Map<string, IssueRelationIssueSummary[]>()),
includeLiveDescendantSummary
? liveDescendantCountMapForIssues(db, companyId, issueIds)
: Promise.resolve(new Map<string, number>()),
]);
const statsByIssueId = new Map(statsRows.map((row) => [row.issueId, row]));
const lastActivityByIssueId = new Map(lastActivityRows.map((row) => [row.issueId, row]));
@@ -4309,6 +4584,7 @@ export function issueService(db: Db) {
lastActivityAt,
...(blockerAttentionByIssueId.has(row.id) ? { blockerAttention: blockerAttentionByIssueId.get(row.id) } : {}),
...(includeBlockedInboxAttention ? { blockedInboxAttention: blockedInboxAttentionByIssueId.get(row.id) ?? null } : {}),
...(includeLiveDescendantSummary ? { liveDescendantCount: liveDescendantCountByIssueId.get(row.id) ?? 0 } : {}),
...(productivityReviewByIssueId.has(row.id)
? { productivityReview: productivityReviewByIssueId.get(row.id) }
: {}),
@@ -4331,6 +4607,7 @@ export function issueService(db: Db) {
lastActivityAt,
...(blockerAttentionByIssueId.has(row.id) ? { blockerAttention: blockerAttentionByIssueId.get(row.id) } : {}),
...(includeBlockedInboxAttention ? { blockedInboxAttention: blockedInboxAttentionByIssueId.get(row.id) ?? null } : {}),
...(includeLiveDescendantSummary ? { liveDescendantCount: liveDescendantCountByIssueId.get(row.id) ?? 0 } : {}),
...(productivityReviewByIssueId.has(row.id)
? { productivityReview: productivityReviewByIssueId.get(row.id) }
: {}),
@@ -4700,7 +4977,7 @@ export function issueService(db: Db) {
actorUserId,
...issueData
} = data;
const child = await issueService(db).create(parent.companyId, {
let child = await issueService(db).create(parent.companyId, {
...issueData,
parentId: parent.id,
projectId: issueData.projectId ?? parent.projectId,
@@ -4723,6 +5000,7 @@ export function issueService(db: Db) {
[...new Set([...existingBlockers.map((row) => row.blockerIssueId), child.id])],
{ agentId: actorAgentId ?? null, userId: actorUserId ?? null },
);
[child] = await withIssueRelationSummaries(parent.companyId, [child], db);
}
return {
@@ -5224,7 +5502,8 @@ export function issueService(db: Db) {
);
}
const [enriched] = await withIssueLabels(tx, [issue]);
return enriched;
const [withRelations] = await withIssueRelationSummaries(companyId, [enriched], tx);
return withRelations;
});
},
+1 -1
View File
@@ -1294,7 +1294,7 @@ export function buildHostServices(
}
const telemetryClient = getTelemetryClient();
if (!telemetryClient) return;
telemetryClient.track(`plugin.${pluginKey}.${eventName}`, params.dimensions);
telemetryClient.trackDynamic(`plugin.${pluginKey}.${eventName}`, params.dimensions);
},
},
+278 -28
View File
@@ -1,4 +1,4 @@
import { and, asc, desc, eq, gt, gte, inArray, isNull, notInArray, sql } from "drizzle-orm";
import { and, asc, desc, eq, gt, gte, inArray, isNull, notInArray, or, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
DEFAULT_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS,
@@ -37,6 +37,7 @@ import { instanceSettingsService } from "../instance-settings.js";
import { issueRecoveryActionService } from "../issue-recovery-actions.js";
import { issueTreeControlService } from "../issue-tree-control.js";
import { TERMINAL_HEARTBEAT_RUN_STATUSES, issueService } from "../issues.js";
import { parseIssueExecutionState } from "../issue-execution-policy.js";
import { evaluateAgentInvokabilityFromDb } from "../agent-invokability.js";
import { getRunLogStore } from "../run-log-store.js";
import {
@@ -72,6 +73,7 @@ const ACTIVE_RUN_OUTPUT_EVIDENCE_TAIL_BYTES = 8 * 1024;
const STRANDED_ISSUE_RECOVERY_ORIGIN_KIND = RECOVERY_ORIGIN_KINDS.strandedIssueRecovery;
const STALE_ACTIVE_RUN_EVALUATION_ORIGIN_KIND = RECOVERY_ORIGIN_KINDS.staleActiveRunEvaluation;
const DEFERRED_WAKE_CONTEXT_KEY = "_paperclipWakeContext";
const EXECUTION_REVIEW_PARTICIPANT_RECOVERY_REASON = "execution_review_participant_recovery";
const SESSIONED_LOCAL_ADAPTERS = new Set([
"claude_local",
"codex_local",
@@ -113,15 +115,20 @@ type RecoveryWakeup = (
type LatestIssueRun = Pick<
typeof heartbeatRuns.$inferSelect,
"id" | "agentId" | "status" | "error" | "errorCode" | "contextSnapshot" | "livenessState"
> | null;
> & {
resultJson?: unknown;
} | null;
type SuccessfulLatestIssueRun = NonNullable<LatestIssueRun> & { status: "succeeded" };
type StrandedRecoveryCause =
| "stranded_assigned_issue"
| "workspace_validation_failed"
| "configuration_incomplete"
| "execution_review_participant_recovery"
| typeof SUCCESSFUL_RUN_MISSING_STATE_REASON;
type StrandedPreviousStatus = "todo" | "in_progress" | "in_review";
type SuccessfulRunHandoffRecoveryEvidence = {
sourceRunId: string | null;
correctiveRunId: string;
@@ -130,6 +137,16 @@ type SuccessfulRunHandoffRecoveryEvidence = {
maxHandoffAttempts: number;
};
function readWorkspaceValidationPayload(latestRun: LatestIssueRun): Record<string, unknown> | null {
const payload = parseObject(parseObject(latestRun?.resultJson).workspaceValidation);
return Object.keys(payload).length > 0 ? payload : null;
}
function readWorkspaceValidationFingerprint(latestRun: LatestIssueRun): string | null {
const payload = readWorkspaceValidationPayload(latestRun);
return readNonEmptyString(payload?.fingerprint);
}
type WatchdogDecisionActor =
| { type: "board"; userId?: string | null; runId?: string | null }
| { type: "agent"; agentId?: string | null; runId?: string | null }
@@ -163,9 +180,29 @@ function summarizeRunFailureForIssueComment(run: LatestIssueRun) {
return null;
}
function buildExecutionReviewParticipantRecoveryComment(latestRun: LatestIssueRun) {
const failureSummary = summarizeRunFailureForIssueComment(latestRun);
return (
"Paperclip retried the pending execution-review participant once, but the review stage still has no completed decision " +
`or live reviewer run.${failureSummary ?? ""} ` +
"Moving it to `blocked` with a source-scoped recovery action so the recovery owner can repair the reviewer runtime, " +
"restore the review stage, or record an intentional manual resolution."
);
}
function buildExecutionReviewParticipantUnavailableComment(latestRun: LatestIssueRun) {
const failureSummary = summarizeRunFailureForIssueComment(latestRun);
return (
"Paperclip cannot continue the pending execution-review participant because the participant is not invokable " +
`and the review stage has no completed decision or live reviewer run.${failureSummary ?? ""} ` +
"Moving it to `blocked` with a source-scoped recovery action so the recovery owner can repair the reviewer runtime, " +
"restore the review stage, or record an intentional manual resolution."
);
}
function didAutomaticRecoveryFail(
latestRun: LatestIssueRun,
expectedRetryReason: "assignment_recovery" | "issue_continuation_needed",
expectedRetryReason: "assignment_recovery" | "issue_continuation_needed" | typeof EXECUTION_REVIEW_PARTICIPANT_RECOVERY_REASON,
) {
if (!latestRun) return false;
@@ -177,6 +214,11 @@ function didAutomaticRecoveryFail(
);
}
function isTerminalIssueRun(latestRun: LatestIssueRun) {
if (!latestRun) return false;
return TERMINAL_HEARTBEAT_RUN_STATUSES.has(latestRun.status);
}
const TRANSIENT_INFRA_CONTINUATION_ERROR_CODES = new Set<string>([
"adapter_failed",
"codex_transient_upstream",
@@ -497,6 +539,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
errorCode: heartbeatRuns.errorCode,
contextSnapshot: heartbeatRuns.contextSnapshot,
livenessState: heartbeatRuns.livenessState,
resultJson: heartbeatRuns.resultJson,
})
.from(heartbeatRuns)
.where(
@@ -510,6 +553,35 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
.then((rows) => rows[0] ?? null);
}
async function getLatestIssueRunForAgent(
companyId: string,
issueId: string,
agentId: string,
): Promise<LatestIssueRun> {
return db
.select({
id: heartbeatRuns.id,
agentId: heartbeatRuns.agentId,
status: heartbeatRuns.status,
error: heartbeatRuns.error,
errorCode: heartbeatRuns.errorCode,
contextSnapshot: heartbeatRuns.contextSnapshot,
livenessState: heartbeatRuns.livenessState,
resultJson: heartbeatRuns.resultJson,
})
.from(heartbeatRuns)
.where(
and(
eq(heartbeatRuns.companyId, companyId),
eq(heartbeatRuns.agentId, agentId),
sql`${heartbeatRuns.contextSnapshot} ->> 'issueId' = ${issueId}`,
),
)
.orderBy(desc(heartbeatRuns.createdAt), desc(heartbeatRuns.id))
.limit(1)
.then((rows) => rows[0] ?? null);
}
async function summarizeRecentContinuationRetries(
companyId: string,
issueId: string,
@@ -558,7 +630,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
return { consecutive, latestFinishedAt };
}
async function hasActiveExecutionPath(companyId: string, issueId: string) {
async function hasActiveExecutionPath(companyId: string, issueId: string, agentId?: string | null) {
const [run, deferredWake] = await Promise.all([
db
.select({ id: heartbeatRuns.id })
@@ -568,6 +640,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
eq(heartbeatRuns.companyId, companyId),
inArray(heartbeatRuns.status, [...EXECUTION_PATH_HEARTBEAT_RUN_STATUSES]),
sql`${heartbeatRuns.contextSnapshot} ->> 'issueId' = ${issueId}`,
agentId ? eq(heartbeatRuns.agentId, agentId) : sql`true`,
),
)
.limit(1)
@@ -580,6 +653,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
eq(agentWakeupRequests.companyId, companyId),
eq(agentWakeupRequests.status, "deferred_issue_execution"),
sql`${agentWakeupRequests.payload} ->> 'issueId' = ${issueId}`,
agentId ? eq(agentWakeupRequests.agentId, agentId) : sql`true`,
),
)
.limit(1)
@@ -605,7 +679,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
.then((rows) => Boolean(rows[0]));
}
async function hasQueuedIssueWake(companyId: string, issueId: string) {
async function hasQueuedIssueWake(companyId: string, issueId: string, agentId?: string | null) {
return db
.select({ id: agentWakeupRequests.id })
.from(agentWakeupRequests)
@@ -614,6 +688,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
eq(agentWakeupRequests.companyId, companyId),
eq(agentWakeupRequests.status, "queued"),
sql`${agentWakeupRequests.payload} ->> 'issueId' = ${issueId}`,
agentId ? eq(agentWakeupRequests.agentId, agentId) : sql`true`,
),
)
.limit(1)
@@ -664,10 +739,11 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
async function enqueueStrandedIssueRecovery(input: {
issueId: string;
agentId: string;
reason: "issue_assignment_recovery" | "issue_continuation_needed";
retryReason: "assignment_recovery" | "issue_continuation_needed";
reason: "issue_assignment_recovery" | "issue_continuation_needed" | typeof EXECUTION_REVIEW_PARTICIPANT_RECOVERY_REASON;
retryReason: "assignment_recovery" | "issue_continuation_needed" | typeof EXECUTION_REVIEW_PARTICIPANT_RECOVERY_REASON;
source: string;
retryOfRunId?: string | null;
extraContext?: Record<string, unknown>;
}) {
const queued = await deps.enqueueWakeup(input.agentId, {
source: "automation",
@@ -676,6 +752,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
payload: withRecoveryModelProfileHint({
issueId: input.issueId,
...(input.retryOfRunId ? { retryOfRunId: input.retryOfRunId } : {}),
...(input.extraContext ?? {}),
}, "normal_model"),
requestedByActorType: "system",
requestedByActorId: null,
@@ -686,6 +763,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
retryReason: input.retryReason,
source: input.source,
...(input.retryOfRunId ? { retryOfRunId: input.retryOfRunId } : {}),
...(input.extraContext ?? {}),
}, "normal_model"),
});
@@ -2039,8 +2117,12 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
].join("\n");
}
async function resolveStrandedIssueRecoveryOwnerAgentId(issue: typeof issues.$inferSelect) {
async function resolveStrandedIssueRecoveryOwnerAgentId(
issue: typeof issues.$inferSelect,
preferredOwnerAgentId?: string | null,
) {
const candidateIds: string[] = [];
if (preferredOwnerAgentId) candidateIds.push(preferredOwnerAgentId);
if (issue.assigneeAgentId) {
const assignee = await getAgent(issue.assigneeAgentId);
if (assignee?.reportsTo) candidateIds.push(assignee.reportsTo);
@@ -2078,7 +2160,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
function buildStrandedIssueRecoveryDescription(input: {
issue: typeof issues.$inferSelect;
latestRun: LatestIssueRun;
previousStatus: "todo" | "in_progress";
previousStatus: StrandedPreviousStatus;
prefix: string;
recoveryCause?: StrandedRecoveryCause;
successfulRunHandoffEvidence?: SuccessfulRunHandoffRecoveryEvidence | null;
@@ -2121,9 +2203,26 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
const retryReason = readNonEmptyString(parseObject(input.latestRun?.contextSnapshot)?.retryReason) ?? "unknown";
const failureSummary = summarizeRunFailureForIssueComment(input.latestRun);
const isReviewParticipantRecovery = input.recoveryCause === "execution_review_participant_recovery";
const detectedInvariant = isReviewParticipantRecovery
? "execution_review_participant_recovery"
: "stranded_assigned_issue";
const requiredAction = isReviewParticipantRecovery
? [
"- Inspect the latest reviewer run and the pending execution-review stage.",
"- Fix the reviewer runtime, restore the source issue to `in_review` with a live participant, or record an intentional manual resolution.",
"- When the source issue has a live review path or has been intentionally resolved, mark this recovery issue done.",
]
: [
"- Inspect the latest run and source issue state.",
"- Fix the runtime/adapter problem, reassign the source issue, or convert the source issue into a clear manual-review state.",
"- When the source issue has a live execution path or has been intentionally resolved, mark this recovery issue done.",
];
return [
"Paperclip exhausted automatic recovery for an assigned issue and created this explicit recovery task.",
isReviewParticipantRecovery
? "Paperclip exhausted automatic recovery for a pending execution-review participant and created this explicit recovery task."
: "Paperclip exhausted automatic recovery for an assigned issue and created this explicit recovery task.",
"",
"## Source",
"",
@@ -2131,7 +2230,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
`- Previous source status: \`${input.previousStatus}\``,
`- Latest retry run: ${runLink}`,
`- Latest retry status: \`${input.latestRun?.status ?? "unknown"}\``,
`- Detected invariant: \`stranded_assigned_issue\``,
`- Detected invariant: \`${detectedInvariant}\``,
`- Retry reason: \`${retryReason}\``,
failureSummary ? `- Failure: ${failureSummary.trim()}` : "- Failure: none recorded",
"",
@@ -2141,16 +2240,14 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
"",
"## Required Action",
"",
"- Inspect the latest run and source issue state.",
"- Fix the runtime/adapter problem, reassign the source issue, or convert the source issue into a clear manual-review state.",
"- When the source issue has a live execution path or has been intentionally resolved, mark this recovery issue done.",
...requiredAction,
].join("\n");
}
async function ensureStrandedIssueRecoveryIssue(input: {
issue: typeof issues.$inferSelect;
latestRun: LatestIssueRun;
previousStatus: "todo" | "in_progress";
previousStatus: StrandedPreviousStatus;
recoveryCause?: StrandedRecoveryCause;
successfulRunHandoffEvidence?: SuccessfulRunHandoffRecoveryEvidence | null;
}) {
@@ -2246,7 +2343,20 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
function strandedRecoveryActionFingerprint(input: {
issue: typeof issues.$inferSelect;
recoveryCause: StrandedRecoveryCause;
latestRun: LatestIssueRun;
}) {
if (input.recoveryCause === "workspace_validation_failed") {
const workspaceFingerprint = readWorkspaceValidationFingerprint(input.latestRun);
if (workspaceFingerprint) {
return [
"source_scoped_recovery",
input.issue.companyId,
input.issue.id,
input.recoveryCause,
workspaceFingerprint,
].join(":");
}
}
return [
"source_scoped_recovery",
input.issue.companyId,
@@ -2258,11 +2368,14 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
function buildStrandedRecoveryActionEvidence(input: {
issue: typeof issues.$inferSelect;
latestRun: LatestIssueRun;
previousStatus: "todo" | "in_progress";
previousStatus: StrandedPreviousStatus;
recoveryCause: StrandedRecoveryCause;
successfulRunHandoffEvidence?: SuccessfulRunHandoffRecoveryEvidence | null;
}) {
const context = parseObject(input.latestRun?.contextSnapshot);
const workspaceValidation = input.recoveryCause === "workspace_validation_failed"
? readWorkspaceValidationPayload(input.latestRun)
: null;
return {
sourceIssueId: input.issue.id,
sourceIdentifier: input.issue.identifier,
@@ -2278,18 +2391,23 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
missingDisposition: input.successfulRunHandoffEvidence?.missingDisposition ?? null,
handoffAttempt: input.successfulRunHandoffEvidence?.handoffAttempt ?? null,
maxHandoffAttempts: input.successfulRunHandoffEvidence?.maxHandoffAttempts ?? null,
...(workspaceValidation ? { workspaceValidation } : {}),
};
}
async function ensureSourceScopedStrandedRecoveryAction(input: {
issue: typeof issues.$inferSelect;
latestRun: LatestIssueRun;
previousStatus: "todo" | "in_progress";
previousStatus: StrandedPreviousStatus;
recoveryCause?: StrandedRecoveryCause;
recoveryOwnerAgentId?: string | null;
successfulRunHandoffEvidence?: SuccessfulRunHandoffRecoveryEvidence | null;
}) {
const recoveryCause = input.recoveryCause ?? "stranded_assigned_issue";
const ownerAgentId = await resolveStrandedIssueRecoveryOwnerAgentId(input.issue);
const ownerAgentId = await resolveStrandedIssueRecoveryOwnerAgentId(
input.issue,
input.recoveryOwnerAgentId,
);
const now = new Date();
const action = await recoveryActionsSvc.upsertSourceScoped({
companyId: input.issue.companyId,
@@ -2303,6 +2421,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
fingerprint: strandedRecoveryActionFingerprint({
issue: input.issue,
recoveryCause,
latestRun: input.latestRun,
}),
evidence: buildStrandedRecoveryActionEvidence({
issue: input.issue,
@@ -2314,9 +2433,13 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
nextAction: recoveryCause === SUCCESSFUL_RUN_MISSING_STATE_REASON
? "Choose and record a valid issue disposition without copying transcript content."
: recoveryCause === "workspace_validation_failed"
? "Repair the source issue workspace link, project workspace cwd, or git checkout before resuming adapter execution."
? readWorkspaceValidationPayload(input.latestRun)?.reason === "git_worktree_branch_incoherence"
? "Repair the source issue git worktree branch incoherence, or choose a new execution workspace, before resuming adapter execution."
: "Repair the source issue workspace link, project workspace cwd, or git checkout before resuming adapter execution."
: recoveryCause === "configuration_incomplete"
? "Bind the missing secret(s) named in the run failure to the agent/project/routine env before resuming adapter execution."
: recoveryCause === "execution_review_participant_recovery"
? "Repair the failed review participant path, restore the source issue to in_review with a live reviewer, or record an intentional manual resolution."
: "Restore a live execution path, fix the runtime/adapter failure, or record an intentional manual resolution.",
wakePolicy: recoveryCause === "workspace_validation_failed" || recoveryCause === "configuration_incomplete"
? {
@@ -2381,7 +2504,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
function buildRecoveryIssueInPlaceEscalationComment(input: {
issue: typeof issues.$inferSelect;
previousStatus: "todo" | "in_progress";
previousStatus: StrandedPreviousStatus;
latestRun: LatestIssueRun;
prefix: string;
}) {
@@ -2408,7 +2531,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
async function escalateStrandedRecoveryIssueInPlace(input: {
issue: typeof issues.$inferSelect;
previousStatus: "todo" | "in_progress";
previousStatus: StrandedPreviousStatus;
latestRun: LatestIssueRun;
}) {
const updated = await issuesSvc.update(input.issue.id, { status: "blocked" });
@@ -2541,10 +2664,11 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
async function escalateStrandedAssignedIssue(input: {
issue: typeof issues.$inferSelect;
previousStatus: "todo" | "in_progress";
previousStatus: StrandedPreviousStatus;
latestRun: LatestIssueRun;
comment?: string;
recoveryCause?: StrandedRecoveryCause;
recoveryOwnerAgentId?: string | null;
successfulRunHandoffEvidence?: SuccessfulRunHandoffRecoveryEvidence | null;
}) {
if (isStrandedIssueRecoveryIssue(input.issue)) {
@@ -2561,6 +2685,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
previousStatus: input.previousStatus,
latestRun: input.latestRun,
recoveryCause,
recoveryOwnerAgentId: input.recoveryOwnerAgentId,
successfulRunHandoffEvidence: input.successfulRunHandoffEvidence,
});
const blockerIds = await existingUnresolvedBlockerIssueIds(input.issue.companyId, input.issue.id);
@@ -2664,6 +2789,8 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
? "recovery.reconcile_workspace_validation_failed"
: input.recoveryCause === "configuration_incomplete"
? "recovery.reconcile_configuration_incomplete"
: input.recoveryCause === "execution_review_participant_recovery"
? "recovery.reconcile_execution_review_participant"
: "recovery.reconcile_stranded_assigned_issue",
recoveryCause: input.recoveryCause ?? "stranded_assigned_issue",
latestRunId: input.latestRun?.id ?? null,
@@ -2717,8 +2844,11 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
.where(
and(
isNull(issues.assigneeUserId),
inArray(issues.status, ["todo", "in_progress"]),
sql`${issues.assigneeAgentId} is not null`,
inArray(issues.status, ["todo", "in_progress", "in_review"]),
or(
sql`${issues.assigneeAgentId} is not null`,
eq(issues.status, "in_review"),
),
),
);
@@ -2730,6 +2860,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
successfulContinuationObserved: 0,
orphanBlockersAssigned: 0,
successfulRunHandoffEscalated: 0,
reviewParticipantRequeued: 0,
escalated: 0,
waitingOnReviewResolved: 0,
recentProgressExempted: 0,
@@ -2738,19 +2869,36 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
};
for (const issue of candidates) {
const agentId = issue.assigneeAgentId;
const executionState = issue.status === "in_review"
? parseIssueExecutionState(issue.executionState)
: null;
const pendingExecutionState = executionState?.status === "pending" ? executionState : null;
const currentParticipant = pendingExecutionState
? pendingExecutionState.currentParticipant
: null;
const participantAgentId = currentParticipant?.type === "agent" ? currentParticipant.agentId : null;
const agentId = issue.status === "in_review" && participantAgentId
? participantAgentId
: issue.assigneeAgentId;
if (!agentId) {
result.skipped += 1;
continue;
}
const agent = await getAgent(agentId);
if (!agent || agent.companyId !== issue.companyId || !(await isAgentInvokable(agent))) {
const agentInvokable = agent && agent.companyId === issue.companyId
? await isAgentInvokable(agent)
: false;
if (issue.status !== "in_review" && !agentInvokable) {
result.skipped += 1;
continue;
}
if (await hasActiveExecutionPath(issue.companyId, issue.id)) {
if (await hasActiveExecutionPath(
issue.companyId,
issue.id,
issue.status === "in_review" ? agentId : null,
)) {
result.skipped += 1;
continue;
}
@@ -2769,7 +2917,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
if (isStrandedIssueRecoveryIssue(issue) && isUnsuccessfulTerminalIssueRun(latestRun)) {
const updated = await escalateStrandedRecoveryIssueInPlace({
issue,
previousStatus: issue.status as "todo" | "in_progress",
previousStatus: issue.status as StrandedPreviousStatus,
latestRun,
});
if (updated) {
@@ -2781,6 +2929,108 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
continue;
}
if (issue.status === "in_review") {
if (!participantAgentId || !pendingExecutionState) {
result.skipped += 1;
continue;
}
const participantLatestRun = await getLatestIssueRunForAgent(
issue.companyId,
issue.id,
participantAgentId,
);
if (!participantLatestRun || !isTerminalIssueRun(participantLatestRun)) {
if (!agentInvokable) {
const updated = await escalateStrandedAssignedIssue({
issue,
previousStatus: "in_review",
latestRun: participantLatestRun,
comment: buildExecutionReviewParticipantUnavailableComment(participantLatestRun),
recoveryCause: EXECUTION_REVIEW_PARTICIPANT_RECOVERY_REASON,
recoveryOwnerAgentId: participantAgentId,
});
if (updated) {
result.escalated += 1;
result.issueIds.push(issue.id);
} else {
result.skipped += 1;
}
} else {
result.skipped += 1;
}
continue;
}
if (!agentInvokable) {
const updated = await escalateStrandedAssignedIssue({
issue,
previousStatus: "in_review",
latestRun: participantLatestRun,
comment: buildExecutionReviewParticipantUnavailableComment(participantLatestRun),
recoveryCause: EXECUTION_REVIEW_PARTICIPANT_RECOVERY_REASON,
recoveryOwnerAgentId: participantAgentId,
});
if (updated) {
result.escalated += 1;
result.issueIds.push(issue.id);
} else {
result.skipped += 1;
}
continue;
}
if (didAutomaticRecoveryFail(participantLatestRun, EXECUTION_REVIEW_PARTICIPANT_RECOVERY_REASON)) {
const updated = await escalateStrandedAssignedIssue({
issue,
previousStatus: "in_review",
latestRun: participantLatestRun,
comment: buildExecutionReviewParticipantRecoveryComment(participantLatestRun),
recoveryCause: EXECUTION_REVIEW_PARTICIPANT_RECOVERY_REASON,
recoveryOwnerAgentId: participantAgentId,
});
if (updated) {
result.escalated += 1;
result.issueIds.push(issue.id);
} else {
result.skipped += 1;
}
continue;
}
if (await hasQueuedIssueWake(issue.companyId, issue.id, participantAgentId)) {
result.skipped += 1;
continue;
}
if (await isInvocationBudgetBlocked(issue, participantAgentId)) {
result.skipped += 1;
continue;
}
const queued = await enqueueStrandedIssueRecovery({
issueId: issue.id,
agentId: participantAgentId,
reason: EXECUTION_REVIEW_PARTICIPANT_RECOVERY_REASON,
retryReason: EXECUTION_REVIEW_PARTICIPANT_RECOVERY_REASON,
source: "issue.execution_review_recovery",
retryOfRunId: participantLatestRun.id,
extraContext: {
currentStageId: pendingExecutionState.currentStageId ?? null,
currentStageType: pendingExecutionState.currentStageType ?? null,
reviewRecoveryInstruction:
"The previous reviewer run ended while this execution-review stage was still pending. Submit the review decision now, or mark the issue blocked with the exact unblock action.",
},
});
if (queued) {
result.reviewParticipantRequeued += 1;
result.issueIds.push(issue.id);
} else {
result.skipped += 1;
}
continue;
}
if (issue.status === "todo") {
if (!latestRun) {
if (await hasQueuedIssueWake(issue.companyId, issue.id)) {
+735
View File
@@ -0,0 +1,735 @@
import { and, asc, desc, eq, gte, inArray, isNull, lte, or, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
activityLog,
agents,
approvals,
authUsers,
heartbeatRuns,
issueApprovals,
issueComments,
issues,
issueThreadInteractions,
} from "@paperclipai/db";
// DTO types are shared with the UI via @paperclipai/shared so both sides consume
// one contract. Re-exported here for back-compat with existing server imports.
import type {
TimelineActorType,
WorkTimelineActor,
WorkTimelineSpan,
WorkTimelineEvent,
WorkTimelineEdge,
WorkTimelineResult,
} from "@paperclipai/shared";
export type {
TimelineActorType,
TimelineEventKind,
TimelineEdgeKind,
WorkTimelineActor,
WorkTimelineSpan,
WorkTimelineEvent,
WorkTimelineEdge,
WorkTimelineResult,
} from "@paperclipai/shared";
export interface WorkTimelineQuery {
companyId: string;
from?: Date;
to?: Date;
userId?: string;
goalId?: string;
projectId?: string;
issueId?: string;
limit?: number;
offset?: number;
canReadIssue?: (issue: WorkTimelineIssueAccessInput) => Promise<boolean>;
}
export interface WorkTimelineIssueAccessInput {
id: string;
companyId: string;
projectId: string | null;
parentId: string | null;
assigneeAgentId: string | null;
assigneeUserId: string | null;
status: string;
}
type IssueRow = {
id: string;
companyId: string;
projectId: string | null;
goalId: string | null;
parentId: string | null;
identifier: string | null;
title: string | null;
createdByAgentId: string | null;
createdByUserId: string | null;
assigneeAgentId: string | null;
assigneeUserId: string | null;
status: string;
createdAt: Date;
};
const DEFAULT_LIMIT = 200;
const MAX_LIMIT = 500;
const MAX_WINDOW_MS = 31 * 24 * 60 * 60 * 1000;
const DEFAULT_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
const MAX_SOURCE_ROWS = 5_000;
function actorId(type: TimelineActorType, id: string) {
return `${type}:${id}`;
}
function normalizeLimit(value: number | undefined) {
if (!Number.isFinite(value)) return DEFAULT_LIMIT;
return Math.max(1, Math.min(MAX_LIMIT, Math.floor(value ?? DEFAULT_LIMIT)));
}
function normalizeOffset(value: number | undefined) {
if (!Number.isFinite(value)) return 0;
return Math.max(0, Math.floor(value ?? 0));
}
export function normalizeTimelineWindow(input: { from?: Date; to?: Date }, now = new Date()) {
const rawTo = input.to ?? now;
const to = rawTo.getTime() > now.getTime() ? now : rawTo;
const requestedFrom = input.from ?? new Date(to.getTime() - DEFAULT_WINDOW_MS);
let from = requestedFrom;
let capped = false;
if (to.getTime() - from.getTime() > MAX_WINDOW_MS) {
from = new Date(to.getTime() - MAX_WINDOW_MS);
capped = true;
}
if (from.getTime() > to.getTime()) {
from = new Date(to.getTime() - DEFAULT_WINDOW_MS);
capped = true;
}
return { from, to, capped };
}
function dateIso(value: Date | null | undefined) {
return value ? value.toISOString() : null;
}
function readString(value: unknown) {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function maybeUuidList(ids: Iterable<string>) {
return Array.from(new Set(Array.from(ids).filter((id) => id.length > 0)));
}
function runOverlapsWindow(from: Date, to: Date) {
const fromIso = from.toISOString();
const toIso = to.toISOString();
return and(
sql`coalesce(${heartbeatRuns.startedAt}, ${heartbeatRuns.createdAt}) <= ${toIso}::timestamptz`,
sql`coalesce(${heartbeatRuns.finishedAt}, ${heartbeatRuns.startedAt}, ${heartbeatRuns.createdAt}) >= ${fromIso}::timestamptz`,
);
}
export function workTimelineService(db: Db) {
async function collectIssueIds(input: WorkTimelineQuery, from: Date, to: Date) {
const ids = new Set<string>();
if (input.issueId) {
ids.add(input.issueId);
}
const filterConditions = [
eq(issues.companyId, input.companyId),
isNull(issues.hiddenAt),
input.goalId ? eq(issues.goalId, input.goalId) : undefined,
input.projectId ? eq(issues.projectId, input.projectId) : undefined,
input.issueId ? eq(issues.id, input.issueId) : undefined,
].filter(Boolean);
const recentlyTouched = await db
.select({ id: issues.id })
.from(issues)
.where(
and(
...filterConditions,
or(
and(gte(issues.createdAt, from), lte(issues.createdAt, to)),
and(gte(issues.updatedAt, from), lte(issues.updatedAt, to)),
),
),
)
.orderBy(desc(issues.updatedAt))
.limit(MAX_SOURCE_ROWS);
for (const row of recentlyTouched) ids.add(row.id);
const runContextRows = await db
.select({ issueId: sql<string | null>`${heartbeatRuns.contextSnapshot} ->> 'issueId'` })
.from(heartbeatRuns)
.where(
and(
eq(heartbeatRuns.companyId, input.companyId),
runOverlapsWindow(from, to),
sql`${heartbeatRuns.contextSnapshot} ->> 'issueId' is not null`,
),
)
.orderBy(desc(heartbeatRuns.createdAt))
.limit(MAX_SOURCE_ROWS);
for (const row of runContextRows) {
if (row.issueId) ids.add(row.issueId);
}
const activityIssueRows = await db
.select({ issueId: activityLog.entityId })
.from(activityLog)
.where(
and(
eq(activityLog.companyId, input.companyId),
eq(activityLog.entityType, "issue"),
gte(activityLog.createdAt, from),
lte(activityLog.createdAt, to),
),
)
.orderBy(desc(activityLog.createdAt))
.limit(MAX_SOURCE_ROWS);
for (const row of activityIssueRows) ids.add(row.issueId);
const commentIssueRows = await db
.select({ issueId: issueComments.issueId })
.from(issueComments)
.where(
and(
eq(issueComments.companyId, input.companyId),
isNull(issueComments.deletedAt),
gte(issueComments.createdAt, from),
lte(issueComments.createdAt, to),
),
)
.orderBy(desc(issueComments.createdAt))
.limit(MAX_SOURCE_ROWS);
for (const row of commentIssueRows) ids.add(row.issueId);
const interactionIssueRows = await db
.select({ issueId: issueThreadInteractions.issueId })
.from(issueThreadInteractions)
.where(
and(
eq(issueThreadInteractions.companyId, input.companyId),
or(
and(gte(issueThreadInteractions.createdAt, from), lte(issueThreadInteractions.createdAt, to)),
and(gte(issueThreadInteractions.resolvedAt, from), lte(issueThreadInteractions.resolvedAt, to)),
),
),
)
.orderBy(desc(issueThreadInteractions.createdAt))
.limit(MAX_SOURCE_ROWS);
for (const row of interactionIssueRows) ids.add(row.issueId);
const approvalIssueRows = await db
.select({ issueId: issueApprovals.issueId })
.from(issueApprovals)
.innerJoin(approvals, eq(issueApprovals.approvalId, approvals.id))
.where(
and(
eq(issueApprovals.companyId, input.companyId),
or(
and(gte(approvals.createdAt, from), lte(approvals.createdAt, to)),
and(gte(approvals.decidedAt, from), lte(approvals.decidedAt, to)),
),
),
)
.orderBy(desc(approvals.createdAt))
.limit(MAX_SOURCE_ROWS);
for (const row of approvalIssueRows) ids.add(row.issueId);
return maybeUuidList(ids);
}
async function loadIssues(input: WorkTimelineQuery, issueIds: string[]) {
if (issueIds.length === 0) return [];
return db
.select({
id: issues.id,
companyId: issues.companyId,
projectId: issues.projectId,
goalId: issues.goalId,
parentId: issues.parentId,
identifier: issues.identifier,
title: issues.title,
createdByAgentId: issues.createdByAgentId,
createdByUserId: issues.createdByUserId,
assigneeAgentId: issues.assigneeAgentId,
assigneeUserId: issues.assigneeUserId,
status: issues.status,
createdAt: issues.createdAt,
})
.from(issues)
.where(
and(
eq(issues.companyId, input.companyId),
isNull(issues.hiddenAt),
inArray(issues.id, issueIds),
input.goalId ? eq(issues.goalId, input.goalId) : undefined,
input.projectId ? eq(issues.projectId, input.projectId) : undefined,
input.issueId ? eq(issues.id, input.issueId) : undefined,
),
);
}
async function applyUserLens(input: WorkTimelineQuery, rows: IssueRow[], from: Date, to: Date) {
if (!input.userId) return rows;
const byId = new Map(rows.map((issue) => [issue.id, issue]));
const selected = new Set<string>();
for (const issue of rows) {
if (issue.createdByUserId === input.userId || issue.assigneeUserId === input.userId) selected.add(issue.id);
}
const [commentRows, approvalRows, interactionRows, activityRows] = await Promise.all([
db
.select({ issueId: issueComments.issueId })
.from(issueComments)
.where(
and(
eq(issueComments.companyId, input.companyId),
eq(issueComments.authorUserId, input.userId),
isNull(issueComments.deletedAt),
gte(issueComments.createdAt, from),
lte(issueComments.createdAt, to),
),
),
db
.select({ issueId: issueApprovals.issueId })
.from(issueApprovals)
.innerJoin(approvals, eq(issueApprovals.approvalId, approvals.id))
.where(
and(
eq(issueApprovals.companyId, input.companyId),
eq(approvals.decidedByUserId, input.userId),
gte(approvals.decidedAt, from),
lte(approvals.decidedAt, to),
),
),
db
.select({ issueId: issueThreadInteractions.issueId })
.from(issueThreadInteractions)
.where(
and(
eq(issueThreadInteractions.companyId, input.companyId),
eq(issueThreadInteractions.resolvedByUserId, input.userId),
gte(issueThreadInteractions.resolvedAt, from),
lte(issueThreadInteractions.resolvedAt, to),
),
),
db
.select({ issueId: activityLog.entityId })
.from(activityLog)
.where(
and(
eq(activityLog.companyId, input.companyId),
eq(activityLog.actorType, "user"),
eq(activityLog.actorId, input.userId),
eq(activityLog.entityType, "issue"),
gte(activityLog.createdAt, from),
lte(activityLog.createdAt, to),
),
),
]);
for (const row of [...commentRows, ...approvalRows, ...interactionRows, ...activityRows]) {
selected.add(row.issueId);
}
let changed = true;
while (changed) {
changed = false;
for (const issue of rows) {
if (issue.parentId && selected.has(issue.parentId) && !selected.has(issue.id)) {
selected.add(issue.id);
changed = true;
}
}
}
return rows.filter((issue) => selected.has(issue.id) || byId.get(issue.parentId ?? "") && selected.has(issue.parentId ?? ""));
}
async function loadActorMaps(companyId: string, actorIds: Set<string>) {
const agentIds = Array.from(actorIds)
.filter((id) => id.startsWith("agent:"))
.map((id) => id.slice("agent:".length));
const userIds = Array.from(actorIds)
.filter((id) => id.startsWith("user:"))
.map((id) => id.slice("user:".length));
const [agentRows, userRows] = await Promise.all([
agentIds.length > 0
? db
.select({ id: agents.id, name: agents.name, icon: agents.icon })
.from(agents)
.where(and(eq(agents.companyId, companyId), inArray(agents.id, maybeUuidList(agentIds))))
: [],
userIds.length > 0
? db
.select({ id: authUsers.id, name: authUsers.name, image: authUsers.image })
.from(authUsers)
.where(inArray(authUsers.id, maybeUuidList(userIds)))
: [],
]);
return {
agents: new Map(agentRows.map((agent) => [agent.id, agent])),
users: new Map(userRows.map((user) => [user.id, user])),
};
}
function actorForIssueCreator(issue: IssueRow) {
if (issue.createdByAgentId) return actorId("agent", issue.createdByAgentId);
if (issue.createdByUserId) return actorId("user", issue.createdByUserId);
return actorId("system", "system");
}
function actorForIssueAssignee(issue: IssueRow) {
if (issue.assigneeAgentId) return actorId("agent", issue.assigneeAgentId);
if (issue.assigneeUserId) return actorId("user", issue.assigneeUserId);
return null;
}
async function getTimeline(input: WorkTimelineQuery): Promise<WorkTimelineResult> {
const { from, to, capped } = normalizeTimelineWindow(input);
const limit = normalizeLimit(input.limit);
const offset = normalizeOffset(input.offset);
const candidateIssueIds = await collectIssueIds(input, from, to);
const loadedIssues = await loadIssues(input, candidateIssueIds);
const userScopedIssues = await applyUserLens(input, loadedIssues, from, to);
const accessibleIssues = input.canReadIssue
? (await Promise.all(userScopedIssues.map(async (issue) => ({
issue,
allowed: await input.canReadIssue?.({
id: issue.id,
companyId: issue.companyId,
projectId: issue.projectId,
parentId: issue.parentId,
assigneeAgentId: issue.assigneeAgentId,
assigneeUserId: issue.assigneeUserId,
status: issue.status,
}),
})))).filter((entry) => entry.allowed).map((entry) => entry.issue)
: userScopedIssues;
const sortedIssues = accessibleIssues.sort((left, right) => right.createdAt.getTime() - left.createdAt.getTime());
const pagedIssues = sortedIssues.slice(offset, offset + limit);
const issueById = new Map(pagedIssues.map((issue) => [issue.id, issue]));
const readableIssueIds = Array.from(issueById.keys());
if (readableIssueIds.length === 0) {
return {
actors: [],
spans: [],
events: [],
edges: [],
pagination: { limit, offset, totalIssues: sortedIssues.length, hasMore: offset + limit < sortedIssues.length },
window: { from: from.toISOString(), to: to.toISOString(), capped },
};
}
const actorIds = new Set<string>();
const events: WorkTimelineEvent[] = [];
const edges: WorkTimelineEdge[] = [];
for (const issue of pagedIssues) {
const creatorActorId = actorForIssueCreator(issue);
actorIds.add(creatorActorId);
events.push({
actorId: creatorActorId,
kind: "created",
issueId: issue.id,
at: issue.createdAt.toISOString(),
});
const assigneeActorId = actorForIssueAssignee(issue);
if (assigneeActorId) {
actorIds.add(assigneeActorId);
edges.push({
fromActorId: creatorActorId,
toActorId: assigneeActorId,
issueId: issue.id,
at: issue.createdAt.toISOString(),
kind: "assignment",
});
}
const parent = issue.parentId ? issueById.get(issue.parentId) : null;
const parentActorId = parent ? actorForIssueAssignee(parent) ?? actorForIssueCreator(parent) : null;
if (parentActorId && assigneeActorId && parentActorId !== assigneeActorId) {
actorIds.add(parentActorId);
edges.push({
fromActorId: parentActorId,
toActorId: assigneeActorId,
issueId: issue.id,
at: issue.createdAt.toISOString(),
kind: "delegation",
});
events.push({
actorId: parentActorId,
kind: "delegated",
issueId: issue.id,
at: issue.createdAt.toISOString(),
});
}
}
const [contextRunRows, activityRunRows, commentRows, approvalRows, interactionRows, logRows] = await Promise.all([
db
.select({
runId: heartbeatRuns.id,
agentId: heartbeatRuns.agentId,
issueId: sql<string | null>`${heartbeatRuns.contextSnapshot} ->> 'issueId'`,
status: heartbeatRuns.status,
startedAt: heartbeatRuns.startedAt,
finishedAt: heartbeatRuns.finishedAt,
createdAt: heartbeatRuns.createdAt,
retryOfRunId: heartbeatRuns.retryOfRunId,
continuationAttempt: heartbeatRuns.continuationAttempt,
invocationSource: heartbeatRuns.invocationSource,
})
.from(heartbeatRuns)
.where(
and(
eq(heartbeatRuns.companyId, input.companyId),
runOverlapsWindow(from, to),
inArray(sql<string>`${heartbeatRuns.contextSnapshot} ->> 'issueId'`, readableIssueIds),
),
),
db
.select({
runId: heartbeatRuns.id,
agentId: heartbeatRuns.agentId,
issueId: activityLog.entityId,
status: heartbeatRuns.status,
startedAt: heartbeatRuns.startedAt,
finishedAt: heartbeatRuns.finishedAt,
createdAt: heartbeatRuns.createdAt,
retryOfRunId: heartbeatRuns.retryOfRunId,
continuationAttempt: heartbeatRuns.continuationAttempt,
invocationSource: heartbeatRuns.invocationSource,
})
.from(activityLog)
.innerJoin(heartbeatRuns, eq(activityLog.runId, heartbeatRuns.id))
.where(
and(
eq(activityLog.companyId, input.companyId),
eq(activityLog.entityType, "issue"),
inArray(activityLog.entityId, readableIssueIds),
runOverlapsWindow(from, to),
),
),
db
.select({
issueId: issueComments.issueId,
authorAgentId: issueComments.authorAgentId,
authorUserId: issueComments.authorUserId,
createdAt: issueComments.createdAt,
})
.from(issueComments)
.where(
and(
eq(issueComments.companyId, input.companyId),
isNull(issueComments.deletedAt),
inArray(issueComments.issueId, readableIssueIds),
gte(issueComments.createdAt, from),
lte(issueComments.createdAt, to),
),
),
db
.select({
issueId: issueApprovals.issueId,
decidedByUserId: approvals.decidedByUserId,
decidedAt: approvals.decidedAt,
requestedByAgentId: approvals.requestedByAgentId,
requestedByUserId: approvals.requestedByUserId,
createdAt: approvals.createdAt,
})
.from(issueApprovals)
.innerJoin(approvals, eq(issueApprovals.approvalId, approvals.id))
.where(
and(
eq(issueApprovals.companyId, input.companyId),
inArray(issueApprovals.issueId, readableIssueIds),
or(
and(gte(approvals.createdAt, from), lte(approvals.createdAt, to)),
and(gte(approvals.decidedAt, from), lte(approvals.decidedAt, to)),
),
),
),
db
.select({
issueId: issueThreadInteractions.issueId,
resolvedByAgentId: issueThreadInteractions.resolvedByAgentId,
resolvedByUserId: issueThreadInteractions.resolvedByUserId,
resolvedAt: issueThreadInteractions.resolvedAt,
createdByAgentId: issueThreadInteractions.createdByAgentId,
createdByUserId: issueThreadInteractions.createdByUserId,
createdAt: issueThreadInteractions.createdAt,
})
.from(issueThreadInteractions)
.where(
and(
eq(issueThreadInteractions.companyId, input.companyId),
inArray(issueThreadInteractions.issueId, readableIssueIds),
or(
and(gte(issueThreadInteractions.createdAt, from), lte(issueThreadInteractions.createdAt, to)),
and(gte(issueThreadInteractions.resolvedAt, from), lte(issueThreadInteractions.resolvedAt, to)),
),
),
),
db
.select({
issueId: activityLog.entityId,
actorType: activityLog.actorType,
actorId: activityLog.actorId,
action: activityLog.action,
details: activityLog.details,
createdAt: activityLog.createdAt,
})
.from(activityLog)
.where(
and(
eq(activityLog.companyId, input.companyId),
eq(activityLog.entityType, "issue"),
inArray(activityLog.entityId, readableIssueIds),
gte(activityLog.createdAt, from),
lte(activityLog.createdAt, to),
),
),
]);
const spanByRunId = new Map<string, WorkTimelineSpan>();
for (const row of [...contextRunRows, ...activityRunRows]) {
if (!row.issueId || !issueById.has(row.issueId) || spanByRunId.has(row.runId)) continue;
const runActorId = actorId("agent", row.agentId);
actorIds.add(runActorId);
spanByRunId.set(row.runId, {
actorId: runActorId,
laneHint: row.invocationSource ?? null,
runId: row.runId,
issueId: row.issueId,
issueIdentifier: issueById.get(row.issueId)?.identifier ?? null,
issueTitle: issueById.get(row.issueId)?.title ?? null,
start: (row.startedAt ?? row.createdAt).toISOString(),
end: dateIso(row.finishedAt),
status: row.status,
retryOfRunId: row.retryOfRunId ?? null,
continuationAttempt: row.continuationAttempt,
invocationSource: row.invocationSource ?? null,
});
}
for (const row of commentRows) {
const commentActorId = row.authorAgentId
? actorId("agent", row.authorAgentId)
: row.authorUserId
? actorId("user", row.authorUserId)
: actorId("system", "system");
actorIds.add(commentActorId);
events.push({ actorId: commentActorId, kind: "commented", issueId: row.issueId, at: row.createdAt.toISOString() });
}
for (const row of approvalRows) {
const approvalActorId = row.decidedByUserId
? actorId("user", row.decidedByUserId)
: row.requestedByAgentId
? actorId("agent", row.requestedByAgentId)
: row.requestedByUserId
? actorId("user", row.requestedByUserId)
: actorId("system", "system");
actorIds.add(approvalActorId);
events.push({
actorId: approvalActorId,
kind: "approved",
issueId: row.issueId,
at: (row.decidedAt ?? row.createdAt).toISOString(),
});
}
for (const row of interactionRows) {
const interactionActorId = row.resolvedByUserId
? actorId("user", row.resolvedByUserId)
: row.resolvedByAgentId
? actorId("agent", row.resolvedByAgentId)
: row.createdByAgentId
? actorId("agent", row.createdByAgentId)
: row.createdByUserId
? actorId("user", row.createdByUserId)
: actorId("system", "system");
actorIds.add(interactionActorId);
events.push({
actorId: interactionActorId,
kind: "approved",
issueId: row.issueId,
at: (row.resolvedAt ?? row.createdAt).toISOString(),
});
}
for (const row of logRows) {
const logActorType = row.actorType === "agent" || row.actorType === "user" || row.actorType === "plugin"
? row.actorType
: "system";
const fromActorId = actorId(logActorType, row.actorId);
actorIds.add(fromActorId);
if (row.action.includes("assign")) {
events.push({ actorId: fromActorId, kind: "assigned", issueId: row.issueId, at: row.createdAt.toISOString() });
const details = row.details && typeof row.details === "object" && !Array.isArray(row.details)
? row.details as Record<string, unknown>
: {};
const targetAgentId = readString(details.assigneeAgentId) ?? readString(details.toAgentId);
const targetUserId = readString(details.assigneeUserId) ?? readString(details.toUserId);
const toActorId = targetAgentId
? actorId("agent", targetAgentId)
: targetUserId
? actorId("user", targetUserId)
: null;
if (toActorId) {
actorIds.add(toActorId);
edges.push({
fromActorId,
toActorId,
issueId: row.issueId,
at: row.createdAt.toISOString(),
kind: "assignment",
});
}
}
}
const actorMaps = await loadActorMaps(input.companyId, actorIds);
const actors: WorkTimelineActor[] = Array.from(actorIds).map((id) => {
const [type, rawId] = id.split(":", 2) as [TimelineActorType, string];
if (type === "agent") {
const agent = actorMaps.agents.get(rawId);
return { id, type, name: agent?.name ?? "Unknown agent", avatar: agent?.icon ?? null };
}
if (type === "user") {
const user = actorMaps.users.get(rawId);
return { id, type, name: user?.name ?? rawId, avatar: user?.image ?? null };
}
return { id, type, name: type === "plugin" ? rawId : "System", avatar: null };
});
return {
actors,
spans: Array.from(spanByRunId.values()).sort((left, right) => left.start.localeCompare(right.start)),
events: events.sort((left, right) => left.at.localeCompare(right.at)),
edges: edges.sort((left, right) => left.at.localeCompare(right.at)),
pagination: {
limit,
offset,
totalIssues: sortedIssues.length,
hasMore: offset + limit < sortedIssues.length,
},
window: { from: from.toISOString(), to: to.toISOString(), capped },
};
}
return { getTimeline };
}
+438 -30
View File
@@ -70,6 +70,17 @@ export interface RealizedExecutionWorkspace extends ExecutionWorkspaceInput {
baseRefSha?: string | null;
}
export class WorkspaceRuntimeValidationFailure extends Error {
code = "workspace_validation_failed" as const;
resultJson: Record<string, unknown>;
constructor(message: string, resultJson: Record<string, unknown>) {
super(message);
this.name = "WorkspaceRuntimeValidationFailure";
this.resultJson = resultJson;
}
}
export interface RuntimeServiceRef {
id: string;
companyId: string;
@@ -640,6 +651,238 @@ async function remoteExists(repoRoot: string, remote: string): Promise<boolean>
.catch(() => false);
}
const GIT_WORKTREE_BRANCH_INCOHERENCE_REASON = "git_worktree_branch_incoherence";
type GitWorktreeCleanliness = "clean" | "dirty" | "unknown";
type GitWorktreeBranchIncoherenceEvidence = {
reason: typeof GIT_WORKTREE_BRANCH_INCOHERENCE_REASON;
fingerprint: string;
sourceIssueId: string | null;
sourceIdentifier: string | null;
executionWorkspaceId: string | null;
worktreePath: string;
repoRoot: string;
expectedBranch: string;
actualBranch: string | null;
cleanliness: GitWorktreeCleanliness;
statusEntryCount: number | null;
provenance: {
expectedBranchRef: string;
actualBranchRef: string | null;
registeredBranchRef: string | null;
registeredPathFound: boolean;
registeredBranchMatchesHead: boolean;
expectedBranchExists: boolean;
actualBranchExists: boolean | null;
expectedHeadSha: string | null;
actualHeadSha: string | null;
sameHead: boolean;
};
safeRepair: {
eligible: boolean;
attempted: boolean;
succeeded: boolean;
reason: string;
};
};
function formatBranchForMessage(branch: string | null | undefined) {
return branch && branch.length > 0 ? branch : "<detached>";
}
function fingerprintWorkspaceBranchIncoherence(input: {
sourceIssueId: string | null;
executionWorkspaceId: string | null;
worktreePath: string;
expectedBranch: string;
actualBranch: string | null;
cleanliness: GitWorktreeCleanliness;
expectedHeadSha: string | null;
actualHeadSha: string | null;
}) {
const digest = createHash("sha256")
.update(stableStringify({
version: 1,
reason: GIT_WORKTREE_BRANCH_INCOHERENCE_REASON,
sourceIssueId: input.sourceIssueId,
executionWorkspaceId: input.executionWorkspaceId,
worktreePath: path.resolve(input.worktreePath),
expectedBranch: input.expectedBranch,
actualBranch: input.actualBranch,
cleanliness: input.cleanliness,
expectedHeadSha: input.expectedHeadSha,
actualHeadSha: input.actualHeadSha,
}))
.digest("hex");
return `workspace_incoherence:v1:sha256:${digest}`;
}
async function inspectGitWorktreeBranchIncoherence(input: {
repoRoot: string;
worktreePath: string;
expectedBranchName: string;
actualBranchName: string | null;
sourceIssue: ExecutionWorkspaceIssueRef | null;
executionWorkspaceId?: string | null;
}): Promise<GitWorktreeBranchIncoherenceEvidence> {
const status = await runGit(
["status", "--porcelain", "--untracked-files=all"],
input.worktreePath,
).catch(() => null);
const statusLines = status === null
? null
: status.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
const cleanliness: GitWorktreeCleanliness =
status === null ? "unknown" : status.trim().length > 0 ? "dirty" : "clean";
const expectedHeadSha = await runGit(
["rev-parse", "--verify", `refs/heads/${input.expectedBranchName}^{commit}`],
input.repoRoot,
).catch(() => null);
const actualHeadSha = await runGit(["rev-parse", "HEAD"], input.worktreePath).catch(() => null);
const actualBranchExists = input.actualBranchName
? await localBranchExists(input.repoRoot, input.actualBranchName)
: null;
const registered = await findRegisteredGitWorktreeByPath(input.repoRoot, input.worktreePath);
const actualBranchRef = input.actualBranchName ? `refs/heads/${input.actualBranchName}` : null;
const registeredBranchRef = registered?.branch ?? null;
const registeredBranchMatchesHead = Boolean(registered && registeredBranchRef === actualBranchRef);
const sameHead = Boolean(expectedHeadSha && actualHeadSha && expectedHeadSha === actualHeadSha);
const expectedBranchExists = Boolean(expectedHeadSha);
const eligible = cleanliness === "clean" && expectedBranchExists && sameHead && registeredBranchMatchesHead;
const safeRepairReason = eligible
? "clean worktree and expected branch points at the current HEAD"
: cleanliness !== "clean"
? "worktree is not clean"
: !registered
? "worktree path is not registered"
: !registeredBranchMatchesHead
? "registered worktree branch does not match HEAD"
: !expectedBranchExists
? "expected branch does not exist"
: !sameHead
? "expected branch and current HEAD differ"
: "safe repair could not be proven";
const fingerprint = fingerprintWorkspaceBranchIncoherence({
sourceIssueId: input.sourceIssue?.id ?? null,
executionWorkspaceId: input.executionWorkspaceId ?? null,
worktreePath: input.worktreePath,
expectedBranch: input.expectedBranchName,
actualBranch: input.actualBranchName,
cleanliness,
expectedHeadSha,
actualHeadSha,
});
return {
reason: GIT_WORKTREE_BRANCH_INCOHERENCE_REASON,
fingerprint,
sourceIssueId: input.sourceIssue?.id ?? null,
sourceIdentifier: input.sourceIssue?.identifier ?? null,
executionWorkspaceId: input.executionWorkspaceId ?? null,
worktreePath: path.resolve(input.worktreePath),
repoRoot: path.resolve(input.repoRoot),
expectedBranch: input.expectedBranchName,
actualBranch: input.actualBranchName,
cleanliness,
statusEntryCount: statusLines?.length ?? null,
provenance: {
expectedBranchRef: `refs/heads/${input.expectedBranchName}`,
actualBranchRef,
registeredBranchRef,
registeredPathFound: Boolean(registered),
registeredBranchMatchesHead,
expectedBranchExists,
actualBranchExists,
expectedHeadSha,
actualHeadSha,
sameHead,
},
safeRepair: {
eligible,
attempted: false,
succeeded: false,
reason: safeRepairReason,
},
};
}
function branchIncoherenceValidationFailure(evidence: GitWorktreeBranchIncoherenceEvidence) {
return new WorkspaceRuntimeValidationFailure(
`Execution workspace git worktree expected branch "${evidence.expectedBranch}" but found "${formatBranchForMessage(evidence.actualBranch)}" at "${evidence.worktreePath}". Safe repair ${evidence.safeRepair.succeeded ? "succeeded" : "was not completed"}: ${evidence.safeRepair.reason}.`,
{
workspaceValidation: evidence,
},
);
}
async function ensureGitWorktreeBranchCoherent(input: {
repoRoot: string;
worktreePath: string;
expectedBranchName: string | null;
sourceIssue: ExecutionWorkspaceIssueRef | null;
executionWorkspaceId?: string | null;
actualBranchName?: string | null;
recorder?: WorkspaceOperationRecorder | null;
}) {
const expectedBranchName = input.expectedBranchName?.trim();
if (!expectedBranchName) return;
const currentBranch = input.actualBranchName !== undefined
? input.actualBranchName
: await runGit(["symbolic-ref", "--quiet", "--short", "HEAD"], input.worktreePath).catch(() => null);
if (currentBranch === expectedBranchName) return;
const evidence = await inspectGitWorktreeBranchIncoherence({
repoRoot: input.repoRoot,
worktreePath: input.worktreePath,
expectedBranchName,
actualBranchName: currentBranch,
sourceIssue: input.sourceIssue,
executionWorkspaceId: input.executionWorkspaceId ?? null,
});
if (!evidence.safeRepair.eligible) {
throw branchIncoherenceValidationFailure(evidence);
}
evidence.safeRepair.attempted = true;
try {
await recordGitOperation(input.recorder, {
phase: "worktree_prepare",
args: ["checkout", expectedBranchName],
cwd: input.worktreePath,
metadata: {
repoRoot: input.repoRoot,
worktreePath: input.worktreePath,
expectedBranchName,
actualBranchName: currentBranch,
branchIncoherenceRepair: true,
fingerprint: evidence.fingerprint,
sourceIssueId: evidence.sourceIssueId,
executionWorkspaceId: evidence.executionWorkspaceId,
},
successMessage: `Repaired clean git worktree branch mismatch at ${input.worktreePath}: checked out ${expectedBranchName}\n`,
failureLabel: `git checkout ${expectedBranchName}`,
});
} catch (error) {
evidence.safeRepair.succeeded = false;
evidence.safeRepair.reason = `safe checkout failed: ${error instanceof Error ? error.message : String(error)}`;
throw branchIncoherenceValidationFailure(evidence);
}
const repairedBranch = await runGit(["symbolic-ref", "--quiet", "--short", "HEAD"], input.worktreePath)
.catch(() => null);
if (repairedBranch !== expectedBranchName) {
evidence.safeRepair.succeeded = false;
evidence.safeRepair.reason = `checkout completed but HEAD is ${formatBranchForMessage(repairedBranch)}`;
throw branchIncoherenceValidationFailure(evidence);
}
evidence.safeRepair.succeeded = true;
evidence.safeRepair.reason = "clean worktree checked out the recorded branch";
}
// Resolve the authoritative base ref for a fresh worktree. A configured local
// branch is mapped to its `origin/<branch>` counterpart so unpushed local
// divergence never leaks into the task branch; remote-tracking refs, SHAs, and
@@ -751,6 +994,22 @@ type GitWorktreeListEntry = {
branch: string | null;
};
export type ManagedGitWorktreeBranchInspection = {
valid: boolean;
reason: string | null;
reasonCode:
| "missing_worktree"
| "not_a_git_checkout"
| "not_registered"
| "wrong_repository_root"
| "branch_mismatch"
| null;
repoRoot: string | null;
worktreePath: string;
expectedBranchName: string | null;
actualBranchName: string | null;
};
function parseGitWorktreeListPorcelain(raw: string): GitWorktreeListEntry[] {
const entries: GitWorktreeListEntry[] = [];
let current: Partial<GitWorktreeListEntry> = {};
@@ -803,6 +1062,19 @@ async function findRegisteredGitWorktreeByBranch(repoRoot: string, branchName: s
return null;
}
async function findRegisteredGitWorktreeByPath(repoRoot: string, worktreePath: string): Promise<GitWorktreeListEntry | null> {
const raw = await runGit(["worktree", "list", "--porcelain"], repoRoot).catch(() => null);
if (!raw) return null;
const expectedPath = await resolvePathForWorktreeComparison(worktreePath);
for (const entry of parseGitWorktreeListPorcelain(raw)) {
if (await resolvePathForWorktreeComparison(entry.worktree) === expectedPath) {
return entry;
}
}
return null;
}
async function isGitCheckout(cwd: string): Promise<boolean> {
return Boolean(await runGit(["rev-parse", "--git-dir"], cwd).catch(() => null));
}
@@ -846,6 +1118,11 @@ async function directoryExists(value: string) {
return fs.stat(value).then((stats) => stats.isDirectory()).catch(() => false);
}
async function resolvePathForWorktreeComparison(value: string): Promise<string> {
const resolved = path.resolve(value);
return fs.realpath(resolved).then((realPath) => path.resolve(realPath)).catch(() => resolved);
}
async function listLinkedGitWorktreePaths(repoRoot: string): Promise<Set<string>> {
const output = await runGit(["worktree", "list", "--porcelain"], repoRoot);
const paths = new Set<string>();
@@ -853,47 +1130,132 @@ async function listLinkedGitWorktreePaths(repoRoot: string): Promise<Set<string>
if (!line.startsWith("worktree ")) continue;
const worktree = line.slice("worktree ".length).trim();
if (!worktree) continue;
paths.add(path.resolve(worktree));
paths.add(await resolvePathForWorktreeComparison(worktree));
}
return paths;
}
export async function inspectManagedGitWorktreeBranch(input: {
worktreePath: string;
expectedBranchName: string | null | undefined;
repoRoot?: string | null;
}): Promise<ManagedGitWorktreeBranchInspection> {
const worktreePath = await resolvePathForWorktreeComparison(input.worktreePath);
const expectedBranchName = asString(input.expectedBranchName, "").trim() || null;
const base = {
worktreePath,
expectedBranchName,
actualBranchName: null,
};
if (!await directoryExists(worktreePath)) {
return {
...base,
valid: false,
reason: `worktree path "${worktreePath}" does not exist`,
reasonCode: "missing_worktree",
repoRoot: input.repoRoot ? path.resolve(input.repoRoot) : null,
};
}
const repoRoot = input.repoRoot
? path.resolve(input.repoRoot)
: await resolveGitOwnerRepoRoot(worktreePath).catch(() => null);
if (!repoRoot) {
return {
...base,
valid: false,
reason: "path is not a git checkout",
reasonCode: "not_a_git_checkout",
repoRoot: null,
};
}
const listedWorktrees = await listLinkedGitWorktreePaths(repoRoot).catch(() => null);
if (!listedWorktrees?.has(worktreePath)) {
return {
...base,
valid: false,
reason: "path is not registered in `git worktree list`",
reasonCode: "not_registered",
repoRoot,
};
}
const worktreeTopLevel = await runGit(["rev-parse", "--show-toplevel"], worktreePath).catch(() => null);
if (!worktreeTopLevel || path.resolve(worktreeTopLevel) !== worktreePath) {
return {
...base,
valid: false,
reason: "git resolves this path to a different repository root",
reasonCode: "wrong_repository_root",
repoRoot,
};
}
const actualBranchName = await runGit(
["symbolic-ref", "--quiet", "--short", "HEAD"],
worktreePath,
).catch(() => null);
if (expectedBranchName && actualBranchName !== expectedBranchName) {
return {
...base,
valid: false,
reason: `worktree HEAD is on "${actualBranchName ?? "<detached>"}" instead of "${expectedBranchName}"`,
reasonCode: "branch_mismatch",
repoRoot,
actualBranchName,
};
}
return {
...base,
valid: true,
reason: null,
reasonCode: null,
repoRoot,
actualBranchName,
};
}
async function validateLinkedGitWorktree(input: {
repoRoot: string;
worktreePath: string;
expectedBranchName: string | null;
}): Promise<{ valid: true } | { valid: false; reason: string }> {
const resolvedWorktreePath = path.resolve(input.worktreePath);
const listedWorktrees = await listLinkedGitWorktreePaths(input.repoRoot);
if (!listedWorktrees.has(resolvedWorktreePath)) {
return {
valid: false,
reason: "path is not registered in `git worktree list`",
};
}): Promise<
| { valid: true }
| {
valid: false;
reason: string;
reasonCode: Exclude<ManagedGitWorktreeBranchInspection["reasonCode"], null>;
actualBranchName?: string | null;
}
const worktreeTopLevel = await runGit(["rev-parse", "--show-toplevel"], resolvedWorktreePath).catch(() => null);
if (!worktreeTopLevel || path.resolve(worktreeTopLevel) !== resolvedWorktreePath) {
return {
valid: false,
reason: "git resolves this path to a different repository root",
};
}
if (input.expectedBranchName) {
const currentBranch = await runGit(
["symbolic-ref", "--quiet", "--short", "HEAD"],
resolvedWorktreePath,
).catch(() => null);
if (currentBranch !== input.expectedBranchName) {
return {
> {
const inspection = await inspectManagedGitWorktreeBranch({
repoRoot: input.repoRoot,
worktreePath: input.worktreePath,
expectedBranchName: input.expectedBranchName,
});
return inspection.valid
? { valid: true }
: {
valid: false,
reason: `worktree HEAD is on "${currentBranch ?? "<detached>"}" instead of "${input.expectedBranchName}"`,
reason: inspection.reason ?? "unknown git worktree mismatch",
reasonCode: inspection.reasonCode ?? "not_a_git_checkout",
actualBranchName: inspection.actualBranchName,
};
}
}
}
return { valid: true };
export function formatManagedGitWorktreeBranchInspection(input: ManagedGitWorktreeBranchInspection) {
return {
valid: input.valid,
reason: input.reason,
reasonCode: input.reasonCode,
repoRoot: input.repoRoot,
worktreePath: input.worktreePath,
expectedBranchName: input.expectedBranchName,
actualBranchName: input.actualBranchName,
};
}
function terminateChildProcess(child: ChildProcess) {
@@ -1328,11 +1690,28 @@ export async function realizeExecutionWorkspace(input: {
}
async function validateReusableWorktree(reusablePath: string) {
return await validateLinkedGitWorktree({
const validation = await validateLinkedGitWorktree({
repoRoot,
worktreePath: reusablePath,
expectedBranchName: branchName,
}).catch(() => null);
if (validation && !validation.valid && validation.reasonCode === "branch_mismatch") {
await ensureGitWorktreeBranchCoherent({
repoRoot,
worktreePath: reusablePath,
expectedBranchName: branchName,
actualBranchName: validation.actualBranchName ?? null,
sourceIssue: input.issue,
executionWorkspaceId: null,
recorder: input.recorder ?? null,
});
return await validateLinkedGitWorktree({
repoRoot,
worktreePath: reusablePath,
expectedBranchName: branchName,
}).catch(() => null);
}
return validation;
}
const existingWorktree = await directoryExists(worktreePath);
@@ -1431,6 +1810,7 @@ export async function realizeExecutionWorkspace(input: {
export async function ensurePersistedExecutionWorkspaceAvailable(input: {
base: ExecutionWorkspaceInput;
workspace: {
id?: string | null;
mode: string | null | undefined;
strategyType: string | null | undefined;
cwd: string | null | undefined;
@@ -1481,6 +1861,34 @@ export async function ensurePersistedExecutionWorkspaceAvailable(input: {
if (await directoryExists(cwd)) {
const reuseBaseRef = input.workspace.baseRef ?? input.base.repoRef ?? null;
const reuseWorktreePath = realized.worktreePath ?? cwd;
if (await isGitCheckout(reuseWorktreePath)) {
await ensureGitWorktreeBranchCoherent({
repoRoot,
worktreePath: reuseWorktreePath,
expectedBranchName: realized.branchName,
sourceIssue: input.issue,
executionWorkspaceId: input.workspace.id ?? null,
recorder: input.recorder ?? null,
});
}
const validation = await validateLinkedGitWorktree({
repoRoot,
worktreePath: reuseWorktreePath,
expectedBranchName: realized.branchName,
});
if (!validation.valid) {
throw new WorkspaceRuntimeValidationFailure(
`Persisted git worktree "${reuseWorktreePath}" is not reusable (${validation.reason}).`,
{
workspaceValidation: {
reason: "git_worktree_not_reusable",
reasonCode: validation.reasonCode,
worktreePath: reuseWorktreePath,
executionWorkspaceId: input.workspace.id ?? null,
},
},
);
}
const baseRefreshWarnings = reuseBaseRef
? await refreshRemoteTrackingBaseRef(repoRoot, reuseBaseRef)
: [];
+1 -1
View File
@@ -19,7 +19,7 @@ In Paperclip, **task** and **issue** refer to the same work item. The UI may use
## Authentication
Env vars auto-injected: `PAPERCLIP_AGENT_ID`, `PAPERCLIP_COMPANY_ID`, `PAPERCLIP_API_URL`, `PAPERCLIP_RUN_ID`. Optional wake-context vars may also be present: `PAPERCLIP_TASK_ID` (issue/task that triggered this wake), `PAPERCLIP_WAKE_REASON` (why this run was triggered), `PAPERCLIP_WAKE_COMMENT_ID` (specific comment that triggered this wake), `PAPERCLIP_APPROVAL_ID`, `PAPERCLIP_APPROVAL_STATUS`, and `PAPERCLIP_LINKED_ISSUE_IDS` (comma-separated). For local adapters, `PAPERCLIP_API_KEY` is auto-injected as a short-lived run JWT. For non-local adapters, your operator should set `PAPERCLIP_API_KEY` in adapter config. All requests use `Authorization: Bearer $PAPERCLIP_API_KEY`. All endpoints under `/api`, all JSON. Never hard-code the API URL.
Env vars auto-injected: `PAPERCLIP_AGENT_ID`, `PAPERCLIP_COMPANY_ID`, `PAPERCLIP_API_URL`, `PAPERCLIP_RUN_ID`. Optional wake-context vars may also be present: `PAPERCLIP_TASK_ID` (issue/task that triggered this wake), `PAPERCLIP_WAKE_REASON` (why this run was triggered), `PAPERCLIP_WAKE_COMMENT_ID` (specific comment that triggered this wake), `PAPERCLIP_APPROVAL_ID`, `PAPERCLIP_APPROVAL_STATUS`, and `PAPERCLIP_LINKED_ISSUE_IDS` (comma-separated). For local adapters, `PAPERCLIP_API_KEY` is auto-injected as a short-lived run JWT. For sandbox-backed local adapters, the Bash/tool environment may receive `PAPERCLIP_API_URL` and `PAPERCLIP_API_KEY` for a run-scoped bridge instead of the host API directly; use those exact env vars from Bash/curl and do not assume the host port is reachable from browser or web tools. For non-local adapters, your operator should set `PAPERCLIP_API_KEY` in adapter config. All requests use `Authorization: Bearer $PAPERCLIP_API_KEY`. All endpoints under `/api`, all JSON. Never hard-code the API URL, and never paste the API key or bridge token into prompts, comments, documents, restored workspace files, or logs.
Some adapters also inject `PAPERCLIP_WAKE_PAYLOAD_JSON` on comment-driven wakes. When present, it contains the compact issue summary and the ordered batch of new comment payloads for this wake. Use it first. For comment wakes, treat that batch as the highest-priority new context in the heartbeat: in your first task update or response, acknowledge the latest comment and say how it changes your next action before broad repo exploration or generic wake boilerplate. Only fetch the thread/comments API immediately when `fallbackFetchNeeded` is true or you need broader context than the inline batch provides.
+6 -10
View File
@@ -69,17 +69,14 @@ export const environmentsApi = {
config?: Record<string, unknown>;
metadata?: Record<string, unknown> | null;
}) => api.post<EnvironmentProbeResult>(`/companies/${companyId}/environments/probe-config`, body),
customImageTemplate: (environmentId: string, companyId: string) =>
api.get<EnvironmentCustomImageOverview>(
`/environments/${environmentId}/custom-image-template?companyId=${encodeURIComponent(companyId)}`,
),
customImageTemplate: (environmentId: string) =>
api.get<EnvironmentCustomImageOverview>(`/environments/${environmentId}/custom-image-template`),
startCustomImageSetupSession: (
environmentId: string,
companyId: string,
body: StartEnvironmentCustomImageSetupSession = {},
) =>
api.post<EnvironmentCustomImageSetupSessionResult>(
`/environments/${environmentId}/custom-image-setup-sessions?companyId=${encodeURIComponent(companyId)}`,
`/environments/${environmentId}/custom-image-setup-sessions`,
body,
),
customImageSetupSession: (sessionId: string) =>
@@ -110,17 +107,16 @@ export const environmentsApi = {
`/environment-custom-image-setup-sessions/${sessionId}/cancel`,
body,
),
rollbackCustomImageTemplate: (environmentId: string, companyId: string) =>
rollbackCustomImageTemplate: (environmentId: string) =>
api.post<EnvironmentCustomImageRollbackResult>(
`/environments/${environmentId}/custom-image-template/rollback?companyId=${encodeURIComponent(companyId)}`,
`/environments/${environmentId}/custom-image-template/rollback`,
{},
),
disableCustomImageTemplate: (
environmentId: string,
companyId: string,
options: { deleteProviderTemplate?: boolean } = {},
) =>
api.delete<EnvironmentCustomImageTemplate>(
`/environments/${environmentId}/custom-image-template?companyId=${encodeURIComponent(companyId)}&deleteProviderTemplate=${options.deleteProviderTemplate === true ? "true" : "false"}`,
`/environments/${environmentId}/custom-image-template?deleteProviderTemplate=${options.deleteProviderTemplate === true ? "true" : "false"}`,
),
};
+8
View File
@@ -71,6 +71,14 @@ describe("issuesApi.list", () => {
);
});
it("passes live descendant summary opt-in through to the company issues endpoint", async () => {
await issuesApi.list("company-1", { includeLiveDescendantSummary: true, limit: 25 });
expect(mockApi.get).toHaveBeenCalledWith(
"/companies/company-1/issues?includeLiveDescendantSummary=true&limit=25",
);
});
it("posts recovery action resolution to the source issue endpoint", async () => {
await issuesApi.resolveRecoveryAction("issue-1", {
actionId: "00000000-0000-0000-0000-0000000000aa",
+2
View File
@@ -60,6 +60,7 @@ export const issuesApi = {
includeRoutineExecutions?: boolean;
includeBlockedBy?: boolean;
includeBlockedInboxAttention?: boolean;
includeLiveDescendantSummary?: boolean;
hasPlanDocument?: boolean;
q?: string;
limit?: number;
@@ -89,6 +90,7 @@ export const issuesApi = {
if (filters?.includeRoutineExecutions) params.set("includeRoutineExecutions", "true");
if (filters?.includeBlockedBy) params.set("includeBlockedBy", "true");
if (filters?.includeBlockedInboxAttention) params.set("includeBlockedInboxAttention", "true");
if (filters?.includeLiveDescendantSummary) params.set("includeLiveDescendantSummary", "true");
if (filters?.hasPlanDocument !== undefined) {
params.set("hasPlanDocument", filters.hasPlanDocument ? "true" : "false");
}
+39 -1
View File
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import { act } from "react";
import { flushSync } from "react-dom";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Issue, IssueBlockedInboxAttention } from "@paperclipai/shared";
@@ -31,6 +31,14 @@ vi.mock("@/lib/router", () => ({
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
function act(callback: () => void | Promise<void>) {
let result: void | Promise<void> | undefined;
flushSync(() => {
result = callback();
});
return result;
}
import { BlockedInboxView } from "./BlockedInboxView";
import { defaultIssueFilterState } from "../lib/issue-filters";
@@ -121,6 +129,7 @@ const blockedViewProps = {
issueFilters: defaultIssueFilterState,
currentUserId: "local-board",
liveIssueIds: new Set<string>(),
subtreeLiveCounts: new Map<string, number>(),
workspaceFilterContext: {},
showStatusColumn: true,
showIdentifierColumn: true,
@@ -306,6 +315,35 @@ describe("BlockedInboxView", () => {
act(() => root.unmount());
});
it("uses loaded live descendants when blocked inbox rows do not have a server summary", async () => {
mockIssuesApi.list.mockResolvedValue([
{
...makeIssue(
"blocked-parent",
"PAP-77",
"Blocked parent with active child",
attention({ reason: "blocked_chain_stalled" }),
),
status: "blocked",
blockerAttention: null,
liveDescendantCount: undefined,
} as unknown as Issue,
]);
const { root } = renderWithClient(
<BlockedInboxView
{...blockedViewProps}
subtreeLiveCounts={new Map([["blocked-parent", 1]])}
/>,
container,
);
await waitFor(() => container.querySelector("a") !== null);
expect(container.querySelector('[aria-label="Blocked · waiting on 1 active sub-task"]')).not.toBeNull();
act(() => root.unmount());
});
it("renders the visible error banner with retry when the query fails", async () => {
mockIssuesApi.list.mockRejectedValue(new Error("network down"));
+22 -3
View File
@@ -6,6 +6,7 @@ import { issuesApi } from "../api/issues";
import { queryKeys } from "../lib/queryKeys";
import { cn } from "../lib/utils";
import { applyIssueFilters, type IssueFilterState, type IssueFilterWorkspaceContext } from "../lib/issue-filters";
import { resolveInboxIssueBlockerAttention } from "../lib/inbox-live-descendants";
import {
blockedRowMatchesSearch,
buildBlockedInboxRows,
@@ -34,6 +35,7 @@ interface BlockedInboxViewProps {
issueFilters: IssueFilterState;
currentUserId: string | null;
liveIssueIds: ReadonlySet<string>;
subtreeLiveCounts: ReadonlyMap<string, number>;
workspaceFilterContext: IssueFilterWorkspaceContext;
showStatusColumn: boolean;
showIdentifierColumn: boolean;
@@ -53,6 +55,7 @@ export function BlockedInboxView({
issueFilters,
currentUserId,
liveIssueIds,
subtreeLiveCounts,
workspaceFilterContext,
showStatusColumn,
showIdentifierColumn,
@@ -67,12 +70,13 @@ export function BlockedInboxView({
error,
refetch,
} = useQuery({
queryKey: queryKeys.issues.listBlockedAttention(companyId),
queryKey: [...queryKeys.issues.listBlockedAttention(companyId), "live-descendant-summary"],
queryFn: () =>
issuesApi.list(companyId, {
attention: "blocked",
includeBlockedInboxAttention: true,
includeBlockedBy: true,
includeLiveDescendantSummary: true,
limit: BLOCKED_LIST_LIMIT,
}),
});
@@ -209,6 +213,8 @@ export function BlockedInboxView({
issueLinkState={issueLinkState}
agentNameById={agentNameById}
userLabelById={userLabelById}
liveIssueIds={liveIssueIds}
subtreeLiveCounts={subtreeLiveCounts}
showStatusColumn={showStatusColumn}
showIdentifierColumn={showIdentifierColumn}
showUpdatedColumn={showUpdatedColumn}
@@ -236,6 +242,8 @@ export function BlockedInboxView({
issueLinkState={issueLinkState}
agentNameById={agentNameById}
userLabelById={userLabelById}
liveIssueIds={liveIssueIds}
subtreeLiveCounts={subtreeLiveCounts}
showStatusColumn={showStatusColumn}
showIdentifierColumn={showIdentifierColumn}
showUpdatedColumn={showUpdatedColumn}
@@ -257,6 +265,8 @@ interface BlockedInboxRowProps {
issueLinkState: unknown;
agentNameById: ReadonlyMap<string, string>;
userLabelById?: ReadonlyMap<string, string>;
liveIssueIds: ReadonlySet<string>;
subtreeLiveCounts: ReadonlyMap<string, number>;
showStatusColumn: boolean;
showIdentifierColumn: boolean;
showUpdatedColumn: boolean;
@@ -283,12 +293,18 @@ function BlockedInboxRow({
issueLinkState,
agentNameById,
userLabelById,
liveIssueIds,
subtreeLiveCounts,
showStatusColumn,
showIdentifierColumn,
showUpdatedColumn,
}: BlockedInboxRowProps) {
const { label: ownerName, isAgent } = resolveOwnerName(row, agentNameById, userLabelById);
const stoppedAge = formatStoppedAge(row.attention.stoppedSinceAt);
const blockerAttention = resolveInboxIssueBlockerAttention(row.issue, {
isLive: liveIssueIds.has(row.issue.id),
loadedSubtreeLiveCount: subtreeLiveCounts.get(row.issue.id) ?? 0,
});
const desktopTrailing = (
<span className="flex shrink-0 items-center gap-3 text-xs">
@@ -345,13 +361,14 @@ function BlockedInboxRow({
desktopMetaLeading={
<BlockedRowDesktopMeta
row={row}
blockerAttention={blockerAttention}
showStatusColumn={showStatusColumn}
showIdentifierColumn={showIdentifierColumn}
/>
}
mobileLeading={
<span className="flex shrink-0 items-center gap-1.5 pt-px">
<StatusIcon status={row.issue.status} blockerAttention={row.issue.blockerAttention} />
<StatusIcon status={row.issue.status} blockerAttention={blockerAttention} />
</span>
}
titleSuffix={
@@ -369,17 +386,19 @@ function BlockedInboxRow({
function BlockedRowDesktopMeta({
row,
blockerAttention,
showStatusColumn,
showIdentifierColumn,
}: {
row: BlockedInboxIssueRow;
blockerAttention: Issue["blockerAttention"] | null;
showStatusColumn: boolean;
showIdentifierColumn: boolean;
}) {
const identifier = row.issue.identifier ?? row.issue.id.slice(0, 8);
return (
<span className="hidden shrink-0 items-center gap-2 sm:inline-flex">
{showStatusColumn ? <StatusIcon status={row.issue.status} blockerAttention={row.issue.blockerAttention} /> : null}
{showStatusColumn ? <StatusIcon status={row.issue.status} blockerAttention={blockerAttention} /> : null}
{showIdentifierColumn ? <span className="font-mono text-xs text-muted-foreground">{identifier}</span> : null}
</span>
);
+2
View File
@@ -35,6 +35,8 @@ function GoalNode({ goal, children, allGoals, depth, goalLink, onSelect }: GoalN
e.stopPropagation();
setExpanded(!expanded);
}}
aria-label={`${goal.title} subtree`}
aria-expanded={expanded}
>
<ChevronRight
className={cn("h-3 w-3 transition-transform", expanded && "rotate-90")}
+12
View File
@@ -67,6 +67,18 @@ describe("InboxIssueMetaLeading live state", () => {
expect(text).not.toMatch(/(^|[^a-z])Live([^a-z]|$)/);
});
it("can suppress the subtree chip when the status glyph already carries descendant liveness", () => {
const text = renderLeading(
<InboxIssueMetaLeading
issue={makeIssue({ id: "parent", identifier: "PAP-1", status: "blocked" })}
isLive={false}
subtreeLiveCount={2}
showSubtreeLiveChip={false}
/>,
);
expect(text).not.toContain("live below");
});
it("renders no live treatment when the issue and its subtree are idle", () => {
const text = renderLeading(
<InboxIssueMetaLeading
+3 -1
View File
@@ -137,6 +137,7 @@ export function InboxIssueMetaLeading({
issue,
isLive,
subtreeLiveCount = 0,
showSubtreeLiveChip = true,
showStatus = true,
showIdentifier = true,
statusSlot,
@@ -145,6 +146,7 @@ export function InboxIssueMetaLeading({
issue: Issue;
isLive: boolean;
subtreeLiveCount?: number;
showSubtreeLiveChip?: boolean;
showStatus?: boolean;
showIdentifier?: boolean;
statusSlot?: ReactNode;
@@ -193,7 +195,7 @@ export function InboxIssueMetaLeading({
</span>
</span>
)}
{!isLive && subtreeLiveCount > 0 && (
{showSubtreeLiveChip && !isLive && subtreeLiveCount > 0 && (
<span
className={cn(
"inline-flex items-center gap-1 rounded-full border px-1.5 py-0.5 sm:gap-1.5 sm:px-2",
@@ -1,7 +1,7 @@
// @vitest-environment jsdom
import { act } from "react";
import type { ComponentProps, ReactNode } from "react";
import { act as reactAct, type ComponentProps, type ReactNode } from "react";
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import { IssueThreadInteractionCard } from "./IssueThreadInteractionCard";
@@ -9,6 +9,7 @@ import { ThemeProvider } from "../context/ThemeContext";
import { TooltipProvider } from "./ui/tooltip";
import {
pendingAskUserQuestionsInteraction,
commentExpiredAskUserQuestionsInteraction,
commentExpiredRequestConfirmationInteraction,
disabledDeclineReasonRequestConfirmationInteraction,
failedRequestConfirmationInteraction,
@@ -23,6 +24,20 @@ let container: HTMLDivElement | null = null;
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
async function act(callback: () => void | Promise<void>) {
if (typeof reactAct === "function") {
await reactAct(callback);
return;
}
let result: void | Promise<void> = undefined;
flushSync(() => {
result = callback();
});
await result;
await new Promise((resolve) => setTimeout(resolve, 0));
}
vi.mock("@/lib/router", () => ({
Link: ({ to, children, className }: { to: string; children: ReactNode; className?: string }) => (
<a href={to} className={className}>{children}</a>
@@ -174,6 +189,42 @@ describe("IssueThreadInteractionCard", () => {
expect(withHandler.textContent).toContain("Cancel question");
});
it("renders expired question interactions as resolved and non-actionable", () => {
const host = renderCard({
interaction: commentExpiredAskUserQuestionsInteraction,
onSubmitInteractionAnswers: vi.fn(),
onCancelInteraction: vi.fn(),
});
expect(host.textContent).toContain("Questions expired by comment");
expect(host.textContent).toContain("A later board/user comment superseded this question request.");
expect(host.textContent).not.toContain("Send answers");
expect(host.textContent).not.toContain("Cancel question");
const jumpLink = Array.from(host.querySelectorAll("a")).find((link) =>
link.textContent?.includes("Jump to comment"),
);
expect(jumpLink?.getAttribute("href")).toBe(
"#comment-22222222-2222-4222-8222-222222222222",
);
});
it("uses singular copy for expired single-question interactions", () => {
const [question] = commentExpiredAskUserQuestionsInteraction.payload.questions;
const host = renderCard({
interaction: {
...commentExpiredAskUserQuestionsInteraction,
payload: {
...commentExpiredAskUserQuestionsInteraction.payload,
questions: [question],
},
},
});
expect(host.textContent).toContain("Question expired by comment");
expect(host.textContent).not.toContain("Questions expired by comment");
});
it("makes child tasks explicit in suggested task trees", () => {
const host = renderCard({
interaction: pendingSuggestedTasksInteraction,
@@ -983,6 +983,24 @@ function AskUserQuestionsCard({
<p className="mt-1">No answer was recorded.</p>
)}
</div>
) : interaction.status === "expired" ? (
<div className="rounded-2xl border border-amber-300/70 bg-amber-50/85 p-4 text-sm leading-6 text-amber-950 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-100">
<div className="flex items-center gap-2 font-semibold">
<AlertTriangle className="h-4 w-4" />
{questions.length === 1 ? "Question expired by comment" : "Questions expired by comment"}
</div>
<p className="mt-1">
A later board/user comment superseded this question request. Create a fresh request if answers are still needed.
</p>
{interaction.result?.commentId ? (
<a
href={`#comment-${interaction.result.commentId}`}
className="mt-3 inline-flex text-sm font-medium underline underline-offset-4"
>
Jump to comment
</a>
) : null}
</div>
) : (
<div className="space-y-3">
{questions.map((question) => {
+5 -1
View File
@@ -1388,11 +1388,13 @@ export function IssuesList({
<div className="flex items-center gap-0.5 sm:gap-1 shrink-0">
{/* View mode toggle */}
<div className="flex items-center border border-border rounded-md overflow-hidden mr-1">
<div className="flex items-center border border-border rounded-md overflow-hidden mr-1" role="group" aria-label="View mode">
<button
className={`p-1.5 transition-colors ${viewState.viewMode === "list" ? "bg-accent text-foreground" : "text-muted-foreground hover:text-foreground"}`}
onClick={() => updateView({ viewMode: "list" })}
title="List view"
aria-label="List view"
aria-pressed={viewState.viewMode === "list"}
>
<List className="h-3.5 w-3.5" />
</button>
@@ -1400,6 +1402,8 @@ export function IssuesList({
className={`p-1.5 transition-colors ${viewState.viewMode === "board" ? "bg-accent text-foreground" : "text-muted-foreground hover:text-foreground"}`}
onClick={() => updateView({ viewMode: "board" })}
title="Board view"
aria-label="Board view"
aria-pressed={viewState.viewMode === "board"}
>
<Columns3 className="h-3.5 w-3.5" />
</button>
+1
View File
@@ -368,6 +368,7 @@ export function ScheduleEditor({
variant={dayOfWeek === d.value ? "default" : "outline"}
size="sm"
className="h-7 px-2 text-xs"
aria-pressed={dayOfWeek === d.value}
onClick={() => {
setDayOfWeek(d.value);
emitChange(preset, hour, minute, d.value, dayOfMonth, customCron);
@@ -370,6 +370,21 @@ export const answeredAskUserQuestionsInteraction = createAskUserQuestionsInterac
},
});
export const commentExpiredAskUserQuestionsInteraction = createAskUserQuestionsInteraction({
id: "interaction-questions-expired-comment",
status: "expired",
resolvedByUserId: issueThreadInteractionFixtureMeta.currentUserId,
resolvedAt: new Date("2026-04-20T14:25:00.000Z"),
updatedAt: new Date("2026-04-20T14:25:00.000Z"),
result: {
version: 1,
answers: [],
expirationReason: "superseded_by_comment",
commentId: "22222222-2222-4222-8222-222222222222",
summaryMarkdown: null,
},
});
export const pendingRequestConfirmationInteraction = createRequestConfirmationInteraction({});
export const genericPendingRequestConfirmationInteraction = createRequestConfirmationInteraction({
+90
View File
@@ -0,0 +1,90 @@
import { describe, expect, it } from "vitest";
import type { Issue, IssueBlockerAttention } from "@paperclipai/shared";
import {
resolveInboxIssueBlockerAttention,
resolveIssueLiveDescendantCount,
} from "./inbox-live-descendants";
function makeIssue(overrides: Partial<Issue>): Issue {
return {
id: "issue-1",
status: "blocked",
blockerAttention: null,
liveDescendantCount: 0,
...overrides,
} as unknown as Issue;
}
function makeBlockerAttention(
overrides: Partial<IssueBlockerAttention> = {},
): IssueBlockerAttention {
return {
state: "none",
reason: null,
unresolvedBlockerCount: 0,
coveredBlockerCount: 0,
stalledBlockerCount: 0,
attentionBlockerCount: 0,
sampleBlockerIdentifier: null,
sampleStalledBlockerIdentifier: null,
...overrides,
};
}
describe("inbox live descendant status helpers", () => {
it("combines server and loaded live descendant counts without double-counting", () => {
expect(resolveIssueLiveDescendantCount(makeIssue({ liveDescendantCount: 3 }), 1)).toBe(3);
expect(resolveIssueLiveDescendantCount(makeIssue({ liveDescendantCount: 0 }), 2)).toBe(2);
expect(resolveIssueLiveDescendantCount(makeIssue({ liveDescendantCount: -1 }), 2.7)).toBe(2);
});
it("synthesizes covered blocker attention for a blocked row with live descendants", () => {
const attention = resolveInboxIssueBlockerAttention(
makeIssue({ liveDescendantCount: 2 }),
{ isLive: false },
);
expect(attention).toMatchObject({
state: "covered",
reason: "active_child",
coveredBlockerCount: 2,
});
});
it("uses loaded live descendants when the server count is absent", () => {
const attention = resolveInboxIssueBlockerAttention(
makeIssue({ liveDescendantCount: undefined }),
{ isLive: false, loadedSubtreeLiveCount: 1 },
);
expect(attention?.state).toBe("covered");
expect(attention?.coveredBlockerCount).toBe(1);
});
it("keeps urgent blocked attention red even when descendants are live", () => {
for (const state of ["needs_attention", "stalled"] as const) {
const original = makeBlockerAttention({ state, reason: "attention_required" });
const attention = resolveInboxIssueBlockerAttention(
makeIssue({ blockerAttention: original, liveDescendantCount: 4 }),
{ isLive: false },
);
expect(attention).toBe(original);
}
});
it("does not synthesize covered attention for the live row itself or non-blocked parents", () => {
expect(
resolveInboxIssueBlockerAttention(
makeIssue({ status: "blocked", liveDescendantCount: 2 }),
{ isLive: true },
),
).toBeNull();
expect(
resolveInboxIssueBlockerAttention(
makeIssue({ status: "done", liveDescendantCount: 2 }),
{ isLive: false },
),
).toBeNull();
});
});
+55
View File
@@ -0,0 +1,55 @@
import type { Issue, IssueBlockerAttention } from "@paperclipai/shared";
type InboxLiveDescendantIssue = Pick<Issue, "status" | "blockerAttention" | "liveDescendantCount">;
interface InboxLiveDescendantOptions {
isLive: boolean;
loadedSubtreeLiveCount?: number;
}
function normalizeLiveDescendantCount(value: unknown): number {
if (typeof value !== "number" || !Number.isFinite(value)) return 0;
return Math.max(0, Math.trunc(value));
}
function asBlockerAttention(value: unknown): IssueBlockerAttention | null {
if (!value || typeof value !== "object") return null;
const attention = value as Partial<IssueBlockerAttention>;
return typeof attention.state === "string" ? attention as IssueBlockerAttention : null;
}
export function resolveIssueLiveDescendantCount(
issue: Pick<Issue, "liveDescendantCount">,
loadedSubtreeLiveCount = 0,
): number {
return Math.max(
normalizeLiveDescendantCount(issue.liveDescendantCount),
normalizeLiveDescendantCount(loadedSubtreeLiveCount),
);
}
export function resolveInboxIssueBlockerAttention(
issue: InboxLiveDescendantIssue,
options: InboxLiveDescendantOptions,
): IssueBlockerAttention | null {
const blockerAttention = asBlockerAttention(issue.blockerAttention);
if (issue.status !== "blocked" || options.isLive) return blockerAttention;
if (blockerAttention?.state === "needs_attention" || blockerAttention?.state === "stalled") {
return blockerAttention;
}
if (blockerAttention?.state === "covered") return blockerAttention;
const liveDescendantCount = resolveIssueLiveDescendantCount(issue, options.loadedSubtreeLiveCount);
if (liveDescendantCount <= 0) return blockerAttention;
return {
state: "covered",
reason: "active_child",
unresolvedBlockerCount: blockerAttention?.unresolvedBlockerCount ?? 0,
coveredBlockerCount: liveDescendantCount,
stalledBlockerCount: blockerAttention?.stalledBlockerCount ?? 0,
attentionBlockerCount: blockerAttention?.attentionBlockerCount ?? 0,
sampleBlockerIdentifier: blockerAttention?.sampleBlockerIdentifier ?? null,
sampleStalledBlockerIdentifier: blockerAttention?.sampleStalledBlockerIdentifier ?? null,
};
}
+34
View File
@@ -462,6 +462,40 @@ describe("buildIssueChatMessages", () => {
});
});
it("does not reattribute a genuine board/user comment that has no derived agent", () => {
const agentMap = new Map<string, Agent>([["agent-1", createAgent("agent-1", "Claude")]]);
const messages = buildIssueChatMessages({
comments: [
createComment({
authorUserId: "local-board",
authorType: "user",
// No agent ever resolved for this comment — a real board action.
derivedAuthorAgentId: null,
derivedCreatedByRunId: null,
runId: null,
runAgentId: null,
}),
],
timelineEvents: [],
linkedRuns: [],
liveRuns: [],
agentMap,
currentUserId: "user-1",
userLabelMap: new Map([["local-board", "Board"]]),
});
expect(messages[0]).toMatchObject({
role: "user",
metadata: {
custom: {
authorType: "user",
authorAgentId: null,
authorUserId: "local-board",
},
},
});
});
it("renders a comment as agent-authored when runAgentId is set from activity log", () => {
const agentMap = new Map<string, Agent>([["agent-1", createAgent("agent-1", "Claude")]]);
const messages = buildIssueChatMessages({
@@ -127,6 +127,34 @@ describe("issue thread interaction helpers", () => {
answers: [{ questionId: "question-1", optionIds: ["option-1"] }],
},
})).toBe("Answered 1 question");
expect(buildIssueThreadInteractionSummary({
id: "interaction-expired",
companyId: "company-1",
issueId: "issue-1",
kind: "ask_user_questions",
status: "expired",
continuationPolicy: "wake_assignee",
createdAt: "2026-04-06T12:00:00.000Z",
updatedAt: "2026-04-06T12:05:00.000Z",
payload: {
version: 1,
questions: [
{
id: "question-1",
prompt: "Pick one",
selectionMode: "single",
options: [{ id: "option-1", label: "Option 1" }],
},
],
},
result: {
version: 1,
answers: [],
expirationReason: "superseded_by_comment",
commentId: "11111111-1111-4111-8111-111111111111",
},
})).toBe("Question expired after comment");
});
it("summarizes checkbox confirmation interactions by count", () => {
+6
View File
@@ -158,6 +158,12 @@ export function buildIssueThreadInteractionSummary(
if (interaction.status === "cancelled") {
return count === 1 ? "Cancelled 1 question" : `Cancelled ${count} questions`;
}
if (interaction.status === "expired") {
if (interaction.result?.expirationReason === "superseded_by_comment") {
return count === 1 ? "Question expired after comment" : "Questions expired after comment";
}
return count === 1 ? "Question expired" : "Questions expired";
}
return count === 1 ? "Asked 1 question" : `Asked ${count} questions`;
}
+2 -2
View File
@@ -161,8 +161,8 @@ export const queryKeys = {
environments: {
list: (companyId: string) => ["environments", companyId] as const,
capabilities: (companyId: string) => ["environment-capabilities", companyId] as const,
customImageTemplate: (companyId: string, environmentId: string) =>
["environments", companyId, environmentId, "custom-image-template"] as const,
customImageTemplate: (environmentId: string) =>
["environments", environmentId, "custom-image-template"] as const,
customImageSetupSession: (sessionId: string) =>
["environment-custom-image-setup-sessions", sessionId] as const,
},
+1 -4
View File
@@ -275,7 +275,6 @@ function getOpenDialog(): HTMLElement | null {
function createSession(overrides: Record<string, unknown> = {}) {
return {
id: "session-1",
companyId: "company-1",
environmentId: "env-1",
templateId: null,
promotedTemplateId: null,
@@ -306,7 +305,6 @@ function createSession(overrides: Record<string, unknown> = {}) {
function createTemplate(overrides: Record<string, unknown> = {}) {
return {
id: "template-1",
companyId: "company-1",
environmentId: "env-1",
provider: "daytona",
templateKind: "snapshot",
@@ -653,7 +651,7 @@ describe("CompanyEnvironments — test provider button", () => {
await waitForAssertion(() => {
expect(getOpenDialog()?.textContent).toContain("Configure image");
});
expect(mockEnvironmentsApi.customImageTemplate).toHaveBeenCalledExactlyOnceWith("env-1", "company-1");
expect(mockEnvironmentsApi.customImageTemplate).toHaveBeenCalledExactlyOnceWith("env-1");
await act(async () => click(findButton(document.body, "Cancel")));
await waitForAssertion(() => expect(getOpenDialog()).toBeNull());
@@ -1011,7 +1009,6 @@ describe("CompanyEnvironments — test provider button", () => {
expect(mockEnvironmentsApi.startCustomImageSetupSession).toHaveBeenCalledWith(
"env-1",
"company-1",
{ templateId: "template-active" },
);
await waitForAssertion(() => {
+5 -8
View File
@@ -674,12 +674,10 @@ function sessionStatusCopy(status: EnvironmentCustomImageSetupSession["status"])
}
function EnvironmentImageTemplatePanel({
companyId,
environment,
providerCapability,
providerDisplayName,
}: {
companyId: string;
environment: Environment;
providerCapability: EnvironmentProviderCapability | null | undefined;
providerDisplayName: string;
@@ -687,11 +685,11 @@ function EnvironmentImageTemplatePanel({
const { pushToast } = useToast();
const queryClient = useQueryClient();
const state = capabilityState(providerCapability);
const overviewKey = queryKeys.environments.customImageTemplate(companyId, environment.id);
const overviewKey = queryKeys.environments.customImageTemplate(environment.id);
const overviewQuery = useQuery({
queryKey: overviewKey,
queryFn: () => environmentsApi.customImageTemplate(environment.id, companyId),
queryFn: () => environmentsApi.customImageTemplate(environment.id),
enabled: state.kind === "supported",
retry: false,
});
@@ -719,7 +717,7 @@ function EnvironmentImageTemplatePanel({
const startSetupMutation = useMutation({
mutationFn: (input: { templateId?: string | null } = {}) =>
environmentsApi.startCustomImageSetupSession(environment.id, companyId, {
environmentsApi.startCustomImageSetupSession(environment.id, {
templateId: input.templateId ?? null,
}),
onSuccess: (result) => {
@@ -796,7 +794,7 @@ function EnvironmentImageTemplatePanel({
});
const rollbackTemplateMutation = useMutation({
mutationFn: () => environmentsApi.rollbackCustomImageTemplate(environment.id, companyId),
mutationFn: () => environmentsApi.rollbackCustomImageTemplate(environment.id),
onSuccess: (result) => {
queryClient.setQueryData(overviewKey, (current: typeof overviewQuery.data) => ({
activeTemplate: result.activeTemplate,
@@ -820,7 +818,7 @@ function EnvironmentImageTemplatePanel({
});
const disableTemplateMutation = useMutation({
mutationFn: () => environmentsApi.disableCustomImageTemplate(environment.id, companyId),
mutationFn: () => environmentsApi.disableCustomImageTemplate(environment.id),
onSuccess: (template) => {
queryClient.setQueryData(overviewKey, (current: typeof overviewQuery.data) => ({
activeTemplate: null,
@@ -1687,7 +1685,6 @@ export function CompanyEnvironments() {
running machine as a reusable image for future runs.
</div>
<EnvironmentImageTemplatePanel
companyId={selectedCompanyId}
environment={editingEnvironment}
providerCapability={editingSandboxCapability}
providerDisplayName={editingSandboxDisplayName}
+1
View File
@@ -554,6 +554,7 @@ export function Costs() {
variant={preset === key ? "secondary" : "ghost"}
size="sm"
onClick={() => setPreset(key)}
aria-pressed={preset === key}
>
{PRESET_LABELS[key]}
</Button>
+49 -3
View File
@@ -1,7 +1,7 @@
// @vitest-environment jsdom
import { act } from "react";
import type { ComponentProps } from "react";
import { flushSync } from "react-dom";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Issue } from "@paperclipai/shared";
@@ -138,6 +138,14 @@ vi.mock("@/lib/router", () => ({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
async function act(callback: () => void | Promise<void>) {
let result: void | Promise<void> = undefined;
flushSync(() => {
result = callback();
});
await result;
}
// jsdom doesn't implement scrollIntoView; the inbox calls it from a passive effect.
if (typeof Element !== "undefined" && !Element.prototype.scrollIntoView) {
Element.prototype.scrollIntoView = () => {};
@@ -227,6 +235,7 @@ function createJoinRequest(
}
function resetInboxApiMocks() {
for (const mock of Object.values(apiMocks)) mock.mockReset();
routerMock.location.pathname = "/";
routerMock.location.search = "";
routerMock.location.hash = "";
@@ -327,6 +336,37 @@ describe("Inbox toolbar", () => {
});
});
it("requests live descendant summaries for issue rows", async () => {
routerMock.location.pathname = "/inbox/mine";
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: 0, gcTime: 0 } },
});
const root = createRoot(container);
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<Inbox />
</QueryClientProvider>,
);
});
await vi.waitFor(() => {
expect(apiMocks.issuesList).toHaveBeenCalledTimes(3);
});
expect(apiMocks.issuesList.mock.calls.map((call) => call[1]?.includeLiveDescendantSummary)).toEqual([
true,
true,
true,
]);
act(() => {
root.unmount();
});
});
it("syncs hover with j/k selection on inbox rows", async () => {
routerMock.location.pathname = "/inbox/mine";
const issueA = createIssue({ id: "issue-a", identifier: "PAP-1001", title: "First inbox row" });
@@ -360,18 +400,24 @@ describe("Inbox toolbar", () => {
await act(async () => {
rows[1]!.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
rows[1]!.dispatchEvent(new MouseEvent("mouseenter", { bubbles: false }));
});
// After hovering row 1, that row is "selected" — same visual state as j/k selection.
expect(linkOf(rows[1]!)?.className).toContain("hover:bg-transparent");
await vi.waitFor(() => {
expect(linkOf(rows[1]!)?.className).toContain("hover:bg-transparent");
});
expect(linkOf(rows[0]!)?.className).toContain("hover:bg-accent/50");
await act(async () => {
rows[0]!.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
rows[0]!.dispatchEvent(new MouseEvent("mouseenter", { bubbles: false }));
});
// Hovering a different row moves the selection to follow the mouse.
expect(linkOf(rows[0]!)?.className).toContain("hover:bg-transparent");
await vi.waitFor(() => {
expect(linkOf(rows[0]!)?.className).toContain("hover:bg-transparent");
});
expect(linkOf(rows[1]!)?.className).toContain("hover:bg-accent/50");
act(() => {
Loaded 100 of 102 files, more files were not shown because too many files have changed in this diff. Show more