diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index c9290369154..0ad5187ab04 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -631,6 +631,12 @@ Expected: - `/api/health` returns `{"status":"ok"}` - `/api/companies` returns a JSON array +### Routine dispatch status and idempotency + +`POST /api/routines/:id/run` supports explicit `manual` and `api` dispatches for both `active` and `paused` routines. `archived` routines cannot run. Automatic schedule and webhook dispatches require the routine and trigger to be active. + +Callers that may retry a dispatch must send a stable `idempotencyKey`. The key is scoped to the company, routine, source, and trigger, and replaying the same request returns the original routine run and linked execution issue instead of creating duplicate work. For branch handoffs, derive the key from the normalized branch name and commit SHA, and retain the returned routine-run and issue links as handoff evidence. + ## Reset Local Dev Database To wipe local dev data and start fresh: diff --git a/packages/db/src/migrations/0171_issue_create_idempotency_keys.sql b/packages/db/src/migrations/0171_issue_create_idempotency_keys.sql index 3eba8b3ce12..d242897ae1c 100644 --- a/packages/db/src/migrations/0171_issue_create_idempotency_keys.sql +++ b/packages/db/src/migrations/0171_issue_create_idempotency_keys.sql @@ -9,6 +9,8 @@ CREATE UNIQUE INDEX IF NOT EXISTS "issue_create_idempotency_keys_company_key_uq" ON "issue_create_idempotency_keys" USING btree ("company_id", "idempotency_key");--> statement-breakpoint CREATE INDEX IF NOT EXISTS "issue_create_idempotency_keys_issue_idx" ON "issue_create_idempotency_keys" USING btree ("issue_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "issue_create_idempotency_keys_company_created_at_idx" + ON "issue_create_idempotency_keys" USING btree ("company_id", "created_at");--> statement-breakpoint CREATE INDEX IF NOT EXISTS "issues_open_normalized_title_created_idx" ON "issues" USING btree ( "company_id", diff --git a/packages/db/src/migrations/0173_folders.sql b/packages/db/src/migrations/0173_folders.sql new file mode 100644 index 00000000000..90ec1aa3df7 --- /dev/null +++ b/packages/db/src/migrations/0173_folders.sql @@ -0,0 +1,40 @@ +CREATE TABLE IF NOT EXISTS "folders" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "kind" text NOT NULL, + "name" text NOT NULL, + "color" text, + "position" integer DEFAULT 0 NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "company_skills" ADD COLUMN IF NOT EXISTS "folder_id" uuid; +--> statement-breakpoint +ALTER TABLE "routines" ADD COLUMN IF NOT EXISTS "folder_id" uuid; +--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM "pg_constraint" WHERE "conname" = 'folders_company_id_companies_id_fk') THEN + ALTER TABLE "folders" ADD CONSTRAINT "folders_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action; + END IF; +END $$; +--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM "pg_constraint" WHERE "conname" = 'company_skills_folder_id_folders_id_fk') THEN + ALTER TABLE "company_skills" ADD CONSTRAINT "company_skills_folder_id_folders_id_fk" FOREIGN KEY ("folder_id") REFERENCES "public"."folders"("id") ON DELETE set null ON UPDATE no action; + END IF; +END $$; +--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM "pg_constraint" WHERE "conname" = 'routines_folder_id_folders_id_fk') THEN + ALTER TABLE "routines" ADD CONSTRAINT "routines_folder_id_folders_id_fk" FOREIGN KEY ("folder_id") REFERENCES "public"."folders"("id") ON DELETE set null ON UPDATE no action; + END IF; +END $$; +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "folders_company_kind_position_idx" ON "folders" USING btree ("company_id","kind","position","name"); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "folders_company_kind_name_uq" ON "folders" USING btree ("company_id","kind","name"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "company_skills_company_folder_idx" ON "company_skills" USING btree ("company_id","folder_id"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "routines_company_folder_idx" ON "routines" USING btree ("company_id","folder_id"); diff --git a/packages/db/src/migrations/0174_decision_training_examples.sql b/packages/db/src/migrations/0174_decision_training_examples.sql new file mode 100644 index 00000000000..cf452f1edd6 --- /dev/null +++ b/packages/db/src/migrations/0174_decision_training_examples.sql @@ -0,0 +1,23 @@ +CREATE TABLE IF NOT EXISTS "decision_training_examples" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL REFERENCES "companies"("id") ON DELETE CASCADE, + "source_kind" text NOT NULL, + "source_id" uuid NOT NULL, + "issue_id" uuid NOT NULL REFERENCES "issues"("id") ON DELETE CASCADE, + "cutoff_at" timestamp with time zone NOT NULL, + "notes" text DEFAULT '' NOT NULL, + "notes_history" jsonb DEFAULT '[]'::jsonb NOT NULL, + "decision_outcome" text, + "snapshot" jsonb NOT NULL, + "created_by_user_id" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "decision_training_examples_source_kind_check" + CHECK ("source_kind" IN ('interaction', 'approval', 'execution_decision')) +);--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "decision_training_examples_company_created_at_idx" + ON "decision_training_examples" USING btree ("company_id", "created_at");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "decision_training_examples_issue_idx" + ON "decision_training_examples" USING btree ("issue_id");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "decision_training_examples_source_author_uq" + ON "decision_training_examples" USING btree ("source_kind", "source_id", "created_by_user_id"); diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index 5e4450b883e..62e54ebac14 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -1198,6 +1198,20 @@ "when": 1784169959628, "tag": "0172_inbox_archive_agent_policies", "breakpoints": true + }, + { + "idx": 173, + "version": "7", + "when": 1784170000000, + "tag": "0173_folders", + "breakpoints": true + }, + { + "idx": 174, + "version": "7", + "when": 1784216720808, + "tag": "0174_decision_training_examples", + "breakpoints": true } ] } diff --git a/packages/db/src/schema/company_skills.ts b/packages/db/src/schema/company_skills.ts index 7e6ad7d1d5f..bc96f3095ec 100644 --- a/packages/db/src/schema/company_skills.ts +++ b/packages/db/src/schema/company_skills.ts @@ -12,6 +12,7 @@ import { import type { CompanySkillFileInventoryEntry, CompanySkillSharingScope } from "@paperclipai/shared"; import { agents } from "./agents.js"; import { companies } from "./companies.js"; +import { folders } from "./folders.js"; import { issues } from "./issues.js"; export const companySkills = pgTable( @@ -19,6 +20,7 @@ export const companySkills = pgTable( { id: uuid("id").primaryKey().defaultRandom(), companyId: uuid("company_id").notNull().references(() => companies.id), + folderId: uuid("folder_id").references(() => folders.id, { onDelete: "set null" }), key: text("key").notNull(), slug: text("slug").notNull(), name: text("name").notNull(), @@ -51,6 +53,7 @@ export const companySkills = pgTable( (table) => ({ companyKeyUniqueIdx: uniqueIndex("company_skills_company_key_idx").on(table.companyId, table.key), companyNameIdx: index("company_skills_company_name_idx").on(table.companyId, table.name), + companyFolderIdx: index("company_skills_company_folder_idx").on(table.companyId, table.folderId), companyCategoriesIdx: index("company_skills_company_categories_idx").using("gin", table.categories), companySharingScopeIdx: index("company_skills_company_sharing_scope_idx").on(table.companyId, table.sharingScope), companyCurrentVersionIdx: index("company_skills_company_current_version_idx").on(table.companyId, table.currentVersionId), diff --git a/packages/db/src/schema/decision_training_examples.ts b/packages/db/src/schema/decision_training_examples.ts new file mode 100644 index 00000000000..5ad9504d842 --- /dev/null +++ b/packages/db/src/schema/decision_training_examples.ts @@ -0,0 +1,39 @@ +import type { + DecisionTrainingNotesHistoryEntry, + DecisionTrainingSnapshotV1, + DecisionTrainingSourceKind, +} from "@paperclipai/shared"; +import { index, jsonb, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core"; +import { companies } from "./companies.js"; +import { issues } from "./issues.js"; + +export const decisionTrainingExamples = pgTable( + "decision_training_examples", + { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }), + sourceKind: text("source_kind").$type().notNull(), + sourceId: uuid("source_id").notNull(), + issueId: uuid("issue_id").notNull().references(() => issues.id, { onDelete: "cascade" }), + cutoffAt: timestamp("cutoff_at", { withTimezone: true }).notNull(), + notes: text("notes").notNull().default(""), + notesHistory: jsonb("notes_history").$type().notNull().default([]), + decisionOutcome: text("decision_outcome"), + snapshot: jsonb("snapshot").$type().notNull(), + createdByUserId: text("created_by_user_id").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + companyCreatedAtIdx: index("decision_training_examples_company_created_at_idx").on( + table.companyId, + table.createdAt, + ), + issueIdx: index("decision_training_examples_issue_idx").on(table.issueId), + sourceAuthorUq: uniqueIndex("decision_training_examples_source_author_uq").on( + table.sourceKind, + table.sourceId, + table.createdByUserId, + ), + }), +); diff --git a/packages/db/src/schema/folders.ts b/packages/db/src/schema/folders.ts new file mode 100644 index 00000000000..862ced00f7e --- /dev/null +++ b/packages/db/src/schema/folders.ts @@ -0,0 +1,38 @@ +import { + index, + integer, + pgTable, + text, + timestamp, + uniqueIndex, + uuid, +} from "drizzle-orm/pg-core"; +import { companies } from "./companies.js"; +import type { FolderKind } from "@paperclipai/shared"; + +export const folders = pgTable( + "folders", + { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }), + kind: text("kind").$type().notNull(), + name: text("name").notNull(), + color: text("color"), + position: integer("position").notNull().default(0), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + companyKindPositionIdx: index("folders_company_kind_position_idx").on( + table.companyId, + table.kind, + table.position, + table.name, + ), + companyKindNameUniqueIdx: uniqueIndex("folders_company_kind_name_uq").on( + table.companyId, + table.kind, + table.name, + ), + }), +); diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index ab9b93281f6..4f13d0adc17 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -35,6 +35,7 @@ export { workspaceOperations } from "./workspace_operations.js"; export { workspaceRuntimeServices } from "./workspace_runtime_services.js"; export { projectGoals } from "./project_goals.js"; export { goals } from "./goals.js"; +export { folders } from "./folders.js"; export { issues } from "./issues.js"; export { issueWatchdogs } from "./issue_watchdogs.js"; export { issuePlanDecompositions } from "./issue_plan_decompositions.js"; @@ -76,6 +77,7 @@ export { issueInboxArchives } from "./issue_inbox_archives.js"; export { userInboxAgentPolicies } from "./user_inbox_agent_policies.js"; export { inboxDismissals } from "./inbox_dismissals.js"; export { feedbackVotes } from "./feedback_votes.js"; +export { decisionTrainingExamples } from "./decision_training_examples.js"; export { feedbackExports } from "./feedback_exports.js"; export { issueReadStates } from "./issue_read_states.js"; export { assets } from "./assets.js"; diff --git a/packages/db/src/schema/issue_create_idempotency_keys.ts b/packages/db/src/schema/issue_create_idempotency_keys.ts index c428d9943dc..8a4f918e1fa 100644 --- a/packages/db/src/schema/issue_create_idempotency_keys.ts +++ b/packages/db/src/schema/issue_create_idempotency_keys.ts @@ -17,5 +17,9 @@ export const issueCreateIdempotencyKeys = pgTable( table.idempotencyKey, ), issueIdx: index("issue_create_idempotency_keys_issue_idx").on(table.issueId), + companyCreatedAtIdx: index("issue_create_idempotency_keys_company_created_at_idx").on( + table.companyId, + table.createdAt, + ), }), ); diff --git a/packages/db/src/schema/routines.ts b/packages/db/src/schema/routines.ts index ba65082c162..5b171f07e58 100644 --- a/packages/db/src/schema/routines.ts +++ b/packages/db/src/schema/routines.ts @@ -17,6 +17,7 @@ import { issues } from "./issues.js"; import { projects } from "./projects.js"; import { goals } from "./goals.js"; import { heartbeatRuns } from "./heartbeat_runs.js"; +import { folders } from "./folders.js"; import type { RoutineEnvConfig, RoutineRevisionSnapshotV1, RoutineVariable } from "@paperclipai/shared"; export const routines = pgTable( @@ -25,6 +26,7 @@ export const routines = pgTable( id: uuid("id").primaryKey().defaultRandom(), companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }), projectId: uuid("project_id").references(() => projects.id, { onDelete: "cascade" }), + folderId: uuid("folder_id").references(() => folders.id, { onDelete: "set null" }), goalId: uuid("goal_id").references(() => goals.id, { onDelete: "set null" }), parentIssueId: uuid("parent_issue_id").references(() => issues.id, { onDelete: "set null" }), title: text("title").notNull(), @@ -56,6 +58,7 @@ export const routines = pgTable( companyStatusIdx: index("routines_company_status_idx").on(table.companyId, table.status), companyAssigneeIdx: index("routines_company_assignee_idx").on(table.companyId, table.assigneeAgentId), companyProjectIdx: index("routines_company_project_idx").on(table.companyId, table.projectId), + companyFolderIdx: index("routines_company_folder_idx").on(table.companyId, table.folderId), companyResponsibleUserIdx: index("routines_company_responsible_user_idx").on(table.companyId, table.responsibleUserId), companyOriginIdx: index("routines_company_origin_idx").on(table.companyId, table.originKind, table.originId), }), diff --git a/packages/shared/src/api.ts b/packages/shared/src/api.ts index 1635ceb3d16..d8ff3bf3ddc 100644 --- a/packages/shared/src/api.ts +++ b/packages/shared/src/api.ts @@ -3,6 +3,10 @@ export const API_PREFIX = "/api"; export const API = { health: `${API_PREFIX}/health`, companies: `${API_PREFIX}/companies`, + companyFolders: `${API_PREFIX}/companies/:companyId/folders`, + companyFolder: `${API_PREFIX}/companies/:companyId/folders/:folderId`, + companyFolderMove: `${API_PREFIX}/companies/:companyId/folders/:folderId/move`, + companyFolderItemMove: `${API_PREFIX}/companies/:companyId/folders/items/move`, agents: `${API_PREFIX}/agents`, projects: `${API_PREFIX}/projects`, environments: `${API_PREFIX}/environments`, diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index cf991c13f34..b24d25a3e3d 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -63,6 +63,12 @@ export type { AttentionSubjectKind, AttentionWorkspaceRef, } from "./types/attention.js"; +export type { + DecisionTrainingExample, + DecisionTrainingNotesHistoryEntry, + DecisionTrainingSnapshotV1, + DecisionTrainingSourceKind, +} from "./types/decision-training.js"; export type { PipelineAutomationRetryBlocker, @@ -889,6 +895,16 @@ export type { IssueTreePreviewRun, IssueTreePreviewTotals, IssueTreePreviewWarning, + IssueExecutionHealthState, + IssueExecutionHealthReasonCode, + IssueExecutionHealthRunEvidence, + IssueExecutionHealthQueuedWakeEvidence, + IssueExecutionHealthInteractionEvidence, + IssueExecutionHealthApprovalEvidence, + IssueExecutionHealthRecoveryEvidence, + IssueExecutionHealthBlockerEvidence, + IssueExecutionHealthEvidence, + IssueExecutionHealthSummary, Goal, Approval, ApprovalComment, @@ -1286,6 +1302,11 @@ export { scoreWorkspaceRuntimeServiceMatch, } from "./workspace-commands.js"; +export { + ISSUE_EXECUTION_HEALTH_STATES, + ISSUE_EXECUTION_HEALTH_REASON_CODES, +} from "./types/reliability.js"; + export { DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE, FEEDBACK_TARGET_TYPES, @@ -2101,12 +2122,36 @@ export type { } from "./environment-support.js"; export type { AdapterRegistryEntry } from "./types/adapter-registry.js"; +export type { + FolderKind, + Folder, + FolderListItem, + FolderListResult, + CreateFolderRequest, + UpdateFolderRequest, + MoveFolderRequest, + MoveFolderItemRequest, +} from "./types/folder.js"; export { adapterRegistryEntrySchema, adapterRegistrySchema, type AdapterRegistryEntryParsed, } from "./validators/adapter-registry.js"; +export { + folderKindSchema, + folderSchema, + folderListItemSchema, + folderListResultSchema, + createFolderSchema, + updateFolderSchema, + moveFolderSchema, + moveFolderItemSchema, + type CreateFolder, + type UpdateFolder, + type MoveFolder, + type MoveFolderItem, +} from "./validators/folder.js"; export { environmentCustomImageTemplateKindSchema, diff --git a/packages/shared/src/types/attention.ts b/packages/shared/src/types/attention.ts index 6e01956439c..a3964d66da6 100644 --- a/packages/shared/src/types/attention.ts +++ b/packages/shared/src/types/attention.ts @@ -166,6 +166,7 @@ export interface AttentionItem { project: AttentionProjectRef | null; workspace: AttentionWorkspaceRef | null; detail: AttentionItemDetail | null; + trainingExampleId: string | null; } export interface AttentionFeed { diff --git a/packages/shared/src/types/company-skill.ts b/packages/shared/src/types/company-skill.ts index 29ea6e3ddcd..5f02d485816 100644 --- a/packages/shared/src/types/company-skill.ts +++ b/packages/shared/src/types/company-skill.ts @@ -34,6 +34,7 @@ export interface CompanySkillVersionFileInventoryEntry extends CompanySkillFileI export interface CompanySkill { id: string; companyId: string; + folderId?: string | null; key: string; slug: string; name: string; @@ -67,6 +68,7 @@ export interface CompanySkill { export interface CompanySkillListItem { id: string; companyId: string; + folderId?: string | null; key: string; slug: string; name: string; @@ -386,6 +388,7 @@ export interface CompanySkillCreateRequest { categories?: string[]; sharingScope?: CompanySkillSharingScope; forkedFromSkillId?: string | null; + folderId?: string | null; } export interface CompanySkillFileDetail { diff --git a/packages/shared/src/types/decision-training.ts b/packages/shared/src/types/decision-training.ts new file mode 100644 index 00000000000..c952b501786 --- /dev/null +++ b/packages/shared/src/types/decision-training.ts @@ -0,0 +1,48 @@ +export type DecisionTrainingSourceKind = "interaction" | "approval" | "execution_decision"; + +export interface DecisionTrainingNotesHistoryEntry { + author: string; + at: string; + body: string; +} + +export interface DecisionTrainingSnapshotV1 { + version: 1; + capturedAt: string; + cutoff: { + at: string; + lastCommentId: string | null; + commentCount: number; + }; + issue: Record; + comments: Array>; + runs: Array>; + decision: { + kind: DecisionTrainingSourceKind; + payload: Record; + actor: Record | null; + outcome: string | null; + }; + code: { + repoUrl: string | null; + ref: string | null; + commitSha: string | null; + resolution: "exact" | "nearest_run" | "none"; + }; +} + +export interface DecisionTrainingExample { + id: string; + companyId: string; + sourceKind: DecisionTrainingSourceKind; + sourceId: string; + issueId: string; + cutoffAt: string; + notes: string; + notesHistory: DecisionTrainingNotesHistoryEntry[]; + decisionOutcome: string | null; + snapshot: DecisionTrainingSnapshotV1; + createdByUserId: string; + createdAt: string; + updatedAt: string; +} diff --git a/packages/shared/src/types/folder.ts b/packages/shared/src/types/folder.ts new file mode 100644 index 00000000000..b5150d84c9b --- /dev/null +++ b/packages/shared/src/types/folder.ts @@ -0,0 +1,46 @@ +export type FolderKind = "routine" | "skill"; + +export interface Folder { + id: string; + companyId: string; + kind: FolderKind; + name: string; + color: string | null; + position: number; + createdAt: Date; + updatedAt: Date; +} + +export interface FolderListItem extends Folder { + itemCount: number; +} + +export interface FolderListResult { + kind: FolderKind; + folders: FolderListItem[]; + allCount: number; + unfiledCount: number; +} + +export interface CreateFolderRequest { + kind: FolderKind; + name: string; + color?: string | null; + position?: number | null; +} + +export interface UpdateFolderRequest { + name?: string; + color?: string | null; + position?: number; +} + +export interface MoveFolderRequest { + position: number; +} + +export interface MoveFolderItemRequest { + kind: FolderKind; + itemId: string; + folderId?: string | null; +} diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 74537a5a339..b987c7b7a20 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -13,6 +13,12 @@ export type { AttentionSubjectKind, AttentionWorkspaceRef, } from "./attention.js"; +export type { + DecisionTrainingExample, + DecisionTrainingNotesHistoryEntry, + DecisionTrainingSnapshotV1, + DecisionTrainingSourceKind, +} from "./decision-training.js"; export type { Environment, EnvironmentDeleteBlastRadius, @@ -163,6 +169,16 @@ export type { CompanySkillInstallCatalogRequest, CompanySkillInstallCatalogResult, } from "./company-skill.js"; +export type { + FolderKind, + Folder, + FolderListItem, + FolderListResult, + CreateFolderRequest, + UpdateFolderRequest, + MoveFolderRequest, + MoveFolderItemRequest, +} from "./folder.js"; export type { CatalogTeamKind, CatalogTeamTrustLevel, @@ -617,6 +633,22 @@ export type { IssueTreePreviewTotals, IssueTreePreviewWarning, } from "./issue-tree-control.js"; +export { + ISSUE_EXECUTION_HEALTH_STATES, + ISSUE_EXECUTION_HEALTH_REASON_CODES, +} from "./reliability.js"; +export type { + IssueExecutionHealthState, + IssueExecutionHealthReasonCode, + IssueExecutionHealthRunEvidence, + IssueExecutionHealthQueuedWakeEvidence, + IssueExecutionHealthInteractionEvidence, + IssueExecutionHealthApprovalEvidence, + IssueExecutionHealthRecoveryEvidence, + IssueExecutionHealthBlockerEvidence, + IssueExecutionHealthEvidence, + IssueExecutionHealthSummary, +} from "./reliability.js"; export type { Goal } from "./goal.js"; export type { Approval, ApprovalComment } from "./approval.js"; export type { diff --git a/packages/shared/src/types/reliability.ts b/packages/shared/src/types/reliability.ts new file mode 100644 index 00000000000..a60bb8f470b --- /dev/null +++ b/packages/shared/src/types/reliability.ts @@ -0,0 +1,93 @@ +export const ISSUE_EXECUTION_HEALTH_STATES = [ + "live_run", + "queued_wake", + "awaiting_interaction", + "awaiting_approval", + "awaiting_user", + "awaiting_review_participant", + "blocked_waiting", + "recovering", + "watchdog_review", + "degraded_runtime", + "no_action_path", + "invalid_state", +] as const; + +export type IssueExecutionHealthState = (typeof ISSUE_EXECUTION_HEALTH_STATES)[number]; + +export const ISSUE_EXECUTION_HEALTH_REASON_CODES = [ + "active_execution_run", + "queued_assignment_or_continuation", + "pending_issue_thread_interaction", + "pending_linked_approval", + "human_assignee_owns_next_action", + "execution_policy_participant_owns_next_action", + "unresolved_blocker_chain_covered", + "open_recovery_issue", + "silent_active_run_under_watchdog", + "agent_uninvokable", + "blocked_by_unassigned_issue", + "blocked_by_cancelled_issue", + "in_review_without_action_path", + "assigned_todo_without_dispatch_path", + "assigned_in_progress_without_execution_path", + "issue_terminal", +] as const; + +export type IssueExecutionHealthReasonCode = (typeof ISSUE_EXECUTION_HEALTH_REASON_CODES)[number]; + +export interface IssueExecutionHealthRunEvidence { + runId: string; + status: string; + livenessState: string | null; + livenessReason: string | null; + silenceLevel: "not_applicable" | "ok" | "suspicious" | "critical" | "snoozed" | null; +} + +export interface IssueExecutionHealthQueuedWakeEvidence { + wakeupRequestId: string; + reason: string | null; + status: string; +} + +export interface IssueExecutionHealthInteractionEvidence { + interactionId: string; + kind: string; + status: string; +} + +export interface IssueExecutionHealthApprovalEvidence { + approvalId: string; + status: string; +} + +export interface IssueExecutionHealthRecoveryEvidence { + recoveryIssueId: string; + recoveryIssueIdentifier: string | null; + originKind: string; +} + +export interface IssueExecutionHealthBlockerEvidence { + blockerIssueId: string; + blockerIssueIdentifier: string | null; + blockerStatus: string; +} + +export interface IssueExecutionHealthEvidence { + activeRun?: IssueExecutionHealthRunEvidence | null; + queuedWake?: IssueExecutionHealthQueuedWakeEvidence | null; + pendingInteraction?: IssueExecutionHealthInteractionEvidence | null; + pendingApproval?: IssueExecutionHealthApprovalEvidence | null; + recoveryIssue?: IssueExecutionHealthRecoveryEvidence | null; + blocker?: IssueExecutionHealthBlockerEvidence | null; + reviewParticipantStatus?: string | null; +} + +export interface IssueExecutionHealthSummary { + state: IssueExecutionHealthState; + reasonCode: IssueExecutionHealthReasonCode; + reason: string; + nextActionOwner: "assignee_agent" | "assignee_user" | "review_participant" | "blocker_owner" | "recovery_owner" | "system" | "none"; + evidence: IssueExecutionHealthEvidence; + evaluatedAt: string; +} diff --git a/packages/shared/src/types/routine.ts b/packages/shared/src/types/routine.ts index 571d7492ef4..bab97b6d4e8 100644 --- a/packages/shared/src/types/routine.ts +++ b/packages/shared/src/types/routine.ts @@ -71,6 +71,7 @@ export interface Routine { id: string; companyId: string; projectId: string | null; + folderId?: string | null; goalId: string | null; parentIssueId: string | null; title: string; diff --git a/packages/shared/src/validators/company-skill.ts b/packages/shared/src/validators/company-skill.ts index f458efaf3c5..5a79027724c 100644 --- a/packages/shared/src/validators/company-skill.ts +++ b/packages/shared/src/validators/company-skill.ts @@ -20,6 +20,7 @@ export const companySkillVersionFileInventoryEntrySchema = companySkillFileInven export const companySkillSchema = z.object({ id: z.string().uuid(), companyId: z.string().uuid(), + folderId: z.string().uuid().nullable().optional(), key: z.string().min(1), slug: z.string().min(1), name: z.string().min(1), diff --git a/packages/shared/src/validators/folder.ts b/packages/shared/src/validators/folder.ts new file mode 100644 index 00000000000..f425ccfde54 --- /dev/null +++ b/packages/shared/src/validators/folder.ts @@ -0,0 +1,55 @@ +import { z } from "zod"; + +export const folderKindSchema = z.enum(["routine", "skill"]); + +export const folderSchema = z.object({ + id: z.string().uuid(), + companyId: z.string().uuid(), + kind: folderKindSchema, + name: z.string().min(1), + color: z.string().nullable(), + position: z.number().int(), + createdAt: z.coerce.date(), + updatedAt: z.coerce.date(), +}); + +export const folderListItemSchema = folderSchema.extend({ + itemCount: z.number().int().nonnegative(), +}); + +export const folderListResultSchema = z.object({ + kind: folderKindSchema, + folders: z.array(folderListItemSchema), + allCount: z.number().int().nonnegative(), + unfiledCount: z.number().int().nonnegative(), +}); + +export const createFolderSchema = z.object({ + kind: folderKindSchema, + name: z.string().trim().min(1).max(120), + color: z.string().trim().min(1).max(80).optional().nullable(), + position: z.number().int().min(0).optional().nullable(), +}); + +export const updateFolderSchema = z.object({ + name: z.string().trim().min(1).max(120).optional(), + color: z.string().trim().min(1).max(80).optional().nullable(), + position: z.number().int().min(0).optional(), +}).refine((value) => Object.keys(value).length > 0, { + message: "At least one folder field is required", +}); + +export const moveFolderSchema = z.object({ + position: z.number().int().min(0), +}); + +export const moveFolderItemSchema = z.object({ + kind: folderKindSchema, + itemId: z.string().uuid(), + folderId: z.string().uuid().optional().nullable(), +}); + +export type CreateFolder = z.infer; +export type UpdateFolder = z.infer; +export type MoveFolder = z.infer; +export type MoveFolderItem = z.infer; diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index 58853af7a9b..e37c0142f3c 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -194,6 +194,20 @@ export { type CompanySkillInstallUpdate, type CompanySkillReset, } from "./company-skill.js"; +export { + folderKindSchema, + folderSchema, + folderListItemSchema, + folderListResultSchema, + createFolderSchema, + updateFolderSchema, + moveFolderSchema, + moveFolderItemSchema, + type CreateFolder, + type UpdateFolder, + type MoveFolder, + type MoveFolderItem, +} from "./folder.js"; export { catalogTeamKindSchema, catalogTeamTrustLevelSchema, diff --git a/packages/shared/src/validators/routine.ts b/packages/shared/src/validators/routine.ts index 56629340fd5..f0e4a92851b 100644 --- a/packages/shared/src/validators/routine.ts +++ b/packages/shared/src/validators/routine.ts @@ -61,6 +61,7 @@ export const routineVariableSchema = z.object({ export const createRoutineSchema = z.object({ projectId: z.string().uuid().optional().nullable(), + folderId: z.string().uuid().optional().nullable(), goalId: z.string().uuid().optional().nullable(), parentIssueId: z.string().uuid().optional().nullable(), title: z.string().trim().min(1).max(200), @@ -85,6 +86,7 @@ export const routineRevisionSnapshotRoutineV1Schema = z.object({ id: z.string().uuid(), companyId: z.string().uuid(), projectId: z.string().uuid().nullable(), + folderId: z.string().uuid().nullable().optional(), goalId: z.string().uuid().nullable(), parentIssueId: z.string().uuid().nullable(), title: z.string().trim().min(1).max(200), diff --git a/server/src/__tests__/attachment-types.test.ts b/server/src/__tests__/attachment-types.test.ts index a1508cf0172..73a2540a999 100644 --- a/server/src/__tests__/attachment-types.test.ts +++ b/server/src/__tests__/attachment-types.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from "vitest"; import { DEFAULT_ALLOWED_TYPES, + deriveDefaultAttachmentFilename, INLINE_ATTACHMENT_TYPES, isInlineAttachmentContentType, matchesContentType, @@ -110,6 +111,27 @@ describe("normalizeContentType", () => { }); }); +describe("deriveDefaultAttachmentFilename", () => { + it("maps common image content types to stable extensions", () => { + expect(deriveDefaultAttachmentFilename("image/png")).toBe("attachment.png"); + expect(deriveDefaultAttachmentFilename("image/jpeg")).toBe("attachment.jpg"); + expect(deriveDefaultAttachmentFilename("image/webp")).toBe("attachment.webp"); + expect(deriveDefaultAttachmentFilename("image/svg+xml")).toBe("attachment.svg"); + }); + + it("maps documents and text types to their natural extensions", () => { + expect(deriveDefaultAttachmentFilename("application/pdf")).toBe("attachment.pdf"); + expect(deriveDefaultAttachmentFilename("text/markdown")).toBe("attachment.md"); + expect(deriveDefaultAttachmentFilename("text/csv")).toBe("attachment.csv"); + expect(deriveDefaultAttachmentFilename("application/json")).toBe("attachment.json"); + }); + + it("falls back to attachment.bin for unknown or empty content types", () => { + expect(deriveDefaultAttachmentFilename("application/x-proprietary")).toBe("attachment.bin"); + expect(deriveDefaultAttachmentFilename("")).toBe("attachment.bin"); + }); +}); + describe("isInlineAttachmentContentType", () => { it("allows the configured inline-safe types", () => { for (const contentType of ["image/png", "image/svg+xml", "application/pdf", "text/plain", "video/mp4"]) { diff --git a/server/src/__tests__/decision-training.test.ts b/server/src/__tests__/decision-training.test.ts new file mode 100644 index 00000000000..ef60112be72 --- /dev/null +++ b/server/src/__tests__/decision-training.test.ts @@ -0,0 +1,212 @@ +import { randomUUID } from "node:crypto"; +import express from "express"; +import request from "supertest"; +import { eq } from "drizzle-orm"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { + activityLog, + companies, + createDb, + decisionTrainingExamples, + issueComments, + issues, + issueThreadInteractions, + projects, +} from "@paperclipai/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; +import { errorHandler } from "../middleware/index.js"; +import { decisionTrainingRoutes } from "../routes/decision-training.js"; +import { attentionService } from "../services/attention.js"; +import { captureDecisionSnapshot, decisionTrainingService } from "../services/decision-training.js"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres decision training tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} + +describeEmbeddedPostgres("decision training", () => { + let db!: ReturnType; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-decision-training-"); + db = createDb(tempDb.connectionString); + }, 30_000); + + afterEach(async () => { + await db.delete(activityLog); + await db.delete(decisionTrainingExamples); + await db.delete(issueThreadInteractions); + await db.delete(issueComments); + await db.delete(issues); + await db.delete(projects); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + async function seedResolvedInteraction() { + const companyId = randomUUID(); + const projectId = randomUUID(); + const issueId = randomUUID(); + const interactionId = randomUUID(); + const cutoffAt = new Date("2026-07-16T12:00:00.000Z"); + const beforeId = randomUUID(); + const atCutoffId = randomUUID(); + const afterId = randomUUID(); + + await db.insert(companies).values({ id: companyId, name: "Decision Co", issuePrefix: `D${companyId.slice(0, 4)}` }); + await db.insert(projects).values({ id: projectId, companyId, name: "Decisions" }); + await db.insert(issues).values({ + id: issueId, + companyId, + projectId, + identifier: "DEC-1", + title: "Choose a rollout strategy", + status: "in_review", + }); + await db.insert(issueThreadInteractions).values({ + id: interactionId, + companyId, + issueId, + kind: "request_confirmation", + status: "accepted", + payload: { question: "Ship it?" } as never, + result: { accepted: true } as never, + resolvedByUserId: "board-user", + resolvedAt: cutoffAt, + }); + await db.insert(issueComments).values([ + { id: beforeId, companyId, issueId, body: "Before", createdAt: new Date("2026-07-16T11:59:59.000Z") }, + { id: atCutoffId, companyId, issueId, body: "At cutoff", createdAt: cutoffAt }, + { id: afterId, companyId, issueId, body: "Leaked later context", createdAt: new Date("2026-07-16T12:00:01.000Z") }, + ]); + return { companyId, issueId, interactionId, cutoffAt, beforeId, atCutoffId, afterId }; + } + + it("includes the cutoff boundary and excludes later comments", async () => { + const seeded = await seedResolvedInteraction(); + const captured = await captureDecisionSnapshot(db, { + companyId: seeded.companyId, + sourceKind: "interaction", + sourceId: seeded.interactionId, + issueId: seeded.issueId, + }, new Date("2026-07-16T13:00:00.000Z")); + + expect(captured.cutoffAt).toEqual(seeded.cutoffAt); + expect(captured.snapshot.cutoff).toEqual({ + at: seeded.cutoffAt.toISOString(), + lastCommentId: seeded.atCutoffId, + commentCount: 2, + }); + expect(captured.snapshot.comments.map((comment) => comment.id)).toEqual([seeded.beforeId, seeded.atCutoffId]); + expect(JSON.stringify(captured.snapshot)).not.toContain("Leaked later context"); + }); + + it("enforces one example per decision and author", async () => { + const seeded = await seedResolvedInteraction(); + const svc = decisionTrainingService(db); + const input = { + companyId: seeded.companyId, + sourceKind: "interaction" as const, + sourceId: seeded.interactionId, + issueId: seeded.issueId, + notes: "Ship behind a flag.", + createdByUserId: "board-user", + }; + + const created = await svc.create(input); + await expect(svc.create(input)).rejects.toMatchObject({ status: 409 }); + const updated = await svc.updateNotes(created.id, "board-user", "Use a 10% canary first."); + expect(updated?.notesHistory).toEqual([ + expect.objectContaining({ author: "board-user", body: "Ship behind a flag." }), + ]); + expect(updated?.snapshot).toEqual(created.snapshot); + }); + + it("enriches attention items with the current user's training example", async () => { + const seeded = await seedResolvedInteraction(); + const example = await decisionTrainingService(db).create({ + companyId: seeded.companyId, + sourceKind: "interaction", + sourceId: seeded.interactionId, + issueId: seeded.issueId, + notes: "Captured guidance.", + createdByUserId: "board-user", + }); + await db + .update(issueThreadInteractions) + .set({ status: "pending", resolvedAt: null, updatedAt: new Date() }) + .where(eq(issueThreadInteractions.id, seeded.interactionId)); + + const feed = await attentionService(db).list(seeded.companyId, { userId: "board-user" }); + const item = feed.items.find((candidate) => candidate.subject.id === seeded.interactionId); + expect(item?.trainingExampleId).toBe(example.id); + }); + + it("rejects agent writes and snapshot mutation", async () => { + const seeded = await seedResolvedInteraction(); + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.actor = { + type: "agent", + agentId: randomUUID(), + companyId: seeded.companyId, + source: "agent_jwt", + }; + next(); + }); + app.use("/api", decisionTrainingRoutes(db)); + app.use(errorHandler); + + await request(app) + .post(`/api/companies/${seeded.companyId}/decision-training`) + .send({ sourceKind: "interaction", sourceId: seeded.interactionId, issueId: seeded.issueId, notes: "No" }) + .expect(403); + + await request(app) + .patch(`/api/decision-training/${randomUUID()}`) + .send({ notes: "Changed", snapshot: { version: 2 } }) + .expect(400); + }); + + it("exports immutable state and labels as JSONL", async () => { + const seeded = await seedResolvedInteraction(); + const example = await decisionTrainingService(db).create({ + companyId: seeded.companyId, + sourceKind: "interaction", + sourceId: seeded.interactionId, + issueId: seeded.issueId, + notes: "Use a feature flag.", + createdByUserId: "board-user", + }); + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.actor = { type: "board", userId: "board-user", source: "local_implicit" }; + next(); + }); + app.use("/api", decisionTrainingRoutes(db)); + app.use(errorHandler); + + const response = await request(app) + .get(`/api/companies/${seeded.companyId}/decision-training/export.jsonl`) + .expect(200); + const line = JSON.parse(response.text.trim()); + expect(line).toEqual({ + state: example.snapshot, + label: { outcome: "accepted", notes: "Use a feature flag." }, + }); + expect(response.text).not.toContain("Leaked later context"); + }); +}); diff --git a/server/src/__tests__/environment-selection-route-guards.test.ts b/server/src/__tests__/environment-selection-route-guards.test.ts index 468afa571f3..0bed552b3b3 100644 --- a/server/src/__tests__/environment-selection-route-guards.test.ts +++ b/server/src/__tests__/environment-selection-route-guards.test.ts @@ -57,6 +57,7 @@ vi.mock("../services/index.js", () => ({ issueService: () => mockIssueService, companyService: () => mockCompanyService, environmentService: () => mockEnvironmentService, + issueExecutionHealthService: () => ({ summarize: async () => null }), issueReferenceService: () => mockIssueReferenceService, logActivity: mockLogActivity, workspaceOperationService: () => ({}), diff --git a/server/src/__tests__/folders-routes.test.ts b/server/src/__tests__/folders-routes.test.ts new file mode 100644 index 00000000000..f0eff113f03 --- /dev/null +++ b/server/src/__tests__/folders-routes.test.ts @@ -0,0 +1,74 @@ +import express from "express"; +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockFolderService = vi.hoisted(() => ({ + list: vi.fn(), + create: vi.fn(), + update: vi.fn(), + moveFolder: vi.fn(), + moveItem: vi.fn(), + deleteFolder: vi.fn(), +})); + +const mockLogActivity = vi.hoisted(() => vi.fn()); + +vi.mock("../services/index.js", () => ({ + folderService: () => mockFolderService, + logActivity: mockLogActivity, +})); + +async function createApp() { + vi.resetModules(); + const [{ errorHandler }, { folderRoutes }] = await Promise.all([ + import("../middleware/index.js") as Promise, + import("../routes/folders.js") as Promise, + ]); + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as any).actor = { + type: "board", + userId: "user-1", + companyIds: ["company-1"], + source: "session", + isInstanceAdmin: false, + }; + next(); + }); + app.use("/api", folderRoutes({} as any)); + app.use(errorHandler); + return app; +} + +describe("folder routes", () => { + beforeEach(() => { + for (const mock of Object.values(mockFolderService)) mock.mockReset(); + mockLogActivity.mockReset(); + }); + + it("routes item moves to the item move handler before the folder reorder route", async () => { + mockFolderService.moveItem.mockResolvedValue({ + kind: "routine", + itemId: "11111111-1111-4111-8111-111111111111", + folderId: "22222222-2222-4222-8222-222222222222", + }); + + const app = await createApp(); + const res = await request(app) + .post("/api/companies/company-1/folders/items/move") + .send({ + kind: "routine", + itemId: "11111111-1111-4111-8111-111111111111", + folderId: "22222222-2222-4222-8222-222222222222", + }); + + expect(res.status).toBe(200); + expect(mockFolderService.moveItem).toHaveBeenCalledWith("company-1", { + kind: "routine", + itemId: "11111111-1111-4111-8111-111111111111", + folderId: "22222222-2222-4222-8222-222222222222", + }); + expect(mockFolderService.moveFolder).not.toHaveBeenCalled(); + }); +}); diff --git a/server/src/__tests__/folders-service.test.ts b/server/src/__tests__/folders-service.test.ts new file mode 100644 index 00000000000..adeecbbd95c --- /dev/null +++ b/server/src/__tests__/folders-service.test.ts @@ -0,0 +1,172 @@ +import { randomUUID } from "node:crypto"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { eq } from "drizzle-orm"; +import { + companies, + companySkills, + createDb, + folders, + routines, +} from "@paperclipai/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; +import { folderService } from "../services/folders.js"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +describeEmbeddedPostgres("folder service", () => { + let db!: ReturnType; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-folders-"); + db = createDb(tempDb.connectionString); + }, 20_000); + + afterEach(async () => { + await db.delete(companySkills); + await db.delete(routines); + await db.delete(folders); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + async function seedCompany() { + const companyId = randomUUID(); + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + defaultResponsibleUserId: "responsible-user", + }); + return companyId; + } + + async function seedRoutine(companyId: string, title: string, folderId?: string | null) { + const [routine] = await db + .insert(routines) + .values({ + companyId, + title, + folderId: folderId ?? null, + responsibleUserId: "responsible-user", + }) + .returning(); + return routine!; + } + + async function seedSkill(companyId: string, slug: string, folderId?: string | null) { + const [skill] = await db + .insert(companySkills) + .values({ + companyId, + folderId: folderId ?? null, + key: `company/${companyId}/${slug}`, + slug, + name: slug, + markdown: `# ${slug}`, + }) + .returning(); + return skill!; + } + + it("creates, updates, reorders, and lists routine folders with counts", async () => { + const companyId = await seedCompany(); + const svc = folderService(db); + + const reporting = await svc.create(companyId, { + kind: "routine", + name: "Reporting", + color: "green", + }); + const cleanup = await svc.create(companyId, { + kind: "routine", + name: "Cleanup", + color: null, + }); + await seedRoutine(companyId, "Filed", reporting.id); + await seedRoutine(companyId, "Unfiled"); + + const renamed = await svc.update(companyId, cleanup.id, { name: "Ops", color: "cyan" }); + expect(renamed).toMatchObject({ id: cleanup.id, name: "Ops", color: "cyan" }); + + const movedFolder = await svc.moveFolder(companyId, reporting.id, { position: 10 }); + expect(movedFolder).toMatchObject({ id: reporting.id, position: 10 }); + + const listed = await svc.list(companyId, "routine"); + expect(listed.allCount).toBe(2); + expect(listed.unfiledCount).toBe(1); + expect(listed.folders).toEqual([ + expect.objectContaining({ id: cleanup.id, name: "Ops", itemCount: 0 }), + expect.objectContaining({ id: reporting.id, name: "Reporting", itemCount: 1 }), + ]); + }); + + it("moves routines and skills to folders and back to virtual Unfiled", async () => { + const companyId = await seedCompany(); + const svc = folderService(db); + const routineFolder = await svc.create(companyId, { kind: "routine", name: "Reports" }); + const skillFolder = await svc.create(companyId, { kind: "skill", name: "Runtime" }); + const routine = await seedRoutine(companyId, "Daily report"); + const skill = await seedSkill(companyId, "review"); + + await expect(svc.moveItem(companyId, { + kind: "routine", + itemId: routine.id, + folderId: routineFolder.id, + })).resolves.toEqual({ kind: "routine", itemId: routine.id, folderId: routineFolder.id }); + await expect(svc.moveItem(companyId, { + kind: "skill", + itemId: skill.id, + folderId: skillFolder.id, + })).resolves.toEqual({ kind: "skill", itemId: skill.id, folderId: skillFolder.id }); + + await expect(svc.moveItem(companyId, { + kind: "routine", + itemId: routine.id, + folderId: null, + })).resolves.toEqual({ kind: "routine", itemId: routine.id, folderId: null }); + + const [updatedRoutine] = await db.select().from(routines).where(eq(routines.id, routine.id)); + const [updatedSkill] = await db.select().from(companySkills).where(eq(companySkills.id, skill.id)); + expect(updatedRoutine?.folderId).toBeNull(); + expect(updatedSkill?.folderId).toBe(skillFolder.id); + }); + + it("rejects moving an item into a folder of the wrong kind", async () => { + const companyId = await seedCompany(); + const svc = folderService(db); + const skillFolder = await svc.create(companyId, { kind: "skill", name: "Runtime" }); + const routine = await seedRoutine(companyId, "Daily report"); + + await expect(svc.moveItem(companyId, { + kind: "routine", + itemId: routine.id, + folderId: skillFolder.id, + })).rejects.toMatchObject({ + status: 422, + message: "Folder kind must match item kind", + }); + }); + + it("deletes folders without deleting contents by moving items to Unfiled", async () => { + const companyId = await seedCompany(); + const svc = folderService(db); + const folder = await svc.create(companyId, { kind: "routine", name: "Reports" }); + const routine = await seedRoutine(companyId, "Daily report", folder.id); + + const deleted = await svc.deleteFolder(companyId, folder.id); + expect(deleted).toMatchObject({ id: folder.id, name: "Reports" }); + + const [updatedRoutine] = await db.select().from(routines).where(eq(routines.id, routine.id)); + expect(updatedRoutine?.folderId).toBeNull(); + expect(await db.select().from(folders).where(eq(folders.id, folder.id))).toHaveLength(0); + }); +}); diff --git a/server/src/__tests__/heartbeat-process-recovery.test.ts b/server/src/__tests__/heartbeat-process-recovery.test.ts index 61cc20ab1b8..71e0edb9909 100644 --- a/server/src/__tests__/heartbeat-process-recovery.test.ts +++ b/server/src/__tests__/heartbeat-process-recovery.test.ts @@ -1499,8 +1499,10 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { await withTempPaperclipHome(async (home) => { const heartbeat = heartbeatService(db); + const previousServerPid = process.pid; + const newServerPid = previousServerPid + 1; await writeHotRestartIntent({ - previousServerPid: process.pid, + previousServerPid, previousServerVersion: "old-version", requestedAt: new Date("2026-03-19T00:05:00.000Z"), }); @@ -1509,49 +1511,54 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { new Date("2026-03-19T00:06:00.000Z"), ); - const adoption = await heartbeat.reconcileHotRestartAdoption( - new Date("2026-03-19T00:07:00.000Z"), - ); - expect(adoption).toMatchObject({ - mode: "reported", - adoptedRunIds: [runId], - finalizedWhileDownRunIds: [], - lostRunIds: [], - skippedRunIds: [], - }); + Object.defineProperty(process, "pid", { value: newServerPid }); + try { + const adoption = await heartbeat.reconcileHotRestartAdoption( + new Date("2026-03-19T00:07:00.000Z"), + ); + expect(adoption).toMatchObject({ + mode: "reported", + adoptedRunIds: [runId], + finalizedWhileDownRunIds: [], + lostRunIds: [], + skippedRunIds: [], + }); - const report = JSON.parse( - await fs.readFile(resolveHotRestartReportPath(home), "utf8"), - ) as Record; - expect(report).toMatchObject({ - previousServerPid: process.pid, - newServerPid: process.pid, - previousServerVersion: "old-version", - adoptedRunIds: [runId], - finalizedWhileDownRunIds: [], - lostRunIds: [], - }); - expect(typeof report.newServerVersion).toBe("string"); - - const reap = await heartbeat.reapOrphanedRuns(); - expect(reap).toEqual({ reaped: 0, runIds: [] }); - const adopted = await db - .select() - .from(heartbeatRuns) - .where(eq(heartbeatRuns.id, runId)) - .then((rows) => rows[0] ?? null); - expect(adopted?.status).toBe("running"); - expect(adopted?.errorCode).not.toBe("process_lost"); - expect(adopted?.resultJson).toMatchObject({ - hotRestart: { - adopted: true, - adoptedAt: "2026-03-19T00:07:00.000Z", - previousServerPid: process.pid, - newServerPid: process.pid, + const report = JSON.parse( + await fs.readFile(resolveHotRestartReportPath(home), "utf8"), + ) as Record; + expect(report).toMatchObject({ + previousServerPid, + newServerPid, previousServerVersion: "old-version", - processPid: child.pid, - }, - }); + adoptedRunIds: [runId], + finalizedWhileDownRunIds: [], + lostRunIds: [], + }); + expect(typeof report.newServerVersion).toBe("string"); + + const reap = await heartbeat.reapOrphanedRuns(); + expect(reap).toEqual({ reaped: 0, runIds: [] }); + const adopted = await db + .select() + .from(heartbeatRuns) + .where(eq(heartbeatRuns.id, runId)) + .then((rows) => rows[0] ?? null); + expect(adopted?.status).toBe("running"); + expect(adopted?.errorCode).not.toBe("process_lost"); + expect(adopted?.resultJson).toMatchObject({ + hotRestart: { + adopted: true, + adoptedAt: "2026-03-19T00:07:00.000Z", + previousServerPid, + newServerPid, + previousServerVersion: "old-version", + processPid: child.pid, + }, + }); + } finally { + Object.defineProperty(process, "pid", { value: previousServerPid }); + } }); }); diff --git a/server/src/__tests__/issue-activity-events-routes.test.ts b/server/src/__tests__/issue-activity-events-routes.test.ts index 550acc57c40..65f71c139ee 100644 --- a/server/src/__tests__/issue-activity-events-routes.test.ts +++ b/server/src/__tests__/issue-activity-events-routes.test.ts @@ -85,6 +85,7 @@ function registerModuleMocks() { companySkillService: () => ({ completeTestRunForIssue: vi.fn(async () => null), }), + issueExecutionHealthService: () => ({ summarize: async () => null }), documentAnnotationService: () => ({ remapOpenThreadsForDocument: async () => [] }), documentService: () => ({}), executionWorkspaceService: () => ({}), diff --git a/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts b/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts index 1a5dd416135..4e6cc088eee 100644 --- a/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts +++ b/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts @@ -119,6 +119,7 @@ const mockExternalObjectService = vi.hoisted(() => ({ getProjectSummary: vi.fn(async () => ({ authRequiredCount: 0, byLiveness: {}, + issueExecutionHealthService: () => ({ summarize: async () => null }), byStatusCategory: {}, highestSeverity: "muted", objects: [], diff --git a/server/src/__tests__/issue-attachment-routes.test.ts b/server/src/__tests__/issue-attachment-routes.test.ts index 648f2b4fc9c..b59899f5223 100644 --- a/server/src/__tests__/issue-attachment-routes.test.ts +++ b/server/src/__tests__/issue-attachment-routes.test.ts @@ -81,6 +81,7 @@ function registerRouteMocks() { listCompanyIds: vi.fn(async () => ["company-1"]), }), issueApprovalService: () => ({}), + issueExecutionHealthService: () => ({ summarize: async () => null }), issueReferenceService: () => ({ deleteDocumentSource: async () => undefined, diffIssueReferenceSummary: () => ({ diff --git a/server/src/__tests__/issue-closed-workspace-routes.test.ts b/server/src/__tests__/issue-closed-workspace-routes.test.ts index d9c82cd7d05..9b7956bb28b 100644 --- a/server/src/__tests__/issue-closed-workspace-routes.test.ts +++ b/server/src/__tests__/issue-closed-workspace-routes.test.ts @@ -107,6 +107,7 @@ function registerServiceMocks() { listCompanyIds: vi.fn(async () => ["company-1"]), }), issueApprovalService: () => ({}), + issueExecutionHealthService: () => ({ summarize: async () => null }), issueReferenceService: () => ({ deleteDocumentSource: async () => undefined, diffIssueReferenceSummary: () => ({ diff --git a/server/src/__tests__/issue-comment-cancel-routes.test.ts b/server/src/__tests__/issue-comment-cancel-routes.test.ts index a85e648c3a2..ebadaab9bcd 100644 --- a/server/src/__tests__/issue-comment-cancel-routes.test.ts +++ b/server/src/__tests__/issue-comment-cancel-routes.test.ts @@ -83,6 +83,7 @@ function registerModuleMocks() { })); vi.doMock("../telemetry.js", () => ({ + issueExecutionHealthService: () => ({ summarize: async () => null }), getTelemetryClient: vi.fn(() => ({ track: vi.fn() })), })); diff --git a/server/src/__tests__/issue-comment-reopen-routes.test.ts b/server/src/__tests__/issue-comment-reopen-routes.test.ts index 3a0b0907480..20ad28ecd81 100644 --- a/server/src/__tests__/issue-comment-reopen-routes.test.ts +++ b/server/src/__tests__/issue-comment-reopen-routes.test.ts @@ -117,6 +117,7 @@ vi.mock("../services/instance-settings.js", () => ({ instanceSettingsService: () => mockInstanceSettingsService, })); + issueExecutionHealthService: () => ({ summarize: async () => null }), vi.mock("../services/issues.js", () => ({ issueService: () => mockIssueService, })); diff --git a/server/src/__tests__/issue-create-deduplication-routes.test.ts b/server/src/__tests__/issue-create-deduplication-routes.test.ts index 3958a6dd286..9a7701cba59 100644 --- a/server/src/__tests__/issue-create-deduplication-routes.test.ts +++ b/server/src/__tests__/issue-create-deduplication-routes.test.ts @@ -19,7 +19,10 @@ import { import { actorMiddleware } from "../middleware/auth.js"; import { errorHandler } from "../middleware/index.js"; import { issueRoutes } from "../routes/issues.js"; -import { issueService } from "../services/issues.js"; +import { + ISSUE_CREATE_IDEMPOTENCY_KEY_RETENTION_DAYS, + issueService, +} from "../services/issues.js"; const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; @@ -112,6 +115,45 @@ describeEmbeddedPostgres("issue create deduplication routes", () => { expect(await db.select().from(issueCreateIdempotencyKeys)).toHaveLength(1); }); + it("expires old idempotency keys before replay lookup", async () => { + const companyId = await seedCompany(); + const parent = await seedParent(companyId); + const app = createApp(); + const oldIssueId = randomUUID(); + const idempotencyKey = "run-1:expired-retry"; + const expiredCreatedAt = new Date( + Date.now() - (ISSUE_CREATE_IDEMPOTENCY_KEY_RETENTION_DAYS + 1) * 24 * 60 * 60 * 1000, + ); + await db.insert(issues).values({ + id: oldIssueId, + companyId, + parentId: parent.id, + title: "Expired retry target", + status: "todo", + priority: "medium", + }); + await db.insert(issueCreateIdempotencyKeys).values({ + companyId, + idempotencyKey, + issueId: oldIssueId, + createdAt: expiredCreatedAt, + }); + + const recreated = await request(app) + .post(`/api/companies/${companyId}/issues`) + .send({ parentId: parent.id, title: "Expired retry creates new work", idempotencyKey }) + .expect(201); + + const rows = await db.select().from(issueCreateIdempotencyKeys); + expect(recreated.body.id).not.toBe(oldIssueId); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + companyId, + idempotencyKey, + issueId: recreated.body.id, + }); + }); + it("returns a recent open sibling whose normalized title matches", async () => { const companyId = await seedCompany(); const parent = await seedParent(companyId); diff --git a/server/src/__tests__/issue-dependency-wakeups-routes.test.ts b/server/src/__tests__/issue-dependency-wakeups-routes.test.ts index da27484dbf7..4dfdfe19695 100644 --- a/server/src/__tests__/issue-dependency-wakeups-routes.test.ts +++ b/server/src/__tests__/issue-dependency-wakeups-routes.test.ts @@ -54,6 +54,7 @@ vi.mock("../services/index.js", () => ({ listCompanyIds: vi.fn(), }), issueApprovalService: () => ({}), + issueExecutionHealthService: () => ({ summarize: async () => null }), issueReferenceService: () => ({ deleteDocumentSource: async () => undefined, diffIssueReferenceSummary: () => ({ diff --git a/server/src/__tests__/issue-document-restore-routes.test.ts b/server/src/__tests__/issue-document-restore-routes.test.ts index 7b528f084cf..a04c5429db4 100644 --- a/server/src/__tests__/issue-document-restore-routes.test.ts +++ b/server/src/__tests__/issue-document-restore-routes.test.ts @@ -126,6 +126,7 @@ function registerModuleMocks() { heartbeatService: () => mockHeartbeatService, instanceSettingsService: () => mockInstanceSettingsService, issueApprovalService: () => ({}), + issueExecutionHealthService: () => ({ summarize: async () => null }), issueReferenceService: () => ({ deleteDocumentSource: async () => undefined, diffIssueReferenceSummary: () => ({ diff --git a/server/src/__tests__/issue-execution-health-routes.test.ts b/server/src/__tests__/issue-execution-health-routes.test.ts new file mode 100644 index 00000000000..62154a3827f --- /dev/null +++ b/server/src/__tests__/issue-execution-health-routes.test.ts @@ -0,0 +1,254 @@ +import express from "express"; +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { errorHandler } from "../middleware/index.js"; +import { issueRoutes } from "../routes/issues.js"; + +const COMPANY_ID = "company-1"; +const ISSUE_ID = "11111111-1111-4111-8111-111111111111"; + +const summary = { + state: "live_run", + reasonCode: "active_execution_run", + reason: "Active heartbeat run run-1 is running.", + nextActionOwner: "assignee_agent", + evidence: { + activeRun: { + runId: "run-1", + status: "running", + livenessState: null, + livenessReason: null, + silenceLevel: "ok", + }, + }, + evaluatedAt: "2026-04-27T15:00:00.000Z", +}; + +const mockIssueService = vi.hoisted(() => ({ + getById: vi.fn(), + getAncestors: vi.fn(async () => []), + getRelationSummaries: vi.fn(async () => ({ blockedBy: [], blocks: [] })), + findMentionedProjectIds: vi.fn(async () => []), + getCommentCursor: vi.fn(async () => ({ totalComments: 0, latestCommentId: null, latestCommentAt: null })), + getComment: vi.fn(async () => null), + listBlockerAttention: vi.fn(async () => new Map()), + listProductivityReviews: vi.fn(async () => new Map()), + getCurrentScheduledRetry: vi.fn(async () => null), + getActiveInboxArchiveFields: vi.fn(async () => ({})), + listAttachments: vi.fn(async () => []), +})); + +const mockExecutionHealthService = vi.hoisted(() => ({ + summarize: vi.fn(), +})); + +const mockExecutionHealthFactory = vi.hoisted(() => () => mockExecutionHealthService); + +const mockProjectService = vi.hoisted(() => ({ + getById: vi.fn(async () => null), + listByIds: vi.fn(async () => []), +})); + +const mockGoalService = vi.hoisted(() => ({ + getById: vi.fn(async () => null), + getDefaultCompanyGoal: vi.fn(async () => null), +})); + +const mockDocumentsService = vi.hoisted(() => ({ + getIssueDocumentPayload: vi.fn(async () => ({})), + getIssueDocumentByKey: vi.fn(async () => null), +})); + +const mockExecutionWorkspaceService = vi.hoisted(() => ({ + getById: vi.fn(async () => null), +})); + +const mockRecoveryActionService = vi.hoisted(() => ({ + getActiveForIssue: vi.fn(async () => null), + listActiveForIssues: vi.fn(async () => new Map()), + revalidateActiveSourceRecovery: vi.fn(async () => null), +})); + +const mockAccessService = vi.hoisted(() => ({ + decide: vi.fn(async () => ({ allowed: true })), + canUser: vi.fn(async () => true), + hasPermission: vi.fn(async () => true), +})); + +const mockAgentService = vi.hoisted(() => ({ + getById: vi.fn(async () => null), +})); + +const mockFeedbackService = vi.hoisted(() => ({ + listIssueVotesForUser: vi.fn(async () => []), + saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })), +})); + +const mockHeartbeatService = vi.hoisted(() => ({ + wakeup: vi.fn(async () => undefined), + reportRunActivity: vi.fn(async () => undefined), +})); + +const mockInstanceSettingsService = vi.hoisted(() => ({ + get: vi.fn(async () => ({ + id: "instance-settings-1", + general: { censorUsernameInLogs: false, feedbackDataSharingPreference: "prompt" }, + })), + listCompanyIds: vi.fn(async () => [COMPANY_ID]), +})); + +const mockIssueReferenceService = vi.hoisted(() => ({ + deleteDocumentSource: vi.fn(async () => undefined), + diffIssueReferenceSummary: vi.fn(() => ({ + addedReferencedIssues: [], + removedReferencedIssues: [], + currentReferencedIssues: [], + })), + emptySummary: vi.fn(() => ({ outbound: [], inbound: [] })), + listIssueReferenceSummary: vi.fn(async () => ({ outbound: [], inbound: [] })), + syncComment: vi.fn(async () => undefined), + syncDocument: vi.fn(async () => undefined), + syncIssue: vi.fn(async () => undefined), +})); + +const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined)); + +const mockRoutineService = vi.hoisted(() => ({ + syncRunStatusForIssue: vi.fn(async () => undefined), +})); + +const mockWorkProductService = vi.hoisted(() => ({ + listForIssue: vi.fn(async () => []), +})); + +vi.mock("../services/index.js", async (importOriginal) => ({ + ...(await importOriginal()), + accessService: () => mockAccessService, + agentService: () => mockAgentService, + documentService: () => mockDocumentsService, + environmentService: () => ({}), + executionWorkspaceService: () => mockExecutionWorkspaceService, + feedbackService: () => mockFeedbackService, + goalService: () => mockGoalService, + heartbeatService: () => mockHeartbeatService, + instanceSettingsService: () => mockInstanceSettingsService, + issueApprovalService: () => ({}), + issueExecutionHealthService: mockExecutionHealthFactory, + issueRecoveryActionService: () => mockRecoveryActionService, + issueReferenceService: () => mockIssueReferenceService, + issueService: () => mockIssueService, + logActivity: mockLogActivity, + projectService: () => mockProjectService, + routineService: () => mockRoutineService, + workProductService: () => mockWorkProductService, +})); + +vi.mock("../services/execution-workspaces.js", () => ({ + executionWorkspaceService: () => mockExecutionWorkspaceService, +})); + +const issueFixture = { + id: ISSUE_ID, + companyId: COMPANY_ID, + identifier: "PAP-2487", + title: "Phase 1 health read model", + description: null, + status: "in_progress", + priority: "medium", + projectId: null, + goalId: null, + parentId: null, + assigneeAgentId: "33333333-3333-4333-8333-333333333333", + assigneeUserId: null, + executionRunId: null, + executionPolicy: null, + executionState: null, + executionWorkspaceId: null, + labels: [], + labelIds: [], + updatedAt: new Date("2026-04-27T15:00:00.000Z"), +}; + +function createApp(actor: Record = { + type: "board", + userId: "local-board", + companyIds: [COMPANY_ID], + source: "local_implicit", + isInstanceAdmin: false, +}) { + const query = { + from: vi.fn(() => query), + innerJoin: vi.fn(() => query), + where: vi.fn(() => query), + orderBy: vi.fn(() => query), + then: (resolve: (value: unknown[]) => unknown) => Promise.resolve([]).then(resolve), + }; + const db = { select: vi.fn(() => query) }; + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as any).actor = actor; + next(); + }); + app.use("/api", issueRoutes(db as any, {} as any)); + app.use(errorHandler); + return app; +} + +describe.sequential("issue execution health on issue routes", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockIssueService.getById.mockResolvedValue(issueFixture); + mockExecutionHealthService.summarize.mockResolvedValue(summary); + }); + + it("includes executionHealth on GET /issues/:id", async () => { + const res = await request(createApp()).get(`/api/issues/${ISSUE_ID}`); + + expect(res.status).toBe(200); + expect(res.body.executionHealth).toEqual(summary); + expect(mockExecutionHealthService.summarize).toHaveBeenCalledWith( + expect.objectContaining({ + id: ISSUE_ID, + companyId: COMPANY_ID, + identifier: "PAP-2487", + status: "in_progress", + }), + ); + }); + + it("includes executionHealth on GET /issues/:id/heartbeat-context", async () => { + const res = await request(createApp()).get(`/api/issues/${ISSUE_ID}/heartbeat-context`); + + expect(res.status).toBe(200); + expect(res.body.executionHealth).toEqual(summary); + }); + + it("denies cross-company access on GET /issues/:id", async () => { + const otherActor = { + type: "board", + userId: "other-board", + companyIds: ["other-company"], + source: "session_cookie", + isInstanceAdmin: false, + }; + const res = await request(createApp(otherActor)).get(`/api/issues/${ISSUE_ID}`); + + expect(res.status).toBeGreaterThanOrEqual(400); + expect(mockExecutionHealthService.summarize).not.toHaveBeenCalled(); + }); + + it("denies cross-company access on GET /issues/:id/heartbeat-context", async () => { + const otherActor = { + type: "board", + userId: "other-board", + companyIds: ["other-company"], + source: "session_cookie", + isInstanceAdmin: false, + }; + const res = await request(createApp(otherActor)).get(`/api/issues/${ISSUE_ID}/heartbeat-context`); + + expect(res.status).toBeGreaterThanOrEqual(400); + expect(mockExecutionHealthService.summarize).not.toHaveBeenCalled(); + }); +}); diff --git a/server/src/__tests__/issue-execution-health.test.ts b/server/src/__tests__/issue-execution-health.test.ts new file mode 100644 index 00000000000..e647190386e --- /dev/null +++ b/server/src/__tests__/issue-execution-health.test.ts @@ -0,0 +1,377 @@ +import { describe, expect, it } from "vitest"; +import { + ISSUE_EXECUTION_HEALTH_REASON_CODES, + ISSUE_EXECUTION_HEALTH_STATES, + type IssueExecutionHealthReasonCode, + type IssueExecutionHealthState, +} from "@paperclipai/shared"; +import { + classifyIssueExecutionHealth, + ISSUE_EXECUTION_HEALTH_CRITICAL_THRESHOLD_MS, + ISSUE_EXECUTION_HEALTH_SUSPICION_THRESHOLD_MS, + type IssueExecutionHealthClassifyInput, + type IssueExecutionHealthIssueInput, + type IssueExecutionHealthRunInput, +} from "../services/issue-execution-health.ts"; + +const NOW = new Date("2026-04-27T15:00:00.000Z"); + +function issue(overrides: Partial = {}): IssueExecutionHealthIssueInput { + return { + id: "issue-1", + companyId: "company-1", + identifier: "PAP-1", + title: "Sample issue", + status: "in_progress", + assigneeAgentId: "agent-1", + assigneeUserId: null, + executionRunId: null, + executionPolicy: null, + executionState: null, + ...overrides, + }; +} + +function run(overrides: Partial = {}): IssueExecutionHealthRunInput { + return { + id: "run-1", + status: "running", + livenessState: null, + livenessReason: null, + lastOutputAt: new Date(NOW.getTime() - 5_000), + processStartedAt: new Date(NOW.getTime() - 10_000), + startedAt: new Date(NOW.getTime() - 10_000), + createdAt: new Date(NOW.getTime() - 10_000), + silenceSnoozedUntil: null, + ...overrides, + }; +} + +function classifyInput(overrides: Partial = {}): IssueExecutionHealthClassifyInput { + return { + issue: overrides.issue ?? issue(), + activeRun: overrides.activeRun ?? null, + queuedWakes: overrides.queuedWakes ?? [], + pendingInteractions: overrides.pendingInteractions ?? [], + pendingApprovals: overrides.pendingApprovals ?? [], + openRecoveryIssues: overrides.openRecoveryIssues ?? [], + blockers: overrides.blockers ?? [], + assigneeAgentStatus: overrides.assigneeAgentStatus ?? "active", + now: overrides.now ?? NOW, + }; +} + +describe("classifyIssueExecutionHealth", () => { + type TableCase = { + name: string; + input: IssueExecutionHealthClassifyInput; + state: IssueExecutionHealthState; + reasonCode: IssueExecutionHealthReasonCode; + nextActionOwner?: string; + }; + + const cases: TableCase[] = [ + { + name: "active run reports live_run / active_execution_run", + input: classifyInput({ activeRun: run() }), + state: "live_run", + reasonCode: "active_execution_run", + nextActionOwner: "assignee_agent", + }, + { + name: "active run silent past critical threshold reports watchdog_review", + input: classifyInput({ + activeRun: run({ + lastOutputAt: new Date(NOW.getTime() - ISSUE_EXECUTION_HEALTH_CRITICAL_THRESHOLD_MS - 60_000), + processStartedAt: new Date(NOW.getTime() - ISSUE_EXECUTION_HEALTH_CRITICAL_THRESHOLD_MS - 60_000), + }), + }), + state: "watchdog_review", + reasonCode: "silent_active_run_under_watchdog", + nextActionOwner: "system", + }, + { + name: "active run silent past suspicion threshold but not critical stays live_run with suspicious silence level", + input: classifyInput({ + activeRun: run({ + lastOutputAt: new Date(NOW.getTime() - ISSUE_EXECUTION_HEALTH_SUSPICION_THRESHOLD_MS - 60_000), + processStartedAt: new Date(NOW.getTime() - ISSUE_EXECUTION_HEALTH_SUSPICION_THRESHOLD_MS - 60_000), + }), + }), + state: "live_run", + reasonCode: "active_execution_run", + }, + { + name: "active run with active snooze ignores silence level", + input: classifyInput({ + activeRun: run({ + lastOutputAt: new Date(NOW.getTime() - ISSUE_EXECUTION_HEALTH_CRITICAL_THRESHOLD_MS - 60_000), + processStartedAt: new Date(NOW.getTime() - ISSUE_EXECUTION_HEALTH_CRITICAL_THRESHOLD_MS - 60_000), + silenceSnoozedUntil: new Date(NOW.getTime() + 60_000), + }), + }), + state: "live_run", + reasonCode: "active_execution_run", + }, + { + name: "open recovery issue reports recovering", + input: classifyInput({ + openRecoveryIssues: [{ id: "recovery-1", identifier: "PAP-99", originKind: "harness_liveness_escalation", status: "todo" }], + }), + state: "recovering", + reasonCode: "open_recovery_issue", + nextActionOwner: "recovery_owner", + }, + { + name: "pending request_confirmation interaction reports awaiting_interaction", + input: classifyInput({ + pendingInteractions: [{ id: "interaction-1", kind: "request_confirmation", status: "pending" }], + }), + state: "awaiting_interaction", + reasonCode: "pending_issue_thread_interaction", + nextActionOwner: "assignee_user", + }, + { + name: "pending linked approval reports awaiting_approval", + input: classifyInput({ + pendingApprovals: [{ id: "approval-1", status: "pending" }], + }), + state: "awaiting_approval", + reasonCode: "pending_linked_approval", + nextActionOwner: "system", + }, + { + name: "in_review with execution-policy agent participant reports awaiting_review_participant", + input: classifyInput({ + issue: issue({ + status: "in_review", + executionState: { currentParticipant: { type: "agent", agentId: "agent-2" } }, + }), + }), + state: "awaiting_review_participant", + reasonCode: "execution_policy_participant_owns_next_action", + nextActionOwner: "review_participant", + }, + { + name: "in_review with human assignee reports awaiting_user", + input: classifyInput({ + issue: issue({ status: "in_review", assigneeAgentId: null, assigneeUserId: "user-1" }), + }), + state: "awaiting_user", + reasonCode: "human_assignee_owns_next_action", + nextActionOwner: "assignee_user", + }, + { + name: "in_review with agent assignee but no participant or user reports invalid_state", + input: classifyInput({ + issue: issue({ status: "in_review" }), + }), + state: "invalid_state", + reasonCode: "in_review_without_action_path", + nextActionOwner: "none", + }, + { + name: "human assignee owns next action when not in review", + input: classifyInput({ + issue: issue({ assigneeAgentId: null, assigneeUserId: "user-1" }), + }), + state: "awaiting_user", + reasonCode: "human_assignee_owns_next_action", + nextActionOwner: "assignee_user", + }, + { + name: "blocked issue with active blocker leaf reports blocked_waiting", + input: classifyInput({ + issue: issue({ status: "blocked" }), + blockers: [ + { + id: "blocker-1", + identifier: "PAP-2", + status: "in_progress", + assigneeAgentId: "agent-2", + assigneeAgentStatus: "active", + assigneeUserId: null, + }, + ], + }), + state: "blocked_waiting", + reasonCode: "unresolved_blocker_chain_covered", + nextActionOwner: "blocker_owner", + }, + { + name: "blocked issue with cancelled blocker reports invalid_state / blocked_by_cancelled_issue", + input: classifyInput({ + issue: issue({ status: "blocked" }), + blockers: [ + { + id: "blocker-2", + identifier: "PAP-3", + status: "cancelled", + assigneeAgentId: null, + assigneeAgentStatus: null, + assigneeUserId: null, + }, + ], + }), + state: "invalid_state", + reasonCode: "blocked_by_cancelled_issue", + }, + { + name: "blocked issue with unassigned blocker reports invalid_state / blocked_by_unassigned_issue", + input: classifyInput({ + issue: issue({ status: "blocked" }), + blockers: [ + { + id: "blocker-3", + identifier: "PAP-4", + status: "todo", + assigneeAgentId: null, + assigneeAgentStatus: null, + assigneeUserId: null, + }, + ], + }), + state: "invalid_state", + reasonCode: "blocked_by_unassigned_issue", + }, + { + name: "blocked issue with uninvokable blocker assignee reports invalid_state / agent_uninvokable", + input: classifyInput({ + issue: issue({ status: "blocked" }), + blockers: [ + { + id: "blocker-4", + identifier: "PAP-5", + status: "todo", + assigneeAgentId: "agent-paused", + assigneeAgentStatus: "paused", + assigneeUserId: null, + }, + ], + }), + state: "invalid_state", + reasonCode: "agent_uninvokable", + }, + { + name: "blocked issue with no unresolved blockers reports invalid_state", + input: classifyInput({ + issue: issue({ status: "blocked" }), + blockers: [ + { + id: "blocker-5", + identifier: "PAP-6", + status: "done", + assigneeAgentId: "agent-2", + assigneeAgentStatus: "active", + assigneeUserId: null, + }, + ], + }), + state: "invalid_state", + reasonCode: "blocked_by_unassigned_issue", + }, + { + name: "queued wake reports queued_wake", + input: classifyInput({ + queuedWakes: [{ id: "wake-1", reason: "issue_assigned", status: "queued" }], + }), + state: "queued_wake", + reasonCode: "queued_assignment_or_continuation", + nextActionOwner: "assignee_agent", + }, + { + name: "agent-assigned todo with no live run / wake / recovery reports no_action_path / assigned_todo_without_dispatch_path", + input: classifyInput({ + issue: issue({ status: "todo" }), + }), + state: "no_action_path", + reasonCode: "assigned_todo_without_dispatch_path", + }, + { + name: "agent-assigned in_progress with no live run / wake / recovery reports no_action_path / assigned_in_progress_without_execution_path", + input: classifyInput({}), + state: "no_action_path", + reasonCode: "assigned_in_progress_without_execution_path", + }, + { + name: "agent-assigned in_progress with paused agent reports invalid_state / agent_uninvokable", + input: classifyInput({ assigneeAgentStatus: "paused" }), + state: "invalid_state", + reasonCode: "agent_uninvokable", + }, + { + name: "completed issue reports no_action_path / issue_terminal", + input: classifyInput({ issue: issue({ status: "done" }) }), + state: "no_action_path", + reasonCode: "issue_terminal", + nextActionOwner: "none", + }, + { + name: "cancelled issue reports no_action_path / issue_terminal", + input: classifyInput({ issue: issue({ status: "cancelled" }) }), + state: "no_action_path", + reasonCode: "issue_terminal", + nextActionOwner: "none", + }, + ]; + + for (const tc of cases) { + it(tc.name, () => { + const result = classifyIssueExecutionHealth(tc.input); + expect(result.state).toBe(tc.state); + expect(result.reasonCode).toBe(tc.reasonCode); + if (tc.nextActionOwner !== undefined) { + expect(result.nextActionOwner).toBe(tc.nextActionOwner); + } + expect(result.evaluatedAt).toBe((tc.input.now ?? NOW).toISOString()); + expect(typeof result.reason).toBe("string"); + expect(result.reason.length).toBeGreaterThan(0); + }); + } + + it("active run takes precedence over queued wakes, interactions, and approvals", () => { + const result = classifyIssueExecutionHealth( + classifyInput({ + activeRun: run(), + queuedWakes: [{ id: "wake-1", reason: null, status: "queued" }], + pendingInteractions: [{ id: "interaction-1", kind: "ask_user_questions", status: "pending" }], + pendingApprovals: [{ id: "approval-1", status: "pending" }], + }), + ); + expect(result.state).toBe("live_run"); + expect(result.evidence.activeRun?.runId).toBe("run-1"); + }); + + it("recovery issue takes precedence over interactions and approvals", () => { + const result = classifyIssueExecutionHealth( + classifyInput({ + openRecoveryIssues: [ + { id: "recovery-1", identifier: "PAP-90", originKind: "harness_liveness_escalation", status: "todo" }, + ], + pendingInteractions: [{ id: "interaction-1", kind: "ask_user_questions", status: "pending" }], + pendingApprovals: [{ id: "approval-1", status: "pending" }], + }), + ); + expect(result.state).toBe("recovering"); + expect(result.evidence.recoveryIssue?.recoveryIssueId).toBe("recovery-1"); + }); + + it("interaction takes precedence over approval", () => { + const result = classifyIssueExecutionHealth( + classifyInput({ + pendingInteractions: [{ id: "interaction-1", kind: "request_confirmation", status: "pending" }], + pendingApprovals: [{ id: "approval-1", status: "pending" }], + }), + ); + expect(result.state).toBe("awaiting_interaction"); + expect(result.evidence.pendingInteraction?.interactionId).toBe("interaction-1"); + }); + + it("emits a stable summary with evaluatedAt set from now", () => { + const result = classifyIssueExecutionHealth(classifyInput()); + expect(ISSUE_EXECUTION_HEALTH_STATES).toContain(result.state); + expect(ISSUE_EXECUTION_HEALTH_REASON_CODES).toContain(result.reasonCode); + expect(result.evaluatedAt).toBe(NOW.toISOString()); + expect(result.evidence).toBeDefined(); + }); +}); diff --git a/server/src/__tests__/issue-execution-policy-routes.test.ts b/server/src/__tests__/issue-execution-policy-routes.test.ts index 2c2e4946320..c9309614a6d 100644 --- a/server/src/__tests__/issue-execution-policy-routes.test.ts +++ b/server/src/__tests__/issue-execution-policy-routes.test.ts @@ -50,6 +50,7 @@ const mockIssueThreadInteractionService = vi.hoisted(() => ({ expireRequestConfirmationsSupersededByComment: vi.fn(async () => []), })); const mockIssueApprovalService = vi.hoisted(() => ({ + issueExecutionHealthService: () => ({ summarize: async () => null }), listApprovalsForIssue: vi.fn(async () => []), })); diff --git a/server/src/__tests__/issue-feedback-routes.test.ts b/server/src/__tests__/issue-feedback-routes.test.ts index 727b36f7f27..b5c3e4e61ef 100644 --- a/server/src/__tests__/issue-feedback-routes.test.ts +++ b/server/src/__tests__/issue-feedback-routes.test.ts @@ -96,6 +96,7 @@ function registerModuleMocks() { goalService: () => ({}), heartbeatService: () => mockHeartbeatService, issueApprovalService: () => ({}), + issueExecutionHealthService: () => ({ summarize: async () => null }), issueReferenceService: () => mockIssueReferenceService, issueRecoveryActionService: () => ({ getActiveForIssue: vi.fn(async () => null), diff --git a/server/src/__tests__/issue-telemetry-routes.test.ts b/server/src/__tests__/issue-telemetry-routes.test.ts index 55a105ee243..32aa21740c4 100644 --- a/server/src/__tests__/issue-telemetry-routes.test.ts +++ b/server/src/__tests__/issue-telemetry-routes.test.ts @@ -64,6 +64,7 @@ function registerModuleMocks() { }), instanceSettingsService: () => ({}), issueApprovalService: () => ({}), + issueExecutionHealthService: () => ({ summarize: async () => null }), issueReferenceService: () => ({ deleteDocumentSource: async () => undefined, diffIssueReferenceSummary: () => ({ diff --git a/server/src/__tests__/issue-thread-interaction-routes.test.ts b/server/src/__tests__/issue-thread-interaction-routes.test.ts index 70577f6e722..7bf6a180e99 100644 --- a/server/src/__tests__/issue-thread-interaction-routes.test.ts +++ b/server/src/__tests__/issue-thread-interaction-routes.test.ts @@ -97,6 +97,7 @@ function registerModuleMocks() { listCompanyIds: vi.fn(async () => ["company-1"]), }), issueApprovalService: () => ({}), + issueExecutionHealthService: () => ({ summarize: async () => null }), issueReferenceService: () => ({ deleteDocumentSource: async () => undefined, diffIssueReferenceSummary: () => ({ diff --git a/server/src/__tests__/issue-update-comment-wakeup-routes.test.ts b/server/src/__tests__/issue-update-comment-wakeup-routes.test.ts index 4fb46c0dc30..088ae323884 100644 --- a/server/src/__tests__/issue-update-comment-wakeup-routes.test.ts +++ b/server/src/__tests__/issue-update-comment-wakeup-routes.test.ts @@ -73,6 +73,7 @@ vi.mock("../services/index.js", () => ({ listCompanyIds: vi.fn(async () => ["company-1"]), }), issueApprovalService: () => ({}), + issueExecutionHealthService: () => ({ summarize: async () => null }), issueReferenceService: () => ({ deleteDocumentSource: async () => undefined, diffIssueReferenceSummary: () => ({ @@ -145,6 +146,7 @@ function registerModuleMocks() { listCompanyIds: vi.fn(async () => ["company-1"]), }), issueApprovalService: () => ({}), + issueExecutionHealthService: () => ({ summarize: async () => null }), issueReferenceService: () => ({ deleteDocumentSource: async () => undefined, diffIssueReferenceSummary: () => ({ diff --git a/server/src/__tests__/issue-workspace-command-authz.test.ts b/server/src/__tests__/issue-workspace-command-authz.test.ts index 1240af41c38..9ea15847298 100644 --- a/server/src/__tests__/issue-workspace-command-authz.test.ts +++ b/server/src/__tests__/issue-workspace-command-authz.test.ts @@ -120,6 +120,7 @@ function registerRouteMocks() { heartbeatService: () => mockHeartbeatService, instanceSettingsService: () => mockInstanceSettingsService, issueApprovalService: () => ({}), + issueExecutionHealthService: () => ({ summarize: async () => null }), issueReferenceService: () => ({ deleteDocumentSource: async () => undefined, diffIssueReferenceSummary: () => ({ diff --git a/server/src/__tests__/issues-goal-context-routes.test.ts b/server/src/__tests__/issues-goal-context-routes.test.ts index 14a011886b4..824537e08d7 100644 --- a/server/src/__tests__/issues-goal-context-routes.test.ts +++ b/server/src/__tests__/issues-goal-context-routes.test.ts @@ -101,6 +101,7 @@ const mockDb = vi.hoisted(() => ({ vi.mock("../services/index.js", () => ({ companyService: () => ({ getById: vi.fn(async () => ({ id: "company-1", attachmentMaxBytes: 10 * 1024 * 1024 })), + issueExecutionHealthService: () => ({ summarize: async () => null }), }), accessService: () => mockAccessService, agentService: () => mockAgentService, diff --git a/server/src/__tests__/routines-service.test.ts b/server/src/__tests__/routines-service.test.ts index 9dccbabc0ca..480cc5b6b07 100644 --- a/server/src/__tests__/routines-service.test.ts +++ b/server/src/__tests__/routines-service.test.ts @@ -1619,6 +1619,46 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => { }); }); + it("reuses paused API routine runs with the same idempotency key", async () => { + const { companyId, agentId, projectId, svc } = await seedFixture(); + const draftRoutine = await svc.create( + companyId, + { + projectId, + goalId: null, + parentIssueId: null, + title: "idempotent draft dispatch", + description: "Create one execution issue", + assigneeAgentId: agentId, + priority: "medium", + status: "paused", + concurrencyPolicy: "always_enqueue", + catchUpPolicy: "skip_missed", + }, + {}, + ); + + const first = await svc.runRoutine(draftRoutine.id, { + source: "api", + idempotencyKey: "branch:pap-14223:commit:abc123", + }); + const replay = await svc.runRoutine(draftRoutine.id, { + source: "api", + idempotencyKey: "branch:pap-14223:commit:abc123", + }); + + expect(replay.id).toBe(first.id); + expect(replay.linkedIssueId).toBe(first.linkedIssueId); + expect(first.status).toBe("issue_created"); + + const routineIssues = await db + .select({ id: issues.id }) + .from(issues) + .where(eq(issues.originId, draftRoutine.id)); + + expect(routineIssues).toHaveLength(1); + }); + it("rejects enabling automation for routines without a default agent", async () => { const { companyId, svc } = await seedFixture(); const draftRoutine = await svc.create( diff --git a/server/src/app.ts b/server/src/app.ts index 68663296a5b..caab6a974a8 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -16,6 +16,7 @@ import { companyRoutes } from "./routes/companies.js"; import { companySkillRoutes } from "./routes/company-skills.js"; import { companySkillPolicyRoutes } from "./routes/company-skill-policy.js"; import { builtInAgentRoutes } from "./routes/built-in-agents.js"; +import { folderRoutes } from "./routes/folders.js"; import { teamsCatalogRoutes } from "./routes/teams-catalog.js"; import { agentRoutes } from "./routes/agents.js"; import { projectRoutes } from "./routes/projects.js"; @@ -37,6 +38,7 @@ import { costRoutes } from "./routes/costs.js"; import { activityRoutes } from "./routes/activity.js"; import { dashboardRoutes } from "./routes/dashboard.js"; import { attentionRoutes } from "./routes/attention.js"; +import { decisionTrainingRoutes } from "./routes/decision-training.js"; import { userProfileRoutes } from "./routes/user-profiles.js"; import { sidebarBadgeRoutes } from "./routes/sidebar-badges.js"; import { sidebarPreferenceRoutes } from "./routes/sidebar-preferences.js"; @@ -235,6 +237,7 @@ export async function createApp( api.use(openApiRoutes()); api.use("/companies", companyRoutes(db, opts.storageService)); api.use(llmRoutes(db)); + api.use(folderRoutes(db)); api.use(companySkillRoutes(db)); api.use(companySkillPolicyRoutes(db)); api.use(builtInAgentRoutes(db)); @@ -261,6 +264,7 @@ export async function createApp( api.use(activityRoutes(db)); api.use(dashboardRoutes(db)); api.use(attentionRoutes(db)); + api.use(decisionTrainingRoutes(db)); api.use(userProfileRoutes(db)); api.use(sidebarBadgeRoutes(db)); api.use(sidebarPreferenceRoutes(db)); diff --git a/server/src/attachment-types.ts b/server/src/attachment-types.ts index 304caf45a3f..831d495d4f6 100644 --- a/server/src/attachment-types.ts +++ b/server/src/attachment-types.ts @@ -112,3 +112,24 @@ export function normalizeIssueAttachmentMaxBytes(value: number | null | undefine } return Math.min(Math.floor(value), MAX_COMPANY_ATTACHMENT_MAX_BYTES, MAX_ATTACHMENT_BYTES); } + +const ATTACHMENT_CONTENT_TYPE_EXTENSIONS: Record = { + "image/png": "png", + "image/jpeg": "jpg", + "image/jpg": "jpg", + "image/webp": "webp", + "image/gif": "gif", + "image/svg+xml": "svg", + "application/pdf": "pdf", + "text/markdown": "md", + "text/plain": "txt", + "application/json": "json", + "text/csv": "csv", + "text/html": "html", + "application/zip": "zip", +}; + +export function deriveDefaultAttachmentFilename(contentType: string): string { + const extension = ATTACHMENT_CONTENT_TYPE_EXTENSIONS[normalizeContentType(contentType)] ?? "bin"; + return `attachment.${extension}`; +} diff --git a/server/src/routes/decision-training.ts b/server/src/routes/decision-training.ts new file mode 100644 index 00000000000..367e0502a9a --- /dev/null +++ b/server/src/routes/decision-training.ts @@ -0,0 +1,168 @@ +import { Router, type Request, type Response } from "express"; +import { z } from "zod"; +import type { Db } from "@paperclipai/db"; +import { validate } from "../middleware/validate.js"; +import { decisionTrainingService, logActivity } from "../services/index.js"; +import { assertCompanyAccess, getActorInfo, hasCompanyAccess } from "./authz.js"; + +const sourceKindSchema = z.enum(["interaction", "approval", "execution_decision"]); +const createSchema = z.object({ + sourceKind: sourceKindSchema, + sourceId: z.string().uuid(), + issueId: z.string().uuid(), + notes: z.string().max(100_000).default(""), +}).strict(); +const updateSchema = z.object({ notes: z.string().max(100_000) }).strict(); + +function requireHumanUser(req: Request, res: Response) { + if (req.actor.type !== "board") { + res.status(403).json({ error: "Decision training writes require a human user" }); + return null; + } + if (!req.actor.userId) { + res.status(403).json({ error: "Board user context required" }); + return null; + } + return req.actor.userId; +} + +export function decisionTrainingRoutes(db: Db) { + const router = Router(); + const svc = decisionTrainingService(db); + + router.post( + "/companies/:companyId/decision-training", + validate(createSchema), + async (req, res) => { + const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); + const userId = requireHumanUser(req, res); + if (!userId) return; + + const example = await svc.create({ + companyId, + sourceKind: req.body.sourceKind, + sourceId: req.body.sourceId, + issueId: req.body.issueId, + notes: req.body.notes, + createdByUserId: userId, + }); + const actor = getActorInfo(req); + await logActivity(db, { + companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "decision_training.created", + entityType: "decision_training_example", + entityId: example.id, + details: { sourceKind: example.sourceKind, sourceId: example.sourceId, issueId: example.issueId }, + }); + res.status(201).json(example); + }, + ); + + router.get("/companies/:companyId/decision-training", async (req, res) => { + const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); + const parsed = z.object({ + project: z.string().uuid().optional(), + kind: sourceKindSchema.optional(), + author: z.string().optional(), + q: z.string().trim().max(500).optional(), + }).safeParse(req.query); + if (!parsed.success) { + res.status(400).json({ error: "Invalid decision training query", details: parsed.error.flatten() }); + return; + } + res.json(await svc.list(companyId, { + projectId: parsed.data.project, + kind: parsed.data.kind, + author: parsed.data.author, + q: parsed.data.q, + })); + }); + + router.get("/companies/:companyId/decision-training/export.jsonl", async (req, res) => { + const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); + const rows = await svc.list(companyId); + const body = rows + .map(({ example }) => JSON.stringify({ + state: example.snapshot, + label: { outcome: example.decisionOutcome, notes: example.notes }, + })) + .join("\n"); + res.type("application/x-ndjson").send(body ? `${body}\n` : ""); + }); + + router.get("/decision-training/:id", async (req, res) => { + const example = await svc.getById(req.params.id as string); + if (!example || !hasCompanyAccess(req, example.companyId)) { + res.status(404).json({ error: "Decision training example not found" }); + return; + } + res.json(example); + }); + + router.patch("/decision-training/:id", validate(updateSchema), async (req, res) => { + const existing = await svc.getById(req.params.id as string); + if (!existing || !hasCompanyAccess(req, existing.companyId)) { + res.status(404).json({ error: "Decision training example not found" }); + return; + } + assertCompanyAccess(req, existing.companyId); + const userId = requireHumanUser(req, res); + if (!userId) return; + const updated = await svc.updateNotes(existing.id, userId, req.body.notes); + if (!updated) { + res.status(404).json({ error: "Decision training example not found" }); + return; + } + const actor = getActorInfo(req); + await logActivity(db, { + companyId: existing.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "decision_training.notes_updated", + entityType: "decision_training_example", + entityId: updated.id, + details: { issueId: updated.issueId }, + }); + res.json(updated); + }); + + router.delete("/decision-training/:id", async (req, res) => { + const existing = await svc.getById(req.params.id as string); + if (!existing || !hasCompanyAccess(req, existing.companyId)) { + res.status(404).json({ error: "Decision training example not found" }); + return; + } + assertCompanyAccess(req, existing.companyId); + const userId = requireHumanUser(req, res); + if (!userId) return; + const deleted = await svc.delete(existing.id); + if (deleted.length === 0) { + res.status(404).json({ error: "Decision training example not found" }); + return; + } + const actor = getActorInfo(req); + await logActivity(db, { + companyId: existing.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "decision_training.deleted", + entityType: "decision_training_example", + entityId: existing.id, + details: { issueId: existing.issueId, deletedByUserId: userId }, + }); + res.status(204).send(); + }); + + return router; +} diff --git a/server/src/routes/folders.ts b/server/src/routes/folders.ts new file mode 100644 index 00000000000..9d01421fd82 --- /dev/null +++ b/server/src/routes/folders.ts @@ -0,0 +1,142 @@ +import { Router } from "express"; +import type { Db } from "@paperclipai/db"; +import { + createFolderSchema, + folderKindSchema, + moveFolderItemSchema, + moveFolderSchema, + updateFolderSchema, +} from "@paperclipai/shared"; +import { validate } from "../middleware/validate.js"; +import { badRequest } from "../errors.js"; +import { folderService, logActivity } from "../services/index.js"; +import { assertCompanyAccess, getActorInfo } from "./authz.js"; + +export function folderRoutes(db: Db) { + const router = Router(); + const svc = folderService(db); + + function parseKind(value: unknown) { + const result = folderKindSchema.safeParse(value); + if (!result.success) throw badRequest("Folder kind query parameter is required"); + return result.data; + } + + router.get("/companies/:companyId/folders", async (req, res) => { + const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); + res.json(await svc.list(companyId, parseKind(req.query.kind))); + }); + + router.post("/companies/:companyId/folders", validate(createFolderSchema), async (req, res) => { + const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); + const created = await svc.create(companyId, req.body); + const actor = getActorInfo(req); + await logActivity(db, { + companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "folder.created", + entityType: "folder", + entityId: created.id, + details: { kind: created.kind, name: created.name, position: created.position }, + }); + res.status(201).json(created); + }); + + router.patch("/companies/:companyId/folders/:folderId", validate(updateFolderSchema), async (req, res) => { + const companyId = req.params.companyId as string; + const folderId = req.params.folderId as string; + assertCompanyAccess(req, companyId); + const updated = await svc.update(companyId, folderId, req.body); + if (!updated) { + res.status(404).json({ error: "Folder not found" }); + return; + } + const actor = getActorInfo(req); + await logActivity(db, { + companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "folder.updated", + entityType: "folder", + entityId: updated.id, + details: { kind: updated.kind, name: updated.name, position: updated.position }, + }); + res.json(updated); + }); + + router.post("/companies/:companyId/folders/items/move", validate(moveFolderItemSchema), async (req, res) => { + const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); + const moved = await svc.moveItem(companyId, req.body); + const actor = getActorInfo(req); + await logActivity(db, { + companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "folder.item_moved", + entityType: req.body.kind === "routine" ? "routine" : "company_skill", + entityId: moved.itemId, + details: { kind: moved.kind, folderId: moved.folderId }, + }); + res.json(moved); + }); + + router.post("/companies/:companyId/folders/:folderId/move", validate(moveFolderSchema), async (req, res) => { + const companyId = req.params.companyId as string; + const folderId = req.params.folderId as string; + assertCompanyAccess(req, companyId); + const updated = await svc.moveFolder(companyId, folderId, req.body); + if (!updated) { + res.status(404).json({ error: "Folder not found" }); + return; + } + const actor = getActorInfo(req); + await logActivity(db, { + companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "folder.moved", + entityType: "folder", + entityId: updated.id, + details: { kind: updated.kind, position: updated.position }, + }); + res.json(updated); + }); + + router.delete("/companies/:companyId/folders/:folderId", async (req, res) => { + const companyId = req.params.companyId as string; + const folderId = req.params.folderId as string; + assertCompanyAccess(req, companyId); + const deleted = await svc.deleteFolder(companyId, folderId); + if (!deleted) { + res.status(404).json({ error: "Folder not found" }); + return; + } + const actor = getActorInfo(req); + await logActivity(db, { + companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "folder.deleted", + entityType: "folder", + entityId: deleted.id, + details: { kind: deleted.kind, name: deleted.name }, + }); + res.json({ deleted }); + }); + + return router; +} diff --git a/server/src/routes/index.ts b/server/src/routes/index.ts index 5c04a1c1046..08b28f8897c 100644 --- a/server/src/routes/index.ts +++ b/server/src/routes/index.ts @@ -3,6 +3,7 @@ export { companyRoutes } from "./companies.js"; export { companySkillRoutes } from "./company-skills.js"; export { companySkillPolicyRoutes } from "./company-skill-policy.js"; export { builtInAgentRoutes } from "./built-in-agents.js"; +export { folderRoutes } from "./folders.js"; export { teamsCatalogRoutes } from "./teams-catalog.js"; export { agentRoutes } from "./agents.js"; export { projectRoutes } from "./projects.js"; diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index ea8443e8c5e..9d1fc60eabc 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -135,6 +135,7 @@ import { } from "./workspace-command-authz.js"; import { shouldWakeAssigneeOnCheckout } from "./issues-checkout-wakeup.js"; import { + deriveDefaultAttachmentFilename, isInlineAttachmentContentType, normalizeIssueAttachmentMaxBytes, normalizeContentType, @@ -2592,6 +2593,9 @@ export function issueRoutes( const companySkillsSvc = companySkillService(db); const documentAnnotationsSvc = documentAnnotationService(db); const issueReferencesSvc = issueReferenceService(db); + const executionHealthSvc = typeof serviceIndex.issueExecutionHealthService === "function" + ? serviceIndex.issueExecutionHealthService(db) + : { summarize: async () => null }; const issueThreadInteractionsSvc = issueThreadInteractionService(db); const taskWatchdogFactory: TaskWatchdogServiceFactory | undefined = Object.prototype.hasOwnProperty.call( serviceIndex, @@ -5068,6 +5072,7 @@ export function issueRoutes( continuationSummary, currentExecutionWorkspace, activeRecoveryAction, + executionHealth, ] = await Promise.all([ resolveIssueProjectAndGoal(issue), @@ -5082,6 +5087,7 @@ export function issueRoutes( documentsSvc.getIssueDocumentByKey(issue.id, ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY), currentExecutionWorkspacePromise, recoveryActionsSvc.getActiveForIssue(issue.companyId, issue.id), + executionHealthSvc.summarize(issue), ]); const recoveryActionsByRelationIssue = await relationRecoveryActionMap( recoveryActionsSvc, @@ -5188,6 +5194,7 @@ export function issueRoutes( : null, planReviewContext, currentExecutionWorkspace, + executionHealth, }); }); @@ -5322,6 +5329,9 @@ export function issueRoutes( const issue = await getAccessibleResource(req, res, svc.getById(id), "Issue not found"); if (!issue) return; if (!(await assertIssueReadAllowed(req, res, issue))) return; + const inboxArchiveFieldsPromise = req.actor.type === "board" && req.actor.userId + ? svc.getActiveInboxArchiveFields(issue, req.actor.userId) + : Promise.resolve({}); const [ { project, goal }, ancestors, @@ -5335,6 +5345,8 @@ export function issueRoutes( scheduledRetry, activeRecoveryAction, linkedCases, + executionHealth, + inboxArchiveFields, ] = await Promise.all([ resolveIssueProjectAndGoal(issue), svc.getAncestors(issue.id), @@ -5348,6 +5360,8 @@ export function issueRoutes( svc.getCurrentScheduledRetry(issue.id), recoveryActionsSvc.getActiveForIssue(issue.companyId, issue.id), listIssueLinkedCases(db, issue.companyId, issue.id), + executionHealthSvc.summarize(issue), + inboxArchiveFieldsPromise, ]); const recoveryActionsByRelationIssue = await relationRecoveryActionMap( recoveryActionsSvc, @@ -5373,6 +5387,7 @@ export function issueRoutes( const workProducts = await workProductsSvc.listForIssue(issue.id); res.json({ ...issue, + ...inboxArchiveFields, goalId: goal?.id ?? issue.goalId, ancestors, ...(blockerAttention ? { blockerAttention } : {}), @@ -5391,6 +5406,7 @@ export function issueRoutes( currentExecutionWorkspace: compactIssueExecutionWorkspace(currentExecutionWorkspace), workProducts, linkedCases, + executionHealth, }); }); @@ -10306,10 +10322,14 @@ export function issueRoutes( } const actor = getActorInfo(req); + const providedFilename = typeof file.originalname === "string" ? file.originalname.trim() : ""; + const originalFilename = providedFilename.length > 0 + ? providedFilename + : deriveDefaultAttachmentFilename(contentType); const stored = await storage.putFile({ companyId, namespace: `issues/${issueId}`, - originalFilename: file.originalname || null, + originalFilename, contentType, body: file.buffer, }); diff --git a/server/src/services/attention.ts b/server/src/services/attention.ts index 203a263cf89..fc58cf3e428 100644 --- a/server/src/services/attention.ts +++ b/server/src/services/attention.ts @@ -5,6 +5,7 @@ import { approvals, assets, companies, + decisionTrainingExamples, heartbeatRunEvents, heartbeatRuns, inboxDismissals, @@ -310,7 +311,7 @@ function decisionVerbs(...verbs: AttentionDecisionVerb[]): AttentionDecisionVerb return verbs; } -type CreateAttentionItemInput = Omit & { +type CreateAttentionItemInput = Omit & { project?: AttentionProjectRef | null; workspace?: AttentionWorkspaceRef | null; detail?: AttentionItemDetail | null; @@ -325,6 +326,7 @@ function createItem(input: CreateAttentionItemInput): AttentionItem { project: input.project ?? null, workspace: input.workspace ?? null, detail: input.detail ?? null, + trainingExampleId: null, rank: 0, }; } @@ -1238,6 +1240,42 @@ export function attentionService(db: Db) { const items = [...deduped.values()] .sort(compareAttentionItems) .map((item, index) => ({ ...item, rank: index + 1 })); + if (options.userId) { + const trainable: Array<{ sourceKind: "approval" | "interaction"; sourceId: string }> = []; + for (const item of items) { + if (item.sourceKind === "approval") { + trainable.push({ sourceKind: "approval", sourceId: item.subject.id }); + } + if (item.sourceKind === "issue_thread_interaction") { + trainable.push({ sourceKind: "interaction", sourceId: item.subject.id }); + } + } + if (trainable.length > 0) { + const examples = await db + .select({ + id: decisionTrainingExamples.id, + sourceKind: decisionTrainingExamples.sourceKind, + sourceId: decisionTrainingExamples.sourceId, + }) + .from(decisionTrainingExamples) + .where(and( + eq(decisionTrainingExamples.companyId, companyId), + eq(decisionTrainingExamples.createdByUserId, options.userId), + inArray(decisionTrainingExamples.sourceId, trainable.map((item) => item.sourceId)), + )); + const exampleBySource = new Map(examples.map((row) => [`${row.sourceKind}:${row.sourceId}`, row.id])); + for (const item of items) { + const sourceKind = item.sourceKind === "approval" + ? "approval" + : item.sourceKind === "issue_thread_interaction" + ? "interaction" + : null; + item.trainingExampleId = sourceKind + ? exampleBySource.get(`${sourceKind}:${item.subject.id}`) ?? null + : null; + } + } + } const countsBySourceKind = emptyCounts(); for (const item of items) countsBySourceKind[item.sourceKind] += 1; diff --git a/server/src/services/company-skills.ts b/server/src/services/company-skills.ts index da6049b256d..6273a4f46f1 100644 --- a/server/src/services/company-skills.ts +++ b/server/src/services/company-skills.ts @@ -119,6 +119,7 @@ type CompanySkillListDbRow = Pick< CompanySkillRow, | "id" | "companyId" + | "folderId" | "key" | "slug" | "name" @@ -151,6 +152,7 @@ type CompanySkillListRow = Pick< CompanySkill, | "id" | "companyId" + | "folderId" | "key" | "slug" | "name" @@ -376,6 +378,7 @@ function selectCompanySkillColumns() { return { id: companySkills.id, companyId: companySkills.companyId, + folderId: companySkills.folderId, key: companySkills.key, slug: companySkills.slug, name: companySkills.name, @@ -2643,6 +2646,7 @@ function toCompanySkillListItem(skill: CompanySkillListRow, attachedAgentCount: return { id: skill.id, companyId: skill.companyId, + folderId: skill.folderId, key: skill.key, slug: skill.slug, name: skill.name, @@ -2908,6 +2912,7 @@ export function companySkillService(db: Db) { .select({ id: companySkills.id, companyId: companySkills.companyId, + folderId: companySkills.folderId, key: companySkills.key, slug: companySkills.slug, name: companySkills.name, diff --git a/server/src/services/decision-training.ts b/server/src/services/decision-training.ts new file mode 100644 index 00000000000..5e8bd4d5ed4 --- /dev/null +++ b/server/src/services/decision-training.ts @@ -0,0 +1,321 @@ +import { + and, + asc, + desc, + eq, + ilike, + isNotNull, + lte, + or, + sql, + type SQL, +} from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { + approvals, + decisionTrainingExamples, + executionWorkspaces, + heartbeatRuns, + issueApprovals, + issueComments, + issueExecutionDecisions, + issues, + issueThreadInteractions, + projectWorkspaces, +} from "@paperclipai/db"; +import type { + DecisionTrainingNotesHistoryEntry, + DecisionTrainingSnapshotV1, + DecisionTrainingSourceKind, +} from "@paperclipai/shared"; +import { conflict, notFound } from "../errors.js"; + +type CaptureInput = { + companyId: string; + sourceKind: DecisionTrainingSourceKind; + sourceId: string; + issueId: string; +}; + +type SourceDecision = { + cutoffAt: Date; + outcome: string | null; + payload: Record; + actor: Record | null; + exactRunId: string | null; +}; + +type ListInput = { + projectId?: string; + kind?: DecisionTrainingSourceKind; + author?: string; + q?: string; +}; + +function jsonCopy(value: unknown): Record { + return JSON.parse(JSON.stringify(value)) as Record; +} + +function findCommitSha(value: unknown): string | null { + if (!value || typeof value !== "object") return null; + if (Array.isArray(value)) { + for (const item of value) { + const found = findCommitSha(item); + if (found) return found; + } + return null; + } + const record = value as Record; + for (const key of ["commitSha", "commitSHA", "gitCommitSha", "headSha", "commit"]) { + const candidate = record[key]; + if (typeof candidate === "string" && /^[0-9a-f]{7,64}$/i.test(candidate)) return candidate; + } + for (const nested of Object.values(record)) { + const found = findCommitSha(nested); + if (found) return found; + } + return null; +} + +async function loadSourceDecision(db: Db, input: CaptureInput, capturedAt: Date): Promise { + if (input.sourceKind === "interaction") { + const row = await db.query.issueThreadInteractions.findFirst({ + where: and( + eq(issueThreadInteractions.id, input.sourceId), + eq(issueThreadInteractions.companyId, input.companyId), + eq(issueThreadInteractions.issueId, input.issueId), + ), + }); + if (!row) throw notFound("Decision interaction not found"); + const resolved = row.resolvedAt != null && row.status !== "pending"; + return { + cutoffAt: resolved ? row.resolvedAt! : capturedAt, + outcome: resolved ? row.status : null, + payload: jsonCopy({ + kind: row.kind, + title: row.title, + summary: row.summary, + payload: row.payload, + result: resolved ? row.result : null, + }), + actor: jsonCopy(resolved + ? { userId: row.resolvedByUserId, agentId: row.resolvedByAgentId } + : { userId: row.createdByUserId, agentId: row.createdByAgentId }), + exactRunId: row.sourceRunId, + }; + } + + if (input.sourceKind === "approval") { + const rows = await db + .select({ approval: approvals }) + .from(approvals) + .innerJoin(issueApprovals, and( + eq(issueApprovals.approvalId, approvals.id), + eq(issueApprovals.issueId, input.issueId), + eq(issueApprovals.companyId, input.companyId), + )) + .where(and(eq(approvals.id, input.sourceId), eq(approvals.companyId, input.companyId))) + .limit(1); + const row = rows[0]?.approval; + if (!row) throw notFound("Decision approval not found"); + const resolved = row.decidedAt != null && row.status !== "pending"; + return { + cutoffAt: resolved ? row.decidedAt! : capturedAt, + outcome: resolved ? row.status : null, + payload: jsonCopy({ type: row.type, payload: row.payload, decisionNote: resolved ? row.decisionNote : null }), + actor: jsonCopy(resolved + ? { userId: row.decidedByUserId } + : { userId: row.requestedByUserId, agentId: row.requestedByAgentId }), + exactRunId: null, + }; + } + + const row = await db.query.issueExecutionDecisions.findFirst({ + where: and( + eq(issueExecutionDecisions.id, input.sourceId), + eq(issueExecutionDecisions.companyId, input.companyId), + eq(issueExecutionDecisions.issueId, input.issueId), + ), + }); + if (!row) throw notFound("Execution decision not found"); + return { + cutoffAt: row.createdAt, + outcome: row.outcome, + payload: jsonCopy({ stageId: row.stageId, stageType: row.stageType, body: row.body }), + actor: jsonCopy({ userId: row.actorUserId, agentId: row.actorAgentId }), + exactRunId: row.createdByRunId, + }; +} + +export async function captureDecisionSnapshot( + db: Db, + input: CaptureInput, + capturedAt = new Date(), +): Promise<{ cutoffAt: Date; decisionOutcome: string | null; snapshot: DecisionTrainingSnapshotV1 }> { + const issue = await db.query.issues.findFirst({ + where: and(eq(issues.id, input.issueId), eq(issues.companyId, input.companyId)), + }); + if (!issue) throw notFound("Issue not found"); + + const decision = await loadSourceDecision(db, input, capturedAt); + const comments = await db + .select() + .from(issueComments) + .where(and( + eq(issueComments.companyId, input.companyId), + eq(issueComments.issueId, input.issueId), + lte(issueComments.createdAt, decision.cutoffAt), + )) + .orderBy(asc(issueComments.createdAt), asc(issueComments.id)); + const runs = await db + .select() + .from(heartbeatRuns) + .where(and( + eq(heartbeatRuns.companyId, input.companyId), + isNotNull(heartbeatRuns.startedAt), + lte(heartbeatRuns.startedAt, decision.cutoffAt), + or( + sql`${heartbeatRuns.contextSnapshot} ->> 'issueId' = ${input.issueId}`, + sql`${heartbeatRuns.contextSnapshot} ->> 'taskId' = ${input.issueId}`, + ), + )) + .orderBy(asc(heartbeatRuns.startedAt), asc(heartbeatRuns.id)); + + const [projectWorkspace] = issue.projectId + ? await db + .select() + .from(projectWorkspaces) + .where(and( + eq(projectWorkspaces.companyId, input.companyId), + eq(projectWorkspaces.projectId, issue.projectId), + lte(projectWorkspaces.createdAt, decision.cutoffAt), + )) + .orderBy(desc(projectWorkspaces.isPrimary), desc(projectWorkspaces.updatedAt)) + .limit(1) + : []; + const [executionWorkspace] = await db + .select() + .from(executionWorkspaces) + .where(and( + eq(executionWorkspaces.companyId, input.companyId), + eq(executionWorkspaces.sourceIssueId, input.issueId), + lte(executionWorkspaces.openedAt, decision.cutoffAt), + )) + .orderBy(desc(executionWorkspaces.lastUsedAt), desc(executionWorkspaces.id)) + .limit(1); + + const exactRun = decision.exactRunId ? runs.find((run) => run.id === decision.exactRunId) ?? null : null; + const latestRunWithCommit = [...runs].reverse().find((run) => findCommitSha(run.contextSnapshot)) ?? null; + const exactCommit = exactRun ? findCommitSha(exactRun.contextSnapshot) : null; + const nearestCommit = latestRunWithCommit ? findCommitSha(latestRunWithCommit.contextSnapshot) : null; + const workspaceCommit = findCommitSha(executionWorkspace?.metadata) ?? findCommitSha(projectWorkspace?.metadata); + const commitSha = exactCommit ?? nearestCommit ?? workspaceCommit; + + return { + cutoffAt: decision.cutoffAt, + decisionOutcome: decision.outcome, + snapshot: { + version: 1, + capturedAt: capturedAt.toISOString(), + cutoff: { + at: decision.cutoffAt.toISOString(), + lastCommentId: comments.at(-1)?.id ?? null, + commentCount: comments.length, + }, + issue: jsonCopy(issue), + comments: comments.map(jsonCopy), + runs: runs.map(jsonCopy), + decision: { + kind: input.sourceKind, + payload: decision.payload, + actor: decision.actor, + outcome: decision.outcome, + }, + code: { + repoUrl: executionWorkspace?.repoUrl ?? projectWorkspace?.repoUrl ?? null, + ref: executionWorkspace?.branchName + ?? executionWorkspace?.baseRef + ?? projectWorkspace?.repoRef + ?? projectWorkspace?.defaultRef + ?? null, + commitSha: commitSha ?? null, + resolution: exactCommit ? "exact" : commitSha ? "nearest_run" : "none", + }, + }, + }; +} + +export function decisionTrainingService(db: Db) { + return { + create: async (input: CaptureInput & { notes: string; createdByUserId: string }) => { + const captured = await captureDecisionSnapshot(db, input); + const rows = await db + .insert(decisionTrainingExamples) + .values({ + companyId: input.companyId, + sourceKind: input.sourceKind, + sourceId: input.sourceId, + issueId: input.issueId, + cutoffAt: captured.cutoffAt, + notes: input.notes, + notesHistory: [], + decisionOutcome: captured.decisionOutcome, + snapshot: captured.snapshot, + createdByUserId: input.createdByUserId, + }) + .onConflictDoNothing({ + target: [ + decisionTrainingExamples.sourceKind, + decisionTrainingExamples.sourceId, + decisionTrainingExamples.createdByUserId, + ], + }) + .returning(); + if (!rows[0]) throw conflict("This decision is already trained by this user"); + return rows[0]; + }, + list: async (companyId: string, input: ListInput = {}) => { + const filters: SQL[] = [eq(decisionTrainingExamples.companyId, companyId)]; + if (input.projectId) filters.push(eq(issues.projectId, input.projectId)); + if (input.kind) filters.push(eq(decisionTrainingExamples.sourceKind, input.kind)); + if (input.author) filters.push(eq(decisionTrainingExamples.createdByUserId, input.author)); + if (input.q) { + const query = `%${input.q}%`; + filters.push(or( + ilike(decisionTrainingExamples.notes, query), + ilike(issues.title, query), + ilike(issues.identifier, query), + )!); + } + return db + .select({ example: decisionTrainingExamples, issueTitle: issues.title, issueIdentifier: issues.identifier }) + .from(decisionTrainingExamples) + .innerJoin(issues, eq(decisionTrainingExamples.issueId, issues.id)) + .where(and(...filters)) + .orderBy(desc(decisionTrainingExamples.createdAt), desc(decisionTrainingExamples.id)); + }, + getById: async (id: string) => db.query.decisionTrainingExamples.findFirst({ + where: eq(decisionTrainingExamples.id, id), + }), + updateNotes: async (id: string, author: string, notes: string) => db.transaction(async (tx) => { + const row = await tx.query.decisionTrainingExamples.findFirst({ + where: eq(decisionTrainingExamples.id, id), + }); + if (!row) return null; + const history: DecisionTrainingNotesHistoryEntry[] = [ + ...(row.notesHistory ?? []), + { author, at: new Date().toISOString(), body: row.notes }, + ]; + const [updated] = await tx + .update(decisionTrainingExamples) + .set({ notes, notesHistory: history, updatedAt: new Date() }) + .where(eq(decisionTrainingExamples.id, id)) + .returning(); + return updated ?? null; + }), + delete: async (id: string) => db + .delete(decisionTrainingExamples) + .where(eq(decisionTrainingExamples.id, id)) + .returning({ id: decisionTrainingExamples.id }), + }; +} diff --git a/server/src/services/folders.ts b/server/src/services/folders.ts new file mode 100644 index 00000000000..7f12a2c1490 --- /dev/null +++ b/server/src/services/folders.ts @@ -0,0 +1,229 @@ +import { and, asc, eq, max, sql } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { companySkills, folders, routines } from "@paperclipai/db"; +import type { + CreateFolder, + Folder, + FolderKind, + FolderListResult, + MoveFolder, + MoveFolderItem, + UpdateFolder, +} from "@paperclipai/shared"; +import { conflict, notFound, unprocessable } from "../errors.js"; + +type FolderRow = typeof folders.$inferSelect; + +function mapFolder(row: FolderRow): Folder { + return { + ...row, + color: row.color ?? null, + }; +} + +function normalizeName(name: string) { + return name.trim(); +} + +function normalizeColor(color: string | null | undefined) { + if (color === undefined) return undefined; + const trimmed = color?.trim() ?? ""; + return trimmed.length > 0 ? trimmed : null; +} + +export function folderService(db: Db) { + async function getFolder(companyId: string, folderId: string) { + return db + .select() + .from(folders) + .where(and(eq(folders.companyId, companyId), eq(folders.id, folderId))) + .then((rows) => rows[0] ?? null); + } + + async function assertNoNameConflict(companyId: string, kind: FolderKind, name: string, excludeFolderId?: string) { + const existing = await db + .select({ id: folders.id }) + .from(folders) + .where(and(eq(folders.companyId, companyId), eq(folders.kind, kind), eq(folders.name, name))) + .then((rows) => rows[0] ?? null); + if (existing && existing.id !== excludeFolderId) { + throw conflict("Folder name already exists for this kind"); + } + } + + async function nextPosition(companyId: string, kind: FolderKind) { + const row = await db + .select({ value: max(folders.position) }) + .from(folders) + .where(and(eq(folders.companyId, companyId), eq(folders.kind, kind))) + .then((rows) => rows[0] ?? null); + return Number(row?.value ?? -1) + 1; + } + + async function routineCounts(companyId: string) { + return db + .select({ + folderId: routines.folderId, + count: sql`count(*)::int`, + }) + .from(routines) + .where(eq(routines.companyId, companyId)) + .groupBy(routines.folderId); + } + + async function skillCounts(companyId: string) { + return db + .select({ + folderId: companySkills.folderId, + count: sql`count(*)::int`, + }) + .from(companySkills) + .where(eq(companySkills.companyId, companyId)) + .groupBy(companySkills.folderId); + } + + async function list(companyId: string, kind: FolderKind): Promise { + const [folderRows, countRows] = await Promise.all([ + db + .select() + .from(folders) + .where(and(eq(folders.companyId, companyId), eq(folders.kind, kind))) + .orderBy(asc(folders.position), asc(folders.name), asc(folders.id)), + kind === "routine" ? routineCounts(companyId) : skillCounts(companyId), + ]); + + const countsByFolderId = new Map(); + for (const row of countRows) { + countsByFolderId.set(row.folderId ?? null, Number(row.count ?? 0)); + } + + return { + kind, + folders: folderRows.map((row) => ({ + ...mapFolder(row), + itemCount: countsByFolderId.get(row.id) ?? 0, + })), + allCount: Array.from(countsByFolderId.values()).reduce((sum, count) => sum + count, 0), + unfiledCount: countsByFolderId.get(null) ?? 0, + }; + } + + async function create(companyId: string, input: CreateFolder): Promise { + const name = normalizeName(input.name); + await assertNoNameConflict(companyId, input.kind, name); + const position = input.position ?? await nextPosition(companyId, input.kind); + const row = await db + .insert(folders) + .values({ + companyId, + kind: input.kind, + name, + color: normalizeColor(input.color) ?? null, + position, + }) + .returning() + .then((rows) => rows[0] ?? null); + if (!row) throw notFound("Failed to create folder"); + return mapFolder(row); + } + + async function update(companyId: string, folderId: string, patch: UpdateFolder): Promise { + const existing = await getFolder(companyId, folderId); + if (!existing) return null; + const nextName = patch.name === undefined ? existing.name : normalizeName(patch.name); + if (nextName !== existing.name) { + await assertNoNameConflict(companyId, existing.kind, nextName, folderId); + } + const row = await db + .update(folders) + .set({ + name: nextName, + color: patch.color === undefined ? existing.color : normalizeColor(patch.color), + position: patch.position ?? existing.position, + updatedAt: new Date(), + }) + .where(and(eq(folders.companyId, companyId), eq(folders.id, folderId))) + .returning() + .then((rows) => rows[0] ?? null); + return row ? mapFolder(row) : null; + } + + async function moveFolder(companyId: string, folderId: string, input: MoveFolder): Promise { + const row = await db + .update(folders) + .set({ position: input.position, updatedAt: new Date() }) + .where(and(eq(folders.companyId, companyId), eq(folders.id, folderId))) + .returning() + .then((rows) => rows[0] ?? null); + return row ? mapFolder(row) : null; + } + + async function deleteFolder(companyId: string, folderId: string): Promise { + return db.transaction(async (tx) => { + const txDb = tx as unknown as Db; + const folder = await txDb + .select() + .from(folders) + .where(and(eq(folders.companyId, companyId), eq(folders.id, folderId))) + .then((rows) => rows[0] ?? null); + if (!folder) return null; + + if (folder.kind === "routine") { + await txDb + .update(routines) + .set({ folderId: null, updatedAt: new Date() }) + .where(and(eq(routines.companyId, companyId), eq(routines.folderId, folderId))); + } else { + await txDb + .update(companySkills) + .set({ folderId: null, updatedAt: new Date() }) + .where(and(eq(companySkills.companyId, companyId), eq(companySkills.folderId, folderId))); + } + + await txDb + .delete(folders) + .where(and(eq(folders.companyId, companyId), eq(folders.id, folderId))); + return mapFolder(folder); + }); + } + + async function assertTargetFolder(companyId: string, kind: FolderKind, folderId: string | null | undefined) { + if (!folderId) return null; + const folder = await getFolder(companyId, folderId); + if (!folder) throw notFound("Folder not found"); + if (folder.kind !== kind) throw unprocessable("Folder kind must match item kind"); + return folder; + } + + async function moveItem(companyId: string, input: MoveFolderItem) { + const targetFolder = await assertTargetFolder(companyId, input.kind, input.folderId ?? null); + if (input.kind === "routine") { + const row = await db + .update(routines) + .set({ folderId: targetFolder?.id ?? null, updatedAt: new Date() }) + .where(and(eq(routines.companyId, companyId), eq(routines.id, input.itemId))) + .returning({ id: routines.id, folderId: routines.folderId }) + .then((rows) => rows[0] ?? null); + if (!row) throw notFound("Routine not found"); + return { kind: input.kind, itemId: row.id, folderId: row.folderId ?? null }; + } + + const row = await db + .update(companySkills) + .set({ folderId: targetFolder?.id ?? null, updatedAt: new Date() }) + .where(and(eq(companySkills.companyId, companyId), eq(companySkills.id, input.itemId))) + .returning({ id: companySkills.id, folderId: companySkills.folderId }) + .then((rows) => rows[0] ?? null); + if (!row) throw notFound("Skill not found"); + return { kind: input.kind, itemId: row.id, folderId: row.folderId ?? null }; + } + + return { + list, + create, + update, + moveFolder, + deleteFolder, + moveItem, + }; +} diff --git a/server/src/services/index.ts b/server/src/services/index.ts index b7745b470f8..2e1ebc21d42 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -5,6 +5,7 @@ export { companySearchExtractService } from "./company-search-extract.js"; export { feedbackService } from "./feedback.js"; export { companySkillService } from "./company-skills.js"; export { companySkillPolicyService, normalizeSkillPolicySourceType } from "./company-skill-policy.js"; +export { folderService } from "./folders.js"; export { agentService, deduplicateAgentName } from "./agents.js"; export { builtInAgentService, @@ -61,6 +62,7 @@ export { goalService } from "./goals.js"; export { activityService, type ActivityFilters } from "./activity.js"; export { workTimelineService, normalizeTimelineWindow } from "./work-timeline.js"; export { attentionService } from "./attention.js"; +export { captureDecisionSnapshot, decisionTrainingService } from "./decision-training.js"; export type { WorkTimelineActor, WorkTimelineEdge, @@ -142,3 +144,5 @@ export { } from "./codex-auth-reconciliation.js"; export { reconcilePersistedRuntimeServicesOnStartup, restartDesiredRuntimeServicesOnStartup } from "./workspace-runtime.js"; export { createStorageServiceFromConfig, getStorageService } from "../storage/index.js"; + +export { issueExecutionHealthService } from "./issue-execution-health.js"; diff --git a/server/src/services/issue-execution-health.ts b/server/src/services/issue-execution-health.ts new file mode 100644 index 00000000000..5a934b7b01f --- /dev/null +++ b/server/src/services/issue-execution-health.ts @@ -0,0 +1,674 @@ +import { and, desc, eq, inArray, isNull, notInArray, sql } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { + agentWakeupRequests, + agents, + approvals, + heartbeatRunWatchdogDecisions, + heartbeatRuns, + issueApprovals, + issueRelations, + issueThreadInteractions, + issues, +} from "@paperclipai/db"; +import type { + IssueExecutionHealthApprovalEvidence, + IssueExecutionHealthBlockerEvidence, + IssueExecutionHealthEvidence, + IssueExecutionHealthInteractionEvidence, + IssueExecutionHealthQueuedWakeEvidence, + IssueExecutionHealthReasonCode, + IssueExecutionHealthRecoveryEvidence, + IssueExecutionHealthRunEvidence, + IssueExecutionHealthState, + IssueExecutionHealthSummary, +} from "@paperclipai/shared"; +import { RECOVERY_ORIGIN_KINDS } from "./recovery/origins.js"; + +export const ISSUE_EXECUTION_HEALTH_SUSPICION_THRESHOLD_MS = 60 * 60 * 1000; +export const ISSUE_EXECUTION_HEALTH_CRITICAL_THRESHOLD_MS = 4 * 60 * 60 * 1000; + +const ACTIVE_RUN_STATUSES = new Set(["queued", "running", "scheduled_retry"]); +const ACTIVE_WAKE_STATUSES = ["queued", "deferred_issue_execution"] as const; +const PENDING_INTERACTION_STATUSES = ["pending"] as const; +const PENDING_APPROVAL_STATUSES = ["pending", "revision_requested"] as const; +const TERMINAL_ISSUE_STATUSES = new Set(["done", "cancelled"]); +const INVOKABLE_AGENT_STATUSES = new Set(["active", "idle", "running", "error"]); +const RECOVERY_ORIGIN_KIND_LIST = [ + RECOVERY_ORIGIN_KINDS.issueGraphLivenessEscalation, + RECOVERY_ORIGIN_KINDS.staleActiveRunEvaluation, + RECOVERY_ORIGIN_KINDS.strandedIssueRecovery, +] as const; + +export interface IssueExecutionHealthIssueInput { + id: string; + companyId: string; + identifier: string | null; + title: string; + status: string; + assigneeAgentId: string | null; + assigneeUserId: string | null; + executionRunId: string | null; + executionPolicy: Record | null; + executionState: Record | null; +} + +export interface IssueExecutionHealthRunInput { + id: string; + status: string; + livenessState: string | null; + livenessReason: string | null; + lastOutputAt: Date | null; + processStartedAt: Date | null; + startedAt: Date | null; + createdAt: Date | null; + silenceSnoozedUntil: Date | null; +} + +export interface IssueExecutionHealthQueuedWakeInput { + id: string; + reason: string | null; + status: string; +} + +export interface IssueExecutionHealthInteractionInput { + id: string; + kind: string; + status: string; +} + +export interface IssueExecutionHealthApprovalInput { + id: string; + status: string; +} + +export interface IssueExecutionHealthRecoveryInput { + id: string; + identifier: string | null; + originKind: string; + status: string; +} + +export interface IssueExecutionHealthBlockerInput { + id: string; + identifier: string | null; + status: string; + assigneeAgentId: string | null; + assigneeAgentStatus: string | null; + assigneeUserId: string | null; +} + +export interface IssueExecutionHealthClassifyInput { + issue: IssueExecutionHealthIssueInput; + activeRun: IssueExecutionHealthRunInput | null; + queuedWakes: IssueExecutionHealthQueuedWakeInput[]; + pendingInteractions: IssueExecutionHealthInteractionInput[]; + pendingApprovals: IssueExecutionHealthApprovalInput[]; + openRecoveryIssues: IssueExecutionHealthRecoveryInput[]; + blockers: IssueExecutionHealthBlockerInput[]; + assigneeAgentStatus: string | null; + now?: Date; +} + +function silenceStartedAt(run: IssueExecutionHealthRunInput) { + return run.lastOutputAt ?? run.processStartedAt ?? run.startedAt ?? run.createdAt ?? null; +} + +function classifySilenceLevel(run: IssueExecutionHealthRunInput, now: Date): IssueExecutionHealthRunEvidence["silenceLevel"] { + if (run.status !== "running") return "not_applicable"; + if (run.silenceSnoozedUntil && run.silenceSnoozedUntil.getTime() > now.getTime()) return "snoozed"; + const startedAt = silenceStartedAt(run); + if (!startedAt) return "ok"; + const ageMs = Math.max(0, now.getTime() - startedAt.getTime()); + if (ageMs >= ISSUE_EXECUTION_HEALTH_CRITICAL_THRESHOLD_MS) return "critical"; + if (ageMs >= ISSUE_EXECUTION_HEALTH_SUSPICION_THRESHOLD_MS) return "suspicious"; + return "ok"; +} + +function summarizeRun(run: IssueExecutionHealthRunInput, now: Date): IssueExecutionHealthRunEvidence { + return { + runId: run.id, + status: run.status, + livenessState: run.livenessState, + livenessReason: run.livenessReason, + silenceLevel: classifySilenceLevel(run, now), + }; +} + +function summarizeWake(wake: IssueExecutionHealthQueuedWakeInput): IssueExecutionHealthQueuedWakeEvidence { + return { wakeupRequestId: wake.id, reason: wake.reason, status: wake.status }; +} + +function summarizeInteraction(interaction: IssueExecutionHealthInteractionInput): IssueExecutionHealthInteractionEvidence { + return { interactionId: interaction.id, kind: interaction.kind, status: interaction.status }; +} + +function summarizeApproval(approval: IssueExecutionHealthApprovalInput): IssueExecutionHealthApprovalEvidence { + return { approvalId: approval.id, status: approval.status }; +} + +function summarizeRecovery(recovery: IssueExecutionHealthRecoveryInput): IssueExecutionHealthRecoveryEvidence { + return { + recoveryIssueId: recovery.id, + recoveryIssueIdentifier: recovery.identifier, + originKind: recovery.originKind, + }; +} + +function summarizeBlocker(blocker: IssueExecutionHealthBlockerInput): IssueExecutionHealthBlockerEvidence { + return { + blockerIssueId: blocker.id, + blockerIssueIdentifier: blocker.identifier, + blockerStatus: blocker.status, + }; +} + +function readPrincipalAgentId(executionState: Record | null): string | null { + if (!executionState) return null; + const participant = executionState.currentParticipant; + if (!participant || typeof participant !== "object") return null; + const value = participant as Record; + if (value.type !== "agent") return null; + return typeof value.agentId === "string" && value.agentId.length > 0 ? value.agentId : null; +} + +function readPrincipalUserId(executionState: Record | null): string | null { + if (!executionState) return null; + const participant = executionState.currentParticipant; + if (!participant || typeof participant !== "object") return null; + const value = participant as Record; + if (value.type !== "user") return null; + return typeof value.userId === "string" && value.userId.length > 0 ? value.userId : null; +} + +function buildSummary(input: { + state: IssueExecutionHealthState; + reasonCode: IssueExecutionHealthReasonCode; + reason: string; + nextActionOwner: IssueExecutionHealthSummary["nextActionOwner"]; + evidence: IssueExecutionHealthEvidence; + now: Date; +}): IssueExecutionHealthSummary { + return { + state: input.state, + reasonCode: input.reasonCode, + reason: input.reason, + nextActionOwner: input.nextActionOwner, + evidence: input.evidence, + evaluatedAt: input.now.toISOString(), + }; +} + +export function classifyIssueExecutionHealth(input: IssueExecutionHealthClassifyInput): IssueExecutionHealthSummary { + const now = input.now ?? new Date(); + const { issue } = input; + + if (TERMINAL_ISSUE_STATUSES.has(issue.status)) { + return buildSummary({ + state: "no_action_path", + reasonCode: "issue_terminal", + reason: `Issue is ${issue.status}; no further action expected.`, + nextActionOwner: "none", + evidence: {}, + now, + }); + } + + // Active execution run takes precedence (silent or not). + if (input.activeRun) { + const runEvidence = summarizeRun(input.activeRun, now); + if (runEvidence.silenceLevel === "critical") { + return buildSummary({ + state: "watchdog_review", + reasonCode: "silent_active_run_under_watchdog", + reason: `Active run ${input.activeRun.id} has been silent past the critical threshold.`, + nextActionOwner: "system", + evidence: { activeRun: runEvidence }, + now, + }); + } + return buildSummary({ + state: "live_run", + reasonCode: "active_execution_run", + reason: `Active heartbeat run ${input.activeRun.id} is ${input.activeRun.status}.`, + nextActionOwner: "assignee_agent", + evidence: { activeRun: runEvidence }, + now, + }); + } + + // Open recovery issues handle their own next action through a follow-up agent. + if (input.openRecoveryIssues.length > 0) { + const recovery = input.openRecoveryIssues[0]!; + return buildSummary({ + state: "recovering", + reasonCode: "open_recovery_issue", + reason: `Open recovery issue ${recovery.identifier ?? recovery.id} (${recovery.originKind}) owns the next action.`, + nextActionOwner: "recovery_owner", + evidence: { recoveryIssue: summarizeRecovery(recovery) }, + now, + }); + } + + // Pending issue thread interaction blocks the assignee until resolved. + if (input.pendingInteractions.length > 0) { + const interaction = input.pendingInteractions[0]!; + return buildSummary({ + state: "awaiting_interaction", + reasonCode: "pending_issue_thread_interaction", + reason: `Issue is awaiting a ${interaction.kind} interaction response.`, + nextActionOwner: "assignee_user", + evidence: { pendingInteraction: summarizeInteraction(interaction) }, + now, + }); + } + + // Pending linked approval blocks the assignee until resolved. + if (input.pendingApprovals.length > 0) { + const approval = input.pendingApprovals[0]!; + return buildSummary({ + state: "awaiting_approval", + reasonCode: "pending_linked_approval", + reason: `Linked approval ${approval.id} is ${approval.status}.`, + nextActionOwner: "system", + evidence: { pendingApproval: summarizeApproval(approval) }, + now, + }); + } + + // Execution-policy review participants own the next action while in_review. + if (issue.status === "in_review") { + const participantAgentId = readPrincipalAgentId(issue.executionState); + const participantUserId = readPrincipalUserId(issue.executionState); + if (participantAgentId) { + return buildSummary({ + state: "awaiting_review_participant", + reasonCode: "execution_policy_participant_owns_next_action", + reason: `Execution-policy review participant agent ${participantAgentId} owns the next action.`, + nextActionOwner: "review_participant", + evidence: {}, + now, + }); + } + if (participantUserId) { + return buildSummary({ + state: "awaiting_user", + reasonCode: "human_assignee_owns_next_action", + reason: `Execution-policy review participant user ${participantUserId} owns the next action.`, + nextActionOwner: "review_participant", + evidence: {}, + now, + }); + } + if (issue.assigneeUserId) { + return buildSummary({ + state: "awaiting_user", + reasonCode: "human_assignee_owns_next_action", + reason: `Issue is in review with human assignee ${issue.assigneeUserId}.`, + nextActionOwner: "assignee_user", + evidence: {}, + now, + }); + } + return buildSummary({ + state: "invalid_state", + reasonCode: "in_review_without_action_path", + reason: "Issue is in review but no participant, user assignee, interaction, approval, or recovery issue owns the next action.", + nextActionOwner: "none", + evidence: {}, + now, + }); + } + + // Human-assigned non-review work waits on the human user. + if (issue.assigneeUserId) { + return buildSummary({ + state: "awaiting_user", + reasonCode: "human_assignee_owns_next_action", + reason: `Issue is assigned to human user ${issue.assigneeUserId}.`, + nextActionOwner: "assignee_user", + evidence: {}, + now, + }); + } + + // Blocked issues classify by blocker chain validity. + if (issue.status === "blocked") { + const unresolvedBlockers = input.blockers.filter( + (blocker) => !TERMINAL_ISSUE_STATUSES.has(blocker.status) || blocker.status === "cancelled", + ); + if (unresolvedBlockers.length === 0) { + return buildSummary({ + state: "invalid_state", + reasonCode: "blocked_by_unassigned_issue", + reason: "Issue is blocked but has no unresolved blockers; the blocker chain is invalid.", + nextActionOwner: "none", + evidence: {}, + now, + }); + } + const cancelledBlocker = unresolvedBlockers.find((blocker) => blocker.status === "cancelled"); + if (cancelledBlocker) { + return buildSummary({ + state: "invalid_state", + reasonCode: "blocked_by_cancelled_issue", + reason: `Issue is blocked by cancelled issue ${cancelledBlocker.identifier ?? cancelledBlocker.id}.`, + nextActionOwner: "none", + evidence: { blocker: summarizeBlocker(cancelledBlocker) }, + now, + }); + } + const orphanBlocker = unresolvedBlockers.find( + (blocker) => !blocker.assigneeAgentId && !blocker.assigneeUserId, + ); + if (orphanBlocker) { + return buildSummary({ + state: "invalid_state", + reasonCode: "blocked_by_unassigned_issue", + reason: `Issue is blocked by unassigned issue ${orphanBlocker.identifier ?? orphanBlocker.id}.`, + nextActionOwner: "none", + evidence: { blocker: summarizeBlocker(orphanBlocker) }, + now, + }); + } + const uninvokableBlocker = unresolvedBlockers.find( + (blocker) => + blocker.assigneeAgentId && + !blocker.assigneeUserId && + !INVOKABLE_AGENT_STATUSES.has(blocker.assigneeAgentStatus ?? ""), + ); + if (uninvokableBlocker) { + return buildSummary({ + state: "invalid_state", + reasonCode: "agent_uninvokable", + reason: `Issue is blocked by ${uninvokableBlocker.identifier ?? uninvokableBlocker.id} whose assignee agent is ${uninvokableBlocker.assigneeAgentStatus ?? "missing"}.`, + nextActionOwner: "none", + evidence: { blocker: summarizeBlocker(uninvokableBlocker) }, + now, + }); + } + const leadBlocker = unresolvedBlockers[0]!; + return buildSummary({ + state: "blocked_waiting", + reasonCode: "unresolved_blocker_chain_covered", + reason: `Issue is blocked by ${leadBlocker.identifier ?? leadBlocker.id}; blocker chain is covered.`, + nextActionOwner: "blocker_owner", + evidence: { blocker: summarizeBlocker(leadBlocker) }, + now, + }); + } + + // Queued wake counts as a planned execution path. + if (input.queuedWakes.length > 0) { + const wake = input.queuedWakes[0]!; + return buildSummary({ + state: "queued_wake", + reasonCode: "queued_assignment_or_continuation", + reason: `Queued wake ${wake.id} (${wake.reason ?? "no reason"}) is awaiting dispatch.`, + nextActionOwner: "assignee_agent", + evidence: { queuedWake: summarizeWake(wake) }, + now, + }); + } + + // Assigned-agent issues without an active run, queued wake, recovery, or other path. + if (issue.assigneeAgentId) { + if (input.assigneeAgentStatus && !INVOKABLE_AGENT_STATUSES.has(input.assigneeAgentStatus)) { + return buildSummary({ + state: "invalid_state", + reasonCode: "agent_uninvokable", + reason: `Assigned agent ${issue.assigneeAgentId} is ${input.assigneeAgentStatus} and cannot be invoked.`, + nextActionOwner: "none", + evidence: {}, + now, + }); + } + if (issue.status === "todo") { + return buildSummary({ + state: "no_action_path", + reasonCode: "assigned_todo_without_dispatch_path", + reason: "Issue is todo with an agent assignee but no active run, queued wake, or recovery issue owning dispatch.", + nextActionOwner: "none", + evidence: {}, + now, + }); + } + if (issue.status === "in_progress") { + return buildSummary({ + state: "no_action_path", + reasonCode: "assigned_in_progress_without_execution_path", + reason: "Issue is in_progress with an agent assignee but no active run, queued wake, or recovery issue owning execution.", + nextActionOwner: "none", + evidence: {}, + now, + }); + } + return buildSummary({ + state: "no_action_path", + reasonCode: "assigned_in_progress_without_execution_path", + reason: `Issue status ${issue.status} has an agent assignee but no active execution path.`, + nextActionOwner: "none", + evidence: {}, + now, + }); + } + + // No assignee at all. + return buildSummary({ + state: "invalid_state", + reasonCode: "blocked_by_unassigned_issue", + reason: "Issue is unassigned and has no waiting path.", + nextActionOwner: "none", + evidence: {}, + now, + }); +} + +export function issueExecutionHealthService(db: Db) { + async function gatherActiveRun(issue: IssueExecutionHealthIssueInput): Promise { + const rows = await db + .select({ + id: heartbeatRuns.id, + status: heartbeatRuns.status, + livenessState: heartbeatRuns.livenessState, + livenessReason: heartbeatRuns.livenessReason, + lastOutputAt: heartbeatRuns.lastOutputAt, + processStartedAt: heartbeatRuns.processStartedAt, + startedAt: heartbeatRuns.startedAt, + createdAt: heartbeatRuns.createdAt, + }) + .from(heartbeatRuns) + .where( + and( + eq(heartbeatRuns.companyId, issue.companyId), + inArray(heartbeatRuns.status, [...ACTIVE_RUN_STATUSES]), + sql`${heartbeatRuns.contextSnapshot} ->> 'issueId' = ${issue.id}`, + ), + ) + .orderBy(desc(heartbeatRuns.createdAt)) + .limit(1); + const row = rows[0]; + if (!row) return null; + const snoozeRow = await db + .select({ snoozedUntil: heartbeatRunWatchdogDecisions.snoozedUntil }) + .from(heartbeatRunWatchdogDecisions) + .where( + and( + eq(heartbeatRunWatchdogDecisions.companyId, issue.companyId), + eq(heartbeatRunWatchdogDecisions.runId, row.id), + inArray(heartbeatRunWatchdogDecisions.decision, ["snooze", "continue"]), + ), + ) + .orderBy(desc(heartbeatRunWatchdogDecisions.createdAt)) + .limit(1); + return { + ...row, + silenceSnoozedUntil: snoozeRow[0]?.snoozedUntil ?? null, + }; + } + + async function gatherQueuedWakes(issue: IssueExecutionHealthIssueInput): Promise { + if (!issue.assigneeAgentId) return []; + const rows = await db + .select({ + id: agentWakeupRequests.id, + reason: agentWakeupRequests.reason, + status: agentWakeupRequests.status, + }) + .from(agentWakeupRequests) + .where( + and( + eq(agentWakeupRequests.companyId, issue.companyId), + eq(agentWakeupRequests.agentId, issue.assigneeAgentId), + inArray(agentWakeupRequests.status, [...ACTIVE_WAKE_STATUSES]), + sql`${agentWakeupRequests.payload} ->> 'issueId' = ${issue.id}`, + ), + ) + .orderBy(desc(agentWakeupRequests.requestedAt)); + return rows; + } + + async function gatherPendingInteractions(issue: IssueExecutionHealthIssueInput): Promise { + const rows = await db + .select({ + id: issueThreadInteractions.id, + kind: issueThreadInteractions.kind, + status: issueThreadInteractions.status, + }) + .from(issueThreadInteractions) + .where( + and( + eq(issueThreadInteractions.companyId, issue.companyId), + eq(issueThreadInteractions.issueId, issue.id), + inArray(issueThreadInteractions.status, [...PENDING_INTERACTION_STATUSES]), + ), + ) + .orderBy(desc(issueThreadInteractions.createdAt)); + return rows; + } + + async function gatherPendingApprovals(issue: IssueExecutionHealthIssueInput): Promise { + const rows = await db + .select({ + id: approvals.id, + status: approvals.status, + }) + .from(issueApprovals) + .innerJoin(approvals, eq(issueApprovals.approvalId, approvals.id)) + .where( + and( + eq(issueApprovals.companyId, issue.companyId), + eq(issueApprovals.issueId, issue.id), + inArray(approvals.status, [...PENDING_APPROVAL_STATUSES]), + ), + ); + return rows; + } + + async function gatherOpenRecoveryIssues(issue: IssueExecutionHealthIssueInput): Promise { + const blockerRows = await db + .select({ + id: issues.id, + identifier: issues.identifier, + originKind: issues.originKind, + status: issues.status, + }) + .from(issueRelations) + .innerJoin(issues, eq(issueRelations.issueId, issues.id)) + .where( + and( + eq(issueRelations.companyId, issue.companyId), + eq(issueRelations.relatedIssueId, issue.id), + eq(issueRelations.type, "blocks"), + inArray(issues.originKind, [...RECOVERY_ORIGIN_KIND_LIST]), + isNull(issues.hiddenAt), + notInArray(issues.status, ["done", "cancelled"]), + ), + ); + if (blockerRows.length > 0) return blockerRows; + + if (!issue.executionRunId) return []; + const staleRunRows = await db + .select({ + id: issues.id, + identifier: issues.identifier, + originKind: issues.originKind, + status: issues.status, + }) + .from(issues) + .where( + and( + eq(issues.companyId, issue.companyId), + eq(issues.originKind, RECOVERY_ORIGIN_KINDS.staleActiveRunEvaluation), + eq(issues.originId, issue.executionRunId), + isNull(issues.hiddenAt), + notInArray(issues.status, ["done", "cancelled"]), + ), + ); + return staleRunRows; + } + + async function gatherBlockers(issue: IssueExecutionHealthIssueInput): Promise { + if (issue.status !== "blocked") return []; + const rows = await db + .select({ + id: issues.id, + identifier: issues.identifier, + status: issues.status, + assigneeAgentId: issues.assigneeAgentId, + assigneeUserId: issues.assigneeUserId, + assigneeAgentStatus: agents.status, + }) + .from(issueRelations) + .innerJoin(issues, eq(issueRelations.issueId, issues.id)) + .leftJoin(agents, eq(agents.id, issues.assigneeAgentId)) + .where( + and( + eq(issueRelations.companyId, issue.companyId), + eq(issueRelations.relatedIssueId, issue.id), + eq(issueRelations.type, "blocks"), + isNull(issues.hiddenAt), + ), + ); + return rows; + } + + async function gatherAssigneeAgentStatus(issue: IssueExecutionHealthIssueInput): Promise { + if (!issue.assigneeAgentId) return null; + const row = await db + .select({ status: agents.status }) + .from(agents) + .where(eq(agents.id, issue.assigneeAgentId)) + .limit(1); + return row[0]?.status ?? null; + } + + async function summarize(issue: IssueExecutionHealthIssueInput, now: Date = new Date()): Promise { + const [activeRun, queuedWakes, pendingInteractions, pendingApprovals, openRecoveryIssues, blockers, assigneeAgentStatus] = + await Promise.all([ + gatherActiveRun(issue), + gatherQueuedWakes(issue), + gatherPendingInteractions(issue), + gatherPendingApprovals(issue), + gatherOpenRecoveryIssues(issue), + gatherBlockers(issue), + gatherAssigneeAgentStatus(issue), + ]); + return classifyIssueExecutionHealth({ + issue, + activeRun, + queuedWakes, + pendingInteractions, + pendingApprovals, + openRecoveryIssues, + blockers, + assigneeAgentStatus, + now, + }); + } + + return { + summarize, + classifyIssueExecutionHealth, + }; +} diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index ccbcada7f03..ce1e36d53cf 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -130,6 +130,9 @@ const ISSUE_COMMENT_RUN_LOG_DERIVATION_MAX_LOG_BYTES = 2_000_000; const ISSUE_COMMENT_RUN_LOG_DERIVATION_CHUNK_BYTES = 256_000; const ISSUE_COMMENT_RUN_LOG_DERIVATION_END_SLACK_MS = 60_000; const ISSUE_COMMENT_RUN_LOG_DERIVATION_MAX_PARALLEL_READS = 8; +export const ISSUE_CREATE_IDEMPOTENCY_KEY_RETENTION_DAYS = 7; +const ISSUE_CREATE_IDEMPOTENCY_KEY_RETENTION_MS = ISSUE_CREATE_IDEMPOTENCY_KEY_RETENTION_DAYS * 24 * 60 * 60 * 1000; +const ISSUE_CREATE_IDEMPOTENCY_KEY_CLEANUP_BATCH_SIZE = 500; const DELETED_ISSUE_COMMENT_BODY = ""; const ISSUE_WAKE_DIAGNOSTICS_ACTIVITY_ACTIONS = ["issue.tree_hold_wakeup_deferred"] as const; @@ -4991,6 +4994,42 @@ export function issueService(db: Db) { return row ?? null; }, + getActiveInboxArchiveFields: async ( + issue: Pick, + userId: string, + ) => { + const [[activity], archive] = await Promise.all([ + lastActivityStatsForIssues(db, issue.companyId, [issue.id]), + db + .select({ + archivedAt: issueInboxArchives.archivedAt, + archivedByActorType: issueInboxArchives.archivedByActorType, + archivedByAgentId: issueInboxArchives.archivedByAgentId, + archivedByRunId: issueInboxArchives.archivedByRunId, + }) + .from(issueInboxArchives) + .where(and( + eq(issueInboxArchives.companyId, issue.companyId), + eq(issueInboxArchives.issueId, issue.id), + eq(issueInboxArchives.userId, userId), + )) + .then((rows) => rows[0] ?? null), + ]); + if (!archive) return {}; + const lastActivityAt = latestIssueActivityAt( + issue.updatedAt, + activity?.latestCommentAt ?? null, + activity?.latestLogAt ?? null, + ) ?? issue.updatedAt; + if (archive.archivedAt < lastActivityAt) return {}; + return { + archivedAt: archive.archivedAt, + archivedByActorType: archive.archivedByActorType, + archivedByAgentId: archive.archivedByAgentId, + archivedByRunId: archive.archivedByRunId, + }; + }, + getById: async (raw: string) => { const id = raw.trim(); const identifier = normalizeIssueReferenceIdentifier(id); @@ -6031,6 +6070,19 @@ export function issueService(db: Db) { let existingIssue: typeof issues.$inferSelect | undefined; let deduplicationReason: "idempotency_key" | "recent_open_title" | null = null; if (idempotencyKey) { + const idempotencyKeyRetentionCutoff = new Date(Date.now() - ISSUE_CREATE_IDEMPOTENCY_KEY_RETENTION_MS); + await tx.execute(sql` + delete from ${issueCreateIdempotencyKeys} + where ${issueCreateIdempotencyKeys.id} in ( + select ${issueCreateIdempotencyKeys.id} + from ${issueCreateIdempotencyKeys} + where ${issueCreateIdempotencyKeys.companyId} = ${companyId} + and ${issueCreateIdempotencyKeys.createdAt} < ${idempotencyKeyRetentionCutoff.toISOString()}::timestamptz + order by ${issueCreateIdempotencyKeys.createdAt} asc, ${issueCreateIdempotencyKeys.id} asc + limit ${ISSUE_CREATE_IDEMPOTENCY_KEY_CLEANUP_BATCH_SIZE} + ) + `); + [existingIssue] = await tx .select() .from(issueCreateIdempotencyKeys) diff --git a/server/src/services/routines.ts b/server/src/services/routines.ts index e64da632909..9320aaae9ea 100644 --- a/server/src/services/routines.ts +++ b/server/src/services/routines.ts @@ -12,6 +12,7 @@ import { documentRevisions, documents, executionWorkspaces, + folders, goals, heartbeatRuns, issueInboxArchives, @@ -974,6 +975,18 @@ export function routineService( if (parentIssue.companyId !== companyId) throw unprocessable("Parent issue must belong to same company"); } + async function assertRoutineFolder(companyId: string, folderId: string | null | undefined) { + if (!folderId) return; + const folder = await db + .select({ id: folders.id, companyId: folders.companyId, kind: folders.kind }) + .from(folders) + .where(eq(folders.id, folderId)) + .then((rows) => rows[0] ?? null); + if (!folder) throw notFound("Folder not found"); + if (folder.companyId !== companyId) throw unprocessable("Folder must belong to same company"); + if (folder.kind !== "routine") throw unprocessable("Folder kind must match routine"); + } + async function listTriggersForRoutineIds(companyId: string, routineIds: string[]) { if (routineIds.length === 0) return new Map(); const rows = await db @@ -2066,6 +2079,7 @@ export function routineService( create: async (companyId: string, input: CreateRoutine, actor: Actor): Promise => { await assertProject(companyId, input.projectId ?? null); + await assertRoutineFolder(companyId, input.folderId ?? null); await assertAssignableAgent(db, companyId, input.assigneeAgentId ?? null, { kind: "routine" }); if (input.goalId) await assertGoal(companyId, input.goalId); if (input.parentIssueId) await assertParentIssue(companyId, input.parentIssueId); @@ -2092,6 +2106,7 @@ export function routineService( .values({ companyId, projectId: input.projectId ?? null, + folderId: input.folderId ?? null, goalId: input.goalId ?? null, parentIssueId: input.parentIssueId ?? null, title: input.title, @@ -2130,6 +2145,7 @@ export function routineService( const existing = await getRoutineById(id); if (!existing) return null; const nextProjectId = patch.projectId === undefined ? existing.projectId : patch.projectId; + const nextFolderId = patch.folderId === undefined ? existing.folderId : patch.folderId; const nextAssigneeAgentId = patch.assigneeAgentId === undefined ? existing.assigneeAgentId : patch.assigneeAgentId; const nextTitle = patch.title ?? existing.title; const nextDescription = patch.description === undefined ? existing.description : patch.description; @@ -2153,6 +2169,7 @@ export function routineService( patch.variables === undefined ? existing.variables : sanitizeRoutineVariableInputs(patch.variables), ); if (patch.projectId !== undefined) await assertProject(existing.companyId, nextProjectId); + if (patch.folderId !== undefined) await assertRoutineFolder(existing.companyId, nextFolderId); if (patch.assigneeAgentId !== undefined || patch.status === "active") { await assertAssignableAgent(db, existing.companyId, nextAssigneeAgentId, { kind: "routine" }); } @@ -2202,6 +2219,7 @@ export function routineService( const candidate: RoutineRow = { ...locked, projectId: nextProjectId, + folderId: nextFolderId, goalId: patch.goalId === undefined ? locked.goalId : patch.goalId, parentIssueId: patch.parentIssueId === undefined ? locked.parentIssueId : patch.parentIssueId, title: nextTitle, @@ -2218,8 +2236,20 @@ export function routineService( updatedByUserId: actor.userId ?? null, }; + const folderChanged = patch.folderId !== undefined && locked.folderId !== candidate.folderId; if (locked.latestRevisionId && routineCurrentFieldsMatch(locked, candidate)) { - return locked; + if (!folderChanged) return locked; + const [updated] = await txDb + .update(routines) + .set({ + folderId: candidate.folderId, + updatedByAgentId: actor.agentId ?? null, + updatedByUserId: actor.userId ?? null, + updatedAt: new Date(), + }) + .where(eq(routines.id, id)) + .returning(); + return updated ?? locked; } const nextSnapshot = await buildRoutineRevisionSnapshot(txDb, candidate); @@ -2252,6 +2282,7 @@ export function routineService( .update(routines) .set({ projectId: candidate.projectId, + folderId: candidate.folderId, goalId: candidate.goalId, parentIssueId: candidate.parentIssueId, title: candidate.title, diff --git a/ui/src/api/folders.ts b/ui/src/api/folders.ts new file mode 100644 index 00000000000..e8eb1752730 --- /dev/null +++ b/ui/src/api/folders.ts @@ -0,0 +1,36 @@ +import type { + CreateFolderRequest, + Folder, + FolderKind, + FolderListResult, + MoveFolderItemRequest, + MoveFolderRequest, + UpdateFolderRequest, +} from "@paperclipai/shared"; +import { api } from "./client"; + +export const foldersApi = { + list: (companyId: string, kind: FolderKind) => + api.get(`/companies/${encodeURIComponent(companyId)}/folders?kind=${kind}`), + create: (companyId: string, payload: CreateFolderRequest) => + api.post(`/companies/${encodeURIComponent(companyId)}/folders`, payload), + update: (companyId: string, folderId: string, payload: UpdateFolderRequest) => + api.patch( + `/companies/${encodeURIComponent(companyId)}/folders/${encodeURIComponent(folderId)}`, + payload, + ), + moveFolder: (companyId: string, folderId: string, payload: MoveFolderRequest) => + api.post( + `/companies/${encodeURIComponent(companyId)}/folders/${encodeURIComponent(folderId)}/move`, + payload, + ), + moveItem: (companyId: string, payload: MoveFolderItemRequest) => + api.post( + `/companies/${encodeURIComponent(companyId)}/folders/items/move`, + payload, + ), + delete: (companyId: string, folderId: string) => + api.delete<{ deleted: Folder }>( + `/companies/${encodeURIComponent(companyId)}/folders/${encodeURIComponent(folderId)}`, + ), +}; diff --git a/ui/src/components/AttentionQueueRow.test.tsx b/ui/src/components/AttentionQueueRow.test.tsx index bc848aa631e..1a7a079b894 100644 --- a/ui/src/components/AttentionQueueRow.test.tsx +++ b/ui/src/components/AttentionQueueRow.test.tsx @@ -112,6 +112,7 @@ function buildItem(overrides: Partial = {}): AttentionItem { workspace: null, detail: null, dismissal: null, + trainingExampleId: null, ...overrides, }; } diff --git a/ui/src/components/RoutineList.tsx b/ui/src/components/RoutineList.tsx index f2cf986cc54..29604651c19 100644 --- a/ui/src/components/RoutineList.tsx +++ b/ui/src/components/RoutineList.tsx @@ -64,6 +64,10 @@ export function RoutineListRow({ disableToggle = false, hideArchiveAction = false, divider = true, + selected = false, + selectMode = false, + extraMenuItems, + onSelectChange, onRunNow, onToggleEnabled, onToggleArchived, @@ -83,6 +87,10 @@ export function RoutineListRow({ hideArchiveAction?: boolean; /** Render a bottom divider between consecutive rows. Off when the group is its own card. */ divider?: boolean; + selected?: boolean; + selectMode?: boolean; + extraMenuItems?: ReactNode; + onSelectChange?: (routine: TRoutine, selected: boolean) => void; onRunNow: (routine: TRoutine) => void; onToggleEnabled: (routine: TRoutine, enabled: boolean) => void; onToggleArchived?: (routine: TRoutine) => void; @@ -102,6 +110,23 @@ export function RoutineListRow({ divider ? " border-b border-border last:border-b-0" : "" }`} > + {selectMode ? ( +
{ + event.preventDefault(); + event.stopPropagation(); + }} + > + onSelectChange?.(routine, event.target.checked)} + /> +
+ ) : null}
{routine.title} @@ -178,6 +203,12 @@ export function RoutineListRow({ > {runningRoutineId === routine.id ? "Running..." : "Run now"} + {extraMenuItems ? ( + <> + + {extraMenuItems} + + ) : null} onToggleEnabled(routine, enabled)} diff --git a/ui/src/components/folders/FolderControls.test.tsx b/ui/src/components/folders/FolderControls.test.tsx new file mode 100644 index 00000000000..c788348d899 --- /dev/null +++ b/ui/src/components/folders/FolderControls.test.tsx @@ -0,0 +1,110 @@ +// @vitest-environment jsdom + +import { flushSync } from "react-dom"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { FolderListResult } from "@paperclipai/shared"; +import { + FolderRail, + folderSearchValue, + normalizeFolderSelection, +} from "./FolderControls"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +function act(callback: () => void | Promise) { + let result: void | Promise | undefined; + flushSync(() => { + result = callback(); + }); + return result; +} + +const folderResult: FolderListResult = { + kind: "routine", + allCount: 4, + unfiledCount: 1, + folders: [ + { + id: "folder-reporting", + companyId: "company-1", + kind: "routine", + name: "Reporting", + color: "var(--folder-swatch-1)", + position: 0, + itemCount: 3, + createdAt: new Date("2026-07-01T00:00:00.000Z"), + updatedAt: new Date("2026-07-01T00:00:00.000Z"), + }, + ], +}; + +describe("FolderControls", () => { + let container: HTMLDivElement; + let root: Root | null; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = null; + }); + + afterEach(() => { + if (root) { + act(() => { + root?.unmount(); + }); + } + container.remove(); + document.body.innerHTML = ""; + }); + + it("normalizes URL selection values for folder persistence", () => { + expect(normalizeFolderSelection(null)).toBe("all"); + expect(normalizeFolderSelection("unfiled")).toBe("unfiled"); + expect(normalizeFolderSelection("folder-reporting")).toBe("folder-reporting"); + expect(folderSearchValue("all")).toBe(""); + expect(folderSearchValue("unfiled")).toBe("unfiled"); + expect(folderSearchValue("folder-reporting")).toBe("folder-reporting"); + }); + + it("renders All, user folders, and Unfiled with counts and selection callbacks", () => { + const onSelect = vi.fn(); + root = createRoot(container); + + act(() => { + root?.render( + , + ); + }); + + expect(container.textContent).toContain("All routines"); + expect(container.textContent).toContain("Reporting"); + expect(container.textContent).toContain("Unfiled"); + expect(container.textContent).toContain("4"); + expect(container.textContent).toContain("3"); + expect(container.textContent).toContain("1"); + + const reportingButton = Array.from(container.querySelectorAll("button")).find((button) => + button.textContent?.includes("Reporting"), + ); + expect(reportingButton).toBeTruthy(); + + act(() => { + reportingButton?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + }); + + expect(onSelect).toHaveBeenCalledWith("folder-reporting"); + }); +}); diff --git a/ui/src/components/folders/FolderControls.tsx b/ui/src/components/folders/FolderControls.tsx new file mode 100644 index 00000000000..d0bc08e5aec --- /dev/null +++ b/ui/src/components/folders/FolderControls.tsx @@ -0,0 +1,583 @@ +import { useEffect, useMemo, useState, type ReactNode } from "react"; +import { + Check, + ChevronDown, + Folder as FolderIcon, + MoreHorizontal, + Plus, + Search, + Trash2, +} from "lucide-react"; +import type { FolderKind, FolderListItem, FolderListResult } from "@paperclipai/shared"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Input } from "@/components/ui/input"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet"; +import { cn } from "@/lib/utils"; + +export type FolderSelection = "all" | "unfiled" | string; + +export const FOLDER_COLORS = [ + "var(--folder-swatch-1)", + "var(--folder-swatch-2)", + "var(--folder-swatch-3)", + "var(--folder-swatch-4)", + "var(--folder-swatch-5)", + "var(--folder-swatch-6)", +]; + +export function normalizeFolderSelection(value: string | null | undefined): FolderSelection { + if (!value) return "all"; + if (value === "unfiled") return "unfiled"; + return value; +} + +export function folderSearchValue(selection: FolderSelection): string { + return selection === "all" ? "" : selection === "unfiled" ? "unfiled" : selection; +} + +export function selectedFolderFromList( + folders: FolderListItem[], + selection: FolderSelection, +): FolderListItem | null { + if (selection === "all" || selection === "unfiled") return null; + return folders.find((folder) => folder.id === selection) ?? null; +} + +export function FolderSwatch({ + color, + className, +}: { + color: string | null | undefined; + className?: string; +}) { + return ( +
{card.forkedFrom ? ( @@ -815,7 +891,7 @@ function SkillCard({ card, onOpen }: { card: DiscoveryCard; onOpen: (card: Disco ) : null}
- + ); } @@ -887,6 +963,24 @@ export function DiscoveryGrid({ onScan, scanPending, scanStatus, + folderResult, + folderSelection = "all", + foldersLoading = false, + selectMode = false, + selectedSkillIds = [], + onFolderSelect, + onCreateFolder, + onRenameFolder, + onEditFolder, + onDeleteFolder, + onToggleSelectMode, + onSelectCard, + onMoveCard, + onCreateFolderAndMoveCard, + onMoveSelected, + onCreateFolderAndMoveSelected, + onClearSelected, + onOpenMobileFolders, }: { tab: DiscoveryTab; tabCounts: Record; @@ -911,6 +1005,24 @@ export function DiscoveryGrid({ onScan: () => void; scanPending: boolean; scanStatus: string | null; + folderResult?: FolderListResult | null; + folderSelection?: FolderSelection; + foldersLoading?: boolean; + selectMode?: boolean; + selectedSkillIds?: string[]; + onFolderSelect?: (selection: FolderSelection) => void; + onCreateFolder?: () => void; + onRenameFolder?: (folder: FolderListItem, name: string) => void; + onEditFolder?: (folder: FolderListItem) => void; + onDeleteFolder?: (folder: FolderListItem) => void; + onToggleSelectMode?: () => void; + onSelectCard?: (card: DiscoveryCard, selected: boolean) => void; + onMoveCard?: (card: DiscoveryCard, folderId: string | null) => void; + onCreateFolderAndMoveCard?: (card: DiscoveryCard) => void; + onMoveSelected?: (folderId: string | null) => void; + onCreateFolderAndMoveSelected?: () => void; + onClearSelected?: () => void; + onOpenMobileFolders?: () => void; }) { // Source filter (github / skills.sh / local / …) lives in the grid so it // narrows whatever the parent already filtered by tab/category/search (PAP-10907 E). @@ -930,15 +1042,33 @@ export function DiscoveryGrid({ [cards, sourceBadgeFilter], ); const sourceFilterActive = sourceBadgeFilter !== "all"; + const showFolderRail = Boolean(folderResult && folderResult.folders.length > 0 && onFolderSelect); + const folderActionsReady = Boolean(onCreateFolder && onRenameFolder && onEditFolder && onDeleteFolder); return ( // On desktop the store is bounded to the viewport so the category sidebar // and the results pane each scroll independently (PAP-10907). Mobile keeps // the natural page flow.
+ {showFolderRail && folderActionsReady ? ( +
+ +
+ ) : null} {/* Secondary category sidebar — the main app nav collapses to a rail while this is present (handled in Layout). */} -
@@ -949,7 +1140,49 @@ export function Routines() { ) : null} {activeTab === "routines" ? ( -
+
+ {showFolderRail ? ( + openCreateFolder()} + onRename={(folder, name) => updateFolder.mutate({ folderId: folder.id, payload: { name } })} + onEdit={(folder) => { + setFolderDialogTarget(folder); + setFolderDialogOpen(true); + }} + onDelete={setDeleteFolderTarget} + /> + ) : null} +
+ {routineViewState.groupBy === "folder" && hasRoutineFolders ? ( +
+ {folderSelection === "all" ? : ( +
+ + {folderSelection === "unfiled" ? "Unfiled" : activeFolder?.name ?? "Folder"} + {sortedRoutines.length} routine{sortedRoutines.length === 1 ? "" : "s"} +
+ )} +
+ ) : null} + {selectMode ? ( + void moveSelectedRoutines(folderId)} + onCreateAndMove={() => openCreateFolder(selectedRoutineIds)} + onClear={() => setSelectedRoutineIds([])} + onDone={() => { + setSelectMode(false); + setSelectedRoutineIds([]); + }} + /> + ) : null} {visibleRoutines.length === 0 ? (
+ ) : sortedRoutines.length === 0 ? ( +
+ + {folderSelection !== "all" ? ( +
+ +
+ ) : null} +
) : (
{routineSections.map((group) => { @@ -995,6 +1243,32 @@ export function Routines() { onRunNow={handleRunNow} onToggleEnabled={handleToggleEnabled} onToggleArchived={handleToggleArchived} + selectMode={selectMode} + selected={selectedRoutineIds.includes(routine.id)} + onSelectChange={(selectedRoutine, selected) => { + setSelectedRoutineIds((current) => + selected + ? Array.from(new Set([...current, selectedRoutine.id])) + : current.filter((id) => id !== selectedRoutine.id) + ); + }} + extraMenuItems={ + { + moveRoutineToFolder.mutate({ itemId: routine.id, folderId }); + pushToast({ + title: "Routine moved", + body: folderId + ? `Moved to ${routineFolders?.folders.find((folder) => folder.id === folderId)?.name ?? "folder"}.` + : "Moved to Unfiled.", + tone: "success", + }); + }} + onCreateAndMove={() => openCreateFolder([routine.id])} + /> + } /> ))} @@ -1003,9 +1277,44 @@ export function Routines() { })}
)} +
) : null} + { + if (folderDialogTarget) updateFolder.mutate({ folderId: folderDialogTarget.id, payload }); + else createFolder.mutate(payload); + }} + /> + { + if (!open) setDeleteFolderTarget(null); + }} + onConfirm={() => { + if (deleteFolderTarget) deleteFolder.mutate(deleteFolderTarget.id); + }} + /> + openCreateFolder()} + /> + { @@ -1027,3 +1336,13 @@ export function Routines() {
); } + +function FolderIconHeader({ label, count }: { label: string; count: number }) { + return ( +
+ + {label} + {count} routine{count === 1 ? "" : "s"} +
+ ); +} diff --git a/ui/src/pages/SkillStudio.test.tsx b/ui/src/pages/SkillStudio.test.tsx index 3ed0cb3afd3..a8afa4b62fc 100644 --- a/ui/src/pages/SkillStudio.test.tsx +++ b/ui/src/pages/SkillStudio.test.tsx @@ -368,6 +368,26 @@ describe("SkillStudio create mode", () => { await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith("/skills/studio/created-skill")); }); + it("forwards the folderId query param so the new skill is filed there (PAP-14086)", async () => { + routeState.search = "?folderId=folder-my-skills"; + + const node = await renderStudio(); + + await waitFor(() => expect(node.querySelector("#skill-name")).toBeTruthy()); + await inputValue(node.querySelector("#skill-name") as HTMLInputElement, "Code Review"); + await click(buttonsNamed(node, "Create skill")[0] as HTMLButtonElement); + + await waitFor(() => expect(mockCompanySkillsApi.create).toHaveBeenCalled()); + + expect(mockCompanySkillsApi.create).toHaveBeenCalledWith( + "company-1", + expect.objectContaining({ + name: "Code Review", + folderId: "folder-my-skills", + }), + ); + }); + it("keeps category commas and spaces editable while creating a skill", async () => { const node = await renderStudio(); diff --git a/ui/src/pages/SkillStudio.tsx b/ui/src/pages/SkillStudio.tsx index c77f5d72c43..953d43a4eed 100644 --- a/ui/src/pages/SkillStudio.tsx +++ b/ui/src/pages/SkillStudio.tsx @@ -277,6 +277,10 @@ export function SkillStudio() { const companyId = selectedCompanyId ?? ""; const isCreateMode = location.pathname.replace(/\/+$/, "").endsWith("/skills/studio/new"); const forkFromSkillId = isCreateMode ? searchParams.get("forkFrom")?.trim() || null : null; + // New skills created from a folder context (e.g. My Skills) carry their + // destination folder through this query param; without it the created skill + // silently lands in Unfiled (PAP-14086). + const newSkillFolderId = isCreateMode ? searchParams.get("folderId")?.trim() || null : null; const skillsQuery = useQuery({ queryKey: queryKeys.companySkills.list(companyId), @@ -333,6 +337,7 @@ export function SkillStudio() { skills={skillsQuery.data ?? []} skillsLoading={skillsQuery.isLoading} forkFromSkillId={forkFromSkillId} + folderId={newSkillFolderId} forkSkill={forkDetailQuery.data ?? null} forkLoading={forkDetailQuery.isLoading} forkError={forkDetailQuery.isError} @@ -373,6 +378,7 @@ function StudioCreateMode({ skills, skillsLoading, forkFromSkillId, + folderId, forkSkill, forkLoading, forkError, @@ -382,6 +388,7 @@ function StudioCreateMode({ skills: CompanySkillListItem[]; skillsLoading: boolean; forkFromSkillId: string | null; + folderId: string | null; forkSkill: CompanySkillDetail | null; forkLoading: boolean; forkError: boolean; @@ -403,6 +410,7 @@ function StudioCreateMode({ (forkSkill ? buildForkSkillDraft(forkSkill) : buildBlankSkillDraft()), - [forkSkill], - ); + const initialDraft = useMemo(() => { + const base = forkSkill ? buildForkSkillDraft(forkSkill) : buildBlankSkillDraft(); + // An explicit folder context from the URL wins over a fork source's folder + // so the new skill is filed where the user launched creation (PAP-14086). + return folderId ? { ...base, folderId } : base; + }, [forkSkill, folderId]); const [draft, setDraft] = useState(initialDraft); const [slugDirty, setSlugDirty] = useState(initialDraft.slug.trim().length > 0); const [categoryDraft, setCategoryDraft] = useState(initialDraft.categories.join(", ")); diff --git a/ui/storybook/stories/what-needs-me.stories.tsx b/ui/storybook/stories/what-needs-me.stories.tsx index 81745ddbb03..410aa9f1042 100644 --- a/ui/storybook/stories/what-needs-me.stories.tsx +++ b/ui/storybook/stories/what-needs-me.stories.tsx @@ -77,6 +77,7 @@ function item( workspace: null, detail: null, dismissal: null, + trainingExampleId: null, ...overrides, }; }