mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-30 07:10:49 +02:00
* docs(agents): three-phase agent quick-create plan
Captures the full design for moving agent creation from manual form +
one-by-one skill attachment to a tiered experience:
- Phase 1 (this PR): one-click curated templates, AI-free.
- Phase 2 (next): AI-recommended skills via the existing quick-create
task mechanism — no new server-side LLM dependency.
- Phase 3 (later): AI creates the whole agent end-to-end, composing
Phase 2 with a new `multica agent create` CLI driver.
Documents the architectural decisions that keep all three phases on
existing infrastructure (no SSE, no server-side LLM SDK, no new WS
channels), the two soft blockers Phase 1 unlocks for later phases
(createSkillWithFiles TX composability + skill same-name dedupe), and
the scope decisions we explicitly opted out of (Anthropic plugin
marketplace, ClawHub UI affordances).
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(skills): harden import against invalid UTF-8 and binary files
PG rejects two byte patterns in a TEXT column. Both crashed real skill
imports we hit while assembling the template catalog:
- Embedded NUL (0x00) -> SQLSTATE 22021. Already stripped by
sanitizeNullBytes, kept as-is.
- Other invalid UTF-8 (e.g. 0x91 — Windows-1252 smart quote in a skill
whose author saved prose from Word). sanitizeNullBytes now also runs
strings.ToValidUTF8 over the content so the second class no longer
takes the whole import down.
For non-text payloads (images, fonts, archives, compiled binaries),
sanitization isn't the right fix — agents never read those as text,
and the bytes can't survive a TEXT column at all. addFile now skips
them by extension before the per-bundle cap counters tick, logging
the skip so an unexpected drop leaves a breadcrumb.
Function name kept for compatibility with the many call sites; both
behaviours are strict supersets of the original.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(skills): split createSkillWithFiles for tx composition + add workspace find-or-create query
Two soft blockers cleared so create-from-template (next commit) can
fold N skill creates and the agent + binding writes into one outer
transaction:
1. createSkillWithFiles used to Begin/Commit its own tx. Caller
composition was impossible — N invocations meant N separate
transactions and no atomicity over the whole materialise step.
Pull the body into createSkillWithFilesInTx(ctx, qtx, input); the
original function becomes a thin wrapper that manages its own tx
for standalone callers. Existing call sites: zero behaviour change.
2. Add GetSkillByWorkspaceAndName sqlc query — workspace skill lookup
by name, anchored to UNIQUE(workspace_id, name) from migration
008. Lets the template materialiser implement find-or-create:
reuse the workspace's existing skill row when a template
references the same name, rather than crashing on the unique
constraint or polluting the workspace with `<name>-2` clones.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(agents): agent template catalog + create-from-template endpoint
Server-side foundation for Phase 1 of the quick-create roadmap (see
docs/agent-quick-create-plan.md). Adds:
- server/internal/agenttmpl/ — embed-loaded catalog of curated agent
templates. Each template ships pre-written instructions plus a list
of skill URLs that get materialised into the workspace at create
time. Validation runs at startup (init() panics on a malformed
template) so a bad JSON ships as a deploy-time defect, not a
runtime 500. Slug must equal the filename basename so the URL
router is mirror-symmetric with the file layout.
- 11 starter templates covering Engineering / Writing / Building /
Testing (code-reviewer, frontend-builder, planner, docs-writer,
one-pager, html-slides, full-stack-engineer, …).
- Three new endpoints, all behind RequireWorkspaceMember:
GET /api/agent-templates — picker list (no instructions)
GET /api/agent-templates/:slug — detail with instructions
POST /api/agents/from-template — materialise + create
Create flow:
1. Auth + runtime authorization happen BEFORE the GitHub fan-out
so a 403 never wastes 20s of upstream fetches.
2. Pre-flight dedupe by cached_name reuses workspace skills
without an HTTP fetch — second create-from-the-same-template
drops from 20s to <100ms.
3. Parallel fetch (30s per-URL timeout) for the remaining skills.
4. Single transaction: every skill insert, the agent insert, and
the agent_skill bindings. On any upstream fetch failure the TX
rolls back and the API returns 422 with `failed_urls` so the
UI can name the bad source(s).
5. extra_skill_ids (user-supplied additions) are verified through
GetSkillInWorkspace per id before attach, so a malicious client
can't graft a skill from another workspace via UUID guessing.
- multica agent create --from-template <slug> CLI flag dispatches to
the new endpoint with a 60s ceiling, matching `multica skill import`.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(agents): one-click create-from-template UI
Frontend half of Phase 1. CreateAgentDialog becomes a state machine
spanning four steps:
chooser → Start blank / From template cards
blank-form → existing manual form (post-chooser)
duplicate-form → existing form pre-filled from a duplicated agent
template-picker → grid of templates, click navigates to detail
template-detail → instructions + skill list preview + one-click Use
Picking a template never lands on the form: name auto-deduped against
existingAgentNames, runtime = first usable one, visibility = private.
Refinement happens on the agent detail page if needed. Same rationale
the doc spells out — templates exist precisely to skip configuration.
New components, all collapsible-by-default so quick-create stays fast:
- template-picker.tsx — categorised grid, lucide icons + semantic
accent tokens resolved through static maps so Tailwind's JIT picks
up every variant (dynamic class strings would silently miss).
- template-detail.tsx — instructions preview, skill list with cached
descriptions, Use CTA. Renders the failedURLs banner when a 422
fires — the only step that can trigger that response.
- instructions-editor.tsx — collapsed preview-card / expanded full
ContentEditor.
- skill-multi-select.tsx + skill-picker-list.tsx — shared multi-
select surface, also adopted by the existing skill-add-dialog.
- avatar-picker.tsx — agent avatar upload, mirrors the inspector's
visual language.
Schema-defended client (CLAUDE.md → API Response Compatibility): the
three new endpoints are wired through parseWithFallback with lenient
zod schemas. Desktop builds outlive any given server — a future
field rename / wrapping must not white-screen older installs.
listAgentTemplates accepts both the current bare array and a future
{templates: [...]} envelope. Coverage: 7 new schema-test cases in
schema.test.ts (null body, missing skills/instructions, malformed
create response, envelope migration).
Catalog + detail go through TanStack Query with staleTime: Infinity —
workspace-independent static data, no per-mount refetch.
Other:
- skill-add-dialog becomes a true multi-select (Confirm button +
checkbox list); attached skills are filtered out of the list.
- agents-page hands the freshly-created Agent back to the dialog so a
follow-up setAgentSkills can attach the form-selected skills.
- agent-overview-pane drops the mx-auto/max-w-2xl frame on config-
tab content; the wider dialog visual language reads better with
tabs filling the column.
- Every new UI string lives in both en/agents.json and
zh-Hans/agents.json under create_dialog.* / tab_body.skills.* —
locales/parity.test.ts blocks drift in CI.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(ci): align skill import test + drop next-only lint suppression
- TestFetchFromSkillsSh_ResolvesRootLevelSkillMd now expects assets/logo.png
to be skipped; matches the new addFile binary-extension guard
(6fafd86e). The .png is intentionally dropped so PG TEXT inserts don't
hit SQLSTATE 22021.
- packages/views shares zero next/* deps, so the @next/next/no-img-element
eslint plugin isn't loaded there. The eslint-disable directive
referencing it produced a hard "rule not found" error in CI lint. Raw
<img> is the right primitive in views; remove the disable comment.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* test(agents): wrap CreateAgentDialog tests in workspace/navigation providers
The dialog now calls useNavigation() and useWorkspacePaths(), both of
which throw outside their providers. The existing tests rendered the
dialog bare and tripped both new requirements:
- NavigationProvider — supply a stub adapter so push() works for the
agent-detail redirect.
- WorkspaceSlugProvider — useWorkspacePaths() requires a slug.
The blank-vs-template chooser is now the default first step; the
existing tests target the runtime picker on the manual form, so the
helper auto-clicks "Start blank" when no template is passed
(duplicate-mode tests skip the chooser).
Manual afterEach(cleanup) + document.body wipe. Base UI's Dialog
portal renders into document.body and leaves focus-guard/inert wrapper
divs behind across tests, so the second test in the suite saw two
"All" / "My Runtime" matches and getByText failed. The wipe is local
to this file rather than the shared setup because it isn't a global
issue — only suites that open Base UI dialogs hit it.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
309 lines
11 KiB
TypeScript
309 lines
11 KiB
TypeScript
import { z } from "zod";
|
|
import type {
|
|
Agent,
|
|
AgentTemplate,
|
|
AgentTemplateSummary,
|
|
Attachment,
|
|
CreateAgentFromTemplateResponse,
|
|
ListIssuesResponse,
|
|
TimelineEntry,
|
|
} from "../types";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Schemas for the highest-risk API endpoints — those whose responses drive
|
|
// the issue detail page (timeline, comments, subscribers) and the issues
|
|
// list. These are the surfaces that white-screened in #2143 / #2147 / #2192.
|
|
//
|
|
// These schemas are intentionally LENIENT:
|
|
// - String enums are stored as `z.string()` rather than `z.enum([...])`.
|
|
// A new server-side enum value should render as a generic fallback in
|
|
// the UI, never crash a `safeParse`.
|
|
// - Optional fields are unioned with `null` and given fallbacks where
|
|
// existing UI code already coerces them.
|
|
// - Arrays default to `[]` so a missing `reactions` / `attachments` /
|
|
// `entries` field doesn't take the page down.
|
|
// - Every object schema ends with `.loose()` so unknown server-side
|
|
// fields pass through unchanged. zod 4's `.object()` defaults to STRIP,
|
|
// which would silently delete fields the schema didn't explicitly list
|
|
// — fine while the TS type doesn't claim them, but the moment a future
|
|
// PR adds a TS field without updating the schema, the cast `as T` lies
|
|
// and the field shows up as `undefined` at runtime. `.loose()` removes
|
|
// that synchronisation hazard.
|
|
//
|
|
// These schemas are deliberately not typed as `z.ZodType<TimelineEntry>` /
|
|
// `z.ZodType<Issue>` etc. — the strict TS types narrow string fields to
|
|
// literal unions, which would defeat the leniency above. `parseWithFallback`
|
|
// returns the parsed value cast to the caller-supplied `T`, so the strict
|
|
// type still flows out at the call site; the schema only guards shape.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const ReactionSchema = z.object({
|
|
id: z.string(),
|
|
comment_id: z.string(),
|
|
actor_type: z.string(),
|
|
actor_id: z.string(),
|
|
emoji: z.string(),
|
|
created_at: z.string(),
|
|
});
|
|
|
|
// Nested attachments embedded in timeline/comment responses stay lenient on
|
|
// purpose: a single malformed attachment must not knock the whole timeline
|
|
// into the fallback `[]`.
|
|
const AttachmentSchema = z.object({
|
|
id: z.string(),
|
|
}).loose();
|
|
|
|
// Standalone attachment lookup (`GET /api/attachments/{id}`) is the source of
|
|
// truth for click-time download URLs. The two fields the download flow opens
|
|
// in a new tab — `download_url` and `url` — must be strings, otherwise we'd
|
|
// happily `window.open(undefined)`. `filename` gates the toast/title and is
|
|
// also enforced so a missing value falls back to the empty record below.
|
|
export const AttachmentResponseSchema = z.object({
|
|
id: z.string(),
|
|
url: z.string(),
|
|
download_url: z.string(),
|
|
filename: z.string(),
|
|
chat_session_id: z.string().nullable().optional(),
|
|
chat_message_id: z.string().nullable().optional(),
|
|
}).loose();
|
|
|
|
export const EMPTY_ATTACHMENT: Attachment = {
|
|
id: "",
|
|
workspace_id: "",
|
|
issue_id: null,
|
|
comment_id: null,
|
|
chat_session_id: null,
|
|
chat_message_id: null,
|
|
uploader_type: "",
|
|
uploader_id: "",
|
|
filename: "",
|
|
url: "",
|
|
download_url: "",
|
|
content_type: "",
|
|
size_bytes: 0,
|
|
created_at: "",
|
|
};
|
|
|
|
// All object schemas use `.loose()` so unknown server-side fields pass
|
|
// through unchanged. zod 4's `.object()` defaults to STRIP, which would
|
|
// silently drop new fields and surface as a "field neither showed up in
|
|
// the UI" mystery the next time the TS type adopted them but the schema
|
|
// wasn't updated in lock-step. `.loose()` removes that synchronisation
|
|
// hazard — the schema validates the shape it knows about and leaves the
|
|
// rest alone.
|
|
const TimelineEntrySchema = z.object({
|
|
type: z.string(),
|
|
id: z.string(),
|
|
actor_type: z.string(),
|
|
actor_id: z.string(),
|
|
created_at: z.string(),
|
|
action: z.string().optional(),
|
|
details: z.record(z.string(), z.unknown()).optional(),
|
|
content: z.string().optional(),
|
|
parent_id: z.string().nullable().optional(),
|
|
updated_at: z.string().optional(),
|
|
comment_type: z.string().optional(),
|
|
reactions: z.array(ReactionSchema).optional(),
|
|
attachments: z.array(AttachmentSchema).optional(),
|
|
coalesced_count: z.number().optional(),
|
|
}).loose();
|
|
|
|
// /timeline returns a flat array of TimelineEntry, oldest first. The
|
|
// previously cursor-paginated wrapper was removed (#1929) — at observed data
|
|
// sizes (p99 ~30 entries per issue) paged delivery only created bugs.
|
|
export const TimelineEntriesSchema = z.array(TimelineEntrySchema);
|
|
|
|
export const EMPTY_TIMELINE_ENTRIES: TimelineEntry[] = [];
|
|
|
|
export const CommentSchema = z.object({
|
|
id: z.string(),
|
|
issue_id: z.string(),
|
|
author_type: z.string(),
|
|
author_id: z.string(),
|
|
content: z.string(),
|
|
type: z.string(),
|
|
parent_id: z.string().nullable(),
|
|
reactions: z.array(ReactionSchema).default([]),
|
|
attachments: z.array(AttachmentSchema).default([]),
|
|
created_at: z.string(),
|
|
updated_at: z.string(),
|
|
}).loose();
|
|
|
|
export const CommentsListSchema = z.array(CommentSchema);
|
|
|
|
const IssueSchema = z.object({
|
|
id: z.string(),
|
|
workspace_id: z.string(),
|
|
number: z.number(),
|
|
identifier: z.string(),
|
|
title: z.string(),
|
|
description: z.string().nullable(),
|
|
status: z.string(),
|
|
priority: z.string(),
|
|
assignee_type: z.string().nullable(),
|
|
assignee_id: z.string().nullable(),
|
|
creator_type: z.string(),
|
|
creator_id: z.string(),
|
|
parent_issue_id: z.string().nullable(),
|
|
project_id: z.string().nullable(),
|
|
position: z.number(),
|
|
due_date: z.string().nullable(),
|
|
reactions: z.array(z.unknown()).optional(),
|
|
labels: z.array(z.unknown()).optional(),
|
|
created_at: z.string(),
|
|
updated_at: z.string(),
|
|
}).loose();
|
|
|
|
export const ListIssuesResponseSchema = z.object({
|
|
issues: z.array(IssueSchema).default([]),
|
|
total: z.number().default(0),
|
|
}).loose();
|
|
|
|
export const EMPTY_LIST_ISSUES_RESPONSE: ListIssuesResponse = {
|
|
issues: [],
|
|
total: 0,
|
|
};
|
|
|
|
const SubscriberSchema = z.object({
|
|
issue_id: z.string(),
|
|
user_type: z.string(),
|
|
user_id: z.string(),
|
|
reason: z.string(),
|
|
created_at: z.string(),
|
|
}).loose();
|
|
|
|
export const SubscribersListSchema = z.array(SubscriberSchema);
|
|
|
|
export const ChildIssuesResponseSchema = z.object({
|
|
issues: z.array(IssueSchema).default([]),
|
|
}).loose();
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Workspace dashboard schemas
|
|
//
|
|
// The dashboard hits three independent rollup endpoints. Each returns a flat
|
|
// array, and every field is consumed by chart / KPI math — a missing number
|
|
// silently degrades to NaN downstream, so we coerce missing numbers to 0.
|
|
// String fields stay lenient (no enum narrowing) to survive future model /
|
|
// agent ID drift.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const DashboardUsageDailySchema = z.object({
|
|
date: z.string(),
|
|
model: z.string(),
|
|
input_tokens: z.number().default(0),
|
|
output_tokens: z.number().default(0),
|
|
cache_read_tokens: z.number().default(0),
|
|
cache_write_tokens: z.number().default(0),
|
|
task_count: z.number().default(0),
|
|
}).loose();
|
|
|
|
export const DashboardUsageDailyListSchema = z.array(DashboardUsageDailySchema);
|
|
|
|
const DashboardUsageByAgentSchema = z.object({
|
|
agent_id: z.string(),
|
|
model: z.string(),
|
|
input_tokens: z.number().default(0),
|
|
output_tokens: z.number().default(0),
|
|
cache_read_tokens: z.number().default(0),
|
|
cache_write_tokens: z.number().default(0),
|
|
task_count: z.number().default(0),
|
|
}).loose();
|
|
|
|
export const DashboardUsageByAgentListSchema = z.array(DashboardUsageByAgentSchema);
|
|
|
|
const DashboardAgentRunTimeSchema = z.object({
|
|
agent_id: z.string(),
|
|
total_seconds: z.number().default(0),
|
|
task_count: z.number().default(0),
|
|
failed_count: z.number().default(0),
|
|
}).loose();
|
|
|
|
export const DashboardAgentRunTimeListSchema = z.array(DashboardAgentRunTimeSchema);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Agent template catalog — `/api/agent-templates*` and the
|
|
// create-from-template response. The desktop app's create-agent picker
|
|
// reaches these endpoints, and a future server change to the template shape
|
|
// would white-screen older installed builds (#2192 pattern) without these
|
|
// parsers. Lenient by the same rules as IssueSchema above: arrays default to
|
|
// `[]`, optional fields stay optional, `.loose()` lets unknown fields pass
|
|
// through unchanged.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const AgentTemplateSkillRefSchema = z.object({
|
|
source_url: z.string(),
|
|
cached_name: z.string().default(""),
|
|
cached_description: z.string().default(""),
|
|
}).loose();
|
|
|
|
const AgentTemplateSummarySchemaBase = z.object({
|
|
slug: z.string(),
|
|
name: z.string(),
|
|
description: z.string().default(""),
|
|
category: z.string().optional(),
|
|
icon: z.string().optional(),
|
|
accent: z.string().optional(),
|
|
// skills MUST default to [] — picker code reads `template.skills.length`
|
|
// and `.map(...)`, both of which crash on `undefined`. The most common
|
|
// future drift (field renamed / wrapped) lands here.
|
|
skills: z.array(AgentTemplateSkillRefSchema).default([]),
|
|
}).loose();
|
|
|
|
export const AgentTemplateSummarySchema = AgentTemplateSummarySchemaBase;
|
|
|
|
// List endpoint historically returns a bare array. Server could legitimately
|
|
// migrate to `{templates: [...]}` later — we accept either shape so an old
|
|
// desktop survives the upgrade.
|
|
export const AgentTemplateSummaryListSchema = z.union([
|
|
z.array(AgentTemplateSummarySchemaBase),
|
|
z.object({ templates: z.array(AgentTemplateSummarySchemaBase).default([]) })
|
|
.loose()
|
|
.transform((v) => v.templates),
|
|
]);
|
|
|
|
export const EMPTY_AGENT_TEMPLATE_SUMMARY_LIST: AgentTemplateSummary[] = [];
|
|
|
|
export const AgentTemplateSchema = AgentTemplateSummarySchemaBase.extend({
|
|
// Detail-only field. Default "" so a malformed detail still renders the
|
|
// header + skill list; the user just sees an empty Instructions block.
|
|
instructions: z.string().default(""),
|
|
}).loose();
|
|
|
|
// Used as the parse fallback for `GET /api/agent-templates/:slug`. Slug comes
|
|
// from the URL, so we round-trip the requested one back into the fallback
|
|
// at the call site (see `getAgentTemplate` in client.ts).
|
|
export const EMPTY_AGENT_TEMPLATE_DETAIL: AgentTemplate = {
|
|
slug: "",
|
|
name: "",
|
|
description: "",
|
|
skills: [],
|
|
instructions: "",
|
|
};
|
|
|
|
// `agent` is a full Agent record — schematising every field would duplicate
|
|
// a 50-field interface and bit-rot fast. We keep it loose and require only
|
|
// `id`, the one field the create-from-template flow consumes (used to
|
|
// navigate to the new agent's detail page). Downstream code already
|
|
// optional-chains the rest.
|
|
const MinimalAgentSchema = z.object({
|
|
id: z.string(),
|
|
}).loose();
|
|
|
|
export const CreateAgentFromTemplateResponseSchema = z.object({
|
|
agent: MinimalAgentSchema,
|
|
imported_skill_ids: z.array(z.string()).default([]),
|
|
reused_skill_ids: z.array(z.string()).default([]),
|
|
}).loose();
|
|
|
|
// Fallback when the success response fails to parse. The agent server-side
|
|
// has likely been created already, so we can't pretend nothing happened —
|
|
// the caller (`create-agent-dialog.tsx`) is responsible for noticing
|
|
// `agent.id === ""` and skipping navigation while keeping the list
|
|
// invalidation, so the user finds their new agent in the list.
|
|
export const EMPTY_CREATE_AGENT_FROM_TEMPLATE_RESPONSE: CreateAgentFromTemplateResponse = {
|
|
agent: { id: "" } as Agent,
|
|
imported_skill_ids: [],
|
|
reused_skill_ids: [],
|
|
};
|