mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 09:41:04 +01:00
feat(ui): show worktree run-engine boot truth
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
4d677ff672
commit
26a70232d5
14 files changed
+621
-120
No files matched your search
@@ -822,6 +822,9 @@ export type {
|
||||
ManagedSettingMetadata,
|
||||
IssueGraphLivenessAutoRecoveryPreview,
|
||||
IssueGraphLivenessAutoRecoveryPreviewItem,
|
||||
WorktreeRunExecutionSuppressedReason,
|
||||
WorktreeRunExecutionActivationState,
|
||||
WorktreeRunEngineStatus,
|
||||
BackupRetentionPolicy,
|
||||
Agent,
|
||||
AgentAccessState,
|
||||
@@ -1611,6 +1614,7 @@ export {
|
||||
MIN_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS,
|
||||
MAX_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS,
|
||||
PAPERCLIP_CLOUD_MANAGED_BY,
|
||||
WORKTREE_SEED_QUARANTINE_ERROR_CODE,
|
||||
} from "./types/instance.js";
|
||||
|
||||
export type {
|
||||
|
||||
@@ -100,7 +100,11 @@ export type {
|
||||
BackupRetentionPolicy,
|
||||
IssueGraphLivenessAutoRecoveryPreview,
|
||||
IssueGraphLivenessAutoRecoveryPreviewItem,
|
||||
WorktreeRunExecutionSuppressedReason,
|
||||
WorktreeRunExecutionActivationState,
|
||||
WorktreeRunEngineStatus,
|
||||
} from "./instance.js";
|
||||
export { WORKTREE_SEED_QUARANTINE_ERROR_CODE } from "./instance.js";
|
||||
export type {
|
||||
SmokeLabServiceStatus,
|
||||
SmokeRun,
|
||||
|
||||
@@ -151,6 +151,62 @@ export interface InstanceExperimentalSettingsWithManaged extends InstanceExperim
|
||||
managedKeys?: Partial<Record<ManagedExperimentalFeatureKey, ManagedSettingMetadata>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Error code stamped on heartbeat runs that were copied into a worktree seed and
|
||||
* quarantined (cancelled, inert) so they never execute in the preview instance.
|
||||
* Owned by the CLI worktree seed flow (cli/src/commands/worktree.ts) and surfaced
|
||||
* in the UI as "inherited — inactive" runs.
|
||||
*/
|
||||
export const WORKTREE_SEED_QUARANTINE_ERROR_CODE = "worktree_seed_quarantine";
|
||||
|
||||
/**
|
||||
* Why the worktree run engine is suppressed. Mirrors the server's fail-closed
|
||||
* ladder in `resolveWorktreeRunExecutionActivation`.
|
||||
*/
|
||||
export type WorktreeRunExecutionSuppressedReason =
|
||||
| "not_worktree_runtime"
|
||||
| "flag_disabled"
|
||||
| "missing_cutoff"
|
||||
| "missing_instance_id"
|
||||
| "missing_seed_epoch"
|
||||
| "instance_id_mismatch"
|
||||
| "settings_read_error";
|
||||
|
||||
/**
|
||||
* Resolved worktree run-engine activation. `armed` means the scheduler executes
|
||||
* runs created after `cutoff`; otherwise `reason` explains the suppression.
|
||||
*/
|
||||
export type WorktreeRunExecutionActivationState =
|
||||
| {
|
||||
armed: true;
|
||||
cutoff: string;
|
||||
activationInstanceId: string;
|
||||
instanceNonce: string;
|
||||
seedEpoch: string;
|
||||
reason: null;
|
||||
}
|
||||
| {
|
||||
armed: false;
|
||||
cutoff: null;
|
||||
activationInstanceId: string | null;
|
||||
reason: WorktreeRunExecutionSuppressedReason;
|
||||
};
|
||||
|
||||
/**
|
||||
* Authoritative boot-truth for the worktree run engine, served to the UI so a
|
||||
* preview user can tell "inherited, inert" state from live execution at a glance.
|
||||
*/
|
||||
export interface WorktreeRunEngineStatus {
|
||||
/** True when the server runtime is a worktree preview (`PAPERCLIP_IN_WORKTREE`). */
|
||||
inWorktree: boolean;
|
||||
/** Activation resolved server-side (mirrors the scheduler gate). */
|
||||
activation: WorktreeRunExecutionActivationState;
|
||||
/** Current boot's server-managed instance identity (nonce), if stamped. */
|
||||
instanceNonce: string | null;
|
||||
/** Count of inherited heartbeat runs quarantined at seed time (durable evidence). */
|
||||
quarantinedRunCount: number;
|
||||
}
|
||||
|
||||
export interface InstanceSettings {
|
||||
id: string;
|
||||
defaultEnvironmentId: string | null;
|
||||
|
||||
@@ -242,6 +242,14 @@ export function instanceSettingsRoutes(db: Db) {
|
||||
},
|
||||
);
|
||||
|
||||
router.get("/instance/settings/experimental/worktree-run-engine", async (req, res) => {
|
||||
// Boot-truth for the worktree run engine (PAP-14312). Readable by any
|
||||
// authenticated org member or instance admin so the persistent banner and
|
||||
// inherited-run surfaces render for everyone viewing a preview instance.
|
||||
assertBoardOrgAccess(req);
|
||||
res.json(await svc.getWorktreeRunEngineStatus());
|
||||
});
|
||||
|
||||
router.post(
|
||||
"/instance/settings/experimental/issue-graph-liveness-auto-recovery/preview",
|
||||
validate(issueGraphLivenessAutoRecoveryRequestSchema),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { companies, instanceSettings } from "@paperclipai/db";
|
||||
import { companies, heartbeatRuns, instanceSettings } from "@paperclipai/db";
|
||||
|
||||
/**
|
||||
* A `Db` or an open transaction handle — the subset of query builders the
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
DEFAULT_BACKUP_RETENTION,
|
||||
DEFAULT_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS,
|
||||
PAPERCLIP_CLOUD_MANAGED_BY,
|
||||
WORKTREE_SEED_QUARANTINE_ERROR_CODE,
|
||||
instanceGeneralSettingsSchema,
|
||||
type InstanceGeneralSettings,
|
||||
instanceExperimentalSettingsSchema,
|
||||
@@ -28,6 +29,9 @@ import {
|
||||
type InstanceSettings,
|
||||
type PatchInstanceSettings,
|
||||
type PatchInstanceExperimentalSettings,
|
||||
type WorktreeRunExecutionSuppressedReason,
|
||||
type WorktreeRunExecutionActivationState,
|
||||
type WorktreeRunEngineStatus,
|
||||
} from "@paperclipai/shared";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { getManagedInstanceConfig, type ManagedInstanceConfig } from "./managed-config.js";
|
||||
@@ -44,30 +48,10 @@ interface InstanceSettingsServiceOptions {
|
||||
generateSeedEpoch?: () => string;
|
||||
}
|
||||
|
||||
type WorktreeRunExecutionSuppressedReason =
|
||||
| "not_worktree_runtime"
|
||||
| "flag_disabled"
|
||||
| "missing_cutoff"
|
||||
| "missing_instance_id"
|
||||
| "missing_seed_epoch"
|
||||
| "instance_id_mismatch"
|
||||
| "settings_read_error";
|
||||
|
||||
export type WorktreeRunExecutionActivationState =
|
||||
| {
|
||||
armed: true;
|
||||
cutoff: string;
|
||||
activationInstanceId: string;
|
||||
instanceNonce: string;
|
||||
seedEpoch: string;
|
||||
reason: null;
|
||||
}
|
||||
| {
|
||||
armed: false;
|
||||
cutoff: null;
|
||||
activationInstanceId: string | null;
|
||||
reason: WorktreeRunExecutionSuppressedReason;
|
||||
};
|
||||
export type {
|
||||
WorktreeRunExecutionSuppressedReason,
|
||||
WorktreeRunExecutionActivationState,
|
||||
} from "@paperclipai/shared";
|
||||
|
||||
export function isTruthyRuntimeEnvValue(value: string | undefined) {
|
||||
return typeof value === "string" && TRUTHY_RUNTIME_ENV_VALUES.has(value.trim().toLowerCase());
|
||||
@@ -509,6 +493,31 @@ export function instanceSettingsService(db: Db, options: InstanceSettingsService
|
||||
return toInstanceSettings(updated ?? current);
|
||||
},
|
||||
|
||||
getWorktreeRunEngineStatus: async (): Promise<WorktreeRunEngineStatus> => {
|
||||
const experimental = normalizeExperimentalSettings((await getOrCreateRow()).experimental);
|
||||
const inWorktree = isTruthyRuntimeEnvValue(runtimeEnv.PAPERCLIP_IN_WORKTREE);
|
||||
const activation = await resolveWorktreeRunExecutionActivationState({
|
||||
getExperimental: async () => experimental,
|
||||
runtimeEnv,
|
||||
});
|
||||
// Only quarantined-run rows survive a seed (P1 deletes the wakeups/monitors
|
||||
// it clears), so this count is the durable evidence of what was neutralized.
|
||||
let quarantinedRunCount = 0;
|
||||
if (inWorktree) {
|
||||
const [row] = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.errorCode, WORKTREE_SEED_QUARANTINE_ERROR_CODE));
|
||||
quarantinedRunCount = row?.count ?? 0;
|
||||
}
|
||||
return {
|
||||
inWorktree,
|
||||
activation,
|
||||
instanceNonce: experimental.worktreeRunExecutionInstanceNonce,
|
||||
quarantinedRunCount,
|
||||
};
|
||||
},
|
||||
|
||||
listCompanyIds: async (): Promise<string[]> =>
|
||||
db
|
||||
.select({ id: companies.id })
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
PatchInstanceSettings,
|
||||
PatchInstanceGeneralSettings,
|
||||
PatchInstanceExperimentalSettings,
|
||||
WorktreeRunEngineStatus,
|
||||
} from "@paperclipai/shared";
|
||||
import { api } from "./client";
|
||||
|
||||
@@ -22,6 +23,8 @@ export const instanceSettingsApi = {
|
||||
api.get<InstanceExperimentalSettingsWithManaged>("/instance/settings/experimental"),
|
||||
updateExperimental: (patch: PatchInstanceExperimentalSettings) =>
|
||||
api.patch<InstanceExperimentalSettingsWithManaged>("/instance/settings/experimental", patch),
|
||||
getWorktreeRunEngine: () =>
|
||||
api.get<WorktreeRunEngineStatus>("/instance/settings/experimental/worktree-run-engine"),
|
||||
previewIssueGraphLivenessAutoRecovery: (input: { lookbackHours?: number }) =>
|
||||
api.post<IssueGraphLivenessAutoRecoveryPreview>(
|
||||
"/instance/settings/experimental/issue-graph-liveness-auto-recovery/preview",
|
||||
|
||||
@@ -608,4 +608,21 @@ describe("IssueRunLedger", () => {
|
||||
|
||||
expect(container.querySelector('[data-testid="responsible-user-denial-notice"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("marks worktree-seed quarantined runs as inherited and inactive", () => {
|
||||
renderLedger({
|
||||
runs: [
|
||||
createRun({
|
||||
runId: "run-inherited-1",
|
||||
status: "cancelled",
|
||||
livenessState: null,
|
||||
errorCode: "worktree_seed_quarantine",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("Inherited · inactive");
|
||||
// The inherited badge replaces the noisy liveness classification.
|
||||
expect(container.textContent).not.toContain("No liveness data");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,10 @@
|
||||
import { useMemo, useState, type ReactNode } from "react";
|
||||
import type { ActivityEvent, Issue, Agent } from "@paperclipai/shared";
|
||||
import { isResponsibleUserDenialCode, responsibleUserLabel } from "@paperclipai/shared";
|
||||
import {
|
||||
isResponsibleUserDenialCode,
|
||||
responsibleUserLabel,
|
||||
WORKTREE_SEED_QUARANTINE_ERROR_CODE,
|
||||
} from "@paperclipai/shared";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Link } from "@/lib/router";
|
||||
import { accessApi, type CurrentBoardAccess } from "../api/access";
|
||||
@@ -292,6 +296,15 @@ function isActiveRun(run: Pick<LedgerRun, "status" | "isLive">) {
|
||||
return run.isLive || ACTIVE_RUN_STATUSES.has(run.status);
|
||||
}
|
||||
|
||||
/**
|
||||
* PAP-14312: runs copied into a worktree seed are quarantined (cancelled +
|
||||
* `worktree_seed_quarantine`) so they never execute in the preview. Render them
|
||||
* as "inherited — inactive" instead of a scary failed/cancelled run.
|
||||
*/
|
||||
function isInheritedInactiveRun(run: Pick<RunForIssue, "errorCode">) {
|
||||
return run.errorCode === WORKTREE_SEED_QUARANTINE_ERROR_CODE;
|
||||
}
|
||||
|
||||
function runSummary(run: LedgerRun, agentMap: ReadonlyMap<string, Pick<Agent, "name">>) {
|
||||
const agentName = compactAgentName(run, agentMap);
|
||||
if (run.status === "running") return `Running now by ${agentName}`;
|
||||
@@ -696,6 +709,7 @@ export function IssueRunLedgerContent({
|
||||
return <div key={`activity:${item.id}`}>{renderActivityEvent?.(item.event)}</div>;
|
||||
}
|
||||
const run = item.run;
|
||||
const inheritedInactive = isInheritedInactiveRun(run);
|
||||
const liveness = livenessCopyForRun(run);
|
||||
const stopReason = stopReasonLabel(run);
|
||||
const duration = formatDuration(run.startedAt, run.finishedAt);
|
||||
@@ -734,21 +748,31 @@ export function IssueRunLedgerContent({
|
||||
<span className="rounded-md border border-border px-1.5 py-0.5 text-(length:--text-micro) capitalize text-muted-foreground">
|
||||
{statusLabel(run.status)}
|
||||
</span>
|
||||
{inheritedInactive ? (
|
||||
<span
|
||||
className="rounded-md border border-slate-400/40 bg-slate-400/10 px-1.5 py-0.5 text-(length:--text-micro) font-medium text-slate-600 dark:text-slate-300"
|
||||
title="Copied from another instance when this worktree was seeded. It never executed here."
|
||||
>
|
||||
Inherited · inactive
|
||||
</span>
|
||||
) : null}
|
||||
{run.isLive ? (
|
||||
<span className="inline-flex items-center gap-1 rounded-md border border-blue-500/30 bg-blue-500/10 px-1.5 py-0.5 text-(length:--text-micro) text-blue-700 dark:text-blue-300">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-blue-500" />
|
||||
live
|
||||
</span>
|
||||
) : null}
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-md border px-1.5 py-0.5 text-(length:--text-micro) font-medium",
|
||||
liveness.tone,
|
||||
)}
|
||||
title={liveness.description}
|
||||
>
|
||||
{liveness.label}
|
||||
</span>
|
||||
{inheritedInactive ? null : (
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-md border px-1.5 py-0.5 text-(length:--text-micro) font-medium",
|
||||
liveness.tone,
|
||||
)}
|
||||
title={liveness.description}
|
||||
>
|
||||
{liveness.label}
|
||||
</span>
|
||||
)}
|
||||
{exhausted ? (
|
||||
<span className="rounded-md border border-red-500/30 bg-red-500/10 px-1.5 py-0.5 text-(length:--text-micro) font-medium text-red-700 dark:text-red-300">
|
||||
Exhausted
|
||||
|
||||
@@ -17,6 +17,7 @@ import { KeyboardShortcutsCheatsheet } from "./KeyboardShortcutsCheatsheet";
|
||||
import { ToastViewport } from "./ToastViewport";
|
||||
import { MobileBottomNav } from "./MobileBottomNav";
|
||||
import { WorktreeBanner } from "./WorktreeBanner";
|
||||
import { WorktreeRunEngineBanner } from "./WorktreeRunEngineBanner";
|
||||
import { DevRestartBanner } from "./DevRestartBanner";
|
||||
import { StandaloneBrowserControls } from "./StandaloneBrowserControls";
|
||||
import { RouteErrorBoundary } from "./RouteErrorBoundary";
|
||||
@@ -592,6 +593,7 @@ export function Layout() {
|
||||
Skip to Main Content
|
||||
</a>
|
||||
<WorktreeBanner />
|
||||
<WorktreeRunEngineBanner variant="strip" />
|
||||
<DevRestartBanner devServer={health?.devServer} />
|
||||
<div className={cn("min-h-0 flex-1", isMobile ? "w-full" : "flex overflow-clip")}>
|
||||
{isMobile && sidebarOpen && (
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { WorktreeRunEngineStatus } from "@paperclipai/shared";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { WorktreeRunEngineBanner } from "./WorktreeRunEngineBanner";
|
||||
|
||||
function render(status: WorktreeRunEngineStatus | undefined, variant: "strip" | "detail" = "detail") {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
flushSync(() => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<WorktreeRunEngineBanner variant={variant} status={status} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
return { container, root };
|
||||
}
|
||||
|
||||
function armed(overrides: Partial<WorktreeRunEngineStatus> = {}): WorktreeRunEngineStatus {
|
||||
return {
|
||||
inWorktree: true,
|
||||
activation: {
|
||||
armed: true,
|
||||
cutoff: "2026-07-10T18:34:00.000Z",
|
||||
activationInstanceId: "nonce-aaaaaaaa1111",
|
||||
reason: null,
|
||||
},
|
||||
instanceNonce: "nonce-aaaaaaaa1111",
|
||||
quarantinedRunCount: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function suppressed(
|
||||
reason: Extract<WorktreeRunEngineStatus["activation"], { armed: false }>["reason"],
|
||||
overrides: Partial<WorktreeRunEngineStatus> = {},
|
||||
): WorktreeRunEngineStatus {
|
||||
return {
|
||||
inWorktree: true,
|
||||
activation: {
|
||||
armed: false,
|
||||
cutoff: null,
|
||||
activationInstanceId: reason === "instance_id_mismatch" ? "nonce-oldoldold9999" : null,
|
||||
reason,
|
||||
},
|
||||
instanceNonce: "nonce-current0000",
|
||||
quarantinedRunCount: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("WorktreeRunEngineBanner", () => {
|
||||
let roots: Root[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
roots = [];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
flushSync(() => {
|
||||
roots.forEach((root) => root.unmount());
|
||||
});
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
function mount(status: WorktreeRunEngineStatus | undefined, variant: "strip" | "detail" = "detail") {
|
||||
const { container, root } = render(status, variant);
|
||||
roots.push(root);
|
||||
return container;
|
||||
}
|
||||
|
||||
it("renders nothing outside a worktree runtime", () => {
|
||||
const container = mount({
|
||||
inWorktree: false,
|
||||
activation: { armed: false, cutoff: null, activationInstanceId: null, reason: "not_worktree_runtime" },
|
||||
instanceNonce: null,
|
||||
quarantinedRunCount: 0,
|
||||
});
|
||||
expect(container.textContent).toBe("");
|
||||
});
|
||||
|
||||
it("renders nothing while the query has no data", () => {
|
||||
const container = mount(undefined);
|
||||
expect(container.textContent).toBe("");
|
||||
});
|
||||
|
||||
it("shows the armed cutoff and a matching identity when armed", () => {
|
||||
const container = mount(armed({ quarantinedRunCount: 3 }));
|
||||
expect(container.textContent).toContain("Run engine armed since");
|
||||
expect(container.textContent).toContain("matches");
|
||||
expect(container.textContent).not.toContain("mismatch");
|
||||
expect(container.textContent).toContain("3 inherited runs were quarantined");
|
||||
});
|
||||
|
||||
it("reads an identity mismatch as bound to another instance", () => {
|
||||
const container = mount(suppressed("instance_id_mismatch"));
|
||||
expect(container.textContent).toContain("bound to another instance");
|
||||
expect(container.textContent).toContain("mismatch");
|
||||
// Both identities are surfaced so the mismatch is legible.
|
||||
expect(container.textContent).toContain("nonce-ol");
|
||||
expect(container.textContent).toContain("nonce-cu");
|
||||
});
|
||||
|
||||
it("explains a missing activation cutoff", () => {
|
||||
const container = mount(suppressed("missing_cutoff"));
|
||||
expect(container.textContent).toContain("missing activation cutoff");
|
||||
expect(container.textContent).toContain("Toggle it off and back on");
|
||||
});
|
||||
|
||||
it("explains a flag-disabled run engine", () => {
|
||||
const container = mount(suppressed("flag_disabled"));
|
||||
expect(container.textContent).toContain("Run engine off");
|
||||
});
|
||||
|
||||
it("renders a dense single-line strip variant", () => {
|
||||
const container = mount(armed(), "strip");
|
||||
const strip = container.querySelector('[data-testid="worktree-run-engine-strip"]');
|
||||
expect(strip).not.toBeNull();
|
||||
expect(strip?.textContent).toContain("Run engine");
|
||||
expect(strip?.textContent).toContain("armed since");
|
||||
});
|
||||
|
||||
it("surfaces quarantined counts in the strip when suppressed", () => {
|
||||
const container = mount(suppressed("flag_disabled", { quarantinedRunCount: 5 }), "strip");
|
||||
const strip = container.querySelector('[data-testid="worktree-run-engine-strip"]');
|
||||
expect(strip?.textContent).toContain("5 inherited runs inactive");
|
||||
});
|
||||
|
||||
// PAP-14415: the strip supplies its own bold "Run engine" subject, so the
|
||||
// suppressed predicate must not restate it (no "Run engine run engine off").
|
||||
it("does not stutter the run-engine subject in the suppressed strip", () => {
|
||||
const container = mount(suppressed("flag_disabled", { quarantinedRunCount: 4 }), "strip");
|
||||
const text = container
|
||||
.querySelector('[data-testid="worktree-run-engine-strip"]')
|
||||
?.textContent?.replace(/\s+/g, " ");
|
||||
expect(text).toContain("Run engine off");
|
||||
expect(text).not.toMatch(/run engine run engine/i);
|
||||
});
|
||||
|
||||
it("uses a bare predicate for a mismatched-identity strip", () => {
|
||||
const container = mount(suppressed("instance_id_mismatch"), "strip");
|
||||
const text = container
|
||||
.querySelector('[data-testid="worktree-run-engine-strip"]')
|
||||
?.textContent?.replace(/\s+/g, " ");
|
||||
expect(text).toContain("Run engine inactive — bound to another instance");
|
||||
expect(text).not.toMatch(/run engine toggle inactive/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,237 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { AlertTriangle, Fingerprint, Pause, Play } from "lucide-react";
|
||||
import type {
|
||||
WorktreeRunEngineStatus,
|
||||
WorktreeRunExecutionActivationState,
|
||||
} from "@paperclipai/shared";
|
||||
import { instanceSettingsApi } from "@/api/instanceSettings";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
function formatActivationTimestamp(iso: string): string {
|
||||
const parsed = new Date(iso);
|
||||
if (Number.isNaN(parsed.getTime())) return iso;
|
||||
return parsed.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" });
|
||||
}
|
||||
|
||||
function shortIdentity(value: string | null | undefined): string {
|
||||
if (!value) return "—";
|
||||
return value.length > 8 ? value.slice(0, 8) : value;
|
||||
}
|
||||
|
||||
/**
|
||||
* `headline`/`detail` drive the fuller detail banner. `stripLabel` is the bare
|
||||
* predicate the dense strip appends after its own bold "Run engine" subject —
|
||||
* the detail headlines each restate that subject, so reusing them there stutters
|
||||
* ("Run engine run engine off"). Keeping a dedicated field stops the two copies
|
||||
* from drifting.
|
||||
*/
|
||||
type SuppressionCopy = { headline: string; detail: string; stripLabel: string };
|
||||
|
||||
/**
|
||||
* Human-readable copy for each fail-closed reason. `instance_id_mismatch` is
|
||||
* handled separately because it interpolates the two identities.
|
||||
*/
|
||||
function suppressionCopy(
|
||||
activation: Extract<WorktreeRunExecutionActivationState, { armed: false }>,
|
||||
instanceNonce: string | null,
|
||||
): SuppressionCopy {
|
||||
switch (activation.reason) {
|
||||
case "flag_disabled":
|
||||
return {
|
||||
headline: "Run engine off",
|
||||
detail:
|
||||
"The scheduler will not execute tasks in this worktree. Inherited tasks stay parked. Turn on “Run tasks in this worktree” to arm execution.",
|
||||
stripLabel: "off",
|
||||
};
|
||||
case "missing_cutoff":
|
||||
return {
|
||||
headline: "Execution suppressed — missing activation cutoff",
|
||||
detail:
|
||||
"This setting has no activation cutoff, so no tasks run automatically. Toggle it off and back on to arm execution for tasks created here.",
|
||||
stripLabel: "suppressed — missing cutoff",
|
||||
};
|
||||
case "missing_instance_id":
|
||||
return {
|
||||
headline: "Execution suppressed — no instance identity",
|
||||
detail:
|
||||
"This worktree has not stamped an instance identity yet, so execution fails closed. Toggle off and back on to arm execution.",
|
||||
stripLabel: "suppressed — no identity",
|
||||
};
|
||||
case "instance_id_mismatch":
|
||||
return {
|
||||
headline: "Toggle inactive — bound to another instance",
|
||||
detail: `This setting was armed against instance ${shortIdentity(
|
||||
activation.activationInstanceId,
|
||||
)} and copied here; this boot is ${shortIdentity(
|
||||
instanceNonce,
|
||||
)}. No tasks run automatically. Toggle off and back on to re-arm execution for this instance.`,
|
||||
stripLabel: "inactive — bound to another instance",
|
||||
};
|
||||
case "settings_read_error":
|
||||
return {
|
||||
headline: "Execution suppressed — settings unavailable",
|
||||
detail:
|
||||
"The run engine could not read its settings, so it fails closed and no tasks run automatically.",
|
||||
stripLabel: "suppressed — settings unavailable",
|
||||
};
|
||||
case "not_worktree_runtime":
|
||||
default:
|
||||
return {
|
||||
headline: "Execution suppressed",
|
||||
detail: "No tasks run automatically in this instance.",
|
||||
stripLabel: "suppressed",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function QuarantineLine({ count }: { count: number }) {
|
||||
if (count <= 0) return null;
|
||||
return (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{count} inherited {count === 1 ? "run was" : "runs were"} quarantined at seed time and remain
|
||||
inactive. Cleared wakeups and monitors are not retained.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function IdentityLine({
|
||||
activation,
|
||||
instanceNonce,
|
||||
}: {
|
||||
activation: WorktreeRunExecutionActivationState;
|
||||
instanceNonce: string | null;
|
||||
}) {
|
||||
const matches =
|
||||
activation.armed || activation.activationInstanceId === instanceNonce;
|
||||
return (
|
||||
<p className="flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Fingerprint className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
|
||||
<span>
|
||||
Bound to <span className="font-mono text-foreground">{shortIdentity(activation.activationInstanceId)}</span>
|
||||
</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>
|
||||
this boot <span className="font-mono text-foreground">{shortIdentity(instanceNonce)}</span>
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-md border px-1.5 py-0.5 text-(length:--text-micro) font-medium",
|
||||
matches
|
||||
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"
|
||||
: "border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300",
|
||||
)}
|
||||
>
|
||||
{matches ? "matches" : "mismatch"}
|
||||
</span>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* PAP-14312: persistent boot-truth for the worktree run engine. Renders nothing
|
||||
* outside a worktree runtime. Otherwise it reports whether the scheduler is armed
|
||||
* or suppressed (and why), which instance identity the toggle is bound to, and how
|
||||
* many inherited runs were quarantined at seed time.
|
||||
*
|
||||
* `variant="strip"` is a single dense line for the app shell; `variant="detail"`
|
||||
* is the fuller block used on the Experimental Settings page.
|
||||
*/
|
||||
export function WorktreeRunEngineBanner({
|
||||
variant = "detail",
|
||||
status: statusOverride,
|
||||
}: {
|
||||
variant?: "strip" | "detail";
|
||||
/** Injected in tests/stories to bypass the network query. */
|
||||
status?: WorktreeRunEngineStatus;
|
||||
}) {
|
||||
const query = useQuery({
|
||||
queryKey: queryKeys.instance.worktreeRunEngine,
|
||||
queryFn: () => instanceSettingsApi.getWorktreeRunEngine(),
|
||||
enabled: statusOverride === undefined,
|
||||
});
|
||||
|
||||
const status = statusOverride ?? query.data;
|
||||
if (!status || !status.inWorktree) return null;
|
||||
|
||||
const { activation, instanceNonce, quarantinedRunCount } = status;
|
||||
const armed = activation.armed;
|
||||
|
||||
if (variant === "strip") {
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
data-testid="worktree-run-engine-strip"
|
||||
className={cn(
|
||||
"flex items-center gap-2 border-b px-3 py-1.5 text-xs",
|
||||
armed
|
||||
? "border-emerald-500/30 bg-emerald-500/5 text-foreground"
|
||||
: "border-amber-500/30 bg-amber-500/5 text-foreground",
|
||||
)}
|
||||
>
|
||||
{armed ? (
|
||||
<Play className="h-3.5 w-3.5 shrink-0 text-emerald-600" aria-hidden="true" />
|
||||
) : (
|
||||
<Pause className="h-3.5 w-3.5 shrink-0 text-amber-700" aria-hidden="true" />
|
||||
)}
|
||||
<span className="min-w-0 truncate">
|
||||
<span className="font-medium">Run engine</span>{" "}
|
||||
{armed ? (
|
||||
<>armed since {formatActivationTimestamp(activation.cutoff)}</>
|
||||
) : (
|
||||
<>{suppressionCopy(activation, instanceNonce).stripLabel}</>
|
||||
)}
|
||||
{quarantinedRunCount > 0 ? (
|
||||
<span className="text-muted-foreground">
|
||||
{" · "}
|
||||
{quarantinedRunCount} inherited {quarantinedRunCount === 1 ? "run" : "runs"} inactive
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (armed) {
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
data-testid="worktree-run-engine-banner"
|
||||
className="space-y-2 rounded-md border border-emerald-500/30 bg-emerald-500/5 px-3 py-2.5 text-sm text-foreground"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Play className="h-4 w-4 shrink-0 text-emerald-600" aria-hidden="true" />
|
||||
<span>
|
||||
Run engine armed since{" "}
|
||||
<span className="font-medium">{formatActivationTimestamp(activation.cutoff)}</span>. Tasks
|
||||
created after this point run automatically.
|
||||
</span>
|
||||
</div>
|
||||
<IdentityLine activation={activation} instanceNonce={instanceNonce} />
|
||||
<QuarantineLine count={quarantinedRunCount} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const copy = suppressionCopy(activation, instanceNonce);
|
||||
// The identity binding is only meaningful when it's the reason execution is
|
||||
// suppressed; showing a "mismatch" badge on a plainly-off engine reads as noise.
|
||||
const showIdentity = activation.reason === "instance_id_mismatch";
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
data-testid="worktree-run-engine-banner"
|
||||
className="space-y-2 rounded-md border border-amber-500/30 bg-amber-500/5 px-3 py-2.5 text-sm"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-700" aria-hidden="true" />
|
||||
<div className="space-y-0.5">
|
||||
<p className="font-medium text-foreground">{copy.headline}</p>
|
||||
<p className="text-muted-foreground">{copy.detail}</p>
|
||||
</div>
|
||||
</div>
|
||||
{showIdentity ? <IdentityLine activation={activation} instanceNonce={instanceNonce} /> : null}
|
||||
<QuarantineLine count={quarantinedRunCount} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -388,6 +388,7 @@ export const queryKeys = {
|
||||
generalSettings: ["instance", "general-settings"] as const,
|
||||
schedulerHeartbeats: ["instance", "scheduler-heartbeats"] as const,
|
||||
experimentalSettings: ["instance", "experimental-settings"] as const,
|
||||
worktreeRunEngine: ["instance", "worktree-run-engine"] as const,
|
||||
},
|
||||
health: ["health"] as const,
|
||||
cloud: {
|
||||
|
||||
@@ -15,6 +15,7 @@ import { queryKeys } from "../lib/queryKeys";
|
||||
const mockInstanceSettingsApi = vi.hoisted(() => ({
|
||||
getExperimental: vi.fn(),
|
||||
updateExperimental: vi.fn(),
|
||||
getWorktreeRunEngine: vi.fn(),
|
||||
previewIssueGraphLivenessAutoRecovery: vi.fn(),
|
||||
runIssueGraphLivenessAutoRecovery: vi.fn(),
|
||||
}));
|
||||
@@ -183,6 +184,20 @@ describe("InstanceExperimentalSettings — Conference Room Chat card (PAP-11233)
|
||||
currentExperimentalSettings = { ...currentExperimentalSettings, ...patch };
|
||||
return { ...currentExperimentalSettings };
|
||||
});
|
||||
// Boot-truth banner (PAP-14312) has its own coverage in
|
||||
// WorktreeRunEngineBanner.test.tsx; here it defaults to not-in-worktree so
|
||||
// the page tests focus on the toggle and delegation.
|
||||
mockInstanceSettingsApi.getWorktreeRunEngine.mockResolvedValue({
|
||||
inWorktree: false,
|
||||
activation: {
|
||||
armed: false,
|
||||
cutoff: null,
|
||||
activationInstanceId: null,
|
||||
reason: "not_worktree_runtime",
|
||||
},
|
||||
instanceNonce: null,
|
||||
quarantinedRunCount: 0,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -429,59 +444,30 @@ describe("InstanceExperimentalSettings — Conference Room Chat card (PAP-11233)
|
||||
"Only tasks created after enabling will run automatically",
|
||||
);
|
||||
expect(container.textContent).toContain("Toggling off and on resets the cutoff.");
|
||||
// Off => no armed banner and no fail-closed hint.
|
||||
expect(container.textContent).not.toContain("Running tasks created after");
|
||||
expect(container.textContent).not.toContain("Execution is suppressed");
|
||||
});
|
||||
|
||||
it("shows the armed timestamp when the flag matches the current instance", async () => {
|
||||
// The armed/suppressed/identity boot-truth states now live in the
|
||||
// WorktreeRunEngineBanner (PAP-14312); the settings card only delegates to it.
|
||||
// Full state coverage lives in WorktreeRunEngineBanner.test.tsx.
|
||||
it("renders the run-engine boot-truth banner inside the worktree card", async () => {
|
||||
setWorktreeRuntimeMeta(true);
|
||||
setWorktreeInstanceIdMeta("inst-current");
|
||||
currentExperimentalSettings = {
|
||||
...currentExperimentalSettings,
|
||||
enableWorktreeRunExecution: true,
|
||||
worktreeRunExecutionActivatedAt: "2026-07-10T18:34:00.000Z",
|
||||
worktreeRunExecutionActivationInstanceId: "inst-current",
|
||||
};
|
||||
mockInstanceSettingsApi.getWorktreeRunEngine.mockResolvedValue({
|
||||
inWorktree: true,
|
||||
activation: {
|
||||
armed: true,
|
||||
cutoff: "2026-07-10T18:34:00.000Z",
|
||||
activationInstanceId: "nonce-current",
|
||||
reason: null,
|
||||
},
|
||||
instanceNonce: "nonce-current",
|
||||
quarantinedRunCount: 2,
|
||||
});
|
||||
await renderPage();
|
||||
|
||||
expect(container.textContent).toContain("Running tasks created after");
|
||||
expect(container.textContent).not.toContain("Execution is suppressed");
|
||||
const toggle = container.querySelector<HTMLButtonElement>(WORKTREE_RUN_EXECUTION_TOGGLE_SELECTOR);
|
||||
expect(toggle?.getAttribute("aria-checked")).toBe("true");
|
||||
});
|
||||
|
||||
it("fails closed with a re-enable hint when the flag was armed in another instance", async () => {
|
||||
setWorktreeRuntimeMeta(true);
|
||||
setWorktreeInstanceIdMeta("inst-current");
|
||||
currentExperimentalSettings = {
|
||||
...currentExperimentalSettings,
|
||||
enableWorktreeRunExecution: true,
|
||||
worktreeRunExecutionActivatedAt: "2026-07-10T18:34:00.000Z",
|
||||
worktreeRunExecutionActivationInstanceId: "inst-other",
|
||||
};
|
||||
await renderPage();
|
||||
|
||||
expect(container.textContent).toContain("Execution is suppressed");
|
||||
expect(container.textContent).toContain("armed in a different instance");
|
||||
expect(container.textContent).toContain("Toggle it off and back on");
|
||||
expect(container.textContent).not.toContain("Running tasks created after");
|
||||
});
|
||||
|
||||
it("fails closed with a re-enable hint when the activation cutoff is missing", async () => {
|
||||
setWorktreeRuntimeMeta(true);
|
||||
setWorktreeInstanceIdMeta("inst-current");
|
||||
currentExperimentalSettings = {
|
||||
...currentExperimentalSettings,
|
||||
enableWorktreeRunExecution: true,
|
||||
worktreeRunExecutionActivatedAt: null,
|
||||
worktreeRunExecutionActivationInstanceId: null,
|
||||
};
|
||||
await renderPage();
|
||||
|
||||
expect(container.textContent).toContain("Execution is suppressed");
|
||||
expect(container.textContent).toContain("missing its activation cutoff");
|
||||
expect(container.textContent).not.toContain("Running tasks created after");
|
||||
const banner = container.querySelector('[data-testid="worktree-run-engine-banner"]');
|
||||
expect(banner).not.toBeNull();
|
||||
expect(banner?.textContent).toContain("Run engine armed since");
|
||||
expect(banner?.textContent).toContain("2 inherited runs were quarantined");
|
||||
});
|
||||
|
||||
it("renders and patches the Built-in Agents experimental toggle", async () => {
|
||||
|
||||
@@ -13,6 +13,7 @@ import { experimentalSettingKey } from "@paperclipai/shared";
|
||||
import { instanceSettingsApi } from "@/api/instanceSettings";
|
||||
import { useHiddenSettings } from "@/hooks/useHiddenSettings";
|
||||
import { getWorktreeInstanceId, isWorktreeRuntime } from "../lib/worktree-branding";
|
||||
import { WorktreeRunEngineBanner } from "@/components/WorktreeRunEngineBanner";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { ToggleSwitch } from "@/components/ui/toggle-switch";
|
||||
@@ -39,43 +40,6 @@ function formatRecoveryState(state: string) {
|
||||
return state.replace(/_/g, " ");
|
||||
}
|
||||
|
||||
type WorktreeRunExecutionDisplayState =
|
||||
| { kind: "off" }
|
||||
| { kind: "armed"; activatedAt: string }
|
||||
| { kind: "fail_closed"; reason: "missing_cutoff" | "missing_instance_id" | "instance_mismatch" };
|
||||
|
||||
/**
|
||||
* Mirror of the server's `resolveWorktreeRunExecutionActivation` fail-closed
|
||||
* ladder (server/src/services/instance-settings.ts) so the card never claims a
|
||||
* copied/legacy row is arming execution. The derived fields are display-only —
|
||||
* the PATCH the toggle sends still writes just the boolean.
|
||||
*/
|
||||
function resolveWorktreeRunExecutionDisplayState(
|
||||
settings:
|
||||
| Pick<
|
||||
InstanceExperimentalSettings,
|
||||
| "enableWorktreeRunExecution"
|
||||
| "worktreeRunExecutionActivatedAt"
|
||||
| "worktreeRunExecutionActivationInstanceId"
|
||||
>
|
||||
| undefined,
|
||||
currentInstanceId: string | null,
|
||||
): WorktreeRunExecutionDisplayState {
|
||||
if (settings?.enableWorktreeRunExecution !== true) return { kind: "off" };
|
||||
if (!settings.worktreeRunExecutionActivatedAt) return { kind: "fail_closed", reason: "missing_cutoff" };
|
||||
if (!currentInstanceId) return { kind: "fail_closed", reason: "missing_instance_id" };
|
||||
if (settings.worktreeRunExecutionActivationInstanceId !== currentInstanceId) {
|
||||
return { kind: "fail_closed", reason: "instance_mismatch" };
|
||||
}
|
||||
return { kind: "armed", activatedAt: settings.worktreeRunExecutionActivatedAt };
|
||||
}
|
||||
|
||||
function formatActivationTimestamp(iso: string): string {
|
||||
const parsed = new Date(iso);
|
||||
if (Number.isNaN(parsed.getTime())) return iso;
|
||||
return parsed.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" });
|
||||
}
|
||||
|
||||
// PAP-11233: keep Conference Room code intact, but hide the user-facing opt-in for now.
|
||||
const SHOW_CONFERENCE_ROOM_EXPERIMENTAL_SETTING = false;
|
||||
|
||||
@@ -277,6 +241,7 @@ export function InstanceExperimentalSettings() {
|
||||
queryClient.setQueryData(queryKeys.instance.experimentalSettings, updatedSettings);
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.instance.experimentalSettings }),
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.instance.worktreeRunEngine }),
|
||||
queryClient.invalidateQueries({ queryKey: ["built-in-agents"] }),
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.health }),
|
||||
]);
|
||||
@@ -467,6 +432,37 @@ export function InstanceExperimentalSettings() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{inWorktree ? (
|
||||
<Card className="block p-5">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="text-sm font-semibold">Run tasks in this worktree</h2>
|
||||
{worktreeRunExecutionManaged ? <ManagedByCloudBadge /> : null}
|
||||
</div>
|
||||
<p className="max-w-2xl text-sm text-muted-foreground">
|
||||
This is an isolated git-worktree preview instance. Turn this on to let the scheduler execute runs
|
||||
here. Only tasks created after enabling will run automatically — copied/pre-existing tasks stay
|
||||
parked. Toggling off and on resets the cutoff.
|
||||
</p>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
checked={enableWorktreeRunExecution}
|
||||
onCheckedChange={(checked) => {
|
||||
if (worktreeRunExecutionManaged) return;
|
||||
toggleMutation.mutate({ enableWorktreeRunExecution: checked });
|
||||
}}
|
||||
disabled={toggleMutation.isPending || worktreeRunExecutionManaged}
|
||||
aria-label="Toggle worktree run execution setting"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<WorktreeRunEngineBanner variant="detail" />
|
||||
</div>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<ExperimentalToggleCard
|
||||
title="Apps"
|
||||
description="Show the Apps navigation and allow access to app connections, gateways, and advanced app tooling."
|
||||
|
||||
Reference in new issue
Block a user