mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-13 23:39:21 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The Runner gives agents tools to change company resources. > - Users need agents to save reusable skills during a task. > - A saved skill needs a visible result that users can inspect and edit. > - This pull request adds `create_skill` and a task feed card linked to Skill Studio. > - Users can open the saved skill from the task and edit the same resource. ## Linked Issues or Issue Description **Subsystem affected** Runner tools, company skill storage, task feed, and Skill Studio. **Problem or motivation** The Runner has no dedicated tool to create a company skill. A user cannot follow a creation result from the task feed to the saved skill. **Proposed solution** Add a company-scoped `create_skill` tool. Save the skill with the existing company policy. Add one creation card to the task. Open a named sidebar tab from that card. Let the user open the same skill in Skill Studio. **Alternatives considered** An agent can write a local file, but that file is not a company skill. A second document copy in the task would become stale after a Studio edit. The sidebar therefore reads the saved skill directly. **Roadmap alignment** This extends the shipped Skills Manager, Skill Studio, and Skills Store milestone. The maintainer requested and approved this scope. Search found no duplicate `create_skill` PR or issue. Related UI validation work: #8715. This PR does not change that validation display. ## What Changed - Add the real Runner tool, its contract, and its mock implementation. - Validate the complete SKILL.md and derive company, task, agent, and run identity from authentication. - Apply the existing company skill policy. Do not assign the skill to an agent. - Make keyed retries return one skill and one creation event. Reject conflicting retries. - Make concurrent file creation safe. Never replace an existing published skill during creation. - Add a creation card, a named sidebar tab, and an Open in Skill Studio action. - Show saved Studio edits when the user returns to the task. - Add storage, policy, mode, retry, UI, and Product E2E tests. Document the tool. - Fix deleted-name reuse, onboarding panel persistence, immediate feed refresh, and mock validation parity from review. - Serialize Studio file edits and renames with skill deletion and recreation. Reject stale editor requests before they can change a replacement skill. - Generate the standalone mock parser and validator from the production contract. Use portable UUIDs so the browser scenario bundle builds. ## Verification - All latest-head PR checks pass on `145dd76a5`, including all server shards, browser E2E, Runner verification, build, typecheck, and release dry run. Greptile: 5/5 with no open findings. An interrupted CI runner was retried successfully. - `pnpm -r typecheck`: passed. - `pnpm build`: passed. - `pnpm check:token-gates`: passed. - Review regressions: 73 storage tests, 6 real API tests, 63 UI tests, and 61 semantic runtime tests passed. Parser synchronization passed. - CI exposed existing fire-and-forget Sentry test races. Reproduced the resumption race locally, then synchronized the related sweep and finalizer assertions on the actual report; all 27 tests across the three affected files pass. - Runner scenario browser build and strict content-security-policy check: passed. - Runner suite: 2,012 tests passed; 10 skipped. - `pnpm test:run`: the general-server batch had 12,416 passes and two failures. The old tool-count assertion was fixed; all 16 authority tests then passed. The chat webhook test had a socket error; it passed four isolated reruns. - Both workspace test groups passed. The isolated route suites completed. Two socket failures in the initial route batches passed on individual reruns; all remaining 61 files passed. - Product E2E `create-skill-studio`: passed with local Codex and local ACPX Claude. - Manual browser test: submit a task, observe the real tool call and creation card, open the sidebar, edit in Studio, save, and return. The task reached Done. The saved second revision and sidebar tab survived a server restart. - The new companion headless Runner Eval passed. Companion coverage PR: https://github.com/paperclipai/paperclip-evals/pull/23. Daytona was not run because no immutable runner image was configured. ## Risks - Database writes and local file writes cannot share one transaction. Recovery accepts only an exact file-for-file retry after a database rollback. Conflicting files remain untouched. - The sidebar displays the current skill. The feed card remains the historical creation receipt. - No database migration, dependency, or workflow change is included. - Remote Daytona behavior still needs a run with a configured immutable image. ## Model Used OpenAI GPT-6 (`gpt-6-astra`) handled design, integration, review, and browser verification. OpenAI `gpt-5.6-luna` assisted with bounded implementation and eval work. Both used code execution and tool access. The host did not expose the context window size. ## 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>
281 lines
10 KiB
TypeScript
281 lines
10 KiB
TypeScript
import type { WorkspaceFileSelector } from "@paperclipai/shared";
|
|
import type { SidePanelTabRecord, SidePanelTabsState } from "@/components/side-panel";
|
|
|
|
const STORAGE_VERSION = 1;
|
|
const MAX_TASK_STATES = 50;
|
|
|
|
export function shouldSuppressTaskPanelUntilPlan(input: {
|
|
deferredPlanAvailable: boolean;
|
|
panelBeforePlanOverride: boolean;
|
|
}) {
|
|
return !input.deferredPlanAvailable && !input.panelBeforePlanOverride;
|
|
}
|
|
|
|
export interface OpenSkillPanelState {
|
|
skill: { id: string; name: string };
|
|
panelBeforePlanOverrideIssueId: string | null;
|
|
}
|
|
|
|
export function openSkillPanelState(
|
|
current: Pick<OpenSkillPanelState, "panelBeforePlanOverrideIssueId">,
|
|
skill: { id: string; name: string },
|
|
issueId: string | null,
|
|
panelSuppressedUntilPlan: boolean,
|
|
): OpenSkillPanelState {
|
|
return {
|
|
skill,
|
|
panelBeforePlanOverrideIssueId:
|
|
panelSuppressedUntilPlan && issueId ? issueId : current.panelBeforePlanOverrideIssueId,
|
|
};
|
|
}
|
|
|
|
export type TaskSidePanelTabPayload =
|
|
| { kind: "properties" }
|
|
| { kind: "subtasks" }
|
|
| { kind: "artifacts" }
|
|
| { kind: "skill"; skillId: string }
|
|
| { kind: "issue-document"; documentKey: string }
|
|
| {
|
|
kind: "files-browser";
|
|
query: string | null;
|
|
folderPath: string | null;
|
|
projectId: string | null;
|
|
workspaceId: string | null;
|
|
}
|
|
| {
|
|
kind: "workspace-file";
|
|
path: string;
|
|
workspace: WorkspaceFileSelector;
|
|
projectId: string | null;
|
|
workspaceId: string | null;
|
|
line: number | null;
|
|
column: number | null;
|
|
};
|
|
|
|
export interface StoredTaskSidePanelState {
|
|
state: SidePanelTabsState<TaskSidePanelTabPayload>;
|
|
launcherOpen: boolean;
|
|
userInteracted: boolean;
|
|
autoPlanHandled: boolean;
|
|
updatedAt: number;
|
|
}
|
|
|
|
interface TaskSidePanelStore {
|
|
version: number;
|
|
tasks: Record<string, StoredTaskSidePanelState>;
|
|
}
|
|
|
|
function storageKey(accountScope: string, companyId: string) {
|
|
return `paperclip:task-side-panel:v${STORAGE_VERSION}:${accountScope}:${companyId}`;
|
|
}
|
|
|
|
function record(value: unknown): Record<string, unknown> | null {
|
|
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
? value as Record<string, unknown>
|
|
: null;
|
|
}
|
|
|
|
function nullableString(value: unknown): string | null | undefined {
|
|
return value === null ? null : typeof value === "string" ? value : undefined;
|
|
}
|
|
|
|
function positiveInteger(value: unknown): number | null | undefined {
|
|
if (value === null) return null;
|
|
return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : undefined;
|
|
}
|
|
|
|
function parsePayload(value: unknown): TaskSidePanelTabPayload | null {
|
|
const input = record(value);
|
|
if (!input) return null;
|
|
const kind = input.kind;
|
|
if (kind === "properties") return { kind };
|
|
if (kind === "subtasks") return { kind };
|
|
if (kind === "artifacts") return { kind };
|
|
if (kind === "skill") {
|
|
return typeof input.skillId === "string" && input.skillId.length > 0 ? { kind, skillId: input.skillId } : null;
|
|
}
|
|
if (kind === "issue-document") {
|
|
return typeof input.documentKey === "string" && input.documentKey.length > 0
|
|
? { kind, documentKey: input.documentKey }
|
|
: null;
|
|
}
|
|
if (kind === "files-browser") {
|
|
const query = nullableString(input.query);
|
|
const folderPath = nullableString(input.folderPath);
|
|
const projectId = nullableString(input.projectId);
|
|
const workspaceId = nullableString(input.workspaceId);
|
|
if ([query, folderPath, projectId, workspaceId].some((entry) => typeof entry === "undefined")) return null;
|
|
return { kind, query: query!, folderPath: folderPath!, projectId: projectId!, workspaceId: workspaceId! };
|
|
}
|
|
if (kind === "workspace-file") {
|
|
if (typeof input.path !== "string" || input.path.length === 0) return null;
|
|
const workspace = input.workspace === "execution" || input.workspace === "project" ? input.workspace : "auto";
|
|
const projectId = nullableString(input.projectId);
|
|
const workspaceId = nullableString(input.workspaceId);
|
|
const line = positiveInteger(input.line);
|
|
const column = positiveInteger(input.column);
|
|
if ([projectId, workspaceId, line, column].some((entry) => typeof entry === "undefined")) return null;
|
|
return {
|
|
kind,
|
|
path: input.path,
|
|
workspace,
|
|
projectId: projectId!,
|
|
workspaceId: workspaceId!,
|
|
line: line!,
|
|
column: column!,
|
|
};
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function parseTab(value: unknown, fileTabsEnabled: boolean): SidePanelTabRecord<TaskSidePanelTabPayload> | null {
|
|
const input = record(value);
|
|
if (!input || typeof input.id !== "string" || typeof input.type !== "string" || typeof input.label !== "string") return null;
|
|
const payload = parsePayload(input.payload);
|
|
if (!payload) return null;
|
|
if (!fileTabsEnabled && (payload.kind === "files-browser" || payload.kind === "workspace-file")) return null;
|
|
return {
|
|
id: input.id,
|
|
type: input.type,
|
|
label: input.label,
|
|
ariaLabel: typeof input.ariaLabel === "string" ? input.ariaLabel : undefined,
|
|
closable: typeof input.closable === "boolean" ? input.closable : true,
|
|
contentMode: input.contentMode === "full-bleed" || input.contentMode === "prose" ? input.contentMode : "padded",
|
|
payload,
|
|
};
|
|
}
|
|
|
|
function parseEntry(value: unknown, fileTabsEnabled: boolean): StoredTaskSidePanelState | null {
|
|
const input = record(value);
|
|
const rawState = record(input?.state);
|
|
if (!input || !rawState || !Array.isArray(rawState.tabs)) return null;
|
|
const seen = new Set<string>();
|
|
const tabs = rawState.tabs.flatMap((raw) => {
|
|
const tab = parseTab(raw, fileTabsEnabled);
|
|
if (!tab || seen.has(tab.id)) return [];
|
|
seen.add(tab.id);
|
|
return [tab];
|
|
});
|
|
const requestedActive = typeof rawState.activeTabId === "string" ? rawState.activeTabId : null;
|
|
return {
|
|
state: {
|
|
tabs,
|
|
activeTabId: requestedActive && seen.has(requestedActive) ? requestedActive : tabs[0]?.id ?? null,
|
|
},
|
|
launcherOpen: input.launcherOpen === true,
|
|
userInteracted: input.userInteracted === true,
|
|
autoPlanHandled: input.autoPlanHandled === true,
|
|
updatedAt: typeof input.updatedAt === "number" && Number.isFinite(input.updatedAt) ? input.updatedAt : 0,
|
|
};
|
|
}
|
|
|
|
function readStore(accountScope: string, companyId: string): TaskSidePanelStore {
|
|
if (typeof window === "undefined") return { version: STORAGE_VERSION, tasks: {} };
|
|
try {
|
|
const parsed = record(JSON.parse(window.localStorage.getItem(storageKey(accountScope, companyId)) ?? "null"));
|
|
if (parsed?.version !== STORAGE_VERSION || !record(parsed.tasks)) return { version: STORAGE_VERSION, tasks: {} };
|
|
return { version: STORAGE_VERSION, tasks: parsed.tasks as Record<string, StoredTaskSidePanelState> };
|
|
} catch {
|
|
return { version: STORAGE_VERSION, tasks: {} };
|
|
}
|
|
}
|
|
|
|
export function readTaskSidePanelState(
|
|
accountScope: string,
|
|
companyId: string,
|
|
taskId: string,
|
|
fileTabsEnabled: boolean,
|
|
): StoredTaskSidePanelState | null {
|
|
return parseEntry(readStore(accountScope, companyId).tasks[taskId], fileTabsEnabled);
|
|
}
|
|
|
|
export function writeTaskSidePanelState(
|
|
accountScope: string,
|
|
companyId: string,
|
|
taskId: string,
|
|
entry: StoredTaskSidePanelState,
|
|
) {
|
|
if (typeof window === "undefined") return;
|
|
try {
|
|
const store = readStore(accountScope, companyId);
|
|
const tasks = { ...store.tasks, [taskId]: entry };
|
|
const retained = Object.entries(tasks)
|
|
.sort((left, right) => (right[1].updatedAt ?? 0) - (left[1].updatedAt ?? 0))
|
|
.slice(0, MAX_TASK_STATES);
|
|
window.localStorage.setItem(storageKey(accountScope, companyId), JSON.stringify({
|
|
version: STORAGE_VERSION,
|
|
tasks: Object.fromEntries(retained),
|
|
} satisfies TaskSidePanelStore));
|
|
} catch {
|
|
// Storage is an enhancement; panel behavior stays usable when unavailable.
|
|
}
|
|
}
|
|
|
|
export function taskPanelPropertiesTab(): SidePanelTabRecord<TaskSidePanelTabPayload> {
|
|
return { id: "properties", type: "properties", label: "Properties", closable: true, contentMode: "padded", payload: { kind: "properties" } };
|
|
}
|
|
|
|
export function taskPanelSubtasksTab(): SidePanelTabRecord<TaskSidePanelTabPayload> {
|
|
return { id: "subtasks", type: "subtasks", label: "Subtasks", closable: true, contentMode: "padded", payload: { kind: "subtasks" } };
|
|
}
|
|
|
|
export function taskPanelArtifactsTab(): SidePanelTabRecord<TaskSidePanelTabPayload> {
|
|
return { id: "artifacts", type: "artifacts", label: "Artifacts", closable: true, contentMode: "padded", payload: { kind: "artifacts" } };
|
|
}
|
|
|
|
export function taskPanelSkillTab(skillId: string, label = "Skill"): SidePanelTabRecord<TaskSidePanelTabPayload> {
|
|
return { id: `skill:${skillId}`, type: "skill", label, closable: true, contentMode: "prose", payload: { kind: "skill", skillId } };
|
|
}
|
|
|
|
export function taskPanelDocumentTab(documentKey: string, label: string): SidePanelTabRecord<TaskSidePanelTabPayload> {
|
|
return {
|
|
id: `document:${documentKey}`,
|
|
type: "issue-document",
|
|
label,
|
|
closable: true,
|
|
contentMode: "prose",
|
|
payload: { kind: "issue-document", documentKey },
|
|
};
|
|
}
|
|
|
|
export function taskPanelFilesTab(): SidePanelTabRecord<TaskSidePanelTabPayload> {
|
|
return {
|
|
id: "files",
|
|
type: "files-browser",
|
|
label: "Files",
|
|
closable: true,
|
|
contentMode: "full-bleed",
|
|
payload: { kind: "files-browser", query: null, folderPath: null, projectId: null, workspaceId: null },
|
|
};
|
|
}
|
|
|
|
export function taskPanelWorkspaceFileTab(input: {
|
|
path: string;
|
|
workspace?: WorkspaceFileSelector;
|
|
projectId?: string | null;
|
|
workspaceId?: string | null;
|
|
line?: number | null;
|
|
column?: number | null;
|
|
}): SidePanelTabRecord<TaskSidePanelTabPayload> {
|
|
const projectId = input.projectId ?? null;
|
|
const workspaceId = input.workspaceId ?? null;
|
|
const scope = projectId && workspaceId ? `${projectId}:${workspaceId}` : input.workspace ?? "auto";
|
|
return {
|
|
id: `workspace-file:${scope}:${input.path}`,
|
|
type: "workspace-file",
|
|
label: input.path.split("/").filter(Boolean).at(-1) ?? input.path,
|
|
ariaLabel: input.path,
|
|
closable: true,
|
|
contentMode: "full-bleed",
|
|
payload: {
|
|
kind: "workspace-file",
|
|
path: input.path,
|
|
workspace: input.workspace ?? "auto",
|
|
projectId,
|
|
workspaceId,
|
|
line: input.line ?? null,
|
|
column: input.column ?? null,
|
|
},
|
|
};
|
|
}
|