[codex] Roll up May 17 branch changes (#6210)

## Thinking Path

> - Paperclip is the control plane for autonomous AI companies, so agent
work needs visible ownership, recovery, and operator controls.
> - This local branch had accumulated several related control-plane
reliability and operator-experience fixes across recovery actions,
watchdog folding, model-profile defaults, mentions, markdown editing,
plugin launchers, and small UI polish.
> - The branch needed to be converted into a PR against the current
`origin/master` without losing dirty work or including lockfile/workflow
churn.
> - The safest standalone shape is a single rollup PR because the
recovery/server/UI files overlap heavily across the local commits and
splitting would create avoidable conflicts.
> - This pull request replays the local branch onto latest
`origin/master`, preserves the uncommitted work as logical commits, and
adds a Zod 4 validator compatibility fix found during verification.
> - The benefit is that the May 17 local branch can be reviewed and
merged as one coherent, conflict-free branch under the 100-file Greptile
limit.

## What Changed

- Rebased the local May 17 branch work onto current `origin/master` in a
dedicated worktree.
- Preserved and committed previously dirty changes for recovery retry
handling, plugin/sidebar launcher polish, and `.herenow` ignores.
- Added recovery-action behavior for returning source issues to `todo`
when retrying source-scoped recovery.
- Included the existing local recovery/liveness/watchdog fold, Codex
cheap-profile, markdown/mention, duplicate-agent, and UI polish commits
from the branch.
- Normalized shared validator `z.record(...)` schemas to explicit
string-key records for Zod 4 compatibility.
- Confirmed the PR has no `pnpm-lock.yaml` or `.github/workflows/*`
changes and stays below the 100-file Greptile limit.

## Verification

- `pnpm install --frozen-lockfile --ignore-scripts`
- `npm run install` in
`node_modules/.pnpm/sqlite3@5.1.7/node_modules/sqlite3` to build the
local native sqlite3 binding after installing with scripts disabled
- `pnpm exec vitest run packages/shared/src/validators/issue.test.ts
packages/shared/src/project-mentions.test.ts
packages/adapter-utils/src/server-utils.test.ts
server/src/__tests__/heartbeat-model-profile.test.ts
server/src/__tests__/issue-recovery-actions.test.ts
server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts
server/src/__tests__/heartbeat-active-run-output-watchdog.test.ts
server/src/__tests__/plugin-local-folders.test.ts
ui/src/components/IssueRecoveryActionCard.test.tsx
ui/src/components/Sidebar.test.tsx
ui/src/components/SidebarAccountMenu.test.tsx
ui/src/components/IssueProperties.test.tsx
ui/src/components/MarkdownEditor.test.tsx
ui/src/components/MarkdownBody.test.tsx
ui/src/lib/duplicate-agent-payload.test.ts
ui/src/pages/Routines.test.tsx`
- First pass: 13 files passed with 201 passing tests; 3 server files
failed before sqlite3 native binding was built.
- After rebuilding sqlite3:
`server/src/__tests__/heartbeat-model-profile.test.ts`,
`server/src/__tests__/issue-recovery-actions.test.ts`, and
`server/src/__tests__/heartbeat-active-run-output-watchdog.test.ts`
passed/loaded; embedded Postgres tests were skipped by the local host
guard.
- `pnpm --filter @paperclipai/shared typecheck`
- `pnpm --filter @paperclipai/adapter-utils typecheck`
- `pnpm --filter @paperclipai/server typecheck`
- `pnpm --filter @paperclipai/ui typecheck`

## Risks

- Medium risk: this is a broad rollup PR across recovery semantics,
server tests, shared validators, and UI surfaces.
- Some embedded Postgres tests skipped locally due the host guard, so CI
should provide the stronger database-backed signal.
- UI changes were covered by component tests, but no browser screenshot
was captured in this PR creation pass.
- This branch may overlap with existing recovery/liveness PR work; merge
this PR independently or restack/close overlapping branches rather than
merging duplicate implementations together.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex, GPT-5-based coding agent, tool-enabled local repository
and GitHub workflow, medium reasoning effort.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta
2026-05-17 17:15:06 -05:00
committed by GitHub
parent 705c1b8d81
commit d734bd43d1
83 changed files with 3675 additions and 180 deletions
+1
View File
@@ -57,3 +57,4 @@ tests/release-smoke/playwright-report/
.superset/ .superset/
.superpowers/ .superpowers/
.claude/worktrees/ .claude/worktrees/
.herenow
+40 -4
View File
@@ -184,7 +184,7 @@ A valid recovery action must name:
- the wake, monitor, timeout, retry, or escalation policy that will move the action forward - the wake, monitor, timeout, retry, or escalation policy that will move the action forward
- the resolution outcome when closed, such as restored, delegated, false positive, blocked, escalated, or cancelled - the resolution outcome when closed, such as restored, delegated, false positive, blocked, escalated, or cancelled
A source-scoped recovery action is the default form. Use it when the next safe move is to repair the source issue's liveness directly: restore a wake path, clarify disposition, re-establish a monitor, record a false positive, or delegate real follow-up work from the source issue. A source-scoped recovery action is the default form. Use it when the next safe move is to repair the source issue's liveness directly: move the source issue back to `todo` so it can be retried, clarify disposition, re-establish a monitor, record a false positive, or delegate real follow-up work from the source issue.
Use an issue-backed recovery action only when the recovery is genuinely independent work or when source-scoped handling would be unsafe or unclear. Examples include: Use an issue-backed recovery action only when the recovery is genuinely independent work or when source-scoped handling would be unsafe or unclear. Examples include:
@@ -196,6 +196,14 @@ Use an issue-backed recovery action only when the recovery is genuinely independ
A comment or system notice can be evidence for a recovery action, but it is not a recovery action by itself. Comment-only recovery is not a healthy liveness path because it does not define a typed owner, wake or monitor policy, retry bound, timeout, escalation path, or resolution outcome. A comment or system notice can be evidence for a recovery action, but it is not a recovery action by itself. Comment-only recovery is not a healthy liveness path because it does not define a typed owner, wake or monitor policy, retry bound, timeout, escalation path, or resolution outcome.
#### Recovery action freshness
Source-scoped recovery actions are snapshots of the source issue's liveness state at the time the action was opened. They must be revalidated after newer durable source activity, including source issue status changes, assignee changes, blocker changes, execution policy or monitor changes, document or work-product updates that define a valid waiting path, and structured resume or disposition updates.
When newer source activity restores a valid live or waiting path, the recovery action is stale and should be folded through the explicit recovery lifecycle instead of being hidden or deleted. Folding means resolving or cancelling the recovery action with a resolution outcome and note that preserve the audit trail.
Plain comments alone do not make a recovery action stale. A comment can provide evidence, but the recovery action should remain visible when the source issue is still stalled and the comment does not create a valid action-path primitive such as a wake, monitor, interaction, approval, blocker, human owner, execution participant, terminal disposition, or delegated follow-up.
### Agent-assigned `todo` ### Agent-assigned `todo`
This is dispatch state: ready to start, not yet actively claimed. This is dispatch state: ready to start, not yet actively claimed.
@@ -326,14 +334,15 @@ This is an active-work continuity recovery.
Startup recovery and periodic recovery are different from normal wakeup delivery. Startup recovery and periodic recovery are different from normal wakeup delivery.
On startup and on the periodic recovery loop, Paperclip now does four things in sequence: On startup and on the periodic recovery loop, Paperclip now does five things in sequence:
1. reap orphaned `running` runs 1. reap orphaned `running` runs
2. resume persisted `queued` runs 2. resume persisted `queued` runs
3. reconcile stranded assigned work 3. reconcile stranded assigned work
4. scan silent active runs and create or update explicit watchdog recovery actions 4. scan silent active runs, revalidate their source issues, and either fold source-resolved watchdogs or create/update explicit watchdog recovery actions
5. reconcile productivity reviews
The stranded-work pass closes the gap where issue state survives a crash but the wake/run path does not. The silent-run scan covers the separate case where a live process exists but has stopped producing observable output. The stranded-work pass closes the gap where issue state survives a crash but the wake/run path does not. The silent-run scan covers the separate case where a live process exists but has stopped producing observable output. The productivity-review pass is later and separate; it reviews unusual progression patterns on assigned source issues, not stale run handles after a source issue already has a valid disposition.
## 10. Silent Active-Run Watchdog ## 10. Silent Active-Run Watchdog
@@ -360,6 +369,33 @@ Operators should prefer `snooze` for known time-bounded quiet periods. `continue
The board can record watchdog decisions. The assigned owner of an issue-backed watchdog evaluation can also record them. Other agents cannot. The board can record watchdog decisions. The assigned owner of an issue-backed watchdog evaluation can also record them. Other agents cannot.
### Source-aware watchdog folding
Active-run watchdog work is source-aware. Before the watchdog creates, refreshes, escalates, or blocks on reviewer work, it must re-read the linked source issue and decide whether the watchdog signal is still about productive source work or only about stale run/process bookkeeping.
Fold watchdog work when all of these are true:
- the run is linked to a source issue in the same company
- the source issue is terminal (`done` or `cancelled`)
- durable source activity from the same run proves the source issue reached that terminal disposition after the stale-run or output-silence evidence point
- there is no independent evidence that the still-running or detached process is doing harmful work, still owns external cleanup that needs an operator decision, or needs a separate security/ownership review
Folding means resolving or cancelling the watchdog recovery action or issue-backed evaluation through the explicit recovery lifecycle. It must preserve the run id, source issue, detected silence or detached-process evidence, terminal source activity, decision reason, and best-effort process cleanup result. It must be idempotent for the `(companyId, runId, sourceIssueId)` signal and must not recursively recover the watchdog evaluation issue itself.
Do not fold watchdog work only because the run is quiet. The watchdog must still create or continue reviewer work when:
- the source issue is still `todo` or `in_progress`, because productive work may still be happening or stuck
- the source issue remains `in_progress` after a successful run with no valid disposition, because the successful-run handoff path owns that bounded correction
- the run terminated or disappeared while the source issue remains `in_progress` without a live path, because stranded assigned recovery owns that continuity repair
- the source issue is terminal but there is no durable same-run terminal activity after the stale evidence point
- there is independent evidence that the process may still be mutating external state, leaking resources, crossing company or ownership boundaries, or otherwise needs operator review
In the normal non-terminal case, critical silence can still create issue-backed evaluation work and block the source issue when blocking is necessary for correctness. In the source-resolved case, a completed source issue should not acquire a new manager review or blocker merely because an old run handle stayed active; only real unresolved work should block work.
This is distinct from productivity review. Productivity review asks whether an assigned source issue has unusual progression patterns, such as no-comment terminal-run streaks, long active duration, or high churn. Source-resolved watchdog folding asks whether a stale active-run signal outlived a source issue that already reached a valid terminal disposition. One does not substitute for the other.
Detached process cleanup is operational hygiene, not source issue liveness. Cleanup should be best-effort and auditable. If cleanup fails but the source issue is already terminal with same-run durable evidence, Paperclip should preserve the cleanup failure on the run/watchdog audit trail and route only the cleanup concern to bounded recovery when a real owner/action remains.
## 11. Auto-Recover vs Explicit Recovery vs Human Escalation ## 11. Auto-Recover vs Explicit Recovery vs Human Escalation
Paperclip uses three different recovery outcomes, depending on how much it can safely infer. Paperclip uses three different recovery outcomes, depending on how much it can safely infer.
@@ -1,20 +1,57 @@
export const REDACTED_COMMAND_TEXT_VALUE = "***REDACTED***"; export const REDACTED_COMMAND_TEXT_VALUE = "***REDACTED***";
const COMMAND_CLI_SECRET_OPTION_RE = const SECRET_NAME_PATTERN =
/(\B-{1,2}(?:api[-_]?key|(?:access[-_]?|auth[-_]?)?token|token|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring)(?:\s+|=)(["']?))[^\s"'`]+(\2)/gi; String.raw`[A-Za-z0-9_-]*(?:api[-_]?key|(?:access[-_]?|auth[-_]?)?token|token|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring)[A-Za-z0-9_-]*`;
const COMMAND_ENV_SECRET_ASSIGNMENT_RE =
/(\b[A-Za-z0-9_]*(?:TOKEN|KEY|SECRET|PASSWORD|PASSWD|AUTHORIZATION|JWT)[A-Za-z0-9_]*\s*=\s*)[^\s"'`]+/gi; const COMMAND_CLI_SECRET_OPTION_RE = new RegExp(
String.raw`(\B-{1,2}${SECRET_NAME_PATTERN}(?:\s+|=)(["']?))[^\s"'` + "`" + String.raw`]+(\2)`,
"gi",
);
const COMMAND_ENV_SECRET_ASSIGNMENT_RE = new RegExp(
String.raw`(\b${SECRET_NAME_PATTERN}\s*=\s*)(?:(["'])([^"'` + "`" + String.raw`\r\n]*)\2|([^\s"'` + "`" + String.raw`]+))`,
"gi",
);
const COMMAND_AUTHORIZATION_BEARER_RE = /(\bAuthorization\s*:\s*Bearer\s+)[^\s"'`]+/gi; const COMMAND_AUTHORIZATION_BEARER_RE = /(\bAuthorization\s*:\s*Bearer\s+)[^\s"'`]+/gi;
const COMMAND_OPENAI_KEY_RE = /\bsk-[A-Za-z0-9_-]{12,}\b/g; const COMMAND_OPENAI_KEY_RE = /\bsk-[A-Za-z0-9_-]{12,}\b/g;
const COMMAND_GITHUB_TOKEN_RE = /\bgh[pousr]_[A-Za-z0-9_]{20,}\b/g; const COMMAND_GITHUB_TOKEN_RE = /\bgh[pousr]_[A-Za-z0-9_]{20,}\b/g;
const COMMAND_JWT_RE = const COMMAND_JWT_RE =
/\b[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}(?:\.[A-Za-z0-9_-]{8,})?\b/g; /\b[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}(?:\.[A-Za-z0-9_-]{8,})?\b/g;
const COMMAND_SECRET_HINTS = [
"api",
"key",
"token",
"auth",
"bearer",
"secret",
"pass",
"credential",
"jwt",
"private",
"cookie",
"connectionstring",
"sk-",
"ghp_",
"gho_",
"ghu_",
"ghs_",
"ghr_",
] as const;
function maybeContainsSecretText(command: string) {
const lower = command.toLowerCase();
return COMMAND_SECRET_HINTS.some((hint) => lower.includes(hint)) || command.includes(".");
}
export function redactCommandText(command: string, redactedValue = REDACTED_COMMAND_TEXT_VALUE): string { export function redactCommandText(command: string, redactedValue = REDACTED_COMMAND_TEXT_VALUE): string {
if (!maybeContainsSecretText(command)) return command;
return command return command
.replace(COMMAND_AUTHORIZATION_BEARER_RE, `$1${redactedValue}`) .replace(COMMAND_AUTHORIZATION_BEARER_RE, `$1${redactedValue}`)
.replace(COMMAND_CLI_SECRET_OPTION_RE, `$1${redactedValue}$3`) .replace(COMMAND_CLI_SECRET_OPTION_RE, `$1${redactedValue}$3`)
.replace(COMMAND_ENV_SECRET_ASSIGNMENT_RE, `$1${redactedValue}`) .replace(
COMMAND_ENV_SECRET_ASSIGNMENT_RE,
(_match, prefix: string, quote: string | undefined) =>
quote ? `${prefix}${quote}${redactedValue}${quote}` : `${prefix}${redactedValue}`,
)
.replace(COMMAND_OPENAI_KEY_RE, redactedValue) .replace(COMMAND_OPENAI_KEY_RE, redactedValue)
.replace(COMMAND_GITHUB_TOKEN_RE, redactedValue) .replace(COMMAND_GITHUB_TOKEN_RE, redactedValue)
.replace(COMMAND_JWT_RE, redactedValue); .replace(COMMAND_JWT_RE, redactedValue);
@@ -53,13 +53,14 @@ describe("buildInvocationEnvForLogs", () => {
const loggedEnv = buildInvocationEnvForLogs( const loggedEnv = buildInvocationEnvForLogs(
{ SAFE_VALUE: "visible" }, { SAFE_VALUE: "visible" },
{ {
resolvedCommand: "env OPENAI_API_KEY=sk-live-example custom-acp --token ghp_example_secret", resolvedCommand:
"env OPENAI_API_KEY=sk-live-example PAPERCLIP_API_KEY='paperclip-quoted-secret' custom-acp --paperclip-api-key=paperclip-flag-secret --token ghp_example_secret",
}, },
); );
expect(loggedEnv.SAFE_VALUE).toBe("visible"); expect(loggedEnv.SAFE_VALUE).toBe("visible");
expect(loggedEnv.PAPERCLIP_RESOLVED_COMMAND).toBe( expect(loggedEnv.PAPERCLIP_RESOLVED_COMMAND).toBe(
"env OPENAI_API_KEY=***REDACTED*** custom-acp --token ***REDACTED***", "env OPENAI_API_KEY=***REDACTED*** PAPERCLIP_API_KEY='***REDACTED***' custom-acp --paperclip-api-key=***REDACTED*** --token ***REDACTED***",
); );
}); });
}); });
+2 -1
View File
@@ -52,7 +52,8 @@ export const modelProfiles: AdapterModelProfileDefinition[] = [
description: "Use the lowest-cost known Codex local model lane without changing the primary model.", description: "Use the lowest-cost known Codex local model lane without changing the primary model.",
adapterConfig: { adapterConfig: {
model: "gpt-5.3-codex-spark", model: "gpt-5.3-codex-spark",
modelReasoningEffort: "low", // Spark is the cheap lane by model price; high effort keeps Codex coding behavior usable for delegated work.
modelReasoningEffort: "high",
}, },
source: "adapter_default", source: "adapter_default",
}, },
@@ -155,6 +155,11 @@ type ManagedRoutine = {
} | null; } | null;
}; };
type ManagedRoutineDefaultDrift = NonNullable<ManagedRoutine["defaultDrift"]>;
type ManagedRoutinesListItemWithDrift = ManagedRoutinesListItem & {
defaultDrift?: ManagedRoutineDefaultDrift | null;
};
type ManagedSkill = { type ManagedSkill = {
status: string; status: string;
skillId?: string | null; skillId?: string | null;
@@ -5905,7 +5910,7 @@ function SettingsBody({ context, initialSection = "root" }: { context: { company
const effectiveSelectedProjectId = selectedProjectId || data.managedProject.projectId || ""; const effectiveSelectedProjectId = selectedProjectId || data.managedProject.projectId || "";
const currentProjectOption = projectOptions.find((project) => project.id === effectiveSelectedProjectId) ?? projectFallbackOption; const currentProjectOption = projectOptions.find((project) => project.id === effectiveSelectedProjectId) ?? projectFallbackOption;
const currentEventPolicy = eventPolicy ?? data.eventIngestion; const currentEventPolicy = eventPolicy ?? data.eventIngestion;
const managedRoutineItems: ManagedRoutinesListItem[] = managedRoutines.map((routine) => { const managedRoutineItems: ManagedRoutinesListItemWithDrift[] = managedRoutines.map((routine) => {
const fallback = routineFallbackFor(routine); const fallback = routineFallbackFor(routine);
const key = routine.resourceKey ?? routine.routineId ?? fallback.title; const key = routine.resourceKey ?? routine.routineId ?? fallback.title;
const status = managedRoutineStatus(routine); const status = managedRoutineStatus(routine);
@@ -6132,7 +6137,7 @@ function SettingsBody({ context, initialSection = "root" }: { context: { company
async function resetManagedRoutineToDefaults(routine: ManagedRoutinesListItem) { async function resetManagedRoutineToDefaults(routine: ManagedRoutinesListItem) {
if (!context.companyId || !routine.resourceKey) return; if (!context.companyId || !routine.resourceKey) return;
const changedFields = routine.defaultDrift?.changedFields ?? []; const changedFields = (routine as ManagedRoutinesListItemWithDrift).defaultDrift?.changedFields ?? [];
const fieldList = changedFields.length > 0 ? changedFields.join(", ") : "managed defaults"; const fieldList = changedFields.length > 0 ? changedFields.join(", ") : "managed defaults";
const confirmed = typeof window === "undefined" || window.confirm( const confirmed = typeof window === "undefined" || window.confirm(
`Update "${routine.title}" to the current LLM Wiki plugin defaults? This replaces ${fieldList}. Cancel to keep the current custom routine text.`, `Update "${routine.title}" to the current LLM Wiki plugin defaults? This replaces ${fieldList}. Cancel to keep the current custom routine text.`,
@@ -1102,10 +1102,10 @@ export async function listPaperclipIngestionCandidates(ctx: PluginContext, input
return { projects, rootIssues: issues }; return { projects, rootIssues: issues };
} }
export async function updateEventIngestionSettings( export async function updateEventIngestionSettings(
ctx: PluginContext, ctx: PluginContext,
input: { companyId: string; settings: WikiEventIngestionSettingsUpdate }, input: { companyId: string; settings: WikiEventIngestionSettingsUpdate },
): Promise<WikiEventIngestionSettings> { ): Promise<WikiEventIngestionSettings> {
await requirePaperclipIngestionPolicy(ctx, { await requirePaperclipIngestionPolicy(ctx, {
companyId: input.companyId, companyId: input.companyId,
wikiId: normalizeWikiId(input.settings.wikiId), wikiId: normalizeWikiId(input.settings.wikiId),
+5
View File
@@ -1047,22 +1047,27 @@ export { deriveProjectUrlKey, normalizeProjectUrlKey, hasNonAsciiContent } from
export { export {
AGENT_MENTION_SCHEME, AGENT_MENTION_SCHEME,
PROJECT_MENTION_SCHEME, PROJECT_MENTION_SCHEME,
ROUTINE_MENTION_SCHEME,
SKILL_MENTION_SCHEME, SKILL_MENTION_SCHEME,
USER_MENTION_SCHEME, USER_MENTION_SCHEME,
buildAgentMentionHref, buildAgentMentionHref,
buildProjectMentionHref, buildProjectMentionHref,
buildRoutineMentionHref,
buildSkillMentionHref, buildSkillMentionHref,
buildUserMentionHref, buildUserMentionHref,
extractAgentMentionIds, extractAgentMentionIds,
extractProjectMentionIds, extractProjectMentionIds,
extractRoutineMentionIds,
extractSkillMentionIds, extractSkillMentionIds,
extractUserMentionIds, extractUserMentionIds,
parseAgentMentionHref, parseAgentMentionHref,
parseProjectMentionHref, parseProjectMentionHref,
parseRoutineMentionHref,
parseSkillMentionHref, parseSkillMentionHref,
parseUserMentionHref, parseUserMentionHref,
type ParsedAgentMention, type ParsedAgentMention,
type ParsedProjectMention, type ParsedProjectMention,
type ParsedRoutineMention,
type ParsedSkillMention, type ParsedSkillMention,
type ParsedUserMention, type ParsedUserMention,
} from "./project-mentions.js"; } from "./project-mentions.js";
@@ -2,14 +2,17 @@ import { describe, expect, it } from "vitest";
import { import {
buildAgentMentionHref, buildAgentMentionHref,
buildProjectMentionHref, buildProjectMentionHref,
buildRoutineMentionHref,
buildSkillMentionHref, buildSkillMentionHref,
buildUserMentionHref, buildUserMentionHref,
extractAgentMentionIds, extractAgentMentionIds,
extractProjectMentionIds, extractProjectMentionIds,
extractRoutineMentionIds,
extractSkillMentionIds, extractSkillMentionIds,
extractUserMentionIds, extractUserMentionIds,
parseAgentMentionHref, parseAgentMentionHref,
parseProjectMentionHref, parseProjectMentionHref,
parseRoutineMentionHref,
parseSkillMentionHref, parseSkillMentionHref,
parseUserMentionHref, parseUserMentionHref,
} from "./project-mentions.js"; } from "./project-mentions.js";
@@ -49,4 +52,12 @@ describe("project-mentions", () => {
}); });
expect(extractSkillMentionIds(`[/release-changelog](${href})`)).toEqual(["skill-123"]); expect(extractSkillMentionIds(`[/release-changelog](${href})`)).toEqual(["skill-123"]);
}); });
it("round-trips routine mentions", () => {
const href = buildRoutineMentionHref("routine-123");
expect(parseRoutineMentionHref(href)).toEqual({
routineId: "routine-123",
});
expect(extractRoutineMentionIds(`[/routine:Weekly review](${href})`)).toEqual(["routine-123"]);
});
}); });
+40
View File
@@ -2,6 +2,7 @@ export const PROJECT_MENTION_SCHEME = "project://";
export const AGENT_MENTION_SCHEME = "agent://"; export const AGENT_MENTION_SCHEME = "agent://";
export const USER_MENTION_SCHEME = "user://"; export const USER_MENTION_SCHEME = "user://";
export const SKILL_MENTION_SCHEME = "skill://"; export const SKILL_MENTION_SCHEME = "skill://";
export const ROUTINE_MENTION_SCHEME = "routine://";
const HEX_COLOR_RE = /^[0-9a-f]{6}$/i; const HEX_COLOR_RE = /^[0-9a-f]{6}$/i;
const HEX_COLOR_SHORT_RE = /^[0-9a-f]{3}$/i; const HEX_COLOR_SHORT_RE = /^[0-9a-f]{3}$/i;
@@ -11,6 +12,7 @@ const PROJECT_MENTION_LINK_RE = /\[[^\]]*]\((project:\/\/[^)\s]+)\)/gi;
const AGENT_MENTION_LINK_RE = /\[[^\]]*]\((agent:\/\/[^)\s]+)\)/gi; const AGENT_MENTION_LINK_RE = /\[[^\]]*]\((agent:\/\/[^)\s]+)\)/gi;
const USER_MENTION_LINK_RE = /\[[^\]]*]\((user:\/\/[^)\s]+)\)/gi; const USER_MENTION_LINK_RE = /\[[^\]]*]\((user:\/\/[^)\s]+)\)/gi;
const SKILL_MENTION_LINK_RE = /\[[^\]]*]\((skill:\/\/[^)\s]+)\)/gi; const SKILL_MENTION_LINK_RE = /\[[^\]]*]\((skill:\/\/[^)\s]+)\)/gi;
const ROUTINE_MENTION_LINK_RE = /\[[^\]]*]\((routine:\/\/[^)\s]+)\)/gi;
const AGENT_ICON_NAME_RE = /^[a-z0-9-]+$/i; const AGENT_ICON_NAME_RE = /^[a-z0-9-]+$/i;
const SKILL_SLUG_RE = /^[a-z0-9][a-z0-9-]*$/i; const SKILL_SLUG_RE = /^[a-z0-9][a-z0-9-]*$/i;
@@ -33,6 +35,10 @@ export interface ParsedSkillMention {
slug: string | null; slug: string | null;
} }
export interface ParsedRoutineMention {
routineId: string;
}
function normalizeHexColor(input: string | null | undefined): string | null { function normalizeHexColor(input: string | null | undefined): string | null {
if (!input) return null; if (!input) return null;
const trimmed = input.trim(); const trimmed = input.trim();
@@ -169,6 +175,28 @@ export function parseSkillMentionHref(href: string): ParsedSkillMention | null {
}; };
} }
export function buildRoutineMentionHref(routineId: string): string {
return `${ROUTINE_MENTION_SCHEME}${routineId.trim()}`;
}
export function parseRoutineMentionHref(href: string): ParsedRoutineMention | null {
if (!href.startsWith(ROUTINE_MENTION_SCHEME)) return null;
let url: URL;
try {
url = new URL(href);
} catch {
return null;
}
if (url.protocol !== "routine:") return null;
const routineId = `${url.hostname}${url.pathname}`.replace(/^\/+/, "").trim();
if (!routineId) return null;
return { routineId };
}
export function extractProjectMentionIds(markdown: string): string[] { export function extractProjectMentionIds(markdown: string): string[] {
if (!markdown) return []; if (!markdown) return [];
const ids = new Set<string>(); const ids = new Set<string>();
@@ -217,6 +245,18 @@ export function extractSkillMentionIds(markdown: string): string[] {
return [...ids]; return [...ids];
} }
export function extractRoutineMentionIds(markdown: string): string[] {
if (!markdown) return [];
const ids = new Set<string>();
const re = new RegExp(ROUTINE_MENTION_LINK_RE);
let match: RegExpExecArray | null;
while ((match = re.exec(markdown)) !== null) {
const parsed = parseRoutineMentionHref(match[1]);
if (parsed) ids.add(parsed.routineId);
}
return [...ids];
}
function normalizeAgentIcon(input: string | null | undefined): string | null { function normalizeAgentIcon(input: string | null | undefined): string | null {
if (!input) return null; if (!input) return null;
const trimmed = input.trim().toLowerCase(); const trimmed = input.trim().toLowerCase();
+4 -4
View File
@@ -31,7 +31,7 @@ export const upsertAgentInstructionsFileSchema = z.object({
export type UpsertAgentInstructionsFile = z.infer<typeof upsertAgentInstructionsFileSchema>; export type UpsertAgentInstructionsFile = z.infer<typeof upsertAgentInstructionsFileSchema>;
const adapterConfigSchema = z.record(z.unknown()).superRefine((value, ctx) => { const adapterConfigSchema = z.record(z.string(), z.unknown()).superRefine((value, ctx) => {
const envValue = value.env; const envValue = value.env;
if (envValue === undefined) return; if (envValue === undefined) return;
const parsed = envConfigSchema.safeParse(envValue); const parsed = envConfigSchema.safeParse(envValue);
@@ -46,7 +46,7 @@ const adapterConfigSchema = z.record(z.unknown()).superRefine((value, ctx) => {
export const createAgentInstructionsBundleSchema = z.object({ export const createAgentInstructionsBundleSchema = z.object({
entryFile: z.string().trim().min(1).optional(), entryFile: z.string().trim().min(1).optional(),
files: z.record(z.string()).refine((files) => Object.keys(files).length > 0, { files: z.record(z.string(), z.string()).refine((files) => Object.keys(files).length > 0, {
message: "instructionsBundle.files must contain at least one file", message: "instructionsBundle.files must contain at least one file",
}), }),
}); });
@@ -78,7 +78,7 @@ export const createAgentSchema = z.object({
defaultEnvironmentId: z.string().uuid().optional().nullable(), defaultEnvironmentId: z.string().uuid().optional().nullable(),
budgetMonthlyCents: z.number().int().nonnegative().optional().default(0), budgetMonthlyCents: z.number().int().nonnegative().optional().default(0),
permissions: agentPermissionsSchema.optional(), permissions: agentPermissionsSchema.optional(),
metadata: z.record(z.unknown()).optional().nullable(), metadata: z.record(z.string(), z.unknown()).optional().nullable(),
}); });
export type CreateAgent = z.infer<typeof createAgentSchema>; export type CreateAgent = z.infer<typeof createAgentSchema>;
@@ -126,7 +126,7 @@ export const wakeAgentSchema = z.object({
source: z.enum(["timer", "assignment", "on_demand", "automation"]).optional().default("on_demand"), source: z.enum(["timer", "assignment", "on_demand", "automation"]).optional().default("on_demand"),
triggerDetail: z.enum(["manual", "ping", "callback", "system"]).optional(), triggerDetail: z.enum(["manual", "ping", "callback", "system"]).optional(),
reason: z.string().optional().nullable(), reason: z.string().optional().nullable(),
payload: z.record(z.unknown()).optional().nullable(), payload: z.record(z.string(), z.unknown()).optional().nullable(),
idempotencyKey: z.string().optional().nullable(), idempotencyKey: z.string().optional().nullable(),
forceFreshSession: z.preprocess( forceFreshSession: z.preprocess(
(value) => (value === null ? undefined : value), (value) => (value === null ? undefined : value),
+2 -2
View File
@@ -5,7 +5,7 @@ import { multilineTextSchema } from "./text.js";
export const createApprovalSchema = z.object({ export const createApprovalSchema = z.object({
type: z.enum(APPROVAL_TYPES), type: z.enum(APPROVAL_TYPES),
requestedByAgentId: z.string().uuid().optional().nullable(), requestedByAgentId: z.string().uuid().optional().nullable(),
payload: z.record(z.unknown()), payload: z.record(z.string(), z.unknown()),
issueIds: z.array(z.string().uuid()).optional(), issueIds: z.array(z.string().uuid()).optional(),
}); });
@@ -24,7 +24,7 @@ export const requestApprovalRevisionSchema = z.object({
export type RequestApprovalRevision = z.infer<typeof requestApprovalRevisionSchema>; export type RequestApprovalRevision = z.infer<typeof requestApprovalRevisionSchema>;
export const resubmitApprovalSchema = z.object({ export const resubmitApprovalSchema = z.object({
payload: z.record(z.unknown()).optional(), payload: z.record(z.string(), z.unknown()).optional(),
}); });
export type ResubmitApproval = z.infer<typeof resubmitApprovalSchema>; export type ResubmitApproval = z.infer<typeof resubmitApprovalSchema>;
@@ -67,11 +67,11 @@ export const portabilityAgentManifestEntrySchema = z.object({
capabilities: z.string().nullable(), capabilities: z.string().nullable(),
reportsToSlug: z.string().min(1).nullable(), reportsToSlug: z.string().min(1).nullable(),
adapterType: z.string().min(1), adapterType: z.string().min(1),
adapterConfig: z.record(z.unknown()), adapterConfig: z.record(z.string(), z.unknown()),
runtimeConfig: z.record(z.unknown()), runtimeConfig: z.record(z.string(), z.unknown()),
permissions: z.record(z.unknown()), permissions: z.record(z.string(), z.unknown()),
budgetMonthlyCents: z.number().int().nonnegative(), budgetMonthlyCents: z.number().int().nonnegative(),
metadata: z.record(z.unknown()).nullable(), metadata: z.record(z.string(), z.unknown()).nullable(),
}); });
export const portabilitySkillManifestEntrySchema = z.object({ export const portabilitySkillManifestEntrySchema = z.object({
@@ -85,7 +85,7 @@ export const portabilitySkillManifestEntrySchema = z.object({
sourceRef: z.string().nullable(), sourceRef: z.string().nullable(),
trustLevel: z.string().nullable(), trustLevel: z.string().nullable(),
compatibility: z.string().nullable(), compatibility: z.string().nullable(),
metadata: z.record(z.unknown()).nullable(), metadata: z.record(z.string(), z.unknown()).nullable(),
fileInventory: z.array(z.object({ fileInventory: z.array(z.object({
path: z.string().min(1), path: z.string().min(1),
kind: z.string().min(1), kind: z.string().min(1),
@@ -102,7 +102,7 @@ export const portabilityProjectManifestEntrySchema = z.object({
targetDate: z.string().nullable(), targetDate: z.string().nullable(),
color: z.string().nullable(), color: z.string().nullable(),
status: z.string().nullable(), status: z.string().nullable(),
executionWorkspacePolicy: z.record(z.unknown()).nullable(), executionWorkspacePolicy: z.record(z.string(), z.unknown()).nullable(),
workspaces: z.array(z.object({ workspaces: z.array(z.object({
key: z.string().min(1), key: z.string().min(1),
name: z.string().min(1), name: z.string().min(1),
@@ -113,10 +113,10 @@ export const portabilityProjectManifestEntrySchema = z.object({
visibility: z.string().nullable(), visibility: z.string().nullable(),
setupCommand: z.string().nullable(), setupCommand: z.string().nullable(),
cleanupCommand: z.string().nullable(), cleanupCommand: z.string().nullable(),
metadata: z.record(z.unknown()).nullable(), metadata: z.record(z.string(), z.unknown()).nullable(),
isPrimary: z.boolean(), isPrimary: z.boolean(),
})).default([]), })).default([]),
metadata: z.record(z.unknown()).nullable(), metadata: z.record(z.string(), z.unknown()).nullable(),
}); });
export const portabilityIssueRoutineTriggerManifestEntrySchema = z.object({ export const portabilityIssueRoutineTriggerManifestEntrySchema = z.object({
@@ -157,15 +157,15 @@ export const portabilityIssueManifestEntrySchema = z.object({
description: z.string().nullable(), description: z.string().nullable(),
recurring: z.boolean().default(false), recurring: z.boolean().default(false),
routine: portabilityIssueRoutineManifestEntrySchema.nullable(), routine: portabilityIssueRoutineManifestEntrySchema.nullable(),
legacyRecurrence: z.record(z.unknown()).nullable(), legacyRecurrence: z.record(z.string(), z.unknown()).nullable(),
status: z.string().nullable(), status: z.string().nullable(),
priority: z.string().nullable(), priority: z.string().nullable(),
labelIds: z.array(z.string().min(1)).default([]), labelIds: z.array(z.string().min(1)).default([]),
billingCode: z.string().nullable(), billingCode: z.string().nullable(),
executionWorkspaceSettings: z.record(z.unknown()).nullable(), executionWorkspaceSettings: z.record(z.string(), z.unknown()).nullable(),
assigneeAdapterOverrides: z.record(z.unknown()).nullable(), assigneeAdapterOverrides: z.record(z.string(), z.unknown()).nullable(),
comments: z.array(portabilityIssueCommentManifestEntrySchema).default([]), comments: z.array(portabilityIssueCommentManifestEntrySchema).default([]),
metadata: z.record(z.unknown()).nullable(), metadata: z.record(z.string(), z.unknown()).nullable(),
}); });
export const portabilityManifestSchema = z.object({ export const portabilityManifestSchema = z.object({
@@ -197,7 +197,7 @@ export const portabilitySourceSchema = z.discriminatedUnion("type", [
z.object({ z.object({
type: z.literal("inline"), type: z.literal("inline"),
rootPath: z.string().min(1).optional().nullable(), rootPath: z.string().min(1).optional().nullable(),
files: z.record(portabilityFileEntrySchema), files: z.record(z.string(), portabilityFileEntrySchema),
}), }),
z.object({ z.object({
type: z.literal("github"), type: z.literal("github"),
@@ -251,7 +251,7 @@ export type CompanyPortabilityPreview = z.infer<typeof companyPortabilityPreview
export const portabilityAdapterOverrideSchema = z.object({ export const portabilityAdapterOverrideSchema = z.object({
adapterType: z.string().min(1), adapterType: z.string().min(1),
adapterConfig: z.record(z.unknown()).optional(), adapterConfig: z.record(z.string(), z.unknown()).optional(),
}); });
export const companyPortabilityImportSchema = companyPortabilityPreviewSchema.extend({ export const companyPortabilityImportSchema = companyPortabilityPreviewSchema.extend({
@@ -24,7 +24,7 @@ export const companySkillSchema = z.object({
trustLevel: companySkillTrustLevelSchema, trustLevel: companySkillTrustLevelSchema,
compatibility: companySkillCompatibilitySchema, compatibility: companySkillCompatibilitySchema,
fileInventory: z.array(companySkillFileInventoryEntrySchema).default([]), fileInventory: z.array(companySkillFileInventoryEntrySchema).default([]),
metadata: z.record(z.unknown()).nullable(), metadata: z.record(z.string(), z.unknown()).nullable(),
createdAt: z.coerce.date(), createdAt: z.coerce.date(),
updatedAt: z.coerce.date(), updatedAt: z.coerce.date(),
}); });
@@ -16,8 +16,8 @@ const environmentFields = {
description: z.string().optional().nullable(), description: z.string().optional().nullable(),
driver: environmentDriverSchema, driver: environmentDriverSchema,
status: environmentStatusSchema.optional().default("active"), status: environmentStatusSchema.optional().default("active"),
config: z.record(z.unknown()).optional().default({}), config: z.record(z.string(), z.unknown()).optional().default({}),
metadata: z.record(z.unknown()).optional().nullable(), metadata: z.record(z.string(), z.unknown()).optional().nullable(),
}; };
export const createEnvironmentSchema = z.object(environmentFields).strict(); export const createEnvironmentSchema = z.object(environmentFields).strict();
@@ -28,8 +28,8 @@ export const updateEnvironmentSchema = z.object({
description: z.string().optional().nullable(), description: z.string().optional().nullable(),
driver: environmentDriverSchema.optional(), driver: environmentDriverSchema.optional(),
status: environmentStatusSchema.optional(), status: environmentStatusSchema.optional(),
config: z.record(z.unknown()).optional(), config: z.record(z.string(), z.unknown()).optional(),
metadata: z.record(z.unknown()).optional().nullable(), metadata: z.record(z.string(), z.unknown()).optional().nullable(),
}).strict(); }).strict();
export type UpdateEnvironment = z.infer<typeof updateEnvironmentSchema>; export type UpdateEnvironment = z.infer<typeof updateEnvironmentSchema>;
@@ -37,7 +37,7 @@ export const probeEnvironmentConfigSchema = z.object({
name: z.string().min(1).optional(), name: z.string().min(1).optional(),
description: z.string().optional().nullable(), description: z.string().optional().nullable(),
driver: environmentDriverSchema, driver: environmentDriverSchema,
config: z.record(z.unknown()).optional().default({}), config: z.record(z.string(), z.unknown()).optional().default({}),
metadata: z.record(z.unknown()).optional().nullable(), metadata: z.record(z.string(), z.unknown()).optional().nullable(),
}).strict(); }).strict();
export type ProbeEnvironmentConfig = z.infer<typeof probeEnvironmentConfigSchema>; export type ProbeEnvironmentConfig = z.infer<typeof probeEnvironmentConfigSchema>;
@@ -13,7 +13,7 @@ export const executionWorkspaceConfigSchema = z.object({
provisionCommand: z.string().optional().nullable(), provisionCommand: z.string().optional().nullable(),
teardownCommand: z.string().optional().nullable(), teardownCommand: z.string().optional().nullable(),
cleanupCommand: z.string().optional().nullable(), cleanupCommand: z.string().optional().nullable(),
workspaceRuntime: z.record(z.unknown()).optional().nullable(), workspaceRuntime: z.record(z.string(), z.unknown()).optional().nullable(),
desiredState: z.enum(["running", "stopped", "manual"]).optional().nullable(), desiredState: z.enum(["running", "stopped", "manual"]).optional().nullable(),
serviceStates: z.record(z.enum(["running", "stopped", "manual"])).optional().nullable(), serviceStates: z.record(z.enum(["running", "stopped", "manual"])).optional().nullable(),
}).strict(); }).strict();
@@ -94,7 +94,7 @@ export const workspaceRuntimeServiceSchema = z.object({
lastUsedAt: z.coerce.date(), lastUsedAt: z.coerce.date(),
startedAt: z.coerce.date(), startedAt: z.coerce.date(),
stoppedAt: z.coerce.date().nullable(), stoppedAt: z.coerce.date().nullable(),
stopPolicy: z.record(z.unknown()).nullable(), stopPolicy: z.record(z.string(), z.unknown()).nullable(),
healthStatus: z.enum(["unknown", "healthy", "unhealthy"]), healthStatus: z.enum(["unknown", "healthy", "unhealthy"]),
configIndex: z.number().int().nonnegative().nullable().optional(), configIndex: z.number().int().nonnegative().nullable().optional(),
createdAt: z.coerce.date(), createdAt: z.coerce.date(),
@@ -125,7 +125,7 @@ export const updateExecutionWorkspaceSchema = z.object({
cleanupEligibleAt: z.string().datetime().optional().nullable(), cleanupEligibleAt: z.string().datetime().optional().nullable(),
cleanupReason: z.string().optional().nullable(), cleanupReason: z.string().optional().nullable(),
config: executionWorkspaceConfigSchema.optional().nullable(), config: executionWorkspaceConfigSchema.optional().nullable(),
metadata: z.record(z.unknown()).optional().nullable(), metadata: z.record(z.string(), z.unknown()).optional().nullable(),
}).strict(); }).strict();
export type UpdateExecutionWorkspace = z.infer<typeof updateExecutionWorkspaceSchema>; export type UpdateExecutionWorkspace = z.infer<typeof updateExecutionWorkspaceSchema>;
@@ -27,7 +27,7 @@ export const createIssueTreeHoldSchema = z
mode: issueTreeControlModeSchema, mode: issueTreeControlModeSchema,
reason: z.string().trim().min(1).max(1000).optional().nullable(), reason: z.string().trim().min(1).max(1000).optional().nullable(),
releasePolicy: issueTreeHoldReleasePolicySchema.optional().nullable(), releasePolicy: issueTreeHoldReleasePolicySchema.optional().nullable(),
metadata: z.record(z.unknown()).optional().nullable(), metadata: z.record(z.string(), z.unknown()).optional().nullable(),
}) })
.strict(); .strict();
@@ -37,7 +37,7 @@ export const releaseIssueTreeHoldSchema = z
.object({ .object({
reason: z.string().trim().min(1).max(1000).optional().nullable(), reason: z.string().trim().min(1).max(1000).optional().nullable(),
releasePolicy: issueTreeHoldReleasePolicySchema.optional().nullable(), releasePolicy: issueTreeHoldReleasePolicySchema.optional().nullable(),
metadata: z.record(z.unknown()).optional().nullable(), metadata: z.record(z.string(), z.unknown()).optional().nullable(),
}) })
.strict(); .strict();
@@ -73,6 +73,25 @@ describe("issue validators", () => {
).toBe(false); ).toBe(false);
}); });
it("allows restored recovery resolutions to return the source issue to todo", () => {
expect(
resolveIssueRecoveryActionSchema.parse({
outcome: "restored",
sourceIssueStatus: "todo",
}),
).toMatchObject({
outcome: "restored",
sourceIssueStatus: "todo",
});
expect(
resolveIssueRecoveryActionSchema.safeParse({
outcome: "false_positive",
sourceIssueStatus: "todo",
}).success,
).toBe(false);
});
it("allows cancelled recovery resolutions to atomically restore the source issue status", () => { it("allows cancelled recovery resolutions to atomically restore the source issue status", () => {
expect( expect(
resolveIssueRecoveryActionSchema.parse({ resolveIssueRecoveryActionSchema.parse({
+12 -8
View File
@@ -116,14 +116,14 @@ export const issueExecutionWorkspaceSettingsSchema = z
mode: z.enum(ISSUE_EXECUTION_WORKSPACE_PREFERENCES).optional(), mode: z.enum(ISSUE_EXECUTION_WORKSPACE_PREFERENCES).optional(),
environmentId: z.string().uuid().optional().nullable(), environmentId: z.string().uuid().optional().nullable(),
workspaceStrategy: executionWorkspaceStrategySchema.optional().nullable(), workspaceStrategy: executionWorkspaceStrategySchema.optional().nullable(),
workspaceRuntime: z.record(z.unknown()).optional().nullable(), workspaceRuntime: z.record(z.string(), z.unknown()).optional().nullable(),
}) })
.strict(); .strict();
export const issueAssigneeAdapterOverridesSchema = z export const issueAssigneeAdapterOverridesSchema = z
.object({ .object({
modelProfile: z.enum(MODEL_PROFILE_KEYS).optional(), modelProfile: z.enum(MODEL_PROFILE_KEYS).optional(),
adapterConfig: z.record(z.unknown()).optional(), adapterConfig: z.record(z.string(), z.unknown()).optional(),
useProjectWorkspace: z.boolean().optional(), useProjectWorkspace: z.boolean().optional(),
}) })
.strict(); .strict();
@@ -248,10 +248,10 @@ export const issueRecoveryActionReadModelSchema = z.object({
returnOwnerAgentId: z.string().uuid().nullable(), returnOwnerAgentId: z.string().uuid().nullable(),
cause: z.string().min(1), cause: z.string().min(1),
fingerprint: z.string().min(1), fingerprint: z.string().min(1),
evidence: z.record(z.unknown()), evidence: z.record(z.string(), z.unknown()),
nextAction: z.string().min(1), nextAction: z.string().min(1),
wakePolicy: z.record(z.unknown()).nullable(), wakePolicy: z.record(z.string(), z.unknown()).nullable(),
monitorPolicy: z.record(z.unknown()).nullable(), monitorPolicy: z.record(z.string(), z.unknown()).nullable(),
attemptCount: z.number().int().nonnegative(), attemptCount: z.number().int().nonnegative(),
maxAttempts: z.number().int().positive().nullable(), maxAttempts: z.number().int().positive().nullable(),
timeoutAt: z.union([z.date(), z.string().datetime()]).nullable(), timeoutAt: z.union([z.date(), z.string().datetime()]).nullable(),
@@ -275,14 +275,18 @@ const RESOLVE_ISSUE_RECOVERY_ACTION_OUTCOMES = [
export const resolveIssueRecoveryActionSchema = z.object({ export const resolveIssueRecoveryActionSchema = z.object({
actionId: z.string().uuid().optional(), actionId: z.string().uuid().optional(),
outcome: z.enum(RESOLVE_ISSUE_RECOVERY_ACTION_OUTCOMES), outcome: z.enum(RESOLVE_ISSUE_RECOVERY_ACTION_OUTCOMES),
sourceIssueStatus: z.enum(["done", "in_review", "blocked"]), sourceIssueStatus: z.enum(["todo", "done", "in_review", "blocked"]),
resolutionNote: multilineTextSchema.optional().nullable(), resolutionNote: multilineTextSchema.optional().nullable(),
}).strict().superRefine((value, ctx) => { }).strict().superRefine((value, ctx) => {
if (value.outcome === "restored") { if (value.outcome === "restored") {
if (value.sourceIssueStatus !== "done" && value.sourceIssueStatus !== "in_review") { if (
value.sourceIssueStatus !== "todo" &&
value.sourceIssueStatus !== "done" &&
value.sourceIssueStatus !== "in_review"
) {
ctx.addIssue({ ctx.addIssue({
code: z.ZodIssueCode.custom, code: z.ZodIssueCode.custom,
message: "Restored recovery actions must move the source issue to done or in_review", message: "Restored recovery actions must move the source issue to todo, done, or in_review",
path: ["sourceIssueStatus"], path: ["sourceIssueStatus"],
}); });
} }
+8 -8
View File
@@ -39,7 +39,7 @@ import { routineVariableSchema } from "./routine.js";
* *
* @see PLUGIN_SPEC.md §10.1 — Manifest shape * @see PLUGIN_SPEC.md §10.1 — Manifest shape
*/ */
export const jsonSchemaSchema = z.record(z.unknown()).refine( export const jsonSchemaSchema = z.record(z.string(), z.unknown()).refine(
(val) => { (val) => {
// Must have a "type" field if non-empty, or be a valid JSON Schema object // Must have a "type" field if non-empty, or be a valid JSON Schema object
if (Object.keys(val).length === 0) return true; if (Object.keys(val).length === 0) return true;
@@ -143,9 +143,9 @@ export const pluginManagedAgentDeclarationSchema = z.object({
capabilities: z.string().max(2000).nullable().optional(), capabilities: z.string().max(2000).nullable().optional(),
adapterType: z.string().min(1).max(100).optional(), adapterType: z.string().min(1).max(100).optional(),
adapterPreference: z.array(z.string().min(1).max(100)).max(10).optional(), adapterPreference: z.array(z.string().min(1).max(100)).max(10).optional(),
adapterConfig: z.record(z.unknown()).optional(), adapterConfig: z.record(z.string(), z.unknown()).optional(),
runtimeConfig: z.record(z.unknown()).optional(), runtimeConfig: z.record(z.string(), z.unknown()).optional(),
permissions: z.record(z.unknown()).optional(), permissions: z.record(z.string(), z.unknown()).optional(),
status: z.enum(["idle", "paused"]).optional(), status: z.enum(["idle", "paused"]).optional(),
budgetMonthlyCents: z.number().int().min(0).optional(), budgetMonthlyCents: z.number().int().min(0).optional(),
instructions: z.object({ instructions: z.object({
@@ -166,7 +166,7 @@ export const pluginManagedProjectDeclarationSchema = z.object({
description: z.string().max(2000).nullable().optional(), description: z.string().max(2000).nullable().optional(),
status: z.enum(["backlog", "planned", "in_progress", "completed", "cancelled"]).optional(), status: z.enum(["backlog", "planned", "in_progress", "completed", "cancelled"]).optional(),
color: z.string().max(32).nullable().optional(), color: z.string().max(32).nullable().optional(),
settings: z.record(z.unknown()).optional(), settings: z.record(z.string(), z.unknown()).optional(),
}); });
export type PluginManagedProjectDeclarationInput = z.infer<typeof pluginManagedProjectDeclarationSchema>; export type PluginManagedProjectDeclarationInput = z.infer<typeof pluginManagedProjectDeclarationSchema>;
@@ -373,7 +373,7 @@ const launcherBoundsByEnvironment: Record<
export const pluginLauncherActionDeclarationSchema = z.object({ export const pluginLauncherActionDeclarationSchema = z.object({
type: z.enum(PLUGIN_LAUNCHER_ACTIONS), type: z.enum(PLUGIN_LAUNCHER_ACTIONS),
target: z.string().min(1), target: z.string().min(1),
params: z.record(z.unknown()).optional(), params: z.record(z.string(), z.unknown()).optional(),
}).superRefine((value, ctx) => { }).superRefine((value, ctx) => {
if (value.type === "performAction" && value.target.includes("/")) { if (value.type === "performAction" && value.target.includes("/")) {
ctx.addIssue({ ctx.addIssue({
@@ -993,7 +993,7 @@ export type InstallPlugin = z.infer<typeof installPluginSchema>;
* the plugin's instanceConfigSchema is done at the service layer. * the plugin's instanceConfigSchema is done at the service layer.
*/ */
export const upsertPluginConfigSchema = z.object({ export const upsertPluginConfigSchema = z.object({
configJson: z.record(z.unknown()), configJson: z.record(z.string(), z.unknown()),
}); });
export type UpsertPluginConfig = z.infer<typeof upsertPluginConfigSchema>; export type UpsertPluginConfig = z.infer<typeof upsertPluginConfigSchema>;
@@ -1003,7 +1003,7 @@ export type UpsertPluginConfig = z.infer<typeof upsertPluginConfigSchema>;
* Allows a partial merge of config values. * Allows a partial merge of config values.
*/ */
export const patchPluginConfigSchema = z.object({ export const patchPluginConfigSchema = z.object({
configJson: z.record(z.unknown()), configJson: z.record(z.string(), z.unknown()),
}); });
export type PatchPluginConfig = z.infer<typeof patchPluginConfigSchema>; export type PatchPluginConfig = z.infer<typeof patchPluginConfigSchema>;
+7 -7
View File
@@ -21,16 +21,16 @@ export const projectExecutionWorkspacePolicySchema = z
defaultProjectWorkspaceId: z.string().uuid().optional().nullable(), defaultProjectWorkspaceId: z.string().uuid().optional().nullable(),
environmentId: z.string().uuid().optional().nullable(), environmentId: z.string().uuid().optional().nullable(),
workspaceStrategy: executionWorkspaceStrategySchema.optional().nullable(), workspaceStrategy: executionWorkspaceStrategySchema.optional().nullable(),
workspaceRuntime: z.record(z.unknown()).optional().nullable(), workspaceRuntime: z.record(z.string(), z.unknown()).optional().nullable(),
branchPolicy: z.record(z.unknown()).optional().nullable(), branchPolicy: z.record(z.string(), z.unknown()).optional().nullable(),
pullRequestPolicy: z.record(z.unknown()).optional().nullable(), pullRequestPolicy: z.record(z.string(), z.unknown()).optional().nullable(),
runtimePolicy: z.record(z.unknown()).optional().nullable(), runtimePolicy: z.record(z.string(), z.unknown()).optional().nullable(),
cleanupPolicy: z.record(z.unknown()).optional().nullable(), cleanupPolicy: z.record(z.string(), z.unknown()).optional().nullable(),
}) })
.strict(); .strict();
export const projectWorkspaceRuntimeConfigSchema = z.object({ export const projectWorkspaceRuntimeConfigSchema = z.object({
workspaceRuntime: z.record(z.unknown()).optional().nullable(), workspaceRuntime: z.record(z.string(), z.unknown()).optional().nullable(),
desiredState: z.enum(["running", "stopped", "manual"]).optional().nullable(), desiredState: z.enum(["running", "stopped", "manual"]).optional().nullable(),
serviceStates: z.record(z.enum(["running", "stopped", "manual"])).optional().nullable(), serviceStates: z.record(z.enum(["running", "stopped", "manual"])).optional().nullable(),
}).strict(); }).strict();
@@ -51,7 +51,7 @@ const projectWorkspaceFields = {
remoteProvider: z.string().optional().nullable(), remoteProvider: z.string().optional().nullable(),
remoteWorkspaceRef: z.string().optional().nullable(), remoteWorkspaceRef: z.string().optional().nullable(),
sharedWorkspaceKey: z.string().optional().nullable(), sharedWorkspaceKey: z.string().optional().nullable(),
metadata: z.record(z.unknown()).optional().nullable(), metadata: z.record(z.string(), z.unknown()).optional().nullable(),
runtimeConfig: projectWorkspaceRuntimeConfigSchema.optional().nullable(), runtimeConfig: projectWorkspaceRuntimeConfigSchema.optional().nullable(),
}; };
+2 -2
View File
@@ -146,8 +146,8 @@ export type UpdateRoutineTrigger = z.infer<typeof updateRoutineTriggerSchema>;
export const runRoutineSchema = z.object({ export const runRoutineSchema = z.object({
triggerId: z.string().uuid().optional().nullable(), triggerId: z.string().uuid().optional().nullable(),
payload: z.record(z.unknown()).optional().nullable(), payload: z.record(z.string(), z.unknown()).optional().nullable(),
variables: z.record(routineVariableValueSchema).optional().nullable(), variables: z.record(z.string(), routineVariableValueSchema).optional().nullable(),
projectId: z.string().uuid().optional().nullable(), projectId: z.string().uuid().optional().nullable(),
assigneeAgentId: z.string().uuid().optional().nullable(), assigneeAgentId: z.string().uuid().optional().nullable(),
idempotencyKey: z.string().trim().max(255).optional().nullable(), idempotencyKey: z.string().trim().max(255).optional().nullable(),
+6 -6
View File
@@ -25,7 +25,7 @@ export const envBindingSchema = z.union([
envBindingSecretRefSchema, envBindingSecretRefSchema,
]); ]);
export const envConfigSchema = z.record(envBindingSchema); export const envConfigSchema = z.record(z.string(), envBindingSchema);
export const createSecretSchema = z.object({ export const createSecretSchema = z.object({
name: z.string().min(1), name: z.string().min(1),
@@ -36,7 +36,7 @@ export const createSecretSchema = z.object({
value: z.string().min(1).optional().nullable(), value: z.string().min(1).optional().nullable(),
description: z.string().optional().nullable(), description: z.string().optional().nullable(),
externalRef: z.string().optional().nullable(), externalRef: z.string().optional().nullable(),
providerMetadata: z.record(z.unknown()).optional().nullable(), providerMetadata: z.record(z.string(), z.unknown()).optional().nullable(),
providerVersionRef: z.string().optional().nullable(), providerVersionRef: z.string().optional().nullable(),
}).superRefine((value, ctx) => { }).superRefine((value, ctx) => {
if ((value.managedMode ?? "paperclip_managed") === "external_reference") { if ((value.managedMode ?? "paperclip_managed") === "external_reference") {
@@ -83,7 +83,7 @@ export const updateSecretSchema = z.object({
providerConfigId: z.string().uuid().optional().nullable(), providerConfigId: z.string().uuid().optional().nullable(),
description: z.string().optional().nullable(), description: z.string().optional().nullable(),
externalRef: z.string().optional().nullable(), externalRef: z.string().optional().nullable(),
providerMetadata: z.record(z.unknown()).optional().nullable(), providerMetadata: z.record(z.string(), z.unknown()).optional().nullable(),
}); });
export type UpdateSecret = z.infer<typeof updateSecretSchema>; export type UpdateSecret = z.infer<typeof updateSecretSchema>;
@@ -198,7 +198,7 @@ export const createSecretProviderConfigSchema = z.object({
displayName: z.string().trim().min(1).max(120), displayName: z.string().trim().min(1).max(120),
status: z.enum(SECRET_PROVIDER_CONFIG_STATUSES).optional(), status: z.enum(SECRET_PROVIDER_CONFIG_STATUSES).optional(),
isDefault: z.boolean().optional(), isDefault: z.boolean().optional(),
config: z.record(z.unknown()).default({}), config: z.record(z.string(), z.unknown()).default({}),
}).superRefine((value, ctx) => { }).superRefine((value, ctx) => {
rejectSensitiveProviderConfigKeys(value.config, ctx); rejectSensitiveProviderConfigKeys(value.config, ctx);
const parsed = secretProviderConfigPayloadSchema.safeParse({ const parsed = secretProviderConfigPayloadSchema.safeParse({
@@ -236,7 +236,7 @@ export const updateSecretProviderConfigSchema = z.object({
displayName: z.string().trim().min(1).max(120).optional(), displayName: z.string().trim().min(1).max(120).optional(),
status: z.enum(SECRET_PROVIDER_CONFIG_STATUSES).optional(), status: z.enum(SECRET_PROVIDER_CONFIG_STATUSES).optional(),
isDefault: z.boolean().optional(), isDefault: z.boolean().optional(),
config: z.record(z.unknown()).optional(), config: z.record(z.string(), z.unknown()).optional(),
}).superRefine((value, ctx) => { }).superRefine((value, ctx) => {
if (value.config !== undefined) { if (value.config !== undefined) {
rejectSensitiveProviderConfigKeys(value.config, ctx); rejectSensitiveProviderConfigKeys(value.config, ctx);
@@ -268,7 +268,7 @@ export const remoteSecretImportSelectionSchema = z.object({
key: z.string().trim().min(1).max(120).regex(/^[a-zA-Z0-9_.-]+$/).optional().nullable(), key: z.string().trim().min(1).max(120).regex(/^[a-zA-Z0-9_.-]+$/).optional().nullable(),
description: z.string().trim().max(500).optional().nullable(), description: z.string().trim().max(500).optional().nullable(),
providerVersionRef: z.string().trim().min(1).max(512).optional().nullable(), providerVersionRef: z.string().trim().min(1).max(512).optional().nullable(),
providerMetadata: z.record(z.unknown()).optional().nullable(), providerMetadata: z.record(z.string(), z.unknown()).optional().nullable(),
}); });
export const remoteSecretImportSchema = z.object({ export const remoteSecretImportSchema = z.object({
@@ -43,7 +43,7 @@ export const createIssueWorkProductSchema = z.object({
isPrimary: z.boolean().optional().default(false), isPrimary: z.boolean().optional().default(false),
healthStatus: z.enum(["unknown", "healthy", "unhealthy"]).optional().default("unknown"), healthStatus: z.enum(["unknown", "healthy", "unhealthy"]).optional().default("unknown"),
summary: z.string().optional().nullable(), summary: z.string().optional().nullable(),
metadata: z.record(z.unknown()).optional().nullable(), metadata: z.record(z.string(), z.unknown()).optional().nullable(),
createdByRunId: z.string().uuid().optional().nullable(), createdByRunId: z.string().uuid().optional().nullable(),
}); });
@@ -84,6 +84,11 @@ vi.mock("../services/index.js", () => ({
getActiveForIssue: vi.fn(async () => null), getActiveForIssue: vi.fn(async () => null),
listActiveForIssues: vi.fn(async () => new Map()), listActiveForIssues: vi.fn(async () => new Map()),
}), }),
issueThreadInteractionService: () => ({
listForIssue: vi.fn(async () => []),
expireRequestConfirmationsSupersededByComment: vi.fn(async () => []),
expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []),
}),
documentService: () => ({}), documentService: () => ({}),
routineService: () => ({}), routineService: () => ({}),
workProductService: () => ({}), workProductService: () => ({}),
@@ -0,0 +1,279 @@
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 { eq, ne } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import {
agentTaskSessions,
agents,
companies,
createDb,
executionWorkspaces,
heartbeatRuns,
issues,
projects,
projectWorkspaces,
} 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,
sessionParams: { sessionId: "fresh-session" },
sessionDisplayId: "fresh-session",
summary: "Accepted plan workspace refresh test run.",
provider: "test",
model: "test-model",
})));
vi.mock("../adapters/index.js", () => ({
getServerAdapter: () => ({
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 accepted-plan workspace refresh tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
async function createGitRepo() {
const repoRoot = await mkdtemp(path.join(os.tmpdir(), "paperclip-accepted-plan-repo-"));
await execFileAsync("git", ["init"], { cwd: repoRoot });
await execFileAsync("git", ["config", "user.email", "paperclip-test@example.com"], { cwd: repoRoot });
await execFileAsync("git", ["config", "user.name", "Paperclip Test"], { cwd: repoRoot });
await writeFile(path.join(repoRoot, "README.md"), "accepted plan workspace refresh\n");
await execFileAsync("git", ["add", "README.md"], { cwd: repoRoot });
await execFileAsync("git", ["commit", "-m", "initial"], { cwd: repoRoot });
return repoRoot;
}
describeEmbeddedPostgres("accepted plan workspace refresh", () => {
let db!: ReturnType<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
const tempRoots: string[] = [];
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-accepted-plan-workspace-");
db = createDb(tempDb.connectionString);
}, 20_000);
afterEach(async () => {
adapterExecute.mockClear();
let idlePolls = 0;
for (let attempt = 0; attempt < 100; attempt += 1) {
const runs = await db
.select({ status: heartbeatRuns.status })
.from(heartbeatRuns);
const hasActiveRun = runs.some((run) => run.status === "queued" || run.status === "running");
if (!hasActiveRun) {
idlePolls += 1;
if (idlePolls >= 5) break;
} else {
idlePolls = 0;
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
while (tempRoots.length > 0) {
const root = tempRoots.pop();
if (root) await rm(root, { recursive: true, force: true }).catch(() => undefined);
}
});
afterAll(async () => {
await db.$client.end();
await tempDb?.cleanup();
});
it("realizes an isolated workspace and drops stale shared task-session params before executing", async () => {
const companyId = randomUUID();
const projectId = randomUUID();
const projectWorkspaceId = randomUUID();
const sharedExecutionWorkspaceId = randomUUID();
const issueId = randomUUID();
const agentId = randomUUID();
const repoRoot = await createGitRepo();
tempRoots.push(repoRoot);
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: "Accepted Plan Workspace Refresh",
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: {},
permissions: {},
createdAt: new Date(),
updatedAt: new Date(),
});
await db.insert(executionWorkspaces).values({
id: sharedExecutionWorkspaceId,
companyId,
projectId,
projectWorkspaceId,
mode: "shared_workspace",
strategyType: "project_primary",
name: "Shared planning workspace",
status: "active",
cwd: repoRoot,
providerType: "local_fs",
providerRef: repoRoot,
createdAt: new Date(),
updatedAt: new Date(),
});
await db.insert(issues).values({
id: issueId,
companyId,
projectId,
projectWorkspaceId,
title: "Implement accepted plan",
status: "in_progress",
workMode: "planning",
priority: "medium",
assigneeAgentId: agentId,
identifier: "PAP-9122",
executionWorkspaceId: sharedExecutionWorkspaceId,
executionWorkspaceSettings: {
mode: "isolated_workspace",
},
createdAt: new Date(),
updatedAt: new Date(),
});
await db.insert(agentTaskSessions).values({
companyId,
agentId,
adapterType: "codex_local",
taskKey: issueId,
sessionParamsJson: {
sessionId: "stale-shared-session",
cwd: repoRoot,
workspaceId: projectWorkspaceId,
},
sessionDisplayId: "stale-shared-session",
});
adapterExecute.mockImplementationOnce(async () => {
await db.update(issues).set({ status: "done", updatedAt: new Date() }).where(eq(issues.id, issueId));
return {
exitCode: 0,
signal: null,
timedOut: false,
sessionParams: { sessionId: "fresh-session" },
sessionDisplayId: "fresh-session",
summary: "Accepted plan workspace refresh test run.",
provider: "test",
model: "test-model",
};
});
const heartbeat = heartbeatService(db);
const run = await heartbeat.wakeup(agentId, {
source: "automation",
triggerDetail: "system",
reason: "issue_commented",
contextSnapshot: {
issueId,
taskId: issueId,
wakeReason: "issue_commented",
interactionKind: "request_confirmation",
interactionStatus: "accepted",
forceFreshSession: true,
workspaceRefreshReason: "accepted_plan_confirmation",
},
});
expect(run).not.toBeNull();
await vi.waitFor(async () => {
const latest = await heartbeat.getRun(run!.id);
expect(latest?.status).toBe("succeeded");
}, { timeout: 10_000 });
expect(adapterExecute).toHaveBeenCalledTimes(1);
const adapterInput = adapterExecute.mock.calls[0]?.[0] as {
runtime: { sessionId: string | null; sessionParams: Record<string, unknown> | null };
context: Record<string, unknown>;
};
expect(adapterInput.runtime.sessionId).toBeNull();
expect(adapterInput.runtime.sessionParams).toBeNull();
expect(adapterInput.context.paperclipWorkspace).toEqual(expect.objectContaining({
mode: "isolated_workspace",
strategy: "git_worktree",
}));
expect((adapterInput.context.paperclipWorkspace as { cwd: string }).cwd).not.toBe(repoRoot);
const refreshedIssue = await db
.select({
executionWorkspaceId: issues.executionWorkspaceId,
executionWorkspaceSettings: issues.executionWorkspaceSettings,
})
.from(issues)
.where(eq(issues.id, issueId))
.then((rows) => rows[0]);
expect(refreshedIssue?.executionWorkspaceId).toBeTruthy();
expect(refreshedIssue?.executionWorkspaceId).not.toBe(sharedExecutionWorkspaceId);
expect(refreshedIssue?.executionWorkspaceSettings).toMatchObject({
mode: "isolated_workspace",
});
const isolatedRows = await db
.select()
.from(executionWorkspaces)
.where(ne(executionWorkspaces.id, sharedExecutionWorkspaceId));
expect(isolatedRows).toHaveLength(1);
expect(isolatedRows[0]).toMatchObject({
mode: "isolated_workspace",
strategyType: "git_worktree",
sourceIssueId: issueId,
});
expect(isolatedRows[0]?.cwd).not.toBe(repoRoot);
}, 20_000);
});
@@ -2,11 +2,15 @@ import { randomUUID } from "node:crypto";
import { and, eq, sql } from "drizzle-orm"; import { and, eq, sql } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { import {
activityLog,
agents, agents,
companies, companies,
createDb, createDb,
heartbeatRunEvents,
heartbeatRunWatchdogDecisions, heartbeatRunWatchdogDecisions,
heartbeatRuns, heartbeatRuns,
issueComments,
issueRecoveryActions,
issueRelations, issueRelations,
issues, issues,
} from "@paperclipai/db"; } from "@paperclipai/db";
@@ -94,7 +98,15 @@ describeEmbeddedPostgres("active-run output watchdog", () => {
await tempDb?.cleanup(); await tempDb?.cleanup();
}); });
async function seedRunningRun(opts: { now: Date; ageMs: number; withOutput?: boolean; logChunk?: string }) { async function seedRunningRun(opts: {
now: Date;
ageMs: number;
withOutput?: boolean;
logChunk?: string;
sourceStatus?: "in_progress" | "done" | "cancelled";
sourceOriginKind?: string;
sameRunTerminalEvidence?: "activity" | "comment";
}) {
const companyId = randomUUID(); const companyId = randomUUID();
const managerId = randomUUID(); const managerId = randomUUID();
const coderId = randomUUID(); const coderId = randomUUID();
@@ -103,6 +115,8 @@ describeEmbeddedPostgres("active-run output watchdog", () => {
const issuePrefix = `W${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`; const issuePrefix = `W${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
const startedAt = new Date(opts.now.getTime() - opts.ageMs); const startedAt = new Date(opts.now.getTime() - opts.ageMs);
const lastOutputAt = opts.withOutput ? new Date(opts.now.getTime() - 5 * 60 * 1000) : null; const lastOutputAt = opts.withOutput ? new Date(opts.now.getTime() - 5 * 60 * 1000) : null;
const sourceStatus = opts.sourceStatus ?? "in_progress";
const terminalEvidenceAt = new Date(startedAt.getTime() + 10 * 60 * 1000);
await db.insert(companies).values({ await db.insert(companies).values({
id: companyId, id: companyId,
@@ -139,11 +153,14 @@ describeEmbeddedPostgres("active-run output watchdog", () => {
id: issueId, id: issueId,
companyId, companyId,
title: "Long running implementation", title: "Long running implementation",
status: "in_progress", status: sourceStatus,
priority: "medium", priority: "medium",
assigneeAgentId: coderId, assigneeAgentId: coderId,
issueNumber: 1, issueNumber: 1,
identifier: `${issuePrefix}-1`, identifier: `${issuePrefix}-1`,
originKind: opts.sourceOriginKind ?? "manual",
completedAt: sourceStatus === "done" ? terminalEvidenceAt : null,
cancelledAt: sourceStatus === "cancelled" ? terminalEvidenceAt : null,
updatedAt: startedAt, updatedAt: startedAt,
createdAt: startedAt, createdAt: startedAt,
}); });
@@ -181,6 +198,35 @@ describeEmbeddedPostgres("active-run output watchdog", () => {
.where(eq(heartbeatRuns.id, runId)); .where(eq(heartbeatRuns.id, runId));
} }
await db.update(issues).set({ executionRunId: runId }).where(eq(issues.id, issueId)); await db.update(issues).set({ executionRunId: runId }).where(eq(issues.id, issueId));
if (opts.sameRunTerminalEvidence === "activity") {
await db.insert(activityLog).values({
companyId,
actorType: "agent",
actorId: coderId,
agentId: coderId,
runId,
action: "issue.updated",
entityType: "issue",
entityId: issueId,
details: {
identifier: `${issuePrefix}-1`,
status: sourceStatus,
_previous: { status: "in_progress" },
},
createdAt: terminalEvidenceAt,
});
} else if (opts.sameRunTerminalEvidence === "comment") {
await db.insert(issueComments).values({
companyId,
issueId,
authorAgentId: coderId,
authorType: "agent",
createdByRunId: runId,
body: "Completed and verified.",
createdAt: terminalEvidenceAt,
updatedAt: terminalEvidenceAt,
});
}
return { companyId, managerId, coderId, issueId, runId, issuePrefix }; return { companyId, managerId, coderId, issueId, runId, issuePrefix };
} }
@@ -271,6 +317,211 @@ describeEmbeddedPostgres("active-run output watchdog", () => {
expect(source?.status).toBe("blocked"); expect(source?.status).toBe("blocked");
}); });
it("folds terminal source issues with same-run durable evidence instead of creating watchdog work", async () => {
const now = new Date("2026-04-22T20:00:00.000Z");
const { companyId, coderId, issueId, runId } = await seedRunningRun({
now,
ageMs: ACTIVE_RUN_OUTPUT_CRITICAL_THRESHOLD_MS + 60_000,
sourceStatus: "done",
sameRunTerminalEvidence: "activity",
});
const heartbeat = heartbeatService(db);
const result = await heartbeat.scanSilentActiveRuns({ now, companyId });
expect(result).toMatchObject({ created: 0, folded: 1, skipped: 0 });
const evaluations = await db
.select()
.from(issues)
.where(and(eq(issues.companyId, companyId), eq(issues.originKind, "stale_active_run_evaluation")));
expect(evaluations).toHaveLength(0);
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId));
expect(run?.status).toBe("succeeded");
expect(run?.errorCode).toBeNull();
expect(run?.finishedAt?.toISOString()).toBe(now.toISOString());
expect(run?.resultJson).toMatchObject({
sourceResolvedWatchdogFold: {
sourceIssueId: issueId,
sourceIssueStatus: "done",
sameRunEvidenceKind: "activity",
evaluationIssueId: null,
evaluationIssueIdentifier: null,
cleanup: { outcome: "no_process_metadata" },
},
});
const [source] = await db.select().from(issues).where(eq(issues.id, issueId));
expect(source?.executionRunId).toBeNull();
const [agent] = await db.select().from(agents).where(eq(agents.id, coderId));
expect(agent?.status).toBe("idle");
const [decision] = await db
.select()
.from(heartbeatRunWatchdogDecisions)
.where(eq(heartbeatRunWatchdogDecisions.runId, runId));
expect(decision?.decision).toBe("dismissed_false_positive");
const [event] = await db
.select()
.from(heartbeatRunEvents)
.where(eq(heartbeatRunEvents.runId, runId));
expect(event?.message).toContain("Source-resolved watchdog fold");
});
it("still escalates terminal source issues without same-run terminal evidence", async () => {
const now = new Date("2026-04-22T20:00:00.000Z");
const { companyId, runId } = await seedRunningRun({
now,
ageMs: ACTIVE_RUN_OUTPUT_CRITICAL_THRESHOLD_MS + 60_000,
sourceStatus: "done",
});
const heartbeat = heartbeatService(db);
const result = await heartbeat.scanSilentActiveRuns({ now, companyId });
expect(result).toMatchObject({ created: 1, folded: 0 });
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId));
expect(run?.status).toBe("running");
const [evaluation] = await db
.select()
.from(issues)
.where(and(eq(issues.companyId, companyId), eq(issues.originKind, "stale_active_run_evaluation")));
expect(evaluation?.originId).toBe(runId);
expect(evaluation?.parentId).toBeNull();
});
it("still escalates when a same-run comment is followed by another actor marking the source done", async () => {
const now = new Date("2026-04-22T20:00:00.000Z");
const { companyId, issueId, runId, issuePrefix } = await seedRunningRun({
now,
ageMs: ACTIVE_RUN_OUTPUT_CRITICAL_THRESHOLD_MS + 60_000,
sourceStatus: "in_progress",
sameRunTerminalEvidence: "comment",
});
const completedAt = new Date(now.getTime() - 5 * 60_000);
await db
.update(issues)
.set({ status: "done", completedAt, updatedAt: completedAt })
.where(eq(issues.id, issueId));
await db.insert(activityLog).values({
companyId,
actorType: "user",
actorId: "board-user",
agentId: null,
runId: null,
action: "issue.updated",
entityType: "issue",
entityId: issueId,
details: {
identifier: `${issuePrefix}-1`,
status: "done",
_previous: { status: "in_progress" },
},
createdAt: completedAt,
});
const heartbeat = heartbeatService(db);
const result = await heartbeat.scanSilentActiveRuns({ now, companyId });
expect(result).toMatchObject({ created: 1, folded: 0 });
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId));
expect(run?.status).toBe("running");
const [evaluation] = await db
.select()
.from(issues)
.where(and(eq(issues.companyId, companyId), eq(issues.originKind, "stale_active_run_evaluation")));
expect(evaluation?.originId).toBe(runId);
expect(evaluation?.parentId).toBeNull();
});
it("folds existing evaluation and active watchdog recovery action idempotently", async () => {
const now = new Date("2026-04-22T20:00:00.000Z");
const { companyId, managerId, issueId, runId, issuePrefix } = await seedRunningRun({
now,
ageMs: ACTIVE_RUN_OUTPUT_CRITICAL_THRESHOLD_MS + 60_000,
sourceStatus: "done",
sameRunTerminalEvidence: "activity",
});
const evaluationIssueId = randomUUID();
await db.insert(issues).values({
id: evaluationIssueId,
companyId,
title: "Existing stale evaluation",
status: "todo",
priority: "high",
assigneeAgentId: managerId,
issueNumber: 2,
identifier: `${issuePrefix}-2`,
originKind: "stale_active_run_evaluation",
originId: runId,
originRunId: runId,
originFingerprint: `stale_active_run:${companyId}:${runId}`,
});
await db.insert(issueRelations).values({
companyId,
issueId: evaluationIssueId,
relatedIssueId: issueId,
type: "blocks",
});
await db.insert(issueRecoveryActions).values({
companyId,
sourceIssueId: issueId,
recoveryIssueId: evaluationIssueId,
kind: "active_run_watchdog",
status: "active",
ownerType: "agent",
ownerAgentId: managerId,
cause: "active_run_watchdog",
fingerprint: `active-run-watchdog:${companyId}:${runId}:${issueId}`,
evidence: { runId },
nextAction: "Review stale active run",
});
const heartbeat = heartbeatService(db);
const first = await heartbeat.scanSilentActiveRuns({ now, companyId });
const second = await heartbeat.scanSilentActiveRuns({ now, companyId });
expect(first).toMatchObject({ created: 0, folded: 1 });
expect(second).toMatchObject({ scanned: 0, created: 0, folded: 0 });
const [evaluation] = await db.select().from(issues).where(eq(issues.id, evaluationIssueId));
expect(evaluation?.status).toBe("done");
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId));
expect(run?.resultJson).toMatchObject({
sourceResolvedWatchdogFold: {
sourceIssueId: issueId,
sourceIssueStatus: "done",
evaluationIssueId,
evaluationIssueIdentifier: `${issuePrefix}-2`,
},
});
const [action] = await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, issueId));
expect(action?.status).toBe("resolved");
expect(action?.outcome).toBe("false_positive");
const decisions = await db
.select()
.from(heartbeatRunWatchdogDecisions)
.where(eq(heartbeatRunWatchdogDecisions.runId, runId));
expect(decisions).toHaveLength(1);
});
it("refuses recovery-on-recovery stale-run recursion", async () => {
const now = new Date("2026-04-22T20:00:00.000Z");
const { companyId } = await seedRunningRun({
now,
ageMs: ACTIVE_RUN_OUTPUT_CRITICAL_THRESHOLD_MS + 60_000,
sourceOriginKind: "stale_active_run_evaluation",
});
const heartbeat = heartbeatService(db);
const result = await heartbeat.scanSilentActiveRuns({ now, companyId });
expect(result).toMatchObject({ created: 0, skipped: 1 });
const evaluations = await db
.select()
.from(issues)
.where(and(eq(issues.companyId, companyId), eq(issues.originKind, "stale_active_run_evaluation")));
expect(evaluations).toHaveLength(1);
});
it("skips snoozed runs and healthy noisy runs", async () => { it("skips snoozed runs and healthy noisy runs", async () => {
const now = new Date("2026-04-22T20:00:00.000Z"); const now = new Date("2026-04-22T20:00:00.000Z");
const stale = await seedRunningRun({ const stale = await seedRunningRun({
@@ -332,6 +332,82 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => {
}); });
}); });
it("treats open recovery issues as active waiting paths for non-assigned-backlog states", async () => {
await enableAutoRecovery();
const { companyId, managerId, blockedIssueId, blockerIssueId } = await seedBlockedChain();
const existingEscalationId = randomUUID();
await db.insert(issues).values({
id: existingEscalationId,
companyId,
title: "Existing liveness unblock work",
status: "todo",
priority: "high",
parentId: blockerIssueId,
assigneeAgentId: managerId,
issueNumber: 5,
identifier: `${`P${companyId.replace(/-/g, "").slice(0, 4)}`}-5`,
originKind: "harness_liveness_escalation",
originId: [
"harness_liveness",
companyId,
blockedIssueId,
"in_review_without_action_path",
blockerIssueId,
].join(":"),
});
const result = await heartbeatService(db).reconcileIssueGraphLiveness();
expect(result.findings).toBe(0);
expect(result.escalationsCreated).toBe(0);
expect(result.existingEscalations).toBe(0);
const escalations = await db
.select()
.from(issues)
.where(and(eq(issues.companyId, companyId), eq(issues.originKind, "harness_liveness_escalation")));
expect(escalations).toHaveLength(1);
});
it("keeps active invalid_review_participant recoveries from being retired", async () => {
await enableAutoRecovery();
const { companyId, managerId, blockedIssueId, blockerIssueId } = await seedBlockedChain();
const existingEscalationId = randomUUID();
await db.insert(issues).values({
id: existingEscalationId,
companyId,
title: "Existing invalid review participant unblock work",
status: "todo",
priority: "high",
parentId: blockedIssueId,
assigneeAgentId: managerId,
issueNumber: 5,
identifier: `${`P${companyId.replace(/-/g, "").slice(0, 4)}`}-5`,
originKind: "harness_liveness_escalation",
originId: [
"harness_liveness",
companyId,
blockedIssueId,
"invalid_review_participant",
blockerIssueId,
].join(":"),
});
const result = await heartbeatService(db).reconcileIssueGraphLiveness();
expect(result.findings).toBe(0);
expect(result.escalationsCreated).toBe(0);
expect(result.existingEscalations).toBe(0);
const escalations = await db
.select()
.from(issues)
.where(and(eq(issues.companyId, companyId), eq(issues.originKind, "harness_liveness_escalation")));
expect(escalations).toHaveLength(1);
});
it("creates one manager escalation, preserves blockers, and records owner selection", async () => { it("creates one manager escalation, preserves blockers, and records owner selection", async () => {
await enableAutoRecovery(); await enableAutoRecovery();
const { companyId, managerId, blockedIssueId, blockerIssueId } = await seedBlockedChain(); const { companyId, managerId, blockedIssueId, blockerIssueId } = await seedBlockedChain();
@@ -1,5 +1,8 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { AdapterModelProfileDefinition } from "../adapters/index.js"; import {
listAdapterModelProfiles,
type AdapterModelProfileDefinition,
} from "../adapters/index.js";
import { import {
mergeModelProfileAdapterConfig, mergeModelProfileAdapterConfig,
normalizeModelProfileWakeContext, normalizeModelProfileWakeContext,
@@ -17,6 +20,27 @@ const cheapProfile: AdapterModelProfileDefinition = {
}; };
describe("heartbeat model profile application", () => { describe("heartbeat model profile application", () => {
it("uses the Codex local adapter cheap default when the agent has no runtime override", async () => {
const modelProfile = resolveModelProfileApplication({
adapterModelProfiles: await listAdapterModelProfiles("codex_local"),
agentRuntimeConfig: {},
issueModelProfile: "cheap",
contextSnapshot: {},
});
expect(modelProfile).toMatchObject({
requested: "cheap",
requestedBy: "issue_override",
applied: "cheap",
configSource: "adapter_default",
fallbackReason: null,
adapterConfig: {
model: "gpt-5.3-codex-spark",
modelReasoningEffort: "high",
},
});
});
it("applies cheap profile patches before explicit issue adapter config overrides", () => { it("applies cheap profile patches before explicit issue adapter config overrides", () => {
const modelProfile = resolveModelProfileApplication({ const modelProfile = resolveModelProfileApplication({
adapterModelProfiles: [cheapProfile], adapterModelProfiles: [cheapProfile],
@@ -21,4 +21,21 @@ describe("compactRunLogChunk", () => {
expect(compacted).toContain("[paperclip truncated run log chunk:"); expect(compacted).toContain("[paperclip truncated run log chunk:");
expect(compacted.endsWith("tail")).toBe(true); expect(compacted.endsWith("tail")).toBe(true);
}); });
it("redacts Paperclip credential shapes before persisting run-log chunks", () => {
const chunk = [
"Authorization: Bearer live-bearer-token-value",
`export PAPERCLIP_API_KEY='paperclip-shell-secret'`,
`payload {"PAPERCLIP_API_KEY":"paperclip-json-secret"}`,
"--paperclip-api-key=paperclip-flag-secret",
].join("\n");
const compacted = compactRunLogChunk(chunk);
expect(compacted).toContain("***REDACTED***");
expect(compacted).not.toContain("live-bearer-token-value");
expect(compacted).not.toContain("paperclip-shell-secret");
expect(compacted).not.toContain("paperclip-json-secret");
expect(compacted).not.toContain("paperclip-flag-secret");
});
}); });
@@ -322,6 +322,18 @@ describe("shouldResetTaskSessionForWake", () => {
).toBe(true); ).toBe(true);
}); });
it("resets session context for accepted planning confirmations that refresh workspace selection", () => {
expect(
shouldResetTaskSessionForWake({
wakeReason: "issue_commented",
interactionKind: "request_confirmation",
interactionStatus: "accepted",
forceFreshSession: true,
workspaceRefreshReason: "accepted_plan_confirmation",
}),
).toBe(true);
});
it("does not reset session context on mention wake comment", () => { it("does not reset session context on mention wake comment", () => {
expect( expect(
shouldResetTaskSessionForWake({ shouldResetTaskSessionForWake({
@@ -106,6 +106,11 @@ function registerModuleMocks() {
syncDocument: async () => undefined, syncDocument: async () => undefined,
syncIssue: async () => undefined, syncIssue: async () => undefined,
}), }),
issueThreadInteractionService: () => ({
listForIssue: vi.fn(async () => []),
expireRequestConfirmationsSupersededByComment: vi.fn(async () => []),
expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []),
}),
issueService: () => mockIssueService, issueService: () => mockIssueService,
logActivity: mockLogActivity, logActivity: mockLogActivity,
projectService: () => ({}), projectService: () => ({}),
@@ -8,6 +8,7 @@ const companyId = "22222222-2222-4222-8222-222222222222";
const ownerAgentId = "33333333-3333-4333-8333-333333333333"; const ownerAgentId = "33333333-3333-4333-8333-333333333333";
const peerAgentId = "44444444-4444-4444-8444-444444444444"; const peerAgentId = "44444444-4444-4444-8444-444444444444";
const ownerRunId = "55555555-5555-4555-8555-555555555555"; const ownerRunId = "55555555-5555-4555-8555-555555555555";
const recoveryActionId = "77777777-7777-4777-8777-777777777777";
const mockIssueService = vi.hoisted(() => ({ const mockIssueService = vi.hoisted(() => ({
addComment: vi.fn(), addComment: vi.fn(),
@@ -62,6 +63,14 @@ const mockIssueThreadInteractionService = vi.hoisted(() => ({
})); }));
const mockIssueRecoveryActionService = vi.hoisted(() => ({ const mockIssueRecoveryActionService = vi.hoisted(() => ({
getActiveForIssue: vi.fn(async () => null), getActiveForIssue: vi.fn(async () => null),
resolveActiveForIssue: vi.fn(async () => null),
}));
const mockHeartbeatService = vi.hoisted(() => ({
wakeup: vi.fn(async () => undefined),
reportRunActivity: vi.fn(async () => undefined),
getRun: vi.fn(async () => null),
getActiveRunForAgent: vi.fn(async () => null),
cancelRun: vi.fn(async () => null),
})); }));
function registerRouteMocks() { function registerRouteMocks() {
@@ -109,13 +118,7 @@ function registerRouteMocks() {
saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })), saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })),
}), }),
goalService: () => ({}), goalService: () => ({}),
heartbeatService: () => ({ heartbeatService: () => mockHeartbeatService,
wakeup: vi.fn(async () => undefined),
reportRunActivity: vi.fn(async () => undefined),
getRun: vi.fn(async () => null),
getActiveRunForAgent: vi.fn(async () => null),
cancelRun: vi.fn(async () => null),
}),
instanceSettingsService: () => ({ instanceSettingsService: () => ({
get: vi.fn(async () => ({ get: vi.fn(async () => ({
id: "instance-settings-1", id: "instance-settings-1",
@@ -189,13 +192,16 @@ async function createApp(actor: Record<string, unknown>) {
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"), vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
vi.importActual<typeof import("../routes/issues.js")>("../routes/issues.js"), vi.importActual<typeof import("../routes/issues.js")>("../routes/issues.js"),
]); ]);
const fakeDb = {
transaction: async (callback: (tx: Record<string, never>) => Promise<unknown>) => callback({}),
};
const app = express(); const app = express();
app.use(express.json()); app.use(express.json());
app.use((req, _res, next) => { app.use((req, _res, next) => {
(req as any).actor = actor; (req as any).actor = actor;
next(); next();
}); });
app.use("/api", issueRoutes({} as any, mockStorageService as any)); app.use("/api", issueRoutes(fakeDb as any, mockStorageService as any));
app.use(errorHandler); app.use(errorHandler);
return app; return app;
} }
@@ -265,6 +271,45 @@ describe("agent issue mutation checkout ownership", () => {
mockIssueService.listWakeableBlockedDependents.mockReset(); mockIssueService.listWakeableBlockedDependents.mockReset();
mockIssueRecoveryActionService.getActiveForIssue.mockReset(); mockIssueRecoveryActionService.getActiveForIssue.mockReset();
mockIssueRecoveryActionService.getActiveForIssue.mockResolvedValue(null); mockIssueRecoveryActionService.getActiveForIssue.mockResolvedValue(null);
mockIssueRecoveryActionService.resolveActiveForIssue.mockReset();
mockIssueRecoveryActionService.resolveActiveForIssue.mockResolvedValue({
id: recoveryActionId,
companyId,
sourceIssueId: issueId,
recoveryIssueId: null,
kind: "issue_graph_liveness",
status: "resolved",
ownerType: "agent",
ownerAgentId,
ownerUserId: null,
previousOwnerAgentId: null,
returnOwnerAgentId: null,
cause: "issue_graph_liveness",
fingerprint: "graph-liveness:test",
evidence: {},
nextAction: "Restore a live execution path.",
wakePolicy: null,
monitorPolicy: null,
attemptCount: 1,
maxAttempts: null,
timeoutAt: null,
lastAttemptAt: new Date("2026-05-13T18:00:00.000Z"),
outcome: "restored",
resolutionNote: "Resolved by recovery owner",
resolvedAt: new Date("2026-05-13T18:05:00.000Z"),
createdAt: new Date("2026-05-13T17:55:00.000Z"),
updatedAt: new Date("2026-05-13T18:05:00.000Z"),
});
mockHeartbeatService.wakeup.mockReset();
mockHeartbeatService.wakeup.mockResolvedValue(undefined);
mockHeartbeatService.reportRunActivity.mockReset();
mockHeartbeatService.reportRunActivity.mockResolvedValue(undefined);
mockHeartbeatService.getRun.mockReset();
mockHeartbeatService.getRun.mockResolvedValue(null);
mockHeartbeatService.getActiveRunForAgent.mockReset();
mockHeartbeatService.getActiveRunForAgent.mockResolvedValue(null);
mockHeartbeatService.cancelRun.mockReset();
mockHeartbeatService.cancelRun.mockResolvedValue(null);
mockIssueService.remove.mockReset(); mockIssueService.remove.mockReset();
mockIssueService.removeAttachment.mockReset(); mockIssueService.removeAttachment.mockReset();
mockIssueService.update.mockReset(); mockIssueService.update.mockReset();
@@ -415,6 +460,47 @@ describe("agent issue mutation checkout ownership", () => {
); );
}); });
it("preserves committed issue updates, comments, documents, and work product writes when recovery revalidation fails", async () => {
const app = await createApp(ownerActor());
mockIssueRecoveryActionService.getActiveForIssue.mockRejectedValueOnce(new Error("revalidation read failed"));
await request(app)
.patch(`/api/issues/${issueId}`)
.send({ title: "Updated after commit" })
.expect(200);
mockIssueRecoveryActionService.getActiveForIssue.mockRejectedValueOnce(new Error("revalidation read failed"));
await request(app)
.post(`/api/issues/${issueId}/comments`)
.send({ body: "progress update" })
.expect(201);
mockIssueRecoveryActionService.getActiveForIssue.mockRejectedValueOnce(new Error("revalidation read failed"));
await request(app)
.put(`/api/issues/${issueId}/documents/plan`)
.send({ format: "markdown", body: "# updated" })
.expect(200);
mockIssueRecoveryActionService.getActiveForIssue.mockRejectedValueOnce(new Error("revalidation read failed"));
await request(app)
.patch("/api/work-products/product-1")
.send({ title: "Updated product" })
.expect(200);
expect(mockIssueService.update).toHaveBeenCalledWith(
issueId,
expect.objectContaining({ title: "Updated after commit" }),
);
expect(mockIssueService.addComment).toHaveBeenCalledWith(
issueId,
"progress update",
expect.any(Object),
expect.any(Object),
);
expect(mockDocumentService.upsertIssueDocument).toHaveBeenCalled();
expect(mockWorkProductService.update).toHaveBeenCalledWith("product-1", { title: "Updated product" });
});
it("preserves board mutations on active checkouts", async () => { it("preserves board mutations on active checkouts", async () => {
const app = await createApp(boardActor()); const app = await createApp(boardActor());
@@ -477,4 +563,103 @@ describe("agent issue mutation checkout ownership", () => {
title: "Claimable update", title: "Claimable update",
}); });
}); });
it("rejects peer-agent status updates that would clear a recovery action they do not own", async () => {
mockIssueService.getById.mockResolvedValue(
makeIssue({ status: "blocked", assigneeAgentId: null, assigneeUserId: "board-user" }),
);
mockIssueRecoveryActionService.getActiveForIssue.mockResolvedValue({
id: recoveryActionId,
ownerAgentId,
});
const res = await request(await createApp(peerActor())).patch(`/api/issues/${issueId}`).send({ status: "todo" });
expect(res.status, JSON.stringify(res.body)).toBe(403);
expect(res.body.error).toBe("Agent cannot resolve another owner's recovery action");
expect(mockIssueService.update).not.toHaveBeenCalled();
});
it("rejects peer-agent recovery resolution on a board-owned source issue", async () => {
mockIssueService.getById.mockResolvedValue(
makeIssue({ status: "blocked", assigneeAgentId: null, assigneeUserId: "board-user" }),
);
mockIssueRecoveryActionService.getActiveForIssue.mockResolvedValue({
id: recoveryActionId,
ownerAgentId,
});
const res = await request(await createApp(peerActor()))
.post(`/api/issues/${issueId}/recovery-actions/resolve`)
.send({
actionId: recoveryActionId,
outcome: "restored",
sourceIssueStatus: "done",
});
expect(res.status, JSON.stringify(res.body)).toBe(403);
expect(res.body.error).toBe("Agent cannot resolve another owner's recovery action");
expect(mockIssueRecoveryActionService.resolveActiveForIssue).not.toHaveBeenCalled();
});
it("allows the named recovery owner to resolve a board-owned source issue", async () => {
mockIssueService.getById.mockResolvedValue(
makeIssue({ status: "blocked", assigneeAgentId: null, assigneeUserId: "board-user" }),
);
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
...makeIssue({ status: "blocked", assigneeAgentId: null, assigneeUserId: "board-user" }),
...patch,
}));
mockIssueRecoveryActionService.getActiveForIssue.mockResolvedValue({
id: recoveryActionId,
ownerAgentId,
});
const res = await request(await createApp(ownerActor()))
.post(`/api/issues/${issueId}/recovery-actions/resolve`)
.send({
actionId: recoveryActionId,
outcome: "restored",
sourceIssueStatus: "done",
});
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(mockIssueService.update).toHaveBeenCalled();
expect(mockIssueRecoveryActionService.resolveActiveForIssue).toHaveBeenCalled();
});
it("wakes the assigned agent when recovery resolution restores a source issue to todo", async () => {
mockIssueService.getById.mockResolvedValue(
makeIssue({ status: "blocked", assigneeAgentId: ownerAgentId }),
);
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
...makeIssue({ status: "blocked", assigneeAgentId: ownerAgentId }),
...patch,
}));
mockIssueRecoveryActionService.getActiveForIssue.mockResolvedValue({
id: recoveryActionId,
ownerAgentId,
});
const res = await request(await createApp(ownerActor()))
.post(`/api/issues/${issueId}/recovery-actions/resolve`)
.send({
actionId: recoveryActionId,
outcome: "restored",
sourceIssueStatus: "todo",
});
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
ownerAgentId,
expect.objectContaining({
reason: "issue_recovery_action_restored",
payload: expect.objectContaining({
issueId,
recoveryActionId,
mutation: "recovery_action_resolution",
}),
}),
);
});
}); });
@@ -76,6 +76,11 @@ vi.mock("../services/index.js", () => ({
syncDocument: async () => undefined, syncDocument: async () => undefined,
syncIssue: async () => undefined, syncIssue: async () => undefined,
}), }),
issueThreadInteractionService: () => ({
listForIssue: vi.fn(async () => []),
expireRequestConfirmationsSupersededByComment: vi.fn(async () => []),
expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []),
}),
issueService: () => mockIssueService, issueService: () => mockIssueService,
logActivity: mockLogActivity, logActivity: mockLogActivity,
projectService: () => ({ projectService: () => ({
@@ -81,6 +81,11 @@ function registerRouteMocks() {
syncDocument: async () => undefined, syncDocument: async () => undefined,
syncIssue: async () => undefined, syncIssue: async () => undefined,
}), }),
issueThreadInteractionService: () => ({
listForIssue: vi.fn(async () => []),
expireRequestConfirmationsSupersededByComment: vi.fn(async () => []),
expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []),
}),
issueRecoveryActionService: () => ({ issueRecoveryActionService: () => ({
getActiveForIssue: vi.fn(async () => null), getActiveForIssue: vi.fn(async () => null),
listActiveForIssues: vi.fn(async () => new Map()), listActiveForIssues: vi.fn(async () => new Map()),
@@ -116,6 +116,11 @@ function registerServiceMocks() {
syncDocument: async () => undefined, syncDocument: async () => undefined,
syncIssue: async () => undefined, syncIssue: async () => undefined,
}), }),
issueThreadInteractionService: () => ({
listForIssue: vi.fn(async () => []),
expireRequestConfirmationsSupersededByComment: vi.fn(async () => []),
expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []),
}),
issueRecoveryActionService: () => ({ issueRecoveryActionService: () => ({
getActiveForIssue: vi.fn(async () => null), getActiveForIssue: vi.fn(async () => null),
listActiveForIssues: vi.fn(async () => new Map()), listActiveForIssues: vi.fn(async () => new Map()),
@@ -65,6 +65,11 @@ vi.mock("../services/index.js", () => ({
getActiveForIssue: vi.fn(async () => null), getActiveForIssue: vi.fn(async () => null),
listActiveForIssues: vi.fn(async () => new Map()), listActiveForIssues: vi.fn(async () => new Map()),
}), }),
issueThreadInteractionService: () => ({
listForIssue: vi.fn(async () => []),
expireRequestConfirmationsSupersededByComment: vi.fn(async () => []),
expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []),
}),
issueService: () => mockIssueService, issueService: () => mockIssueService,
logActivity: vi.fn(async () => undefined), logActivity: vi.fn(async () => undefined),
projectService: () => ({ projectService: () => ({
@@ -5,9 +5,13 @@ import { and, eq } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { import {
agents, agents,
agentWakeupRequests,
activityLog, activityLog,
companies, companies,
createDb, createDb,
environmentLeases,
environments,
heartbeatRuns,
issueComments, issueComments,
issueRecoveryActions, issueRecoveryActions,
issueRelations, issueRelations,
@@ -130,7 +134,11 @@ describeEmbeddedPostgres("issue recovery actions", () => {
afterEach(async () => { afterEach(async () => {
await db.delete(issueRecoveryActions); await db.delete(issueRecoveryActions);
await db.delete(issueComments); await db.delete(issueComments);
await db.delete(environmentLeases);
await db.delete(activityLog); await db.delete(activityLog);
await db.delete(heartbeatRuns);
await db.delete(agentWakeupRequests);
await db.delete(environments);
await db.delete(issues); await db.delete(issues);
await db.delete(agents); await db.delete(agents);
await db.delete(companies); await db.delete(companies);
@@ -191,6 +199,24 @@ describeEmbeddedPostgres("issue recovery actions", () => {
return { companyId, managerId, coderId, sourceIssueId, prefix, sourceIssue: sourceIssue! }; return { companyId, managerId, coderId, sourceIssueId, prefix, sourceIssue: sourceIssue! };
} }
async function seedHeartbeatRun(input: {
companyId: string;
agentId: string;
runId: string;
issueId?: string;
status?: string;
}) {
await db.insert(heartbeatRuns).values({
id: input.runId,
companyId: input.companyId,
agentId: input.agentId,
invocationSource: "manual",
status: input.status ?? "running",
startedAt: new Date("2026-05-13T18:00:00.000Z"),
contextSnapshot: input.issueId ? { issueId: input.issueId } : undefined,
});
}
function createApp(actor: any = { type: "board", source: "local_implicit" }) { function createApp(actor: any = { type: "board", source: "local_implicit" }) {
const app = express(); const app = express();
app.use(express.json()); app.use(express.json());
@@ -545,6 +571,390 @@ describeEmbeddedPostgres("issue recovery actions", () => {
); );
}); });
it("resolves an active recovery action by returning the source issue to todo", async () => {
const { companyId, managerId, sourceIssueId } = await seedCompany();
await db.update(issues).set({ status: "blocked" }).where(eq(issues.id, sourceIssueId));
const recoveryActionSvc = issueRecoveryActionService(db);
const action = await recoveryActionSvc.upsertSourceScoped({
companyId,
sourceIssueId,
kind: "issue_graph_liveness",
ownerType: "agent",
ownerAgentId: managerId,
cause: "issue_graph_liveness",
fingerprint: "graph-liveness:try-again",
evidence: { latestIssueStatus: "blocked" },
nextAction: "Restore a live execution path.",
wakePolicy: { type: "manual" },
});
const app = createApp();
const resolved = await request(app)
.post(`/api/issues/${sourceIssueId}/recovery-actions/resolve`)
.send({
actionId: action.id,
outcome: "restored",
sourceIssueStatus: "todo",
resolutionNote: "Try the source issue again.",
})
.expect(200);
expect(resolved.body.issue).toMatchObject({
id: sourceIssueId,
status: "todo",
activeRecoveryAction: null,
});
expect(resolved.body.recoveryAction).toMatchObject({
id: action.id,
status: "resolved",
outcome: "restored",
resolutionNote: "Try the source issue again.",
});
expect(await recoveryActionSvc.getActiveForIssue(companyId, sourceIssueId)).toBeNull();
});
it("marks a recovery action stale when a blocked source issue is manually moved to todo", async () => {
const { companyId, managerId, sourceIssueId } = await seedCompany();
await db
.update(issues)
.set({ status: "blocked", assigneeAgentId: null, assigneeUserId: "board-user" })
.where(eq(issues.id, sourceIssueId));
const recoveryActionSvc = issueRecoveryActionService(db);
const action = await recoveryActionSvc.upsertSourceScoped({
companyId,
sourceIssueId,
kind: "issue_graph_liveness",
ownerType: "agent",
ownerAgentId: managerId,
cause: "issue_graph_liveness",
fingerprint: "graph-liveness:manual-restore",
evidence: { latestIssueStatus: "blocked" },
nextAction: "Restore a live execution path.",
wakePolicy: { type: "manual" },
});
const app = createApp();
const patched = await request(app)
.patch(`/api/issues/${sourceIssueId}`)
.send({ status: "todo" })
.expect(200);
expect(patched.body).toMatchObject({
id: sourceIssueId,
status: "todo",
activeRecoveryAction: null,
});
const [actionRow] = await db
.select()
.from(issueRecoveryActions)
.where(eq(issueRecoveryActions.id, action.id));
expect(actionRow).toMatchObject({
status: "cancelled",
outcome: "cancelled",
resolutionNote: "Recovery action became stale because the source issue was manually moved from blocked to todo.",
});
expect(actionRow?.resolvedAt).toBeTruthy();
expect(await recoveryActionSvc.getActiveForIssue(companyId, sourceIssueId)).toBeNull();
const detail = await request(app).get(`/api/issues/${sourceIssueId}`).expect(200);
expect(detail.body.activeRecoveryAction).toBeNull();
const activityRows = await db
.select()
.from(activityLog)
.where(eq(activityLog.entityId, sourceIssueId));
expect(activityRows.map((row) => row.action)).toEqual(
expect.arrayContaining(["issue.updated", "issue.recovery_action_resolved"]),
);
expect(activityRows.find((row) => row.action === "issue.recovery_action_resolved")?.details).toMatchObject({
source: "source_revalidation",
trigger: "issue_update",
});
});
it("folds stale recovery during read projection after the source issue reaches done", async () => {
const { companyId, managerId, sourceIssueId } = await seedCompany();
const recoveryActionSvc = issueRecoveryActionService(db);
const action = await recoveryActionSvc.upsertSourceScoped({
companyId,
sourceIssueId,
kind: "issue_graph_liveness",
ownerType: "agent",
ownerAgentId: managerId,
cause: "issue_graph_liveness",
fingerprint: "graph-liveness:done-projection",
evidence: { latestIssueStatus: "in_progress" },
nextAction: "Restore a live execution path.",
wakePolicy: { type: "manual" },
});
await db.update(issues).set({ status: "done" }).where(eq(issues.id, sourceIssueId));
const app = createApp();
const detail = await request(app).get(`/api/issues/${sourceIssueId}`).expect(200);
expect(detail.body).toMatchObject({
id: sourceIssueId,
status: "done",
activeRecoveryAction: null,
});
const [actionRow] = await db
.select()
.from(issueRecoveryActions)
.where(eq(issueRecoveryActions.id, action.id));
expect(actionRow).toMatchObject({
status: "cancelled",
outcome: "cancelled",
resolutionNote: "Recovery action became stale because the source issue reached done.",
});
expect(actionRow?.resolvedAt).toBeTruthy();
const activityRows = await db
.select()
.from(activityLog)
.where(eq(activityLog.entityId, sourceIssueId));
expect(activityRows.find((row) => row.action === "issue.recovery_action_resolved")?.details).toMatchObject({
source: "source_revalidation",
trigger: "read_projection",
recoveryActionId: action.id,
});
});
it("keeps active recovery visible when a plain comment does not create a live path", async () => {
const { companyId, managerId, sourceIssueId } = await seedCompany();
await db
.update(issues)
.set({ assigneeAgentId: null, assigneeUserId: "board-user" })
.where(eq(issues.id, sourceIssueId));
const recoveryActionSvc = issueRecoveryActionService(db);
const action = await recoveryActionSvc.upsertSourceScoped({
companyId,
sourceIssueId,
kind: "issue_graph_liveness",
ownerType: "agent",
ownerAgentId: managerId,
cause: "issue_graph_liveness",
fingerprint: "graph-liveness:plain-comment",
evidence: { latestIssueStatus: "in_progress" },
nextAction: "Restore a live execution path.",
wakePolicy: { type: "manual" },
});
const app = createApp();
await request(app)
.post(`/api/issues/${sourceIssueId}/comments`)
.send({ body: "I am looking at this, but not changing the disposition." })
.expect(201);
expect(await recoveryActionSvc.getActiveForIssue(companyId, sourceIssueId)).toMatchObject({
id: action.id,
status: "active",
});
const detail = await request(app).get(`/api/issues/${sourceIssueId}`).expect(200);
expect(detail.body.activeRecoveryAction).toMatchObject({ id: action.id });
});
it("folds stale recovery when a structured resume comment restores todo dispatch", async () => {
const { companyId, managerId, sourceIssueId } = await seedCompany();
await db
.update(issues)
.set({ status: "blocked", assigneeAgentId: null, assigneeUserId: "board-user" })
.where(eq(issues.id, sourceIssueId));
const recoveryActionSvc = issueRecoveryActionService(db);
const action = await recoveryActionSvc.upsertSourceScoped({
companyId,
sourceIssueId,
kind: "issue_graph_liveness",
ownerType: "agent",
ownerAgentId: managerId,
cause: "issue_graph_liveness",
fingerprint: "graph-liveness:resume-comment",
evidence: { latestIssueStatus: "blocked" },
nextAction: "Restore a live execution path.",
wakePolicy: { type: "manual" },
});
const app = createApp();
await request(app)
.post(`/api/issues/${sourceIssueId}/comments`)
.send({ body: "Resume this now.", resume: true })
.expect(201);
const [sourceIssue] = await db.select().from(issues).where(eq(issues.id, sourceIssueId));
expect(sourceIssue?.status).toBe("todo");
const [actionRow] = await db
.select()
.from(issueRecoveryActions)
.where(eq(issueRecoveryActions.id, action.id));
expect(actionRow).toMatchObject({
status: "cancelled",
outcome: "cancelled",
resolutionNote: "Recovery action became stale because the source issue was manually moved from blocked to todo.",
});
expect(await recoveryActionSvc.getActiveForIssue(companyId, sourceIssueId)).toBeNull();
const activityRows = await db
.select()
.from(activityLog)
.where(eq(activityLog.entityId, sourceIssueId));
expect(activityRows.find((row) => row.action === "issue.recovery_action_resolved")?.details).toMatchObject({
source: "source_revalidation",
trigger: "comment",
recoveryActionId: action.id,
});
});
it("rejects peer-agent source issue updates that would hide another owner's recovery action", async () => {
const { companyId, managerId, coderId, sourceIssueId } = await seedCompany();
await db
.update(issues)
.set({ status: "blocked", assigneeAgentId: null, assigneeUserId: "board-user" })
.where(eq(issues.id, sourceIssueId));
const recoveryActionSvc = issueRecoveryActionService(db);
const action = await recoveryActionSvc.upsertSourceScoped({
companyId,
sourceIssueId,
kind: "issue_graph_liveness",
ownerType: "agent",
ownerAgentId: managerId,
cause: "issue_graph_liveness",
fingerprint: "graph-liveness:peer-status-update",
evidence: { latestIssueStatus: "blocked" },
nextAction: "Restore a live execution path.",
wakePolicy: { type: "manual" },
});
const app = createApp({
type: "agent",
agentId: coderId,
companyId,
runId: randomUUID(),
source: "agent_jwt",
});
await request(app)
.patch(`/api/issues/${sourceIssueId}`)
.send({ status: "todo" })
.expect(403);
const [sourceIssue] = await db.select().from(issues).where(eq(issues.id, sourceIssueId));
expect(sourceIssue?.status).toBe("blocked");
const [actionRow] = await db
.select()
.from(issueRecoveryActions)
.where(eq(issueRecoveryActions.id, action.id));
expect(actionRow).toMatchObject({
status: "active",
outcome: null,
resolvedAt: null,
});
});
it("rejects peer-agent recovery action resolution on a board-owned source issue", async () => {
const { companyId, managerId, coderId, sourceIssueId } = await seedCompany();
await db
.update(issues)
.set({ status: "blocked", assigneeAgentId: null, assigneeUserId: "board-user" })
.where(eq(issues.id, sourceIssueId));
const recoveryActionSvc = issueRecoveryActionService(db);
const action = await recoveryActionSvc.upsertSourceScoped({
companyId,
sourceIssueId,
kind: "issue_graph_liveness",
ownerType: "agent",
ownerAgentId: managerId,
cause: "issue_graph_liveness",
fingerprint: "graph-liveness:peer-resolution",
evidence: { latestIssueStatus: "blocked" },
nextAction: "Restore a live execution path.",
wakePolicy: { type: "manual" },
});
const app = createApp({
type: "agent",
agentId: coderId,
companyId,
runId: randomUUID(),
source: "agent_jwt",
});
await request(app)
.post(`/api/issues/${sourceIssueId}/recovery-actions/resolve`)
.send({
actionId: action.id,
outcome: "restored",
sourceIssueStatus: "done",
resolutionNote: "Peer agent should not be able to clear this recovery.",
})
.expect(403);
const [sourceIssue] = await db.select().from(issues).where(eq(issues.id, sourceIssueId));
expect(sourceIssue?.status).toBe("blocked");
const [actionRow] = await db
.select()
.from(issueRecoveryActions)
.where(eq(issueRecoveryActions.id, action.id));
expect(actionRow).toMatchObject({
status: "active",
outcome: null,
resolvedAt: null,
});
});
it("allows the named recovery owner to resolve a board-owned source recovery action", async () => {
const { companyId, managerId, sourceIssueId } = await seedCompany();
await db
.update(issues)
.set({ status: "blocked", assigneeAgentId: null, assigneeUserId: "board-user" })
.where(eq(issues.id, sourceIssueId));
const recoveryActionSvc = issueRecoveryActionService(db);
const action = await recoveryActionSvc.upsertSourceScoped({
companyId,
sourceIssueId,
kind: "issue_graph_liveness",
ownerType: "agent",
ownerAgentId: managerId,
cause: "issue_graph_liveness",
fingerprint: "graph-liveness:owner-resolution",
evidence: { latestIssueStatus: "blocked" },
nextAction: "Restore a live execution path.",
wakePolicy: { type: "manual" },
});
const runId = randomUUID();
const app = createApp({
type: "agent",
agentId: managerId,
companyId,
runId,
source: "agent_jwt",
});
await seedHeartbeatRun({
companyId,
agentId: managerId,
runId,
issueId: sourceIssueId,
});
const resolved = await request(app)
.post(`/api/issues/${sourceIssueId}/recovery-actions/resolve`)
.send({
actionId: action.id,
outcome: "restored",
sourceIssueStatus: "done",
resolutionNote: "Recovery owner verified the work was intentionally completed.",
})
.expect(200);
expect(resolved.body.issue).toMatchObject({
id: sourceIssueId,
status: "done",
activeRecoveryAction: null,
});
expect(resolved.body.recoveryAction).toMatchObject({
id: action.id,
status: "resolved",
outcome: "restored",
});
});
it("rejects blocked recovery resolution when the source issue has no first-class blockers", async () => { it("rejects blocked recovery resolution when the source issue has no first-class blockers", async () => {
const { companyId, managerId, sourceIssueId } = await seedCompany(); const { companyId, managerId, sourceIssueId } = await seedCompany();
const recoveryActionSvc = issueRecoveryActionService(db); const recoveryActionSvc = issueRecoveryActionService(db);
@@ -58,6 +58,11 @@ function registerModuleMocks() {
syncDocument: async () => undefined, syncDocument: async () => undefined,
syncIssue: async () => undefined, syncIssue: async () => undefined,
}), }),
issueThreadInteractionService: () => ({
listForIssue: vi.fn(async () => []),
expireRequestConfirmationsSupersededByComment: vi.fn(async () => []),
expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []),
}),
issueRecoveryActionService: () => ({ issueRecoveryActionService: () => ({
getActiveForIssue: vi.fn(async () => null), getActiveForIssue: vi.fn(async () => null),
listActiveForIssues: vi.fn(async () => new Map()), listActiveForIssues: vi.fn(async () => new Map()),
@@ -106,6 +106,7 @@ function createIssue(overrides: Record<string, unknown> = {}) {
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
companyId: "company-1", companyId: "company-1",
status: "in_progress", status: "in_progress",
workMode: "standard",
priority: "medium", priority: "medium",
projectId: null, projectId: null,
goalId: null, goalId: null,
@@ -481,6 +482,57 @@ describe.sequential("issue thread interaction routes", () => {
); );
}); });
it("forces a fresh workspace-aware session when accepting a planning confirmation", async () => {
mockIssueService.getById.mockResolvedValueOnce(createIssue({ workMode: "planning" }));
mockInteractionService.acceptInteraction.mockResolvedValueOnce({
interaction: {
id: "interaction-plan",
companyId: "company-1",
issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
kind: "request_confirmation",
status: "accepted",
continuationPolicy: "wake_assignee_on_accept",
idempotencyKey: "confirmation:issue:plan:revision",
sourceCommentId: null,
sourceRunId: "run-plan",
payload: {
version: 1,
prompt: "Approve this plan?",
},
result: {
version: 1,
outcome: "accepted",
},
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-plan/accept")
.send({});
expect(res.status).toBe(200);
expect(mockHeartbeatService.wakeup).toHaveBeenCalledTimes(1);
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
ASSIGNEE_AGENT_ID,
expect.objectContaining({
reason: "issue_commented",
contextSnapshot: expect.objectContaining({
issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
interactionId: "interaction-plan",
interactionKind: "request_confirmation",
interactionStatus: "accepted",
forceFreshSession: true,
workspaceRefreshReason: "accepted_plan_confirmation",
}),
}),
);
});
it("wakes the returned agent when accepting an agent-authored confirmation from a board review assignee", async () => { it("wakes the returned agent when accepting an agent-authored confirmation from a board review assignee", async () => {
mockIssueService.getById.mockResolvedValueOnce(createIssue({ mockIssueService.getById.mockResolvedValueOnce(createIssue({
status: "in_review", status: "in_review",
@@ -119,6 +119,11 @@ function registerRouteMocks() {
getActiveForIssue: vi.fn(async () => null), getActiveForIssue: vi.fn(async () => null),
listActiveForIssues: vi.fn(async () => new Map()), listActiveForIssues: vi.fn(async () => new Map()),
}), }),
issueThreadInteractionService: () => ({
listForIssue: vi.fn(async () => []),
expireRequestConfirmationsSupersededByComment: vi.fn(async () => []),
expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []),
}),
issueService: () => mockIssueService, issueService: () => mockIssueService,
logActivity: mockLogActivity, logActivity: mockLogActivity,
projectService: () => ({}), projectService: () => ({}),
@@ -115,6 +115,11 @@ vi.mock("../services/index.js", () => ({
getActiveForIssue: vi.fn(async () => null), getActiveForIssue: vi.fn(async () => null),
listActiveForIssues: vi.fn(async () => new Map()), listActiveForIssues: vi.fn(async () => new Map()),
}), }),
issueThreadInteractionService: () => ({
listForIssue: vi.fn(async () => []),
expireRequestConfirmationsSupersededByComment: vi.fn(async () => []),
expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []),
}),
issueReferenceService: () => mockIssueReferenceService, issueReferenceService: () => mockIssueReferenceService,
issueService: () => mockIssueService, issueService: () => mockIssueService,
logActivity: mockLogActivity, logActivity: mockLogActivity,
@@ -2408,6 +2408,52 @@ describeEmbeddedPostgres("issueService blockers and dependency wake readiness",
}); });
}); });
it("unblocks a source issue when a liveness escalation recovery issue is marked done", 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 sourceIssueId = randomUUID();
const recoveryIssueId = randomUUID();
await db.insert(issues).values([
{
id: sourceIssueId,
companyId,
title: "Source issue",
status: "blocked",
priority: "medium",
},
{
id: recoveryIssueId,
companyId,
title: "Liveness escalation issue",
status: "in_progress",
priority: "high",
originKind: "harness_liveness_escalation",
originId: `harness_liveness:${companyId}:${sourceIssueId}:invalid_review_participant:none`,
},
]);
await svc.update(sourceIssueId, {
blockedByIssueIds: [recoveryIssueId],
});
await expect(svc.getRelationSummaries(sourceIssueId)).resolves.toMatchObject({
blockedBy: [expect.objectContaining({ id: recoveryIssueId })],
});
await svc.update(recoveryIssueId, {
status: "done",
});
await expect(svc.getRelationSummaries(sourceIssueId)).resolves.toMatchObject({
blockedBy: [],
});
});
it("rejects execution when unresolved blockers remain", async () => { it("rejects execution when unresolved blockers remain", async () => {
const companyId = randomUUID(); const companyId = randomUUID();
const assigneeAgentId = randomUUID(); const assigneeAgentId = randomUUID();
@@ -219,6 +219,14 @@ describe("plugin local folders", () => {
expect(leftovers.filter((name) => name.includes(".paperclip-"))).toEqual([]); expect(leftovers.filter((name) => name.includes(".paperclip-"))).toEqual([]);
}); });
it("creates missing nested parent directories for atomic writes", async () => {
const root = await makeRoot();
await writePluginLocalFolderTextAtomic(root, "cases/active/smoke/README.md", "hello");
await expect(readPluginLocalFolderText(root, "cases/active/smoke/README.md")).resolves.toBe("hello");
});
it("returns the real folder key after deleting a file", async () => { it("returns the real folder key after deleting a file", async () => {
const root = await makeRoot(); const root = await makeRoot();
await fs.writeFile(path.join(root, "stale.md"), "delete me", "utf8"); await fs.writeFile(path.join(root, "stale.md"), "delete me", "utf8");
+4
View File
@@ -70,7 +70,9 @@ describe("redaction", () => {
const input = [ const input = [
"Authorization: Bearer live-bearer-token-value", "Authorization: Bearer live-bearer-token-value",
`payload {"apiKey":"json-secret-value"}`, `payload {"apiKey":"json-secret-value"}`,
`paperclip {"PAPERCLIP_API_KEY":"paperclip-json-secret"}`,
`escaped {\\"apiKey\\":\\"escaped-json-secret\\"}`, `escaped {\\"apiKey\\":\\"escaped-json-secret\\"}`,
`export PAPERCLIP_API_KEY='paperclip-shell-secret'`,
`GITHUB_TOKEN=${githubToken}`, `GITHUB_TOKEN=${githubToken}`,
`session=${jwt}`, `session=${jwt}`,
].join("\n"); ].join("\n");
@@ -80,7 +82,9 @@ describe("redaction", () => {
expect(result).toContain(REDACTED_EVENT_VALUE); expect(result).toContain(REDACTED_EVENT_VALUE);
expect(result).not.toContain("live-bearer-token-value"); expect(result).not.toContain("live-bearer-token-value");
expect(result).not.toContain("json-secret-value"); expect(result).not.toContain("json-secret-value");
expect(result).not.toContain("paperclip-json-secret");
expect(result).not.toContain("escaped-json-secret"); expect(result).not.toContain("escaped-json-secret");
expect(result).not.toContain("paperclip-shell-secret");
expect(result).not.toContain(githubToken); expect(result).not.toContain(githubToken);
expect(result).not.toContain(jwt); expect(result).not.toContain(jwt);
}); });
+39 -8
View File
@@ -1,19 +1,49 @@
import { redactCommandText } from "@paperclipai/adapter-utils"; import { redactCommandText } from "@paperclipai/adapter-utils";
const SECRET_PAYLOAD_KEY_RE = const SECRET_FIELD_NAME_PATTERN =
/(api[-_]?key|access[-_]?token|auth(?:_?token)?|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring)/i; String.raw`[A-Za-z0-9_-]*(?:api[-_]?key|access[-_]?token|auth(?:_?token)?|token|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring)[A-Za-z0-9_-]*`;
const SECRET_PAYLOAD_KEY_RE = new RegExp(SECRET_FIELD_NAME_PATTERN, "i");
const COMMAND_PAYLOAD_KEY_RE = const COMMAND_PAYLOAD_KEY_RE =
/(^command$|^cmd$|command[-_]?line|resolved[-_]?command|PAPERCLIP_RESOLVED_COMMAND)/i; /(^command$|^cmd$|command[-_]?line|resolved[-_]?command|PAPERCLIP_RESOLVED_COMMAND)/i;
const COMMAND_ARGS_PAYLOAD_KEY_RE = /^(commandArgs|command_?args|argv)$/i; const COMMAND_ARGS_PAYLOAD_KEY_RE = /^(commandArgs|command_?args|argv)$/i;
const JWT_VALUE_RE = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)?$/; const JWT_VALUE_RE = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)?$/;
const CLI_SECRET_FLAG_RE = const CLI_SECRET_FLAG_RE = new RegExp(String.raw`^-{1,2}${SECRET_FIELD_NAME_PATTERN}$`, "i");
/^-{1,2}(?:api[-_]?key|(?:access[-_]?|auth[-_]?)?token|token|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring)$/i; const JSON_SECRET_FIELD_TEXT_RE = new RegExp(
const JSON_SECRET_FIELD_TEXT_RE = String.raw`((?:"|')?${SECRET_FIELD_NAME_PATTERN}(?:"|')?\s*:\s*(?:"|'))[^"'` + "`" + String.raw`\r\n]+((?:"|'))`,
/((?:"|')?(?:api[-_]?key|access[-_]?token|auth(?:_?token)?|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring)(?:"|')?\s*:\s*(?:"|'))[^"'`\r\n]+((?:"|'))/gi; "gi",
const ESCAPED_JSON_SECRET_FIELD_TEXT_RE = );
/((?:\\")?(?:api[-_]?key|access[-_]?token|auth(?:_?token)?|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring)(?:\\")?\s*:\s*(?:\\"))[^\\\r\n]+((?:\\"))/gi; const ESCAPED_JSON_SECRET_FIELD_TEXT_RE = new RegExp(
String.raw`((?:\\")?${SECRET_FIELD_NAME_PATTERN}(?:\\")?\s*:\s*(?:\\"))[^\\\r\n]+((?:\\"))`,
"gi",
);
const SECRET_TEXT_HINTS = [
"api",
"key",
"token",
"auth",
"bearer",
"secret",
"pass",
"credential",
"jwt",
"private",
"cookie",
"connectionstring",
"sk-",
"ghp_",
"gho_",
"ghu_",
"ghs_",
"ghr_",
] as const;
export const REDACTED_EVENT_VALUE = "***REDACTED***"; export const REDACTED_EVENT_VALUE = "***REDACTED***";
function maybeContainsSecretText(input: string) {
const lower = input.toLowerCase();
return SECRET_TEXT_HINTS.some((hint) => lower.includes(hint)) || input.includes(".");
}
function isPlainObject(value: unknown): value is Record<string, unknown> { function isPlainObject(value: unknown): value is Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) return false; if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
const proto = Object.getPrototypeOf(value); const proto = Object.getPrototypeOf(value);
@@ -94,6 +124,7 @@ export function redactEventPayload(payload: Record<string, unknown> | null): Rec
} }
export function redactSensitiveText(input: string): string { export function redactSensitiveText(input: string): string {
if (!maybeContainsSecretText(input)) return input;
return redactCommandText( return redactCommandText(
input input
.replace(JSON_SECRET_FIELD_TEXT_RE, `$1${REDACTED_EVENT_VALUE}$2`) .replace(JSON_SECRET_FIELD_TEXT_RE, `$1${REDACTED_EVENT_VALUE}$2`)
+412 -7
View File
@@ -117,6 +117,13 @@ const updateIssueRouteSchema = updateIssueSchema.extend({
type ParsedExecutionState = NonNullable<ReturnType<typeof parseIssueExecutionState>>; type ParsedExecutionState = NonNullable<ReturnType<typeof parseIssueExecutionState>>;
type NormalizedExecutionPolicy = NonNullable<ReturnType<typeof normalizeIssueExecutionPolicy>>; type NormalizedExecutionPolicy = NonNullable<ReturnType<typeof normalizeIssueExecutionPolicy>>;
type IssueRouteSnapshot = typeof issueRows.$inferSelect;
type RecoveryRevalidationTrigger =
| "issue_update"
| "comment"
| "document"
| "work_product"
| "read_projection";
type CompanySearchService = { type CompanySearchService = {
search(companyId: string, query: CompanySearchQuery): Promise<CompanySearchResponse>; search(companyId: string, query: CompanySearchQuery): Promise<CompanySearchResponse>;
}; };
@@ -636,6 +643,8 @@ function queueResolvedInteractionContinuationWakeup(input: {
}; };
actor: { actorType: "user" | "agent"; actorId: string }; actor: { actorType: "user" | "agent"; actorId: string };
source: string; source: string;
forceFreshSession?: boolean;
workspaceRefreshReason?: string | null;
}) { }) {
if ( if (
input.interaction.continuationPolicy !== "wake_assignee" input.interaction.continuationPolicy !== "wake_assignee"
@@ -648,6 +657,8 @@ function queueResolvedInteractionContinuationWakeup(input: {
if (input.interaction.status === "expired") return; if (input.interaction.status === "expired") return;
if (!input.issue.assigneeAgentId || isClosedIssueStatus(input.issue.status)) return; if (!input.issue.assigneeAgentId || isClosedIssueStatus(input.issue.status)) return;
const forceFreshSession = input.forceFreshSession === true;
const workspaceRefreshReason = readNonEmptyString(input.workspaceRefreshReason);
void input.heartbeat.wakeup(input.issue.assigneeAgentId, { void input.heartbeat.wakeup(input.issue.assigneeAgentId, {
source: "automation", source: "automation",
triggerDetail: "system", triggerDetail: "system",
@@ -673,6 +684,8 @@ function queueResolvedInteractionContinuationWakeup(input: {
sourceRunId: input.interaction.sourceRunId ?? null, sourceRunId: input.interaction.sourceRunId ?? null,
wakeReason: "issue_commented", wakeReason: "issue_commented",
source: input.source, source: input.source,
...(forceFreshSession ? { forceFreshSession: true } : {}),
...(workspaceRefreshReason ? { workspaceRefreshReason } : {}),
}, },
}).catch((err) => logger.warn({ }).catch((err) => logger.warn({
err, err,
@@ -843,6 +856,7 @@ export function issueRoutes(
const workProductsSvc = workProductService(db); const workProductsSvc = workProductService(db);
const documentsSvc = documentService(db); const documentsSvc = documentService(db);
const issueReferencesSvc = issueReferenceService(db); const issueReferencesSvc = issueReferenceService(db);
const issueThreadInteractionsSvc = issueThreadInteractionService(db);
const routinesSvc = routineService(db, { const routinesSvc = routineService(db, {
pluginWorkerManager: opts.pluginWorkerManager, pluginWorkerManager: opts.pluginWorkerManager,
}); });
@@ -857,6 +871,182 @@ export function issueRoutes(
}; };
const feedbackExportService = opts?.feedbackExportService; const feedbackExportService = opts?.feedbackExportService;
const environmentsSvc = environmentService(db); const environmentsSvc = environmentService(db);
async function classifySourceRecoveryRevalidation(input: {
issue: IssueRouteSnapshot;
trigger: RecoveryRevalidationTrigger;
statusChanged?: boolean;
assigneeChanged?: boolean;
blockersChanged?: boolean;
executionPolicyChanged?: boolean;
monitorChanged?: boolean;
documentChanged?: boolean;
workProductChanged?: boolean;
resumeRequested?: boolean;
reopened?: boolean;
blockedToTodoRecovery?: boolean;
}): Promise<string | null> {
const { issue } = input;
if (issue.status === "done" || issue.status === "cancelled") {
return `Recovery action became stale because the source issue reached ${issue.status}.`;
}
if (input.blockedToTodoRecovery === true) {
return "Recovery action became stale because the source issue was manually moved from blocked to todo.";
}
if (input.trigger === "read_projection") return null;
if (
input.trigger === "comment" &&
input.resumeRequested !== true &&
input.reopened !== true &&
input.statusChanged !== true
) {
return null;
}
const durableSourceChange =
input.statusChanged === true ||
input.assigneeChanged === true ||
input.blockersChanged === true ||
input.executionPolicyChanged === true ||
input.monitorChanged === true ||
input.documentChanged === true ||
input.workProductChanged === true ||
input.resumeRequested === true ||
input.reopened === true;
if (!durableSourceChange) return null;
if (issue.status === "blocked") {
const readiness = await svc.getDependencyReadiness(issue.id);
if (readiness.unresolvedBlockerCount > 0) {
return "Recovery action became stale because the source issue now has unresolved first-class blockers.";
}
return null;
}
if (issue.assigneeUserId && issue.status !== "done" && issue.status !== "cancelled") {
return "Recovery action became stale because the source issue now has a human owner.";
}
if ((issue.status === "todo" || issue.status === "in_progress") && issue.assigneeAgentId) {
return `Recovery action became stale because the source issue is ${issue.status} with an agent owner.`;
}
if (issue.status === "in_review") {
const executionState = parseIssueExecutionState(issue.executionState);
const participant = executionState?.status === "pending" ? executionState.currentParticipant : null;
if (
(participant?.type === "agent" && readNonEmptyString(participant.agentId)) ||
(participant?.type === "user" && readNonEmptyString(participant.userId))
) {
return "Recovery action became stale because the source issue now has a typed review participant.";
}
const interactions = await issueThreadInteractionsSvc.listForIssue(issue.id);
if (interactions.some((interaction) => interaction.status === "pending")) {
return "Recovery action became stale because the source issue now has a pending issue interaction.";
}
const approvals = await issueApprovalsSvc.listApprovalsForIssue(issue.id);
if (approvals.some((approval) => approval.status === "pending" || approval.status === "revision_requested")) {
return "Recovery action became stale because the source issue now has a pending approval.";
}
}
const monitor = summarizeIssueMonitor(issue, normalizeIssueExecutionPolicy(issue.executionPolicy ?? null));
if (monitor.nextCheckAt && Date.parse(monitor.nextCheckAt) > Date.now()) {
return "Recovery action became stale because the source issue now has a scheduled monitor.";
}
return null;
}
async function revalidateActiveSourceRecovery(input: {
issue: IssueRouteSnapshot;
trigger: RecoveryRevalidationTrigger;
actor?: ReturnType<typeof getActorInfo> | null;
activeRecoveryAction?: Awaited<ReturnType<typeof recoveryActionsSvc.getActiveForIssue>> | null;
statusChanged?: boolean;
assigneeChanged?: boolean;
blockersChanged?: boolean;
executionPolicyChanged?: boolean;
monitorChanged?: boolean;
documentChanged?: boolean;
workProductChanged?: boolean;
resumeRequested?: boolean;
reopened?: boolean;
blockedToTodoRecovery?: boolean;
}) {
const activeRecoveryAction =
input.activeRecoveryAction === undefined
? await recoveryActionsSvc.getActiveForIssue(input.issue.companyId, input.issue.id)
: input.activeRecoveryAction;
if (!activeRecoveryAction) return null;
const resolutionNote = await classifySourceRecoveryRevalidation(input);
if (!resolutionNote) return activeRecoveryAction;
const resolved = await recoveryActionsSvc.resolveActiveForIssue({
companyId: input.issue.companyId,
sourceIssueId: input.issue.id,
actionId: activeRecoveryAction.id,
status: "cancelled",
outcome: "cancelled",
resolutionNote,
});
if (!resolved) return activeRecoveryAction;
const actor = input.actor;
await logActivity(db, {
companyId: input.issue.companyId,
actorType: actor?.actorType ?? "system",
actorId: actor?.actorId ?? "system",
agentId: actor?.agentId ?? null,
runId: actor?.runId ?? null,
action: "issue.recovery_action_resolved",
entityType: "issue",
entityId: input.issue.id,
details: {
identifier: input.issue.identifier,
recoveryActionId: resolved.id,
recoveryActionStatus: resolved.status,
outcome: resolved.outcome,
sourceIssueStatus: input.issue.status,
resolutionNote: resolved.resolutionNote,
source: "source_revalidation",
trigger: input.trigger,
},
});
return null;
}
async function revalidateActiveSourceRecoveryForRead(input: Parameters<typeof revalidateActiveSourceRecovery>[0]) {
try {
return await revalidateActiveSourceRecovery(input);
} catch (err) {
logger.warn(
{ err, issueId: input.issue.id, trigger: input.trigger },
"failed to revalidate recovery action during read projection",
);
return input.activeRecoveryAction ?? null;
}
}
async function revalidateActiveSourceRecoveryAfterCommittedWrite(
input: Parameters<typeof revalidateActiveSourceRecovery>[0],
) {
try {
return await revalidateActiveSourceRecovery(input);
} catch (err) {
logger.warn(
{ err, issueId: input.issue.id, trigger: input.trigger },
"failed to revalidate recovery action after committed issue write",
);
return input.activeRecoveryAction ?? null;
}
}
function withContentPath<T extends { id: string }>(attachment: T) { function withContentPath<T extends { id: string }>(attachment: T) {
return { return {
...attachment, ...attachment,
@@ -1240,6 +1430,51 @@ export function issueRoutes(
return false; return false;
} }
async function assertRecoveryActionAuthority(
req: Request,
res: Response,
issue: { id: string; companyId: string; assigneeAgentId: string | null },
activeRecoveryAction: Awaited<ReturnType<typeof recoveryActionsSvc.getActiveForIssue>>,
input: { source: "issue_update" | "recovery_action_resolution" },
) {
if (req.actor.type !== "agent") return true;
if (!activeRecoveryAction) return true;
const actorAgentId = req.actor.agentId;
if (!actorAgentId) {
res.status(403).json({ error: "Agent authentication required" });
return false;
}
if (issue.assigneeAgentId === actorAgentId) return true;
if (
issue.assigneeAgentId &&
await hasActiveCheckoutManagementOverride(actorAgentId, issue.companyId, issue.assigneeAgentId)
) {
return true;
}
if (activeRecoveryAction.ownerAgentId === actorAgentId) return true;
if (
activeRecoveryAction.ownerAgentId &&
await hasActiveCheckoutManagementOverride(actorAgentId, issue.companyId, activeRecoveryAction.ownerAgentId)
) {
return true;
}
res.status(403).json({
error: "Agent cannot resolve another owner's recovery action",
details: {
issueId: issue.id,
recoveryActionId: activeRecoveryAction.id,
actorAgentId,
assigneeAgentId: issue.assigneeAgentId,
recoveryOwnerAgentId: activeRecoveryAction.ownerAgentId,
source: input.source,
securityPrinciples: ["Least Privilege", "Complete Mediation", "Secure Defaults"],
},
});
return false;
}
async function resolveActiveIssueRun(issue: { async function resolveActiveIssueRun(issue: {
id: string; id: string;
assigneeAgentId: string | null; assigneeAgentId: string | null;
@@ -1512,6 +1747,19 @@ export function issueRoutes(
listSuccessfulRunHandoffStates(db, companyId, issueIds), listSuccessfulRunHandoffStates(db, companyId, issueIds),
recoveryActionsSvc.listActiveForIssues(companyId, issueIds), recoveryActionsSvc.listActiveForIssues(companyId, issueIds),
]); ]);
const actor = getActorInfo(req);
await Promise.all(result.map(async (issue) => {
const activeRecoveryAction = recoveryActionByIssue.get(issue.id) ?? null;
if (!activeRecoveryAction) return;
const revalidated = await revalidateActiveSourceRecoveryForRead({
issue,
trigger: "read_projection",
actor,
activeRecoveryAction,
});
if (revalidated) recoveryActionByIssue.set(issue.id, revalidated);
else recoveryActionByIssue.delete(issue.id);
}));
res.json(result.map((issue) => ({ res.json(result.map((issue) => ({
...issue, ...issue,
successfulRunHandoff: handoffStates.get(issue.id) ?? null, successfulRunHandoff: handoffStates.get(issue.id) ?? null,
@@ -1668,6 +1916,12 @@ export function issueRoutes(
relations, relations,
recoveryActionsByRelationIssue, recoveryActionsByRelationIssue,
); );
const revalidatedActiveRecoveryAction = await revalidateActiveSourceRecoveryForRead({
issue,
trigger: "read_projection",
actor: getActorInfo(req),
activeRecoveryAction,
});
res.json({ res.json({
issue: { issue: {
@@ -1680,7 +1934,7 @@ export function issueRoutes(
...(blockerAttention ? { blockerAttention } : {}), ...(blockerAttention ? { blockerAttention } : {}),
productivityReview, productivityReview,
scheduledRetry, scheduledRetry,
activeRecoveryAction, activeRecoveryAction: revalidatedActiveRecoveryAction,
priority: issue.priority, priority: issue.priority,
projectId: issue.projectId, projectId: issue.projectId,
goalId: goal?.id ?? issue.goalId, goalId: goal?.id ?? issue.goalId,
@@ -1786,6 +2040,12 @@ export function issueRoutes(
relations, relations,
recoveryActionsByRelationIssue, recoveryActionsByRelationIssue,
); );
const revalidatedActiveRecoveryAction = await revalidateActiveSourceRecoveryForRead({
issue,
trigger: "read_projection",
actor: getActorInfo(req),
activeRecoveryAction,
});
const mentionedProjects = mentionedProjectIds.length > 0 const mentionedProjects = mentionedProjectIds.length > 0
? await projectsSvc.listByIds(issue.companyId, mentionedProjectIds) ? await projectsSvc.listByIds(issue.companyId, mentionedProjectIds)
: []; : [];
@@ -1801,7 +2061,7 @@ export function issueRoutes(
productivityReview, productivityReview,
successfulRunHandoff: successfulRunHandoffStates.get(issue.id) ?? null, successfulRunHandoff: successfulRunHandoffStates.get(issue.id) ?? null,
scheduledRetry, scheduledRetry,
activeRecoveryAction, activeRecoveryAction: revalidatedActiveRecoveryAction,
blockedBy: relationsWithRecoveryActions.blockedBy, blockedBy: relationsWithRecoveryActions.blockedBy,
blocks: relationsWithRecoveryActions.blocks, blocks: relationsWithRecoveryActions.blocks,
relatedWork: referenceSummary, relatedWork: referenceSummary,
@@ -1823,7 +2083,11 @@ export function issueRoutes(
return; return;
} }
assertCompanyAccess(req, issue.companyId); assertCompanyAccess(req, issue.companyId);
const active = await recoveryActionsSvc.getActiveForIssue(issue.companyId, issue.id); const active = await revalidateActiveSourceRecoveryForRead({
issue,
trigger: "read_projection",
actor: getActorInfo(req),
});
res.json({ res.json({
active, active,
actions: active ? [active] : [], actions: active ? [active] : [],
@@ -1839,6 +2103,18 @@ export function issueRoutes(
} }
assertCompanyAccess(req, existing.companyId); assertCompanyAccess(req, existing.companyId);
if (!(await assertAgentIssueMutationAllowed(req, res, existing))) return; if (!(await assertAgentIssueMutationAllowed(req, res, existing))) return;
const activeRecoveryAction = await recoveryActionsSvc.getActiveForIssue(existing.companyId, existing.id);
if (
!(await assertRecoveryActionAuthority(
req,
res,
existing,
activeRecoveryAction,
{ source: "recovery_action_resolution" },
))
) {
return;
}
const { actionId, outcome, sourceIssueStatus, resolutionNote } = req.body; const { actionId, outcome, sourceIssueStatus, resolutionNote } = req.body;
if (outcome === "false_positive" || outcome === "cancelled") { if (outcome === "false_positive" || outcome === "cancelled") {
@@ -1948,6 +2224,36 @@ export function issueRoutes(
}, },
}); });
if (
sourceIssueStatus === "todo" &&
existing.status !== result.issue.status &&
result.issue.assigneeAgentId
) {
void heartbeat.wakeup(result.issue.assigneeAgentId, {
source: "automation",
triggerDetail: "system",
reason: "issue_recovery_action_restored",
payload: {
issueId: result.issue.id,
recoveryActionId: result.recoveryAction.id,
mutation: "recovery_action_resolution",
},
requestedByActorType: actor.actorType,
requestedByActorId: actor.actorId,
contextSnapshot: {
issueId: result.issue.id,
taskId: result.issue.id,
wakeReason: "issue_recovery_action_restored",
source: "issue.recovery_action_resolution",
recoveryActionId: result.recoveryAction.id,
},
}).catch((err) =>
logger.warn(
{ err, issueId: result.issue.id, agentId: result.issue.assigneeAgentId },
"failed to wake agent after recovery action restored issue",
));
}
res.json({ res.json({
issue: { issue: {
...result.issue, ...result.issue,
@@ -2087,6 +2393,13 @@ export function issueRoutes(
}); });
} }
await revalidateActiveSourceRecoveryAfterCommittedWrite({
issue,
trigger: "document",
actor,
documentChanged: true,
});
res.status(result.created ? 201 : 200).json(doc); res.status(result.created ? 201 : 200).json(doc);
}); });
@@ -2274,6 +2587,13 @@ export function issueRoutes(
source: "issue.document_restored", source: "issue.document_restored",
}); });
await revalidateActiveSourceRecoveryAfterCommittedWrite({
issue,
trigger: "document",
actor,
documentChanged: true,
});
res.json(result.document); res.json(result.document);
}, },
); );
@@ -2344,6 +2664,12 @@ export function issueRoutes(
actor, actor,
source: "issue.document_deleted", source: "issue.document_deleted",
}); });
await revalidateActiveSourceRecoveryAfterCommittedWrite({
issue,
trigger: "document",
actor,
documentChanged: true,
});
res.json({ ok: true }); res.json({ ok: true });
}); });
@@ -2376,6 +2702,12 @@ export function issueRoutes(
entityId: issue.id, entityId: issue.id,
details: { workProductId: product.id, type: product.type, provider: product.provider }, details: { workProductId: product.id, type: product.type, provider: product.provider },
}); });
await revalidateActiveSourceRecoveryAfterCommittedWrite({
issue,
trigger: "work_product",
actor,
workProductChanged: true,
});
res.status(201).json(product); res.status(201).json(product);
}); });
@@ -2410,6 +2742,12 @@ export function issueRoutes(
entityId: existing.issueId, entityId: existing.issueId,
details: { workProductId: product.id, changedKeys: Object.keys(req.body).sort() }, details: { workProductId: product.id, changedKeys: Object.keys(req.body).sort() },
}); });
await revalidateActiveSourceRecoveryAfterCommittedWrite({
issue,
trigger: "work_product",
actor,
workProductChanged: true,
});
res.json(product); res.json(product);
}); });
@@ -2444,6 +2782,12 @@ export function issueRoutes(
entityId: existing.issueId, entityId: existing.issueId,
details: { workProductId: removed.id, type: removed.type }, details: { workProductId: removed.id, type: removed.type },
}); });
await revalidateActiveSourceRecoveryAfterCommittedWrite({
issue,
trigger: "work_product",
actor,
workProductChanged: true,
});
res.json(removed); res.json(removed);
}); });
@@ -2931,6 +3275,28 @@ export function issueRoutes(
const requestedAssigneeAgentId = const requestedAssigneeAgentId =
normalizedAssigneeAgentId === undefined ? existing.assigneeAgentId : normalizedAssigneeAgentId; normalizedAssigneeAgentId === undefined ? existing.assigneeAgentId : normalizedAssigneeAgentId;
const explicitMoveToTodoRequested = reopenRequested || resumeRequested === true; const explicitMoveToTodoRequested = reopenRequested || resumeRequested === true;
const recoveryRelevantSourceMutationRequested =
req.body.status !== undefined ||
normalizedAssigneeAgentId !== undefined ||
req.body.assigneeUserId !== undefined ||
Array.isArray(req.body.blockedByIssueIds) ||
req.body.executionPolicy !== undefined ||
explicitMoveToTodoRequested;
const activeRecoveryActionBeforeUpdate = recoveryRelevantSourceMutationRequested
? await recoveryActionsSvc.getActiveForIssue(existing.companyId, existing.id)
: null;
if (
recoveryRelevantSourceMutationRequested &&
!(await assertRecoveryActionAuthority(
req,
res,
existing,
activeRecoveryActionBeforeUpdate,
{ source: "issue_update" },
))
) {
return;
}
const effectiveMoveToTodoRequested = const effectiveMoveToTodoRequested =
explicitMoveToTodoRequested || explicitMoveToTodoRequested ||
(!!commentBody && (!!commentBody &&
@@ -3207,6 +3573,7 @@ export function issueRoutes(
let issueResponse: typeof issue & { let issueResponse: typeof issue & {
blockedBy?: unknown; blockedBy?: unknown;
blocks?: unknown; blocks?: unknown;
activeRecoveryAction?: unknown;
relatedWork?: Awaited<ReturnType<typeof issueReferencesSvc.listIssueReferenceSummary>>; relatedWork?: Awaited<ReturnType<typeof issueReferencesSvc.listIssueReferenceSummary>>;
referencedIssueIdentifiers?: string[]; referencedIssueIdentifiers?: string[];
} = issue; } = issue;
@@ -3258,6 +3625,32 @@ export function issueRoutes(
previous.status !== undefined && previous.status !== undefined &&
issue.status === "todo"; issue.status === "todo";
const reopenFromStatus = reopened ? existing.status : null; const reopenFromStatus = reopened ? existing.status : null;
const statusChangedFromBlockedToTodo =
existing.status === "blocked" &&
issue.status === "todo" &&
(req.body.status !== undefined || reopened);
const revalidatedRecoveryAction = await revalidateActiveSourceRecoveryAfterCommittedWrite({
issue,
trigger: "issue_update",
actor,
activeRecoveryAction: activeRecoveryActionBeforeUpdate ?? undefined,
statusChanged: existing.status !== issue.status,
assigneeChanged:
existing.assigneeAgentId !== issue.assigneeAgentId ||
existing.assigneeUserId !== issue.assigneeUserId,
blockersChanged: Array.isArray(req.body.blockedByIssueIds),
executionPolicyChanged: req.body.executionPolicy !== undefined,
monitorChanged,
resumeRequested: resumeRequested === true,
reopened,
blockedToTodoRecovery: statusChangedFromBlockedToTodo,
});
if (activeRecoveryActionBeforeUpdate && !revalidatedRecoveryAction) {
issueResponse = {
...issueResponse,
activeRecoveryAction: null,
};
}
await logActivity(db, { await logActivity(db, {
companyId: issue.companyId, companyId: issue.companyId,
actorType: actor.actorType, actorType: actor.actorType,
@@ -3531,10 +3924,6 @@ export function issueRoutes(
existing.status === "backlog" && existing.status === "backlog" &&
issue.status !== "backlog" && issue.status !== "backlog" &&
req.body.status !== undefined; req.body.status !== undefined;
const statusChangedFromBlockedToTodo =
existing.status === "blocked" &&
issue.status === "todo" &&
(req.body.status !== undefined || reopened);
const statusChangedFromClosedToTodo = const statusChangedFromClosedToTodo =
isClosedIssueStatus(existing.status) && isClosedIssueStatus(existing.status) &&
issue.status === "todo" && issue.status === "todo" &&
@@ -4126,12 +4515,18 @@ export function issueRoutes(
}); });
} }
const acceptedPlanConfirmation =
interaction.kind === "request_confirmation" &&
interaction.status === "accepted" &&
issue.workMode === "planning";
queueResolvedInteractionContinuationWakeup({ queueResolvedInteractionContinuationWakeup({
heartbeat, heartbeat,
issue: continuationWakeIssue, issue: continuationWakeIssue,
interaction, interaction,
actor, actor,
source: "issue.interaction.accept", source: "issue.interaction.accept",
forceFreshSession: acceptedPlanConfirmation,
workspaceRefreshReason: acceptedPlanConfirmation ? "accepted_plan_confirmation" : null,
}); });
res.json(interaction); res.json(interaction);
@@ -4630,6 +5025,16 @@ export function issueRoutes(
source: "issue.comment", source: "issue.comment",
}); });
await revalidateActiveSourceRecoveryAfterCommittedWrite({
issue: currentIssue,
trigger: "comment",
actor,
statusChanged: reopened,
resumeRequested: resumeRequested === true,
reopened,
blockedToTodoRecovery: reopened && reopenFromStatus === "blocked" && currentIssue.status === "todo",
});
// Merge all wakeups from this comment into one enqueue per agent to avoid duplicate runs. // Merge all wakeups from this comment into one enqueue per agent to avoid duplicate runs.
void (async () => { void (async () => {
const wakeups = new Map<string, Parameters<typeof heartbeat.wakeup>[1]>(); const wakeups = new Map<string, Parameters<typeof heartbeat.wakeup>[1]>();
+1 -1
View File
@@ -1000,7 +1000,7 @@ function redactInlineBase64ImageData(chunk: string) {
} }
export function compactRunLogChunk(chunk: string, maxChars = MAX_PERSISTED_LOG_CHUNK_CHARS) { export function compactRunLogChunk(chunk: string, maxChars = MAX_PERSISTED_LOG_CHUNK_CHARS) {
const normalized = redactInlineBase64ImageData(chunk); const normalized = redactSensitiveText(redactInlineBase64ImageData(chunk));
if (normalized.length <= maxChars) return normalized; if (normalized.length <= maxChars) return normalized;
const headChars = Math.max(0, Math.floor(maxChars * 0.6)); const headChars = Math.max(0, Math.floor(maxChars * 0.6));
+23 -1
View File
@@ -73,7 +73,10 @@ import {
issueTreeControlService, issueTreeControlService,
type ActiveIssueTreePauseHoldGate, type ActiveIssueTreePauseHoldGate,
} from "./issue-tree-control.js"; } from "./issue-tree-control.js";
import { parseIssueGraphLivenessIncidentKey } from "./recovery/origins.js"; import {
parseIssueGraphLivenessIncidentKey,
RECOVERY_ORIGIN_KINDS,
} from "./recovery/origins.js";
import { classifyIssueGraphLiveness, type IssueLivenessFinding } from "./recovery/issue-graph-liveness.js"; import { classifyIssueGraphLiveness, type IssueLivenessFinding } from "./recovery/issue-graph-liveness.js";
const ALL_ISSUE_STATUSES = ["backlog", "todo", "in_progress", "in_review", "blocked", "done", "cancelled"]; const ALL_ISSUE_STATUSES = ["backlog", "todo", "in_progress", "in_review", "blocked", "done", "cancelled"];
@@ -4515,6 +4518,25 @@ export function issueService(db: Db) {
} }
} }
const [enriched] = await withIssueLabels(tx, [updated]); const [enriched] = await withIssueLabels(tx, [updated]);
if (
(issueData.status === "done" || issueData.status === "cancelled") &&
existing.status !== issueData.status &&
existing.originKind === RECOVERY_ORIGIN_KINDS.issueGraphLivenessEscalation
) {
const parsedIncident = parseIssueGraphLivenessIncidentKey(existing.originId);
if (parsedIncident?.issueId && parsedIncident.companyId === existing.companyId) {
await tx
.delete(issueRelations)
.where(
and(
eq(issueRelations.companyId, existing.companyId),
eq(issueRelations.issueId, existing.id),
eq(issueRelations.relatedIssueId, parsedIncident.issueId),
eq(issueRelations.type, "blocks"),
),
);
}
}
return enriched; return enriched;
}; };
+6 -2
View File
@@ -486,8 +486,12 @@ export async function writePluginLocalFolderTextAtomic(
contents: string, contents: string,
) { ) {
const rootRealPath = await fs.realpath(rootPath); const rootRealPath = await fs.realpath(rootPath);
const resolved = await resolvePluginLocalFolderPath(rootPath, relativePath); const normalized = normalizeRelativePath(relativePath);
await fs.mkdir(path.dirname(resolved.absolutePath), { recursive: true }); const parentRelativePath = path.dirname(normalized);
if (parentRelativePath !== ".") {
await ensureDirectoryInsideRoot(rootRealPath, parentRelativePath);
}
const resolved = await resolvePluginLocalFolderPath(rootRealPath, normalized);
await assertPathInsideRoot(rootRealPath, path.dirname(resolved.absolutePath)); await assertPathInsideRoot(rootRealPath, path.dirname(resolved.absolutePath));
const tempPath = path.join( const tempPath = path.join(
path.dirname(resolved.absolutePath), path.dirname(resolved.absolutePath),
+384 -4
View File
@@ -1,4 +1,4 @@
import { and, asc, desc, eq, gt, inArray, isNull, notInArray, sql } from "drizzle-orm"; import { and, asc, desc, eq, gt, gte, inArray, isNull, notInArray, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db"; import type { Db } from "@paperclipai/db";
import { import {
DEFAULT_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS, DEFAULT_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS,
@@ -11,11 +11,12 @@ import {
agents, agents,
agentWakeupRequests, agentWakeupRequests,
approvals, approvals,
activityLog,
companies, companies,
issueComments,
heartbeatRunEvents, heartbeatRunEvents,
heartbeatRunWatchdogDecisions, heartbeatRunWatchdogDecisions,
heartbeatRuns, heartbeatRuns,
issueComments,
issueApprovals, issueApprovals,
issueRecoveryActions, issueRecoveryActions,
issueRelations, issueRelations,
@@ -26,6 +27,7 @@ import { parseObject, asBoolean, asNumber } from "../../adapters/utils.js";
import { runningProcesses } from "../../adapters/index.js"; import { runningProcesses } from "../../adapters/index.js";
import { forbidden, notFound } from "../../errors.js"; import { forbidden, notFound } from "../../errors.js";
import { logger } from "../../middleware/logger.js"; import { logger } from "../../middleware/logger.js";
import { isPidAlive, isProcessGroupAlive, terminateLocalService } from "../local-service-supervisor.js";
import { redactCurrentUserText } from "../../log-redaction.js"; import { redactCurrentUserText } from "../../log-redaction.js";
import { redactSensitiveText } from "../../redaction.js"; import { redactSensitiveText } from "../../redaction.js";
import { logActivity } from "../activity-log.js"; import { logActivity } from "../activity-log.js";
@@ -68,6 +70,15 @@ const ACTIVE_RUN_OUTPUT_EVIDENCE_TAIL_BYTES = 8 * 1024;
const STRANDED_ISSUE_RECOVERY_ORIGIN_KIND = RECOVERY_ORIGIN_KINDS.strandedIssueRecovery; const STRANDED_ISSUE_RECOVERY_ORIGIN_KIND = RECOVERY_ORIGIN_KINDS.strandedIssueRecovery;
const STALE_ACTIVE_RUN_EVALUATION_ORIGIN_KIND = RECOVERY_ORIGIN_KINDS.staleActiveRunEvaluation; const STALE_ACTIVE_RUN_EVALUATION_ORIGIN_KIND = RECOVERY_ORIGIN_KINDS.staleActiveRunEvaluation;
const DEFERRED_WAKE_CONTEXT_KEY = "_paperclipWakeContext"; const DEFERRED_WAKE_CONTEXT_KEY = "_paperclipWakeContext";
const SESSIONED_LOCAL_ADAPTERS = new Set([
"claude_local",
"codex_local",
"cursor",
"gemini_local",
"hermes_local",
"opencode_local",
"pi_local",
]);
type RecoveryWakeupOptions = { type RecoveryWakeupOptions = {
source?: "timer" | "assignment" | "on_demand" | "automation"; source?: "timer" | "assignment" | "on_demand" | "automation";
@@ -673,6 +684,16 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
return `stale_active_run:${companyId}:${runId}`; return `stale_active_run:${companyId}:${runId}`;
} }
function isTerminalIssueStatus(status: string | null | undefined) {
return status === "done" || status === "cancelled";
}
function isRecoveryOriginIssue(issue: typeof issues.$inferSelect) {
return Object.values(RECOVERY_ORIGIN_KINDS).includes(
issue.originKind as typeof RECOVERY_ORIGIN_KINDS[keyof typeof RECOVERY_ORIGIN_KINDS],
);
}
function silenceStartedAtForRun(run: Pick<typeof heartbeatRuns.$inferSelect, "lastOutputAt" | "processStartedAt" | "startedAt" | "createdAt">) { function silenceStartedAtForRun(run: Pick<typeof heartbeatRuns.$inferSelect, "lastOutputAt" | "processStartedAt" | "startedAt" | "createdAt">) {
return run.lastOutputAt ?? run.processStartedAt ?? run.startedAt ?? run.createdAt ?? null; return run.lastOutputAt ?? run.processStartedAt ?? run.startedAt ?? run.createdAt ?? null;
} }
@@ -798,6 +819,309 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
return issue ?? null; return issue ?? null;
} }
async function latestSameRunSourceTerminalEvidence(input: {
run: typeof heartbeatRuns.$inferSelect;
sourceIssue: typeof issues.$inferSelect;
evidenceAfter: Date | null;
}) {
if (!isTerminalIssueStatus(input.sourceIssue.status)) return null;
const after = input.evidenceAfter ?? input.run.startedAt ?? input.run.createdAt ?? null;
const activityPredicates = [
eq(activityLog.companyId, input.run.companyId),
eq(activityLog.runId, input.run.id),
eq(activityLog.action, "issue.updated"),
eq(activityLog.entityType, "issue"),
eq(activityLog.entityId, input.sourceIssue.id),
sql`${activityLog.details} ->> 'status' = ${input.sourceIssue.status}`,
];
if (after) {
activityPredicates.push(gte(activityLog.createdAt, after));
}
const activity = await db
.select({
id: activityLog.id,
createdAt: activityLog.createdAt,
action: activityLog.action,
})
.from(activityLog)
.where(and(...activityPredicates))
.orderBy(desc(activityLog.createdAt))
.limit(1)
.then((rows) => rows[0] ?? null);
if (activity) {
return {
kind: "activity" as const,
id: activity.id,
createdAt: activity.createdAt,
action: activity.action,
};
}
return null;
}
async function nextRunEventSeq(runId: string) {
const [row] = await db
.select({ maxSeq: sql<number | null>`max(${heartbeatRunEvents.seq})` })
.from(heartbeatRunEvents)
.where(eq(heartbeatRunEvents.runId, runId));
return Number(row?.maxSeq ?? 0) + 1;
}
async function appendRecoveryRunEvent(
run: typeof heartbeatRuns.$inferSelect,
event: {
level: "info" | "warn" | "error";
message: string;
payload?: Record<string, unknown>;
},
) {
await db.insert(heartbeatRunEvents).values({
companyId: run.companyId,
runId: run.id,
agentId: run.agentId,
seq: await nextRunEventSeq(run.id),
eventType: "lifecycle",
stream: "system",
level: event.level,
message: event.message,
payload: event.payload ?? null,
});
}
async function cleanupSourceResolvedRunProcess(input: {
run: typeof heartbeatRuns.$inferSelect;
runningAgent: typeof agents.$inferSelect;
}) {
if (!SESSIONED_LOCAL_ADAPTERS.has(input.runningAgent.adapterType)) {
return {
attempted: false,
outcome: "skipped_non_local_adapter",
adapterType: input.runningAgent.adapterType,
};
}
const running = runningProcesses.get(input.run.id);
const pid = running?.child.pid ?? input.run.processPid ?? null;
const processGroupId = running?.processGroupId ?? input.run.processGroupId ?? null;
if (typeof pid !== "number" && typeof processGroupId !== "number") {
return {
attempted: false,
outcome: "no_process_metadata",
adapterType: input.runningAgent.adapterType,
};
}
const wasAlive =
(typeof pid === "number" && isPidAlive(pid)) ||
(typeof processGroupId === "number" && isProcessGroupAlive(processGroupId));
if (!wasAlive) {
runningProcesses.delete(input.run.id);
return {
attempted: false,
outcome: "not_running",
adapterType: input.runningAgent.adapterType,
pid,
processGroupId,
};
}
try {
await terminateLocalService(
{
pid: typeof pid === "number" && Number.isInteger(pid) && pid > 0
? pid
: (processGroupId ?? 0),
processGroupId: typeof processGroupId === "number" && Number.isInteger(processGroupId) && processGroupId > 0
? processGroupId
: null,
},
running ? { forceAfterMs: Math.max(1, running.graceSec) * 1000 } : undefined,
);
runningProcesses.delete(input.run.id);
const stillAlive =
(typeof pid === "number" && isPidAlive(pid)) ||
(typeof processGroupId === "number" && isProcessGroupAlive(processGroupId));
return {
attempted: true,
outcome: stillAlive ? "termination_sent_still_running" : "terminated",
adapterType: input.runningAgent.adapterType,
pid,
processGroupId,
};
} catch (error) {
return {
attempted: true,
outcome: "failed",
adapterType: input.runningAgent.adapterType,
pid,
processGroupId,
error: error instanceof Error ? error.message : String(error),
};
}
}
async function finalizeAgentAfterSourceResolvedRun(run: typeof heartbeatRuns.$inferSelect, status: "succeeded" | "cancelled") {
const [runningCountRow] = await db
.select({ count: sql<number>`count(*)::int` })
.from(heartbeatRuns)
.where(and(eq(heartbeatRuns.agentId, run.agentId), eq(heartbeatRuns.status, "running")));
const runningCount = Number(runningCountRow?.count ?? 0);
const nextStatus = runningCount > 0 ? "running" : status === "succeeded" || status === "cancelled" ? "idle" : "error";
await db
.update(agents)
.set({
status: nextStatus,
lastHeartbeatAt: new Date(),
updatedAt: new Date(),
})
.where(and(eq(agents.id, run.agentId), notInArray(agents.status, ["paused", "terminated"])));
}
async function foldSourceResolvedStaleRun(input: {
run: typeof heartbeatRuns.$inferSelect;
runningAgent: typeof agents.$inferSelect;
sourceIssue: typeof issues.$inferSelect;
evidence: Awaited<ReturnType<typeof latestSameRunSourceTerminalEvidence>>;
existingEvaluation: Awaited<ReturnType<typeof findOpenStaleRunEvaluation>>;
silenceStartedAt: Date | null;
silenceAgeMs: number | null;
now: Date;
}) {
if (!input.evidence) return { kind: "skipped" as const };
const cleanup = await cleanupSourceResolvedRunProcess({ run: input.run, runningAgent: input.runningAgent });
const finalRunStatus = input.sourceIssue.status === "cancelled" ? "cancelled" : "succeeded";
const resultJson = {
...parseObject(input.run.resultJson),
sourceResolvedWatchdogFold: {
sourceIssueId: input.sourceIssue.id,
sourceIssueIdentifier: input.sourceIssue.identifier,
sourceIssueStatus: input.sourceIssue.status,
sameRunEvidenceKind: input.evidence.kind,
sameRunEvidenceId: input.evidence.id,
sameRunEvidenceAt: input.evidence.createdAt.toISOString(),
silenceStartedAt: input.silenceStartedAt?.toISOString() ?? null,
silenceAgeMs: input.silenceAgeMs,
evaluationIssueId: input.existingEvaluation?.id ?? null,
evaluationIssueIdentifier: input.existingEvaluation?.identifier ?? null,
cleanup,
},
};
const finalizedRun = await db.transaction(async (tx) => {
const [updatedRun] = await tx
.update(heartbeatRuns)
.set({
status: finalRunStatus,
finishedAt: input.now,
error: null,
errorCode: null,
resultJson,
updatedAt: input.now,
})
.where(and(eq(heartbeatRuns.id, input.run.id), eq(heartbeatRuns.companyId, input.run.companyId), eq(heartbeatRuns.status, "running")))
.returning();
if (!updatedRun) return null;
if (input.run.wakeupRequestId) {
await tx
.update(agentWakeupRequests)
.set({
status: finalRunStatus === "succeeded" ? "completed" : "cancelled",
finishedAt: input.now,
error: null,
updatedAt: input.now,
})
.where(and(eq(agentWakeupRequests.id, input.run.wakeupRequestId), eq(agentWakeupRequests.companyId, input.run.companyId)));
}
await tx
.update(issues)
.set({
executionRunId: null,
executionAgentNameKey: null,
executionLockedAt: null,
updatedAt: input.now,
})
.where(
and(
eq(issues.id, input.sourceIssue.id),
eq(issues.companyId, input.run.companyId),
eq(issues.executionRunId, input.run.id),
),
);
return updatedRun;
});
if (!finalizedRun) return { kind: "skipped" as const };
if (input.existingEvaluation && !isTerminalIssueStatus(input.existingEvaluation.status)) {
await issuesSvc.update(input.existingEvaluation.id, { status: "done" });
await issuesSvc.addComment(input.existingEvaluation.id, [
"Source-resolved watchdog fold.",
"",
`- Source issue: ${input.sourceIssue.identifier ?? input.sourceIssue.id}`,
`- Run: \`${input.run.id}\``,
`- Same-run evidence: \`${input.evidence.kind}:${input.evidence.id}\` at ${input.evidence.createdAt.toISOString()}`,
"- Outcome: false positive; the source issue already reached a terminal disposition from this run.",
].join("\n"), { runId: input.run.id });
}
const activeRecoveryAction = await recoveryActionsSvc.getActiveForIssue(input.run.companyId, input.sourceIssue.id);
if (activeRecoveryAction?.kind === "active_run_watchdog") {
await recoveryActionsSvc.resolveActiveForIssue({
companyId: input.run.companyId,
sourceIssueId: input.sourceIssue.id,
actionId: activeRecoveryAction.id,
status: "resolved",
outcome: "false_positive",
resolutionNote: "Source issue reached a terminal disposition through durable same-run activity; watchdog folded as source-resolved.",
});
}
const [decision] = await db
.insert(heartbeatRunWatchdogDecisions)
.values({
companyId: input.run.companyId,
runId: input.run.id,
evaluationIssueId: input.existingEvaluation?.id ?? null,
decision: "dismissed_false_positive",
reason: "Source issue already reached a terminal disposition through durable same-run activity.",
createdByRunId: input.run.id,
})
.returning();
await appendRecoveryRunEvent(finalizedRun, {
level: cleanup.outcome === "failed" ? "warn" : "info",
message: "Source-resolved watchdog fold finalized stale active run",
payload: resultJson.sourceResolvedWatchdogFold,
});
await logActivity(db, {
companyId: input.run.companyId,
actorType: "system",
actorId: "system",
agentId: input.run.agentId,
runId: input.run.id,
action: "heartbeat.output_stale_source_resolved",
entityType: "heartbeat_run",
entityId: input.run.id,
details: {
source: "recovery.scan_silent_active_runs",
sourceIssueId: input.sourceIssue.id,
sourceIssueIdentifier: input.sourceIssue.identifier,
sourceIssueStatus: input.sourceIssue.status,
evaluationIssueId: input.existingEvaluation?.id ?? null,
watchdogDecisionId: decision.id,
sameRunEvidenceKind: input.evidence.kind,
sameRunEvidenceId: input.evidence.id,
sameRunEvidenceAt: input.evidence.createdAt.toISOString(),
cleanup,
},
});
await finalizeAgentAfterSourceResolvedRun(finalizedRun, finalRunStatus);
return { kind: "folded" as const, evaluationIssueId: input.existingEvaluation?.id ?? null };
}
async function resolveStaleRunOwnerAgentId(input: { async function resolveStaleRunOwnerAgentId(input: {
run: typeof heartbeatRuns.$inferSelect; run: typeof heartbeatRuns.$inferSelect;
runningAgent: typeof agents.$inferSelect; runningAgent: typeof agents.$inferSelect;
@@ -1030,6 +1354,47 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
const runningAgent = await getAgent(input.run.agentId); const runningAgent = await getAgent(input.run.agentId);
if (!runningAgent || runningAgent.companyId !== input.run.companyId) return { kind: "skipped" as const }; if (!runningAgent || runningAgent.companyId !== input.run.companyId) return { kind: "skipped" as const };
const sourceIssue = await resolveStaleRunSourceIssue(input.run); const sourceIssue = await resolveStaleRunSourceIssue(input.run);
const existing = await findOpenStaleRunEvaluation(input.run.companyId, input.run.id);
if (sourceIssue && isRecoveryOriginIssue(sourceIssue)) {
await logActivity(db, {
companyId: input.run.companyId,
actorType: "system",
actorId: "system",
agentId: input.run.agentId,
runId: input.run.id,
action: "heartbeat.output_stale_recovery_recursion_refused",
entityType: "heartbeat_run",
entityId: input.run.id,
details: {
source: "recovery.scan_silent_active_runs",
sourceIssueId: sourceIssue.id,
sourceIssueIdentifier: sourceIssue.identifier,
sourceIssueOriginKind: sourceIssue.originKind,
existingEvaluationIssueId: existing?.id ?? null,
},
});
return { kind: "skipped" as const };
}
const silenceStartedAt = silenceStartedAtForRun(input.run);
if (sourceIssue && isTerminalIssueStatus(sourceIssue.status)) {
const terminalEvidence = await latestSameRunSourceTerminalEvidence({
run: input.run,
sourceIssue,
evidenceAfter: silenceStartedAt,
});
if (terminalEvidence) {
return foldSourceResolvedStaleRun({
run: input.run,
runningAgent,
sourceIssue,
evidence: terminalEvidence,
existingEvaluation: existing,
silenceStartedAt,
silenceAgeMs: silenceAgeMsForRun(input.run, input.now),
now: input.now,
});
}
}
const prefix = await getCompanyIssuePrefix(input.run.companyId); const prefix = await getCompanyIssuePrefix(input.run.companyId);
const evidence = await collectStaleRunEvidence({ const evidence = await collectStaleRunEvidence({
run: input.run, run: input.run,
@@ -1039,7 +1404,6 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
now: input.now, now: input.now,
}); });
const level = (evidence.silenceAgeMs ?? 0) >= ACTIVE_RUN_OUTPUT_CRITICAL_THRESHOLD_MS ? "critical" : "suspicious"; const level = (evidence.silenceAgeMs ?? 0) >= ACTIVE_RUN_OUTPUT_CRITICAL_THRESHOLD_MS ? "critical" : "suspicious";
const existing = await findOpenStaleRunEvaluation(input.run.companyId, input.run.id);
if (existing) { if (existing) {
if (level === "critical" && existing.priority !== "high") { if (level === "critical" && existing.priority !== "high") {
await issuesSvc.update(existing.id, { await issuesSvc.update(existing.id, {
@@ -1174,6 +1538,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
created: 0, created: 0,
existing: 0, existing: 0,
escalated: 0, escalated: 0,
folded: 0,
snoozed: 0, snoozed: 0,
skipped: 0, skipped: 0,
evaluationIssueIds: [] as string[], evaluationIssueIds: [] as string[],
@@ -1188,6 +1553,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
if (outcome.kind === "created") result.created += 1; if (outcome.kind === "created") result.created += 1;
else if (outcome.kind === "existing") result.existing += 1; else if (outcome.kind === "existing") result.existing += 1;
else if (outcome.kind === "escalated") result.escalated += 1; else if (outcome.kind === "escalated") result.escalated += 1;
else if (outcome.kind === "folded") result.folded += 1;
else result.skipped += 1; else result.skipped += 1;
if ("evaluationIssueId" in outcome && outcome.evaluationIssueId) { if ("evaluationIssueId" in outcome && outcome.evaluationIssueId) {
result.evaluationIssueIds.push(outcome.evaluationIssueId); result.evaluationIssueIds.push(outcome.evaluationIssueId);
@@ -2382,7 +2748,6 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
if (row.originKind === RECOVERY_ORIGIN_KINDS.issueGraphLivenessEscalation) { if (row.originKind === RECOVERY_ORIGIN_KINDS.issueGraphLivenessEscalation) {
const parsed = parseIssueGraphLivenessIncidentKey(row.originId); const parsed = parseIssueGraphLivenessIncidentKey(row.originId);
if (!parsed || parsed.companyId !== row.companyId) return []; if (!parsed || parsed.companyId !== row.companyId) return [];
if (parsed.state !== "blocked_by_assigned_backlog_issue") return [];
return [ return [
{ {
companyId: row.companyId, companyId: row.companyId,
@@ -2575,6 +2940,21 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
) { ) {
continue; continue;
} }
const sourceIssue = await db
.select({
id: issues.id,
status: issues.status,
})
.from(issues)
.where(and(eq(issues.companyId, parsed.companyId), eq(issues.id, parsed.issueId)))
.then((rows) => rows[0] ?? null);
if (sourceIssue && !["done", "cancelled"].includes(sourceIssue.status)) {
const blockerIds = await existingBlockerIssueIds(parsed.companyId, sourceIssue.id);
if (blockerIds.includes(recovery.id)) {
result.activeSkipped += 1;
continue;
}
}
if (await removeRecoveryBlockerFromSource(recovery)) { if (await removeRecoveryBlockerFromSource(recovery)) {
result.blockerRelationsRemoved += 1; result.blockerRelationsRemoved += 1;
} }
+1 -1
View File
@@ -131,7 +131,7 @@ export const issuesApi = {
data: { data: {
actionId?: string; actionId?: string;
outcome: "restored" | "false_positive" | "blocked" | "cancelled"; outcome: "restored" | "false_positive" | "blocked" | "cancelled";
sourceIssueStatus: "done" | "in_review" | "blocked"; sourceIssueStatus: "todo" | "done" | "in_review" | "blocked";
resolutionNote?: string | null; resolutionNote?: string | null;
}, },
) => api.post<ResolveRecoveryActionResponse>(`/issues/${id}/recovery-actions/resolve`, data), ) => api.post<ResolveRecoveryActionResponse>(`/issues/${id}/recovery-actions/resolve`, data),
@@ -121,6 +121,22 @@ async function flush() {
}); });
} }
async function waitForAssertion(assertion: () => void, attempts = 20) {
let lastError: unknown;
for (let attempt = 0; attempt < attempts; attempt += 1) {
try {
assertion();
return;
} catch (error) {
lastError = error;
await flush();
}
}
throw lastError;
}
function createIssue(overrides: Partial<Issue> = {}): Issue { function createIssue(overrides: Partial<Issue> = {}): Issue {
return { return {
id: "issue-1", id: "issue-1",
@@ -476,6 +492,60 @@ describe("IssueProperties", () => {
act(() => root.unmount()); act(() => root.unmount());
}); });
it("searches all company issues when adding a blocker", async () => {
const onUpdate = vi.fn();
const loadedIssue = createIssue({ id: "issue-3", identifier: "PAP-3", title: "Loaded issue", status: "todo" });
const remoteIssue = createIssue({ id: "issue-99", identifier: "PAP-99", title: "Remote blocker", status: "in_progress" });
mockIssuesApi.list.mockImplementation((_companyId: string, filters?: { q?: string; limit?: number }) => {
if (filters?.q === "remote") return Promise.resolve([remoteIssue]);
return Promise.resolve([loadedIssue]);
});
const root = renderProperties(container, {
issue: createIssue(),
childIssues: [],
onUpdate,
inline: true,
});
await flush();
const addButton = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("Add blocker"));
expect(addButton).not.toBeUndefined();
await act(async () => {
addButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();
const searchInput = container.querySelector('input[aria-label="Search issues to add as blockers"]') as HTMLInputElement | null;
expect(searchInput).not.toBeNull();
await act(async () => {
const nativeSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
nativeSetter?.call(searchInput, "remote");
searchInput!.dispatchEvent(new Event("input", { bubbles: true }));
});
await waitForAssertion(() => {
expect(mockIssuesApi.list).toHaveBeenCalledWith("company-1", { q: "remote", limit: 50 });
expect(container.textContent).toContain("PAP-99 Remote blocker");
expect(container.textContent).not.toContain("PAP-3 Loaded issue");
});
const candidateButton = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("PAP-99 Remote blocker"));
expect(candidateButton).not.toBeUndefined();
await act(async () => {
candidateButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onUpdate).toHaveBeenCalledWith({ blockedByIssueIds: ["issue-99"] });
act(() => root.unmount());
});
it("removes a blocked-by issue from the chip remove action after confirmation", async () => { it("removes a blocked-by issue from the chip remove action after confirmation", async () => {
const onUpdate = vi.fn(); const onUpdate = vi.fn();
const root = renderProperties(container, { const root = renderProperties(container, {
+40 -14
View File
@@ -145,6 +145,8 @@ interface IssuePropertiesProps {
inline?: boolean; inline?: boolean;
} }
const ISSUE_BLOCKER_SEARCH_LIMIT = 50;
function PropertyRow({ label, children }: { label: string; children: React.ReactNode }) { function PropertyRow({ label, children }: { label: string; children: React.ReactNode }) {
return ( return (
<div className="flex items-start gap-3 py-1.5"> <div className="flex items-start gap-3 py-1.5">
@@ -405,6 +407,7 @@ export function IssueProperties({
const [monitorAtInput, setMonitorAtInput] = useState(() => toDateTimeLocalValue(issue.executionPolicy?.monitor?.nextCheckAt)); const [monitorAtInput, setMonitorAtInput] = useState(() => toDateTimeLocalValue(issue.executionPolicy?.monitor?.nextCheckAt));
const [monitorNotesInput, setMonitorNotesInput] = useState(issue.executionPolicy?.monitor?.notes ?? ""); const [monitorNotesInput, setMonitorNotesInput] = useState(issue.executionPolicy?.monitor?.notes ?? "");
const [monitorServiceInput, setMonitorServiceInput] = useState(issue.executionPolicy?.monitor?.serviceName ?? ""); const [monitorServiceInput, setMonitorServiceInput] = useState(issue.executionPolicy?.monitor?.serviceName ?? "");
const normalizedBlockedBySearch = blockedBySearch.trim();
const { data: session } = useQuery({ const { data: session } = useQuery({
queryKey: queryKeys.auth.session, queryKey: queryKeys.auth.session,
@@ -443,10 +446,21 @@ export function IssueProperties({
enabled: !!companyId, enabled: !!companyId,
}); });
const { data: allIssues } = useQuery({ const { data: allIssues, isFetching: isFetchingIssuePickerIssues } = useQuery({
queryKey: queryKeys.issues.list(companyId!), queryKey: queryKeys.issues.list(companyId!),
queryFn: () => issuesApi.list(companyId!), queryFn: () => issuesApi.list(companyId!),
enabled: !!companyId && (blockedByOpen || parentOpen), enabled: !!companyId && (parentOpen || (blockedByOpen && normalizedBlockedBySearch.length === 0)),
});
const { data: searchedBlockedByIssues, isFetching: isFetchingSearchedBlockedByIssues } = useQuery({
queryKey: companyId
? queryKeys.issues.search(companyId, normalizedBlockedBySearch, undefined, ISSUE_BLOCKER_SEARCH_LIMIT)
: ["issues", "blocker-search", normalizedBlockedBySearch, ISSUE_BLOCKER_SEARCH_LIMIT],
queryFn: () => issuesApi.list(companyId!, {
q: normalizedBlockedBySearch,
limit: ISSUE_BLOCKER_SEARCH_LIMIT,
}),
enabled: !!companyId && blockedByOpen && normalizedBlockedBySearch.length > 0,
}); });
const createLabel = useMutation({ const createLabel = useMutation({
@@ -1648,27 +1662,28 @@ export function IssueProperties({
</> </>
); );
const blockingIssues = issue.blocks ?? []; const blockingIssues = issue.blocks ?? [];
const blockerOptions = (allIssues ?? []) const blockerSearchActive = normalizedBlockedBySearch.length > 0;
.filter((candidate) => candidate.id !== issue.id) const blockerSourceIssues = blockerSearchActive ? searchedBlockedByIssues : allIssues;
.filter((candidate) => { const blockerOptions = (blockerSourceIssues ?? [])
if (!blockedBySearch.trim()) return true; .filter((candidate) => candidate.id !== issue.id);
const query = blockedBySearch.toLowerCase(); if (!blockerSearchActive) {
return ( blockerOptions.sort((a, b) => {
(candidate.identifier ?? "").toLowerCase().includes(query) ||
candidate.title.toLowerCase().includes(query)
);
})
.sort((a, b) => {
const aLabel = `${a.identifier ?? ""} ${a.title}`.trim(); const aLabel = `${a.identifier ?? ""} ${a.title}`.trim();
const bLabel = `${b.identifier ?? ""} ${b.title}`.trim(); const bLabel = `${b.identifier ?? ""} ${b.title}`.trim();
return aLabel.localeCompare(bLabel); return aLabel.localeCompare(bLabel);
}); });
}
const blockerOptionsLoading = blockedByOpen && (
blockerSearchActive ? isFetchingSearchedBlockedByIssues : isFetchingIssuePickerIssues
);
const toggleBlockedBy = (blockedByIssueId: string) => { const toggleBlockedBy = (blockedByIssueId: string) => {
const nextBlockedByIds = blockedByIds.includes(blockedByIssueId) const nextBlockedByIds = blockedByIds.includes(blockedByIssueId)
? blockedByIds.filter((candidate) => candidate !== blockedByIssueId) ? blockedByIds.filter((candidate) => candidate !== blockedByIssueId)
: [...blockedByIds, blockedByIssueId]; : [...blockedByIds, blockedByIssueId];
onUpdate({ blockedByIssueIds: nextBlockedByIds }); onUpdate({ blockedByIssueIds: nextBlockedByIds });
setBlockedByOpen(false);
setBlockedBySearch("");
}; };
const removeBlockedBy = (blockedByIssueId: string) => { const removeBlockedBy = (blockedByIssueId: string) => {
onUpdate({ blockedByIssueIds: blockedByIds.filter((candidate) => candidate !== blockedByIssueId) }); onUpdate({ blockedByIssueIds: blockedByIds.filter((candidate) => candidate !== blockedByIssueId) });
@@ -1682,6 +1697,7 @@ export function IssueProperties({
value={blockedBySearch} value={blockedBySearch}
onChange={(e) => setBlockedBySearch(e.target.value)} onChange={(e) => setBlockedBySearch(e.target.value)}
autoFocus={!inline} autoFocus={!inline}
aria-label="Search issues to add as blockers"
/> />
<div className="max-h-48 overflow-y-auto overscroll-contain"> <div className="max-h-48 overflow-y-auto overscroll-contain">
<button <button
@@ -1689,7 +1705,11 @@ export function IssueProperties({
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50", "flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
blockedByIds.length === 0 && "bg-accent", blockedByIds.length === 0 && "bg-accent",
)} )}
onClick={() => onUpdate({ blockedByIssueIds: [] })} onClick={() => {
onUpdate({ blockedByIssueIds: [] });
setBlockedByOpen(false);
setBlockedBySearch("");
}}
> >
No blockers No blockers
</button> </button>
@@ -1709,9 +1729,15 @@ export function IssueProperties({
{candidate.identifier ? `${candidate.identifier} ` : ""} {candidate.identifier ? `${candidate.identifier} ` : ""}
{candidate.title} {candidate.title}
</span> </span>
{selected && <Check className="ml-auto h-3.5 w-3.5 shrink-0 text-foreground" aria-hidden="true" />}
</button> </button>
); );
})} })}
{blockerOptionsLoading ? (
<div className="px-2 py-2 text-xs text-muted-foreground">Searching issues...</div>
) : blockerOptions.length === 0 ? (
<div className="px-2 py-2 text-xs text-muted-foreground">No matching issues.</div>
) : null}
</div> </div>
</> </>
); );
@@ -165,19 +165,20 @@ describe("IssueRecoveryActionCard", () => {
expect(node.textContent).toContain("Resolved as restored"); expect(node.textContent).toContain("Resolved as restored");
}); });
it("calls resolve with done and does not offer delegated recovery", () => { it("calls resolve with todo and does not offer delegated recovery", () => {
const onResolve = vi.fn(); const onResolve = vi.fn();
const node = render( const node = render(
<IssueRecoveryActionCard action={buildAction()} onResolve={onResolve} />, <IssueRecoveryActionCard action={buildAction()} onResolve={onResolve} />,
); );
click(node.querySelector("[data-testid='recovery-action-resolve-trigger']")); click(node.querySelector("[data-testid='recovery-action-resolve-trigger']"));
expect(document.body.textContent).toContain("Try again");
expect(document.body.textContent).toContain("Mark issue done"); expect(document.body.textContent).toContain("Mark issue done");
expect(document.body.textContent).not.toContain("Mark blocked"); expect(document.body.textContent).not.toContain("Mark blocked");
expect(document.body.textContent).not.toContain("Delegate follow-up issue"); expect(document.body.textContent).not.toContain("Delegate follow-up issue");
click([...document.body.querySelectorAll("button")].find((button) => button.textContent?.includes("Mark issue done")) ?? null); click([...document.body.querySelectorAll("button")].find((button) => button.textContent?.includes("Try again")) ?? null);
expect(onResolve).toHaveBeenCalledWith("done"); expect(onResolve).toHaveBeenCalledWith("todo");
}); });
it("does not offer blocked recovery resolution without a blocker selection flow", () => { it("does not offer blocked recovery resolution without a blocker selection flow", () => {
@@ -186,6 +187,7 @@ describe("IssueRecoveryActionCard", () => {
); );
click(node.querySelector("[data-testid='recovery-action-resolve-trigger']")); click(node.querySelector("[data-testid='recovery-action-resolve-trigger']"));
expect(document.body.textContent).toContain("Try again");
expect(document.body.textContent).toContain("Mark issue done"); expect(document.body.textContent).toContain("Mark issue done");
expect(document.body.textContent).toContain("Send for review"); expect(document.body.textContent).toContain("Send for review");
expect(document.body.textContent).toContain("False positive, done"); expect(document.body.textContent).toContain("False positive, done");
@@ -25,6 +25,7 @@ export type RecoveryCardCardState = RecoveryDisplayState;
export const deriveRecoveryCardState = deriveRecoveryDisplayState; export const deriveRecoveryCardState = deriveRecoveryDisplayState;
export type RecoveryResolveOutcome = export type RecoveryResolveOutcome =
| "todo"
| "done" | "done"
| "in_review" | "in_review"
| "false_positive_done" | "false_positive_done"
@@ -292,6 +293,11 @@ const RESOLVE_OPTIONS: Array<{
destructive?: boolean; destructive?: boolean;
boardOnly?: boolean; boardOnly?: boolean;
}> = [ }> = [
{
outcome: "todo",
label: "Try again",
description: "Dismiss recovery and return the source issue to todo.",
},
{ {
outcome: "done", outcome: "done",
label: "Mark issue done", label: "Mark issue done",
+1
View File
@@ -238,6 +238,7 @@ describe("IssueRow", () => {
const link = container.querySelector("[data-inbox-issue-link]") as HTMLAnchorElement | null; const link = container.querySelector("[data-inbox-issue-link]") as HTMLAnchorElement | null;
expect(link).not.toBeNull(); expect(link).not.toBeNull();
expect(link?.textContent).toContain("Planning"); expect(link?.textContent).toContain("Planning");
expect(link?.textContent?.match(/Planning/g)).toHaveLength(1);
act(() => { act(() => {
root.unmount(); root.unmount();
+1 -2
View File
@@ -126,7 +126,6 @@ export function IssueRow({
<span className="flex shrink-0 items-center gap-1 pt-px sm:hidden"> <span className="flex shrink-0 items-center gap-1 pt-px sm:hidden">
{mobileLeading ?? <StatusIcon status={issue.status} blockerAttention={issue.blockerAttention} className={selectedStatusClass} />} {mobileLeading ?? <StatusIcon status={issue.status} blockerAttention={issue.blockerAttention} className={selectedStatusClass} />}
{productivityReviewIndicator} {productivityReviewIndicator}
{planningModeIndicator}
{parkedBlockerIndicator} {parkedBlockerIndicator}
{recoveryIndicator} {recoveryIndicator}
</span> </span>
@@ -153,11 +152,11 @@ export function IssueRow({
<span className="shrink-0 font-mono text-xs text-muted-foreground"> <span className="shrink-0 font-mono text-xs text-muted-foreground">
{identifier} {identifier}
</span> </span>
{planningModeIndicator}
{parkedBlockerIndicator} {parkedBlockerIndicator}
{recoveryIndicator} {recoveryIndicator}
</> </>
)} )}
{planningModeIndicator}
{mobileMeta ? ( {mobileMeta ? (
<> <>
<span className="text-xs text-muted-foreground sm:hidden" aria-hidden="true"> <span className="text-xs text-muted-foreground sm:hidden" aria-hidden="true">
+4
View File
@@ -16,6 +16,8 @@ import { cn, relativeTime } from "../lib/utils";
import { queryKeys } from "../lib/queryKeys"; import { queryKeys } from "../lib/queryKeys";
import { keepPreviousDataForSameQueryTail } from "../lib/query-placeholder-data"; import { keepPreviousDataForSameQueryTail } from "../lib/query-placeholder-data";
import { describeRunRetryState } from "../lib/runRetryState"; import { describeRunRetryState } from "../lib/runRetryState";
import { readSourceResolvedWatchdogFold } from "../lib/source-resolved-watchdog-fold";
import { SourceResolvedFoldBadge } from "./SourceResolvedFoldBadge";
type IssueRunLedgerProps = { type IssueRunLedgerProps = {
issueId: string; issueId: string;
@@ -693,6 +695,7 @@ export function IssueRunLedgerContent({
const continuation = continuationLabel(run); const continuation = continuationLabel(run);
const retryState = describeRunRetryState(run); const retryState = describeRunRetryState(run);
const agentName = compactAgentName(run, agentMap); const agentName = compactAgentName(run, agentMap);
const sourceResolvedFold = readSourceResolvedWatchdogFold(run.resultJson);
return ( return (
<article <article
key={`run:${run.runId}`} key={`run:${run.runId}`}
@@ -773,6 +776,7 @@ export function IssueRunLedgerContent({
</span> </span>
); );
})()} })()}
{sourceResolvedFold ? <SourceResolvedFoldBadge /> : null}
<span className="ml-auto shrink-0">{relativeTime(item.timestamp)}</span> <span className="ml-auto shrink-0">{relativeTime(item.timestamp)}</span>
</div> </div>
+3 -1
View File
@@ -1250,7 +1250,9 @@ export function IssuesList({
} }
else if (viewState.groupBy === "project" && groupKey !== "__no_project") defaults.projectId = groupKey; else if (viewState.groupBy === "project" && groupKey !== "__no_project") defaults.projectId = groupKey;
else if (viewState.groupBy === "workspace" && groupKey !== "__no_workspace") { else if (viewState.groupBy === "workspace" && groupKey !== "__no_workspace") {
const representativeIssue = group?.items.find((issue) => issue.executionWorkspaceId === groupKey) ?? null; const representativeIssue = group?.items.find((issue) =>
issue.executionWorkspaceId === groupKey || issue.projectWorkspaceId === groupKey,
) ?? null;
const executionWorkspace = executionWorkspaceById.get(groupKey); const executionWorkspace = executionWorkspaceById.get(groupKey);
if (executionWorkspace) { if (executionWorkspace) {
defaults.executionWorkspaceId = groupKey; defaults.executionWorkspaceId = groupKey;
+5 -2
View File
@@ -8,6 +8,7 @@ import {
buildAgentMentionHref, buildAgentMentionHref,
buildIssueReferenceHref, buildIssueReferenceHref,
buildProjectMentionHref, buildProjectMentionHref,
buildRoutineMentionHref,
buildSkillMentionHref, buildSkillMentionHref,
buildUserMentionHref, buildUserMentionHref,
} from "@paperclipai/shared"; } from "@paperclipai/shared";
@@ -92,12 +93,12 @@ describe("MarkdownBody", () => {
expect(html).toContain('alt="Org chart"'); expect(html).toContain('alt="Org chart"');
}); });
it("renders user, agent, project, and skill mentions as chips", () => { it("renders user, agent, project, skill, and routine mentions as chips", () => {
const html = renderToStaticMarkup( const html = renderToStaticMarkup(
<QueryClientProvider client={new QueryClient()}> <QueryClientProvider client={new QueryClient()}>
<ThemeProvider> <ThemeProvider>
<MarkdownBody> <MarkdownBody>
{`[@Taylor](${buildUserMentionHref("user-123")}) [@CodexCoder](${buildAgentMentionHref("agent-123", "code")}) [@Paperclip App](${buildProjectMentionHref("project-456", "#336699")}) [/release-changelog](${buildSkillMentionHref("skill-789", "release-changelog")})`} {`[@Taylor](${buildUserMentionHref("user-123")}) [@CodexCoder](${buildAgentMentionHref("agent-123", "code")}) [@Paperclip App](${buildProjectMentionHref("project-456", "#336699")}) [/release-changelog](${buildSkillMentionHref("skill-789", "release-changelog")}) [/routine:Weekly review](${buildRoutineMentionHref("routine-123")})`}
</MarkdownBody> </MarkdownBody>
</ThemeProvider> </ThemeProvider>
</QueryClientProvider>, </QueryClientProvider>,
@@ -113,6 +114,8 @@ describe("MarkdownBody", () => {
expect(html).toContain("--paperclip-mention-project-color:#336699"); expect(html).toContain("--paperclip-mention-project-color:#336699");
expect(html).toContain('href="/skills/skill-789"'); expect(html).toContain('href="/skills/skill-789"');
expect(html).toContain('data-mention-kind="skill"'); expect(html).toContain('data-mention-kind="skill"');
expect(html).toContain('href="/routines/routine-123"');
expect(html).toContain('data-mention-kind="routine"');
}); });
it("sanitizes unsafe javascript markdown links", () => { it("sanitizes unsafe javascript markdown links", () => {
+7 -5
View File
@@ -586,11 +586,13 @@ export function MarkdownBody({
? `/projects/${parsed.projectId}` ? `/projects/${parsed.projectId}`
: parsed.kind === "issue" : parsed.kind === "issue"
? `/issues/${parsed.identifier}` ? `/issues/${parsed.identifier}`
: parsed.kind === "skill" : parsed.kind === "skill"
? `/skills/${parsed.skillId}` ? `/skills/${parsed.skillId}`
: parsed.kind === "user" : parsed.kind === "routine"
? "/company/settings/access" ? `/routines/${parsed.routineId}`
: `/agents/${parsed.agentId}`; : parsed.kind === "user"
? "/company/settings/access"
: `/agents/${parsed.agentId}`;
return ( return (
<a <a
href={targetHref} href={targetHref}
+31 -1
View File
@@ -3,7 +3,7 @@
import { act } from "react"; import { act } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { buildProjectMentionHref, buildSkillMentionHref } from "@paperclipai/shared"; import { buildProjectMentionHref, buildRoutineMentionHref, buildSkillMentionHref } from "@paperclipai/shared";
import { import {
computeMentionMenuPosition, computeMentionMenuPosition,
findClosestAutocompleteAnchor, findClosestAutocompleteAnchor,
@@ -553,6 +553,16 @@ describe("MarkdownEditor", () => {
expect(findMentionMatch("/open issue", "/open issue".length)).toBeNull(); expect(findMentionMatch("/open issue", "/open issue".length)).toBeNull();
}); });
it("keeps routine slash queries active across spaces", () => {
expect(findMentionMatch("/routine:Weekly release review", "/routine:Weekly release review".length)).toEqual({
trigger: "skill",
marker: "/",
query: "routine:Weekly release review",
atPos: 0,
endPos: "/routine:Weekly release review".length,
});
});
it("does not treat Enter as skill autocomplete accept", () => { it("does not treat Enter as skill autocomplete accept", () => {
expect(shouldAcceptAutocompleteKey("Enter", "skill")).toBe(false); expect(shouldAcceptAutocompleteKey("Enter", "skill")).toBe(false);
expect(shouldAcceptAutocompleteKey("Enter", "skill", true)).toBe(true); expect(shouldAcceptAutocompleteKey("Enter", "skill", true)).toBe(true);
@@ -623,6 +633,26 @@ describe("MarkdownEditor", () => {
expect(found).toBe(skillLink); expect(found).toBe(skillLink);
}); });
it("finds routine anchors by mention metadata instead of visible text", () => {
const editable = document.createElement("div");
const routineLink = document.createElement("a");
routineLink.setAttribute("href", buildRoutineMentionHref("routine-123"));
routineLink.textContent = "/routine:Weekly release review ";
editable.appendChild(routineLink);
const found = findClosestAutocompleteAnchor(editable, {
id: "routine:routine-123",
kind: "routine",
routineId: "routine-123",
name: "Weekly release review",
status: "active",
href: buildRoutineMentionHref("routine-123"),
aliases: ["routine:Weekly release review", "Weekly release review"],
});
expect(found).toBe(routineLink);
});
it("places the caret after the mention's trailing space when present", () => { it("places the caret after the mention's trailing space when present", () => {
const editable = document.createElement("div"); const editable = document.createElement("div");
editable.contentEditable = "true"; editable.contentEditable = "true";
+40 -10
View File
@@ -31,8 +31,13 @@ import {
thematicBreakPlugin, thematicBreakPlugin,
type RealmPlugin, type RealmPlugin,
} from "@mdxeditor/editor"; } from "@mdxeditor/editor";
import { buildAgentMentionHref, buildProjectMentionHref, buildUserMentionHref } from "@paperclipai/shared"; import {
import { Boxes, User } from "lucide-react"; buildAgentMentionHref,
buildProjectMentionHref,
buildRoutineMentionHref,
buildUserMentionHref,
} from "@paperclipai/shared";
import { Boxes, CalendarClock, User } from "lucide-react";
import { AgentIcon } from "./AgentIconPicker"; import { AgentIcon } from "./AgentIconPicker";
import { applyMentionChipDecoration, clearMentionChipDecoration, parseMentionChipHref } from "../lib/mention-chips"; import { applyMentionChipDecoration, clearMentionChipDecoration, parseMentionChipHref } from "../lib/mention-chips";
import { MentionAwareLinkNode, mentionAwareLinkNodeReplacement } from "../lib/mention-aware-link-node"; import { MentionAwareLinkNode, mentionAwareLinkNodeReplacement } from "../lib/mention-aware-link-node";
@@ -41,7 +46,7 @@ import { looksLikeMarkdownPaste } from "../lib/markdownPaste";
import { normalizeMarkdown } from "../lib/normalize-markdown"; import { normalizeMarkdown } from "../lib/normalize-markdown";
import { pasteNormalizationPlugin } from "../lib/paste-normalization"; import { pasteNormalizationPlugin } from "../lib/paste-normalization";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
import { useEditorAutocomplete, type SkillCommandOption } from "../context/EditorAutocompleteContext"; import { useEditorAutocomplete, type SlashCommandOption } from "../context/EditorAutocompleteContext";
/* ---- Mention types ---- */ /* ---- Mention types ---- */
@@ -188,7 +193,7 @@ interface MentionState {
endPos: number; endPos: number;
} }
type AutocompleteOption = MentionOption | SkillCommandOption; type AutocompleteOption = MentionOption | SlashCommandOption;
interface MentionMenuViewport { interface MentionMenuViewport {
offsetLeft: number; offsetLeft: number;
@@ -260,7 +265,9 @@ export function findMentionMatch(
if (atPos === -1) return null; if (atPos === -1) return null;
const query = text.slice(atPos + 1, offset); const query = text.slice(atPos + 1, offset);
if (trigger === "skill" && /\s/.test(query)) return null; if (trigger === "skill" && /\s/.test(query) && !query.toLowerCase().startsWith("routine:")) {
return null;
}
return { return {
trigger: trigger ?? "mention", trigger: trigger ?? "mention",
@@ -423,12 +430,21 @@ function mentionMarkdown(option: MentionOption): string {
return `[@${option.name}](${buildAgentMentionHref(agentId, option.agentIcon ?? null)}) `; return `[@${option.name}](${buildAgentMentionHref(agentId, option.agentIcon ?? null)}) `;
} }
function skillMarkdown(option: SkillCommandOption): string { function slashCommandLabel(option: SlashCommandOption): string {
return option.kind === "routine" ? `/routine:${option.name}` : `/${option.slug}`;
}
function slashCommandMarkdown(option: SlashCommandOption): string {
if (option.kind === "routine") {
return `[${slashCommandLabel(option)}](${buildRoutineMentionHref(option.routineId)}) `;
}
return `[/${option.slug}](${option.href}) `; return `[/${option.slug}](${option.href}) `;
} }
function autocompleteMarkdown(option: AutocompleteOption): string { function autocompleteMarkdown(option: AutocompleteOption): string {
return option.kind === "skill" ? skillMarkdown(option) : mentionMarkdown(option); return option.kind === "skill" || option.kind === "routine"
? slashCommandMarkdown(option)
: mentionMarkdown(option);
} }
export function shouldAcceptAutocompleteKey( export function shouldAcceptAutocompleteKey(
@@ -461,6 +477,9 @@ function autocompleteOptionMatchesLink(option: AutocompleteOption, href: string)
if (option.kind === "skill") { if (option.kind === "skill") {
return parsed.kind === "skill" && parsed.skillId === option.skillId; return parsed.kind === "skill" && parsed.skillId === option.skillId;
} }
if (option.kind === "routine") {
return parsed.kind === "routine" && parsed.routineId === option.routineId;
}
if (option.kind === "project" && option.projectId) { if (option.kind === "project" && option.projectId) {
return parsed.kind === "project" && parsed.projectId === option.projectId; return parsed.kind === "project" && parsed.projectId === option.projectId;
@@ -785,7 +804,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
continue; continue;
} }
if (parsed.kind === "skill") { if (parsed.kind === "skill" || parsed.kind === "routine") {
applyMentionChipDecoration(link, parsed); applyMentionChipDecoration(link, parsed);
continue; continue;
} }
@@ -1256,7 +1275,9 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
setMentionIndex(i); setMentionIndex(i);
}} }}
> >
{option.kind === "skill" ? ( {option.kind === "routine" ? (
<CalendarClock className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
) : option.kind === "skill" ? (
<Boxes className="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> <Boxes className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
) : option.kind === "project" && option.projectId ? ( ) : option.kind === "project" && option.projectId ? (
<span <span
@@ -1271,7 +1292,11 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
className="h-3.5 w-3.5 shrink-0 text-muted-foreground" className="h-3.5 w-3.5 shrink-0 text-muted-foreground"
/> />
)} )}
<span>{option.kind === "skill" ? `/${option.slug}` : option.name}</span> <span>
{option.kind === "skill" || option.kind === "routine"
? slashCommandLabel(option)
: option.name}
</span>
{option.kind === "project" && option.projectId && ( {option.kind === "project" && option.projectId && (
<span className="ml-auto text-[10px] uppercase tracking-wide text-muted-foreground"> <span className="ml-auto text-[10px] uppercase tracking-wide text-muted-foreground">
Project Project
@@ -1287,6 +1312,11 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
Skill Skill
</span> </span>
)} )}
{option.kind === "routine" && (
<span className="ml-auto text-[10px] uppercase tracking-wide text-muted-foreground">
Routine
</span>
)}
</button> </button>
))} ))}
</div>, </div>,
+1 -1
View File
@@ -134,7 +134,7 @@ export function RoutineListRow<TRoutine extends RoutineListRowItem>({
<div className="flex items-center gap-3" onClick={(event) => { event.preventDefault(); event.stopPropagation(); }}> <div className="flex items-center gap-3" onClick={(event) => { event.preventDefault(); event.stopPropagation(); }}>
{runNowButton ? ( {runNowButton ? (
<Button <Button
variant="ghost" variant="outline"
size="sm" size="sm"
disabled={runDisabled} disabled={runDisabled}
onClick={() => onRunNow(routine)} onClick={() => onRunNow(routine)}
+24 -1
View File
@@ -70,6 +70,12 @@ vi.mock("@/plugins/slots", () => ({
PluginSlotOutlet: () => null, PluginSlotOutlet: () => null,
})); }));
vi.mock("@/plugins/launchers", () => ({
PluginLauncherOutlet: ({ placementZones }: { placementZones: string[] }) => (
<div data-plugin-launcher-zone={placementZones.join(",")}>Plugin launcher outlet</div>
),
}));
vi.mock("./SidebarCompanyMenu", () => ({ vi.mock("./SidebarCompanyMenu", () => ({
SidebarCompanyMenu: () => <div>Company menu</div>, SidebarCompanyMenu: () => <div>Company menu</div>,
})); }));
@@ -129,7 +135,7 @@ describe("Sidebar", () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false }); mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false });
const root = await renderSidebar(); const root = await renderSidebar();
const topSearchLink = container.querySelector('a[aria-label="Search"]'); const topSearchLink = container.querySelector('a[aria-label="Open search"]');
expect(topSearchLink?.getAttribute("href")).toBe("/search"); expect(topSearchLink?.getAttribute("href")).toBe("/search");
const workLinks = [...container.querySelectorAll("nav a")].map((anchor) => anchor.textContent?.trim()); const workLinks = [...container.querySelectorAll("nav a")].map((anchor) => anchor.textContent?.trim());
expect(workLinks).not.toContain("Search"); expect(workLinks).not.toContain("Search");
@@ -139,6 +145,23 @@ describe("Sidebar", () => {
}); });
}); });
it("renders plugin sidebar launchers inside the Work section", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false });
const root = await renderSidebar();
const workSection = [...container.querySelectorAll("nav [data-plugin-launcher-zone]")]
.find((node) => node.getAttribute("data-plugin-launcher-zone") === "sidebar");
expect(workSection?.textContent).toContain("Plugin launcher outlet");
const workSectionContainer = workSection?.parentElement?.parentElement;
expect(workSectionContainer?.textContent).toContain("Work");
expect(workSectionContainer?.textContent).toContain("Issues");
expect(workSectionContainer?.textContent).toContain("Goals");
await act(async () => {
root.unmount();
});
});
it("does not flash the Workspaces link while experimental settings are loading", async () => { it("does not flash the Workspaces link while experimental settings are loading", async () => {
mockInstanceSettingsApi.getExperimental.mockImplementation(() => new Promise(() => {})); mockInstanceSettingsApi.getExperimental.mockImplementation(() => new Promise(() => {}));
const root = await renderSidebar(); const root = await renderSidebar();
+9 -2
View File
@@ -27,6 +27,7 @@ import { queryKeys } from "../lib/queryKeys";
import { useInboxBadge } from "../hooks/useInboxBadge"; import { useInboxBadge } from "../hooks/useInboxBadge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { PluginSlotOutlet } from "@/plugins/slots"; import { PluginSlotOutlet } from "@/plugins/slots";
import { PluginLauncherOutlet } from "@/plugins/launchers";
import { SidebarCompanyMenu } from "./SidebarCompanyMenu"; import { SidebarCompanyMenu } from "./SidebarCompanyMenu";
export function Sidebar() { export function Sidebar() {
@@ -61,8 +62,8 @@ export function Sidebar() {
variant="ghost" variant="ghost"
size="icon-sm" size="icon-sm"
className="text-muted-foreground shrink-0" className="text-muted-foreground shrink-0"
aria-label="Search" aria-label="Open search"
title="Search" title="Open search"
> >
<NavLink to="/search"> <NavLink to="/search">
<Search className="h-4 w-4" /> <Search className="h-4 w-4" />
@@ -101,6 +102,12 @@ export function Sidebar() {
<SidebarSection label="Work"> <SidebarSection label="Work">
<SidebarNavItem to="/issues" label="Issues" icon={CircleDot} /> <SidebarNavItem to="/issues" label="Issues" icon={CircleDot} />
<SidebarNavItem to="/routines" label="Routines" icon={Repeat} /> <SidebarNavItem to="/routines" label="Routines" icon={Repeat} />
<PluginLauncherOutlet
placementZones={["sidebar"]}
context={pluginContext}
className="flex flex-col gap-0.5"
itemClassName="text-[13px] font-medium"
/>
<SidebarNavItem to="/goals" label="Goals" icon={Target} /> <SidebarNavItem to="/goals" label="Goals" icon={Target} />
{showWorkspacesLink ? ( {showWorkspacesLink ? (
<SidebarNavItem to="/workspaces" label="Workspaces" icon={GitBranch} /> <SidebarNavItem to="/workspaces" label="Workspaces" icon={GitBranch} />
@@ -110,7 +110,7 @@ describe("SidebarAccountMenu", () => {
expect(document.body.textContent).toContain("Paperclip v1.2.3"); expect(document.body.textContent).toContain("Paperclip v1.2.3");
expect(document.body.textContent).toContain("jane@example.com"); expect(document.body.textContent).toContain("jane@example.com");
expect(document.body.querySelector('[data-slot="popover-content"]')?.className) expect(document.body.querySelector('[data-slot="popover-content"]')?.className)
.toContain("w-[var(--radix-popover-trigger-width)]"); .toContain("w-[277px]");
await act(async () => { await act(async () => {
root.unmount(); root.unmount();
+1 -1
View File
@@ -160,7 +160,7 @@ export function SidebarAccountMenu({
side="top" side="top"
align="start" align="start"
sideOffset={10} sideOffset={10}
className="w-[var(--radix-popover-trigger-width)] max-w-[calc(100vw-1rem)] overflow-hidden rounded-t-2xl rounded-b-none border-border p-0 shadow-2xl" className="w-[277px] max-w-[calc(100vw-1rem)] overflow-hidden rounded-t-2xl rounded-b-none border-border p-0 shadow-2xl"
> >
<div className="h-24 bg-[linear-gradient(135deg,hsl(var(--primary))_0%,hsl(var(--accent))_55%,hsl(var(--muted))_100%)]" /> <div className="h-24 bg-[linear-gradient(135deg,hsl(var(--primary))_0%,hsl(var(--accent))_55%,hsl(var(--muted))_100%)]" />
<div className="-mt-8 px-4 pb-4"> <div className="-mt-8 px-4 pb-4">
@@ -0,0 +1,33 @@
import { Sparkles } from "lucide-react";
import { cn } from "@/lib/utils";
export interface SourceResolvedFoldBadgeProps {
className?: string;
title?: string;
/** When true (default) the leading sparkles icon is rendered. */
showIcon?: boolean;
}
export function SourceResolvedFoldBadge({
className,
title = "System folded this run as a source-resolved false positive.",
showIcon = true,
}: SourceResolvedFoldBadgeProps) {
return (
<span
className={cn(
"inline-flex items-center gap-1 rounded-md border px-1.5 py-0.5 text-[11px] font-medium",
"border-emerald-300/60 bg-emerald-50/80 text-emerald-900",
"dark:border-emerald-500/30 dark:bg-emerald-500/10 dark:text-emerald-200",
className,
)}
title={title}
aria-label="Source-resolved watchdog fold"
>
{showIcon ? <Sparkles className="h-3 w-3 text-emerald-700 dark:text-emerald-300" aria-hidden /> : null}
Source-resolved
</span>
);
}
export default SourceResolvedFoldBadge;
@@ -0,0 +1,177 @@
import { Sparkles } from "lucide-react";
import { Link } from "@/lib/router";
import { cn, relativeTime } from "@/lib/utils";
import {
type SourceResolvedWatchdogFold,
formatCleanupOutcome,
formatSilenceAgeMs,
shortenEvidenceId,
} from "@/lib/source-resolved-watchdog-fold";
export interface SourceResolvedFoldCalloutProps {
fold: SourceResolvedWatchdogFold;
/** Time the run was finalized — used for the "system audit · {when}" header chip. */
finalizedAt?: string | Date | null;
className?: string;
}
function isoOrLocaleString(value: string | null | undefined): string | null {
if (!value) return null;
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString();
}
function issueLink(id: string, identifier: string | null) {
return `/issues/${identifier ?? id}`;
}
function MetaRow({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<div className="grid grid-cols-[10rem_1fr] gap-x-3 gap-y-0 py-1 text-xs sm:grid-cols-[12rem_1fr]">
<dt className="truncate text-[11px] font-medium uppercase tracking-[0.08em] text-emerald-900/70 dark:text-emerald-200/70">
{label}
</dt>
<dd className="min-w-0 break-words text-emerald-950 dark:text-emerald-100">{children}</dd>
</div>
);
}
export function SourceResolvedFoldCallout({
fold,
finalizedAt,
className,
}: SourceResolvedFoldCalloutProps) {
const sourceLabel = fold.sourceIssueIdentifier ?? fold.sourceIssueId.slice(0, 8);
const evidenceShort = shortenEvidenceId(fold.sameRunEvidenceId);
const evidenceAt = isoOrLocaleString(fold.sameRunEvidenceAt);
const silenceAgeLabel = formatSilenceAgeMs(fold.silenceAgeMs);
const silenceStartedLabel = isoOrLocaleString(fold.silenceStartedAt);
const cleanupLabel = formatCleanupOutcome(fold.cleanup.outcome);
const finalizedRelative = finalizedAt ? relativeTime(finalizedAt) : null;
const evaluationLabel = fold.evaluationIssueIdentifier ?? fold.evaluationIssueId?.slice(0, 8);
return (
<section
role="status"
aria-label="Source-resolved watchdog fold"
data-source-resolved-fold
className={cn(
"relative w-full overflow-hidden rounded-lg border text-sm shadow-[0_1px_0_rgba(15,23,42,0.02)]",
"border-emerald-300/70 bg-emerald-50/80 text-emerald-950",
"dark:border-emerald-500/40 dark:bg-emerald-500/10 dark:text-emerald-100",
className,
)}
>
<header className="flex items-start gap-3 px-3 py-2.5 sm:px-4">
<span
className={cn(
"mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-md",
"bg-emerald-100 text-emerald-800 dark:bg-emerald-500/20 dark:text-emerald-200",
)}
aria-hidden
>
<Sparkles className="h-4 w-4 text-emerald-700 dark:text-emerald-300" />
</span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[11px] font-semibold uppercase tracking-[0.14em]">
<span className="text-emerald-900 dark:text-emerald-200">SOURCE-RESOLVED FOLD</span>
<span className="text-muted-foreground/60" aria-hidden>·</span>
<span className="font-medium normal-case tracking-normal text-muted-foreground">
system audit
</span>
{finalizedRelative ? (
<>
<span className="text-muted-foreground/60" aria-hidden>·</span>
<span className="font-medium normal-case tracking-normal text-muted-foreground">
{finalizedRelative}
</span>
</>
) : null}
</div>
<p className="mt-1 text-[14px] leading-6">
This run was folded as a source-resolved false positive.
</p>
</div>
</header>
<dl
className={cn(
"divide-y border-t bg-background/40 px-3 py-2 sm:px-4 dark:bg-background/20",
"border-emerald-300/60 dark:border-emerald-500/30",
"[&>*]:border-emerald-300/40 dark:[&>*]:border-emerald-500/20",
)}
>
<MetaRow label="Source issue">
<span className="inline-flex flex-wrap items-center gap-1.5">
<Link
to={issueLink(fold.sourceIssueId, fold.sourceIssueIdentifier)}
className="rounded-sm font-medium underline-offset-2 hover:underline"
>
{sourceLabel}
</Link>
<span className="rounded-md border border-emerald-300/60 bg-background/60 px-1.5 py-0.5 text-[11px] font-medium text-emerald-900 dark:border-emerald-500/30 dark:text-emerald-200">
{fold.sourceIssueStatus}
</span>
</span>
</MetaRow>
<MetaRow label="Same-run evidence">
<span className="inline-flex flex-wrap items-baseline gap-1.5">
<span className="rounded bg-background/70 px-1.5 py-0.5 font-mono text-[11px] text-emerald-900 dark:bg-background/40 dark:text-emerald-100">
{fold.sameRunEvidenceKind}
</span>
<code
className="rounded bg-background/70 px-1.5 py-0.5 font-mono text-[11px] text-emerald-900 dark:bg-background/40 dark:text-emerald-100"
title={fold.sameRunEvidenceId}
>
{evidenceShort}
</code>
{evidenceAt ? (
<span className="text-[11px] text-muted-foreground">at {evidenceAt}</span>
) : null}
</span>
</MetaRow>
<MetaRow label="Silence age before fold">
{silenceAgeLabel ? (
<span>
{silenceAgeLabel}
{silenceStartedLabel ? (
<span className="text-muted-foreground"> (silence started {silenceStartedLabel})</span>
) : null}
</span>
) : (
<span className="text-muted-foreground">unknown</span>
)}
</MetaRow>
<MetaRow label="Process cleanup">
<span
className="inline-flex flex-wrap items-baseline gap-1.5"
title={fold.cleanup.outcome}
>
<span>{cleanupLabel}</span>
{fold.cleanup.error ? (
<span className="text-muted-foreground"> {fold.cleanup.error}</span>
) : null}
</span>
</MetaRow>
{fold.evaluationIssueId ? (
<MetaRow label="Evaluation issue">
<Link
to={issueLink(fold.evaluationIssueId, fold.evaluationIssueIdentifier)}
className="rounded-sm font-medium underline-offset-2 hover:underline"
>
{evaluationLabel}
</Link>
</MetaRow>
) : null}
</dl>
</section>
);
}
export default SourceResolvedFoldCallout;
+1 -1
View File
@@ -59,7 +59,7 @@ function DialogContent({
<DialogPrimitive.Content <DialogPrimitive.Content
data-slot="dialog-content" data-slot="dialog-content"
className={cn( className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-[0.97] data-[state=open]:zoom-in-[0.97] data-[state=closed]:slide-out-to-top-[1%] data-[state=open]:slide-in-from-top-[1%] fixed top-[max(1rem,env(safe-area-inset-top))] md:top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-0 md:translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-150 ease-[cubic-bezier(0.16,1,0.3,1)] outline-none sm:max-w-lg", "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-[0.97] data-[state=open]:zoom-in-[0.97] data-[state=closed]:slide-out-to-top-[1%] data-[state=open]:slide-in-from-top-[1%] fixed top-[max(1rem,env(safe-area-inset-top))] md:top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-0 md:translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-150 ease-[cubic-bezier(0.16,1,0.3,1)] outline-none sm:max-w-lg [&>*]:min-w-0",
className className
)} )}
{...props} {...props}
+48 -14
View File
@@ -1,7 +1,8 @@
import { createContext, useContext, useMemo, type ReactNode } from "react"; import { createContext, useContext, useMemo, type ReactNode } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { buildSkillMentionHref } from "@paperclipai/shared"; import { buildRoutineMentionHref, buildSkillMentionHref } from "@paperclipai/shared";
import { companySkillsApi } from "../api/companySkills"; import { companySkillsApi } from "../api/companySkills";
import { routinesApi } from "../api/routines";
import { useCompany } from "./CompanyContext"; import { useCompany } from "./CompanyContext";
import { queryKeys } from "../lib/queryKeys"; import { queryKeys } from "../lib/queryKeys";
@@ -17,8 +18,20 @@ export interface SkillCommandOption {
aliases: string[]; aliases: string[];
} }
export interface RoutineCommandOption {
id: string;
kind: "routine";
routineId: string;
name: string;
status: string;
href: string;
aliases: string[];
}
export type SlashCommandOption = SkillCommandOption | RoutineCommandOption;
interface EditorAutocompleteContextValue { interface EditorAutocompleteContextValue {
slashCommands: SkillCommandOption[]; slashCommands: SlashCommandOption[];
} }
const EditorAutocompleteContext = createContext<EditorAutocompleteContextValue>({ const EditorAutocompleteContext = createContext<EditorAutocompleteContextValue>({
@@ -34,20 +47,41 @@ export function EditorAutocompleteProvider({ children }: { children: ReactNode }
queryFn: () => companySkillsApi.list(selectedCompanyId!), queryFn: () => companySkillsApi.list(selectedCompanyId!),
enabled: Boolean(selectedCompanyId), enabled: Boolean(selectedCompanyId),
}); });
const { data: routines = [] } = useQuery({
queryKey: selectedCompanyId
? queryKeys.routines.list(selectedCompanyId)
: ["routines", "__none__", "__all-projects__"],
queryFn: () => routinesApi.list(selectedCompanyId!),
enabled: Boolean(selectedCompanyId),
});
const value = useMemo<EditorAutocompleteContextValue>(() => ({ const value = useMemo<EditorAutocompleteContextValue>(() => ({
slashCommands: companySkills.map((skill) => ({ slashCommands: [
id: `skill:${skill.id}`, ...companySkills.map((skill) => ({
kind: "skill", id: `skill:${skill.id}`,
skillId: skill.id, kind: "skill" as const,
key: skill.key, skillId: skill.id,
name: skill.name, key: skill.key,
slug: skill.slug, name: skill.name,
description: skill.description ?? null, slug: skill.slug,
href: buildSkillMentionHref(skill.id, skill.slug), description: skill.description ?? null,
aliases: [skill.slug, skill.name, skill.key], href: buildSkillMentionHref(skill.id, skill.slug),
})), aliases: [skill.slug, skill.name, skill.key],
}), [companySkills]); })),
...routines
.filter((routine) => routine.status !== "archived")
.sort((left, right) => left.title.localeCompare(right.title))
.map((routine) => ({
id: `routine:${routine.id}`,
kind: "routine" as const,
routineId: routine.id,
name: routine.title,
status: routine.status,
href: buildRoutineMentionHref(routine.id),
aliases: [`routine:${routine.title}`, routine.title, routine.id],
})),
],
}), [companySkills, routines]);
return ( return (
<EditorAutocompleteContext.Provider value={value}> <EditorAutocompleteContext.Provider value={value}>
+4
View File
@@ -61,6 +61,8 @@ const ACTIVITY_ROW_VERBS: Record<string, string> = {
"agent.runtime_session_reset": "reset session for", "agent.runtime_session_reset": "reset session for",
"heartbeat.invoked": "invoked heartbeat for", "heartbeat.invoked": "invoked heartbeat for",
"heartbeat.cancelled": "cancelled heartbeat for", "heartbeat.cancelled": "cancelled heartbeat for",
"heartbeat.output_stale_source_resolved": "system-folded stale run on",
"heartbeat.output_stale_recovery_recursion_refused": "refused recovery-on-recovery for",
"approval.created": "requested approval", "approval.created": "requested approval",
"approval.approved": "approved", "approval.approved": "approved",
"approval.rejected": "rejected", "approval.rejected": "rejected",
@@ -115,6 +117,8 @@ const ISSUE_ACTIVITY_LABELS: Record<string, string> = {
"agent.terminated": "terminated the agent", "agent.terminated": "terminated the agent",
"heartbeat.invoked": "invoked a heartbeat", "heartbeat.invoked": "invoked a heartbeat",
"heartbeat.cancelled": "cancelled a heartbeat", "heartbeat.cancelled": "cancelled a heartbeat",
"heartbeat.output_stale_source_resolved": "System folded a stale run",
"heartbeat.output_stale_recovery_recursion_refused": "Refused recovery-on-recovery escalation",
"approval.created": "requested approval", "approval.created": "requested approval",
"approval.approved": "approved", "approval.approved": "approved",
"approval.rejected": "rejected", "approval.rejected": "rejected",
@@ -0,0 +1,85 @@
// @vitest-environment node
import { describe, expect, it } from "vitest";
import { buildDuplicateAgentPayload, duplicateAgentName } from "./duplicate-agent-payload";
import type { AgentDetail } from "@paperclipai/shared";
const baseAgent: AgentDetail = {
id: "agent-1",
companyId: "company-1",
name: "Senior Product Engineer",
urlKey: "senior-product-engineer",
role: "engineer",
title: "Senior Product Engineer",
icon: "code",
status: "idle",
reportsTo: "manager-1",
capabilities: "Builds product features.",
adapterType: "codex_local",
adapterConfig: {
model: "gpt-5.3-codex",
instructionsBundleMode: "managed",
instructionsRootPath: "/tmp/original/instructions",
instructionsEntryFile: "AGENTS.md",
instructionsFilePath: "/tmp/original/instructions/AGENTS.md",
promptTemplate: "legacy prompt",
bootstrapPromptTemplate: "legacy bootstrap",
},
runtimeConfig: {
heartbeat: { enabled: true },
},
defaultEnvironmentId: "environment-1",
budgetMonthlyCents: 500,
spentMonthlyCents: 123,
pauseReason: null,
pausedAt: null,
permissions: { canCreateAgents: true },
lastHeartbeatAt: null,
metadata: { source: "test" },
createdAt: new Date("2026-05-10T00:00:00.000Z"),
updatedAt: new Date("2026-05-10T00:00:00.000Z"),
chainOfCommand: [],
access: {
canAssignTasks: true,
taskAssignSource: "explicit_grant",
membership: null,
grants: [],
},
};
describe("duplicate agent payload", () => {
it("suffixes duplicate names", () => {
expect(duplicateAgentName("Senior Product Engineer")).toBe("Senior Product Engineer Copy");
expect(duplicateAgentName(" ")).toBe("Agent Copy");
});
it("copies agent fields while removing original instruction paths", () => {
const payload = buildDuplicateAgentPayload(baseAgent, {
entryFile: "AGENTS.md",
files: {
"AGENTS.md": "You are a copy.",
},
});
expect(payload).toMatchObject({
name: "Senior Product Engineer Copy",
role: "engineer",
title: "Senior Product Engineer",
icon: "code",
reportsTo: "manager-1",
capabilities: "Builds product features.",
adapterType: "codex_local",
adapterConfig: { model: "gpt-5.3-codex" },
runtimeConfig: { heartbeat: { enabled: true } },
defaultEnvironmentId: "environment-1",
budgetMonthlyCents: 500,
permissions: { canCreateAgents: true },
metadata: { source: "test" },
instructionsBundle: {
entryFile: "AGENTS.md",
files: { "AGENTS.md": "You are a copy." },
},
});
expect(payload.adapterConfig).not.toHaveProperty("instructionsFilePath");
expect(payload.adapterConfig).not.toHaveProperty("promptTemplate");
});
});
+78
View File
@@ -0,0 +1,78 @@
import type { AgentDetail } from "@paperclipai/shared";
const INSTRUCTION_CONFIG_KEYS = [
"instructionsBundleMode",
"instructionsRootPath",
"instructionsEntryFile",
"instructionsFilePath",
"agentsMdPath",
"promptTemplate",
"bootstrapPromptTemplate",
] as const;
export type DuplicateInstructionsBundle = {
entryFile: string;
files: Record<string, string>;
};
type DuplicateAgentSource = Pick<
AgentDetail,
| "name"
| "role"
| "title"
| "icon"
| "reportsTo"
| "capabilities"
| "adapterType"
| "adapterConfig"
| "runtimeConfig"
| "defaultEnvironmentId"
| "budgetMonthlyCents"
| "permissions"
| "metadata"
>;
function cloneRecord(value: Record<string, unknown> | null | undefined): Record<string, unknown> {
if (!value) return {};
return JSON.parse(JSON.stringify(value)) as Record<string, unknown>;
}
export function duplicateAgentName(name: string): string {
const trimmed = name.trim();
return `${trimmed || "Agent"} Copy`;
}
export function buildDuplicateAgentPayload(
agent: DuplicateAgentSource,
instructionsBundle?: DuplicateInstructionsBundle | null,
): Record<string, unknown> {
const adapterConfig = cloneRecord(agent.adapterConfig);
for (const key of INSTRUCTION_CONFIG_KEYS) {
delete adapterConfig[key];
}
const payload: Record<string, unknown> = {
name: duplicateAgentName(agent.name),
role: agent.role,
adapterType: agent.adapterType,
adapterConfig,
runtimeConfig: cloneRecord(agent.runtimeConfig),
defaultEnvironmentId: agent.defaultEnvironmentId ?? null,
budgetMonthlyCents: agent.budgetMonthlyCents ?? 0,
permissions: {
canCreateAgents: Boolean(agent.permissions?.canCreateAgents),
},
};
if (agent.title) payload.title = agent.title;
if (agent.icon) payload.icon = agent.icon;
if (agent.reportsTo) payload.reportsTo = agent.reportsTo;
if (agent.capabilities) payload.capabilities = agent.capabilities;
if (agent.metadata) payload.metadata = cloneRecord(agent.metadata);
if (instructionsBundle && Object.keys(instructionsBundle.files).length > 0) {
payload.instructionsBundle = instructionsBundle;
}
return payload;
}
+14
View File
@@ -3,6 +3,7 @@ import {
parseAgentMentionHref, parseAgentMentionHref,
parseIssueReferenceHref, parseIssueReferenceHref,
parseProjectMentionHref, parseProjectMentionHref,
parseRoutineMentionHref,
parseSkillMentionHref, parseSkillMentionHref,
parseUserMentionHref, parseUserMentionHref,
} from "@paperclipai/shared"; } from "@paperclipai/shared";
@@ -32,6 +33,10 @@ export type ParsedMentionChip =
kind: "skill"; kind: "skill";
skillId: string; skillId: string;
slug: string | null; slug: string | null;
}
| {
kind: "routine";
routineId: string;
}; };
const iconMaskCache = new Map<string, string>(); const iconMaskCache = new Map<string, string>();
@@ -84,6 +89,14 @@ export function parseMentionChipHref(href: string): ParsedMentionChip | null {
}; };
} }
const routine = parseRoutineMentionHref(href);
if (routine) {
return {
kind: "routine",
routineId: routine.routineId,
};
}
return null; return null;
} }
@@ -135,6 +148,7 @@ export function clearMentionChipDecoration(element: HTMLElement) {
"paperclip-mention-chip--agent", "paperclip-mention-chip--agent",
"paperclip-mention-chip--issue", "paperclip-mention-chip--issue",
"paperclip-mention-chip--project", "paperclip-mention-chip--project",
"paperclip-mention-chip--routine",
"paperclip-mention-chip--user", "paperclip-mention-chip--user",
"paperclip-mention-chip--skill", "paperclip-mention-chip--skill",
"paperclip-project-mention-chip", "paperclip-project-mention-chip",
+134
View File
@@ -0,0 +1,134 @@
import type { HeartbeatRun } from "@paperclipai/shared";
export type SourceResolvedFoldCleanupOutcome =
| "terminated"
| "termination_sent_still_running"
| "failed"
| "not_running"
| "no_process_metadata"
| "skipped_non_local_adapter"
| string;
export interface SourceResolvedFoldCleanup {
attempted: boolean;
outcome: SourceResolvedFoldCleanupOutcome;
adapterType: string | null;
pid: number | null;
processGroupId: number | null;
error: string | null;
}
export interface SourceResolvedWatchdogFold {
sourceIssueId: string;
sourceIssueIdentifier: string | null;
sourceIssueStatus: string;
sameRunEvidenceKind: string;
sameRunEvidenceId: string;
sameRunEvidenceAt: string;
silenceStartedAt: string | null;
silenceAgeMs: number | null;
evaluationIssueId: string | null;
evaluationIssueIdentifier: string | null;
cleanup: SourceResolvedFoldCleanup;
}
function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
function asString(value: unknown): string | null {
return typeof value === "string" ? value : null;
}
function asFiniteNumber(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
function asBoolean(value: unknown): boolean {
return value === true;
}
function parseCleanup(value: unknown): SourceResolvedFoldCleanup {
const record = asRecord(value);
if (!record) {
return {
attempted: false,
outcome: "no_process_metadata",
adapterType: null,
pid: null,
processGroupId: null,
error: null,
};
}
return {
attempted: asBoolean(record.attempted),
outcome: asString(record.outcome) ?? "no_process_metadata",
adapterType: asString(record.adapterType),
pid: asFiniteNumber(record.pid),
processGroupId: asFiniteNumber(record.processGroupId),
error: asString(record.error),
};
}
export function parseSourceResolvedWatchdogFold(value: unknown): SourceResolvedWatchdogFold | null {
const record = asRecord(value);
if (!record) return null;
const sourceIssueId = asString(record.sourceIssueId);
const sourceIssueStatus = asString(record.sourceIssueStatus);
if (!sourceIssueId || !sourceIssueStatus) return null;
const evidenceKind = asString(record.sameRunEvidenceKind);
const evidenceId = asString(record.sameRunEvidenceId);
const evidenceAt = asString(record.sameRunEvidenceAt);
if (!evidenceKind || !evidenceId || !evidenceAt) return null;
return {
sourceIssueId,
sourceIssueIdentifier: asString(record.sourceIssueIdentifier),
sourceIssueStatus,
sameRunEvidenceKind: evidenceKind,
sameRunEvidenceId: evidenceId,
sameRunEvidenceAt: evidenceAt,
silenceStartedAt: asString(record.silenceStartedAt),
silenceAgeMs: asFiniteNumber(record.silenceAgeMs),
evaluationIssueId: asString(record.evaluationIssueId),
evaluationIssueIdentifier: asString(record.evaluationIssueIdentifier),
cleanup: parseCleanup(record.cleanup),
};
}
export function readSourceResolvedWatchdogFold(
resultJson: HeartbeatRun["resultJson"] | Record<string, unknown> | null | undefined,
): SourceResolvedWatchdogFold | null {
const record = asRecord(resultJson);
if (!record) return null;
return parseSourceResolvedWatchdogFold(record.sourceResolvedWatchdogFold);
}
const CLEANUP_OUTCOME_LABELS: Record<string, string> = {
terminated: "terminated",
termination_sent_still_running: "termination sent (still running)",
failed: "failed",
not_running: "not running",
no_process_metadata: "no process metadata",
skipped_non_local_adapter: "skipped (non-local adapter)",
};
export function formatCleanupOutcome(outcome: string): string {
return CLEANUP_OUTCOME_LABELS[outcome] ?? outcome.replace(/_/g, " ");
}
export function formatSilenceAgeMs(ms: number | null | undefined): string | null {
if (!ms || ms <= 0) return null;
const totalMinutes = Math.floor(ms / 60_000);
if (totalMinutes < 1) return "under 1 minute";
if (totalMinutes < 60) return `${totalMinutes} minute${totalMinutes === 1 ? "" : "s"}`;
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
if (minutes === 0) return `${hours} hour${hours === 1 ? "" : "s"}`;
return `${hours}h ${minutes}m`;
}
export function shortenEvidenceId(id: string): string {
if (id.length <= 12) return id;
return id.slice(0, 8);
}
+107
View File
@@ -42,9 +42,13 @@ import { RunButton, PauseResumeButton } from "../components/AgentActionButtons";
import { BudgetPolicyCard } from "../components/BudgetPolicyCard"; import { BudgetPolicyCard } from "../components/BudgetPolicyCard";
import { FileTree, buildFileTree } from "../components/FileTree"; import { FileTree, buildFileTree } from "../components/FileTree";
import { ScrollToBottom } from "../components/ScrollToBottom"; import { ScrollToBottom } from "../components/ScrollToBottom";
import { SourceResolvedFoldCallout } from "../components/SourceResolvedFoldCallout";
import { SourceResolvedFoldBadge } from "../components/SourceResolvedFoldBadge";
import { readSourceResolvedWatchdogFold } from "../lib/source-resolved-watchdog-fold";
import { formatCents, formatDate, relativeTime, formatTokens, visibleRunCostUsd } from "../lib/utils"; import { formatCents, formatDate, relativeTime, formatTokens, visibleRunCostUsd } from "../lib/utils";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
import { describeRunRetryState } from "../lib/runRetryState"; import { describeRunRetryState } from "../lib/runRetryState";
import { buildDuplicateAgentPayload, duplicateAgentName, type DuplicateInstructionsBundle } from "../lib/duplicate-agent-payload";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { Tabs } from "@/components/ui/tabs"; import { Tabs } from "@/components/ui/tabs";
@@ -83,6 +87,8 @@ import { RunTranscriptView, type TranscriptMode } from "../components/transcript
import { import {
isUuidLike, isUuidLike,
type Agent, type Agent,
type AgentInstructionsBundle,
type AgentInstructionsFileSummary,
type AgentSkillEntry, type AgentSkillEntry,
type AgentSkillSnapshot, type AgentSkillSnapshot,
type AgentDetail as AgentDetailRecord, type AgentDetail as AgentDetailRecord,
@@ -101,6 +107,34 @@ import {
isReadOnlyUnmanagedSkillEntry, isReadOnlyUnmanagedSkillEntry,
} from "../lib/agent-skills-state"; } from "../lib/agent-skills-state";
async function loadDuplicateInstructionsBundle(
agentId: string,
companyId?: string,
): Promise<DuplicateInstructionsBundle | null> {
const bundle = await agentsApi.instructionsBundle(agentId, companyId);
const files: Record<string, string> = {};
for (const summary of bundle.files) {
const path = duplicateInstructionFilePath(bundle, summary);
if (!path) continue;
const file = await agentsApi.instructionsFile(agentId, summary.path, companyId);
files[path] = file.content;
}
const entryFile = Object.prototype.hasOwnProperty.call(files, bundle.entryFile)
? bundle.entryFile
: Object.keys(files)[0] ?? "AGENTS.md";
return Object.keys(files).length > 0 ? { entryFile, files } : null;
}
function duplicateInstructionFilePath(
_bundle: AgentInstructionsBundle,
summary: AgentInstructionsFileSummary,
): string | null {
if (summary.deprecated || summary.virtual) return null;
return summary.path;
}
const runStatusIcons: Record<string, { icon: typeof CheckCircle2; color: string }> = { const runStatusIcons: Record<string, { icon: typeof CheckCircle2; color: string }> = {
succeeded: { icon: CheckCircle2, color: "text-green-600 dark:text-green-400" }, succeeded: { icon: CheckCircle2, color: "text-green-600 dark:text-green-400" },
failed: { icon: XCircle, color: "text-red-600 dark:text-red-400" }, failed: { icon: XCircle, color: "text-red-600 dark:text-red-400" },
@@ -635,6 +669,7 @@ export function AgentDetail() {
const { companies, selectedCompanyId, setSelectedCompanyId } = useCompany(); const { companies, selectedCompanyId, setSelectedCompanyId } = useCompany();
const { closePanel } = usePanel(); const { closePanel } = usePanel();
const { openNewIssue } = useDialogActions(); const { openNewIssue } = useDialogActions();
const { pushToast } = useToastActions();
const { setBreadcrumbs } = useBreadcrumbs(); const { setBreadcrumbs } = useBreadcrumbs();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -805,6 +840,57 @@ export function AgentDetail() {
}, },
}); });
const duplicateAgent = useMutation({
mutationFn: async () => {
if (!agent?.id || !resolvedCompanyId) {
throw new Error("Agent is not ready to duplicate");
}
const instructionsBundle = await loadDuplicateInstructionsBundle(agent.id, resolvedCompanyId);
const payload = buildDuplicateAgentPayload(agent, instructionsBundle);
try {
return await agentsApi.create(resolvedCompanyId, payload);
} catch (error) {
if (error instanceof ApiError && error.status === 409 && error.message.includes("requires board approval")) {
const hire = await agentsApi.hire(resolvedCompanyId, payload);
return hire.agent;
}
throw error;
}
},
onSuccess: async (createdAgent) => {
setActionError(null);
if (resolvedCompanyId) {
await queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(resolvedCompanyId) });
}
pushToast({
title: "Agent duplicated",
body: createdAgent.name,
tone: "success",
});
navigate(`/agents/${agentRouteRef(createdAgent)}/dashboard`);
},
onError: (err) => {
const message = err instanceof Error ? err.message : "Failed to duplicate agent";
setActionError(message);
pushToast({
title: "Could not duplicate agent",
body: message,
tone: "error",
});
},
});
const handleDuplicateAgent = useCallback(() => {
if (!agent || duplicateAgent.isPending) return;
const nextName = duplicateAgentName(agent.name);
const confirmed = window.confirm(`Duplicate ${agent.name} as ${nextName}?`);
setMoreOpen(false);
if (!confirmed) return;
duplicateAgent.mutate();
}, [agent, duplicateAgent]);
const budgetMutation = useMutation({ const budgetMutation = useMutation({
mutationFn: (amount: number) => mutationFn: (amount: number) =>
budgetsApi.upsertPolicy(resolvedCompanyId!, { budgetsApi.upsertPolicy(resolvedCompanyId!, {
@@ -977,6 +1063,18 @@ export function AgentDetail() {
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-44 p-1" align="end"> <PopoverContent className="w-44 p-1" align="end">
<button
className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50"
disabled={duplicateAgent.isPending}
onClick={handleDuplicateAgent}
>
{duplicateAgent.isPending ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Copy className="h-3 w-3" />
)}
Duplicate Agent
</button>
<button <button
className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50" className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50"
onClick={() => { onClick={() => {
@@ -2899,6 +2997,7 @@ function RunListItem({ run, isSelected, agentId }: { run: HeartbeatRun; isSelect
const summary = run.resultJson const summary = run.resultJson
? String((run.resultJson as Record<string, unknown>).summary ?? (run.resultJson as Record<string, unknown>).result ?? "") ? String((run.resultJson as Record<string, unknown>).summary ?? (run.resultJson as Record<string, unknown>).result ?? "")
: run.error ?? ""; : run.error ?? "";
const sourceResolvedFold = readSourceResolvedWatchdogFold(run.resultJson);
return ( return (
<Link <Link
@@ -2922,6 +3021,7 @@ function RunListItem({ run, isSelected, agentId }: { run: HeartbeatRun; isSelect
)}> )}>
{sourceLabels[run.invocationSource] ?? run.invocationSource} {sourceLabels[run.invocationSource] ?? run.invocationSource}
</span> </span>
{sourceResolvedFold ? <SourceResolvedFoldBadge showIcon={false} className="shrink-0 text-[10px] py-0" /> : null}
<span className="ml-auto text-[11px] text-muted-foreground shrink-0"> <span className="ml-auto text-[11px] text-muted-foreground shrink-0">
{relativeTime(run.createdAt)} {relativeTime(run.createdAt)}
</span> </span>
@@ -3474,6 +3574,13 @@ function RunDetail({ run: initialRun, agentRouteId, adapterType, adapterConfig }
</div> </div>
)} )}
{(() => {
const fold = readSourceResolvedWatchdogFold(run.resultJson);
if (!fold) return null;
if (run.status === "failed" || run.status === "timed_out") return null;
return <SourceResolvedFoldCallout fold={fold} finalizedAt={run.finishedAt} />;
})()}
{/* Log viewer */} {/* Log viewer */}
<LogViewer run={run} adapterType={adapterType} /> <LogViewer run={run} adapterType={adapterType} />
<ScrollToBottom /> <ScrollToBottom />
+4 -1
View File
@@ -1770,7 +1770,7 @@ export function IssueDetail() {
mutationFn: (data: { mutationFn: (data: {
actionId?: string; actionId?: string;
outcome: ResolveRecoveryActionOutcome; outcome: ResolveRecoveryActionOutcome;
sourceIssueStatus: "done" | "in_review" | "blocked"; sourceIssueStatus: "todo" | "done" | "in_review" | "blocked";
resolutionNote?: string | null; resolutionNote?: string | null;
}) => issuesApi.resolveRecoveryAction(issueId!, data), }) => issuesApi.resolveRecoveryAction(issueId!, data),
onSuccess: ({ issue: nextIssue }) => { onSuccess: ({ issue: nextIssue }) => {
@@ -3000,6 +3000,9 @@ export function IssueDetail() {
const actionId = activeRecoveryActionId; const actionId = activeRecoveryActionId;
if (!actionId) return; if (!actionId) return;
switch (outcome) { switch (outcome) {
case "todo":
void resolveRecoveryAction.mutateAsync({ actionId, outcome: "restored", sourceIssueStatus: "todo" });
return;
case "done": case "done":
void resolveRecoveryAction.mutateAsync({ actionId, outcome: "restored", sourceIssueStatus: "done" }); void resolveRecoveryAction.mutateAsync({ actionId, outcome: "restored", sourceIssueStatus: "done" });
return; return;
+2 -1
View File
@@ -456,7 +456,7 @@ describe("Routines page", () => {
}); });
}); });
it("shows a row-level run now button on the routines table", async () => { it("shows an outlined row-level run now button on the routines table", async () => {
routinesListMock.mockResolvedValue([createRoutine({ id: "routine-1", title: "Morning sync" })]); routinesListMock.mockResolvedValue([createRoutine({ id: "routine-1", title: "Morning sync" })]);
issuesListMock.mockResolvedValue([]); issuesListMock.mockResolvedValue([]);
@@ -489,6 +489,7 @@ describe("Routines page", () => {
} }
expect(runNowButton).toBeTruthy(); expect(runNowButton).toBeTruthy();
expect(runNowButton?.getAttribute("data-variant")).toBe("outline");
await act(async () => { await act(async () => {
root.unmount(); root.unmount();
+32 -3
View File
@@ -158,6 +158,32 @@ function focusFirstElement(container: HTMLElement | null): void {
container.focus(); container.focus();
} }
function resolveLauncherNavigationTarget(target: string, hostContext: PluginLauncherContext): string {
if (/^https?:\/\//.test(target) || target.startsWith("/") || target.startsWith("#") || target.startsWith(".") || target.startsWith("?")) {
return target;
}
const companyPrefix = hostContext.companyPrefix?.trim();
return companyPrefix ? `/${companyPrefix}/${target}` : target;
}
function launcherRoutePath(launcher: ResolvedPluginLauncher): string | null {
if (launcher.action.type !== "navigate" && launcher.action.type !== "deepLink") return null;
if (/^https?:\/\//.test(launcher.action.target)) return null;
const [pathOnly] = launcher.action.target.split(/[?#]/, 1);
const segment = pathOnly?.split("/").filter(Boolean).at(-1);
return segment ? segment.toLowerCase() : null;
}
function launcherDisplayName(launcher: ResolvedPluginLauncher, contribution: PluginUiContribution | undefined): string {
if (launcher.placementZone !== "sidebar" || !contribution) return launcher.displayName;
const routePath = launcherRoutePath(launcher);
if (!routePath) return launcher.displayName;
const routeSidebar = contribution.slots.find((slot) =>
slot.type === "routeSidebar" && slot.routePath?.toLowerCase() === routePath
);
return routeSidebar?.displayName ?? launcher.displayName;
}
function trapFocus(container: HTMLElement, event: KeyboardEvent): void { function trapFocus(container: HTMLElement, event: KeyboardEvent): void {
if (event.key !== "Tab") return; if (event.key !== "Tab") return;
const focusable = Array.from( const focusable = Array.from(
@@ -652,13 +678,13 @@ export function PluginLauncherProvider({ children }: { children: ReactNode }) {
) => { ) => {
switch (launcher.action.type) { switch (launcher.action.type) {
case "navigate": case "navigate":
navigate(launcher.action.target); navigate(resolveLauncherNavigationTarget(launcher.action.target, hostContext));
return; return;
case "deepLink": case "deepLink":
if (/^https?:\/\//.test(launcher.action.target)) { if (/^https?:\/\//.test(launcher.action.target)) {
window.open(launcher.action.target, "_blank", "noopener,noreferrer"); window.open(launcher.action.target, "_blank", "noopener,noreferrer");
} else { } else {
navigate(launcher.action.target); navigate(resolveLauncherNavigationTarget(launcher.action.target, hostContext));
} }
return; return;
case "performAction": case "performAction":
@@ -725,10 +751,12 @@ export function usePluginLauncherRuntime(): PluginLauncherRuntimeContextValue {
} }
function DefaultLauncherTrigger({ function DefaultLauncherTrigger({
displayName,
launcher, launcher,
placementZone, placementZone,
onClick, onClick,
}: { }: {
displayName?: string;
launcher: ResolvedPluginLauncher; launcher: ResolvedPluginLauncher;
placementZone: PluginLauncherPlacementZone; placementZone: PluginLauncherPlacementZone;
onClick: (event: ReactMouseEvent<HTMLButtonElement>) => void; onClick: (event: ReactMouseEvent<HTMLButtonElement>) => void;
@@ -741,7 +769,7 @@ function DefaultLauncherTrigger({
className={launcherTriggerClassName(placementZone)} className={launcherTriggerClassName(placementZone)}
onClick={onClick} onClick={onClick}
> >
{launcher.displayName} {displayName ?? launcher.displayName}
</Button> </Button>
); );
} }
@@ -786,6 +814,7 @@ export function PluginLauncherOutlet({
{launchers.map((launcher) => ( {launchers.map((launcher) => (
<div key={`${launcher.pluginKey}:${launcher.id}`} className={itemClassName}> <div key={`${launcher.pluginKey}:${launcher.id}`} className={itemClassName}>
<DefaultLauncherTrigger <DefaultLauncherTrigger
displayName={launcherDisplayName(launcher, contributionsByPluginId.get(launcher.pluginId))}
launcher={launcher} launcher={launcher}
placementZone={launcher.placementZone} placementZone={launcher.placementZone}
onClick={(event) => { onClick={(event) => {
@@ -0,0 +1,180 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { ReactNode } from "react";
import { SourceResolvedFoldCallout } from "@/components/SourceResolvedFoldCallout";
import { SourceResolvedFoldBadge } from "@/components/SourceResolvedFoldBadge";
import type { SourceResolvedWatchdogFold } from "@/lib/source-resolved-watchdog-fold";
function StoryFrame({ title, description, children }: { title: string; description?: string; children: ReactNode }) {
return (
<main className="min-h-screen bg-background p-4 text-foreground sm:p-8">
<div className="mx-auto max-w-3xl space-y-5">
<header>
<div className="text-xs font-medium uppercase tracking-[0.2em] text-muted-foreground">
Active-run watchdog · source-resolved fold
</div>
<h1 className="mt-1 text-2xl font-semibold">{title}</h1>
{description ? (
<p className="mt-2 max-w-3xl text-sm text-muted-foreground">{description}</p>
) : null}
</header>
{children}
</div>
</main>
);
}
function buildFold(overrides: Partial<SourceResolvedWatchdogFold> = {}): SourceResolvedWatchdogFold {
return {
sourceIssueId: "00000000-0000-0000-0000-000093220000",
sourceIssueIdentifier: "PAP-9322",
sourceIssueStatus: "done",
sameRunEvidenceKind: "activity",
sameRunEvidenceId: "f49d4f8b-c2ee-4b3d-9d24-32deadbeef01",
sameRunEvidenceAt: "2026-05-12T18:14:33.000Z",
silenceStartedAt: "2026-05-12T18:30:00.000Z",
silenceAgeMs: 18 * 60_000,
evaluationIssueId: null,
evaluationIssueIdentifier: null,
cleanup: {
attempted: true,
outcome: "terminated",
adapterType: "claude_local",
pid: 23912,
processGroupId: 23912,
error: null,
},
...overrides,
};
}
const finalizedAt = "2026-05-12T18:48:11.000Z";
function DefaultPanel() {
return <SourceResolvedFoldCallout fold={buildFold()} finalizedAt={finalizedAt} />;
}
const meta = {
title: "Paperclip/Source-resolved Fold",
component: DefaultPanel,
parameters: { layout: "fullscreen" },
} satisfies Meta<typeof DefaultPanel>;
export default meta;
type Story = StoryObj<typeof meta>;
export const FoldCalloutFullEvidence: Story = {
render: () => (
<StoryFrame
title="Run details — source-resolved fold callout"
description="Rendered above the log/events area on /agents/:id/runs/:runId when the watchdog auto-folds a stale run whose source already reached a terminal disposition through durable same-run activity."
>
<SourceResolvedFoldCallout fold={buildFold()} finalizedAt={finalizedAt} />
</StoryFrame>
),
};
export const FoldCalloutWithEvaluationIssue: Story = {
render: () => (
<StoryFrame
title="Fold callout with legacy evaluation issue"
description="When a stale_active_run_evaluation issue existed, the fold closes it `done` and surfaces the deep-link for forensic continuity."
>
<SourceResolvedFoldCallout
fold={buildFold({
evaluationIssueId: "00000000-0000-0000-0000-0000eval0001",
evaluationIssueIdentifier: "PAP-9323",
cleanup: {
attempted: true,
outcome: "termination_sent_still_running",
adapterType: "claude_local",
pid: 23912,
processGroupId: 23912,
error: null,
},
})}
finalizedAt={finalizedAt}
/>
</StoryFrame>
),
};
export const FoldCalloutCleanupFailed: Story = {
render: () => (
<StoryFrame
title="Fold callout with cleanup error"
description="Process cleanup is best-effort; the audit message surfaces failure mode and original outcome token (kept as `title` on the span)."
>
<SourceResolvedFoldCallout
fold={buildFold({
cleanup: {
attempted: true,
outcome: "failed",
adapterType: "claude_local",
pid: 23912,
processGroupId: 23912,
error: "kill ESRCH (process already gone)",
},
})}
finalizedAt={finalizedAt}
/>
</StoryFrame>
),
};
export const FoldCalloutCancelledSource: Story = {
render: () => (
<StoryFrame
title="Fold callout when the source was cancelled"
description="When the source issue terminated as `cancelled`, the run finalizes as `cancelled` and the callout reflects the source status."
>
<SourceResolvedFoldCallout
fold={buildFold({
sourceIssueStatus: "cancelled",
cleanup: {
attempted: false,
outcome: "no_process_metadata",
adapterType: null,
pid: null,
processGroupId: null,
error: null,
},
})}
finalizedAt={finalizedAt}
/>
</StoryFrame>
),
};
export const RunRowBadgeContext: Story = {
render: () => (
<StoryFrame
title="Run-row Source-resolved badge"
description="Chip placed alongside the existing Profile / silence chips on each run row. Subdued emerald — distinct from the green status checkmark, but not a hot warning."
>
<div className="space-y-3 rounded-lg border border-border/60 bg-background/60 p-4 text-xs">
<div className="flex flex-wrap items-center gap-1.5">
<span className="font-medium text-foreground">Run</span>
<code className="rounded bg-background/70 px-1.5 py-0.5 font-mono text-foreground">7accd7a4</code>
<span className="text-muted-foreground">by ClaudeCoder</span>
<span className="rounded-md border border-border px-1.5 py-0.5 capitalize text-muted-foreground">succeeded</span>
<span className="rounded-md border border-emerald-500/30 bg-emerald-500/10 px-1.5 py-0.5 font-medium text-emerald-700 dark:text-emerald-300">
Completed
</span>
<SourceResolvedFoldBadge />
<span className="ml-auto text-muted-foreground">3m ago</span>
</div>
<div className="flex flex-wrap items-center gap-1.5">
<span className="font-medium text-foreground">Run</span>
<code className="rounded bg-background/70 px-1.5 py-0.5 font-mono text-foreground">2606404d</code>
<span className="text-muted-foreground">by ClaudeCoder</span>
<span className="rounded-md border border-border px-1.5 py-0.5 capitalize text-muted-foreground">succeeded</span>
<span className="rounded-md border border-emerald-500/30 bg-emerald-500/10 px-1.5 py-0.5 font-medium text-emerald-700 dark:text-emerald-300">
Completed
</span>
<span className="ml-auto text-muted-foreground">12m ago</span>
</div>
</div>
</StoryFrame>
),
};