[codex] Add issue monitor liveness controls (#4988)
## Thinking Path > - Paperclip is a control plane for autonomous AI companies where work must stay observable, governable, and recoverable. > - The task/heartbeat subsystem owns agent execution continuity, issue state transitions, and visible recovery behavior. > - Waiting on an external service is not the same as being blocked when the assignee still owns a future check. > - The gap was that agents had no first-class one-shot monitor state for external-service waits, so recovery could look stalled or require ad hoc comments. > - This pull request adds bounded issue monitors that can wake the owner, clear exhausted waits, and produce explicit recovery behavior. > - It also surfaces monitor status in the board UI and documents when to use monitors versus `blocked`. > - The benefit is clearer liveness semantics for asynchronous waits without weakening single-assignee task ownership. ## What Changed - Added issue monitor fields, shared types, validators, constants, and an idempotent `0075` migration for scheduled monitor state. - Added server-side monitor scheduling, dispatch, recovery bounds, activity logging, and external-ref redaction. - Added board/agent route coverage for monitor permissions and child monitor scheduling. - Added issue detail/property UI for monitor state, a monitor activity card, and Storybook stories for review surfaces. - Documented monitor semantics and recovery policy behavior in `doc/execution-semantics.md`. - Addressed Greptile review feedback by preserving monitor state in skipped-stage builders and making board monitor saves send `scheduledBy: "board"`. ## Verification - `pnpm install --frozen-lockfile` - `pnpm run preflight:workspace-links && pnpm exec vitest run server/src/__tests__/issue-execution-policy-routes.test.ts server/src/__tests__/issue-execution-policy.test.ts server/src/__tests__/issue-monitor-scheduler.test.ts server/src/__tests__/recovery-classifiers.test.ts ui/src/components/IssueMonitorActivityCard.test.tsx ui/src/components/IssueProperties.test.tsx ui/src/lib/activity-format.test.ts` - First run passed 5 files and failed to collect 2 server suites because the worktree was missing the optional `acpx/runtime` dependency. - After `pnpm install --frozen-lockfile`, reran the 2 failed suites successfully. - `pnpm exec vitest run server/src/__tests__/issue-monitor-scheduler.test.ts server/src/__tests__/recovery-classifiers.test.ts` - `pnpm --filter @paperclipai/shared typecheck && pnpm --filter @paperclipai/db typecheck && pnpm --filter @paperclipai/server typecheck && pnpm --filter @paperclipai/ui typecheck` - `pnpm exec vitest run server/src/__tests__/issue-execution-policy.test.ts ui/src/components/IssueProperties.test.tsx` - `pnpm --filter @paperclipai/server typecheck && pnpm --filter @paperclipai/ui typecheck` - `pnpm exec vitest run ui/src/components/IssueMonitorActivityCard.test.tsx ui/src/components/IssueProperties.test.tsx` - `pnpm --filter @paperclipai/ui typecheck` - Storybook screenshot captured from `http://127.0.0.1:6006/iframe.html?viewMode=story&id=product-issue-monitor-surfaces--monitor-surfaces` with Playwright. ## Screenshots  ## Risks - Medium: this changes heartbeat recovery behavior for scheduled external-service waits, so regressions could affect wake timing or recovery issue creation. - Migration risk is reduced by using `IF NOT EXISTS` for the new issue monitor columns and index. - External monitor references are treated as secret-adjacent and are intentionally omitted from visible activity/wake payloads. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, GPT-5 coding agent with repository tool use and terminal execution. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots or Storybook review surfaces - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -67,13 +67,15 @@ This is the right state for:
|
||||
|
||||
- waiting on another issue
|
||||
- waiting on a human decision
|
||||
- waiting on an external dependency or system
|
||||
- waiting on an external dependency or system when Paperclip does not own a scheduled re-check
|
||||
- work that automatic recovery could not safely continue
|
||||
|
||||
### `in_review`
|
||||
|
||||
Execution work is paused because the next move belongs to a reviewer or approver, not the current executor.
|
||||
|
||||
An external review service can also be a valid review path when the issue keeps an agent assignee and has an active one-shot monitor that will wake that assignee to check the service later.
|
||||
|
||||
### `done`
|
||||
|
||||
The work is complete and terminal.
|
||||
@@ -164,6 +166,7 @@ The valid action-path primitives are:
|
||||
- a queued wake or continuation that can be delivered to the responsible agent
|
||||
- a typed execution-policy participant, such as `executionState.currentParticipant`
|
||||
- a pending issue-thread interaction or linked approval that is waiting for a specific responder
|
||||
- a one-shot issue monitor (`executionPolicy.monitor.nextCheckAt`) that will wake the assignee for a future check
|
||||
- a human owner via `assigneeUserId`
|
||||
- a first-class blocker chain whose unresolved leaf issues are themselves healthy
|
||||
- an open explicit recovery issue that names the owner and action needed to restore liveness
|
||||
@@ -188,6 +191,7 @@ A healthy active-work state means at least one of these is true:
|
||||
|
||||
- there is an active run for the issue
|
||||
- there is already a queued continuation wake
|
||||
- there is an active one-shot monitor that will wake the assignee for a future check
|
||||
- there is an open explicit recovery issue for the lost execution path
|
||||
|
||||
An agent-owned `in_progress` issue is stalled when it has no active run, no queued continuation, and no explicit recovery surface. A still-running but silent process is not automatically stalled; it is handled by the active-run watchdog contract.
|
||||
@@ -202,11 +206,34 @@ A healthy `in_review` issue has at least one valid action path:
|
||||
- a pending issue-thread interaction or linked approval waiting for a named responder
|
||||
- a human owner via `assigneeUserId`
|
||||
- an active run or queued wake that is expected to process the review state
|
||||
- an active one-shot monitor for an external service or async review loop that the assignee owns
|
||||
- an open explicit recovery issue for an ambiguous review handoff
|
||||
|
||||
Agent-assigned `in_review` with no typed participant is only healthy when one of the other paths exists. Assignment to the same agent that produced the handoff is not, by itself, a review path.
|
||||
|
||||
An `in_review` issue is stalled when it has no typed participant, no pending interaction or approval, no user owner, no active run, no queued wake, and no explicit recovery issue. Paperclip should surface that state as recovery work rather than silently completing the issue or leaving blocker chains parked indefinitely.
|
||||
An `in_review` issue is stalled when it has no typed participant, no pending interaction or approval, no user owner, no active monitor, no active run, no queued wake, and no explicit recovery issue. Paperclip should surface that state as recovery work rather than silently completing the issue or leaving blocker chains parked indefinitely.
|
||||
|
||||
### Issue monitors
|
||||
|
||||
An issue monitor is a one-shot deferred action path for agent-owned issues in `in_progress` or `in_review`.
|
||||
|
||||
Use a monitor when the current assignee owns a future check against an async system or external service. Examples include Greptile review loops, GitHub checks, Vercel deployments, or provider jobs where the agent should come back later and decide what happens next.
|
||||
|
||||
Monitor policy lives under `executionPolicy.monitor` and includes:
|
||||
|
||||
- `nextCheckAt`: when Paperclip should wake the assignee
|
||||
- `notes`: non-secret instructions for what the assignee should check
|
||||
- `serviceName`: optional non-secret external-service context
|
||||
- `externalRef`: optional external-service reference input; Paperclip treats it as secret-adjacent, redacts it before persistence/visibility, and omits it from activity and wake payloads
|
||||
- `timeoutAt`, `maxAttempts`, and `recoveryPolicy`: optional recovery hints for bounded waits
|
||||
|
||||
Monitors are not recurring intervals. When a monitor fires, Paperclip clears the scheduled monitor and queues an `issue_monitor_due` wake for the assignee. If the external service is still pending, the assignee must explicitly re-arm the monitor with a new `nextCheckAt`. If the issue moves to `done`, `cancelled`, an invalid status, or a human/unassigned owner, the monitor is cleared.
|
||||
|
||||
Because `serviceName` and `notes` remain visible in issue activity and wake context, operators should keep them short and non-secret. Put enough context for the assignee to know what to inspect, but do not include signed URLs, bearer tokens, customer secrets, tenant-private identifiers, or provider links with embedded credentials.
|
||||
|
||||
Monitor bounds are enforced. Paperclip rejects attempts to re-arm a monitor whose `timeoutAt` or `maxAttempts` is already exhausted. When a scheduled monitor reaches an exhausted bound at trigger time, Paperclip clears it and follows `recoveryPolicy`: `wake_owner` queues a bounded recovery wake for the assignee, `create_recovery_issue` opens visible recovery work, and `escalate_to_board` records a board-visible escalation comment/activity.
|
||||
|
||||
Use `blocked` instead of a monitor when no Paperclip assignee owns a responsible polling path. In that case, name the external owner/action or create first-class recovery/blocker work.
|
||||
|
||||
### `blocked`
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 180 KiB |
@@ -0,0 +1,7 @@
|
||||
ALTER TABLE "issues" ADD COLUMN IF NOT EXISTS "monitor_next_check_at" timestamp with time zone;--> statement-breakpoint
|
||||
ALTER TABLE "issues" ADD COLUMN IF NOT EXISTS "monitor_wake_requested_at" timestamp with time zone;--> statement-breakpoint
|
||||
ALTER TABLE "issues" ADD COLUMN IF NOT EXISTS "monitor_last_triggered_at" timestamp with time zone;--> statement-breakpoint
|
||||
ALTER TABLE "issues" ADD COLUMN IF NOT EXISTS "monitor_attempt_count" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "issues" ADD COLUMN IF NOT EXISTS "monitor_notes" text;--> statement-breakpoint
|
||||
ALTER TABLE "issues" ADD COLUMN IF NOT EXISTS "monitor_scheduled_by" text;--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "issues_company_monitor_due_idx" ON "issues" USING btree ("company_id","monitor_next_check_at");
|
||||
File diff suppressed because it is too large
Load Diff
@@ -526,6 +526,13 @@
|
||||
"when": 1777384535070,
|
||||
"tag": "0074_striped_genesis",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 75,
|
||||
"version": "7",
|
||||
"when": 1777572332006,
|
||||
"tag": "0075_cultured_sebastian_shaw",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -50,6 +50,12 @@ export const issues = pgTable(
|
||||
assigneeAdapterOverrides: jsonb("assignee_adapter_overrides").$type<Record<string, unknown>>(),
|
||||
executionPolicy: jsonb("execution_policy").$type<Record<string, unknown>>(),
|
||||
executionState: jsonb("execution_state").$type<Record<string, unknown>>(),
|
||||
monitorNextCheckAt: timestamp("monitor_next_check_at", { withTimezone: true }),
|
||||
monitorWakeRequestedAt: timestamp("monitor_wake_requested_at", { withTimezone: true }),
|
||||
monitorLastTriggeredAt: timestamp("monitor_last_triggered_at", { withTimezone: true }),
|
||||
monitorAttemptCount: integer("monitor_attempt_count").notNull().default(0),
|
||||
monitorNotes: text("monitor_notes"),
|
||||
monitorScheduledBy: text("monitor_scheduled_by"),
|
||||
executionWorkspaceId: uuid("execution_workspace_id")
|
||||
.references((): AnyPgColumn => executionWorkspaces.id, { onDelete: "set null" }),
|
||||
executionWorkspacePreference: text("execution_workspace_preference"),
|
||||
@@ -78,6 +84,7 @@ export const issues = pgTable(
|
||||
originIdx: index("issues_company_origin_idx").on(table.companyId, table.originKind, table.originId),
|
||||
projectWorkspaceIdx: index("issues_company_project_workspace_idx").on(table.companyId, table.projectWorkspaceId),
|
||||
executionWorkspaceIdx: index("issues_company_execution_workspace_idx").on(table.companyId, table.executionWorkspaceId),
|
||||
dueMonitorIdx: index("issues_company_monitor_due_idx").on(table.companyId, table.monitorNextCheckAt),
|
||||
identifierIdx: uniqueIndex("issues_identifier_idx").on(table.identifier),
|
||||
titleSearchIdx: index("issues_title_search_idx").using("gin", table.title.op("gin_trgm_ops")),
|
||||
identifierSearchIdx: index("issues_identifier_search_idx").using("gin", table.identifier.op("gin_trgm_ops")),
|
||||
|
||||
@@ -221,9 +221,39 @@ export type IssueExecutionPolicyMode = (typeof ISSUE_EXECUTION_POLICY_MODES)[num
|
||||
export const ISSUE_EXECUTION_STAGE_TYPES = ["review", "approval"] as const;
|
||||
export type IssueExecutionStageType = (typeof ISSUE_EXECUTION_STAGE_TYPES)[number];
|
||||
|
||||
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];
|
||||
|
||||
export const ISSUE_EXECUTION_MONITOR_STATE_STATUSES = ["scheduled", "triggered", "cleared"] as const;
|
||||
export type IssueExecutionMonitorStateStatus = (typeof ISSUE_EXECUTION_MONITOR_STATE_STATUSES)[number];
|
||||
|
||||
export const ISSUE_EXECUTION_MONITOR_CLEAR_REASONS = [
|
||||
"manual",
|
||||
"triggered",
|
||||
"done",
|
||||
"cancelled",
|
||||
"invalid_status",
|
||||
"invalid_assignee",
|
||||
"dispatch_skipped",
|
||||
"timeout_exceeded",
|
||||
"max_attempts_exhausted",
|
||||
] as const;
|
||||
export type IssueExecutionMonitorClearReason = (typeof ISSUE_EXECUTION_MONITOR_CLEAR_REASONS)[number];
|
||||
|
||||
export const ISSUE_EXECUTION_DECISION_OUTCOMES = ["approved", "changes_requested"] as const;
|
||||
export type IssueExecutionDecisionOutcome = (typeof ISSUE_EXECUTION_DECISION_OUTCOMES)[number];
|
||||
|
||||
|
||||
@@ -35,7 +35,12 @@ export {
|
||||
ISSUE_REFERENCE_SOURCE_KINDS,
|
||||
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,
|
||||
ISSUE_EXECUTION_DECISION_OUTCOMES,
|
||||
GOAL_LEVELS,
|
||||
GOAL_STATUSES,
|
||||
@@ -136,7 +141,12 @@ export {
|
||||
type IssueReferenceSourceKind,
|
||||
type IssueExecutionPolicyMode,
|
||||
type IssueExecutionStageType,
|
||||
type IssueMonitorScheduledBy,
|
||||
type IssueExecutionMonitorKind,
|
||||
type IssueExecutionMonitorRecoveryPolicy,
|
||||
type IssueExecutionStateStatus,
|
||||
type IssueExecutionMonitorStateStatus,
|
||||
type IssueExecutionMonitorClearReason,
|
||||
type IssueExecutionDecisionOutcome,
|
||||
type GoalLevel,
|
||||
type GoalStatus,
|
||||
@@ -340,6 +350,8 @@ export type {
|
||||
IssueReferenceSource,
|
||||
IssueRelatedWorkItem,
|
||||
IssueRelatedWorkSummary,
|
||||
IssueExecutionMonitorPolicy,
|
||||
IssueExecutionMonitorState,
|
||||
IssueRelation,
|
||||
IssueRelationIssueSummary,
|
||||
IssueExecutionPolicy,
|
||||
|
||||
@@ -145,6 +145,8 @@ export type {
|
||||
IssueRelatedWorkSummary,
|
||||
IssueRelation,
|
||||
IssueRelationIssueSummary,
|
||||
IssueExecutionMonitorPolicy,
|
||||
IssueExecutionMonitorState,
|
||||
IssueExecutionPolicy,
|
||||
IssueExecutionState,
|
||||
IssueExecutionStage,
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import type {
|
||||
IssueExecutionMonitorClearReason,
|
||||
IssueExecutionMonitorKind,
|
||||
IssueExecutionMonitorRecoveryPolicy,
|
||||
IssueExecutionMonitorStateStatus,
|
||||
IssueExecutionDecisionOutcome,
|
||||
IssueMonitorScheduledBy,
|
||||
IssueExecutionPolicyMode,
|
||||
IssueReferenceSourceKind,
|
||||
IssueExecutionStageType,
|
||||
@@ -201,10 +206,40 @@ export interface IssueExecutionStage {
|
||||
participants: IssueExecutionStageParticipant[];
|
||||
}
|
||||
|
||||
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 {
|
||||
mode: IssueExecutionPolicyMode;
|
||||
commentRequired: boolean;
|
||||
stages: IssueExecutionStage[];
|
||||
monitor?: IssueExecutionMonitorPolicy | null;
|
||||
}
|
||||
|
||||
export interface IssueExecutionMonitorState {
|
||||
status: IssueExecutionMonitorStateStatus;
|
||||
nextCheckAt: string | null;
|
||||
lastTriggeredAt: string | null;
|
||||
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;
|
||||
}
|
||||
|
||||
export interface IssueReviewRequest {
|
||||
@@ -222,6 +257,7 @@ export interface IssueExecutionState {
|
||||
completedStageIds: string[];
|
||||
lastDecisionId: string | null;
|
||||
lastDecisionOutcome: IssueExecutionDecisionOutcome | null;
|
||||
monitor?: IssueExecutionMonitorState | null;
|
||||
}
|
||||
|
||||
export interface IssueExecutionDecision {
|
||||
@@ -270,6 +306,11 @@ export interface Issue {
|
||||
assigneeAdapterOverrides: IssueAssigneeAdapterOverrides | null;
|
||||
executionPolicy?: IssueExecutionPolicy | null;
|
||||
executionState?: IssueExecutionState | null;
|
||||
monitorNextCheckAt?: Date | null;
|
||||
monitorLastTriggeredAt?: Date | null;
|
||||
monitorAttemptCount?: number;
|
||||
monitorNotes?: string | null;
|
||||
monitorScheduledBy?: IssueMonitorScheduledBy | null;
|
||||
executionWorkspaceId: string | null;
|
||||
executionWorkspacePreference: string | null;
|
||||
executionWorkspaceSettings: IssueExecutionWorkspaceSettings | null;
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
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,
|
||||
ISSUE_EXECUTION_STATE_STATUSES,
|
||||
ISSUE_MONITOR_SCHEDULED_BY,
|
||||
ISSUE_PRIORITIES,
|
||||
clampIssueRequestDepth,
|
||||
ISSUE_STATUSES,
|
||||
@@ -103,10 +108,40 @@ export const issueExecutionStageSchema = z.object({
|
||||
participants: z.array(issueExecutionStageParticipantSchema).default([]),
|
||||
});
|
||||
|
||||
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({
|
||||
mode: z.enum(ISSUE_EXECUTION_POLICY_MODES).optional().default("normal"),
|
||||
commentRequired: z.boolean().optional().default(true),
|
||||
stages: z.array(issueExecutionStageSchema).default([]),
|
||||
monitor: issueExecutionMonitorPolicySchema.optional().nullable(),
|
||||
});
|
||||
|
||||
export const issueExecutionMonitorStateSchema = z.object({
|
||||
status: z.enum(ISSUE_EXECUTION_MONITOR_STATE_STATUSES),
|
||||
nextCheckAt: z.string().datetime().nullable(),
|
||||
lastTriggeredAt: z.string().datetime().nullable(),
|
||||
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(),
|
||||
});
|
||||
|
||||
export const issueReviewRequestSchema = z.object({
|
||||
@@ -124,6 +159,7 @@ export const issueExecutionStateSchema = z.object({
|
||||
completedStageIds: z.array(z.string().uuid()).default([]),
|
||||
lastDecisionId: z.string().uuid().nullable(),
|
||||
lastDecisionOutcome: z.enum(ISSUE_EXECUTION_DECISION_OUTCOMES).nullable(),
|
||||
monitor: issueExecutionMonitorStateSchema.optional().nullable(),
|
||||
});
|
||||
|
||||
const issueRequestDepthInputSchema = z
|
||||
|
||||
@@ -7,6 +7,7 @@ const mockIssueService = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
assertCheckoutOwner: vi.fn(),
|
||||
update: vi.fn(),
|
||||
createChild: vi.fn(),
|
||||
addComment: vi.fn(),
|
||||
findMentionedAgents: vi.fn(),
|
||||
getRelationSummaries: vi.fn(),
|
||||
@@ -16,21 +17,26 @@ 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),
|
||||
cancelRun: vi.fn(async () => null),
|
||||
}));
|
||||
|
||||
const mockAccessService = vi.hoisted(() => ({
|
||||
canUser: vi.fn(async () => false),
|
||||
hasPermission: vi.fn(async () => false),
|
||||
}));
|
||||
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined));
|
||||
|
||||
function registerModuleMocks() {
|
||||
vi.doMock("../services/index.js", () => ({
|
||||
companyService: () => ({
|
||||
getById: vi.fn(async () => ({ id: "company-1", attachmentMaxBytes: 10 * 1024 * 1024 })),
|
||||
}),
|
||||
accessService: () => ({
|
||||
canUser: vi.fn(async () => false),
|
||||
hasPermission: vi.fn(async () => false),
|
||||
}),
|
||||
accessService: () => mockAccessService,
|
||||
agentService: () => ({
|
||||
getById: vi.fn(async () => null),
|
||||
}),
|
||||
@@ -42,6 +48,9 @@ function registerModuleMocks() {
|
||||
}),
|
||||
goalService: () => ({}),
|
||||
heartbeatService: () => mockHeartbeatService,
|
||||
environmentService: () => ({
|
||||
getById: vi.fn(async () => null),
|
||||
}),
|
||||
instanceSettingsService: () => ({
|
||||
get: vi.fn(async () => ({
|
||||
id: "instance-settings-1",
|
||||
@@ -67,7 +76,7 @@ function registerModuleMocks() {
|
||||
syncIssue: async () => undefined,
|
||||
}),
|
||||
issueService: () => mockIssueService,
|
||||
logActivity: vi.fn(async () => undefined),
|
||||
logActivity: mockLogActivity,
|
||||
projectService: () => ({}),
|
||||
routineService: () => ({
|
||||
syncRunStatusForIssue: vi.fn(async () => undefined),
|
||||
@@ -76,7 +85,22 @@ function registerModuleMocks() {
|
||||
}));
|
||||
}
|
||||
|
||||
async function createApp() {
|
||||
type TestActor =
|
||||
| {
|
||||
type: "board";
|
||||
userId: string;
|
||||
companyIds: string[];
|
||||
source: "local_implicit";
|
||||
isInstanceAdmin: boolean;
|
||||
}
|
||||
| {
|
||||
type: "agent";
|
||||
agentId: string;
|
||||
companyId: string;
|
||||
runId: string | null;
|
||||
};
|
||||
|
||||
async function createApp(actor?: TestActor) {
|
||||
const [{ errorHandler }, { issueRoutes }] = await Promise.all([
|
||||
import("../middleware/index.js"),
|
||||
import("../routes/issues.js"),
|
||||
@@ -84,7 +108,7 @@ async function createApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
(req as any).actor = {
|
||||
(req as any).actor = actor ?? {
|
||||
type: "board",
|
||||
userId: "local-board",
|
||||
companyIds: ["company-1"],
|
||||
@@ -111,6 +135,17 @@ describe("issue execution policy routes", () => {
|
||||
mockIssueService.getRelationSummaries.mockResolvedValue({ blockedBy: [], blocks: [] });
|
||||
mockIssueService.listWakeableBlockedDependents.mockResolvedValue([]);
|
||||
mockIssueService.getWakeableParentAfterChildCompletion.mockResolvedValue(null);
|
||||
mockIssueService.createChild.mockResolvedValue({
|
||||
issue: {
|
||||
id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
|
||||
companyId: "company-1",
|
||||
identifier: "PAP-1002",
|
||||
title: "Child issue",
|
||||
},
|
||||
parentBlockerAdded: false,
|
||||
});
|
||||
mockAccessService.canUser.mockResolvedValue(false);
|
||||
mockAccessService.hasPermission.mockResolvedValue(false);
|
||||
});
|
||||
|
||||
it("does not auto-start execution review when reviewers are added to an already in_review issue", async () => {
|
||||
@@ -162,4 +197,175 @@ 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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("lets a board user create a child issue with a scheduled monitor", async () => {
|
||||
mockIssueService.getById.mockResolvedValue({
|
||||
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
companyId: "company-1",
|
||||
status: "in_progress",
|
||||
assigneeAgentId: "11111111-1111-4111-8111-111111111111",
|
||||
assigneeUserId: null,
|
||||
createdByUserId: "local-board",
|
||||
identifier: "PAP-1001",
|
||||
title: "Parent issue",
|
||||
executionPolicy: null,
|
||||
executionState: null,
|
||||
});
|
||||
|
||||
const res = await request(await createApp())
|
||||
.post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/children")
|
||||
.send({
|
||||
title: "Child monitor",
|
||||
status: "in_review",
|
||||
assigneeAgentId: "33333333-3333-4333-8333-333333333333",
|
||||
executionPolicy: {
|
||||
monitor: {
|
||||
nextCheckAt: "2026-04-11T12:30:00.000Z",
|
||||
scheduledBy: "assignee",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
const createPayload = mockIssueService.createChild.mock.calls[0]?.[1] as {
|
||||
executionPolicy: { monitor: { scheduledBy: string } };
|
||||
};
|
||||
expect(createPayload.executionPolicy.monitor.scheduledBy).toBe("board");
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
action: "issue.monitor_scheduled",
|
||||
details: expect.objectContaining({
|
||||
scheduledBy: "board",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects child monitor scheduling by a non-assignee agent even with task assignment permission", async () => {
|
||||
mockAccessService.hasPermission.mockResolvedValue(true);
|
||||
mockIssueService.getById.mockResolvedValue({
|
||||
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
companyId: "company-1",
|
||||
status: "in_progress",
|
||||
assigneeAgentId: "11111111-1111-4111-8111-111111111111",
|
||||
assigneeUserId: null,
|
||||
createdByUserId: "local-board",
|
||||
identifier: "PAP-1001",
|
||||
title: "Parent issue",
|
||||
executionPolicy: null,
|
||||
executionState: null,
|
||||
});
|
||||
|
||||
const res = await request(await createApp({
|
||||
type: "agent",
|
||||
agentId: "22222222-2222-4222-8222-222222222222",
|
||||
companyId: "company-1",
|
||||
runId: "run-1",
|
||||
}))
|
||||
.post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/children")
|
||||
.send({
|
||||
title: "Child monitor",
|
||||
status: "in_review",
|
||||
assigneeAgentId: "33333333-3333-4333-8333-333333333333",
|
||||
executionPolicy: {
|
||||
monitor: {
|
||||
nextCheckAt: "2026-04-11T12:30:00.000Z",
|
||||
scheduledBy: "board",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.error).toBe("Only the assignee agent or a board user can manage issue monitors");
|
||||
expect(mockIssueService.createChild).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("normalizes spoofed child monitor scheduledBy to the assignee actor", async () => {
|
||||
mockAccessService.hasPermission.mockResolvedValue(true);
|
||||
mockIssueService.getById.mockResolvedValue({
|
||||
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: "Parent issue",
|
||||
executionPolicy: null,
|
||||
executionState: null,
|
||||
});
|
||||
|
||||
const res = await request(await createApp({
|
||||
type: "agent",
|
||||
agentId: "33333333-3333-4333-8333-333333333333",
|
||||
companyId: "company-1",
|
||||
runId: "run-1",
|
||||
}))
|
||||
.post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/children")
|
||||
.send({
|
||||
title: "Child monitor",
|
||||
status: "in_review",
|
||||
assigneeAgentId: "33333333-3333-4333-8333-333333333333",
|
||||
executionPolicy: {
|
||||
monitor: {
|
||||
nextCheckAt: "2026-04-11T12:30:00.000Z",
|
||||
scheduledBy: "board",
|
||||
externalRef: "https://example.test/deploy?token=secret",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
const createPayload = mockIssueService.createChild.mock.calls[0]?.[1] as {
|
||||
executionPolicy: { monitor: { scheduledBy: string; externalRef: string | null } };
|
||||
};
|
||||
expect(createPayload.executionPolicy.monitor.scheduledBy).toBe("assignee");
|
||||
expect(createPayload.executionPolicy.monitor.externalRef).toBe("[redacted]");
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
action: "issue.monitor_scheduled",
|
||||
entityId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
|
||||
details: expect.not.objectContaining({ externalRef: expect.anything() }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -112,6 +112,26 @@ describe("normalizeIssueExecutionPolicy", () => {
|
||||
it("throws for invalid input", () => {
|
||||
expect(() => normalizeIssueExecutionPolicy({ stages: [{ type: "invalid_type" }] })).toThrow();
|
||||
});
|
||||
|
||||
it("keeps monitor-only policies", () => {
|
||||
const result = normalizeIssueExecutionPolicy({
|
||||
monitor: {
|
||||
nextCheckAt: "2026-04-11T12:30:00.000Z",
|
||||
notes: "Check deployment",
|
||||
externalRef: "https://example.test/deploy?token=secret",
|
||||
},
|
||||
stages: [],
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
stages: [],
|
||||
monitor: {
|
||||
nextCheckAt: "2026-04-11T12:30:00.000Z",
|
||||
notes: "Check deployment",
|
||||
scheduledBy: "assignee",
|
||||
externalRef: "[redacted]",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseIssueExecutionState", () => {
|
||||
@@ -1261,4 +1281,169 @@ describe("issue execution policy transitions", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("monitor policy", () => {
|
||||
it("schedules a one-shot monitor on an active agent-owned issue", () => {
|
||||
const policy = normalizeIssueExecutionPolicy({
|
||||
stages: [],
|
||||
monitor: {
|
||||
nextCheckAt: "2026-04-11T12:30:00.000Z",
|
||||
notes: "Check deployment",
|
||||
scheduledBy: "board",
|
||||
},
|
||||
})!;
|
||||
|
||||
const result = applyIssueExecutionPolicyTransition({
|
||||
issue: {
|
||||
status: "in_progress",
|
||||
assigneeAgentId: coderAgentId,
|
||||
assigneeUserId: null,
|
||||
executionPolicy: null,
|
||||
executionState: null,
|
||||
monitorAttemptCount: 0,
|
||||
monitorNextCheckAt: null,
|
||||
monitorLastTriggeredAt: null,
|
||||
monitorNotes: null,
|
||||
monitorScheduledBy: null,
|
||||
},
|
||||
policy,
|
||||
previousPolicy: null,
|
||||
requestedAssigneePatch: {},
|
||||
actor: { userId: boardUserId },
|
||||
monitorExplicitlyUpdated: true,
|
||||
});
|
||||
|
||||
expect(result.patch.monitorNextCheckAt).toEqual(new Date("2026-04-11T12:30:00.000Z"));
|
||||
expect(result.patch.monitorScheduledBy).toBe("board");
|
||||
expect(result.patch.executionState).toMatchObject({
|
||||
status: "idle",
|
||||
monitor: {
|
||||
status: "scheduled",
|
||||
nextCheckAt: "2026-04-11T12:30:00.000Z",
|
||||
notes: "Check deployment",
|
||||
scheduledBy: "board",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("auto-clears a scheduled monitor when the issue moves to done", () => {
|
||||
const policy = normalizeIssueExecutionPolicy({
|
||||
stages: [],
|
||||
monitor: {
|
||||
nextCheckAt: "2026-04-11T12:30:00.000Z",
|
||||
notes: "Check deployment",
|
||||
scheduledBy: "assignee",
|
||||
},
|
||||
})!;
|
||||
|
||||
const result = applyIssueExecutionPolicyTransition({
|
||||
issue: {
|
||||
status: "in_progress",
|
||||
assigneeAgentId: coderAgentId,
|
||||
assigneeUserId: null,
|
||||
executionPolicy: policy,
|
||||
executionState: {
|
||||
status: "idle",
|
||||
currentStageId: null,
|
||||
currentStageIndex: null,
|
||||
currentStageType: null,
|
||||
currentParticipant: null,
|
||||
returnAssignee: null,
|
||||
completedStageIds: [],
|
||||
lastDecisionId: null,
|
||||
lastDecisionOutcome: null,
|
||||
monitor: {
|
||||
status: "scheduled",
|
||||
nextCheckAt: "2026-04-11T12:30:00.000Z",
|
||||
lastTriggeredAt: null,
|
||||
attemptCount: 0,
|
||||
notes: "Check deployment",
|
||||
scheduledBy: "assignee",
|
||||
clearedAt: null,
|
||||
clearReason: null,
|
||||
},
|
||||
},
|
||||
monitorAttemptCount: 0,
|
||||
monitorNextCheckAt: new Date("2026-04-11T12:30:00.000Z"),
|
||||
monitorLastTriggeredAt: null,
|
||||
monitorNotes: "Check deployment",
|
||||
monitorScheduledBy: "assignee",
|
||||
},
|
||||
policy,
|
||||
previousPolicy: policy,
|
||||
requestedStatus: "done",
|
||||
requestedAssigneePatch: {},
|
||||
actor: { agentId: coderAgentId },
|
||||
});
|
||||
|
||||
expect(result.patch.executionPolicy).toBeNull();
|
||||
expect(result.patch.monitorNextCheckAt).toBeNull();
|
||||
expect(result.patch.executionState).toMatchObject({
|
||||
monitor: {
|
||||
status: "cleared",
|
||||
clearReason: "done",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects explicitly scheduling a monitor on an invalid issue state", () => {
|
||||
const policy = normalizeIssueExecutionPolicy({
|
||||
stages: [],
|
||||
monitor: {
|
||||
nextCheckAt: "2026-04-11T12:30:00.000Z",
|
||||
notes: "Check deployment",
|
||||
},
|
||||
})!;
|
||||
|
||||
expect(() =>
|
||||
applyIssueExecutionPolicyTransition({
|
||||
issue: {
|
||||
status: "blocked",
|
||||
assigneeAgentId: coderAgentId,
|
||||
assigneeUserId: null,
|
||||
executionPolicy: null,
|
||||
executionState: null,
|
||||
},
|
||||
policy,
|
||||
previousPolicy: null,
|
||||
requestedAssigneePatch: {},
|
||||
actor: { agentId: coderAgentId },
|
||||
monitorExplicitlyUpdated: true,
|
||||
}),
|
||||
).toThrow("Monitor can only be scheduled");
|
||||
});
|
||||
|
||||
it("rejects explicitly re-arming a monitor after max attempts are exhausted", () => {
|
||||
const policy = normalizeIssueExecutionPolicy({
|
||||
stages: [],
|
||||
monitor: {
|
||||
nextCheckAt: "2099-04-11T12:30:00.000Z",
|
||||
maxAttempts: 1,
|
||||
scheduledBy: "assignee",
|
||||
},
|
||||
})!;
|
||||
|
||||
expect(() =>
|
||||
applyIssueExecutionPolicyTransition({
|
||||
issue: {
|
||||
status: "in_review",
|
||||
assigneeAgentId: coderAgentId,
|
||||
assigneeUserId: null,
|
||||
executionPolicy: null,
|
||||
executionState: null,
|
||||
monitorAttemptCount: 1,
|
||||
monitorNextCheckAt: null,
|
||||
monitorLastTriggeredAt: null,
|
||||
monitorNotes: null,
|
||||
monitorScheduledBy: "assignee",
|
||||
},
|
||||
policy,
|
||||
previousPolicy: null,
|
||||
requestedAssigneePatch: {},
|
||||
actor: { agentId: coderAgentId },
|
||||
monitorExplicitlyUpdated: true,
|
||||
}),
|
||||
).toThrow("Monitor bounds are already exhausted");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,448 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
activityLog,
|
||||
agentRuntimeState,
|
||||
agentWakeupRequests,
|
||||
agents,
|
||||
companies,
|
||||
companySkills,
|
||||
createDb,
|
||||
documentRevisions,
|
||||
documents,
|
||||
environmentLeases,
|
||||
heartbeatRunEvents,
|
||||
heartbeatRuns,
|
||||
issueComments,
|
||||
issueDocuments,
|
||||
issues,
|
||||
workspaceRuntimeServices,
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
import { heartbeatService } from "../services/heartbeat.ts";
|
||||
import { normalizeIssueExecutionPolicy, parseIssueExecutionState } from "../services/issue-execution-policy.ts";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
if (!embeddedPostgresSupport.supported) {
|
||||
console.warn(
|
||||
`Skipping embedded Postgres issue monitor scheduler tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
||||
);
|
||||
}
|
||||
|
||||
describeEmbeddedPostgres("issue monitor scheduler", () => {
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
const seededAgentIds = new Set<string>();
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issue-monitor-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
}, 20_000);
|
||||
|
||||
async function waitForHeartbeatIdle(timeoutMs = 3_000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const active = await db
|
||||
.select({ id: heartbeatRuns.id })
|
||||
.from(heartbeatRuns)
|
||||
.where(sql`${heartbeatRuns.status} in ('queued', 'running', 'scheduled_retry')`);
|
||||
if (active.length === 0) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
throw new Error("Timed out waiting for issue monitor heartbeat runs to settle");
|
||||
}
|
||||
|
||||
async function heartbeatSideEffectFingerprint() {
|
||||
const [active, events, activity, leases, runtimeServices] = await Promise.all([
|
||||
db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(heartbeatRuns)
|
||||
.where(sql`${heartbeatRuns.status} in ('queued', 'running', 'scheduled_retry')`),
|
||||
db.select({ count: sql<number>`count(*)` }).from(heartbeatRunEvents),
|
||||
db.select({ count: sql<number>`count(*)` }).from(activityLog),
|
||||
db.select({ count: sql<number>`count(*)` }).from(environmentLeases),
|
||||
db.select({ count: sql<number>`count(*)` }).from(workspaceRuntimeServices),
|
||||
]);
|
||||
|
||||
return [
|
||||
active[0]?.count ?? 0,
|
||||
events[0]?.count ?? 0,
|
||||
activity[0]?.count ?? 0,
|
||||
leases[0]?.count ?? 0,
|
||||
runtimeServices[0]?.count ?? 0,
|
||||
].join(":");
|
||||
}
|
||||
|
||||
async function waitForHeartbeatSideEffectsSettled(timeoutMs = 5_000, quietMs = 500) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let previous = "";
|
||||
let stableSince = Date.now();
|
||||
while (Date.now() < deadline) {
|
||||
const current = await heartbeatSideEffectFingerprint();
|
||||
const activeCount = Number(current.split(":")[0] ?? 0);
|
||||
if (current !== previous || activeCount > 0) {
|
||||
previous = current;
|
||||
stableSince = Date.now();
|
||||
} else if (Date.now() - stableSince >= quietMs) {
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
throw new Error("Timed out waiting for issue monitor heartbeat side effects to settle");
|
||||
}
|
||||
|
||||
async function cleanupRows() {
|
||||
await waitForHeartbeatSideEffectsSettled();
|
||||
await db.delete(heartbeatRunEvents);
|
||||
await db.delete(issueComments);
|
||||
await db.delete(documentRevisions);
|
||||
await db.delete(issueDocuments);
|
||||
await db.delete(documents);
|
||||
await db.delete(activityLog);
|
||||
await db.delete(environmentLeases);
|
||||
await db.delete(workspaceRuntimeServices);
|
||||
await db.delete(issues);
|
||||
await db.delete(heartbeatRuns);
|
||||
await db.delete(agentWakeupRequests);
|
||||
await db.delete(agentRuntimeState);
|
||||
await db.delete(agents);
|
||||
await db.delete(companySkills);
|
||||
await db.delete(companies);
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
seededAgentIds.clear();
|
||||
let lastError: unknown = null;
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
try {
|
||||
await cleanupRows();
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
async function seedFixture(input?: {
|
||||
agentStatus?: "active" | "paused";
|
||||
issueStatus?: "in_progress" | "in_review";
|
||||
monitorAttemptCount?: number;
|
||||
monitor?: Record<string, unknown>;
|
||||
}) {
|
||||
const companyId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
const issueId = randomUUID();
|
||||
const nextCheckAt = new Date("2026-04-11T12:30:00.000Z");
|
||||
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
|
||||
|
||||
const monitorAttemptCount = input?.monitorAttemptCount ?? 0;
|
||||
const monitor = {
|
||||
nextCheckAt: nextCheckAt.toISOString(),
|
||||
notes: "Check deploy",
|
||||
scheduledBy: "assignee",
|
||||
...(input?.monitor ?? {}),
|
||||
};
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
companyId,
|
||||
name: "Monitor Bot",
|
||||
role: "engineer",
|
||||
status: input?.agentStatus ?? "active",
|
||||
adapterType: "process",
|
||||
adapterConfig: {
|
||||
command: process.execPath,
|
||||
args: ["-e", ""],
|
||||
cwd: process.cwd(),
|
||||
},
|
||||
runtimeConfig: {
|
||||
heartbeat: {
|
||||
enabled: false,
|
||||
wakeOnDemand: true,
|
||||
},
|
||||
},
|
||||
permissions: {},
|
||||
});
|
||||
seededAgentIds.add(agentId);
|
||||
|
||||
await db.insert(issues).values({
|
||||
id: issueId,
|
||||
companyId,
|
||||
title: "Watch external deploy",
|
||||
status: input?.issueStatus ?? "in_progress",
|
||||
priority: "medium",
|
||||
assigneeAgentId: agentId,
|
||||
issueNumber: 1,
|
||||
identifier: `${issuePrefix}-1`,
|
||||
executionPolicy: {
|
||||
mode: "normal",
|
||||
commentRequired: true,
|
||||
stages: [],
|
||||
monitor,
|
||||
},
|
||||
executionState: {
|
||||
status: "idle",
|
||||
currentStageId: null,
|
||||
currentStageIndex: null,
|
||||
currentStageType: null,
|
||||
currentParticipant: null,
|
||||
returnAssignee: null,
|
||||
completedStageIds: [],
|
||||
lastDecisionId: null,
|
||||
lastDecisionOutcome: null,
|
||||
monitor: {
|
||||
status: "scheduled",
|
||||
nextCheckAt: nextCheckAt.toISOString(),
|
||||
lastTriggeredAt: null,
|
||||
attemptCount: monitorAttemptCount,
|
||||
notes: "Check deploy",
|
||||
scheduledBy: "assignee",
|
||||
serviceName: typeof monitor.serviceName === "string" ? monitor.serviceName : null,
|
||||
externalRef: typeof monitor.externalRef === "string" ? monitor.externalRef : null,
|
||||
timeoutAt: typeof monitor.timeoutAt === "string" ? monitor.timeoutAt : null,
|
||||
maxAttempts: typeof monitor.maxAttempts === "number" ? monitor.maxAttempts : null,
|
||||
recoveryPolicy: typeof monitor.recoveryPolicy === "string" ? monitor.recoveryPolicy : null,
|
||||
clearedAt: null,
|
||||
clearReason: null,
|
||||
},
|
||||
},
|
||||
monitorNextCheckAt: nextCheckAt,
|
||||
monitorAttemptCount,
|
||||
monitorNotes: "Check deploy",
|
||||
monitorScheduledBy: "assignee",
|
||||
});
|
||||
|
||||
return { companyId, agentId, issueId, nextCheckAt };
|
||||
}
|
||||
|
||||
it("triggers due issue monitors once and clears the one-shot schedule", async () => {
|
||||
const { issueId, agentId } = await seedFixture();
|
||||
const heartbeat = heartbeatService(db);
|
||||
const tickAt = new Date("2026-04-11T12:31:00.000Z");
|
||||
|
||||
const result = await heartbeat.tickTimers(tickAt);
|
||||
|
||||
expect(result.enqueued).toBe(1);
|
||||
|
||||
const issue = await db.select().from(issues).where(eq(issues.id, issueId)).then((rows) => rows[0]!);
|
||||
expect(issue.monitorNextCheckAt).toBeNull();
|
||||
expect(issue.monitorAttemptCount).toBe(1);
|
||||
expect(issue.monitorLastTriggeredAt?.toISOString()).toBe(tickAt.toISOString());
|
||||
expect(normalizeIssueExecutionPolicy(issue.executionPolicy ?? null)?.monitor ?? null).toBeNull();
|
||||
expect(parseIssueExecutionState(issue.executionState)?.monitor).toMatchObject({
|
||||
status: "triggered",
|
||||
lastTriggeredAt: tickAt.toISOString(),
|
||||
attemptCount: 1,
|
||||
});
|
||||
|
||||
const wakeup = await db
|
||||
.select()
|
||||
.from(agentWakeupRequests)
|
||||
.where(eq(agentWakeupRequests.agentId, agentId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
expect(wakeup?.reason).toBe("issue_monitor_due");
|
||||
|
||||
const activity = await db
|
||||
.select()
|
||||
.from(activityLog)
|
||||
.where(eq(activityLog.entityId, issueId))
|
||||
.then((rows) => rows.map((row) => row.action));
|
||||
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);
|
||||
const tickAt = new Date("2026-04-11T12:31:00.000Z");
|
||||
|
||||
const result = await heartbeat.tickTimers(tickAt);
|
||||
|
||||
expect(result.skipped).toBe(1);
|
||||
|
||||
const issue = await db.select().from(issues).where(eq(issues.id, issueId)).then((rows) => rows[0]!);
|
||||
expect(issue.monitorNextCheckAt).toBeNull();
|
||||
expect(parseIssueExecutionState(issue.executionState)?.monitor).toMatchObject({
|
||||
status: "cleared",
|
||||
clearReason: "dispatch_skipped",
|
||||
});
|
||||
|
||||
const activity = await db
|
||||
.select()
|
||||
.from(activityLog)
|
||||
.where(eq(activityLog.entityId, issueId))
|
||||
.then((rows) => rows.map((row) => row.action));
|
||||
expect(activity).toContain("issue.monitor_skipped");
|
||||
});
|
||||
|
||||
it("clears exhausted monitors and queues bounded owner recovery instead of another due check", async () => {
|
||||
const { issueId, agentId } = await seedFixture({
|
||||
monitorAttemptCount: 1,
|
||||
monitor: {
|
||||
maxAttempts: 1,
|
||||
recoveryPolicy: "wake_owner",
|
||||
},
|
||||
});
|
||||
const heartbeat = heartbeatService(db);
|
||||
const tickAt = new Date("2026-04-11T12:31:00.000Z");
|
||||
|
||||
const result = await heartbeat.tickTimers(tickAt);
|
||||
|
||||
expect(result.enqueued).toBe(0);
|
||||
expect(result.skipped).toBe(1);
|
||||
|
||||
const issue = await db.select().from(issues).where(eq(issues.id, issueId)).then((rows) => rows[0]!);
|
||||
expect(issue.monitorNextCheckAt).toBeNull();
|
||||
expect(parseIssueExecutionState(issue.executionState)?.monitor).toMatchObject({
|
||||
status: "cleared",
|
||||
clearReason: "max_attempts_exhausted",
|
||||
});
|
||||
|
||||
const wakeup = await db
|
||||
.select()
|
||||
.from(agentWakeupRequests)
|
||||
.where(eq(agentWakeupRequests.agentId, agentId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
expect(wakeup?.reason).toBe("issue_monitor_recovery");
|
||||
expect(wakeup?.payload).toMatchObject({
|
||||
issueId,
|
||||
clearReason: "max_attempts_exhausted",
|
||||
maxAttempts: 1,
|
||||
});
|
||||
|
||||
const activity = await db
|
||||
.select()
|
||||
.from(activityLog)
|
||||
.where(eq(activityLog.entityId, issueId))
|
||||
.then((rows) => rows.map((row) => row.action));
|
||||
expect(activity).toContain("issue.monitor_exhausted");
|
||||
expect(activity).toContain("issue.monitor_recovery_wake_queued");
|
||||
expect(activity).not.toContain("issue.monitor_triggered");
|
||||
});
|
||||
|
||||
it("clears timed-out monitors and creates a visible recovery issue when requested", async () => {
|
||||
const { issueId, companyId } = await seedFixture({
|
||||
monitor: {
|
||||
timeoutAt: "2026-04-11T12:00:00.000Z",
|
||||
recoveryPolicy: "create_recovery_issue",
|
||||
},
|
||||
});
|
||||
const heartbeat = heartbeatService(db);
|
||||
const tickAt = new Date("2026-04-11T12:31:00.000Z");
|
||||
|
||||
const result = await heartbeat.tickTimers(tickAt);
|
||||
|
||||
expect(result.enqueued).toBe(0);
|
||||
expect(result.skipped).toBe(1);
|
||||
|
||||
const issue = await db.select().from(issues).where(eq(issues.id, issueId)).then((rows) => rows[0]!);
|
||||
expect(issue.monitorNextCheckAt).toBeNull();
|
||||
expect(parseIssueExecutionState(issue.executionState)?.monitor).toMatchObject({
|
||||
status: "cleared",
|
||||
clearReason: "timeout_exceeded",
|
||||
});
|
||||
|
||||
const recoveryIssue = await db
|
||||
.select()
|
||||
.from(issues)
|
||||
.where(eq(issues.originId, issueId))
|
||||
.then((rows) => rows.find((row) => row.companyId === companyId && row.originKind === "stranded_issue_recovery") ?? null);
|
||||
expect(recoveryIssue).toMatchObject({
|
||||
parentId: issueId,
|
||||
priority: "high",
|
||||
});
|
||||
expect(["todo", "in_progress"]).toContain(recoveryIssue?.status);
|
||||
});
|
||||
|
||||
it("omits external monitor refs from wake payloads and activity details", async () => {
|
||||
const { issueId, agentId } = await seedFixture({
|
||||
monitor: {
|
||||
serviceName: "Deploy provider",
|
||||
externalRef: "https://provider.example/deploy/123?token=secret",
|
||||
},
|
||||
});
|
||||
const heartbeat = heartbeatService(db);
|
||||
const tickAt = new Date("2026-04-11T12:31:00.000Z");
|
||||
|
||||
await heartbeat.tickTimers(tickAt);
|
||||
|
||||
const wakeup = await db
|
||||
.select()
|
||||
.from(agentWakeupRequests)
|
||||
.where(eq(agentWakeupRequests.agentId, agentId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
expect(JSON.stringify(wakeup?.payload)).not.toContain("provider.example");
|
||||
expect(wakeup?.payload).not.toHaveProperty("externalRef");
|
||||
|
||||
const activity = await db
|
||||
.select()
|
||||
.from(activityLog)
|
||||
.where(eq(activityLog.entityId, issueId));
|
||||
expect(JSON.stringify(activity.map((row) => row.details))).not.toContain("provider.example");
|
||||
expect(activity.find((row) => row.action === "issue.monitor_triggered")?.details).not.toHaveProperty("externalRef");
|
||||
});
|
||||
});
|
||||
@@ -74,6 +74,100 @@ describe("recovery classifier boundary", () => {
|
||||
expect(classifyIssueGraphLiveness(input)).toEqual(classifyIssueGraphLivenessCompat(input));
|
||||
});
|
||||
|
||||
it("treats a scheduled monitor as an explicit review action path", () => {
|
||||
const findings = classifyIssueGraphLiveness({
|
||||
now: "2026-04-30T18:00:00.000Z",
|
||||
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("does not treat overdue or exhausted monitors as explicit waiting paths", () => {
|
||||
const baseIssue = {
|
||||
id: issueId,
|
||||
companyId,
|
||||
identifier: "PAP-2945",
|
||||
title: "Wait for external review",
|
||||
status: "in_review",
|
||||
assigneeAgentId: agentId,
|
||||
assigneeUserId: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: null,
|
||||
};
|
||||
const agents = [
|
||||
{
|
||||
id: agentId,
|
||||
companyId,
|
||||
name: "Coder",
|
||||
role: "engineer",
|
||||
status: "idle",
|
||||
reportsTo: managerId,
|
||||
},
|
||||
];
|
||||
|
||||
const overdue = classifyIssueGraphLiveness({
|
||||
now: "2026-04-30T20:00:00.000Z",
|
||||
issues: [
|
||||
{
|
||||
...baseIssue,
|
||||
executionState: null,
|
||||
monitorNextCheckAt: "2026-04-30T19:00:00.000Z",
|
||||
},
|
||||
],
|
||||
relations: [],
|
||||
agents,
|
||||
});
|
||||
|
||||
const exhausted = classifyIssueGraphLiveness({
|
||||
now: "2026-04-30T18:00:00.000Z",
|
||||
issues: [
|
||||
{
|
||||
...baseIssue,
|
||||
executionPolicy: {
|
||||
monitor: {
|
||||
nextCheckAt: "2026-04-30T19:00:00.000Z",
|
||||
maxAttempts: 1,
|
||||
},
|
||||
},
|
||||
executionState: null,
|
||||
monitorNextCheckAt: "2026-04-30T19:00:00.000Z",
|
||||
monitorAttemptCount: 1,
|
||||
},
|
||||
],
|
||||
relations: [],
|
||||
agents,
|
||||
});
|
||||
|
||||
expect(overdue[0]?.state).toBe("in_review_without_action_path");
|
||||
expect(exhausted[0]?.state).toBe("in_review_without_action_path");
|
||||
});
|
||||
|
||||
it("keeps run liveness continuation decision parity with the compatibility export", () => {
|
||||
const input = {
|
||||
run: {
|
||||
|
||||
+180
-3
@@ -81,6 +81,8 @@ import {
|
||||
applyIssueExecutionPolicyTransition,
|
||||
normalizeIssueExecutionPolicy,
|
||||
parseIssueExecutionState,
|
||||
redactIssueMonitorExternalRef,
|
||||
setIssueExecutionPolicyMonitorScheduledBy,
|
||||
} from "../services/issue-execution-policy.js";
|
||||
import type { PluginWorkerManager } from "../services/plugin-worker-manager.js";
|
||||
|
||||
@@ -165,6 +167,53 @@ function summarizeIssueReferenceActivityDetails(input:
|
||||
};
|
||||
}
|
||||
|
||||
function monitorPoliciesEqual(left: NormalizedExecutionPolicy | null, right: NormalizedExecutionPolicy | null) {
|
||||
return JSON.stringify(left?.monitor ?? null) === JSON.stringify(right?.monitor ?? null);
|
||||
}
|
||||
|
||||
function applyActorMonitorScheduledBy(
|
||||
policy: NormalizedExecutionPolicy | null,
|
||||
actorType: "agent" | "user",
|
||||
) {
|
||||
return setIssueExecutionPolicyMonitorScheduledBy(policy, actorType === "user" ? "board" : "assignee");
|
||||
}
|
||||
|
||||
function assertCanManageIssueMonitor(req: Request, assigneeAgentId: string | null, monitorChanged: boolean) {
|
||||
if (!monitorChanged) return;
|
||||
if (req.actor.type === "board") return;
|
||||
if (req.actor.type === "agent" && req.actor.agentId && req.actor.agentId === assigneeAgentId) return;
|
||||
throw forbidden("Only the assignee agent or a board user can manage issue monitors");
|
||||
}
|
||||
|
||||
function summarizeIssueMonitor(
|
||||
issue: {
|
||||
monitorNextCheckAt?: Date | null;
|
||||
monitorLastTriggeredAt?: Date | null;
|
||||
monitorAttemptCount?: number | null;
|
||||
monitorNotes?: string | null;
|
||||
monitorScheduledBy?: string | null;
|
||||
executionState?: unknown;
|
||||
},
|
||||
policy: NormalizedExecutionPolicy | null,
|
||||
) {
|
||||
const state = parseIssueExecutionState(issue.executionState);
|
||||
return {
|
||||
nextCheckAt: issue.monitorNextCheckAt?.toISOString() ?? policy?.monitor?.nextCheckAt ?? null,
|
||||
lastTriggeredAt: issue.monitorLastTriggeredAt?.toISOString() ?? state?.monitor?.lastTriggeredAt ?? null,
|
||||
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: redactIssueMonitorExternalRef(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,
|
||||
};
|
||||
}
|
||||
|
||||
function activityExecutionParticipantKey(participant: ActivityExecutionParticipant): string {
|
||||
return participant.type === "agent" ? `agent:${participant.agentId}` : `user:${participant.userId}`;
|
||||
}
|
||||
@@ -1812,7 +1861,11 @@ export function issueRoutes(
|
||||
await assertIssueEnvironmentSelection(companyId, req.body.executionWorkspaceSettings?.environmentId);
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
const executionPolicy = normalizeIssueExecutionPolicy(req.body.executionPolicy);
|
||||
const executionPolicy = applyActorMonitorScheduledBy(
|
||||
normalizeIssueExecutionPolicy(req.body.executionPolicy),
|
||||
actor.actorType,
|
||||
);
|
||||
assertCanManageIssueMonitor(req, req.body.assigneeAgentId ?? null, Boolean(executionPolicy?.monitor));
|
||||
const issue = await svc.create(companyId, {
|
||||
...req.body,
|
||||
executionPolicy,
|
||||
@@ -1847,6 +1900,29 @@ export function issueRoutes(
|
||||
},
|
||||
});
|
||||
|
||||
if (executionPolicy?.monitor) {
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: "issue.monitor_scheduled",
|
||||
entityType: "issue",
|
||||
entityId: issue.id,
|
||||
details: {
|
||||
identifier: issue.identifier,
|
||||
nextCheckAt: executionPolicy.monitor.nextCheckAt,
|
||||
notes: executionPolicy.monitor.notes,
|
||||
scheduledBy: executionPolicy.monitor.scheduledBy,
|
||||
serviceName: executionPolicy.monitor.serviceName ?? null,
|
||||
timeoutAt: executionPolicy.monitor.timeoutAt ?? null,
|
||||
maxAttempts: executionPolicy.monitor.maxAttempts ?? null,
|
||||
recoveryPolicy: executionPolicy.monitor.recoveryPolicy ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
void queueIssueAssignmentWakeup({
|
||||
heartbeat,
|
||||
issue,
|
||||
@@ -1879,7 +1955,11 @@ export function issueRoutes(
|
||||
await assertIssueEnvironmentSelection(parent.companyId, req.body.executionWorkspaceSettings?.environmentId);
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
const executionPolicy = normalizeIssueExecutionPolicy(req.body.executionPolicy);
|
||||
const executionPolicy = applyActorMonitorScheduledBy(
|
||||
normalizeIssueExecutionPolicy(req.body.executionPolicy),
|
||||
actor.actorType,
|
||||
);
|
||||
assertCanManageIssueMonitor(req, req.body.assigneeAgentId ?? null, Boolean(executionPolicy?.monitor));
|
||||
const { issue, parentBlockerAdded } = await svc.createChild(parent.id, {
|
||||
...req.body,
|
||||
executionPolicy,
|
||||
@@ -1908,6 +1988,30 @@ export function issueRoutes(
|
||||
},
|
||||
});
|
||||
|
||||
if (executionPolicy?.monitor) {
|
||||
await logActivity(db, {
|
||||
companyId: parent.companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: "issue.monitor_scheduled",
|
||||
entityType: "issue",
|
||||
entityId: issue.id,
|
||||
details: {
|
||||
identifier: issue.identifier,
|
||||
parentId: parent.id,
|
||||
nextCheckAt: executionPolicy.monitor.nextCheckAt,
|
||||
notes: executionPolicy.monitor.notes,
|
||||
scheduledBy: executionPolicy.monitor.scheduledBy,
|
||||
serviceName: executionPolicy.monitor.serviceName ?? null,
|
||||
timeoutAt: executionPolicy.monitor.timeoutAt ?? null,
|
||||
maxAttempts: executionPolicy.monitor.maxAttempts ?? null,
|
||||
recoveryPolicy: executionPolicy.monitor.recoveryPolicy ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
void queueIssueAssignmentWakeup({
|
||||
heartbeat,
|
||||
issue,
|
||||
@@ -1921,6 +2025,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);
|
||||
@@ -2043,7 +2168,10 @@ export function issueRoutes(
|
||||
updateFields.status = "todo";
|
||||
}
|
||||
if (req.body.executionPolicy !== undefined) {
|
||||
updateFields.executionPolicy = normalizeIssueExecutionPolicy(req.body.executionPolicy);
|
||||
updateFields.executionPolicy = applyActorMonitorScheduledBy(
|
||||
normalizeIssueExecutionPolicy(req.body.executionPolicy),
|
||||
actor.actorType,
|
||||
);
|
||||
}
|
||||
const previousExecutionPolicy = normalizeIssueExecutionPolicy(existing.executionPolicy ?? null);
|
||||
const nextExecutionPolicy =
|
||||
@@ -2053,10 +2181,13 @@ export function issueRoutes(
|
||||
if (normalizedAssigneeAgentId !== undefined) {
|
||||
updateFields.assigneeAgentId = normalizedAssigneeAgentId;
|
||||
}
|
||||
const monitorChanged = monitorPoliciesEqual(previousExecutionPolicy, nextExecutionPolicy) === false;
|
||||
assertCanManageIssueMonitor(req, existing.assigneeAgentId, req.body.executionPolicy !== undefined && monitorChanged);
|
||||
|
||||
const transition = applyIssueExecutionPolicyTransition({
|
||||
issue: existing,
|
||||
policy: nextExecutionPolicy,
|
||||
previousPolicy: previousExecutionPolicy,
|
||||
requestedStatus: typeof updateFields.status === "string" ? updateFields.status : undefined,
|
||||
requestedAssigneePatch: {
|
||||
assigneeAgentId: normalizedAssigneeAgentId,
|
||||
@@ -2069,6 +2200,7 @@ export function issueRoutes(
|
||||
},
|
||||
commentBody,
|
||||
reviewRequest: reviewRequest === undefined ? undefined : reviewRequest,
|
||||
monitorExplicitlyUpdated: req.body.executionPolicy !== undefined && monitorChanged,
|
||||
});
|
||||
const decisionId = transition.decision ? randomUUID() : null;
|
||||
if (decisionId) {
|
||||
@@ -2372,6 +2504,51 @@ export function issueRoutes(
|
||||
});
|
||||
}
|
||||
|
||||
const nextStoredExecutionPolicy = normalizeIssueExecutionPolicy(issue.executionPolicy ?? null);
|
||||
const previousMonitor = summarizeIssueMonitor(existing, previousExecutionPolicy);
|
||||
const nextMonitor = summarizeIssueMonitor(issue, nextStoredExecutionPolicy);
|
||||
const monitorScheduledChanged = previousMonitor.nextCheckAt !== nextMonitor.nextCheckAt;
|
||||
if (nextMonitor.nextCheckAt && (monitorScheduledChanged || previousMonitor.notes !== nextMonitor.notes)) {
|
||||
await logActivity(db, {
|
||||
companyId: issue.companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: "issue.monitor_scheduled",
|
||||
entityType: "issue",
|
||||
entityId: issue.id,
|
||||
details: {
|
||||
identifier: issue.identifier,
|
||||
nextCheckAt: nextMonitor.nextCheckAt,
|
||||
previousNextCheckAt: previousMonitor.nextCheckAt,
|
||||
notes: nextMonitor.notes,
|
||||
scheduledBy: nextMonitor.scheduledBy,
|
||||
serviceName: nextMonitor.serviceName,
|
||||
timeoutAt: nextMonitor.timeoutAt,
|
||||
maxAttempts: nextMonitor.maxAttempts,
|
||||
recoveryPolicy: nextMonitor.recoveryPolicy,
|
||||
},
|
||||
});
|
||||
} else if (!nextMonitor.nextCheckAt && previousMonitor.nextCheckAt) {
|
||||
await logActivity(db, {
|
||||
companyId: issue.companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: "issue.monitor_cleared",
|
||||
entityType: "issue",
|
||||
entityId: issue.id,
|
||||
details: {
|
||||
identifier: issue.identifier,
|
||||
previousNextCheckAt: previousMonitor.nextCheckAt,
|
||||
reason: nextMonitor.clearReason ?? "manual",
|
||||
notes: previousMonitor.notes,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (issue.status === "done" && existing.status !== "done") {
|
||||
const tc = getTelemetryClient();
|
||||
if (tc && actor.agentId) {
|
||||
|
||||
@@ -3,7 +3,7 @@ import path from "node:path";
|
||||
import { execFile as execFileCallback } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { and, asc, desc, eq, getTableColumns, gt, inArray, isNull, lte, notInArray, or, sql } from "drizzle-orm";
|
||||
import { and, asc, desc, eq, getTableColumns, gt, inArray, isNull, lt, lte, notInArray, or, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
AGENT_DEFAULT_MAX_CONCURRENT_RUNS,
|
||||
@@ -14,6 +14,9 @@ import {
|
||||
type EnvironmentLeaseStatus,
|
||||
type ExecutionWorkspace,
|
||||
type ExecutionWorkspaceConfig,
|
||||
type IssueExecutionMonitorClearReason,
|
||||
type IssueExecutionMonitorPolicy,
|
||||
type IssueExecutionMonitorRecoveryPolicy,
|
||||
type ModelProfileKey,
|
||||
type RunLivenessState,
|
||||
} from "@paperclipai/shared";
|
||||
@@ -85,7 +88,12 @@ import {
|
||||
sanitizeRuntimeServiceBaseEnv,
|
||||
} from "./workspace-runtime.js";
|
||||
import { issueService } from "./issues.js";
|
||||
import { parseIssueExecutionState } from "./issue-execution-policy.js";
|
||||
import {
|
||||
buildIssueMonitorClearedPatch,
|
||||
buildIssueMonitorTriggeredPatch,
|
||||
normalizeIssueExecutionPolicy,
|
||||
parseIssueExecutionState,
|
||||
} from "./issue-execution-policy.js";
|
||||
import {
|
||||
ISSUE_TREE_CONTROL_INTERACTION_WAKE_REASONS,
|
||||
isVerifiedIssueTreeControlInteractionWake,
|
||||
@@ -2328,6 +2336,689 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
.then((rows) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
const issueMonitorDispatchColumns = {
|
||||
id: issues.id,
|
||||
companyId: issues.companyId,
|
||||
projectId: issues.projectId,
|
||||
goalId: issues.goalId,
|
||||
identifier: issues.identifier,
|
||||
title: issues.title,
|
||||
status: issues.status,
|
||||
priority: issues.priority,
|
||||
assigneeAgentId: issues.assigneeAgentId,
|
||||
assigneeUserId: issues.assigneeUserId,
|
||||
billingCode: issues.billingCode,
|
||||
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;
|
||||
projectId: string | null;
|
||||
goalId: string | null;
|
||||
identifier: string | null;
|
||||
title: string;
|
||||
status: string;
|
||||
priority: string;
|
||||
assigneeAgentId: string | null;
|
||||
assigneeUserId: string | null;
|
||||
billingCode: 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;
|
||||
}
|
||||
|
||||
function parseMonitorDate(value: string | null | undefined) {
|
||||
if (!value) return null;
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
function issueMonitorLimitClearReason(input: {
|
||||
monitor: IssueExecutionMonitorPolicy | null;
|
||||
nextAttemptCount: number;
|
||||
now: Date;
|
||||
}): IssueExecutionMonitorClearReason | null {
|
||||
const timeoutAt = parseMonitorDate(input.monitor?.timeoutAt ?? null);
|
||||
if (timeoutAt && input.now.getTime() >= timeoutAt.getTime()) {
|
||||
return "timeout_exceeded";
|
||||
}
|
||||
const maxAttempts = input.monitor?.maxAttempts ?? null;
|
||||
if (maxAttempts !== null && input.nextAttemptCount > maxAttempts) {
|
||||
return "max_attempts_exhausted";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function monitorRecoveryPolicy(
|
||||
monitor: IssueExecutionMonitorPolicy | null,
|
||||
): IssueExecutionMonitorRecoveryPolicy {
|
||||
return monitor?.recoveryPolicy ?? "wake_owner";
|
||||
}
|
||||
|
||||
function monitorRecoveryDetails(input: {
|
||||
claimed: IssueMonitorDispatchRow;
|
||||
scheduledAtIso: string;
|
||||
nextAttemptCount: number;
|
||||
clearReason: IssueExecutionMonitorClearReason;
|
||||
recoveryPolicy: IssueExecutionMonitorRecoveryPolicy;
|
||||
monitor: IssueExecutionMonitorPolicy | null;
|
||||
source: "manual" | "scheduled";
|
||||
}) {
|
||||
return {
|
||||
identifier: input.claimed.identifier,
|
||||
nextCheckAt: input.scheduledAtIso,
|
||||
attemptedAttemptCount: input.nextAttemptCount,
|
||||
notes: input.claimed.monitorNotes ?? null,
|
||||
serviceName: input.monitor?.serviceName ?? null,
|
||||
timeoutAt: input.monitor?.timeoutAt ?? null,
|
||||
maxAttempts: input.monitor?.maxAttempts ?? null,
|
||||
clearReason: input.clearReason,
|
||||
recoveryPolicy: input.recoveryPolicy,
|
||||
source: input.source,
|
||||
};
|
||||
}
|
||||
|
||||
function formatIssueIdentifierLink(identifier: string | null, fallback: string) {
|
||||
if (!identifier) return fallback;
|
||||
const prefix = identifier.split("-")[0];
|
||||
if (!prefix || !/^[A-Z][A-Z0-9]*-\d+$/.test(identifier)) return identifier;
|
||||
return `[${identifier}](/${prefix}/issues/${identifier})`;
|
||||
}
|
||||
|
||||
function monitorRecoveryComment(input: {
|
||||
issue: IssueMonitorDispatchRow;
|
||||
clearReason: IssueExecutionMonitorClearReason;
|
||||
recoveryPolicy: IssueExecutionMonitorRecoveryPolicy;
|
||||
nextAttemptCount: number;
|
||||
}) {
|
||||
const label = formatIssueIdentifierLink(input.issue.identifier, input.issue.id);
|
||||
const reason =
|
||||
input.clearReason === "timeout_exceeded"
|
||||
? "its timeout was reached"
|
||||
: "its maximum attempt count was reached";
|
||||
return [
|
||||
`Paperclip cleared the scheduled external-service monitor for ${label} because ${reason}.`,
|
||||
"",
|
||||
`- Attempt count: ${input.nextAttemptCount}`,
|
||||
`- Recovery policy: ${input.recoveryPolicy}`,
|
||||
"",
|
||||
"Next action: inspect the external service state, record the result on this issue, and restore an explicit execution or waiting path if more work remains.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
async function findOpenIssueMonitorRecoveryIssue(claimed: IssueMonitorDispatchRow) {
|
||||
return db
|
||||
.select()
|
||||
.from(issues)
|
||||
.where(
|
||||
and(
|
||||
eq(issues.companyId, claimed.companyId),
|
||||
eq(issues.originKind, RECOVERY_ORIGIN_KINDS.strandedIssueRecovery),
|
||||
eq(issues.originId, claimed.id),
|
||||
isNull(issues.hiddenAt),
|
||||
notInArray(issues.status, ["done", "cancelled"]),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(issues.createdAt))
|
||||
.limit(1)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
async function performIssueMonitorRecovery(input: {
|
||||
claimed: IssueMonitorDispatchRow;
|
||||
scheduledAtIso: string;
|
||||
nextAttemptCount: number;
|
||||
clearReason: IssueExecutionMonitorClearReason;
|
||||
recoveryPolicy: IssueExecutionMonitorRecoveryPolicy;
|
||||
monitor: IssueExecutionMonitorPolicy | null;
|
||||
actorType: "user" | "agent" | "system";
|
||||
actorId: string;
|
||||
agentId: string | null;
|
||||
runId: string | null;
|
||||
activitySource: "manual" | "scheduled";
|
||||
}) {
|
||||
const details = monitorRecoveryDetails({
|
||||
claimed: input.claimed,
|
||||
scheduledAtIso: input.scheduledAtIso,
|
||||
nextAttemptCount: input.nextAttemptCount,
|
||||
clearReason: input.clearReason,
|
||||
recoveryPolicy: input.recoveryPolicy,
|
||||
monitor: input.monitor,
|
||||
source: input.activitySource,
|
||||
});
|
||||
|
||||
if (input.recoveryPolicy === "create_recovery_issue") {
|
||||
let recoveryIssue = await findOpenIssueMonitorRecoveryIssue(input.claimed);
|
||||
if (!recoveryIssue) {
|
||||
recoveryIssue = await issuesSvc.create(input.claimed.companyId, {
|
||||
title: `Recover external-service monitor for ${input.claimed.identifier ?? input.claimed.title}`,
|
||||
description: monitorRecoveryComment({
|
||||
issue: input.claimed,
|
||||
clearReason: input.clearReason,
|
||||
recoveryPolicy: input.recoveryPolicy,
|
||||
nextAttemptCount: input.nextAttemptCount,
|
||||
}),
|
||||
status: "todo",
|
||||
priority: "high",
|
||||
parentId: input.claimed.id,
|
||||
projectId: input.claimed.projectId,
|
||||
goalId: input.claimed.goalId,
|
||||
assigneeAgentId: input.claimed.assigneeAgentId,
|
||||
originKind: RECOVERY_ORIGIN_KINDS.strandedIssueRecovery,
|
||||
originId: input.claimed.id,
|
||||
originFingerprint: `issue_monitor:${input.clearReason}`,
|
||||
billingCode: input.claimed.billingCode,
|
||||
});
|
||||
}
|
||||
|
||||
if (recoveryIssue.assigneeAgentId) {
|
||||
await enqueueWakeup(recoveryIssue.assigneeAgentId, {
|
||||
source: "automation",
|
||||
triggerDetail: "system",
|
||||
reason: "issue_monitor_recovery_issue",
|
||||
idempotencyKey: `issue-monitor-recovery-issue:${input.claimed.id}:${input.clearReason}:${input.scheduledAtIso}`,
|
||||
payload: { issueId: recoveryIssue.id, sourceIssueId: input.claimed.id },
|
||||
requestedByActorType: input.actorType,
|
||||
requestedByActorId: input.actorId,
|
||||
contextSnapshot: {
|
||||
issueId: recoveryIssue.id,
|
||||
sourceIssueId: input.claimed.id,
|
||||
source: "issue.monitor.recovery_issue",
|
||||
wakeReason: "issue_monitor_recovery_issue",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await logActivity(db, {
|
||||
companyId: input.claimed.companyId,
|
||||
actorType: input.actorType,
|
||||
actorId: input.actorId,
|
||||
agentId: input.agentId,
|
||||
runId: input.runId,
|
||||
action: "issue.monitor_recovery_issue_created",
|
||||
entityType: "issue",
|
||||
entityId: input.claimed.id,
|
||||
details: {
|
||||
...details,
|
||||
recoveryIssueId: recoveryIssue.id,
|
||||
recoveryIdentifier: recoveryIssue.identifier,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (input.recoveryPolicy === "escalate_to_board") {
|
||||
await db.insert(issueComments).values({
|
||||
companyId: input.claimed.companyId,
|
||||
issueId: input.claimed.id,
|
||||
body: monitorRecoveryComment({
|
||||
issue: input.claimed,
|
||||
clearReason: input.clearReason,
|
||||
recoveryPolicy: input.recoveryPolicy,
|
||||
nextAttemptCount: input.nextAttemptCount,
|
||||
}),
|
||||
});
|
||||
|
||||
await logActivity(db, {
|
||||
companyId: input.claimed.companyId,
|
||||
actorType: input.actorType,
|
||||
actorId: input.actorId,
|
||||
agentId: input.agentId,
|
||||
runId: input.runId,
|
||||
action: "issue.monitor_escalated_to_board",
|
||||
entityType: "issue",
|
||||
entityId: input.claimed.id,
|
||||
details,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await enqueueWakeup(input.claimed.assigneeAgentId!, {
|
||||
source: "automation",
|
||||
triggerDetail: "system",
|
||||
reason: "issue_monitor_recovery",
|
||||
idempotencyKey: `issue-monitor-recovery:${input.claimed.id}:${input.clearReason}:${input.scheduledAtIso}`,
|
||||
payload: {
|
||||
issueId: input.claimed.id,
|
||||
monitorAttemptCount: input.nextAttemptCount,
|
||||
monitorNotes: input.claimed.monitorNotes ?? null,
|
||||
clearReason: input.clearReason,
|
||||
serviceName: input.monitor?.serviceName ?? null,
|
||||
timeoutAt: input.monitor?.timeoutAt ?? null,
|
||||
maxAttempts: input.monitor?.maxAttempts ?? null,
|
||||
},
|
||||
requestedByActorType: input.actorType,
|
||||
requestedByActorId: input.actorId,
|
||||
contextSnapshot: {
|
||||
issueId: input.claimed.id,
|
||||
source: "issue.monitor.recovery",
|
||||
wakeReason: "issue_monitor_recovery",
|
||||
monitorAttemptCount: input.nextAttemptCount,
|
||||
monitorNotes: input.claimed.monitorNotes ?? null,
|
||||
clearReason: input.clearReason,
|
||||
serviceName: input.monitor?.serviceName ?? null,
|
||||
timeoutAt: input.monitor?.timeoutAt ?? null,
|
||||
maxAttempts: input.monitor?.maxAttempts ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
await logActivity(db, {
|
||||
companyId: input.claimed.companyId,
|
||||
actorType: input.actorType,
|
||||
actorId: input.actorId,
|
||||
agentId: input.agentId,
|
||||
runId: input.runId,
|
||||
action: "issue.monitor_recovery_wake_queued",
|
||||
entityType: "issue",
|
||||
entityId: input.claimed.id,
|
||||
details,
|
||||
});
|
||||
}
|
||||
|
||||
async function clearIssueMonitorAndRecover(input: {
|
||||
claimed: IssueMonitorDispatchRow;
|
||||
policy: ReturnType<typeof normalizeIssueExecutionPolicy>;
|
||||
scheduledAtIso: string;
|
||||
nextAttemptCount: number;
|
||||
clearReason: IssueExecutionMonitorClearReason;
|
||||
recoveryPolicy: IssueExecutionMonitorRecoveryPolicy;
|
||||
monitor: IssueExecutionMonitorPolicy | null;
|
||||
now: Date;
|
||||
actorType: "user" | "agent" | "system";
|
||||
actorId: string;
|
||||
agentId: string | null;
|
||||
runId: string | null;
|
||||
activitySource: "manual" | "scheduled";
|
||||
}) {
|
||||
await db
|
||||
.update(issues)
|
||||
.set({
|
||||
...buildIssueMonitorClearedPatch({
|
||||
issue: input.claimed,
|
||||
policy: input.policy,
|
||||
clearReason: input.clearReason,
|
||||
clearedAt: input.now,
|
||||
}),
|
||||
updatedAt: input.now,
|
||||
})
|
||||
.where(eq(issues.id, input.claimed.id));
|
||||
|
||||
await logActivity(db, {
|
||||
companyId: input.claimed.companyId,
|
||||
actorType: input.actorType,
|
||||
actorId: input.actorId,
|
||||
agentId: input.agentId,
|
||||
runId: input.runId,
|
||||
action: "issue.monitor_exhausted",
|
||||
entityType: "issue",
|
||||
entityId: input.claimed.id,
|
||||
details: monitorRecoveryDetails({
|
||||
claimed: input.claimed,
|
||||
scheduledAtIso: input.scheduledAtIso,
|
||||
nextAttemptCount: input.nextAttemptCount,
|
||||
clearReason: input.clearReason,
|
||||
recoveryPolicy: input.recoveryPolicy,
|
||||
monitor: input.monitor,
|
||||
source: input.activitySource,
|
||||
}),
|
||||
});
|
||||
|
||||
await performIssueMonitorRecovery({
|
||||
claimed: input.claimed,
|
||||
scheduledAtIso: input.scheduledAtIso,
|
||||
nextAttemptCount: input.nextAttemptCount,
|
||||
clearReason: input.clearReason,
|
||||
recoveryPolicy: input.recoveryPolicy,
|
||||
monitor: input.monitor,
|
||||
actorType: input.actorType,
|
||||
actorId: input.actorId,
|
||||
agentId: input.agentId,
|
||||
runId: input.runId,
|
||||
activitySource: input.activitySource,
|
||||
});
|
||||
|
||||
return { outcome: "skipped" as const, reason: input.clearReason };
|
||||
}
|
||||
|
||||
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 clearReason = issueMonitorLimitClearReason({ monitor, nextAttemptCount, now: input.now });
|
||||
const recoveryPolicy = monitorRecoveryPolicy(monitor);
|
||||
const monitorMetadata = {
|
||||
serviceName: monitor?.serviceName ?? null,
|
||||
timeoutAt: monitor?.timeoutAt ?? null,
|
||||
maxAttempts: monitor?.maxAttempts ?? null,
|
||||
recoveryPolicy: monitor?.recoveryPolicy ?? null,
|
||||
};
|
||||
|
||||
if (clearReason) {
|
||||
return clearIssueMonitorAndRecover({
|
||||
claimed,
|
||||
policy,
|
||||
scheduledAtIso,
|
||||
nextAttemptCount,
|
||||
clearReason,
|
||||
recoveryPolicy,
|
||||
monitor,
|
||||
now: input.now,
|
||||
actorType: input.actorType,
|
||||
actorId: input.actorId,
|
||||
agentId: input.agentId,
|
||||
runId: input.runId,
|
||||
activitySource: input.activitySource,
|
||||
});
|
||||
}
|
||||
|
||||
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(issueMonitorDispatchColumns)
|
||||
.from(issues)
|
||||
.where(
|
||||
and(
|
||||
sql`${issues.monitorNextCheckAt} is not null`,
|
||||
lte(issues.monitorNextCheckAt, now),
|
||||
isNull(issues.assigneeUserId),
|
||||
sql`${issues.assigneeAgentId} is not null`,
|
||||
inArray(issues.status, ["in_progress", "in_review"]),
|
||||
or(
|
||||
isNull(issues.monitorWakeRequestedAt),
|
||||
lt(issues.monitorWakeRequestedAt, staleClaimThreshold),
|
||||
),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(issues.monitorNextCheckAt), asc(issues.updatedAt))
|
||||
.limit(50);
|
||||
|
||||
let triggered = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (const due of dueMonitors) {
|
||||
const claimed = await db.transaction(async (tx) => {
|
||||
const [updated] = await tx
|
||||
.update(issues)
|
||||
.set({
|
||||
monitorWakeRequestedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(issues.id, due.id),
|
||||
sql`${issues.monitorNextCheckAt} is not null`,
|
||||
lte(issues.monitorNextCheckAt, now),
|
||||
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) continue;
|
||||
|
||||
try {
|
||||
const result = await dispatchClaimedIssueMonitor(claimed, {
|
||||
now,
|
||||
source: "automation",
|
||||
triggerDetail: "system",
|
||||
wakeReason: "issue_monitor_due",
|
||||
actorType: "system",
|
||||
actorId: "heartbeat_scheduler",
|
||||
agentId: null,
|
||||
runId: null,
|
||||
clearOnClientError: true,
|
||||
activitySource: "scheduled",
|
||||
});
|
||||
if (result.outcome === "triggered") triggered += 1;
|
||||
if (result.outcome === "skipped") skipped += 1;
|
||||
} catch (err) {
|
||||
logger.error({ err, issueId: claimed.id }, "issue monitor tick failed");
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
checked: dueMonitors.length,
|
||||
triggered,
|
||||
skipped,
|
||||
};
|
||||
}
|
||||
|
||||
async function getOldestRunForSession(agentId: string, sessionId: string) {
|
||||
return db
|
||||
.select({
|
||||
@@ -7735,6 +8426,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
}),
|
||||
|
||||
wakeup: enqueueWakeup,
|
||||
triggerIssueMonitor,
|
||||
|
||||
reportRunActivity: clearDetachedRunWarning,
|
||||
|
||||
@@ -7804,7 +8496,13 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
else skipped += 1;
|
||||
}
|
||||
|
||||
return { checked, enqueued, skipped };
|
||||
const issueMonitors = await tickDueIssueMonitors(now);
|
||||
|
||||
return {
|
||||
checked: checked + issueMonitors.checked,
|
||||
enqueued: enqueued + issueMonitors.triggered,
|
||||
skipped: skipped + issueMonitors.skipped,
|
||||
};
|
||||
},
|
||||
|
||||
cancelRun: (runId: string) => cancelRunInternal(runId),
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { IssueExecutionDecision, IssueExecutionPolicy, IssueExecutionStage, IssueExecutionStagePrincipal, IssueExecutionState } from "@paperclipai/shared";
|
||||
import type {
|
||||
IssueExecutionDecision,
|
||||
IssueExecutionMonitorClearReason,
|
||||
IssueExecutionMonitorPolicy,
|
||||
IssueExecutionMonitorState,
|
||||
IssueExecutionPolicy,
|
||||
IssueExecutionStage,
|
||||
IssueExecutionStagePrincipal,
|
||||
IssueExecutionState,
|
||||
IssueMonitorScheduledBy,
|
||||
} from "@paperclipai/shared";
|
||||
import { issueExecutionPolicySchema, issueExecutionStateSchema } from "@paperclipai/shared";
|
||||
import { unprocessable } from "../errors.js";
|
||||
|
||||
@@ -12,6 +22,12 @@ type IssueLike = AssigneeLike & {
|
||||
status: string;
|
||||
executionPolicy?: IssueExecutionPolicy | Record<string, unknown> | null;
|
||||
executionState?: IssueExecutionState | Record<string, unknown> | null;
|
||||
monitorNextCheckAt?: Date | null;
|
||||
monitorWakeRequestedAt?: Date | null;
|
||||
monitorLastTriggeredAt?: Date | null;
|
||||
monitorAttemptCount?: number | null;
|
||||
monitorNotes?: string | null;
|
||||
monitorScheduledBy?: string | null;
|
||||
};
|
||||
|
||||
type ActorLike = {
|
||||
@@ -27,11 +43,13 @@ type RequestedAssigneePatch = {
|
||||
type TransitionInput = {
|
||||
issue: IssueLike;
|
||||
policy: IssueExecutionPolicy | null;
|
||||
previousPolicy?: IssueExecutionPolicy | null;
|
||||
requestedStatus?: string;
|
||||
requestedAssigneePatch: RequestedAssigneePatch;
|
||||
actor: ActorLike;
|
||||
commentBody?: string | null;
|
||||
reviewRequest?: IssueExecutionState["reviewRequest"] | null;
|
||||
monitorExplicitlyUpdated?: boolean;
|
||||
};
|
||||
|
||||
type TransitionResult = {
|
||||
@@ -43,6 +61,280 @@ type TransitionResult = {
|
||||
const COMPLETED_STATUS: IssueExecutionState["status"] = "completed";
|
||||
const PENDING_STATUS: IssueExecutionState["status"] = "pending";
|
||||
const CHANGES_REQUESTED_STATUS: IssueExecutionState["status"] = "changes_requested";
|
||||
const MONITOR_INVALID_MESSAGE = "Monitor can only be scheduled on issues assigned to an agent in in_progress or in_review";
|
||||
const MONITOR_BOUNDS_EXHAUSTED_MESSAGE = "Monitor bounds are already exhausted";
|
||||
export const REDACTED_ISSUE_MONITOR_EXTERNAL_REF = "[redacted]";
|
||||
|
||||
function normalizeMonitorNotes(notes: string | null | undefined) {
|
||||
if (typeof notes !== "string") return null;
|
||||
const trimmed = notes.trim();
|
||||
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;
|
||||
}
|
||||
|
||||
export function redactIssueMonitorExternalRef(value: string | null | undefined) {
|
||||
return normalizeMonitorText(value) ? REDACTED_ISSUE_MONITOR_EXTERNAL_REF : null;
|
||||
}
|
||||
|
||||
function monitorMetadataFromPolicy(monitor: IssueExecutionMonitorPolicy) {
|
||||
return {
|
||||
kind: monitor.kind ?? null,
|
||||
serviceName: normalizeMonitorText(monitor.serviceName),
|
||||
externalRef: redactIssueMonitorExternalRef(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: redactIssueMonitorExternalRef(state?.externalRef),
|
||||
timeoutAt: state?.timeoutAt ?? null,
|
||||
maxAttempts: state?.maxAttempts ?? null,
|
||||
recoveryPolicy: state?.recoveryPolicy ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function blankExecutionState(): IssueExecutionState {
|
||||
return {
|
||||
status: "idle",
|
||||
currentStageId: null,
|
||||
currentStageIndex: null,
|
||||
currentStageType: null,
|
||||
currentParticipant: null,
|
||||
returnAssignee: null,
|
||||
reviewRequest: null,
|
||||
completedStageIds: [],
|
||||
lastDecisionId: null,
|
||||
lastDecisionOutcome: null,
|
||||
monitor: null,
|
||||
};
|
||||
}
|
||||
|
||||
function isoString(value: Date | string | null | undefined): string | null {
|
||||
if (!value) return null;
|
||||
if (value instanceof Date) return value.toISOString();
|
||||
return value;
|
||||
}
|
||||
|
||||
function monitorStatesEqual(left: IssueExecutionMonitorState | null, right: IssueExecutionMonitorState | null): boolean {
|
||||
return JSON.stringify(left ?? null) === JSON.stringify(right ?? null);
|
||||
}
|
||||
|
||||
function executionStateWithMonitor(
|
||||
stageState: IssueExecutionState | null,
|
||||
monitorState: IssueExecutionMonitorState | null,
|
||||
): IssueExecutionState | null {
|
||||
if (!stageState && !monitorState) return null;
|
||||
const base = stageState ? { ...stageState } : blankExecutionState();
|
||||
return {
|
||||
...base,
|
||||
monitor: monitorState,
|
||||
};
|
||||
}
|
||||
|
||||
function derivePersistedMonitorState(input: {
|
||||
issue: IssueLike;
|
||||
state: IssueExecutionState | null;
|
||||
policy: IssueExecutionPolicy | null;
|
||||
}): IssueExecutionMonitorState | null {
|
||||
const fromState = input.state?.monitor ?? null;
|
||||
const scheduledMonitor = input.policy?.monitor ?? null;
|
||||
const nextCheckAt = isoString(input.issue.monitorNextCheckAt) ?? scheduledMonitor?.nextCheckAt ?? fromState?.nextCheckAt ?? null;
|
||||
const lastTriggeredAt = isoString(input.issue.monitorLastTriggeredAt) ?? fromState?.lastTriggeredAt ?? null;
|
||||
const attemptCount = input.issue.monitorAttemptCount ?? fromState?.attemptCount ?? 0;
|
||||
const notes = scheduledMonitor?.notes ?? normalizeMonitorNotes(input.issue.monitorNotes) ?? fromState?.notes ?? null;
|
||||
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 {
|
||||
status: "scheduled",
|
||||
nextCheckAt,
|
||||
lastTriggeredAt,
|
||||
attemptCount,
|
||||
notes,
|
||||
scheduledBy,
|
||||
...metadata,
|
||||
clearedAt: null,
|
||||
clearReason: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (fromState?.status === "cleared") {
|
||||
return {
|
||||
...fromState,
|
||||
notes,
|
||||
scheduledBy,
|
||||
attemptCount,
|
||||
lastTriggeredAt,
|
||||
...metadata,
|
||||
};
|
||||
}
|
||||
|
||||
if (fromState?.status === "triggered" || lastTriggeredAt || attemptCount > 0) {
|
||||
return {
|
||||
status: "triggered",
|
||||
nextCheckAt: null,
|
||||
lastTriggeredAt,
|
||||
attemptCount,
|
||||
notes,
|
||||
scheduledBy,
|
||||
...metadata,
|
||||
clearedAt: null,
|
||||
clearReason: null,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildScheduledMonitorState(
|
||||
previous: IssueExecutionMonitorState | null,
|
||||
monitor: IssueExecutionMonitorPolicy,
|
||||
): IssueExecutionMonitorState {
|
||||
return {
|
||||
status: "scheduled",
|
||||
nextCheckAt: monitor.nextCheckAt,
|
||||
lastTriggeredAt: previous?.lastTriggeredAt ?? null,
|
||||
attemptCount: previous?.attemptCount ?? 0,
|
||||
notes: monitor.notes ?? null,
|
||||
scheduledBy: monitor.scheduledBy,
|
||||
...monitorMetadataFromPolicy(monitor),
|
||||
clearedAt: null,
|
||||
clearReason: null,
|
||||
};
|
||||
}
|
||||
|
||||
function buildTriggeredMonitorState(input: {
|
||||
previous: IssueExecutionMonitorState | null;
|
||||
triggeredAt: Date;
|
||||
}): IssueExecutionMonitorState {
|
||||
return {
|
||||
status: "triggered",
|
||||
nextCheckAt: null,
|
||||
lastTriggeredAt: input.triggeredAt.toISOString(),
|
||||
attemptCount: (input.previous?.attemptCount ?? 0) + 1,
|
||||
notes: input.previous?.notes ?? null,
|
||||
scheduledBy: input.previous?.scheduledBy ?? null,
|
||||
...monitorMetadataFromState(input.previous),
|
||||
clearedAt: null,
|
||||
clearReason: null,
|
||||
};
|
||||
}
|
||||
|
||||
function buildClearedMonitorState(input: {
|
||||
previous: IssueExecutionMonitorState | null;
|
||||
clearReason: IssueExecutionMonitorClearReason;
|
||||
clearedAt: Date;
|
||||
}): IssueExecutionMonitorState {
|
||||
return {
|
||||
status: "cleared",
|
||||
nextCheckAt: null,
|
||||
lastTriggeredAt: input.previous?.lastTriggeredAt ?? null,
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
function issueAllowsMonitor(status: string, assigneeAgentId: string | null, assigneeUserId: string | null) {
|
||||
return Boolean(assigneeAgentId) && !assigneeUserId && (status === "in_progress" || status === "in_review");
|
||||
}
|
||||
|
||||
function monitorClearReasonForIssue(
|
||||
status: string,
|
||||
assigneeAgentId: string | null,
|
||||
assigneeUserId: string | null,
|
||||
): IssueExecutionMonitorClearReason | null {
|
||||
if (status === "done") return "done";
|
||||
if (status === "cancelled") return "cancelled";
|
||||
if (!issueAllowsMonitor(status, assigneeAgentId, assigneeUserId)) {
|
||||
if (assigneeUserId || !assigneeAgentId) return "invalid_assignee";
|
||||
return "invalid_status";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseMonitorDate(value: string | null | undefined) {
|
||||
if (!value) return null;
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
function exhaustedMonitorClearReason(input: {
|
||||
monitor: IssueExecutionMonitorPolicy;
|
||||
attemptCount: number;
|
||||
now: Date;
|
||||
}): IssueExecutionMonitorClearReason | null {
|
||||
const timeoutAt = parseMonitorDate(input.monitor.timeoutAt ?? null);
|
||||
if (timeoutAt && input.now.getTime() >= timeoutAt.getTime()) {
|
||||
return "timeout_exceeded";
|
||||
}
|
||||
const maxAttempts = input.monitor.maxAttempts ?? null;
|
||||
if (maxAttempts !== null && input.attemptCount >= maxAttempts) {
|
||||
return "max_attempts_exhausted";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function nextAssigneeIds(input: {
|
||||
issue: IssueLike;
|
||||
requestedAssigneePatch: RequestedAssigneePatch;
|
||||
stagePatch: Record<string, unknown>;
|
||||
}) {
|
||||
const assigneeAgentId =
|
||||
input.stagePatch.assigneeAgentId !== undefined
|
||||
? (input.stagePatch.assigneeAgentId as string | null)
|
||||
: input.requestedAssigneePatch.assigneeAgentId !== undefined
|
||||
? input.requestedAssigneePatch.assigneeAgentId ?? null
|
||||
: input.issue.assigneeAgentId ?? null;
|
||||
const assigneeUserId =
|
||||
input.stagePatch.assigneeUserId !== undefined
|
||||
? (input.stagePatch.assigneeUserId as string | null)
|
||||
: input.requestedAssigneePatch.assigneeUserId !== undefined
|
||||
? input.requestedAssigneePatch.assigneeUserId ?? null
|
||||
: input.issue.assigneeUserId ?? null;
|
||||
return { assigneeAgentId, assigneeUserId };
|
||||
}
|
||||
|
||||
export function stripMonitorFromExecutionPolicy(policy: IssueExecutionPolicy | null): IssueExecutionPolicy | null {
|
||||
if (!policy) return null;
|
||||
if (!policy.monitor) return policy;
|
||||
if (policy.stages.length === 0) return null;
|
||||
return {
|
||||
mode: policy.mode,
|
||||
commentRequired: policy.commentRequired,
|
||||
stages: policy.stages,
|
||||
};
|
||||
}
|
||||
|
||||
export function setIssueExecutionPolicyMonitorScheduledBy(
|
||||
policy: IssueExecutionPolicy | null,
|
||||
scheduledBy: IssueMonitorScheduledBy,
|
||||
): IssueExecutionPolicy | null {
|
||||
if (!policy?.monitor) return policy;
|
||||
return {
|
||||
...policy,
|
||||
monitor: {
|
||||
...policy.monitor,
|
||||
scheduledBy,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeIssueExecutionPolicy(input: unknown): IssueExecutionPolicy | null {
|
||||
if (input == null) return null;
|
||||
@@ -81,12 +373,27 @@ export function normalizeIssueExecutionPolicy(input: unknown): IssueExecutionPol
|
||||
})
|
||||
.filter((stage): stage is NonNullable<typeof stage> => stage !== null);
|
||||
|
||||
if (stages.length === 0) return null;
|
||||
const monitor = parsed.data.monitor
|
||||
? {
|
||||
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: redactIssueMonitorExternalRef(parsed.data.monitor.externalRef),
|
||||
timeoutAt: parsed.data.monitor.timeoutAt ?? null,
|
||||
maxAttempts: parsed.data.monitor.maxAttempts ?? null,
|
||||
recoveryPolicy: parsed.data.monitor.recoveryPolicy ?? null,
|
||||
}
|
||||
: null;
|
||||
|
||||
if (stages.length === 0 && !monitor) return null;
|
||||
|
||||
return {
|
||||
mode: parsed.data.mode ?? "normal",
|
||||
commentRequired: true,
|
||||
stages,
|
||||
...(monitor ? { monitor } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -173,6 +480,7 @@ function buildCompletedState(previous: IssueExecutionState | null, currentStage:
|
||||
completedStageIds,
|
||||
lastDecisionId: previous?.lastDecisionId ?? null,
|
||||
lastDecisionOutcome: "approved",
|
||||
monitor: previous?.monitor ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -192,6 +500,7 @@ function buildStateWithCompletedStages(input: {
|
||||
completedStageIds: input.completedStageIds,
|
||||
lastDecisionId: input.previous?.lastDecisionId ?? null,
|
||||
lastDecisionOutcome: input.previous?.lastDecisionOutcome ?? null,
|
||||
monitor: input.previous?.monitor ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -211,6 +520,7 @@ function buildSkippedStageCompletedState(input: {
|
||||
completedStageIds: input.completedStageIds,
|
||||
lastDecisionId: input.previous?.lastDecisionId ?? null,
|
||||
lastDecisionOutcome: input.previous?.lastDecisionOutcome ?? null,
|
||||
monitor: input.previous?.monitor ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -233,6 +543,7 @@ function buildPendingState(input: {
|
||||
completedStageIds: input.previous?.completedStageIds ?? [],
|
||||
lastDecisionId: input.previous?.lastDecisionId ?? null,
|
||||
lastDecisionOutcome: input.previous?.lastDecisionOutcome ?? null,
|
||||
monitor: input.previous?.monitor ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -293,7 +604,7 @@ function canAutoSkipPendingStage(input: {
|
||||
input.stage.participants.every((participant) => principalsEqual(participant, input.returnAssignee));
|
||||
}
|
||||
|
||||
export function applyIssueExecutionPolicyTransition(input: TransitionInput): TransitionResult {
|
||||
function applyIssueExecutionStageTransition(input: TransitionInput): TransitionResult {
|
||||
const patch: Record<string, unknown> = {};
|
||||
const existingState = parseIssueExecutionState(input.issue.executionState);
|
||||
const currentAssignee = assigneePrincipal(input.issue);
|
||||
@@ -560,3 +871,180 @@ export function applyIssueExecutionPolicyTransition(input: TransitionInput): Tra
|
||||
workflowControlledAssignment: true,
|
||||
};
|
||||
}
|
||||
|
||||
function applyMonitorTransition(input: TransitionInput, stagePatch: Record<string, unknown>) {
|
||||
const patch: Record<string, unknown> = {};
|
||||
const previousPolicy = input.previousPolicy ?? normalizeIssueExecutionPolicy(input.issue.executionPolicy ?? null);
|
||||
const existingState = parseIssueExecutionState(input.issue.executionState);
|
||||
const currentMonitorState = derivePersistedMonitorState({
|
||||
issue: input.issue,
|
||||
state: existingState,
|
||||
policy: previousPolicy,
|
||||
});
|
||||
const nextStatus =
|
||||
typeof stagePatch.status === "string"
|
||||
? (stagePatch.status as string)
|
||||
: input.requestedStatus ?? input.issue.status;
|
||||
const { assigneeAgentId, assigneeUserId } = nextAssigneeIds({
|
||||
issue: input.issue,
|
||||
requestedAssigneePatch: input.requestedAssigneePatch,
|
||||
stagePatch,
|
||||
});
|
||||
const stageState =
|
||||
stagePatch.executionState !== undefined
|
||||
? parseIssueExecutionState(stagePatch.executionState)
|
||||
: existingState;
|
||||
const invalidReason = input.policy?.monitor
|
||||
? monitorClearReasonForIssue(nextStatus, assigneeAgentId, assigneeUserId)
|
||||
: null;
|
||||
|
||||
let targetMonitorState = currentMonitorState;
|
||||
|
||||
if (input.policy?.monitor) {
|
||||
if (invalidReason) {
|
||||
if (input.monitorExplicitlyUpdated) {
|
||||
throw unprocessable(MONITOR_INVALID_MESSAGE);
|
||||
}
|
||||
patch.executionPolicy = stripMonitorFromExecutionPolicy(input.policy);
|
||||
patch.monitorNextCheckAt = null;
|
||||
patch.monitorWakeRequestedAt = null;
|
||||
targetMonitorState = buildClearedMonitorState({
|
||||
previous: currentMonitorState,
|
||||
clearReason: invalidReason,
|
||||
clearedAt: new Date(),
|
||||
});
|
||||
} else {
|
||||
const exhaustedReason = exhaustedMonitorClearReason({
|
||||
monitor: input.policy.monitor,
|
||||
attemptCount: currentMonitorState?.attemptCount ?? 0,
|
||||
now: new Date(),
|
||||
});
|
||||
if (exhaustedReason) {
|
||||
if (input.monitorExplicitlyUpdated) {
|
||||
throw unprocessable(MONITOR_BOUNDS_EXHAUSTED_MESSAGE, { clearReason: exhaustedReason });
|
||||
}
|
||||
patch.executionPolicy = stripMonitorFromExecutionPolicy(input.policy);
|
||||
patch.monitorNextCheckAt = null;
|
||||
patch.monitorWakeRequestedAt = null;
|
||||
targetMonitorState = buildClearedMonitorState({
|
||||
previous: currentMonitorState,
|
||||
clearReason: exhaustedReason,
|
||||
clearedAt: new Date(),
|
||||
});
|
||||
} else {
|
||||
patch.monitorNextCheckAt = new Date(input.policy.monitor.nextCheckAt);
|
||||
patch.monitorWakeRequestedAt = null;
|
||||
patch.monitorNotes = input.policy.monitor.notes ?? null;
|
||||
patch.monitorScheduledBy = input.policy.monitor.scheduledBy;
|
||||
targetMonitorState = buildScheduledMonitorState(currentMonitorState, input.policy.monitor);
|
||||
}
|
||||
}
|
||||
} else if (previousPolicy?.monitor) {
|
||||
patch.monitorNextCheckAt = null;
|
||||
patch.monitorWakeRequestedAt = null;
|
||||
targetMonitorState = buildClearedMonitorState({
|
||||
previous: currentMonitorState,
|
||||
clearReason:
|
||||
input.monitorExplicitlyUpdated
|
||||
? "manual"
|
||||
: monitorClearReasonForIssue(nextStatus, assigneeAgentId, assigneeUserId) ?? "manual",
|
||||
clearedAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
if (stagePatch.executionState !== undefined || !monitorStatesEqual(currentMonitorState, targetMonitorState)) {
|
||||
patch.executionState = executionStateWithMonitor(stageState, targetMonitorState);
|
||||
}
|
||||
|
||||
return patch;
|
||||
}
|
||||
|
||||
export function buildInitialIssueMonitorFields(input: {
|
||||
policy: IssueExecutionPolicy | null;
|
||||
status: string;
|
||||
assigneeAgentId?: string | null;
|
||||
assigneeUserId?: string | null;
|
||||
}) {
|
||||
if (!input.policy?.monitor) return {};
|
||||
if (!issueAllowsMonitor(input.status, input.assigneeAgentId ?? null, input.assigneeUserId ?? null)) {
|
||||
throw unprocessable(MONITOR_INVALID_MESSAGE);
|
||||
}
|
||||
const exhaustedReason = exhaustedMonitorClearReason({
|
||||
monitor: input.policy.monitor,
|
||||
attemptCount: 0,
|
||||
now: new Date(),
|
||||
});
|
||||
if (exhaustedReason) {
|
||||
throw unprocessable(MONITOR_BOUNDS_EXHAUSTED_MESSAGE, { clearReason: exhaustedReason });
|
||||
}
|
||||
|
||||
const monitorState = buildScheduledMonitorState(null, input.policy.monitor);
|
||||
return {
|
||||
monitorNextCheckAt: new Date(input.policy.monitor.nextCheckAt),
|
||||
monitorWakeRequestedAt: null,
|
||||
monitorNotes: input.policy.monitor.notes ?? null,
|
||||
monitorScheduledBy: input.policy.monitor.scheduledBy,
|
||||
executionState: executionStateWithMonitor(null, monitorState) as Record<string, unknown> | null,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildIssueMonitorTriggeredPatch(input: {
|
||||
issue: IssueLike;
|
||||
policy: IssueExecutionPolicy | null;
|
||||
triggeredAt: Date;
|
||||
}) {
|
||||
const existingState = parseIssueExecutionState(input.issue.executionState);
|
||||
const currentMonitorState = derivePersistedMonitorState({
|
||||
issue: input.issue,
|
||||
state: existingState,
|
||||
policy: input.policy,
|
||||
});
|
||||
const nextMonitorState = buildTriggeredMonitorState({
|
||||
previous: currentMonitorState,
|
||||
triggeredAt: input.triggeredAt,
|
||||
});
|
||||
|
||||
return {
|
||||
executionPolicy: stripMonitorFromExecutionPolicy(input.policy) as Record<string, unknown> | null,
|
||||
executionState: executionStateWithMonitor(existingState, nextMonitorState) as Record<string, unknown> | null,
|
||||
monitorNextCheckAt: null,
|
||||
monitorWakeRequestedAt: null,
|
||||
monitorLastTriggeredAt: input.triggeredAt,
|
||||
monitorAttemptCount: nextMonitorState.attemptCount,
|
||||
monitorNotes: nextMonitorState.notes,
|
||||
monitorScheduledBy: nextMonitorState.scheduledBy,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildIssueMonitorClearedPatch(input: {
|
||||
issue: IssueLike;
|
||||
policy: IssueExecutionPolicy | null;
|
||||
clearReason: IssueExecutionMonitorClearReason;
|
||||
clearedAt?: Date;
|
||||
}) {
|
||||
const existingState = parseIssueExecutionState(input.issue.executionState);
|
||||
const currentMonitorState = derivePersistedMonitorState({
|
||||
issue: input.issue,
|
||||
state: existingState,
|
||||
policy: input.policy,
|
||||
});
|
||||
const nextMonitorState = buildClearedMonitorState({
|
||||
previous: currentMonitorState,
|
||||
clearReason: input.clearReason,
|
||||
clearedAt: input.clearedAt ?? new Date(),
|
||||
});
|
||||
|
||||
return {
|
||||
executionPolicy: stripMonitorFromExecutionPolicy(input.policy) as Record<string, unknown> | null,
|
||||
executionState: executionStateWithMonitor(existingState, nextMonitorState) as Record<string, unknown> | null,
|
||||
monitorNextCheckAt: null,
|
||||
monitorWakeRequestedAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function applyIssueExecutionPolicyTransition(input: TransitionInput): TransitionResult {
|
||||
const stageResult = applyIssueExecutionStageTransition(input);
|
||||
const monitorPatch = applyMonitorTransition(input, stageResult.patch);
|
||||
Object.assign(stageResult.patch, monitorPatch);
|
||||
return stageResult;
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
parseProjectExecutionWorkspacePolicy,
|
||||
} from "./execution-workspace-policy.js";
|
||||
import { mergeExecutionWorkspaceConfig } from "./execution-workspaces.js";
|
||||
import { buildInitialIssueMonitorFields, normalizeIssueExecutionPolicy } from "./issue-execution-policy.js";
|
||||
import { instanceSettingsService } from "./instance-settings.js";
|
||||
import { redactCurrentUserText } from "../log-redaction.js";
|
||||
import { resolveIssueGoalId, resolveNextIssueGoalId } from "./issue-goal-fallback.js";
|
||||
@@ -1421,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`,
|
||||
@@ -2815,6 +2822,15 @@ export function issueService(db: Db) {
|
||||
if (values.status === "cancelled") {
|
||||
values.cancelledAt = new Date();
|
||||
}
|
||||
Object.assign(
|
||||
values,
|
||||
buildInitialIssueMonitorFields({
|
||||
policy: normalizeIssueExecutionPolicy(issueData.executionPolicy ?? null),
|
||||
status: values.status ?? "backlog",
|
||||
assigneeAgentId: values.assigneeAgentId ?? null,
|
||||
assigneeUserId: values.assigneeUserId ?? null,
|
||||
}),
|
||||
);
|
||||
|
||||
const [issue] = await tx.insert(issues).values(values).returning();
|
||||
if (inputLabelIds) {
|
||||
|
||||
@@ -22,7 +22,10 @@ export interface IssueLivenessIssueInput {
|
||||
assigneeUserId?: string | null;
|
||||
createdByAgentId?: string | null;
|
||||
createdByUserId?: string | null;
|
||||
executionPolicy?: Record<string, unknown> | null;
|
||||
executionState?: Record<string, unknown> | null;
|
||||
monitorNextCheckAt?: Date | string | null;
|
||||
monitorAttemptCount?: number | null;
|
||||
}
|
||||
|
||||
export interface IssueLivenessRelationInput {
|
||||
@@ -99,6 +102,7 @@ export interface IssueGraphLivenessInput {
|
||||
pendingInteractions?: IssueLivenessWaitingPathInput[];
|
||||
pendingApprovals?: IssueLivenessWaitingPathInput[];
|
||||
openRecoveryIssues?: IssueLivenessWaitingPathInput[];
|
||||
now?: Date | string;
|
||||
}
|
||||
|
||||
const INVOKABLE_AGENT_STATUSES = new Set(["active", "idle", "running", "error"]);
|
||||
@@ -140,6 +144,45 @@ function hasWaitingPath(
|
||||
return waitingPaths.some((entry) => entry.companyId === companyId && entry.issueId === issueId);
|
||||
}
|
||||
|
||||
function readRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: null;
|
||||
}
|
||||
|
||||
function readPositiveInteger(value: unknown): number | null {
|
||||
return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : null;
|
||||
}
|
||||
|
||||
function readDateMs(value: unknown): number | null {
|
||||
if (!(typeof value === "string" || value instanceof Date)) return null;
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
const time = date.getTime();
|
||||
return Number.isNaN(time) ? null : time;
|
||||
}
|
||||
|
||||
function monitorFromIssue(issue: IssueLivenessIssueInput) {
|
||||
const policyMonitor = readRecord(readRecord(issue.executionPolicy)?.monitor);
|
||||
const stateMonitor = readRecord(readRecord(issue.executionState)?.monitor);
|
||||
return { policyMonitor, stateMonitor };
|
||||
}
|
||||
|
||||
function hasScheduledMonitor(issue: IssueLivenessIssueInput, nowMs: number) {
|
||||
const nextCheckAtMs = readDateMs(issue.monitorNextCheckAt);
|
||||
if (nextCheckAtMs === null || nextCheckAtMs <= nowMs) return false;
|
||||
|
||||
const { policyMonitor, stateMonitor } = monitorFromIssue(issue);
|
||||
const timeoutAtMs = readDateMs(policyMonitor?.timeoutAt ?? stateMonitor?.timeoutAt);
|
||||
if (timeoutAtMs !== null && timeoutAtMs <= nowMs) return false;
|
||||
|
||||
const maxAttempts = readPositiveInteger(policyMonitor?.maxAttempts ?? stateMonitor?.maxAttempts);
|
||||
const stateAttemptCount = readPositiveInteger(stateMonitor?.attemptCount) ?? 0;
|
||||
const attemptCount = issue.monitorAttemptCount ?? stateAttemptCount;
|
||||
if (maxAttempts !== null && attemptCount >= maxAttempts) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function readPrincipalAgentId(principal: unknown): string | null {
|
||||
if (!principal || typeof principal !== "object") return null;
|
||||
const value = principal as Record<string, unknown>;
|
||||
@@ -308,6 +351,7 @@ function finding(input: {
|
||||
}
|
||||
|
||||
export function classifyIssueGraphLiveness(input: IssueGraphLivenessInput): IssueLivenessFinding[] {
|
||||
const nowMs = readDateMs(input.now ?? new Date()) ?? Date.now();
|
||||
const issuesById = new Map(input.issues.map((issue) => [issue.id, issue]));
|
||||
const agentsById = new Map(input.agents.map((agent) => [agent.id, agent]));
|
||||
const blockersByBlockedIssueId = new Map<string, IssueLivenessRelationInput[]>();
|
||||
@@ -351,6 +395,7 @@ export function classifyIssueGraphLiveness(input: IssueGraphLivenessInput): Issu
|
||||
|
||||
function hasExplicitWaitingPath(issue: IssueLivenessIssueInput) {
|
||||
return Boolean(issue.assigneeUserId) ||
|
||||
hasScheduledMonitor(issue, nowMs) ||
|
||||
hasActiveExecutionPath(issue.companyId, issue.id, activeRuns, queuedWakeRequests) ||
|
||||
hasWaitingPath(issue.companyId, issue.id, pendingInteractions) ||
|
||||
hasWaitingPath(issue.companyId, issue.id, pendingApprovals) ||
|
||||
|
||||
@@ -1836,7 +1836,10 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
||||
assigneeUserId: issues.assigneeUserId,
|
||||
createdByAgentId: issues.createdByAgentId,
|
||||
createdByUserId: issues.createdByUserId,
|
||||
executionPolicy: issues.executionPolicy,
|
||||
executionState: issues.executionState,
|
||||
monitorNextCheckAt: issues.monitorNextCheckAt,
|
||||
monitorAttemptCount: issues.monitorAttemptCount,
|
||||
})
|
||||
.from(issues)
|
||||
.where(
|
||||
@@ -1966,6 +1969,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
||||
pendingInteractions: interactionRows,
|
||||
pendingApprovals: approvalRows,
|
||||
openRecoveryIssues,
|
||||
now: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,196 @@
|
||||
// @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("does not render external references from monitor metadata", () => {
|
||||
const root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<IssueMonitorActivityCard
|
||||
issue={createIssue({
|
||||
executionPolicy: {
|
||||
mode: "normal",
|
||||
commentRequired: true,
|
||||
stages: [],
|
||||
monitor: {
|
||||
nextCheckAt: "2026-04-11T12:30:00.000Z",
|
||||
notes: "Check deployment health",
|
||||
scheduledBy: "board",
|
||||
serviceName: "Deploy provider",
|
||||
externalRef: "https://provider.example/deploy/123?token=secret",
|
||||
},
|
||||
},
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("Deploy provider");
|
||||
expect(container.textContent).not.toContain("provider.example");
|
||||
expect(container.textContent).not.toContain("token=secret");
|
||||
|
||||
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,71 @@
|
||||
import type { Issue } from "@paperclipai/shared";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { formatMonitorOffset } from "@/lib/issue-monitor";
|
||||
import { formatDateTime } from "@/lib/utils";
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
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 ? (
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{monitor.serviceName}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -969,4 +969,84 @@ describe("IssueProperties", () => {
|
||||
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("renders monitor controls and clears an existing monitor", async () => {
|
||||
const onUpdate = vi.fn();
|
||||
const root = renderProperties(container, {
|
||||
issue: createIssue({
|
||||
status: "in_progress",
|
||||
assigneeAgentId: "agent-1",
|
||||
executionPolicy: createExecutionPolicy({
|
||||
monitor: {
|
||||
nextCheckAt: "2026-04-11T12:30:00.000Z",
|
||||
notes: "Check deployment",
|
||||
scheduledBy: "board",
|
||||
},
|
||||
}),
|
||||
executionState: createExecutionState({
|
||||
status: "idle",
|
||||
currentStageId: null,
|
||||
currentStageIndex: null,
|
||||
currentStageType: null,
|
||||
currentParticipant: null,
|
||||
returnAssignee: null,
|
||||
lastDecisionOutcome: null,
|
||||
monitor: {
|
||||
status: "scheduled",
|
||||
nextCheckAt: "2026-04-11T12:30:00.000Z",
|
||||
lastTriggeredAt: null,
|
||||
attemptCount: 0,
|
||||
notes: "Check deployment",
|
||||
scheduledBy: "board",
|
||||
clearedAt: null,
|
||||
clearReason: null,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
childIssues: [],
|
||||
onUpdate,
|
||||
inline: true,
|
||||
});
|
||||
await flush();
|
||||
|
||||
expect(container.textContent).toContain("Monitor");
|
||||
expect(container.textContent).toContain("Next check");
|
||||
expect(container.querySelector('input[type="datetime-local"]')).toBeNull();
|
||||
expect(container.querySelector('input[placeholder="What should the agent re-check?"]')).toBeNull();
|
||||
|
||||
const monitorTrigger = Array.from(container.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.includes("Next check"));
|
||||
expect(monitorTrigger).not.toBeUndefined();
|
||||
|
||||
await act(async () => {
|
||||
monitorTrigger!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flush();
|
||||
|
||||
const inputs = Array.from(container.querySelectorAll("input"));
|
||||
const datetimeInput = inputs.find((input) => input.getAttribute("type") === "datetime-local");
|
||||
const textInput = inputs.find((input) => input.getAttribute("placeholder") === "What should the agent re-check?");
|
||||
const clearButton = Array.from(container.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.includes("Clear"));
|
||||
|
||||
expect(datetimeInput).toBeTruthy();
|
||||
expect(textInput).toBeTruthy();
|
||||
expect(clearButton).toBeTruthy();
|
||||
expect(datetimeInput!.value).toBeTruthy();
|
||||
expect(textInput!.value).toBe("Check deployment");
|
||||
|
||||
act(() => {
|
||||
clearButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(onUpdate).toHaveBeenCalledWith({
|
||||
executionPolicy: {
|
||||
mode: "normal",
|
||||
commentRequired: true,
|
||||
stages: [],
|
||||
},
|
||||
});
|
||||
|
||||
act(() => root.unmount());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,6 +25,7 @@ import { getRecentProjectIds, trackRecentProject } from "../lib/recent-projects"
|
||||
import { orderItemsBySelectedAndRecent } from "../lib/recent-selections";
|
||||
import { formatAssigneeUserLabel } from "../lib/assignees";
|
||||
import { buildExecutionPolicy, stageParticipantValues } from "../lib/issue-execution-policy";
|
||||
import { formatMonitorOffset } from "../lib/issue-monitor";
|
||||
import { StatusIcon } from "./StatusIcon";
|
||||
import { PriorityIcon } from "./PriorityIcon";
|
||||
import { Identity } from "./Identity";
|
||||
@@ -33,7 +34,7 @@ import { formatDate, cn, projectUrl } from "../lib/utils";
|
||||
import { timeAgo } from "../lib/timeAgo";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { User, Hexagon, ArrowUpRight, Tag, Plus, GitBranch, FolderOpen, Check, ExternalLink } from "lucide-react";
|
||||
import { User, Hexagon, ArrowUpRight, Tag, Plus, GitBranch, FolderOpen, Check, ExternalLink, Clock } from "lucide-react";
|
||||
import { AgentIcon } from "./AgentIconPicker";
|
||||
|
||||
function TruncatedCopyable({ value, icon: Icon }: { value: string; icon: React.ComponentType<{ className?: string }> }) {
|
||||
@@ -118,6 +119,14 @@ function issuesWorkspaceFilterHref(workspaceId: string) {
|
||||
return `/issues?${params.toString()}`;
|
||||
}
|
||||
|
||||
function toDateTimeLocalValue(value: string | null | undefined) {
|
||||
if (!value) return "";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "";
|
||||
const offsetMs = date.getTimezoneOffset() * 60_000;
|
||||
return new Date(date.getTime() - offsetMs).toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
interface IssuePropertiesProps {
|
||||
issue: Issue;
|
||||
childIssues?: Issue[];
|
||||
@@ -219,10 +228,14 @@ export function IssueProperties({
|
||||
const [reviewerSearch, setReviewerSearch] = useState("");
|
||||
const [approversOpen, setApproversOpen] = useState(false);
|
||||
const [approverSearch, setApproverSearch] = useState("");
|
||||
const [monitorOpen, setMonitorOpen] = useState(false);
|
||||
const [labelsOpen, setLabelsOpen] = useState(false);
|
||||
const [labelSearch, setLabelSearch] = useState("");
|
||||
const [newLabelName, setNewLabelName] = useState("");
|
||||
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 { data: session } = useQuery({
|
||||
queryKey: queryKeys.auth.session,
|
||||
@@ -459,6 +472,145 @@ export function IssueProperties({
|
||||
}
|
||||
return `${stageLabel} pending${participantLabel ? ` with ${participantLabel}` : ""}`;
|
||||
})();
|
||||
useEffect(() => {
|
||||
setMonitorAtInput(toDateTimeLocalValue(issue.executionPolicy?.monitor?.nextCheckAt));
|
||||
setMonitorNotesInput(issue.executionPolicy?.monitor?.notes ?? "");
|
||||
setMonitorServiceInput(issue.executionPolicy?.monitor?.serviceName ?? "");
|
||||
}, [
|
||||
issue.executionPolicy?.monitor?.nextCheckAt,
|
||||
issue.executionPolicy?.monitor?.notes,
|
||||
issue.executionPolicy?.monitor?.serviceName,
|
||||
]);
|
||||
|
||||
const updateMonitor = (nextMonitor: Issue["executionPolicy"] extends infer T
|
||||
? T extends { monitor?: infer M | null } | null | undefined
|
||||
? M | null
|
||||
: never
|
||||
: never) => {
|
||||
const basePolicy = buildExecutionPolicy({
|
||||
existingPolicy: issue.executionPolicy ?? null,
|
||||
reviewerValues,
|
||||
approverValues,
|
||||
});
|
||||
if (!basePolicy && !nextMonitor) {
|
||||
onUpdate({ executionPolicy: null });
|
||||
return;
|
||||
}
|
||||
onUpdate({
|
||||
executionPolicy: {
|
||||
mode: basePolicy?.mode ?? issue.executionPolicy?.mode ?? "normal",
|
||||
commentRequired: true,
|
||||
stages: basePolicy?.stages ?? [],
|
||||
...(nextMonitor ? { monitor: nextMonitor } : {}),
|
||||
},
|
||||
});
|
||||
};
|
||||
const saveMonitor = () => {
|
||||
if (!monitorAtInput) return;
|
||||
const nextCheckAt = new Date(monitorAtInput);
|
||||
if (Number.isNaN(nextCheckAt.getTime())) return;
|
||||
const serviceName = monitorServiceInput.trim() || null;
|
||||
updateMonitor({
|
||||
nextCheckAt: nextCheckAt.toISOString(),
|
||||
notes: monitorNotesInput.trim() || null,
|
||||
scheduledBy: "board",
|
||||
kind: serviceName ? "external_service" : null,
|
||||
serviceName,
|
||||
externalRef: null,
|
||||
});
|
||||
setMonitorOpen(false);
|
||||
};
|
||||
const clearMonitor = () => {
|
||||
updateMonitor(null);
|
||||
setMonitorOpen(false);
|
||||
};
|
||||
const currentMonitorLabel = (() => {
|
||||
if (issue.executionPolicy?.monitor?.nextCheckAt) {
|
||||
return `Next check ${formatDate(new Date(issue.executionPolicy.monitor.nextCheckAt))}`;
|
||||
}
|
||||
if (issue.executionState?.monitor?.status === "cleared") {
|
||||
return "Cleared";
|
||||
}
|
||||
if (issue.monitorLastTriggeredAt) {
|
||||
return `Last triggered ${timeAgo(issue.monitorLastTriggeredAt)}`;
|
||||
}
|
||||
return "Not scheduled";
|
||||
})();
|
||||
const monitorNextCheckAt = issue.executionPolicy?.monitor?.nextCheckAt ?? null;
|
||||
const monitorTrigger = (
|
||||
<span className="inline-flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-0.5">
|
||||
{monitorNextCheckAt ? (
|
||||
<Clock className="mt-0.5 h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
) : null}
|
||||
<span
|
||||
className={cn(
|
||||
"min-w-0 text-sm break-words",
|
||||
monitorNextCheckAt ? "text-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
title={monitorNextCheckAt ? currentMonitorLabel : undefined}
|
||||
>
|
||||
{monitorNextCheckAt ? `Next check ${formatMonitorOffset(monitorNextCheckAt)}` : currentMonitorLabel}
|
||||
</span>
|
||||
{monitorNextCheckAt ? (
|
||||
<span className="text-xs text-muted-foreground" title={currentMonitorLabel}>
|
||||
{formatDate(new Date(monitorNextCheckAt))}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
const monitorAttemptBadge = issue.monitorAttemptCount && issue.monitorAttemptCount > 0 ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Attempt {issue.monitorAttemptCount}
|
||||
</span>
|
||||
) : null;
|
||||
const monitorContent = (
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<div className="flex flex-col gap-2 md:flex-row">
|
||||
<input
|
||||
type="datetime-local"
|
||||
className="rounded-md border border-border bg-transparent px-2 py-1 text-xs"
|
||||
value={monitorAtInput}
|
||||
onChange={(e) => setMonitorAtInput(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="What should the agent re-check?"
|
||||
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)}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center rounded-full border border-border px-2 py-0.5 text-xs text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground disabled:opacity-50"
|
||||
disabled={!monitorAtInput}
|
||||
onClick={saveMonitor}
|
||||
>
|
||||
Schedule
|
||||
</button>
|
||||
{issue.executionPolicy?.monitor ? (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center rounded-full border border-border px-2 py-0.5 text-xs text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
|
||||
onClick={clearMonitor}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const selectedIssueLabels = useMemo(() => {
|
||||
const selectedIds = issue.labelIds ?? [];
|
||||
if (selectedIds.length === 0) return issue.labels ?? [];
|
||||
@@ -1248,6 +1400,19 @@ export function IssueProperties({
|
||||
</PropertyRow>
|
||||
)}
|
||||
|
||||
<PropertyPicker
|
||||
inline={inline}
|
||||
label="Monitor"
|
||||
open={monitorOpen}
|
||||
onOpenChange={setMonitorOpen}
|
||||
triggerContent={monitorTrigger}
|
||||
triggerClassName="min-w-0 max-w-full"
|
||||
popoverClassName={cn("max-w-full", inline ? "w-full" : "w-80 sm:w-[32rem]")}
|
||||
extra={monitorAttemptBadge}
|
||||
>
|
||||
{monitorContent}
|
||||
</PropertyPicker>
|
||||
|
||||
{issue.requestDepth > 0 && (
|
||||
<PropertyRow label="Depth">
|
||||
<span className="text-sm font-mono">{issue.requestDepth}</span>
|
||||
|
||||
@@ -57,4 +57,12 @@ describe("activity formatting", () => {
|
||||
expect(formatActivityVerb("issue.reviewers_updated", details, { agentMap })).toBe("updated reviewers on");
|
||||
expect(formatIssueActivityAction("issue.reviewers_updated", details, { agentMap })).toBe("updated reviewers");
|
||||
});
|
||||
|
||||
it("formats monitor activity with direct verbs", () => {
|
||||
expect(formatActivityVerb("issue.monitor_scheduled")).toBe("scheduled monitor on");
|
||||
expect(formatActivityVerb("issue.monitor_exhausted")).toBe("exhausted monitor on");
|
||||
expect(formatIssueActivityAction("issue.monitor_triggered")).toBe("triggered a monitor");
|
||||
expect(formatIssueActivityAction("issue.monitor_cleared")).toBe("cleared a monitor");
|
||||
expect(formatIssueActivityAction("issue.monitor_recovery_issue_created")).toBe("created a monitor recovery issue");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,6 +33,14 @@ const ACTIVITY_ROW_VERBS: Record<string, string> = {
|
||||
"issue.document_created": "created document for",
|
||||
"issue.document_updated": "updated document on",
|
||||
"issue.document_deleted": "deleted document from",
|
||||
"issue.monitor_scheduled": "scheduled monitor on",
|
||||
"issue.monitor_triggered": "triggered monitor for",
|
||||
"issue.monitor_cleared": "cleared monitor on",
|
||||
"issue.monitor_skipped": "skipped monitor for",
|
||||
"issue.monitor_exhausted": "exhausted monitor on",
|
||||
"issue.monitor_recovery_wake_queued": "queued monitor recovery for",
|
||||
"issue.monitor_recovery_issue_created": "created monitor recovery for",
|
||||
"issue.monitor_escalated_to_board": "escalated monitor for",
|
||||
"issue.commented": "commented on",
|
||||
"issue.deleted": "deleted",
|
||||
"agent.created": "created",
|
||||
@@ -75,6 +83,14 @@ const ISSUE_ACTIVITY_LABELS: Record<string, string> = {
|
||||
"issue.document_created": "created a document",
|
||||
"issue.document_updated": "updated a document",
|
||||
"issue.document_deleted": "deleted a document",
|
||||
"issue.monitor_scheduled": "scheduled a monitor",
|
||||
"issue.monitor_triggered": "triggered a monitor",
|
||||
"issue.monitor_cleared": "cleared a monitor",
|
||||
"issue.monitor_skipped": "skipped a monitor",
|
||||
"issue.monitor_exhausted": "exhausted a monitor",
|
||||
"issue.monitor_recovery_wake_queued": "queued a monitor recovery wake",
|
||||
"issue.monitor_recovery_issue_created": "created a monitor recovery issue",
|
||||
"issue.monitor_escalated_to_board": "escalated a monitor to the board",
|
||||
"issue.deleted": "deleted the issue",
|
||||
"agent.created": "created an agent",
|
||||
"agent.updated": "updated the agent",
|
||||
@@ -296,6 +312,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
|
||||
|
||||
@@ -62,6 +62,7 @@ export function buildExecutionPolicy(input: {
|
||||
}): IssueExecutionPolicy | null {
|
||||
const mode = input.existingPolicy?.mode ?? "normal";
|
||||
const stages: IssueExecutionPolicy["stages"] = [];
|
||||
const monitor = input.existingPolicy?.monitor ?? null;
|
||||
|
||||
const existingReviewStage = input.existingPolicy?.stages.find((stage) => stage.type === "review");
|
||||
const reviewParticipants = mergeParticipants(existingReviewStage?.participants, input.reviewerValues);
|
||||
@@ -85,11 +86,12 @@ export function buildExecutionPolicy(input: {
|
||||
});
|
||||
}
|
||||
|
||||
if (stages.length === 0) return null;
|
||||
if (stages.length === 0 && !monitor) return null;
|
||||
|
||||
return {
|
||||
mode,
|
||||
commentRequired: true,
|
||||
stages,
|
||||
...(monitor ? { monitor } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
export 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`;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import type { Issue } from "@paperclipai/shared";
|
||||
import { IssueMonitorActivityCard } from "@/components/IssueMonitorActivityCard";
|
||||
import { IssueProperties } from "@/components/IssueProperties";
|
||||
import {
|
||||
storybookExecutionWorkspaces,
|
||||
storybookIssueDocuments,
|
||||
storybookIssues,
|
||||
} from "../fixtures/paperclipData";
|
||||
|
||||
const issueDocumentSummaries = storybookIssueDocuments.map(({ body: _body, ...summary }) => summary);
|
||||
|
||||
const baseIssue: Issue = {
|
||||
...storybookIssues[0]!,
|
||||
planDocument: storybookIssueDocuments.find((document) => document.key === "plan") ?? null,
|
||||
documentSummaries: issueDocumentSummaries,
|
||||
currentExecutionWorkspace: storybookExecutionWorkspaces[0]!,
|
||||
};
|
||||
|
||||
const inFiveMinutes = () => new Date(Date.now() + 5 * 60_000);
|
||||
const inTwoHours = () => new Date(Date.now() + 2 * 60 * 60_000);
|
||||
|
||||
const monitoredIssue: Issue = {
|
||||
...baseIssue,
|
||||
monitorNextCheckAt: inFiveMinutes(),
|
||||
monitorNotes: "Polling Greptile for completed analysis.",
|
||||
monitorAttemptCount: 2,
|
||||
executionPolicy: {
|
||||
...(baseIssue.executionPolicy ?? { mode: "normal", commentRequired: true, stages: [] }),
|
||||
monitor: {
|
||||
nextCheckAt: inFiveMinutes().toISOString(),
|
||||
notes: "Polling Greptile for completed analysis.",
|
||||
kind: "external_service",
|
||||
scheduledBy: "assignee",
|
||||
serviceName: "Greptile",
|
||||
externalRef: "https://app.greptile.com/runs/abc123",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const longerWaitIssue: Issue = {
|
||||
...baseIssue,
|
||||
monitorNextCheckAt: inTwoHours(),
|
||||
monitorNotes: null,
|
||||
monitorAttemptCount: 0,
|
||||
executionPolicy: {
|
||||
...(baseIssue.executionPolicy ?? { mode: "normal", commentRequired: true, stages: [] }),
|
||||
monitor: {
|
||||
nextCheckAt: inTwoHours().toISOString(),
|
||||
notes: null,
|
||||
kind: null,
|
||||
scheduledBy: "assignee",
|
||||
serviceName: null,
|
||||
externalRef: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const triggeredIssue: Issue = {
|
||||
...baseIssue,
|
||||
monitorNextCheckAt: null,
|
||||
monitorLastTriggeredAt: new Date(Date.now() - 3 * 60_000),
|
||||
monitorAttemptCount: 3,
|
||||
monitorNotes: "Greptile review was checked and needs another pass.",
|
||||
executionPolicy: {
|
||||
...(baseIssue.executionPolicy ?? { mode: "normal", commentRequired: true, stages: [] }),
|
||||
},
|
||||
executionState: {
|
||||
...(baseIssue.executionState ?? {
|
||||
status: "pending",
|
||||
currentStageId: null,
|
||||
currentStageIndex: null,
|
||||
currentStageType: null,
|
||||
currentParticipant: null,
|
||||
returnAssignee: null,
|
||||
reviewRequest: null,
|
||||
completedStageIds: [],
|
||||
lastDecisionId: null,
|
||||
lastDecisionOutcome: null,
|
||||
}),
|
||||
monitor: null,
|
||||
},
|
||||
};
|
||||
|
||||
const clearedIssue: Issue = {
|
||||
...baseIssue,
|
||||
monitorNextCheckAt: null,
|
||||
monitorLastTriggeredAt: null,
|
||||
monitorAttemptCount: 0,
|
||||
monitorNotes: null,
|
||||
executionPolicy: {
|
||||
...(baseIssue.executionPolicy ?? { mode: "normal", commentRequired: true, stages: [] }),
|
||||
},
|
||||
executionState: {
|
||||
...(baseIssue.executionState ?? {
|
||||
status: "pending",
|
||||
currentStageId: null,
|
||||
currentStageIndex: null,
|
||||
currentStageType: null,
|
||||
currentParticipant: null,
|
||||
returnAssignee: null,
|
||||
reviewRequest: null,
|
||||
completedStageIds: [],
|
||||
lastDecisionId: null,
|
||||
lastDecisionOutcome: null,
|
||||
}),
|
||||
monitor: {
|
||||
status: "cleared",
|
||||
nextCheckAt: null,
|
||||
lastTriggeredAt: null,
|
||||
attemptCount: 0,
|
||||
notes: null,
|
||||
scheduledBy: "board",
|
||||
kind: null,
|
||||
serviceName: null,
|
||||
externalRef: null,
|
||||
timeoutAt: null,
|
||||
maxAttempts: null,
|
||||
recoveryPolicy: null,
|
||||
clearedAt: new Date(Date.now() - 60_000).toISOString(),
|
||||
clearReason: "manual",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function MonitorSurfaceStories() {
|
||||
return (
|
||||
<div className="space-y-8 p-6">
|
||||
<section className="space-y-2">
|
||||
<div className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
IssueMonitorActivityCard - external service (Greptile)
|
||||
</div>
|
||||
<IssueMonitorActivityCard
|
||||
issue={monitoredIssue}
|
||||
onCheckNow={() => undefined}
|
||||
checkingNow={false}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="space-y-2">
|
||||
<div className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
IssueMonitorActivityCard - generic 2h wait, no service metadata
|
||||
</div>
|
||||
<IssueMonitorActivityCard
|
||||
issue={longerWaitIssue}
|
||||
onCheckNow={() => undefined}
|
||||
checkingNow={false}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="space-y-2">
|
||||
<div className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
IssueMonitorActivityCard - returns null when no monitor is set
|
||||
</div>
|
||||
<div className="rounded-lg border border-dashed border-border px-3 py-2 text-xs text-muted-foreground">
|
||||
(intentionally renders nothing for issues without a scheduled monitor)
|
||||
</div>
|
||||
<IssueMonitorActivityCard issue={baseIssue} onCheckNow={() => undefined} />
|
||||
</section>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<section className="space-y-2">
|
||||
<div className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
IssueProperties Monitor row - Not scheduled (default state)
|
||||
</div>
|
||||
<div className="rounded-lg border border-border bg-background/70 p-4">
|
||||
<IssueProperties
|
||||
issue={baseIssue}
|
||||
onUpdate={() => undefined}
|
||||
inline
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-2">
|
||||
<div className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
IssueProperties Monitor row - Scheduled (Greptile, in 5m)
|
||||
</div>
|
||||
<div className="rounded-lg border border-border bg-background/70 p-4">
|
||||
<IssueProperties
|
||||
issue={monitoredIssue}
|
||||
onUpdate={() => undefined}
|
||||
inline
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-2">
|
||||
<div className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
IssueProperties Monitor row - Triggered recently
|
||||
</div>
|
||||
<div className="rounded-lg border border-border bg-background/70 p-4">
|
||||
<IssueProperties
|
||||
issue={triggeredIssue}
|
||||
onUpdate={() => undefined}
|
||||
inline
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-2">
|
||||
<div className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
IssueProperties Monitor row - Cleared
|
||||
</div>
|
||||
<div className="rounded-lg border border-border bg-background/70 p-4">
|
||||
<IssueProperties
|
||||
issue={clearedIssue}
|
||||
onUpdate={() => undefined}
|
||||
inline
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: "Product/Issue Monitor surfaces",
|
||||
component: MonitorSurfaceStories,
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"Surfaces the IssueMonitorActivityCard and IssueProperties Monitor row in scheduled / not-scheduled / external-service variants for UX review.",
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof MonitorSurfaceStories>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const MonitorSurfaces: Story = {};
|
||||
Reference in New Issue
Block a user