Clean up experimental settings features (#12681)
## Thinking Path > - Paperclip is the open source app that people use to manage AI agents for work. > - Instance settings control optional product features and developer tools. > - The experimental settings page mixed active experiments, internal tools, and old recovery controls. > - Some workspace links also used the selected company instead of the workspace owner. > - These problems made settings hard to scan and could send users to the wrong company route. > - This pull request removes old controls, groups developer settings, and resolves workspace links from workspace data. > - The benefit is a smaller settings surface and correct workspace navigation. ## Linked Issues or Issue Description **What existing behavior does this improve?** This improves the instance experimental settings page, task watchdog controls, dependency wake recovery, and execution workspace routes. **Current behavior** The settings page shows old recovery controls and mixes product experiments with internal developer settings. Task watchdogs require an extra feature flag. Some direct workspace links use the current company prefix instead of the company that owns the workspace. **Proposed behavior** Remove the old task recovery experiment and its unused API surface. Make task watchdog controls available without the removed flag. Put worktree execution and managed environment controls in the developer section. Resolve direct workspace links from the workspace owner and reject a company prefix that does not own the workspace. **Reason and benefit** The smaller settings page is easier to understand. The server keeps only the dependency wake backstop that it still uses. Workspace links open under the correct company route. **Breaking changes** This removes the experimental issue graph recovery preview and run endpoints. It also removes the task watchdog feature flag. Task watchdog data and dependency wake behavior remain available. ## What Changed - Removed the old task watchdog and issue graph recovery feature flags. - Removed the old issue graph recovery preview, run controls, API contracts, and unused recovery implementation. - Kept resolved dependency wakes as the scheduler backstop. - Grouped product experiments and Paperclip developer settings on the instance settings page. - Made task watchdog controls available without an extra experimental flag. - Added owner-aware redirects and company checks for execution workspace routes. - Hid the false stopped-state badge while a workspace has no active runtime state. - Updated focused server and UI tests for the new behavior. ## Verification - `pnpm check:token-gates` - `pnpm -r typecheck` - `pnpm build` - `pnpm test:run` completed with 5,620 passing tests and four failures in unchanged workspace runtime port tests. The same four failures repeat when the two files run alone. - The complete GitHub CI matrix passed, including all server, serialized server, build, canary, and end-to-end jobs. ## Risks - Clients that call the removed experimental recovery endpoints must stop calling them. - The route checks depend on workspace detail access. An unknown or cross-company workspace returns the global not-found page. - There are no database migrations, lockfile changes, workflow changes, or design image changes. > 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 with GPT-5. The exact deployment ID and context window are not exposed. Reasoning, tool use, and code execution were enabled. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
24a674f885
commit
141f202e40
|
|
@ -802,8 +802,6 @@ An upgrade may encounter an already-active agent-owned recovery action. Papercli
|
|||
|
||||
Create an issue-backed recovery action only when a separate issue is the right execution object. In that fallback form, the source issue remains visible and is blocked on the recovery issue when blocking is necessary for correctness. The recovery owner must restore a live path, resolve the source issue manually, delegate real follow-up work, or record the reason the signal is a false positive.
|
||||
|
||||
Instance-level issue-graph liveness auto-recovery is disabled by default. When enabled, its lookback window means "dependency paths updated within the last N hours"; older findings remain advisory and are counted as outside the configured lookback instead of creating recovery actions automatically. This is an operator noise control, not the older staleness delay for determining whether a chain is old enough to surface.
|
||||
|
||||
### Human Escalation
|
||||
|
||||
Human escalation is required when the next safe action depends on board judgment, budget/approval policy, or information unavailable to the control plane.
|
||||
|
|
|
|||
|
|
@ -120,16 +120,8 @@ export const INSTANCE_FEATURE_CATALOG: Record<InstanceFeatureKey, FeatureCatalog
|
|||
cloudDefault: false,
|
||||
selfHostedDefault: false,
|
||||
},
|
||||
enableTaskWatchdogs: {
|
||||
title: "Task Watchdogs",
|
||||
description:
|
||||
"Show task detail controls for configuring watchdog agents that verify stopped task subtrees and restore live paths when work should continue.",
|
||||
tier: "managed",
|
||||
cloudDefault: false,
|
||||
selfHostedDefault: false,
|
||||
},
|
||||
enableIssuePlanDecompositions: {
|
||||
title: "Task Plan Decomposition Panel",
|
||||
title: "Task Plan Decomposition",
|
||||
description: "Show accepted-plan decomposition history on task detail pages.",
|
||||
tier: "managed",
|
||||
cloudDefault: false,
|
||||
|
|
@ -237,14 +229,6 @@ export const INSTANCE_FEATURE_CATALOG: Record<InstanceFeatureKey, FeatureCatalog
|
|||
cloudDefault: false,
|
||||
selfHostedDefault: false,
|
||||
},
|
||||
enableIssueGraphLivenessAutoRecovery: {
|
||||
title: "Auto-Create Recovery Tasks",
|
||||
description:
|
||||
"Let the heartbeat scheduler create recovery tasks for task dependency chains found inside the configured lookback window.",
|
||||
tier: "managed",
|
||||
cloudDefault: false,
|
||||
selfHostedDefault: false,
|
||||
},
|
||||
enableWorkspaceBranchReconcileForward: {
|
||||
title: "Workspace Branch Reconcile Forward",
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -892,8 +892,6 @@ export type {
|
|||
InstanceSettings,
|
||||
ManagedExperimentalFeatureKey,
|
||||
ManagedSettingMetadata,
|
||||
IssueGraphLivenessAutoRecoveryPreview,
|
||||
IssueGraphLivenessAutoRecoveryPreviewItem,
|
||||
BackupRetentionPolicy,
|
||||
Agent,
|
||||
AgentAccessState,
|
||||
|
|
@ -1726,9 +1724,6 @@ export {
|
|||
WEEKLY_RETENTION_PRESETS,
|
||||
MONTHLY_RETENTION_PRESETS,
|
||||
DEFAULT_BACKUP_RETENTION,
|
||||
DEFAULT_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS,
|
||||
MIN_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS,
|
||||
MAX_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS,
|
||||
PAPERCLIP_CLOUD_MANAGED_BY,
|
||||
} from "./types/instance.js";
|
||||
|
||||
|
|
@ -1772,7 +1767,6 @@ export {
|
|||
managedSettingMetadataSchema,
|
||||
patchInstanceExperimentalSettingsSchema,
|
||||
patchInstanceSettingsSchema,
|
||||
issueGraphLivenessAutoRecoveryRequestSchema,
|
||||
createSmokeRunSchema,
|
||||
updateSmokeRunSchema,
|
||||
recordSmokeRunStepSchema,
|
||||
|
|
@ -1788,7 +1782,6 @@ export {
|
|||
trustAuthorizationPolicySchema,
|
||||
type PatchInstanceExperimentalSettings,
|
||||
type PatchInstanceSettings,
|
||||
type IssueGraphLivenessAutoRecoveryRequest,
|
||||
type CreateSmokeRun,
|
||||
type UpdateSmokeRun,
|
||||
type RecordSmokeRunStep,
|
||||
|
|
|
|||
|
|
@ -107,8 +107,6 @@ export type {
|
|||
ManagedExperimentalFeatureKey,
|
||||
ManagedSettingMetadata,
|
||||
BackupRetentionPolicy,
|
||||
IssueGraphLivenessAutoRecoveryPreview,
|
||||
IssueGraphLivenessAutoRecoveryPreviewItem,
|
||||
} from "./instance.js";
|
||||
export type {
|
||||
SmokeLabServiceStatus,
|
||||
|
|
@ -130,9 +128,6 @@ export {
|
|||
WEEKLY_RETENTION_PRESETS,
|
||||
MONTHLY_RETENTION_PRESETS,
|
||||
DEFAULT_BACKUP_RETENTION,
|
||||
DEFAULT_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS,
|
||||
MIN_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS,
|
||||
MAX_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS,
|
||||
PAPERCLIP_CLOUD_MANAGED_BY,
|
||||
} from "./instance.js";
|
||||
export {
|
||||
|
|
|
|||
|
|
@ -3,10 +3,6 @@ import type { FeedbackDataSharingPreference } from "./feedback.js";
|
|||
export const DAILY_RETENTION_PRESETS = [3, 7, 14] as const;
|
||||
export const WEEKLY_RETENTION_PRESETS = [1, 2, 4] as const;
|
||||
export const MONTHLY_RETENTION_PRESETS = [1, 3, 6] as const;
|
||||
export const DEFAULT_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS = 24;
|
||||
export const MIN_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS = 1;
|
||||
export const MAX_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS = 24 * 30;
|
||||
|
||||
export interface BackupRetentionPolicy {
|
||||
dailyDays: (typeof DAILY_RETENTION_PRESETS)[number];
|
||||
weeklyWeeks: (typeof WEEKLY_RETENTION_PRESETS)[number];
|
||||
|
|
@ -63,7 +59,6 @@ export interface InstanceExperimentalSettings {
|
|||
enableCases: boolean;
|
||||
enableConferenceRoomChat: boolean;
|
||||
enableClassicTaskInterface: boolean;
|
||||
enableTaskWatchdogs: boolean;
|
||||
enableIssuePlanDecompositions: boolean;
|
||||
enableExperimentalFileViewer: boolean;
|
||||
enableExternalObjects: boolean;
|
||||
|
|
@ -85,7 +80,6 @@ export interface InstanceExperimentalSettings {
|
|||
*/
|
||||
enableSimplifiedEnglishInteractions: boolean;
|
||||
autoRestartDevServerWhenIdle: boolean;
|
||||
enableIssueGraphLivenessAutoRecovery: boolean;
|
||||
enableWorkspaceBranchReconcileForward: boolean;
|
||||
enableWorkspaceDirtyQuarantineRepair: boolean;
|
||||
/**
|
||||
|
|
@ -125,7 +119,6 @@ export interface InstanceExperimentalSettings {
|
|||
* from another instance fail closed.
|
||||
*/
|
||||
worktreeRunExecutionActivationInstanceId: string | null;
|
||||
issueGraphLivenessAutoRecoveryLookbackHours: number;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -164,34 +157,3 @@ export interface InstanceSettings {
|
|||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface IssueGraphLivenessAutoRecoveryPreviewItem {
|
||||
issueId: string;
|
||||
identifier: string | null;
|
||||
title: string;
|
||||
state: string;
|
||||
severity: string;
|
||||
reason: string;
|
||||
recoveryIssueId: string;
|
||||
recoveryIdentifier: string | null;
|
||||
recoveryTitle: string | null;
|
||||
recommendedOwnerAgentId: string | null;
|
||||
incidentKey: string;
|
||||
latestDependencyUpdatedAt: string;
|
||||
dependencyPath: Array<{
|
||||
issueId: string;
|
||||
identifier: string | null;
|
||||
title: string;
|
||||
status: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface IssueGraphLivenessAutoRecoveryPreview {
|
||||
lookbackHours: number;
|
||||
cutoff: string;
|
||||
generatedAt: string;
|
||||
findings: number;
|
||||
recoverableFindings: number;
|
||||
skippedOutsideLookback: number;
|
||||
items: IssueGraphLivenessAutoRecoveryPreviewItem[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,11 +48,9 @@ export {
|
|||
managedSettingMetadataSchema,
|
||||
patchInstanceExperimentalSettingsSchema,
|
||||
patchInstanceSettingsSchema,
|
||||
issueGraphLivenessAutoRecoveryRequestSchema,
|
||||
type InstanceExperimentalSettings,
|
||||
type PatchInstanceExperimentalSettings,
|
||||
type PatchInstanceSettings,
|
||||
type IssueGraphLivenessAutoRecoveryRequest,
|
||||
} from "./instance.js";
|
||||
|
||||
export {
|
||||
|
|
|
|||
|
|
@ -18,6 +18,16 @@ describe("instance experimental settings validators", () => {
|
|||
).toEqual({ enablePaperclipDeveloperMode: true });
|
||||
});
|
||||
|
||||
it("strips retired watchdog and liveness auto-recovery settings", () => {
|
||||
expect(
|
||||
patchInstanceExperimentalSettingsSchema.parse({
|
||||
enableTaskWatchdogs: false,
|
||||
enableIssueGraphLivenessAutoRecovery: true,
|
||||
issueGraphLivenessAutoRecoveryLookbackHours: 24,
|
||||
}),
|
||||
).toEqual({});
|
||||
});
|
||||
|
||||
it("defaults workspace branch repair settings on", () => {
|
||||
const settings = instanceExperimentalSettingsSchema.parse({});
|
||||
|
||||
|
|
|
|||
|
|
@ -5,9 +5,6 @@ import {
|
|||
WEEKLY_RETENTION_PRESETS,
|
||||
MONTHLY_RETENTION_PRESETS,
|
||||
DEFAULT_BACKUP_RETENTION,
|
||||
DEFAULT_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS,
|
||||
MAX_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS,
|
||||
MIN_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS,
|
||||
} from "../types/instance.js";
|
||||
import { feedbackDataSharingPreferenceSchema } from "./feedback.js";
|
||||
import { shapeWithoutDefaults } from "./partial.js";
|
||||
|
|
@ -53,7 +50,6 @@ export const instanceExperimentalSettingsSchema = z.object({
|
|||
enableCases: z.boolean().default(false),
|
||||
enableConferenceRoomChat: z.boolean().default(false),
|
||||
enableClassicTaskInterface: z.boolean().default(false),
|
||||
enableTaskWatchdogs: z.boolean().default(false),
|
||||
enableIssuePlanDecompositions: z.boolean().default(false),
|
||||
enableExperimentalFileViewer: z.boolean().default(false),
|
||||
enableExternalObjects: z.boolean().default(false),
|
||||
|
|
@ -68,7 +64,6 @@ export const instanceExperimentalSettingsSchema = z.object({
|
|||
enablePaperclipDeveloperMode: z.boolean().default(false),
|
||||
enableSimplifiedEnglishInteractions: z.boolean().default(false),
|
||||
autoRestartDevServerWhenIdle: z.boolean().default(false),
|
||||
enableIssueGraphLivenessAutoRecovery: z.boolean().default(false),
|
||||
enableWorkspaceBranchReconcileForward: z.boolean().default(true),
|
||||
enableWorkspaceDirtyQuarantineRepair: z.boolean().default(true),
|
||||
enableOwnerInstanceAdmin: z.boolean().default(false),
|
||||
|
|
@ -82,12 +77,6 @@ export const instanceExperimentalSettingsSchema = z.object({
|
|||
enableWorktreeRunExecution: z.boolean().default(false),
|
||||
worktreeRunExecutionActivatedAt: z.string().datetime().nullable().default(null),
|
||||
worktreeRunExecutionActivationInstanceId: z.string().min(1).nullable().default(null),
|
||||
issueGraphLivenessAutoRecoveryLookbackHours: z
|
||||
.number()
|
||||
.int()
|
||||
.min(MIN_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS)
|
||||
.max(MAX_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS)
|
||||
.default(DEFAULT_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS),
|
||||
}).strict();
|
||||
|
||||
export const patchInstanceExperimentalSettingsSchema = z
|
||||
|
|
@ -120,15 +109,6 @@ export const patchInstanceSettingsSchema = z.object({
|
|||
defaultEnvironmentId: z.string().guid().nullable().optional(),
|
||||
}).strict();
|
||||
|
||||
export const issueGraphLivenessAutoRecoveryRequestSchema = z.object({
|
||||
lookbackHours: z
|
||||
.number()
|
||||
.int()
|
||||
.min(MIN_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS)
|
||||
.max(MAX_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS)
|
||||
.optional(),
|
||||
}).strict();
|
||||
|
||||
// The longest time a task drain can run before it expires on its own. A
|
||||
// caller can send a shorter `ttlMs`, but not a longer one — the request must
|
||||
// fail instead of the server silently clamping the value.
|
||||
|
|
@ -150,9 +130,6 @@ export type PatchInstanceExperimentalSettings = Partial<
|
|||
>
|
||||
>;
|
||||
export type PatchInstanceSettings = z.infer<typeof patchInstanceSettingsSchema>;
|
||||
export type IssueGraphLivenessAutoRecoveryRequest = z.infer<
|
||||
typeof issueGraphLivenessAutoRecoveryRequestSchema
|
||||
>;
|
||||
export type StartTaskDrainRequest = z.infer<typeof startTaskDrainRequestSchema>;
|
||||
|
||||
export const instanceSettingsSchema = z.object({
|
||||
|
|
|
|||
|
|
@ -68,10 +68,8 @@ vi.mock("../adapters/index.ts", async () => {
|
|||
|
||||
import { heartbeatService } from "../services/heartbeat.ts";
|
||||
import { attentionService } from "../services/attention.ts";
|
||||
import { instanceSettingsService } from "../services/instance-settings.ts";
|
||||
import { issueService } from "../services/issues.ts";
|
||||
import { runningProcesses } from "../adapters/index.ts";
|
||||
import { DEFAULT_LIVENESS_REESCALATION_COOLDOWN_MS } from "../services/recovery/service.ts";
|
||||
import {
|
||||
buildIssueBlockersResolvedWakeStateKey,
|
||||
buildIssueBlockersResolvedWakeStateKeyWithoutCycle,
|
||||
|
|
@ -86,7 +84,7 @@ if (!embeddedPostgresSupport.supported) {
|
|||
);
|
||||
}
|
||||
|
||||
describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => {
|
||||
describeEmbeddedPostgres("heartbeat resolved dependency wake reconciliation", () => {
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
let db: ReturnType<typeof createDb>;
|
||||
|
||||
|
|
@ -98,7 +96,7 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => {
|
|||
afterEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
runningProcesses.clear();
|
||||
// reconcileIssueGraphLiveness heals dependency wakes by enqueuing an
|
||||
// Dependency reconciliation heals missing wakes by enqueuing an
|
||||
// on-demand wake, which dispatches a heartbeat run fire-and-forget (see
|
||||
// startNextQueuedRunForAgent → executeRun in the heartbeat service). That
|
||||
// background run keeps writing rows (workspace_operations, heartbeat_run_events)
|
||||
|
|
@ -127,23 +125,12 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => {
|
|||
await db.delete(companyMemberships);
|
||||
await db.delete(companySkills);
|
||||
await db.delete(companies);
|
||||
await instanceSettingsService(db).updateExperimental({
|
||||
enableIssueGraphLivenessAutoRecovery: false,
|
||||
enableIsolatedWorkspaces: false,
|
||||
issueGraphLivenessAutoRecoveryLookbackHours: 24,
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await tempDb?.cleanup();
|
||||
}, 30_000);
|
||||
|
||||
async function enableAutoRecovery() {
|
||||
await instanceSettingsService(db).updateExperimental({
|
||||
enableIssueGraphLivenessAutoRecovery: true,
|
||||
});
|
||||
}
|
||||
|
||||
async function seedBlockedChain(opts: {
|
||||
outsideLookback?: boolean;
|
||||
blockerStatus?: string;
|
||||
|
|
@ -353,27 +340,6 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => {
|
|||
return { companyId, agentId, blockedIssueId, blockerIssueId, executionWorkspaceId };
|
||||
}
|
||||
|
||||
it("keeps liveness findings advisory when auto recovery is disabled", async () => {
|
||||
await instanceSettingsService(db).updateExperimental({
|
||||
enableIssueGraphLivenessAutoRecovery: false,
|
||||
});
|
||||
const { companyId } = await seedBlockedChain();
|
||||
const heartbeat = heartbeatService(db);
|
||||
|
||||
const result = await heartbeat.reconcileIssueGraphLiveness();
|
||||
|
||||
expect(result.findings).toBe(1);
|
||||
expect(result.autoRecoveryEnabled).toBe(false);
|
||||
expect(result.escalationsCreated).toBe(0);
|
||||
expect(result.skippedAutoRecoveryDisabled).toBe(1);
|
||||
|
||||
const escalations = await db
|
||||
.select()
|
||||
.from(issues)
|
||||
.where(and(eq(issues.companyId, companyId), eq(issues.originKind, "harness_liveness_escalation")));
|
||||
expect(escalations).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("runs exactly one bounded review-path recovery before surfacing a stalled decision", async () => {
|
||||
const companyId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
|
|
@ -465,16 +431,14 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("keeps resolved dependency wake reconciliation active when liveness auto recovery is disabled", async () => {
|
||||
it("keeps resolved dependency wake reconciliation active", async () => {
|
||||
const { companyId, agentId, blockedIssueId, blockerIssueId } =
|
||||
await seedResolvedDependencyBackstopFixture({ workspaceState: "none" });
|
||||
|
||||
const result = await heartbeatService(db).reconcileIssueGraphLiveness();
|
||||
const result = await heartbeatService(db).reconcileResolvedDependencyWakes();
|
||||
|
||||
expect(result.autoRecoveryEnabled).toBe(false);
|
||||
expect(result.dependencyWakesHealed).toBe(1);
|
||||
expect(result.dependencyWakeIssueIds).toEqual([blockedIssueId]);
|
||||
expect(result.escalationsCreated).toBe(0);
|
||||
expect(result.healed).toBe(1);
|
||||
expect(result.issueIds).toEqual([blockedIssueId]);
|
||||
|
||||
const wake = await db
|
||||
.select({
|
||||
|
|
@ -508,16 +472,13 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => {
|
|||
});
|
||||
|
||||
it("heals a blocked dependent whose done blocker has no workspace finalize obligation", async () => {
|
||||
await enableAutoRecovery();
|
||||
const { companyId, agentId, blockedIssueId, blockerIssueId } =
|
||||
await seedResolvedDependencyBackstopFixture({ workspaceState: "none" });
|
||||
|
||||
const result = await heartbeatService(db).reconcileIssueGraphLiveness();
|
||||
const result = await heartbeatService(db).reconcileResolvedDependencyWakes();
|
||||
|
||||
expect(result.findings).toBe(0);
|
||||
expect(result.dependencyWakesHealed).toBe(1);
|
||||
expect(result.dependencyWakeIssueIds).toEqual([blockedIssueId]);
|
||||
expect(result.escalationsCreated).toBe(0);
|
||||
expect(result.healed).toBe(1);
|
||||
expect(result.issueIds).toEqual([blockedIssueId]);
|
||||
|
||||
const wake = await db
|
||||
.select({
|
||||
|
|
@ -552,20 +513,20 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => {
|
|||
await seedResolvedDependencyBackstopFixture({ workspaceState: "none", assignee: null });
|
||||
const heartbeat = heartbeatService(db);
|
||||
|
||||
const beforeAssignment = await heartbeat.reconcileIssueGraphLiveness();
|
||||
const beforeAssignment = await heartbeat.reconcileResolvedDependencyWakes();
|
||||
|
||||
expect(beforeAssignment.dependencyWakesHealed).toBe(0);
|
||||
expect(beforeAssignment.dependencyWakeBackstopChecked).toBe(0);
|
||||
expect(beforeAssignment.healed).toBe(0);
|
||||
expect(beforeAssignment.checked).toBe(0);
|
||||
|
||||
await db
|
||||
.update(issues)
|
||||
.set({ assigneeAgentId: agentId, updatedAt: new Date() })
|
||||
.where(eq(issues.id, blockedIssueId));
|
||||
|
||||
const afterAssignment = await heartbeat.reconcileIssueGraphLiveness();
|
||||
const afterAssignment = await heartbeat.reconcileResolvedDependencyWakes();
|
||||
|
||||
expect(afterAssignment.dependencyWakesHealed).toBe(1);
|
||||
expect(afterAssignment.dependencyWakeIssueIds).toEqual([blockedIssueId]);
|
||||
expect(afterAssignment.healed).toBe(1);
|
||||
expect(afterAssignment.issueIds).toEqual([blockedIssueId]);
|
||||
|
||||
const wake = await db
|
||||
.select({
|
||||
|
|
@ -612,10 +573,10 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => {
|
|||
idempotencyKey,
|
||||
});
|
||||
|
||||
const result = await heartbeatService(db).reconcileIssueGraphLiveness();
|
||||
const result = await heartbeatService(db).reconcileResolvedDependencyWakes();
|
||||
|
||||
expect(result.dependencyWakesHealed).toBe(1);
|
||||
expect(result.dependencyWakeExistingSkipped).toBe(0);
|
||||
expect(result.healed).toBe(1);
|
||||
expect(result.existingWakeSkipped).toBe(0);
|
||||
|
||||
const wakes = await db
|
||||
.select({
|
||||
|
|
@ -634,16 +595,14 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => {
|
|||
});
|
||||
|
||||
it("waits for workspace finalize before healing a resolved blocked dependent", async () => {
|
||||
await enableAutoRecovery();
|
||||
const { companyId, agentId, blockedIssueId, blockerIssueId, executionWorkspaceId } =
|
||||
await seedResolvedDependencyBackstopFixture({ workspaceState: "not_finalized" });
|
||||
const heartbeat = heartbeatService(db);
|
||||
|
||||
const beforeFinalize = await heartbeat.reconcileIssueGraphLiveness();
|
||||
const beforeFinalize = await heartbeat.reconcileResolvedDependencyWakes();
|
||||
|
||||
expect(beforeFinalize.findings).toBe(0);
|
||||
expect(beforeFinalize.dependencyWakesHealed).toBe(0);
|
||||
expect(beforeFinalize.dependencyWakeNotReadySkipped).toBe(1);
|
||||
expect(beforeFinalize.healed).toBe(0);
|
||||
expect(beforeFinalize.notReadySkipped).toBe(1);
|
||||
|
||||
const wakesBeforeFinalize = await db
|
||||
.select()
|
||||
|
|
@ -660,10 +619,10 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => {
|
|||
startedAt: new Date(),
|
||||
});
|
||||
|
||||
const afterFinalize = await heartbeat.reconcileIssueGraphLiveness();
|
||||
const afterFinalize = await heartbeat.reconcileResolvedDependencyWakes();
|
||||
|
||||
expect(afterFinalize.dependencyWakesHealed).toBe(1);
|
||||
expect(afterFinalize.dependencyWakeIssueIds).toEqual([blockedIssueId]);
|
||||
expect(afterFinalize.healed).toBe(1);
|
||||
expect(afterFinalize.issueIds).toEqual([blockedIssueId]);
|
||||
|
||||
const wake = await db
|
||||
.select({
|
||||
|
|
@ -684,7 +643,6 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => {
|
|||
});
|
||||
|
||||
it("does not duplicate an existing dependency wake keyed to any resolved blocker", async () => {
|
||||
await enableAutoRecovery();
|
||||
const { companyId, agentId, blockedIssueId, blockerIssueId } =
|
||||
await seedResolvedDependencyBackstopFixture({ workspaceState: "none" });
|
||||
const secondBlockerIssueId = randomUUID();
|
||||
|
|
@ -724,10 +682,10 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => {
|
|||
idempotencyKey: `issue_blockers_resolved:${blockedIssueId}:${blockerIdNotUsedByBackstop}`,
|
||||
});
|
||||
|
||||
const result = await heartbeatService(db).reconcileIssueGraphLiveness();
|
||||
const result = await heartbeatService(db).reconcileResolvedDependencyWakes();
|
||||
|
||||
expect(result.dependencyWakesHealed).toBe(0);
|
||||
expect(result.dependencyWakeExistingSkipped).toBe(1);
|
||||
expect(result.healed).toBe(0);
|
||||
expect(result.existingWakeSkipped).toBe(1);
|
||||
|
||||
const wakes = await db
|
||||
.select({
|
||||
|
|
@ -743,7 +701,6 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => {
|
|||
});
|
||||
|
||||
it("heals a multi-blocker dependent when only a completed wake for an earlier blocker exists", async () => {
|
||||
await enableAutoRecovery();
|
||||
const { companyId, agentId, blockedIssueId, blockerIssueId } =
|
||||
await seedResolvedDependencyBackstopFixture({ workspaceState: "none" });
|
||||
const secondBlockerIssueId = randomUUID();
|
||||
|
|
@ -785,11 +742,11 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => {
|
|||
const readiness = await issueService(db).getDependencyReadiness(blockedIssueId);
|
||||
expect(readiness.isDependencyReady).toBe(true);
|
||||
|
||||
const result = await heartbeatService(db).reconcileIssueGraphLiveness();
|
||||
const result = await heartbeatService(db).reconcileResolvedDependencyWakes();
|
||||
|
||||
expect(result.dependencyWakesHealed).toBe(1);
|
||||
expect(result.dependencyWakeIssueIds).toEqual([blockedIssueId]);
|
||||
expect(result.dependencyWakeExistingSkipped).toBe(0);
|
||||
expect(result.healed).toBe(1);
|
||||
expect(result.issueIds).toEqual([blockedIssueId]);
|
||||
expect(result.existingWakeSkipped).toBe(0);
|
||||
|
||||
const stateKey = buildIssueBlockersResolvedWakeStateKey({
|
||||
dependentIssueId: blockedIssueId,
|
||||
|
|
@ -805,8 +762,8 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => {
|
|||
|
||||
// A second reconciliation pass finds the state-key wake and stays bounded:
|
||||
// it heals nothing more and never enqueues a second wake for the same state.
|
||||
const secondPass = await heartbeatService(db).reconcileIssueGraphLiveness();
|
||||
expect(secondPass.dependencyWakesHealed).toBe(0);
|
||||
const secondPass = await heartbeatService(db).reconcileResolvedDependencyWakes();
|
||||
expect(secondPass.healed).toBe(0);
|
||||
|
||||
const stateKeyWakes = await db
|
||||
.select({ id: agentWakeupRequests.id })
|
||||
|
|
@ -816,7 +773,6 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => {
|
|||
});
|
||||
|
||||
it("heals a blocked dependent after a terminal reset when a previous-cycle old-key wake exists", async () => {
|
||||
await enableAutoRecovery();
|
||||
const { companyId, agentId, blockedIssueId, blockerIssueId } =
|
||||
await seedResolvedDependencyBackstopFixture({ workspaceState: "none" });
|
||||
const previousCycleWakeAt = new Date("2026-07-01T12:00:00.000Z");
|
||||
|
|
@ -845,11 +801,11 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => {
|
|||
}),
|
||||
});
|
||||
|
||||
const result = await heartbeatService(db).reconcileIssueGraphLiveness();
|
||||
const result = await heartbeatService(db).reconcileResolvedDependencyWakes();
|
||||
|
||||
expect(result.dependencyWakesHealed).toBe(1);
|
||||
expect(result.dependencyWakeIssueIds).toEqual([blockedIssueId]);
|
||||
expect(result.dependencyWakeExistingSkipped).toBe(0);
|
||||
expect(result.healed).toBe(1);
|
||||
expect(result.issueIds).toEqual([blockedIssueId]);
|
||||
expect(result.existingWakeSkipped).toBe(0);
|
||||
|
||||
const cycleKey = buildIssueBlockersResolvedWakeStateKey({
|
||||
dependentIssueId: blockedIssueId,
|
||||
|
|
@ -864,8 +820,8 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => {
|
|||
expect(healedWake).not.toBeNull();
|
||||
expect(["queued", "claimed", "completed"]).toContain(healedWake?.status);
|
||||
|
||||
const secondPass = await heartbeatService(db).reconcileIssueGraphLiveness();
|
||||
expect(secondPass.dependencyWakesHealed).toBe(0);
|
||||
const secondPass = await heartbeatService(db).reconcileResolvedDependencyWakes();
|
||||
expect(secondPass.healed).toBe(0);
|
||||
|
||||
const cycleKeyWakes = await db
|
||||
.select({ id: agentWakeupRequests.id })
|
||||
|
|
@ -875,7 +831,6 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => {
|
|||
});
|
||||
|
||||
it("does not re-heal when a completed old-key wake is from the current blocked cycle", async () => {
|
||||
await enableAutoRecovery();
|
||||
const { companyId, agentId, blockedIssueId, blockerIssueId } =
|
||||
await seedResolvedDependencyBackstopFixture({ workspaceState: "none" });
|
||||
const blockedTransitionAt = new Date("2026-08-01T12:00:00.000Z");
|
||||
|
|
@ -904,14 +859,13 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => {
|
|||
}),
|
||||
});
|
||||
|
||||
const result = await heartbeatService(db).reconcileIssueGraphLiveness();
|
||||
const result = await heartbeatService(db).reconcileResolvedDependencyWakes();
|
||||
|
||||
expect(result.dependencyWakesHealed).toBe(0);
|
||||
expect(result.dependencyWakeExistingSkipped).toBe(1);
|
||||
expect(result.healed).toBe(0);
|
||||
expect(result.existingWakeSkipped).toBe(1);
|
||||
});
|
||||
|
||||
it("counts null dependency wake returns as deferred instead of enqueue failures", async () => {
|
||||
await enableAutoRecovery();
|
||||
const { companyId, agentId } =
|
||||
await seedResolvedDependencyBackstopFixture({ workspaceState: "none" });
|
||||
await db
|
||||
|
|
@ -921,11 +875,11 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => {
|
|||
})
|
||||
.where(eq(agents.id, agentId));
|
||||
|
||||
const result = await heartbeatService(db).reconcileIssueGraphLiveness();
|
||||
const result = await heartbeatService(db).reconcileResolvedDependencyWakes();
|
||||
|
||||
expect(result.dependencyWakesHealed).toBe(0);
|
||||
expect(result.dependencyWakeDeferredOrFailed).toBe(1);
|
||||
expect(result.dependencyWakeEnqueueFailed).toBe(0);
|
||||
expect(result.healed).toBe(0);
|
||||
expect(result.deferredOrFailed).toBe(1);
|
||||
expect(result.enqueueFailed).toBe(0);
|
||||
|
||||
const skippedWake = await db
|
||||
.select({
|
||||
|
|
@ -941,671 +895,4 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("does not create recovery issues outside the configured lookback window", async () => {
|
||||
await enableAutoRecovery();
|
||||
const { companyId } = await seedBlockedChain({ outsideLookback: true });
|
||||
const heartbeat = heartbeatService(db);
|
||||
|
||||
const result = await heartbeat.reconcileIssueGraphLiveness();
|
||||
|
||||
expect(result.findings).toBe(1);
|
||||
expect(result.escalationsCreated).toBe(0);
|
||||
expect(result.skippedOutsideLookback).toBe(1);
|
||||
|
||||
const escalations = await db
|
||||
.select()
|
||||
.from(issues)
|
||||
.where(and(eq(issues.companyId, companyId), eq(issues.originKind, "harness_liveness_escalation")));
|
||||
expect(escalations).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("suppresses liveness escalation when the source issue is under an active pause hold", async () => {
|
||||
await enableAutoRecovery();
|
||||
const { companyId, blockedIssueId } = await seedBlockedChain();
|
||||
|
||||
await db.insert(issueTreeHolds).values({
|
||||
companyId,
|
||||
rootIssueId: blockedIssueId,
|
||||
mode: "pause",
|
||||
status: "active",
|
||||
reason: "pause liveness recovery subtree",
|
||||
releasePolicy: { strategy: "manual" },
|
||||
});
|
||||
|
||||
const result = await heartbeatService(db).reconcileIssueGraphLiveness();
|
||||
|
||||
expect(result.findings).toBe(1);
|
||||
expect(result.escalationsCreated).toBe(0);
|
||||
expect(result.existingEscalations).toBe(0);
|
||||
expect(result.skipped).toBe(1);
|
||||
|
||||
const escalations = await db
|
||||
.select()
|
||||
.from(issues)
|
||||
.where(and(eq(issues.companyId, companyId), eq(issues.originKind, "harness_liveness_escalation")));
|
||||
expect(escalations).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("treats an active executionRunId on the leaf blocker as a live execution path", async () => {
|
||||
await enableAutoRecovery();
|
||||
const { companyId, managerId, blockedIssueId, blockerIssueId } = await seedBlockedChain();
|
||||
const runId = randomUUID();
|
||||
await db.insert(heartbeatRuns).values({
|
||||
id: runId,
|
||||
companyId,
|
||||
agentId: managerId,
|
||||
status: "running",
|
||||
contextSnapshot: { issueId: blockedIssueId },
|
||||
});
|
||||
await db.update(issues).set({ executionRunId: runId }).where(eq(issues.id, blockerIssueId));
|
||||
const heartbeat = heartbeatService(db);
|
||||
|
||||
const result = await heartbeat.reconcileIssueGraphLiveness();
|
||||
|
||||
expect(result.findings).toBe(0);
|
||||
expect(result.escalationsCreated).toBe(0);
|
||||
});
|
||||
|
||||
it("creates one bounded escalation for an assigned backlog blocker leaf", async () => {
|
||||
await enableAutoRecovery();
|
||||
const { companyId, coderId, blockedIssueId, blockerIssueId } = await seedBlockedChain({
|
||||
blockerStatus: "backlog",
|
||||
blockerAssigneeAgentId: "coder",
|
||||
});
|
||||
const heartbeat = heartbeatService(db);
|
||||
|
||||
const first = await heartbeat.reconcileIssueGraphLiveness();
|
||||
const second = await heartbeat.reconcileIssueGraphLiveness();
|
||||
|
||||
expect(first.findings).toBe(1);
|
||||
expect(first.escalationsCreated).toBe(1);
|
||||
expect(second.findings).toBe(0);
|
||||
expect(second.escalationsCreated).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);
|
||||
expect(escalations[0]).toMatchObject({
|
||||
parentId: blockerIssueId,
|
||||
assigneeAgentId: coderId,
|
||||
originId: [
|
||||
"harness_liveness",
|
||||
companyId,
|
||||
blockedIssueId,
|
||||
"blocked_by_assigned_backlog_issue",
|
||||
blockerIssueId,
|
||||
].join(":"),
|
||||
originFingerprint: [
|
||||
"harness_liveness_leaf",
|
||||
companyId,
|
||||
"blocked_by_assigned_backlog_issue",
|
||||
blockerIssueId,
|
||||
].join(":"),
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
const heartbeat = heartbeatService(db);
|
||||
|
||||
const first = await heartbeat.reconcileIssueGraphLiveness();
|
||||
|
||||
expect(first.escalationsCreated).toBe(1);
|
||||
const [sourceAfterFirst] = await db
|
||||
.select({ updatedAt: issues.updatedAt })
|
||||
.from(issues)
|
||||
.where(eq(issues.id, blockedIssueId));
|
||||
const eventsAfterFirst = await db.select().from(activityLog).where(eq(activityLog.companyId, companyId));
|
||||
expect(eventsAfterFirst.filter((event) => event.action === "issue.blockers.updated")).toHaveLength(1);
|
||||
|
||||
const second = await heartbeat.reconcileIssueGraphLiveness();
|
||||
|
||||
expect(second.escalationsCreated).toBe(0);
|
||||
const [sourceAfterSecond] = await db
|
||||
.select({ updatedAt: issues.updatedAt })
|
||||
.from(issues)
|
||||
.where(eq(issues.id, blockedIssueId));
|
||||
expect(sourceAfterSecond?.updatedAt.getTime()).toBe(sourceAfterFirst?.updatedAt.getTime());
|
||||
|
||||
const escalations = await db
|
||||
.select()
|
||||
.from(issues)
|
||||
.where(
|
||||
and(
|
||||
eq(issues.companyId, companyId),
|
||||
eq(issues.originKind, "harness_liveness_escalation"),
|
||||
),
|
||||
);
|
||||
expect(escalations).toHaveLength(1);
|
||||
expect(escalations[0]).toMatchObject({
|
||||
parentId: blockerIssueId,
|
||||
assigneeAgentId: managerId,
|
||||
assigneeAdapterOverrides: { modelProfile: "cheap" },
|
||||
status: expect.stringMatching(/^(todo|in_progress|done)$/),
|
||||
originFingerprint: [
|
||||
"harness_liveness_leaf",
|
||||
companyId,
|
||||
"blocked_by_unassigned_issue",
|
||||
blockerIssueId,
|
||||
].join(":"),
|
||||
});
|
||||
|
||||
const blockers = await db
|
||||
.select({ blockerIssueId: issueRelations.issueId })
|
||||
.from(issueRelations)
|
||||
.where(eq(issueRelations.relatedIssueId, blockedIssueId));
|
||||
expect(blockers.map((row) => row.blockerIssueId).sort()).toEqual(
|
||||
[blockerIssueId, escalations[0]!.id].sort(),
|
||||
);
|
||||
|
||||
const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, blockedIssueId));
|
||||
expect(comments).toHaveLength(1);
|
||||
expect(comments[0]?.body).toContain("harness-level liveness incident");
|
||||
expect(comments[0]?.body).toContain(escalations[0]?.identifier ?? escalations[0]!.id);
|
||||
|
||||
const events = await db.select().from(activityLog).where(eq(activityLog.companyId, companyId));
|
||||
const createdEvent = events.find((event) => event.action === "issue.harness_liveness_escalation_created");
|
||||
expect(createdEvent).toBeTruthy();
|
||||
expect(createdEvent?.details).toMatchObject({
|
||||
recoveryIssueId: blockerIssueId,
|
||||
ownerSelection: {
|
||||
selectedAgentId: managerId,
|
||||
selectedReason: "root_agent",
|
||||
selectedSourceIssueId: blockerIssueId,
|
||||
},
|
||||
workspaceSelection: {
|
||||
reuseRecoveryExecutionWorkspace: false,
|
||||
inheritedExecutionWorkspaceFromIssueId: null,
|
||||
projectWorkspaceSourceIssueId: blockerIssueId,
|
||||
},
|
||||
});
|
||||
expect(events.filter((event) => event.action === "issue.blockers.updated")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("skips budget-blocked direct owners and assigns recovery to the manager fallback", async () => {
|
||||
await enableAutoRecovery();
|
||||
const { companyId, managerId, coderId, blockedIssueId, blockerIssueId } = await seedBlockedChain();
|
||||
const issueTimestamp = new Date(Date.now() - 25 * 60 * 60 * 1000);
|
||||
await db
|
||||
.update(issues)
|
||||
.set({
|
||||
status: "in_review",
|
||||
assigneeAgentId: coderId,
|
||||
updatedAt: issueTimestamp,
|
||||
})
|
||||
.where(eq(issues.id, blockerIssueId));
|
||||
await db.insert(budgetPolicies).values({
|
||||
companyId,
|
||||
scopeType: "agent",
|
||||
scopeId: coderId,
|
||||
metric: "billed_cents",
|
||||
windowKind: "calendar_month_utc",
|
||||
amount: 1,
|
||||
hardStopEnabled: true,
|
||||
isActive: true,
|
||||
});
|
||||
await db.insert(costEvents).values({
|
||||
companyId,
|
||||
agentId: coderId,
|
||||
issueId: blockerIssueId,
|
||||
provider: "test",
|
||||
biller: "test",
|
||||
billingType: "tokens",
|
||||
model: "test-model",
|
||||
costCents: 1,
|
||||
occurredAt: new Date(),
|
||||
});
|
||||
|
||||
const result = await heartbeatService(db).reconcileIssueGraphLiveness();
|
||||
|
||||
expect(result.escalationsCreated).toBe(1);
|
||||
const escalations = await db
|
||||
.select()
|
||||
.from(issues)
|
||||
.where(and(eq(issues.companyId, companyId), eq(issues.originKind, "harness_liveness_escalation")));
|
||||
expect(escalations).toHaveLength(1);
|
||||
expect(escalations[0]).toMatchObject({
|
||||
parentId: blockerIssueId,
|
||||
assigneeAgentId: managerId,
|
||||
originId: [
|
||||
"harness_liveness",
|
||||
companyId,
|
||||
blockedIssueId,
|
||||
"in_review_without_action_path",
|
||||
blockerIssueId,
|
||||
].join(":"),
|
||||
});
|
||||
|
||||
const events = await db.select().from(activityLog).where(eq(activityLog.companyId, companyId));
|
||||
const createdEvent = events.find((event) => event.action === "issue.harness_liveness_escalation_created");
|
||||
expect(createdEvent?.details).toMatchObject({
|
||||
ownerSelection: {
|
||||
selectedAgentId: managerId,
|
||||
selectedReason: "assignee_reporting_chain",
|
||||
budgetBlockedCandidateAgentIds: [coderId],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("parents recovery under the leaf blocker without inheriting dependent or blocker execution state for manager-owned recovery", async () => {
|
||||
await enableAutoRecovery();
|
||||
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
|
||||
|
||||
const companyId = randomUUID();
|
||||
const managerId = randomUUID();
|
||||
const blockedIssueId = randomUUID();
|
||||
const blockerIssueId = randomUUID();
|
||||
const dependentProjectId = randomUUID();
|
||||
const blockerProjectId = randomUUID();
|
||||
const dependentProjectWorkspaceId = randomUUID();
|
||||
const blockerProjectWorkspaceId = randomUUID();
|
||||
const dependentExecutionWorkspaceId = randomUUID();
|
||||
const blockerExecutionWorkspaceId = randomUUID();
|
||||
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
|
||||
const issueTimestamp = new Date(Date.now() - 60 * 60 * 1000);
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(agents).values({
|
||||
id: managerId,
|
||||
companyId,
|
||||
name: "Root Operator",
|
||||
role: "operator",
|
||||
status: "idle",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: { heartbeat: { wakeOnDemand: false } },
|
||||
permissions: {},
|
||||
});
|
||||
await db.insert(projects).values([
|
||||
{
|
||||
id: dependentProjectId,
|
||||
companyId,
|
||||
name: "Dependent workspace project",
|
||||
status: "in_progress",
|
||||
},
|
||||
{
|
||||
id: blockerProjectId,
|
||||
companyId,
|
||||
name: "Blocker workspace project",
|
||||
status: "in_progress",
|
||||
},
|
||||
]);
|
||||
await db.insert(projectWorkspaces).values([
|
||||
{
|
||||
id: dependentProjectWorkspaceId,
|
||||
companyId,
|
||||
projectId: dependentProjectId,
|
||||
name: "Dependent primary",
|
||||
},
|
||||
{
|
||||
id: blockerProjectWorkspaceId,
|
||||
companyId,
|
||||
projectId: blockerProjectId,
|
||||
name: "Blocker primary",
|
||||
},
|
||||
]);
|
||||
await db.insert(executionWorkspaces).values([
|
||||
{
|
||||
id: dependentExecutionWorkspaceId,
|
||||
companyId,
|
||||
projectId: dependentProjectId,
|
||||
projectWorkspaceId: dependentProjectWorkspaceId,
|
||||
mode: "operator_branch",
|
||||
strategyType: "git_worktree",
|
||||
name: "Dependent branch",
|
||||
status: "active",
|
||||
providerType: "git_worktree",
|
||||
},
|
||||
{
|
||||
id: blockerExecutionWorkspaceId,
|
||||
companyId,
|
||||
projectId: blockerProjectId,
|
||||
projectWorkspaceId: blockerProjectWorkspaceId,
|
||||
mode: "operator_branch",
|
||||
strategyType: "git_worktree",
|
||||
name: "Blocker branch",
|
||||
status: "active",
|
||||
providerType: "git_worktree",
|
||||
},
|
||||
]);
|
||||
await db.insert(issues).values([
|
||||
{
|
||||
id: blockedIssueId,
|
||||
companyId,
|
||||
projectId: dependentProjectId,
|
||||
projectWorkspaceId: dependentProjectWorkspaceId,
|
||||
executionWorkspaceId: dependentExecutionWorkspaceId,
|
||||
executionWorkspacePreference: "reuse_existing",
|
||||
executionWorkspaceSettings: { mode: "operator_branch" },
|
||||
title: "Blocked dependent",
|
||||
status: "blocked",
|
||||
priority: "medium",
|
||||
issueNumber: 1,
|
||||
identifier: `${issuePrefix}-1`,
|
||||
createdAt: issueTimestamp,
|
||||
updatedAt: issueTimestamp,
|
||||
},
|
||||
{
|
||||
id: blockerIssueId,
|
||||
companyId,
|
||||
projectId: blockerProjectId,
|
||||
projectWorkspaceId: blockerProjectWorkspaceId,
|
||||
executionWorkspaceId: blockerExecutionWorkspaceId,
|
||||
executionWorkspacePreference: "reuse_existing",
|
||||
executionWorkspaceSettings: { mode: "operator_branch" },
|
||||
title: "Unassigned leaf blocker",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
issueNumber: 2,
|
||||
identifier: `${issuePrefix}-2`,
|
||||
createdAt: issueTimestamp,
|
||||
updatedAt: issueTimestamp,
|
||||
},
|
||||
]);
|
||||
await db.insert(issueRelations).values({
|
||||
companyId,
|
||||
issueId: blockerIssueId,
|
||||
relatedIssueId: blockedIssueId,
|
||||
type: "blocks",
|
||||
});
|
||||
|
||||
const result = await heartbeatService(db).reconcileIssueGraphLiveness();
|
||||
|
||||
expect(result.escalationsCreated).toBe(1);
|
||||
const escalations = await db
|
||||
.select()
|
||||
.from(issues)
|
||||
.where(and(eq(issues.companyId, companyId), eq(issues.originKind, "harness_liveness_escalation")));
|
||||
expect(escalations).toHaveLength(1);
|
||||
expect(escalations[0]).toMatchObject({
|
||||
parentId: blockerIssueId,
|
||||
projectId: blockerProjectId,
|
||||
projectWorkspaceId: blockerProjectWorkspaceId,
|
||||
executionWorkspaceId: null,
|
||||
executionWorkspacePreference: null,
|
||||
assigneeAgentId: managerId,
|
||||
assigneeAdapterOverrides: { modelProfile: "cheap" },
|
||||
});
|
||||
});
|
||||
|
||||
it("reuses one open recovery issue for multiple dependents with the same leaf blocker", async () => {
|
||||
await enableAutoRecovery();
|
||||
const { companyId, blockedIssueId, blockerIssueId } = await seedBlockedChain();
|
||||
const secondBlockedIssueId = randomUUID();
|
||||
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
|
||||
const issueTimestamp = new Date(Date.now() - 60 * 60 * 1000);
|
||||
await db.insert(issues).values({
|
||||
id: secondBlockedIssueId,
|
||||
companyId,
|
||||
title: "Second blocked parent",
|
||||
status: "blocked",
|
||||
priority: "medium",
|
||||
issueNumber: 3,
|
||||
identifier: `${issuePrefix}-3`,
|
||||
createdAt: issueTimestamp,
|
||||
updatedAt: issueTimestamp,
|
||||
});
|
||||
await db.insert(issueRelations).values({
|
||||
companyId,
|
||||
issueId: blockerIssueId,
|
||||
relatedIssueId: secondBlockedIssueId,
|
||||
type: "blocks",
|
||||
});
|
||||
const heartbeat = heartbeatService(db);
|
||||
|
||||
const result = await heartbeat.reconcileIssueGraphLiveness();
|
||||
|
||||
expect(result.findings).toBe(2);
|
||||
expect(result.escalationsCreated).toBe(1);
|
||||
expect(result.existingEscalations).toBe(1);
|
||||
const escalations = await db
|
||||
.select()
|
||||
.from(issues)
|
||||
.where(and(eq(issues.companyId, companyId), eq(issues.originKind, "harness_liveness_escalation")));
|
||||
expect(escalations).toHaveLength(1);
|
||||
|
||||
const blockers = await db
|
||||
.select({ blockedIssueId: issueRelations.relatedIssueId })
|
||||
.from(issueRelations)
|
||||
.where(and(eq(issueRelations.companyId, companyId), eq(issueRelations.issueId, escalations[0]!.id)));
|
||||
expect(blockers.map((row) => row.blockedIssueId).sort()).toEqual(
|
||||
[blockedIssueId, secondBlockedIssueId].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it("holds a recently closed matching escalation, then re-escalates after the cooldown", async () => {
|
||||
await enableAutoRecovery();
|
||||
const { companyId, managerId, blockedIssueId, blockerIssueId } = await seedBlockedChain();
|
||||
const heartbeat = heartbeatService(db);
|
||||
const now = new Date();
|
||||
const incidentKey = [
|
||||
"harness_liveness",
|
||||
companyId,
|
||||
blockedIssueId,
|
||||
"blocked_by_unassigned_issue",
|
||||
blockerIssueId,
|
||||
].join(":");
|
||||
const closedEscalationId = randomUUID();
|
||||
|
||||
await db.insert(issues).values({
|
||||
id: closedEscalationId,
|
||||
companyId,
|
||||
title: "Closed escalation",
|
||||
status: "done",
|
||||
priority: "high",
|
||||
parentId: blockedIssueId,
|
||||
assigneeAgentId: managerId,
|
||||
issueNumber: 3,
|
||||
identifier: "CLOSED-3",
|
||||
originKind: "harness_liveness_escalation",
|
||||
originId: incidentKey,
|
||||
createdAt: new Date(now.getTime() - 30 * 60 * 1000),
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
const held = await heartbeat.reconcileIssueGraphLiveness({ now });
|
||||
|
||||
expect(held.escalationsCreated).toBe(0);
|
||||
expect(held.skippedReescalationCooldown).toBe(1);
|
||||
|
||||
const result = await heartbeat.reconcileIssueGraphLiveness({
|
||||
now: new Date(now.getTime() + DEFAULT_LIVENESS_REESCALATION_COOLDOWN_MS + 1),
|
||||
});
|
||||
|
||||
expect(result.escalationsCreated).toBe(1);
|
||||
expect(result.existingEscalations).toBe(0);
|
||||
|
||||
const openEscalations = await db
|
||||
.select()
|
||||
.from(issues)
|
||||
.where(
|
||||
and(
|
||||
eq(issues.companyId, companyId),
|
||||
eq(issues.originKind, "harness_liveness_escalation"),
|
||||
eq(issues.originId, incidentKey),
|
||||
),
|
||||
);
|
||||
expect(openEscalations).toHaveLength(2);
|
||||
const freshEscalation = openEscalations.find((issue) => issue.status !== "done");
|
||||
expect(freshEscalation).toMatchObject({
|
||||
parentId: blockerIssueId,
|
||||
assigneeAgentId: managerId,
|
||||
status: expect.stringMatching(/^(todo|in_progress|done)$/),
|
||||
});
|
||||
|
||||
const blockers = await db
|
||||
.select({ blockerIssueId: issueRelations.issueId })
|
||||
.from(issueRelations)
|
||||
.where(eq(issueRelations.relatedIssueId, blockedIssueId));
|
||||
expect(blockers.some((row) => row.blockerIssueId === closedEscalationId)).toBe(false);
|
||||
expect(blockers.some((row) => row.blockerIssueId === freshEscalation?.id)).toBe(true);
|
||||
});
|
||||
|
||||
it("re-escalates immediately after a matching escalation is cancelled", async () => {
|
||||
await enableAutoRecovery();
|
||||
const { companyId, managerId, blockedIssueId, blockerIssueId } = await seedBlockedChain();
|
||||
const heartbeat = heartbeatService(db);
|
||||
const now = new Date();
|
||||
const incidentKey = [
|
||||
"harness_liveness",
|
||||
companyId,
|
||||
blockedIssueId,
|
||||
"blocked_by_unassigned_issue",
|
||||
blockerIssueId,
|
||||
].join(":");
|
||||
|
||||
await db.insert(issues).values({
|
||||
id: randomUUID(),
|
||||
companyId,
|
||||
title: "Cancelled escalation",
|
||||
status: "cancelled",
|
||||
priority: "high",
|
||||
parentId: blockedIssueId,
|
||||
assigneeAgentId: managerId,
|
||||
issueNumber: 3,
|
||||
identifier: "CANCELLED-3",
|
||||
originKind: "harness_liveness_escalation",
|
||||
originId: incidentKey,
|
||||
createdAt: new Date(now.getTime() - 30 * 60 * 1000),
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
const result = await heartbeat.reconcileIssueGraphLiveness({ now });
|
||||
|
||||
expect(result.escalationsCreated).toBe(1);
|
||||
expect(result.skippedReescalationCooldown).toBe(0);
|
||||
});
|
||||
|
||||
it("removes closed liveness escalations from blocker relations during reconciliation", async () => {
|
||||
await enableAutoRecovery();
|
||||
const { companyId, blockedIssueId, blockerIssueId } = await seedBlockedChain();
|
||||
const heartbeat = heartbeatService(db);
|
||||
|
||||
const first = await heartbeat.reconcileIssueGraphLiveness();
|
||||
expect(first.escalationsCreated).toBe(1);
|
||||
|
||||
const escalations = await db
|
||||
.select()
|
||||
.from(issues)
|
||||
.where(
|
||||
and(
|
||||
eq(issues.companyId, companyId),
|
||||
eq(issues.originKind, "harness_liveness_escalation"),
|
||||
),
|
||||
);
|
||||
expect(escalations).toHaveLength(1);
|
||||
|
||||
await db
|
||||
.update(issues)
|
||||
.set({ status: "done", blockedByIssueIds: [] })
|
||||
.where(eq(issues.id, escalations[0]!.id));
|
||||
await db
|
||||
.update(issues)
|
||||
.set({ status: "done", blockedByIssueIds: [] })
|
||||
.where(eq(issues.id, blockerIssueId));
|
||||
|
||||
const second = await heartbeat.reconcileIssueGraphLiveness();
|
||||
expect(second.obsoleteRecoveryBlockerRelationsRemoved).toBe(0);
|
||||
expect(second.doneRecoveryBlockerRelationsRemoved).toBe(1);
|
||||
|
||||
const blockers = await db
|
||||
.select({ blockerIssueId: issueRelations.issueId })
|
||||
.from(issueRelations)
|
||||
.where(eq(issueRelations.relatedIssueId, blockedIssueId));
|
||||
expect(blockers.some((row) => row.blockerIssueId === escalations[0]!.id)).toBe(false);
|
||||
});
|
||||
|
||||
it("handles an armed cutoff when no liveness findings exist", async () => {
|
||||
const heartbeat = heartbeatService(db);
|
||||
|
||||
const result = await heartbeat.reconcileIssueGraphLiveness({
|
||||
issueCreatedAtGte: new Date(),
|
||||
});
|
||||
|
||||
expect(result.findings).toBe(0);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -12,8 +12,6 @@ const mockInstanceSettingsService = vi.hoisted(() => ({
|
|||
listCompanyIds: vi.fn(),
|
||||
}));
|
||||
const mockHeartbeatService = vi.hoisted(() => ({
|
||||
buildIssueGraphLivenessAutoRecoveryPreview: vi.fn(),
|
||||
reconcileIssueGraphLiveness: vi.fn(),
|
||||
computeTaskDrain: vi.fn(),
|
||||
applyTaskDrain: vi.fn(),
|
||||
stopTaskDrain: vi.fn(),
|
||||
|
|
@ -84,8 +82,6 @@ describe("instance settings routes", () => {
|
|||
mockInstanceSettingsService.updateGeneral.mockReset();
|
||||
mockInstanceSettingsService.updateExperimental.mockReset();
|
||||
mockInstanceSettingsService.listCompanyIds.mockReset();
|
||||
mockHeartbeatService.buildIssueGraphLivenessAutoRecoveryPreview.mockReset();
|
||||
mockHeartbeatService.reconcileIssueGraphLiveness.mockReset();
|
||||
mockHeartbeatService.computeTaskDrain.mockReset();
|
||||
mockHeartbeatService.applyTaskDrain.mockReset();
|
||||
mockHeartbeatService.stopTaskDrain.mockReset();
|
||||
|
|
@ -122,13 +118,11 @@ describe("instance settings routes", () => {
|
|||
enableGoalsSidebarLink: false,
|
||||
enableServerInfoDebugView: false,
|
||||
autoRestartDevServerWhenIdle: false,
|
||||
enableIssueGraphLivenessAutoRecovery: true,
|
||||
enableWorkspaceBranchReconcileForward: true,
|
||||
enableWorkspaceDirtyQuarantineRepair: true,
|
||||
enableWorktreeRunExecution: false,
|
||||
worktreeRunExecutionActivatedAt: null,
|
||||
worktreeRunExecutionActivationInstanceId: null,
|
||||
issueGraphLivenessAutoRecoveryLookbackHours: 24,
|
||||
},
|
||||
createdAt: "2026-06-20T00:00:00.000Z",
|
||||
updatedAt: "2026-06-20T00:00:00.000Z",
|
||||
|
|
@ -143,20 +137,17 @@ describe("instance settings routes", () => {
|
|||
enableIsolatedWorkspaces: false,
|
||||
enableIssuePlanDecompositions: false,
|
||||
enableExperimentalFileViewer: false,
|
||||
enableTaskWatchdogs: false,
|
||||
enableExternalObjects: false,
|
||||
enableBuiltInAgents: false,
|
||||
enableBetaSkills: false,
|
||||
enableGoalsSidebarLink: false,
|
||||
enableServerInfoDebugView: false,
|
||||
autoRestartDevServerWhenIdle: false,
|
||||
enableIssueGraphLivenessAutoRecovery: true,
|
||||
enableWorkspaceBranchReconcileForward: true,
|
||||
enableWorkspaceDirtyQuarantineRepair: true,
|
||||
enableWorktreeRunExecution: false,
|
||||
worktreeRunExecutionActivatedAt: null,
|
||||
worktreeRunExecutionActivationInstanceId: null,
|
||||
issueGraphLivenessAutoRecoveryLookbackHours: 24,
|
||||
});
|
||||
mockInstanceSettingsService.update.mockResolvedValue({
|
||||
id: "instance-settings-1",
|
||||
|
|
@ -177,13 +168,11 @@ describe("instance settings routes", () => {
|
|||
enableGoalsSidebarLink: false,
|
||||
enableServerInfoDebugView: false,
|
||||
autoRestartDevServerWhenIdle: false,
|
||||
enableIssueGraphLivenessAutoRecovery: true,
|
||||
enableWorkspaceBranchReconcileForward: true,
|
||||
enableWorkspaceDirtyQuarantineRepair: true,
|
||||
enableWorktreeRunExecution: false,
|
||||
worktreeRunExecutionActivatedAt: null,
|
||||
worktreeRunExecutionActivationInstanceId: null,
|
||||
issueGraphLivenessAutoRecoveryLookbackHours: 24,
|
||||
},
|
||||
createdAt: "2026-06-20T00:00:00.000Z",
|
||||
updatedAt: "2026-06-20T01:00:00.000Z",
|
||||
|
|
@ -203,43 +192,19 @@ describe("instance settings routes", () => {
|
|||
enableIsolatedWorkspaces: true,
|
||||
enableIssuePlanDecompositions: true,
|
||||
enableExperimentalFileViewer: true,
|
||||
enableTaskWatchdogs: true,
|
||||
enableExternalObjects: false,
|
||||
enableBuiltInAgents: true,
|
||||
enableGoalsSidebarLink: false,
|
||||
enableServerInfoDebugView: true,
|
||||
autoRestartDevServerWhenIdle: false,
|
||||
enableIssueGraphLivenessAutoRecovery: true,
|
||||
enableWorkspaceBranchReconcileForward: true,
|
||||
enableWorkspaceDirtyQuarantineRepair: true,
|
||||
enableWorktreeRunExecution: false,
|
||||
worktreeRunExecutionActivatedAt: null,
|
||||
worktreeRunExecutionActivationInstanceId: null,
|
||||
issueGraphLivenessAutoRecoveryLookbackHours: 24,
|
||||
},
|
||||
});
|
||||
mockInstanceSettingsService.listCompanyIds.mockResolvedValue(["company-1", "company-2"]);
|
||||
mockHeartbeatService.buildIssueGraphLivenessAutoRecoveryPreview.mockResolvedValue({
|
||||
lookbackHours: 24,
|
||||
cutoff: "2026-04-26T12:00:00.000Z",
|
||||
generatedAt: "2026-04-27T12:00:00.000Z",
|
||||
findings: 1,
|
||||
recoverableFindings: 1,
|
||||
skippedOutsideLookback: 0,
|
||||
items: [],
|
||||
});
|
||||
mockHeartbeatService.reconcileIssueGraphLiveness.mockResolvedValue({
|
||||
findings: 1,
|
||||
autoRecoveryEnabled: true,
|
||||
lookbackHours: 24,
|
||||
cutoff: "2026-04-26T12:00:00.000Z",
|
||||
escalationsCreated: 1,
|
||||
existingEscalations: 0,
|
||||
skipped: 0,
|
||||
skippedAutoRecoveryDisabled: 0,
|
||||
skippedOutsideLookback: 0,
|
||||
escalationIssueIds: ["issue-2"],
|
||||
});
|
||||
mockEnvironmentService.getById.mockResolvedValue({
|
||||
id: "env-1",
|
||||
driver: "local",
|
||||
|
|
@ -263,20 +228,17 @@ describe("instance settings routes", () => {
|
|||
enableIsolatedWorkspaces: false,
|
||||
enableIssuePlanDecompositions: false,
|
||||
enableExperimentalFileViewer: false,
|
||||
enableTaskWatchdogs: false,
|
||||
enableExternalObjects: false,
|
||||
enableBuiltInAgents: false,
|
||||
enableBetaSkills: false,
|
||||
enableGoalsSidebarLink: false,
|
||||
enableServerInfoDebugView: false,
|
||||
autoRestartDevServerWhenIdle: false,
|
||||
enableIssueGraphLivenessAutoRecovery: true,
|
||||
enableWorkspaceBranchReconcileForward: true,
|
||||
enableWorkspaceDirtyQuarantineRepair: true,
|
||||
enableWorktreeRunExecution: false,
|
||||
worktreeRunExecutionActivatedAt: null,
|
||||
worktreeRunExecutionActivationInstanceId: null,
|
||||
issueGraphLivenessAutoRecoveryLookbackHours: 24,
|
||||
});
|
||||
|
||||
const patchRes = await request(app)
|
||||
|
|
@ -290,6 +252,24 @@ describe("instance settings routes", () => {
|
|||
expect(mockLogActivity).toHaveBeenCalledTimes(2);
|
||||
}, 10_000);
|
||||
|
||||
it("does not expose the retired liveness auto-recovery endpoints", async () => {
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "local-board",
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: true,
|
||||
});
|
||||
|
||||
await request(app)
|
||||
.post("/api/instance/settings/experimental/issue-graph-liveness-auto-recovery/preview")
|
||||
.send({ lookbackHours: 24 })
|
||||
.expect(404);
|
||||
await request(app)
|
||||
.post("/api/instance/settings/experimental/issue-graph-liveness-auto-recovery/run")
|
||||
.send({ lookbackHours: 24 })
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it("strips server-managed worktree run execution fields before updating experimental settings", async () => {
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
|
|
@ -514,68 +494,6 @@ describe("instance settings routes", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("allows local board users to update issue graph liveness auto-recovery", async () => {
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "local-board",
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: true,
|
||||
});
|
||||
|
||||
await request(app)
|
||||
.patch("/api/instance/settings/experimental")
|
||||
.send({
|
||||
enableIssueGraphLivenessAutoRecovery: true,
|
||||
issueGraphLivenessAutoRecoveryLookbackHours: 12,
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(mockInstanceSettingsService.updateExperimental).toHaveBeenCalledWith({
|
||||
enableIssueGraphLivenessAutoRecovery: true,
|
||||
issueGraphLivenessAutoRecoveryLookbackHours: 12,
|
||||
});
|
||||
});
|
||||
|
||||
it("previews issue graph liveness recovery candidates before enabling", async () => {
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "local-board",
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: true,
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/instance/settings/experimental/issue-graph-liveness-auto-recovery/preview")
|
||||
.send({ lookbackHours: 12 })
|
||||
.expect(200);
|
||||
|
||||
expect(res.body).toMatchObject({ lookbackHours: 24, recoverableFindings: 1 });
|
||||
expect(mockHeartbeatService.buildIssueGraphLivenessAutoRecoveryPreview).toHaveBeenCalledWith({
|
||||
lookbackHours: 12,
|
||||
});
|
||||
});
|
||||
|
||||
it("kicks off issue graph liveness recovery on demand", async () => {
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "local-board",
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: true,
|
||||
});
|
||||
|
||||
await request(app)
|
||||
.post("/api/instance/settings/experimental/issue-graph-liveness-auto-recovery/run")
|
||||
.send({ lookbackHours: 12 })
|
||||
.expect(200);
|
||||
|
||||
expect(mockHeartbeatService.reconcileIssueGraphLiveness).toHaveBeenCalledWith({
|
||||
runId: null,
|
||||
force: true,
|
||||
lookbackHours: 12,
|
||||
});
|
||||
expect(mockLogActivity).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("allows local board users to update environment controls", async () => {
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
|
|
@ -594,24 +512,6 @@ describe("instance settings routes", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("allows local board users to update task watchdog controls", async () => {
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "local-board",
|
||||
source: "local_implicit",
|
||||
isInstanceAdmin: true,
|
||||
});
|
||||
|
||||
await request(app)
|
||||
.patch("/api/instance/settings/experimental")
|
||||
.send({ enableTaskWatchdogs: true })
|
||||
.expect(200);
|
||||
|
||||
expect(mockInstanceSettingsService.updateExperimental).toHaveBeenCalledWith({
|
||||
enableTaskWatchdogs: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("allows non-admin board users with company access to read but not update experimental settings", async () => {
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
|
|
@ -625,7 +525,7 @@ describe("instance settings routes", () => {
|
|||
|
||||
await request(app)
|
||||
.patch("/api/instance/settings/experimental")
|
||||
.send({ enableTaskWatchdogs: true })
|
||||
.send({ enableEnvironments: true })
|
||||
.expect(403);
|
||||
|
||||
expect(mockInstanceSettingsService.updateExperimental).not.toHaveBeenCalled();
|
||||
|
|
|
|||
|
|
@ -15,16 +15,13 @@ describe("instance settings service", () => {
|
|||
enableIsolatedWorkspaces: true,
|
||||
enableIssuePlanDecompositions: true,
|
||||
enableExperimentalFileViewer: true,
|
||||
enableTaskWatchdogs: true,
|
||||
enableBuiltInAgents: true,
|
||||
enableGoalsSidebarLink: true,
|
||||
enableServerInfoDebugView: true,
|
||||
enablePaperclipDeveloperMode: true,
|
||||
autoRestartDevServerWhenIdle: true,
|
||||
enableIssueGraphLivenessAutoRecovery: true,
|
||||
enableWorkspaceBranchReconcileForward: true,
|
||||
enableWorkspaceDirtyQuarantineRepair: false,
|
||||
issueGraphLivenessAutoRecoveryLookbackHours: 48,
|
||||
enableNewestFirstIssueThread: true,
|
||||
})).toEqual({
|
||||
enableEnvironments: true,
|
||||
|
|
@ -41,7 +38,6 @@ describe("instance settings service", () => {
|
|||
enableCases: false,
|
||||
enableIssuePlanDecompositions: true,
|
||||
enableExperimentalFileViewer: true,
|
||||
enableTaskWatchdogs: true,
|
||||
enableBuiltInAgents: true,
|
||||
enableBetaSkills: false,
|
||||
enableSummaries: false,
|
||||
|
|
@ -52,7 +48,6 @@ describe("instance settings service", () => {
|
|||
enablePaperclipDeveloperMode: true,
|
||||
enableSimplifiedEnglishInteractions: false,
|
||||
autoRestartDevServerWhenIdle: true,
|
||||
enableIssueGraphLivenessAutoRecovery: true,
|
||||
enableWorkspaceBranchReconcileForward: true,
|
||||
enableWorkspaceDirtyQuarantineRepair: false,
|
||||
enableOwnerInstanceAdmin: false,
|
||||
|
|
@ -61,7 +56,6 @@ describe("instance settings service", () => {
|
|||
enableWorktreeRunExecution: false,
|
||||
worktreeRunExecutionActivatedAt: null,
|
||||
worktreeRunExecutionActivationInstanceId: null,
|
||||
issueGraphLivenessAutoRecoveryLookbackHours: 48,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -114,14 +108,6 @@ describe("instance settings service", () => {
|
|||
).toBe(true);
|
||||
});
|
||||
|
||||
it("defaults enableTaskWatchdogs to false for empty and legacy stored settings", () => {
|
||||
expect(normalizeExperimentalSettings(undefined).enableTaskWatchdogs).toBe(false);
|
||||
expect(normalizeExperimentalSettings({}).enableTaskWatchdogs).toBe(false);
|
||||
expect(
|
||||
normalizeExperimentalSettings({ enableExperimentalFileViewer: true }).enableTaskWatchdogs,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("defaults enableSmokeLab to false for empty and legacy stored settings", () => {
|
||||
expect(normalizeExperimentalSettings(undefined).enableSmokeLab).toBe(false);
|
||||
expect(normalizeExperimentalSettings({}).enableSmokeLab).toBe(false);
|
||||
|
|
@ -167,7 +153,7 @@ describe("instance settings service", () => {
|
|||
expect(normalizeExperimentalSettings(undefined).enableWorkspaceBranchReconcileForward).toBe(true);
|
||||
expect(normalizeExperimentalSettings({}).enableWorkspaceBranchReconcileForward).toBe(true);
|
||||
expect(
|
||||
normalizeExperimentalSettings({ enableIssueGraphLivenessAutoRecovery: true })
|
||||
normalizeExperimentalSettings({ enableExperimentalFileViewer: true })
|
||||
.enableWorkspaceBranchReconcileForward,
|
||||
).toBe(true);
|
||||
expect(normalizeExperimentalSettings(undefined).enableWorkspaceDirtyQuarantineRepair).toBe(true);
|
||||
|
|
|
|||
|
|
@ -29,7 +29,6 @@ describe("managedFeatureKeySet", () => {
|
|||
// Server-managed bookkeeping fields are not overlayable features.
|
||||
expect(keys.has("worktreeRunExecutionActivatedAt")).toBe(false);
|
||||
expect(keys.has("worktreeRunExecutionActivationInstanceId")).toBe(false);
|
||||
expect(keys.has("issueGraphLivenessAutoRecoveryLookbackHours")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -61,10 +61,7 @@ const {
|
|||
skipped: 0,
|
||||
issueIds: [],
|
||||
})),
|
||||
reconcileIssueGraphLiveness: vi.fn(async () => ({
|
||||
escalationsCreated: 0,
|
||||
dependencyWakesHealed: 0,
|
||||
})),
|
||||
reconcileResolvedDependencyWakes: vi.fn(async () => ({ healed: 0 })),
|
||||
reconcileTaskWatchdogs: vi.fn(async () => ({ triggered: 0 })),
|
||||
scanSilentActiveRuns: vi.fn(async () => ({ created: 0, escalated: 0 })),
|
||||
sweepStaleIssueLocks: vi.fn(async () => ({ cleared: 0 })),
|
||||
|
|
|
|||
|
|
@ -1316,11 +1316,11 @@ export async function startServer(): Promise<StartedServer> {
|
|||
);
|
||||
}
|
||||
|
||||
const issueGraphReconciled = await heartbeat.reconcileIssueGraphLiveness();
|
||||
if (issueGraphReconciled.escalationsCreated > 0 || issueGraphReconciled.dependencyWakesHealed > 0) {
|
||||
const dependencyWakesReconciled = await heartbeat.reconcileResolvedDependencyWakes();
|
||||
if (dependencyWakesReconciled.healed > 0) {
|
||||
logger.warn(
|
||||
{ ...issueGraphReconciled },
|
||||
"startup issue-graph liveness reconciliation changed issue graph state",
|
||||
{ ...dependencyWakesReconciled },
|
||||
"startup dependency-wake reconciliation restored task execution paths",
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1556,9 +1556,9 @@ export async function startServer(): Promise<StartedServer> {
|
|||
}
|
||||
})
|
||||
.then(async () => {
|
||||
const reconciled = await heartbeat.reconcileIssueGraphLiveness();
|
||||
if (reconciled.escalationsCreated > 0 || reconciled.dependencyWakesHealed > 0) {
|
||||
logger.warn({ ...reconciled }, "periodic issue-graph liveness reconciliation changed issue graph state");
|
||||
const reconciled = await heartbeat.reconcileResolvedDependencyWakes();
|
||||
if (reconciled.healed > 0) {
|
||||
logger.warn({ ...reconciled }, "periodic dependency-wake reconciliation restored task execution paths");
|
||||
}
|
||||
})
|
||||
.then(async () => {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { Router, type Request } from "express";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
issueGraphLivenessAutoRecoveryRequestSchema,
|
||||
patchInstanceSettingsSchema,
|
||||
patchInstanceExperimentalSettingsSchema,
|
||||
patchInstanceGeneralSettingsSchema,
|
||||
|
|
@ -291,55 +290,6 @@ export function instanceSettingsRoutes(db: Db) {
|
|||
},
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/instance/settings/experimental/issue-graph-liveness-auto-recovery/preview",
|
||||
validate(issueGraphLivenessAutoRecoveryRequestSchema),
|
||||
async (req, res) => {
|
||||
assertCanManageInstanceSettings(req);
|
||||
res.json(await heartbeat.buildIssueGraphLivenessAutoRecoveryPreview({
|
||||
lookbackHours: req.body.lookbackHours,
|
||||
}));
|
||||
},
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/instance/settings/experimental/issue-graph-liveness-auto-recovery/run",
|
||||
validate(issueGraphLivenessAutoRecoveryRequestSchema),
|
||||
async (req, res) => {
|
||||
assertCanManageInstanceSettings(req);
|
||||
const actor = getActorInfo(req);
|
||||
const result = await heartbeat.reconcileIssueGraphLiveness({
|
||||
runId: actor.runId,
|
||||
force: true,
|
||||
lookbackHours: req.body.lookbackHours,
|
||||
});
|
||||
const companyIds = await svc.listCompanyIds();
|
||||
await Promise.all(
|
||||
companyIds.map((companyId) =>
|
||||
logActivity(db, {
|
||||
companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
agentApiKeyId: actor.agentApiKeyId,
|
||||
action: "instance.settings.issue_graph_liveness_auto_recovery_run",
|
||||
entityType: "instance_settings",
|
||||
entityId: "default",
|
||||
details: {
|
||||
lookbackHours: result.lookbackHours,
|
||||
escalationsCreated: result.escalationsCreated,
|
||||
existingEscalations: result.existingEscalations,
|
||||
skippedOutsideLookback: result.skippedOutsideLookback,
|
||||
escalationIssueIds: result.escalationIssueIds,
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
res.json(result);
|
||||
},
|
||||
);
|
||||
|
||||
router.get("/instance/task-drain", async (req, res) => {
|
||||
assertBoardOrgAccess(req);
|
||||
res.json(heartbeat.getTaskDrainStatus());
|
||||
|
|
|
|||
|
|
@ -172,7 +172,6 @@ import {
|
|||
patchInstanceGeneralSettingsSchema,
|
||||
patchInstanceExperimentalSettingsSchema,
|
||||
patchInstanceSettingsSchema,
|
||||
issueGraphLivenessAutoRecoveryRequestSchema,
|
||||
startTaskDrainRequestSchema,
|
||||
// Resource memberships
|
||||
updateDocumentResourceMembershipSchema,
|
||||
|
|
@ -7062,22 +7061,6 @@ for (const route of [
|
|||
});
|
||||
}
|
||||
|
||||
registerCurrentRoute({
|
||||
method: "post",
|
||||
path: "/api/instance/settings/experimental/issue-graph-liveness-auto-recovery/preview",
|
||||
tags: ["instance-settings"],
|
||||
summary: "Preview issue graph liveness auto-recovery",
|
||||
body: issueGraphLivenessAutoRecoveryRequestSchema,
|
||||
});
|
||||
|
||||
registerCurrentRoute({
|
||||
method: "post",
|
||||
path: "/api/instance/settings/experimental/issue-graph-liveness-auto-recovery/run",
|
||||
tags: ["instance-settings"],
|
||||
summary: "Run issue graph liveness auto-recovery",
|
||||
body: issueGraphLivenessAutoRecoveryRequestSchema,
|
||||
});
|
||||
|
||||
registerCurrentRoute({
|
||||
method: "get",
|
||||
path: "/api/issues/{id}/accepted-plan-decompositions",
|
||||
|
|
|
|||
|
|
@ -17067,24 +17067,11 @@ export function heartbeatService(
|
|||
return recovery.buildRunOutputSilence(run, now);
|
||||
}
|
||||
|
||||
async function buildIssueGraphLivenessAutoRecoveryPreview(opts?: {
|
||||
lookbackHours?: number;
|
||||
now?: Date;
|
||||
}) {
|
||||
return recovery.buildIssueGraphLivenessAutoRecoveryPreview(opts);
|
||||
}
|
||||
|
||||
async function reconcileIssueGraphLiveness(opts?: {
|
||||
async function reconcileResolvedDependencyWakes(opts?: {
|
||||
runId?: string | null;
|
||||
force?: boolean;
|
||||
lookbackHours?: number;
|
||||
now?: Date;
|
||||
reescalationCooldownMs?: number;
|
||||
companyId?: string | null;
|
||||
}) {
|
||||
return recovery.reconcileIssueGraphLiveness({
|
||||
...opts,
|
||||
issueCreatedAtGte: await getWorktreeExecutionCutoff(),
|
||||
});
|
||||
return recovery.reconcileResolvedDependencyWakeBackstop(opts);
|
||||
}
|
||||
|
||||
async function updateRuntimeState(
|
||||
|
|
@ -25489,9 +25476,7 @@ export function heartbeatService(
|
|||
|
||||
sweepStaleIssueLocks,
|
||||
|
||||
buildIssueGraphLivenessAutoRecoveryPreview,
|
||||
|
||||
reconcileIssueGraphLiveness,
|
||||
reconcileResolvedDependencyWakes,
|
||||
|
||||
scanSilentActiveRuns,
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ export type InstanceSettingsWriteDb = Pick<
|
|||
import {
|
||||
DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE,
|
||||
DEFAULT_BACKUP_RETENTION,
|
||||
DEFAULT_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS,
|
||||
PAPERCLIP_CLOUD_MANAGED_BY,
|
||||
instanceGeneralSettingsSchema,
|
||||
type InstanceGeneralSettings,
|
||||
|
|
@ -232,7 +231,6 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta
|
|||
enableClassicTaskInterface: parsed.data.enableClassicTaskInterface ?? false,
|
||||
enableIssuePlanDecompositions: parsed.data.enableIssuePlanDecompositions ?? false,
|
||||
enableExperimentalFileViewer: parsed.data.enableExperimentalFileViewer ?? false,
|
||||
enableTaskWatchdogs: parsed.data.enableTaskWatchdogs ?? false,
|
||||
enableExternalObjects: parsed.data.enableExternalObjects ?? false,
|
||||
enableSmokeLab: parsed.data.enableSmokeLab ?? false,
|
||||
enableBuiltInAgents: parsed.data.enableBuiltInAgents ?? false,
|
||||
|
|
@ -245,7 +243,6 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta
|
|||
enablePaperclipDeveloperMode: parsed.data.enablePaperclipDeveloperMode ?? false,
|
||||
enableSimplifiedEnglishInteractions: parsed.data.enableSimplifiedEnglishInteractions ?? false,
|
||||
autoRestartDevServerWhenIdle: parsed.data.autoRestartDevServerWhenIdle ?? false,
|
||||
enableIssueGraphLivenessAutoRecovery: parsed.data.enableIssueGraphLivenessAutoRecovery ?? false,
|
||||
enableWorkspaceBranchReconcileForward: parsed.data.enableWorkspaceBranchReconcileForward ?? true,
|
||||
enableWorkspaceDirtyQuarantineRepair: parsed.data.enableWorkspaceDirtyQuarantineRepair ?? true,
|
||||
enableOwnerInstanceAdmin: parsed.data.enableOwnerInstanceAdmin ?? false,
|
||||
|
|
@ -255,9 +252,6 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta
|
|||
worktreeRunExecutionActivatedAt: parsed.data.worktreeRunExecutionActivatedAt ?? null,
|
||||
worktreeRunExecutionActivationInstanceId:
|
||||
parsed.data.worktreeRunExecutionActivationInstanceId ?? null,
|
||||
issueGraphLivenessAutoRecoveryLookbackHours:
|
||||
parsed.data.issueGraphLivenessAutoRecoveryLookbackHours ??
|
||||
DEFAULT_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS,
|
||||
};
|
||||
}
|
||||
return {
|
||||
|
|
@ -271,7 +265,6 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta
|
|||
enableCases: false,
|
||||
enableConferenceRoomChat: false,
|
||||
enableClassicTaskInterface: false,
|
||||
enableTaskWatchdogs: false,
|
||||
enableIssuePlanDecompositions: false,
|
||||
enableExperimentalFileViewer: false,
|
||||
enableExternalObjects: false,
|
||||
|
|
@ -286,7 +279,6 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta
|
|||
enablePaperclipDeveloperMode: false,
|
||||
enableSimplifiedEnglishInteractions: false,
|
||||
autoRestartDevServerWhenIdle: false,
|
||||
enableIssueGraphLivenessAutoRecovery: false,
|
||||
enableWorkspaceBranchReconcileForward: true,
|
||||
enableWorkspaceDirtyQuarantineRepair: true,
|
||||
enableOwnerInstanceAdmin: false,
|
||||
|
|
@ -295,8 +287,6 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta
|
|||
enableWorktreeRunExecution: false,
|
||||
worktreeRunExecutionActivatedAt: null,
|
||||
worktreeRunExecutionActivationInstanceId: null,
|
||||
issueGraphLivenessAutoRecoveryLookbackHours:
|
||||
DEFAULT_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -69,6 +69,13 @@ vi.mock("./pages/audit/CompanyActivity", () => ({
|
|||
},
|
||||
}));
|
||||
|
||||
vi.mock("./pages/Issues", () => ({
|
||||
Issues: () => {
|
||||
const location = useLocation();
|
||||
return <div>{`TASKS_PAGE@${location.pathname}`}</div>;
|
||||
},
|
||||
}));
|
||||
|
||||
const PAP_COMPANY = {
|
||||
id: "company-1",
|
||||
name: "Paperclip",
|
||||
|
|
@ -172,4 +179,11 @@ describe("App Activity routing (PAP-16302)", () => {
|
|||
expect(container.textContent).not.toContain("No organization matches prefix");
|
||||
flushSync(() => root.unmount());
|
||||
});
|
||||
|
||||
it("redirects the bare /tasks post-login target to the real task list", async () => {
|
||||
const root = renderAppAt(container, "/tasks");
|
||||
await waitForRoute(container, "TASKS_PAGE@/PAP/issues");
|
||||
expect(container.textContent).not.toContain("/tasks/dashboard");
|
||||
flushSync(() => root.unmount());
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -13,6 +13,10 @@ import { AppsExperimentalGate } from "./components/AppsExperimentalGate";
|
|||
import { CloudManagedPageGate } from "./components/CloudManagedPageGate";
|
||||
import { HiddenSettingsPageGate } from "./components/HiddenSettingsPageGate";
|
||||
import { IsolatedWorkspacesRouteGate } from "./components/IsolatedWorkspacesRouteGate";
|
||||
import {
|
||||
ExecutionWorkspaceCompanyGate,
|
||||
UnprefixedExecutionWorkspaceRedirect,
|
||||
} from "./components/UnprefixedExecutionWorkspaceRedirect";
|
||||
import { useHiddenSettings } from "./hooks/useHiddenSettings";
|
||||
import { Cases } from "./pages/Cases";
|
||||
import { CaseDetail } from "./pages/CaseDetail";
|
||||
|
|
@ -239,6 +243,7 @@ function boardRoutes() {
|
|||
<Route path="workspaces" element={<Workspaces />} />
|
||||
</Route>
|
||||
<Route path="issues" element={<Issues />} />
|
||||
<Route path="tasks" element={<Navigate to="/issues" replace />} />
|
||||
<Route path="search" element={<Search />} />
|
||||
<Route path="issues/all" element={<Navigate to="/issues" replace />} />
|
||||
<Route path="issues/active" element={<Navigate to="/issues" replace />} />
|
||||
|
|
@ -304,12 +309,14 @@ function boardRoutes() {
|
|||
<Route path="routines/:routineId" element={<RoutineDetail />} />
|
||||
<Route path="routines/:routineId/:section" element={<RoutineDetail />} />
|
||||
<Route element={<IsolatedWorkspacesRouteGate />}>
|
||||
<Route path="execution-workspaces/:workspaceId" element={<ExecutionWorkspaceDetail />} />
|
||||
<Route path="execution-workspaces/:workspaceId/services" element={<ExecutionWorkspaceDetail />} />
|
||||
<Route path="execution-workspaces/:workspaceId/configuration" element={<ExecutionWorkspaceDetail />} />
|
||||
<Route path="execution-workspaces/:workspaceId/runtime-logs" element={<ExecutionWorkspaceDetail />} />
|
||||
<Route path="execution-workspaces/:workspaceId/issues" element={<ExecutionWorkspaceDetail />} />
|
||||
<Route path="execution-workspaces/:workspaceId/routines" element={<ExecutionWorkspaceDetail />} />
|
||||
<Route element={<ExecutionWorkspaceCompanyGate />}>
|
||||
<Route path="execution-workspaces/:workspaceId" element={<ExecutionWorkspaceDetail />} />
|
||||
<Route path="execution-workspaces/:workspaceId/services" element={<ExecutionWorkspaceDetail />} />
|
||||
<Route path="execution-workspaces/:workspaceId/configuration" element={<ExecutionWorkspaceDetail />} />
|
||||
<Route path="execution-workspaces/:workspaceId/runtime-logs" element={<ExecutionWorkspaceDetail />} />
|
||||
<Route path="execution-workspaces/:workspaceId/issues" element={<ExecutionWorkspaceDetail />} />
|
||||
<Route path="execution-workspaces/:workspaceId/routines" element={<ExecutionWorkspaceDetail />} />
|
||||
</Route>
|
||||
</Route>
|
||||
<Route path="goals" element={<Goals />} />
|
||||
<Route path="goals/:goalId" element={<GoalDetail />} />
|
||||
|
|
@ -664,6 +671,7 @@ export function App() {
|
|||
<Route path="instance/settings/*" element={<LegacySettingsRedirect />} />
|
||||
<Route path="companies" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="issues" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="tasks" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="issues/:issueId" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="routines" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="routines/:routineId" element={<UnprefixedBoardRedirect />} />
|
||||
|
|
@ -709,12 +717,12 @@ export function App() {
|
|||
<Route path="projects/:projectId/workspaces/:workspaceId" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="projects/:projectId/configuration" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="workspaces" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="execution-workspaces/:workspaceId" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="execution-workspaces/:workspaceId/services" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="execution-workspaces/:workspaceId/configuration" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="execution-workspaces/:workspaceId/runtime-logs" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="execution-workspaces/:workspaceId/issues" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="execution-workspaces/:workspaceId/routines" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="execution-workspaces/:workspaceId" element={<UnprefixedExecutionWorkspaceRedirect />} />
|
||||
<Route path="execution-workspaces/:workspaceId/services" element={<UnprefixedExecutionWorkspaceRedirect />} />
|
||||
<Route path="execution-workspaces/:workspaceId/configuration" element={<UnprefixedExecutionWorkspaceRedirect />} />
|
||||
<Route path="execution-workspaces/:workspaceId/runtime-logs" element={<UnprefixedExecutionWorkspaceRedirect />} />
|
||||
<Route path="execution-workspaces/:workspaceId/issues" element={<UnprefixedExecutionWorkspaceRedirect />} />
|
||||
<Route path="execution-workspaces/:workspaceId/routines" element={<UnprefixedExecutionWorkspaceRedirect />} />
|
||||
<Route path=":companyPrefix" element={<Layout />}>
|
||||
{boardRoutes()}
|
||||
</Route>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import type {
|
|||
InstanceExperimentalSettingsWithManaged,
|
||||
InstanceGeneralSettings,
|
||||
InstanceSettings,
|
||||
IssueGraphLivenessAutoRecoveryPreview,
|
||||
PatchInstanceSettings,
|
||||
PatchInstanceGeneralSettings,
|
||||
PatchInstanceExperimentalSettings,
|
||||
|
|
@ -22,36 +21,4 @@ export const instanceSettingsApi = {
|
|||
api.get<InstanceExperimentalSettingsWithManaged>("/instance/settings/experimental"),
|
||||
updateExperimental: (patch: PatchInstanceExperimentalSettings) =>
|
||||
api.patch<InstanceExperimentalSettingsWithManaged>("/instance/settings/experimental", patch),
|
||||
previewIssueGraphLivenessAutoRecovery: (input: { lookbackHours?: number }) =>
|
||||
api.post<IssueGraphLivenessAutoRecoveryPreview>(
|
||||
"/instance/settings/experimental/issue-graph-liveness-auto-recovery/preview",
|
||||
input,
|
||||
),
|
||||
runIssueGraphLivenessAutoRecovery: (input: { lookbackHours?: number }) =>
|
||||
api.post<{
|
||||
findings: number;
|
||||
autoRecoveryEnabled: boolean;
|
||||
lookbackHours: number;
|
||||
cutoff: string;
|
||||
escalationsCreated: number;
|
||||
existingEscalations: number;
|
||||
skipped: number;
|
||||
skippedAutoRecoveryDisabled: number;
|
||||
skippedOutsideLookback: number;
|
||||
dependencyWakeBackstopChecked: number;
|
||||
dependencyWakesHealed: number;
|
||||
dependencyWakeExistingSkipped: number;
|
||||
dependencyWakeLivePathSkipped: number;
|
||||
dependencyWakeInteractionSkipped: number;
|
||||
dependencyWakePauseHoldSkipped: number;
|
||||
dependencyWakeNotReadySkipped: number;
|
||||
dependencyWakeCandidateLimitSkipped: number;
|
||||
dependencyWakeDeferredOrFailed: number;
|
||||
dependencyWakeEnqueueFailed: number;
|
||||
dependencyWakeIssueIds: string[];
|
||||
escalationIssueIds: string[];
|
||||
}>(
|
||||
"/instance/settings/experimental/issue-graph-liveness-auto-recovery/run",
|
||||
input,
|
||||
),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -497,7 +497,6 @@ describe("IssueProperties", () => {
|
|||
],
|
||||
});
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
|
||||
enableTaskWatchdogs: false,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -507,7 +506,6 @@ describe("IssueProperties", () => {
|
|||
|
||||
it("does not show a Plan tab for a planning-mode issue without a plan document", async () => {
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
|
||||
enableTaskWatchdogs: false,
|
||||
enableClassicTaskInterface: false,
|
||||
});
|
||||
const root = renderProperties(container, {
|
||||
|
|
@ -555,7 +553,6 @@ describe("IssueProperties", () => {
|
|||
latestRevisionId: "revision-evidence",
|
||||
} satisfies IssueDocument;
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
|
||||
enableTaskWatchdogs: false,
|
||||
enableClassicTaskInterface: false,
|
||||
});
|
||||
mockIssuesApi.getDocument.mockResolvedValue(planDocument);
|
||||
|
|
@ -859,7 +856,6 @@ describe("IssueProperties", () => {
|
|||
// The chat shell hosts the full tree in the center pane; the slim pill row
|
||||
// + its Add sub-task button only render in the classic layout (PAP-496).
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
|
||||
enableTaskWatchdogs: false,
|
||||
enableClassicTaskInterface: true,
|
||||
});
|
||||
const onAddSubIssue = vi.fn();
|
||||
|
|
@ -904,24 +900,7 @@ describe("IssueProperties", () => {
|
|||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("hides watchdog setup controls while the experimental flag is off", async () => {
|
||||
const root = renderProperties(container, {
|
||||
issue: createIssue(),
|
||||
childIssues: [],
|
||||
onUpdate: vi.fn(),
|
||||
});
|
||||
await flush();
|
||||
|
||||
expect(container.textContent).not.toContain("Watchdog");
|
||||
expect(container.textContent).not.toContain("Set watchdog");
|
||||
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("shows watchdog setup controls when the experimental flag is enabled", async () => {
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
|
||||
enableTaskWatchdogs: true,
|
||||
});
|
||||
it("always shows watchdog setup controls", async () => {
|
||||
const root = renderProperties(container, {
|
||||
issue: createIssue(),
|
||||
childIssues: [],
|
||||
|
|
@ -1206,7 +1185,6 @@ describe("IssueProperties", () => {
|
|||
// The sub-task pill row (with its collapse control) is classic-layout only
|
||||
// now — the chat shell promotes sub-tasks to their own pane tab (PAP-496).
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
|
||||
enableTaskWatchdogs: false,
|
||||
enableClassicTaskInterface: true,
|
||||
});
|
||||
const blockedBy = Array.from({ length: 7 }, (_, index) => ({
|
||||
|
|
@ -2517,7 +2495,6 @@ describe("IssueProperties", () => {
|
|||
|
||||
it("shows the empty watchdog state and saves a new watchdog via the API", async () => {
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
|
||||
enableTaskWatchdogs: true,
|
||||
});
|
||||
mockAgentsApi.list.mockResolvedValue([watchdogAgent]);
|
||||
const onUpdate = vi.fn();
|
||||
|
|
@ -2584,7 +2561,6 @@ describe("IssueProperties", () => {
|
|||
|
||||
it("updates cached issue detail when saving a watchdog", async () => {
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
|
||||
enableTaskWatchdogs: true,
|
||||
});
|
||||
mockAgentsApi.list.mockResolvedValue([watchdogAgent]);
|
||||
const savedWatchdog = createWatchdogSummary({
|
||||
|
|
@ -2639,7 +2615,6 @@ describe("IssueProperties", () => {
|
|||
|
||||
it("renders an existing watchdog and removes it via the API", async () => {
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
|
||||
enableTaskWatchdogs: true,
|
||||
});
|
||||
mockAgentsApi.list.mockResolvedValue([watchdogAgent]);
|
||||
const onUpdate = vi.fn();
|
||||
|
|
@ -2681,7 +2656,6 @@ describe("IssueProperties", () => {
|
|||
|
||||
it("truncates the watchdog instructions one-line summary in the properties value column", async () => {
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
|
||||
enableTaskWatchdogs: true,
|
||||
});
|
||||
mockAgentsApi.list.mockResolvedValue([watchdogAgent]);
|
||||
const instructions = "get greptile to stop re-reviewing the same task unless a fresh code change lands";
|
||||
|
|
@ -2720,7 +2694,6 @@ describe("IssueProperties", () => {
|
|||
|
||||
it("links to the generated watchdog task when one exists", async () => {
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
|
||||
enableTaskWatchdogs: true,
|
||||
});
|
||||
mockAgentsApi.list.mockResolvedValue([watchdogAgent]);
|
||||
const root = renderProperties(container, {
|
||||
|
|
|
|||
|
|
@ -1351,7 +1351,6 @@ describe("NewIssueDialog", () => {
|
|||
it("reveals the watchdog editor from the overflow menu", async () => {
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
|
||||
enableIsolatedWorkspaces: false,
|
||||
enableTaskWatchdogs: true,
|
||||
});
|
||||
|
||||
const { root } = renderDialog(container);
|
||||
|
|
@ -1378,7 +1377,6 @@ describe("NewIssueDialog", () => {
|
|||
it("submits the configured watchdog from a restored draft", async () => {
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
|
||||
enableIsolatedWorkspaces: false,
|
||||
enableTaskWatchdogs: true,
|
||||
});
|
||||
localStorage.setItem(
|
||||
"paperclip:issue-draft",
|
||||
|
|
|
|||
|
|
@ -1096,7 +1096,7 @@ export function NewIssueDialog() {
|
|||
: {}),
|
||||
...(executionWorkspaceSettings ? { executionWorkspaceSettings } : {}),
|
||||
...(executionPolicy ? { executionPolicy } : {}),
|
||||
...(taskWatchdogsEnabled && watchdogAgentId
|
||||
...(watchdogAgentId
|
||||
? { watchdog: { agentId: watchdogAgentId, instructions: watchdogInstructions.trim() || null } }
|
||||
: {}),
|
||||
});
|
||||
|
|
@ -1200,7 +1200,6 @@ export function NewIssueDialog() {
|
|||
? currentProject?.executionWorkspacePolicy ?? null
|
||||
: null;
|
||||
const currentProjectSupportsExecutionWorkspace = Boolean(currentProjectExecutionWorkspacePolicy?.enabled);
|
||||
const taskWatchdogsEnabled = experimentalSettings?.enableTaskWatchdogs === true;
|
||||
const selectableReusableWorkspaces = reusableExecutionWorkspaces ?? [];
|
||||
const selectedReusableExecutionWorkspace = selectableReusableWorkspaces.find(
|
||||
(workspace) => workspace.id === selectedExecutionWorkspaceId,
|
||||
|
|
@ -1606,7 +1605,7 @@ export function NewIssueDialog() {
|
|||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center justify-center rounded-md p-1 text-muted-foreground hover:bg-accent/50 transition-colors"
|
||||
title={taskWatchdogsEnabled ? "Add reviewer, approver, or watchdog" : "Add reviewer or approver"}
|
||||
title="Add reviewer, approver, or watchdog"
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</button>
|
||||
|
|
@ -1640,29 +1639,27 @@ export function NewIssueDialog() {
|
|||
<ShieldCheck className="h-3 w-3" />
|
||||
Approver
|
||||
</button>
|
||||
{taskWatchdogsEnabled && (
|
||||
<button
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
||||
showWatchdogRow && "bg-accent",
|
||||
)}
|
||||
onClick={() => {
|
||||
if (showWatchdogRow) {
|
||||
setShowWatchdogRow(false);
|
||||
setWatchdogAgentId("");
|
||||
setWatchdogInstructions("");
|
||||
setWatchdogEditorOpen(false);
|
||||
} else {
|
||||
setShowWatchdogRow(true);
|
||||
setWatchdogEditorOpen(true);
|
||||
}
|
||||
setParticipantMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
<ScanEye className="h-3 w-3" />
|
||||
Watchdog
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
||||
showWatchdogRow && "bg-accent",
|
||||
)}
|
||||
onClick={() => {
|
||||
if (showWatchdogRow) {
|
||||
setShowWatchdogRow(false);
|
||||
setWatchdogAgentId("");
|
||||
setWatchdogInstructions("");
|
||||
setWatchdogEditorOpen(false);
|
||||
} else {
|
||||
setShowWatchdogRow(true);
|
||||
setWatchdogEditorOpen(true);
|
||||
}
|
||||
setParticipantMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
<ScanEye className="h-3 w-3" />
|
||||
Watchdog
|
||||
</button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
|
@ -1759,7 +1756,7 @@ export function NewIssueDialog() {
|
|||
)}
|
||||
|
||||
{/* Watchdog row */}
|
||||
{taskWatchdogsEnabled && showWatchdogRow && (
|
||||
{showWatchdogRow && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground mt-1">
|
||||
<span className="w-6 shrink-0 flex items-center justify-center"><ScanEye className="h-3.5 w-3.5" /></span>
|
||||
<Popover open={watchdogEditorOpen} onOpenChange={setWatchdogEditorOpen}>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,115 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
ExecutionWorkspaceCompanyGate,
|
||||
UnprefixedExecutionWorkspaceRedirect,
|
||||
} from "./UnprefixedExecutionWorkspaceRedirect";
|
||||
|
||||
const mockExecutionWorkspacesApi = vi.hoisted(() => ({ get: vi.fn() }));
|
||||
vi.mock("@/api/execution-workspaces", () => ({
|
||||
executionWorkspacesApi: mockExecutionWorkspacesApi,
|
||||
}));
|
||||
|
||||
const PAP = { id: "company-pap", name: "Paperclip", issuePrefix: "PAP", status: "active" };
|
||||
const FOR = { id: "company-for", name: "Forgotten Runes", issuePrefix: "FOR", status: "active" };
|
||||
vi.mock("@/context/CompanyContext", () => ({
|
||||
useCompany: () => ({
|
||||
companies: [PAP, FOR],
|
||||
selectedCompanyId: FOR.id,
|
||||
selectedCompany: FOR,
|
||||
loading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../pages/NotFound", () => ({
|
||||
NotFoundPage: () => <div>NOT_FOUND</div>,
|
||||
}));
|
||||
|
||||
function Destination() {
|
||||
const location = useLocation();
|
||||
return <div>{`DESTINATION@${location.pathname}${location.search}${location.hash}`}</div>;
|
||||
}
|
||||
|
||||
describe("UnprefixedExecutionWorkspaceRedirect", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
flushSync(() => root?.unmount());
|
||||
container.remove();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function render(path: string) {
|
||||
root = createRoot(container);
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
flushSync(() => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<Routes>
|
||||
<Route path="execution-workspaces/:workspaceId/issues" element={<UnprefixedExecutionWorkspaceRedirect />} />
|
||||
<Route path=":companyPrefix/execution-workspaces/:workspaceId/issues" element={<Destination />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function renderGate(path: string) {
|
||||
root = createRoot(container);
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
flushSync(() => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<Routes>
|
||||
<Route path=":companyPrefix" element={<ExecutionWorkspaceCompanyGate />}>
|
||||
<Route path="execution-workspaces/:workspaceId/issues" element={<Destination />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
it("uses the workspace owner instead of the selected company after login", async () => {
|
||||
mockExecutionWorkspacesApi.get.mockResolvedValue({ id: "workspace-1", companyId: PAP.id });
|
||||
render("/execution-workspaces/workspace-1/issues?tab=open#latest");
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(container.textContent).toContain(
|
||||
"DESTINATION@/PAP/execution-workspaces/workspace-1/issues?tab=open#latest",
|
||||
);
|
||||
});
|
||||
expect(container.textContent).not.toContain("/FOR/execution-workspaces");
|
||||
});
|
||||
|
||||
it("shows not found when the workspace cannot be resolved", async () => {
|
||||
mockExecutionWorkspacesApi.get.mockRejectedValue(new Error("Execution workspace not found"));
|
||||
render("/execution-workspaces/missing/issues");
|
||||
|
||||
await vi.waitFor(() => expect(container.textContent).toContain("NOT_FOUND"));
|
||||
expect(container.textContent).not.toContain("DESTINATION@");
|
||||
});
|
||||
|
||||
it("rejects a prefixed route for a different company's workspace", async () => {
|
||||
mockExecutionWorkspacesApi.get.mockResolvedValue({ id: "workspace-1", companyId: PAP.id });
|
||||
renderGate("/FOR/execution-workspaces/workspace-1/issues");
|
||||
|
||||
await vi.waitFor(() => expect(container.textContent).toContain("NOT_FOUND"));
|
||||
expect(container.textContent).not.toContain("DESTINATION@");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import { executionWorkspacesApi } from "@/api/execution-workspaces";
|
||||
import { useCompany } from "@/context/CompanyContext";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { Navigate, Outlet, useLocation, useParams } from "@/lib/router";
|
||||
import { PaperclipLoading } from "./AnimatedPaperclipIcon";
|
||||
import { NotFoundPage } from "../pages/NotFound";
|
||||
|
||||
/** Resolve a prefix-free workspace URL from the resource, not browsing state. */
|
||||
export function UnprefixedExecutionWorkspaceRedirect() {
|
||||
const location = useLocation();
|
||||
const { workspaceId } = useParams<{ workspaceId?: string }>();
|
||||
const { companies, loading: companiesLoading } = useCompany();
|
||||
const workspaceQuery = useQuery({
|
||||
queryKey: queryKeys.executionWorkspaces.detail(workspaceId ?? "__missing__"),
|
||||
queryFn: () => executionWorkspacesApi.get(workspaceId!),
|
||||
enabled: Boolean(workspaceId),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
if (!workspaceId) return <NotFoundPage scope="global" />;
|
||||
if (companiesLoading || workspaceQuery.isPending) return <PaperclipLoading />;
|
||||
if (workspaceQuery.isError) return <NotFoundPage scope="global" />;
|
||||
|
||||
const targetCompany = companies.find(
|
||||
(company) => company.id === workspaceQuery.data.companyId,
|
||||
);
|
||||
if (!targetCompany) return <NotFoundPage scope="global" />;
|
||||
|
||||
return (
|
||||
<Navigate
|
||||
to={`/${targetCompany.issuePrefix}${location.pathname}${location.search}${location.hash}`}
|
||||
replace
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Reject a prefixed URL when its prefix and workspace belong to different companies. */
|
||||
export function ExecutionWorkspaceCompanyGate() {
|
||||
const { companyPrefix, workspaceId } = useParams<{
|
||||
companyPrefix?: string;
|
||||
workspaceId?: string;
|
||||
}>();
|
||||
const { companies, loading: companiesLoading } = useCompany();
|
||||
const workspaceQuery = useQuery({
|
||||
queryKey: queryKeys.executionWorkspaces.detail(workspaceId ?? "__missing__"),
|
||||
queryFn: () => executionWorkspacesApi.get(workspaceId!),
|
||||
enabled: Boolean(workspaceId),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
if (!workspaceId || !companyPrefix) return <NotFoundPage scope="global" />;
|
||||
if (companiesLoading || workspaceQuery.isPending) return <PaperclipLoading />;
|
||||
if (workspaceQuery.isError) return <NotFoundPage scope="global" />;
|
||||
|
||||
const routeCompany = companies.find(
|
||||
(company) => company.issuePrefix.toUpperCase() === companyPrefix.toUpperCase(),
|
||||
);
|
||||
if (!routeCompany || routeCompany.id !== workspaceQuery.data.companyId) {
|
||||
return <NotFoundPage scope="global" />;
|
||||
}
|
||||
|
||||
return <Outlet />;
|
||||
}
|
||||
|
|
@ -168,6 +168,23 @@ describe("WorkspaceAccessCard", () => {
|
|||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("does not show a provisioning badge or spinner when the workspace can be started", () => {
|
||||
const { root } = renderCard({
|
||||
access: accessState({
|
||||
state: "stopped",
|
||||
title: "Workspace is not running",
|
||||
description: "Start the workspace runtime to publish its board.",
|
||||
action: { kind: "start", label: "Start workspace" },
|
||||
}),
|
||||
});
|
||||
|
||||
const badge = container.querySelector("[data-testid='workspace-access-badge']");
|
||||
expect(badge).toBeNull();
|
||||
expect(container.querySelector(".animate-spin")).toBeNull();
|
||||
expect(findButton("Start workspace")).toBeDefined();
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("always names the state and the cause instead of a bare failure", () => {
|
||||
const { root } = renderCard({
|
||||
access: accessState({
|
||||
|
|
|
|||
|
|
@ -19,7 +19,9 @@ import type { WorkspaceAccessState } from "../lib/workspace-access-state";
|
|||
* and `degraded` the "todo" (amber) tone; the icon variants are the contrast-
|
||||
* corrected pair the token layer already tunes per mode.
|
||||
*/
|
||||
const STATE_BADGE_CLASSES: Record<WorkspaceAccessState["state"], string> = {
|
||||
type ActiveWorkspaceAccessState = Exclude<WorkspaceAccessState["state"], "stopped">;
|
||||
|
||||
const STATE_BADGE_CLASSES: Record<ActiveWorkspaceAccessState, string> = {
|
||||
provisioning: "border-border text-muted-foreground",
|
||||
validating: "border-border text-muted-foreground",
|
||||
ready: "border-(--status-task-done) text-(--status-task-icon-done)",
|
||||
|
|
@ -28,7 +30,7 @@ const STATE_BADGE_CLASSES: Record<WorkspaceAccessState["state"], string> = {
|
|||
failed: "border-destructive/50 text-destructive",
|
||||
};
|
||||
|
||||
const STATE_LABELS: Record<WorkspaceAccessState["state"], string> = {
|
||||
const STATE_LABELS: Record<ActiveWorkspaceAccessState, string> = {
|
||||
provisioning: "Provisioning",
|
||||
validating: "Validating clone",
|
||||
ready: "Ready",
|
||||
|
|
@ -80,18 +82,20 @@ export function WorkspaceAccessCard({
|
|||
<CardHeader>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<CardTitle>{access.title}</CardTitle>
|
||||
<span
|
||||
data-testid="workspace-access-badge"
|
||||
className={cn(
|
||||
"inline-flex items-center rounded-full border bg-background px-2.5 py-1 text-xs",
|
||||
STATE_BADGE_CLASSES[access.state],
|
||||
)}
|
||||
>
|
||||
{(access.state === "repairing" || access.state === "provisioning") && (
|
||||
<Loader2 className="mr-1.5 h-3 w-3 animate-spin" />
|
||||
)}
|
||||
{STATE_LABELS[access.state]}
|
||||
</span>
|
||||
{access.state !== "stopped" ? (
|
||||
<span
|
||||
data-testid="workspace-access-badge"
|
||||
className={cn(
|
||||
"inline-flex items-center rounded-full border bg-background px-2.5 py-1 text-xs",
|
||||
STATE_BADGE_CLASSES[access.state],
|
||||
)}
|
||||
>
|
||||
{(access.state === "repairing" || access.state === "provisioning") && (
|
||||
<Loader2 className="mr-1.5 h-3 w-3 animate-spin" />
|
||||
)}
|
||||
{STATE_LABELS[access.state]}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<CardDescription>{access.description}</CardDescription>
|
||||
</CardHeader>
|
||||
|
|
|
|||
|
|
@ -191,7 +191,6 @@ export function IssueProperties({
|
|||
queryKey: queryKeys.instance.experimentalSettings,
|
||||
queryFn: () => instanceSettingsApi.getExperimental(),
|
||||
});
|
||||
const taskWatchdogsEnabled = experimentalSettings?.enableTaskWatchdogs === true;
|
||||
// Managed-sandbox-only policy: the workspace folder is a host filesystem
|
||||
// path, so the Folder row disappears. The Branch row above it stays. The gate
|
||||
// fails closed whenever the policy is unknown — in flight and also on a failed
|
||||
|
|
@ -2419,32 +2418,30 @@ export function IssueProperties({
|
|||
{monitorContent}
|
||||
</PropertyPicker>
|
||||
|
||||
{taskWatchdogsEnabled ? (
|
||||
<PropertyPicker
|
||||
inline={inline}
|
||||
label="Watchdog"
|
||||
open={watchdogOpen}
|
||||
onOpenChange={setWatchdogOpen}
|
||||
triggerContent={watchdogTrigger}
|
||||
triggerClassName="min-w-0 max-w-full"
|
||||
popoverClassName={cn("max-w-full", inline ? "w-full" : "w-80 sm:w-96")}
|
||||
extra={
|
||||
watchdogIssueRef ? (
|
||||
<Link
|
||||
to={`/issues/${watchdogIssueRef.id}`}
|
||||
className="inline-flex items-center justify-center h-5 w-5 rounded hover:bg-accent/50 transition-colors text-muted-foreground hover:text-foreground"
|
||||
title="Open watchdog task"
|
||||
aria-label="Open watchdog task"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<ArrowUpRight className="h-3 w-3" />
|
||||
</Link>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{watchdogContent}
|
||||
</PropertyPicker>
|
||||
) : null}
|
||||
<PropertyPicker
|
||||
inline={inline}
|
||||
label="Watchdog"
|
||||
open={watchdogOpen}
|
||||
onOpenChange={setWatchdogOpen}
|
||||
triggerContent={watchdogTrigger}
|
||||
triggerClassName="min-w-0 max-w-full"
|
||||
popoverClassName={cn("max-w-full", inline ? "w-full" : "w-80 sm:w-96")}
|
||||
extra={
|
||||
watchdogIssueRef ? (
|
||||
<Link
|
||||
to={`/issues/${watchdogIssueRef.id}`}
|
||||
className="inline-flex items-center justify-center h-5 w-5 rounded hover:bg-accent/50 transition-colors text-muted-foreground hover:text-foreground"
|
||||
title="Open watchdog task"
|
||||
aria-label="Open watchdog task"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<ArrowUpRight className="h-3 w-3" />
|
||||
</Link>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{watchdogContent}
|
||||
</PropertyPicker>
|
||||
</PropertySection>
|
||||
|
||||
{hasWorkspaceRuntimeControls || issue.currentExecutionWorkspace?.branchName || issue.currentExecutionWorkspace?.cwd || issue.executionWorkspaceId ? (
|
||||
|
|
|
|||
|
|
@ -7,6 +7,12 @@ import {
|
|||
} from "./company-routes";
|
||||
|
||||
describe("company routes", () => {
|
||||
it("treats the task-list alias as an unprefixed board route", () => {
|
||||
expect(isBoardPathWithoutPrefix("/tasks")).toBe(true);
|
||||
expect(extractCompanyPrefixFromPath("/tasks")).toBeNull();
|
||||
expect(applyCompanyPrefix("/tasks", "PAP")).toBe("/PAP/tasks");
|
||||
});
|
||||
|
||||
it("treats execution workspace paths as board routes that need a company prefix", () => {
|
||||
expect(isBoardPathWithoutPrefix("/execution-workspaces/workspace-123")).toBe(true);
|
||||
expect(isBoardPathWithoutPrefix("/execution-workspaces/workspace-123/routines")).toBe(true);
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ const BOARD_ROUTE_ROOTS = new Set([
|
|||
"workspaces",
|
||||
"execution-workspaces",
|
||||
"issues",
|
||||
"tasks",
|
||||
"routines",
|
||||
"goals",
|
||||
"artifacts",
|
||||
|
|
|
|||
|
|
@ -253,7 +253,7 @@ describe("resolveWorkspaceAccessState", () => {
|
|||
});
|
||||
|
||||
expect(access).toMatchObject({
|
||||
state: "provisioning",
|
||||
state: "stopped",
|
||||
action: { kind: "start", label: "Start workspace" },
|
||||
});
|
||||
});
|
||||
|
|
@ -344,8 +344,11 @@ describe("resolveWorkspaceAccessState", () => {
|
|||
|
||||
it("offers start when nothing is running", () => {
|
||||
expect(
|
||||
resolveWorkspaceAccessState({ runtimeServices: [], operations: [] }),
|
||||
).toMatchObject({ state: "provisioning", action: { kind: "start", label: "Start workspace" } });
|
||||
resolveWorkspaceAccessState({
|
||||
runtimeServices: [runtimeService({ status: "stopped", healthStatus: "unknown", url: null })],
|
||||
operations: [],
|
||||
}),
|
||||
).toMatchObject({ state: "stopped", action: { kind: "start", label: "Start workspace" } });
|
||||
|
||||
expect(
|
||||
resolveWorkspaceAccessState({
|
||||
|
|
@ -353,7 +356,20 @@ describe("resolveWorkspaceAccessState", () => {
|
|||
operations: [],
|
||||
handoffFailure: { reason: "runtime_not_running" },
|
||||
}),
|
||||
).toMatchObject({ state: "provisioning", action: { kind: "start" } });
|
||||
).toMatchObject({ state: "stopped", action: { kind: "start" } });
|
||||
});
|
||||
|
||||
it("shows provisioning only while a runtime service is actually starting", () => {
|
||||
expect(
|
||||
resolveWorkspaceAccessState({
|
||||
runtimeServices: [runtimeService({ status: "starting", healthStatus: "unknown", url: null })],
|
||||
operations: [],
|
||||
handoffFailure: { reason: "runtime_not_running" },
|
||||
}),
|
||||
).toMatchObject({
|
||||
state: "provisioning",
|
||||
action: { kind: "wait", label: "Starting workspace" },
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses to call an unhealthy running runtime ready", () => {
|
||||
|
|
@ -374,7 +390,7 @@ describe("resolveWorkspaceAccessState", () => {
|
|||
|
||||
it("handles missing operations and services without throwing", () => {
|
||||
expect(resolveWorkspaceAccessState({ runtimeServices: null, operations: undefined }).state)
|
||||
.toBe("provisioning");
|
||||
.toBe("stopped");
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -37,8 +37,10 @@ export type WorkspaceAccessNotice = {
|
|||
action: WorkspaceAccessAction;
|
||||
};
|
||||
|
||||
export type WorkspaceAccessDisplayState = WorkspaceReadinessState | "stopped";
|
||||
|
||||
export type WorkspaceAccessState = {
|
||||
state: WorkspaceReadinessState;
|
||||
state: WorkspaceAccessDisplayState;
|
||||
title: string;
|
||||
description: string;
|
||||
action: WorkspaceAccessAction;
|
||||
|
|
@ -147,6 +149,9 @@ export function resolveWorkspaceAccessState(input: {
|
|||
const servingService = runtimeServices.find(
|
||||
(service) => service.status === "running" && service.healthStatus === "healthy" && service.url,
|
||||
);
|
||||
const startingService = runtimeServices.find(
|
||||
(service) => service.status === "provisioning" || service.status === "starting",
|
||||
);
|
||||
const repairFinishedAt = timestampMs(repair?.finishedAt);
|
||||
const servingServiceStartedAt = timestampMs(servingService?.startedAt);
|
||||
const provisionFinishedAt = timestampMs(provision?.finishedAt);
|
||||
|
|
@ -227,9 +232,9 @@ export function resolveWorkspaceAccessState(input: {
|
|||
// runtime rows, because it is the only signal that looked inside the clone.
|
||||
const staleNotReadyFailure = failure?.reason === "workspace_not_ready" && readinessConfirmsServing;
|
||||
if (failure && !staleNotReadyFailure) {
|
||||
if (failure.reason === "runtime_not_running" && !servingService) {
|
||||
if (failure.reason === "runtime_not_running" && !servingService && !startingService) {
|
||||
return {
|
||||
state: "provisioning",
|
||||
state: "stopped",
|
||||
title: "Workspace is not running",
|
||||
description: "Start the workspace runtime to publish its board.",
|
||||
action: { kind: "start", label: "Start workspace" },
|
||||
|
|
@ -266,6 +271,16 @@ export function resolveWorkspaceAccessState(input: {
|
|||
}
|
||||
}
|
||||
|
||||
if (startingService) {
|
||||
return {
|
||||
state: "provisioning",
|
||||
title: "Workspace is starting",
|
||||
description: "Paperclip is starting the workspace runtime and waiting for its board URL.",
|
||||
action: { kind: "wait", label: "Starting workspace" },
|
||||
handoffAvailable,
|
||||
};
|
||||
}
|
||||
|
||||
if (servingService) {
|
||||
return {
|
||||
state: "ready",
|
||||
|
|
@ -292,7 +307,7 @@ export function resolveWorkspaceAccessState(input: {
|
|||
}
|
||||
|
||||
return {
|
||||
state: "provisioning",
|
||||
state: "stopped",
|
||||
title: "Workspace is not running",
|
||||
description: "Start the workspace runtime to publish its board.",
|
||||
action: { kind: "start", label: "Start workspace" },
|
||||
|
|
|
|||
|
|
@ -242,14 +242,11 @@ function makeInstanceSettings({
|
|||
enableIsolatedWorkspaces: true,
|
||||
enableStreamlinedLeftNavigation: false,
|
||||
enableConferenceRoomChat: false,
|
||||
enableTaskWatchdogs: true,
|
||||
enableIssuePlanDecompositions: true,
|
||||
enableExperimentalFileViewer: false,
|
||||
enableExternalObjects: false,
|
||||
enableBuiltInAgents,
|
||||
autoRestartDevServerWhenIdle: false,
|
||||
enableIssueGraphLivenessAutoRecovery: false,
|
||||
issueGraphLivenessAutoRecoveryLookbackHours: 24,
|
||||
},
|
||||
createdAt: new Date("2026-01-01T00:00:00Z"),
|
||||
updatedAt: new Date("2026-01-01T00:00:00Z"),
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|||
import type {
|
||||
InstanceExperimentalSettings as InstanceExperimentalSettingsPayload,
|
||||
InstanceExperimentalSettingsWithManaged,
|
||||
IssueGraphLivenessAutoRecoveryPreview,
|
||||
} from "@paperclipai/shared";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { InstanceExperimentalSettings } from "./InstanceExperimentalSettings";
|
||||
|
|
@ -15,8 +14,6 @@ import { queryKeys } from "../lib/queryKeys";
|
|||
const mockInstanceSettingsApi = vi.hoisted(() => ({
|
||||
getExperimental: vi.fn(),
|
||||
updateExperimental: vi.fn(),
|
||||
previewIssueGraphLivenessAutoRecovery: vi.fn(),
|
||||
runIssueGraphLivenessAutoRecovery: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/api/instanceSettings", () => ({
|
||||
|
|
@ -47,8 +44,6 @@ const CONFERENCE_TOGGLE_SELECTOR =
|
|||
'button[aria-label="Toggle conference room chat experimental setting"]';
|
||||
const STREAMLINED_TOGGLE_SELECTOR =
|
||||
'button[aria-label="Toggle streamlined left navigation experimental setting"]';
|
||||
const TASK_WATCHDOGS_TOGGLE_SELECTOR =
|
||||
'button[aria-label="Toggle task watchdogs experimental setting"]';
|
||||
const CLASSIC_TASK_INTERFACE_TOGGLE_SELECTOR =
|
||||
'button[aria-label="Toggle classic task interface experimental setting"]';
|
||||
const GOALS_SIDEBAR_LINK_TOGGLE_SELECTOR =
|
||||
|
|
@ -68,8 +63,6 @@ const SUMMARIES_TOGGLE_SELECTOR =
|
|||
'button[aria-label="Toggle summaries experimental setting"]';
|
||||
const STATUS_CARDS_TOGGLE_SELECTOR =
|
||||
'button[aria-label="Toggle status cards experimental setting"]';
|
||||
const AUTO_RECOVERY_TOGGLE_SELECTOR =
|
||||
'button[aria-label="Toggle task graph liveness auto-recovery"]';
|
||||
const PAPERCLIP_RUNNER_TOGGLE_SELECTOR =
|
||||
'button[aria-label="Toggle Paperclip Runner experimental setting"]';
|
||||
|
||||
|
|
@ -94,14 +87,11 @@ function defaultExperimentalSettings(): InstanceExperimentalSettingsPayload {
|
|||
enableStatusCards: false,
|
||||
enableDecisions: false,
|
||||
enableGoalsSidebarLink: false,
|
||||
enableTaskWatchdogs: false,
|
||||
enableServerInfoDebugView: false,
|
||||
enablePaperclipDeveloperMode: false,
|
||||
enableSimplifiedEnglishInteractions: false,
|
||||
enableSmokeLab: false,
|
||||
autoRestartDevServerWhenIdle: false,
|
||||
enableIssueGraphLivenessAutoRecovery: false,
|
||||
issueGraphLivenessAutoRecoveryLookbackHours: 24,
|
||||
enableWorkspaceBranchReconcileForward: true,
|
||||
enableWorkspaceDirtyQuarantineRepair: true,
|
||||
enableOwnerInstanceAdmin: false,
|
||||
|
|
@ -113,18 +103,6 @@ function defaultExperimentalSettings(): InstanceExperimentalSettingsPayload {
|
|||
};
|
||||
}
|
||||
|
||||
function emptyRecoveryPreview(): IssueGraphLivenessAutoRecoveryPreview {
|
||||
return {
|
||||
lookbackHours: 24,
|
||||
cutoff: "2026-07-12T16:00:00.000Z",
|
||||
generatedAt: "2026-07-13T16:00:00.000Z",
|
||||
findings: 0,
|
||||
recoverableFindings: 0,
|
||||
skippedOutsideLookback: 0,
|
||||
items: [],
|
||||
};
|
||||
}
|
||||
|
||||
const WORKTREE_RUN_EXECUTION_TOGGLE_SELECTOR =
|
||||
'button[aria-label="Toggle worktree run execution setting"]';
|
||||
|
||||
|
|
@ -262,45 +240,11 @@ describe("InstanceExperimentalSettings — Conference Room Chat card (PAP-11233)
|
|||
expect(mockInstanceSettingsApi.updateExperimental).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders and patches the Task Watchdogs experimental toggle on and off", async () => {
|
||||
it("does not render a Task Watchdogs toggle because watchdogs are always enabled", async () => {
|
||||
await renderPage();
|
||||
|
||||
expect(container.textContent).toContain("Task Watchdogs");
|
||||
expect(container.textContent).toContain(
|
||||
"Show task detail controls for configuring watchdog agents that verify stopped task subtrees and restore live paths when work should continue.",
|
||||
);
|
||||
|
||||
const toggle = container.querySelector<HTMLButtonElement>(TASK_WATCHDOGS_TOGGLE_SELECTOR);
|
||||
expect(toggle?.getAttribute("aria-checked")).toBe("false");
|
||||
|
||||
await act(async () => {
|
||||
toggle?.click();
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(mockInstanceSettingsApi.updateExperimental).toHaveBeenCalledWith({
|
||||
enableTaskWatchdogs: true,
|
||||
});
|
||||
expect(toggle?.getAttribute("aria-checked")).toBe("true");
|
||||
|
||||
flushSync(() => {
|
||||
root?.unmount();
|
||||
});
|
||||
root = null;
|
||||
container.textContent = "";
|
||||
await renderPage();
|
||||
|
||||
const enabledToggle = container.querySelector<HTMLButtonElement>(TASK_WATCHDOGS_TOGGLE_SELECTOR);
|
||||
expect(enabledToggle?.getAttribute("aria-checked")).toBe("true");
|
||||
|
||||
await act(async () => {
|
||||
enabledToggle?.click();
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(mockInstanceSettingsApi.updateExperimental).toHaveBeenLastCalledWith({
|
||||
enableTaskWatchdogs: false,
|
||||
});
|
||||
expect(container.textContent).not.toContain("Task Watchdogs");
|
||||
expect(container.querySelector('button[aria-label="Toggle task watchdogs experimental setting"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("does not expose the retired Runner Preview Ingress setting separately", async () => {
|
||||
|
|
@ -673,89 +617,6 @@ describe("InstanceExperimentalSettings — Conference Room Chat card (PAP-11233)
|
|||
expect(toggle?.getAttribute("aria-checked")).toBe("true");
|
||||
});
|
||||
|
||||
it("removes the auto-recovery confirmation overlay after enabling only", async () => {
|
||||
mockInstanceSettingsApi.previewIssueGraphLivenessAutoRecovery.mockResolvedValue(emptyRecoveryPreview());
|
||||
await renderPage();
|
||||
|
||||
const toggle = container.querySelector<HTMLButtonElement>(AUTO_RECOVERY_TOGGLE_SELECTOR);
|
||||
expect(toggle?.getAttribute("aria-checked")).toBe("false");
|
||||
|
||||
await act(async () => {
|
||||
toggle?.click();
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(mockInstanceSettingsApi.previewIssueGraphLivenessAutoRecovery).toHaveBeenCalledWith({
|
||||
lookbackHours: 24,
|
||||
});
|
||||
expect(document.body.textContent).toContain("Confirm auto-recovery");
|
||||
expect(document.body.querySelector('[data-slot="dialog-overlay"]')).not.toBeNull();
|
||||
|
||||
const enableOnlyButton = [...document.body.querySelectorAll<HTMLButtonElement>("button")].find(
|
||||
(button) => button.textContent === "Enable only",
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
enableOnlyButton?.click();
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(mockInstanceSettingsApi.updateExperimental).toHaveBeenCalledWith({
|
||||
enableIssueGraphLivenessAutoRecovery: true,
|
||||
issueGraphLivenessAutoRecoveryLookbackHours: 24,
|
||||
});
|
||||
expect(document.body.textContent).not.toContain("Confirm auto-recovery");
|
||||
expect(document.body.querySelector('[data-slot="dialog-overlay"]')).toBeNull();
|
||||
const enabledToggle = container.querySelector<HTMLButtonElement>(AUTO_RECOVERY_TOGGLE_SELECTOR);
|
||||
expect(enabledToggle?.getAttribute("aria-checked")).toBe("true");
|
||||
});
|
||||
|
||||
it("removes the auto-recovery confirmation overlay after enabling and running", async () => {
|
||||
mockInstanceSettingsApi.previewIssueGraphLivenessAutoRecovery.mockResolvedValue(emptyRecoveryPreview());
|
||||
mockInstanceSettingsApi.runIssueGraphLivenessAutoRecovery.mockResolvedValue({
|
||||
findings: 0,
|
||||
autoRecoveryEnabled: true,
|
||||
lookbackHours: 24,
|
||||
cutoff: "2026-07-12T16:00:00.000Z",
|
||||
escalationsCreated: 0,
|
||||
existingEscalations: 0,
|
||||
skipped: 0,
|
||||
skippedAutoRecoveryDisabled: 0,
|
||||
});
|
||||
await renderPage();
|
||||
|
||||
const toggle = container.querySelector<HTMLButtonElement>(AUTO_RECOVERY_TOGGLE_SELECTOR);
|
||||
expect(toggle?.getAttribute("aria-checked")).toBe("false");
|
||||
|
||||
await act(async () => {
|
||||
toggle?.click();
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(document.body.textContent).toContain("Confirm auto-recovery");
|
||||
expect(document.body.querySelector('[data-slot="dialog-overlay"]')).not.toBeNull();
|
||||
|
||||
const enableAndRunButton = [...document.body.querySelectorAll<HTMLButtonElement>("button")].find(
|
||||
(button) => button.textContent === "Enable",
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
enableAndRunButton?.click();
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(mockInstanceSettingsApi.updateExperimental).toHaveBeenCalledWith({
|
||||
enableIssueGraphLivenessAutoRecovery: true,
|
||||
issueGraphLivenessAutoRecoveryLookbackHours: 24,
|
||||
});
|
||||
expect(mockInstanceSettingsApi.runIssueGraphLivenessAutoRecovery).toHaveBeenCalledWith({
|
||||
lookbackHours: 24,
|
||||
});
|
||||
expect(document.body.textContent).not.toContain("Confirm auto-recovery");
|
||||
expect(document.body.querySelector('[data-slot="dialog-overlay"]')).toBeNull();
|
||||
const enabledToggle = container.querySelector<HTMLButtonElement>(AUTO_RECOVERY_TOGGLE_SELECTOR);
|
||||
expect(enabledToggle?.getAttribute("aria-checked")).toBe("true");
|
||||
});
|
||||
});
|
||||
|
||||
describe("InstanceExperimentalSettings — cloud-managed keys", () => {
|
||||
|
|
@ -795,6 +656,7 @@ describe("InstanceExperimentalSettings — cloud-managed keys", () => {
|
|||
root?.unmount();
|
||||
});
|
||||
root = null;
|
||||
queryClient?.clear();
|
||||
container.remove();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
|
@ -862,58 +724,6 @@ describe("InstanceExperimentalSettings — cloud-managed keys", () => {
|
|||
expect(mockInstanceSettingsApi.updateExperimental).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("locks the managed auto-recovery toggle without opening the preview dialog", async () => {
|
||||
await renderPage({
|
||||
...defaultExperimentalSettings(),
|
||||
managedKeys: {
|
||||
enableIssueGraphLivenessAutoRecovery: { managed: true, managedBy: "paperclip-cloud" },
|
||||
},
|
||||
});
|
||||
|
||||
const toggle = container.querySelector<HTMLButtonElement>(AUTO_RECOVERY_TOGGLE_SELECTOR);
|
||||
expect(toggle?.disabled).toBe(true);
|
||||
|
||||
await act(() => toggle?.click());
|
||||
await flushReact();
|
||||
|
||||
expect(mockInstanceSettingsApi.previewIssueGraphLivenessAutoRecovery).not.toHaveBeenCalled();
|
||||
expect(mockInstanceSettingsApi.updateExperimental).not.toHaveBeenCalled();
|
||||
expect(document.body.textContent).not.toContain("Confirm auto-recovery");
|
||||
});
|
||||
|
||||
it("closes an open recovery preview when a refresh marks auto-recovery as managed", async () => {
|
||||
mockInstanceSettingsApi.previewIssueGraphLivenessAutoRecovery.mockResolvedValue(
|
||||
emptyRecoveryPreview(),
|
||||
);
|
||||
const settings = defaultExperimentalSettings();
|
||||
await renderPage(settings);
|
||||
|
||||
const toggle = container.querySelector<HTMLButtonElement>(AUTO_RECOVERY_TOGGLE_SELECTOR);
|
||||
await act(() => toggle?.click());
|
||||
await flushReact();
|
||||
expect(document.body.textContent).toContain("Confirm auto-recovery");
|
||||
|
||||
const managedSettings: InstanceExperimentalSettingsWithManaged = {
|
||||
...settings,
|
||||
enableIssueGraphLivenessAutoRecovery: true,
|
||||
managedKeys: {
|
||||
enableIssueGraphLivenessAutoRecovery: { managed: true, managedBy: "paperclip-cloud" },
|
||||
},
|
||||
};
|
||||
await act(() => {
|
||||
queryClient.setQueryData(queryKeys.instance.experimentalSettings, managedSettings);
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(document.body.textContent).not.toContain("Confirm auto-recovery");
|
||||
expect(document.body.querySelector('[data-slot="dialog-overlay"]')).toBeNull();
|
||||
expect(mockInstanceSettingsApi.updateExperimental).not.toHaveBeenCalled();
|
||||
expect(mockInstanceSettingsApi.runIssueGraphLivenessAutoRecovery).not.toHaveBeenCalled();
|
||||
|
||||
const lockedToggle = container.querySelector<HTMLButtonElement>(AUTO_RECOVERY_TOGGLE_SELECTOR);
|
||||
expect(lockedToggle?.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("renders no managed badge and keeps toggles editable without managedKeys (self-hosted)", async () => {
|
||||
await renderPage(defaultExperimentalSettings());
|
||||
|
||||
|
|
@ -963,23 +773,43 @@ describe("InstanceExperimentalSettings — card ordering and headings (PAP-393)"
|
|||
});
|
||||
root = null;
|
||||
container.remove();
|
||||
setWorktreeRuntimeMeta(false);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders every card heading in alphabetical order", async () => {
|
||||
it("groups developer and legacy settings into sections", async () => {
|
||||
setWorktreeRuntimeMeta(true);
|
||||
await renderPage(defaultExperimentalSettings());
|
||||
|
||||
const headings = [...container.querySelectorAll("h2")].map(
|
||||
const headings = [...container.querySelectorAll("section > div > h2")].map(
|
||||
(heading) => heading.textContent ?? "",
|
||||
);
|
||||
expect(headings).toEqual([
|
||||
"Experimental features",
|
||||
"Paperclip Developer Mode",
|
||||
"Legacy",
|
||||
]);
|
||||
|
||||
// Sanity: the page rendered a meaningful set of cards, not an empty list.
|
||||
expect(headings.length).toBeGreaterThan(10);
|
||||
const sections = [...container.querySelectorAll("section")];
|
||||
expect(sections.at(0)?.textContent).not.toContain("Run tasks in this worktree");
|
||||
expect(sections.at(0)?.textContent).not.toContain("Managed Environment Only");
|
||||
expect(sections.at(-2)?.textContent).toContain("Run tasks in this worktree");
|
||||
expect(sections.at(-2)?.textContent).toContain("Managed Environment Only");
|
||||
expect(sections.at(-2)?.textContent).toContain("Auto-Restart Dev Server When Idle");
|
||||
expect(sections.at(-2)?.textContent).toContain("Server Info Debug View");
|
||||
expect(sections.at(-2)?.textContent).toContain("Smoke Lab");
|
||||
expect(sections.at(-2)?.textContent).toContain("Task Plan Decomposition");
|
||||
expect(sections.at(-1)?.textContent).toContain("These features are going to be removed.");
|
||||
expect(sections.at(-1)?.textContent).toContain("Classic Task Interface");
|
||||
expect(sections.at(-1)?.textContent).toContain("Goals Sidebar Link");
|
||||
});
|
||||
|
||||
const alphabetical = [...headings].sort((a, b) =>
|
||||
a.localeCompare(b, undefined, { sensitivity: "base" }),
|
||||
);
|
||||
expect(headings).toEqual(alphabetical);
|
||||
it("renders setting cards without a background color", async () => {
|
||||
await renderPage(defaultExperimentalSettings());
|
||||
|
||||
const cards = [...container.querySelectorAll('[data-slot="card"]')];
|
||||
expect(cards.length).toBeGreaterThan(10);
|
||||
expect(cards.every((card) => card.classList.contains("bg-transparent"))).toBe(true);
|
||||
});
|
||||
|
||||
it("no longer renders an 'Experimental' secondary badge on any card", async () => {
|
||||
|
|
@ -1033,7 +863,7 @@ describe("InstanceExperimentalSettings — operator-hidden cards", () => {
|
|||
|
||||
expect(container.textContent).not.toContain("Enable Environments");
|
||||
expect(container.textContent).toContain("Beta skills");
|
||||
expect(container.textContent).toContain("Task Watchdogs");
|
||||
expect(container.textContent).toContain("Apps");
|
||||
});
|
||||
|
||||
it("shows every toggle when nothing is hidden", async () => {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue