feat(server): port rescued data and reliability deltas

This commit is contained in:
Dotta committed 2026-07-16 17:56:41 +00:00
1 parent 8923dfbc7f
commit d104895c41
78 files changed
+5048 -71

No files matched your search

+6
View File
@@ -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:
@@ -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",
@@ -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");
@@ -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");
@@ -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
}
]
}
+3
View File
@@ -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),
@@ -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<DecisionTrainingSourceKind>().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<DecisionTrainingNotesHistoryEntry[]>().notNull().default([]),
decisionOutcome: text("decision_outcome"),
snapshot: jsonb("snapshot").$type<DecisionTrainingSnapshotV1>().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,
),
}),
);
+38
View File
@@ -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<FolderKind>().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,
),
}),
);
+2
View File
@@ -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";
@@ -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,
),
}),
);
+3
View File
@@ -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),
}),
+4
View File
@@ -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`,
+45
View File
@@ -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,
+1
View File
@@ -166,6 +166,7 @@ export interface AttentionItem {
project: AttentionProjectRef | null;
workspace: AttentionWorkspaceRef | null;
detail: AttentionItemDetail | null;
trainingExampleId: string | null;
}
export interface AttentionFeed {
@@ -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 {
@@ -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<string, unknown>;
comments: Array<Record<string, unknown>>;
runs: Array<Record<string, unknown>>;
decision: {
kind: DecisionTrainingSourceKind;
payload: Record<string, unknown>;
actor: Record<string, unknown> | 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;
}
+46
View File
@@ -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;
}
+32
View File
@@ -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 {
+93
View File
@@ -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;
}
+1
View File
@@ -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;
@@ -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),
+55
View File
@@ -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<typeof createFolderSchema>;
export type UpdateFolder = z.infer<typeof updateFolderSchema>;
export type MoveFolder = z.infer<typeof moveFolderSchema>;
export type MoveFolderItem = z.infer<typeof moveFolderItemSchema>;
+14
View File
@@ -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,
@@ -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),
@@ -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"]) {
@@ -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<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | 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");
});
});
@@ -57,6 +57,7 @@ vi.mock("../services/index.js", () => ({
issueService: () => mockIssueService,
companyService: () => mockCompanyService,
environmentService: () => mockEnvironmentService,
issueExecutionHealthService: () => ({ summarize: async () => null }),
issueReferenceService: () => mockIssueReferenceService,
logActivity: mockLogActivity,
workspaceOperationService: () => ({}),
@@ -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<typeof import("../middleware/index.js")>,
import("../routes/folders.js") as Promise<typeof import("../routes/folders.js")>,
]);
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();
});
});
@@ -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<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | 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);
});
});
@@ -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<string, unknown>;
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<string, unknown>;
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 });
}
});
});
@@ -85,6 +85,7 @@ function registerModuleMocks() {
companySkillService: () => ({
completeTestRunForIssue: vi.fn(async () => null),
}),
issueExecutionHealthService: () => ({ summarize: async () => null }),
documentAnnotationService: () => ({ remapOpenThreadsForDocument: async () => [] }),
documentService: () => ({}),
executionWorkspaceService: () => ({}),
@@ -119,6 +119,7 @@ const mockExternalObjectService = vi.hoisted(() => ({
getProjectSummary: vi.fn(async () => ({
authRequiredCount: 0,
byLiveness: {},
issueExecutionHealthService: () => ({ summarize: async () => null }),
byStatusCategory: {},
highestSeverity: "muted",
objects: [],
@@ -81,6 +81,7 @@ function registerRouteMocks() {
listCompanyIds: vi.fn(async () => ["company-1"]),
}),
issueApprovalService: () => ({}),
issueExecutionHealthService: () => ({ summarize: async () => null }),
issueReferenceService: () => ({
deleteDocumentSource: async () => undefined,
diffIssueReferenceSummary: () => ({
@@ -107,6 +107,7 @@ function registerServiceMocks() {
listCompanyIds: vi.fn(async () => ["company-1"]),
}),
issueApprovalService: () => ({}),
issueExecutionHealthService: () => ({ summarize: async () => null }),
issueReferenceService: () => ({
deleteDocumentSource: async () => undefined,
diffIssueReferenceSummary: () => ({
@@ -83,6 +83,7 @@ function registerModuleMocks() {
}));
vi.doMock("../telemetry.js", () => ({
issueExecutionHealthService: () => ({ summarize: async () => null }),
getTelemetryClient: vi.fn(() => ({ track: vi.fn() })),
}));
@@ -117,6 +117,7 @@ vi.mock("../services/instance-settings.js", () => ({
instanceSettingsService: () => mockInstanceSettingsService,
}));
issueExecutionHealthService: () => ({ summarize: async () => null }),
vi.mock("../services/issues.js", () => ({
issueService: () => mockIssueService,
}));
@@ -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);
@@ -54,6 +54,7 @@ vi.mock("../services/index.js", () => ({
listCompanyIds: vi.fn(),
}),
issueApprovalService: () => ({}),
issueExecutionHealthService: () => ({ summarize: async () => null }),
issueReferenceService: () => ({
deleteDocumentSource: async () => undefined,
diffIssueReferenceSummary: () => ({
@@ -126,6 +126,7 @@ function registerModuleMocks() {
heartbeatService: () => mockHeartbeatService,
instanceSettingsService: () => mockInstanceSettingsService,
issueApprovalService: () => ({}),
issueExecutionHealthService: () => ({ summarize: async () => null }),
issueReferenceService: () => ({
deleteDocumentSource: async () => undefined,
diffIssueReferenceSummary: () => ({
@@ -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<typeof import("../services/index.js")>()),
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<string, unknown> = {
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();
});
});
@@ -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> = {}): 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> = {}): 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> = {}): 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();
});
});
@@ -50,6 +50,7 @@ const mockIssueThreadInteractionService = vi.hoisted(() => ({
expireRequestConfirmationsSupersededByComment: vi.fn(async () => []),
}));
const mockIssueApprovalService = vi.hoisted(() => ({
issueExecutionHealthService: () => ({ summarize: async () => null }),
listApprovalsForIssue: vi.fn(async () => []),
}));
@@ -96,6 +96,7 @@ function registerModuleMocks() {
goalService: () => ({}),
heartbeatService: () => mockHeartbeatService,
issueApprovalService: () => ({}),
issueExecutionHealthService: () => ({ summarize: async () => null }),
issueReferenceService: () => mockIssueReferenceService,
issueRecoveryActionService: () => ({
getActiveForIssue: vi.fn(async () => null),
@@ -64,6 +64,7 @@ function registerModuleMocks() {
}),
instanceSettingsService: () => ({}),
issueApprovalService: () => ({}),
issueExecutionHealthService: () => ({ summarize: async () => null }),
issueReferenceService: () => ({
deleteDocumentSource: async () => undefined,
diffIssueReferenceSummary: () => ({
@@ -97,6 +97,7 @@ function registerModuleMocks() {
listCompanyIds: vi.fn(async () => ["company-1"]),
}),
issueApprovalService: () => ({}),
issueExecutionHealthService: () => ({ summarize: async () => null }),
issueReferenceService: () => ({
deleteDocumentSource: async () => undefined,
diffIssueReferenceSummary: () => ({
@@ -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: () => ({
@@ -120,6 +120,7 @@ function registerRouteMocks() {
heartbeatService: () => mockHeartbeatService,
instanceSettingsService: () => mockInstanceSettingsService,
issueApprovalService: () => ({}),
issueExecutionHealthService: () => ({ summarize: async () => null }),
issueReferenceService: () => ({
deleteDocumentSource: async () => undefined,
diffIssueReferenceSummary: () => ({
@@ -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,
@@ -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(
+4
View File
@@ -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));
+21
View File
@@ -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<string, string> = {
"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}`;
}
+168
View File
@@ -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;
}
+142
View File
@@ -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;
}
+1
View File
@@ -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";
+21 -1
View File
@@ -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,
});
+39 -1
View File
@@ -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<AttentionItem, "id" | "dismissalKey" | "rank" | "dismissal" | "project" | "workspace" | "detail"> & {
type CreateAttentionItemInput = Omit<AttentionItem, "id" | "dismissalKey" | "rank" | "dismissal" | "project" | "workspace" | "detail" | "trainingExampleId"> & {
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;
+5
View File
@@ -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,
+321
View File
@@ -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<string, unknown>;
actor: Record<string, unknown> | null;
exactRunId: string | null;
};
type ListInput = {
projectId?: string;
kind?: DecisionTrainingSourceKind;
author?: string;
q?: string;
};
function jsonCopy(value: unknown): Record<string, unknown> {
return JSON.parse(JSON.stringify(value)) as Record<string, unknown>;
}
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<string, unknown>;
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<SourceDecision> {
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 }),
};
}
+229
View File
@@ -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<number>`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<number>`count(*)::int`,
})
.from(companySkills)
.where(eq(companySkills.companyId, companyId))
.groupBy(companySkills.folderId);
}
async function list(companyId: string, kind: FolderKind): Promise<FolderListResult> {
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<string | null, number>();
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<Folder> {
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<Folder | null> {
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<Folder | null> {
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<Folder | null> {
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,
};
}
+4
View File
@@ -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";
@@ -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<string, unknown> | null;
executionState: Record<string, unknown> | 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<string, unknown> | null): string | null {
if (!executionState) return null;
const participant = executionState.currentParticipant;
if (!participant || typeof participant !== "object") return null;
const value = participant as Record<string, unknown>;
if (value.type !== "agent") return null;
return typeof value.agentId === "string" && value.agentId.length > 0 ? value.agentId : null;
}
function readPrincipalUserId(executionState: Record<string, unknown> | null): string | null {
if (!executionState) return null;
const participant = executionState.currentParticipant;
if (!participant || typeof participant !== "object") return null;
const value = participant as Record<string, unknown>;
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<IssueExecutionHealthRunInput | null> {
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<IssueExecutionHealthQueuedWakeInput[]> {
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<IssueExecutionHealthInteractionInput[]> {
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<IssueExecutionHealthApprovalInput[]> {
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<IssueExecutionHealthRecoveryInput[]> {
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<IssueExecutionHealthBlockerInput[]> {
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<string | null> {
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<IssueExecutionHealthSummary> {
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,
};
}
+52
View File
@@ -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<IssueRow, "id" | "companyId" | "updatedAt">,
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)
+32 -1
View File
@@ -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<string, RoutineTrigger[]>();
const rows = await db
@@ -2066,6 +2079,7 @@ export function routineService(
create: async (companyId: string, input: CreateRoutine, actor: Actor): Promise<Routine> => {
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,
+36
View File
@@ -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<FolderListResult>(`/companies/${encodeURIComponent(companyId)}/folders?kind=${kind}`),
create: (companyId: string, payload: CreateFolderRequest) =>
api.post<Folder>(`/companies/${encodeURIComponent(companyId)}/folders`, payload),
update: (companyId: string, folderId: string, payload: UpdateFolderRequest) =>
api.patch<Folder>(
`/companies/${encodeURIComponent(companyId)}/folders/${encodeURIComponent(folderId)}`,
payload,
),
moveFolder: (companyId: string, folderId: string, payload: MoveFolderRequest) =>
api.post<Folder>(
`/companies/${encodeURIComponent(companyId)}/folders/${encodeURIComponent(folderId)}/move`,
payload,
),
moveItem: (companyId: string, payload: MoveFolderItemRequest) =>
api.post<MoveFolderItemRequest>(
`/companies/${encodeURIComponent(companyId)}/folders/items/move`,
payload,
),
delete: (companyId: string, folderId: string) =>
api.delete<{ deleted: Folder }>(
`/companies/${encodeURIComponent(companyId)}/folders/${encodeURIComponent(folderId)}`,
),
};
@@ -112,6 +112,7 @@ function buildItem(overrides: Partial<AttentionItem> = {}): AttentionItem {
workspace: null,
detail: null,
dismissal: null,
trainingExampleId: null,
...overrides,
};
}
+31
View File
@@ -64,6 +64,10 @@ export function RoutineListRow<TRoutine extends RoutineListRowItem>({
disableToggle = false,
hideArchiveAction = false,
divider = true,
selected = false,
selectMode = false,
extraMenuItems,
onSelectChange,
onRunNow,
onToggleEnabled,
onToggleArchived,
@@ -83,6 +87,10 @@ export function RoutineListRow<TRoutine extends RoutineListRowItem>({
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<TRoutine extends RoutineListRowItem>({
divider ? " border-b border-border last:border-b-0" : ""
}`}
>
{selectMode ? (
<div
className="flex items-start pt-0.5 sm:pt-1"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
}}
>
<input
type="checkbox"
className="h-4 w-4 rounded border-border"
checked={selected}
aria-label={`Select ${routine.title}`}
onChange={(event) => onSelectChange?.(routine, event.target.checked)}
/>
</div>
) : null}
<div className="min-w-0 flex-1 space-y-1.5">
<div className="flex flex-wrap items-center gap-2">
<span className="truncate text-sm font-medium">{routine.title}</span>
@@ -178,6 +203,12 @@ export function RoutineListRow<TRoutine extends RoutineListRowItem>({
>
{runningRoutineId === routine.id ? "Running..." : "Run now"}
</DropdownMenuItem>
{extraMenuItems ? (
<>
<DropdownMenuSeparator />
{extraMenuItems}
</>
) : null}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => onToggleEnabled(routine, enabled)}
@@ -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<void>) {
let result: void | Promise<void> | 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(
<FolderRail
result={folderResult}
selection="all"
itemLabelPlural="routines"
allLabel="All routines"
onSelect={onSelect}
onCreate={vi.fn()}
onRename={vi.fn()}
onEdit={vi.fn()}
onDelete={vi.fn()}
/>,
);
});
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");
});
});
@@ -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 (
<span
aria-hidden="true"
className={cn("h-2.5 w-2.5 shrink-0 rounded-(--rad-3) border border-border/40", className)}
style={{ backgroundColor: color || "var(--folder-swatch-6)" }}
/>
);
}
function selectionLabel({
folders,
selection,
allLabel,
}: {
folders: FolderListItem[];
selection: FolderSelection;
allLabel: string;
}) {
if (selection === "all") return allLabel;
if (selection === "unfiled") return "Unfiled";
return folders.find((folder) => folder.id === selection)?.name ?? allLabel;
}
function selectionCount(result: FolderListResult | null | undefined, selection: FolderSelection) {
if (!result) return 0;
if (selection === "all") return result.allCount;
if (selection === "unfiled") return result.unfiledCount;
return result.folders.find((folder) => folder.id === selection)?.itemCount ?? 0;
}
export function FolderChip({
result,
selection,
allLabel,
onClick,
}: {
result: FolderListResult | null | undefined;
selection: FolderSelection;
allLabel: string;
onClick: () => void;
}) {
const folder = result ? selectedFolderFromList(result.folders, selection) : null;
return (
<Button variant="outline" size="sm" className="max-w-full justify-start" onClick={onClick}>
{selection === "all" ? <FolderIcon className="mr-2 h-3.5 w-3.5" /> : <FolderSwatch color={folder?.color} className="mr-2" />}
<span className="truncate">{selectionLabel({ folders: result?.folders ?? [], selection, allLabel })}</span>
<span className="ml-2 text-xs text-muted-foreground">{selectionCount(result, selection)}</span>
<ChevronDown className="ml-1 h-3.5 w-3.5 shrink-0" />
</Button>
);
}
export function FolderRail({
result,
selection,
itemLabelPlural,
allLabel,
loading = false,
onSelect,
onCreate,
onRename,
onEdit,
onDelete,
}: {
result: FolderListResult | null | undefined;
selection: FolderSelection;
itemLabelPlural: string;
allLabel: string;
loading?: boolean;
onSelect: (selection: FolderSelection) => void;
onCreate: () => void;
onRename: (folder: FolderListItem, name: string) => void;
onEdit: (folder: FolderListItem) => void;
onDelete: (folder: FolderListItem) => void;
}) {
const folders = result?.folders ?? [];
const [renamingFolderId, setRenamingFolderId] = useState<string | null>(null);
const [renameDraft, setRenameDraft] = useState("");
useEffect(() => {
if (!renamingFolderId) return;
const folder = folders.find((entry) => entry.id === renamingFolderId);
if (!folder) setRenamingFolderId(null);
}, [folders, renamingFolderId]);
function startRename(folder: FolderListItem) {
setRenamingFolderId(folder.id);
setRenameDraft(folder.name);
}
function commitRename(folder: FolderListItem) {
const name = renameDraft.trim();
if (name && name !== folder.name) onRename(folder, name);
setRenamingFolderId(null);
}
function renderVirtualRow(
key: FolderSelection,
label: string,
count: number,
icon: ReactNode,
) {
const active = selection === key;
return (
<button
type="button"
className={cn(
"grid w-full grid-cols-(--gtc-folder-row) items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition-colors hover:bg-accent/40",
active ? "bg-accent/60 text-foreground" : "text-muted-foreground",
)}
aria-current={active ? "page" : undefined}
onClick={() => onSelect(key)}
>
<span className="h-4 w-4">{icon}</span>
<span className="truncate">{label}</span>
<span className="text-xs text-muted-foreground">{count}</span>
</button>
);
}
return (
<nav aria-label={`${itemLabelPlural} folders`} className="hidden w-(--sz-folder-rail) shrink-0 border-r border-border pr-3 md:block">
<div className="mb-2 flex items-center justify-between gap-2">
<div className="text-(length:--text-micro) font-medium uppercase tracking-wide text-muted-foreground">Folders</div>
<Button variant="ghost" size="icon-sm" title="New folder" onClick={onCreate}>
<Plus className="h-3.5 w-3.5" />
</Button>
</div>
{loading ? (
<div className="space-y-2">
<div className="h-7 rounded-md bg-muted/60" />
<div className="h-7 rounded-md bg-muted/40" />
<div className="h-7 rounded-md bg-muted/30" />
</div>
) : (
<div className="space-y-0.5">
{renderVirtualRow("all", allLabel, result?.allCount ?? 0, <FolderIcon className="h-3.5 w-3.5" />)}
{folders.map((folder) => {
const active = selection === folder.id;
const isRenaming = renamingFolderId === folder.id;
return (
<div
key={folder.id}
className={cn(
"group grid grid-cols-(--gtc-folder-action-row) items-center gap-2 rounded-md px-2 py-1 text-sm transition-colors hover:bg-accent/40",
active ? "bg-accent/60 text-foreground" : "text-muted-foreground",
)}
>
<span className="h-4 w-4" />
<button
type="button"
className="flex min-w-0 items-center gap-2 text-left"
aria-current={active ? "page" : undefined}
onClick={() => onSelect(folder.id)}
onDoubleClick={() => startRename(folder)}
>
<FolderSwatch color={folder.color} />
{isRenaming ? (
<input
value={renameDraft}
onChange={(event) => setRenameDraft(event.target.value)}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => {
if (event.key === "Enter") commitRename(folder);
if (event.key === "Escape") setRenamingFolderId(null);
}}
onBlur={() => commitRename(folder)}
className="h-6 min-w-0 flex-1 rounded-sm border border-border bg-background px-1 text-sm outline-none"
autoFocus
/>
) : (
<span className="truncate">{folder.name}</span>
)}
</button>
<span className="text-xs text-muted-foreground">{folder.itemCount}</span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className="h-6 w-6 opacity-0 group-hover:opacity-100 data-[state=open]:opacity-100"
aria-label={`Folder actions for ${folder.name}`}
>
<MoreHorizontal className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={() => startRename(folder)}>Rename</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onEdit(folder)}>Edit color</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onSelect={() => onDelete(folder)}>
<Trash2 className="h-3.5 w-3.5" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
})}
<div className="px-2 pb-1 pt-3 text-(length:--text-micro) font-medium uppercase tracking-wide text-muted-foreground">
System
</div>
{renderVirtualRow("unfiled", "Unfiled", result?.unfiledCount ?? 0, <FolderSwatch color={null} className="mt-0.5" />)}
</div>
)}
</nav>
);
}
export function MobileFolderSheet({
open,
onOpenChange,
result,
selection,
allLabel,
itemLabelPlural,
onSelect,
onCreate,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
result: FolderListResult | null | undefined;
selection: FolderSelection;
allLabel: string;
itemLabelPlural: string;
onSelect: (selection: FolderSelection) => void;
onCreate: () => void;
}) {
function select(next: FolderSelection) {
onSelect(next);
onOpenChange(false);
}
const folders = result?.folders ?? [];
const rows = [
{ id: "all" as FolderSelection, label: allLabel, count: result?.allCount ?? 0, color: null },
...folders.map((folder) => ({ id: folder.id, label: folder.name, count: folder.itemCount, color: folder.color })),
{ id: "unfiled" as FolderSelection, label: "Unfiled", count: result?.unfiledCount ?? 0, color: null },
];
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="bottom" className="max-h-(--sz-80dvh) rounded-t-lg pb-4">
<SheetHeader className="border-b border-border px-4 py-3">
<SheetTitle>{itemLabelPlural} folders</SheetTitle>
</SheetHeader>
<div className="overflow-y-auto px-3">
{rows.map((row) => (
<button
key={row.id}
type="button"
className="flex w-full items-center gap-2 rounded-md px-2 py-2 text-left text-sm hover:bg-accent/50"
onClick={() => select(row.id)}
>
{row.id === "all" ? <FolderIcon className="h-3.5 w-3.5 text-muted-foreground" /> : <FolderSwatch color={row.color} />}
<span className="min-w-0 flex-1 truncate">{row.label}</span>
<span className="text-xs text-muted-foreground">{row.count}</span>
{selection === row.id ? <Check className="h-3.5 w-3.5" /> : null}
</button>
))}
</div>
<div className="border-t border-border px-4 pt-3">
<Button size="sm" variant="outline" className="w-full" onClick={onCreate}>
<Plus className="mr-2 h-3.5 w-3.5" />
New folder
</Button>
</div>
</SheetContent>
</Sheet>
);
}
export function MoveToMenu({
folders,
currentFolderId,
onMove,
onCreateAndMove,
}: {
folders: FolderListItem[];
currentFolderId: string | null | undefined;
onMove: (folderId: string | null) => void;
onCreateAndMove: () => void;
}) {
const [query, setQuery] = useState("");
const visibleFolders = useMemo(() => {
const lowered = query.trim().toLowerCase();
if (!lowered) return folders;
return folders.filter((folder) => folder.name.toLowerCase().includes(lowered));
}, [folders, query]);
return (
<DropdownMenuSub>
<DropdownMenuSubTrigger>Move to...</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-56">
<div className="flex items-center gap-2 px-2 py-1.5">
<Search className="h-3.5 w-3.5 text-muted-foreground" />
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={(event) => event.stopPropagation()}
placeholder="Search folders"
className="h-7 min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
/>
</div>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => onMove(null)}>
<FolderSwatch color={null} />
Unfiled
{currentFolderId == null ? <Check className="ml-auto h-3.5 w-3.5" /> : null}
</DropdownMenuItem>
{visibleFolders.map((folder) => (
<DropdownMenuItem key={folder.id} onSelect={() => onMove(folder.id)}>
<FolderSwatch color={folder.color} />
<span className="min-w-0 flex-1 truncate">{folder.name}</span>
{currentFolderId === folder.id ? <Check className="ml-auto h-3.5 w-3.5" /> : null}
</DropdownMenuItem>
))}
{visibleFolders.length === 0 ? (
<div className="px-2 py-2 text-xs text-muted-foreground">No folders match.</div>
) : null}
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={onCreateAndMove}>
<Plus className="h-3.5 w-3.5" />
New folder...
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
);
}
export function FolderFormDialog({
open,
kind,
folder,
onOpenChange,
onSubmit,
pending = false,
}: {
open: boolean;
kind: FolderKind;
folder: FolderListItem | null;
onOpenChange: (open: boolean) => void;
onSubmit: (payload: { name: string; color: string | null }) => void;
pending?: boolean;
}) {
const [name, setName] = useState("");
const [color, setColor] = useState<string | null>(FOLDER_COLORS[0] ?? null);
const isEdit = Boolean(folder);
useEffect(() => {
if (!open) return;
setName(folder?.name ?? "");
setColor(folder?.color ?? FOLDER_COLORS[0] ?? null);
}, [folder, open]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{isEdit ? "Edit folder" : "Create folder"}</DialogTitle>
<DialogDescription>
{kind === "routine" ? "Organize routines in this company." : "Organize installed company skills."}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="folder-name">Name</label>
<Input
id="folder-name"
value={name}
onChange={(event) => setName(event.target.value)}
autoFocus
onKeyDown={(event) => {
if (event.key === "Enter" && name.trim()) onSubmit({ name: name.trim(), color });
}}
/>
</div>
<div className="space-y-2">
<div className="text-sm font-medium">Color</div>
<div className="flex flex-wrap gap-2">
{FOLDER_COLORS.map((swatch) => (
<button
key={swatch}
type="button"
aria-label={`Use folder color ${swatch}`}
className={cn(
"h-7 w-7 rounded-md border border-border",
color === swatch && "ring-2 ring-ring ring-offset-2 ring-offset-background",
)}
style={{ backgroundColor: swatch }}
onClick={() => setColor(swatch)}
/>
))}
<button
type="button"
className={cn(
"h-7 rounded-md border border-border px-2 text-xs text-muted-foreground",
color == null && "ring-2 ring-ring ring-offset-2 ring-offset-background",
)}
onClick={() => setColor(null)}
>
None
</button>
</div>
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={pending}>
Cancel
</Button>
<Button onClick={() => onSubmit({ name: name.trim(), color })} disabled={pending || !name.trim()}>
{pending ? "Saving..." : isEdit ? "Save" : "Create folder"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export function DeleteFolderDialog({
open,
folder,
itemLabelPlural,
onOpenChange,
onConfirm,
pending = false,
}: {
open: boolean;
folder: FolderListItem | null;
itemLabelPlural: string;
onOpenChange: (open: boolean) => void;
onConfirm: () => void;
pending?: boolean;
}) {
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete folder</AlertDialogTitle>
<AlertDialogDescription>
The {folder?.itemCount ?? 0} {itemLabelPlural} in this folder won't be deleted. They'll move to Unfiled.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={pending}>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
disabled={pending || !folder}
onClick={(event) => {
event.preventDefault();
onConfirm();
}}
>
{pending ? "Deleting..." : "Delete folder"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
export function BulkBar({
selectedCount,
folders,
onMove,
onCreateAndMove,
onClear,
onDone,
}: {
selectedCount: number;
folders: FolderListItem[];
onMove: (folderId: string | null) => void;
onCreateAndMove: () => void;
onClear: () => void;
onDone: () => void;
}) {
if (selectedCount === 0) return null;
return (
<div className="sticky top-2 z-10 flex flex-wrap items-center gap-2 rounded-md border border-border bg-background/95 px-3 py-2 shadow-sm backdrop-blur">
<span className="mr-auto text-sm text-muted-foreground">{selectedCount} selected</span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" variant="outline">Move to...</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<MoveToMenu
folders={folders}
currentFolderId={undefined}
onMove={onMove}
onCreateAndMove={onCreateAndMove}
/>
</DropdownMenuContent>
</DropdownMenu>
<Button size="sm" variant="ghost" onClick={onClear}>Deselect all</Button>
<Button size="sm" onClick={onDone}>Done</Button>
</div>
);
}
+10
View File
@@ -163,6 +163,12 @@
--status-task-done: #22c55e;
--status-task-blocked: #dc2626;
--status-task-cancelled: #a8aeb2;
--folder-swatch-1: var(--status-task-in_progress);
--folder-swatch-2: var(--status-task-in_review);
--folder-swatch-3: var(--status-task-done);
--folder-swatch-4: var(--status-agent-running);
--folder-swatch-5: var(--status-task-todo);
--folder-swatch-6: var(--status-task-backlog);
/* Task status ICON hues (PAP-238 3b) — AA-tuned (every glyph ≥ 3:1 against
its surface, WCAG 2.1 AA, both modes). Distinct from the
@@ -1692,6 +1698,8 @@ span.paperclip-mention-chip[data-mention-kind="external-object"] {
--sz-calc-41: min(520px,calc(100vw - 2rem)); /* Extracted from ui/src/pages/Secrets.tsx (w-[min(520px,calc(100vw-2rem))]). */
--sz-calc-42: min(80vh,34rem); /* Extracted from ui/src/pages/Secrets.tsx (max-h-[min(80vh,34rem)]). */
--sz-14rem: 14rem; /* Extracted from ui/src/pages/TeamCatalog.tsx (max-w-[14rem]). */
--sz-folder-rail: 13.25rem;
--sz-80dvh: 80dvh;
--rad-3: 3px; /* Extracted from ui/src/components/ExternalObjectPill.tsx (ring-[3px]). */
--rad-4: 4px; /* Extracted from ui/src/components/IssueChatThread.tsx (rounded-br-[4px]). */
--rad-2: 2px; /* Extracted from ui/src/components/SidebarCompanyMenu.tsx (ring-[2px]). */
@@ -1824,6 +1832,8 @@ span.paperclip-mention-chip[data-mention-kind="external-object"] {
--gtc-62: 1fr minmax(0,18rem);
--gtc-63: 280px 1fr;
--gtc-64: minmax(0,20rem) 1fr;
--gtc-folder-row: 1rem minmax(0,1fr) auto;
--gtc-folder-action-row: 1rem minmax(0,1fr) auto auto;
--gtr-1: auto auto; /* Extracted from ui/src/components/ui/card.tsx (grid-rows-[auto_auto]). */
--gtr-2: 1fr; /* Extracted from ui/src/pages/CompanySkills.tsx (grid-rows-[1fr]). */
--gtr-3: 0fr; /* Extracted from ui/src/pages/CompanySkills.tsx (grid-rows-[0fr]). */
+1
View File
@@ -47,6 +47,7 @@ function buildItem(overrides: Partial<AttentionItem> = {}): AttentionItem {
workspace: null,
detail: null,
dismissal: null,
trainingExampleId: null,
...overrides,
};
}
+3
View File
@@ -193,6 +193,9 @@ export const queryKeys = {
documentAnnotations: (routineId: string, key: "description", status: "open" | "resolved" | "all" = "all") =>
["routines", "document-annotations", routineId, key, status] as const,
},
folders: {
list: (companyId: string, kind: string) => ["folders", companyId, kind] as const,
},
pipelines: {
list: (companyId: string) => ["pipelines", companyId] as const,
detail: (pipelineId: string) => ["pipelines", "detail", pipelineId] as const,
+2
View File
@@ -21,6 +21,7 @@ export type SkillCreateDraft = {
sharingScope: Exclude<CompanySkillSharingScope, "public_link">;
forkedFromSkillId: string | null;
forkedFromName: string | null;
folderId?: string | null;
};
export function normalizeSkillDraftSlug(value: string) {
@@ -128,5 +129,6 @@ export function skillCreateDraftToPayload(draft: SkillCreateDraft): CompanySkill
categories: draft.categories,
sharingScope: draft.sharingScope,
forkedFromSkillId: draft.forkedFromSkillId,
folderId: draft.folderId ?? null,
};
}
+376 -7
View File
@@ -22,6 +22,7 @@ import type {
CompanySkillVersion,
} from "@paperclipai/shared";
import { companySkillsApi } from "../api/companySkills";
import { foldersApi } from "../api/folders";
import { agentsApi } from "../api/agents";
import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
@@ -90,6 +91,18 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
import {
BulkBar,
DeleteFolderDialog,
FolderChip,
FolderFormDialog,
FolderRail,
MobileFolderSheet,
MoveToMenu,
folderSearchValue,
normalizeFolderSelection,
type FolderSelection,
} from "../components/folders/FolderControls";
import {
AlertTriangle,
ArrowUpCircle,
@@ -116,6 +129,7 @@ import {
Lock,
ExternalLink,
FlaskConical,
MoreHorizontal,
Paperclip,
Pause,
Pencil,
@@ -133,6 +147,7 @@ import {
History,
XOctagon,
} from "lucide-react";
import type { FolderListItem, FolderListResult } from "@paperclipai/shared";
type SkillTreeNode = {
name: string;
@@ -539,6 +554,7 @@ const DISCOVERY_SORTS: DiscoverySort[] = ["agents", "stars", "forks", "recent",
export type DiscoveryCard = {
key: string;
skillId: string | null;
folderId?: string | null;
catalogRef: string | null;
name: string;
slug: string;
@@ -612,6 +628,7 @@ function buildDiscoveryCards(
cards.push({
key: skill.key,
skillId: skill.id,
folderId: skill.folderId ?? null,
catalogRef: catalogMatch ? catalogMatch.id : null,
name: skill.name,
slug: skill.slug,
@@ -640,6 +657,7 @@ function buildDiscoveryCards(
cards.push({
key: entry.key,
skillId: null,
folderId: null,
catalogRef: entry.id,
name: entry.name,
slug: entry.slug,
@@ -732,11 +750,36 @@ function SkillCategoryChip({ label }: { label: string }) {
);
}
function SkillCard({ card, onOpen }: { card: DiscoveryCard; onOpen: (card: DiscoveryCard) => void }) {
function SkillCard({
card,
folders,
selected = false,
selectMode = false,
onOpen,
onSelectChange,
onMove,
onCreateFolderAndMove,
}: {
card: DiscoveryCard;
folders?: FolderListItem[];
selected?: boolean;
selectMode?: boolean;
onOpen: (card: DiscoveryCard) => void;
onSelectChange?: (card: DiscoveryCard, selected: boolean) => void;
onMove?: (card: DiscoveryCard, folderId: string | null) => void;
onCreateFolderAndMove?: (card: DiscoveryCard) => void;
}) {
return (
<button
type="button"
<div
onClick={() => onOpen(card)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onOpen(card);
}
}}
role="button"
tabIndex={0}
className={cn(
// Quiet interactive-card affordance (DECISION-SHEET: one recipe for
// clickable cards): pointer cursor, border darkens, slight lift.
@@ -745,6 +788,16 @@ function SkillCard({ card, onOpen }: { card: DiscoveryCard; onOpen: (card: Disco
)}
>
<div className="flex items-start gap-3">
{selectMode && card.installed ? (
<input
type="checkbox"
className="mt-1 h-4 w-4 rounded border-border"
checked={selected}
aria-label={`Select ${card.name}`}
onClick={(event) => event.stopPropagation()}
onChange={(event) => onSelectChange?.(card, event.target.checked)}
/>
) : null}
<SkillCardIcon card={card} />
<div className="min-w-0 flex-1">
<div className="truncate font-mono text-sm font-medium text-foreground">{card.name}</div>
@@ -762,6 +815,29 @@ function SkillCard({ card, onOpen }: { card: DiscoveryCard; onOpen: (card: Disco
</span>
);
})()}
{card.installed && folders && onMove && onCreateFolderAndMove ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className="-mr-1 -mt-1 opacity-70 group-hover:opacity-100"
aria-label={`More actions for ${card.name}`}
onClick={(event) => event.stopPropagation()}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" onClick={(event) => event.stopPropagation()}>
<MoveToMenu
folders={folders}
currentFolderId={card.folderId}
onMove={(folderId) => onMove(card, folderId)}
onCreateAndMove={() => onCreateFolderAndMove(card)}
/>
</DropdownMenuContent>
</DropdownMenu>
) : null}
</div>
{card.forkedFrom ? (
@@ -815,7 +891,7 @@ function SkillCard({ card, onOpen }: { card: DiscoveryCard; onOpen: (card: Disco
) : null}
</div>
</div>
</button>
</div>
);
}
@@ -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<DiscoveryTab, number>;
@@ -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.
<div className="flex min-h-(--sz-calc-30) md:h-(--sz-calc-33) md:min-h-0 md:overflow-hidden">
{showFolderRail && folderActionsReady ? (
<div className="hidden shrink-0 pl-4 pt-4 md:block">
<FolderRail
result={folderResult}
selection={folderSelection}
itemLabelPlural="skills"
allLabel="All skills"
loading={foldersLoading}
onSelect={onFolderSelect!}
onCreate={onCreateFolder!}
onRename={onRenameFolder!}
onEdit={onEditFolder!}
onDelete={onDeleteFolder!}
/>
</div>
) : null}
{/* Secondary category sidebar — the main app nav collapses to a rail while
this is present (handled in Layout). */}
<aside className="hidden w-60 shrink-0 flex-col overflow-hidden border-r border-border md:flex">
<aside className={cn("hidden w-60 shrink-0 flex-col overflow-hidden border-r border-border md:flex", showFolderRail && "md:hidden")}>
<div className="border-b border-border px-4 py-4">
<h2 className="text-sm font-semibold text-foreground">Skills Store</h2>
<p className="text-xs text-muted-foreground">Discover, install, fork, share</p>
@@ -1051,6 +1181,27 @@ export function DiscoveryGrid({
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{folderResult && onFolderSelect ? (
<div className="w-full md:hidden">
<FolderChip
result={folderResult}
selection={folderSelection}
allLabel="All skills"
onClick={onOpenMobileFolders ?? (() => undefined)}
/>
</div>
) : null}
{onCreateFolder ? (
<Button variant="outline" size="sm" onClick={onCreateFolder}>
<Plus className="mr-1 h-3.5 w-3.5" />
New folder
</Button>
) : null}
{onToggleSelectMode ? (
<Button variant="ghost" size="sm" onClick={onToggleSelectMode}>
{selectMode ? "Done" : "Select"}
</Button>
) : null}
</div>
{/* Mobile category selector (sidebar is hidden below md) */}
@@ -1107,6 +1258,18 @@ export function DiscoveryGrid({
{/* Grid body */}
<div className="min-h-0 flex-1 overflow-auto p-4">
{scanStatus ? <p className="mb-3 text-xs text-muted-foreground">{scanStatus}</p> : null}
{selectMode && onMoveSelected && onCreateFolderAndMoveSelected && onClearSelected ? (
<div className="mb-3">
<BulkBar
selectedCount={selectedSkillIds.length}
folders={folderResult?.folders ?? []}
onMove={onMoveSelected}
onCreateAndMove={onCreateFolderAndMoveSelected}
onClear={onClearSelected}
onDone={onToggleSelectMode ?? onClearSelected}
/>
</div>
) : null}
{loading ? (
<PageSkeleton variant="list" />
) : error ? (
@@ -1156,7 +1319,17 @@ export function DiscoveryGrid({
</p>
<div className="grid gap-3 [grid-template-columns:repeat(auto-fill,minmax(19rem,1fr))]">
{sourceFilteredCards.map((card) => (
<SkillCard key={card.key} card={card} onOpen={onOpenCard} />
<SkillCard
key={card.key}
card={card}
folders={folderResult?.folders}
selected={selectedSkillIds.includes(card.skillId ?? "")}
selectMode={selectMode}
onOpen={onOpenCard}
onSelectChange={onSelectCard}
onMove={onMoveCard}
onCreateFolderAndMove={onCreateFolderAndMoveCard}
/>
))}
</div>
</>
@@ -3550,6 +3723,13 @@ export function CompanySkills() {
const [createError, setCreateError] = useState<string | null>(null);
const [importDialogOpen, setImportDialogOpen] = useState(false);
const [importFromProjectOpen, setImportFromProjectOpen] = useState(false);
const [folderDialogOpen, setFolderDialogOpen] = useState(false);
const [folderDialogTarget, setFolderDialogTarget] = useState<FolderListItem | null>(null);
const [deleteFolderTarget, setDeleteFolderTarget] = useState<FolderListItem | null>(null);
const [mobileFoldersOpen, setMobileFoldersOpen] = useState(false);
const [selectMode, setSelectMode] = useState(false);
const [selectedSkillIds, setSelectedSkillIds] = useState<string[]>([]);
const [moveAfterCreateSkillIds, setMoveAfterCreateSkillIds] = useState<string[]>([]);
const parsedRoute = useMemo(() => parseSkillRoute(routePath), [routePath]);
const isStudioNew = routePath === "studio/new";
const routeSkillToken = isStudioNew ? null : parsedRoute.skillToken;
@@ -3575,6 +3755,7 @@ export function CompanySkills() {
// Discovery grid owns `/skills` whenever no specific skill or catalog entry is
// selected; selecting either drops into the existing master/detail surfaces.
const isDiscovery = !isStudioNew && !routeSkillToken && !selectedCatalogRef;
const folderSelection = normalizeFolderSelection(searchParams.get("folder"));
function setDiscoveryTab(tab: DiscoveryTab) {
setSearchParams((current) => {
@@ -3582,10 +3763,29 @@ export function CompanySkills() {
if (tab === "all") params.delete("tab");
else params.set("tab", tab);
params.delete("category");
if (tab !== "installed") params.delete("folder");
return params;
});
}
function setFolderSelection(selection: FolderSelection) {
setSearchParams((current) => {
const params = new URLSearchParams(current);
params.set("tab", "installed");
params.delete("category");
const value = folderSearchValue(selection);
if (value) params.set("folder", value);
else params.delete("folder");
return params;
});
}
function openCreateFolder(moveSkillIds: string[] = []) {
setMoveAfterCreateSkillIds(moveSkillIds);
setFolderDialogTarget(null);
setFolderDialogOpen(true);
}
function setDetailTab(tab: SkillDetailTab) {
setSearchParams((current) => {
const params = new URLSearchParams(current);
@@ -3655,6 +3855,11 @@ export function CompanySkills() {
queryFn: () => companySkillsApi.list(selectedCompanyId!),
enabled: Boolean(selectedCompanyId),
});
const skillFoldersQuery = useQuery({
queryKey: queryKeys.folders.list(selectedCompanyId ?? "", "skill"),
queryFn: () => foldersApi.list(selectedCompanyId!, "skill"),
enabled: Boolean(selectedCompanyId && isDiscovery && discoveryTab === "installed"),
});
const installedSkills = skillsQuery.data ?? [];
const routeResolution = useMemo(
@@ -4034,10 +4239,14 @@ export function CompanySkills() {
const visibleDiscoveryCards = useMemo(() => {
const filtered = discoveryTabCards.filter((card) => {
if (discoveryCategory && !card.categories.includes(discoveryCategory)) return false;
if (discoveryTab === "installed") {
if (folderSelection === "unfiled" && card.folderId) return false;
if (folderSelection !== "all" && folderSelection !== "unfiled" && card.folderId !== folderSelection) return false;
}
return discoveryMatchesSearch(card, discoverySearch.trim());
});
return sortDiscoveryCards(filtered, discoverySort, discoveryTab !== "bundled");
}, [discoveryTabCards, discoveryCategory, discoverySearch, discoverySort, discoveryTab]);
}, [discoveryTabCards, discoveryCategory, discoverySearch, discoverySort, discoveryTab, folderSelection]);
const selectedCatalogSkill = catalogDetailQuery.data
?? (catalogListQuery.data ?? []).find((entry) => entry.id === selectedCatalogRef || entry.key === selectedCatalogRef)
@@ -4105,6 +4314,87 @@ export function CompanySkills() {
policyDenial.capture(error, "Installing this skill");
},
});
const createFolder = useMutation({
mutationFn: (payload: { name: string; color: string | null }) =>
foldersApi.create(selectedCompanyId!, { kind: "skill", ...payload }),
onSuccess: async (folder) => {
setFolderDialogOpen(false);
setFolderDialogTarget(null);
await queryClient.invalidateQueries({ queryKey: queryKeys.folders.list(selectedCompanyId!, "skill") });
if (moveAfterCreateSkillIds.length > 0) {
const ids = moveAfterCreateSkillIds;
setMoveAfterCreateSkillIds([]);
await Promise.all(ids.map((itemId) =>
foldersApi.moveItem(selectedCompanyId!, { kind: "skill", itemId, folderId: folder.id })
));
await Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.companySkills.list(selectedCompanyId!) }),
queryClient.invalidateQueries({ queryKey: queryKeys.folders.list(selectedCompanyId!, "skill") }),
]);
} else {
setFolderSelection(folder.id);
}
pushToast({ tone: "success", title: "Folder created", body: folder.name });
},
onError: (error) => {
pushToast({
tone: "error",
title: "Folder save failed",
body: error instanceof Error ? error.message : "Failed to save folder.",
});
},
});
const updateFolder = useMutation({
mutationFn: ({ folderId, payload }: { folderId: string; payload: { name?: string; color?: string | null } }) =>
foldersApi.update(selectedCompanyId!, folderId, payload),
onSuccess: async () => {
setFolderDialogOpen(false);
setFolderDialogTarget(null);
await queryClient.invalidateQueries({ queryKey: queryKeys.folders.list(selectedCompanyId!, "skill") });
},
});
const deleteFolder = useMutation({
mutationFn: (folderId: string) => foldersApi.delete(selectedCompanyId!, folderId),
onSuccess: async (_, folderId) => {
if (folderSelection === folderId) setFolderSelection("all");
setDeleteFolderTarget(null);
await Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.companySkills.list(selectedCompanyId!) }),
queryClient.invalidateQueries({ queryKey: queryKeys.folders.list(selectedCompanyId!, "skill") }),
]);
pushToast({ tone: "success", title: "Folder deleted", body: "Skills moved to Unfiled." });
},
});
const moveSkillToFolder = useMutation({
mutationFn: ({ itemId, folderId }: { itemId: string; folderId: string | null }) =>
foldersApi.moveItem(selectedCompanyId!, { kind: "skill", itemId, folderId }),
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.companySkills.list(selectedCompanyId!) }),
queryClient.invalidateQueries({ queryKey: queryKeys.folders.list(selectedCompanyId!, "skill") }),
]);
},
onError: (error) => {
pushToast({
tone: "error",
title: "Move failed",
body: error instanceof Error ? error.message : "Failed to move skill.",
});
},
});
async function moveSelectedSkills(folderId: string | null) {
const ids = selectedSkillIds;
if (ids.length === 0) return;
await Promise.all(ids.map((itemId) => foldersApi.moveItem(selectedCompanyId!, { kind: "skill", itemId, folderId })));
setSelectedSkillIds([]);
setSelectMode(false);
await Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.companySkills.list(selectedCompanyId!) }),
queryClient.invalidateQueries({ queryKey: queryKeys.folders.list(selectedCompanyId!, "skill") }),
]);
pushToast({ tone: "success", title: "Skills moved", body: `${ids.length} skill${ids.length === 1 ? "" : "s"} filed.` });
}
const eligibleAgentsForAttach = useMemo(() => {
const data = agentsQuery.data ?? [];
@@ -4272,6 +4562,8 @@ export function CompanySkills() {
const studioDescription = studioForkFromId
? "Review the fork metadata and create an editable company copy."
: "Create an editable company skill in the Paperclip workspace.";
const skillFolderResult = skillFoldersQuery.data ?? null;
const showInstalledFolders = isDiscovery && discoveryTab === "installed";
return (
<>
@@ -4452,6 +4744,40 @@ export function CompanySkills() {
/>
) : null}
<FolderFormDialog
open={folderDialogOpen}
kind="skill"
folder={folderDialogTarget}
pending={createFolder.isPending || updateFolder.isPending}
onOpenChange={setFolderDialogOpen}
onSubmit={(payload) => {
if (folderDialogTarget) updateFolder.mutate({ folderId: folderDialogTarget.id, payload });
else createFolder.mutate(payload);
}}
/>
<DeleteFolderDialog
open={deleteFolderTarget !== null}
folder={deleteFolderTarget}
itemLabelPlural="skills"
pending={deleteFolder.isPending}
onOpenChange={(open) => {
if (!open) setDeleteFolderTarget(null);
}}
onConfirm={() => {
if (deleteFolderTarget) deleteFolder.mutate(deleteFolderTarget.id);
}}
/>
<MobileFolderSheet
open={mobileFoldersOpen}
onOpenChange={setMobileFoldersOpen}
result={skillFolderResult}
selection={folderSelection}
allLabel="All skills"
itemLabelPlural="Skills"
onSelect={setFolderSelection}
onCreate={() => openCreateFolder()}
/>
{isStudioNew ? (
<div className="min-h-(--sz-calc-30)">
<div className="border-b border-border px-4 py-5">
@@ -4508,6 +4834,49 @@ export function CompanySkills() {
onScan={() => scanProjects.mutate()}
scanPending={scanProjects.isPending}
scanStatus={scanStatusMessage}
folderResult={showInstalledFolders ? skillFolderResult : null}
folderSelection={folderSelection}
foldersLoading={skillFoldersQuery.isLoading}
selectMode={showInstalledFolders && selectMode}
selectedSkillIds={selectedSkillIds}
onFolderSelect={showInstalledFolders ? setFolderSelection : undefined}
onOpenMobileFolders={showInstalledFolders ? () => setMobileFoldersOpen(true) : undefined}
onCreateFolder={showInstalledFolders ? () => openCreateFolder() : undefined}
onRenameFolder={showInstalledFolders ? (folder, name) => updateFolder.mutate({ folderId: folder.id, payload: { name } }) : undefined}
onEditFolder={showInstalledFolders ? (folder) => {
setFolderDialogTarget(folder);
setFolderDialogOpen(true);
} : undefined}
onDeleteFolder={showInstalledFolders ? setDeleteFolderTarget : undefined}
onToggleSelectMode={showInstalledFolders ? () => {
setSelectMode((current) => !current);
if (selectMode) setSelectedSkillIds([]);
} : undefined}
onSelectCard={showInstalledFolders ? (card, selected) => {
if (!card.skillId) return;
setSelectedSkillIds((current) =>
selected
? Array.from(new Set([...current, card.skillId!]))
: current.filter((id) => id !== card.skillId)
);
} : undefined}
onMoveCard={showInstalledFolders ? (card, folderId) => {
if (!card.skillId) return;
moveSkillToFolder.mutate({ itemId: card.skillId, folderId });
pushToast({
tone: "success",
title: "Skill moved",
body: folderId
? `Moved to ${skillFolderResult?.folders.find((folder) => folder.id === folderId)?.name ?? "folder"}.`
: "Moved to Unfiled.",
});
} : undefined}
onCreateFolderAndMoveCard={showInstalledFolders ? (card) => {
if (card.skillId) openCreateFolder([card.skillId]);
} : undefined}
onMoveSelected={showInstalledFolders ? (folderId) => void moveSelectedSkills(folderId) : undefined}
onCreateFolderAndMoveSelected={showInstalledFolders ? () => openCreateFolder(selectedSkillIds) : undefined}
onClearSelected={showInstalledFolders ? () => setSelectedSkillIds([]) : undefined}
/>
) : activeView === "installed" && selectedSkillId ? (
<SkillDetailPage
+39 -6
View File
@@ -4,7 +4,7 @@ import type { AnchorHTMLAttributes, ReactNode } from "react";
import { flushSync } from "react-dom";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Issue, RoutineListItem } from "@paperclipai/shared";
import type { FolderListResult, Issue, RoutineListItem } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Routines, buildRoutineGroups, buildRoutineSections, sortRoutines } from "./Routines";
@@ -20,6 +20,7 @@ async function act(callback: () => void | Promise<void>) {
const navigateMock = vi.fn();
const routinesListMock = vi.fn<(companyId: string) => Promise<RoutineListItem[]>>();
const foldersListMock = vi.fn<(companyId: string, kind: string) => Promise<FolderListResult>>();
const issuesListMock = vi.fn<(companyId: string, filters?: Record<string, unknown>) => Promise<Issue[]>>();
const markdownEditorRenderMock = vi.fn((props: { mentions?: Array<{ id: string; name: string }> }) => props);
const issuesListRenderMock = vi.fn(({ issues }: { issues: Issue[] }) => (
@@ -58,6 +59,16 @@ vi.mock("../api/routines", () => ({
},
}));
vi.mock("../api/folders", () => ({
foldersApi: {
list: (companyId: string, kind: string) => foldersListMock(companyId, kind),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
moveItem: vi.fn(),
},
}));
vi.mock("../api/issues", () => ({
issuesApi: {
list: (companyId: string, filters?: Record<string, unknown>) => issuesListMock(companyId, filters),
@@ -343,6 +354,13 @@ describe("Routines page", () => {
currentSearch = "";
navigateMock.mockReset();
routinesListMock.mockReset();
foldersListMock.mockReset();
foldersListMock.mockResolvedValue({
kind: "routine",
folders: [],
allCount: 0,
unfiledCount: 0,
});
issuesListMock.mockReset();
markdownEditorRenderMock.mockClear();
issuesListRenderMock.mockClear();
@@ -398,6 +416,22 @@ describe("Routines page", () => {
expect(groups[1]?.items.map((item) => item.title)).toEqual(["Reflection review"]);
});
it("uses a flat group when Folder grouping is active", () => {
const routines = [
createRoutine({ id: "routine-1", title: "Morning sync", projectId: "project-1" }),
createRoutine({ id: "routine-2", title: "Weekly digest", projectId: "project-2" }),
];
const groups = buildRoutineGroups(
routines,
"folder",
new Map(),
new Map(),
);
expect(groups).toEqual([{ key: "__all", label: null, items: routines }]);
});
it("sorts routines by selected field and direction without mutating the source list", () => {
const routines = [
createRoutine({
@@ -489,7 +523,7 @@ describe("Routines page", () => {
});
});
it("defaults the routines list to project groups sorted by title", async () => {
it("defaults the routines list to folder mode without rendering project groups", async () => {
routinesListMock.mockResolvedValue([
createRoutine({ id: "routine-1", title: "Weekly digest", projectId: "project-1" }),
createRoutine({ id: "routine-2", title: "Morning sync", projectId: "project-1" }),
@@ -513,17 +547,15 @@ describe("Routines page", () => {
await flush();
});
for (let attempts = 0; attempts < 5 && !container.textContent?.includes("Project Alpha"); attempts += 1) {
for (let attempts = 0; attempts < 5 && !container.textContent?.includes("Morning sync"); attempts += 1) {
await act(async () => {
await flush();
});
}
const text = container.textContent ?? "";
expect(text.indexOf("Project Alpha")).toBeLessThan(text.indexOf("Project Beta"));
expect(text.indexOf("Morning sync")).toBeLessThan(text.indexOf("Weekly digest"));
expect(text.indexOf("Project Alpha")).toBeLessThan(text.indexOf("Morning sync"));
expect(text.indexOf("Weekly digest")).toBeLessThan(text.indexOf("Project Beta"));
expect(text).toContain("New folder");
await act(async () => {
root.unmount();
@@ -531,6 +563,7 @@ describe("Routines page", () => {
});
it("renders built-in routines in a dedicated section on the routines tab", async () => {
localStorage.setItem("paperclip:routines-view:company-1", JSON.stringify({ groupBy: "project" }));
routinesListMock.mockResolvedValue([
createRoutine({
id: "routine-1",
+327 -8
View File
@@ -3,6 +3,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Link, useNavigate, useSearchParams } from "@/lib/router";
import { ArrowUpDown, Check, ChevronDown, ChevronRight, Layers, Plus, Repeat } from "lucide-react";
import { routinesApi } from "../api/routines";
import { foldersApi } from "../api/folders";
import { agentsApi } from "../api/agents";
import { projectsApi } from "../api/projects";
import { issuesApi } from "../api/issues";
@@ -12,6 +13,7 @@ import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { useToastActions } from "../context/ToastContext";
import { buildMarkdownMentionOptions } from "../lib/company-members";
import { cn } from "../lib/utils";
import { queryKeys } from "../lib/queryKeys";
import { groupBy } from "../lib/groupBy";
import { createIssueDetailLocationState } from "../lib/issueDetailBreadcrumb";
@@ -46,6 +48,21 @@ import {
} from "@/components/ui/select";
import { Tabs, TabsContent } from "@/components/ui/tabs";
import type { RoutineListItem, RoutineVariable } from "@paperclipai/shared";
import type { FolderListItem } from "@paperclipai/shared";
import {
BulkBar,
DeleteFolderDialog,
FolderChip,
FolderFormDialog,
FolderRail,
FolderSwatch,
MobileFolderSheet,
MoveToMenu,
folderSearchValue,
normalizeFolderSelection,
selectedFolderFromList,
type FolderSelection,
} from "../components/folders/FolderControls";
const concurrencyPolicies = ["coalesce_if_active", "always_enqueue", "skip_if_active"];
const catchUpPolicies = ["skip_missed", "enqueue_missed_with_cap"];
@@ -66,7 +83,7 @@ function autoResizeTextarea(element: HTMLTextAreaElement | null) {
}
type RoutinesTab = "routines" | "runs";
type RoutineGroupBy = "none" | "project" | "assignee";
type RoutineGroupBy = "folder" | "none" | "project" | "assignee";
type RoutineSortField = "updated" | "created" | "title" | "lastRun";
type RoutineSortDir = "asc" | "desc";
@@ -88,7 +105,7 @@ const builtInRoutineGroupKey = "__built_in_routines";
const defaultRoutineViewState: RoutineViewState = {
sortField: "title",
sortDir: "asc",
groupBy: "project",
groupBy: "folder",
collapsedGroups: [],
};
@@ -120,6 +137,7 @@ function buildRoutineMutationPayload(input: {
title: string;
description: string;
projectId: string;
folderId: string | null;
assigneeAgentId: string;
priority: string;
concurrencyPolicy: string;
@@ -130,6 +148,7 @@ function buildRoutineMutationPayload(input: {
...input,
description: input.description.trim() || null,
projectId: input.projectId || null,
folderId: input.folderId || null,
assigneeAgentId: input.assigneeAgentId || null,
};
}
@@ -140,7 +159,7 @@ export function buildRoutineGroups(
projectById: Map<string, { name: string }>,
agentById: Map<string, { name: string }>,
): RoutineGroup[] {
if (groupByValue === "none") {
if (groupByValue === "none" || groupByValue === "folder") {
return [{ key: "__all", label: null, items: routines }];
}
@@ -267,12 +286,19 @@ export function Routines() {
const { setBreadcrumbs } = useBreadcrumbs();
const queryClient = useQueryClient();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [searchParams, setSearchParams] = useSearchParams();
const { pushToast } = useToastActions();
const descriptionEditorRef = useRef<MarkdownEditorRef>(null);
const titleInputRef = useRef<HTMLTextAreaElement | null>(null);
const assigneeSelectorRef = useRef<HTMLButtonElement | null>(null);
const projectSelectorRef = useRef<HTMLButtonElement | null>(null);
const [folderDialogOpen, setFolderDialogOpen] = useState(false);
const [folderDialogTarget, setFolderDialogTarget] = useState<FolderListItem | null>(null);
const [deleteFolderTarget, setDeleteFolderTarget] = useState<FolderListItem | null>(null);
const [mobileFoldersOpen, setMobileFoldersOpen] = useState(false);
const [selectMode, setSelectMode] = useState(false);
const [selectedRoutineIds, setSelectedRoutineIds] = useState<string[]>([]);
const [moveAfterCreateIds, setMoveAfterCreateIds] = useState<string[]>([]);
const [runningRoutineId, setRunningRoutineId] = useState<string | null>(null);
const [statusMutationRoutineId, setStatusMutationRoutineId] = useState<string | null>(null);
const [runDialogRoutine, setRunDialogRoutine] = useState<RoutineListItem | null>(null);
@@ -283,6 +309,7 @@ export function Routines() {
title: string;
description: string;
projectId: string;
folderId: string | null;
assigneeAgentId: string;
priority: string;
concurrencyPolicy: string;
@@ -292,6 +319,7 @@ export function Routines() {
title: "",
description: "",
projectId: "",
folderId: null,
assigneeAgentId: "",
priority: "medium",
concurrencyPolicy: "coalesce_if_active",
@@ -302,6 +330,7 @@ export function Routines() {
? `paperclip:routines-view:${selectedCompanyId}`
: "paperclip:routines-view";
const [routineViewState, setRoutineViewState] = useState<RoutineViewState>(() => getRoutineViewState(routineViewStateKey));
const folderSelection = normalizeFolderSelection(searchParams.get("folder"));
useEffect(() => {
setBreadcrumbs([{ label: "Routines" }]);
@@ -316,6 +345,11 @@ export function Routines() {
queryFn: () => routinesApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const { data: routineFolders, isLoading: foldersLoading } = useQuery({
queryKey: queryKeys.folders.list(selectedCompanyId!, "routine"),
queryFn: () => foldersApi.list(selectedCompanyId!, "routine"),
enabled: !!selectedCompanyId && activeTab === "routines",
});
const { data: agents } = useQuery({
queryKey: queryKeys.agents.list(selectedCompanyId!),
queryFn: () => agentsApi.list(selectedCompanyId!),
@@ -373,6 +407,7 @@ export function Routines() {
title: "",
description: "",
projectId: "",
folderId: null,
assigneeAgentId: "",
priority: "medium",
concurrencyPolicy: "coalesce_if_active",
@@ -392,6 +427,74 @@ export function Routines() {
navigate(`/routines/${routine.id}?tab=triggers`);
},
});
const createFolder = useMutation({
mutationFn: (payload: { name: string; color: string | null }) =>
foldersApi.create(selectedCompanyId!, { kind: "routine", ...payload }),
onSuccess: async (folder) => {
setFolderDialogOpen(false);
setFolderDialogTarget(null);
await queryClient.invalidateQueries({ queryKey: queryKeys.folders.list(selectedCompanyId!, "routine") });
if (moveAfterCreateIds.length > 0) {
const ids = moveAfterCreateIds;
setMoveAfterCreateIds([]);
await Promise.all(ids.map((itemId) =>
foldersApi.moveItem(selectedCompanyId!, { kind: "routine", itemId, folderId: folder.id })
));
await Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.routines.list(selectedCompanyId!) }),
queryClient.invalidateQueries({ queryKey: queryKeys.folders.list(selectedCompanyId!, "routine") }),
]);
} else {
setFolderSelection(folder.id);
}
pushToast({ title: "Folder created", body: folder.name, tone: "success" });
},
onError: (mutationError) => {
pushToast({
title: "Failed to save folder",
body: mutationError instanceof Error ? mutationError.message : "Paperclip could not save the folder.",
tone: "error",
});
},
});
const updateFolder = useMutation({
mutationFn: ({ folderId, payload }: { folderId: string; payload: { name?: string; color?: string | null } }) =>
foldersApi.update(selectedCompanyId!, folderId, payload),
onSuccess: async () => {
setFolderDialogOpen(false);
setFolderDialogTarget(null);
await queryClient.invalidateQueries({ queryKey: queryKeys.folders.list(selectedCompanyId!, "routine") });
},
});
const deleteFolder = useMutation({
mutationFn: (folderId: string) => foldersApi.delete(selectedCompanyId!, folderId),
onSuccess: async (_, folderId) => {
if (folderSelection === folderId) setFolderSelection("all");
setDeleteFolderTarget(null);
await Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.routines.list(selectedCompanyId!) }),
queryClient.invalidateQueries({ queryKey: queryKeys.folders.list(selectedCompanyId!, "routine") }),
]);
pushToast({ title: "Folder deleted", body: "Items moved to Unfiled.", tone: "success" });
},
});
const moveRoutineToFolder = useMutation({
mutationFn: ({ itemId, folderId }: { itemId: string; folderId: string | null }) =>
foldersApi.moveItem(selectedCompanyId!, { kind: "routine", itemId, folderId }),
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.routines.list(selectedCompanyId!) }),
queryClient.invalidateQueries({ queryKey: queryKeys.folders.list(selectedCompanyId!, "routine") }),
]);
},
onError: (mutationError) => {
pushToast({
title: "Move failed",
body: mutationError instanceof Error ? mutationError.message : "Paperclip could not move the routine.",
tone: "error",
});
},
});
const updateIssue = useMutation({
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
issuesApi.update(id, data),
@@ -494,9 +597,15 @@ export function Routines() {
() => (routines ?? []).filter((routine) => routine.status !== "archived"),
[routines],
);
const folderFilteredRoutines = useMemo(() => {
if (routineViewState.groupBy !== "folder") return visibleRoutines;
if (folderSelection === "all") return visibleRoutines;
if (folderSelection === "unfiled") return visibleRoutines.filter((routine) => !routine.folderId);
return visibleRoutines.filter((routine) => routine.folderId === folderSelection);
}, [folderSelection, routineViewState.groupBy, visibleRoutines]);
const sortedRoutines = useMemo(
() => sortRoutines(visibleRoutines, routineViewState.sortField, routineViewState.sortDir),
[routineViewState.sortDir, routineViewState.sortField, visibleRoutines],
() => sortRoutines(folderFilteredRoutines, routineViewState.sortField, routineViewState.sortDir),
[folderFilteredRoutines, routineViewState.sortDir, routineViewState.sortField],
);
const routineSections = useMemo(
() => buildRoutineSections(sortedRoutines, routineViewState.groupBy, projectById, agentById),
@@ -513,6 +622,9 @@ export function Routines() {
);
const currentAssignee = draft.assigneeAgentId ? agentById.get(draft.assigneeAgentId) ?? null : null;
const currentProject = draft.projectId ? projectById.get(draft.projectId) ?? null : null;
const activeFolder = selectedFolderFromList(routineFolders?.folders ?? [], folderSelection);
const hasRoutineFolders = (routineFolders?.folders.length ?? 0) > 0;
const showFolderRail = activeTab === "routines" && routineViewState.groupBy === "folder" && hasRoutineFolders;
function updateRoutineView(patch: Partial<RoutineViewState>) {
setRoutineViewState((current) => {
@@ -529,6 +641,43 @@ export function Routines() {
});
}
function setFolderSelection(selection: FolderSelection) {
setSearchParams((current) => {
const params = new URLSearchParams(current);
const value = folderSearchValue(selection);
if (value) params.set("folder", value);
else params.delete("folder");
return params;
});
}
function openCreateFolder(moveItemIds: string[] = []) {
setMoveAfterCreateIds(moveItemIds);
setFolderDialogTarget(null);
setFolderDialogOpen(true);
}
function openCreateRoutine() {
setDraft((current) => ({
...current,
folderId: folderSelection === "all" || folderSelection === "unfiled" ? null : folderSelection,
}));
setComposerOpen(true);
}
async function moveSelectedRoutines(folderId: string | null) {
const ids = selectedRoutineIds;
if (ids.length === 0) return;
await Promise.all(ids.map((itemId) => foldersApi.moveItem(selectedCompanyId!, { kind: "routine", itemId, folderId })));
setSelectedRoutineIds([]);
setSelectMode(false);
await Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.routines.list(selectedCompanyId!) }),
queryClient.invalidateQueries({ queryKey: queryKeys.folders.list(selectedCompanyId!, "routine") }),
]);
pushToast({ title: "Routines moved", body: `${ids.length} routine${ids.length === 1 ? "" : "s"} filed.`, tone: "success" });
}
function handleRunNow(routine: RoutineListItem) {
setRunDialogRoutine(routine);
}
@@ -574,7 +723,7 @@ export function Routines() {
Recurring work definitions that materialize into auditable execution tasks.
</p>
</div>
<Button onClick={() => setComposerOpen(true)}>
<Button onClick={openCreateRoutine}>
<Plus className="mr-2 h-4 w-4" />
Create routine
</Button>
@@ -647,6 +796,7 @@ export function Routines() {
<PopoverContent align="end" className="w-44 p-0">
<div className="p-2 space-y-0.5">
{([
["folder", "Folder"],
["project", "Project"],
["assignee", "Agent"],
["none", "None"],
@@ -667,8 +817,29 @@ export function Routines() {
</div>
</PopoverContent>
</Popover>
{routineViewState.groupBy === "folder" && !hasRoutineFolders ? (
<Button variant="outline" size="sm" onClick={() => openCreateFolder()}>
<Plus className="mr-2 h-3.5 w-3.5" />
New folder
</Button>
) : null}
{showFolderRail ? (
<Button variant="ghost" size="sm" className="text-xs" onClick={() => setSelectMode((current) => !current)}>
{selectMode ? "Done" : "Select"}
</Button>
) : null}
</div>
</div>
{routineViewState.groupBy === "folder" ? (
<div className="md:hidden">
<FolderChip
result={routineFolders}
selection={folderSelection}
allLabel="All routines"
onClick={() => setMobileFoldersOpen(true)}
/>
</div>
) : null}
</TabsContent>
<TabsContent value="runs">
<IssuesList
@@ -843,6 +1014,26 @@ export function Routines() {
);
}}
/>
<span>filed in</span>
<Select
value={draft.folderId ?? "__unfiled"}
onValueChange={(value) => setDraft((current) => ({
...current,
folderId: value === "__unfiled" ? null : value,
}))}
>
<SelectTrigger className="h-8 w-auto min-w-32 border-0 bg-muted/50 px-2 shadow-none">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__unfiled">Unfiled</SelectItem>
{(routineFolders?.folders ?? []).map((folder) => (
<SelectItem key={folder.id} value={folder.id}>
{folder.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
@@ -949,7 +1140,49 @@ export function Routines() {
) : null}
{activeTab === "routines" ? (
<div>
<div className={cn(showFolderRail && "flex gap-4")}>
{showFolderRail ? (
<FolderRail
result={routineFolders}
selection={folderSelection}
allLabel="All routines"
itemLabelPlural="routines"
loading={foldersLoading}
onSelect={setFolderSelection}
onCreate={() => openCreateFolder()}
onRename={(folder, name) => updateFolder.mutate({ folderId: folder.id, payload: { name } })}
onEdit={(folder) => {
setFolderDialogTarget(folder);
setFolderDialogOpen(true);
}}
onDelete={setDeleteFolderTarget}
/>
) : null}
<div className="min-w-0 flex-1">
{routineViewState.groupBy === "folder" && hasRoutineFolders ? (
<div className="mb-3 flex flex-wrap items-center gap-2">
{folderSelection === "all" ? <FolderIconHeader label="All routines" count={sortedRoutines.length} /> : (
<div className="flex min-w-0 items-center gap-2 text-sm">
<FolderSwatch color={activeFolder?.color} />
<span className="truncate font-medium">{folderSelection === "unfiled" ? "Unfiled" : activeFolder?.name ?? "Folder"}</span>
<span className="text-muted-foreground">{sortedRoutines.length} routine{sortedRoutines.length === 1 ? "" : "s"}</span>
</div>
)}
</div>
) : null}
{selectMode ? (
<BulkBar
selectedCount={selectedRoutineIds.length}
folders={routineFolders?.folders ?? []}
onMove={(folderId) => void moveSelectedRoutines(folderId)}
onCreateAndMove={() => openCreateFolder(selectedRoutineIds)}
onClear={() => setSelectedRoutineIds([])}
onDone={() => {
setSelectMode(false);
setSelectedRoutineIds([]);
}}
/>
) : null}
{visibleRoutines.length === 0 ? (
<div className="py-12">
<EmptyState
@@ -957,6 +1190,21 @@ export function Routines() {
message="No active routines. Use Create routine to define the first recurring workflow."
/>
</div>
) : sortedRoutines.length === 0 ? (
<div className="py-12">
<EmptyState
icon={Repeat}
message={folderSelection === "all" ? "No routines match this view." : "This folder is empty."}
/>
{folderSelection !== "all" ? (
<div className="mt-3 flex justify-center">
<Button size="sm" onClick={openCreateRoutine}>
<Plus className="mr-2 h-3.5 w-3.5" />
New routine in this folder
</Button>
</div>
) : null}
</div>
) : (
<div className="flex flex-col gap-3">
{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={
<MoveToMenu
folders={routineFolders?.folders ?? []}
currentFolderId={routine.folderId ?? null}
onMove={(folderId) => {
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])}
/>
}
/>
))}
</CollapsibleContent>
@@ -1003,9 +1277,44 @@ export function Routines() {
})}
</div>
)}
</div>
</div>
) : null}
<FolderFormDialog
open={folderDialogOpen}
kind="routine"
folder={folderDialogTarget}
pending={createFolder.isPending || updateFolder.isPending}
onOpenChange={setFolderDialogOpen}
onSubmit={(payload) => {
if (folderDialogTarget) updateFolder.mutate({ folderId: folderDialogTarget.id, payload });
else createFolder.mutate(payload);
}}
/>
<DeleteFolderDialog
open={deleteFolderTarget !== null}
folder={deleteFolderTarget}
itemLabelPlural="routines"
pending={deleteFolder.isPending}
onOpenChange={(open) => {
if (!open) setDeleteFolderTarget(null);
}}
onConfirm={() => {
if (deleteFolderTarget) deleteFolder.mutate(deleteFolderTarget.id);
}}
/>
<MobileFolderSheet
open={mobileFoldersOpen}
onOpenChange={setMobileFoldersOpen}
result={routineFolders}
selection={folderSelection}
allLabel="All routines"
itemLabelPlural="Routines"
onSelect={setFolderSelection}
onCreate={() => openCreateFolder()}
/>
<RoutineRunVariablesDialog
open={runDialogRoutine !== null}
onOpenChange={(next) => {
@@ -1027,3 +1336,13 @@ export function Routines() {
</div>
);
}
function FolderIconHeader({ label, count }: { label: string; count: number }) {
return (
<div className="flex min-w-0 items-center gap-2 text-sm">
<Repeat className="h-3.5 w-3.5 text-muted-foreground" />
<span className="truncate font-medium">{label}</span>
<span className="text-muted-foreground">{count} routine{count === 1 ? "" : "s"}</span>
</div>
);
}
+20
View File
@@ -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();
+16 -4
View File
@@ -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({
<StudioNewSkillPanel
companyId={companyId}
forkFromSkillId={forkFromSkillId}
folderId={folderId}
forkSkill={forkSkill}
forkLoading={forkLoading}
forkError={forkError}
@@ -416,12 +424,14 @@ function StudioCreateMode({
function StudioNewSkillPanel({
companyId,
forkFromSkillId,
folderId,
forkSkill,
forkLoading,
forkError,
}: {
companyId: string;
forkFromSkillId: string | null;
folderId: string | null;
forkSkill: CompanySkillDetail | null;
forkLoading: boolean;
forkError: boolean;
@@ -429,10 +439,12 @@ function StudioNewSkillPanel({
const navigate = useNavigate();
const queryClient = useQueryClient();
const toast = useOptionalToastActions();
const initialDraft = useMemo(
() => (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<SkillCreateDraft>(initialDraft);
const [slugDirty, setSlugDirty] = useState(initialDraft.slug.trim().length > 0);
const [categoryDraft, setCategoryDraft] = useState(initialDraft.categories.join(", "));
@@ -77,6 +77,7 @@ function item(
workspace: null,
detail: null,
dismissal: null,
trainingExampleId: null,
...overrides,
};
}