@crossworks/client-types 0.230.43

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Safe headers for serving stored file bytes.
3
+ *
4
+ * A user (or an email/agent-ingested file) can upload an SVG or HTML document
5
+ * with embedded `<script>`. If we echo the stored mime type with
6
+ * `content-disposition: inline`, the browser executes that script in our
7
+ * origin — stored XSS, and on the public `/s/[token]` share route it runs for
8
+ * any visitor. This helper closes that:
9
+ *
10
+ * - `X-Content-Type-Options: nosniff` always, so the browser can't upgrade a
11
+ * mislabeled response to an executable type.
12
+ * - `inline` only for an allowlist of genuinely previewable types; everything
13
+ * else — notably `text/html` — is forced to `attachment` (download) and
14
+ * relabeled `application/octet-stream`.
15
+ * - `image/svg+xml` is served inline with its real type so the UI can embed
16
+ * it via `<img>` (where SVG scripts never run), but with a hardening CSP
17
+ * that `sandbox`es the document and blocks scripts/network. That defangs
18
+ * the one path where an SVG would execute: direct top-level navigation to
19
+ * the raw URL. See `SVG_CSP` below.
20
+ */
21
+
22
+ /**
23
+ * Locked-down policy for SVG bytes. `sandbox` (with no `allow-scripts`) is the
24
+ * load-bearing directive: even on direct navigation the SVG renders as an
25
+ * inert, scriptless document in a unique origin. `default-src 'none'` cuts off
26
+ * network egress (no beacons / external fetches); inline `<style>`, `data:`
27
+ * images and fonts stay allowed so legitimate SVGs still render. This header is
28
+ * ignored when the SVG loads as an `<img>` subresource, which is harmless —
29
+ * images don't execute scripts regardless.
30
+ */
31
+ const SVG_CSP =
32
+ "default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src data:; sandbox";
33
+
34
+ /** Base mime types safe to render inline. HTML is deliberately absent. */
35
+ const INLINE_SAFE = new Set([
36
+ 'image/png',
37
+ 'image/jpeg',
38
+ 'image/gif',
39
+ 'image/webp',
40
+ 'image/avif',
41
+ 'image/bmp',
42
+ 'image/x-icon',
43
+ 'application/pdf',
44
+ 'text/plain',
45
+ 'audio/mpeg',
46
+ 'audio/ogg',
47
+ 'audio/wav',
48
+ 'audio/webm',
49
+ 'audio/mp4',
50
+ 'audio/aac',
51
+ 'audio/flac',
52
+ 'video/mp4',
53
+ 'video/webm',
54
+ 'video/ogg',
55
+ 'video/quicktime',
56
+ ]);
57
+
58
+ function baseType(mime: string | null | undefined): string {
59
+ return (mime ?? '').split(';')[0]!.trim().toLowerCase();
60
+ }
61
+
62
+ export type SafeDownloadHeaders = {
63
+ 'content-type': string;
64
+ 'content-disposition': string;
65
+ 'x-content-type-options': 'nosniff';
66
+ 'content-security-policy'?: string;
67
+ };
68
+
69
+ /**
70
+ * Headers for serving `bytes` of `mimeType` named `filename`. Previewable types
71
+ * render inline with their real type; everything else downloads as an opaque
72
+ * octet-stream. SVG renders inline too, but with `SVG_CSP` to neutralize any
73
+ * embedded script on direct navigation. The filename is RFC 5987
74
+ * percent-encoded, so it can never inject header content regardless of its
75
+ * characters.
76
+ */
77
+ export function safeDownloadHeaders(
78
+ mimeType: string | null | undefined,
79
+ filename: string | null | undefined,
80
+ ): SafeDownloadHeaders {
81
+ const mime = baseType(mimeType);
82
+ const encoded = encodeURIComponent(filename || 'download');
83
+ if (mime === 'image/svg+xml') {
84
+ return {
85
+ 'content-type': 'image/svg+xml',
86
+ 'content-disposition': `inline; filename*=UTF-8''${encoded}`,
87
+ 'x-content-type-options': 'nosniff',
88
+ 'content-security-policy': SVG_CSP,
89
+ };
90
+ }
91
+ const inline = mime.length > 0 && INLINE_SAFE.has(mime);
92
+ return {
93
+ 'content-type': inline ? mime : 'application/octet-stream',
94
+ 'content-disposition': `${inline ? 'inline' : 'attachment'}; filename*=UTF-8''${encoded}`,
95
+ 'x-content-type-options': 'nosniff',
96
+ };
97
+ }
@@ -0,0 +1,167 @@
1
+ /**
2
+ * The single map from Mantle theme tokens → Mermaid `themeVariables`.
3
+ *
4
+ * Two surfaces render Mermaid and both come through here, so a palette change
5
+ * lands in one place: the Pages/chat NodeView (client/web page-editor
6
+ * diagram-view) and the /print surface's PDF sidecar, which loads this as an
7
+ * IIFE from share-runtime (server/web build-share-runtime step 5).
8
+ *
9
+ * The house rule for colour: **filled shapes that carry text sit on neutral
10
+ * surfaces** (`background`/`card`/`muted`/`secondary`) with `foreground` text.
11
+ * The `chart-1..5` tokens are 3:1 data ink — fine as fills where nothing has
12
+ * to be read on top of them (pie slices, journey task chips, gitgraph
13
+ * branches), never as text and never behind a label. Where a diagram type
14
+ * needs more series than the palette has, the palette CYCLES: the theme
15
+ * generator owns every colour in the product (`pnpm themes:build`), so
16
+ * inventing an off-palette sixth hue here is not an option.
17
+ *
18
+ * Values must be concrete — mermaid's `base` theme runs khroma colour maths on
19
+ * them, which parses hex/rgb/hsl but not `color-mix()`. The generated
20
+ * themes.css emits plain hex, so reading the token straight out of
21
+ * `getComputedStyle` is safe.
22
+ */
23
+
24
+ /** Resolve a CSS custom property to a concrete value; the fallback keeps a
25
+ * missing token from yielding an empty colour string. */
26
+ export type ThemeToken = (name: string, fallback: string) => string;
27
+
28
+ /** GitHub-ish light defaults — only reached if a token is missing entirely. */
29
+ const FALLBACK_CHARTS = ['#666ed1', '#ae467f', '#ad5700', '#4b830f', '#00889b'];
30
+
31
+ /**
32
+ * @param token resolves `--foo` against whatever element owns the theme
33
+ * @param darkMode mermaid's `base` theme branches on this for every value NOT
34
+ * set below — gantt/journey section tints (a `-1` vs `-4` lighten
35
+ * multiplier), `edgeLabelBackground`, gitgraph `branchLabelColor`, quadrant
36
+ * `scaleLabelColor`. Unset it defaults to false, i.e. light-mode maths on a
37
+ * dark brain.
38
+ */
39
+ export function mermaidThemeVariables(
40
+ token: ThemeToken,
41
+ darkMode: boolean,
42
+ ): Record<string, string | boolean> {
43
+ const charts = [1, 2, 3, 4, 5].map((i) => token(`--chart-${i}`, FALLBACK_CHARTS[i - 1]!));
44
+ /** Categorical fill by index, cycling once the 5 chart tokens run out. */
45
+ const series = (i: number) => charts[i % charts.length]!;
46
+
47
+ const foreground = token('--foreground', '#1f2328');
48
+ const mutedForeground = token('--muted-foreground', '#59636e');
49
+ const background = token('--background', '#ffffff');
50
+ const card = token('--card', '#ffffff');
51
+ const muted = token('--muted', '#f6f8fa');
52
+ const secondary = token('--secondary', '#eff2f5');
53
+ const accent = token('--accent', '#eaeef2');
54
+ const border = token('--border', '#d1d9e0');
55
+ const primary = token('--primary', '#0969da');
56
+ const destructive = token('--destructive', '#d1242f');
57
+
58
+ const vars: Record<string, string | boolean> = {
59
+ darkMode,
60
+ fontFamily: 'Inter, ui-sans-serif, system-ui, sans-serif',
61
+
62
+ // ── Core surfaces. Most diagram types derive from these three "primary/
63
+ // secondary/tertiary" slots rather than naming their own variable, which
64
+ // is why sequence, class, state and ER already track the theme without
65
+ // per-type entries below.
66
+ background,
67
+ mainBkg: muted,
68
+ primaryColor: muted,
69
+ primaryTextColor: foreground,
70
+ primaryBorderColor: border,
71
+ secondaryColor: card,
72
+ secondaryTextColor: foreground,
73
+ secondaryBorderColor: border,
74
+ tertiaryColor: background,
75
+ tertiaryTextColor: foreground,
76
+ tertiaryBorderColor: border,
77
+ lineColor: mutedForeground,
78
+ textColor: foreground,
79
+ noteBkgColor: muted,
80
+ noteTextColor: foreground,
81
+ noteBorderColor: border,
82
+
83
+ // ── Gantt. Section bands alternate three neutral surfaces; task bars stay
84
+ // neutral too because mermaid paints the task label INSIDE the bar, so a
85
+ // chart fill would put 3:1 data ink behind text. `crit` is the one solid
86
+ // fill, hence `background` as its ink (light text on the light-mode
87
+ // destructive, dark text on the dark-mode one — the token flips with the
88
+ // mode, so one value is correct in both).
89
+ sectionBkgColor: muted,
90
+ altSectionBkgColor: background,
91
+ sectionBkgColor2: card,
92
+ excludeBkgColor: secondary,
93
+ taskBkgColor: accent,
94
+ taskBorderColor: border,
95
+ activeTaskBkgColor: secondary,
96
+ activeTaskBorderColor: primary,
97
+ doneTaskBkgColor: muted,
98
+ doneTaskBorderColor: border,
99
+ critBkgColor: destructive,
100
+ critBorderColor: destructive,
101
+ todayLineColor: primary,
102
+ gridColor: border,
103
+ vertLineColor: border,
104
+ taskTextColor: foreground,
105
+ taskTextOutsideColor: foreground,
106
+ taskTextClickableColor: primary,
107
+
108
+ // ── Quadrant chart. Four subtle neutral surfaces make the quadrants read
109
+ // as a checkerboard without competing with the plotted points, which are
110
+ // the only thing here that should carry chart ink.
111
+ quadrant1Fill: background,
112
+ quadrant2Fill: muted,
113
+ quadrant3Fill: card,
114
+ quadrant4Fill: secondary,
115
+ quadrant1TextFill: foreground,
116
+ quadrant2TextFill: foreground,
117
+ quadrant3TextFill: foreground,
118
+ quadrant4TextFill: foreground,
119
+ quadrantPointFill: series(0),
120
+ quadrantPointTextFill: foreground,
121
+ quadrantXAxisTextFill: mutedForeground,
122
+ quadrantYAxisTextFill: mutedForeground,
123
+ quadrantTitleFill: foreground,
124
+ quadrantInternalBorderStrokeFill: border,
125
+ quadrantExternalBorderStrokeFill: border,
126
+
127
+ // ── Pie text + strokes. Slice fills are chart ink (below); the label sits
128
+ // ON the slice, so it takes `background` — which inverts with the mode and
129
+ // therefore contrasts with a chart token in both.
130
+ pieStrokeColor: background,
131
+ pieOuterStrokeColor: border,
132
+ pieTitleTextColor: foreground,
133
+ pieSectionTextColor: background,
134
+ pieLegendTextColor: foreground,
135
+
136
+ // ── Error strip (mermaid's own, not the NodeView's).
137
+ errorBkgColor: destructive,
138
+ errorTextColor: background,
139
+ };
140
+
141
+ // ── Categorical series. Mermaid names these individually and falls back to
142
+ // hardcoded pastels for any index left unset, so each family is filled to
143
+ // its full count rather than the first five.
144
+ for (let i = 0; i < 12; i++) {
145
+ vars[`pie${i + 1}`] = series(i); // pie1..pie12 (1-based)
146
+ vars[`cScale${i}`] = series(i); // mindmap / journey sections
147
+ }
148
+ for (let i = 0; i < 8; i++) {
149
+ vars[`git${i}`] = series(i); // gitgraph branches
150
+ vars[`fillType${i}`] = series(i); // user-journey task chips
151
+ }
152
+ return vars;
153
+ }
154
+
155
+ /**
156
+ * The same map, read off `<html>` — what both live browsers want. Dark mode
157
+ * comes from the `dark` class next-themes sets; the /print surface never sets
158
+ * it, which is correct, since a PDF forces a light background regardless of
159
+ * the brain's mode.
160
+ */
161
+ export function mermaidThemeVariablesFromDocument(): Record<string, string | boolean> {
162
+ const cs = getComputedStyle(document.documentElement);
163
+ return mermaidThemeVariables(
164
+ (name, fallback) => cs.getPropertyValue(name).trim() || fallback,
165
+ document.documentElement.classList.contains('dark'),
166
+ );
167
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Curated model choices offered in the onboarding "Models" step.
3
+ *
4
+ * PURE DATA — imported by both the onboarding client (cards UI) and the API
5
+ * route (validation), so keep it dependency-free. Lives in system-manifest/
6
+ * because it's product-shipped model policy: the `recommended` entry in each
7
+ * list MUST match the corresponding manifest default (`PERSONA_MANIFEST.model`
8
+ * / the indexing workers' model in `MANIFEST_WORKERS`) — onboarding merely
9
+ * writes the user's pick as operator overlay on top of the manifest seed.
10
+ *
11
+ * Slugs are OpenRouter ids (verified against the live catalog). `azure: true`
12
+ * marks OpenAI-family models that can also be served from an Azure OpenAI
13
+ * deployment (the Azure route strips the `openai/` prefix to form the
14
+ * deployment name). Prices are indicative $/M tokens (in · out) — shown to
15
+ * the user as a class signal, not a quote.
16
+ */
17
+ export type ModelChoice = {
18
+ /** OpenRouter model slug (`vendor/model`). */
19
+ id: string;
20
+ /** Display name for the card. */
21
+ name: string;
22
+ /** One-line honest pitch — strengths + who should pick it. */
23
+ blurb: string;
24
+ /** Indicative pricing, e.g. "$3 · $15 /M". */
25
+ price: string;
26
+ /** Deployable on Azure OpenAI (OpenAI-family only). */
27
+ azure?: boolean;
28
+ /** The shipped default — pre-selected, badge on the card. */
29
+ recommended?: boolean;
30
+ };
31
+
32
+ /** Top-tier choices for the assistant (the persona/responder agent). */
33
+ export const ASSISTANT_MODEL_CHOICES: readonly ModelChoice[] = [
34
+ {
35
+ id: 'anthropic/claude-sonnet-5',
36
+ name: 'Claude Sonnet 5',
37
+ blurb:
38
+ 'The shipped default — Anthropic’s newest Sonnet: superb reasoning and tool use, 1M context, and cheaper than the generation before it.',
39
+ price: '$2 · $10 /M',
40
+ recommended: true,
41
+ },
42
+ {
43
+ id: 'anthropic/claude-opus-4.8',
44
+ name: 'Claude Opus 4.8',
45
+ blurb:
46
+ 'Anthropic’s flagship — the strongest option for hard technical and analytical work. Same class Mantle uses for its app-builder.',
47
+ price: '$5 · $25 /M',
48
+ },
49
+ {
50
+ id: 'openai/gpt-5.5',
51
+ name: 'GPT-5.5',
52
+ blurb:
53
+ 'OpenAI’s newest flagship — excellent general intelligence. The top-tier pick that can also run from an Azure OpenAI deployment.',
54
+ price: '$5 · $30 /M',
55
+ azure: true,
56
+ },
57
+ {
58
+ id: 'x-ai/grok-4.20',
59
+ name: 'Grok 4.20',
60
+ blurb:
61
+ 'Frontier-class at a budget price — fast and capable, the value pick if cost per conversation matters most.',
62
+ price: '$1.25 · $2.50 /M',
63
+ },
64
+ ];
65
+
66
+ /** Fast/cheap choices for the background workers (indexing pipeline: the
67
+ * extractor, summarizer, reflector, document reader, and narrator). These
68
+ * process EVERYTHING the brain ingests, so price and speed dominate. */
69
+ export const WORKER_MODEL_CHOICES: readonly ModelChoice[] = [
70
+ {
71
+ id: 'google/gemini-3.1-flash-lite',
72
+ name: 'Gemini 3.1 Flash Lite',
73
+ blurb:
74
+ 'The shipped default — very fast, very cheap, 1M-token context. Ideal for the always-on indexing that reads everything you add.',
75
+ price: '$0.25 · $1.50 /M',
76
+ recommended: true,
77
+ },
78
+ {
79
+ id: 'openai/gpt-5.4-nano',
80
+ name: 'GPT-5.4 Nano',
81
+ blurb:
82
+ 'The cheapest capable worker — great throughput on light summarisation. The budget pick on the Azure route.',
83
+ price: '$0.20 · $1.25 /M',
84
+ azure: true,
85
+ },
86
+ {
87
+ id: 'openai/gpt-5.4-mini',
88
+ name: 'GPT-5.4 Mini',
89
+ blurb:
90
+ 'A step up in extraction quality while staying cheap — the stronger Azure-capable worker for dense technical documents.',
91
+ price: '$0.75 · $4.50 /M',
92
+ azure: true,
93
+ },
94
+ {
95
+ id: 'anthropic/claude-haiku-4.5',
96
+ name: 'Claude Haiku 4.5',
97
+ blurb:
98
+ 'The premium fast model — noticeably sharper summaries and fact extraction, at a higher per-token price than the other workers.',
99
+ price: '$1 · $5 /M',
100
+ },
101
+ ];
102
+
103
+ /** Worker kinds the onboarding "worker model" pick applies to — the text
104
+ * indexing pipeline. Modality workers (tts/stt/vision/image_gen/search)
105
+ * keep their manifest defaults; they're tuned per-modality in Settings. */
106
+ export const WORKER_MODEL_KINDS = [
107
+ 'extractor',
108
+ 'summarizer',
109
+ 'reflector',
110
+ 'document',
111
+ 'narrator',
112
+ 'suggester',
113
+ ] as const;
114
+
115
+ /** Azure deployment name for a curated slug (`openai/gpt-5.4` → `gpt-5.4`). */
116
+ export function azureDeploymentName(slug: string): string {
117
+ return slug.replace(/^openai\//, '');
118
+ }
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Pure types + presentation helpers for the Runners screen — Mantle's in-app
3
+ * replica of the DBOS admin console. No DBOS / pg imports, so it's safe to pull
4
+ * into a client component without dragging the DBOS SDK + postgres into the
5
+ * browser bundle. The server-only data layer lives in `./runners.ts`.
6
+ *
7
+ * Everything here mirrors DBOS's WorkflowStatus / StepInfo (the system-DB
8
+ * execution journal); see apps/api/src/runs.ts for the original apps/api-side
9
+ * read layer this parallels.
10
+ */
11
+
12
+ /** DBOS workflow lifecycle states (StatusString in the SDK). */
13
+ export type RunnerStatus =
14
+ | 'ENQUEUED'
15
+ | 'PENDING'
16
+ | 'SUCCESS'
17
+ | 'ERROR'
18
+ | 'CANCELLED'
19
+ | 'MAX_RECOVERY_ATTEMPTS_EXCEEDED'
20
+ | 'DELAYED';
21
+
22
+ /** Order shown as filter chips (active states first, terminal after). */
23
+ export const RUNNER_STATUSES: RunnerStatus[] = [
24
+ 'ENQUEUED',
25
+ 'PENDING',
26
+ 'SUCCESS',
27
+ 'ERROR',
28
+ 'CANCELLED',
29
+ 'MAX_RECOVERY_ATTEMPTS_EXCEEDED',
30
+ 'DELAYED',
31
+ ];
32
+
33
+ /** A run is still in flight (queued, delayed, or executing). */
34
+ export function isRunnerActive(status: string): boolean {
35
+ return status === 'ENQUEUED' || status === 'PENDING' || status === 'DELAYED';
36
+ }
37
+
38
+ /** A single runner execution, projected from DBOS WorkflowStatus. */
39
+ export type RunnerRun = {
40
+ workflowID: string;
41
+ /** Runner function name, e.g. 'assistantTurnWorkflow' / 'pingWorkflow'. */
42
+ name: string;
43
+ status: string;
44
+ queue?: string;
45
+ /** Which apps/api process executed it (helps when scaled out). */
46
+ executorId?: string;
47
+ appVersion?: string;
48
+ /** How many times DBOS has tried to recover this run after a crash. */
49
+ recoveryAttempts?: number;
50
+ createdAt: number;
51
+ dequeuedAt?: number;
52
+ completedAt?: number;
53
+ updatedAt?: number;
54
+ /** Wall-clock the runner actually executed (ms). Undefined until completed. */
55
+ runMs?: number;
56
+ /** End-to-end latency incl. queue wait (ms). Undefined until completed. */
57
+ totalMs?: number;
58
+ /** Time spent waiting in the queue before a worker picked it up (ms). */
59
+ queuedMs?: number;
60
+ /** Failure message when status is a failure state. */
61
+ error?: string;
62
+ };
63
+
64
+ /** One journaled step within a run (DBOS StepInfo). */
65
+ export type RunnerStep = {
66
+ functionID: number;
67
+ name: string;
68
+ durationMs?: number;
69
+ startedAt?: number;
70
+ completedAt?: number;
71
+ /** Set when this step invoked a child workflow. */
72
+ childWorkflowID?: string;
73
+ error?: string;
74
+ };
75
+
76
+ /** A run plus its steps and (truncated) input/output, for the detail pane. */
77
+ export type RunnerRunDetail = RunnerRun & {
78
+ /** Truncated JSON preview of the run's input args (owner-only). */
79
+ input?: string;
80
+ /** Truncated JSON preview of the run's return value (owner-only). */
81
+ output?: string;
82
+ steps: RunnerStep[];
83
+ };
84
+
85
+ /** Queue config + live counts — the header "is the runner healthy" strip. */
86
+ export type RunnerQueueHealth = {
87
+ name: string;
88
+ /** Global cap on concurrently-executing runs across all workers. */
89
+ concurrency?: number;
90
+ /** Per-worker cap. */
91
+ workerConcurrency?: number;
92
+ rateLimit?: { limitPerPeriod: number; periodSec: number };
93
+ /** Runs waiting in the queue (ENQUEUED). */
94
+ enqueued: number;
95
+ /** Runs currently executing (PENDING). */
96
+ pending: number;
97
+ /** See RunnerListPage.engineAvailable. */
98
+ engineAvailable?: boolean;
99
+ };
100
+
101
+ export type RunnerListPage = {
102
+ runs: RunnerRun[];
103
+ /** True when another page exists (we over-fetch by one to know). */
104
+ hasMore: boolean;
105
+ /**
106
+ * False when the DBOS system database is not readable from here — runners
107
+ * were never provisioned, or the app connects with a role that cannot see the
108
+ * schema. Distinguishes "no runs" from "no runner engine", which otherwise
109
+ * look identical to a healthy idle queue.
110
+ */
111
+ engineAvailable?: boolean;
112
+ };
113
+
114
+ /** The lifecycle controls this screen exposes (the "observe + lifecycle" cut —
115
+ * no destructive maintenance like delete / garbage-collect). */
116
+ export type RunnerAction = 'cancel' | 'resume' | 'restart' | 'fork';
117
+
118
+ /**
119
+ * Which lifecycle actions make sense for a run in a given status. Drives which
120
+ * buttons render in the detail pane.
121
+ * - cancel: only an in-flight run can be cancelled.
122
+ * - resume: re-run a halted run from its last completed step.
123
+ * - restart: fork from step 0 → a brand-new run from scratch (new id).
124
+ * - fork: fork from a chosen step → a new run (new id).
125
+ */
126
+ export function availableActions(status: string): RunnerAction[] {
127
+ switch (status) {
128
+ case 'ENQUEUED':
129
+ case 'PENDING':
130
+ case 'DELAYED':
131
+ return ['cancel'];
132
+ case 'CANCELLED':
133
+ case 'MAX_RECOVERY_ATTEMPTS_EXCEEDED':
134
+ return ['resume', 'restart', 'fork'];
135
+ case 'ERROR':
136
+ return ['resume', 'restart', 'fork'];
137
+ case 'SUCCESS':
138
+ return ['restart', 'fork'];
139
+ default:
140
+ return [];
141
+ }
142
+ }
143
+
144
+ export const RUNNER_ACTION_LABEL: Record<RunnerAction, string> = {
145
+ cancel: 'Cancel',
146
+ resume: 'Resume',
147
+ restart: 'Restart',
148
+ fork: 'Fork from step',
149
+ };
150
+
151
+ /** Status dot colour — mirrors the /traces palette (emerald/amber/destructive/
152
+ * muted) plus sky for "queued/waiting" so a waiting run reads apart from a
153
+ * running one. */
154
+ export function runnerStatusDot(status: string): string {
155
+ switch (status) {
156
+ case 'SUCCESS':
157
+ return 'bg-emerald-500';
158
+ case 'ERROR':
159
+ case 'MAX_RECOVERY_ATTEMPTS_EXCEEDED':
160
+ return 'bg-destructive';
161
+ case 'CANCELLED':
162
+ return 'bg-muted-foreground/40';
163
+ case 'PENDING':
164
+ return 'bg-amber-500';
165
+ case 'ENQUEUED':
166
+ case 'DELAYED':
167
+ return 'bg-sky-500';
168
+ default:
169
+ return 'bg-muted-foreground/40';
170
+ }
171
+ }
172
+
173
+ export function runnerStatusText(status: string): string {
174
+ switch (status) {
175
+ case 'SUCCESS':
176
+ return 'text-emerald-700 dark:text-emerald-300';
177
+ case 'ERROR':
178
+ case 'MAX_RECOVERY_ATTEMPTS_EXCEEDED':
179
+ return 'text-destructive-ink';
180
+ case 'CANCELLED':
181
+ return 'text-muted-foreground';
182
+ case 'PENDING':
183
+ return 'text-amber-700 dark:text-amber-300';
184
+ case 'ENQUEUED':
185
+ case 'DELAYED':
186
+ return 'text-sky-700 dark:text-sky-300';
187
+ default:
188
+ return 'text-muted-foreground';
189
+ }
190
+ }
191
+
192
+ /** Compact label for a status (the long MAX_RECOVERY… is unwieldy in a chip). */
193
+ export function runnerStatusLabel(status: string): string {
194
+ return status === 'MAX_RECOVERY_ATTEMPTS_EXCEEDED' ? 'MAX RETRIES' : status;
195
+ }
@@ -0,0 +1,86 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { parseSearchQuery, SEARCH_NODE_TYPES } from './search-query';
3
+
4
+ const sp = (query: string) => new URLSearchParams(query);
5
+
6
+ describe('parseSearchQuery', () => {
7
+ it('requires q', () => {
8
+ expect(parseSearchQuery(sp(''))).toEqual({ error: 'q required' });
9
+ expect(parseSearchQuery(sp('q=%20%20'))).toEqual({ error: 'q required' });
10
+ });
11
+
12
+ it('defaults: nodes mode, limit 20', () => {
13
+ const r = parseSearchQuery(sp('q=printer'));
14
+ expect(r).toEqual({
15
+ q: 'printer',
16
+ mode: 'nodes',
17
+ type: undefined,
18
+ branch: undefined,
19
+ tags: undefined,
20
+ limit: 20,
21
+ });
22
+ });
23
+
24
+ it('accepts chunks mode, rejects unknown modes', () => {
25
+ expect(parseSearchQuery(sp('q=x&mode=chunks'))).toMatchObject({ mode: 'chunks' });
26
+ expect(parseSearchQuery(sp('q=x&mode=fuzzy'))).toEqual({
27
+ error: "mode must be 'nodes' or 'chunks'",
28
+ });
29
+ });
30
+
31
+ it('validates type against the tool enum', () => {
32
+ for (const t of SEARCH_NODE_TYPES) {
33
+ expect(parseSearchQuery(sp(`q=x&type=${t}`))).toMatchObject({ type: t });
34
+ }
35
+ expect(parseSearchQuery(sp('q=x&type=widget'))).toEqual({ error: "unknown type 'widget'" });
36
+ });
37
+
38
+ it('validates branch as ltree-safe', () => {
39
+ expect(parseSearchQuery(sp('q=x&branch=files.work'))).toMatchObject({ branch: 'files.work' });
40
+ expect(parseSearchQuery(sp('q=x&branch=files.work;drop'))).toEqual({
41
+ error: 'invalid branch',
42
+ });
43
+ expect(parseSearchQuery(sp('q=x&branch=.leading'))).toEqual({ error: 'invalid branch' });
44
+ });
45
+
46
+ it('rejects over-long branches (Postgres ltree label cap is 255)', () => {
47
+ const longLabel = 'a'.repeat(256);
48
+ expect(parseSearchQuery(sp(`q=x&branch=files.${longLabel}`))).toEqual({
49
+ error: 'invalid branch',
50
+ });
51
+ const longTotal = Array.from({ length: 60 }, () => 'abcdefghij').join('.');
52
+ expect(parseSearchQuery(sp(`q=x&branch=${longTotal}`))).toEqual({
53
+ error: 'invalid branch',
54
+ });
55
+ });
56
+
57
+ it('splits tags on commas, trims, drops empties, caps at 10', () => {
58
+ expect(parseSearchQuery(sp('q=x&tags=work,%20home%20,,'))).toMatchObject({
59
+ tags: ['work', 'home'],
60
+ });
61
+ const many = Array.from({ length: 14 }, (_, i) => `t${i}`).join(',');
62
+ const r = parseSearchQuery(sp(`q=x&tags=${many}`));
63
+ expect(r).toMatchObject({ tags: Array.from({ length: 10 }, (_, i) => `t${i}`) });
64
+ expect(parseSearchQuery(sp('q=x&tags=%20,'))).toMatchObject({ tags: undefined });
65
+ });
66
+
67
+ it('clamps limit to 1..50 with default 20', () => {
68
+ expect(parseSearchQuery(sp('q=x&limit=5'))).toMatchObject({ limit: 5 });
69
+ expect(parseSearchQuery(sp('q=x&limit=500'))).toMatchObject({ limit: 50 });
70
+ expect(parseSearchQuery(sp('q=x&limit=0'))).toEqual({ error: 'invalid limit' });
71
+ expect(parseSearchQuery(sp('q=x&limit=abc'))).toEqual({ error: 'invalid limit' });
72
+ });
73
+
74
+ it('limit parsing is strict-integer, not parseInt-prefix', () => {
75
+ expect(parseSearchQuery(sp('q=x&limit=1e3'))).toMatchObject({ limit: 50 }); // 1000 → clamp
76
+ expect(parseSearchQuery(sp('q=x&limit=2.9'))).toEqual({ error: 'invalid limit' });
77
+ expect(parseSearchQuery(sp('q=x&limit=5abc'))).toEqual({ error: 'invalid limit' });
78
+ expect(parseSearchQuery(sp('q=x&limit=-1'))).toEqual({ error: 'invalid limit' });
79
+ });
80
+
81
+ it('rejects an over-long q', () => {
82
+ expect(parseSearchQuery(sp(`q=${'a'.repeat(501)}`))).toEqual({
83
+ error: 'q too long (max 500)',
84
+ });
85
+ });
86
+ });