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:
parent
3571b6c38b
commit
b13eb5b2b5
|
|
@ -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");
|
||||
|
|
@ -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");
|
||||
|
|
@ -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;
|
||||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"];
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -67,6 +67,9 @@ vi.mock("../services/index.js", () => ({
|
|||
agentService: () => ({
|
||||
getById: vi.fn(),
|
||||
}),
|
||||
companySkillService: () => ({
|
||||
completeTestRunForIssue: vi.fn(async () => null),
|
||||
}),
|
||||
executionWorkspaceService: () => ({}),
|
||||
goalService: () => ({
|
||||
getById: vi.fn(),
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ describe("instance settings service", () => {
|
|||
autoRestartDevServerWhenIdle: true,
|
||||
enableIssueGraphLivenessAutoRecovery: true,
|
||||
enableWorkspaceBranchReconcileForward: true,
|
||||
enableWorktreeRunExecution: false,
|
||||
issueGraphLivenessAutoRecoveryLookbackHours: 48,
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -82,6 +82,9 @@ function registerModuleMocks() {
|
|||
agentService: () => ({
|
||||
getById: vi.fn(async () => null),
|
||||
}),
|
||||
companySkillService: () => ({
|
||||
completeTestRunForIssue: vi.fn(async () => null),
|
||||
}),
|
||||
documentAnnotationService: () => ({ remapOpenThreadsForDocument: async () => [] }),
|
||||
documentService: () => ({}),
|
||||
executionWorkspaceService: () => ({}),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 })),
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -81,6 +81,9 @@ function registerServiceMocks() {
|
|||
agentService: () => ({
|
||||
getById: vi.fn(async () => null),
|
||||
}),
|
||||
companySkillService: () => ({
|
||||
completeTestRunForIssue: vi.fn(async () => null),
|
||||
}),
|
||||
documentAnnotationService: () => ({ remapOpenThreadsForDocument: async () => [] }),
|
||||
documentService: () => ({}),
|
||||
executionWorkspaceService: () => mockExecutionWorkspaceService,
|
||||
|
|
|
|||
|
|
@ -120,6 +120,9 @@ function registerModuleMocks() {
|
|||
}),
|
||||
accessService: () => mockAccessService,
|
||||
agentService: () => ({ getById: vi.fn(async () => null) }),
|
||||
companySkillService: () => ({
|
||||
completeTestRunForIssue: vi.fn(async () => null),
|
||||
}),
|
||||
documentAnnotationService: () => mockDocumentAnnotationService,
|
||||
documentService: () => ({}),
|
||||
executionWorkspaceService: () => ({}),
|
||||
|
|
|
|||
|
|
@ -131,6 +131,9 @@ vi.mock("../services/index.js", () => ({
|
|||
}),
|
||||
accessService: () => mockAccessService,
|
||||
agentService: () => mockAgentService,
|
||||
companySkillService: () => ({
|
||||
completeTestRunForIssue: vi.fn(async () => null),
|
||||
}),
|
||||
documentAnnotationService: () => ({ remapOpenThreadsForDocument: async () => [] }),
|
||||
documentService: () => ({}),
|
||||
executionWorkspaceService: () => ({}),
|
||||
|
|
|
|||
|
|
@ -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 () => ({})),
|
||||
|
|
|
|||
|
|
@ -115,6 +115,9 @@ function registerModuleMocks() {
|
|||
}),
|
||||
accessService: () => mockAccessService,
|
||||
agentService: () => mockAgentService,
|
||||
companySkillService: () => ({
|
||||
completeTestRunForIssue: vi.fn(async () => null),
|
||||
}),
|
||||
documentAnnotationService: () => ({ remapOpenThreadsForDocument: async () => [] }),
|
||||
documentService: () => mockDocumentsService,
|
||||
executionWorkspaceService: () => ({}),
|
||||
|
|
|
|||
|
|
@ -75,6 +75,9 @@ function registerModuleMocks() {
|
|||
},
|
||||
})),
|
||||
}),
|
||||
companySkillService: () => ({
|
||||
completeTestRunForIssue: vi.fn(async () => null),
|
||||
}),
|
||||
documentAnnotationService: () => ({ remapOpenThreadsForDocument: async () => [] }),
|
||||
documentService: () => ({}),
|
||||
executionWorkspaceService: () => ({}),
|
||||
|
|
|
|||
|
|
@ -87,6 +87,9 @@ function registerModuleMocks() {
|
|||
}),
|
||||
accessService: () => mockAccessService,
|
||||
agentService: () => mockAgentService,
|
||||
companySkillService: () => ({
|
||||
completeTestRunForIssue: vi.fn(async () => null),
|
||||
}),
|
||||
documentAnnotationService: () => ({ remapOpenThreadsForDocument: async () => [] }),
|
||||
documentService: () => ({}),
|
||||
executionWorkspaceService: () => mockExecutionWorkspaceService,
|
||||
|
|
|
|||
|
|
@ -50,6 +50,9 @@ function registerModuleMocks() {
|
|||
hasPermission: vi.fn(),
|
||||
}),
|
||||
agentService: () => mockAgentService,
|
||||
companySkillService: () => ({
|
||||
completeTestRunForIssue: vi.fn(async () => null),
|
||||
}),
|
||||
documentAnnotationService: () => ({ remapOpenThreadsForDocument: async () => [] }),
|
||||
documentService: () => ({}),
|
||||
executionWorkspaceService: () => ({}),
|
||||
|
|
|
|||
|
|
@ -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 () => [] }),
|
||||
|
|
|
|||
|
|
@ -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: () => ({}),
|
||||
|
|
|
|||
|
|
@ -109,6 +109,9 @@ function registerRouteMocks() {
|
|||
}),
|
||||
accessService: () => mockAccessService,
|
||||
agentService: () => mockAgentService,
|
||||
companySkillService: () => ({
|
||||
completeTestRunForIssue: vi.fn(async () => null),
|
||||
}),
|
||||
documentAnnotationService: () => ({ remapOpenThreadsForDocument: async () => [] }),
|
||||
documentService: () => ({}),
|
||||
executionWorkspaceService: () => mockExecutionWorkspaceService,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 })),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
}
|
||||
|
|
@ -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 *
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
))
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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] : []),
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]!);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 />} />
|
||||
|
|
|
|||
|
|
@ -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) }),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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?");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in New Issue