Skill Studio: three-pane skill IDE with sandboxed test runs (#9241)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The Skills Manager gives operators a reusable skill layer, but
iteration still required manual edits, ad hoc prompts, and indirect run
inspection.
> - Skill authors need a focused workflow for editing skill files,
saving representative test inputs, and running those inputs through an
agent without exposing harness tasks as normal company work.
> - The backend therefore needs durable test inputs, reusable run
templates, hidden harness issues, scoped run execution, retention
metadata, and read-containment rules around hidden work.
> - The frontend needs a three-pane Studio that keeps skill files, saved
inputs/templates, and run output/history visible together while
preserving the existing design system and token rules.
> - This pull request ships that Skill Studio surface end to end:
database migrations, shared contracts, server APIs/services, hidden
harness execution behavior, UI routes/components, and focused tests.
> - The benefit is faster and safer skill iteration, with inspectable
outputs and fewer ways for internal harness work to leak into normal
task lists, costs, or adjacent read APIs.

## Linked Issues or Issue Description

No public GitHub issue exists for this feature. Feature request summary:

- Problem: Skill authors need to edit and test company skills in one
place instead of switching between the skill detail page, task creation,
run output, and manual prompt history.
- Proposed solution: Add a Skill Studio workbench with saved inputs,
reusable templates, hidden sandboxed test runs, live run status, output
inspection, run history, rerun/delete controls, and frontmatter-aware
editing.
- Expected users: Paperclip operators and agent-company maintainers who
create, fork, import, and tune skills.
- Related public PRs: Supersedes #9205, which was replaced so the public
PR branch name follows contributor policy.
- Duplicate search: searched public GitHub issues and PRs for "Skill
Studio"; no other active public issue or PR directly covers this
feature.

## What Changed

- Added database migrations for Skill Studio test inputs, test runs,
test run retention, and reusable run templates.
- Added shared Skill Studio types, validators, route helpers,
frontmatter utilities, and status handling.
- Added server services and routes for saved inputs, test runs,
templates, reruns, terminal-run deletion, hidden harness issue
execution, and run-detail hydration.
- Strengthened hidden-issue read containment across issue-adjacent
routes and cost rollups used by skill test harness work.
- Added the Skill Studio UI with skill file editing, frontmatter
editing, saved inputs, templates, run creation/cancel/rerun/delete
flows, output rendering, history, route support, and responsive pane
behavior.
- Added focused backend, shared, and UI tests for the new APIs, routing
logic, editor/run behavior, hidden-issue containment, and migration
safety.
- Rebased onto current `master`, removed the generated lockfile diff
from the PR, and verified no workflow files are changed.

## Verification

- [x] `pnpm --filter @paperclipai/db check:migrations`
- [x] `pnpm check:token-gates`
- [x] `pnpm exec vitest run
server/src/__tests__/company-skills-service.test.ts
server/src/__tests__/company-skills-routes.test.ts
server/src/__tests__/company-skill-test-runs-service.test.ts
ui/src/lib/skill-studio.test.ts ui/src/pages/SkillStudio.test.tsx` — 5
files, 132 tests passed
- [x] Greptile review on the latest PR head
- [x] GitHub PR checks on the latest PR head

## Risks

- Medium risk because this is a broad feature touching database schema,
server orchestration, issue visibility, and a large UI surface.
- Hidden harness issue containment is security-sensitive; this PR
includes regression coverage for adjacent read paths and cost rollups.
- The new migrations are additive and use idempotent guards where
applicable, but deployed databases that previously tested draft
migration numbers should still be checked carefully.
- The UI depends on a new resizable panels package in `ui/package.json`;
the lockfile is intentionally left to repository automation.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

OpenAI Codex, GPT-5 coding agent with shell, git, and GitHub CLI tool
use. Earlier feature commits include assistance from other Paperclip
coding agents; this PR preparation, rebase, cleanup commit, and PR body
were completed by OpenAI Codex in a Paperclip worktree.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Dotta 2026-07-09 13:08:56 -05:00 committed by GitHub
parent 3571b6c38b
commit b13eb5b2b5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
141 changed files with 17360 additions and 1082 deletions

View File

@ -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");

View File

@ -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");

View File

@ -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;

View File

@ -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
}
]
}

View File

@ -12,6 +12,7 @@ import {
import type { CompanySkillFileInventoryEntry, CompanySkillSharingScope } from "@paperclipai/shared";
import { agents } from "./agents.js";
import { companies } from "./companies.js";
import { 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<Record<string, unknown>>().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,
),
}),
);

View File

@ -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";

View File

@ -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,

View File

@ -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;

View File

@ -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;
}

View File

