diff --git a/packages/db/src/migrations/0137_skill_studio_server_foundation.sql b/packages/db/src/migrations/0137_skill_studio_server_foundation.sql new file mode 100644 index 0000000000..49d85b3cbd --- /dev/null +++ b/packages/db/src/migrations/0137_skill_studio_server_foundation.sql @@ -0,0 +1,76 @@ +ALTER TABLE "issues" ADD COLUMN IF NOT EXISTS "harness_kind" text;--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "issues_company_harness_kind_idx" ON "issues" USING btree ("company_id","harness_kind");--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "company_skill_test_inputs" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "skill_id" uuid NOT NULL, + "name" text NOT NULL, + "content" text NOT NULL, + "created_by" text, + "deleted_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +);--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "company_skill_test_runs" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "skill_id" uuid NOT NULL, + "input_id" uuid, + "input_snapshot" text NOT NULL, + "skill_version_id" uuid NOT NULL, + "agent_id" uuid NOT NULL, + "agent_config_snapshot" jsonb DEFAULT '{}'::jsonb NOT NULL, + "issue_id" uuid NOT NULL, + "status" text DEFAULT 'queued' NOT NULL, + "output_document_key" text DEFAULT 'output' NOT NULL, + "output_snapshot" text DEFAULT '' NOT NULL, + "error" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +);--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "company_skill_test_inputs" ADD CONSTRAINT "company_skill_test_inputs_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "company_skill_test_inputs" ADD CONSTRAINT "company_skill_test_inputs_skill_id_company_skills_id_fk" FOREIGN KEY ("skill_id") REFERENCES "public"."company_skills"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "company_skill_test_runs" ADD CONSTRAINT "company_skill_test_runs_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "company_skill_test_runs" ADD CONSTRAINT "company_skill_test_runs_skill_id_company_skills_id_fk" FOREIGN KEY ("skill_id") REFERENCES "public"."company_skills"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "company_skill_test_runs" ADD CONSTRAINT "company_skill_test_runs_input_id_company_skill_test_inputs_id_fk" FOREIGN KEY ("input_id") REFERENCES "public"."company_skill_test_inputs"("id") ON DELETE set null ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "company_skill_test_runs" ADD CONSTRAINT "company_skill_test_runs_skill_version_id_company_skill_versions_id_fk" FOREIGN KEY ("skill_version_id") REFERENCES "public"."company_skill_versions"("id") ON DELETE restrict ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "company_skill_test_runs" ADD CONSTRAINT "company_skill_test_runs_agent_id_agents_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."agents"("id") ON DELETE restrict ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "company_skill_test_runs" ADD CONSTRAINT "company_skill_test_runs_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE restrict ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$;--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "company_skill_test_inputs_company_skill_name_idx" ON "company_skill_test_inputs" USING btree ("company_id","skill_id","name");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "company_skill_test_inputs_company_skill_active_idx" ON "company_skill_test_inputs" USING btree ("company_id","skill_id","deleted_at");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "company_skill_test_runs_company_skill_created_idx" ON "company_skill_test_runs" USING btree ("company_id","skill_id","created_at");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "company_skill_test_runs_company_issue_idx" ON "company_skill_test_runs" USING btree ("company_id","issue_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "company_skill_test_runs_company_input_created_idx" ON "company_skill_test_runs" USING btree ("company_id","input_id","created_at");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "company_skill_test_runs_company_status_idx" ON "company_skill_test_runs" USING btree ("company_id","status"); diff --git a/packages/db/src/migrations/0138_skill_studio_run_retention.sql b/packages/db/src/migrations/0138_skill_studio_run_retention.sql new file mode 100644 index 0000000000..3abc9ad741 --- /dev/null +++ b/packages/db/src/migrations/0138_skill_studio_run_retention.sql @@ -0,0 +1,5 @@ +ALTER TABLE "company_skill_test_runs" ADD COLUMN IF NOT EXISTS "deleted_at" timestamp with time zone;--> statement-breakpoint +ALTER TABLE "company_skill_test_runs" ADD COLUMN IF NOT EXISTS "superseded_at" timestamp with time zone;--> statement-breakpoint +ALTER TABLE "company_skill_test_runs" ADD COLUMN IF NOT EXISTS "harness_issue_expires_at" timestamp with time zone;--> statement-breakpoint +ALTER TABLE "company_skill_test_runs" ADD COLUMN IF NOT EXISTS "harness_issue_deleted_at" timestamp with time zone;--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "company_skill_test_runs_company_harness_expires_idx" ON "company_skill_test_runs" USING btree ("company_id","harness_issue_expires_at"); diff --git a/packages/db/src/migrations/0139_skill_studio_run_templates.sql b/packages/db/src/migrations/0139_skill_studio_run_templates.sql new file mode 100644 index 0000000000..069a99d36c --- /dev/null +++ b/packages/db/src/migrations/0139_skill_studio_run_templates.sql @@ -0,0 +1,44 @@ +CREATE TABLE IF NOT EXISTS "company_skill_test_run_templates" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "name" text NOT NULL, + "description" text, + "body" text NOT NULL, + "created_by_agent_id" uuid, + "created_by_user_id" text, + "updated_by_agent_id" uuid, + "updated_by_user_id" text, + "deleted_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "company_skill_test_run_templates" ADD CONSTRAINT "company_skill_test_run_templates_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "company_skill_test_run_templates" ADD CONSTRAINT "company_skill_test_run_templates_created_by_agent_id_agents_id_fk" FOREIGN KEY ("created_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "company_skill_test_run_templates" ADD CONSTRAINT "company_skill_test_run_templates_updated_by_agent_id_agents_id_fk" FOREIGN KEY ("updated_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "company_skill_test_run_templates_company_active_idx" ON "company_skill_test_run_templates" USING btree ("company_id","deleted_at","name"); +--> statement-breakpoint +ALTER TABLE "company_skill_test_runs" ADD COLUMN IF NOT EXISTS "template_id" text; +--> statement-breakpoint +ALTER TABLE "company_skill_test_runs" ADD COLUMN IF NOT EXISTS "template_name" text; +--> statement-breakpoint +ALTER TABLE "company_skill_test_runs" ADD COLUMN IF NOT EXISTS "template_body" text; +--> statement-breakpoint +ALTER TABLE "company_skill_test_runs" ADD COLUMN IF NOT EXISTS "rendered_template_body" text; +--> statement-breakpoint +ALTER TABLE "company_skill_test_runs" ADD COLUMN IF NOT EXISTS "harness_issue_description" text DEFAULT '' NOT NULL; diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index 8269c18347..1526989ee3 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -946,6 +946,27 @@ "when": 1783555200000, "tag": "0136_acpx_default_engine_migration", "breakpoints": true + }, + { + "idx": 137, + "version": "7", + "when": 1783555201000, + "tag": "0137_skill_studio_server_foundation", + "breakpoints": true + }, + { + "idx": 138, + "version": "7", + "when": 1783555202000, + "tag": "0138_skill_studio_run_retention", + "breakpoints": true + }, + { + "idx": 139, + "version": "7", + "when": 1783555203000, + "tag": "0139_skill_studio_run_templates", + "breakpoints": true } ] } diff --git a/packages/db/src/schema/company_skills.ts b/packages/db/src/schema/company_skills.ts index abb1d2b6b9..7e6ad7d1d5 100644 --- a/packages/db/src/schema/company_skills.ts +++ b/packages/db/src/schema/company_skills.ts @@ -12,6 +12,7 @@ import { import type { CompanySkillFileInventoryEntry, CompanySkillSharingScope } from "@paperclipai/shared"; import { agents } from "./agents.js"; import { companies } from "./companies.js"; +import { issues } from "./issues.js"; export const companySkills = pgTable( "company_skills", @@ -131,3 +132,103 @@ export const companySkillComments = pgTable( parentIdx: index("company_skill_comments_parent_idx").on(table.parentCommentId), }), ); + +export const companySkillTestInputs = pgTable( + "company_skill_test_inputs", + { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }), + skillId: uuid("skill_id").notNull().references(() => companySkills.id, { onDelete: "cascade" }), + name: text("name").notNull(), + content: text("content").notNull(), + createdBy: text("created_by"), + deletedAt: timestamp("deleted_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + companySkillNameIdx: index("company_skill_test_inputs_company_skill_name_idx").on( + table.companyId, + table.skillId, + table.name, + ), + companySkillActiveIdx: index("company_skill_test_inputs_company_skill_active_idx").on( + table.companyId, + table.skillId, + table.deletedAt, + ), + }), +); + +export const companySkillTestRunTemplates = pgTable( + "company_skill_test_run_templates", + { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }), + name: text("name").notNull(), + description: text("description"), + body: text("body").notNull(), + createdByAgentId: uuid("created_by_agent_id").references(() => agents.id, { onDelete: "set null" }), + createdByUserId: text("created_by_user_id"), + updatedByAgentId: uuid("updated_by_agent_id").references(() => agents.id, { onDelete: "set null" }), + updatedByUserId: text("updated_by_user_id"), + deletedAt: timestamp("deleted_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + companyActiveIdx: index("company_skill_test_run_templates_company_active_idx").on( + table.companyId, + table.deletedAt, + table.name, + ), + }), +); + +export const companySkillTestRuns = pgTable( + "company_skill_test_runs", + { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }), + skillId: uuid("skill_id").notNull().references(() => companySkills.id, { onDelete: "cascade" }), + inputId: uuid("input_id").references(() => companySkillTestInputs.id, { onDelete: "set null" }), + inputSnapshot: text("input_snapshot").notNull(), + skillVersionId: uuid("skill_version_id").notNull().references(() => companySkillVersions.id, { onDelete: "restrict" }), + agentId: uuid("agent_id").notNull().references(() => agents.id, { onDelete: "restrict" }), + agentConfigSnapshot: jsonb("agent_config_snapshot").$type>().notNull().default({}), + issueId: uuid("issue_id").notNull().references(() => issues.id, { onDelete: "restrict" }), + templateId: text("template_id"), + templateName: text("template_name"), + templateBody: text("template_body"), + renderedTemplateBody: text("rendered_template_body"), + harnessIssueDescription: text("harness_issue_description").notNull().default(""), + status: text("status").notNull().default("queued"), + outputDocumentKey: text("output_document_key").notNull().default("output"), + outputSnapshot: text("output_snapshot").notNull().default(""), + error: text("error"), + deletedAt: timestamp("deleted_at", { withTimezone: true }), + supersededAt: timestamp("superseded_at", { withTimezone: true }), + harnessIssueExpiresAt: timestamp("harness_issue_expires_at", { withTimezone: true }), + harnessIssueDeletedAt: timestamp("harness_issue_deleted_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + companySkillCreatedIdx: index("company_skill_test_runs_company_skill_created_idx").on( + table.companyId, + table.skillId, + table.createdAt, + ), + companyIssueIdx: uniqueIndex("company_skill_test_runs_company_issue_idx").on(table.companyId, table.issueId), + companyInputCreatedIdx: index("company_skill_test_runs_company_input_created_idx").on( + table.companyId, + table.inputId, + table.createdAt, + ), + companyStatusIdx: index("company_skill_test_runs_company_status_idx").on(table.companyId, table.status), + companyHarnessIssueExpiresIdx: index("company_skill_test_runs_company_harness_expires_idx").on( + table.companyId, + table.harnessIssueExpiresAt, + ), + }), +); diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index 6d418922cc..7d1180376c 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -90,7 +90,15 @@ export { companySecretVersions } from "./company_secret_versions.js"; export { companySecretBindings } from "./company_secret_bindings.js"; export { userSecretDeclarations } from "./user_secret_declarations.js"; export { secretAccessEvents } from "./secret_access_events.js"; -export { companySkills, companySkillVersions, companySkillStars, companySkillComments } from "./company_skills.js"; +export { + companySkills, + companySkillVersions, + companySkillStars, + companySkillComments, + companySkillTestInputs, + companySkillTestRunTemplates, + companySkillTestRuns, +} from "./company_skills.js"; export { plugins } from "./plugins.js"; export { pluginConfig } from "./plugin_config.js"; export { pluginCompanySettings } from "./plugin_company_settings.js"; diff --git a/packages/db/src/schema/issues.ts b/packages/db/src/schema/issues.ts index 18164184f4..f70957efab 100644 --- a/packages/db/src/schema/issues.ts +++ b/packages/db/src/schema/issues.ts @@ -32,6 +32,7 @@ export const issues = pgTable( description: text("description"), status: text("status").notNull().default("backlog"), workMode: text("work_mode").notNull().default("standard"), + harnessKind: text("harness_kind"), priority: text("priority").notNull().default("medium"), assigneeAgentId: uuid("assignee_agent_id").references(() => agents.id), assigneeUserId: text("assignee_user_id"), @@ -73,6 +74,7 @@ export const issues = pgTable( }, (table) => ({ companyStatusIdx: index("issues_company_status_idx").on(table.companyId, table.status), + companyHarnessKindIdx: index("issues_company_harness_kind_idx").on(table.companyId, table.harnessKind), assigneeStatusIdx: index("issues_company_assignee_status_idx").on( table.companyId, table.assigneeAgentId, diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index f326328dbd..aab701a670 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -212,8 +212,10 @@ export const INBOX_MINE_ISSUE_STATUS_FILTER = INBOX_MINE_ISSUE_STATUSES.join("," export const ISSUE_PRIORITIES = ["critical", "high", "medium", "low"] as const; export type IssuePriority = (typeof ISSUE_PRIORITIES)[number]; -export const ISSUE_WORK_MODES = ["standard", "ask", "planning"] as const; +export const ISSUE_WORK_MODES = ["standard", "ask", "planning", "skill_test"] as const; export type IssueWorkMode = (typeof ISSUE_WORK_MODES)[number]; +export const ISSUE_HARNESS_KINDS = ["skill_test"] as const; +export type IssueHarnessKind = (typeof ISSUE_HARNESS_KINDS)[number]; export const MAX_ISSUE_REQUEST_DEPTH = 1024; export const ISSUE_COMMENT_AUTHOR_TYPES = ["user", "agent", "system"] as const; diff --git a/packages/shared/src/frontmatter.test.ts b/packages/shared/src/frontmatter.test.ts index fa5dda0ae6..ee9e36feda 100644 --- a/packages/shared/src/frontmatter.test.ts +++ b/packages/shared/src/frontmatter.test.ts @@ -1,5 +1,27 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; -import { parseFrontmatterMarkdown } from "./frontmatter.js"; +import { + analyzeFrontmatterBlock, + detectFrontmatterRoundTripIssues, + getSkillFrontmatterUnknownKeys, + joinFrontmatterBlock, + parseFrontmatterFields, + parseFrontmatterMarkdown, + skillFrontmatterSchema, + splitFrontmatterBlock, + stringifyFrontmatter, +} from "./frontmatter.js"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../.."); +const skillMarkdownSearchRoots = [ + "packages/skills-catalog/catalog", + "packages/teams-catalog/catalog", + "packages/adapters/hermes/skills", + "packages/plugins/plugin-llm-wiki/skills", + "skills", +]; describe("parseFrontmatterMarkdown", () => { it("parses folded and literal YAML block scalars", () => { @@ -99,3 +121,225 @@ describe("parseFrontmatterMarkdown", () => { expect(parsed.frontmatter.version).toBe("1."); }); }); + +describe("splitFrontmatterBlock", () => { + it("splits every bundled skill markdown file without losing bytes", () => { + const skillMarkdownFiles = collectSkillMarkdownFiles(); + + expect(skillMarkdownFiles.length).toBeGreaterThan(0); + for (const filePath of skillMarkdownFiles) { + const raw = fs.readFileSync(filePath, "utf8"); + const split = splitFrontmatterBlock(raw); + const joined = split.hasFrontmatter + ? `---\n${split.frontmatterText}\n---\n${split.body}` + : split.body; + + expect(joined, path.relative(repoRoot, filePath)).toBe(raw); + } + }); + + it("leaves files without frontmatter untouched", () => { + const raw = "Body starts immediately.\n\n---\nThis is not frontmatter.\n"; + const split = splitFrontmatterBlock(raw); + + expect(split).toEqual({ + frontmatterText: "", + body: raw, + hasFrontmatter: false, + }); + }); + + it("treats an empty opening block as frontmatter", () => { + const raw = "---\n---\nBody\n"; + + expect(splitFrontmatterBlock(raw)).toEqual({ + frontmatterText: "", + body: "Body\n", + hasFrontmatter: true, + }); + }); +}); + +describe("stringifyFrontmatter", () => { + it.each([ + { + label: "nested metadata", + value: { + name: "demo-skill", + description: "Demo skill", + metadata: { + source: { + kind: "github-dir", + repo: "paperclipai/paperclip", + path: "skills/paperclip", + }, + }, + }, + }, + { + label: "arrays", + value: { + name: "tool-skill", + description: "Tool skill", + "allowed-tools": ["Read", "Write", "Bash"], + tags: ["skills", "frontmatter"], + }, + }, + { + label: "block scalars", + value: { + name: "block-skill", + description: "First line\nsecond line\n\nThird paragraph\n", + metadata: { + notes: "Keep\nall\nline breaks", + }, + }, + }, + ])("serializes parser-compatible YAML for $label", ({ value }) => { + const first = parseFrontmatterMarkdown(`---\n${stringifyFrontmatter(value)}\n---\n`).frontmatter; + const second = parseFrontmatterMarkdown(`---\n${stringifyFrontmatter(first)}\n---\n`).frontmatter; + + expect(second).toEqual(first); + }); +}); + +describe("skillFrontmatterSchema", () => { + it("validates core skill frontmatter fields while allowing unknown keys", () => { + const parsed = skillFrontmatterSchema.parse({ + name: "demo-skill", + description: "A demo skill.", + "allowed-tools": ["Read", "Write"], + metadata: { nested: { enabled: true } }, + tags: ["demo"], + }); + + expect(parsed.tags).toEqual(["demo"]); + expect(getSkillFrontmatterUnknownKeys(parsed)).toEqual(["tags"]); + }); + + it("rejects non-slug skill names", () => { + expect(() => skillFrontmatterSchema.parse({ + name: "Demo Skill", + description: "A demo skill.", + })).toThrow(); + }); +}); + +describe("detectFrontmatterRoundTripIssues", () => { + it("reports YAML constructs that fields mode cannot preserve", () => { + const issues = detectFrontmatterRoundTripIssues([ + "# leading comment", + "\"quoted-key\": value", + "base: &base", + "copy: *base", + ].join("\n")); + + expect(issues.map((issue) => issue.kind)).toEqual([ + "comment", + "quoted_key", + "anchor", + "alias", + ]); + }); +}); + +describe("joinFrontmatterBlock", () => { + it("is the exact inverse of splitFrontmatterBlock (byte-identity round-trip)", () => { + const samples = [ + "---\nname: reflection-coach\ndescription: A coach\n---\n# Body\n\nHello\n", + "---\nname: x\n---\nno trailing newline", + "---\nname: x\n---\n", // empty body + "---\ndescription: >\n folded\n text\n---\nBody with comment: value\n", + "# just markdown, no frontmatter\n", + "---\nunterminated frontmatter\nstill body", + "---\nmetadata:\n author: Paperclip\n # comment stays\n---\nbody\n", + ]; + for (const raw of samples) { + expect(joinFrontmatterBlock(splitFrontmatterBlock(raw))).toBe(raw); + } + }); + + it("returns the body untouched when there is no frontmatter", () => { + expect( + joinFrontmatterBlock({ frontmatterText: "", body: "just body", hasFrontmatter: false }), + ).toBe("just body"); + }); +}); + +describe("parseFrontmatterFields", () => { + it("parses the raw block text into an object and is lenient on garbage", () => { + expect(parseFrontmatterFields("name: foo\ndescription: bar")).toEqual({ + name: "foo", + description: "bar", + }); + expect(parseFrontmatterFields("")).toEqual({}); + expect(parseFrontmatterFields("# only a comment")).toEqual({}); + }); +}); + +describe("analyzeFrontmatterBlock", () => { + it("marks a simple inline block as round-trippable", () => { + const result = analyzeFrontmatterBlock("name: reflection-coach\ndescription: A coach"); + expect(result.canRoundTrip).toBe(true); + expect(result.issues).toEqual([]); + expect(result.parsed).toEqual({ name: "reflection-coach", description: "A coach" }); + }); + + it("marks a block with allowed-tools and metadata as round-trippable", () => { + const raw = [ + "name: coach", + "description: A coach", + "allowed-tools:", + " - Read", + " - Grep", + "metadata:", + " author: Paperclip", + " version: 2", + ].join("\n"); + const result = analyzeFrontmatterBlock(raw); + expect(result.canRoundTrip).toBe(true); + expect(result.parsed["allowed-tools"]).toEqual(["Read", "Grep"]); + }); + + it("refuses fields mode when comments are present (would be dropped)", () => { + const result = analyzeFrontmatterBlock("name: coach # inline note\ndescription: x"); + expect(result.canRoundTrip).toBe(false); + expect(result.issues.some((issue) => issue.kind === "comment")).toBe(true); + }); + + it("refuses fields mode for folded scalars the serializer cannot reproduce", () => { + const raw = ["description: >", " first line", " second line"].join("\n"); + const result = analyzeFrontmatterBlock(raw); + // No detector "issue", but re-serialization is not byte-identical, so it is + // still not round-trippable — the strict serialize-back gate catches it. + expect(result.canRoundTrip).toBe(false); + }); + + it("treats an empty block as round-trippable", () => { + const result = analyzeFrontmatterBlock(""); + expect(result.canRoundTrip).toBe(true); + expect(result.parsed).toEqual({}); + }); +}); + +function collectSkillMarkdownFiles() { + return skillMarkdownSearchRoots.flatMap((relativeRoot) => { + const absoluteRoot = path.join(repoRoot, relativeRoot); + return fs.existsSync(absoluteRoot) ? collectSkillMarkdownFilesUnder(absoluteRoot) : []; + }).sort(); +} + +function collectSkillMarkdownFilesUnder(root: string): string[] { + const files: string[] = []; + for (const entry of fs.readdirSync(root, { withFileTypes: true })) { + const absolutePath = path.join(root, entry.name); + if (entry.isDirectory()) { + files.push(...collectSkillMarkdownFilesUnder(absolutePath)); + continue; + } + if (entry.isFile() && entry.name === "SKILL.md") { + files.push(absolutePath); + } + } + return files; +} diff --git a/packages/shared/src/frontmatter.ts b/packages/shared/src/frontmatter.ts index 610bd1d57a..4ee9c9b9d7 100644 --- a/packages/shared/src/frontmatter.ts +++ b/packages/shared/src/frontmatter.ts @@ -1,9 +1,67 @@ +import { z } from "zod"; + export interface MarkdownDoc { frontmatter: Record; body: string; hasFrontmatter: boolean; } +export interface FrontmatterBlock { + frontmatterText: string; + body: string; + hasFrontmatter: boolean; +} + +export type FrontmatterRoundTripIssueKind = + | "anchor" + | "alias" + | "comment" + | "quoted_key" + | "tag"; + +export interface FrontmatterRoundTripIssue { + kind: FrontmatterRoundTripIssueKind; + line: number; + column: number; + message: string; +} + +const SKILL_FRONTMATTER_SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +const SUPPORTED_FRONTMATTER_KEY_RE = /^[A-Za-z0-9_. -]+$/; + +type SerializableFrontmatterValue = + | null + | string + | number + | boolean + | SerializableFrontmatterValue[] + | { [key: string]: SerializableFrontmatterValue }; + +const skillMetadataValueSchema: z.ZodType = z.lazy(() => + z.union([ + z.string(), + z.number(), + z.boolean(), + z.null(), + z.array(skillMetadataValueSchema), + z.record(skillMetadataValueSchema), + ]) +); + +export const skillFrontmatterSchema = z.object({ + name: z.string().regex(SKILL_FRONTMATTER_SLUG_RE, "Expected a lowercase URL slug."), + description: z.string().min(1), + "allowed-tools": z.array(z.string()).optional(), + metadata: z.record(skillMetadataValueSchema).optional(), +}).passthrough(); + +export const skillFrontmatterKnownKeys = [ + "name", + "description", + "allowed-tools", + "metadata", +] as const; + export function isPlainRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } @@ -31,6 +89,160 @@ export function asStringArray(value: unknown): string[] | null { return out; } +export function splitFrontmatterBlock(raw: string): FrontmatterBlock { + if (!raw.startsWith("---\n")) { + return { frontmatterText: "", body: raw, hasFrontmatter: false }; + } + + const closing = raw.indexOf("\n---\n", 3); + if (closing < 0) { + return { frontmatterText: "", body: raw, hasFrontmatter: false }; + } + + return { + frontmatterText: raw.slice(4, closing), + body: raw.slice(closing + 5), + hasFrontmatter: true, + }; +} + +export function stringifyFrontmatter(value: Record): string { + return stringifyYamlRecord(assertSerializableRecord(value), 0).join("\n"); +} + +export function getSkillFrontmatterUnknownKeys(value: Record) { + const known = new Set(skillFrontmatterKnownKeys); + return Object.keys(value).filter((key) => !known.has(key)); +} + +export function detectFrontmatterRoundTripIssues(rawYaml: string): FrontmatterRoundTripIssue[] { + const issues: FrontmatterRoundTripIssue[] = []; + const lines = rawYaml.split("\n"); + + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]!; + const contentStart = line.search(/\S/u); + if (contentStart < 0) continue; + + const content = line.slice(contentStart); + if (content.startsWith("#")) { + issues.push({ + kind: "comment", + line: index + 1, + column: contentStart + 1, + message: "Comments are not preserved by the frontmatter field serializer.", + }); + continue; + } + + const inlineComment = findRoundTripPattern(line, /(^|\s)#/u); + if (inlineComment >= 0) { + issues.push({ + kind: "comment", + line: index + 1, + column: inlineComment + 1, + message: "Inline comments are not preserved by the frontmatter field serializer.", + }); + } + + const quotedKey = /^\s*(?:-\s*)?(["']).+?\1\s*:/u.exec(line); + if (quotedKey) { + issues.push({ + kind: "quoted_key", + line: index + 1, + column: line.indexOf(quotedKey[1]!) + 1, + message: "Quoted YAML keys cannot be round-tripped by the frontmatter parser.", + }); + } + + const anchor = findRoundTripPattern(line, /(^|[\s,[{])&[A-Za-z0-9_-]+/u); + if (anchor >= 0) { + issues.push({ + kind: "anchor", + line: index + 1, + column: anchor + 1, + message: "YAML anchors cannot be round-tripped by the frontmatter parser.", + }); + } + + const alias = findRoundTripPattern(line, /(^|[\s,[{])\*[A-Za-z0-9_-]+/u); + if (alias >= 0) { + issues.push({ + kind: "alias", + line: index + 1, + column: alias + 1, + message: "YAML aliases cannot be round-tripped by the frontmatter parser.", + }); + } + + const tag = findRoundTripPattern(line, /(^|\s)![A-Za-z!][^\s]*/u); + if (tag >= 0) { + issues.push({ + kind: "tag", + line: index + 1, + column: tag + 1, + message: "YAML tags cannot be round-tripped by the frontmatter parser.", + }); + } + } + + return issues; +} + +/** + * Recombine a split block into a full markdown document. This is the exact + * inverse of {@link splitFrontmatterBlock}: `join(split(x)) === x` for every + * input, so opening a file and saving it untouched is byte-identical. The body + * is passed through verbatim — never re-parsed or re-serialized. + */ +export function joinFrontmatterBlock(block: FrontmatterBlock): string { + if (!block.hasFrontmatter) return block.body; + return `---\n${block.frontmatterText}\n---\n${block.body}`; +} + +/** + * Parse the raw YAML of a frontmatter block (the text between the `---` fences, + * as returned by {@link splitFrontmatterBlock}) into a plain object. Lenient: + * unparseable input yields `{}` rather than throwing. + */ +export function parseFrontmatterFields(frontmatterText: string): Record { + return parseYamlFrontmatter(frontmatterText); +} + +export interface FrontmatterAnalysis { + /** The parsed field object (best-effort; `{}` when nothing parses). */ + parsed: Record; + /** + * True when the field editor is safe to use: the raw YAML both parses and + * re-serializes byte-for-byte. When false, editing must stay in raw-YAML mode + * so bytes the serializer can't reproduce (comments, anchors, folded scalars, + * custom ordering, quoting) are never silently rewritten. + */ + canRoundTrip: boolean; + /** Structural features that make the block non-round-trippable, if any. */ + issues: FrontmatterRoundTripIssue[]; +} + +/** + * Decide whether a frontmatter block can be edited through the structured + * field form. Fields mode is only offered when re-serializing the parsed object + * reproduces the original block exactly — this is the load-bearing round-trip + * safety gate for the Skill Studio FrontmatterPanel (PAP-13145 Option B). + */ +export function analyzeFrontmatterBlock(frontmatterText: string): FrontmatterAnalysis { + const issues = detectFrontmatterRoundTripIssues(frontmatterText); + const parsed = parseYamlFrontmatter(frontmatterText); + let canRoundTrip = issues.length === 0; + if (canRoundTrip) { + try { + canRoundTrip = stringifyFrontmatter(parsed) === frontmatterText; + } catch { + canRoundTrip = false; + } + } + return { parsed, canRoundTrip, issues }; +} + export function parseFrontmatterMarkdown(raw: string): MarkdownDoc { const normalized = raw.replace(/\r\n/g, "\n"); if (!normalized.startsWith("---\n")) { @@ -51,6 +263,163 @@ export function parseFrontmatterMarkdown(raw: string): MarkdownDoc { }; } +function assertSerializableRecord(value: Record) { + const out: Record = {}; + for (const [key, entryValue] of Object.entries(value)) { + if (entryValue === undefined) continue; + out[key] = assertSerializableValue(entryValue); + } + return out; +} + +function assertSerializableValue(value: unknown): SerializableFrontmatterValue { + if ( + value === null + || typeof value === "string" + || typeof value === "boolean" + ) { + return value; + } + if (typeof value === "number") { + if (!Number.isFinite(value)) { + throw new TypeError("Frontmatter numbers must be finite."); + } + return value; + } + if (Array.isArray(value)) { + return value + .filter((entry) => entry !== undefined) + .map((entry) => assertSerializableValue(entry)); + } + if (isPlainRecord(value)) { + return assertSerializableRecord(value); + } + throw new TypeError(`Unsupported frontmatter value type: ${typeof value}`); +} + +function stringifyYamlRecord(record: Record, indentLevel: number): string[] { + const lines: string[] = []; + for (const [key, value] of Object.entries(record)) { + assertYamlKey(key); + lines.push(...stringifyYamlProperty(key, value, indentLevel)); + } + return lines; +} + +function stringifyYamlProperty(key: string, value: SerializableFrontmatterValue, indentLevel: number): string[] { + const indent = " ".repeat(indentLevel); + if (Array.isArray(value)) { + if (value.length === 0) return [`${indent}${key}: []`]; + return [`${indent}${key}:`, ...stringifyYamlArray(value, indentLevel + 2)]; + } + if (isSerializableRecord(value)) { + const entries = Object.entries(value); + if (entries.length === 0) return [`${indent}${key}: {}`]; + return [`${indent}${key}:`, ...stringifyYamlRecord(value, indentLevel + 2)]; + } + if (typeof value === "string" && value.includes("\n")) { + return stringifyBlockScalarProperty(key, value, indentLevel); + } + return [`${indent}${key}: ${stringifyYamlScalar(value)}`]; +} + +function stringifyYamlArray(values: SerializableFrontmatterValue[], indentLevel: number): string[] { + const indent = " ".repeat(indentLevel); + const lines: string[] = []; + for (const value of values) { + if (Array.isArray(value)) { + if (value.length === 0) { + lines.push(`${indent}- []`); + } else { + lines.push(`${indent}-`); + lines.push(...stringifyYamlArray(value, indentLevel + 2)); + } + continue; + } + + if (isSerializableRecord(value)) { + const entries = Object.entries(value); + if (entries.length === 0) { + lines.push(`${indent}- {}`); + } else { + lines.push(`${indent}-`); + lines.push(...stringifyYamlRecord(value, indentLevel + 2)); + } + continue; + } + + if (typeof value === "string" && value.includes("\n")) { + lines.push(...stringifyBlockScalarArrayItem(value, indentLevel)); + continue; + } + + lines.push(`${indent}- ${stringifyYamlScalar(value)}`); + } + return lines; +} + +function stringifyBlockScalarProperty(key: string, value: string, indentLevel: number) { + const indent = " ".repeat(indentLevel); + return [ + `${indent}${key}: ${blockScalarIndicator(value)}`, + ...indentBlockScalarValue(value, indentLevel + 2), + ]; +} + +function stringifyBlockScalarArrayItem(value: string, indentLevel: number) { + const indent = " ".repeat(indentLevel); + return [ + `${indent}- ${blockScalarIndicator(value)}`, + ...indentBlockScalarValue(value, indentLevel + 2), + ]; +} + +function blockScalarIndicator(value: string) { + if (!value.endsWith("\n")) return "|-"; + if (value.endsWith("\n\n")) return "|+"; + return "|"; +} + +function indentBlockScalarValue(value: string, indentLevel: number) { + const indent = " ".repeat(indentLevel); + return value.split("\n").map((line) => `${indent}${line}`); +} + +function stringifyYamlScalar(value: Exclude>) { + if (value === null) return "null"; + if (typeof value === "boolean") return value ? "true" : "false"; + if (typeof value === "number") return String(value); + if (isPlainYamlScalar(value)) return value; + return JSON.stringify(value); +} + +function isPlainYamlScalar(value: string) { + if (value.length === 0) return false; + if (value.trim() !== value) return false; + if (value === "null" || value === "~" || value === "true" || value === "false") return false; + if (value === "[]" || value === "{}") return false; + if (/^-?\d+(\.\d+)?$/u.test(value)) return false; + if (/["'[\]{}#,>&*!|@`]/u.test(value)) return false; + if (value.includes(":")) return false; + return true; +} + +function assertYamlKey(key: string) { + if (!SUPPORTED_FRONTMATTER_KEY_RE.test(key) || key.includes(":")) { + throw new TypeError(`Unsupported frontmatter key: ${key}`); + } +} + +function isSerializableRecord(value: SerializableFrontmatterValue): value is Record { + return isPlainRecord(value); +} + +function findRoundTripPattern(line: string, pattern: RegExp) { + const match = pattern.exec(line); + if (!match) return -1; + return match.index + (match[1]?.length ?? 0); +} + function parseYamlFrontmatter(raw: string): Record { const prepared = prepareYamlLines(raw); const firstContentIndex = prepared.findIndex((line) => !line.isBlank && !line.isComment); diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index d888cea22c..9502219f3e 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -80,11 +80,24 @@ export type { PipelineStageAutomation, } from "./types/pipeline.js"; export { + analyzeFrontmatterBlock, asBoolean, asString, asStringArray, + detectFrontmatterRoundTripIssues, + getSkillFrontmatterUnknownKeys, isPlainRecord as isFrontmatterPlainRecord, + joinFrontmatterBlock, + parseFrontmatterFields, parseFrontmatterMarkdown, + skillFrontmatterKnownKeys, + skillFrontmatterSchema, + splitFrontmatterBlock, + stringifyFrontmatter, + type FrontmatterAnalysis, + type FrontmatterBlock, + type FrontmatterRoundTripIssue, + type FrontmatterRoundTripIssueKind, type MarkdownDoc, } from "./frontmatter.js"; export { @@ -128,6 +141,7 @@ export { INBOX_MINE_ISSUE_STATUS_FILTER, ISSUE_PRIORITIES, ISSUE_WORK_MODES, + ISSUE_HARNESS_KINDS, MAX_ISSUE_REQUEST_DEPTH, ISSUE_COMMENT_AUTHOR_TYPES, ISSUE_COMMENT_METADATA_ROW_TYPES, @@ -273,6 +287,7 @@ export { type IssueStatus, type IssuePriority, type IssueWorkMode, + type IssueHarnessKind, type IssueCommentAuthorType, type IssueCommentMetadataRowType, type IssueCommentPresentationKind, @@ -442,6 +457,8 @@ export type { CompanySkillSourceBadge, CompanySkillSharingScope, CompanySkillListSort, + CompanySkillListInclude, + CompanySkillLastEditor, CompanySkillFileInventoryEntry, CompanySkillVersionFileInventoryEntry, CompanySkill, @@ -480,6 +497,23 @@ export type { CompanySkillCreateRequest, CompanySkillFileDetail, CompanySkillFileUpdateRequest, + CompanySkillFileDeleteRequest, + CompanySkillFileDeleteResult, + CompanySkillTestRunStatus, + CompanySkillTestInput, + CompanySkillTestInputCreateRequest, + CompanySkillTestInputUpdateRequest, + CompanySkillTestRunTemplate, + CompanySkillTestRunTemplateCreateRequest, + CompanySkillTestRunTemplateUpdateRequest, + CompanySkillTestRunTemplateSnapshot, + CompanySkillTestRunCostSummary, + CompanySkillTestRun, + CompanySkillTestRunCreateRequest, + CompanySkillTestRunListQuery, + CompanySkillTestRunHarnessContentUnavailableReason, + CompanySkillTestRunHarnessContent, + CompanySkillTestRunDetail, CatalogSkillKind, CatalogSkillFileKind, CatalogSkillFile, @@ -1139,6 +1173,7 @@ export { normalizeAgentApiKeyScope, standardAgentKeyScopeSchema, taskBridgeAgentKeyScopeSchema, + skillTestAgentKeyScopeSchema, createAgentKeySchema, agentMineInboxQuerySchema, wakeAgentSchema, @@ -1154,6 +1189,7 @@ export { type UpdateAgentInstructionsPath, type AgentApiKeyScope, type TaskBridgeAgentKeyScope, + type SkillTestAgentKeyScope, type CreateAgentKey, type AgentMineInboxQuery, type WakeAgent, @@ -1475,6 +1511,19 @@ export { companySkillCreateSchema, companySkillFileDetailSchema, companySkillFileUpdateSchema, + companySkillFileDeleteSchema, + companySkillTestRunStatusSchema, + companySkillTestInputSchema, + companySkillTestInputCreateSchema, + companySkillTestInputUpdateSchema, + companySkillTestRunTemplateSchema, + companySkillTestRunTemplateCreateSchema, + companySkillTestRunTemplateUpdateSchema, + companySkillTestRunTemplateSnapshotSchema, + companySkillTestRunCostSummarySchema, + companySkillTestRunSchema, + companySkillTestRunCreateSchema, + companySkillTestRunListQuerySchema, catalogSkillKindSchema, catalogSkillFileSchema, catalogSkillGitHubSourceSchema, diff --git a/packages/shared/src/types/company-skill.ts b/packages/shared/src/types/company-skill.ts index 4573ecc816..c255c1f67f 100644 --- a/packages/shared/src/types/company-skill.ts +++ b/packages/shared/src/types/company-skill.ts @@ -1,3 +1,6 @@ +import type { IssueAttachment, IssueDocument } from "./issue.js"; +import type { IssueWorkProduct } from "./work-product.js"; + export type CompanySkillSourceType = "local_path" | "github" | "url" | "catalog" | "skills_sh"; export type CompanySkillTrustLevel = "markdown_only" | "assets" | "scripts_executables"; @@ -10,6 +13,15 @@ export type CompanySkillSharingScope = "private" | "company" | "public_link"; export type CompanySkillListSort = "alphabetical" | "recent" | "installs" | "stars" | "agents" | "forks"; +export type CompanySkillListInclude = "lastEditor"; + +export interface CompanySkillLastEditor { + kind: "user" | "agent"; + id: string; + name: string | null; + imageUrl: string | null; +} + export interface CompanySkillFileInventoryEntry { path: string; kind: "skill" | "markdown" | "reference" | "script" | "asset" | "other"; @@ -91,6 +103,7 @@ export interface CompanySkillListItem { originHash: string | null; packageName: string | null; packageVersion: string | null; + lastEditor?: CompanySkillLastEditor | null; } export interface CompanySkillUsageAgent { @@ -126,6 +139,7 @@ export interface CompanySkillListQuery { sort?: CompanySkillListSort; categories?: string[]; scope?: CompanySkillSharingScope; + include?: CompanySkillListInclude[]; } export interface CompanySkillCategoryCount { @@ -365,6 +379,187 @@ export interface CompanySkillFileUpdateRequest { content: string; } +export interface CompanySkillFileDeleteRequest { + path: string; + target: "file" | "folder"; +} + +export interface CompanySkillFileDeleteResult { + skillId: string; + path: string; + target: "file" | "folder"; + deletedPaths: string[]; +} + +export type CompanySkillTestRunStatus = "queued" | "running" | "succeeded" | "failed" | "cancelled"; + +export interface CompanySkillTestInput { + id: string; + companyId: string; + skillId: string; + name: string; + content: string; + createdBy: string | null; + deletedAt: Date | null; + createdAt: Date; + updatedAt: Date; +} + +export interface CompanySkillTestInputCreateRequest { + name: string; + content: string; +} + +export interface CompanySkillTestInputUpdateRequest { + name?: string; + content?: string; +} + +export interface CompanySkillTestRunTemplate { + id: string; + companyId: string; + name: string; + description: string | null; + body: string; + builtIn: boolean; + createdByAgentId: string | null; + createdByUserId: string | null; + updatedByAgentId: string | null; + updatedByUserId: string | null; + deletedAt: Date | null; + createdAt: Date; + updatedAt: Date; +} + +export interface CompanySkillTestRunTemplateCreateRequest { + name: string; + description?: string | null; + body: string; +} + +export interface CompanySkillTestRunTemplateUpdateRequest { + name?: string; + description?: string | null; + body?: string; +} + +export interface CompanySkillTestRunTemplateSnapshot { + templateId: string | null; + templateName: string | null; + templateBody: string | null; +} + +export interface CompanySkillTestRunCostSummary { + costCents: number; + inputTokens: number; + cachedInputTokens: number; + outputTokens: number; +} + +export interface CompanySkillTestRun { + id: string; + companyId: string; + skillId: string; + inputId: string | null; + inputSnapshot: string; + skillVersionId: string; + agentId: string; + agentConfigSnapshot: Record; + issueId: string; + templateId: string | null; + templateName: string | null; + templateBody: string | null; + renderedTemplateBody: string | null; + harnessIssueDescription: string; + status: CompanySkillTestRunStatus; + outputDocumentKey: string; + outputSnapshot: string; + error: string | null; + deletedAt: Date | null; + supersededAt: Date | null; + harnessIssueExpiresAt: Date | null; + harnessIssueDeletedAt: Date | null; + createdAt: Date; + updatedAt: Date; + cost: CompanySkillTestRunCostSummary; + taskExpired: boolean; +} + +export interface CompanySkillTestRunCreateRequest { + inputId?: string | null; + content?: string | null; + agentId: string; + /** + * Omitted uses the built-in default template, null means "No template", and + * a string selects a built-in or custom template id. + */ + templateId?: string | null; + /** + * Re-run can provide the viewed run's template body snapshot so the new run + * does not silently pick up later edits to the source template. + */ + templateSnapshot?: CompanySkillTestRunTemplateSnapshot | null; + /** + * Pin a specific skill version for this run instead of the live head. Used by + * Re-run to reproduce the viewed run's `skillVersionId` snapshot. + */ + skillVersionId?: string | null; +} + +export interface CompanySkillTestRunListQuery { + inputId?: string; +} + +export type CompanySkillTestRunHarnessContentUnavailableReason = "expired" | "deleted" | "missing"; + +/** + * Rich renderable content hydrated from the run's own hidden harness issue. + * When the harness issue has expired or been deleted, `available` is false and + * the collections are empty; stored run snapshots (input/output/template) + * remain usable on the run itself. + */ +export interface CompanySkillTestRunHarnessContent { + available: boolean; + unavailableReason: CompanySkillTestRunHarnessContentUnavailableReason | null; + documents: IssueDocument[]; + attachments: IssueAttachment[]; + workProducts: IssueWorkProduct[]; +} + +export interface CompanySkillTestRunDetail extends CompanySkillTestRun { + skillVersion: CompanySkillVersion; + outputBody: string; + harnessContent: CompanySkillTestRunHarnessContent; + harnessIssue: { + id: string; + identifier: string | null; + title: string; + status: string; + hiddenAt: Date | null; + } | null; + documents: Array<{ + key: string; + title: string | null; + updatedAt: Date; + body: string; + }>; + interactions: Array<{ + id: string; + kind: string; + status: string; + title: string; + createdAt: Date; + updatedAt: Date; + }>; + artifacts: Array<{ + id: string; + kind: "attachment" | "work_product"; + title: string; + summary: string | null; + createdAt: Date; + }>; +} + export type CatalogSkillKind = "bundled" | "optional"; export type CatalogSkillFileKind = CompanySkillFileInventoryEntry["kind"]; diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 3ad6dc17ba..246c30f161 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -64,6 +64,8 @@ export type { CompanySkillSourceBadge, CompanySkillSharingScope, CompanySkillListSort, + CompanySkillListInclude, + CompanySkillLastEditor, CompanySkillFileInventoryEntry, CompanySkillVersionFileInventoryEntry, CompanySkill, @@ -102,6 +104,23 @@ export type { CompanySkillCreateRequest, CompanySkillFileDetail, CompanySkillFileUpdateRequest, + CompanySkillFileDeleteRequest, + CompanySkillFileDeleteResult, + CompanySkillTestRunStatus, + CompanySkillTestInput, + CompanySkillTestInputCreateRequest, + CompanySkillTestInputUpdateRequest, + CompanySkillTestRunTemplate, + CompanySkillTestRunTemplateCreateRequest, + CompanySkillTestRunTemplateUpdateRequest, + CompanySkillTestRunTemplateSnapshot, + CompanySkillTestRunCostSummary, + CompanySkillTestRun, + CompanySkillTestRunCreateRequest, + CompanySkillTestRunListQuery, + CompanySkillTestRunHarnessContentUnavailableReason, + CompanySkillTestRunHarnessContent, + CompanySkillTestRunDetail, CatalogSkillKind, CatalogSkillFileKind, CatalogSkillFile, diff --git a/packages/shared/src/types/instance.ts b/packages/shared/src/types/instance.ts index f03ac7df6f..0a00c8cbe1 100644 --- a/packages/shared/src/types/instance.ts +++ b/packages/shared/src/types/instance.ts @@ -60,6 +60,13 @@ export interface InstanceExperimentalSettings { autoRestartDevServerWhenIdle: boolean; enableIssueGraphLivenessAutoRecovery: boolean; enableWorkspaceBranchReconcileForward: boolean; + /** + * Worktree preview instances (`PAPERCLIP_IN_WORKTREE=true`) suppress the + * heartbeat run engine by default so previews never self-execute tasks. When + * this is enabled the worktree-instance scheduling suppression is lifted so + * runs actually execute inside the preview. Ignored outside a worktree. + */ + enableWorktreeRunExecution: boolean; issueGraphLivenessAutoRecoveryLookbackHours: number; } diff --git a/packages/shared/src/types/issue.ts b/packages/shared/src/types/issue.ts index 5bc6010b32..5be8c89cd0 100644 --- a/packages/shared/src/types/issue.ts +++ b/packages/shared/src/types/issue.ts @@ -13,6 +13,7 @@ import type { IssueReferenceSourceKind, IssueExecutionStageType, IssueExecutionStateStatus, + IssueHarnessKind, IssueOriginKind, IssuePriority, IssueRecoveryActionKind, @@ -146,6 +147,7 @@ export interface AcceptedPlanDecompositionChild { description?: string | null; status: IssueStatus; workMode: IssueWorkMode; + harnessKind?: IssueHarnessKind | null; priority: IssuePriority; assigneeAgentId?: string | null; assigneeUserId?: string | null; diff --git a/packages/shared/src/validators/agent.ts b/packages/shared/src/validators/agent.ts index 65934b7e6d..5133896310 100644 --- a/packages/shared/src/validators/agent.ts +++ b/packages/shared/src/validators/agent.ts @@ -137,13 +137,20 @@ export const standardAgentKeyScopeSchema = z.object({ kind: z.literal("standard"), }).strict(); +export const skillTestAgentKeyScopeSchema = z.object({ + kind: z.literal("skill_test"), + issueId: z.string().uuid(), +}).strict(); + export const agentApiKeyScopeSchema = z.union([ standardAgentKeyScopeSchema, taskBridgeAgentKeyScopeSchema, + skillTestAgentKeyScopeSchema, ]); export type AgentApiKeyScope = z.infer; export type TaskBridgeAgentKeyScope = z.infer; +export type SkillTestAgentKeyScope = z.infer; export function normalizeAgentApiKeyScope(value: unknown): AgentApiKeyScope { const parsed = agentApiKeyScopeSchema.safeParse(value); diff --git a/packages/shared/src/validators/company-skill.ts b/packages/shared/src/validators/company-skill.ts index 793f5671a1..acf7cb4664 100644 --- a/packages/shared/src/validators/company-skill.ts +++ b/packages/shared/src/validators/company-skill.ts @@ -6,6 +6,7 @@ export const companySkillCompatibilitySchema = z.enum(["compatible", "unknown", export const companySkillSourceBadgeSchema = z.enum(["paperclip", "github", "local", "url", "catalog", "skills_sh"]); export const companySkillSharingScopeSchema = z.enum(["private", "company", "public_link"]); export const companySkillListSortSchema = z.enum(["alphabetical", "recent", "installs", "stars", "agents", "forks"]); +export const companySkillListIncludeSchema = z.enum(["lastEditor"]); export const companySkillFileInventoryEntrySchema = z.object({ path: z.string().min(1), @@ -59,6 +60,12 @@ export const companySkillListItemSchema = companySkillSchema.extend({ originHash: z.string().nullable(), packageName: z.string().nullable(), packageVersion: z.string().nullable(), + lastEditor: z.object({ + kind: z.enum(["user", "agent"]), + id: z.string().min(1), + name: z.string().nullable(), + imageUrl: z.string().nullable(), + }).nullable().optional(), }); export const companySkillUsageAgentSchema = z.object({ @@ -98,6 +105,7 @@ export const companySkillListQuerySchema = z.object({ sort: companySkillListSortSchema.optional(), categories: z.array(z.string().min(1)).optional(), scope: companySkillSharingScopeSchema.optional(), + include: z.array(companySkillListIncludeSchema).optional(), }); export const companySkillCategoryCountSchema = z.object({ @@ -319,6 +327,132 @@ export const companySkillFileUpdateSchema = z.object({ content: z.string(), }); +export const companySkillFileDeleteSchema = z.object({ + path: z.string().min(1), + target: z.enum(["file", "folder"]), +}); + +export const companySkillTestRunStatusSchema = z.enum(["queued", "running", "succeeded", "failed", "cancelled"]); + +export const companySkillTestInputSchema = z.object({ + id: z.string().uuid(), + companyId: z.string().uuid(), + skillId: z.string().uuid(), + name: z.string().min(1), + content: z.string(), + createdBy: z.string().nullable(), + deletedAt: z.coerce.date().nullable(), + createdAt: z.coerce.date(), + updatedAt: z.coerce.date(), +}); + +export const companySkillTestInputCreateSchema = z.object({ + name: z.string().trim().min(1), + content: z.string().min(1), +}); + +export const companySkillTestInputUpdateSchema = z.object({ + name: z.string().trim().min(1).optional(), + content: z.string().min(1).optional(), +}).refine((value) => value.name !== undefined || value.content !== undefined, { + message: "At least one field is required", +}); + +export const companySkillTestRunTemplateSchema = z.object({ + id: z.string().min(1), + companyId: z.string().uuid(), + name: z.string().min(1), + description: z.string().nullable(), + body: z.string().min(1), + builtIn: z.boolean(), + createdByAgentId: z.string().uuid().nullable(), + createdByUserId: z.string().nullable(), + updatedByAgentId: z.string().uuid().nullable(), + updatedByUserId: z.string().nullable(), + deletedAt: z.coerce.date().nullable(), + createdAt: z.coerce.date(), + updatedAt: z.coerce.date(), +}); + +export const companySkillTestRunTemplateCreateSchema = z.object({ + name: z.string().trim().min(1).max(120), + description: z.string().trim().max(500).nullable().optional(), + body: z.string().min(1).max(20_000), +}); + +export const companySkillTestRunTemplateUpdateSchema = z.object({ + name: z.string().trim().min(1).max(120).optional(), + description: z.string().trim().max(500).nullable().optional(), + body: z.string().min(1).max(20_000).optional(), +}).refine( + (value) => value.name !== undefined || value.description !== undefined || value.body !== undefined, + { message: "At least one field is required" }, +); + +export const companySkillTestRunTemplateSnapshotSchema = z.object({ + templateId: z.string().min(1).nullable(), + templateName: z.string().min(1).nullable(), + templateBody: z.string().min(1).max(20_000).nullable(), +}).refine( + (value) => + (value.templateId === null && value.templateName === null && value.templateBody === null) + || (value.templateId !== null && value.templateName !== null && value.templateBody !== null), + { message: "Template snapshot must be all null or include id, name, and body" }, +); + +export const companySkillTestRunCostSummarySchema = z.object({ + costCents: z.number().int().nonnegative(), + inputTokens: z.number().int().nonnegative(), + cachedInputTokens: z.number().int().nonnegative(), + outputTokens: z.number().int().nonnegative(), +}); + +export const companySkillTestRunSchema = z.object({ + id: z.string().uuid(), + companyId: z.string().uuid(), + skillId: z.string().uuid(), + inputId: z.string().uuid().nullable(), + inputSnapshot: z.string(), + skillVersionId: z.string().uuid(), + agentId: z.string().uuid(), + agentConfigSnapshot: z.record(z.string(), z.unknown()), + issueId: z.string().uuid(), + templateId: z.string().nullable(), + templateName: z.string().nullable(), + templateBody: z.string().nullable(), + renderedTemplateBody: z.string().nullable(), + harnessIssueDescription: z.string(), + status: companySkillTestRunStatusSchema, + outputDocumentKey: z.string().min(1), + outputSnapshot: z.string(), + error: z.string().nullable(), + deletedAt: z.coerce.date().nullable(), + supersededAt: z.coerce.date().nullable(), + harnessIssueExpiresAt: z.coerce.date().nullable(), + harnessIssueDeletedAt: z.coerce.date().nullable(), + createdAt: z.coerce.date(), + updatedAt: z.coerce.date(), + cost: companySkillTestRunCostSummarySchema, + taskExpired: z.boolean(), +}); + +export const companySkillTestRunCreateSchema = z.object({ + inputId: z.string().uuid().nullable().optional(), + content: z.string().min(1).nullable().optional(), + agentId: z.string().uuid(), + templateId: z.string().min(1).nullable().optional(), + templateSnapshot: companySkillTestRunTemplateSnapshotSchema.nullable().optional(), + // Re-run pins the viewed run's skill version instead of the live head, so the + // new run reproduces the same snapshots (golden-path step 5). + skillVersionId: z.string().uuid().nullable().optional(), +}).refine((value) => Boolean(value.inputId) || Boolean(value.content?.trim()), { + message: "inputId or content is required", +}); + +export const companySkillTestRunListQuerySchema = z.object({ + inputId: z.string().uuid().optional(), +}); + export const catalogSkillKindSchema = z.enum(["bundled", "optional"]); export const catalogSkillFileSchema = z.object({ @@ -397,6 +531,13 @@ export type CompanySkillListQuery = z.infer; export type CompanySkillProjectScan = z.infer; export type CompanySkillCreate = z.infer; export type CompanySkillFileUpdate = z.infer; +export type CompanySkillFileDelete = z.infer; +export type CompanySkillTestInputCreate = z.infer; +export type CompanySkillTestInputUpdate = z.infer; +export type CompanySkillTestRunTemplateCreate = z.infer; +export type CompanySkillTestRunTemplateUpdate = z.infer; +export type CompanySkillTestRunCreate = z.infer; +export type CompanySkillTestRunListQuery = z.infer; export type CompanySkillVersionCreate = z.infer; export type CompanySkillCommentCreate = z.infer; export type CompanySkillCommentUpdate = z.infer; diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index 1d3d3b4284..b76d6dee12 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -104,6 +104,7 @@ export { companySkillSourceBadgeSchema, companySkillSharingScopeSchema, companySkillListSortSchema, + companySkillListIncludeSchema, companySkillFileInventoryEntrySchema, companySkillVersionFileInventoryEntrySchema, companySkillSchema, @@ -136,6 +137,19 @@ export { companySkillCreateSchema, companySkillFileDetailSchema, companySkillFileUpdateSchema, + companySkillFileDeleteSchema, + companySkillTestRunStatusSchema, + companySkillTestInputSchema, + companySkillTestInputCreateSchema, + companySkillTestInputUpdateSchema, + companySkillTestRunTemplateSchema, + companySkillTestRunTemplateCreateSchema, + companySkillTestRunTemplateUpdateSchema, + companySkillTestRunTemplateSnapshotSchema, + companySkillTestRunCostSummarySchema, + companySkillTestRunSchema, + companySkillTestRunCreateSchema, + companySkillTestRunListQuerySchema, catalogSkillKindSchema, catalogSkillFileSchema, catalogSkillGitHubSourceSchema, @@ -152,6 +166,12 @@ export { type CompanySkillProjectScan, type CompanySkillCreate, type CompanySkillFileUpdate, + type CompanySkillTestInputCreate, + type CompanySkillTestInputUpdate, + type CompanySkillTestRunTemplateCreate, + type CompanySkillTestRunTemplateUpdate, + type CompanySkillTestRunCreate, + type CompanySkillTestRunListQuery, type CompanySkillVersionCreate, type CompanySkillCommentCreate, type CompanySkillCommentUpdate, @@ -238,6 +258,7 @@ export { normalizeAgentApiKeyScope, standardAgentKeyScopeSchema, taskBridgeAgentKeyScopeSchema, + skillTestAgentKeyScopeSchema, createAgentKeySchema, agentMineInboxQuerySchema, wakeAgentSchema, @@ -253,6 +274,7 @@ export { type UpdateAgentInstructionsPath, type AgentApiKeyScope, type TaskBridgeAgentKeyScope, + type SkillTestAgentKeyScope, type CreateAgentKey, type AgentMineInboxQuery, type WakeAgent, diff --git a/packages/shared/src/validators/instance.test.ts b/packages/shared/src/validators/instance.test.ts index d77a00bcfb..34e6174894 100644 --- a/packages/shared/src/validators/instance.test.ts +++ b/packages/shared/src/validators/instance.test.ts @@ -23,6 +23,22 @@ describe("instance experimental settings validators", () => { expect(settings.enableGoalsSidebarLink).toBe(false); }); + it("defaults worktree run execution off", () => { + const settings = instanceExperimentalSettingsSchema.parse({}); + + expect(settings.enableWorktreeRunExecution).toBe(false); + }); + + it("accepts worktree run execution patches", () => { + expect( + patchInstanceExperimentalSettingsSchema.parse({ + enableWorktreeRunExecution: true, + }), + ).toEqual({ + enableWorktreeRunExecution: true, + }); + }); + it("accepts server info debug view patches", () => { expect( patchInstanceExperimentalSettingsSchema.parse({ diff --git a/packages/shared/src/validators/instance.ts b/packages/shared/src/validators/instance.ts index 18ea416118..158edeffe7 100644 --- a/packages/shared/src/validators/instance.ts +++ b/packages/shared/src/validators/instance.ts @@ -54,6 +54,7 @@ export const instanceExperimentalSettingsSchema = z.object({ autoRestartDevServerWhenIdle: z.boolean().default(false), enableIssueGraphLivenessAutoRecovery: z.boolean().default(false), enableWorkspaceBranchReconcileForward: z.boolean().default(false), + enableWorktreeRunExecution: z.boolean().default(false), issueGraphLivenessAutoRecoveryLookbackHours: z .number() .int() diff --git a/packages/shared/src/validators/issue.test.ts b/packages/shared/src/validators/issue.test.ts index 8834dcb0bb..f766dd7890 100644 --- a/packages/shared/src/validators/issue.test.ts +++ b/packages/shared/src/validators/issue.test.ts @@ -245,12 +245,18 @@ describe("issue validators", () => { }).status).toBe("backlog"); }); - it("defaults issue work mode to standard and accepts ask and planning", () => { + it("defaults issue work mode to standard and accepts ask, planning, and skill_test", () => { expect(createIssueSchema.parse({ title: "Plan first" }).workMode).toBe("standard"); expect(createIssueSchema.parse({ title: "Ask first", workMode: "ask" }).workMode).toBe("ask"); expect(createIssueSchema.parse({ title: "Plan first", workMode: "planning" }).workMode).toBe("planning"); + expect(createIssueSchema.parse({ + title: "Harness test", + workMode: "skill_test", + harnessKind: "skill_test", + })).toMatchObject({ workMode: "skill_test", harnessKind: "skill_test" }); expect(updateIssueSchema.parse({ workMode: "ask" }).workMode).toBe("ask"); expect(updateIssueSchema.parse({ workMode: "planning" }).workMode).toBe("planning"); + expect(updateIssueSchema.parse({ workMode: "skill_test" }).workMode).toBe("skill_test"); expect(suggestedTaskDraftSchema.parse({ clientKey: "ask-child", title: "Ask child", @@ -261,6 +267,11 @@ describe("issue validators", () => { title: "Plan child", workMode: "planning", }).workMode).toBe("planning"); + expect(suggestedTaskDraftSchema.parse({ + clientKey: "skill-test-child", + title: "Test child", + workMode: "skill_test", + }).workMode).toBe("skill_test"); }); it("validates blocked inbox attention payloads and requires redacted secret fields", () => { diff --git a/packages/shared/src/validators/issue.ts b/packages/shared/src/validators/issue.ts index 449f05ad90..0b94aa8aee 100644 --- a/packages/shared/src/validators/issue.ts +++ b/packages/shared/src/validators/issue.ts @@ -12,6 +12,7 @@ import { ISSUE_COMMENT_METADATA_ROW_TYPES, ISSUE_COMMENT_PRESENTATION_KINDS, ISSUE_COMMENT_PRESENTATION_TONES, + ISSUE_HARNESS_KINDS, ISSUE_MONITOR_SCHEDULED_BY, ISSUE_PRIORITIES, ISSUE_RECOVERY_ACTION_KINDS, @@ -384,6 +385,7 @@ const createIssueBaseSchema = z.object({ description: multilineTextSchema.optional().nullable(), status: z.enum(ISSUE_STATUSES), workMode: z.enum(ISSUE_WORK_MODES).optional().default("standard"), + harnessKind: z.enum(ISSUE_HARNESS_KINDS).optional().nullable(), priority: z.enum(ISSUE_PRIORITIES).optional().default("medium"), assigneeAgentId: z.string().uuid().optional().nullable(), assigneeUserId: z.string().optional().nullable(), diff --git a/scripts/screenshot-fork-flow.mjs b/scripts/screenshot-fork-flow.mjs new file mode 100644 index 0000000000..6cea613789 --- /dev/null +++ b/scripts/screenshot-fork-flow.mjs @@ -0,0 +1,47 @@ +#!/usr/bin/env node +// Screenshot the PAP-13112 "Edit a copy" fork-flow stories. +import fs from "node:fs/promises"; +import path from "node:path"; +import { chromium } from "@playwright/test"; + +const OUT = process.argv[2] || "screenshots/pap-13112"; +const BASE = "http://localhost:6006/iframe.html"; +await fs.mkdir(path.resolve(OUT), { recursive: true }); + +const shots = [ + { id: "skill-studio-editacopy--read-only-banner-cta", name: "01-readonly-banner-cta", w: 560, h: 260 }, + { id: "skill-studio-editacopy--fork-dialog-agents-switch-on", name: "02-fork-dialog-switch-on", w: 720, h: 640 }, + { id: "skill-studio-editacopy--fork-dialog-agents-switch-on", name: "03-fork-dialog-switch-off", w: 720, h: 640, toggleOff: true }, + { id: "skill-studio-editacopy--fork-dialog-no-agents", name: "04-fork-dialog-no-agents", w: 720, h: 560 }, + { id: "skill-studio-editacopy--fork-dialog-existing-copy", name: "05-fork-dialog-existing-copy", w: 720, h: 700 }, + { id: "skill-studio-editacopy--forked-skill-header", name: "06-lineage-chip", w: 720, h: 200 }, + { id: "skill-studio-editacopy--project-scan-source-notice", name: "07-project-scan-notice", w: 620, h: 200 }, +]; + +const browser = await chromium.launch({ + headless: true, + executablePath: process.env.CHROME_PATH || undefined, + args: ["--no-sandbox", "--disable-dev-shm-usage"], +}); +try { + for (const shot of shots) { + const ctx = await browser.newContext({ + viewport: { width: shot.w, height: shot.h }, + deviceScaleFactor: 2, + }); + const page = await ctx.newPage(); + await page.goto(`${BASE}?id=${shot.id}&viewMode=story`, { waitUntil: "networkidle" }); + await page.waitForTimeout(1200); + if (shot.toggleOff) { + const toggle = page.locator('button[aria-label="Switch these agents to the copy"]'); + await toggle.click(); + await page.waitForTimeout(400); + } + const out = path.join(OUT, `${shot.name}.png`); + await page.screenshot({ path: out, fullPage: false }); + console.log(`Wrote ${out}`); + await ctx.close(); + } +} finally { + await browser.close(); +} diff --git a/server/src/__tests__/agent-auth-jwt.test.ts b/server/src/__tests__/agent-auth-jwt.test.ts index c5fe3f8b95..7d809ce3d3 100644 --- a/server/src/__tests__/agent-auth-jwt.test.ts +++ b/server/src/__tests__/agent-auth-jwt.test.ts @@ -67,6 +67,18 @@ describe("agent local JWT", () => { }); }); + it("round-trips a skill_test run scope", () => { + vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); + const issueId = "11111111-1111-4111-8111-111111111111"; + const token = createLocalAgentJwt("agent-1", "company-1", "claude_local", "run-1", "user-1", { + kind: "skill_test", + issueId, + }); + + const claims = verifyLocalAgentJwt(token!); + expect(claims?.key_scope).toEqual({ kind: "skill_test", issueId }); + }); + it("returns null when secret is missing", () => { process.env[secretEnv] = ""; const token = createLocalAgentJwt("agent-1", "company-1", "claude_local", "run-1"); diff --git a/server/src/__tests__/agent-auth-middleware.test.ts b/server/src/__tests__/agent-auth-middleware.test.ts index 54c4fe150b..ff27e685b7 100644 --- a/server/src/__tests__/agent-auth-middleware.test.ts +++ b/server/src/__tests__/agent-auth-middleware.test.ts @@ -197,6 +197,35 @@ describe("agent auth middleware", () => { }); }); + it("preserves signed skill_test JWT scope on the request actor", async () => { + const companyId = randomUUID(); + const agentId = randomUUID(); + const runId = randomUUID(); + const issueId = randomUUID(); + const { db } = createDbState({ + agent: { id: agentId, companyId }, + run: { id: runId, companyId, agentId, responsibleUserId: "user-claim" }, + }); + const token = createLocalAgentJwt(agentId, companyId, "codex_local", runId, "user-claim", { + kind: "skill_test", + issueId, + }); + + const res = await request(createApp(db)) + .get("/actor") + .set("Authorization", `Bearer ${token}`) + .set("X-Paperclip-Run-Id", runId); + + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + type: "agent", + agentId, + companyId, + keyScope: { kind: "skill_test", issueId }, + source: "agent_jwt", + }); + }); + it("rejects mismatched run headers for agent JWTs and audits the spoof attempt", async () => { const companyId = randomUUID(); const agentId = randomUUID(); diff --git a/server/src/__tests__/agent-skills-routes.test.ts b/server/src/__tests__/agent-skills-routes.test.ts index 323d2ddd1f..e1f02e7170 100644 --- a/server/src/__tests__/agent-skills-routes.test.ts +++ b/server/src/__tests__/agent-skills-routes.test.ts @@ -275,11 +275,13 @@ describe.sequential("agent skill routes", () => { ), ); mockCompanySkillService.resolveRequestedSkillEntries.mockImplementation( - async (_companyId: string, requested: Array<{ key: string; versionId?: string | null }>) => - requested.map((entry) => ({ + async (_companyId: string, requested: Array<{ key: string; versionId?: string | null }>) => ({ + resolved: requested.map((entry) => ({ key: entry.key === "paperclip" ? "paperclipai/paperclip/paperclip" : entry.key, versionId: entry.versionId ?? null, })), + unresolved: [], + }), ); mockAdapter.listSkills.mockResolvedValue({ adapterType: "claude_local", @@ -493,6 +495,61 @@ describe.sequential("agent skill routes", () => { ); }); + it("preserves stale desired keys instead of 422-ing when syncing (PAP-13222)", async () => { + mockAgentService.getById.mockResolvedValue(makeAgent("acpx_local")); + // The agent already carries a stale desired key that no longer resolves to a + // company-library skill. Toggling a resolvable skill must still succeed and + // keep the stale key so it stays visible/removable in the UI. + mockCompanySkillService.resolveRequestedSkillEntries.mockImplementationOnce( + async ( + _companyId: string, + requested: Array<{ key: string; versionId?: string | null }>, + options?: { tolerateUnknownReferences?: boolean }, + ) => { + expect(options?.tolerateUnknownReferences).toBe(true); + const resolved: Array<{ key: string; versionId: string | null }> = []; + const unresolved: string[] = []; + for (const entry of requested) { + if (entry.key === "stale/removed/skill") { + unresolved.push(entry.key); + } else { + resolved.push({ + key: entry.key === "paperclip" ? "paperclipai/paperclip/paperclip" : entry.key, + versionId: entry.versionId ?? null, + }); + } + } + return { resolved, unresolved }; + }, + ); + + const res = await requestApp(await createApp(), (baseUrl) => request(baseUrl) + .post("/api/agents/11111111-1111-4111-8111-111111111111/skills/sync?companyId=company-1") + .send({ desiredSkills: ["paperclip", "stale/removed/skill"] })); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + // Stale key preserved in the persisted config alongside the resolved skill. + expect(mockAgentService.update).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + adapterConfig: expect.objectContaining({ + paperclipSkillSync: expect.objectContaining({ + desiredSkills: ["paperclipai/paperclip/paperclip", "stale/removed/skill"], + }), + }), + }), + expect.any(Object), + ); + // Runtime version selection only considers resolvable keys. + expect(mockCompanySkillService.listRuntimeSkillEntries).toHaveBeenCalledWith( + "company-1", + expect.objectContaining({ versionSelections: expect.any(Map) }), + ); + const versionSelections = mockCompanySkillService.listRuntimeSkillEntries.mock.calls.at(-1)?.[1] + ?.versionSelections as Map | undefined; + expect(versionSelections?.has("stale/removed/skill")).toBe(false); + }); + it("skips runtime materialization when listing persistent skill adapters", async () => { mockAgentService.getById.mockResolvedValue(makeAgent("cursor")); mockAdapter.listSkills.mockResolvedValue({ diff --git a/server/src/__tests__/authorization-service.test.ts b/server/src/__tests__/authorization-service.test.ts index de0484939e..213b192e47 100644 --- a/server/src/__tests__/authorization-service.test.ts +++ b/server/src/__tests__/authorization-service.test.ts @@ -1521,4 +1521,72 @@ describeEmbeddedPostgres("authorization service", () => { reason: "deny_scope", }); }); + + it("scopes skill-test keys to their own issue only", async () => { + const company = await createCompany(db, "SkillTest"); + const skillTestAgent = await createAgent(db, company.id); + const ownIssue = await createIssue(db, company.id, { assigneeAgentId: skillTestAgent.id }); + const otherIssue = await createIssue(db, company.id); + const actor = { + type: "agent" as const, + agentId: skillTestAgent.id, + companyId: company.id, + source: "agent_key" as const, + keyScope: { + kind: "skill_test" as const, + issueId: ownIssue.id, + }, + }; + const authz = authorizationService(db); + + for (const action of ["issue:read", "issue:comment", "issue:mutate"] as const) { + await expect(authz.decide({ + actor, + action, + resource: { + type: "issue", + companyId: company.id, + issueId: ownIssue.id, + }, + })).resolves.toMatchObject({ + allowed: true, + }); + } + + await expect(authz.decide({ + actor, + action: "issue:mutate", + resource: { + type: "issue", + companyId: company.id, + issueId: otherIssue.id, + }, + })).resolves.toMatchObject({ + allowed: false, + reason: "deny_scope", + }); + + await expect(authz.decide({ + actor, + action: "company_scope:read", + resource: { type: "company", companyId: company.id }, + })).resolves.toMatchObject({ + allowed: false, + reason: "deny_scope", + }); + + await expect(authz.decide({ + actor, + action: "tasks:assign", + resource: { + type: "issue", + companyId: company.id, + parentIssueId: ownIssue.id, + assigneeAgentId: skillTestAgent.id, + }, + })).resolves.toMatchObject({ + allowed: false, + reason: "deny_scope", + }); + }); }); diff --git a/server/src/__tests__/company-search-service.test.ts b/server/src/__tests__/company-search-service.test.ts index 940f4c0c92..d4edf955eb 100644 --- a/server/src/__tests__/company-search-service.test.ts +++ b/server/src/__tests__/company-search-service.test.ts @@ -267,6 +267,11 @@ describeEmbeddedPostgres("companySearchService", () => { title: "Hidden needle", hiddenAt: new Date(), }); + await createIssue(companyId, { + identifier: "HAR-1", + title: "Harness needle", + harnessKind: "skill_test", + }); await createIssue(otherCompanyId, { identifier: "OTH-1", title: "Other company needle", diff --git a/server/src/__tests__/company-skill-test-runs-service.test.ts b/server/src/__tests__/company-skill-test-runs-service.test.ts new file mode 100644 index 0000000000..a0948639bd --- /dev/null +++ b/server/src/__tests__/company-skill-test-runs-service.test.ts @@ -0,0 +1,753 @@ +import { randomUUID } from "node:crypto"; +import os from "node:os"; +import path from "node:path"; +import { promises as fs } from "node:fs"; +import { eq } from "drizzle-orm"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { + agents, + assets, + companies, + companySkillTestRunTemplates, + companySkillTestRuns, + companySkills, + createDb, + documents, + issueAttachments, + issueDocuments, + issueWorkProducts, + issues, +} from "@paperclipai/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; +import { companySkillService } from "../services/company-skills.js"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres company skill test run tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} + +describeEmbeddedPostgres("companySkillService skill test runs", () => { + let db!: ReturnType; + let svc!: ReturnType; + let tempDb: Awaited> | null = null; + const cleanupDirs = new Set(); + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-company-skill-test-runs-"); + db = createDb(tempDb.connectionString); + svc = companySkillService(db); + }, 20_000); + + afterEach(async () => { + await db.delete(issueAttachments); + await db.delete(issueWorkProducts); + await db.delete(assets); + await db.delete(issueDocuments); + await db.delete(documents); + await db.delete(companySkillTestRuns); + await db.delete(companySkillTestRunTemplates); + await db.delete(issues); + await db.delete(agents); + await db.delete(companySkills); + await db.delete(companies); + await Promise.all(Array.from(cleanupDirs, (dir) => fs.rm(dir, { recursive: true, force: true }))); + cleanupDirs.clear(); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + async function seedSkillAndAgent() { + const companyId = randomUUID(); + const skillId = randomUUID(); + const agentId = randomUUID(); + const skillDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-skill-test-run-")); + cleanupDirs.add(skillDir); + await fs.writeFile(path.join(skillDir, "SKILL.md"), "# Review Skill\n", "utf8"); + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await db.insert(agents).values({ + id: agentId, + companyId, + name: "Tester", + role: "engineer", + status: "idle", + adapterType: "codex_local", + adapterConfig: { model: "gpt-5.4" }, + }); + await db.insert(companySkills).values({ + id: skillId, + companyId, + key: `company/${companyId}/review`, + slug: "review", + name: "Review Skill", + description: null, + markdown: "# Review Skill\n", + sourceType: "local_path", + sourceLocator: skillDir, + trustLevel: "markdown_only", + compatibility: "compatible", + fileInventory: [{ path: "SKILL.md", kind: "skill" }], + metadata: { sourceKind: "managed_local" }, + }); + return { companyId, skillId, agentId }; + } + + const runDeps = (companyId: string) => ({ + createHarnessIssue: async (issue: Parameters[4]["createHarnessIssue"]>[0]) => { + await db.insert(issues).values({ ...issue, companyId, priority: "medium" }); + return { id: issue.id }; + }, + wakeHarnessIssue: async () => null, + retentionDays: 7, + }); + + it("cleans up the harness issue if persisting the test run fails", async () => { + const { companyId, skillId, agentId } = await seedSkillAndAgent(); + const cleanedIssueIds: string[] = []; + let createdIssueId: string | null = null; + let wakeCalls = 0; + + await expect( + svc.createTestRun( + companyId, + skillId, + { content: "test this skill", agentId }, + { type: "user", userId: "local-board" }, + { + createHarnessIssue: async (issue) => { + createdIssueId = issue.id; + await db.insert(issues).values({ ...issue, companyId, priority: "medium" }); + return { id: issue.id }; + }, + wakeHarnessIssue: async () => { + wakeCalls += 1; + }, + cleanupHarnessIssue: async (issueId) => { + cleanedIssueIds.push(issueId); + await db + .update(issues) + .set({ status: "cancelled", hiddenAt: new Date() }) + .where(eq(issues.id, issueId)); + }, + retentionDays: Number.NaN, + }, + ), + ).rejects.toThrow(); + + expect(createdIssueId).toBeTruthy(); + expect(cleanedIssueIds).toEqual([createdIssueId]); + expect(wakeCalls).toBe(0); + const issue = await db + .select({ status: issues.status, hiddenAt: issues.hiddenAt }) + .from(issues) + .where(eq(issues.id, createdIssueId!)) + .then((rows) => rows[0] ?? null); + expect(issue?.status).toBe("cancelled"); + expect(issue?.hiddenAt).toBeInstanceOf(Date); + }); + + it("appends the built-in default template while keeping the input snapshot clean", async () => { + const { companyId, skillId, agentId } = await seedSkillAndAgent(); + const run = await svc.createTestRun( + companyId, + skillId, + { content: "test this skill", agentId }, + { type: "user", userId: "local-board" }, + runDeps(companyId), + ); + + expect(run.inputSnapshot).toBe("test this skill"); + expect(run.templateId).toBe("built-in:default-test-template"); + expect(run.templateName).toBe("Default test template"); + expect(run.templateBody).toContain("{{skillName}}"); + expect(run.renderedTemplateBody).toContain("Skills Studio test for `Review Skill`"); + expect(run.renderedTemplateBody).toContain(`company/${companyId}/review`); + expect(run.renderedTemplateBody).toContain("issue document `output`"); + expect(run.harnessIssueDescription).toBe(`test this skill\n\n---\n\n${run.renderedTemplateBody}`); + + const issue = await db + .select({ description: issues.description }) + .from(issues) + .where(eq(issues.id, run.issueId)) + .then((rows) => rows[0] ?? null); + expect(issue?.description).toBe(run.harnessIssueDescription); + }); + + it("honors No template without weakening the clean run snapshot", async () => { + const { companyId, skillId, agentId } = await seedSkillAndAgent(); + const run = await svc.createTestRun( + companyId, + skillId, + { content: "test this skill", agentId, templateId: null }, + { type: "user", userId: "local-board" }, + runDeps(companyId), + ); + + expect(run.inputSnapshot).toBe("test this skill"); + expect(run.templateId).toBeNull(); + expect(run.templateName).toBeNull(); + expect(run.templateBody).toBeNull(); + expect(run.renderedTemplateBody).toBeNull(); + expect(run.harnessIssueDescription).toBe("test this skill"); + + const issue = await db + .select({ description: issues.description }) + .from(issues) + .where(eq(issues.id, run.issueId)) + .then((rows) => rows[0] ?? null); + expect(issue?.description).toBe("test this skill"); + }); + + it("manages custom templates and renders only explicit placeholders", async () => { + const { companyId, skillId, agentId } = await seedSkillAndAgent(); + const template = await svc.createTestRunTemplate(companyId, { + name: "Focused smoke", + description: "Short run", + body: "Run {{skillName}} v{{skillVersion}} for {{runId}} on {{issueId}} into {{outputDocumentKey}}.", + }, { type: "user", userId: "local-board" }); + + const listed = await svc.listTestRunTemplates(companyId); + expect(listed.map((entry) => entry.id)).toEqual(["built-in:default-test-template", template.id]); + expect(listed[0]?.builtIn).toBe(true); + + const run = await svc.createTestRun( + companyId, + skillId, + { content: "custom template run", agentId, templateId: template.id }, + { type: "user", userId: "local-board" }, + runDeps(companyId), + ); + expect(run.templateId).toBe(template.id); + expect(run.templateName).toBe("Focused smoke"); + expect(run.renderedTemplateBody).toContain("Run Review Skill v1"); + expect(run.renderedTemplateBody).toContain(run.id); + expect(run.renderedTemplateBody).toContain(run.issueId); + expect(run.renderedTemplateBody).toContain("into output"); + + await expect( + svc.createTestRunTemplate(companyId, { + name: "Bad template", + body: "Use {{unknownPlaceholder}}.", + }, { type: "user", userId: "local-board" }), + ).rejects.toThrow(/unknown template placeholder/i); + + const updated = await svc.updateTestRunTemplate(companyId, template.id, { + name: "Focused smoke v2", + body: "Use {{skillKey}}.", + }, { type: "user", userId: "local-board" }); + expect(updated?.name).toBe("Focused smoke v2"); + expect(updated?.body).toBe("Use {{skillKey}}."); + + await expect( + svc.updateTestRunTemplate(companyId, "built-in:default-test-template", { name: "Changed" }), + ).rejects.toThrow(/read-only/i); + + const deleted = await svc.deleteTestRunTemplate(companyId, template.id); + expect(deleted?.deletedAt).toBeInstanceOf(Date); + expect((await svc.listTestRunTemplates(companyId)).map((entry) => entry.id)).toEqual([ + "built-in:default-test-template", + ]); + }); + + it("rejects unknown or cross-company template ids", async () => { + const first = await seedSkillAndAgent(); + const second = await seedSkillAndAgent(); + const otherTemplate = await svc.createTestRunTemplate(second.companyId, { + name: "Other company", + body: "Other {{skillName}}.", + }, { type: "user", userId: "local-board" }); + + await expect( + svc.createTestRun( + first.companyId, + first.skillId, + { content: "test", agentId: first.agentId, templateId: randomUUID() }, + { type: "user", userId: "local-board" }, + runDeps(first.companyId), + ), + ).rejects.toThrow(/test run template not found/i); + + await expect( + svc.createTestRun( + first.companyId, + first.skillId, + { content: "test", agentId: first.agentId, templateId: otherTemplate.id }, + { type: "user", userId: "local-board" }, + runDeps(first.companyId), + ), + ).rejects.toThrow(/test run template not found/i); + }); + + it("re-run can use the viewed template body snapshot after source template edits", async () => { + const { companyId, skillId, agentId } = await seedSkillAndAgent(); + const template = await svc.createTestRunTemplate(companyId, { + name: "Snapshot me", + body: "Original {{skillName}}.", + }, { type: "user", userId: "local-board" }); + const first = await svc.createTestRun( + companyId, + skillId, + { content: "repeatable", agentId, templateId: template.id }, + { type: "user", userId: "local-board" }, + runDeps(companyId), + ); + + await svc.updateTestRunTemplate(companyId, template.id, { + body: "Edited {{skillName}}.", + }, { type: "user", userId: "local-board" }); + + const reRun = await svc.createTestRun( + companyId, + skillId, + { + content: first.inputSnapshot, + agentId: first.agentId, + skillVersionId: first.skillVersionId, + templateSnapshot: { + templateId: first.templateId, + templateName: first.templateName, + templateBody: first.templateBody, + }, + }, + { type: "user", userId: "local-board" }, + runDeps(companyId), + ); + expect(reRun.skillVersionId).toBe(first.skillVersionId); + expect(reRun.templateId).toBe(template.id); + expect(reRun.templateBody).toBe("Original {{skillName}}."); + expect(reRun.renderedTemplateBody).toBe("Original Review Skill."); + expect(reRun.harnessIssueDescription).toContain("Original Review Skill."); + expect(reRun.harnessIssueDescription).not.toContain("Edited Review Skill."); + }); + + it("only deletes terminal runs and soft-deletes them out of history", async () => { + const { companyId, skillId, agentId } = await seedSkillAndAgent(); + const run = await svc.createTestRun( + companyId, + skillId, + { content: "test this skill", agentId }, + { type: "user", userId: "local-board" }, + runDeps(companyId), + ); + + // In-flight run must be cancelled first. + await expect( + svc.deleteTestRun(companyId, skillId, run.id, { hideHarnessIssue: async () => null }), + ).rejects.toThrow(/cancel the run/i); + + await svc.completeTestRunForIssue({ companyId, issueId: run.issueId, outcome: "succeeded" }); + + const hidden: string[] = []; + const deleted = await svc.deleteTestRun(companyId, skillId, run.id, { + hideHarnessIssue: async (issueId) => { + hidden.push(issueId); + }, + }); + expect(deleted?.id).toBe(run.id); + expect(deleted?.harnessIssueDeletedAt).toBeInstanceOf(Date); + expect(deleted?.taskExpired).toBe(true); + expect(hidden).toEqual([run.issueId]); + + // Gone from listings and detail. + expect(await svc.listTestRuns(companyId, skillId)).toHaveLength(0); + expect(await svc.getTestRunDetail(companyId, skillId, run.id)).toBeNull(); + + // Deleting again is a no-op 404 (returns null). + expect( + await svc.deleteTestRun(companyId, skillId, run.id, { hideHarnessIssue: async () => null }), + ).toBeNull(); + }); + + it("re-run pins an explicit skill version instead of the live head", async () => { + const { companyId, skillId, agentId } = await seedSkillAndAgent(); + const first = await svc.createTestRun( + companyId, + skillId, + { content: "first run", agentId }, + { type: "user", userId: "local-board" }, + runDeps(companyId), + ); + const pinnedVersionId = first.skillVersionId; + + const reRun = await svc.createTestRun( + companyId, + skillId, + { content: "first run", agentId, skillVersionId: pinnedVersionId }, + { type: "user", userId: "local-board" }, + runDeps(companyId), + ); + expect(reRun.skillVersionId).toBe(pinnedVersionId); + + // A bogus version id is rejected rather than silently falling back to head. + await expect( + svc.createTestRun( + companyId, + skillId, + { content: "first run", agentId, skillVersionId: randomUUID() }, + { type: "user", userId: "local-board" }, + runDeps(companyId), + ), + ).rejects.toThrow(/skill version not found/i); + }); + + it("ignores superseded test harness issue transitions", async () => { + const { companyId, skillId, agentId } = await seedSkillAndAgent(); + const first = await svc.createTestRun( + companyId, + skillId, + { content: "first run", agentId }, + { type: "user", userId: "local-board" }, + runDeps(companyId), + ); + const replacement = await svc.createTestRun( + companyId, + skillId, + { content: "replacement run", agentId }, + { type: "user", userId: "local-board" }, + runDeps(companyId), + ); + + const listed = await svc.listTestRuns(companyId, skillId); + expect(listed.map((run) => run.id)).toEqual([replacement.id, first.id]); + expect(listed.find((run) => run.id === first.id)?.supersededAt).toBeInstanceOf(Date); + expect(await svc.getTestRunDetail(companyId, skillId, first.id)).not.toBeNull(); + expect(await svc.markTestRunRunning(companyId, first.issueId)).toBeNull(); + expect(await svc.completeTestRunForIssue({ + companyId, + issueId: first.issueId, + outcome: "succeeded", + })).toBeNull(); + const cancelledIssueIds: string[] = []; + expect(await svc.cancelTestRun(companyId, skillId, first.id, { + cancelHarnessIssue: async (issueId) => { + cancelledIssueIds.push(issueId); + }, + })).toBeNull(); + expect(cancelledIssueIds).toEqual([]); + + const firstRow = await db + .select({ + status: companySkillTestRuns.status, + error: companySkillTestRuns.error, + supersededAt: companySkillTestRuns.supersededAt, + outputSnapshot: companySkillTestRuns.outputSnapshot, + }) + .from(companySkillTestRuns) + .where(eq(companySkillTestRuns.id, first.id)) + .then((rows) => rows[0] ?? null); + expect(firstRow?.status).toBe("cancelled"); + expect(firstRow?.error).toBe("Superseded by newer run"); + expect(firstRow?.supersededAt).toBeInstanceOf(Date); + expect(firstRow?.outputSnapshot).toBe(""); + + const hiddenIssueIds: string[] = []; + const deletedFirst = await svc.deleteTestRun(companyId, skillId, first.id, { + hideHarnessIssue: async (issueId) => { + hiddenIssueIds.push(issueId); + }, + }); + expect(deletedFirst?.id).toBe(first.id); + expect(hiddenIssueIds).toEqual([first.issueId]); + expect((await svc.listTestRuns(companyId, skillId)).map((run) => run.id)).toEqual([replacement.id]); + + const runningReplacement = await svc.markTestRunRunning(companyId, replacement.issueId); + expect(runningReplacement?.status).toBe("running"); + }); + + it("snapshots output and keeps run history after harness issue retention", async () => { + const companyId = randomUUID(); + const skillId = randomUUID(); + const agentId = randomUUID(); + const skillDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-skill-test-run-")); + cleanupDirs.add(skillDir); + await fs.writeFile(path.join(skillDir, "SKILL.md"), "# Review Skill\n", "utf8"); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await db.insert(agents).values({ + id: agentId, + companyId, + name: "Tester", + role: "engineer", + status: "idle", + adapterType: "codex_local", + adapterConfig: { + model: "gpt-5.4", + paperclipSkillSync: { desiredSkills: [`company/${companyId}/review`] }, + instructionsFilePath: "/tmp/AGENTS.md", + }, + }); + await db.insert(companySkills).values({ + id: skillId, + companyId, + key: `company/${companyId}/review`, + slug: "review", + name: "Review Skill", + description: null, + markdown: "# Review Skill\n", + sourceType: "local_path", + sourceLocator: skillDir, + trustLevel: "markdown_only", + compatibility: "compatible", + fileInventory: [{ path: "SKILL.md", kind: "skill" }], + metadata: { sourceKind: "managed_local" }, + }); + + const input = await svc.createTestInput(companyId, skillId, { + name: "cases/simple", + content: "Try the review skill", + }, { type: "user", userId: "local-board" }); + const run = await svc.createTestRun(companyId, skillId, { + inputId: input.id, + agentId, + }, { type: "user", userId: "local-board" }, { + createHarnessIssue: async (issue) => { + await db.insert(issues).values({ + ...issue, + companyId, + priority: "medium", + }); + return { id: issue.id }; + }, + wakeHarnessIssue: async () => null, + retentionDays: 0, + }); + + expect(run.skillVersionId).toMatch(/[0-9a-f-]{36}/); + expect(run.inputSnapshot).toBe("Try the review skill"); + expect(run.agentConfigSnapshot).toEqual(expect.objectContaining({ + adapterType: "codex_local", + model: "gpt-5.4", + instructionsRef: "/tmp/AGENTS.md", + })); + + const documentId = randomUUID(); + await db.insert(documents).values({ + id: documentId, + companyId, + title: "Output", + format: "markdown", + latestBody: "## Result\n\nThe skill responded.", + createdByAgentId: agentId, + updatedByAgentId: agentId, + }); + await db.insert(issueDocuments).values({ + companyId, + issueId: run.issueId, + documentId, + key: "output", + }); + + const completed = await svc.completeTestRunForIssue({ + companyId, + issueId: run.issueId, + outcome: "succeeded", + }); + expect(completed?.status).toBe("succeeded"); + expect(completed?.outputSnapshot).toBe("## Result\n\nThe skill responded."); + + await db + .update(companySkillTestRuns) + .set({ harnessIssueExpiresAt: new Date(Date.now() - 60_000) }) + .where(eq(companySkillTestRuns.id, run.id)); + + const pruned = await svc.pruneExpiredTestHarnessIssues(companyId); + expect(pruned.pruned).toBe(1); + const detail = await svc.getTestRunDetail(companyId, skillId, run.id); + expect(detail?.taskExpired).toBe(true); + expect(detail?.harnessIssue).toBeNull(); + expect(detail?.outputSnapshot).toBe("## Result\n\nThe skill responded."); + expect(detail?.outputBody).toBe("## Result\n\nThe skill responded."); + expect(detail?.harnessContent).toEqual({ + available: false, + unavailableReason: "expired", + documents: [], + attachments: [], + workProducts: [], + }); + }); + + it("hydrates rich documents, attachments, and work products scoped to the run's harness issue", async () => { + const { companyId, skillId, agentId } = await seedSkillAndAgent(); + const run = await svc.createTestRun( + companyId, + skillId, + { content: "produce rich output", agentId }, + { type: "user", userId: "local-board" }, + runDeps(companyId), + ); + // Sibling run in the same company whose content must not leak into `run`'s detail. + const otherRun = await svc.createTestRun( + companyId, + skillId, + { content: "unrelated run", agentId }, + { type: "user", userId: "local-board" }, + runDeps(companyId), + ); + + async function seedIssueContent(issueId: string, marker: string) { + const documentId = randomUUID(); + await db.insert(documents).values({ + id: documentId, + companyId, + title: `Output ${marker}`, + format: "markdown", + latestBody: `## Result ${marker}`, + createdByAgentId: agentId, + updatedByAgentId: agentId, + }); + await db.insert(issueDocuments).values({ companyId, issueId, documentId, key: "output" }); + const assetId = randomUUID(); + await db.insert(assets).values({ + id: assetId, + companyId, + provider: "local", + objectKey: `skill-tests/${marker}.png`, + contentType: "image/png", + byteSize: 2048, + sha256: marker.repeat(8).slice(0, 64).padEnd(64, "0"), + originalFilename: `${marker}.png`, + createdByAgentId: agentId, + }); + const attachmentId = randomUUID(); + await db.insert(issueAttachments).values({ id: attachmentId, companyId, issueId, assetId }); + const workProductId = randomUUID(); + await db.insert(issueWorkProducts).values({ + id: workProductId, + companyId, + issueId, + type: "artifact", + provider: "paperclip", + title: `Artifact ${marker}`, + status: "active", + summary: `Generated ${marker}`, + metadata: { + attachmentId, + contentType: "image/png", + byteSize: 2048, + contentPath: `/api/attachments/${attachmentId}/content`, + originalFilename: `${marker}.png`, + }, + }); + return { documentId, attachmentId, workProductId }; + } + + const mine = await seedIssueContent(run.issueId, "mine"); + await seedIssueContent(otherRun.issueId, "other"); + + const detail = await svc.getTestRunDetail(companyId, skillId, run.id); + expect(detail).not.toBeNull(); + expect(detail?.harnessContent.available).toBe(true); + expect(detail?.harnessContent.unavailableReason).toBeNull(); + + expect(detail?.harnessContent.documents).toHaveLength(1); + const doc = detail!.harnessContent.documents[0]!; + expect(doc).toEqual(expect.objectContaining({ + id: mine.documentId, + companyId, + issueId: run.issueId, + key: "output", + title: "Output mine", + format: "markdown", + body: "## Result mine", + createdByAgentId: agentId, + })); + expect(typeof doc.latestRevisionNumber).toBe("number"); + expect(doc.createdAt).toBeInstanceOf(Date); + expect(doc.updatedAt).toBeInstanceOf(Date); + expect("sourceTrust" in doc).toBe(true); + + expect(detail?.harnessContent.attachments).toHaveLength(1); + const attachment = detail!.harnessContent.attachments[0]!; + expect(attachment).toEqual(expect.objectContaining({ + id: mine.attachmentId, + companyId, + issueId: run.issueId, + contentType: "image/png", + byteSize: 2048, + originalFilename: "mine.png", + contentPath: `/api/attachments/${mine.attachmentId}/content`, + openPath: `/api/attachments/${mine.attachmentId}/content`, + downloadPath: `/api/attachments/${mine.attachmentId}/content?download=1`, + })); + + expect(detail?.harnessContent.workProducts).toHaveLength(1); + const workProduct = detail!.harnessContent.workProducts[0]!; + expect(workProduct).toEqual(expect.objectContaining({ + id: mine.workProductId, + companyId, + issueId: run.issueId, + type: "artifact", + provider: "paperclip", + title: "Artifact mine", + summary: "Generated mine", + })); + expect(workProduct.metadata).toEqual(expect.objectContaining({ + attachmentId: mine.attachmentId, + contentType: "image/png", + byteSize: 2048, + originalFilename: "mine.png", + })); + + // Compatibility summaries stay in sync with the rich collections. + expect(detail?.documents).toEqual([ + expect.objectContaining({ key: "output", title: "Output mine", body: "## Result mine" }), + ]); + expect(detail?.artifacts).toEqual(expect.arrayContaining([ + expect.objectContaining({ id: mine.attachmentId, kind: "attachment", title: "mine.png" }), + expect.objectContaining({ id: mine.workProductId, kind: "work_product", title: "Artifact mine" }), + ])); + expect(detail?.artifacts).toHaveLength(2); + }); + + it("keeps hydration company-scoped and reports a deleted harness issue", async () => { + const first = await seedSkillAndAgent(); + const second = await seedSkillAndAgent(); + const run = await svc.createTestRun( + first.companyId, + first.skillId, + { content: "scoped run", agentId: first.agentId }, + { type: "user", userId: "local-board" }, + runDeps(first.companyId), + ); + + // Cross-company access never resolves another company's run. + expect(await svc.getTestRunDetail(second.companyId, first.skillId, run.id)).toBeNull(); + expect(await svc.getTestRunDetail(second.companyId, second.skillId, run.id)).toBeNull(); + + // Harness issue marked deleted outside the retention path -> clear "deleted" + // state, while stored run snapshots stay usable. + await db + .update(companySkillTestRuns) + .set({ harnessIssueDeletedAt: new Date() }) + .where(eq(companySkillTestRuns.id, run.id)); + const detail = await svc.getTestRunDetail(first.companyId, first.skillId, run.id); + expect(detail).not.toBeNull(); + expect(detail?.harnessIssue).toBeNull(); + expect(detail?.inputSnapshot).toBe("scoped run"); + expect(detail?.harnessContent).toEqual({ + available: false, + unavailableReason: "deleted", + documents: [], + attachments: [], + workProducts: [], + }); + }); +}); diff --git a/server/src/__tests__/company-skills-routes.test.ts b/server/src/__tests__/company-skills-routes.test.ts index c964c4b15e..8b64c3551c 100644 --- a/server/src/__tests__/company-skills-routes.test.ts +++ b/server/src/__tests__/company-skills-routes.test.ts @@ -31,12 +31,38 @@ const mockCompanySkillService = vi.hoisted(() => ({ createLocalSkill: vi.fn(), updateSkill: vi.fn(), updateFile: vi.fn(), + deleteFile: vi.fn(), scanProjectWorkspaces: vi.fn(), deleteSkill: vi.fn(), auditSkill: vi.fn(), getById: vi.fn(), installUpdate: vi.fn(), resetSkill: vi.fn(), + listTestInputs: vi.fn(), + createTestInput: vi.fn(), + updateTestInput: vi.fn(), + deleteTestInput: vi.fn(), + listTestRunTemplates: vi.fn(), + createTestRunTemplate: vi.fn(), + updateTestRunTemplate: vi.fn(), + deleteTestRunTemplate: vi.fn(), + createTestRun: vi.fn(), + listTestRuns: vi.fn(), + getTestRunDetail: vi.fn(), + cancelTestRun: vi.fn(), + deleteTestRun: vi.fn(), + pruneExpiredTestHarnessIssues: vi.fn(), +})); + +const mockIssueService = vi.hoisted(() => ({ + create: vi.fn(), + getById: vi.fn(), + update: vi.fn(), +})); + +const mockHeartbeatService = vi.hoisted(() => ({ + wakeup: vi.fn(), + cancelRun: vi.fn(), })); const mockCatalogService = vi.hoisted(() => ({ @@ -83,6 +109,8 @@ function registerModuleMocks() { accessService: () => mockAccessService, agentService: () => mockAgentService, companySkillService: () => mockCompanySkillService, + issueService: () => mockIssueService, + heartbeatService: () => mockHeartbeatService, logActivity: mockLogActivity, })); } @@ -338,6 +366,12 @@ describe("company skill mutation permissions", () => { markdown: true, editable: true, }); + mockCompanySkillService.deleteFile.mockResolvedValue({ + skillId: "skill-1", + path: "references", + target: "folder", + deletedPaths: ["references/example.md"], + }); mockCompanySkillService.scanProjectWorkspaces.mockResolvedValue({ scannedProjects: 0, scannedWorkspaces: 0, @@ -381,6 +415,150 @@ describe("company skill mutation permissions", () => { sourceRef: "sha256:def", metadata: { originHash: "sha256:def" }, }); + mockCompanySkillService.pruneExpiredTestHarnessIssues.mockResolvedValue({ pruned: 0 }); + mockCompanySkillService.listTestInputs.mockResolvedValue([]); + mockCompanySkillService.createTestInput.mockResolvedValue({ + id: "11111111-1111-4111-8111-111111111111", + companyId: "company-1", + skillId: "skill-1", + name: "smoke/input", + content: "Try the skill", + createdBy: "board", + deletedAt: null, + createdAt: new Date("2026-05-26T00:00:00.000Z"), + updatedAt: new Date("2026-05-26T00:00:00.000Z"), + }); + mockCompanySkillService.updateTestInput.mockResolvedValue({ + id: "11111111-1111-4111-8111-111111111111", + companyId: "company-1", + skillId: "skill-1", + name: "smoke/renamed", + content: "Try the skill again", + createdBy: "board", + deletedAt: null, + createdAt: new Date("2026-05-26T00:00:00.000Z"), + updatedAt: new Date("2026-05-26T00:01:00.000Z"), + }); + mockCompanySkillService.deleteTestInput.mockResolvedValue({ + id: "11111111-1111-4111-8111-111111111111", + companyId: "company-1", + skillId: "skill-1", + name: "smoke/renamed", + content: "Try the skill again", + createdBy: "board", + deletedAt: new Date("2026-05-26T00:02:00.000Z"), + createdAt: new Date("2026-05-26T00:00:00.000Z"), + updatedAt: new Date("2026-05-26T00:02:00.000Z"), + }); + const templateResponse = { + id: "66666666-6666-4666-8666-666666666666", + companyId: "company-1", + name: "Custom template", + description: "Custom run guidance", + body: "Run {{skillName}} into {{outputDocumentKey}}.", + builtIn: false, + createdByAgentId: null, + createdByUserId: "local-board", + updatedByAgentId: null, + updatedByUserId: "local-board", + deletedAt: null, + createdAt: new Date("2026-05-26T00:00:00.000Z"), + updatedAt: new Date("2026-05-26T00:00:00.000Z"), + }; + mockCompanySkillService.listTestRunTemplates.mockResolvedValue([{ + ...templateResponse, + id: "built-in:default-test-template", + name: "Default test template", + description: "Paperclip default", + body: "Default {{skillName}}", + builtIn: true, + createdByUserId: null, + updatedByUserId: null, + }, templateResponse]); + mockCompanySkillService.createTestRunTemplate.mockResolvedValue(templateResponse); + mockCompanySkillService.updateTestRunTemplate.mockResolvedValue({ + ...templateResponse, + name: "Renamed template", + updatedAt: new Date("2026-05-26T00:01:00.000Z"), + }); + mockCompanySkillService.deleteTestRunTemplate.mockResolvedValue({ + ...templateResponse, + deletedAt: new Date("2026-05-26T00:02:00.000Z"), + updatedAt: new Date("2026-05-26T00:02:00.000Z"), + }); + mockCompanySkillService.listTestRuns.mockResolvedValue([]); + mockCompanySkillService.getTestRunDetail.mockResolvedValue(null); + mockCompanySkillService.createTestRun.mockResolvedValue({ + id: "22222222-2222-4222-8222-222222222222", + companyId: "company-1", + skillId: "skill-1", + inputId: "11111111-1111-4111-8111-111111111111", + inputSnapshot: "Try the skill", + skillVersionId: "33333333-3333-4333-8333-333333333333", + agentId: "55555555-5555-4555-8555-555555555555", + agentConfigSnapshot: { adapterType: "codex_local" }, + issueId: "44444444-4444-4444-8444-444444444444", + templateId: "built-in:default-test-template", + templateName: "Default test template", + templateBody: "Default {{skillName}}", + renderedTemplateBody: "Default Review", + harnessIssueDescription: "Try the skill\n\n---\n\nDefault Review", + status: "queued", + outputDocumentKey: "output", + outputSnapshot: "", + error: null, + deletedAt: null, + supersededAt: null, + harnessIssueExpiresAt: null, + harnessIssueDeletedAt: null, + createdAt: new Date("2026-05-26T00:00:00.000Z"), + updatedAt: new Date("2026-05-26T00:00:00.000Z"), + cost: { costCents: 0, inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 }, + taskExpired: false, + }); + mockCompanySkillService.cancelTestRun.mockResolvedValue({ + id: "22222222-2222-4222-8222-222222222222", + companyId: "company-1", + skillId: "skill-1", + inputId: "11111111-1111-4111-8111-111111111111", + inputSnapshot: "Try the skill", + skillVersionId: "33333333-3333-4333-8333-333333333333", + agentId: "55555555-5555-4555-8555-555555555555", + agentConfigSnapshot: { adapterType: "codex_local" }, + issueId: "44444444-4444-4444-8444-444444444444", + templateId: "built-in:default-test-template", + templateName: "Default test template", + templateBody: "Default {{skillName}}", + renderedTemplateBody: "Default Review", + harnessIssueDescription: "Try the skill\n\n---\n\nDefault Review", + status: "cancelled", + outputDocumentKey: "output", + outputSnapshot: "", + error: "Cancelled by operator", + deletedAt: null, + supersededAt: null, + harnessIssueExpiresAt: null, + harnessIssueDeletedAt: null, + createdAt: new Date("2026-05-26T00:00:00.000Z"), + updatedAt: new Date("2026-05-26T00:01:00.000Z"), + cost: { costCents: 0, inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 }, + taskExpired: false, + }); + mockIssueService.create.mockResolvedValue({ + id: "44444444-4444-4444-8444-444444444444", + companyId: "company-1", + identifier: "PAP-999", + title: "Skill test: Review", + }); + mockIssueService.getById.mockResolvedValue({ + id: "44444444-4444-4444-8444-444444444444", + companyId: "company-1", + status: "in_progress", + executionRunId: "run-1", + }); + mockIssueService.update.mockResolvedValue({}); + mockHeartbeatService.wakeup.mockResolvedValue({}); + mockHeartbeatService.cancelRun.mockResolvedValue({}); mockCatalogService.listCatalogSkillsOrEmpty.mockReturnValue([]); mockCatalogService.getCatalogSkillOrThrow.mockReturnValue({ id: "paperclipai:bundled:software-development:review", @@ -743,14 +921,14 @@ describe("company skill mutation permissions", () => { it("blocks same-company agents with skill creation disabled from mutating company skills", async () => { mockAgentService.getById.mockResolvedValue({ - id: "agent-1", + id: "55555555-5555-4555-8555-555555555555", companyId: "company-1", permissions: { canCreateSkills: false }, }); const res = await request(await createApp({ type: "agent", - agentId: "agent-1", + agentId: "55555555-5555-4555-8555-555555555555", companyId: "company-1", runId: "run-1", })) @@ -759,21 +937,21 @@ describe("company skill mutation permissions", () => { expect(res.status, JSON.stringify(res.body)).toBe(403); expect(res.body.error).toBe("Missing permission: skills:create"); - expect(mockAccessService.hasPermission).toHaveBeenCalledWith("company-1", "agent", "agent-1", "skills:create"); - expect(mockAccessService.hasPermission).not.toHaveBeenCalledWith("company-1", "agent", "agent-1", "agents:create"); + expect(mockAccessService.hasPermission).toHaveBeenCalledWith("company-1", "agent", "55555555-5555-4555-8555-555555555555", "skills:create"); + expect(mockAccessService.hasPermission).not.toHaveBeenCalledWith("company-1", "agent", "55555555-5555-4555-8555-555555555555", "agents:create"); expect(mockCompanySkillService.importFromSource).not.toHaveBeenCalled(); }); it("blocks agent catalog installs for other companies", async () => { mockAgentService.getById.mockResolvedValue({ - id: "agent-1", + id: "55555555-5555-4555-8555-555555555555", companyId: "company-1", permissions: { canCreateSkills: true }, }); const res = await request(await createApp({ type: "agent", - agentId: "agent-1", + agentId: "55555555-5555-4555-8555-555555555555", companyId: "company-1", runId: "run-1", })) @@ -788,13 +966,14 @@ describe("company skill mutation permissions", () => { const app = await createApp({ type: "board", source: "local_implicit" }); await request(app) - .get("/api/companies/company-1/skills?sort=stars&categories[]=memory&category=git&scope=company&q=review") + .get("/api/companies/company-1/skills?sort=stars&categories[]=memory&category=git&scope=company&q=review&include=lastEditor") .expect(200); expect(mockCompanySkillService.list).toHaveBeenCalledWith("company-1", { q: "review", sort: "stars", categories: ["git", "memory"], scope: "company", + include: ["lastEditor"], }); await request(app).get("/api/companies/company-1/skills/categories").expect(200); @@ -852,6 +1031,34 @@ describe("company skill mutation permissions", () => { })); }); + it("deletes skill files and logs the mutation", async () => { + const app = await createApp({ type: "board", source: "local_implicit", userId: "user-1" }); + + const res = await request(app) + .delete("/api/companies/company-1/skills/skill-1/files") + .send({ path: "references", target: "folder" }); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + + expect(mockCompanySkillService.deleteFile).toHaveBeenCalledWith("company-1", "skill-1", { + path: "references", + target: "folder", + }, { + type: "user", + userId: "user-1", + }); + expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ + action: "company.skill_file_deleted", + entityType: "company_skill", + entityId: "skill-1", + details: { + path: "references", + target: "folder", + deletedPaths: ["references/example.md"], + }, + })); + }); + it("stars, forks, and comments on skills through company-scoped endpoints", async () => { const app = await createApp({ type: "board", source: "local_implicit", userId: "user-1" }); @@ -919,14 +1126,14 @@ describe("company skill mutation permissions", () => { it("allows agents with canCreateSkills to mutate company skills", async () => { mockAgentService.getById.mockResolvedValue({ - id: "agent-1", + id: "55555555-5555-4555-8555-555555555555", companyId: "company-1", permissions: { canCreateSkills: true }, }); const res = await request(await createApp({ type: "agent", - agentId: "agent-1", + agentId: "55555555-5555-4555-8555-555555555555", companyId: "company-1", runId: "run-1", })) @@ -942,14 +1149,14 @@ describe("company skill mutation permissions", () => { it("allows same-company agents with missing skill creation permission to mutate company skills", async () => { mockAgentService.getById.mockResolvedValue({ - id: "agent-1", + id: "55555555-5555-4555-8555-555555555555", companyId: "company-1", permissions: {}, }); const res = await request(await createApp({ type: "agent", - agentId: "agent-1", + agentId: "55555555-5555-4555-8555-555555555555", companyId: "company-1", runId: "run-1", })) @@ -965,7 +1172,7 @@ describe("company skill mutation permissions", () => { it("allows agents with explicit skills:create grants to mutate company skills", async () => { mockAgentService.getById.mockResolvedValue({ - id: "agent-1", + id: "55555555-5555-4555-8555-555555555555", companyId: "company-1", permissions: { canCreateSkills: false }, }); @@ -980,7 +1187,7 @@ describe("company skill mutation permissions", () => { const res = await request(await createApp({ type: "agent", - agentId: "agent-1", + agentId: "55555555-5555-4555-8555-555555555555", companyId: "company-1", runId: "run-1", })) @@ -988,7 +1195,7 @@ describe("company skill mutation permissions", () => { .send({ source: "https://github.com/vercel-labs/agent-browser" }); expect(res.status, JSON.stringify(res.body)).toBe(201); - expect(mockAccessService.hasPermission).toHaveBeenCalledWith("company-1", "agent", "agent-1", "skills:create"); + expect(mockAccessService.hasPermission).toHaveBeenCalledWith("company-1", "agent", "55555555-5555-4555-8555-555555555555", "skills:create"); expect(mockCompanySkillService.importFromSource).toHaveBeenCalledWith( "company-1", "https://github.com/vercel-labs/agent-browser", @@ -997,7 +1204,7 @@ describe("company skill mutation permissions", () => { it("does not allow explicit agents:create grants to mutate company skills", async () => { mockAgentService.getById.mockResolvedValue({ - id: "agent-1", + id: "55555555-5555-4555-8555-555555555555", companyId: "company-1", permissions: { canCreateSkills: false }, }); @@ -1012,7 +1219,7 @@ describe("company skill mutation permissions", () => { const res = await request(await createApp({ type: "agent", - agentId: "agent-1", + agentId: "55555555-5555-4555-8555-555555555555", companyId: "company-1", runId: "run-1", })) @@ -1021,10 +1228,329 @@ describe("company skill mutation permissions", () => { expect(res.status, JSON.stringify(res.body)).toBe(403); expect(res.body.error).toBe("Missing permission: skills:create"); - expect(mockAccessService.hasPermission).toHaveBeenCalledWith("company-1", "agent", "agent-1", "skills:create"); + expect(mockAccessService.hasPermission).toHaveBeenCalledWith("company-1", "agent", "55555555-5555-4555-8555-555555555555", "skills:create"); expect(mockCompanySkillService.importFromSource).not.toHaveBeenCalled(); }); + it("routes skill test input CRUD through skills mutation permissions", async () => { + const app = await createApp({ + type: "board", + userId: "local-board", + companyIds: ["company-1"], + source: "local_implicit", + isInstanceAdmin: false, + }); + + const created = await request(app) + .post("/api/companies/company-1/skills/skill-1/test-inputs") + .send({ name: "smoke/input", content: "Try the skill" }); + expect(created.status, JSON.stringify(created.body)).toBe(201); + expect(mockCompanySkillService.createTestInput).toHaveBeenCalledWith( + "company-1", + "skill-1", + { name: "smoke/input", content: "Try the skill" }, + { type: "user", userId: "local-board" }, + ); + + const updated = await request(app) + .patch("/api/companies/company-1/skills/skill-1/test-inputs/11111111-1111-4111-8111-111111111111") + .send({ name: "smoke/renamed", content: "Try the skill again" }); + expect(updated.status, JSON.stringify(updated.body)).toBe(200); + expect(mockCompanySkillService.updateTestInput).toHaveBeenCalledWith( + "company-1", + "skill-1", + "11111111-1111-4111-8111-111111111111", + { name: "smoke/renamed", content: "Try the skill again" }, + ); + + const removed = await request(app) + .delete("/api/companies/company-1/skills/skill-1/test-inputs/11111111-1111-4111-8111-111111111111"); + expect(removed.status, JSON.stringify(removed.body)).toBe(200); + expect(mockCompanySkillService.deleteTestInput).toHaveBeenCalledWith( + "company-1", + "skill-1", + "11111111-1111-4111-8111-111111111111", + ); + }); + + it("routes skill test run template CRUD through skills mutation permissions", async () => { + const app = await createApp({ + type: "board", + userId: "local-board", + companyIds: ["company-1"], + source: "local_implicit", + isInstanceAdmin: false, + }); + + const listed = await request(app).get("/api/companies/company-1/skill-test-run-templates"); + expect(listed.status, JSON.stringify(listed.body)).toBe(200); + expect(mockCompanySkillService.listTestRunTemplates).toHaveBeenCalledWith("company-1"); + + const created = await request(app) + .post("/api/companies/company-1/skill-test-run-templates") + .send({ name: "Custom template", description: "Custom run guidance", body: "Run {{skillName}}." }); + expect(created.status, JSON.stringify(created.body)).toBe(201); + expect(mockCompanySkillService.createTestRunTemplate).toHaveBeenCalledWith( + "company-1", + { name: "Custom template", description: "Custom run guidance", body: "Run {{skillName}}." }, + { type: "user", userId: "local-board" }, + ); + + const updated = await request(app) + .patch("/api/companies/company-1/skill-test-run-templates/66666666-6666-4666-8666-666666666666") + .send({ name: "Renamed template" }); + expect(updated.status, JSON.stringify(updated.body)).toBe(200); + expect(mockCompanySkillService.updateTestRunTemplate).toHaveBeenCalledWith( + "company-1", + "66666666-6666-4666-8666-666666666666", + { name: "Renamed template" }, + { type: "user", userId: "local-board" }, + ); + + const removed = await request(app) + .delete("/api/companies/company-1/skill-test-run-templates/66666666-6666-4666-8666-666666666666"); + expect(removed.status, JSON.stringify(removed.body)).toBe(200); + expect(mockCompanySkillService.deleteTestRunTemplate).toHaveBeenCalledWith( + "company-1", + "66666666-6666-4666-8666-666666666666", + ); + }); + + it("creates and cancels skill test runs through hidden issue orchestration", async () => { + mockCompanySkillService.createTestRun.mockImplementationOnce(async ( + _companyId: string, + _skillId: string, + _body: unknown, + _actor: unknown, + deps: { + createHarnessIssue: (input: Record) => Promise; + wakeHarnessIssue: (issueId: string, agentId: string) => Promise; + }, + ) => { + await deps.createHarnessIssue({ + id: "44444444-4444-4444-8444-444444444444", + title: "Skill test: Review", + description: "Try the skill", + assigneeAgentId: "55555555-5555-4555-8555-555555555555", + harnessKind: "skill_test", + workMode: "skill_test", + status: "todo", + originKind: "skill_test", + originId: "22222222-2222-4222-8222-222222222222", + originFingerprint: "skill_test:22222222-2222-4222-8222-222222222222", + }); + await deps.wakeHarnessIssue("44444444-4444-4444-8444-444444444444", "55555555-5555-4555-8555-555555555555"); + return { + id: "22222222-2222-4222-8222-222222222222", + companyId: "company-1", + skillId: "skill-1", + inputId: "11111111-1111-4111-8111-111111111111", + inputSnapshot: "Try the skill", + skillVersionId: "33333333-3333-4333-8333-333333333333", + agentId: "55555555-5555-4555-8555-555555555555", + agentConfigSnapshot: { adapterType: "codex_local" }, + issueId: "44444444-4444-4444-8444-444444444444", + templateId: "built-in:default-test-template", + templateName: "Default test template", + templateBody: "Default {{skillName}}", + renderedTemplateBody: "Default Review", + harnessIssueDescription: "Try the skill\n\n---\n\nDefault Review", + status: "queued", + outputDocumentKey: "output", + outputSnapshot: "", + error: null, + deletedAt: null, + supersededAt: null, + harnessIssueExpiresAt: null, + harnessIssueDeletedAt: null, + createdAt: new Date("2026-05-26T00:00:00.000Z"), + updatedAt: new Date("2026-05-26T00:00:00.000Z"), + cost: { costCents: 0, inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 }, + taskExpired: false, + }; + }); + mockCompanySkillService.cancelTestRun.mockImplementationOnce(async ( + _companyId: string, + _skillId: string, + _runId: string, + deps: { cancelHarnessIssue: (issueId: string) => Promise }, + ) => { + await deps.cancelHarnessIssue("44444444-4444-4444-8444-444444444444"); + return { + id: "22222222-2222-4222-8222-222222222222", + companyId: "company-1", + skillId: "skill-1", + inputId: "11111111-1111-4111-8111-111111111111", + inputSnapshot: "Try the skill", + skillVersionId: "33333333-3333-4333-8333-333333333333", + agentId: "55555555-5555-4555-8555-555555555555", + agentConfigSnapshot: { adapterType: "codex_local" }, + issueId: "44444444-4444-4444-8444-444444444444", + templateId: "built-in:default-test-template", + templateName: "Default test template", + templateBody: "Default {{skillName}}", + renderedTemplateBody: "Default Review", + harnessIssueDescription: "Try the skill\n\n---\n\nDefault Review", + status: "cancelled", + outputDocumentKey: "output", + outputSnapshot: "", + error: "Cancelled by operator", + deletedAt: null, + supersededAt: null, + harnessIssueExpiresAt: null, + harnessIssueDeletedAt: null, + createdAt: new Date("2026-05-26T00:00:00.000Z"), + updatedAt: new Date("2026-05-26T00:01:00.000Z"), + cost: { costCents: 0, inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 }, + taskExpired: false, + }; + }); + + const app = await createApp({ + type: "board", + userId: "local-board", + companyIds: ["company-1"], + source: "local_implicit", + isInstanceAdmin: false, + }); + + const created = await request(app) + .post("/api/companies/company-1/skills/skill-1/test-runs") + .send({ inputId: "11111111-1111-4111-8111-111111111111", agentId: "55555555-5555-4555-8555-555555555555" }); + expect(created.status, JSON.stringify(created.body)).toBe(201); + expect(mockIssueService.create).toHaveBeenCalledWith("company-1", expect.objectContaining({ + harnessKind: "skill_test", + workMode: "skill_test", + assigneeAgentId: "55555555-5555-4555-8555-555555555555", + description: "Try the skill", + })); + expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith("55555555-5555-4555-8555-555555555555", expect.objectContaining({ + reason: "skill_test_run_created", + payload: expect.objectContaining({ issueId: "44444444-4444-4444-8444-444444444444", skillId: "skill-1" }), + })); + + const cancelled = await request(app) + .post("/api/companies/company-1/skills/skill-1/test-runs/22222222-2222-4222-8222-222222222222/cancel") + .send({}); + expect(cancelled.status, JSON.stringify(cancelled.body)).toBe(200); + expect(mockHeartbeatService.cancelRun).toHaveBeenCalledWith("run-1", "Cancelled by skill test run request"); + expect(mockIssueService.update).toHaveBeenCalledWith("44444444-4444-4444-8444-444444444444", expect.objectContaining({ + status: "cancelled", + actorUserId: "local-board", + })); + }); + + it("does not prune expired harness issues from test run reads", async () => { + mockCompanySkillService.listTestRuns.mockResolvedValueOnce([]); + mockCompanySkillService.getTestRunDetail.mockResolvedValueOnce({ + id: "22222222-2222-4222-8222-222222222222", + companyId: "company-1", + skillId: "skill-1", + status: "succeeded", + harnessContent: { available: false, unavailableReason: "expired", documents: [], attachments: [], workProducts: [] }, + }); + + const app = await createApp({ + type: "board", + userId: "local-board", + companyIds: ["company-1"], + source: "local_implicit", + isInstanceAdmin: false, + }); + + const listed = await request(app) + .get("/api/companies/company-1/skills/skill-1/test-runs"); + expect(listed.status, JSON.stringify(listed.body)).toBe(200); + + const detail = await request(app) + .get("/api/companies/company-1/skills/skill-1/test-runs/22222222-2222-4222-8222-222222222222"); + expect(detail.status, JSON.stringify(detail.body)).toBe(200); + + expect(mockCompanySkillService.listTestRuns).toHaveBeenCalledWith("company-1", "skill-1", {}); + expect(mockCompanySkillService.getTestRunDetail).toHaveBeenCalledWith( + "company-1", + "skill-1", + "22222222-2222-4222-8222-222222222222", + ); + expect(mockCompanySkillService.pruneExpiredTestHarnessIssues).not.toHaveBeenCalled(); + }); + + it("deletes a terminal test run and hides its harness task", async () => { + mockIssueService.getById.mockResolvedValueOnce({ + id: "44444444-4444-4444-8444-444444444444", + companyId: "company-1", + status: "done", + executionRunId: null, + }); + mockCompanySkillService.deleteTestRun.mockImplementationOnce(async ( + _companyId: string, + _skillId: string, + _runId: string, + deps: { hideHarnessIssue: (issueId: string) => Promise }, + ) => { + await deps.hideHarnessIssue("44444444-4444-4444-8444-444444444444"); + return { + id: "22222222-2222-4222-8222-222222222222", + companyId: "company-1", + skillId: "skill-1", + inputId: null, + inputSnapshot: "Try the skill", + skillVersionId: "33333333-3333-4333-8333-333333333333", + agentId: "55555555-5555-4555-8555-555555555555", + agentConfigSnapshot: { adapterType: "codex_local" }, + issueId: "44444444-4444-4444-8444-444444444444", + templateId: "built-in:default-test-template", + templateName: "Default test template", + templateBody: "Default {{skillName}}", + renderedTemplateBody: "Default Review", + harnessIssueDescription: "Try the skill\n\n---\n\nDefault Review", + status: "succeeded", + outputDocumentKey: "output", + outputSnapshot: "", + error: null, + deletedAt: new Date("2026-05-26T00:02:00.000Z"), + supersededAt: null, + harnessIssueExpiresAt: null, + harnessIssueDeletedAt: null, + createdAt: new Date("2026-05-26T00:00:00.000Z"), + updatedAt: new Date("2026-05-26T00:02:00.000Z"), + cost: { costCents: 0, inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 }, + taskExpired: false, + }; + }); + + const app = await createApp({ + type: "board", + userId: "local-board", + companyIds: ["company-1"], + source: "local_implicit", + isInstanceAdmin: false, + }); + + const deleted = await request(app) + .delete("/api/companies/company-1/skills/skill-1/test-runs/22222222-2222-4222-8222-222222222222"); + expect(deleted.status, JSON.stringify(deleted.body)).toBe(200); + expect(mockCompanySkillService.deleteTestRun).toHaveBeenCalled(); + expect(mockIssueService.update).toHaveBeenCalledWith( + "44444444-4444-4444-8444-444444444444", + expect.objectContaining({ hiddenAt: expect.any(Date) }), + ); + }); + + it("returns 404 when deleting a missing test run", async () => { + mockCompanySkillService.deleteTestRun.mockResolvedValueOnce(null); + const app = await createApp({ + type: "board", + userId: "local-board", + companyIds: ["company-1"], + source: "local_implicit", + isInstanceAdmin: false, + }); + const res = await request(app) + .delete("/api/companies/company-1/skills/skill-1/test-runs/22222222-2222-4222-8222-222222222222"); + expect(res.status).toBe(404); + }); + it("returns a blocking error when attempting to delete a skill still used by agents", async () => { const { unprocessable } = await import("../errors.js"); mockCompanySkillService.deleteSkill.mockImplementationOnce(async () => { diff --git a/server/src/__tests__/company-skills-service.test.ts b/server/src/__tests__/company-skills-service.test.ts index e4a01860c0..532abac3de 100644 --- a/server/src/__tests__/company-skills-service.test.ts +++ b/server/src/__tests__/company-skills-service.test.ts @@ -4,7 +4,7 @@ import path from "node:path"; import { promises as fs } from "node:fs"; import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { eq } from "drizzle-orm"; -import { agents, companies, companySkills, createDb } from "@paperclipai/db"; +import { agents, authUsers, companies, companySkillVersions, companySkills, createDb } from "@paperclipai/db"; import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase, @@ -41,6 +41,7 @@ describeEmbeddedPostgres("companySkillService.list", () => { await db.delete(agents); await db.delete(companySkills); await db.delete(companies); + await db.delete(authUsers); await Promise.all(Array.from(cleanupDirs, (dir) => fs.rm(dir, { recursive: true, force: true }))); cleanupDirs.clear(); }); @@ -102,6 +103,168 @@ describeEmbeddedPostgres("companySkillService.list", () => { }); }); + it("optionally enriches list items with latest version editor identities", async () => { + const companyId = randomUUID(); + const userSkillId = randomUUID(); + const agentSkillId = randomUUID(); + const unattributedSkillId = randomUUID(); + const versionlessSkillId = randomUUID(); + const agentId = randomUUID(); + const userId = "board-editor"; + const now = new Date(); + async function writeTrackedSkillDir(slug: string, name: string) { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), `paperclip-${slug}-`)); + cleanupDirs.add(dir); + await fs.writeFile(path.join(dir, "SKILL.md"), `---\nname: ${name}\n---\n\n# ${name}\n`, "utf8"); + return dir; + } + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await db.insert(authUsers).values({ + id: userId, + name: "Ada Lovelace", + email: "ada@example.com", + emailVerified: true, + image: "https://example.com/ada.png", + createdAt: now, + updatedAt: now, + }); + await db.insert(agents).values({ + id: agentId, + companyId, + name: "CodexCoder", + role: "engineer", + adapterType: "codex_local", + adapterConfig: {}, + }); + await db.insert(companySkills).values([ + { + id: userSkillId, + companyId, + key: `company/${companyId}/user-edited-skill`, + slug: "user-edited-skill", + name: "User Edited Skill", + description: null, + markdown: "# User Edited Skill", + sourceType: "local_path", + sourceLocator: await writeTrackedSkillDir("user-edited-skill", "User Edited Skill"), + trustLevel: "markdown_only", + compatibility: "compatible", + fileInventory: [{ path: "SKILL.md", kind: "skill" }], + }, + { + id: agentSkillId, + companyId, + key: `company/${companyId}/agent-edited-skill`, + slug: "agent-edited-skill", + name: "Agent Edited Skill", + description: null, + markdown: "# Agent Edited Skill", + sourceType: "local_path", + sourceLocator: await writeTrackedSkillDir("agent-edited-skill", "Agent Edited Skill"), + trustLevel: "markdown_only", + compatibility: "compatible", + fileInventory: [{ path: "SKILL.md", kind: "skill" }], + }, + { + id: unattributedSkillId, + companyId, + key: `company/${companyId}/unattributed-skill`, + slug: "unattributed-skill", + name: "Unattributed Skill", + description: null, + markdown: "# Unattributed Skill", + sourceType: "local_path", + sourceLocator: await writeTrackedSkillDir("unattributed-skill", "Unattributed Skill"), + trustLevel: "markdown_only", + compatibility: "compatible", + fileInventory: [{ path: "SKILL.md", kind: "skill" }], + }, + { + id: versionlessSkillId, + companyId, + key: `company/${companyId}/versionless-skill`, + slug: "versionless-skill", + name: "Versionless Skill", + description: null, + markdown: "# Versionless Skill", + sourceType: "local_path", + sourceLocator: await writeTrackedSkillDir("versionless-skill", "Versionless Skill"), + trustLevel: "markdown_only", + compatibility: "compatible", + fileInventory: [{ path: "SKILL.md", kind: "skill" }], + }, + ]); + await db.insert(companySkillVersions).values([ + { + id: randomUUID(), + companyId, + companySkillId: userSkillId, + revisionNumber: 1, + fileInventory: [], + createdAt: new Date("2026-01-01T00:00:00.000Z"), + }, + { + id: randomUUID(), + companyId, + companySkillId: userSkillId, + revisionNumber: 2, + fileInventory: [], + authorUserId: userId, + createdAt: new Date("2026-01-02T00:00:00.000Z"), + }, + { + id: randomUUID(), + companyId, + companySkillId: agentSkillId, + revisionNumber: 1, + fileInventory: [], + authorAgentId: agentId, + createdAt: new Date("2026-01-03T00:00:00.000Z"), + }, + { + id: randomUUID(), + companyId, + companySkillId: unattributedSkillId, + revisionNumber: 1, + fileInventory: [], + createdAt: new Date("2026-01-04T00:00:00.000Z"), + }, + ]); + + const defaultList = await svc.list(companyId); + expect(defaultList.find((skill) => skill.id === userSkillId)).not.toHaveProperty("lastEditor"); + + const enriched = await svc.list(companyId, { include: ["lastEditor"] }); + expect(enriched.find((skill) => skill.id === userSkillId)).toMatchObject({ + lastEditor: { + kind: "user", + id: userId, + name: "Ada Lovelace", + imageUrl: "https://example.com/ada.png", + }, + }); + expect(enriched.find((skill) => skill.id === agentSkillId)).toMatchObject({ + lastEditor: { + kind: "agent", + id: agentId, + name: "CodexCoder", + imageUrl: null, + }, + }); + expect(enriched.find((skill) => skill.id === unattributedSkillId)).toMatchObject({ + lastEditor: null, + }); + expect(enriched.find((skill) => skill.id === versionlessSkillId)).toMatchObject({ + lastEditor: null, + }); + }); + it("rejects skill inventory refresh for a missing company", async () => { await expect(svc.list(randomUUID())).rejects.toMatchObject({ status: 404, @@ -109,6 +272,159 @@ describeEmbeddedPostgres("companySkillService.list", () => { }); }); + it("does not retouch unchanged bundled skills during list refresh", async () => { + const companyId = randomUUID(); + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + + const initialList = await svc.list(companyId, { sort: "recent" }); + const bundledSkill = initialList.find((skill) => skill.key.startsWith("paperclipai/paperclip/")); + expect(bundledSkill).toBeDefined(); + if (!bundledSkill) throw new Error("Expected bundled Paperclip skills fixture"); + + const preservedUpdatedAt = new Date("2026-01-01T00:00:00.000Z"); + await db + .update(companySkills) + .set({ updatedAt: preservedUpdatedAt }) + .where(eq(companySkills.id, bundledSkill.id)); + + const refreshedList = await svc.list(companyId, { sort: "recent" }); + const refreshedSkill = refreshedList.find((skill) => skill.id === bundledSkill.id); + + expect(refreshedSkill?.updatedAt.toISOString()).toBe(preservedUpdatedAt.toISOString()); + }); + + it("does not retouch bundled skills with stale missing-source metadata during list refresh", async () => { + const companyId = randomUUID(); + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + + const initialList = await svc.list(companyId, { sort: "recent" }); + const bundledSkill = initialList.find((skill) => skill.key.startsWith("paperclipai/paperclip/")); + expect(bundledSkill).toBeDefined(); + if (!bundledSkill) throw new Error("Expected bundled Paperclip skills fixture"); + + const preservedUpdatedAt = new Date("2026-01-04T00:00:00.000Z"); + await db + .update(companySkills) + .set({ + metadata: { + skillKey: bundledSkill.key, + sourceKind: "paperclip_bundled", + missingSource: { + reason: "local_source_missing", + detectedAt: "2026-01-01T00:00:00.000Z", + sourcePath: bundledSkill.sourceLocator, + sourceType: "local_path", + sourceLocator: bundledSkill.sourceLocator, + }, + }, + updatedAt: preservedUpdatedAt, + }) + .where(eq(companySkills.id, bundledSkill.id)); + + const refreshedList = await svc.list(companyId, { sort: "recent" }); + const refreshedSkill = refreshedList.find((skill) => skill.id === bundledSkill.id); + const stored = await svc.getById(companyId, bundledSkill.id); + + expect(refreshedSkill?.updatedAt.toISOString()).toBe(preservedUpdatedAt.toISOString()); + expect(stored?.metadata?.missingSource).toMatchObject({ + reason: "local_source_missing", + sourceLocator: bundledSkill.sourceLocator, + }); + }); + + it("does not retouch unchanged local-path imports", async () => { + const companyId = randomUUID(); + const skillDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-idempotent-import-skill-")); + cleanupDirs.add(skillDir); + await fs.writeFile( + path.join(skillDir, "SKILL.md"), + "---\nname: Idempotent Import Skill\n---\n\n# Idempotent Import Skill\n", + "utf8", + ); + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + + const imported = await svc.importFromSource(companyId, skillDir); + const skillId = imported.imported[0]?.id; + expect(skillId).toEqual(expect.any(String)); + if (!skillId) throw new Error("Expected imported skill id"); + + const preservedUpdatedAt = new Date("2026-01-02T00:00:00.000Z"); + await db + .update(companySkills) + .set({ updatedAt: preservedUpdatedAt }) + .where(eq(companySkills.id, skillId)); + + await svc.importFromSource(companyId, skillDir); + const stored = await svc.getById(companyId, skillId); + + expect(stored?.updatedAt.toISOString()).toBe(preservedUpdatedAt.toISOString()); + }); + + it("refreshes local-path imports with legacy null metadata fields", async () => { + const companyId = randomUUID(); + const skillDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-null-metadata-import-skill-")); + cleanupDirs.add(skillDir); + await fs.writeFile( + path.join(skillDir, "SKILL.md"), + "---\nname: Null Metadata Import Skill\n---\n\n# Null Metadata Import Skill\n", + "utf8", + ); + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + + const imported = await svc.importFromSource(companyId, skillDir); + const skillId = imported.imported[0]?.id; + const skillKey = imported.imported[0]?.key; + expect(skillId).toEqual(expect.any(String)); + expect(skillKey).toEqual(expect.any(String)); + if (!skillId || !skillKey) throw new Error("Expected imported skill id and key"); + + const preservedUpdatedAt = new Date("2026-01-03T00:00:00.000Z"); + await db + .update(companySkills) + .set({ + metadata: { + sourceKind: "local_path", + skillKey, + owner: null, + repo: null, + ref: null, + trackingRef: null, + repoSkillDir: null, + }, + updatedAt: preservedUpdatedAt, + }) + .where(eq(companySkills.id, skillId)); + + await svc.importFromSource(companyId, skillDir); + const stored = await svc.getById(companyId, skillId); + + expect(stored?.updatedAt.toISOString()).not.toBe(preservedUpdatedAt.toISOString()); + expect(stored?.metadata).toMatchObject({ sourceKind: "local_path", skillKey }); + expect(stored?.metadata).not.toHaveProperty("owner"); + expect(stored?.metadata).not.toHaveProperty("repo"); + expect(stored?.metadata).not.toHaveProperty("ref"); + }); + it("does not persist audit failures for remote-source skills", async () => { const companyId = randomUUID(); const skillId = randomUUID(); @@ -304,7 +620,7 @@ describeEmbeddedPostgres("companySkillService.list", () => { }); }); - it("updates categories, normalizes values, and reflects them in list filters and counts", async () => { + it("updates categories, allows spaces, and reflects them in list filters and counts", async () => { const companyId = randomUUID(); await db.insert(companies).values({ id: companyId, @@ -320,20 +636,23 @@ describeEmbeddedPostgres("companySkillService.list", () => { }); const updated = await svc.updateSkill(companyId, skill.id, { - categories: ["Memory", "review", "memory", " "], + categories: ["Memory Tools", "review", "memory tools", " "], }); - expect(updated.categories).toEqual(["memory", "review"]); + expect(updated.categories).toEqual(["Memory Tools", "review"]); await expect(svc.detail(companyId, skill.id)).resolves.toMatchObject({ id: skill.id, - categories: ["memory", "review"], + categories: ["Memory Tools", "review"], }); await expect(svc.list(companyId, { categories: ["review"] })).resolves.toEqual([ - expect.objectContaining({ id: skill.id, categories: ["memory", "review"] }), + expect.objectContaining({ id: skill.id, categories: ["Memory Tools", "review"] }), + ]); + await expect(svc.list(companyId, { categories: ["memory tools"] })).resolves.toEqual([ + expect.objectContaining({ id: skill.id, categories: ["Memory Tools", "review"] }), ]); await expect(svc.list(companyId, { categories: ["engineering"] })).resolves.toEqual([]); await expect(svc.categoryCounts(companyId)).resolves.toEqual([ - { slug: "memory", count: 1 }, + { slug: "Memory Tools", count: 1 }, { slug: "review", count: 1 }, ]); @@ -344,6 +663,72 @@ describeEmbeddedPostgres("companySkillService.list", () => { await expect(svc.categoryCounts(companyId)).resolves.toEqual([]); }); + it("resolves detail by unique skill slug for Studio deep links", async () => { + const companyId = randomUUID(); + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + + const skill = await svc.createLocalSkill(companyId, { + name: "Paperclip Blog Cover Image", + slug: "paperclip-blog-cover-image", + markdown: "# Paperclip Blog Cover Image\n", + }); + + await expect(svc.detail(companyId, "paperclip-blog-cover-image")).resolves.toMatchObject({ + id: skill.id, + slug: "paperclip-blog-cover-image", + name: "Paperclip Blog Cover Image", + }); + }); + + it("does not resolve ambiguous skill slugs", async () => { + const companyId = randomUUID(); + const skillA = randomUUID(); + const skillB = randomUUID(); + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await db.insert(companySkills).values([ + { + id: skillA, + companyId, + key: `company/${companyId}/duplicate-a`, + slug: "duplicate", + name: "Duplicate A", + markdown: "# Duplicate A\n", + sourceType: "local_path", + sourceLocator: null, + trustLevel: "markdown_only", + compatibility: "compatible", + fileInventory: [{ path: "SKILL.md", kind: "skill" }], + metadata: { sourceKind: "local_path" }, + }, + { + id: skillB, + companyId, + key: `company/${companyId}/duplicate-b`, + slug: "duplicate", + name: "Duplicate B", + markdown: "# Duplicate B\n", + sourceType: "local_path", + sourceLocator: null, + trustLevel: "markdown_only", + compatibility: "compatible", + fileInventory: [{ path: "SKILL.md", kind: "skill" }], + metadata: { sourceKind: "local_path" }, + }, + ]); + + await expect(svc.detail(companyId, "duplicate")).resolves.toBeNull(); + }); + it("creates a fork from the creation flow with copied files and lineage", async () => { const companyId = randomUUID(); const sourceSkillId = randomUUID(); @@ -672,24 +1057,110 @@ describeEmbeddedPostgres("companySkillService.list", () => { await expect(svc.resolveRequestedSkillEntries(companyId, [ "pinned-skill", - ])).resolves.toEqual([ - { key: `company/${companyId}/pinned-skill`, versionId: null }, - ]); + ])).resolves.toEqual({ + resolved: [{ key: `company/${companyId}/pinned-skill`, versionId: null }], + unresolved: [], + }); await expect(svc.resolveRequestedSkillEntries(companyId, [ { key: "pinned-skill", versionId: null }, - ])).resolves.toEqual([ - { key: `company/${companyId}/pinned-skill`, versionId: null }, - ]); + ])).resolves.toEqual({ + resolved: [{ key: `company/${companyId}/pinned-skill`, versionId: null }], + unresolved: [], + }); await expect(svc.resolveRequestedSkillEntries(companyId, [ { key: "pinned-skill", versionId: version.id }, - ])).resolves.toEqual([ - { key: `company/${companyId}/pinned-skill`, versionId: version.id }, - ]); + ])).resolves.toEqual({ + resolved: [{ key: `company/${companyId}/pinned-skill`, versionId: version.id }], + unresolved: [], + }); await expect(svc.resolveRequestedSkillEntries(companyId, [ { key: "other-skill", versionId: version.id }, ])).rejects.toMatchObject({ status: 422 }); }); + it("rejects unknown desired keys by default but preserves them when tolerating (PAP-13222)", async () => { + const companyId = randomUUID(); + const skillId = randomUUID(); + const skillDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-tolerant-skill-")); + cleanupDirs.add(skillDir); + await fs.writeFile(path.join(skillDir, "SKILL.md"), "# Real Skill\n", "utf8"); + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await db.insert(companySkills).values({ + id: skillId, + companyId, + key: `company/${companyId}/real-skill`, + slug: "real-skill", + name: "Real Skill", + description: null, + markdown: "# Real Skill", + sourceType: "local_path", + sourceLocator: skillDir, + trustLevel: "markdown_only", + compatibility: "compatible", + fileInventory: [{ path: "SKILL.md", kind: "skill" }], + }); + + // Strict (default): a stale/unknown key is a hard 422. + await expect(svc.resolveRequestedSkillEntries(companyId, [ + "real-skill", + "stale/removed/skill", + ])).rejects.toMatchObject({ status: 422 }); + + // Tolerant: the resolvable key resolves, and the stale key is preserved + // (not thrown) so callers can keep it visible/removable. + await expect(svc.resolveRequestedSkillEntries( + companyId, + ["real-skill", "stale/removed/skill"], + { tolerateUnknownReferences: true }, + )).resolves.toEqual({ + resolved: [{ key: `company/${companyId}/real-skill`, versionId: null }], + unresolved: ["stale/removed/skill"], + }); + + // Ambiguity is still fatal even when tolerating unknown references. Two + // library skills sharing a slug make a bare-slug reference ambiguous. + const otherId = randomUUID(); + await db.insert(companySkills).values({ + id: otherId, + companyId, + key: `company/${companyId}/dup-a`, + slug: "dup", + name: "Dup A", + description: null, + markdown: "# Dup A", + sourceType: "local_path", + sourceLocator: skillDir, + trustLevel: "markdown_only", + compatibility: "compatible", + fileInventory: [{ path: "SKILL.md", kind: "skill" }], + }); + const otherId2 = randomUUID(); + await db.insert(companySkills).values({ + id: otherId2, + companyId, + key: `company/${companyId}/dup-b`, + slug: "dup", + name: "Dup B", + description: null, + markdown: "# Dup B", + sourceType: "local_path", + sourceLocator: skillDir, + trustLevel: "markdown_only", + compatibility: "compatible", + fileInventory: [{ path: "SKILL.md", kind: "skill" }], + }); + await expect(svc.resolveRequestedSkillEntries( + companyId, + ["dup"], + { tolerateUnknownReferences: true }, + )).rejects.toMatchObject({ status: 422 }); + }); + it("preserves missing local-path skills that active agents still desire", async () => { const companyId = randomUUID(); const skillId = randomUUID(); @@ -755,6 +1226,21 @@ describeEmbeddedPostgres("companySkillService.list", () => { sourcePath: missingSkillDir, }); expect(Number.isNaN(Date.parse(String((marker as Record).detectedAt)))).toBe(false); + + const preservedUpdatedAt = new Date("2026-01-05T00:00:00.000Z"); + await db + .update(companySkills) + .set({ updatedAt: preservedUpdatedAt }) + .where(eq(companySkills.id, skillId)); + + await svc.list(companyId); + const stableStored = await svc.getById(companyId, skillId); + + expect(stableStored?.updatedAt.toISOString()).toBe(preservedUpdatedAt.toISOString()); + expect(stableStored?.metadata?.missingSource).toMatchObject({ + detectedAt: (marker as Record).detectedAt, + sourceLocator: missingSkillDir, + }); }); it("continues pruning missing local-path skills that no active agent desires", async () => { diff --git a/server/src/__tests__/costs-service.test.ts b/server/src/__tests__/costs-service.test.ts index c8d9bda461..1effd44490 100644 --- a/server/src/__tests__/costs-service.test.ts +++ b/server/src/__tests__/costs-service.test.ts @@ -513,6 +513,7 @@ describeEmbeddedPostgres("cost and finance aggregate overflow handling", () => { const rootIssueId = randomUUID(); const childIssueId = randomUUID(); const grandchildIssueId = randomUUID(); + const harnessIssueId = randomUUID(); const siblingIssueId = randomUUID(); await db.insert(companies).values({ @@ -562,6 +563,18 @@ describeEmbeddedPostgres("cost and finance aggregate overflow handling", () => { issueNumber: 3, identifier: "TST-3", }, + { + id: harnessIssueId, + companyId, + parentId: rootIssueId, + title: "Hidden skill test harness", + status: "done", + priority: "medium", + issueNumber: 5, + identifier: "TST-5", + workMode: "skill_test", + harnessKind: "skill_test", + }, { id: siblingIssueId, companyId, @@ -652,6 +665,7 @@ describeEmbeddedPostgres("cost and finance aggregate overflow handling", () => { const rootIssueId = randomUUID(); const childIssueId = randomUUID(); const grandchildIssueId = randomUUID(); + const harnessIssueId = randomUUID(); const siblingIssueId = randomUUID(); await db.insert(companies).values({ @@ -710,11 +724,24 @@ describeEmbeddedPostgres("cost and finance aggregate overflow handling", () => { issueNumber: 4, identifier: "TST-4", }, + { + id: harnessIssueId, + companyId, + parentId: rootIssueId, + title: "Harness child", + status: "done", + priority: "medium", + workMode: "skill_test", + harnessKind: "skill_test", + issueNumber: 5, + identifier: "TST-5", + }, ]); const linkedViaContextRunId = randomUUID(); const linkedViaActivityRunId = randomUUID(); const grandchildRunId = randomUUID(); + const harnessRunId = randomUUID(); const siblingRunId = randomUUID(); const livePartialRunId = randomUUID(); @@ -751,6 +778,17 @@ describeEmbeddedPostgres("cost and finance aggregate overflow handling", () => { finishedAt: new Date("2026-04-10T00:10:30.000Z"), contextSnapshot: { issueId: grandchildIssueId }, }, + // 45s harness run under root - should be excluded from visible issue tree rollups + { + id: harnessRunId, + companyId, + agentId, + invocationSource: "on_demand", + status: "completed", + startedAt: new Date("2026-04-10T00:15:00.000Z"), + finishedAt: new Date("2026-04-10T00:15:45.000Z"), + contextSnapshot: { issueId: harnessIssueId }, + }, // sibling run NOT under root – should be excluded { id: siblingRunId, diff --git a/server/src/__tests__/document-annotation-routes.test.ts b/server/src/__tests__/document-annotation-routes.test.ts index 1fd64e02dc..450d7fbfdd 100644 --- a/server/src/__tests__/document-annotation-routes.test.ts +++ b/server/src/__tests__/document-annotation-routes.test.ts @@ -120,6 +120,9 @@ function registerModuleMocks() { hasPermission: vi.fn(async () => false), }), agentService: () => ({ getById: vi.fn(), list: vi.fn(async () => []) }), + companySkillService: () => ({ + completeTestRunForIssue: vi.fn(async () => null), + }), companyService: () => ({ getById: vi.fn(async () => ({ id: companyId, attachmentMaxBytes: 10_000_000 })) }), documentAnnotationService: () => mockAnnotationService, documentService: () => mockDocumentService, diff --git a/server/src/__tests__/environment-selection-route-guards.test.ts b/server/src/__tests__/environment-selection-route-guards.test.ts index e65edb0687..468afa571f 100644 --- a/server/src/__tests__/environment-selection-route-guards.test.ts +++ b/server/src/__tests__/environment-selection-route-guards.test.ts @@ -67,6 +67,9 @@ vi.mock("../services/index.js", () => ({ agentService: () => ({ getById: vi.fn(), }), + companySkillService: () => ({ + completeTestRunForIssue: vi.fn(async () => null), + }), executionWorkspaceService: () => ({}), goalService: () => ({ getById: vi.fn(), diff --git a/server/src/__tests__/external-object-routes.test.ts b/server/src/__tests__/external-object-routes.test.ts index 24e8064336..96a6d30e3f 100644 --- a/server/src/__tests__/external-object-routes.test.ts +++ b/server/src/__tests__/external-object-routes.test.ts @@ -50,6 +50,7 @@ function registerRouteMocks() { vi.doMock("../services/index.js", () => ({ accessService: () => mockAccessService, agentService: () => mockAgentService, + companySkillService: () => ({}), companyService: () => ({ getById: vi.fn(async () => null), }), @@ -103,13 +104,20 @@ async function createApp(actor: Express.Request["actor"]) { vi.importActual("../middleware/index.js"), vi.importActual("../routes/issues.js"), ]); + const routeDb = { + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn(async () => [makeIssue()]), + })), + })), + }; const app = express(); app.use(express.json()); app.use((req, _res, next) => { req.actor = actor; next(); }); - app.use("/api", issueRoutes({} as any, { provider: "local_disk" } as any)); + app.use("/api", issueRoutes(routeDb as any, { provider: "local_disk" } as any)); app.use(errorHandler); return app; } @@ -161,7 +169,7 @@ describe("external object routes", () => { mockIssueService.assertCheckoutOwner.mockResolvedValue({ adoptedFromRunId: null }); mockAccessService.hasPermission.mockResolvedValue(false); mockAccessService.decide.mockImplementation(async ({ action }: { action: string }) => ({ - allowed: action === "issue:mutate", + allowed: action === "issue:read" || action === "issue:mutate", explanation: "Denied by test mock", })); mockAgentService.list.mockResolvedValue([ @@ -169,9 +177,9 @@ describe("external object routes", () => { { id: peerAgentId, companyId, reportsTo: null, permissions: { canCreateAgents: false } }, ]); mockExternalObjectsService.getIssueSummary.mockResolvedValue({ total: 1, objects: [] }); - mockExternalObjectsService.getIssueSummaries.mockResolvedValue(new Map([ - [issueId, { total: 1, objects: [] }], - ])); + mockExternalObjectsService.getIssueSummaries.mockImplementation(async (_companyId: string, issueIds: string[]) => + new Map(issueIds.map((id) => [id, { total: 1, objects: [] }])), + ); mockExternalObjectsService.listForIssue.mockResolvedValue([]); mockExternalObjectsService.refreshIssueObjects.mockResolvedValue([ { object: { id: "77777777-7777-4777-8777-777777777777" }, refreshed: false, reason: "no_resolver" }, @@ -200,6 +208,22 @@ describe("external object routes", () => { expect(mockExternalObjectsService.getIssueSummary).toHaveBeenCalledWith(issueId); }); + it("requires issue read access before reading issue external objects", async () => { + mockAccessService.decide.mockResolvedValue({ + allowed: false, + explanation: "Denied by test mock", + }); + const app = await createApp(ownerActor()); + + const summary = await request(app).get(`/api/issues/${issueId}/external-object-summary`); + expect(summary.status).toBe(403); + expect(mockExternalObjectsService.getIssueSummary).not.toHaveBeenCalled(); + + const list = await request(app).get(`/api/issues/${issueId}/external-objects`); + expect(list.status).toBe(403); + expect(mockExternalObjectsService.listForIssue).not.toHaveBeenCalled(); + }); + it("allows board users to fetch company-scoped external object summaries in bulk", async () => { const app = await createApp(boardActor()); @@ -212,6 +236,22 @@ describe("external object routes", () => { expect(mockExternalObjectsService.getIssueSummaries).toHaveBeenCalledWith(companyId, [issueId]); }); + it("filters bulk external object summaries through issue read access", async () => { + mockAccessService.decide.mockResolvedValue({ + allowed: false, + explanation: "Denied by test mock", + }); + const app = await createApp(ownerActor()); + + const res = await request(app) + .post(`/api/companies/${companyId}/issues/external-object-summaries`) + .send({ issueIds: [issueId] }); + + expect(res.status).toBe(200); + expect(res.body.summaries).toEqual({}); + expect(mockExternalObjectsService.getIssueSummaries).toHaveBeenCalledWith(companyId, []); + }); + it("enforces company access on bulk external object summaries", async () => { const app = await createApp({ ...ownerActor(), companyId: "other-company" }); diff --git a/server/src/__tests__/heartbeat-context-summary.test.ts b/server/src/__tests__/heartbeat-context-summary.test.ts index 4463640d97..8d696e335f 100644 --- a/server/src/__tests__/heartbeat-context-summary.test.ts +++ b/server/src/__tests__/heartbeat-context-summary.test.ts @@ -90,6 +90,23 @@ describe("buildPaperclipTaskMarkdown", () => { expect(assignment).toContain("do not produce an implementation plan"); }); + it("adds dry-run containment guidance for skill-test issues", () => { + const assignment = buildPaperclipTaskMarkdown({ + issue: { + id: "issue-skill-test", + identifier: "PAP-417", + title: "Test skill draft", + workMode: "skill_test", + description: null, + }, + }); + + expect(assignment).toContain("- Work mode: \"skill_test\""); + expect(assignment).toContain("Skill test mode directive:"); + expect(assignment).toContain("Make no durable changes outside this issue."); + expect(assignment).toContain("Write your final output as issue document `output`"); + }); + it("prefers ordinary comment planning guidance over stale accepted confirmation state", () => { const commentWake = buildPaperclipTaskMarkdown({ issue: { diff --git a/server/src/__tests__/heartbeat-scheduling-suppression.test.ts b/server/src/__tests__/heartbeat-scheduling-suppression.test.ts index 4fb35931fb..f583e60fb8 100644 --- a/server/src/__tests__/heartbeat-scheduling-suppression.test.ts +++ b/server/src/__tests__/heartbeat-scheduling-suppression.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; -import { resolveHeartbeatSchedulingSuppression } from "../services/heartbeat.ts"; +import { + resolveHeartbeatSchedulingSuppression, + resolveSkillTestRunCompletionForHeartbeatOutcome, +} from "../services/heartbeat.ts"; describe("heartbeat scheduling suppression", () => { it("suppresses heartbeat scheduling for worktree runtimes", () => { @@ -26,4 +29,50 @@ describe("heartbeat scheduling suppression", () => { reason: null, }); }); + + it("lifts worktree suppression when run execution is explicitly allowed", () => { + expect( + resolveHeartbeatSchedulingSuppression( + { PAPERCLIP_IN_WORKTREE: "true" }, + { allowWorktreeRunExecution: true }, + ), + ).toEqual({ + suppressed: false, + reason: null, + }); + }); + + it("still suppresses database restore even when worktree run execution is allowed", () => { + expect( + resolveHeartbeatSchedulingSuppression( + { + PAPERCLIP_IN_WORKTREE: "true", + PAPERCLIP_DATABASE_RESTORE_IN_PROGRESS: "1", + }, + { allowWorktreeRunExecution: true }, + ), + ).toEqual({ + suppressed: true, + reason: "database_restore_in_progress", + }); + }); + + it("maps unsuccessful heartbeat outcomes to terminal skill test run outcomes", () => { + expect(resolveSkillTestRunCompletionForHeartbeatOutcome("succeeded", null)).toBeNull(); + expect(resolveSkillTestRunCompletionForHeartbeatOutcome("cancelled", null)).toEqual({ + outcome: "cancelled", + error: "Harness run was cancelled", + heartbeatOutcome: "cancelled", + }); + expect(resolveSkillTestRunCompletionForHeartbeatOutcome("timed_out", null)).toEqual({ + outcome: "failed", + error: "Timed out", + heartbeatOutcome: "timed_out", + }); + expect(resolveSkillTestRunCompletionForHeartbeatOutcome("failed", "Adapter crashed")).toEqual({ + outcome: "failed", + error: "Adapter crashed", + heartbeatOutcome: "failed", + }); + }); }); diff --git a/server/src/__tests__/heartbeat-worktree-suppression.test.ts b/server/src/__tests__/heartbeat-worktree-suppression.test.ts index bb1e23c944..58daa176c5 100644 --- a/server/src/__tests__/heartbeat-worktree-suppression.test.ts +++ b/server/src/__tests__/heartbeat-worktree-suppression.test.ts @@ -13,6 +13,7 @@ import { documents, heartbeatRunEvents, heartbeatRuns, + instanceSettings, issueComments, issueDocuments, issues, @@ -22,6 +23,7 @@ import { startEmbeddedPostgresTestDatabase, } from "./helpers/embedded-postgres.js"; import { heartbeatService, resolveHeartbeatSchedulingSuppression } from "../services/heartbeat.ts"; +import { instanceSettingsService } from "../services/instance-settings.ts"; const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; @@ -72,6 +74,7 @@ describeEmbeddedPostgres("heartbeat worktree suppression", () => { await db.delete(companySkills); await db.delete(agents); await db.delete(companies); + await db.delete(instanceSettings); }); afterAll(async () => { diff --git a/server/src/__tests__/instance-settings-service.test.ts b/server/src/__tests__/instance-settings-service.test.ts index 3fbed144ba..5f2ca06995 100644 --- a/server/src/__tests__/instance-settings-service.test.ts +++ b/server/src/__tests__/instance-settings-service.test.ts @@ -33,6 +33,7 @@ describe("instance settings service", () => { autoRestartDevServerWhenIdle: true, enableIssueGraphLivenessAutoRecovery: true, enableWorkspaceBranchReconcileForward: true, + enableWorktreeRunExecution: false, issueGraphLivenessAutoRecoveryLookbackHours: 48, }); }); diff --git a/server/src/__tests__/issue-activity-events-routes.test.ts b/server/src/__tests__/issue-activity-events-routes.test.ts index 9b6daa189b..550acc57c4 100644 --- a/server/src/__tests__/issue-activity-events-routes.test.ts +++ b/server/src/__tests__/issue-activity-events-routes.test.ts @@ -82,6 +82,9 @@ function registerModuleMocks() { agentService: () => ({ getById: vi.fn(async () => null), }), + companySkillService: () => ({ + completeTestRunForIssue: vi.fn(async () => null), + }), documentAnnotationService: () => ({ remapOpenThreadsForDocument: async () => [] }), documentService: () => ({}), executionWorkspaceService: () => ({}), diff --git a/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts b/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts index 0e5ea769ed..821a5c37f0 100644 --- a/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts +++ b/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts @@ -179,6 +179,9 @@ function registerRouteMocks() { accessService: () => mockAccessService, agentService: () => mockAgentService, clampIssueListLimit: (value: number) => Math.min(Math.max(value, 1), 500), + companySkillService: () => ({ + completeTestRunForIssue: vi.fn(async () => null), + }), companyService: () => mockCompanyService, documentAnnotationService: () => ({ remapOpenThreadsForDocument: async () => [] }), documentService: () => mockDocumentService, diff --git a/server/src/__tests__/issue-assigned-backlog-contract-routes.test.ts b/server/src/__tests__/issue-assigned-backlog-contract-routes.test.ts index 13d8c727ea..52e8224fc2 100644 --- a/server/src/__tests__/issue-assigned-backlog-contract-routes.test.ts +++ b/server/src/__tests__/issue-assigned-backlog-contract-routes.test.ts @@ -42,6 +42,9 @@ vi.mock("../services/index.js", () => ({ }, })), }), + companySkillService: () => ({ + completeTestRunForIssue: vi.fn(async () => null), + }), companyService: () => ({ getById: vi.fn(async () => ({ id: "company-1", attachmentMaxBytes: 10 * 1024 * 1024 })), }), diff --git a/server/src/__tests__/issue-attachment-routes.test.ts b/server/src/__tests__/issue-attachment-routes.test.ts index b7f6fb7379..42a9423392 100644 --- a/server/src/__tests__/issue-attachment-routes.test.ts +++ b/server/src/__tests__/issue-attachment-routes.test.ts @@ -19,6 +19,14 @@ const mockWorkProductService = vi.hoisted(() => ({ getById: vi.fn(), update: vi.fn(), })); +const mockAccessService = vi.hoisted(() => ({ + decide: vi.fn(async () => ({ + allowed: true, + explanation: "Allowed by test mock", + })), + canUser: vi.fn(), + hasPermission: vi.fn(), +})); const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined)); @@ -41,13 +49,11 @@ function registerRouteMocks() { })); vi.doMock("../services/index.js", () => ({ - accessService: () => ({ - canUser: vi.fn(), - hasPermission: vi.fn(), - }), + accessService: () => mockAccessService, agentService: () => ({ getById: vi.fn(), }), + companySkillService: () => ({}), companyService: () => mockCompanyService, documentAnnotationService: () => ({ remapOpenThreadsForDocument: async () => [] }), documentService: () => ({}), @@ -232,7 +238,21 @@ describe("issue attachment routes", () => { vi.doUnmock("../middleware/index.js"); registerRouteMocks(); vi.clearAllMocks(); + mockAccessService.decide.mockResolvedValue({ + allowed: true, + explanation: "Allowed by test mock", + }); mockLogActivity.mockResolvedValue(undefined); + mockIssueService.getById.mockResolvedValue({ + id: "11111111-1111-4111-8111-111111111111", + companyId: "company-1", + projectId: null, + parentId: null, + status: "todo", + assigneeAgentId: null, + assigneeUserId: null, + identifier: "PAP-1", + }); mockCompanyService.getById.mockResolvedValue({ id: "company-1", attachmentMaxBytes: 1024 * 1024 * 1024, @@ -503,6 +523,21 @@ describe("issue attachment routes", () => { expect(storage.getObject).not.toHaveBeenCalled(); }); + it("rejects same-company attachment content reads outside the parent issue boundary", async () => { + const storage = createStorageService(); + mockIssueService.getAttachmentById.mockResolvedValue(makeAttachment("video/mp4", "clip.mp4")); + mockAccessService.decide.mockResolvedValue({ + allowed: false, + explanation: "Denied by test mock", + }); + + const app = await createApp(storage); + const res = await request(app).get("/api/attachments/attachment-1/content"); + + expect(res.status).toBe(403); + expect(storage.getObject).not.toHaveBeenCalled(); + }); + it("canonicalizes paperclip artifact metadata before creating a work product", async () => { const storage = createStorageService(); const issue = { diff --git a/server/src/__tests__/issue-closed-workspace-routes.test.ts b/server/src/__tests__/issue-closed-workspace-routes.test.ts index fd322bc898..d9c82cd7d0 100644 --- a/server/src/__tests__/issue-closed-workspace-routes.test.ts +++ b/server/src/__tests__/issue-closed-workspace-routes.test.ts @@ -81,6 +81,9 @@ function registerServiceMocks() { agentService: () => ({ getById: vi.fn(async () => null), }), + companySkillService: () => ({ + completeTestRunForIssue: vi.fn(async () => null), + }), documentAnnotationService: () => ({ remapOpenThreadsForDocument: async () => [] }), documentService: () => ({}), executionWorkspaceService: () => mockExecutionWorkspaceService, diff --git a/server/src/__tests__/issue-comment-cancel-routes.test.ts b/server/src/__tests__/issue-comment-cancel-routes.test.ts index bd46a12ce3..a85e648c3a 100644 --- a/server/src/__tests__/issue-comment-cancel-routes.test.ts +++ b/server/src/__tests__/issue-comment-cancel-routes.test.ts @@ -120,6 +120,9 @@ function registerModuleMocks() { }), accessService: () => mockAccessService, agentService: () => ({ getById: vi.fn(async () => null) }), + companySkillService: () => ({ + completeTestRunForIssue: vi.fn(async () => null), + }), documentAnnotationService: () => mockDocumentAnnotationService, documentService: () => ({}), executionWorkspaceService: () => ({}), diff --git a/server/src/__tests__/issue-comment-reopen-routes.test.ts b/server/src/__tests__/issue-comment-reopen-routes.test.ts index 22ef7d7fa7..3a0b090748 100644 --- a/server/src/__tests__/issue-comment-reopen-routes.test.ts +++ b/server/src/__tests__/issue-comment-reopen-routes.test.ts @@ -131,6 +131,9 @@ vi.mock("../services/index.js", () => ({ }), accessService: () => mockAccessService, agentService: () => mockAgentService, + companySkillService: () => ({ + completeTestRunForIssue: vi.fn(async () => null), + }), documentAnnotationService: () => ({ remapOpenThreadsForDocument: async () => [] }), documentService: () => ({}), executionWorkspaceService: () => ({}), diff --git a/server/src/__tests__/issue-dependency-wakeups-routes.test.ts b/server/src/__tests__/issue-dependency-wakeups-routes.test.ts index 38c16f7e43..da27484dbf 100644 --- a/server/src/__tests__/issue-dependency-wakeups-routes.test.ts +++ b/server/src/__tests__/issue-dependency-wakeups-routes.test.ts @@ -29,6 +29,9 @@ vi.mock("../services/index.js", () => ({ agentService: () => ({ getById: vi.fn(), }), + companySkillService: () => ({ + completeTestRunForIssue: vi.fn(async () => null), + }), documentAnnotationService: () => ({ remapOpenThreadsForDocument: async () => [] }), documentService: () => ({ getIssueDocumentPayload: vi.fn(async () => ({})), diff --git a/server/src/__tests__/issue-document-restore-routes.test.ts b/server/src/__tests__/issue-document-restore-routes.test.ts index d0fe8df9ba..7b528f084c 100644 --- a/server/src/__tests__/issue-document-restore-routes.test.ts +++ b/server/src/__tests__/issue-document-restore-routes.test.ts @@ -115,6 +115,9 @@ function registerModuleMocks() { }), accessService: () => mockAccessService, agentService: () => mockAgentService, + companySkillService: () => ({ + completeTestRunForIssue: vi.fn(async () => null), + }), documentAnnotationService: () => ({ remapOpenThreadsForDocument: async () => [] }), documentService: () => mockDocumentsService, executionWorkspaceService: () => ({}), diff --git a/server/src/__tests__/issue-execution-policy-routes.test.ts b/server/src/__tests__/issue-execution-policy-routes.test.ts index cce87c1b4e..2c2e494632 100644 --- a/server/src/__tests__/issue-execution-policy-routes.test.ts +++ b/server/src/__tests__/issue-execution-policy-routes.test.ts @@ -75,6 +75,9 @@ function registerModuleMocks() { }, })), }), + companySkillService: () => ({ + completeTestRunForIssue: vi.fn(async () => null), + }), documentAnnotationService: () => ({ remapOpenThreadsForDocument: async () => [] }), documentService: () => ({}), executionWorkspaceService: () => ({}), diff --git a/server/src/__tests__/issue-feedback-routes.test.ts b/server/src/__tests__/issue-feedback-routes.test.ts index 7e4107d6d8..727b36f7f2 100644 --- a/server/src/__tests__/issue-feedback-routes.test.ts +++ b/server/src/__tests__/issue-feedback-routes.test.ts @@ -87,6 +87,9 @@ function registerModuleMocks() { }), accessService: () => mockAccessService, agentService: () => mockAgentService, + companySkillService: () => ({ + completeTestRunForIssue: vi.fn(async () => null), + }), documentAnnotationService: () => ({ remapOpenThreadsForDocument: async () => [] }), documentService: () => ({}), executionWorkspaceService: () => mockExecutionWorkspaceService, diff --git a/server/src/__tests__/issue-telemetry-routes.test.ts b/server/src/__tests__/issue-telemetry-routes.test.ts index 9c97818d83..55a105ee24 100644 --- a/server/src/__tests__/issue-telemetry-routes.test.ts +++ b/server/src/__tests__/issue-telemetry-routes.test.ts @@ -50,6 +50,9 @@ function registerModuleMocks() { hasPermission: vi.fn(), }), agentService: () => mockAgentService, + companySkillService: () => ({ + completeTestRunForIssue: vi.fn(async () => null), + }), documentAnnotationService: () => ({ remapOpenThreadsForDocument: async () => [] }), documentService: () => ({}), executionWorkspaceService: () => ({}), diff --git a/server/src/__tests__/issue-thread-interaction-routes.test.ts b/server/src/__tests__/issue-thread-interaction-routes.test.ts index 0c08bcb0d2..c0ab044906 100644 --- a/server/src/__tests__/issue-thread-interaction-routes.test.ts +++ b/server/src/__tests__/issue-thread-interaction-routes.test.ts @@ -71,6 +71,9 @@ function registerModuleMocks() { })), }), clampIssueListLimit: (value: number) => value, + companySkillService: () => ({ + completeTestRunForIssue: vi.fn(async () => null), + }), ISSUE_LIST_DEFAULT_LIMIT: 500, ISSUE_LIST_MAX_LIMIT: 1000, documentAnnotationService: () => ({ remapOpenThreadsForDocument: async () => [] }), diff --git a/server/src/__tests__/issue-update-comment-wakeup-routes.test.ts b/server/src/__tests__/issue-update-comment-wakeup-routes.test.ts index 41b8c76a28..4fb46c0dc3 100644 --- a/server/src/__tests__/issue-update-comment-wakeup-routes.test.ts +++ b/server/src/__tests__/issue-update-comment-wakeup-routes.test.ts @@ -50,6 +50,9 @@ vi.mock("../services/index.js", () => ({ agent: { id: raw }, })), }), + companySkillService: () => ({ + completeTestRunForIssue: vi.fn(async () => null), + }), documentAnnotationService: () => ({ remapOpenThreadsForDocument: async () => [] }), documentService: () => ({}), executionWorkspaceService: () => ({}), @@ -119,6 +122,9 @@ function registerModuleMocks() { agent: { id: raw }, })), }), + companySkillService: () => ({ + completeTestRunForIssue: vi.fn(async () => null), + }), documentAnnotationService: () => ({ remapOpenThreadsForDocument: async () => [] }), documentService: () => ({}), executionWorkspaceService: () => ({}), diff --git a/server/src/__tests__/issue-workspace-command-authz.test.ts b/server/src/__tests__/issue-workspace-command-authz.test.ts index 18494d1a49..1240af41c3 100644 --- a/server/src/__tests__/issue-workspace-command-authz.test.ts +++ b/server/src/__tests__/issue-workspace-command-authz.test.ts @@ -109,6 +109,9 @@ function registerRouteMocks() { }), accessService: () => mockAccessService, agentService: () => mockAgentService, + companySkillService: () => ({ + completeTestRunForIssue: vi.fn(async () => null), + }), documentAnnotationService: () => ({ remapOpenThreadsForDocument: async () => [] }), documentService: () => ({}), executionWorkspaceService: () => mockExecutionWorkspaceService, diff --git a/server/src/__tests__/issues-goal-context-routes.test.ts b/server/src/__tests__/issues-goal-context-routes.test.ts index ac755efb7f..14a011886b 100644 --- a/server/src/__tests__/issues-goal-context-routes.test.ts +++ b/server/src/__tests__/issues-goal-context-routes.test.ts @@ -104,6 +104,9 @@ vi.mock("../services/index.js", () => ({ }), accessService: () => mockAccessService, agentService: () => mockAgentService, + companySkillService: () => ({ + completeTestRunForIssue: vi.fn(async () => null), + }), documentAnnotationService: () => ({ remapOpenThreadsForDocument: async () => [] }), documentService: () => mockDocumentsService, environmentService: () => mockEnvironmentService, diff --git a/server/src/__tests__/low-trust-red-team-routes.test.ts b/server/src/__tests__/low-trust-red-team-routes.test.ts index 53a5f47a08..d8a893f171 100644 --- a/server/src/__tests__/low-trust-red-team-routes.test.ts +++ b/server/src/__tests__/low-trust-red-team-routes.test.ts @@ -11,13 +11,19 @@ import { agentRuntimeState, agents, approvals, + assets, companies, companySkills, createDb, + documentAnnotationComments, + documentAnnotationThreads, documentRevisions, documents, + externalObjectMentions, + externalObjects, heartbeatRunEvents, heartbeatRuns, + issueAttachments, issueApprovals, issueComments, issueDocuments, @@ -68,6 +74,7 @@ async function deleteHeartbeatRunsAndWakeupsAfterActivityLogDrains(db: Db) { await db.delete(activityLog); await db.delete(heartbeatRunEvents); try { + await db.delete(heartbeatRunEvents); await db.delete(heartbeatRuns); await db.delete(agentWakeupRequests); return; @@ -94,6 +101,17 @@ function agentActor(fixture: Fixture, agentId = fixture.agents.lowTrust.id): Exp }; } +function skillTestActor(fixture: Fixture, issueId = fixture.issues.assignedReview.id): Express.Request["actor"] { + return { + type: "agent", + agentId: fixture.agents.standard.id, + companyId: fixture.company.id, + runId: fixture.runs.standard.id, + source: "agent_jwt", + keyScope: { kind: "skill_test", issueId }, + }; +} + function boardActor(fixture: Fixture): Express.Request["actor"] { return { type: "board", @@ -309,6 +327,9 @@ async function seedLowTrustFixture(db: Db) { issueSibling: canary("FLAG-ISSUE-SIBLING-7R4G"), commentSibling: canary("FLAG-COMMENT-SIBLING-7R4G"), documentSibling: canary("FLAG-DOC-SIBLING-7R4G"), + annotationSibling: canary("FLAG-ANNOTATION-SIBLING-7R4G"), + attachmentSibling: canary("FLAG-ATTACHMENT-SIBLING-7R4G"), + externalObjectSibling: canary("FLAG-EXTERNAL-OBJECT-SIBLING-7R4G"), workProductSibling: canary("FLAG-WP-SIBLING-7R4G"), approval: canary("FLAG-APPROVAL-7R4G"), agentConfig: canary("FLAG-AGENTCFG-7R4G"), @@ -479,6 +500,76 @@ async function seedLowTrustFixture(db: Db) { documentId: siblingDoc!.id, key: "canary", }); + const [siblingAnnotationThread] = await db.insert(documentAnnotationThreads).values({ + companyId: company!.id, + issueId: siblingOutOfScope!.id, + documentId: siblingDoc!.id, + documentKey: "canary", + originalRevisionId: siblingRevision!.id, + originalRevisionNumber: 1, + currentRevisionId: siblingRevision!.id, + currentRevisionNumber: 1, + selectedText: "Sibling", + prefixText: "", + suffixText: " doc", + normalizedStart: 0, + normalizedEnd: 7, + markdownStart: 0, + markdownEnd: 7, + anchorSelector: { + quote: { exact: "Sibling", prefix: "", suffix: " doc" }, + position: { normalizedStart: 0, normalizedEnd: 7, markdownStart: 0, markdownEnd: 7 }, + }, + createdByAgentId: standard!.id, + }).returning(); + await db.insert(documentAnnotationComments).values({ + companyId: company!.id, + threadId: siblingAnnotationThread!.id, + issueId: siblingOutOfScope!.id, + documentId: siblingDoc!.id, + body: canaries.annotationSibling, + authorType: "agent", + authorAgentId: standard!.id, + }); + const [siblingAttachmentAsset] = await db.insert(assets).values({ + companyId: company!.id, + provider: "local_disk", + objectKey: `issues/${siblingOutOfScope!.id}/attachment-canary.txt`, + contentType: "text/plain", + byteSize: canaries.attachmentSibling.length, + sha256: `sha256-${nonce}`, + originalFilename: "attachment-canary.txt", + createdByAgentId: standard!.id, + }).returning(); + const [siblingAttachment] = await db.insert(issueAttachments).values({ + companyId: company!.id, + issueId: siblingOutOfScope!.id, + assetId: siblingAttachmentAsset!.id, + }).returning(); + const [siblingExternalObject] = await db.insert(externalObjects).values({ + companyId: company!.id, + providerKey: "url", + objectType: "link", + externalId: `external-${nonce}`, + sanitizedCanonicalUrl: "https://example.invalid/redacted", + canonicalIdentityHash: `external-hash-${nonce}`, + displayKey: "EXT-1", + displayTitle: canaries.externalObjectSibling, + data: { canary: canaries.externalObjectSibling }, + }).returning(); + await db.insert(externalObjectMentions).values({ + companyId: company!.id, + sourceIssueId: siblingOutOfScope!.id, + sourceKind: "description", + matchedTextRedacted: canaries.externalObjectSibling, + sanitizedDisplayUrl: "https://example.invalid/redacted", + canonicalIdentityHash: `external-hash-${nonce}`, + canonicalIdentity: { url: "https://example.invalid/redacted" }, + objectId: siblingExternalObject!.id, + providerKey: "url", + detectorKey: "test", + objectType: "link", + }); await db.insert(issueWorkProducts).values({ companyId: company!.id, projectId: outOfScopeProject!.id, @@ -502,6 +593,12 @@ async function seedLowTrustFixture(db: Db) { approvalId: approval!.id, linkedByAgentId: standard!.id, }); + await db.insert(issueApprovals).values({ + companyId: company!.id, + issueId: siblingOutOfScope!.id, + approvalId: approval!.id, + linkedByAgentId: standard!.id, + }); return { company: company!, @@ -509,6 +606,10 @@ async function seedLowTrustFixture(db: Db) { projects: { allowed: allowedProject!, outOfScope: outOfScopeProject! }, issues: { reviewRoot: reviewRoot!, assignedReview: assignedReview!, sameBoundaryChild: sameBoundaryChild!, siblingOutOfScope: siblingOutOfScope! }, approvals: { issueLinkedCanary: approval! }, + sensitiveRows: { + siblingAnnotationThreadId: siblingAnnotationThread!.id, + siblingAttachmentId: siblingAttachment!.id, + }, runs: { lowTrust: lowTrustRun!, standard: standardRun! }, canaries, }; @@ -528,6 +629,12 @@ describeEmbeddedPostgres("low-trust red-team HTTP route regression suite", () => await db.delete(issueApprovals); await db.delete(approvals); await db.delete(issueWorkProducts); + await db.delete(issueAttachments); + await db.delete(assets); + await db.delete(externalObjectMentions); + await db.delete(externalObjects); + await db.delete(documentAnnotationComments); + await db.delete(documentAnnotationThreads); await db.delete(issueDocuments); await db.delete(documentRevisions); await db.delete(documents); @@ -751,6 +858,45 @@ describeEmbeddedPostgres("low-trust red-team HTTP route regression suite", () => id: "LT-08", req: () => request(app).get(`/api/issues/${fixture.issues.siblingOutOfScope.id}/documents/canary`), }, + { + id: "LT-08 revisions", + req: () => request(app).get(`/api/issues/${fixture.issues.siblingOutOfScope.id}/documents/canary/revisions`), + }, + { + id: "LT-08 annotations", + req: () => request(app) + .get(`/api/issues/${fixture.issues.siblingOutOfScope.id}/documents/canary/annotations`) + .query({ includeComments: "true" }), + }, + { + id: "LT-08 annotation thread", + req: () => request(app) + .get(`/api/issues/${fixture.issues.siblingOutOfScope.id}/documents/canary/annotations/${fixture.sensitiveRows.siblingAnnotationThreadId}`), + }, + { + id: "LT recovery actions", + req: () => request(app).get(`/api/issues/${fixture.issues.siblingOutOfScope.id}/recovery-actions`), + }, + { + id: "LT external objects", + req: () => request(app).get(`/api/issues/${fixture.issues.siblingOutOfScope.id}/external-objects`), + }, + { + id: "LT external object summary", + req: () => request(app).get(`/api/issues/${fixture.issues.siblingOutOfScope.id}/external-object-summary`), + }, + { + id: "LT approvals", + req: () => request(app).get(`/api/issues/${fixture.issues.siblingOutOfScope.id}/approvals`), + }, + { + id: "LT attachments", + req: () => request(app).get(`/api/issues/${fixture.issues.siblingOutOfScope.id}/attachments`), + }, + { + id: "LT attachment content", + req: () => request(app).get(`/api/attachments/${fixture.sensitiveRows.siblingAttachmentId}/content`), + }, { id: "LT-15/16", req: () => request(app).get(`/api/agents/${fixture.agents.cto.id}`), @@ -822,6 +968,88 @@ describeEmbeddedPostgres("low-trust red-team HTTP route regression suite", () => expect(after.wakeups.length, attempt.id).toBe(before.wakeups.length); expect(after.runs.length, attempt.id).toBe(before.runs.length); } + + const beforeBulkSummary = await snapshot(db); + const bulkSummary = await request(app) + .post(`/api/companies/${fixture.company.id}/issues/external-object-summaries`) + .send({ issueIds: [fixture.issues.siblingOutOfScope.id] }); + expect(bulkSummary.status, JSON.stringify(bulkSummary.body)).toBe(200); + expect(bulkSummary.body.summaries).toEqual({}); + expectNoCanary(bulkSummary.body, ...forbiddenMarkers); + const afterBulkSummary = await snapshot(db); + expect(afterBulkSummary.issues.length).toBe(beforeBulkSummary.issues.length); + expect(afterBulkSummary.comments.length).toBe(beforeBulkSummary.comments.length); + expect(afterBulkSummary.documents.length).toBe(beforeBulkSummary.documents.length); + expect(afterBulkSummary.workProducts.length).toBe(beforeBulkSummary.workProducts.length); + expect(afterBulkSummary.approvals.length).toBe(beforeBulkSummary.approvals.length); + expect(afterBulkSummary.relations.length).toBe(beforeBulkSummary.relations.length); + expect(afterBulkSummary.interactions.length).toBe(beforeBulkSummary.interactions.length); + expect(afterBulkSummary.wakeups.length).toBe(beforeBulkSummary.wakeups.length); + expect(afterBulkSummary.runs.length).toBe(beforeBulkSummary.runs.length); + }); + + it("denies skill-test scoped tokens on foreign issue-adjacent reads", async () => { + const fixture = await seedLowTrustFixture(db); + const app = createApp(db, skillTestActor(fixture)); + const forbiddenMarkers = Object.values(fixture.canaries); + + const ownIssue = await request(app).get(`/api/issues/${fixture.issues.assignedReview.id}`); + expect(ownIssue.status, JSON.stringify(ownIssue.body)).toBe(200); + + const attempts = [ + { + id: "skill-test attachments", + req: () => request(app).get(`/api/issues/${fixture.issues.siblingOutOfScope.id}/attachments`), + }, + { + id: "skill-test attachment content", + req: () => request(app).get(`/api/attachments/${fixture.sensitiveRows.siblingAttachmentId}/content`), + }, + { + id: "skill-test document revisions", + req: () => request(app).get(`/api/issues/${fixture.issues.siblingOutOfScope.id}/documents/canary/revisions`), + }, + { + id: "skill-test annotations", + req: () => request(app) + .get(`/api/issues/${fixture.issues.siblingOutOfScope.id}/documents/canary/annotations`) + .query({ includeComments: "true" }), + }, + { + id: "skill-test annotation thread", + req: () => request(app) + .get(`/api/issues/${fixture.issues.siblingOutOfScope.id}/documents/canary/annotations/${fixture.sensitiveRows.siblingAnnotationThreadId}`), + }, + { + id: "skill-test approvals", + req: () => request(app).get(`/api/issues/${fixture.issues.siblingOutOfScope.id}/approvals`), + }, + { + id: "skill-test recovery actions", + req: () => request(app).get(`/api/issues/${fixture.issues.siblingOutOfScope.id}/recovery-actions`), + }, + { + id: "skill-test external objects", + req: () => request(app).get(`/api/issues/${fixture.issues.siblingOutOfScope.id}/external-objects`), + }, + { + id: "skill-test external object summary", + req: () => request(app).get(`/api/issues/${fixture.issues.siblingOutOfScope.id}/external-object-summary`), + }, + ]; + + for (const attempt of attempts) { + const res = await attempt.req(); + expect(res.status, `${attempt.id}: ${JSON.stringify(res.body)}`).toBe(403); + expectNoCanary(res.body, ...forbiddenMarkers); + } + + const bulkSummary = await request(app) + .post(`/api/companies/${fixture.company.id}/issues/external-object-summaries`) + .send({ issueIds: [fixture.issues.siblingOutOfScope.id] }); + expect(bulkSummary.status, JSON.stringify(bulkSummary.body)).toBe(200); + expect(bulkSummary.body.summaries).toEqual({}); + expectNoCanary(bulkSummary.body, ...forbiddenMarkers); }); it("counts blocked inbox issues with the low-trust boundary applied in the database", async () => { @@ -864,7 +1092,14 @@ describeEmbeddedPostgres("low-trust red-team HTTP route regression suite", () => const lowTrustApp = createApp(db, agentActor(fixture)); const standardApp = createApp(db, agentActor(fixture, fixture.agents.standard.id)); const gateway = await createControlledGatewayServer(); - const heartbeat = heartbeatService(db); + const heartbeat = heartbeatService(db, { + runtimeEnv: { + ...process.env, + PAPERCLIP_IN_WORKTREE: "false", + PAPERCLIP_DATABASE_RESTORE_IN_PROGRESS: "false", + PAPERCLIP_RESTORE_IN_PROGRESS: "false", + }, + }); try { const comment = await request(lowTrustApp) @@ -931,6 +1166,10 @@ describeEmbeddedPostgres("low-trust red-team HTTP route regression suite", () => waitTimeoutMs: 2_000, }, }).where(eq(agents.id, fixture.agents.standard.id)); + await db.update(heartbeatRuns).set({ + status: "succeeded", + finishedAt: new Date("2026-05-14T12:02:00.000Z"), + }).where(eq(heartbeatRuns.id, fixture.runs.standard.id)); const run = await heartbeat.wakeup(fixture.agents.standard.id, { source: "automation", diff --git a/server/src/__tests__/server-startup-feedback-export.test.ts b/server/src/__tests__/server-startup-feedback-export.test.ts index 96f59ecdf2..e4420e0bb6 100644 --- a/server/src/__tests__/server-startup-feedback-export.test.ts +++ b/server/src/__tests__/server-startup-feedback-export.test.ts @@ -29,7 +29,12 @@ const { const createDbMock = vi.fn(() => ({}) as never); const detectPortMock = vi.fn(async (port: number) => port); const deriveAuthTrustedOriginsMock = vi.fn(() => []); + const resolveHeartbeatSchedulingSuppressionMock = vi.fn(() => ({ + suppressed: false, + reason: null, + })); const heartbeatServiceMock = { + resolveSchedulingSuppression: resolveHeartbeatSchedulingSuppressionMock, reapOrphanedRuns: vi.fn(async () => ({ reaped: 0, runIds: [] })), promoteDueScheduledRetries: vi.fn(async () => ({ promoted: 0, runIds: [] })), resumeQueuedRuns: vi.fn(async () => undefined), @@ -62,10 +67,6 @@ const { tickScheduledTriggers: vi.fn(async () => ({ triggered: 0 })), }; const routineServiceFactoryMock = vi.fn(() => routineServiceMock); - const resolveHeartbeatSchedulingSuppressionMock = vi.fn(() => ({ - suppressed: false, - reason: null, - })); const feedbackExportServiceMock = { flushPendingFeedbackTraces: vi.fn(async () => ({ attempted: 0, sent: 0, failed: 0 })), }; diff --git a/server/src/agent-auth-jwt.ts b/server/src/agent-auth-jwt.ts index 4cb25e97ab..140bcfafe4 100644 --- a/server/src/agent-auth-jwt.ts +++ b/server/src/agent-auth-jwt.ts @@ -1,4 +1,5 @@ import { createHmac, timingSafeEqual } from "node:crypto"; +import { normalizeAgentApiKeyScope, type AgentApiKeyScope } from "@paperclipai/shared"; import { resolvePaperclipInstanceId } from "./home-paths.js"; interface JwtHeader { @@ -12,6 +13,7 @@ export interface LocalAgentJwtClaims { adapter_type: string; run_id: string; responsible_user_id?: string | null; + key_scope?: AgentApiKeyScope | null; iat: number; exp: number; iss?: string; @@ -117,6 +119,7 @@ export function createLocalAgentJwt( adapterType: string, runId: string, responsibleUserId?: string | null, + keyScope: AgentApiKeyScope = { kind: "standard" }, ) { const config = jwtConfig(); if (!config) return null; @@ -128,6 +131,7 @@ export function createLocalAgentJwt( adapter_type: adapterType, run_id: runId, responsible_user_id: responsibleUserId?.trim() || null, + ...(keyScope.kind === "standard" ? {} : { key_scope: keyScope }), iat: now, exp: now + config.ttlSeconds, iss: config.issuer, @@ -203,6 +207,9 @@ export function verifyLocalAgentJwt(token: string): LocalAgentJwtClaims | null { ? claims.responsible_user_id.trim() : null : undefined; + const keyScopeClaim = Object.hasOwn(claims, "key_scope") + ? normalizeAgentApiKeyScope(claims.key_scope) + : undefined; const iat = typeof claims.iat === "number" ? claims.iat : null; const exp = typeof claims.exp === "number" ? claims.exp : null; if (!sub || !adapterType || !runId || !iat || !exp) return null; @@ -231,6 +238,7 @@ export function verifyLocalAgentJwt(token: string): LocalAgentJwtClaims | null { adapter_type: adapterType, run_id: runId, ...(responsibleUserClaim !== undefined ? { responsible_user_id: responsibleUserClaim } : {}), + ...(keyScopeClaim !== undefined ? { key_scope: keyScopeClaim } : {}), iat, exp, ...(issuer ? { iss: issuer } : {}), diff --git a/server/src/index.ts b/server/src/index.ts index b1fe7e6f48..3056d02b86 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -45,7 +45,6 @@ import { reconcileCloudUpstreamRunsOnStartup, reconcileCodexLocalManagedHomesOnStartup, reconcilePersistedRuntimeServicesOnStartup, - resolveHeartbeatSchedulingSuppression, routineService, } from "./services/index.js"; import { @@ -825,7 +824,7 @@ export async function startServer(): Promise { drainHeartbeatRunsForShutdown = heartbeat.drainRunningRunsForShutdown; const environmentCustomImages = environmentCustomImageService(db as any, { pluginWorkerManager }); const routines = routineService(db as any, { pluginWorkerManager }); - const heartbeatSchedulingSuppression = resolveHeartbeatSchedulingSuppression(); + const heartbeatSchedulingSuppression = await heartbeat.resolveSchedulingSuppression(); // Reap orphaned runs before timer ticks start so wakeups cannot coalesce // into a dead "running" row during startup recovery. @@ -916,6 +915,10 @@ export async function startServer(): Promise { } heartbeatSchedulerInterval = setInterval(() => { + // Async so the suppression checks below can honor the override-aware + // resolver (e.g. worktree run-execution opt-in). The gated work is still + // wrapped in trackHeartbeatSchedulerWork with its own error handling. + void (async () => { if (heartbeatSchedulerStopped) return; const sweptRuntimeStatuses = heartbeat.sweepExpiredRuntimeStatuses(); if (sweptRuntimeStatuses > 0) { @@ -925,7 +928,7 @@ export async function startServer(): Promise { ); } - if (!resolveHeartbeatSchedulingSuppression().suppressed) { + if (!(await heartbeat.resolveSchedulingSuppression()).suppressed) { trackHeartbeatSchedulerWork(heartbeat .tickTimers(new Date()) .then((result) => { @@ -962,7 +965,7 @@ export async function startServer(): Promise { })); if (heartbeatSchedulerStopped) return; - if (!resolveHeartbeatSchedulingSuppression().suppressed) { + if (!(await heartbeat.resolveSchedulingSuppression()).suppressed) { // Periodically reap orphaned runs (5-min staleness threshold) and make sure // persisted queued work is still being driven forward. trackHeartbeatSchedulerWork(heartbeat @@ -1019,6 +1022,7 @@ export async function startServer(): Promise { logger.error({ err }, "periodic heartbeat recovery failed"); })); } + })(); }, config.heartbeatSchedulerIntervalMs); } diff --git a/server/src/middleware/auth.ts b/server/src/middleware/auth.ts index e174d1319a..c1b75c8a91 100644 --- a/server/src/middleware/auth.ts +++ b/server/src/middleware/auth.ts @@ -314,6 +314,7 @@ export function actorMiddleware(db: Db, opts: ActorMiddlewareOptions): RequestHa agentId: claims.sub, companyId: claims.company_id, keyId: undefined, + keyScope: normalizeAgentApiKeyScope(claims.key_scope), runId: claims.run_id, onBehalfOfUserId, onBehalfOfMemberships, diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index d43481375b..d3bbdcee52 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -1433,6 +1433,7 @@ export function agentRoutes( adapterType: string, adapterConfig: Record, requestedDesiredSkills: AgentDesiredSkillEntry[] | undefined, + options: { tolerateUnknownDesiredSkills?: boolean } = {}, ) { if (!requestedDesiredSkills) { return { @@ -1443,17 +1444,25 @@ export function agentRoutes( }; } - const resolvedRequestedSkillEntries = await companySkills.resolveRequestedSkillEntries( - companyId, - requestedDesiredSkills, - ); + const { resolved: resolvedRequestedSkillEntries, unresolved: unresolvedDesiredSkillKeys } = + await companySkills.resolveRequestedSkillEntries(companyId, requestedDesiredSkills, { + tolerateUnknownReferences: options.tolerateUnknownDesiredSkills, + }); + // Runtime materialization + version selection only ever consider skills that + // actually resolve to the company library; stale keys can't be materialized. const runtimeSkillEntries = await companySkills.listRuntimeSkillEntries(companyId, { materializeMissing: shouldMaterializeRuntimeSkillsForAdapter(adapterType), versionSelections: skillVersionSelectionMap(resolvedRequestedSkillEntries), }); - const desiredSkillEntries = resolvedRequestedSkillEntries.filter( + const resolvedDesiredSkillEntries = resolvedRequestedSkillEntries.filter( (entry, index, entries) => entries.findIndex((candidate) => candidate.key === entry.key) === index, ); + // Preserve stale/unresolvable keys in the persisted desired set so they stay + // visible (and explicitly removable) instead of vanishing on the next save. + const desiredSkillEntries: AgentDesiredSkillEntry[] = [ + ...resolvedDesiredSkillEntries, + ...unresolvedDesiredSkillKeys.map((key) => ({ key, versionId: null })), + ]; const desiredSkills = desiredSkillEntries.map((entry) => entry.key); return { @@ -1725,6 +1734,10 @@ export function agentRoutes( agent.adapterType, agent.adapterConfig as Record, requestedSkills, + // Toggling a resolvable skill must not fail just because the agent + // already carries stale desired keys (e.g. a skill removed from the + // library). Preserve those keys so they remain visible/removable. + { tolerateUnknownDesiredSkills: true }, ); if (!desiredSkills || !desiredSkillEntries || !runtimeSkillEntries) { throw unprocessable("Skill sync requires desiredSkills."); diff --git a/server/src/routes/company-skills.ts b/server/src/routes/company-skills.ts index 78111febfc..a5eb2545a9 100644 --- a/server/src/routes/company-skills.ts +++ b/server/src/routes/company-skills.ts @@ -5,6 +5,7 @@ import { companySkillCommentCreateSchema, companySkillCommentUpdateSchema, companySkillCreateSchema, + companySkillFileDeleteSchema, companySkillFileUpdateSchema, companySkillForkSchema, companySkillImportSchema, @@ -13,12 +14,18 @@ import { companySkillListQuerySchema, companySkillProjectScanRequestSchema, companySkillResetSchema, + companySkillTestInputCreateSchema, + companySkillTestInputUpdateSchema, + companySkillTestRunTemplateCreateSchema, + companySkillTestRunTemplateUpdateSchema, + companySkillTestRunCreateSchema, + companySkillTestRunListQuerySchema, companySkillUpdateSchema, companySkillVersionCreateSchema, } from "@paperclipai/shared"; import { trackSkillImported } from "@paperclipai/shared/telemetry"; import { validate } from "../middleware/validate.js"; -import { accessService, agentService, companySkillService, logActivity } from "../services/index.js"; +import { accessService, agentService, companySkillService, heartbeatService, issueService, logActivity } from "../services/index.js"; import { getCatalogSkillOrThrow, listCatalogSkillsOrEmpty, @@ -41,6 +48,8 @@ export function companySkillRoutes(db: Db) { const agents = agentService(db); const access = accessService(db); const svc = companySkillService(db); + const issues = issueService(db); + const heartbeat = heartbeatService(db); function canCreateSkills(agent: { permissions: Record | null | undefined }) { if (!agent.permissions || typeof agent.permissions !== "object") return true; @@ -122,6 +131,32 @@ export function companySkillRoutes(db: Db) { throw forbidden("Missing permission: skills:create"); } + async function assertCanStartSkillTestRuns(req: Request, companyId: string) { + assertCompanyAccess(req, companyId); + + if (req.actor.type === "board") { + if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return; + const allowed = await access.canUser(companyId, req.actor.userId, "tasks:assign"); + if (!allowed) { + throw forbidden("Missing permission: tasks:assign"); + } + return; + } + + if (!req.actor.agentId) { + throw forbidden("Agent authentication required"); + } + + const actorAgent = await agents.getById(req.actor.agentId); + if (!actorAgent || actorAgent.companyId !== companyId) { + throw forbidden("Agent key cannot access another company"); + } + const allowedByGrant = await access.hasPermission(companyId, "agent", actorAgent.id, "tasks:assign"); + if (!allowedByGrant) { + throw forbidden("Missing permission: tasks:assign"); + } + } + router.get("/skills/catalog", async (req, res) => { assertAuthenticated(req); const query = catalogSkillListQuerySchema.parse({ @@ -157,6 +192,10 @@ export function companySkillRoutes(db: Db) { ...queryStringArray(req.query["categories[]"]), ], scope: firstQueryString(req.query.scope), + include: [ + ...queryStringArray(req.query.include), + ...queryStringArray(req.query["include[]"]), + ], })); res.json(result); }); @@ -211,6 +250,358 @@ export function companySkillRoutes(db: Db) { res.json(result); }); + router.get("/companies/:companyId/skills/:skillId/test-inputs", async (req, res) => { + const companyId = req.params.companyId as string; + const skillId = req.params.skillId as string; + assertCompanyAccess(req, companyId); + res.json(await svc.listTestInputs(companyId, skillId)); + }); + + router.post( + "/companies/:companyId/skills/:skillId/test-inputs", + validate(companySkillTestInputCreateSchema), + async (req, res) => { + const companyId = req.params.companyId as string; + const skillId = req.params.skillId as string; + await assertCanMutateCompanySkills(req, companyId); + const result = await svc.createTestInput(companyId, skillId, req.body, skillActor(req)); + const actor = getActorInfo(req); + await logActivity(db, { + companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "company.skill_test_input_created", + entityType: "company_skill_test_input", + entityId: result.id, + details: { skillId, name: result.name }, + }); + res.status(201).json(result); + }, + ); + + router.patch( + "/companies/:companyId/skills/:skillId/test-inputs/:inputId", + validate(companySkillTestInputUpdateSchema), + async (req, res) => { + const companyId = req.params.companyId as string; + const skillId = req.params.skillId as string; + const inputId = req.params.inputId as string; + await assertCanMutateCompanySkills(req, companyId); + const result = await svc.updateTestInput(companyId, skillId, inputId, req.body); + if (!result) { + res.status(404).json({ error: "Test input not found" }); + return; + } + const actor = getActorInfo(req); + await logActivity(db, { + companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "company.skill_test_input_updated", + entityType: "company_skill_test_input", + entityId: result.id, + details: { skillId, changedKeys: Object.keys(req.body).sort() }, + }); + res.json(result); + }, + ); + + router.delete("/companies/:companyId/skills/:skillId/test-inputs/:inputId", async (req, res) => { + const companyId = req.params.companyId as string; + const skillId = req.params.skillId as string; + const inputId = req.params.inputId as string; + await assertCanMutateCompanySkills(req, companyId); + const result = await svc.deleteTestInput(companyId, skillId, inputId); + if (!result) { + res.status(404).json({ error: "Test input not found" }); + return; + } + const actor = getActorInfo(req); + await logActivity(db, { + companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "company.skill_test_input_deleted", + entityType: "company_skill_test_input", + entityId: result.id, + details: { skillId, name: result.name }, + }); + res.json(result); + }); + + router.get("/companies/:companyId/skill-test-run-templates", async (req, res) => { + const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); + res.json(await svc.listTestRunTemplates(companyId)); + }); + + router.post( + "/companies/:companyId/skill-test-run-templates", + validate(companySkillTestRunTemplateCreateSchema), + async (req, res) => { + const companyId = req.params.companyId as string; + await assertCanMutateCompanySkills(req, companyId); + const result = await svc.createTestRunTemplate(companyId, req.body, skillActor(req)); + const actor = getActorInfo(req); + await logActivity(db, { + companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "company.skill_test_run_template_created", + entityType: "company_skill_test_run_template", + entityId: result.id, + details: { name: result.name }, + }); + res.status(201).json(result); + }, + ); + + router.patch( + "/companies/:companyId/skill-test-run-templates/:templateId", + validate(companySkillTestRunTemplateUpdateSchema), + async (req, res) => { + const companyId = req.params.companyId as string; + const templateId = req.params.templateId as string; + await assertCanMutateCompanySkills(req, companyId); + const result = await svc.updateTestRunTemplate(companyId, templateId, req.body, skillActor(req)); + if (!result) { + res.status(404).json({ error: "Test run template not found" }); + return; + } + const actor = getActorInfo(req); + await logActivity(db, { + companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "company.skill_test_run_template_updated", + entityType: "company_skill_test_run_template", + entityId: result.id, + details: { changedKeys: Object.keys(req.body).sort() }, + }); + res.json(result); + }, + ); + + router.delete("/companies/:companyId/skill-test-run-templates/:templateId", async (req, res) => { + const companyId = req.params.companyId as string; + const templateId = req.params.templateId as string; + await assertCanMutateCompanySkills(req, companyId); + const result = await svc.deleteTestRunTemplate(companyId, templateId); + if (!result) { + res.status(404).json({ error: "Test run template not found" }); + return; + } + const actor = getActorInfo(req); + await logActivity(db, { + companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "company.skill_test_run_template_deleted", + entityType: "company_skill_test_run_template", + entityId: result.id, + details: { name: result.name }, + }); + res.json(result); + }); + + router.get("/companies/:companyId/skills/:skillId/test-runs", async (req, res) => { + const companyId = req.params.companyId as string; + const skillId = req.params.skillId as string; + assertCompanyAccess(req, companyId); + const query = companySkillTestRunListQuerySchema.parse({ + inputId: firstQueryString(req.query.inputId), + }); + res.json(await svc.listTestRuns(companyId, skillId, query)); + }); + + router.get("/companies/:companyId/skills/:skillId/test-runs/:runId", async (req, res) => { + const companyId = req.params.companyId as string; + const skillId = req.params.skillId as string; + const runId = req.params.runId as string; + assertCompanyAccess(req, companyId); + const result = await svc.getTestRunDetail(companyId, skillId, runId); + if (!result) { + res.status(404).json({ error: "Test run not found" }); + return; + } + res.json(result); + }); + + router.post( + "/companies/:companyId/skills/:skillId/test-runs", + validate(companySkillTestRunCreateSchema), + async (req, res) => { + const companyId = req.params.companyId as string; + const skillId = req.params.skillId as string; + await assertCanStartSkillTestRuns(req, companyId); + const actor = getActorInfo(req); + const result = await svc.createTestRun(companyId, skillId, req.body, skillActor(req), { + createHarnessIssue: async (harnessIssue) => { + const created = await issues.create(companyId, { + ...harnessIssue, + priority: "medium", + createdByAgentId: actor.agentId, + createdByUserId: actor.actorType === "user" ? actor.actorId : null, + actorRunId: actor.runId, + }); + await logActivity(db, { + companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "issue.created", + entityType: "issue", + entityId: created.id, + details: { + title: created.title, + identifier: created.identifier, + harnessKind: "skill_test", + source: "company_skill_test_run", + skillId, + }, + }); + return { id: created.id }; + }, + wakeHarnessIssue: async (issueId, agentId) => heartbeat.wakeup(agentId, { + source: "assignment", + triggerDetail: "system", + reason: "skill_test_run_created", + payload: { issueId, skillId }, + requestedByActorType: actor.actorType, + requestedByActorId: actor.actorId, + contextSnapshot: { issueId, source: "company.skill_test_run" }, + }), + cleanupHarnessIssue: async (issueId) => { + const issue = await issues.getById(issueId); + if (!issue || issue.companyId !== companyId) return; + await issues.update(issueId, { + status: "cancelled", + hiddenAt: new Date(), + actorAgentId: actor.agentId ?? null, + actorUserId: actor.actorType === "user" ? actor.actorId : null, + }); + await logActivity(db, { + companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "company.skill_test_harness_issue_cleaned_up", + entityType: "issue", + entityId: issueId, + details: { skillId }, + }); + }, + }); + await logActivity(db, { + companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "company.skill_test_run_created", + entityType: "company_skill_test_run", + entityId: result.id, + details: { + skillId, + inputId: result.inputId, + skillVersionId: result.skillVersionId, + agentId: result.agentId, + issueId: result.issueId, + }, + }); + res.status(201).json(result); + }, + ); + + router.post("/companies/:companyId/skills/:skillId/test-runs/:runId/cancel", async (req, res) => { + const companyId = req.params.companyId as string; + const skillId = req.params.skillId as string; + const runId = req.params.runId as string; + await assertCanStartSkillTestRuns(req, companyId); + const actor = getActorInfo(req); + const result = await svc.cancelTestRun(companyId, skillId, runId, { + cancelHarnessIssue: async (issueId) => { + const issue = await issues.getById(issueId); + if (!issue || issue.companyId !== companyId) return; + if (issue.executionRunId) { + await heartbeat.cancelRun(issue.executionRunId, "Cancelled by skill test run request"); + } + if (issue.status !== "done" && issue.status !== "cancelled") { + await issues.update(issueId, { + status: "cancelled", + actorAgentId: actor.agentId ?? null, + actorUserId: actor.actorType === "user" ? actor.actorId : null, + }); + } + }, + }); + if (!result) { + res.status(404).json({ error: "Test run not found" }); + return; + } + await logActivity(db, { + companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "company.skill_test_run_cancelled", + entityType: "company_skill_test_run", + entityId: result.id, + details: { skillId, issueId: result.issueId }, + }); + res.json(result); + }); + + router.delete("/companies/:companyId/skills/:skillId/test-runs/:runId", async (req, res) => { + const companyId = req.params.companyId as string; + const skillId = req.params.skillId as string; + const runId = req.params.runId as string; + await assertCanStartSkillTestRuns(req, companyId); + const actor = getActorInfo(req); + const result = await svc.deleteTestRun(companyId, skillId, runId, { + hideHarnessIssue: async (issueId) => { + const issue = await issues.getById(issueId); + if (!issue || issue.companyId !== companyId) return; + await issues.update(issueId, { + hiddenAt: new Date(), + actorAgentId: actor.agentId ?? null, + actorUserId: actor.actorType === "user" ? actor.actorId : null, + }); + }, + }); + if (!result) { + res.status(404).json({ error: "Test run not found" }); + return; + } + await logActivity(db, { + companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "company.skill_test_run_deleted", + entityType: "company_skill_test_run", + entityId: result.id, + details: { skillId, issueId: result.issueId }, + }); + res.json(result); + }); + router.post( "/companies/:companyId/skills/:skillId/versions", validate(companySkillVersionCreateSchema), @@ -503,6 +894,36 @@ export function companySkillRoutes(db: Db) { }, ); + router.delete( + "/companies/:companyId/skills/:skillId/files", + validate(companySkillFileDeleteSchema), + async (req, res) => { + const companyId = req.params.companyId as string; + const skillId = req.params.skillId as string; + await assertCanMutateCompanySkills(req, companyId); + const result = await svc.deleteFile(companyId, skillId, req.body, skillActor(req)); + + const actor = getActorInfo(req); + await logActivity(db, { + companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "company.skill_file_deleted", + entityType: "company_skill", + entityId: skillId, + details: { + path: result.path, + target: result.target, + deletedPaths: result.deletedPaths, + }, + }); + + res.json(result); + }, + ); + router.post( "/companies/:companyId/skills/import", validate(companySkillImportSchema), diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 4954b16142..a9ba840c58 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -92,6 +92,7 @@ import * as serviceIndex from "../services/index.js"; import { accessService, agentService, + companySkillService, companyService, companySearchService, executionWorkspaceService, @@ -2051,6 +2052,7 @@ export function issueRoutes( const executionWorkspacesSvc = executionWorkspaceServiceDirect(db); const workProductsSvc = workProductService(db); const documentsSvc = documentService(db); + const companySkillsSvc = companySkillService(db); const documentAnnotationsSvc = documentAnnotationService(db); const issueReferencesSvc = issueReferenceService(db); const issueThreadInteractionsSvc = issueThreadInteractionService(db); @@ -2805,6 +2807,10 @@ export function issueRoutes( return req.actor.type === "agent" && req.actor.source === "agent_key" && req.actor.keyScope?.kind === "task_bridge"; } + function isSkillTestScopedActor(req: Request) { + return req.actor.type === "agent" && req.actor.keyScope?.kind === "skill_test"; + } + function taskBridgeOriginForActor(req: Request) { return isTaskBridgeKeyActor(req) && req.actor.keyId ? { originKind: "task_bridge", originId: req.actor.keyId } @@ -4814,6 +4820,7 @@ export function issueRoutes( return; } assertCompanyAccess(req, issue.companyId); + if (!(await assertIssueReadAllowed(req, res, issue))) return; const active = await revalidateActiveSourceRecoveryForRead({ issue, trigger: "read_projection", @@ -5015,6 +5022,7 @@ export function issueRoutes( return; } assertCompanyAccess(req, issue.companyId); + if (!(await assertIssueReadAllowed(req, res, issue))) return; const objects = await externalObjectsSvc.listForIssue(issue.id); res.json(objects); }); @@ -5027,6 +5035,7 @@ export function issueRoutes( return; } assertCompanyAccess(req, issue.companyId); + if (!(await assertIssueReadAllowed(req, res, issue))) return; const summary = await externalObjectsSvc.getIssueSummary(issue.id); res.json(summary); }); @@ -5034,7 +5043,23 @@ export function issueRoutes( router.post("/companies/:companyId/issues/external-object-summaries", validate(externalObjectSummariesSchema), async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); - const summaries = await externalObjectsSvc.getIssueSummaries(companyId, req.body.issueIds); + const requestedIssueIds = [...new Set(req.body.issueIds as string[])]; + const candidateIssues = requestedIssueIds.length > 0 + ? await db + .select({ + id: issueRows.id, + companyId: issueRows.companyId, + projectId: issueRows.projectId, + parentId: issueRows.parentId, + assigneeAgentId: issueRows.assigneeAgentId, + assigneeUserId: issueRows.assigneeUserId, + status: issueRows.status, + }) + .from(issueRows) + .where(and(eq(issueRows.companyId, companyId), inArray(issueRows.id, requestedIssueIds))) + : []; + const readableIssueIds = (await filterIssuesForActor(req, candidateIssues)).map((issue) => issue.id); + const summaries = await externalObjectsSvc.getIssueSummaries(companyId, readableIssueIds); res.json({ summaries: Object.fromEntries(summaries) }); }); @@ -5123,6 +5148,7 @@ export function issueRoutes( return; } assertCompanyAccess(req, issue.companyId); + if (!(await assertIssueReadAllowed(req, res, issue))) return; const keyParsed = issueDocumentKeySchema.safeParse(String(req.params.key ?? "").trim().toLowerCase()); if (!keyParsed.success) { res.status(400).json({ error: "Invalid document key", details: keyParsed.error.issues }); @@ -5199,6 +5225,7 @@ export function issueRoutes( return; } assertCompanyAccess(req, issue.companyId); + if (!(await assertIssueReadAllowed(req, res, issue))) return; const keyParsed = issueDocumentKeySchema.safeParse(String(req.params.key ?? "").trim().toLowerCase()); if (!keyParsed.success) { res.status(400).json({ error: "Invalid document key", details: keyParsed.error.issues }); @@ -5549,6 +5576,7 @@ export function issueRoutes( return; } assertCompanyAccess(req, issue.companyId); + if (!(await assertIssueReadAllowed(req, res, issue))) return; const keyParsed = issueDocumentKeySchema.safeParse(String(req.params.key ?? "").trim().toLowerCase()); if (!keyParsed.success) { res.status(400).json({ error: "Invalid document key", details: keyParsed.error.issues }); @@ -6187,6 +6215,7 @@ export function issueRoutes( } assertCompanyAccess(req, issue.companyId); if (await assertLowTrustControlPlaneDenied(req, res, issue.companyId, issue)) return; + if (!(await assertIssueReadAllowed(req, res, issue))) return; const approvals = await issueApprovalsSvc.listApprovalsForIssue(id); res.json(approvals); }); @@ -6259,6 +6288,16 @@ export function issueRoutes( router.post("/companies/:companyId/issues", applyCreateIssueStatusDefault, validate(createIssueSchema), async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); + if (isSkillTestScopedActor(req)) { + res.status(403).json({ + error: "Skill-test run tokens cannot create issues.", + details: { + scopedIssueId: req.actor.keyScope?.kind === "skill_test" ? req.actor.keyScope.issueId : null, + securityPrinciples: ["Least Privilege", "Complete Mediation", "Fail Securely"], + }, + }); + return; + } if (await assertLowTrustControlPlaneDenied(req, res, companyId, null)) return; assertNoAgentHostWorkspaceCommandMutation(req, collectIssueWorkspaceCommandPaths(req.body)); const sanitizedBody = await sanitizeIssueCreateAttribution(db, req, res, companyId, req.body, { @@ -7629,6 +7668,36 @@ export function issueRoutes( } } + if ( + issue.harnessKind === "skill_test" && + existing.status !== issue.status && + (issue.status === "done" || issue.status === "cancelled") + ) { + const completedRun = await companySkillsSvc.completeTestRunForIssue({ + companyId: issue.companyId, + issueId: issue.id, + outcome: issue.status === "done" ? "succeeded" : "cancelled", + error: issue.status === "cancelled" ? "Harness issue was cancelled" : null, + }); + if (completedRun) { + await logActivity(db, { + companyId: issue.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "company.skill_test_run_completed", + entityType: "company_skill_test_run", + entityId: completedRun.id, + details: { + issueId: issue.id, + status: completedRun.status, + outputDocumentKey: completedRun.outputDocumentKey, + }, + }); + } + } + let comment = null; if (commentBody) { const commentReferenceSummaryBefore = updateReferenceSummaryAfter @@ -8118,6 +8187,9 @@ export function issueRoutes( if (req.actor.type === "agent" && !checkoutRunId) return; const updated = await svc.checkout(id, req.body.agentId, req.body.expectedStatuses, checkoutRunId); const actor = getActorInfo(req); + if (updated?.harnessKind === "skill_test") { + await companySkillsSvc.markTestRunRunning(updated.companyId, updated.id); + } await logActivity(db, { companyId: issue.companyId, @@ -9591,6 +9663,7 @@ export function issueRoutes( return; } assertCompanyAccess(req, issue.companyId); + if (!(await assertIssueReadAllowed(req, res, issue))) return; const attachments = await svc.listAttachments(issueId); res.json(attachments.map(withContentPath)); }); @@ -9695,6 +9768,12 @@ export function issueRoutes( return; } assertCompanyAccess(req, attachment.companyId); + const issue = await svc.getById(attachment.issueId); + if (!issue) { + res.status(404).json({ error: "Issue not found" }); + return; + } + if (!(await assertIssueReadAllowed(req, res, issue))) return; const contentLength = attachment.byteSize; const range = parseAttachmentRangeHeader( diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index b37ee0508f..8c34c562ed 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -89,9 +89,16 @@ import { startEnvironmentCustomImageSetupSessionSchema, // Company skills companySkillCreateSchema, + companySkillFileDeleteSchema, companySkillFileUpdateSchema, companySkillImportSchema, companySkillProjectScanRequestSchema, + companySkillTestInputCreateSchema, + companySkillTestInputUpdateSchema, + companySkillTestRunCreateSchema, + companySkillTestRunListQuerySchema, + companySkillTestRunTemplateCreateSchema, + companySkillTestRunTemplateUpdateSchema, // Issue tree createIssueTreeHoldSchema, previewIssueTreeControlSchema, @@ -3522,6 +3529,153 @@ registry.registerPath({ responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, }); +registry.registerPath({ + method: "delete", + path: "/api/companies/{companyId}/skills/{skillId}/files", + tags: ["skills"], + summary: "Delete a skill file or folder", + request: { + params: z.object({ companyId: z.string(), skillId: z.string() }), + body: jsonBody(companySkillFileDeleteSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}/skills/{skillId}/test-inputs", + tags: ["skills"], + summary: "List skill test inputs", + request: { params: z.object({ companyId: z.string(), skillId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/companies/{companyId}/skills/{skillId}/test-inputs", + tags: ["skills"], + summary: "Create a skill test input", + request: { + params: z.object({ companyId: z.string(), skillId: z.string() }), + body: jsonBody(companySkillTestInputCreateSchema), + }, + responses: { 201: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "patch", + path: "/api/companies/{companyId}/skills/{skillId}/test-inputs/{inputId}", + tags: ["skills"], + summary: "Update a skill test input", + request: { + params: z.object({ companyId: z.string(), skillId: z.string(), inputId: z.string() }), + body: jsonBody(companySkillTestInputUpdateSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "delete", + path: "/api/companies/{companyId}/skills/{skillId}/test-inputs/{inputId}", + tags: ["skills"], + summary: "Delete a skill test input", + request: { params: z.object({ companyId: z.string(), skillId: z.string(), inputId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}/skill-test-run-templates", + tags: ["skills"], + summary: "List skill test-run templates", + request: { params: z.object({ companyId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/companies/{companyId}/skill-test-run-templates", + tags: ["skills"], + summary: "Create a skill test-run template", + request: { + params: z.object({ companyId: z.string() }), + body: jsonBody(companySkillTestRunTemplateCreateSchema), + }, + responses: { 201: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "patch", + path: "/api/companies/{companyId}/skill-test-run-templates/{templateId}", + tags: ["skills"], + summary: "Update a skill test-run template", + request: { + params: z.object({ companyId: z.string(), templateId: z.string() }), + body: jsonBody(companySkillTestRunTemplateUpdateSchema), + }, + responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "delete", + path: "/api/companies/{companyId}/skill-test-run-templates/{templateId}", + tags: ["skills"], + summary: "Delete a skill test-run template", + request: { params: z.object({ companyId: z.string(), templateId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}/skills/{skillId}/test-runs", + tags: ["skills"], + summary: "List skill test runs", + request: { + params: z.object({ companyId: z.string(), skillId: z.string() }), + query: companySkillTestRunListQuerySchema, + }, + responses: { 200: r.ok(), 401: r.unauthorized, 422: r.unprocessable }, +}); + +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}/skills/{skillId}/test-runs/{runId}", + tags: ["skills"], + summary: "Get a skill test run", + request: { params: z.object({ companyId: z.string(), skillId: z.string(), runId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "post", + path: "/api/companies/{companyId}/skills/{skillId}/test-runs", + tags: ["skills"], + summary: "Create a skill test run", + request: { + params: z.object({ companyId: z.string(), skillId: z.string() }), + body: jsonBody(companySkillTestRunCreateSchema), + }, + responses: { 201: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registry.registerPath({ + method: "post", + path: "/api/companies/{companyId}/skills/{skillId}/test-runs/{runId}/cancel", + tags: ["skills"], + summary: "Cancel a skill test run", + request: { params: z.object({ companyId: z.string(), skillId: z.string(), runId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound }, +}); + +registry.registerPath({ + method: "delete", + path: "/api/companies/{companyId}/skills/{skillId}/test-runs/{runId}", + tags: ["skills"], + summary: "Delete a skill test run", + request: { params: z.object({ companyId: z.string(), skillId: z.string(), runId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound }, +}); + registry.registerPath({ method: "post", path: "/api/companies/{companyId}/skills/import", diff --git a/server/src/routes/user-profiles.ts b/server/src/routes/user-profiles.ts index 7b50e94a36..11d99273d9 100644 --- a/server/src/routes/user-profiles.ts +++ b/server/src/routes/user-profiles.ts @@ -17,6 +17,7 @@ import type { UserProfileWindowStats, } from "@paperclipai/shared"; import { notFound } from "../errors.js"; +import { visibleIssueCondition } from "../services/issue-visibility.js"; import { assertCompanyAccess } from "./authz.js"; type CompanyUserRow = { @@ -147,7 +148,7 @@ async function loadWindowStats( assignedOpenIssues: sql`count(distinct case when ${issues.assigneeUserId} = ${userId} and ${issues.status} in (${sql.join(openStatuses.map((status) => sql`${status}`), sql`, `)}) then ${issues.id} end)::int`, }) .from(issues) - .where(and(eq(issues.companyId, companyId), isNull(issues.hiddenAt))); + .where(and(eq(issues.companyId, companyId), visibleIssueCondition())); const commentConditions = [ eq(issueComments.companyId, companyId), @@ -252,7 +253,7 @@ async function loadDailyStats(db: Db, companyId: string, userId: string): Promis .where( and( eq(issues.companyId, companyId), - isNull(issues.hiddenAt), + visibleIssueCondition(), eq(issues.status, "done"), gte(issues.completedAt, firstDay), userIssueInvolvementSql(companyId, userId), @@ -333,7 +334,7 @@ export function userProfileRoutes(db: Db) { .where( and( eq(issues.companyId, companyId), - isNull(issues.hiddenAt), + visibleIssueCondition(), userIssueInvolvementSql(companyId, userId), ), ) diff --git a/server/src/services/activity.ts b/server/src/services/activity.ts index b7f80dca0d..a9c6f592dd 100644 --- a/server/src/services/activity.ts +++ b/server/src/services/activity.ts @@ -16,6 +16,7 @@ import { } from "@paperclipai/db"; import { ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY } from "@paperclipai/shared"; import { logger } from "../middleware/logger.js"; +import { visibleIssueCondition } from "./issue-visibility.js"; import { classifyRunLiveness } from "./run-liveness.js"; export interface ActivityFilters { @@ -354,7 +355,7 @@ export function activityService(db: Db) { ...conditions, or( sql`${activityLog.entityType} != 'issue'`, - isNull(issues.hiddenAt), + visibleIssueCondition(), ), ), ) @@ -543,7 +544,7 @@ export function activityService(db: Db) { eq(activityLog.companyId, run.companyId), eq(activityLog.runId, runId), eq(activityLog.entityType, "issue"), - isNull(issues.hiddenAt), + visibleIssueCondition(), ), ) .orderBy(issueIdAsText); @@ -569,7 +570,7 @@ export function activityService(db: Db) { and( eq(issues.companyId, run.companyId), eq(issues.id, contextIssueId), - isNull(issues.hiddenAt), + visibleIssueCondition(), ), ) .then((rows) => rows[0] ?? null); diff --git a/server/src/services/adapter-registry-bootstrap.reconcile.test.ts b/server/src/services/adapter-registry-bootstrap.reconcile.test.ts index fb406926d0..1e32e3575f 100644 --- a/server/src/services/adapter-registry-bootstrap.reconcile.test.ts +++ b/server/src/services/adapter-registry-bootstrap.reconcile.test.ts @@ -12,6 +12,7 @@ vi.mock("../adapters/registry.js", async (orig) => ({ })); vi.mock("./adapter-plugin-store.js", () => ({ + listAdapterPlugins: () => [], setAdapterDisabled: (type: string, disabled: boolean) => setAdapterDisabled(type, disabled), })); diff --git a/server/src/services/authorization.ts b/server/src/services/authorization.ts index dccd0ea6c1..247b3d5cdc 100644 --- a/server/src/services/authorization.ts +++ b/server/src/services/authorization.ts @@ -11,7 +11,13 @@ import { principalPermissionGrants, projects, } from "@paperclipai/db"; -import type { AgentApiKeyScope, PermissionKey, PrincipalType, TaskBridgeAgentKeyScope } from "@paperclipai/shared"; +import type { + AgentApiKeyScope, + PermissionKey, + PrincipalType, + SkillTestAgentKeyScope, + TaskBridgeAgentKeyScope, +} from "@paperclipai/shared"; import { LOW_TRUST_REVIEW_PRESET, extractAgentMentionIds, type LowTrustBoundary } from "@paperclipai/shared"; import { LOW_TRUST_ISSUE_ANCESTRY_MAX_DEPTH, @@ -1070,6 +1076,48 @@ export function authorizationService(db: Db) { return denyBridge("Task bridge key cannot use this API action."); } + function decideSkillTestAccess(input: { + action: AuthorizationAction; + resource: AuthorizationResource; + scope: SkillTestAgentKeyScope; + }): AuthorizationDecision | null { + const denySkillTest = (explanation: string) => + deny({ + action: input.action, + reason: "deny_scope", + explanation, + }); + const allowSkillTest = (explanation: string) => + allow({ + action: input.action, + reason: "allow_explicit_grant", + explanation, + }); + + if ( + input.action === "company_scope:read" || + input.action === "agent:read" || + input.action === "agent:wake" || + input.action === "project:read" || + input.action === "runtime:manage" || + input.action === "secrets:read" || + input.action === "tasks:assign" + ) { + return denySkillTest("Skill-test run tokens cannot use company-wide, peer-agent, project, runtime, secret, or task-create APIs."); + } + + if (input.action === "issue:read" || input.action === "issue:comment" || input.action === "issue:mutate") { + if (input.resource.type !== "issue") { + return denySkillTest("Skill-test issue access requires an issue resource."); + } + return input.resource.issueId === input.scope.issueId + ? allowSkillTest("Allowed for the scoped skill-test issue.") + : denySkillTest("Skill-test run token can only access its own harness issue."); + } + + return denySkillTest("Skill-test run token cannot use this API action."); + } + async function assignmentTargetIsInCompany(resource: AuthorizationResource) { if (resource.type !== "issue") return true; if (resource.assigneeAgentId) { @@ -1452,6 +1500,15 @@ export function authorizationService(db: Db) { }); } + if (input.actor.keyScope?.kind === "skill_test") { + const skillTestDecision = decideSkillTestAccess({ + action: input.action, + resource: input.resource, + scope: input.actor.keyScope, + }); + if (skillTestDecision) return skillTestDecision; + } + if (input.actor.source === "agent_key" && input.actor.keyScope?.kind === "task_bridge") { const keyId = input.actor.keyId ?? null; if (!keyId) { diff --git a/server/src/services/company-search.ts b/server/src/services/company-search.ts index 6c2d9479e9..37401c550f 100644 --- a/server/src/services/company-search.ts +++ b/server/src/services/company-search.ts @@ -19,6 +19,7 @@ import { type CompanySearchSnippet, } from "@paperclipai/shared"; import { companyArtifactsService } from "./company-artifacts.js"; +import { visibleIssueCondition } from "./issue-visibility.js"; const MIN_TOKEN_LENGTH = 2; const MIN_FUZZY_QUERY_LENGTH = 4; @@ -649,7 +650,7 @@ export function companySearchService(db: Db) { .from(issues) .where(and( eq(issues.companyId, companyId), - isNull(issues.hiddenAt), + visibleIssueCondition(), issueSearchCondition(scope, { issueTextMatch, commentMatch, documentMatch, fuzzyMatch }), )) .orderBy(desc(score), desc(issues.updatedAt), desc(issues.id)) diff --git a/server/src/services/company-skills.ts b/server/src/services/company-skills.ts index 6d23e80861..4cdfb7da81 100644 --- a/server/src/services/company-skills.ts +++ b/server/src/services/company-skills.ts @@ -2,9 +2,28 @@ import { createHash, randomUUID } from "node:crypto"; import { promises as fs } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { and, asc, desc, eq, isNull, sql } from "drizzle-orm"; +import { and, asc, desc, eq, inArray, isNull, lt, sql } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; -import { agents as agentsTable, companies, companySkillComments, companySkillStars, companySkillVersions, companySkills } from "@paperclipai/db"; +import { + agents as agentsTable, + assets, + authUsers, + companies, + companySkillComments, + companySkillStars, + companySkillTestInputs, + companySkillTestRunTemplates, + companySkillTestRuns, + companySkillVersions, + companySkills, + costEvents, + documents, + issueAttachments, + issueDocuments, + issues, + issueThreadInteractions, + issueWorkProducts, +} from "@paperclipai/db"; import { readPaperclipSkillSyncPreference, writePaperclipSkillSyncPreference } from "@paperclipai/adapter-utils/server-utils"; import type { PaperclipDesiredSkillEntry, PaperclipSkillEntry } from "@paperclipai/adapter-utils/server-utils"; import type { @@ -21,6 +40,8 @@ import type { CompanySkillCreateRequest, CompanySkillCompatibility, CompanySkillDetail, + CompanySkillFileDeleteRequest, + CompanySkillFileDeleteResult, CompanySkillFileDetail, CompanySkillFileInventoryEntry, CompanySkillForkPrecheckResult, @@ -33,6 +54,7 @@ import type { CompanySkillInstallCatalogResult, CompanySkillListQuery, CompanySkillListItem, + CompanySkillLastEditor, CompanySkillOriginalSummary, CompanySkillProjectScanConflict, CompanySkillProjectScanRequest, @@ -41,6 +63,19 @@ import type { CompanySkillSharingScope, CompanySkillSourceBadge, CompanySkillSourceType, + CompanySkillTestInput, + CompanySkillTestInputCreateRequest, + CompanySkillTestInputUpdateRequest, + CompanySkillTestRun, + CompanySkillTestRunCreateRequest, + CompanySkillTestRunDetail, + CompanySkillTestRunHarnessContent, + CompanySkillTestRunListQuery, + CompanySkillTestRunTemplate, + CompanySkillTestRunTemplateCreateRequest, + CompanySkillTestRunTemplateSnapshot, + CompanySkillTestRunTemplateUpdateRequest, + CompanySkillTestRunStatus, CompanySkillTrustLevel, CompanySkillUpdateRequest, CompanySkillUpdateStatus, @@ -49,12 +84,16 @@ import type { CompanySkillVersion, CompanySkillVersionCreateRequest, CompanySkillVersionFileInventoryEntry, + IssueAttachment, + IssueDocument, } from "@paperclipai/shared"; -import { normalizeAgentUrlKey, parseFrontmatterMarkdown } from "@paperclipai/shared"; +import { isUuidLike, normalizeAgentUrlKey, parseFrontmatterMarkdown } from "@paperclipai/shared"; import { resolvePaperclipInstanceRoot } from "../home-paths.js"; import { conflict, notFound, unprocessable } from "../errors.js"; import { ghFetch, gitHubApiBase, resolveRawGitHubUrl } from "./github-fetch.js"; import { agentService } from "./agents.js"; +import { issueDocumentSelect, mapIssueDocumentRow } from "./documents.js"; +import { toIssueWorkProduct } from "./work-products.js"; import { projectService } from "./projects.js"; import { normalizePortablePath } from "./portable-path.js"; import { @@ -72,6 +111,9 @@ import { type CompanySkillRow = typeof companySkills.$inferSelect; type CompanySkillVersionRow = typeof companySkillVersions.$inferSelect; type CompanySkillCommentRow = typeof companySkillComments.$inferSelect; +type CompanySkillTestInputRow = typeof companySkillTestInputs.$inferSelect; +type CompanySkillTestRunTemplateRow = typeof companySkillTestRunTemplates.$inferSelect; +type CompanySkillTestRunRow = typeof companySkillTestRuns.$inferSelect; type CompanySkillListDbRow = Pick< CompanySkillRow, | "id" @@ -167,6 +209,33 @@ type ImportedSkill = { metadata: Record | null; }; +type ImportedSkillPersistValues = Pick< + CompanySkill, + | "companyId" + | "key" + | "slug" + | "name" + | "description" + | "markdown" + | "sourceType" + | "sourceLocator" + | "sourceRef" + | "trustLevel" + | "compatibility" + | "iconUrl" + | "color" + | "tagline" + | "authorName" + | "homepageUrl" + | "categories" + | "sharingScope" + | "installCount" +> & { + fileInventory: Array>; + metadata: Record; + updatedAt: Date; +}; + type PackageSkillConflictStrategy = "replace" | "rename" | "skip"; export type ImportPackageSkillResult = { @@ -971,6 +1040,86 @@ function inventoryEntriesEqual( }); } +function stableJsonComparable(value: unknown): unknown { + if (value === undefined) return undefined; + if (Array.isArray(value)) { + return value.map((entry) => stableJsonComparable(entry) ?? null); + } + if (isPlainRecord(value)) { + const out: Record = {}; + for (const key of Object.keys(value).sort()) { + const normalized = stableJsonComparable(value[key]); + if (normalized !== undefined) out[key] = normalized ?? null; + } + return out; + } + return value; +} + +function stableJsonEqual(left: unknown, right: unknown) { + return JSON.stringify(stableJsonComparable(left)) === JSON.stringify(stableJsonComparable(right)); +} + +function isPaperclipBundledSkillKey(key: string) { + return key.startsWith("paperclipai/paperclip/"); +} + +function stripDerivedPaperclipBundledMetadata(key: string, metadata: unknown): unknown { + if (metadata === null || metadata === undefined) return {}; + const comparable = stableJsonComparable(metadata); + if (!isPlainRecord(comparable)) return comparable; + + const out = { ...comparable }; + if (out.skillKey === key) delete out.skillKey; + if (out.sourceKind === "paperclip_bundled") delete out.sourceKind; + delete out.missingSource; + return out; +} + +function importedSkillMetadataEqual(existing: CompanySkill, values: ImportedSkillPersistValues) { + const incomingMetadata = isPlainRecord(values.metadata) ? values.metadata : null; + if (isPaperclipBundledSkillKey(values.key) && asString(incomingMetadata?.sourceKind) === "paperclip_bundled") { + return JSON.stringify(stripDerivedPaperclipBundledMetadata(existing.key, existing.metadata)) + === JSON.stringify(stripDerivedPaperclipBundledMetadata(values.key, values.metadata)); + } + return stableJsonEqual(existing.metadata ?? null, values.metadata); +} + +function stringArraysEqual(left: string[], right: string[]) { + if (left.length !== right.length) return false; + return left.every((entry, index) => entry === right[index]); +} + +function importedSkillPersistValuesMatchExisting( + existing: CompanySkill, + values: ImportedSkillPersistValues, +) { + return existing.companyId === values.companyId + && existing.key === values.key + && existing.slug === values.slug + && existing.name === values.name + && existing.description === values.description + && existing.markdown === values.markdown + && existing.sourceType === values.sourceType + && existing.sourceLocator === values.sourceLocator + && existing.sourceRef === values.sourceRef + && existing.trustLevel === values.trustLevel + && existing.compatibility === values.compatibility + && inventoryEntriesEqual( + existing.fileInventory, + normalizeFileInventory({ fileInventory: values.fileInventory }), + ) + && existing.iconUrl === values.iconUrl + && existing.color === values.color + && existing.tagline === values.tagline + && existing.authorName === values.authorName + && existing.homepageUrl === values.homepageUrl + && stringArraysEqual(existing.categories, normalizeCategoryList(values.categories)) + && existing.sharingScope === values.sharingScope + && existing.installCount === values.installCount + && importedSkillMetadataEqual(existing, values); +} + function inferLocalSkillInventoryMode( skill: Pick, ): LocalSkillInventoryMode { @@ -1361,14 +1510,29 @@ function normalizeMutableSharingScope(value: unknown): CompanySkillSharingScope throw unprocessable("Invalid skill sharing scope."); } -function normalizeCategorySlug(value: unknown) { +function normalizeCategoryName(value: unknown) { if (typeof value !== "string") return null; - return normalizeSkillSlug(value); + const normalized = value.trim().replace(/\s+/g, " "); + return normalized.length > 0 ? normalized : null; +} + +function categoryLookupKey(value: string) { + return value.toLocaleLowerCase(); } function normalizeCategoryList(values: unknown): string[] { if (!Array.isArray(values)) return []; - return Array.from(new Set(values.map(normalizeCategorySlug).filter((value): value is string => Boolean(value)))); + const seen = new Set(); + const categories: string[] = []; + for (const value of values) { + const category = normalizeCategoryName(value); + if (!category) continue; + const key = categoryLookupKey(category); + if (seen.has(key)) continue; + seen.add(key); + categories.push(category); + } + return categories; } function normalizeStoreText(value: unknown, maxLength = 500) { @@ -1447,6 +1611,147 @@ function toCompanySkillComment(row: CompanySkillCommentRow): CompanySkillComment }; } +function toCompanySkillTestInput(row: CompanySkillTestInputRow): CompanySkillTestInput { + return { + ...row, + deletedAt: row.deletedAt ?? null, + }; +} + +const BUILT_IN_SKILL_TEST_RUN_TEMPLATE_ID = "built-in:default-test-template"; +const BUILT_IN_SKILL_TEST_RUN_TEMPLATE_DATE = new Date("2026-01-01T00:00:00.000Z"); +const BUILT_IN_SKILL_TEST_RUN_TEMPLATE_BODY = [ + "You are running a Skills Studio test for `{{skillName}}` (`{{skillKey}}`), skill version v{{skillVersion}}.", + "", + "Invoke and use the selected skill under test: `{{skillInvocation}}`. Use the pinned skill revision supplied by Paperclip as the source of truth, regardless of any other runtime skills.", + "", + "This is a test run. Do not make durable changes outside this test task. Do not mutate unrelated issues, push, publish, send external messages, or affect real work.", + "", + "If the skill would create documents, images, videos, files, or other assets, create test versions in an obviously test-scoped location when applicable, then post the results back to this task as issue documents, attachments, or work products.", + "", + "Write the final result to issue document `{{outputDocumentKey}}`, then mark this test task done.", +].join("\n"); + +function builtInSkillTestRunTemplate(companyId: string): CompanySkillTestRunTemplate { + return { + id: BUILT_IN_SKILL_TEST_RUN_TEMPLATE_ID, + companyId, + name: "Default test template", + description: "Paperclip's read-only default harness instructions for Skills Studio runs.", + body: BUILT_IN_SKILL_TEST_RUN_TEMPLATE_BODY, + builtIn: true, + createdByAgentId: null, + createdByUserId: null, + updatedByAgentId: null, + updatedByUserId: null, + deletedAt: null, + createdAt: BUILT_IN_SKILL_TEST_RUN_TEMPLATE_DATE, + updatedAt: BUILT_IN_SKILL_TEST_RUN_TEMPLATE_DATE, + }; +} + +function toCompanySkillTestRunTemplate(row: CompanySkillTestRunTemplateRow): CompanySkillTestRunTemplate { + return { + ...row, + description: row.description ?? null, + builtIn: false, + createdByAgentId: row.createdByAgentId ?? null, + createdByUserId: row.createdByUserId ?? null, + updatedByAgentId: row.updatedByAgentId ?? null, + updatedByUserId: row.updatedByUserId ?? null, + deletedAt: row.deletedAt ?? null, + }; +} + +const ALLOWED_SKILL_TEST_TEMPLATE_PLACEHOLDERS = new Set([ + "skillName", + "skillKey", + "skillInvocation", + "skillVersion", + "runId", + "issueId", + "outputDocumentKey", +]); + +function validateSkillTestTemplatePlaceholders(body: string) { + const unknown = new Set(); + const recognized = /\{\{\s*([A-Za-z][A-Za-z0-9]*)\s*\}\}/g; + for (const match of body.matchAll(recognized)) { + const key = match[1] ?? ""; + if (!ALLOWED_SKILL_TEST_TEMPLATE_PLACEHOLDERS.has(key)) { + unknown.add(key); + } + } + if (body.replace(recognized, "").includes("{{") || body.replace(recognized, "").includes("}}")) { + throw unprocessable("Malformed template placeholder. Use explicit placeholders like {{skillName}}."); + } + if (unknown.size > 0) { + throw unprocessable(`Unknown template placeholder${unknown.size === 1 ? "" : "s"}: ${Array.from(unknown).sort().join(", ")}`); + } +} + +function renderSkillTestTemplate(body: string, values: Record) { + validateSkillTestTemplatePlaceholders(body); + return body.replace(/\{\{\s*([A-Za-z][A-Za-z0-9]*)\s*\}\}/g, (_match, rawKey: string) => values[rawKey] ?? ""); +} + +function buildHarnessIssueDescription(inputSnapshot: string, renderedTemplateBody: string | null) { + const trimmedInput = inputSnapshot.trim(); + const trimmedTemplate = renderedTemplateBody?.trim() ?? ""; + return trimmedTemplate ? `${trimmedInput}\n\n---\n\n${trimmedTemplate}` : trimmedInput; +} + +function normalizeTestRunStatus(value: string): CompanySkillTestRunStatus { + return value === "running" || value === "succeeded" || value === "failed" || value === "cancelled" + ? value + : "queued"; +} + +function emptyTestRunCost() { + return { + costCents: 0, + inputTokens: 0, + cachedInputTokens: 0, + outputTokens: 0, + }; +} + +function toCompanySkillTestRun( + row: CompanySkillTestRunRow, + cost = emptyTestRunCost(), + taskExpired = false, +): CompanySkillTestRun { + return { + ...row, + inputId: row.inputId ?? null, + agentConfigSnapshot: isPlainRecord(row.agentConfigSnapshot) ? row.agentConfigSnapshot : {}, + templateId: row.templateId ?? null, + templateName: row.templateName ?? null, + templateBody: row.templateBody ?? null, + renderedTemplateBody: row.renderedTemplateBody ?? null, + harnessIssueDescription: row.harnessIssueDescription || row.inputSnapshot, + status: normalizeTestRunStatus(row.status), + outputDocumentKey: row.outputDocumentKey || "output", + outputSnapshot: row.outputSnapshot ?? "", + error: row.error ?? null, + deletedAt: row.deletedAt ?? null, + supersededAt: row.supersededAt ?? null, + harnessIssueExpiresAt: row.harnessIssueExpiresAt ?? null, + harnessIssueDeletedAt: row.harnessIssueDeletedAt ?? null, + cost, + taskExpired, + }; +} + +function versionInventorySnapshotEqual( + left: CompanySkillVersionFileInventoryEntry[], + right: CompanySkillVersionFileInventoryEntry[], +) { + const normalize = (entries: CompanySkillVersionFileInventoryEntry[]) => + JSON.stringify([...entries].sort((a, b) => a.path.localeCompare(b.path))); + return normalize(left) === normalize(right); +} + function getSkillMeta(skill: Pick): SkillSourceMeta { return isPlainRecord(skill.metadata) ? skill.metadata as SkillSourceMeta : {}; } @@ -1606,15 +1911,30 @@ async function assertVersionMatchesSkill( } } +export interface ResolvedRequestedSkillEntries { + /** References that resolved to a company-library skill. */ + resolved: PaperclipDesiredSkillEntry[]; + /** + * References that could not be resolved to a company-library skill, returned + * in first-seen order. Only populated when `tolerateUnknownReferences` is set; + * otherwise unknown references throw. Callers preserve these so stale desired + * keys stay visible (and removable) instead of silently 422-ing a whole save. + */ + unresolved: string[]; +} + async function resolveRequestedSkillEntriesOrThrow( db: Db, companyId: string, skills: CompanySkill[], requestedSelections: Array, -) { + options: { tolerateUnknownReferences?: boolean } = {}, +): Promise { const missing = new Set(); const ambiguous = new Set(); const resolved = new Map(); + const unresolved: string[] = []; + const seenUnresolved = new Set(); for (const rawSelection of requestedSelections) { const selection = normalizeRequestedDesiredSkillSelection(rawSelection); @@ -1640,9 +1960,20 @@ async function resolveRequestedSkillEntriesOrThrow( continue; } + // Unknown / stale reference (no longer in the company library). + if (options.tolerateUnknownReferences) { + if (!seenUnresolved.has(selection.key)) { + seenUnresolved.add(selection.key); + unresolved.push(selection.key); + } + continue; + } missing.add(selection.key); } + // Ambiguous references are always a hard error — they signal a genuine + // conflict the caller must disambiguate. Unknown references are only fatal + // when the caller has not opted into tolerating (and preserving) stale keys. if (ambiguous.size > 0 || missing.size > 0) { const problems: string[] = []; if (ambiguous.size > 0) { @@ -1654,7 +1985,7 @@ async function resolveRequestedSkillEntriesOrThrow( throw unprocessable(`Invalid company skill selection (${problems.join("; ")}).`); } - return Array.from(resolved.values()); + return { resolved: Array.from(resolved.values()), unresolved }; } function resolveDesiredSkillKeys( @@ -2181,6 +2512,66 @@ function toCompanySkillListItem(skill: CompanySkillListRow, attachedAgentCount: }; } +async function listLastEditorsBySkillId( + db: Db, + companyId: string, + skillIds: string[], +): Promise> { + if (skillIds.length === 0) return new Map(); + const rows = await db + .selectDistinctOn([companySkillVersions.companySkillId], { + companySkillId: companySkillVersions.companySkillId, + authorAgentId: companySkillVersions.authorAgentId, + authorUserId: companySkillVersions.authorUserId, + userName: authUsers.name, + userImage: authUsers.image, + agentName: agentsTable.name, + }) + .from(companySkillVersions) + .leftJoin(authUsers, eq(authUsers.id, companySkillVersions.authorUserId)) + .leftJoin( + agentsTable, + and( + eq(agentsTable.companyId, companyId), + eq(agentsTable.id, companySkillVersions.authorAgentId), + ), + ) + .where(and( + eq(companySkillVersions.companyId, companyId), + inArray(companySkillVersions.companySkillId, skillIds), + )) + .orderBy( + companySkillVersions.companySkillId, + desc(companySkillVersions.createdAt), + desc(companySkillVersions.revisionNumber), + desc(companySkillVersions.id), + ); + + const editors = new Map(); + for (const row of rows) { + if (row.authorUserId) { + editors.set(row.companySkillId, { + kind: "user", + id: row.authorUserId, + name: row.userName ?? null, + imageUrl: row.userImage ?? null, + }); + continue; + } + if (row.authorAgentId) { + editors.set(row.companySkillId, { + kind: "agent", + id: row.authorAgentId, + name: row.agentName ?? null, + imageUrl: null, + }); + continue; + } + editors.set(row.companySkillId, null); + } + return editors; +} + export function companySkillService(db: Db) { const agents = agentService(db); const projects = projectService(db); @@ -2236,6 +2627,7 @@ export function companySkillService(db: Db) { for (const skill of skills) { if (skill.sourceType !== "local_path") continue; + if (isPaperclipBundledSkillKey(skill.key) || asString(skill.metadata?.sourceKind) === "paperclip_bundled") continue; if (!missingIds.has(skill.id)) { const metadata = getMissingSourceMarker(skill.metadata) @@ -2247,7 +2639,7 @@ export function companySkillService(db: Db) { : null; const nextTrustLevel = nextInventory ? deriveTrustLevel(nextInventory) : skill.trustLevel; const inventoryChanged = nextInventory ? !inventoryEntriesEqual(skill.fileInventory, nextInventory) : false; - const metadataChanged = JSON.stringify(metadata ?? {}) !== JSON.stringify(skill.metadata ?? {}); + const metadataChanged = !stableJsonEqual(metadata ?? {}, skill.metadata ?? {}); if (inventoryChanged || metadataChanged || nextTrustLevel !== skill.trustLevel) { await db .update(companySkills) @@ -2268,7 +2660,7 @@ export function companySkillService(db: Db) { skill.metadata, buildMissingLocalSourceMarker(skill), ); - if (JSON.stringify(metadata) !== JSON.stringify(skill.metadata ?? {})) { + if (!stableJsonEqual(metadata, skill.metadata ?? {})) { await db .update(companySkills) .set({ metadata, updatedAt: new Date() }) @@ -2354,10 +2746,15 @@ export function companySkillService(db: Db) { .then((entries) => entries.map((entry) => toCompanySkillListRow(entry as CompanySkillListDbRow))); const agentRows = await agents.list(companyId); const q = query.q?.trim().toLowerCase() ?? ""; - const categories = new Set((query.categories ?? []).map(normalizeCategorySlug).filter((value): value is string => Boolean(value))); + const categories = new Set( + (query.categories ?? []) + .map(normalizeCategoryName) + .filter((value): value is string => Boolean(value)) + .map(categoryLookupKey), + ); const filtered = rows.filter((skill) => { if (query.scope && skill.sharingScope !== query.scope) return false; - if (categories.size > 0 && !skill.categories.some((category) => categories.has(category))) return false; + if (categories.size > 0 && !skill.categories.some((category) => categories.has(categoryLookupKey(category)))) return false; if (q) { const haystack = [ skill.name, @@ -2388,6 +2785,13 @@ export function companySkillService(db: Db) { if (sort === "forks") return right.forkCount - left.forkCount || left.name.localeCompare(right.name); return left.name.localeCompare(right.name) || left.key.localeCompare(right.key); }); + if (query.include?.includes("lastEditor")) { + const lastEditors = await listLastEditorsBySkillId(db, companyId, items.map((item) => item.id)); + return items.map((item) => ({ + ...item, + lastEditor: lastEditors.get(item.id) ?? null, + })); + } return items; } @@ -2444,6 +2848,20 @@ export function companySkillService(db: Db) { return row ? toCompanySkill(row) : null; } + async function getBySlugIfUnique(companyId: string, slug: string) { + const rows = await db + .select(selectCompanySkillColumns()) + .from(companySkills) + .where(and(eq(companySkills.companyId, companyId), eq(companySkills.slug, slug))); + return rows.length === 1 ? toCompanySkill(rows[0]!) : null; + } + + async function getByRouteRef(companyId: string, ref: string) { + return (isUuidLike(ref) ? await getById(companyId, ref) : null) + ?? await getBySlugIfUnique(companyId, ref) + ?? await getByKey(companyId, ref); + } + async function getVersion(companyId: string, skillId: string, versionId: string): Promise { const row = await db .select() @@ -2578,7 +2996,7 @@ export function companySkillService(db: Db) { async function detail(companyId: string, id: string, actor?: SkillActor | null): Promise { await ensureSkillInventoryCurrent(companyId); - const skill = await getById(companyId, id); + const skill = await getByRouteRef(companyId, id); if (!skill) return null; const usedByAgents = await usage(companyId, skill.key); const existingForks = await existingForkSummaries(companyId, skill.id, actor); @@ -2587,7 +3005,7 @@ export function companySkillService(db: Db) { usedByAgents.length, usedByAgents, await getCurrentVersion(skill), - await isStarredByActor(companyId, id, actor), + await isStarredByActor(companyId, skill.id, actor), existingForks, ); } @@ -3473,6 +3891,71 @@ export function companySkillService(db: Db) { return detail; } + async function deleteFile( + companyId: string, + skillId: string, + input: CompanySkillFileDeleteRequest, + actor: SkillActor | null = null, + ): Promise { + await ensureSkillInventoryCurrent(companyId); + const skill = await getById(companyId, skillId); + if (!skill) throw notFound("Skill not found"); + + const source = deriveSkillSourceInfo(skill); + if (!source.editable || skill.sourceType !== "local_path") { + throw unprocessable(source.editableReason ?? "This skill cannot be edited."); + } + + const normalizedPath = normalizePortablePath(input.path); + if (!normalizedPath) { + throw unprocessable("Skill file path is required."); + } + + const deletedPaths = input.target === "folder" + ? skill.fileInventory + .map((entry) => normalizePortablePath(entry.path)) + .filter((entryPath) => entryPath.startsWith(`${normalizedPath}/`)) + : skill.fileInventory + .map((entry) => normalizePortablePath(entry.path)) + .filter((entryPath) => entryPath === normalizedPath); + + if (deletedPaths.length === 0) { + throw notFound(input.target === "folder" ? "Skill folder not found" : "Skill file not found"); + } + if (deletedPaths.includes("SKILL.md")) { + throw unprocessable("SKILL.md cannot be deleted."); + } + + const absolutePath = resolveLocalSkillFilePath(skill, normalizedPath); + if (!absolutePath) throw notFound("Skill file not found"); + + await fs.rm(absolutePath, { + recursive: input.target === "folder", + force: false, + }).catch((error) => { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + throw notFound(input.target === "folder" ? "Skill folder not found" : "Skill file not found"); + } + throw error; + }); + + await db + .update(companySkills) + .set({ updatedAt: new Date() }) + .where(eq(companySkills.id, skill.id)); + + await createVersion(companyId, skillId, { + label: input.target === "folder" ? `Deleted ${normalizedPath}/` : `Deleted ${normalizedPath}`, + }, actor); + + return { + skillId: skill.id, + path: normalizedPath, + target: input.target, + deletedPaths, + }; + } + async function installUpdate(companyId: string, skillId: string, options: { force?: boolean } = {}): Promise { await ensureSkillInventoryCurrent(companyId); const skill = await getById(companyId, skillId); @@ -4566,7 +5049,7 @@ export function companySkillService(db: Db) { }; const parsed = parseFrontmatterMarkdown(skill.markdown); const storeMetadata = readSkillStoreMetadata(parsed.frontmatter, metadata); - const values = { + const values: ImportedSkillPersistValues = { companyId, key: skill.key, slug: skill.slug, @@ -4590,6 +5073,10 @@ export function companySkillService(db: Db) { metadata, updatedAt: new Date(), }; + if (existing && importedSkillPersistValuesMatchExisting(existing, values)) { + out.push(existing); + continue; + } const row = existing ? await db .update(companySkills) @@ -4648,6 +5135,775 @@ export function companySkillService(db: Db) { return { imported, warnings }; } + async function listTestInputs(companyId: string, skillId: string): Promise { + const skill = await getById(companyId, skillId); + if (!skill) throw notFound("Skill not found"); + const rows = await db + .select() + .from(companySkillTestInputs) + .where(and( + eq(companySkillTestInputs.companyId, companyId), + eq(companySkillTestInputs.skillId, skillId), + isNull(companySkillTestInputs.deletedAt), + )) + .orderBy(asc(companySkillTestInputs.name), asc(companySkillTestInputs.createdAt)); + return rows.map(toCompanySkillTestInput); + } + + async function createTestInput( + companyId: string, + skillId: string, + input: CompanySkillTestInputCreateRequest, + actor: SkillActor | null = null, + ): Promise { + const skill = await getById(companyId, skillId); + if (!skill) throw notFound("Skill not found"); + const row = await db + .insert(companySkillTestInputs) + .values({ + companyId, + skillId, + name: input.name.trim(), + content: input.content, + createdBy: actor?.type === "agent" + ? actor.agentId ?? null + : actor?.type === "user" + ? actor.userId ?? null + : null, + }) + .returning() + .then((rows) => rows[0] ?? null); + if (!row) throw notFound("Failed to persist test input"); + return toCompanySkillTestInput(row); + } + + async function updateTestInput( + companyId: string, + skillId: string, + inputId: string, + input: CompanySkillTestInputUpdateRequest, + ): Promise { + const patch: Partial = { updatedAt: new Date() }; + if (input.name !== undefined) patch.name = input.name.trim(); + if (input.content !== undefined) patch.content = input.content; + const row = await db + .update(companySkillTestInputs) + .set(patch) + .where(and( + eq(companySkillTestInputs.companyId, companyId), + eq(companySkillTestInputs.skillId, skillId), + eq(companySkillTestInputs.id, inputId), + isNull(companySkillTestInputs.deletedAt), + )) + .returning() + .then((rows) => rows[0] ?? null); + return row ? toCompanySkillTestInput(row) : null; + } + + async function deleteTestInput(companyId: string, skillId: string, inputId: string): Promise { + const row = await db + .update(companySkillTestInputs) + .set({ deletedAt: new Date(), updatedAt: new Date() }) + .where(and( + eq(companySkillTestInputs.companyId, companyId), + eq(companySkillTestInputs.skillId, skillId), + eq(companySkillTestInputs.id, inputId), + isNull(companySkillTestInputs.deletedAt), + )) + .returning() + .then((rows) => rows[0] ?? null); + return row ? toCompanySkillTestInput(row) : null; + } + + async function listTestRunTemplates(companyId: string): Promise { + const rows = await db + .select() + .from(companySkillTestRunTemplates) + .where(and(eq(companySkillTestRunTemplates.companyId, companyId), isNull(companySkillTestRunTemplates.deletedAt))) + .orderBy(asc(companySkillTestRunTemplates.name), asc(companySkillTestRunTemplates.createdAt)); + return [ + builtInSkillTestRunTemplate(companyId), + ...rows.map(toCompanySkillTestRunTemplate), + ]; + } + + async function createTestRunTemplate( + companyId: string, + input: CompanySkillTestRunTemplateCreateRequest, + actor: SkillActor | null = null, + ): Promise { + validateSkillTestTemplatePlaceholders(input.body); + const row = await db + .insert(companySkillTestRunTemplates) + .values({ + companyId, + name: input.name.trim(), + description: input.description?.trim() || null, + body: input.body, + createdByAgentId: actor?.type === "agent" ? actor.agentId ?? null : null, + createdByUserId: actor?.type === "user" ? actor.userId ?? null : null, + updatedByAgentId: actor?.type === "agent" ? actor.agentId ?? null : null, + updatedByUserId: actor?.type === "user" ? actor.userId ?? null : null, + }) + .returning() + .then((rows) => rows[0] ?? null); + if (!row) throw notFound("Failed to persist test run template"); + return toCompanySkillTestRunTemplate(row); + } + + async function updateTestRunTemplate( + companyId: string, + templateId: string, + input: CompanySkillTestRunTemplateUpdateRequest, + actor: SkillActor | null = null, + ): Promise { + if (templateId === BUILT_IN_SKILL_TEST_RUN_TEMPLATE_ID) { + throw unprocessable("Built-in test run templates are read-only."); + } + if (input.body !== undefined) { + validateSkillTestTemplatePlaceholders(input.body); + } + const patch: Partial = { + updatedAt: new Date(), + updatedByAgentId: actor?.type === "agent" ? actor.agentId ?? null : null, + updatedByUserId: actor?.type === "user" ? actor.userId ?? null : null, + }; + if (input.name !== undefined) patch.name = input.name.trim(); + if (input.description !== undefined) patch.description = input.description?.trim() || null; + if (input.body !== undefined) patch.body = input.body; + const row = await db + .update(companySkillTestRunTemplates) + .set(patch) + .where(and( + eq(companySkillTestRunTemplates.companyId, companyId), + eq(companySkillTestRunTemplates.id, templateId), + isNull(companySkillTestRunTemplates.deletedAt), + )) + .returning() + .then((rows) => rows[0] ?? null); + return row ? toCompanySkillTestRunTemplate(row) : null; + } + + async function deleteTestRunTemplate(companyId: string, templateId: string): Promise { + if (templateId === BUILT_IN_SKILL_TEST_RUN_TEMPLATE_ID) { + throw unprocessable("Built-in test run templates are read-only."); + } + const row = await db + .update(companySkillTestRunTemplates) + .set({ deletedAt: new Date(), updatedAt: new Date() }) + .where(and( + eq(companySkillTestRunTemplates.companyId, companyId), + eq(companySkillTestRunTemplates.id, templateId), + isNull(companySkillTestRunTemplates.deletedAt), + )) + .returning() + .then((rows) => rows[0] ?? null); + return row ? toCompanySkillTestRunTemplate(row) : null; + } + + async function resolveTestRunTemplateSnapshot( + companyId: string, + input: CompanySkillTestRunCreateRequest, + ): Promise { + if (input.templateSnapshot !== undefined) { + const snapshot = input.templateSnapshot; + if (!snapshot || snapshot.templateId === null) return null; + validateSkillTestTemplatePlaceholders(snapshot.templateBody ?? ""); + return { + templateId: snapshot.templateId, + templateName: snapshot.templateName, + templateBody: snapshot.templateBody, + }; + } + + const templateId = input.templateId === undefined ? BUILT_IN_SKILL_TEST_RUN_TEMPLATE_ID : input.templateId; + if (templateId === null) return null; + if (templateId === BUILT_IN_SKILL_TEST_RUN_TEMPLATE_ID) { + const template = builtInSkillTestRunTemplate(companyId); + return { + templateId: template.id, + templateName: template.name, + templateBody: template.body, + }; + } + const row = await db + .select() + .from(companySkillTestRunTemplates) + .where(and( + eq(companySkillTestRunTemplates.companyId, companyId), + eq(companySkillTestRunTemplates.id, templateId), + isNull(companySkillTestRunTemplates.deletedAt), + )) + .then((rows) => rows[0] ?? null); + if (!row) throw notFound("Test run template not found"); + return { + templateId: row.id, + templateName: row.name, + templateBody: row.body, + }; + } + + async function ensureRunSkillVersion( + companyId: string, + skill: CompanySkill, + actor: SkillActor | null, + ): Promise { + const currentSnapshot = serializeVersionFileInventory(await collectVersionFileInventory(companyId, skill)); + if (currentSnapshot.length === 0) { + throw unprocessable("Cannot run a skill test for a skill with zero files."); + } + + const currentVersion = await getCurrentVersion(skill); + if (!currentVersion || !versionInventorySnapshotEqual(currentVersion.fileInventory, currentSnapshot)) { + return createVersion(companyId, skill.id, { label: "Auto version for test run" }, actor); + } + return currentVersion; + } + + function snapshotAgentConfig(agent: Awaited>) { + if (!agent) return {}; + const adapterConfig = isPlainRecord(agent.adapterConfig) ? agent.adapterConfig : {}; + const runtimeConfig = isPlainRecord(agent.runtimeConfig) ? agent.runtimeConfig : {}; + return { + agentId: agent.id, + name: agent.name, + role: agent.role, + adapterType: agent.adapterType, + model: asString(adapterConfig.model) ?? asString(runtimeConfig.model) ?? null, + adapterConfig, + runtimeConfig, + assignedSkills: isPlainRecord(adapterConfig.paperclipSkillSync) + ? adapterConfig.paperclipSkillSync + : null, + instructionsRef: + asString(adapterConfig.instructionsFilePath) ?? + asString(adapterConfig.instructionsPath) ?? + asString(adapterConfig.instructionsRef) ?? + null, + }; + } + + async function testRunCostByIssueIds(companyId: string, issueIds: string[]) { + if (issueIds.length === 0) return new Map>(); + const rows = await db + .select({ + issueId: costEvents.issueId, + costCents: sql`coalesce(sum(${costEvents.costCents}), 0)::int`, + inputTokens: sql`coalesce(sum(${costEvents.inputTokens}), 0)::int`, + cachedInputTokens: sql`coalesce(sum(${costEvents.cachedInputTokens}), 0)::int`, + outputTokens: sql`coalesce(sum(${costEvents.outputTokens}), 0)::int`, + }) + .from(costEvents) + .where(and(eq(costEvents.companyId, companyId), inArray(costEvents.issueId, issueIds))) + .groupBy(costEvents.issueId); + return new Map(rows.flatMap((row) => row.issueId + ? [[row.issueId, { + costCents: Number(row.costCents ?? 0), + inputTokens: Number(row.inputTokens ?? 0), + cachedInputTokens: Number(row.cachedInputTokens ?? 0), + outputTokens: Number(row.outputTokens ?? 0), + }]] + : [])); + } + + async function hydrateTestRuns(companyId: string, rows: CompanySkillTestRunRow[]): Promise { + const costByIssueId = await testRunCostByIssueIds(companyId, rows.map((row) => row.issueId)); + return rows.map((row) => toCompanySkillTestRun( + row, + costByIssueId.get(row.issueId) ?? emptyTestRunCost(), + Boolean(row.harnessIssueDeletedAt), + )); + } + + async function createTestRun( + companyId: string, + skillId: string, + input: CompanySkillTestRunCreateRequest, + actor: SkillActor | null, + deps: { + createHarnessIssue: (issue: { + id: string; + title: string; + description: string; + assigneeAgentId: string; + harnessKind: "skill_test"; + workMode: "skill_test"; + status: "todo"; + originKind: "skill_test"; + originId: string; + originFingerprint: string; + }) => Promise<{ id: string }>; + wakeHarnessIssue: (issueId: string, agentId: string) => Promise; + cleanupHarnessIssue?: (issueId: string) => Promise; + retentionDays?: number; + }, + ): Promise { + const skill = await getById(companyId, skillId); + if (!skill) throw notFound("Skill not found"); + const agent = await agents.getById(input.agentId); + if (!agent || agent.companyId !== companyId) throw notFound("Agent not found"); + if (agent.status === "paused") throw unprocessable("Paused agents cannot run skill tests."); + + const sourceInput = input.inputId + ? await db + .select() + .from(companySkillTestInputs) + .where(and( + eq(companySkillTestInputs.companyId, companyId), + eq(companySkillTestInputs.skillId, skillId), + eq(companySkillTestInputs.id, input.inputId), + isNull(companySkillTestInputs.deletedAt), + )) + .then((rows) => rows[0] ?? null) + : null; + if (input.inputId && !sourceInput) throw notFound("Test input not found"); + const inputSnapshot = (sourceInput?.content ?? input.content ?? "").trim(); + if (!inputSnapshot) throw unprocessable("Test input content cannot be empty."); + + // Re-run pins the viewed run's version so the new run reproduces the same + // snapshots; a plain run auto-snapshots the live head. + const version = input.skillVersionId + ? await getVersion(companyId, skillId, input.skillVersionId) + : await ensureRunSkillVersion(companyId, skill, actor); + if (!version) throw notFound("Skill version not found"); + const runId = randomUUID(); + const issueId = randomUUID(); + const outputDocumentKey = "output"; + const templateSnapshot = await resolveTestRunTemplateSnapshot(companyId, input); + const renderedTemplateBody = templateSnapshot?.templateBody + ? renderSkillTestTemplate(templateSnapshot.templateBody, { + skillName: skill.name, + skillKey: skill.key, + skillInvocation: skill.key, + skillVersion: String(version.revisionNumber), + runId, + issueId, + outputDocumentKey, + }).trim() + : null; + const harnessIssueDescription = buildHarnessIssueDescription(inputSnapshot, renderedTemplateBody); + await deps.createHarnessIssue({ + id: issueId, + title: `Skill test: ${skill.name}`, + description: harnessIssueDescription, + assigneeAgentId: agent.id, + harnessKind: "skill_test", + workMode: "skill_test", + status: "todo", + originKind: "skill_test", + originId: runId, + originFingerprint: `skill_test:${runId}`, + }); + + const now = new Date(); + const retentionDays = Math.max(0, deps.retentionDays ?? 7); + const previousExpiresAt = new Date(now.getTime() + retentionDays * 24 * 60 * 60 * 1000); + const cleanupCreatedHarnessIssue = async () => { + await deps.cleanupHarnessIssue?.(issueId).catch(() => {}); + }; + const row = await db.transaction(async (tx) => { + await tx + .update(companySkillTestRuns) + .set({ + supersededAt: now, + status: sql` + case when ${companySkillTestRuns.status} in ('queued', 'running') + then 'cancelled' + else ${companySkillTestRuns.status} + end + `, + error: sql` + case when ${companySkillTestRuns.status} in ('queued', 'running') + then coalesce(${companySkillTestRuns.error}, 'Superseded by newer run') + else ${companySkillTestRuns.error} + end + `, + harnessIssueExpiresAt: previousExpiresAt, + updatedAt: now, + }) + .where(and( + eq(companySkillTestRuns.companyId, companyId), + eq(companySkillTestRuns.skillId, skillId), + sourceInput?.id + ? eq(companySkillTestRuns.inputId, sourceInput.id) + : isNull(companySkillTestRuns.inputId), + isNull(companySkillTestRuns.supersededAt), + )); + return await tx + .insert(companySkillTestRuns) + .values({ + id: runId, + companyId, + skillId, + inputId: sourceInput?.id ?? null, + inputSnapshot, + skillVersionId: version.id, + agentId: agent.id, + agentConfigSnapshot: snapshotAgentConfig(agent), + issueId, + templateId: templateSnapshot?.templateId ?? null, + templateName: templateSnapshot?.templateName ?? null, + templateBody: templateSnapshot?.templateBody ?? null, + renderedTemplateBody, + harnessIssueDescription, + status: "queued", + outputDocumentKey, + }) + .returning() + .then((rows) => rows[0] ?? null); + }).catch(async (error) => { + await cleanupCreatedHarnessIssue(); + throw error; + }); + if (!row) { + await cleanupCreatedHarnessIssue(); + throw notFound("Failed to persist skill test run"); + } + await deps.wakeHarnessIssue(issueId, agent.id); + return (await hydrateTestRuns(companyId, [row]))[0]!; + } + + async function listTestRuns( + companyId: string, + skillId: string, + query: CompanySkillTestRunListQuery = {}, + ): Promise { + const skill = await getById(companyId, skillId); + if (!skill) throw notFound("Skill not found"); + const conditions = [ + eq(companySkillTestRuns.companyId, companyId), + eq(companySkillTestRuns.skillId, skillId), + isNull(companySkillTestRuns.deletedAt), + ]; + if (query.inputId) conditions.push(eq(companySkillTestRuns.inputId, query.inputId)); + const rows = await db + .select() + .from(companySkillTestRuns) + .where(and(...conditions)) + .orderBy(desc(companySkillTestRuns.createdAt), desc(companySkillTestRuns.id)); + return hydrateTestRuns(companyId, rows); + } + + async function getTestRunDetail(companyId: string, skillId: string, runId: string): Promise { + const row = await db + .select() + .from(companySkillTestRuns) + .where(and( + eq(companySkillTestRuns.companyId, companyId), + eq(companySkillTestRuns.skillId, skillId), + eq(companySkillTestRuns.id, runId), + isNull(companySkillTestRuns.deletedAt), + )) + .then((rows) => rows[0] ?? null); + if (!row) return null; + const [run] = await hydrateTestRuns(companyId, [row]); + if (!run) return null; + const harnessIssueGone = Boolean(row.harnessIssueDeletedAt); + const [version, issue, documentRows, interactionRows, attachmentRows, workProductRows] = await Promise.all([ + getVersion(companyId, skillId, row.skillVersionId), + harnessIssueGone + ? Promise.resolve(null) + : db + .select({ + id: issues.id, + identifier: issues.identifier, + title: issues.title, + status: issues.status, + hiddenAt: issues.hiddenAt, + }) + .from(issues) + .where(and(eq(issues.companyId, companyId), eq(issues.id, row.issueId))) + .then((rows) => rows[0] ?? null), + harnessIssueGone + ? Promise.resolve([]) + : db + .select(issueDocumentSelect) + .from(issueDocuments) + .innerJoin(documents, eq(issueDocuments.documentId, documents.id)) + .where(and(eq(issueDocuments.companyId, companyId), eq(issueDocuments.issueId, row.issueId))) + .orderBy(asc(issueDocuments.key)), + harnessIssueGone + ? Promise.resolve([]) + : db + .select({ + id: issueThreadInteractions.id, + kind: issueThreadInteractions.kind, + status: issueThreadInteractions.status, + title: issueThreadInteractions.title, + createdAt: issueThreadInteractions.createdAt, + updatedAt: issueThreadInteractions.updatedAt, + }) + .from(issueThreadInteractions) + .where(and(eq(issueThreadInteractions.companyId, companyId), eq(issueThreadInteractions.issueId, row.issueId))) + .orderBy(desc(issueThreadInteractions.createdAt)), + harnessIssueGone + ? Promise.resolve([]) + : db + .select({ + id: issueAttachments.id, + companyId: issueAttachments.companyId, + issueId: issueAttachments.issueId, + issueCommentId: issueAttachments.issueCommentId, + assetId: issueAttachments.assetId, + provider: assets.provider, + objectKey: assets.objectKey, + contentType: assets.contentType, + byteSize: assets.byteSize, + sha256: assets.sha256, + originalFilename: assets.originalFilename, + createdByAgentId: assets.createdByAgentId, + createdByUserId: assets.createdByUserId, + createdAt: issueAttachments.createdAt, + updatedAt: issueAttachments.updatedAt, + }) + .from(issueAttachments) + .innerJoin(assets, eq(issueAttachments.assetId, assets.id)) + .where(and(eq(issueAttachments.companyId, companyId), eq(issueAttachments.issueId, row.issueId))) + .orderBy(desc(issueAttachments.createdAt)), + harnessIssueGone + ? Promise.resolve([]) + : db + .select() + .from(issueWorkProducts) + .where(and(eq(issueWorkProducts.companyId, companyId), eq(issueWorkProducts.issueId, row.issueId))) + .orderBy(desc(issueWorkProducts.isPrimary), desc(issueWorkProducts.updatedAt)), + ]); + if (!version) throw notFound("Skill version not found"); + const harnessAvailable = !harnessIssueGone && issue !== null; + const harnessDocuments: IssueDocument[] = harnessAvailable + ? documentRows.map((doc) => ({ + ...mapIssueDocumentRow(doc, false), + format: doc.format as IssueDocument["format"], + body: doc.latestBody, + })) + : []; + const harnessAttachments: IssueAttachment[] = harnessAvailable + ? attachmentRows.map((attachment) => ({ + ...attachment, + contentPath: `/api/attachments/${attachment.id}/content`, + openPath: `/api/attachments/${attachment.id}/content`, + downloadPath: `/api/attachments/${attachment.id}/content?download=1`, + })) + : []; + const harnessWorkProducts = harnessAvailable ? workProductRows.map(toIssueWorkProduct) : []; + const harnessContent: CompanySkillTestRunHarnessContent = { + available: harnessAvailable, + unavailableReason: harnessAvailable + ? null + : harnessIssueGone + ? (row.harnessIssueExpiresAt && row.harnessIssueDeletedAt && row.harnessIssueExpiresAt <= row.harnessIssueDeletedAt + ? "expired" + : "deleted") + : "missing", + documents: harnessDocuments, + attachments: harnessAttachments, + workProducts: harnessWorkProducts, + }; + return { + ...run, + skillVersion: version, + outputBody: run.outputSnapshot, + harnessContent, + harnessIssue: issue ? { + id: issue.id, + identifier: issue.identifier ?? null, + title: issue.title, + status: issue.status, + hiddenAt: issue.hiddenAt ?? null, + } : null, + documents: harnessDocuments.map((doc) => ({ + key: doc.key, + title: doc.title ?? null, + updatedAt: doc.updatedAt, + body: doc.body, + })), + interactions: interactionRows.map((interaction) => ({ + id: interaction.id, + kind: interaction.kind, + status: interaction.status, + title: interaction.title ?? interaction.kind, + createdAt: interaction.createdAt, + updatedAt: interaction.updatedAt, + })), + artifacts: [ + ...harnessAttachments.map((attachment) => ({ + id: attachment.id, + kind: "attachment" as const, + title: attachment.originalFilename ?? "Attachment", + summary: null, + createdAt: attachment.createdAt, + })), + ...harnessWorkProducts.map((product) => ({ + id: product.id, + kind: "work_product" as const, + title: product.title, + summary: product.summary ?? null, + createdAt: product.createdAt, + })), + ].sort((left, right) => right.createdAt.getTime() - left.createdAt.getTime()), + }; + } + + async function completeTestRunForIssue(input: { + companyId: string; + issueId: string; + outcome: "succeeded" | "failed" | "cancelled"; + error?: string | null; + }): Promise { + const row = await db + .select() + .from(companySkillTestRuns) + .where(and( + eq(companySkillTestRuns.companyId, input.companyId), + eq(companySkillTestRuns.issueId, input.issueId), + isNull(companySkillTestRuns.deletedAt), + isNull(companySkillTestRuns.supersededAt), + )) + .then((rows) => rows[0] ?? null); + if (!row || ["succeeded", "failed", "cancelled"].includes(row.status)) return row + ? (await hydrateTestRuns(input.companyId, [row]))[0] ?? null + : null; + + const outputDocumentKey = row.outputDocumentKey || "output"; + const outputDocument = await db + .select({ body: documents.latestBody }) + .from(issueDocuments) + .innerJoin(documents, eq(issueDocuments.documentId, documents.id)) + .where(and( + eq(issueDocuments.companyId, input.companyId), + eq(issueDocuments.issueId, input.issueId), + eq(issueDocuments.key, outputDocumentKey), + )) + .then((rows) => rows[0] ?? null); + const updated = await db + .update(companySkillTestRuns) + .set({ + status: input.outcome, + outputSnapshot: outputDocument?.body ?? row.outputSnapshot ?? "", + error: input.error ?? null, + updatedAt: new Date(), + }) + .where(and(eq(companySkillTestRuns.companyId, input.companyId), eq(companySkillTestRuns.id, row.id))) + .returning() + .then((rows) => rows[0] ?? null); + return updated ? (await hydrateTestRuns(input.companyId, [updated]))[0] ?? null : null; + } + + async function markTestRunRunning(companyId: string, issueId: string): Promise { + const row = await db + .update(companySkillTestRuns) + .set({ status: "running", updatedAt: new Date() }) + .where(and( + eq(companySkillTestRuns.companyId, companyId), + eq(companySkillTestRuns.issueId, issueId), + eq(companySkillTestRuns.status, "queued"), + isNull(companySkillTestRuns.deletedAt), + isNull(companySkillTestRuns.supersededAt), + )) + .returning() + .then((rows) => rows[0] ?? null); + return row ? (await hydrateTestRuns(companyId, [row]))[0] ?? null : null; + } + + async function cancelTestRun( + companyId: string, + skillId: string, + runId: string, + deps: { cancelHarnessIssue: (issueId: string) => Promise }, + ): Promise { + const existing = await db + .select() + .from(companySkillTestRuns) + .where(and( + eq(companySkillTestRuns.companyId, companyId), + eq(companySkillTestRuns.skillId, skillId), + eq(companySkillTestRuns.id, runId), + isNull(companySkillTestRuns.deletedAt), + isNull(companySkillTestRuns.supersededAt), + )) + .then((rows) => rows[0] ?? null); + if (!existing) return null; + if (["succeeded", "failed", "cancelled"].includes(existing.status)) { + return (await hydrateTestRuns(companyId, [existing]))[0] ?? null; + } + await deps.cancelHarnessIssue(existing.issueId); + return completeTestRunForIssue({ + companyId, + issueId: existing.issueId, + outcome: "cancelled", + error: "Cancelled by operator", + }); + } + + async function deleteTestRun( + companyId: string, + skillId: string, + runId: string, + deps: { hideHarnessIssue: (issueId: string) => Promise }, + ): Promise { + const existing = await db + .select() + .from(companySkillTestRuns) + .where(and( + eq(companySkillTestRuns.companyId, companyId), + eq(companySkillTestRuns.skillId, skillId), + eq(companySkillTestRuns.id, runId), + isNull(companySkillTestRuns.deletedAt), + )) + .then((rows) => rows[0] ?? null); + if (!existing) return null; + // Only terminal runs are deletable — an in-flight run must be cancelled first + // so we never orphan a live harness task. + if (!["succeeded", "failed", "cancelled"].includes(existing.status)) { + throw unprocessable("Cancel the run before deleting it."); + } + const now = new Date(); + const updated = await db.transaction(async (tx) => { + return await tx + .update(companySkillTestRuns) + .set({ deletedAt: now, harnessIssueDeletedAt: existing.harnessIssueDeletedAt ?? now, updatedAt: now }) + .where(and( + eq(companySkillTestRuns.companyId, companyId), + eq(companySkillTestRuns.id, runId), + )) + .returning() + .then((rows) => rows[0] ?? null); + }); + // Hide the (already-terminal) harness task so the deleted run leaves nothing + // dangling on the board; best-effort, run row is the source of truth. + if (!existing.harnessIssueDeletedAt) { + await deps.hideHarnessIssue(existing.issueId).catch(() => {}); + } + return updated ? (await hydrateTestRuns(companyId, [updated]))[0] ?? null : null; + } + + async function pruneExpiredTestHarnessIssues(companyId: string, now = new Date()): Promise<{ pruned: number }> { + const rows = await db + .select({ + id: companySkillTestRuns.id, + issueId: companySkillTestRuns.issueId, + }) + .from(companySkillTestRuns) + .where(and( + eq(companySkillTestRuns.companyId, companyId), + lt(companySkillTestRuns.harnessIssueExpiresAt, now), + isNull(companySkillTestRuns.harnessIssueDeletedAt), + )); + for (const row of rows) { + await db.transaction(async (tx) => { + await tx + .update(issues) + .set({ hiddenAt: now, updatedAt: now }) + .where(and(eq(issues.companyId, companyId), eq(issues.id, row.issueId), eq(issues.harnessKind, "skill_test"))); + await tx + .update(companySkillTestRuns) + .set({ harnessIssueDeletedAt: now, updatedAt: now }) + .where(and(eq(companySkillTestRuns.companyId, companyId), eq(companySkillTestRuns.id, row.id))); + }); + } + return { pruned: rows.length }; + } + async function deleteSkill(companyId: string, skillId: string): Promise { const row = await db .select() @@ -4692,13 +5948,18 @@ export function companySkillService(db: Db) { listFull, getById, getByKey, + getByRouteRef, resolveRequestedSkillKeys: async (companyId: string, requestedReferences: string[]) => { const skills = await listFull(companyId); return resolveRequestedSkillKeysOrThrow(skills, requestedReferences); }, - resolveRequestedSkillEntries: async (companyId: string, requestedSelections: Array) => { + resolveRequestedSkillEntries: async ( + companyId: string, + requestedSelections: Array, + options?: { tolerateUnknownReferences?: boolean }, + ) => { const skills = await listFull(companyId); - return resolveRequestedSkillEntriesOrThrow(db, companyId, skills, requestedSelections); + return resolveRequestedSkillEntriesOrThrow(db, companyId, skills, requestedSelections, options); }, categoryCounts, detail, @@ -4717,8 +5978,25 @@ export function companySkillService(db: Db) { readFile, updateSkill, updateFile, + deleteFile, createLocalSkill, deleteSkill, + listTestInputs, + createTestInput, + updateTestInput, + deleteTestInput, + listTestRunTemplates, + createTestRunTemplate, + updateTestRunTemplate, + deleteTestRunTemplate, + createTestRun, + listTestRuns, + getTestRunDetail, + completeTestRunForIssue, + markTestRunRunning, + cancelTestRun, + deleteTestRun, + pruneExpiredTestHarnessIssues, importFromSource, installFromCatalog, scanProjectWorkspaces, diff --git a/server/src/services/costs.ts b/server/src/services/costs.ts index 05008d8222..781d0cae11 100644 --- a/server/src/services/costs.ts +++ b/server/src/services/costs.ts @@ -4,6 +4,7 @@ import type { Db } from "@paperclipai/db"; import { activityLog, agents, companies, costEvents, heartbeatRuns, issues, projects } from "@paperclipai/db"; import { notFound, unprocessable } from "../errors.js"; import { budgetService, type BudgetServiceHooks } from "./budgets.js"; +import { visibleIssueCondition } from "./issue-visibility.js"; export interface CostDateRange { from?: Date; @@ -153,6 +154,7 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) { WHERE ${issues.companyId} = ${companyId} AND ${issues.parentId} = ${issueId} AND ${issues.hiddenAt} IS NULL + AND ${issues.harnessKind} IS NULL ` : sql` SELECT ${issues.id} @@ -160,6 +162,7 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) { WHERE ${issues.companyId} = ${companyId} AND ${issues.id} = ${issueId} AND ${issues.hiddenAt} IS NULL + AND ${issues.harnessKind} IS NULL `; const cteSeedText = options.excludeRoot @@ -169,6 +172,7 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) { WHERE ${issues.companyId} = ${companyId} AND ${issues.parentId} = ${issueId} AND ${issues.hiddenAt} IS NULL + AND ${issues.harnessKind} IS NULL ` : sql` SELECT (${issues.id})::text AS id @@ -176,6 +180,7 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) { WHERE ${issues.companyId} = ${companyId} AND ${issues.id} = ${issueId} AND ${issues.hiddenAt} IS NULL + AND ${issues.harnessKind} IS NULL `; const issueTreeCondition = sql` @@ -188,6 +193,7 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) { JOIN issue_tree ON ${childIssues.parentId} = issue_tree.id WHERE ${childIssues.companyId} = ${companyId} AND ${childIssues.hiddenAt} IS NULL + AND ${childIssues.harnessKind} IS NULL ) SELECT id FROM issue_tree ) @@ -202,6 +208,7 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) { JOIN issue_tree ON (${childIssues.parentId})::text = issue_tree.id WHERE ${childIssues.companyId} = ${companyId} AND ${childIssues.hiddenAt} IS NULL + AND ${childIssues.harnessKind} IS NULL ) SELECT count(distinct ${heartbeatRuns.id})::int AS "runCount", @@ -245,7 +252,7 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) { .where( and( eq(issues.companyId, companyId), - isNull(issues.hiddenAt), + visibleIssueCondition(), issueTreeCondition, ), ), diff --git a/server/src/services/dashboard.ts b/server/src/services/dashboard.ts index 1493a3f59e..20d47dbfef 100644 --- a/server/src/services/dashboard.ts +++ b/server/src/services/dashboard.ts @@ -3,6 +3,7 @@ import type { Db } from "@paperclipai/db"; import { agents, approvals, companies, costEvents, heartbeatRuns, issues } from "@paperclipai/db"; import { notFound } from "../errors.js"; import { budgetService } from "./budgets.js"; +import { visibleIssueCondition } from "./issue-visibility.js"; const DASHBOARD_RUN_ACTIVITY_DAYS = 14; @@ -43,7 +44,7 @@ export function dashboardService(db: Db) { const taskRows = await db .select({ status: issues.status, count: sql`count(*)` }) .from(issues) - .where(eq(issues.companyId, companyId)) + .where(and(eq(issues.companyId, companyId), visibleIssueCondition())) .groupBy(issues.status); const pendingApprovals = await db diff --git a/server/src/services/documents.ts b/server/src/services/documents.ts index 78d57f92ae..ff35742650 100644 --- a/server/src/services/documents.ts +++ b/server/src/services/documents.ts @@ -39,7 +39,7 @@ export function extractLegacyPlanBody(description: string | null | undefined) { return body ? body : null; } -function mapIssueDocumentRow( +export function mapIssueDocumentRow( row: { id: string; companyId: string; @@ -86,7 +86,7 @@ function mapIssueDocumentRow( }; } -const issueDocumentSelect = { +export const issueDocumentSelect = { id: documents.id, companyId: documents.companyId, issueId: issueDocuments.issueId, diff --git a/server/src/services/execution-workspaces.ts b/server/src/services/execution-workspaces.ts index 5a5f486cce..13564637c4 100644 --- a/server/src/services/execution-workspaces.ts +++ b/server/src/services/execution-workspaces.ts @@ -27,6 +27,7 @@ import { deriveProjectUrlKey, WORKSPACE_OVERVIEW_LINKED_ISSUE_LIMIT } from "@pap import { conflict, notFound, unprocessable } from "../errors.js"; import { parseProjectExecutionWorkspacePolicy } from "./execution-workspace-policy.js"; import { issueRecoveryActionService } from "./issue-recovery-actions.js"; +import { visibleIssueCondition } from "./issue-visibility.js"; import { readProjectWorkspaceRuntimeConfig } from "./project-workspace-runtime-config.js"; import { listCurrentRuntimeServicesForExecutionWorkspaces, @@ -890,7 +891,7 @@ export function executionWorkspaceService(db: Db) { .where( and( eq(issues.companyId, companyId), - isNull(issues.hiddenAt), + visibleIssueCondition(), inArray(issues.executionWorkspaceId, workspaceIds), ), ) diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index a88f597d27..0f305846da 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -33,6 +33,8 @@ import { activityLog, approvals, companyMemberships, + companySkillTestRuns, + companySkillVersions, companySkills as companySkillsTable, companies, costEvents, @@ -112,6 +114,7 @@ import { sanitizeRuntimeServiceBaseEnv, } from "./workspace-runtime.js"; import { issueService } from "./issues.js"; +import { visibleIssueCondition } from "./issue-visibility.js"; import { ISSUE_BLOCKERS_RESOLVED_WAKE_REASON, } from "./issue-dependency-wakeups.js"; @@ -4244,6 +4247,7 @@ export async function buildPaperclipWakePayload(input: { : [], executionStage: Object.keys(executionStage).length > 0 ? executionStage : null, taskWatchdog: (input.contextSnapshot.taskWatchdog ?? null) as unknown, + skillTest: (input.contextSnapshot.paperclipSkillTest ?? null) as unknown, continuationSummary: safeContinuationSummary ? { key: safeContinuationSummary.key, @@ -4589,6 +4593,13 @@ export function buildPaperclipTaskMarkdown(input: { "Planning mode directive:", directive, ); + } else if (issue.workMode === "skill_test") { + lines.push( + `- Work mode: ${quoteTaskScalar("skill_test")}`, + "", + "Skill test mode directive:", + "You are testing a pinned skill revision. Make no durable changes outside this issue. Do not push, publish, send external messages, or mutate other issues. Write your final output as issue document `output`, then finish by marking this issue done.", + ); } else if (acceptedPlanContinuation) { lines.push( "", @@ -4717,6 +4728,40 @@ export function normalizeSessionParams(params: Record | null | type RunSessionOutcome = "succeeded" | "interrupted" | "failed" | "cancelled" | "timed_out"; +type SkillTestHeartbeatCompletion = { + outcome: "failed" | "cancelled"; + error: string | null; + heartbeatOutcome: RunSessionOutcome; +}; + +export function resolveSkillTestRunCompletionForHeartbeatOutcome( + outcome: RunSessionOutcome, + error: string | null | undefined, +): SkillTestHeartbeatCompletion | null { + if (outcome === "cancelled") { + return { + outcome: "cancelled", + error: error ?? "Harness run was cancelled", + heartbeatOutcome: outcome, + }; + } + if (outcome === "timed_out") { + return { + outcome: "failed", + error: error ?? "Timed out", + heartbeatOutcome: outcome, + }; + } + if (outcome === "failed") { + return { + outcome: "failed", + error: error ?? "Adapter failed", + heartbeatOutcome: outcome, + }; + } + return null; +} + const HERMES_ADAPTER_TYPE = "hermes_local"; const HERMES_SESSION_ID_REGEX = /^(?:\d{8}_\d{6}_[A-Za-z0-9_-]{4,}|[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$/; @@ -4888,8 +4933,9 @@ function isTruthyRuntimeEnvValue(value: string | undefined) { export function resolveHeartbeatSchedulingSuppression( env: Record = process.env, + overrides: { allowWorktreeRunExecution?: boolean } = {}, ): { suppressed: boolean; reason: "worktree_instance" | "database_restore_in_progress" | null } { - if (isTruthyRuntimeEnvValue(env.PAPERCLIP_IN_WORKTREE)) { + if (isTruthyRuntimeEnvValue(env.PAPERCLIP_IN_WORKTREE) && !overrides.allowWorktreeRunExecution) { return { suppressed: true, reason: "worktree_instance" }; } if ( @@ -4907,7 +4953,37 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) enabled: (await instanceSettings.getGeneral()).censorUsernameInLogs, }); const runtimeEnv = options.runtimeEnv ?? process.env; - const getSchedulingSuppression = () => resolveHeartbeatSchedulingSuppression(runtimeEnv); + const inWorktreeRuntime = isTruthyRuntimeEnvValue(runtimeEnv.PAPERCLIP_IN_WORKTREE); + // Preview worktree instances suppress the run engine by default. Users can lift + // that per-worktree via the `enableWorktreeRunExecution` experimental setting + // (worktree instances have their own isolated DB, so it can't affect the parent). + // Only worktree runtimes ever read the setting; a short TTL keeps the hot-path + // suppression checks off the DB, and a read failure falls back to prior/default + // (fail closed to suppression). + let cachedWorktreeRunExecutionOverride: { value: boolean; at: number } = { value: false, at: 0 }; + const WORKTREE_RUN_EXECUTION_OVERRIDE_TTL_MS = 3_000; + const resolveWorktreeRunExecutionOverride = async (): Promise => { + if (!inWorktreeRuntime) return false; + const now = Date.now(); + if (now - cachedWorktreeRunExecutionOverride.at < WORKTREE_RUN_EXECUTION_OVERRIDE_TTL_MS) { + return cachedWorktreeRunExecutionOverride.value; + } + try { + const experimental = await instanceSettings.getExperimental(); + cachedWorktreeRunExecutionOverride = { + value: experimental.enableWorktreeRunExecution === true, + at: now, + }; + } catch { + // Keep the prior (default-false) value so a settings read failure fails + // closed to the safe suppressed state. + } + return cachedWorktreeRunExecutionOverride.value; + }; + const getSchedulingSuppression = async () => + resolveHeartbeatSchedulingSuppression(runtimeEnv, { + allowWorktreeRunExecution: await resolveWorktreeRunExecutionOverride(), + }); const runLogStore = getRunLogStore(); const secretsSvc = secretService(db); @@ -4938,6 +5014,72 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) const taskWatchdogs = taskWatchdogService(db, { enqueueWakeup }); let unsafeTextProjectionPromise: Promise | null = null; + async function completeSkillTestRunForHeartbeatOutcome(input: { + run: typeof heartbeatRuns.$inferSelect; + issueId: string | null; + issueWorkMode?: string | null; + outcome: RunSessionOutcome; + error: string | null; + }) { + const completion = resolveSkillTestRunCompletionForHeartbeatOutcome(input.outcome, input.error); + if (!completion || !input.issueId) return null; + + let isSkillTestIssue = input.issueWorkMode === "skill_test"; + if (!isSkillTestIssue && input.issueWorkMode === undefined) { + const issueRow = await db + .select({ + workMode: issues.workMode, + harnessKind: issues.harnessKind, + }) + .from(issues) + .where(and(eq(issues.companyId, input.run.companyId), eq(issues.id, input.issueId))) + .then((rows) => rows[0] ?? null); + isSkillTestIssue = issueRow?.workMode === "skill_test" || issueRow?.harnessKind === "skill_test"; + } + if (!isSkillTestIssue) return null; + + const existingRun = await db + .select({ + id: companySkillTestRuns.id, + status: companySkillTestRuns.status, + }) + .from(companySkillTestRuns) + .where(and( + eq(companySkillTestRuns.companyId, input.run.companyId), + eq(companySkillTestRuns.issueId, input.issueId), + )) + .then((rows) => rows[0] ?? null); + if (!existingRun || ["succeeded", "failed", "cancelled"].includes(existingRun.status)) return null; + + const completedRun = await companySkills.completeTestRunForIssue({ + companyId: input.run.companyId, + issueId: input.issueId, + outcome: completion.outcome, + error: completion.error, + }); + if (!completedRun) return null; + + await logActivity(db, { + companyId: input.run.companyId, + actorType: "system", + actorId: "heartbeat_finalize", + agentId: input.run.agentId, + runId: input.run.id, + action: "company.skill_test_run_completed", + entityType: "company_skill_test_run", + entityId: completedRun.id, + details: { + issueId: input.issueId, + status: completedRun.status, + outputDocumentKey: completedRun.outputDocumentKey, + heartbeatOutcome: completion.heartbeatOutcome, + source: "heartbeat.run_finalized", + }, + }); + + return completedRun; + } + async function releaseEnvironmentLeasesForRun(input: { runId: string; companyId: string; @@ -5103,6 +5245,54 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) .then((rows) => rows[0] ?? null); } + async function getPinnedSkillTestContext(companyId: string, issueId: string) { + const row = await db + .select({ + testRunId: companySkillTestRuns.id, + skillId: companySkillTestRuns.skillId, + inputId: companySkillTestRuns.inputId, + skillVersionId: companySkillTestRuns.skillVersionId, + outputDocumentKey: companySkillTestRuns.outputDocumentKey, + fileInventory: companySkillVersions.fileInventory, + revisionNumber: companySkillVersions.revisionNumber, + label: companySkillVersions.label, + }) + .from(companySkillTestRuns) + .innerJoin( + companySkillVersions, + and( + eq(companySkillVersions.id, companySkillTestRuns.skillVersionId), + eq(companySkillVersions.companyId, companySkillTestRuns.companyId), + ), + ) + .where(and(eq(companySkillTestRuns.companyId, companyId), eq(companySkillTestRuns.issueId, issueId))) + .then((rows) => rows[0] ?? null); + if (!row) return null; + const fileInventory = Array.isArray(row.fileInventory) + ? row.fileInventory.flatMap((entry) => { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) return []; + const record = entry as unknown as Record; + const path = typeof record.path === "string" ? record.path : ""; + if (!path) return []; + return [{ + path, + kind: typeof record.kind === "string" ? record.kind : "other", + content: typeof record.content === "string" ? record.content : "", + }]; + }) + : []; + return { + testRunId: row.testRunId, + skillId: row.skillId, + inputId: row.inputId ?? null, + skillVersionId: row.skillVersionId, + revisionNumber: row.revisionNumber, + label: row.label ?? null, + outputDocumentKey: row.outputDocumentKey, + fileInventory, + }; + } + async function getRoutineEnvForExecutionIssue( companyId: string, issueContext: Awaited> | null, @@ -5500,7 +5690,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) eq(issues.companyId, claimed.companyId), eq(issues.originKind, RECOVERY_ORIGIN_KINDS.strandedIssueRecovery), eq(issues.originId, claimed.id), - isNull(issues.hiddenAt), + visibleIssueCondition(), notInArray(issues.status, ["done", "cancelled"]), ), ) @@ -7120,7 +7310,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) RECOVERY_ORIGIN_KINDS.issueGraphLivenessEscalation, ]), eq(issues.originId, issue.id), - isNull(issues.hiddenAt), + visibleIssueCondition(), notInArray(issues.status, ["done", "cancelled"]), ), ) @@ -9979,7 +10169,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) } async function resumeQueuedRuns() { - if (getSchedulingSuppression().suppressed) return; + if ((await getSchedulingSuppression()).suppressed) return; const queuedRuns = await db .select({ agentId: heartbeatRuns.agentId }) @@ -10107,7 +10297,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) } async function startNextQueuedRunForAgent(agentId: string) { - if (getSchedulingSuppression().suppressed) return []; + if ((await getSchedulingSuppression()).suppressed) return []; return withAgentStartLock(agentId, async () => { const agent = await getAgent(agentId); @@ -10187,7 +10377,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) } async function executeRun(runId: string) { - if (getSchedulingSuppression().suppressed) return; + if ((await getSchedulingSuppression()).suppressed) return; let run = await getRun(runId); if (!run) return; @@ -10455,6 +10645,18 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) } else { delete context.paperclipContinuationSummary; } + const pinnedSkillTestContext = + issueRef?.workMode === "skill_test" + ? await getPinnedSkillTestContext(agent.companyId, issueRef.id) + : null; + if (pinnedSkillTestContext) { + context.paperclipSkillTest = { + ...pinnedSkillTestContext, + directive: "Use this pinned file inventory as the exact skill revision under test, regardless of synced runtime skills.", + }; + } else { + delete context.paperclipSkillTest; + } const paperclipWakePayload = await buildPaperclipWakePayload({ db, companyId: agent.companyId, @@ -11729,8 +11931,19 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) }; const adapter = getServerAdapter(agent.adapterType); + const localAgentJwtScope = + issueRef?.workMode === "skill_test" + ? { kind: "skill_test" as const, issueId: issueRef.id } + : { kind: "standard" as const }; const authToken = adapter.supportsLocalAgentJwt - ? createLocalAgentJwt(agent.id, agent.companyId, agent.adapterType, run.id, run.responsibleUserId) + ? createLocalAgentJwt( + agent.id, + agent.companyId, + agent.adapterType, + run.id, + run.responsibleUserId, + localAgentJwtScope, + ) : null; if (adapter.supportsLocalAgentJwt && !authToken) { logger.warn( @@ -12185,6 +12398,24 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) exitCode: adapterResult.exitCode, }, }); + try { + await completeSkillTestRunForHeartbeatOutcome({ + run: finalizedRun, + issueId, + issueWorkMode: issueRef?.workMode ?? null, + outcome, + error: runErrorMessage, + }); + } catch (err) { + logger.warn( + { err, runId: finalizedRun.id, issueId }, + "failed to complete skill test run after heartbeat finalization", + ); + await onLog( + "stderr", + `[paperclip] Failed to complete skill test run: ${err instanceof Error ? err.message : String(err)}\n`, + ); + } const livenessRun = finalizedRun; await refreshContinuationSummaryForRun(livenessRun, agent); const skipRunIssueComment = parseObject(livenessRun.contextSnapshot).skipIssueComment === true; @@ -12381,6 +12612,20 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) message, }); const livenessRun = await classifyAndPersistRunLiveness(failedRun) ?? failedRun; + try { + await completeSkillTestRunForHeartbeatOutcome({ + run: livenessRun, + issueId, + issueWorkMode: issueRef?.workMode ?? null, + outcome: "failed", + error: message, + }); + } catch (err) { + logger.warn( + { err, runId: livenessRun.id, issueId }, + "failed to complete skill test run after heartbeat adapter failure", + ); + } await refreshContinuationSummaryForRun(livenessRun, agent); if (!isWorkspaceValidationFailedRun(livenessRun) && !isConfigurationIncompleteFailedRun(livenessRun)) { await finalizeIssueCommentPolicy(livenessRun, agent); @@ -12476,6 +12721,20 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) message, }).catch(() => undefined); const livenessRun = await classifyAndPersistRunLiveness(failedRun).catch(() => failedRun); + const setupFailureIssueId = readNonEmptyString(parseObject(livenessRun.contextSnapshot).issueId); + if (setupFailureIssueId) { + await completeSkillTestRunForHeartbeatOutcome({ + run: livenessRun, + issueId: setupFailureIssueId, + outcome: "failed", + error: message, + }).catch((completionErr) => { + logger.warn( + { err: completionErr, runId: livenessRun.id, issueId: setupFailureIssueId }, + "failed to complete skill test run after heartbeat setup failure", + ); + }); + } const failedAgent = setupFailureAgent ?? await getAgent(run.agentId).catch(() => null); if (failedAgent) { await refreshContinuationSummaryForRun(livenessRun, failedAgent).catch(() => undefined); @@ -13420,7 +13679,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) }); }; - const schedulingSuppression = getSchedulingSuppression(); + const schedulingSuppression = await getSchedulingSuppression(); if (schedulingSuppression.suppressed) { await writeSkippedHeartbeatRequest("heartbeat.scheduling_suppressed", { reason: schedulingSuppression.reason, @@ -15008,6 +15267,10 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) reportRunActivity: clearDetachedRunWarning, reapOrphanedRuns, + // Override-aware scheduling-suppression check (honors the worktree + // run-execution experimental setting). Callers outside the service that + // gate on suppression should prefer this over the env-only resolver. + resolveSchedulingSuppression: getSchedulingSuppression, drainRunningRunsForShutdown, promoteDueScheduledRetries, @@ -15050,7 +15313,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) buildRunOutputSilence, tickTimers: async (now = new Date()) => { - if (getSchedulingSuppression().suppressed) { + if ((await getSchedulingSuppression()).suppressed) { return { checked: 0, enqueued: 0, diff --git a/server/src/services/instance-settings.ts b/server/src/services/instance-settings.ts index cdb0ef3047..55f08f1f29 100644 --- a/server/src/services/instance-settings.ts +++ b/server/src/services/instance-settings.ts @@ -59,6 +59,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta autoRestartDevServerWhenIdle: parsed.data.autoRestartDevServerWhenIdle ?? false, enableIssueGraphLivenessAutoRecovery: parsed.data.enableIssueGraphLivenessAutoRecovery ?? false, enableWorkspaceBranchReconcileForward: parsed.data.enableWorkspaceBranchReconcileForward ?? false, + enableWorktreeRunExecution: parsed.data.enableWorktreeRunExecution ?? false, issueGraphLivenessAutoRecoveryLookbackHours: parsed.data.issueGraphLivenessAutoRecoveryLookbackHours ?? DEFAULT_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS, @@ -80,6 +81,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta autoRestartDevServerWhenIdle: false, enableIssueGraphLivenessAutoRecovery: false, enableWorkspaceBranchReconcileForward: false, + enableWorktreeRunExecution: false, issueGraphLivenessAutoRecoveryLookbackHours: DEFAULT_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS, }; diff --git a/server/src/services/issue-visibility.ts b/server/src/services/issue-visibility.ts new file mode 100644 index 0000000000..8857e79bb1 --- /dev/null +++ b/server/src/services/issue-visibility.ts @@ -0,0 +1,10 @@ +import { and, isNull, type SQL } from "drizzle-orm"; +import { issues } from "@paperclipai/db"; + +export function visibleIssueCondition(): SQL { + return and(isNull(issues.hiddenAt), isNull(issues.harnessKind))!; +} + +export function visibleIssueSql(alias = "issues") { + return `"${alias}"."hidden_at" IS NULL AND "${alias}"."harness_kind" IS NULL`; +} diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 213a3dc044..2f75a672bf 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -101,6 +101,7 @@ import { RECOVERY_ORIGIN_KINDS, } from "./recovery/origins.js"; import { classifyIssueGraphLiveness, type IssueLivenessFinding } from "./recovery/issue-graph-liveness.js"; +import { visibleIssueCondition } from "./issue-visibility.js"; const ALL_ISSUE_STATUSES = ["backlog", "todo", "in_progress", "in_review", "blocked", "done", "cancelled"]; const MAX_ISSUE_COMMENT_PAGE_LIMIT = 500; @@ -1728,6 +1729,7 @@ async function liveDescendantCountMapForIssues( JOIN heartbeat_runs live_run ON live_run.id = live_issue.execution_run_id WHERE live_issue.company_id = ${companyId} AND live_issue.hidden_at IS NULL + AND live_issue.harness_kind IS NULL AND live_run.company_id = ${companyId} AND live_run.status IN ('queued', 'running') UNION @@ -1736,6 +1738,7 @@ async function liveDescendantCountMapForIssues( JOIN issues live_issue ON live_issue.id::text = (live_run.context_snapshot ->> 'issueId') WHERE live_issue.company_id = ${companyId} AND live_issue.hidden_at IS NULL + AND live_issue.harness_kind IS NULL AND live_run.company_id = ${companyId} AND live_run.status IN ('queued', 'running') ), @@ -1745,6 +1748,7 @@ async function liveDescendantCountMapForIssues( JOIN issues parent ON parent.id = live_issues.parent_id WHERE parent.company_id = ${companyId} AND parent.hidden_at IS NULL + AND parent.harness_kind IS NULL UNION ALL SELECT live_ancestors.live_issue_id, @@ -1755,6 +1759,7 @@ async function liveDescendantCountMapForIssues( JOIN issues parent ON parent.id = live_ancestors.next_parent_id WHERE parent.company_id = ${companyId} AND parent.hidden_at IS NULL + AND parent.harness_kind IS NULL AND NOT parent.id = ANY(live_ancestors.visited_issue_ids) ) SELECT @@ -1961,7 +1966,7 @@ async function listIssueProductivityReviewMap( eq(issues.companyId, companyId), eq(issues.originKind, PRODUCTIVITY_REVIEW_ORIGIN_KIND), inArray(issues.originId, chunk), - isNull(issues.hiddenAt), + visibleIssueCondition(), notInArray(issues.status, PRODUCTIVITY_REVIEW_TERMINAL_STATUSES), ), ) @@ -2226,7 +2231,7 @@ async function listIssueBlockerAttentionMap( and( eq(issues.companyId, companyId), eq(issues.originKind, BLOCKER_ATTENTION_OPEN_RECOVERY_ORIGIN_KIND), - isNull(issues.hiddenAt), + visibleIssueCondition(), notInArray(issues.status, BLOCKER_ATTENTION_OPEN_RECOVERY_TERMINAL_STATUSES), ), ); @@ -2429,6 +2434,7 @@ const issueListSelect = { `, status: issues.status, workMode: issues.workMode, + harnessKind: issues.harnessKind, priority: issues.priority, assigneeAgentId: issues.assigneeAgentId, assigneeUserId: issues.assigneeUserId, @@ -2978,7 +2984,7 @@ async function listIssueBlockedInboxAttentionMap( .from(issues) .where(and( eq(issues.companyId, companyId), - isNull(issues.hiddenAt), + visibleIssueCondition(), notInArray(issues.status, [...BLOCKED_INBOX_TERMINAL_STATUSES]), )), dbOrTx @@ -3388,7 +3394,7 @@ async function blockedInboxIssueConditions( ) { const conditions = [ eq(issues.companyId, companyId), - isNull(issues.hiddenAt), + visibleIssueCondition(), notInArray(issues.status, [...BLOCKED_INBOX_TERMINAL_STATUSES]), ]; const touchedByUserId = filters?.touchedByUserId?.trim() || undefined; @@ -4600,7 +4606,7 @@ export function issueService(db: Db) { }); } - const conditions = [eq(issues.companyId, companyId)]; + const conditions = [eq(issues.companyId, companyId), visibleIssueCondition()]; const assigneeAgentFilter = parseIssueAssigneeAgentFilter(filters?.assigneeAgentId); assertValidAssigneeAgentFilter(assigneeAgentFilter); const limit = typeof filters?.limit === "number" && Number.isFinite(filters.limit) @@ -4723,8 +4729,6 @@ export function issueService(db: Db) { if (filters?.excludeRoutineExecutions && !filters?.originKind && !filters?.originId) { conditions.push(ne(issues.originKind, "routine_execution")); } - conditions.push(isNull(issues.hiddenAt)); - const priorityOrder = sql`CASE ${issues.priority} WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 ELSE 4 END`; const searchOrder = sql` CASE @@ -4847,7 +4851,7 @@ export function issueService(db: Db) { return countBlockedInboxIssues(db, companyId, filters); } - const conditions = [eq(issues.companyId, companyId), isNull(issues.hiddenAt)]; + const conditions = [eq(issues.companyId, companyId), visibleIssueCondition()]; const statuses = parseStatusFilter(filters?.status); if (statuses.length === 1) conditions.push(eq(issues.status, statuses[0]!)); else if (statuses.length > 1) conditions.push(inArray(issues.status, statuses)); @@ -4889,7 +4893,7 @@ export function issueService(db: Db) { ) => { const conditions = [ eq(issues.companyId, companyId), - isNull(issues.hiddenAt), + visibleIssueCondition(), nonPluginOperationIssueCondition(), unreadForUserCondition(companyId, userId), ]; @@ -5234,6 +5238,7 @@ export function issueService(db: Db) { WHERE company_id = ${issue.companyId} AND id = ${issue.id} AND hidden_at IS NULL + AND harness_kind IS NULL UNION ALL SELECT child.id, @@ -5254,6 +5259,7 @@ export function issueService(db: Db) { JOIN issue_tree ON child.parent_id = issue_tree.id WHERE child.company_id = ${issue.companyId} AND child.hidden_at IS NULL + AND child.harness_kind IS NULL AND issue_tree.depth < ${maxDepth + 1} AND NOT child.id = ANY(issue_tree.path) ) @@ -5320,6 +5326,7 @@ export function issueService(db: Db) { AND relation.type = 'blocks' AND blocker.company_id = ${issue.companyId} AND blocker.hidden_at IS NULL + AND blocker.harness_kind IS NULL AND relation.related_issue_id::text IN (${nodeIdValues}) ) SELECT * diff --git a/server/src/services/pipeline-case-outputs.ts b/server/src/services/pipeline-case-outputs.ts index 01fc0edb7c..c71701a2c2 100644 --- a/server/src/services/pipeline-case-outputs.ts +++ b/server/src/services/pipeline-case-outputs.ts @@ -25,6 +25,7 @@ import { type SourceTrustMetadata, } from "@paperclipai/shared"; import { notFound } from "../errors.js"; +import { visibleIssueCondition } from "./issue-visibility.js"; import { isLowTrustQuarantined, LOW_TRUST_QUARANTINED_BODY } from "./source-trust.js"; const PREVIEW_TEXT_MAX_LENGTH = 500; @@ -300,7 +301,7 @@ export function pipelineCaseOutputsService(db: Db) { eq(pipelineCaseIssueLinks.caseId, caseId), isNull(pipelineCaseIssueLinks.retiredAt), eq(issues.companyId, companyId), - isNull(issues.hiddenAt), + visibleIssueCondition(), isNull(issues.cancelledAt), ne(issues.status, "cancelled"), )) diff --git a/server/src/services/pipelines-aggregation.ts b/server/src/services/pipelines-aggregation.ts index 0a6ef574fd..e89d8e27a0 100644 --- a/server/src/services/pipelines-aggregation.ts +++ b/server/src/services/pipelines-aggregation.ts @@ -12,6 +12,7 @@ import { routines, } from "@paperclipai/db"; import { notFound } from "../errors.js"; +import { visibleIssueCondition } from "./issue-visibility.js"; export const PIPELINE_ATTENTION_DEFAULT_LIMIT = 50; export const PIPELINE_ATTENTION_MAX_LIMIT = 100; @@ -569,7 +570,7 @@ export async function loadActiveWorkForCases( inArray(pipelineCaseIssueLinks.role, ["work", "automation"]), eq(issues.companyId, companyId), eq(issues.status, "in_progress"), - isNull(issues.hiddenAt), + visibleIssueCondition(), )) .orderBy(desc(issues.updatedAt)); for (const row of rows) { @@ -721,7 +722,7 @@ async function loadOpenWorkIssuesForCases(db: Db, companyId: string, caseIds: st eq(issues.companyId, companyId), ne(issues.status, "done"), ne(issues.status, "cancelled"), - isNull(issues.hiddenAt), + visibleIssueCondition(), )) .orderBy(desc(issues.updatedAt)); for (const row of rows) { diff --git a/server/src/services/pipelines.ts b/server/src/services/pipelines.ts index 558339fb52..a4a58a42ef 100644 --- a/server/src/services/pipelines.ts +++ b/server/src/services/pipelines.ts @@ -49,6 +49,7 @@ import type { IssueAssignmentWakeupDeps } from "./issue-assignment-wakeup.js"; import { logActivity } from "./activity-log.js"; import { assertAssignableAgent } from "./agent-assignability.js"; import { authorizationService } from "./authorization.js"; +import { visibleIssueCondition } from "./issue-visibility.js"; import { formatPipelineCaseOutputContextMarkdown, pipelineCaseOutputsService, @@ -359,7 +360,7 @@ async function getUsableConversationIssue(db: PipelineDb, companyId: string, iss .where(and( eq(issues.companyId, companyId), eq(issues.id, issueId), - isNull(issues.hiddenAt), + visibleIssueCondition(), isNull(issues.cancelledAt), ne(issues.status, "cancelled"), )) @@ -408,7 +409,7 @@ async function resolveLatestCaseIssueLink( eq(pipelineCaseIssueLinks.caseId, input.caseId), inArray(pipelineCaseIssueLinks.role, input.roles), eq(issues.companyId, input.companyId), - isNull(issues.hiddenAt), + visibleIssueCondition(), isNull(issues.cancelledAt), ne(issues.status, "cancelled"), )) @@ -1924,7 +1925,7 @@ async function postSystemCommentOnLinkedIssues( inArray(pipelineCaseIssueLinks.role, input.roles), ne(issues.status, "done"), ne(issues.status, "cancelled"), - isNull(issues.hiddenAt), + visibleIssueCondition(), )); for (const row of rows) { @@ -2022,7 +2023,7 @@ async function notifyDependentWorkIssuesOfUpstreamContentChange( eq(issues.companyId, input.companyId), ne(issues.status, "done"), ne(issues.status, "cancelled"), - isNull(issues.hiddenAt), + visibleIssueCondition(), )); const issueIdsByCase = new Map(); for (const row of linkRows) { diff --git a/server/src/services/productivity-review.ts b/server/src/services/productivity-review.ts index e7fad83898..45962040ba 100644 --- a/server/src/services/productivity-review.ts +++ b/server/src/services/productivity-review.ts @@ -14,6 +14,7 @@ import { logger } from "../middleware/logger.js"; import { logActivity } from "./activity-log.js"; import { budgetService } from "./budgets.js"; import { issueService } from "./issues.js"; +import { visibleIssueCondition } from "./issue-visibility.js"; import { recoveryAssigneeAdapterOverrides, withRecoveryModelProfileHint, @@ -250,7 +251,7 @@ export function productivityReviewService(db: Db, deps?: { enqueueWakeup?: Enque eq(issues.companyId, companyId), eq(issues.originKind, PRODUCTIVITY_REVIEW_ORIGIN_KIND), eq(issues.originId, sourceIssueId), - isNull(issues.hiddenAt), + visibleIssueCondition(), notInArray(issues.status, ["done", "cancelled"]), ), ) @@ -298,7 +299,7 @@ export function productivityReviewService(db: Db, deps?: { enqueueWakeup?: Enque eq(issues.companyId, companyId), eq(issues.originKind, PRODUCTIVITY_REVIEW_ORIGIN_KIND), eq(issues.originId, sourceIssueId), - isNull(issues.hiddenAt), + visibleIssueCondition(), sql`${issues.status} <> 'cancelled'`, sql`${issues.createdAt} >= ${cutoff.toISOString()}::timestamptz`, ), @@ -771,7 +772,7 @@ export function productivityReviewService(db: Db, deps?: { enqueueWakeup?: Enque .where( and( opts?.companyId ? eq(issues.companyId, opts.companyId) : undefined, - isNull(issues.hiddenAt), + visibleIssueCondition(), isNull(issues.assigneeUserId), inArray(issues.status, ["todo", "in_progress"]), sql`${issues.assigneeAgentId} is not null`, diff --git a/server/src/services/recovery/service.ts b/server/src/services/recovery/service.ts index c9ffde514c..336f5a8fb0 100644 --- a/server/src/services/recovery/service.ts +++ b/server/src/services/recovery/service.ts @@ -26,6 +26,7 @@ import { } from "@paperclipai/db"; import { parseObject, asBoolean, asNumber } from "../../adapters/utils.js"; import { runningProcesses } from "../../adapters/index.js"; +import { visibleIssueCondition } from "../issue-visibility.js"; import { forbidden, notFound } from "../../errors.js"; import { logger } from "../../middleware/logger.js"; import { isPidAlive, isProcessGroupAlive, terminateLocalService } from "../local-service-supervisor.js"; @@ -1010,7 +1011,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) eq(issues.companyId, companyId), eq(issues.originKind, STALE_ACTIVE_RUN_EVALUATION_ORIGIN_KIND), eq(issues.originId, runId), - isNull(issues.hiddenAt), + visibleIssueCondition(), notInArray(issues.status, ["done", "cancelled"]), ), ) @@ -1036,7 +1037,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) eq(issues.companyId, companyId), eq(issues.originKind, STALE_ACTIVE_RUN_EVALUATION_ORIGIN_KIND), eq(issues.originId, runId), - isNull(issues.hiddenAt), + visibleIssueCondition(), eq(issues.status, "done"), ), ) @@ -1133,7 +1134,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) const [issue] = await db .select() .from(issues) - .where(and(eq(issues.companyId, run.companyId), eq(issues.id, issueId), isNull(issues.hiddenAt))) + .where(and(eq(issues.companyId, run.companyId), eq(issues.id, issueId), visibleIssueCondition())) .limit(1); return issue ?? null; } @@ -1499,7 +1500,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) ? db .select({ id: issues.id, identifier: issues.identifier, title: issues.title, status: issues.status }) .from(issues) - .where(and(eq(issues.companyId, input.run.companyId), eq(issues.parentId, input.sourceIssue.id), isNull(issues.hiddenAt))) + .where(and(eq(issues.companyId, input.run.companyId), eq(issues.parentId, input.sourceIssue.id), visibleIssueCondition())) .orderBy(desc(issues.updatedAt)) .limit(8) : Promise.resolve([]), @@ -2100,7 +2101,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) eq(issues.companyId, companyId), eq(issues.originKind, STRANDED_ISSUE_RECOVERY_ORIGIN_KIND), eq(issues.originId, sourceIssueId), - isNull(issues.hiddenAt), + visibleIssueCondition(), notInArray(issues.status, ["done", "cancelled"]), ), ) @@ -2641,7 +2642,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) and( eq(issues.companyId, issue.companyId), eq(issues.parentId, issue.id), - isNull(issues.hiddenAt), + visibleIssueCondition(), notInArray(issues.status, ["done", "cancelled"]), ), ); @@ -3332,7 +3333,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) .from(issues) .where( and( - isNull(issues.hiddenAt), + visibleIssueCondition(), notInArray(issues.originKind, [RECOVERY_ORIGIN_KINDS.issueGraphLivenessEscalation]), ), )); @@ -3389,7 +3390,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) .innerJoin(heartbeatRuns, eq(issues.executionRunId, heartbeatRuns.id)) .where( and( - isNull(issues.hiddenAt), + visibleIssueCondition(), notInArray(issues.originKind, [RECOVERY_ORIGIN_KINDS.issueGraphLivenessEscalation]), inArray(heartbeatRuns.status, [...EXECUTION_PATH_HEARTBEAT_RUN_STATUSES]), ), @@ -3431,7 +3432,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) .from(issues) .where( and( - isNull(issues.hiddenAt), + visibleIssueCondition(), inArray(issues.originKind, [ STRANDED_ISSUE_RECOVERY_ORIGIN_KIND, RECOVERY_ORIGIN_KINDS.issueGraphLivenessEscalation, @@ -3523,7 +3524,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) eq(issues.companyId, companyId), eq(issues.originKind, RECOVERY_ORIGIN_KINDS.issueGraphLivenessEscalation), eq(issues.originId, incidentKey), - isNull(issues.hiddenAt), + visibleIssueCondition(), notInArray(issues.status, ["done", "cancelled"]), ), ) @@ -3540,7 +3541,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) eq(issues.companyId, finding.companyId), eq(issues.originKind, RECOVERY_ORIGIN_KINDS.issueGraphLivenessEscalation), eq(issues.originFingerprint, livenessRecoveryLeafFingerprint(finding)), - isNull(issues.hiddenAt), + visibleIssueCondition(), notInArray(issues.status, ["done", "cancelled"]), ), ) @@ -3556,7 +3557,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) and( eq(issues.companyId, finding.companyId), eq(issues.originKind, RECOVERY_ORIGIN_KINDS.issueGraphLivenessEscalation), - isNull(issues.hiddenAt), + visibleIssueCondition(), notInArray(issues.status, ["done", "cancelled"]), ), ); @@ -3633,7 +3634,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) .where( and( eq(issues.originKind, RECOVERY_ORIGIN_KINDS.issueGraphLivenessEscalation), - isNull(issues.hiddenAt), + visibleIssueCondition(), notInArray(issues.status, ["done", "cancelled"]), ), ); @@ -3692,7 +3693,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) .where( and( eq(issues.originKind, RECOVERY_ORIGIN_KINDS.issueGraphLivenessEscalation), - isNull(issues.hiddenAt), + visibleIssueCondition(), inArray(issues.status, ["done", "cancelled"]), ), ); @@ -4112,7 +4113,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) const queryCandidates = (afterIssueId: string | null) => { const filters = [ eq(issues.status, "blocked"), - isNull(issues.hiddenAt), + visibleIssueCondition(), sql`${issues.assigneeAgentId} is not null`, ]; if (opts?.companyId) filters.push(eq(issues.companyId, opts.companyId)); diff --git a/server/src/services/routines.ts b/server/src/services/routines.ts index 5fcd373dae..0d21e4b026 100644 --- a/server/src/services/routines.ts +++ b/server/src/services/routines.ts @@ -60,6 +60,7 @@ import { getTelemetryClient } from "../telemetry.js"; import { getConfiguredSecretProvider } from "../secrets/configured-provider.js"; import { issueService } from "./issues.js"; import { assertAssignableAgent } from "./agent-assignability.js"; +import { visibleIssueCondition } from "./issue-visibility.js"; import { secretService } from "./secrets.js"; import { getSecretProvider } from "../secrets/provider-registry.js"; import { parseCron, validateCron } from "./cron.js"; @@ -1060,7 +1061,7 @@ export function routineService( eq(issues.originKind, "routine_execution"), inArray(issues.originId, routineIds), inArray(issues.status, OPEN_ISSUE_STATUSES), - isNull(issues.hiddenAt), + visibleIssueCondition(), ), ) .orderBy(issues.originId, desc(issues.updatedAt), desc(issues.createdAt)); @@ -1098,7 +1099,7 @@ export function routineService( eq(issues.originKind, "routine_execution"), inArray(issues.originId, missingRoutineIds), inArray(issues.status, OPEN_ISSUE_STATUSES), - isNull(issues.hiddenAt), + visibleIssueCondition(), ), ) .orderBy(issues.originId, desc(issues.updatedAt), desc(issues.createdAt)); @@ -1250,7 +1251,7 @@ export function routineService( eq(issues.originKind, originKind), eq(issues.originId, originId), inArray(issues.status, OPEN_ISSUE_STATUSES), - isNull(issues.hiddenAt), + visibleIssueCondition(), ...(fingerprintCondition ? [fingerprintCondition] : []), ), ) @@ -1276,7 +1277,7 @@ export function routineService( eq(issues.originKind, originKind), eq(issues.originId, originId), inArray(issues.status, OPEN_ISSUE_STATUSES), - isNull(issues.hiddenAt), + visibleIssueCondition(), ...(fingerprintCondition ? [fingerprintCondition] : []), ), ) diff --git a/server/src/services/task-watchdogs.ts b/server/src/services/task-watchdogs.ts index 3e2be28e94..daee9a52c7 100644 --- a/server/src/services/task-watchdogs.ts +++ b/server/src/services/task-watchdogs.ts @@ -21,6 +21,7 @@ import { parseObject } from "../adapters/utils.js"; import { logActivity } from "./activity-log.js"; import { evaluateAgentInvokabilityFromDb } from "./agent-invokability.js"; import { issueService } from "./issues.js"; +import { visibleIssueCondition } from "./issue-visibility.js"; import { TASK_WATCHDOG_ORIGIN_KIND } from "./task-watchdog-scope.js"; const TASK_WATCHDOG_STOP_FINGERPRINT_PREFIX = "task_watchdog_stop:"; @@ -740,6 +741,7 @@ export function taskWatchdogService(db: Db, deps: TaskWatchdogServiceDeps = {}) WHERE company_id = ${companyId} AND id = ${watchedIssueId} AND hidden_at IS NULL + AND harness_kind IS NULL UNION ALL SELECT child.id, @@ -758,6 +760,7 @@ export function taskWatchdogService(db: Db, deps: TaskWatchdogServiceDeps = {}) JOIN watched_issues ON child.parent_id = watched_issues.id WHERE child.company_id = ${companyId} AND child.hidden_at IS NULL + AND child.harness_kind IS NULL AND child.origin_kind <> ${TASK_WATCHDOG_ORIGIN_KIND} AND watched_issues.depth < ${TASK_WATCHDOG_SUBTREE_MAX_DEPTH - 1} ) @@ -836,7 +839,7 @@ export function taskWatchdogService(db: Db, deps: TaskWatchdogServiceDeps = {}) .where(and( eq(issues.companyId, companyId), inArray(issues.id, subtreeIssueIds), - isNull(issues.hiddenAt), + visibleIssueCondition(), inArray(heartbeatRuns.status, [...TASK_WATCHDOG_LIVE_RUN_STATUSES]), )), db @@ -1025,7 +1028,7 @@ export function taskWatchdogService(db: Db, deps: TaskWatchdogServiceDeps = {}) eq(issues.companyId, companyId), eq(issues.originKind, TASK_WATCHDOG_ORIGIN_KIND), eq(issues.originId, watchedIssueId), - isNull(issues.hiddenAt), + visibleIssueCondition(), )) .orderBy(asc(issues.createdAt), asc(issues.id)) .limit(1) @@ -1177,7 +1180,7 @@ export function taskWatchdogService(db: Db, deps: TaskWatchdogServiceDeps = {}) .where(and( eq(issues.companyId, input.watchdog.companyId), eq(issues.id, input.watchdog.watchdogIssueId), - isNull(issues.hiddenAt), + visibleIssueCondition(), )) .then((rows) => rows[0] ?? null) : null; @@ -1280,7 +1283,7 @@ export function taskWatchdogService(db: Db, deps: TaskWatchdogServiceDeps = {}) const sourceIssue = await db .select() .from(issues) - .where(and(eq(issues.companyId, watchdog.companyId), eq(issues.id, watchdog.issueId), isNull(issues.hiddenAt))) + .where(and(eq(issues.companyId, watchdog.companyId), eq(issues.id, watchdog.issueId), visibleIssueCondition())) .then((rows) => rows[0] ?? null); if (!sourceIssue || sourceIssue.originKind === TASK_WATCHDOG_ORIGIN_KIND) { return { state: "skipped" as const, reason: "watched_issue_not_applicable" }; @@ -1314,7 +1317,7 @@ export function taskWatchdogService(db: Db, deps: TaskWatchdogServiceDeps = {}) .where(and( eq(issues.companyId, watchdog.companyId), eq(issues.id, existingWatchdogIssueId), - isNull(issues.hiddenAt), + visibleIssueCondition(), )) .then((rows) => rows[0] ?? null) : null; @@ -1420,12 +1423,14 @@ export function taskWatchdogService(db: Db, deps: TaskWatchdogServiceDeps = {}) WHERE company_id = ${companyId} AND id = ${issueId} AND hidden_at IS NULL + AND harness_kind IS NULL UNION ALL SELECT parent.id, parent.parent_id, ancestors.depth + 1 FROM issues parent JOIN ancestors ON parent.id = ancestors.parent_id WHERE parent.company_id = ${companyId} AND parent.hidden_at IS NULL + AND parent.harness_kind IS NULL AND ancestors.depth < ${TASK_WATCHDOG_SUBTREE_MAX_DEPTH - 1} ) SELECT id FROM ancestors diff --git a/server/src/services/work-timeline.ts b/server/src/services/work-timeline.ts index 46d36976da..96e7e6722b 100644 --- a/server/src/services/work-timeline.ts +++ b/server/src/services/work-timeline.ts @@ -11,6 +11,7 @@ import { issues, issueThreadInteractions, } from "@paperclipai/db"; +import { visibleIssueCondition } from "./issue-visibility.js"; // DTO types are shared with the UI via @paperclipai/shared so both sides consume // one contract. Re-exported here for back-compat with existing server imports. @@ -205,7 +206,7 @@ export function workTimelineService(db: Db) { const filterConditions = [ eq(issues.companyId, input.companyId), - isNull(issues.hiddenAt), + visibleIssueCondition(), input.goalId ? eq(issues.goalId, input.goalId) : undefined, input.projectId ? eq(issues.projectId, input.projectId) : undefined, input.issueId ? eq(issues.id, input.issueId) : undefined, @@ -331,7 +332,7 @@ export function workTimelineService(db: Db) { .where( and( eq(issues.companyId, input.companyId), - isNull(issues.hiddenAt), + visibleIssueCondition(), inArray(issues.id, issueIds), input.goalId ? eq(issues.goalId, input.goalId) : undefined, input.projectId ? eq(issues.projectId, input.projectId) : undefined, diff --git a/tests/e2e/signoff-policy.spec.ts b/tests/e2e/signoff-policy.spec.ts index 9e98810ee3..0ffb2d5816 100644 --- a/tests/e2e/signoff-policy.spec.ts +++ b/tests/e2e/signoff-policy.spec.ts @@ -75,6 +75,27 @@ async function getIssueRunLockState(board: APIRequestContext, issueId: string): }; } +async function retryAgentPatchWithCurrentLockOnConflict( + board: APIRequestContext, + agent: AgentAuth, + issueId: string, + failedRes: Awaited>, + patchData: Record, +) { + if (failedRes.status() !== 409) return failedRes; + const issueRunLock = await getIssueRunLockState(board, issueId); + if (issueRunLock.assigneeAgentId !== agent.agentId) return failedRes; + + const lockedRunId = issueRunLock.checkoutRunId ?? issueRunLock.executionRunId; + if (!lockedRunId) return failedRes; + + const retryRes = await agent.request.patch(`${BASE_URL}/api/issues/${issueId}`, { + headers: { "X-Paperclip-Run-Id": lockedRunId }, + data: patchData, + }); + return retryRes.ok() ? retryRes : failedRes; +} + /** PATCH an issue as an agent with a fresh heartbeat run ID. */ async function agentPatch( board: APIRequestContext, @@ -112,13 +133,8 @@ async function agentCheckoutAndPatch( }); if (!checkoutRes.ok()) { if (checkoutRes.status() === 409) { - const issueRunLock = await getIssueRunLockState(board, issueId); - const lockedRunId = issueRunLock.checkoutRunId ?? issueRunLock.executionRunId; - const res = await agent.request.patch(`${BASE_URL}/api/issues/${issueId}`, { - headers: { "X-Paperclip-Run-Id": lockedRunId ?? runId }, - data: patchData, - }); - if (res.ok() && issueRunLock.assigneeAgentId === agent.agentId) { + const res = await retryAgentPatchWithCurrentLockOnConflict(board, agent, issueId, checkoutRes, patchData); + if (res.ok()) { return res; } } @@ -141,7 +157,7 @@ async function agentCheckoutAndPatch( headers: { "X-Paperclip-Run-Id": runId }, data: patchData, }); - return res; + return retryAgentPatchWithCurrentLockOnConflict(board, agent, issueId, res, patchData); } async function setupCompany(boardRequest: APIRequestContext): Promise { @@ -380,10 +396,14 @@ test.describe("Signoff execution policy", () => { const issueId = issue.id; // Executor marks done → routes to reviewer - await agentCheckoutAndPatch( + const doneRes = await agentCheckoutAndPatch( ctx.boardRequest, ctx.executor, issueId, ["in_progress"], { status: "done", comment: "Done." }, ); + expect(doneRes.ok()).toBe(true); + const doneIssue = await doneRes.json(); + expect(doneIssue.status).toBe("in_review"); + expect(doneIssue.assigneeAgentId).toBe(ctx.reviewer.agentId); // Reviewer tries to approve without comment → should fail const noCommentRes = await agentPatch( diff --git a/ui/package.json b/ui/package.json index cab88c52e9..1e545b206d 100644 --- a/ui/package.json +++ b/ui/package.json @@ -44,8 +44,8 @@ "@paperclipai/adapter-opencode-local": "workspace:*", "@paperclipai/adapter-pi-local": "workspace:*", "@paperclipai/adapter-utils": "workspace:*", - "@paperclipai/shared": "workspace:*", "@paperclipai/hermes-paperclip-adapter": "workspace:*", + "@paperclipai/shared": "workspace:*", "@radix-ui/react-slot": "^1.3.0", "@tailwindcss/typography": "^0.5.20", "@tanstack/react-query": "^5.101.2", @@ -63,22 +63,23 @@ "react-dom": "^19.2.7", "react-i18next": "^17.0.7", "react-markdown": "^10.1.0", + "react-resizable-panels": "^4.12.1", "react-router-dom": "^7.16.0", "remark-gfm": "^4.0.1", "tailwind-merge": "^3.6.0" }, "devDependencies": { - "@tailwindcss/vite": "^4.3.0", "@storybook/addon-a11y": "10.4.2", "@storybook/addon-docs": "10.4.6", "@storybook/react-vite": "10.4.6", + "@tailwindcss/vite": "^4.3.0", "@types/node": "^22.19.21", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^4.3.4", + "storybook": "10.4.6", "tailwindcss": "^4.3.0", "typescript": "^5.7.3", - "storybook": "10.4.6", "vite": "^6.1.0", "vitest": "^4.1.8" } diff --git a/ui/src/App.test.tsx b/ui/src/App.test.tsx index ed6bba5d9e..9f7b3cd32d 100644 --- a/ui/src/App.test.tsx +++ b/ui/src/App.test.tsx @@ -6,6 +6,7 @@ import { createRoot } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { CloudAccessGate } from "./components/CloudAccessGate"; +import appSource from "./App.tsx?raw"; const mockHealthApi = vi.hoisted(() => ({ get: vi.fn(), @@ -227,3 +228,17 @@ describe("CloudAccessGate", () => { unmountRoot(root); }); }); + +describe("Skill Studio routes", () => { + it("registers create mode before the skillId route in prefixed and unprefixed routing", () => { + const createRoute = 'path="skills/studio/new"'; + const detailRoute = 'path="skills/studio/:skillId"'; + const createIndexes = [...appSource.matchAll(new RegExp(createRoute, "g"))].map((match) => match.index ?? -1); + const detailIndexes = [...appSource.matchAll(new RegExp(detailRoute, "g"))].map((match) => match.index ?? -1); + + expect(createIndexes).toHaveLength(2); + expect(detailIndexes).toHaveLength(2); + expect(createIndexes[0]).toBeLessThan(detailIndexes[0]!); + expect(createIndexes[1]).toBeLessThan(detailIndexes[1]!); + }); +}); diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 943b2ecd54..54797e9945 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -45,6 +45,7 @@ import { CompanySettingsPluginPage } from "./pages/CompanySettingsPluginPage"; import { CompanyAccess, CompanyAccessLegacyRoute } from "./pages/CompanyAccess"; import { CompanyInvites } from "./pages/CompanyInvites"; import { CompanySkills } from "./pages/CompanySkills"; +import { SkillStudio } from "./pages/SkillStudio"; import { Secrets } from "./pages/Secrets"; import { CompanyExport } from "./pages/CompanyExport"; import { CompanyImport } from "./pages/CompanyImport"; @@ -105,6 +106,10 @@ function boardRoutes() { } /> } /> } /> + } /> + } /> + } /> + } /> } /> } /> } /> @@ -220,6 +225,33 @@ function InboxRootRedirect() { return ; } +function LegacySkillStudioRedirect() { + const location = useLocation(); + const { companies, selectedCompany, loading } = useCompany(); + const { companyPrefix, skillId } = useParams<{ companyPrefix?: string; skillId?: string }>(); + + if (loading) return null; + + const targetCompany = + (companyPrefix + ? companies.find((company) => company.issuePrefix.toUpperCase() === companyPrefix.toUpperCase()) + : null) ?? + selectedCompany ?? + companies[0] ?? + null; + + if (!targetCompany || !skillId) { + return ; + } + + return ( + + ); +} + function LegacySettingsRedirect() { const location = useLocation(); const { companies, selectedCompany, loading } = useCompany(); @@ -421,6 +453,10 @@ export function App() { } /> } /> } /> + } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/ui/src/api/client.ts b/ui/src/api/client.ts index d3bf0a5eea..69b51b6470 100644 --- a/ui/src/api/client.ts +++ b/ui/src/api/client.ts @@ -47,4 +47,6 @@ export const api = { patch: (path: string, body: unknown) => request(path, { method: "PATCH", body: JSON.stringify(body) }), delete: (path: string) => request(path, { method: "DELETE" }), + deleteWithBody: (path: string, body: unknown) => + request(path, { method: "DELETE", body: JSON.stringify(body) }), }; diff --git a/ui/src/api/companySkills.ts b/ui/src/api/companySkills.ts index 1e3aeb8f3d..731a8f6da7 100644 --- a/ui/src/api/companySkills.ts +++ b/ui/src/api/companySkills.ts @@ -10,7 +10,11 @@ import type { CompanySkillCreateRequest, CompanySkillDetail, CompanySkillFileDetail, + CompanySkillFileDeleteRequest, + CompanySkillFileDeleteResult, + CompanySkillForkPrecheckResult, CompanySkillForkRequest, + CompanySkillForkResult, CompanySkillImportResult, CompanySkillInstallCatalogRequest, CompanySkillInstallCatalogResult, @@ -19,6 +23,16 @@ import type { CompanySkillProjectScanRequest, CompanySkillProjectScanResult, CompanySkillStarResult, + CompanySkillTestInput, + CompanySkillTestInputCreateRequest, + CompanySkillTestInputUpdateRequest, + CompanySkillTestRun, + CompanySkillTestRunCreateRequest, + CompanySkillTestRunDetail, + CompanySkillTestRunListQuery, + CompanySkillTestRunTemplate, + CompanySkillTestRunTemplateCreateRequest, + CompanySkillTestRunTemplateUpdateRequest, CompanySkillUpdateRequest, CompanySkillUpdateStatus, CompanySkillVersion, @@ -39,6 +53,7 @@ export const companySkillsApi = { if (query.sort) params.set("sort", query.sort); if (query.scope) params.set("scope", query.scope); for (const category of query.categories ?? []) params.append("categories[]", category); + for (const include of query.include ?? []) params.append("include[]", include); const search = params.toString(); return api.get(`/companies/${encodeURIComponent(companyId)}/skills${search ? `?${search}` : ""}`); }, @@ -61,6 +76,76 @@ export const companySkillsApi = { `/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/versions`, payload, ), + // --- Skill Studio test inputs (PAP-12960 P1 API) --- + testInputs: (companyId: string, skillId: string) => + api.get( + `/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/test-inputs`, + ), + createTestInput: (companyId: string, skillId: string, payload: CompanySkillTestInputCreateRequest) => + api.post( + `/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/test-inputs`, + payload, + ), + updateTestInput: ( + companyId: string, + skillId: string, + inputId: string, + payload: CompanySkillTestInputUpdateRequest, + ) => + api.patch( + `/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/test-inputs/${encodeURIComponent(inputId)}`, + payload, + ), + deleteTestInput: (companyId: string, skillId: string, inputId: string) => + api.delete( + `/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/test-inputs/${encodeURIComponent(inputId)}`, + ), + // --- Skill Studio cross-skill run templates --- + testRunTemplates: (companyId: string) => + api.get( + `/companies/${encodeURIComponent(companyId)}/skill-test-run-templates`, + ), + createTestRunTemplate: (companyId: string, payload: CompanySkillTestRunTemplateCreateRequest) => + api.post( + `/companies/${encodeURIComponent(companyId)}/skill-test-run-templates`, + payload, + ), + updateTestRunTemplate: (companyId: string, templateId: string, payload: CompanySkillTestRunTemplateUpdateRequest) => + api.patch( + `/companies/${encodeURIComponent(companyId)}/skill-test-run-templates/${encodeURIComponent(templateId)}`, + payload, + ), + deleteTestRunTemplate: (companyId: string, templateId: string) => + api.delete( + `/companies/${encodeURIComponent(companyId)}/skill-test-run-templates/${encodeURIComponent(templateId)}`, + ), + // --- Skill Studio test runs --- + testRuns: (companyId: string, skillId: string, query: CompanySkillTestRunListQuery = {}) => { + const params = new URLSearchParams(); + if (query.inputId) params.set("inputId", query.inputId); + const search = params.toString(); + return api.get( + `/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/test-runs${search ? `?${search}` : ""}`, + ); + }, + testRunDetail: (companyId: string, skillId: string, runId: string) => + api.get( + `/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/test-runs/${encodeURIComponent(runId)}`, + ), + createTestRun: (companyId: string, skillId: string, payload: CompanySkillTestRunCreateRequest) => + api.post( + `/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/test-runs`, + payload, + ), + cancelTestRun: (companyId: string, skillId: string, runId: string) => + api.post( + `/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/test-runs/${encodeURIComponent(runId)}/cancel`, + {}, + ), + deleteTestRun: (companyId: string, skillId: string, runId: string) => + api.delete( + `/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/test-runs/${encodeURIComponent(runId)}`, + ), star: (companyId: string, skillId: string) => api.post( `/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/star`, @@ -71,10 +156,14 @@ export const companySkillsApi = { `/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/star`, ), fork: (companyId: string, skillId: string, payload: CompanySkillForkRequest = {}) => - api.post( + api.post( `/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/fork`, payload, ), + forkPrecheck: (companyId: string, skillId: string) => + api.get( + `/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/fork-precheck`, + ), comments: (companyId: string, skillId: string) => api.get( `/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/comments`, @@ -106,6 +195,11 @@ export const companySkillsApi = { `/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/files`, { path, content }, ), + deleteFile: (companyId: string, skillId: string, payload: CompanySkillFileDeleteRequest) => + api.deleteWithBody( + `/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/files`, + payload, + ), create: (companyId: string, payload: CompanySkillCreateRequest) => api.post( `/companies/${encodeURIComponent(companyId)}/skills`, diff --git a/ui/src/components/ActivityCharts.tsx b/ui/src/components/ActivityCharts.tsx index 429d412f8b..cc72236270 100644 --- a/ui/src/components/ActivityCharts.tsx +++ b/ui/src/components/ActivityCharts.tsx @@ -21,7 +21,7 @@ function emptyRunDay(date: string): DashboardRunActivityDay { const runSegmentColors = { succeeded: "var(--hex-10b981)", - recovered: "var(--hex-f59e0b)", + recovered: "var(--status-task-todo)", failed: "var(--hex-ef4444)", other: "var(--hex-737373)", } as const; diff --git a/ui/src/components/FrontmatterPanel.test.tsx b/ui/src/components/FrontmatterPanel.test.tsx new file mode 100644 index 0000000000..78139113b9 --- /dev/null +++ b/ui/src/components/FrontmatterPanel.test.tsx @@ -0,0 +1,228 @@ +// @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 { joinFrontmatterBlock, splitFrontmatterBlock } from "@paperclipai/shared"; +import { + FrontmatterPanel, + type FrontmatterPanelChange, + type FrontmatterPanelProps, +} from "./FrontmatterPanel"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +function flushUi(callback: () => void) { + flushSync(callback); +} + +function setValue(el: HTMLInputElement | HTMLTextAreaElement, value: string) { + const proto = el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype; + const setter = Object.getOwnPropertyDescriptor(proto, "value")!.set!; + flushUi(() => { + setter.call(el, value); + el.dispatchEvent(new Event("input", { bubbles: true })); + }); +} + +function click(el: Element | null | undefined) { + flushUi(() => { + el?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); +} + +function findButtonByText(container: HTMLElement, text: string): HTMLButtonElement | null { + return ( + Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent?.trim() === text, + ) ?? null + ); +} + +function expandPanel(container: HTMLElement) { + const toggle = container.querySelector('button[aria-controls="frontmatter-panel-body"]'); + click(toggle); +} + +describe("FrontmatterPanel", () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + flushUi(() => root.unmount()); + container.remove(); + }); + + function render(props: Partial & { onChange?: (c: FrontmatterPanelChange) => void }) { + const onChange = props.onChange ?? vi.fn(); + flushUi(() => { + root.render( + , + ); + }); + return onChange; + } + + it("does not emit on mount — byte-identity round-trip is preserved when untouched", () => { + const onChange = vi.fn(); + render({ + frontmatterText: "name: reflection-coach\ndescription: A helpful coach", + hasFrontmatter: true, + onChange, + }); + expect(onChange).not.toHaveBeenCalled(); + const toggle = container.querySelector('button[aria-controls="frontmatter-panel-body"]'); + expect(toggle?.getAttribute("aria-expanded")).toBe("false"); + expect(container.querySelector("#fm-name")).toBeNull(); + expandPanel(container); + // The Fields tab is available for this round-trippable block after expansion. + expect(container.querySelector("#fm-name")?.value).toBe("reflection-coach"); + }); + + it("edits `name` in Fields mode and emits only that field changed", () => { + const onChange = vi.fn(); + render({ + frontmatterText: "name: coach\ndescription: A coach", + hasFrontmatter: true, + onChange, + }); + expandPanel(container); + const nameInput = container.querySelector("#fm-name")!; + setValue(nameInput, "new-coach"); + const last = onChange.mock.calls.at(-1)![0] as FrontmatterPanelChange; + expect(last.frontmatterText).toBe("name: new-coach\ndescription: A coach"); + expect(last.hasFrontmatter).toBe(true); + }); + + it("edits allowed-tools via the chip input", () => { + const onChange = vi.fn(); + render({ + frontmatterText: "name: coach\nallowed-tools:\n - Read", + hasFrontmatter: true, + onChange, + }); + expandPanel(container); + const chipInput = container.querySelector('input[aria-label="Add tool"]')!; + setValue(chipInput, "Grep"); + flushUi(() => { + chipInput.dispatchEvent( + new KeyboardEvent("keydown", { key: "Enter", bubbles: true }), + ); + }); + const last = onChange.mock.calls.at(-1)![0] as FrontmatterPanelChange; + expect(last.frontmatterText).toBe("name: coach\nallowed-tools:\n - Read\n - Grep"); + }); + + it("edits nested metadata scalar values", () => { + const onChange = vi.fn(); + render({ + frontmatterText: "name: coach\nmetadata:\n author: Paperclip\n version: 2", + hasFrontmatter: true, + onChange, + }); + expandPanel(container); + const valueInput = container.querySelector('input[aria-label="Value for author"]')!; + setValue(valueInput, "Anthropic"); + const last = onChange.mock.calls.at(-1)![0] as FrontmatterPanelChange; + // author changes; version stays a NUMBER (not requoted) because it was untouched. + expect(last.frontmatterText).toBe("name: coach\nmetadata:\n author: Anthropic\n version: 2"); + }); + + it("locks Fields mode for non-round-trippable YAML (inline comment) and preserves raw bytes", () => { + const onChange = vi.fn(); + const raw = "name: coach # keep me\ndescription: A coach"; + render({ frontmatterText: raw, hasFrontmatter: true, onChange }); + expandPanel(container); + + // Fields tab is disabled; the YAML textarea is shown instead. + const fieldsTab = findButtonByText(container, "Fields"); + expect(fieldsTab?.getAttribute("aria-disabled")).toBe("true"); + const yaml = container.querySelector('textarea[aria-label="Frontmatter YAML"]'); + expect(yaml?.value).toBe(raw); + expect(container.querySelector("#fm-name")).toBeNull(); + + // Editing raw YAML passes through byte-for-byte. + setValue(yaml!, `${raw}\nextra: value`); + const last = onChange.mock.calls.at(-1)![0] as FrontmatterPanelChange; + expect(last.frontmatterText).toBe(`${raw}\nextra: value`); + }); + + it("shows a warning chip for missing name/description on SKILL.md", () => { + render({ + frontmatterText: "name: coach", + hasFrontmatter: true, + fileName: "SKILL.md", + }); + const chip = container.querySelector('[data-testid="frontmatter-warning-chip"]'); + expect(chip?.textContent).toContain("issue"); + }); + + it("flags a wrong-typed allowed-tools value instead of editing it as a list", () => { + render({ + frontmatterText: "name: coach\ndescription: x\nallowed-tools: Read", + hasFrontmatter: true, + }); + expandPanel(container); + expect(container.textContent).toContain("Expected a list"); + }); + + it("adds frontmatter to a file that has none, seeding name from the slug", () => { + const onChange = vi.fn(); + render({ + frontmatterText: "", + hasFrontmatter: false, + fileName: "SKILL.md", + skillSlug: "reflection-coach", + onChange, + }); + const addButton = container.querySelector('[data-testid="add-frontmatter"]'); + expect(addButton).toBeTruthy(); + click(addButton); + const last = onChange.mock.calls.at(-1)![0] as FrontmatterPanelChange; + expect(last.hasFrontmatter).toBe(true); + expect(last.frontmatterText).toContain("name: reflection-coach"); + expect(last.frontmatterText).toContain("description:"); + }); + + it("renders read-only fields without editing affordances", () => { + render({ + frontmatterText: "name: coach\nallowed-tools:\n - Read", + hasFrontmatter: true, + readOnly: true, + }); + expandPanel(container); + const nameInput = container.querySelector("#fm-name"); + expect(nameInput?.readOnly).toBe(true); + // No chip remove buttons, no add-tool input. + expect(container.querySelector('input[aria-label="Add tool"]')).toBeNull(); + expect(container.querySelector('button[aria-label="Remove Read"]')).toBeNull(); + }); + + it("keeps the full document byte-identical through split → panel → join with no edits", () => { + const file = "---\nname: coach\ndescription: A coach\n---\n# Body\n\nHello world\n"; + const block = splitFrontmatterBlock(file); + const onChange = vi.fn(); + render({ + frontmatterText: block.frontmatterText, + hasFrontmatter: block.hasFrontmatter, + onChange, + }); + expect(onChange).not.toHaveBeenCalled(); + // The parent would rejoin the untouched block with the untouched body. + expect(joinFrontmatterBlock(block)).toBe(file); + }); +}); diff --git a/ui/src/components/FrontmatterPanel.tsx b/ui/src/components/FrontmatterPanel.tsx new file mode 100644 index 0000000000..c8b27a5b30 --- /dev/null +++ b/ui/src/components/FrontmatterPanel.tsx @@ -0,0 +1,732 @@ +import { useCallback, useMemo, useState } from "react"; +import { + analyzeFrontmatterBlock, + asStringArray, + getSkillFrontmatterUnknownKeys, + isFrontmatterPlainRecord, + parseFrontmatterFields, + stringifyFrontmatter, +} from "@paperclipai/shared"; +import { AlertTriangle, ChevronDown, ChevronRight, Info, Plus, Trash2, X } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Collapsible, CollapsibleContent } from "@/components/ui/collapsible"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; + +/** + * Skill Studio frontmatter editor (PAP-13145 Option B / PAP-13155). + * + * The rich markdown editor must never see the leading `---` YAML block or it + * corrupts it into headings/thematic breaks on every round-trip. Studio splits + * the file into a frontmatter block (owned by this panel) and a body (owned by + * MarkdownEditor); this panel offers a schema-aware Fields form when the YAML + * can be safely re-serialized, and always falls back to raw YAML when it can't. + * + * Byte-identity guarantee (QA PAP-13156): this panel only calls `onChange` in + * response to a real user edit. Opening a file and saving it untouched never + * routes through the serializer, so bytes are preserved exactly. Callers must + * mount this panel with a per-file `key` so its local state resets on file + * switches. + */ + +type FrontmatterMode = "fields" | "yaml"; + +export interface FrontmatterPanelChange { + frontmatterText: string; + hasFrontmatter: boolean; +} + +export interface FrontmatterPanelProps { + /** Raw YAML block between the `---` fences (from `splitFrontmatterBlock`). */ + frontmatterText: string; + hasFrontmatter: boolean; + /** File path, e.g. `SKILL.md`. Drives required-field rules + expand default. */ + fileName: string; + /** Skill slug used to seed `name:` when adding frontmatter to a SKILL.md. */ + skillSlug?: string; + readOnly?: boolean; + onChange: (change: FrontmatterPanelChange) => void; + className?: string; +} + +type ScalarValue = string | number | boolean | null; + +interface ScalarRow { + id: string; + key: string; + /** Original parsed value, preserved verbatim until the row is edited. */ + rawValue: unknown; + text: string; + edited: boolean; +} + +interface UnknownRow extends ScalarRow { + editable: boolean; +} + +interface FormModel { + hasName: boolean; + name: string; + hasDescription: boolean; + description: string; + allowedToolsPresent: boolean; + /** null → present but not a string list (type mismatch, edit in YAML). */ + allowedTools: string[] | null; + metadataPresent: boolean; + /** null → metadata is a plain scalar record we can edit as rows. */ + metadataComplex: unknown; + metaRows: ScalarRow[]; + unknown: UnknownRow[]; +} + +function isScalar(value: unknown): value is ScalarValue { + return ( + value === null + || typeof value === "string" + || typeof value === "number" + || typeof value === "boolean" + ); +} + +function scalarToText(value: unknown): string { + if (value === null || value === undefined) return ""; + if (typeof value === "boolean") return value ? "true" : "false"; + return String(value); +} + +/** Coerce edited text back to a YAML scalar so `version: 2` stays a number. */ +function coerceScalar(text: string): ScalarValue { + if (text === "") return ""; + if (text === "true") return true; + if (text === "false") return false; + if (text === "null" || text === "~") return null; + if (/^-?\d+(\.\d+)?$/u.test(text)) return Number(text); + return text; +} + +function isFlatScalarRecord(value: unknown): value is Record { + return isFrontmatterPlainRecord(value) && Object.values(value).every(isScalar); +} + +function buildFormModel(obj: Record): FormModel { + const allowedToolsPresent = "allowed-tools" in obj; + const metadataValue = obj.metadata; + const metadataPresent = "metadata" in obj; + const metadataEditable = metadataPresent && isFlatScalarRecord(metadataValue); + + const metaRows: ScalarRow[] = metadataEditable + ? Object.entries(metadataValue as Record).map(([key, value], index) => ({ + id: `meta-${index}-${key}`, + key, + rawValue: value, + text: scalarToText(value), + edited: false, + })) + : []; + + const unknown: UnknownRow[] = getSkillFrontmatterUnknownKeys(obj).map((key, index) => { + const value = obj[key]; + const editable = isScalar(value); + return { + id: `unknown-${index}-${key}`, + key, + rawValue: value, + text: scalarToText(value), + edited: false, + editable, + }; + }); + + return { + hasName: "name" in obj, + name: typeof obj.name === "string" ? obj.name : scalarToText(obj.name), + hasDescription: "description" in obj, + description: typeof obj.description === "string" ? obj.description : scalarToText(obj.description), + allowedToolsPresent, + allowedTools: allowedToolsPresent ? asStringArray(obj["allowed-tools"]) : [], + metadataPresent, + metadataComplex: metadataPresent && !metadataEditable ? metadataValue : null, + metaRows, + unknown, + }; +} + +function scalarRowsToObject(rows: ScalarRow[]): Record { + const out: Record = {}; + for (const row of rows) { + const key = row.key.trim(); + if (!key) continue; + out[key] = row.edited ? coerceScalar(row.text) : row.rawValue; + } + return out; +} + +/** + * Rebuild the frontmatter object from the form. Known keys are emitted in schema + * order (name, description, allowed-tools, metadata) followed by unknown keys — + * matches the panel's display order. Only reached in Fields mode, which is only + * available for round-trippable blocks, so this never runs on messy YAML. + */ +function serializeForm(form: FormModel): string { + const out: Record = {}; + if (form.hasName) out.name = form.name; + if (form.hasDescription) out.description = form.description; + if (form.allowedToolsPresent) out["allowed-tools"] = form.allowedTools ?? []; + if (form.metadataPresent) { + out.metadata = form.metadataComplex !== null ? form.metadataComplex : scalarRowsToObject(form.metaRows); + } + for (const row of form.unknown) { + const key = row.key.trim(); + if (!key) continue; + out[key] = row.editable && row.edited ? coerceScalar(row.text) : row.rawValue; + } + return stringifyFrontmatter(out); +} + +const SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; + +interface ValidationIssue { + field: string; + message: string; +} + +function collectValidation(form: FormModel, isSkillFile: boolean): ValidationIssue[] { + const issues: ValidationIssue[] = []; + const name = form.name.trim(); + const description = form.description.trim(); + if (isSkillFile && !name) issues.push({ field: "name", message: "SKILL.md needs a name." }); + if (name && !SLUG_RE.test(name)) { + issues.push({ field: "name", message: "Use lowercase letters, numbers and hyphens." }); + } + if (isSkillFile && !description) { + issues.push({ field: "description", message: "SKILL.md needs a description." }); + } + if (form.allowedToolsPresent && form.allowedTools === null) { + issues.push({ field: "allowed-tools", message: "Expected a list — edit in YAML." }); + } + return issues; +} + +function isSkillMarkdown(fileName: string): boolean { + return /(^|\/)skill\.md$/i.test(fileName); +} + +export function FrontmatterPanel({ + frontmatterText, + hasFrontmatter, + fileName, + skillSlug, + readOnly = false, + onChange, + className, +}: FrontmatterPanelProps) { + const isSkillFile = isSkillMarkdown(fileName); + + // `yamlText` is the canonical raw block — always equal to whatever we've last + // emitted (or the original text before any edit). `form` is authoritative only + // while in Fields mode. Both initialize from props; the caller keys us per file. + const [present, setPresent] = useState(hasFrontmatter); + const [yamlText, setYamlText] = useState(frontmatterText); + const initialAnalysis = useMemo(() => analyzeFrontmatterBlock(frontmatterText), [frontmatterText]); + const [form, setForm] = useState(() => buildFormModel(initialAnalysis.parsed)); + const [mode, setMode] = useState( + hasFrontmatter && initialAnalysis.canRoundTrip ? "fields" : "yaml", + ); + const [open, setOpen] = useState(false); + + const analysis = useMemo(() => analyzeFrontmatterBlock(yamlText), [yamlText]); + const canUseFields = present && analysis.canRoundTrip; + const effectiveMode: FrontmatterMode = mode === "fields" && !canUseFields ? "yaml" : mode; + + const validation = useMemo( + () => (effectiveMode === "fields" ? collectValidation(form, isSkillFile) : []), + [effectiveMode, form, isSkillFile], + ); + + const emit = useCallback( + (nextRaw: string, nextPresent: boolean) => { + onChange({ frontmatterText: nextRaw, hasFrontmatter: nextPresent }); + }, + [onChange], + ); + + const commitForm = useCallback( + (nextForm: FormModel) => { + setForm(nextForm); + let nextRaw: string; + try { + nextRaw = serializeForm(nextForm); + } catch { + // Unsupported key/value (e.g. a metadata key with a colon) — drop this + // edit rather than emit corrupt YAML. + return; + } + setYamlText(nextRaw); + emit(nextRaw, true); + }, + [emit], + ); + + const handleYamlChange = useCallback( + (next: string) => { + setYamlText(next); + emit(next, true); + }, + [emit], + ); + + const switchMode = useCallback( + (next: FrontmatterMode) => { + if (next === "fields") { + if (!canUseFields) return; + setForm(buildFormModel(parseFrontmatterFields(yamlText))); + } + setMode(next); + }, + [canUseFields, yamlText], + ); + + const addFrontmatter = useCallback(() => { + const seed: FormModel = { + hasName: true, + name: isSkillFile ? (skillSlug ?? "").trim() : "", + hasDescription: isSkillFile, + description: "", + allowedToolsPresent: false, + allowedTools: [], + metadataPresent: false, + metadataComplex: null, + metaRows: [], + unknown: [], + }; + setPresent(true); + setMode("fields"); + setOpen(true); + setForm(seed); + const nextRaw = serializeForm(seed); + setYamlText(nextRaw); + emit(nextRaw, true); + }, [emit, isSkillFile, skillSlug]); + + const summary = useMemo(() => buildSummary(analysis.parsed), [analysis.parsed]); + const warningCount = validation.length; + + const chevron = open ? ( +