mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 12:37:55 +01:00
Add issue monitor follow-up controls
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
ba7486090f
commit
1b46ba6674
19 files changed
+852
-116
No files matched your search
@@ -224,6 +224,17 @@ export type IssueExecutionStageType = (typeof ISSUE_EXECUTION_STAGE_TYPES)[numbe
|
||||
export const ISSUE_MONITOR_SCHEDULED_BY = ["assignee", "board"] as const;
|
||||
export type IssueMonitorScheduledBy = (typeof ISSUE_MONITOR_SCHEDULED_BY)[number];
|
||||
|
||||
export const ISSUE_EXECUTION_MONITOR_KINDS = ["external_service"] as const;
|
||||
export type IssueExecutionMonitorKind = (typeof ISSUE_EXECUTION_MONITOR_KINDS)[number];
|
||||
|
||||
export const ISSUE_EXECUTION_MONITOR_RECOVERY_POLICIES = [
|
||||
"wake_owner",
|
||||
"create_recovery_issue",
|
||||
"escalate_to_board",
|
||||
] as const;
|
||||
export type IssueExecutionMonitorRecoveryPolicy =
|
||||
(typeof ISSUE_EXECUTION_MONITOR_RECOVERY_POLICIES)[number];
|
||||
|
||||
export const ISSUE_EXECUTION_STATE_STATUSES = ["idle", "pending", "changes_requested", "completed"] as const;
|
||||
export type IssueExecutionStateStatus = (typeof ISSUE_EXECUTION_STATE_STATUSES)[number];
|
||||
|
||||
|
||||
@@ -36,6 +36,8 @@ export {
|
||||
ISSUE_EXECUTION_POLICY_MODES,
|
||||
ISSUE_EXECUTION_STAGE_TYPES,
|
||||
ISSUE_MONITOR_SCHEDULED_BY,
|
||||
ISSUE_EXECUTION_MONITOR_KINDS,
|
||||
ISSUE_EXECUTION_MONITOR_RECOVERY_POLICIES,
|
||||
ISSUE_EXECUTION_STATE_STATUSES,
|
||||
ISSUE_EXECUTION_MONITOR_STATE_STATUSES,
|
||||
ISSUE_EXECUTION_MONITOR_CLEAR_REASONS,
|
||||
@@ -140,6 +142,8 @@ export {
|
||||
type IssueExecutionPolicyMode,
|
||||
type IssueExecutionStageType,
|
||||
type IssueMonitorScheduledBy,
|
||||
type IssueExecutionMonitorKind,
|
||||
type IssueExecutionMonitorRecoveryPolicy,
|
||||
type IssueExecutionStateStatus,
|
||||
type IssueExecutionMonitorStateStatus,
|
||||
type IssueExecutionMonitorClearReason,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type {
|
||||
IssueExecutionMonitorClearReason,
|
||||
IssueExecutionMonitorKind,
|
||||
IssueExecutionMonitorRecoveryPolicy,
|
||||
IssueExecutionMonitorStateStatus,
|
||||
IssueExecutionDecisionOutcome,
|
||||
IssueMonitorScheduledBy,
|
||||
@@ -208,6 +210,12 @@ export interface IssueExecutionMonitorPolicy {
|
||||
nextCheckAt: string;
|
||||
notes: string | null;
|
||||
scheduledBy: IssueMonitorScheduledBy;
|
||||
kind?: IssueExecutionMonitorKind | null;
|
||||
serviceName?: string | null;
|
||||
externalRef?: string | null;
|
||||
timeoutAt?: string | null;
|
||||
maxAttempts?: number | null;
|
||||
recoveryPolicy?: IssueExecutionMonitorRecoveryPolicy | null;
|
||||
}
|
||||
|
||||
export interface IssueExecutionPolicy {
|
||||
@@ -224,6 +232,12 @@ export interface IssueExecutionMonitorState {
|
||||
attemptCount: number;
|
||||
notes: string | null;
|
||||
scheduledBy: IssueMonitorScheduledBy | null;
|
||||
kind?: IssueExecutionMonitorKind | null;
|
||||
serviceName?: string | null;
|
||||
externalRef?: string | null;
|
||||
timeoutAt?: string | null;
|
||||
maxAttempts?: number | null;
|
||||
recoveryPolicy?: IssueExecutionMonitorRecoveryPolicy | null;
|
||||
clearedAt: string | null;
|
||||
clearReason: IssueExecutionMonitorClearReason | null;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import { z } from "zod";
|
||||
import {
|
||||
ISSUE_EXECUTION_DECISION_OUTCOMES,
|
||||
ISSUE_EXECUTION_MONITOR_CLEAR_REASONS,
|
||||
ISSUE_EXECUTION_MONITOR_KINDS,
|
||||
ISSUE_EXECUTION_MONITOR_RECOVERY_POLICIES,
|
||||
ISSUE_EXECUTION_MONITOR_STATE_STATUSES,
|
||||
ISSUE_EXECUTION_POLICY_MODES,
|
||||
ISSUE_EXECUTION_STAGE_TYPES,
|
||||
@@ -110,6 +112,12 @@ export const issueExecutionMonitorPolicySchema = z.object({
|
||||
nextCheckAt: z.string().datetime(),
|
||||
notes: z.string().max(500).optional().nullable().default(null),
|
||||
scheduledBy: z.enum(ISSUE_MONITOR_SCHEDULED_BY).optional().default("assignee"),
|
||||
kind: z.enum(ISSUE_EXECUTION_MONITOR_KINDS).optional().nullable().default(null),
|
||||
serviceName: z.string().trim().min(1).max(120).optional().nullable().default(null),
|
||||
externalRef: z.string().trim().min(1).max(500).optional().nullable().default(null),
|
||||
timeoutAt: z.string().datetime().optional().nullable().default(null),
|
||||
maxAttempts: z.number().int().positive().max(100).optional().nullable().default(null),
|
||||
recoveryPolicy: z.enum(ISSUE_EXECUTION_MONITOR_RECOVERY_POLICIES).optional().nullable().default(null),
|
||||
});
|
||||
|
||||
export const issueExecutionPolicySchema = z.object({
|
||||
@@ -126,6 +134,12 @@ export const issueExecutionMonitorStateSchema = z.object({
|
||||
attemptCount: z.number().int().nonnegative().default(0),
|
||||
notes: z.string().max(500).nullable(),
|
||||
scheduledBy: z.enum(ISSUE_MONITOR_SCHEDULED_BY).nullable(),
|
||||
kind: z.enum(ISSUE_EXECUTION_MONITOR_KINDS).nullable().optional().default(null),
|
||||
serviceName: z.string().trim().min(1).max(120).nullable().optional().default(null),
|
||||
externalRef: z.string().trim().min(1).max(500).nullable().optional().default(null),
|
||||
timeoutAt: z.string().datetime().nullable().optional().default(null),
|
||||
maxAttempts: z.number().int().positive().max(100).nullable().optional().default(null),
|
||||
recoveryPolicy: z.enum(ISSUE_EXECUTION_MONITOR_RECOVERY_POLICIES).nullable().optional().default(null),
|
||||
clearedAt: z.string().datetime().nullable(),
|
||||
clearReason: z.enum(ISSUE_EXECUTION_MONITOR_CLEAR_REASONS).nullable(),
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@ const mockIssueService = vi.hoisted(() => ({
|
||||
|
||||
const mockHeartbeatService = vi.hoisted(() => ({
|
||||
wakeup: vi.fn(async () => undefined),
|
||||
triggerIssueMonitor: vi.fn(async () => ({ outcome: "triggered" as const })),
|
||||
reportRunActivity: vi.fn(async () => undefined),
|
||||
getRun: vi.fn(async () => null),
|
||||
getActiveRunForAgent: vi.fn(async () => null),
|
||||
@@ -162,4 +163,41 @@ describe("issue execution policy routes", () => {
|
||||
expect(updatePatch.executionState).toBeUndefined();
|
||||
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("triggers a scheduled monitor immediately from the dedicated route", async () => {
|
||||
const issue = {
|
||||
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
companyId: "company-1",
|
||||
status: "in_progress",
|
||||
assigneeAgentId: "33333333-3333-4333-8333-333333333333",
|
||||
assigneeUserId: null,
|
||||
createdByUserId: "local-board",
|
||||
identifier: "PAP-1001",
|
||||
title: "Manual monitor trigger",
|
||||
executionPolicy: normalizeIssueExecutionPolicy({
|
||||
monitor: {
|
||||
nextCheckAt: "2026-04-11T12:30:00.000Z",
|
||||
notes: "Check deployment",
|
||||
scheduledBy: "board",
|
||||
},
|
||||
}),
|
||||
executionState: null,
|
||||
};
|
||||
mockIssueService.getById.mockResolvedValue(issue);
|
||||
|
||||
const res = await request(await createApp())
|
||||
.post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/monitor/check-now")
|
||||
.send({});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ ok: true });
|
||||
expect(mockHeartbeatService.triggerIssueMonitor).toHaveBeenCalledWith(
|
||||
"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
expect.objectContaining({
|
||||
actorType: "user",
|
||||
actorId: "local-board",
|
||||
agentId: null,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -46,9 +46,9 @@ describeEmbeddedPostgres("issue monitor scheduler", () => {
|
||||
}
|
||||
seededAgentIds.clear();
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
await db.delete(activityLog);
|
||||
await db.delete(issues);
|
||||
await db.delete(heartbeatRunEvents);
|
||||
await db.delete(activityLog);
|
||||
await db.delete(heartbeatRuns);
|
||||
await db.delete(agentWakeupRequests);
|
||||
await db.delete(agentRuntimeState);
|
||||
@@ -184,6 +184,52 @@ describeEmbeddedPostgres("issue monitor scheduler", () => {
|
||||
expect(activity).toContain("issue.monitor_triggered");
|
||||
});
|
||||
|
||||
it("lets the board trigger a scheduled issue monitor immediately", async () => {
|
||||
const { issueId, agentId, nextCheckAt } = await seedFixture();
|
||||
const heartbeat = heartbeatService(db);
|
||||
const triggeredAt = new Date("2026-04-11T12:00:00.000Z");
|
||||
|
||||
const result = await heartbeat.triggerIssueMonitor(issueId, {
|
||||
now: triggeredAt,
|
||||
actorType: "user",
|
||||
actorId: "local-board",
|
||||
});
|
||||
|
||||
expect(result.outcome).toBe("triggered");
|
||||
|
||||
const issue = await db.select().from(issues).where(eq(issues.id, issueId)).then((rows) => rows[0]!);
|
||||
expect(issue.monitorNextCheckAt).toBeNull();
|
||||
expect(issue.monitorLastTriggeredAt?.toISOString()).toBe(triggeredAt.toISOString());
|
||||
expect(issue.monitorAttemptCount).toBe(1);
|
||||
expect(normalizeIssueExecutionPolicy(issue.executionPolicy ?? null)?.monitor ?? null).toBeNull();
|
||||
|
||||
const wakeup = await db
|
||||
.select()
|
||||
.from(agentWakeupRequests)
|
||||
.where(eq(agentWakeupRequests.agentId, agentId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
expect(wakeup?.reason).toBe("issue_monitor_due");
|
||||
expect(wakeup?.payload).toMatchObject({
|
||||
issueId,
|
||||
nextCheckAt: nextCheckAt.toISOString(),
|
||||
source: "manual",
|
||||
});
|
||||
|
||||
const activity = await db
|
||||
.select()
|
||||
.from(activityLog)
|
||||
.where(eq(activityLog.entityId, issueId))
|
||||
.orderBy(activityLog.createdAt);
|
||||
expect(activity.map((row) => row.action)).toContain("issue.monitor_triggered");
|
||||
const triggerEvent = activity.find((row) => row.action === "issue.monitor_triggered");
|
||||
expect(triggerEvent?.actorType).toBe("user");
|
||||
expect(triggerEvent?.actorId).toBe("local-board");
|
||||
expect(triggerEvent?.details).toMatchObject({
|
||||
nextCheckAt: nextCheckAt.toISOString(),
|
||||
source: "manual",
|
||||
});
|
||||
});
|
||||
|
||||
it("clears due monitors that cannot be dispatched and records a skip", async () => {
|
||||
const { issueId } = await seedFixture({ agentStatus: "paused" });
|
||||
const heartbeat = heartbeatService(db);
|
||||
|
||||
@@ -74,6 +74,39 @@ describe("recovery classifier boundary", () => {
|
||||
expect(classifyIssueGraphLiveness(input)).toEqual(classifyIssueGraphLivenessCompat(input));
|
||||
});
|
||||
|
||||
it("treats a scheduled monitor as an explicit review action path", () => {
|
||||
const findings = classifyIssueGraphLiveness({
|
||||
issues: [
|
||||
{
|
||||
id: issueId,
|
||||
companyId,
|
||||
identifier: "PAP-2945",
|
||||
title: "Wait for external review",
|
||||
status: "in_review",
|
||||
assigneeAgentId: agentId,
|
||||
assigneeUserId: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: null,
|
||||
executionState: null,
|
||||
monitorNextCheckAt: "2026-04-30T19:00:00.000Z",
|
||||
},
|
||||
],
|
||||
relations: [],
|
||||
agents: [
|
||||
{
|
||||
id: agentId,
|
||||
companyId,
|
||||
name: "Coder",
|
||||
role: "engineer",
|
||||
status: "idle",
|
||||
reportsTo: managerId,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(findings).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps run liveness continuation decision parity with the compatibility export", () => {
|
||||
const input = {
|
||||
run: {
|
||||
|
||||
@@ -202,6 +202,12 @@ function summarizeIssueMonitor(
|
||||
attemptCount: issue.monitorAttemptCount ?? state?.monitor?.attemptCount ?? 0,
|
||||
notes: policy?.monitor?.notes ?? issue.monitorNotes ?? state?.monitor?.notes ?? null,
|
||||
scheduledBy: issue.monitorScheduledBy ?? policy?.monitor?.scheduledBy ?? state?.monitor?.scheduledBy ?? null,
|
||||
kind: policy?.monitor?.kind ?? state?.monitor?.kind ?? null,
|
||||
serviceName: policy?.monitor?.serviceName ?? state?.monitor?.serviceName ?? null,
|
||||
externalRef: policy?.monitor?.externalRef ?? state?.monitor?.externalRef ?? null,
|
||||
timeoutAt: policy?.monitor?.timeoutAt ?? state?.monitor?.timeoutAt ?? null,
|
||||
maxAttempts: policy?.monitor?.maxAttempts ?? state?.monitor?.maxAttempts ?? null,
|
||||
recoveryPolicy: policy?.monitor?.recoveryPolicy ?? state?.monitor?.recoveryPolicy ?? null,
|
||||
status: state?.monitor?.status ?? (policy?.monitor ? "scheduled" : null),
|
||||
clearReason: state?.monitor?.clearReason ?? null,
|
||||
};
|
||||
@@ -1908,6 +1914,11 @@ export function issueRoutes(
|
||||
nextCheckAt: executionPolicy.monitor.nextCheckAt,
|
||||
notes: executionPolicy.monitor.notes,
|
||||
scheduledBy: executionPolicy.monitor.scheduledBy,
|
||||
serviceName: executionPolicy.monitor.serviceName ?? null,
|
||||
externalRef: executionPolicy.monitor.externalRef ?? null,
|
||||
timeoutAt: executionPolicy.monitor.timeoutAt ?? null,
|
||||
maxAttempts: executionPolicy.monitor.maxAttempts ?? null,
|
||||
recoveryPolicy: executionPolicy.monitor.recoveryPolicy ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1986,6 +1997,27 @@ export function issueRoutes(
|
||||
res.status(201).json(issue);
|
||||
});
|
||||
|
||||
router.post("/issues/:id/monitor/check-now", async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const issue = await svc.getById(id);
|
||||
if (!issue) {
|
||||
res.status(404).json({ error: "Issue not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, issue.companyId);
|
||||
assertCanManageIssueMonitor(req, issue.assigneeAgentId, true);
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
await heartbeat.triggerIssueMonitor(issue.id, {
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId ?? null,
|
||||
runId: actor.runId ?? null,
|
||||
});
|
||||
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.patch("/issues/:id", validate(updateIssueRouteSchema), async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const existing = await svc.getById(id);
|
||||
@@ -2464,6 +2496,11 @@ export function issueRoutes(
|
||||
previousNextCheckAt: previousMonitor.nextCheckAt,
|
||||
notes: nextMonitor.notes,
|
||||
scheduledBy: nextMonitor.scheduledBy,
|
||||
serviceName: nextMonitor.serviceName,
|
||||
externalRef: nextMonitor.externalRef,
|
||||
timeoutAt: nextMonitor.timeoutAt,
|
||||
maxAttempts: nextMonitor.maxAttempts,
|
||||
recoveryPolicy: nextMonitor.recoveryPolicy,
|
||||
},
|
||||
});
|
||||
} else if (!nextMonitor.nextCheckAt && previousMonitor.nextCheckAt) {
|
||||
|
||||
+275
-114
@@ -2333,26 +2333,274 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
.then((rows) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
const issueMonitorDispatchColumns = {
|
||||
id: issues.id,
|
||||
companyId: issues.companyId,
|
||||
identifier: issues.identifier,
|
||||
title: issues.title,
|
||||
status: issues.status,
|
||||
assigneeAgentId: issues.assigneeAgentId,
|
||||
assigneeUserId: issues.assigneeUserId,
|
||||
executionPolicy: issues.executionPolicy,
|
||||
executionState: issues.executionState,
|
||||
monitorNextCheckAt: issues.monitorNextCheckAt,
|
||||
monitorWakeRequestedAt: issues.monitorWakeRequestedAt,
|
||||
monitorLastTriggeredAt: issues.monitorLastTriggeredAt,
|
||||
monitorAttemptCount: issues.monitorAttemptCount,
|
||||
monitorNotes: issues.monitorNotes,
|
||||
monitorScheduledBy: issues.monitorScheduledBy,
|
||||
};
|
||||
|
||||
interface IssueMonitorDispatchRow {
|
||||
id: string;
|
||||
companyId: string;
|
||||
identifier: string | null;
|
||||
title: string;
|
||||
status: string;
|
||||
assigneeAgentId: string | null;
|
||||
assigneeUserId: string | null;
|
||||
executionPolicy: Record<string, unknown> | null;
|
||||
executionState: Record<string, unknown> | null;
|
||||
monitorNextCheckAt: Date | null;
|
||||
monitorWakeRequestedAt: Date | null;
|
||||
monitorLastTriggeredAt: Date | null;
|
||||
monitorAttemptCount: number | null;
|
||||
monitorNotes: string | null;
|
||||
monitorScheduledBy: string | null;
|
||||
}
|
||||
|
||||
async function dispatchClaimedIssueMonitor(
|
||||
claimed: IssueMonitorDispatchRow,
|
||||
input: {
|
||||
now: Date;
|
||||
source: "automation" | "on_demand";
|
||||
triggerDetail: "manual" | "system";
|
||||
wakeReason: string;
|
||||
actorType: "user" | "agent" | "system";
|
||||
actorId: string;
|
||||
agentId: string | null;
|
||||
runId: string | null;
|
||||
clearOnClientError: boolean;
|
||||
activitySource: "manual" | "scheduled";
|
||||
},
|
||||
) {
|
||||
if (!claimed.assigneeAgentId || !claimed.monitorNextCheckAt) {
|
||||
throw conflict("Issue monitor is not ready to dispatch");
|
||||
}
|
||||
|
||||
const scheduledAtIso = claimed.monitorNextCheckAt.toISOString();
|
||||
const nextAttemptCount = (claimed.monitorAttemptCount ?? 0) + 1;
|
||||
const policy = normalizeIssueExecutionPolicy(claimed.executionPolicy ?? null);
|
||||
const monitor = policy?.monitor ?? null;
|
||||
const monitorMetadata = {
|
||||
serviceName: monitor?.serviceName ?? null,
|
||||
externalRef: monitor?.externalRef ?? null,
|
||||
timeoutAt: monitor?.timeoutAt ?? null,
|
||||
maxAttempts: monitor?.maxAttempts ?? null,
|
||||
recoveryPolicy: monitor?.recoveryPolicy ?? null,
|
||||
};
|
||||
|
||||
try {
|
||||
await enqueueWakeup(claimed.assigneeAgentId, {
|
||||
source: input.source,
|
||||
triggerDetail: input.triggerDetail,
|
||||
reason: input.wakeReason,
|
||||
idempotencyKey: `issue-monitor:${claimed.id}:${scheduledAtIso}`,
|
||||
payload: {
|
||||
issueId: claimed.id,
|
||||
nextCheckAt: scheduledAtIso,
|
||||
monitorAttemptCount: nextAttemptCount,
|
||||
monitorNotes: claimed.monitorNotes ?? null,
|
||||
...monitorMetadata,
|
||||
source: input.activitySource,
|
||||
},
|
||||
requestedByActorType: input.actorType,
|
||||
requestedByActorId: input.actorId,
|
||||
contextSnapshot: {
|
||||
issueId: claimed.id,
|
||||
source: "issue.monitor",
|
||||
wakeReason: input.wakeReason,
|
||||
nextCheckAt: scheduledAtIso,
|
||||
monitorAttemptCount: nextAttemptCount,
|
||||
monitorNotes: claimed.monitorNotes ?? null,
|
||||
...monitorMetadata,
|
||||
manualTrigger: input.activitySource === "manual",
|
||||
},
|
||||
});
|
||||
|
||||
await db
|
||||
.update(issues)
|
||||
.set({
|
||||
...buildIssueMonitorTriggeredPatch({
|
||||
issue: claimed,
|
||||
policy,
|
||||
triggeredAt: input.now,
|
||||
}),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(issues.id, claimed.id));
|
||||
|
||||
await logActivity(db, {
|
||||
companyId: claimed.companyId,
|
||||
actorType: input.actorType,
|
||||
actorId: input.actorId,
|
||||
agentId: input.agentId,
|
||||
runId: input.runId,
|
||||
action: "issue.monitor_triggered",
|
||||
entityType: "issue",
|
||||
entityId: claimed.id,
|
||||
details: {
|
||||
identifier: claimed.identifier,
|
||||
nextCheckAt: scheduledAtIso,
|
||||
lastTriggeredAt: input.now.toISOString(),
|
||||
attemptCount: nextAttemptCount,
|
||||
notes: claimed.monitorNotes ?? null,
|
||||
...monitorMetadata,
|
||||
source: input.activitySource,
|
||||
},
|
||||
});
|
||||
|
||||
return { outcome: "triggered" as const };
|
||||
} catch (err) {
|
||||
if (err instanceof HttpError && err.status >= 400 && err.status < 500) {
|
||||
if (input.clearOnClientError) {
|
||||
await db
|
||||
.update(issues)
|
||||
.set({
|
||||
...buildIssueMonitorClearedPatch({
|
||||
issue: claimed,
|
||||
policy,
|
||||
clearReason: "dispatch_skipped",
|
||||
clearedAt: input.now,
|
||||
}),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(issues.id, claimed.id));
|
||||
|
||||
await logActivity(db, {
|
||||
companyId: claimed.companyId,
|
||||
actorType: input.actorType,
|
||||
actorId: input.actorId,
|
||||
agentId: input.agentId,
|
||||
runId: input.runId,
|
||||
action: "issue.monitor_skipped",
|
||||
entityType: "issue",
|
||||
entityId: claimed.id,
|
||||
details: {
|
||||
identifier: claimed.identifier,
|
||||
nextCheckAt: scheduledAtIso,
|
||||
attemptCount: nextAttemptCount,
|
||||
notes: claimed.monitorNotes ?? null,
|
||||
reason: err.message,
|
||||
source: input.activitySource,
|
||||
},
|
||||
});
|
||||
|
||||
return { outcome: "skipped" as const, reason: err.message };
|
||||
}
|
||||
|
||||
await db
|
||||
.update(issues)
|
||||
.set({
|
||||
monitorWakeRequestedAt: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(issues.id, claimed.id));
|
||||
} else {
|
||||
await db
|
||||
.update(issues)
|
||||
.set({
|
||||
monitorWakeRequestedAt: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(issues.id, claimed.id));
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function triggerIssueMonitor(issueId: string, input?: {
|
||||
now?: Date;
|
||||
actorType?: "user" | "agent" | "system";
|
||||
actorId?: string | null;
|
||||
agentId?: string | null;
|
||||
runId?: string | null;
|
||||
wakeReason?: string;
|
||||
}) {
|
||||
const now = input?.now ?? new Date();
|
||||
const actorType = input?.actorType ?? "system";
|
||||
const actorId = input?.actorId ?? (actorType === "system" ? "heartbeat_scheduler" : null);
|
||||
if (!actorId) {
|
||||
throw conflict("Issue monitor trigger requires an actor");
|
||||
}
|
||||
|
||||
const issue = await db
|
||||
.select(issueMonitorDispatchColumns)
|
||||
.from(issues)
|
||||
.where(eq(issues.id, issueId))
|
||||
.limit(1)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!issue) {
|
||||
throw notFound("Issue not found");
|
||||
}
|
||||
if (!issue.monitorNextCheckAt) {
|
||||
throw conflict("Issue has no scheduled monitor");
|
||||
}
|
||||
if (!issue.assigneeAgentId || issue.assigneeUserId) {
|
||||
throw conflict("Issue monitor requires an agent assignee");
|
||||
}
|
||||
if (!["in_progress", "in_review"].includes(issue.status)) {
|
||||
throw conflict("Issue monitor can only run while the issue is in progress or in review");
|
||||
}
|
||||
|
||||
const staleClaimThreshold = new Date(now.getTime() - 5 * 60 * 1000);
|
||||
const claimed = await db.transaction(async (tx) => {
|
||||
const [updated] = await tx
|
||||
.update(issues)
|
||||
.set({
|
||||
monitorWakeRequestedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(issues.id, issueId),
|
||||
sql`${issues.monitorNextCheckAt} is not null`,
|
||||
isNull(issues.assigneeUserId),
|
||||
sql`${issues.assigneeAgentId} is not null`,
|
||||
inArray(issues.status, ["in_progress", "in_review"]),
|
||||
or(
|
||||
isNull(issues.monitorWakeRequestedAt),
|
||||
lt(issues.monitorWakeRequestedAt, staleClaimThreshold),
|
||||
),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
return (updated ?? null) as IssueMonitorDispatchRow | null;
|
||||
});
|
||||
|
||||
if (!claimed) {
|
||||
throw conflict("Issue monitor check is already in progress");
|
||||
}
|
||||
|
||||
return dispatchClaimedIssueMonitor(claimed, {
|
||||
now,
|
||||
source: "on_demand",
|
||||
triggerDetail: "manual",
|
||||
wakeReason: input?.wakeReason ?? "issue_monitor_due",
|
||||
actorType,
|
||||
actorId,
|
||||
agentId: input?.agentId ?? null,
|
||||
runId: input?.runId ?? null,
|
||||
clearOnClientError: false,
|
||||
activitySource: "manual",
|
||||
});
|
||||
}
|
||||
|
||||
async function tickDueIssueMonitors(now = new Date()) {
|
||||
const staleClaimThreshold = new Date(now.getTime() - 5 * 60 * 1000);
|
||||
const dueMonitors = await db
|
||||
.select({
|
||||
id: issues.id,
|
||||
companyId: issues.companyId,
|
||||
identifier: issues.identifier,
|
||||
title: issues.title,
|
||||
status: issues.status,
|
||||
assigneeAgentId: issues.assigneeAgentId,
|
||||
assigneeUserId: issues.assigneeUserId,
|
||||
executionPolicy: issues.executionPolicy,
|
||||
executionState: issues.executionState,
|
||||
monitorNextCheckAt: issues.monitorNextCheckAt,
|
||||
monitorWakeRequestedAt: issues.monitorWakeRequestedAt,
|
||||
monitorLastTriggeredAt: issues.monitorLastTriggeredAt,
|
||||
monitorAttemptCount: issues.monitorAttemptCount,
|
||||
monitorNotes: issues.monitorNotes,
|
||||
monitorScheduledBy: issues.monitorScheduledBy,
|
||||
})
|
||||
.select(issueMonitorDispatchColumns)
|
||||
.from(issues)
|
||||
.where(
|
||||
and(
|
||||
@@ -2396,115 +2644,27 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
return updated ?? null;
|
||||
return (updated ?? null) as IssueMonitorDispatchRow | null;
|
||||
});
|
||||
|
||||
if (!claimed?.assigneeAgentId || !claimed.monitorNextCheckAt) continue;
|
||||
|
||||
const scheduledAtIso = claimed.monitorNextCheckAt.toISOString();
|
||||
const nextAttemptCount = (claimed.monitorAttemptCount ?? 0) + 1;
|
||||
const policy = normalizeIssueExecutionPolicy(claimed.executionPolicy ?? null);
|
||||
if (!claimed) continue;
|
||||
|
||||
try {
|
||||
await enqueueWakeup(claimed.assigneeAgentId, {
|
||||
const result = await dispatchClaimedIssueMonitor(claimed, {
|
||||
now,
|
||||
source: "automation",
|
||||
triggerDetail: "system",
|
||||
reason: "issue_monitor_due",
|
||||
idempotencyKey: `issue-monitor:${claimed.id}:${scheduledAtIso}`,
|
||||
payload: {
|
||||
issueId: claimed.id,
|
||||
nextCheckAt: scheduledAtIso,
|
||||
monitorAttemptCount: nextAttemptCount,
|
||||
monitorNotes: claimed.monitorNotes ?? null,
|
||||
},
|
||||
requestedByActorType: "system",
|
||||
requestedByActorId: "heartbeat_scheduler",
|
||||
contextSnapshot: {
|
||||
issueId: claimed.id,
|
||||
source: "issue.monitor",
|
||||
wakeReason: "issue_monitor_due",
|
||||
nextCheckAt: scheduledAtIso,
|
||||
monitorAttemptCount: nextAttemptCount,
|
||||
monitorNotes: claimed.monitorNotes ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
await db
|
||||
.update(issues)
|
||||
.set({
|
||||
...buildIssueMonitorTriggeredPatch({
|
||||
issue: claimed,
|
||||
policy,
|
||||
triggeredAt: now,
|
||||
}),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(issues.id, claimed.id));
|
||||
|
||||
await logActivity(db, {
|
||||
companyId: claimed.companyId,
|
||||
wakeReason: "issue_monitor_due",
|
||||
actorType: "system",
|
||||
actorId: "heartbeat_scheduler",
|
||||
agentId: null,
|
||||
runId: null,
|
||||
action: "issue.monitor_triggered",
|
||||
entityType: "issue",
|
||||
entityId: claimed.id,
|
||||
details: {
|
||||
identifier: claimed.identifier,
|
||||
nextCheckAt: scheduledAtIso,
|
||||
lastTriggeredAt: now.toISOString(),
|
||||
attemptCount: nextAttemptCount,
|
||||
notes: claimed.monitorNotes ?? null,
|
||||
},
|
||||
clearOnClientError: true,
|
||||
activitySource: "scheduled",
|
||||
});
|
||||
|
||||
triggered += 1;
|
||||
if (result.outcome === "triggered") triggered += 1;
|
||||
if (result.outcome === "skipped") skipped += 1;
|
||||
} catch (err) {
|
||||
if (err instanceof HttpError && err.status >= 400 && err.status < 500) {
|
||||
await db
|
||||
.update(issues)
|
||||
.set({
|
||||
...buildIssueMonitorClearedPatch({
|
||||
issue: claimed,
|
||||
policy,
|
||||
clearReason: "dispatch_skipped",
|
||||
clearedAt: now,
|
||||
}),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(issues.id, claimed.id));
|
||||
|
||||
await logActivity(db, {
|
||||
companyId: claimed.companyId,
|
||||
actorType: "system",
|
||||
actorId: "heartbeat_scheduler",
|
||||
agentId: null,
|
||||
runId: null,
|
||||
action: "issue.monitor_skipped",
|
||||
entityType: "issue",
|
||||
entityId: claimed.id,
|
||||
details: {
|
||||
identifier: claimed.identifier,
|
||||
nextCheckAt: scheduledAtIso,
|
||||
attemptCount: nextAttemptCount,
|
||||
notes: claimed.monitorNotes ?? null,
|
||||
reason: err.message,
|
||||
},
|
||||
});
|
||||
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
await db
|
||||
.update(issues)
|
||||
.set({
|
||||
monitorWakeRequestedAt: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(issues.id, claimed.id));
|
||||
|
||||
logger.error({ err, issueId: claimed.id }, "issue monitor tick failed");
|
||||
}
|
||||
}
|
||||
@@ -7923,6 +8083,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
}),
|
||||
|
||||
wakeup: enqueueWakeup,
|
||||
triggerIssueMonitor,
|
||||
|
||||
reportRunActivity: clearDetachedRunWarning,
|
||||
|
||||
|
||||
@@ -69,6 +69,34 @@ function normalizeMonitorNotes(notes: string | null | undefined) {
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function normalizeMonitorText(value: string | null | undefined) {
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function monitorMetadataFromPolicy(monitor: IssueExecutionMonitorPolicy) {
|
||||
return {
|
||||
kind: monitor.kind ?? null,
|
||||
serviceName: normalizeMonitorText(monitor.serviceName),
|
||||
externalRef: normalizeMonitorText(monitor.externalRef),
|
||||
timeoutAt: monitor.timeoutAt ?? null,
|
||||
maxAttempts: monitor.maxAttempts ?? null,
|
||||
recoveryPolicy: monitor.recoveryPolicy ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function monitorMetadataFromState(state: IssueExecutionMonitorState | null | undefined) {
|
||||
return {
|
||||
kind: state?.kind ?? null,
|
||||
serviceName: normalizeMonitorText(state?.serviceName),
|
||||
externalRef: normalizeMonitorText(state?.externalRef),
|
||||
timeoutAt: state?.timeoutAt ?? null,
|
||||
maxAttempts: state?.maxAttempts ?? null,
|
||||
recoveryPolicy: state?.recoveryPolicy ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function blankExecutionState(): IssueExecutionState {
|
||||
return {
|
||||
status: "idle",
|
||||
@@ -77,6 +105,7 @@ function blankExecutionState(): IssueExecutionState {
|
||||
currentStageType: null,
|
||||
currentParticipant: null,
|
||||
returnAssignee: null,
|
||||
reviewRequest: null,
|
||||
completedStageIds: [],
|
||||
lastDecisionId: null,
|
||||
lastDecisionOutcome: null,
|
||||
@@ -120,6 +149,7 @@ function derivePersistedMonitorState(input: {
|
||||
const scheduledByRaw = input.issue.monitorScheduledBy ?? scheduledMonitor?.scheduledBy ?? fromState?.scheduledBy ?? null;
|
||||
const scheduledBy =
|
||||
scheduledByRaw === "assignee" || scheduledByRaw === "board" ? scheduledByRaw : null;
|
||||
const metadata = scheduledMonitor ? monitorMetadataFromPolicy(scheduledMonitor) : monitorMetadataFromState(fromState);
|
||||
|
||||
if (nextCheckAt) {
|
||||
return {
|
||||
@@ -129,6 +159,7 @@ function derivePersistedMonitorState(input: {
|
||||
attemptCount,
|
||||
notes,
|
||||
scheduledBy,
|
||||
...metadata,
|
||||
clearedAt: null,
|
||||
clearReason: null,
|
||||
};
|
||||
@@ -141,6 +172,7 @@ function derivePersistedMonitorState(input: {
|
||||
scheduledBy,
|
||||
attemptCount,
|
||||
lastTriggeredAt,
|
||||
...metadata,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -152,6 +184,7 @@ function derivePersistedMonitorState(input: {
|
||||
attemptCount,
|
||||
notes,
|
||||
scheduledBy,
|
||||
...metadata,
|
||||
clearedAt: null,
|
||||
clearReason: null,
|
||||
};
|
||||
@@ -171,6 +204,7 @@ function buildScheduledMonitorState(
|
||||
attemptCount: previous?.attemptCount ?? 0,
|
||||
notes: monitor.notes ?? null,
|
||||
scheduledBy: monitor.scheduledBy,
|
||||
...monitorMetadataFromPolicy(monitor),
|
||||
clearedAt: null,
|
||||
clearReason: null,
|
||||
};
|
||||
@@ -187,6 +221,7 @@ function buildTriggeredMonitorState(input: {
|
||||
attemptCount: (input.previous?.attemptCount ?? 0) + 1,
|
||||
notes: input.previous?.notes ?? null,
|
||||
scheduledBy: input.previous?.scheduledBy ?? null,
|
||||
...monitorMetadataFromState(input.previous),
|
||||
clearedAt: null,
|
||||
clearReason: null,
|
||||
};
|
||||
@@ -204,6 +239,7 @@ function buildClearedMonitorState(input: {
|
||||
attemptCount: input.previous?.attemptCount ?? 0,
|
||||
notes: input.previous?.notes ?? null,
|
||||
scheduledBy: input.previous?.scheduledBy ?? null,
|
||||
...monitorMetadataFromState(input.previous),
|
||||
clearedAt: input.clearedAt.toISOString(),
|
||||
clearReason: input.clearReason,
|
||||
};
|
||||
@@ -314,6 +350,12 @@ export function normalizeIssueExecutionPolicy(input: unknown): IssueExecutionPol
|
||||
nextCheckAt: parsed.data.monitor.nextCheckAt,
|
||||
notes: normalizeMonitorNotes(parsed.data.monitor.notes),
|
||||
scheduledBy: parsed.data.monitor.scheduledBy,
|
||||
kind: parsed.data.monitor.kind ?? null,
|
||||
serviceName: normalizeMonitorText(parsed.data.monitor.serviceName),
|
||||
externalRef: normalizeMonitorText(parsed.data.monitor.externalRef),
|
||||
timeoutAt: parsed.data.monitor.timeoutAt ?? null,
|
||||
maxAttempts: parsed.data.monitor.maxAttempts ?? null,
|
||||
recoveryPolicy: parsed.data.monitor.recoveryPolicy ?? null,
|
||||
}
|
||||
: null;
|
||||
|
||||
|
||||
@@ -1422,6 +1422,12 @@ const issueListSelect = {
|
||||
assigneeAdapterOverrides: issues.assigneeAdapterOverrides,
|
||||
executionPolicy: sql<null>`null`,
|
||||
executionState: sql<null>`null`,
|
||||
monitorNextCheckAt: issues.monitorNextCheckAt,
|
||||
monitorWakeRequestedAt: issues.monitorWakeRequestedAt,
|
||||
monitorLastTriggeredAt: issues.monitorLastTriggeredAt,
|
||||
monitorAttemptCount: issues.monitorAttemptCount,
|
||||
monitorNotes: issues.monitorNotes,
|
||||
monitorScheduledBy: issues.monitorScheduledBy,
|
||||
executionWorkspaceId: issues.executionWorkspaceId,
|
||||
executionWorkspacePreference: issues.executionWorkspacePreference,
|
||||
executionWorkspaceSettings: sql<null>`null`,
|
||||
|
||||
@@ -23,6 +23,7 @@ export interface IssueLivenessIssueInput {
|
||||
createdByAgentId?: string | null;
|
||||
createdByUserId?: string | null;
|
||||
executionState?: Record<string, unknown> | null;
|
||||
monitorNextCheckAt?: Date | string | null;
|
||||
}
|
||||
|
||||
export interface IssueLivenessRelationInput {
|
||||
@@ -140,6 +141,10 @@ function hasWaitingPath(
|
||||
return waitingPaths.some((entry) => entry.companyId === companyId && entry.issueId === issueId);
|
||||
}
|
||||
|
||||
function hasScheduledMonitor(issue: IssueLivenessIssueInput) {
|
||||
return Boolean(issue.monitorNextCheckAt);
|
||||
}
|
||||
|
||||
function readPrincipalAgentId(principal: unknown): string | null {
|
||||
if (!principal || typeof principal !== "object") return null;
|
||||
const value = principal as Record<string, unknown>;
|
||||
@@ -351,6 +356,7 @@ export function classifyIssueGraphLiveness(input: IssueGraphLivenessInput): Issu
|
||||
|
||||
function hasExplicitWaitingPath(issue: IssueLivenessIssueInput) {
|
||||
return Boolean(issue.assigneeUserId) ||
|
||||
hasScheduledMonitor(issue) ||
|
||||
hasActiveExecutionPath(issue.companyId, issue.id, activeRuns, queuedWakeRequests) ||
|
||||
hasWaitingPath(issue.companyId, issue.id, pendingInteractions) ||
|
||||
hasWaitingPath(issue.companyId, issue.id, pendingApprovals) ||
|
||||
|
||||
@@ -1837,6 +1837,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
||||
createdByAgentId: issues.createdByAgentId,
|
||||
createdByUserId: issues.createdByUserId,
|
||||
executionState: issues.executionState,
|
||||
monitorNextCheckAt: issues.monitorNextCheckAt,
|
||||
})
|
||||
.from(issues)
|
||||
.where(
|
||||
|
||||
@@ -126,6 +126,7 @@ export const issuesApi = {
|
||||
}>(`/issues/${id}/tree-control/state`),
|
||||
releaseTreeHold: (id: string, holdId: string, data: ReleaseIssueTreeHold) =>
|
||||
api.post<IssueTreeHold>(`/issues/${id}/tree-holds/${holdId}/release`, data),
|
||||
checkMonitorNow: (id: string) => api.post<{ ok: true }>(`/issues/${id}/monitor/check-now`, {}),
|
||||
remove: (id: string) => api.delete<Issue>(`/issues/${id}`),
|
||||
checkout: (id: string, agentId: string) =>
|
||||
api.post<Issue>(`/issues/${id}/checkout`, {
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { Issue } from "@paperclipai/shared";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { IssueMonitorActivityCard } from "./IssueMonitorActivityCard";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
function createIssue(overrides: Partial<Issue> = {}): Issue {
|
||||
return {
|
||||
id: "issue-1",
|
||||
companyId: "company-1",
|
||||
projectId: null,
|
||||
projectWorkspaceId: null,
|
||||
goalId: null,
|
||||
parentId: null,
|
||||
title: "Watch deploy",
|
||||
description: null,
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
assigneeAgentId: "agent-1",
|
||||
assigneeUserId: null,
|
||||
checkoutRunId: null,
|
||||
executionRunId: null,
|
||||
executionAgentNameKey: null,
|
||||
executionLockedAt: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: "local-board",
|
||||
issueNumber: 1,
|
||||
identifier: "PAP-1",
|
||||
requestDepth: 0,
|
||||
billingCode: null,
|
||||
assigneeAdapterOverrides: null,
|
||||
executionPolicy: {
|
||||
mode: "normal",
|
||||
commentRequired: true,
|
||||
stages: [],
|
||||
monitor: {
|
||||
nextCheckAt: "2026-04-11T12:30:00.000Z",
|
||||
notes: "Check deployment health",
|
||||
scheduledBy: "board",
|
||||
},
|
||||
},
|
||||
executionState: {
|
||||
status: "idle",
|
||||
currentStageId: null,
|
||||
currentStageIndex: null,
|
||||
currentStageType: null,
|
||||
currentParticipant: null,
|
||||
returnAssignee: null,
|
||||
reviewRequest: null,
|
||||
completedStageIds: [],
|
||||
lastDecisionId: null,
|
||||
lastDecisionOutcome: null,
|
||||
monitor: {
|
||||
status: "scheduled",
|
||||
nextCheckAt: "2026-04-11T12:30:00.000Z",
|
||||
lastTriggeredAt: null,
|
||||
attemptCount: 0,
|
||||
notes: "Check deployment health",
|
||||
scheduledBy: "board",
|
||||
clearedAt: null,
|
||||
clearReason: null,
|
||||
},
|
||||
},
|
||||
monitorNextCheckAt: new Date("2026-04-11T12:30:00.000Z"),
|
||||
monitorLastTriggeredAt: null,
|
||||
monitorAttemptCount: 0,
|
||||
monitorNotes: "Check deployment health",
|
||||
monitorScheduledBy: "board",
|
||||
executionWorkspaceId: null,
|
||||
executionWorkspacePreference: null,
|
||||
executionWorkspaceSettings: null,
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
cancelledAt: null,
|
||||
hiddenAt: null,
|
||||
createdAt: new Date("2026-04-11T10:00:00.000Z"),
|
||||
updatedAt: new Date("2026-04-11T10:00:00.000Z"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("IssueMonitorActivityCard", () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-04-11T12:00:00.000Z"));
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("renders the scheduled monitor details and check-now action", () => {
|
||||
const onCheckNow = vi.fn();
|
||||
const root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root.render(<IssueMonitorActivityCard issue={createIssue()} onCheckNow={onCheckNow} />);
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("Monitor scheduled");
|
||||
expect(container.textContent).toContain("Next check");
|
||||
expect(container.textContent).toContain("in 30m");
|
||||
expect(container.textContent).toContain("Check deployment health");
|
||||
|
||||
const button = Array.from(container.querySelectorAll("button")).find((candidate) =>
|
||||
candidate.textContent?.includes("Check now"),
|
||||
);
|
||||
expect(button).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
button?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(onCheckNow).toHaveBeenCalledTimes(1);
|
||||
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("renders nothing when the issue has no scheduled monitor", () => {
|
||||
const root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<IssueMonitorActivityCard
|
||||
issue={createIssue({
|
||||
executionPolicy: {
|
||||
mode: "normal",
|
||||
commentRequired: true,
|
||||
stages: [],
|
||||
},
|
||||
executionState: {
|
||||
status: "idle",
|
||||
currentStageId: null,
|
||||
currentStageIndex: null,
|
||||
currentStageType: null,
|
||||
currentParticipant: null,
|
||||
returnAssignee: null,
|
||||
reviewRequest: null,
|
||||
completedStageIds: [],
|
||||
lastDecisionId: null,
|
||||
lastDecisionOutcome: null,
|
||||
monitor: null,
|
||||
},
|
||||
monitorNextCheckAt: null,
|
||||
monitorNotes: null,
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(container.textContent).toBe("");
|
||||
|
||||
act(() => root.unmount());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { Issue } from "@paperclipai/shared";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { formatDateTime } from "@/lib/utils";
|
||||
|
||||
function formatMonitorOffset(nextCheckAt: Date | string): string {
|
||||
const deltaMs = new Date(nextCheckAt).getTime() - Date.now();
|
||||
const absMinutes = Math.round(Math.abs(deltaMs) / 60_000);
|
||||
if (absMinutes <= 0) return "now";
|
||||
if (absMinutes < 60) return deltaMs >= 0 ? `in ${absMinutes}m` : `${absMinutes}m ago`;
|
||||
|
||||
const absHours = Math.round(absMinutes / 60);
|
||||
if (absHours < 24) return deltaMs >= 0 ? `in ${absHours}h` : `${absHours}h ago`;
|
||||
|
||||
const absDays = Math.round(absHours / 24);
|
||||
return deltaMs >= 0 ? `in ${absDays}d` : `${absDays}d ago`;
|
||||
}
|
||||
|
||||
function resolveScheduledMonitor(issue: Issue) {
|
||||
const nextCheckAt =
|
||||
issue.monitorNextCheckAt?.toISOString() ??
|
||||
issue.executionPolicy?.monitor?.nextCheckAt ??
|
||||
issue.executionState?.monitor?.nextCheckAt ??
|
||||
null;
|
||||
if (!nextCheckAt) return null;
|
||||
|
||||
return {
|
||||
nextCheckAt,
|
||||
notes: issue.executionPolicy?.monitor?.notes ?? issue.monitorNotes ?? issue.executionState?.monitor?.notes ?? null,
|
||||
attemptCount: issue.monitorAttemptCount ?? issue.executionState?.monitor?.attemptCount ?? 0,
|
||||
serviceName: issue.executionPolicy?.monitor?.serviceName ?? issue.executionState?.monitor?.serviceName ?? null,
|
||||
externalRef: issue.executionPolicy?.monitor?.externalRef ?? issue.executionState?.monitor?.externalRef ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
interface IssueMonitorActivityCardProps {
|
||||
issue: Issue;
|
||||
onCheckNow?: (() => void) | null;
|
||||
checkingNow?: boolean;
|
||||
}
|
||||
|
||||
export function IssueMonitorActivityCard({
|
||||
issue,
|
||||
onCheckNow = null,
|
||||
checkingNow = false,
|
||||
}: IssueMonitorActivityCardProps) {
|
||||
const monitor = resolveScheduledMonitor(issue);
|
||||
if (!monitor) return null;
|
||||
|
||||
return (
|
||||
<div className="mb-3 rounded-lg border border-border bg-muted/30 px-3 py-2">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-foreground">Monitor scheduled</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Next check {formatDateTime(monitor.nextCheckAt)} ({formatMonitorOffset(monitor.nextCheckAt)})
|
||||
</div>
|
||||
{monitor.notes ? (
|
||||
<div className="mt-1 text-xs text-muted-foreground">{monitor.notes}</div>
|
||||
) : null}
|
||||
{monitor.serviceName || monitor.externalRef ? (
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{[monitor.serviceName, monitor.externalRef].filter(Boolean).join(" - ")}
|
||||
</div>
|
||||
) : null}
|
||||
{monitor.attemptCount > 0 ? (
|
||||
<div className="mt-1 text-xs text-muted-foreground">Attempt {monitor.attemptCount}</div>
|
||||
) : null}
|
||||
</div>
|
||||
{onCheckNow ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="shrink-0 shadow-none"
|
||||
onClick={onCheckNow}
|
||||
disabled={checkingNow}
|
||||
>
|
||||
{checkingNow ? "Checking..." : "Check now"}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -233,6 +233,8 @@ export function IssueProperties({
|
||||
const [newLabelColor, setNewLabelColor] = useState("#6366f1");
|
||||
const [monitorAtInput, setMonitorAtInput] = useState(() => toDateTimeLocalValue(issue.executionPolicy?.monitor?.nextCheckAt));
|
||||
const [monitorNotesInput, setMonitorNotesInput] = useState(issue.executionPolicy?.monitor?.notes ?? "");
|
||||
const [monitorServiceInput, setMonitorServiceInput] = useState(issue.executionPolicy?.monitor?.serviceName ?? "");
|
||||
const [monitorExternalRefInput, setMonitorExternalRefInput] = useState(issue.executionPolicy?.monitor?.externalRef ?? "");
|
||||
|
||||
const { data: session } = useQuery({
|
||||
queryKey: queryKeys.auth.session,
|
||||
@@ -472,7 +474,14 @@ export function IssueProperties({
|
||||
useEffect(() => {
|
||||
setMonitorAtInput(toDateTimeLocalValue(issue.executionPolicy?.monitor?.nextCheckAt));
|
||||
setMonitorNotesInput(issue.executionPolicy?.monitor?.notes ?? "");
|
||||
}, [issue.executionPolicy?.monitor?.nextCheckAt, issue.executionPolicy?.monitor?.notes]);
|
||||
setMonitorServiceInput(issue.executionPolicy?.monitor?.serviceName ?? "");
|
||||
setMonitorExternalRefInput(issue.executionPolicy?.monitor?.externalRef ?? "");
|
||||
}, [
|
||||
issue.executionPolicy?.monitor?.nextCheckAt,
|
||||
issue.executionPolicy?.monitor?.notes,
|
||||
issue.executionPolicy?.monitor?.serviceName,
|
||||
issue.executionPolicy?.monitor?.externalRef,
|
||||
]);
|
||||
|
||||
const updateMonitor = (nextMonitor: Issue["executionPolicy"] extends infer T
|
||||
? T extends { monitor?: infer M | null } | null | undefined
|
||||
@@ -501,10 +510,15 @@ export function IssueProperties({
|
||||
if (!monitorAtInput) return;
|
||||
const nextCheckAt = new Date(monitorAtInput);
|
||||
if (Number.isNaN(nextCheckAt.getTime())) return;
|
||||
const serviceName = monitorServiceInput.trim() || null;
|
||||
const externalRef = monitorExternalRefInput.trim() || null;
|
||||
updateMonitor({
|
||||
nextCheckAt: nextCheckAt.toISOString(),
|
||||
notes: monitorNotesInput.trim() || null,
|
||||
scheduledBy: issue.executionPolicy?.monitor?.scheduledBy ?? "assignee",
|
||||
kind: serviceName || externalRef ? "external_service" : null,
|
||||
serviceName,
|
||||
externalRef,
|
||||
});
|
||||
};
|
||||
const clearMonitor = () => updateMonitor(null);
|
||||
@@ -1334,6 +1348,22 @@ export function IssueProperties({
|
||||
value={monitorNotesInput}
|
||||
onChange={(e) => setMonitorNotesInput(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 md:flex-row">
|
||||
<input
|
||||
type="text"
|
||||
className="min-w-0 flex-1 rounded-md border border-border bg-transparent px-2 py-1 text-xs"
|
||||
placeholder="External service"
|
||||
value={monitorServiceInput}
|
||||
onChange={(e) => setMonitorServiceInput(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className="min-w-0 flex-1 rounded-md border border-border bg-transparent px-2 py-1 text-xs"
|
||||
placeholder="External ref or URL"
|
||||
value={monitorExternalRefInput}
|
||||
onChange={(e) => setMonitorExternalRefInput(e.target.value)}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -304,6 +304,14 @@ export function formatIssueActivityAction(
|
||||
});
|
||||
if (structuredChange) return structuredChange;
|
||||
|
||||
if (action.startsWith("issue.monitor_") && details) {
|
||||
const serviceName = typeof details.serviceName === "string" && details.serviceName.trim()
|
||||
? details.serviceName.trim()
|
||||
: null;
|
||||
const base = ISSUE_ACTIVITY_LABELS[action] ?? action.replace(/[._]/g, " ");
|
||||
return serviceName ? `${base} for ${serviceName}` : base;
|
||||
}
|
||||
|
||||
if (
|
||||
(action === "issue.document_created" || action === "issue.document_updated" || action === "issue.document_deleted") &&
|
||||
details
|
||||
|
||||
@@ -70,6 +70,7 @@ import { IssuesList } from "../components/IssuesList";
|
||||
import { AgentIcon } from "../components/AgentIconPicker";
|
||||
import { IssueReferenceActivitySummary } from "../components/IssueReferenceActivitySummary";
|
||||
import { IssueRelatedWorkPanel } from "../components/IssueRelatedWorkPanel";
|
||||
import { IssueMonitorActivityCard } from "../components/IssueMonitorActivityCard";
|
||||
import { IssueProperties } from "../components/IssueProperties";
|
||||
import { IssueRunLedger } from "../components/IssueRunLedger";
|
||||
import { IssueWorkspaceCard } from "../components/IssueWorkspaceCard";
|
||||
@@ -881,6 +882,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
|
||||
});
|
||||
|
||||
type IssueDetailActivityTabProps = {
|
||||
issue: Issue;
|
||||
issueId: string;
|
||||
companyId: string;
|
||||
issueStatus: Issue["status"];
|
||||
@@ -891,10 +893,13 @@ type IssueDetailActivityTabProps = {
|
||||
userProfileMap: Map<string, import("../lib/company-members").CompanyUserProfile>;
|
||||
pendingApprovalAction: { approvalId: string; action: "approve" | "reject" } | null;
|
||||
onApprovalAction: (approvalId: string, action: "approve" | "reject") => void;
|
||||
onCheckMonitorNow: () => void;
|
||||
checkingMonitorNow: boolean;
|
||||
handoffFocusSignal?: number;
|
||||
};
|
||||
|
||||
function IssueDetailActivityTab({
|
||||
issue,
|
||||
issueId,
|
||||
companyId,
|
||||
issueStatus,
|
||||
@@ -905,6 +910,8 @@ function IssueDetailActivityTab({
|
||||
userProfileMap,
|
||||
pendingApprovalAction,
|
||||
onApprovalAction,
|
||||
onCheckMonitorNow,
|
||||
checkingMonitorNow,
|
||||
handoffFocusSignal = 0,
|
||||
}: IssueDetailActivityTabProps) {
|
||||
const { data: activity, isLoading: activityLoading } = useQuery({
|
||||
@@ -1091,6 +1098,11 @@ function IssueDetailActivityTab({
|
||||
</div>
|
||||
)}
|
||||
<IssueContinuationHandoff document={continuationHandoff} focusSignal={handoffFocusSignal} />
|
||||
<IssueMonitorActivityCard
|
||||
issue={issue}
|
||||
onCheckNow={onCheckMonitorNow}
|
||||
checkingNow={checkingMonitorNow}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1754,6 +1766,26 @@ export function IssueDetail() {
|
||||
updateChildIssue.mutate({ id, data });
|
||||
}, [updateChildIssue]);
|
||||
|
||||
const checkIssueMonitorNow = useMutation({
|
||||
mutationFn: () => issuesApi.checkMonitorNow(issueId!),
|
||||
onSuccess: () => {
|
||||
invalidateIssueDetail();
|
||||
invalidateIssueRunState();
|
||||
invalidateIssueCollections();
|
||||
pushToast({
|
||||
title: "Monitor check queued",
|
||||
tone: "success",
|
||||
});
|
||||
},
|
||||
onError: (err) => {
|
||||
pushToast({
|
||||
title: "Monitor check failed",
|
||||
body: err instanceof Error ? err.message : "Unable to trigger the monitor right now",
|
||||
tone: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const approvalDecision = useMutation({
|
||||
mutationFn: async ({ approvalId, action }: { approvalId: string; action: "approve" | "reject" }) => {
|
||||
if (action === "approve") {
|
||||
@@ -3676,6 +3708,7 @@ export function IssueDetail() {
|
||||
<TabsContent value="activity">
|
||||
{detailTab === "activity" ? (
|
||||
<IssueDetailActivityTab
|
||||
issue={issue}
|
||||
issueId={issue.id}
|
||||
companyId={issue.companyId}
|
||||
issueStatus={issue.status}
|
||||
@@ -3689,6 +3722,8 @@ export function IssueDetail() {
|
||||
onApprovalAction={(approvalId, action) => {
|
||||
approvalDecision.mutate({ approvalId, action });
|
||||
}}
|
||||
onCheckMonitorNow={() => checkIssueMonitorNow.mutate()}
|
||||
checkingMonitorNow={checkIssueMonitorNow.isPending}
|
||||
/>
|
||||
) : null}
|
||||
</TabsContent>
|
||||
|
||||
Reference in new issue
Block a user