[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:
@@ -57,3 +57,4 @@ tests/release-smoke/playwright-report/
|
||||
.superset/
|
||||
.superpowers/
|
||||
.claude/worktrees/
|
||||
.herenow
|
||||
|
||||
@@ -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 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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
#### 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`
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
2. resume persisted `queued` runs
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
|
||||
### 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
|
||||
|
||||
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***";
|
||||
|
||||
const COMMAND_CLI_SECRET_OPTION_RE =
|
||||
/(\B-{1,2}(?:api[-_]?key|(?:access[-_]?|auth[-_]?)?token|token|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring)(?:\s+|=)(["']?))[^\s"'`]+(\2)/gi;
|
||||
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 SECRET_NAME_PATTERN =
|
||||
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_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_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_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;
|
||||
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 {
|
||||
if (!maybeContainsSecretText(command)) return command;
|
||||
return command
|
||||
.replace(COMMAND_AUTHORIZATION_BEARER_RE, `$1${redactedValue}`)
|
||||
.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_GITHUB_TOKEN_RE, redactedValue)
|
||||
.replace(COMMAND_JWT_RE, redactedValue);
|
||||
|
||||
@@ -53,13 +53,14 @@ describe("buildInvocationEnvForLogs", () => {
|
||||
const loggedEnv = buildInvocationEnvForLogs(
|
||||
{ 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.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***",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -52,7 +52,8 @@ export const modelProfiles: AdapterModelProfileDefinition[] = [
|
||||
description: "Use the lowest-cost known Codex local model lane without changing the primary model.",
|
||||
adapterConfig: {
|
||||
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",
|
||||
},
|
||||
|
||||
@@ -155,6 +155,11 @@ type ManagedRoutine = {
|
||||
} | null;
|
||||
};
|
||||
|
||||
type ManagedRoutineDefaultDrift = NonNullable<ManagedRoutine["defaultDrift"]>;
|
||||
type ManagedRoutinesListItemWithDrift = ManagedRoutinesListItem & {
|
||||
defaultDrift?: ManagedRoutineDefaultDrift | null;
|
||||
};
|
||||
|
||||
type ManagedSkill = {
|
||||
status: string;
|
||||
skillId?: string | null;
|
||||
@@ -5905,7 +5910,7 @@ function SettingsBody({ context, initialSection = "root" }: { context: { company
|
||||
const effectiveSelectedProjectId = selectedProjectId || data.managedProject.projectId || "";
|
||||
const currentProjectOption = projectOptions.find((project) => project.id === effectiveSelectedProjectId) ?? projectFallbackOption;
|
||||
const currentEventPolicy = eventPolicy ?? data.eventIngestion;
|
||||
const managedRoutineItems: ManagedRoutinesListItem[] = managedRoutines.map((routine) => {
|
||||
const managedRoutineItems: ManagedRoutinesListItemWithDrift[] = managedRoutines.map((routine) => {
|
||||
const fallback = routineFallbackFor(routine);
|
||||
const key = routine.resourceKey ?? routine.routineId ?? fallback.title;
|
||||
const status = managedRoutineStatus(routine);
|
||||
@@ -6132,7 +6137,7 @@ function SettingsBody({ context, initialSection = "root" }: { context: { company
|
||||
|
||||
async function resetManagedRoutineToDefaults(routine: ManagedRoutinesListItem) {
|
||||
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 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.`,
|
||||
|
||||
@@ -1102,10 +1102,10 @@ export async function listPaperclipIngestionCandidates(ctx: PluginContext, input
|
||||
return { projects, rootIssues: issues };
|
||||
}
|
||||
|
||||
export async function updateEventIngestionSettings(
|
||||
export async function updateEventIngestionSettings(
|
||||
ctx: PluginContext,
|
||||
input: { companyId: string; settings: WikiEventIngestionSettingsUpdate },
|
||||
): Promise<WikiEventIngestionSettings> {
|
||||
): Promise<WikiEventIngestionSettings> {
|
||||
await requirePaperclipIngestionPolicy(ctx, {
|
||||
companyId: input.companyId,
|
||||
wikiId: normalizeWikiId(input.settings.wikiId),
|
||||
|
||||
@@ -1047,22 +1047,27 @@ export { deriveProjectUrlKey, normalizeProjectUrlKey, hasNonAsciiContent } from
|
||||
export {
|
||||
AGENT_MENTION_SCHEME,
|
||||
PROJECT_MENTION_SCHEME,
|
||||
ROUTINE_MENTION_SCHEME,
|
||||
SKILL_MENTION_SCHEME,
|
||||
USER_MENTION_SCHEME,
|
||||
buildAgentMentionHref,
|
||||
buildProjectMentionHref,
|
||||
buildRoutineMentionHref,
|
||||
buildSkillMentionHref,
|
||||
buildUserMentionHref,
|
||||
extractAgentMentionIds,
|
||||
extractProjectMentionIds,
|
||||
extractRoutineMentionIds,
|
||||
extractSkillMentionIds,
|
||||
extractUserMentionIds,
|
||||
parseAgentMentionHref,
|
||||
parseProjectMentionHref,
|
||||
parseRoutineMentionHref,
|
||||
parseSkillMentionHref,
|
||||
parseUserMentionHref,
|
||||
type ParsedAgentMention,
|
||||
type ParsedProjectMention,
|
||||
type ParsedRoutineMention,
|
||||
type ParsedSkillMention,
|
||||
type ParsedUserMention,
|
||||
} from "./project-mentions.js";
|
||||
|
||||
@@ -2,14 +2,17 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildAgentMentionHref,
|
||||
buildProjectMentionHref,
|
||||
buildRoutineMentionHref,
|
||||
buildSkillMentionHref,
|
||||
buildUserMentionHref,
|
||||
extractAgentMentionIds,
|
||||
extractProjectMentionIds,
|
||||
extractRoutineMentionIds,
|
||||
extractSkillMentionIds,
|
||||
extractUserMentionIds,
|
||||
parseAgentMentionHref,
|
||||
parseProjectMentionHref,
|
||||
parseRoutineMentionHref,
|
||||
parseSkillMentionHref,
|
||||
parseUserMentionHref,
|
||||
} from "./project-mentions.js";
|
||||
@@ -49,4 +52,12 @@ describe("project-mentions", () => {
|
||||
});
|
||||
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"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ export const PROJECT_MENTION_SCHEME = "project://";
|
||||
export const AGENT_MENTION_SCHEME = "agent://";
|
||||
export const USER_MENTION_SCHEME = "user://";
|
||||
export const SKILL_MENTION_SCHEME = "skill://";
|
||||
export const ROUTINE_MENTION_SCHEME = "routine://";
|
||||
|
||||
const HEX_COLOR_RE = /^[0-9a-f]{6}$/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 USER_MENTION_LINK_RE = /\[[^\]]*]\((user:\/\/[^)\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 SKILL_SLUG_RE = /^[a-z0-9][a-z0-9-]*$/i;
|
||||
|
||||
@@ -33,6 +35,10 @@ export interface ParsedSkillMention {
|
||||
slug: string | null;
|
||||
}
|
||||
|
||||
export interface ParsedRoutineMention {
|
||||
routineId: string;
|
||||
}
|
||||
|
||||
function normalizeHexColor(input: string | null | undefined): string | null {
|
||||
if (!input) return null;
|
||||
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[] {
|
||||
if (!markdown) return [];
|
||||
const ids = new Set<string>();
|
||||
@@ -217,6 +245,18 @@ export function extractSkillMentionIds(markdown: string): string[] {
|
||||
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 {
|
||||
if (!input) return null;
|
||||
const trimmed = input.trim().toLowerCase();
|
||||
|
||||
@@ -31,7 +31,7 @@ export const upsertAgentInstructionsFileSchema = z.object({
|
||||
|
||||
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;
|
||||
if (envValue === undefined) return;
|
||||
const parsed = envConfigSchema.safeParse(envValue);
|
||||
@@ -46,7 +46,7 @@ const adapterConfigSchema = z.record(z.unknown()).superRefine((value, ctx) => {
|
||||
|
||||
export const createAgentInstructionsBundleSchema = z.object({
|
||||
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",
|
||||
}),
|
||||
});
|
||||
@@ -78,7 +78,7 @@ export const createAgentSchema = z.object({
|
||||
defaultEnvironmentId: z.string().uuid().optional().nullable(),
|
||||
budgetMonthlyCents: z.number().int().nonnegative().optional().default(0),
|
||||
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>;
|
||||
@@ -126,7 +126,7 @@ export const wakeAgentSchema = z.object({
|
||||
source: z.enum(["timer", "assignment", "on_demand", "automation"]).optional().default("on_demand"),
|
||||
triggerDetail: z.enum(["manual", "ping", "callback", "system"]).optional(),
|
||||
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(),
|
||||
forceFreshSession: z.preprocess(
|
||||
(value) => (value === null ? undefined : value),
|
||||
|
||||
@@ -5,7 +5,7 @@ import { multilineTextSchema } from "./text.js";
|
||||
export const createApprovalSchema = z.object({
|
||||
type: z.enum(APPROVAL_TYPES),
|
||||
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(),
|
||||
});
|
||||
|
||||
@@ -24,7 +24,7 @@ export const requestApprovalRevisionSchema = z.object({
|
||||
export type RequestApprovalRevision = z.infer<typeof requestApprovalRevisionSchema>;
|
||||
|
||||
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>;
|
||||
|
||||
@@ -67,11 +67,11 @@ export const portabilityAgentManifestEntrySchema = z.object({
|
||||
capabilities: z.string().nullable(),
|
||||
reportsToSlug: z.string().min(1).nullable(),
|
||||
adapterType: z.string().min(1),
|
||||
adapterConfig: z.record(z.unknown()),
|
||||
runtimeConfig: z.record(z.unknown()),
|
||||
permissions: z.record(z.unknown()),
|
||||
adapterConfig: z.record(z.string(), z.unknown()),
|
||||
runtimeConfig: z.record(z.string(), z.unknown()),
|
||||
permissions: z.record(z.string(), z.unknown()),
|
||||
budgetMonthlyCents: z.number().int().nonnegative(),
|
||||
metadata: z.record(z.unknown()).nullable(),
|
||||
metadata: z.record(z.string(), z.unknown()).nullable(),
|
||||
});
|
||||
|
||||
export const portabilitySkillManifestEntrySchema = z.object({
|
||||
@@ -85,7 +85,7 @@ export const portabilitySkillManifestEntrySchema = z.object({
|
||||
sourceRef: z.string().nullable(),
|
||||
trustLevel: 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({
|
||||
path: z.string().min(1),
|
||||
kind: z.string().min(1),
|
||||
@@ -102,7 +102,7 @@ export const portabilityProjectManifestEntrySchema = z.object({
|
||||
targetDate: z.string().nullable(),
|
||||
color: 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({
|
||||
key: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
@@ -113,10 +113,10 @@ export const portabilityProjectManifestEntrySchema = z.object({
|
||||
visibility: z.string().nullable(),
|
||||
setupCommand: z.string().nullable(),
|
||||
cleanupCommand: z.string().nullable(),
|
||||
metadata: z.record(z.unknown()).nullable(),
|
||||
metadata: z.record(z.string(), z.unknown()).nullable(),
|
||||
isPrimary: z.boolean(),
|
||||
})).default([]),
|
||||
metadata: z.record(z.unknown()).nullable(),
|
||||
metadata: z.record(z.string(), z.unknown()).nullable(),
|
||||
});
|
||||
|
||||
export const portabilityIssueRoutineTriggerManifestEntrySchema = z.object({
|
||||
@@ -157,15 +157,15 @@ export const portabilityIssueManifestEntrySchema = z.object({
|
||||
description: z.string().nullable(),
|
||||
recurring: z.boolean().default(false),
|
||||
routine: portabilityIssueRoutineManifestEntrySchema.nullable(),
|
||||
legacyRecurrence: z.record(z.unknown()).nullable(),
|
||||
legacyRecurrence: z.record(z.string(), z.unknown()).nullable(),
|
||||
status: z.string().nullable(),
|
||||
priority: z.string().nullable(),
|
||||
labelIds: z.array(z.string().min(1)).default([]),
|
||||
billingCode: z.string().nullable(),
|
||||
executionWorkspaceSettings: z.record(z.unknown()).nullable(),
|
||||
assigneeAdapterOverrides: z.record(z.unknown()).nullable(),
|
||||
executionWorkspaceSettings: z.record(z.string(), z.unknown()).nullable(),
|
||||
assigneeAdapterOverrides: z.record(z.string(), z.unknown()).nullable(),
|
||||
comments: z.array(portabilityIssueCommentManifestEntrySchema).default([]),
|
||||
metadata: z.record(z.unknown()).nullable(),
|
||||
metadata: z.record(z.string(), z.unknown()).nullable(),
|
||||
});
|
||||
|
||||
export const portabilityManifestSchema = z.object({
|
||||
@@ -197,7 +197,7 @@ export const portabilitySourceSchema = z.discriminatedUnion("type", [
|
||||
z.object({
|
||||
type: z.literal("inline"),
|
||||
rootPath: z.string().min(1).optional().nullable(),
|
||||
files: z.record(portabilityFileEntrySchema),
|
||||
files: z.record(z.string(), portabilityFileEntrySchema),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("github"),
|
||||
@@ -251,7 +251,7 @@ export type CompanyPortabilityPreview = z.infer<typeof companyPortabilityPreview
|
||||
|
||||
export const portabilityAdapterOverrideSchema = z.object({
|
||||
adapterType: z.string().min(1),
|
||||
adapterConfig: z.record(z.unknown()).optional(),
|
||||
adapterConfig: z.record(z.string(), z.unknown()).optional(),
|
||||
});
|
||||
|
||||
export const companyPortabilityImportSchema = companyPortabilityPreviewSchema.extend({
|
||||
|
||||
@@ -24,7 +24,7 @@ export const companySkillSchema = z.object({
|
||||
trustLevel: companySkillTrustLevelSchema,
|
||||
compatibility: companySkillCompatibilitySchema,
|
||||
fileInventory: z.array(companySkillFileInventoryEntrySchema).default([]),
|
||||
metadata: z.record(z.unknown()).nullable(),
|
||||
metadata: z.record(z.string(), z.unknown()).nullable(),
|
||||
createdAt: z.coerce.date(),
|
||||
updatedAt: z.coerce.date(),
|
||||
});
|
||||
|
||||
@@ -16,8 +16,8 @@ const environmentFields = {
|
||||
description: z.string().optional().nullable(),
|
||||
driver: environmentDriverSchema,
|
||||
status: environmentStatusSchema.optional().default("active"),
|
||||
config: z.record(z.unknown()).optional().default({}),
|
||||
metadata: z.record(z.unknown()).optional().nullable(),
|
||||
config: z.record(z.string(), z.unknown()).optional().default({}),
|
||||
metadata: z.record(z.string(), z.unknown()).optional().nullable(),
|
||||
};
|
||||
|
||||
export const createEnvironmentSchema = z.object(environmentFields).strict();
|
||||
@@ -28,8 +28,8 @@ export const updateEnvironmentSchema = z.object({
|
||||
description: z.string().optional().nullable(),
|
||||
driver: environmentDriverSchema.optional(),
|
||||
status: environmentStatusSchema.optional(),
|
||||
config: z.record(z.unknown()).optional(),
|
||||
metadata: z.record(z.unknown()).optional().nullable(),
|
||||
config: z.record(z.string(), z.unknown()).optional(),
|
||||
metadata: z.record(z.string(), z.unknown()).optional().nullable(),
|
||||
}).strict();
|
||||
export type UpdateEnvironment = z.infer<typeof updateEnvironmentSchema>;
|
||||
|
||||
@@ -37,7 +37,7 @@ export const probeEnvironmentConfigSchema = z.object({
|
||||
name: z.string().min(1).optional(),
|
||||
description: z.string().optional().nullable(),
|
||||
driver: environmentDriverSchema,
|
||||
config: z.record(z.unknown()).optional().default({}),
|
||||
metadata: z.record(z.unknown()).optional().nullable(),
|
||||
config: z.record(z.string(), z.unknown()).optional().default({}),
|
||||
metadata: z.record(z.string(), z.unknown()).optional().nullable(),
|
||||
}).strict();
|
||||
export type ProbeEnvironmentConfig = z.infer<typeof probeEnvironmentConfigSchema>;
|
||||
|
||||
@@ -13,7 +13,7 @@ export const executionWorkspaceConfigSchema = z.object({
|
||||
provisionCommand: z.string().optional().nullable(),
|
||||
teardownCommand: 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(),
|
||||
serviceStates: z.record(z.enum(["running", "stopped", "manual"])).optional().nullable(),
|
||||
}).strict();
|
||||
@@ -94,7 +94,7 @@ export const workspaceRuntimeServiceSchema = z.object({
|
||||
lastUsedAt: z.coerce.date(),
|
||||
startedAt: z.coerce.date(),
|
||||
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"]),
|
||||
configIndex: z.number().int().nonnegative().nullable().optional(),
|
||||
createdAt: z.coerce.date(),
|
||||
@@ -125,7 +125,7 @@ export const updateExecutionWorkspaceSchema = z.object({
|
||||
cleanupEligibleAt: z.string().datetime().optional().nullable(),
|
||||
cleanupReason: z.string().optional().nullable(),
|
||||
config: executionWorkspaceConfigSchema.optional().nullable(),
|
||||
metadata: z.record(z.unknown()).optional().nullable(),
|
||||
metadata: z.record(z.string(), z.unknown()).optional().nullable(),
|
||||
}).strict();
|
||||
|
||||
export type UpdateExecutionWorkspace = z.infer<typeof updateExecutionWorkspaceSchema>;
|
||||
|
||||
@@ -27,7 +27,7 @@ export const createIssueTreeHoldSchema = z
|
||||
mode: issueTreeControlModeSchema,
|
||||
reason: z.string().trim().min(1).max(1000).optional().nullable(),
|
||||
releasePolicy: issueTreeHoldReleasePolicySchema.optional().nullable(),
|
||||
metadata: z.record(z.unknown()).optional().nullable(),
|
||||
metadata: z.record(z.string(), z.unknown()).optional().nullable(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
@@ -37,7 +37,7 @@ export const releaseIssueTreeHoldSchema = z
|
||||
.object({
|
||||
reason: z.string().trim().min(1).max(1000).optional().nullable(),
|
||||
releasePolicy: issueTreeHoldReleasePolicySchema.optional().nullable(),
|
||||
metadata: z.record(z.unknown()).optional().nullable(),
|
||||
metadata: z.record(z.string(), z.unknown()).optional().nullable(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
|
||||
@@ -73,6 +73,25 @@ describe("issue validators", () => {
|
||||
).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", () => {
|
||||
expect(
|
||||
resolveIssueRecoveryActionSchema.parse({
|
||||
|
||||
@@ -116,14 +116,14 @@ export const issueExecutionWorkspaceSettingsSchema = z
|
||||
mode: z.enum(ISSUE_EXECUTION_WORKSPACE_PREFERENCES).optional(),
|
||||
environmentId: z.string().uuid().optional().nullable(),
|
||||
workspaceStrategy: executionWorkspaceStrategySchema.optional().nullable(),
|
||||
workspaceRuntime: z.record(z.unknown()).optional().nullable(),
|
||||
workspaceRuntime: z.record(z.string(), z.unknown()).optional().nullable(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const issueAssigneeAdapterOverridesSchema = z
|
||||
.object({
|
||||
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(),
|
||||
})
|
||||
.strict();
|
||||
@@ -248,10 +248,10 @@ export const issueRecoveryActionReadModelSchema = z.object({
|
||||
returnOwnerAgentId: z.string().uuid().nullable(),
|
||||
cause: 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),
|
||||
wakePolicy: z.record(z.unknown()).nullable(),
|
||||
monitorPolicy: z.record(z.unknown()).nullable(),
|
||||
wakePolicy: z.record(z.string(), z.unknown()).nullable(),
|
||||
monitorPolicy: z.record(z.string(), z.unknown()).nullable(),
|
||||
attemptCount: z.number().int().nonnegative(),
|
||||
maxAttempts: z.number().int().positive().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({
|
||||
actionId: z.string().uuid().optional(),
|
||||
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(),
|
||||
}).strict().superRefine((value, ctx) => {
|
||||
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({
|
||||
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"],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ import { routineVariableSchema } from "./routine.js";
|
||||
*
|
||||
* @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) => {
|
||||
// Must have a "type" field if non-empty, or be a valid JSON Schema object
|
||||
if (Object.keys(val).length === 0) return true;
|
||||
@@ -143,9 +143,9 @@ export const pluginManagedAgentDeclarationSchema = z.object({
|
||||
capabilities: z.string().max(2000).nullable().optional(),
|
||||
adapterType: z.string().min(1).max(100).optional(),
|
||||
adapterPreference: z.array(z.string().min(1).max(100)).max(10).optional(),
|
||||
adapterConfig: z.record(z.unknown()).optional(),
|
||||
runtimeConfig: z.record(z.unknown()).optional(),
|
||||
permissions: z.record(z.unknown()).optional(),
|
||||
adapterConfig: z.record(z.string(), z.unknown()).optional(),
|
||||
runtimeConfig: z.record(z.string(), z.unknown()).optional(),
|
||||
permissions: z.record(z.string(), z.unknown()).optional(),
|
||||
status: z.enum(["idle", "paused"]).optional(),
|
||||
budgetMonthlyCents: z.number().int().min(0).optional(),
|
||||
instructions: z.object({
|
||||
@@ -166,7 +166,7 @@ export const pluginManagedProjectDeclarationSchema = z.object({
|
||||
description: z.string().max(2000).nullable().optional(),
|
||||
status: z.enum(["backlog", "planned", "in_progress", "completed", "cancelled"]).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>;
|
||||
@@ -373,7 +373,7 @@ const launcherBoundsByEnvironment: Record<
|
||||
export const pluginLauncherActionDeclarationSchema = z.object({
|
||||
type: z.enum(PLUGIN_LAUNCHER_ACTIONS),
|
||||
target: z.string().min(1),
|
||||
params: z.record(z.unknown()).optional(),
|
||||
params: z.record(z.string(), z.unknown()).optional(),
|
||||
}).superRefine((value, ctx) => {
|
||||
if (value.type === "performAction" && value.target.includes("/")) {
|
||||
ctx.addIssue({
|
||||
@@ -993,7 +993,7 @@ export type InstallPlugin = z.infer<typeof installPluginSchema>;
|
||||
* the plugin's instanceConfigSchema is done at the service layer.
|
||||
*/
|
||||
export const upsertPluginConfigSchema = z.object({
|
||||
configJson: z.record(z.unknown()),
|
||||
configJson: z.record(z.string(), z.unknown()),
|
||||
});
|
||||
|
||||
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.
|
||||
*/
|
||||
export const patchPluginConfigSchema = z.object({
|
||||
configJson: z.record(z.unknown()),
|
||||
configJson: z.record(z.string(), z.unknown()),
|
||||
});
|
||||
|
||||
export type PatchPluginConfig = z.infer<typeof patchPluginConfigSchema>;
|
||||
|
||||
@@ -21,16 +21,16 @@ export const projectExecutionWorkspacePolicySchema = z
|
||||
defaultProjectWorkspaceId: z.string().uuid().optional().nullable(),
|
||||
environmentId: z.string().uuid().optional().nullable(),
|
||||
workspaceStrategy: executionWorkspaceStrategySchema.optional().nullable(),
|
||||
workspaceRuntime: z.record(z.unknown()).optional().nullable(),
|
||||
branchPolicy: z.record(z.unknown()).optional().nullable(),
|
||||
pullRequestPolicy: z.record(z.unknown()).optional().nullable(),
|
||||
runtimePolicy: z.record(z.unknown()).optional().nullable(),
|
||||
cleanupPolicy: z.record(z.unknown()).optional().nullable(),
|
||||
workspaceRuntime: z.record(z.string(), z.unknown()).optional().nullable(),
|
||||
branchPolicy: z.record(z.string(), z.unknown()).optional().nullable(),
|
||||
pullRequestPolicy: z.record(z.string(), z.unknown()).optional().nullable(),
|
||||
runtimePolicy: z.record(z.string(), z.unknown()).optional().nullable(),
|
||||
cleanupPolicy: z.record(z.string(), z.unknown()).optional().nullable(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
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(),
|
||||
serviceStates: z.record(z.enum(["running", "stopped", "manual"])).optional().nullable(),
|
||||
}).strict();
|
||||
@@ -51,7 +51,7 @@ const projectWorkspaceFields = {
|
||||
remoteProvider: z.string().optional().nullable(),
|
||||
remoteWorkspaceRef: 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(),
|
||||
};
|
||||
|
||||
|
||||
@@ -146,8 +146,8 @@ export type UpdateRoutineTrigger = z.infer<typeof updateRoutineTriggerSchema>;
|
||||
|
||||
export const runRoutineSchema = z.object({
|
||||
triggerId: z.string().uuid().optional().nullable(),
|
||||
payload: z.record(z.unknown()).optional().nullable(),
|
||||
variables: z.record(routineVariableValueSchema).optional().nullable(),
|
||||
payload: z.record(z.string(), z.unknown()).optional().nullable(),
|
||||
variables: z.record(z.string(), routineVariableValueSchema).optional().nullable(),
|
||||
projectId: z.string().uuid().optional().nullable(),
|
||||
assigneeAgentId: z.string().uuid().optional().nullable(),
|
||||
idempotencyKey: z.string().trim().max(255).optional().nullable(),
|
||||
|
||||
@@ -25,7 +25,7 @@ export const envBindingSchema = z.union([
|
||||
envBindingSecretRefSchema,
|
||||
]);
|
||||
|
||||
export const envConfigSchema = z.record(envBindingSchema);
|
||||
export const envConfigSchema = z.record(z.string(), envBindingSchema);
|
||||
|
||||
export const createSecretSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
@@ -36,7 +36,7 @@ export const createSecretSchema = z.object({
|
||||
value: z.string().min(1).optional().nullable(),
|
||||
description: 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(),
|
||||
}).superRefine((value, ctx) => {
|
||||
if ((value.managedMode ?? "paperclip_managed") === "external_reference") {
|
||||
@@ -83,7 +83,7 @@ export const updateSecretSchema = z.object({
|
||||
providerConfigId: z.string().uuid().optional().nullable(),
|
||||
description: 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>;
|
||||
@@ -198,7 +198,7 @@ export const createSecretProviderConfigSchema = z.object({
|
||||
displayName: z.string().trim().min(1).max(120),
|
||||
status: z.enum(SECRET_PROVIDER_CONFIG_STATUSES).optional(),
|
||||
isDefault: z.boolean().optional(),
|
||||
config: z.record(z.unknown()).default({}),
|
||||
config: z.record(z.string(), z.unknown()).default({}),
|
||||
}).superRefine((value, ctx) => {
|
||||
rejectSensitiveProviderConfigKeys(value.config, ctx);
|
||||
const parsed = secretProviderConfigPayloadSchema.safeParse({
|
||||
@@ -236,7 +236,7 @@ export const updateSecretProviderConfigSchema = z.object({
|
||||
displayName: z.string().trim().min(1).max(120).optional(),
|
||||
status: z.enum(SECRET_PROVIDER_CONFIG_STATUSES).optional(),
|
||||
isDefault: z.boolean().optional(),
|
||||
config: z.record(z.unknown()).optional(),
|
||||
config: z.record(z.string(), z.unknown()).optional(),
|
||||
}).superRefine((value, ctx) => {
|
||||
if (value.config !== undefined) {
|
||||
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(),
|
||||
description: z.string().trim().max(500).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({
|
||||
|
||||
@@ -43,7 +43,7 @@ export const createIssueWorkProductSchema = z.object({
|
||||
isPrimary: z.boolean().optional().default(false),
|
||||
healthStatus: z.enum(["unknown", "healthy", "unhealthy"]).optional().default("unknown"),
|
||||
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(),
|
||||
});
|
||||
|
||||
|
||||
@@ -84,6 +84,11 @@ vi.mock("../services/index.js", () => ({
|
||||
getActiveForIssue: vi.fn(async () => null),
|
||||
listActiveForIssues: vi.fn(async () => new Map()),
|
||||
}),
|
||||
issueThreadInteractionService: () => ({
|
||||
listForIssue: vi.fn(async () => []),
|
||||
expireRequestConfirmationsSupersededByComment: vi.fn(async () => []),
|
||||
expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []),
|
||||
}),
|
||||
documentService: () => ({}),
|
||||
routineService: () => ({}),
|
||||
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 { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
activityLog,
|
||||
agents,
|
||||
companies,
|
||||
createDb,
|
||||
heartbeatRunEvents,
|
||||
heartbeatRunWatchdogDecisions,
|
||||
heartbeatRuns,
|
||||
issueComments,
|
||||
issueRecoveryActions,
|
||||
issueRelations,
|
||||
issues,
|
||||
} from "@paperclipai/db";
|
||||
@@ -94,7 +98,15 @@ describeEmbeddedPostgres("active-run output watchdog", () => {
|
||||
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 managerId = randomUUID();
|
||||
const coderId = randomUUID();
|
||||
@@ -103,6 +115,8 @@ describeEmbeddedPostgres("active-run output watchdog", () => {
|
||||
const issuePrefix = `W${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
|
||||
const startedAt = new Date(opts.now.getTime() - opts.ageMs);
|
||||
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({
|
||||
id: companyId,
|
||||
@@ -139,11 +153,14 @@ describeEmbeddedPostgres("active-run output watchdog", () => {
|
||||
id: issueId,
|
||||
companyId,
|
||||
title: "Long running implementation",
|
||||
status: "in_progress",
|
||||
status: sourceStatus,
|
||||
priority: "medium",
|
||||
assigneeAgentId: coderId,
|
||||
issueNumber: 1,
|
||||
identifier: `${issuePrefix}-1`,
|
||||
originKind: opts.sourceOriginKind ?? "manual",
|
||||
completedAt: sourceStatus === "done" ? terminalEvidenceAt : null,
|
||||
cancelledAt: sourceStatus === "cancelled" ? terminalEvidenceAt : null,
|
||||
updatedAt: startedAt,
|
||||
createdAt: startedAt,
|
||||
});
|
||||
@@ -181,6 +198,35 @@ describeEmbeddedPostgres("active-run output watchdog", () => {
|
||||
.where(eq(heartbeatRuns.id, runId));
|
||||
}
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -271,6 +317,211 @@ describeEmbeddedPostgres("active-run output watchdog", () => {
|
||||
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 () => {
|
||||
const now = new Date("2026-04-22T20:00:00.000Z");
|
||||
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 () => {
|
||||
await enableAutoRecovery();
|
||||
const { companyId, managerId, blockedIssueId, blockerIssueId } = await seedBlockedChain();
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AdapterModelProfileDefinition } from "../adapters/index.js";
|
||||
import {
|
||||
listAdapterModelProfiles,
|
||||
type AdapterModelProfileDefinition,
|
||||
} from "../adapters/index.js";
|
||||
import {
|
||||
mergeModelProfileAdapterConfig,
|
||||
normalizeModelProfileWakeContext,
|
||||
@@ -17,6 +20,27 @@ const cheapProfile: AdapterModelProfileDefinition = {
|
||||
};
|
||||
|
||||
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", () => {
|
||||
const modelProfile = resolveModelProfileApplication({
|
||||
adapterModelProfiles: [cheapProfile],
|
||||
|
||||
@@ -21,4 +21,21 @@ describe("compactRunLogChunk", () => {
|
||||
expect(compacted).toContain("[paperclip truncated run log chunk:");
|
||||
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);
|
||||
});
|
||||
|
||||
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", () => {
|
||||
expect(
|
||||
shouldResetTaskSessionForWake({
|
||||
|
||||
@@ -106,6 +106,11 @@ function registerModuleMocks() {
|
||||
syncDocument: async () => undefined,
|
||||
syncIssue: async () => undefined,
|
||||
}),
|
||||
issueThreadInteractionService: () => ({
|
||||
listForIssue: vi.fn(async () => []),
|
||||
expireRequestConfirmationsSupersededByComment: vi.fn(async () => []),
|
||||
expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []),
|
||||
}),
|
||||
issueService: () => mockIssueService,
|
||||
logActivity: mockLogActivity,
|
||||
projectService: () => ({}),
|
||||
|
||||
@@ -8,6 +8,7 @@ const companyId = "22222222-2222-4222-8222-222222222222";
|
||||
const ownerAgentId = "33333333-3333-4333-8333-333333333333";
|
||||
const peerAgentId = "44444444-4444-4444-8444-444444444444";
|
||||
const ownerRunId = "55555555-5555-4555-8555-555555555555";
|
||||
const recoveryActionId = "77777777-7777-4777-8777-777777777777";
|
||||
|
||||
const mockIssueService = vi.hoisted(() => ({
|
||||
addComment: vi.fn(),
|
||||
@@ -62,6 +63,14 @@ const mockIssueThreadInteractionService = vi.hoisted(() => ({
|
||||
}));
|
||||
const mockIssueRecoveryActionService = vi.hoisted(() => ({
|
||||
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() {
|
||||
@@ -109,13 +118,7 @@ function registerRouteMocks() {
|
||||
saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })),
|
||||
}),
|
||||
goalService: () => ({}),
|
||||
heartbeatService: () => ({
|
||||
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),
|
||||
}),
|
||||
heartbeatService: () => mockHeartbeatService,
|
||||
instanceSettingsService: () => ({
|
||||
get: vi.fn(async () => ({
|
||||
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("../routes/issues.js")>("../routes/issues.js"),
|
||||
]);
|
||||
const fakeDb = {
|
||||
transaction: async (callback: (tx: Record<string, never>) => Promise<unknown>) => callback({}),
|
||||
};
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
(req as any).actor = actor;
|
||||
next();
|
||||
});
|
||||
app.use("/api", issueRoutes({} as any, mockStorageService as any));
|
||||
app.use("/api", issueRoutes(fakeDb as any, mockStorageService as any));
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
@@ -265,6 +271,45 @@ describe("agent issue mutation checkout ownership", () => {
|
||||
mockIssueService.listWakeableBlockedDependents.mockReset();
|
||||
mockIssueRecoveryActionService.getActiveForIssue.mockReset();
|
||||
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.removeAttachment.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 () => {
|
||||
const app = await createApp(boardActor());
|
||||
|
||||
@@ -477,4 +563,103 @@ describe("agent issue mutation checkout ownership", () => {
|
||||
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,
|
||||
syncIssue: async () => undefined,
|
||||
}),
|
||||
issueThreadInteractionService: () => ({
|
||||
listForIssue: vi.fn(async () => []),
|
||||
expireRequestConfirmationsSupersededByComment: vi.fn(async () => []),
|
||||
expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []),
|
||||
}),
|
||||
issueService: () => mockIssueService,
|
||||
logActivity: mockLogActivity,
|
||||
projectService: () => ({
|
||||
|
||||
@@ -81,6 +81,11 @@ function registerRouteMocks() {
|
||||
syncDocument: async () => undefined,
|
||||
syncIssue: async () => undefined,
|
||||
}),
|
||||
issueThreadInteractionService: () => ({
|
||||
listForIssue: vi.fn(async () => []),
|
||||
expireRequestConfirmationsSupersededByComment: vi.fn(async () => []),
|
||||
expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []),
|
||||
}),
|
||||
issueRecoveryActionService: () => ({
|
||||
getActiveForIssue: vi.fn(async () => null),
|
||||
listActiveForIssues: vi.fn(async () => new Map()),
|
||||
|
||||
@@ -116,6 +116,11 @@ function registerServiceMocks() {
|
||||
syncDocument: async () => undefined,
|
||||
syncIssue: async () => undefined,
|
||||
}),
|
||||
issueThreadInteractionService: () => ({
|
||||
listForIssue: vi.fn(async () => []),
|
||||
expireRequestConfirmationsSupersededByComment: vi.fn(async () => []),
|
||||
expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []),
|
||||
}),
|
||||
issueRecoveryActionService: () => ({
|
||||
getActiveForIssue: vi.fn(async () => null),
|
||||
listActiveForIssues: vi.fn(async () => new Map()),
|
||||
|
||||
@@ -65,6 +65,11 @@ vi.mock("../services/index.js", () => ({
|
||||
getActiveForIssue: vi.fn(async () => null),
|
||||
listActiveForIssues: vi.fn(async () => new Map()),
|
||||
}),
|
||||
issueThreadInteractionService: () => ({
|
||||
listForIssue: vi.fn(async () => []),
|
||||
expireRequestConfirmationsSupersededByComment: vi.fn(async () => []),
|
||||
expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []),
|
||||
}),
|
||||
issueService: () => mockIssueService,
|
||||
logActivity: vi.fn(async () => undefined),
|
||||
projectService: () => ({
|
||||
|
||||
@@ -5,9 +5,13 @@ import { and, eq } from "drizzle-orm";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
agents,
|
||||
agentWakeupRequests,
|
||||
activityLog,
|
||||
companies,
|
||||
createDb,
|
||||
environmentLeases,
|
||||
environments,
|
||||
heartbeatRuns,
|
||||
issueComments,
|
||||
issueRecoveryActions,
|
||||
issueRelations,
|
||||
@@ -130,7 +134,11 @@ describeEmbeddedPostgres("issue recovery actions", () => {
|
||||
afterEach(async () => {
|
||||
await db.delete(issueRecoveryActions);
|
||||
await db.delete(issueComments);
|
||||
await db.delete(environmentLeases);
|
||||
await db.delete(activityLog);
|
||||
await db.delete(heartbeatRuns);
|
||||
await db.delete(agentWakeupRequests);
|
||||
await db.delete(environments);
|
||||
await db.delete(issues);
|
||||
await db.delete(agents);
|
||||
await db.delete(companies);
|
||||
@@ -191,6 +199,24 @@ describeEmbeddedPostgres("issue recovery actions", () => {
|
||||
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" }) {
|
||||
const app = express();
|
||||
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 () => {
|
||||
const { companyId, managerId, sourceIssueId } = await seedCompany();
|
||||
const recoveryActionSvc = issueRecoveryActionService(db);
|
||||
|
||||
@@ -58,6 +58,11 @@ function registerModuleMocks() {
|
||||
syncDocument: async () => undefined,
|
||||
syncIssue: async () => undefined,
|
||||
}),
|
||||
issueThreadInteractionService: () => ({
|
||||
listForIssue: vi.fn(async () => []),
|
||||
expireRequestConfirmationsSupersededByComment: vi.fn(async () => []),
|
||||
expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []),
|
||||
}),
|
||||
issueRecoveryActionService: () => ({
|
||||
getActiveForIssue: vi.fn(async () => null),
|
||||
listActiveForIssues: vi.fn(async () => new Map()),
|
||||
|
||||
@@ -106,6 +106,7 @@ function createIssue(overrides: Record<string, unknown> = {}) {
|
||||
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
companyId: "company-1",
|
||||
status: "in_progress",
|
||||
workMode: "standard",
|
||||
priority: "medium",
|
||||
projectId: 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 () => {
|
||||
mockIssueService.getById.mockResolvedValueOnce(createIssue({
|
||||
status: "in_review",
|
||||
|
||||
@@ -119,6 +119,11 @@ function registerRouteMocks() {
|
||||
getActiveForIssue: vi.fn(async () => null),
|
||||
listActiveForIssues: vi.fn(async () => new Map()),
|
||||
}),
|
||||
issueThreadInteractionService: () => ({
|
||||
listForIssue: vi.fn(async () => []),
|
||||
expireRequestConfirmationsSupersededByComment: vi.fn(async () => []),
|
||||
expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []),
|
||||
}),
|
||||
issueService: () => mockIssueService,
|
||||
logActivity: mockLogActivity,
|
||||
projectService: () => ({}),
|
||||
|
||||
@@ -115,6 +115,11 @@ vi.mock("../services/index.js", () => ({
|
||||
getActiveForIssue: vi.fn(async () => null),
|
||||
listActiveForIssues: vi.fn(async () => new Map()),
|
||||
}),
|
||||
issueThreadInteractionService: () => ({
|
||||
listForIssue: vi.fn(async () => []),
|
||||
expireRequestConfirmationsSupersededByComment: vi.fn(async () => []),
|
||||
expireStaleRequestConfirmationsForIssueDocument: vi.fn(async () => []),
|
||||
}),
|
||||
issueReferenceService: () => mockIssueReferenceService,
|
||||
issueService: () => mockIssueService,
|
||||
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 () => {
|
||||
const companyId = randomUUID();
|
||||
const assigneeAgentId = randomUUID();
|
||||
|
||||
@@ -219,6 +219,14 @@ describe("plugin local folders", () => {
|
||||
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 () => {
|
||||
const root = await makeRoot();
|
||||
await fs.writeFile(path.join(root, "stale.md"), "delete me", "utf8");
|
||||
|
||||
@@ -70,7 +70,9 @@ describe("redaction", () => {
|
||||
const input = [
|
||||
"Authorization: Bearer live-bearer-token-value",
|
||||
`payload {"apiKey":"json-secret-value"}`,
|
||||
`paperclip {"PAPERCLIP_API_KEY":"paperclip-json-secret"}`,
|
||||
`escaped {\\"apiKey\\":\\"escaped-json-secret\\"}`,
|
||||
`export PAPERCLIP_API_KEY='paperclip-shell-secret'`,
|
||||
`GITHUB_TOKEN=${githubToken}`,
|
||||
`session=${jwt}`,
|
||||
].join("\n");
|
||||
@@ -80,7 +82,9 @@ describe("redaction", () => {
|
||||
expect(result).toContain(REDACTED_EVENT_VALUE);
|
||||
expect(result).not.toContain("live-bearer-token-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("paperclip-shell-secret");
|
||||
expect(result).not.toContain(githubToken);
|
||||
expect(result).not.toContain(jwt);
|
||||
});
|
||||
|
||||
+39
-8
@@ -1,19 +1,49 @@
|
||||
import { redactCommandText } from "@paperclipai/adapter-utils";
|
||||
|
||||
const SECRET_PAYLOAD_KEY_RE =
|
||||
/(api[-_]?key|access[-_]?token|auth(?:_?token)?|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring)/i;
|
||||
const SECRET_FIELD_NAME_PATTERN =
|
||||
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 =
|
||||
/(^command$|^cmd$|command[-_]?line|resolved[-_]?command|PAPERCLIP_RESOLVED_COMMAND)/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 CLI_SECRET_FLAG_RE =
|
||||
/^-{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 =
|
||||
/((?:"|')?(?: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 =
|
||||
/((?:\\")?(?:api[-_]?key|access[-_]?token|auth(?:_?token)?|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring)(?:\\")?\s*:\s*(?:\\"))[^\\\r\n]+((?:\\"))/gi;
|
||||
const CLI_SECRET_FLAG_RE = new RegExp(String.raw`^-{1,2}${SECRET_FIELD_NAME_PATTERN}$`, "i");
|
||||
const JSON_SECRET_FIELD_TEXT_RE = new RegExp(
|
||||
String.raw`((?:"|')?${SECRET_FIELD_NAME_PATTERN}(?:"|')?\s*:\s*(?:"|'))[^"'` + "`" + String.raw`\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***";
|
||||
|
||||
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> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
||||
const proto = Object.getPrototypeOf(value);
|
||||
@@ -94,6 +124,7 @@ export function redactEventPayload(payload: Record<string, unknown> | null): Rec
|
||||
}
|
||||
|
||||
export function redactSensitiveText(input: string): string {
|
||||
if (!maybeContainsSecretText(input)) return input;
|
||||
return redactCommandText(
|
||||
input
|
||||
.replace(JSON_SECRET_FIELD_TEXT_RE, `$1${REDACTED_EVENT_VALUE}$2`)
|
||||
|
||||
+412
-7
@@ -117,6 +117,13 @@ const updateIssueRouteSchema = updateIssueSchema.extend({
|
||||
|
||||
type ParsedExecutionState = NonNullable<ReturnType<typeof parseIssueExecutionState>>;
|
||||
type NormalizedExecutionPolicy = NonNullable<ReturnType<typeof normalizeIssueExecutionPolicy>>;
|
||||
type IssueRouteSnapshot = typeof issueRows.$inferSelect;
|
||||
type RecoveryRevalidationTrigger =
|
||||
| "issue_update"
|
||||
| "comment"
|
||||
| "document"
|
||||
| "work_product"
|
||||
| "read_projection";
|
||||
type CompanySearchService = {
|
||||
search(companyId: string, query: CompanySearchQuery): Promise<CompanySearchResponse>;
|
||||
};
|
||||
@@ -636,6 +643,8 @@ function queueResolvedInteractionContinuationWakeup(input: {
|
||||
};
|
||||
actor: { actorType: "user" | "agent"; actorId: string };
|
||||
source: string;
|
||||
forceFreshSession?: boolean;
|
||||
workspaceRefreshReason?: string | null;
|
||||
}) {
|
||||
if (
|
||||
input.interaction.continuationPolicy !== "wake_assignee"
|
||||
@@ -648,6 +657,8 @@ function queueResolvedInteractionContinuationWakeup(input: {
|
||||
if (input.interaction.status === "expired") 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, {
|
||||
source: "automation",
|
||||
triggerDetail: "system",
|
||||
@@ -673,6 +684,8 @@ function queueResolvedInteractionContinuationWakeup(input: {
|
||||
sourceRunId: input.interaction.sourceRunId ?? null,
|
||||
wakeReason: "issue_commented",
|
||||
source: input.source,
|
||||
...(forceFreshSession ? { forceFreshSession: true } : {}),
|
||||
...(workspaceRefreshReason ? { workspaceRefreshReason } : {}),
|
||||
},
|
||||
}).catch((err) => logger.warn({
|
||||
err,
|
||||
@@ -843,6 +856,7 @@ export function issueRoutes(
|
||||
const workProductsSvc = workProductService(db);
|
||||
const documentsSvc = documentService(db);
|
||||
const issueReferencesSvc = issueReferenceService(db);
|
||||
const issueThreadInteractionsSvc = issueThreadInteractionService(db);
|
||||
const routinesSvc = routineService(db, {
|
||||
pluginWorkerManager: opts.pluginWorkerManager,
|
||||
});
|
||||
@@ -857,6 +871,182 @@ export function issueRoutes(
|
||||
};
|
||||
const feedbackExportService = opts?.feedbackExportService;
|
||||
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) {
|
||||
return {
|
||||
...attachment,
|
||||
@@ -1240,6 +1430,51 @@ export function issueRoutes(
|
||||
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: {
|
||||
id: string;
|
||||
assigneeAgentId: string | null;
|
||||
@@ -1512,6 +1747,19 @@ export function issueRoutes(
|
||||
listSuccessfulRunHandoffStates(db, 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) => ({
|
||||
...issue,
|
||||
successfulRunHandoff: handoffStates.get(issue.id) ?? null,
|
||||
@@ -1668,6 +1916,12 @@ export function issueRoutes(
|
||||
relations,
|
||||
recoveryActionsByRelationIssue,
|
||||
);
|
||||
const revalidatedActiveRecoveryAction = await revalidateActiveSourceRecoveryForRead({
|
||||
issue,
|
||||
trigger: "read_projection",
|
||||
actor: getActorInfo(req),
|
||||
activeRecoveryAction,
|
||||
});
|
||||
|
||||
res.json({
|
||||
issue: {
|
||||
@@ -1680,7 +1934,7 @@ export function issueRoutes(
|
||||
...(blockerAttention ? { blockerAttention } : {}),
|
||||
productivityReview,
|
||||
scheduledRetry,
|
||||
activeRecoveryAction,
|
||||
activeRecoveryAction: revalidatedActiveRecoveryAction,
|
||||
priority: issue.priority,
|
||||
projectId: issue.projectId,
|
||||
goalId: goal?.id ?? issue.goalId,
|
||||
@@ -1786,6 +2040,12 @@ export function issueRoutes(
|
||||
relations,
|
||||
recoveryActionsByRelationIssue,
|
||||
);
|
||||
const revalidatedActiveRecoveryAction = await revalidateActiveSourceRecoveryForRead({
|
||||
issue,
|
||||
trigger: "read_projection",
|
||||
actor: getActorInfo(req),
|
||||
activeRecoveryAction,
|
||||
});
|
||||
const mentionedProjects = mentionedProjectIds.length > 0
|
||||
? await projectsSvc.listByIds(issue.companyId, mentionedProjectIds)
|
||||
: [];
|
||||
@@ -1801,7 +2061,7 @@ export function issueRoutes(
|
||||
productivityReview,
|
||||
successfulRunHandoff: successfulRunHandoffStates.get(issue.id) ?? null,
|
||||
scheduledRetry,
|
||||
activeRecoveryAction,
|
||||
activeRecoveryAction: revalidatedActiveRecoveryAction,
|
||||
blockedBy: relationsWithRecoveryActions.blockedBy,
|
||||
blocks: relationsWithRecoveryActions.blocks,
|
||||
relatedWork: referenceSummary,
|
||||
@@ -1823,7 +2083,11 @@ export function issueRoutes(
|
||||
return;
|
||||
}
|
||||
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({
|
||||
active,
|
||||
actions: active ? [active] : [],
|
||||
@@ -1839,6 +2103,18 @@ export function issueRoutes(
|
||||
}
|
||||
assertCompanyAccess(req, existing.companyId);
|
||||
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;
|
||||
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({
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -2274,6 +2587,13 @@ export function issueRoutes(
|
||||
source: "issue.document_restored",
|
||||
});
|
||||
|
||||
await revalidateActiveSourceRecoveryAfterCommittedWrite({
|
||||
issue,
|
||||
trigger: "document",
|
||||
actor,
|
||||
documentChanged: true,
|
||||
});
|
||||
|
||||
res.json(result.document);
|
||||
},
|
||||
);
|
||||
@@ -2344,6 +2664,12 @@ export function issueRoutes(
|
||||
actor,
|
||||
source: "issue.document_deleted",
|
||||
});
|
||||
await revalidateActiveSourceRecoveryAfterCommittedWrite({
|
||||
issue,
|
||||
trigger: "document",
|
||||
actor,
|
||||
documentChanged: true,
|
||||
});
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
@@ -2376,6 +2702,12 @@ export function issueRoutes(
|
||||
entityId: issue.id,
|
||||
details: { workProductId: product.id, type: product.type, provider: product.provider },
|
||||
});
|
||||
await revalidateActiveSourceRecoveryAfterCommittedWrite({
|
||||
issue,
|
||||
trigger: "work_product",
|
||||
actor,
|
||||
workProductChanged: true,
|
||||
});
|
||||
res.status(201).json(product);
|
||||
});
|
||||
|
||||
@@ -2410,6 +2742,12 @@ export function issueRoutes(
|
||||
entityId: existing.issueId,
|
||||
details: { workProductId: product.id, changedKeys: Object.keys(req.body).sort() },
|
||||
});
|
||||
await revalidateActiveSourceRecoveryAfterCommittedWrite({
|
||||
issue,
|
||||
trigger: "work_product",
|
||||
actor,
|
||||
workProductChanged: true,
|
||||
});
|
||||
res.json(product);
|
||||
});
|
||||
|
||||
@@ -2444,6 +2782,12 @@ export function issueRoutes(
|
||||
entityId: existing.issueId,
|
||||
details: { workProductId: removed.id, type: removed.type },
|
||||
});
|
||||
await revalidateActiveSourceRecoveryAfterCommittedWrite({
|
||||
issue,
|
||||
trigger: "work_product",
|
||||
actor,
|
||||
workProductChanged: true,
|
||||
});
|
||||
res.json(removed);
|
||||
});
|
||||
|
||||
@@ -2931,6 +3275,28 @@ export function issueRoutes(
|
||||
const requestedAssigneeAgentId =
|
||||
normalizedAssigneeAgentId === undefined ? existing.assigneeAgentId : normalizedAssigneeAgentId;
|
||||
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 =
|
||||
explicitMoveToTodoRequested ||
|
||||
(!!commentBody &&
|
||||
@@ -3207,6 +3573,7 @@ export function issueRoutes(
|
||||
let issueResponse: typeof issue & {
|
||||
blockedBy?: unknown;
|
||||
blocks?: unknown;
|
||||
activeRecoveryAction?: unknown;
|
||||
relatedWork?: Awaited<ReturnType<typeof issueReferencesSvc.listIssueReferenceSummary>>;
|
||||
referencedIssueIdentifiers?: string[];
|
||||
} = issue;
|
||||
@@ -3258,6 +3625,32 @@ export function issueRoutes(
|
||||
previous.status !== undefined &&
|
||||
issue.status === "todo";
|
||||
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, {
|
||||
companyId: issue.companyId,
|
||||
actorType: actor.actorType,
|
||||
@@ -3531,10 +3924,6 @@ export function issueRoutes(
|
||||
existing.status === "backlog" &&
|
||||
issue.status !== "backlog" &&
|
||||
req.body.status !== undefined;
|
||||
const statusChangedFromBlockedToTodo =
|
||||
existing.status === "blocked" &&
|
||||
issue.status === "todo" &&
|
||||
(req.body.status !== undefined || reopened);
|
||||
const statusChangedFromClosedToTodo =
|
||||
isClosedIssueStatus(existing.status) &&
|
||||
issue.status === "todo" &&
|
||||
@@ -4126,12 +4515,18 @@ export function issueRoutes(
|
||||
});
|
||||
}
|
||||
|
||||
const acceptedPlanConfirmation =
|
||||
interaction.kind === "request_confirmation" &&
|
||||
interaction.status === "accepted" &&
|
||||
issue.workMode === "planning";
|
||||
queueResolvedInteractionContinuationWakeup({
|
||||
heartbeat,
|
||||
issue: continuationWakeIssue,
|
||||
interaction,
|
||||
actor,
|
||||
source: "issue.interaction.accept",
|
||||
forceFreshSession: acceptedPlanConfirmation,
|
||||
workspaceRefreshReason: acceptedPlanConfirmation ? "accepted_plan_confirmation" : null,
|
||||
});
|
||||
|
||||
res.json(interaction);
|
||||
@@ -4630,6 +5025,16 @@ export function issueRoutes(
|
||||
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.
|
||||
void (async () => {
|
||||
const wakeups = new Map<string, Parameters<typeof heartbeat.wakeup>[1]>();
|
||||
|
||||
@@ -1000,7 +1000,7 @@ function redactInlineBase64ImageData(chunk: string) {
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
const headChars = Math.max(0, Math.floor(maxChars * 0.6));
|
||||
|
||||
@@ -73,7 +73,10 @@ import {
|
||||
issueTreeControlService,
|
||||
type ActiveIssueTreePauseHoldGate,
|
||||
} 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";
|
||||
|
||||
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]);
|
||||
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;
|
||||
};
|
||||
|
||||
|
||||
@@ -486,8 +486,12 @@ export async function writePluginLocalFolderTextAtomic(
|
||||
contents: string,
|
||||
) {
|
||||
const rootRealPath = await fs.realpath(rootPath);
|
||||
const resolved = await resolvePluginLocalFolderPath(rootPath, relativePath);
|
||||
await fs.mkdir(path.dirname(resolved.absolutePath), { recursive: true });
|
||||
const normalized = normalizeRelativePath(relativePath);
|
||||
const parentRelativePath = path.dirname(normalized);
|
||||
if (parentRelativePath !== ".") {
|
||||
await ensureDirectoryInsideRoot(rootRealPath, parentRelativePath);
|
||||
}
|
||||
const resolved = await resolvePluginLocalFolderPath(rootRealPath, normalized);
|
||||
await assertPathInsideRoot(rootRealPath, path.dirname(resolved.absolutePath));
|
||||
const tempPath = path.join(
|
||||
path.dirname(resolved.absolutePath),
|
||||
|
||||
@@ -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 {
|
||||
DEFAULT_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS,
|
||||
@@ -11,11 +11,12 @@ import {
|
||||
agents,
|
||||
agentWakeupRequests,
|
||||
approvals,
|
||||
activityLog,
|
||||
companies,
|
||||
issueComments,
|
||||
heartbeatRunEvents,
|
||||
heartbeatRunWatchdogDecisions,
|
||||
heartbeatRuns,
|
||||
issueComments,
|
||||
issueApprovals,
|
||||
issueRecoveryActions,
|
||||
issueRelations,
|
||||
@@ -26,6 +27,7 @@ import { parseObject, asBoolean, asNumber } from "../../adapters/utils.js";
|
||||
import { runningProcesses } from "../../adapters/index.js";
|
||||
import { forbidden, notFound } from "../../errors.js";
|
||||
import { logger } from "../../middleware/logger.js";
|
||||
import { isPidAlive, isProcessGroupAlive, terminateLocalService } from "../local-service-supervisor.js";
|
||||
import { redactCurrentUserText } from "../../log-redaction.js";
|
||||
import { redactSensitiveText } from "../../redaction.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 STALE_ACTIVE_RUN_EVALUATION_ORIGIN_KIND = RECOVERY_ORIGIN_KINDS.staleActiveRunEvaluation;
|
||||
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 = {
|
||||
source?: "timer" | "assignment" | "on_demand" | "automation";
|
||||
@@ -673,6 +684,16 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
||||
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">) {
|
||||
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;
|
||||
}
|
||||
|
||||
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: {
|
||||
run: typeof heartbeatRuns.$inferSelect;
|
||||
runningAgent: typeof agents.$inferSelect;
|
||||
@@ -1030,6 +1354,47 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
||||
const runningAgent = await getAgent(input.run.agentId);
|
||||
if (!runningAgent || runningAgent.companyId !== input.run.companyId) return { kind: "skipped" as const };
|
||||
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 evidence = await collectStaleRunEvidence({
|
||||
run: input.run,
|
||||
@@ -1039,7 +1404,6 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
||||
now: input.now,
|
||||
});
|
||||
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 (level === "critical" && existing.priority !== "high") {
|
||||
await issuesSvc.update(existing.id, {
|
||||
@@ -1174,6 +1538,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
||||
created: 0,
|
||||
existing: 0,
|
||||
escalated: 0,
|
||||
folded: 0,
|
||||
snoozed: 0,
|
||||
skipped: 0,
|
||||
evaluationIssueIds: [] as string[],
|
||||
@@ -1188,6 +1553,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
||||
if (outcome.kind === "created") result.created += 1;
|
||||
else if (outcome.kind === "existing") result.existing += 1;
|
||||
else if (outcome.kind === "escalated") result.escalated += 1;
|
||||
else if (outcome.kind === "folded") result.folded += 1;
|
||||
else result.skipped += 1;
|
||||
if ("evaluationIssueId" in outcome && 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) {
|
||||
const parsed = parseIssueGraphLivenessIncidentKey(row.originId);
|
||||
if (!parsed || parsed.companyId !== row.companyId) return [];
|
||||
if (parsed.state !== "blocked_by_assigned_backlog_issue") return [];
|
||||
return [
|
||||
{
|
||||
companyId: row.companyId,
|
||||
@@ -2575,6 +2940,21 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
||||
) {
|
||||
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)) {
|
||||
result.blockerRelationsRemoved += 1;
|
||||
}
|
||||
|
||||
@@ -131,7 +131,7 @@ export const issuesApi = {
|
||||
data: {
|
||||
actionId?: string;
|
||||
outcome: "restored" | "false_positive" | "blocked" | "cancelled";
|
||||
sourceIssueStatus: "done" | "in_review" | "blocked";
|
||||
sourceIssueStatus: "todo" | "done" | "in_review" | "blocked";
|
||||
resolutionNote?: string | null;
|
||||
},
|
||||
) => 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 {
|
||||
return {
|
||||
id: "issue-1",
|
||||
@@ -476,6 +492,60 @@ describe("IssueProperties", () => {
|
||||
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 () => {
|
||||
const onUpdate = vi.fn();
|
||||
const root = renderProperties(container, {
|
||||
|
||||
@@ -145,6 +145,8 @@ interface IssuePropertiesProps {
|
||||
inline?: boolean;
|
||||
}
|
||||
|
||||
const ISSUE_BLOCKER_SEARCH_LIMIT = 50;
|
||||
|
||||
function PropertyRow({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<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 [monitorNotesInput, setMonitorNotesInput] = useState(issue.executionPolicy?.monitor?.notes ?? "");
|
||||
const [monitorServiceInput, setMonitorServiceInput] = useState(issue.executionPolicy?.monitor?.serviceName ?? "");
|
||||
const normalizedBlockedBySearch = blockedBySearch.trim();
|
||||
|
||||
const { data: session } = useQuery({
|
||||
queryKey: queryKeys.auth.session,
|
||||
@@ -443,10 +446,21 @@ export function IssueProperties({
|
||||
enabled: !!companyId,
|
||||
});
|
||||
|
||||
const { data: allIssues } = useQuery({
|
||||
const { data: allIssues, isFetching: isFetchingIssuePickerIssues } = useQuery({
|
||||
queryKey: queryKeys.issues.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({
|
||||
@@ -1648,27 +1662,28 @@ export function IssueProperties({
|
||||
</>
|
||||
);
|
||||
const blockingIssues = issue.blocks ?? [];
|
||||
const blockerOptions = (allIssues ?? [])
|
||||
.filter((candidate) => candidate.id !== issue.id)
|
||||
.filter((candidate) => {
|
||||
if (!blockedBySearch.trim()) return true;
|
||||
const query = blockedBySearch.toLowerCase();
|
||||
return (
|
||||
(candidate.identifier ?? "").toLowerCase().includes(query) ||
|
||||
candidate.title.toLowerCase().includes(query)
|
||||
);
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const blockerSearchActive = normalizedBlockedBySearch.length > 0;
|
||||
const blockerSourceIssues = blockerSearchActive ? searchedBlockedByIssues : allIssues;
|
||||
const blockerOptions = (blockerSourceIssues ?? [])
|
||||
.filter((candidate) => candidate.id !== issue.id);
|
||||
if (!blockerSearchActive) {
|
||||
blockerOptions.sort((a, b) => {
|
||||
const aLabel = `${a.identifier ?? ""} ${a.title}`.trim();
|
||||
const bLabel = `${b.identifier ?? ""} ${b.title}`.trim();
|
||||
return aLabel.localeCompare(bLabel);
|
||||
});
|
||||
}
|
||||
const blockerOptionsLoading = blockedByOpen && (
|
||||
blockerSearchActive ? isFetchingSearchedBlockedByIssues : isFetchingIssuePickerIssues
|
||||
);
|
||||
|
||||
const toggleBlockedBy = (blockedByIssueId: string) => {
|
||||
const nextBlockedByIds = blockedByIds.includes(blockedByIssueId)
|
||||
? blockedByIds.filter((candidate) => candidate !== blockedByIssueId)
|
||||
: [...blockedByIds, blockedByIssueId];
|
||||
onUpdate({ blockedByIssueIds: nextBlockedByIds });
|
||||
setBlockedByOpen(false);
|
||||
setBlockedBySearch("");
|
||||
};
|
||||
const removeBlockedBy = (blockedByIssueId: string) => {
|
||||
onUpdate({ blockedByIssueIds: blockedByIds.filter((candidate) => candidate !== blockedByIssueId) });
|
||||
@@ -1682,6 +1697,7 @@ export function IssueProperties({
|
||||
value={blockedBySearch}
|
||||
onChange={(e) => setBlockedBySearch(e.target.value)}
|
||||
autoFocus={!inline}
|
||||
aria-label="Search issues to add as blockers"
|
||||
/>
|
||||
<div className="max-h-48 overflow-y-auto overscroll-contain">
|
||||
<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",
|
||||
blockedByIds.length === 0 && "bg-accent",
|
||||
)}
|
||||
onClick={() => onUpdate({ blockedByIssueIds: [] })}
|
||||
onClick={() => {
|
||||
onUpdate({ blockedByIssueIds: [] });
|
||||
setBlockedByOpen(false);
|
||||
setBlockedBySearch("");
|
||||
}}
|
||||
>
|
||||
No blockers
|
||||
</button>
|
||||
@@ -1709,9 +1729,15 @@ export function IssueProperties({
|
||||
{candidate.identifier ? `${candidate.identifier} ` : ""}
|
||||
{candidate.title}
|
||||
</span>
|
||||
{selected && <Check className="ml-auto h-3.5 w-3.5 shrink-0 text-foreground" aria-hidden="true" />}
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -165,19 +165,20 @@ describe("IssueRecoveryActionCard", () => {
|
||||
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 node = render(
|
||||
<IssueRecoveryActionCard action={buildAction()} onResolve={onResolve} />,
|
||||
);
|
||||
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).not.toContain("Mark blocked");
|
||||
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", () => {
|
||||
@@ -186,6 +187,7 @@ describe("IssueRecoveryActionCard", () => {
|
||||
);
|
||||
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("Send for review");
|
||||
expect(document.body.textContent).toContain("False positive, done");
|
||||
|
||||
@@ -25,6 +25,7 @@ export type RecoveryCardCardState = RecoveryDisplayState;
|
||||
export const deriveRecoveryCardState = deriveRecoveryDisplayState;
|
||||
|
||||
export type RecoveryResolveOutcome =
|
||||
| "todo"
|
||||
| "done"
|
||||
| "in_review"
|
||||
| "false_positive_done"
|
||||
@@ -292,6 +293,11 @@ const RESOLVE_OPTIONS: Array<{
|
||||
destructive?: boolean;
|
||||
boardOnly?: boolean;
|
||||
}> = [
|
||||
{
|
||||
outcome: "todo",
|
||||
label: "Try again",
|
||||
description: "Dismiss recovery and return the source issue to todo.",
|
||||
},
|
||||
{
|
||||
outcome: "done",
|
||||
label: "Mark issue done",
|
||||
|
||||
@@ -238,6 +238,7 @@ describe("IssueRow", () => {
|
||||
const link = container.querySelector("[data-inbox-issue-link]") as HTMLAnchorElement | null;
|
||||
expect(link).not.toBeNull();
|
||||
expect(link?.textContent).toContain("Planning");
|
||||
expect(link?.textContent?.match(/Planning/g)).toHaveLength(1);
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
|
||||
@@ -126,7 +126,6 @@ export function IssueRow({
|
||||
<span className="flex shrink-0 items-center gap-1 pt-px sm:hidden">
|
||||
{mobileLeading ?? <StatusIcon status={issue.status} blockerAttention={issue.blockerAttention} className={selectedStatusClass} />}
|
||||
{productivityReviewIndicator}
|
||||
{planningModeIndicator}
|
||||
{parkedBlockerIndicator}
|
||||
{recoveryIndicator}
|
||||
</span>
|
||||
@@ -153,11 +152,11 @@ export function IssueRow({
|
||||
<span className="shrink-0 font-mono text-xs text-muted-foreground">
|
||||
{identifier}
|
||||
</span>
|
||||
{planningModeIndicator}
|
||||
{parkedBlockerIndicator}
|
||||
{recoveryIndicator}
|
||||
</>
|
||||
)}
|
||||
{planningModeIndicator}
|
||||
{mobileMeta ? (
|
||||
<>
|
||||
<span className="text-xs text-muted-foreground sm:hidden" aria-hidden="true">
|
||||
|
||||
@@ -16,6 +16,8 @@ import { cn, relativeTime } from "../lib/utils";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { keepPreviousDataForSameQueryTail } from "../lib/query-placeholder-data";
|
||||
import { describeRunRetryState } from "../lib/runRetryState";
|
||||
import { readSourceResolvedWatchdogFold } from "../lib/source-resolved-watchdog-fold";
|
||||
import { SourceResolvedFoldBadge } from "./SourceResolvedFoldBadge";
|
||||
|
||||
type IssueRunLedgerProps = {
|
||||
issueId: string;
|
||||
@@ -693,6 +695,7 @@ export function IssueRunLedgerContent({
|
||||
const continuation = continuationLabel(run);
|
||||
const retryState = describeRunRetryState(run);
|
||||
const agentName = compactAgentName(run, agentMap);
|
||||
const sourceResolvedFold = readSourceResolvedWatchdogFold(run.resultJson);
|
||||
return (
|
||||
<article
|
||||
key={`run:${run.runId}`}
|
||||
@@ -773,6 +776,7 @@ export function IssueRunLedgerContent({
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
{sourceResolvedFold ? <SourceResolvedFoldBadge /> : null}
|
||||
<span className="ml-auto shrink-0">{relativeTime(item.timestamp)}</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1250,7 +1250,9 @@ export function IssuesList({
|
||||
}
|
||||
else if (viewState.groupBy === "project" && groupKey !== "__no_project") defaults.projectId = groupKey;
|
||||
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);
|
||||
if (executionWorkspace) {
|
||||
defaults.executionWorkspaceId = groupKey;
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
buildAgentMentionHref,
|
||||
buildIssueReferenceHref,
|
||||
buildProjectMentionHref,
|
||||
buildRoutineMentionHref,
|
||||
buildSkillMentionHref,
|
||||
buildUserMentionHref,
|
||||
} from "@paperclipai/shared";
|
||||
@@ -92,12 +93,12 @@ describe("MarkdownBody", () => {
|
||||
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(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<ThemeProvider>
|
||||
<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>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>,
|
||||
@@ -113,6 +114,8 @@ describe("MarkdownBody", () => {
|
||||
expect(html).toContain("--paperclip-mention-project-color:#336699");
|
||||
expect(html).toContain('href="/skills/skill-789"');
|
||||
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", () => {
|
||||
|
||||
@@ -588,6 +588,8 @@ export function MarkdownBody({
|
||||
? `/issues/${parsed.identifier}`
|
||||
: parsed.kind === "skill"
|
||||
? `/skills/${parsed.skillId}`
|
||||
: parsed.kind === "routine"
|
||||
? `/routines/${parsed.routineId}`
|
||||
: parsed.kind === "user"
|
||||
? "/company/settings/access"
|
||||
: `/agents/${parsed.agentId}`;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { buildProjectMentionHref, buildSkillMentionHref } from "@paperclipai/shared";
|
||||
import { buildProjectMentionHref, buildRoutineMentionHref, buildSkillMentionHref } from "@paperclipai/shared";
|
||||
import {
|
||||
computeMentionMenuPosition,
|
||||
findClosestAutocompleteAnchor,
|
||||
@@ -553,6 +553,16 @@ describe("MarkdownEditor", () => {
|
||||
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", () => {
|
||||
expect(shouldAcceptAutocompleteKey("Enter", "skill")).toBe(false);
|
||||
expect(shouldAcceptAutocompleteKey("Enter", "skill", true)).toBe(true);
|
||||
@@ -623,6 +633,26 @@ describe("MarkdownEditor", () => {
|
||||
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", () => {
|
||||
const editable = document.createElement("div");
|
||||
editable.contentEditable = "true";
|
||||
|
||||
@@ -31,8 +31,13 @@ import {
|
||||
thematicBreakPlugin,
|
||||
type RealmPlugin,
|
||||
} from "@mdxeditor/editor";
|
||||
import { buildAgentMentionHref, buildProjectMentionHref, buildUserMentionHref } from "@paperclipai/shared";
|
||||
import { Boxes, User } from "lucide-react";
|
||||
import {
|
||||
buildAgentMentionHref,
|
||||
buildProjectMentionHref,
|
||||
buildRoutineMentionHref,
|
||||
buildUserMentionHref,
|
||||
} from "@paperclipai/shared";
|
||||
import { Boxes, CalendarClock, User } from "lucide-react";
|
||||
import { AgentIcon } from "./AgentIconPicker";
|
||||
import { applyMentionChipDecoration, clearMentionChipDecoration, parseMentionChipHref } from "../lib/mention-chips";
|
||||
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 { pasteNormalizationPlugin } from "../lib/paste-normalization";
|
||||
import { cn } from "../lib/utils";
|
||||
import { useEditorAutocomplete, type SkillCommandOption } from "../context/EditorAutocompleteContext";
|
||||
import { useEditorAutocomplete, type SlashCommandOption } from "../context/EditorAutocompleteContext";
|
||||
|
||||
/* ---- Mention types ---- */
|
||||
|
||||
@@ -188,7 +193,7 @@ interface MentionState {
|
||||
endPos: number;
|
||||
}
|
||||
|
||||
type AutocompleteOption = MentionOption | SkillCommandOption;
|
||||
type AutocompleteOption = MentionOption | SlashCommandOption;
|
||||
|
||||
interface MentionMenuViewport {
|
||||
offsetLeft: number;
|
||||
@@ -260,7 +265,9 @@ export function findMentionMatch(
|
||||
|
||||
if (atPos === -1) return null;
|
||||
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 {
|
||||
trigger: trigger ?? "mention",
|
||||
@@ -423,12 +430,21 @@ function mentionMarkdown(option: MentionOption): string {
|
||||
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}) `;
|
||||
}
|
||||
|
||||
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(
|
||||
@@ -461,6 +477,9 @@ function autocompleteOptionMatchesLink(option: AutocompleteOption, href: string)
|
||||
if (option.kind === "skill") {
|
||||
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) {
|
||||
return parsed.kind === "project" && parsed.projectId === option.projectId;
|
||||
@@ -785,7 +804,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||
continue;
|
||||
}
|
||||
|
||||
if (parsed.kind === "skill") {
|
||||
if (parsed.kind === "skill" || parsed.kind === "routine") {
|
||||
applyMentionChipDecoration(link, parsed);
|
||||
continue;
|
||||
}
|
||||
@@ -1256,7 +1275,9 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||
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" />
|
||||
) : option.kind === "project" && option.projectId ? (
|
||||
<span
|
||||
@@ -1271,7 +1292,11 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||
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 && (
|
||||
<span className="ml-auto text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
Project
|
||||
@@ -1287,6 +1312,11 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||
Skill
|
||||
</span>
|
||||
)}
|
||||
{option.kind === "routine" && (
|
||||
<span className="ml-auto text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
Routine
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>,
|
||||
|
||||
@@ -134,7 +134,7 @@ export function RoutineListRow<TRoutine extends RoutineListRowItem>({
|
||||
<div className="flex items-center gap-3" onClick={(event) => { event.preventDefault(); event.stopPropagation(); }}>
|
||||
{runNowButton ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={runDisabled}
|
||||
onClick={() => onRunNow(routine)}
|
||||
|
||||
@@ -70,6 +70,12 @@ vi.mock("@/plugins/slots", () => ({
|
||||
PluginSlotOutlet: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/plugins/launchers", () => ({
|
||||
PluginLauncherOutlet: ({ placementZones }: { placementZones: string[] }) => (
|
||||
<div data-plugin-launcher-zone={placementZones.join(",")}>Plugin launcher outlet</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("./SidebarCompanyMenu", () => ({
|
||||
SidebarCompanyMenu: () => <div>Company menu</div>,
|
||||
}));
|
||||
@@ -129,7 +135,7 @@ describe("Sidebar", () => {
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false });
|
||||
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");
|
||||
const workLinks = [...container.querySelectorAll("nav a")].map((anchor) => anchor.textContent?.trim());
|
||||
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 () => {
|
||||
mockInstanceSettingsApi.getExperimental.mockImplementation(() => new Promise(() => {}));
|
||||
const root = await renderSidebar();
|
||||
|
||||
@@ -27,6 +27,7 @@ import { queryKeys } from "../lib/queryKeys";
|
||||
import { useInboxBadge } from "../hooks/useInboxBadge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PluginSlotOutlet } from "@/plugins/slots";
|
||||
import { PluginLauncherOutlet } from "@/plugins/launchers";
|
||||
import { SidebarCompanyMenu } from "./SidebarCompanyMenu";
|
||||
|
||||
export function Sidebar() {
|
||||
@@ -61,8 +62,8 @@ export function Sidebar() {
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground shrink-0"
|
||||
aria-label="Search"
|
||||
title="Search"
|
||||
aria-label="Open search"
|
||||
title="Open search"
|
||||
>
|
||||
<NavLink to="/search">
|
||||
<Search className="h-4 w-4" />
|
||||
@@ -101,6 +102,12 @@ export function Sidebar() {
|
||||
<SidebarSection label="Work">
|
||||
<SidebarNavItem to="/issues" label="Issues" icon={CircleDot} />
|
||||
<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} />
|
||||
{showWorkspacesLink ? (
|
||||
<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("jane@example.com");
|
||||
expect(document.body.querySelector('[data-slot="popover-content"]')?.className)
|
||||
.toContain("w-[var(--radix-popover-trigger-width)]");
|
||||
.toContain("w-[277px]");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
|
||||
@@ -160,7 +160,7 @@ export function SidebarAccountMenu({
|
||||
side="top"
|
||||
align="start"
|
||||
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="-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;
|
||||
@@ -59,7 +59,7 @@ function DialogContent({
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { createContext, useContext, useMemo, type ReactNode } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { buildSkillMentionHref } from "@paperclipai/shared";
|
||||
import { buildRoutineMentionHref, buildSkillMentionHref } from "@paperclipai/shared";
|
||||
import { companySkillsApi } from "../api/companySkills";
|
||||
import { routinesApi } from "../api/routines";
|
||||
import { useCompany } from "./CompanyContext";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
|
||||
@@ -17,8 +18,20 @@ export interface SkillCommandOption {
|
||||
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 {
|
||||
slashCommands: SkillCommandOption[];
|
||||
slashCommands: SlashCommandOption[];
|
||||
}
|
||||
|
||||
const EditorAutocompleteContext = createContext<EditorAutocompleteContextValue>({
|
||||
@@ -34,11 +47,19 @@ export function EditorAutocompleteProvider({ children }: { children: ReactNode }
|
||||
queryFn: () => companySkillsApi.list(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>(() => ({
|
||||
slashCommands: companySkills.map((skill) => ({
|
||||
slashCommands: [
|
||||
...companySkills.map((skill) => ({
|
||||
id: `skill:${skill.id}`,
|
||||
kind: "skill",
|
||||
kind: "skill" as const,
|
||||
skillId: skill.id,
|
||||
key: skill.key,
|
||||
name: skill.name,
|
||||
@@ -47,7 +68,20 @@ export function EditorAutocompleteProvider({ children }: { children: ReactNode }
|
||||
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 (
|
||||
<EditorAutocompleteContext.Provider value={value}>
|
||||
|
||||
@@ -61,6 +61,8 @@ const ACTIVITY_ROW_VERBS: Record<string, string> = {
|
||||
"agent.runtime_session_reset": "reset session for",
|
||||
"heartbeat.invoked": "invoked 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.approved": "approved",
|
||||
"approval.rejected": "rejected",
|
||||
@@ -115,6 +117,8 @@ const ISSUE_ACTIVITY_LABELS: Record<string, string> = {
|
||||
"agent.terminated": "terminated the agent",
|
||||
"heartbeat.invoked": "invoked 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.approved": "approved",
|
||||
"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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
parseAgentMentionHref,
|
||||
parseIssueReferenceHref,
|
||||
parseProjectMentionHref,
|
||||
parseRoutineMentionHref,
|
||||
parseSkillMentionHref,
|
||||
parseUserMentionHref,
|
||||
} from "@paperclipai/shared";
|
||||
@@ -32,6 +33,10 @@ export type ParsedMentionChip =
|
||||
kind: "skill";
|
||||
skillId: string;
|
||||
slug: string | null;
|
||||
}
|
||||
| {
|
||||
kind: "routine";
|
||||
routineId: 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;
|
||||
}
|
||||
|
||||
@@ -135,6 +148,7 @@ export function clearMentionChipDecoration(element: HTMLElement) {
|
||||
"paperclip-mention-chip--agent",
|
||||
"paperclip-mention-chip--issue",
|
||||
"paperclip-mention-chip--project",
|
||||
"paperclip-mention-chip--routine",
|
||||
"paperclip-mention-chip--user",
|
||||
"paperclip-mention-chip--skill",
|
||||
"paperclip-project-mention-chip",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -42,9 +42,13 @@ import { RunButton, PauseResumeButton } from "../components/AgentActionButtons";
|
||||
import { BudgetPolicyCard } from "../components/BudgetPolicyCard";
|
||||
import { FileTree, buildFileTree } from "../components/FileTree";
|
||||
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 { cn } from "../lib/utils";
|
||||
import { describeRunRetryState } from "../lib/runRetryState";
|
||||
import { buildDuplicateAgentPayload, duplicateAgentName, type DuplicateInstructionsBundle } from "../lib/duplicate-agent-payload";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Tabs } from "@/components/ui/tabs";
|
||||
@@ -83,6 +87,8 @@ import { RunTranscriptView, type TranscriptMode } from "../components/transcript
|
||||
import {
|
||||
isUuidLike,
|
||||
type Agent,
|
||||
type AgentInstructionsBundle,
|
||||
type AgentInstructionsFileSummary,
|
||||
type AgentSkillEntry,
|
||||
type AgentSkillSnapshot,
|
||||
type AgentDetail as AgentDetailRecord,
|
||||
@@ -101,6 +107,34 @@ import {
|
||||
isReadOnlyUnmanagedSkillEntry,
|
||||
} 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 }> = {
|
||||
succeeded: { icon: CheckCircle2, color: "text-green-600 dark:text-green-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 { closePanel } = usePanel();
|
||||
const { openNewIssue } = useDialogActions();
|
||||
const { pushToast } = useToastActions();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const queryClient = useQueryClient();
|
||||
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({
|
||||
mutationFn: (amount: number) =>
|
||||
budgetsApi.upsertPolicy(resolvedCompanyId!, {
|
||||
@@ -977,6 +1063,18 @@ export function AgentDetail() {
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<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
|
||||
className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50"
|
||||
onClick={() => {
|
||||
@@ -2899,6 +2997,7 @@ function RunListItem({ run, isSelected, agentId }: { run: HeartbeatRun; isSelect
|
||||
const summary = run.resultJson
|
||||
? String((run.resultJson as Record<string, unknown>).summary ?? (run.resultJson as Record<string, unknown>).result ?? "")
|
||||
: run.error ?? "";
|
||||
const sourceResolvedFold = readSourceResolvedWatchdogFold(run.resultJson);
|
||||
|
||||
return (
|
||||
<Link
|
||||
@@ -2922,6 +3021,7 @@ function RunListItem({ run, isSelected, agentId }: { run: HeartbeatRun; isSelect
|
||||
)}>
|
||||
{sourceLabels[run.invocationSource] ?? run.invocationSource}
|
||||
</span>
|
||||
{sourceResolvedFold ? <SourceResolvedFoldBadge showIcon={false} className="shrink-0 text-[10px] py-0" /> : null}
|
||||
<span className="ml-auto text-[11px] text-muted-foreground shrink-0">
|
||||
{relativeTime(run.createdAt)}
|
||||
</span>
|
||||
@@ -3474,6 +3574,13 @@ function RunDetail({ run: initialRun, agentRouteId, adapterType, adapterConfig }
|
||||
</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 */}
|
||||
<LogViewer run={run} adapterType={adapterType} />
|
||||
<ScrollToBottom />
|
||||
|
||||
@@ -1770,7 +1770,7 @@ export function IssueDetail() {
|
||||
mutationFn: (data: {
|
||||
actionId?: string;
|
||||
outcome: ResolveRecoveryActionOutcome;
|
||||
sourceIssueStatus: "done" | "in_review" | "blocked";
|
||||
sourceIssueStatus: "todo" | "done" | "in_review" | "blocked";
|
||||
resolutionNote?: string | null;
|
||||
}) => issuesApi.resolveRecoveryAction(issueId!, data),
|
||||
onSuccess: ({ issue: nextIssue }) => {
|
||||
@@ -3000,6 +3000,9 @@ export function IssueDetail() {
|
||||
const actionId = activeRecoveryActionId;
|
||||
if (!actionId) return;
|
||||
switch (outcome) {
|
||||
case "todo":
|
||||
void resolveRecoveryAction.mutateAsync({ actionId, outcome: "restored", sourceIssueStatus: "todo" });
|
||||
return;
|
||||
case "done":
|
||||
void resolveRecoveryAction.mutateAsync({ actionId, outcome: "restored", sourceIssueStatus: "done" });
|
||||
return;
|
||||
|
||||
@@ -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" })]);
|
||||
issuesListMock.mockResolvedValue([]);
|
||||
|
||||
@@ -489,6 +489,7 @@ describe("Routines page", () => {
|
||||
}
|
||||
|
||||
expect(runNowButton).toBeTruthy();
|
||||
expect(runNowButton?.getAttribute("data-variant")).toBe("outline");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
|
||||
@@ -158,6 +158,32 @@ function focusFirstElement(container: HTMLElement | null): void {
|
||||
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 {
|
||||
if (event.key !== "Tab") return;
|
||||
const focusable = Array.from(
|
||||
@@ -652,13 +678,13 @@ export function PluginLauncherProvider({ children }: { children: ReactNode }) {
|
||||
) => {
|
||||
switch (launcher.action.type) {
|
||||
case "navigate":
|
||||
navigate(launcher.action.target);
|
||||
navigate(resolveLauncherNavigationTarget(launcher.action.target, hostContext));
|
||||
return;
|
||||
case "deepLink":
|
||||
if (/^https?:\/\//.test(launcher.action.target)) {
|
||||
window.open(launcher.action.target, "_blank", "noopener,noreferrer");
|
||||
} else {
|
||||
navigate(launcher.action.target);
|
||||
navigate(resolveLauncherNavigationTarget(launcher.action.target, hostContext));
|
||||
}
|
||||
return;
|
||||
case "performAction":
|
||||
@@ -725,10 +751,12 @@ export function usePluginLauncherRuntime(): PluginLauncherRuntimeContextValue {
|
||||
}
|
||||
|
||||
function DefaultLauncherTrigger({
|
||||
displayName,
|
||||
launcher,
|
||||
placementZone,
|
||||
onClick,
|
||||
}: {
|
||||
displayName?: string;
|
||||
launcher: ResolvedPluginLauncher;
|
||||
placementZone: PluginLauncherPlacementZone;
|
||||
onClick: (event: ReactMouseEvent<HTMLButtonElement>) => void;
|
||||
@@ -741,7 +769,7 @@ function DefaultLauncherTrigger({
|
||||
className={launcherTriggerClassName(placementZone)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{launcher.displayName}
|
||||
{displayName ?? launcher.displayName}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -786,6 +814,7 @@ export function PluginLauncherOutlet({
|
||||
{launchers.map((launcher) => (
|
||||
<div key={`${launcher.pluginKey}:${launcher.id}`} className={itemClassName}>
|
||||
<DefaultLauncherTrigger
|
||||
displayName={launcherDisplayName(launcher, contributionsByPluginId.get(launcher.pluginId))}
|
||||
launcher={launcher}
|
||||
placementZone={launcher.placementZone}
|
||||
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>
|
||||
),
|
||||
};
|
||||
Reference in New Issue
Block a user