@ -1,9 +1,67 @@
import { z } from "zod";
export interface MarkdownDoc {
frontmatter: Record<string, unknown>;
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<unknown> = 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<string, unknown> {
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, unknown>): string {
return stringifyYamlRecord(assertSerializableRecord(value), 0).join("\n");
}
export function getSkillFrontmatterUnknownKeys(value: Record<string, unknown>) {
const known = new Set<string>(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<string, unknown> {
return parseYamlFrontmatter(frontmatterText);
}
export interface FrontmatterAnalysis {
/** The parsed field object (best-effort; `{}` when nothing parses). */
parsed: Record<string, unknown>;
/**
* 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<string, unknown>) {
const out: Record<string, SerializableFrontmatterValue> = {};
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<string, SerializableFrontmatterValue>, 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<SerializableFrontmatterValue, SerializableFrontmatterValue[] | Record<string, SerializableFrontmatterValue>>) {
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<string, SerializableFrontmatterValue> {
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<string, unknown> {
const prepared = prepareYamlLines(raw);
const firstContentIndex = prepared.findIndex((line) => !line.isBlank && !line.isComment);

View File

@ -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,

View File

@ -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<string, unknown>;
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"];

View File

@ -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,

View File

@ -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;
}

View File

@ -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;

View File

@ -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<typeof agentApiKeyScopeSchema>;
export type TaskBridgeAgentKeyScope = z.infer<typeof taskBridgeAgentKeyScopeSchema>;
export type SkillTestAgentKeyScope = z.infer<typeof skillTestAgentKeyScopeSchema>;
export function normalizeAgentApiKeyScope(value: unknown): AgentApiKeyScope {
const parsed = agentApiKeyScopeSchema.safeParse(value);

View File

@ -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<typeof companySkillListQuerySchema>;
export type CompanySkillProjectScan = z.infer<typeof companySkillProjectScanRequestSchema>;
export type CompanySkillCreate = z.infer<typeof companySkillCreateSchema>;
export type CompanySkillFileUpdate = z.infer<typeof companySkillFileUpdateSchema>;
export type CompanySkillFileDelete = z.infer<typeof companySkillFileDeleteSchema>;
export type CompanySkillTestInputCreate = z.infer<typeof companySkillTestInputCreateSchema>;
export type CompanySkillTestInputUpdate = z.infer<typeof companySkillTestInputUpdateSchema>;
export type CompanySkillTestRunTemplateCreate = z.infer<typeof companySkillTestRunTemplateCreateSchema>;
export type CompanySkillTestRunTemplateUpdate = z.infer<typeof companySkillTestRunTemplateUpdateSchema>;
export type CompanySkillTestRunCreate = z.infer<typeof companySkillTestRunCreateSchema>;
export type CompanySkillTestRunListQuery = z.infer<typeof companySkillTestRunListQuerySchema>;
export type CompanySkillVersionCreate = z.infer<typeof companySkillVersionCreateSchema>;
export type CompanySkillCommentCreate = z.infer<typeof companySkillCommentCreateSchema>;
export type CompanySkillCommentUpdate = z.infer<typeof companySkillCommentUpdateSchema>;

View File

@ -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,

View File

@ -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({

View File

@ -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()

View File

@ -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", () => {

View File

@ -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(),

View File

@ -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();
}

View File

@ -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");

View File

@ -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();

View File

@ -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<string, unknown> | 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({

View File

@ -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",
});
});
});

View File

@ -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",

View File

@ -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<typeof createDb>;
let svc!: ReturnType<typeof companySkillService>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
const cleanupDirs = new Set<string>();
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<Parameters<typeof svc.createTestRun>[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: [],
});
});
});

View File

@ -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<string, unknown>) => Promise<unknown>;
wakeHarnessIssue: (issueId: string, agentId: string) => Promise<unknown>;
},
) => {
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<unknown> },
) => {
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<unknown> },
) => {
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 () => {

View File

@ -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<string, unknown>).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<string, unknown>).detectedAt,
sourceLocator: missingSkillDir,
});
});
it("continues pruning missing local-path skills that no active agent desires", async () => {

View File

@ -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,

View File

@ -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,

View File

@ -67,6 +67,9 @@ vi.mock("../services/index.js", () => ({
agentService: () => ({
getById: vi.fn(),
}),
companySkillService: () => ({
completeTestRunForIssue: vi.fn(async () => null),
}),
executionWorkspaceService: () => ({}),
goalService: () => ({
getById: vi.fn(),

View File

@ -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<typeof import("../middleware/index.js")>("../middleware/index.js"),
vi.importActual<typeof import("../routes/issues.js")>("../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" });

View File

@ -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: {

View File

@ -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",
});
});
});

View File

@ -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 () => {

View File

@ -33,6 +33,7 @@ describe("instance settings service", () => {
autoRestartDevServerWhenIdle: true,
enableIssueGraphLivenessAutoRecovery: true,
enableWorkspaceBranchReconcileForward: true,
enableWorktreeRunExecution: false,
issueGraphLivenessAutoRecoveryLookbackHours: 48,
});
});

View File

@ -82,6 +82,9 @@ function registerModuleMocks() {
agentService: () => ({
getById: vi.fn(async () => null),
}),
companySkillService: () => ({
completeTestRunForIssue: vi.fn(async () => null),
}),
documentAnnotationService: () => ({ remapOpenThreadsForDocument: async () => [] }),
documentService: () => ({}),
executionWorkspaceService: () => ({}),

View File

@ -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,

View File

@ -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 })),
}),

View File

@ -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 = {

View File

@ -81,6 +81,9 @@ function registerServiceMocks() {
agentService: () => ({
getById: vi.fn(async () => null),
}),
companySkillService: () => ({
completeTestRunForIssue: vi.fn(async () => null),
}),
documentAnnotationService: () => ({ remapOpenThreadsForDocument: async () => [] }),
documentService: () => ({}),
executionWorkspaceService: () => mockExecutionWorkspaceService,

View File

@ -120,6 +120,9 @@ function registerModuleMocks() {
}),
accessService: () => mockAccessService,
agentService: () => ({ getById: vi.fn(async () => null) }),
companySkillService: () => ({
completeTestRunForIssue: vi.fn(async () => null),
}),
documentAnnotationService: () => mockDocumentAnnotationService,
documentService: () => ({}),
executionWorkspaceService: () => ({}),

View File

@ -131,6 +131,9 @@ vi.mock("../services/index.js", () => ({
}),
accessService: () => mockAccessService,
agentService: () => mockAgentService,
companySkillService: () => ({
completeTestRunForIssue: vi.fn(async () => null),
}),
documentAnnotationService: () => ({ remapOpenThreadsForDocument: async () => [] }),
documentService: () => ({}),
executionWorkspaceService: () => ({}),

View File

@ -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 () => ({})),

View File

@ -115,6 +115,9 @@ function registerModuleMocks() {
}),
accessService: () => mockAccessService,
agentService: () => mockAgentService,
companySkillService: () => ({
completeTestRunForIssue: vi.fn(async () => null),
}),
documentAnnotationService: () => ({ remapOpenThreadsForDocument: async () => [] }),
documentService: () => mockDocumentsService,
executionWorkspaceService: () => ({}),

View File

@ -75,6 +75,9 @@ function registerModuleMocks() {
},
})),
}),
companySkillService: () => ({
completeTestRunForIssue: vi.fn(async () => null),
}),
documentAnnotationService: () => ({ remapOpenThreadsForDocument: async () => [] }),
documentService: () => ({}),
executionWorkspaceService: () => ({}),

View File

@ -87,6 +87,9 @@ function registerModuleMocks() {
}),
accessService: () => mockAccessService,
agentService: () => mockAgentService,
companySkillService: () => ({
completeTestRunForIssue: vi.fn(async () => null),
}),
documentAnnotationService: () => ({ remapOpenThreadsForDocument: async () => [] }),
documentService: () => ({}),
executionWorkspaceService: () => mockExecutionWorkspaceService,

View File

@ -50,6 +50,9 @@ function registerModuleMocks() {
hasPermission: vi.fn(),
}),
agentService: () => mockAgentService,
companySkillService: () => ({
completeTestRunForIssue: vi.fn(async () => null),
}),
documentAnnotationService: () => ({ remapOpenThreadsForDocument: async () => [] }),
documentService: () => ({}),
executionWorkspaceService: () => ({}),

View File

@ -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 () => [] }),

View File

@ -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: () => ({}),

View File

@ -109,6 +109,9 @@ function registerRouteMocks() {
}),
accessService: () => mockAccessService,
agentService: () => mockAgentService,
companySkillService: () => ({
completeTestRunForIssue: vi.fn(async () => null),
}),
documentAnnotationService: () => ({ remapOpenThreadsForDocument: async () => [] }),
documentService: () => ({}),
executionWorkspaceService: () => mockExecutionWorkspaceService,

View File

@ -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,

View File

@ -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",

View File

@ -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 })),
};

View File

@ -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 } : {}),

View File

@ -45,7 +45,6 @@ import {
reconcileCloudUpstreamRunsOnStartup,
reconcileCodexLocalManagedHomesOnStartup,
reconcilePersistedRuntimeServicesOnStartup,
resolveHeartbeatSchedulingSuppression,
routineService,
} from "./services/index.js";
import {
@ -825,7 +824,7 @@ export async function startServer(): Promise<StartedServer> {
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<StartedServer> {
}
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<StartedServer> {
);
}
if (!resolveHeartbeatSchedulingSuppression().suppressed) {
if (!(await heartbeat.resolveSchedulingSuppression()).suppressed) {
trackHeartbeatSchedulerWork(heartbeat
.tickTimers(new Date())
.then((result) => {
@ -962,7 +965,7 @@ export async function startServer(): Promise<StartedServer> {
}));
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<StartedServer> {
logger.error({ err }, "periodic heartbeat recovery failed");
}));
}
})();
}, config.heartbeatSchedulerIntervalMs);
}

View File

@ -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,

View File

@ -1433,6 +1433,7 @@ export function agentRoutes(
adapterType: string,
adapterConfig: Record<string, unknown>,
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<string, unknown>,
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.");

View File

@ -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<string, unknown> | 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),

View File

@ -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(

View File

@ -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",

View File

@ -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<number>`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),
),
)

View File

@ -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);

View File

@ -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),
}));

View File

@ -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) {

View File

@ -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))

File diff suppressed because it is too large Load Diff

View File

@ -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<boolean>`
@ -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,
),
),

View File

@ -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<number>`count(*)` })
.from(issues)
.where(eq(issues.companyId, companyId))
.where(and(eq(issues.companyId, companyId), visibleIssueCondition()))
.groupBy(issues.status);
const pendingApprovals = await db

View File

@ -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,

View File

@ -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),
),
)

View File

@ -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<string, unknown> | 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<string, string | undefined> = 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<boolean> => {
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<boolean> | 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<string, unknown>;
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<ReturnType<typeof getIssueExecutionContext>> | 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,

View File

@ -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,
};

View File

@ -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`;
}

View File

@ -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<number>`
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 *

View File

@ -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"),
))

View File

@ -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) {

View File

@ -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<string, string[]>();
for (const row of linkRows) {

View File

@ -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`,

View File

@ -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));

View File

@ -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] : []),
),
)

View File

@ -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

View File

@ -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,

View File

@ -75,6 +75,27 @@ async function getIssueRunLockState(board: APIRequestContext, issueId: string):
};
}
async function retryAgentPatchWithCurrentLockOnConflict(
board: APIRequestContext,
agent: AgentAuth,
issueId: string,
failedRes: Awaited<ReturnType<APIRequestContext["patch"]>>,
patchData: Record<string, unknown>,
) {
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<TestContext> {
@ -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(

View File

@ -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"
}

View File

@ -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]!);
});
});

View File

@ -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() {
<Route path="company/settings/instance/plugins/:pluginId" element={<PluginSettings />} />
<Route path="company/settings/instance/adapters" element={<AdapterManager />} />
<Route path="company/settings/:settingsRoutePath/*" element={<CompanySettingsPluginPage />} />
<Route path="skills/studio" element={<SkillStudio />} />
<Route path="skills/studio/new" element={<SkillStudio />} />
<Route path="skills/studio/:skillId" element={<SkillStudio />} />
<Route path="skills/:skillId/studio" element={<LegacySkillStudioRedirect />} />
<Route path="skills/*" element={<CompanySkills />} />
<Route path="settings" element={<LegacySettingsRedirect />} />
<Route path="settings/*" element={<LegacySettingsRedirect />} />
@ -220,6 +225,33 @@ function InboxRootRedirect() {
return <Navigate to={`/inbox/${loadLastInboxTab()}`} replace />;
}
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 <Navigate to="/skills/studio" replace />;
}
return (
<Navigate
to={`/${targetCompany.issuePrefix}/skills/studio/${encodeURIComponent(skillId)}${location.search}${location.hash}`}
replace
/>
);
}
function LegacySettingsRedirect() {
const location = useLocation();
const { companies, selectedCompany, loading } = useCompany();
@ -421,6 +453,10 @@ export function App() {
<Route path="pipelines/:pipelineId/cases/:caseId" element={<UnprefixedBoardRedirect />} />
<Route path="artifacts" element={<UnprefixedBoardRedirect />} />
<Route path="u/:userSlug" element={<UnprefixedBoardRedirect />} />
<Route path="skills/studio" element={<UnprefixedBoardRedirect />} />
<Route path="skills/studio/new" element={<UnprefixedBoardRedirect />} />
<Route path="skills/studio/:skillId" element={<UnprefixedBoardRedirect />} />
<Route path="skills/:skillId/studio" element={<LegacySkillStudioRedirect />} />
<Route path="skills/*" element={<UnprefixedBoardRedirect />} />
<Route path="settings" element={<LegacySettingsRedirect />} />
<Route path="settings/*" element={<LegacySettingsRedirect />} />

View File

@ -47,4 +47,6 @@ export const api = {
patch: <T>(path: string, body: unknown) =>
request<T>(path, { method: "PATCH", body: JSON.stringify(body) }),
delete: <T>(path: string) => request<T>(path, { method: "DELETE" }),
deleteWithBody: <T>(path: string, body: unknown) =>
request<T>(path, { method: "DELETE", body: JSON.stringify(body) }),
};

View File

@ -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<CompanySkillListItem[]>(`/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<CompanySkillTestInput[]>(
`/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/test-inputs`,
),
createTestInput: (companyId: string, skillId: string, payload: CompanySkillTestInputCreateRequest) =>
api.post<CompanySkillTestInput>(
`/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/test-inputs`,
payload,
),
updateTestInput: (
companyId: string,
skillId: string,
inputId: string,
payload: CompanySkillTestInputUpdateRequest,
) =>
api.patch<CompanySkillTestInput>(
`/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/test-inputs/${encodeURIComponent(inputId)}`,
payload,
),
deleteTestInput: (companyId: string, skillId: string, inputId: string) =>
api.delete<CompanySkillTestInput>(
`/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/test-inputs/${encodeURIComponent(inputId)}`,
),
// --- Skill Studio cross-skill run templates ---
testRunTemplates: (companyId: string) =>
api.get<CompanySkillTestRunTemplate[]>(
`/companies/${encodeURIComponent(companyId)}/skill-test-run-templates`,
),
createTestRunTemplate: (companyId: string, payload: CompanySkillTestRunTemplateCreateRequest) =>
api.post<CompanySkillTestRunTemplate>(
`/companies/${encodeURIComponent(companyId)}/skill-test-run-templates`,
payload,
),
updateTestRunTemplate: (companyId: string, templateId: string, payload: CompanySkillTestRunTemplateUpdateRequest) =>
api.patch<CompanySkillTestRunTemplate>(
`/companies/${encodeURIComponent(companyId)}/skill-test-run-templates/${encodeURIComponent(templateId)}`,
payload,
),
deleteTestRunTemplate: (companyId: string, templateId: string) =>
api.delete<CompanySkillTestRunTemplate>(
`/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<CompanySkillTestRun[]>(
`/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/test-runs${search ? `?${search}` : ""}`,
);
},
testRunDetail: (companyId: string, skillId: string, runId: string) =>
api.get<CompanySkillTestRunDetail>(
`/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/test-runs/${encodeURIComponent(runId)}`,
),
createTestRun: (companyId: string, skillId: string, payload: CompanySkillTestRunCreateRequest) =>
api.post<CompanySkillTestRun>(
`/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/test-runs`,
payload,
),
cancelTestRun: (companyId: string, skillId: string, runId: string) =>
api.post<CompanySkillTestRun>(
`/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/test-runs/${encodeURIComponent(runId)}/cancel`,
{},
),
deleteTestRun: (companyId: string, skillId: string, runId: string) =>
api.delete<CompanySkillTestRun>(
`/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/test-runs/${encodeURIComponent(runId)}`,
),
star: (companyId: string, skillId: string) =>
api.post<CompanySkillStarResult>(
`/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<CompanySkill>(
api.post<CompanySkillForkResult>(
`/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/fork`,
payload,
),
forkPrecheck: (companyId: string, skillId: string) =>
api.get<CompanySkillForkPrecheckResult>(
`/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/fork-precheck`,
),
comments: (companyId: string, skillId: string) =>
api.get<CompanySkillComment[]>(
`/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<CompanySkillFileDeleteResult>(
`/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/files`,
payload,
),
create: (companyId: string, payload: CompanySkillCreateRequest) =>
api.post<CompanySkill>(
`/companies/${encodeURIComponent(companyId)}/skills`,

View File

@ -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;

View File

@ -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<HTMLButtonElement>("button")).find(
(button) => button.textContent?.trim() === text,
) ?? null
);
}
function expandPanel(container: HTMLElement) {
const toggle = container.querySelector<HTMLButtonElement>('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<FrontmatterPanelProps> & { onChange?: (c: FrontmatterPanelChange) => void }) {
const onChange = props.onChange ?? vi.fn();
flushUi(() => {
root.render(
<FrontmatterPanel
frontmatterText={props.frontmatterText ?? ""}
hasFrontmatter={props.hasFrontmatter ?? false}
fileName={props.fileName ?? "SKILL.md"}
skillSlug={props.skillSlug}
readOnly={props.readOnly}
onChange={onChange}
/>,
);
});
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<HTMLButtonElement>('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<HTMLInputElement>("#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<HTMLInputElement>("#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<HTMLInputElement>('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<HTMLInputElement>('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<HTMLTextAreaElement>('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<HTMLButtonElement>('[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<HTMLInputElement>("#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);
});
});

View File

@ -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<string, ScalarValue> {
return isFrontmatterPlainRecord(value) && Object.values(value).every(isScalar);
}
function buildFormModel(obj: Record<string, unknown>): 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<string, ScalarValue>).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<string, unknown> {
const out: Record<string, unknown> = {};
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<string, unknown> = {};
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<FormModel>(() => buildFormModel(initialAnalysis.parsed));
const [mode, setMode] = useState<FrontmatterMode>(
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 ? (
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
) : (
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
);
return (
<TooltipProvider>
<Collapsible
open={open}
onOpenChange={setOpen}
className={cn("border-b border-border", className)}
data-testid="frontmatter-panel"
>
<div className="flex items-center gap-2 px-3 py-1.5">
<button
type="button"
onClick={() => setOpen((value) => !value)}
className="flex min-w-0 flex-1 items-center gap-1.5 text-left"
aria-expanded={open}
aria-controls="frontmatter-panel-body"
>
{chevron}
<span className="text-sm font-medium">Frontmatter</span>
{!open && present ? (
<span className="truncate text-xs text-muted-foreground">{summary}</span>
) : null}
{!open && !present ? (
<span className="text-xs text-muted-foreground">None</span>
) : null}
</button>
{present ? (
<Tabs
value={effectiveMode}
onValueChange={(value) => switchMode(value as FrontmatterMode)}
>
<TabsList variant="line" className="h-7">
{canUseFields ? (
<TabsTrigger value="fields" className="px-2 py-0.5 text-xs">
Fields
</TabsTrigger>
) : (
<Tooltip>
<TooltipTrigger asChild>
<span>
<TabsTrigger
value="fields"
disabled
aria-disabled="true"
className="px-2 py-0.5 text-xs opacity-50"
>
Fields
</TabsTrigger>
</span>
</TooltipTrigger>
<TooltipContent className="max-w-60">
Switch to YAML to edit. This frontmatter uses YAML features the form can't safely
round-trip (e.g. comments, anchors, or custom ordering). Editing here keeps it
byte-for-byte.
</TooltipContent>
</Tooltip>
)}
<TabsTrigger value="yaml" className="px-2 py-0.5 text-xs">
YAML
</TabsTrigger>
</TabsList>
</Tabs>
) : !readOnly ? (
<Button variant="ghost" size="sm" onClick={addFrontmatter} data-testid="add-frontmatter">
<Plus className="mr-1 h-3.5 w-3.5" />
Add frontmatter
</Button>
) : null}
{present && effectiveMode === "fields" && warningCount > 0 ? (
<Badge variant="outline" className="gap-1 text-amber-500" data-testid="frontmatter-warning-chip">
<AlertTriangle className="h-3.5 w-3.5" />
{warningCount} {warningCount === 1 ? "issue" : "issues"}
</Badge>
) : null}
</div>
<CollapsibleContent id="frontmatter-panel-body">
{present ? (
<div className="px-3 pb-3">
{effectiveMode === "fields" ? (
<FieldsForm
form={form}
validation={validation}
readOnly={readOnly}
onCommit={commitForm}
/>
) : (
<YamlEditor
value={yamlText}
readOnly={readOnly}
canReturnToFields={canUseFields}
parseError={present && !analysis.canRoundTrip && analysis.issues.length === 0}
onChange={handleYamlChange}
/>
)}
</div>
) : (
<div className="px-3 pb-2 text-xs text-muted-foreground">
This file has no frontmatter.
</div>
)}
</CollapsibleContent>
</Collapsible>
</TooltipProvider>
);
}
function buildSummary(parsed: Record<string, unknown>): string {
const parts: string[] = [];
if (typeof parsed.name === "string" && parsed.name.trim()) parts.push(parsed.name.trim());
const tools = asStringArray(parsed["allowed-tools"]);
if (tools && tools.length > 0) parts.push(`${tools.length} ${tools.length === 1 ? "tool" : "tools"}`);
if (isFrontmatterPlainRecord(parsed.metadata)) {
const count = Object.keys(parsed.metadata).length;
if (count > 0) parts.push(`${count} metadata`);
}
return parts.join(" · ");
}
function fieldWarning(validation: ValidationIssue[], field: string): string | null {
return validation.find((issue) => issue.field === field)?.message ?? null;
}
function FieldWarning({ message }: { message: string }) {
return (
<p className="mt-1 flex items-center gap-1 text-xs text-amber-500">
<AlertTriangle className="h-3.5 w-3.5 shrink-0" />
{message}
</p>
);
}
function FieldsForm({
form,
validation,
readOnly,
onCommit,
}: {
form: FormModel;
validation: ValidationIssue[];
readOnly: boolean;
onCommit: (form: FormModel) => void;
}) {
const nameWarning = fieldWarning(validation, "name");
const descriptionWarning = fieldWarning(validation, "description");
const toolsWarning = fieldWarning(validation, "allowed-tools");
return (
<div className="space-y-3 pt-1">
{form.hasName ? (
<div>
<Label htmlFor="fm-name" className="text-xs text-muted-foreground">
name
</Label>
<Input
id="fm-name"
value={form.name}
readOnly={readOnly}
aria-invalid={Boolean(nameWarning)}
onChange={(event) => onCommit({ ...form, name: event.target.value })}
className="mt-1"
/>
{nameWarning ? <FieldWarning message={nameWarning} /> : null}
</div>
) : null}
{form.hasDescription ? (
<div>
<Label htmlFor="fm-description" className="text-xs text-muted-foreground">
description
</Label>
<Textarea
id="fm-description"
value={form.description}
readOnly={readOnly}
rows={3}
onChange={(event) => onCommit({ ...form, description: event.target.value })}
className="mt-1"
/>
{descriptionWarning ? <FieldWarning message={descriptionWarning} /> : null}
</div>
) : null}
{form.allowedToolsPresent ? (
<div>
<Label className="text-xs text-muted-foreground">allowed-tools</Label>
{form.allowedTools === null ? (
<p className="mt-1 text-xs text-amber-500">
{toolsWarning ?? "Expected a list — edit in YAML."}
</p>
) : (
<ChipInput
values={form.allowedTools}
readOnly={readOnly}
placeholder="Add a tool…"
onChange={(next) => onCommit({ ...form, allowedTools: next })}
/>
)}
</div>
) : null}
{form.metadataPresent ? (
<div>
<Label className="text-xs text-muted-foreground">metadata</Label>
{form.metadataComplex !== null ? (
<p className="mt-1 text-xs text-muted-foreground">Complex value edit in YAML.</p>
) : (
<MetadataRows
rows={form.metaRows}
readOnly={readOnly}
onChange={(rows) => onCommit({ ...form, metaRows: rows })}
/>
)}
</div>
) : null}
{form.unknown.map((row, index) =>
row.editable ? (
<div key={row.id}>
<Label htmlFor={`fm-unknown-${row.id}`} className="text-xs text-muted-foreground">
{row.key}
</Label>
<Input
id={`fm-unknown-${row.id}`}
value={row.text}
readOnly={readOnly}
onChange={(event) => {
const unknown = form.unknown.slice();
unknown[index] = { ...row, text: event.target.value, edited: true };
onCommit({ ...form, unknown });
}}
className="mt-1"
/>
</div>
) : (
<div key={row.id}>
<Label className="text-xs text-muted-foreground">{row.key}</Label>
<p className="mt-1 text-xs text-muted-foreground">Complex value edit in YAML.</p>
</div>
),
)}
</div>
);
}
function MetadataRows({
rows,
readOnly,
onChange,
}: {
rows: ScalarRow[];
readOnly: boolean;
onChange: (rows: ScalarRow[]) => void;
}) {
const update = (index: number, patch: Partial<ScalarRow>) => {
const next = rows.slice();
next[index] = { ...next[index]!, ...patch, edited: true };
onChange(next);
};
const remove = (index: number) => {
onChange(rows.filter((_, i) => i !== index));
};
const add = () => {
onChange([
...rows,
{ id: `meta-new-${rows.length}-${Date.now()}`, key: "", rawValue: "", text: "", edited: true },
]);
};
return (
<div className="mt-1 space-y-1.5">
{rows.map((row, index) => (
<div key={row.id} className="flex items-center gap-1.5">
<Input
aria-label={`Metadata key ${index + 1}`}
value={row.key}
readOnly={readOnly}
placeholder="key"
onChange={(event) => update(index, { key: event.target.value })}
className="h-8 flex-1 font-mono text-xs"
/>
<Input
aria-label={`Value for ${row.key || `field ${index + 1}`}`}
value={row.text}
readOnly={readOnly}
placeholder="value"
onChange={(event) => update(index, { text: event.target.value })}
className="h-8 flex-1 text-xs"
/>
{!readOnly ? (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
aria-label={`Remove ${row.key || `field ${index + 1}`}`}
onClick={() => remove(index)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
) : null}
</div>
))}
{!readOnly ? (
<Button variant="ghost" size="sm" onClick={add} className="text-xs">
<Plus className="mr-1 h-3.5 w-3.5" />
add field
</Button>
) : null}
</div>
);
}
function ChipInput({
values,
readOnly,
placeholder,
onChange,
}: {
values: string[];
readOnly: boolean;
placeholder?: string;
onChange: (values: string[]) => void;
}) {
const [draft, setDraft] = useState("");
const commit = () => {
const value = draft.trim();
if (!value) return;
onChange([...values, value]);
setDraft("");
};
return (
<div className="mt-1 flex flex-wrap items-center gap-1.5 rounded-md border border-border px-2 py-1.5">
{values.map((value, index) => (
<Badge key={`${value}-${index}`} variant="secondary" className="gap-1">
{value}
{!readOnly ? (
<button
type="button"
aria-label={`Remove ${value}`}
onClick={() => onChange(values.filter((_, i) => i !== index))}
className="hover:text-foreground"
>
<X className="h-3 w-3" />
</button>
) : null}
</Badge>
))}
{!readOnly ? (
<input
value={draft}
placeholder={values.length === 0 ? placeholder : undefined}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === ",") {
event.preventDefault();
commit();
} else if (event.key === "Backspace" && draft === "" && values.length > 0) {
onChange(values.slice(0, -1));
}
}}
onBlur={commit}
aria-label="Add tool"
className="min-w-24 flex-1 bg-transparent text-xs outline-none"
/>
) : null}
</div>
);
}
function YamlEditor({
value,
readOnly,
canReturnToFields,
parseError,
onChange,
}: {
value: string;
readOnly: boolean;
canReturnToFields: boolean;
parseError: boolean;
onChange: (value: string) => void;
}) {
return (
<div className="pt-1">
{!canReturnToFields && !parseError ? (
<div className="mb-1.5 flex items-start gap-2 rounded-md bg-muted/40 px-2 py-1.5 text-xs text-muted-foreground">
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0" aria-hidden="true" />
<span>Editing raw YAML to preserve formatting the form can't reconstruct.</span>
</div>
) : null}
<Textarea
value={value}
readOnly={readOnly}
spellCheck={false}
rows={Math.min(12, Math.max(3, value.split("\n").length))}
onChange={(event) => onChange(event.target.value)}
className="font-mono text-xs"
aria-label="Frontmatter YAML"
/>
<p className="mt-1 text-xs text-muted-foreground">
Raw YAML is the source of truth in this mode.
</p>
</div>
);
}

View File

@ -327,4 +327,28 @@ describe("IssueAttachmentsSection", () => {
"/api/attachments/pdf-attachment/content?download=1",
);
});
it("can render read-only attachments without destructive controls", async () => {
const attachment = makeAttachment({
id: "read-only-pdf",
originalFilename: "stored-report.pdf",
contentType: "application/pdf",
contentPath: "/api/attachments/read-only-pdf/content",
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueAttachmentsSection attachments={[attachment]} onImageClick={vi.fn()} />
</QueryClientProvider>,
);
});
await flushReact();
expect(container.textContent).toContain("stored-report.pdf");
expect(container.querySelector('a[aria-label="Open stored-report.pdf"]')).toBeTruthy();
expect(container.querySelector('a[aria-label="Download stored-report.pdf"]')).toBeTruthy();
expect(container.querySelector('button[title="Delete attachment"]')).toBeNull();
expect(container.textContent).not.toContain("Delete this attachment?");
});
});

View File

@ -26,7 +26,7 @@ interface IssueAttachmentsSectionProps {
error?: string | null;
dragActive?: boolean;
deletePending?: boolean;
onDelete: (attachmentId: string) => void;
onDelete?: (attachmentId: string) => void;
onImageClick: (attachment: IssueAttachment) => void;
onDragEnter?: (evt: DragEvent<HTMLDivElement>) => void;
onDragOver?: (evt: DragEvent<HTMLDivElement>) => void;
@ -51,7 +51,7 @@ function AttachmentActions({
onPreview,
}: {
attachment: IssueAttachment;
onDelete: (attachmentId: string) => void;
onDelete?: (attachmentId: string) => void;
deletePending?: boolean;
onPreview?: (attachment: IssueAttachment) => void;
}) {
@ -79,16 +79,18 @@ function AttachmentActions({
<Download className="h-4 w-4" />
</a>
</Button>
<Button
variant="ghost"
size="icon-sm"
title="Delete attachment"
className="text-muted-foreground hover:text-destructive"
onClick={() => onDelete(attachment.id)}
disabled={deletePending}
>
<Trash2 className="h-4 w-4" />
</Button>
{onDelete ? (
<Button
variant="ghost"
size="icon-sm"
title="Delete attachment"
className="text-muted-foreground hover:text-destructive"
onClick={() => onDelete(attachment.id)}
disabled={deletePending}
>
<Trash2 className="h-4 w-4" />
</Button>
) : null}
</div>
);
}
@ -107,7 +109,7 @@ function MarkdownAttachmentCard({
deletePending,
}: {
attachment: IssueAttachment;
onDelete: (attachmentId: string) => void;
onDelete?: (attachmentId: string) => void;
deletePending?: boolean;
}) {
const filename = attachmentFilename(attachment);
@ -152,7 +154,7 @@ function VideoAttachmentCard({
onPreview,
}: {
attachment: IssueAttachment;
onDelete: (attachmentId: string) => void;
onDelete?: (attachmentId: string) => void;
deletePending?: boolean;
onPreview?: (attachment: IssueAttachment) => void;
}) {
@ -182,7 +184,7 @@ function GenericAttachmentRow({
deletePending,
}: {
attachment: IssueAttachment;
onDelete: (attachmentId: string) => void;
onDelete?: (attachmentId: string) => void;
deletePending?: boolean;
}) {
const filename = attachmentFilename(attachment);
@ -245,6 +247,7 @@ export function IssueAttachmentsSection({
const requestDelete = (attachmentId: string) => setConfirmDeleteId(attachmentId);
const confirmDelete = (attachmentId: string) => {
if (!onDelete) return;
onDelete(attachmentId);
setConfirmDeleteId(null);
};
@ -289,7 +292,7 @@ export function IssueAttachmentsSection({
loading="lazy"
/>
<div className="absolute inset-0 bg-black/0 transition-colors group-hover:bg-black/30" />
{confirmDeleteId === attachment.id ? (
{onDelete && confirmDeleteId === attachment.id ? (
<div
className="absolute inset-0 flex flex-col items-center justify-center gap-1.5 bg-black/60"
onClick={(event) => event.stopPropagation()}
@ -319,7 +322,7 @@ export function IssueAttachmentsSection({
</button>
</div>
</div>
) : (
) : onDelete ? (
<button
type="button"
className="absolute right-1.5 top-1.5 rounded-md bg-black/50 p-1 text-white opacity-0 transition-opacity hover:bg-destructive group-hover:opacity-100"
@ -331,7 +334,7 @@ export function IssueAttachmentsSection({
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
) : null}
</div>
))}
</div>
@ -343,7 +346,7 @@ export function IssueAttachmentsSection({
<MarkdownAttachmentCard
key={attachment.id}
attachment={attachment}
onDelete={requestDelete}
onDelete={onDelete ? requestDelete : undefined}
deletePending={deletePending}
/>
))}
@ -356,7 +359,7 @@ export function IssueAttachmentsSection({
<VideoAttachmentCard
key={attachment.id}
attachment={attachment}
onDelete={requestDelete}
onDelete={onDelete ? requestDelete : undefined}
deletePending={deletePending}
onPreview={onImageClick}
/>
@ -370,14 +373,14 @@ export function IssueAttachmentsSection({
<GenericAttachmentRow
key={attachment.id}
attachment={attachment}
onDelete={requestDelete}
onDelete={onDelete ? requestDelete : undefined}
deletePending={deletePending}
/>
))}
</div>
)}
{confirmDeleteId && !imageAttachments.some((attachment) => attachment.id === confirmDeleteId) ? (
{onDelete && confirmDeleteId && !imageAttachments.some((attachment) => attachment.id === confirmDeleteId) ? (
<div className="flex items-center justify-between gap-3 rounded-md border border-destructive/20 bg-destructive/5 px-4 py-3">
<p className="text-sm font-medium text-destructive">Delete this attachment? This cannot be undone.</p>
<div className="flex shrink-0 items-center gap-2">

View File

@ -485,6 +485,44 @@ describe("Layout", () => {
});
});
it("forces the app sidebar rail only for the Skills Store route", async () => {
async function renderAt(pathname: string) {
currentPathname = pathname;
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<Layout />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
return root;
}
let root = await renderAt("/PAP/skills/studio");
expect(mockSetForceCollapsed).toHaveBeenCalledWith(true);
await act(async () => {
root.unmount();
});
mockSetForceCollapsed.mockClear();
container.innerHTML = "";
root = await renderAt("/PAP/agents/briefing-analyst/skills");
expect(mockSetForceCollapsed).not.toHaveBeenCalledWith(true);
expect(mockSetForceCollapsed).toHaveBeenCalledWith(false);
await act(async () => {
root.unmount();
});
});
it("renders a route-scoped plugin sidebar for a matching plugin page route", async () => {
currentPathname = "/PAP/wiki";
mockPluginSlots.slots = [

View File

@ -52,6 +52,16 @@ function getCompanyRouteSegment(pathname: string, companyPrefix: string | undefi
return segments[1]?.toLowerCase() ?? null;
}
function isSkillsStoreRoute(pathname: string, companyPrefix: string | undefined) {
const segments = pathname.split("/").filter(Boolean);
if (segments[0]?.toLowerCase() === "skills") return true;
if (!companyPrefix) return false;
return (
segments[0]?.toUpperCase() === companyPrefix.toUpperCase() &&
segments[1]?.toLowerCase() === "skills"
);
}
export function Layout() {
const {
sidebarOpen,
@ -83,8 +93,8 @@ export function Layout() {
const navigationType = useNavigationType();
const isCompanySettingsRoute = location.pathname.includes("/company/settings");
// The Skills Store renders its own secondary (category) sidebar, so the main
// app nav collapses to its rail throughout the /skills section (PAP-10879).
const isSkillsRoute = /(^|\/)skills(\/|$)/.test(location.pathname);
// app nav collapses to its rail throughout the Skills Store section (PAP-10879).
const isSkillsRoute = isSkillsStoreRoute(location.pathname, companyPrefix);
const onboardingTriggered = useRef(false);
const lastMainScrollTop = useRef(0);
const previousPathname = useRef<string | null>(null);

View File

@ -0,0 +1,38 @@
import { skillAccentColor } from "@/lib/skill-create";
/**
* Minimal shape needed to render a skill's square icon. `DiscoveryCard`
* (store) and the agent-skills row model both satisfy this, so the icon can be
* shared without pulling the whole store page into other modules.
*/
export interface SkillIconCard {
key: string;
name: string;
slug?: string | null;
iconUrl: string | null;
color: string | null;
}
export function SkillCardIcon({ card, size = 36 }: { card: SkillIconCard; size?: number }) {
if (card.iconUrl) {
return (
<img
src={card.iconUrl}
alt=""
className="shrink-0 rounded-md object-cover"
style={{ width: size, height: size }}
/>
);
}
const accent = skillAccentColor(card.key, card.color);
const letter = (card.slug || card.name || "?").trim().charAt(0).toUpperCase();
return (
<span
aria-hidden="true"
className="flex shrink-0 items-center justify-center rounded-md font-semibold text-white"
style={{ width: size, height: size, backgroundColor: accent, fontSize: Math.round(size * 0.42) }}
>
{letter}
</span>
);
}

Some files were not shown because too many files have changed in this diff Show More