Skip to content

Commit d9feef4

Browse files
authored
Merge branch 'main' into blove/followups
2 parents ff31410 + bb2db99 commit d9feef4

112 files changed

Lines changed: 673 additions & 106 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ jobs:
3232
cockpit_e2e: ${{ steps.scope.outputs.cockpit_e2e }}
3333
website_e2e: ${{ steps.scope.outputs.website_e2e }}
3434
posthog: ${{ steps.scope.outputs.posthog }}
35+
scripts_tests: ${{ steps.scope.outputs.scripts_tests }}
3536
steps:
3637
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
3738
with:
@@ -77,6 +78,24 @@ jobs:
7778
- name: Validate CI workflow guards
7879
run: node --test scripts/ci-workflow.spec.mjs
7980

81+
scripts-tests:
82+
name: Scripts — generator / proxy vitest suites
83+
needs: ci-scope
84+
if: github.event_name == 'push' || needs.ci-scope.outputs.scripts_tests == 'true'
85+
runs-on: ubuntu-latest
86+
steps:
87+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
88+
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
89+
with:
90+
node-version: 22
91+
cache: npm
92+
- run: npm ci
93+
# Vitest suites for the root deployment/proxy generators (drift checks,
94+
# framework-adapter branching, requirements unions, rate limits). The
95+
# node:test suites in scripts/ run in ci-scope and library instead —
96+
# see scripts/vite.config.mts for the split.
97+
- run: npx nx test scripts
98+
8099
library:
81100
name: Library — lint / test / build
82101
needs: ci-scope
@@ -543,6 +562,7 @@ jobs:
543562
- cockpit-e2e-summary
544563
- website-e2e
545564
- posthog-sync-plan
565+
- scripts-tests
546566
if: ${{ always() && github.event_name == 'pull_request' }}
547567
runs-on: ubuntu-latest
548568
steps:
@@ -561,6 +581,7 @@ jobs:
561581
RESULT_COCKPIT_E2E: ${{ needs.cockpit-e2e-summary.result }}
562582
RESULT_WEBSITE_E2E: ${{ needs.website-e2e.result }}
563583
RESULT_POSTHOG: ${{ needs.posthog-sync-plan.result }}
584+
RESULT_SCRIPTS_TESTS: ${{ needs.scripts-tests.result }}
564585
SCOPE_LIBRARY: ${{ needs.ci-scope.outputs.library }}
565586
SCOPE_ANGULAR_COMPATIBILITY: ${{ needs.ci-scope.outputs.angular_compatibility }}
566587
SCOPE_WEBSITE: ${{ needs.ci-scope.outputs.website }}
@@ -572,6 +593,7 @@ jobs:
572593
SCOPE_COCKPIT_E2E: ${{ needs.ci-scope.outputs.cockpit_e2e }}
573594
SCOPE_WEBSITE_E2E: ${{ needs.ci-scope.outputs.website_e2e }}
574595
SCOPE_POSTHOG: ${{ needs.ci-scope.outputs.posthog }}
596+
SCOPE_SCRIPTS_TESTS: ${{ needs.ci-scope.outputs.scripts_tests }}
575597
run: |
576598
set -euo pipefail
577599
@@ -624,6 +646,7 @@ jobs:
624646
require_scoped "cockpit_e2e" "Cockpit — e2e" "$RESULT_COCKPIT_E2E" "$SCOPE_COCKPIT_E2E"
625647
require_scoped "website_e2e" "Website — e2e" "$RESULT_WEBSITE_E2E" "$SCOPE_WEBSITE_E2E"
626648
require_scoped "posthog" "PostHog — dashboards-as-code drift check" "$RESULT_POSTHOG" "$SCOPE_POSTHOG"
649+
require_scoped "scripts_tests" "Scripts — generator / proxy vitest suites" "$RESULT_SCRIPTS_TESTS" "$SCOPE_SCRIPTS_TESTS"
627650
628651
if [[ "$failed" -ne 0 ]]; then
629652
exit 1

apps/cockpit/src/components/cockpit-shell.spec.tsx

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,20 @@ const seedMode = (activeMode: 'Run' | 'Code' | 'Docs' | 'API') => {
2323
}));
2424
};
2525

26+
const renderShellFor = (slug: string[]) => {
27+
const pageModel = getCockpitPageModel(slug);
28+
return render(
29+
<ThemeProvider theme="light">
30+
<CockpitShell
31+
navigationTree={pageModel.navigationTree}
32+
presentation={pageModel.presentation}
33+
entryTitle={pageModel.entry.title}
34+
contentBundle={contentBundle}
35+
/>
36+
</ThemeProvider>,
37+
);
38+
};
39+
2640
const renderShell = () => render(
2741
<ThemeProvider theme="light">
2842
<CockpitShell
@@ -85,3 +99,29 @@ describe('CockpitShell control-plane mode state', () => {
8599
expect(screen.getByRole('region', { name: 'Code mode' })).toBeTruthy();
86100
});
87101
});
102+
103+
describe('CockpitShell documentation link', () => {
104+
beforeEach(() => {
105+
window.localStorage.clear();
106+
window.history.replaceState({}, '', '/');
107+
});
108+
109+
it('links a capability to its page on the docs site', () => {
110+
renderShellFor(['langgraph', 'core-capabilities', 'streaming', 'overview', 'python']);
111+
112+
const link = screen.getByRole('link', { name: /read docs/i });
113+
expect(link.getAttribute('href')).toBe(
114+
'https://threadplane.ai/docs/langgraph/guides/streaming'
115+
);
116+
expect(link.getAttribute('target')).toBe('_blank');
117+
expect(link.getAttribute('rel')).toBe('noopener noreferrer');
118+
});
119+
120+
it('renders no link for a capability with no published docs page', () => {
121+
// deep-agents carries the NO_COCKPIT_DOCS_LINK sentinel: the website has no
122+
// deep-agents library yet, so there is nothing to link to.
123+
renderShellFor(['deep-agents', 'core-capabilities', 'planning', 'overview', 'python']);
124+
125+
expect(screen.queryByRole('link', { name: /read docs/i })).toBeNull();
126+
});
127+
});

apps/cockpit/src/components/cockpit-shell.tsx

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import React, { useEffect, useRef, useState } from 'react';
44
import { cockpitManifest } from '@threadplane/cockpit-registry';
5-
import { Menu } from 'lucide-react';
5+
import { BookOpen, Menu } from 'lucide-react';
66
import {
77
parseControlPlaneMode,
88
useControlPlanePreferences,
@@ -11,6 +11,7 @@ import {
1111
import type { ContentBundle } from '../lib/content-bundle';
1212
import type { CapabilityPresentation, NavigationProduct } from '../lib/route-resolution';
1313
import { PRODUCT_LABELS } from '../lib/navigation-labels';
14+
import { resolveDocsUrl } from '../lib/docs-links';
1415
import { CodeMode } from './code-mode/code-mode';
1516
import { ApiMode } from './api-mode/api-mode';
1617
import { NarrativeDocs } from './narrative-docs/narrative-docs';
@@ -47,6 +48,9 @@ export function CockpitShell({
4748
const backendAssetPaths = isCapability ? (presentation.backendAssetPaths ?? []) : [];
4849
const entry = presentation.entry;
4950
const contextLabel = `${PRODUCT_LABELS[entry.product] ?? toLabel(entry.product)} / ${toLabel(entry.section)} / ${toLabel(entry.topic)}`;
51+
// Null for the capabilities that have no published docs page yet — those
52+
// render no link at all rather than one that 404s.
53+
const docsUrl = resolveDocsUrl(presentation.docsPath);
5054

5155
useEffect(() => {
5256
if (!preferences.hydrated || queryHandled.current) return;
@@ -111,6 +115,17 @@ export function CockpitShell({
111115
</button>
112116
<p className="hidden md:block text-[var(--ds-text-muted)] font-mono text-xs truncate">{contextLabel}</p>
113117
</div>
118+
{docsUrl ? (
119+
<a
120+
className="shrink-0 inline-flex items-center gap-1.5 text-xs text-[var(--ds-text-secondary)] hover:text-[var(--ds-text-primary)] no-underline"
121+
href={docsUrl}
122+
target="_blank"
123+
rel="noopener noreferrer"
124+
>
125+
<BookOpen size={14} aria-hidden="true" />
126+
Read docs
127+
</a>
128+
) : null}
114129
</header>
115130

116131
<div className="min-h-0 relative">
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
2+
import { join, resolve } from 'node:path';
3+
import { describe, expect, it } from 'vitest';
4+
import {
5+
COCKPIT_DOCS_LINKS,
6+
COCKPIT_TOPICS_WITHOUT_DOCS,
7+
NO_COCKPIT_DOCS_LINK,
8+
cockpitManifest,
9+
} from '@threadplane/cockpit-registry';
10+
import { docsConfig } from '../../../website/src/lib/docs-config';
11+
12+
/**
13+
* Guard for the cockpit -> website documentation links.
14+
*
15+
* `docsPath` used to be generated from a five-segment formula that matched no
16+
* route the website has ever served, so every link 404'd and nothing noticed:
17+
* the shape was asserted against a regex, never against reality. This spec
18+
* checks each declared path against the website's real content tree and its
19+
* real nav config, so a docs rename breaks a test instead of a link.
20+
*/
21+
22+
const findWorkspaceRoot = (): string => {
23+
let dir = process.cwd();
24+
while (dir !== resolve(dir, '..')) {
25+
if (existsSync(join(dir, 'nx.json'))) return dir;
26+
dir = resolve(dir, '..');
27+
}
28+
throw new Error('workspace root (nx.json) not found');
29+
};
30+
31+
const WORKSPACE_ROOT = findWorkspaceRoot();
32+
const DOCS_CONTENT_ROOT = join(WORKSPACE_ROOT, 'apps/website/content/docs');
33+
34+
/** Every `/docs/<library>/<section>/<slug>` the website's nav actually offers. */
35+
const navRoutes = new Set(
36+
docsConfig.flatMap((library) =>
37+
library.sections.flatMap((section) =>
38+
section.pages.map((page) => `/docs/${library.id}/${section.id}/${page.slug}`)
39+
)
40+
)
41+
);
42+
43+
/** Every `/docs/<library>/<section>/<slug>` backed by an `.mdx` file on disk. */
44+
const contentRoutes = new Set<string>();
45+
for (const library of readdirSync(DOCS_CONTENT_ROOT, { withFileTypes: true })) {
46+
if (!library.isDirectory()) continue;
47+
const libraryDir = join(DOCS_CONTENT_ROOT, library.name);
48+
for (const section of readdirSync(libraryDir, { withFileTypes: true })) {
49+
if (!section.isDirectory()) continue;
50+
const sectionDir = join(libraryDir, section.name);
51+
for (const file of readdirSync(sectionDir)) {
52+
if (!file.endsWith('.mdx')) continue;
53+
contentRoutes.add(
54+
`/docs/${library.name}/${section.name}/${file.slice(0, -'.mdx'.length)}`
55+
);
56+
}
57+
}
58+
}
59+
60+
/**
61+
* Descriptors are duplicated per example (cockpit examples are standalone), so
62+
* they are read off disk rather than imported — an example whose module nobody
63+
* imports still has to declare a link that resolves.
64+
*/
65+
const readDescriptorDocsPaths = (): { file: string; key: string; docsPath: string }[] => {
66+
const results: { file: string; key: string; docsPath: string }[] = [];
67+
const cockpitRoot = join(WORKSPACE_ROOT, 'cockpit');
68+
for (const product of readdirSync(cockpitRoot, { withFileTypes: true })) {
69+
if (!product.isDirectory()) continue;
70+
const productDir = join(cockpitRoot, product.name);
71+
for (const topic of readdirSync(productDir, { withFileTypes: true })) {
72+
if (!topic.isDirectory()) continue;
73+
for (const lane of readdirSync(join(productDir, topic.name), { withFileTypes: true })) {
74+
if (!lane.isDirectory()) continue;
75+
const file = join(productDir, topic.name, lane.name, 'src/index.ts');
76+
if (!existsSync(file)) continue;
77+
const source = readFileSync(file, 'utf-8');
78+
const identity = /manifestIdentity:\s*\{[^}]*?product:\s*'([^']+)'[^}]*?section:\s*'([^']+)'[^}]*?topic:\s*'([^']+)'/s.exec(
79+
source
80+
);
81+
const declared = /\n {2}docsPath: '([^']*)',/.exec(source);
82+
if (!identity || !declared) continue;
83+
results.push({
84+
file: file.slice(WORKSPACE_ROOT.length + 1),
85+
key: `${identity[1]}/${identity[2]}/${identity[3]}`,
86+
docsPath: declared[1],
87+
});
88+
}
89+
}
90+
}
91+
return results;
92+
};
93+
94+
const descriptors = readDescriptorDocsPaths();
95+
96+
describe('cockpit docs links', () => {
97+
it('reads a docs route list from the website that is not empty', () => {
98+
// Guards the guard: an empty derived list would let everything below pass.
99+
expect(navRoutes.size).toBeGreaterThan(50);
100+
expect(contentRoutes.size).toBeGreaterThan(50);
101+
});
102+
103+
it('points every mapped capability at a page the website actually serves', () => {
104+
const broken = Object.entries(COCKPIT_DOCS_LINKS)
105+
.filter(([, path]) => path !== NO_COCKPIT_DOCS_LINK)
106+
.filter(([, path]) => !contentRoutes.has(path) || !navRoutes.has(path))
107+
.map(([key, path]) => `${key} -> ${path}`);
108+
109+
expect(broken).toEqual([]);
110+
});
111+
112+
it('blanks only the capabilities that are known to have no docs page', () => {
113+
const blanked = Object.entries(COCKPIT_DOCS_LINKS)
114+
.filter(([, path]) => path === NO_COCKPIT_DOCS_LINK)
115+
.map(([key]) => key)
116+
.sort();
117+
118+
expect(blanked).toEqual([...COCKPIT_TOPICS_WITHOUT_DOCS].sort());
119+
});
120+
121+
it('maps every manifest entry', () => {
122+
const unmapped = cockpitManifest
123+
.filter((entry) => !(`${entry.product}/${entry.section}/${entry.topic}` in COCKPIT_DOCS_LINKS))
124+
.map((entry) => `${entry.product}/${entry.section}/${entry.topic}`);
125+
126+
expect(unmapped).toEqual([]);
127+
});
128+
129+
it('keeps every per-example descriptor in step with the shared table', () => {
130+
expect(descriptors.length).toBeGreaterThan(60);
131+
132+
const drifted = descriptors
133+
.filter(({ key, docsPath }) => docsPath !== COCKPIT_DOCS_LINKS[key])
134+
.map(({ file, key, docsPath }) => `${file}: ${key} declares ${docsPath || '(blank)'}`);
135+
136+
expect(drifted).toEqual([]);
137+
});
138+
139+
it('declares no five-segment legacy docs path anywhere', () => {
140+
const legacy = descriptors
141+
.filter(({ docsPath }) => docsPath.split('/').filter(Boolean).length > 4)
142+
.map(({ file, docsPath }) => `${file}: ${docsPath}`);
143+
144+
expect(legacy).toEqual([]);
145+
});
146+
});

apps/cockpit/src/lib/docs-links.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { NO_COCKPIT_DOCS_LINK } from '@threadplane/cockpit-registry';
2+
3+
/**
4+
* Absolute URL for a capability's `docsPath`.
5+
*
6+
* `docsPath` is a website-relative path (`/docs/<library>/<section>/<slug>`),
7+
* but the cockpit is served from its own origin (cockpit.threadplane.ai), so
8+
* the link has to be absolutised against the docs site.
9+
*
10+
* Returns `null` when the capability has no published docs page — callers
11+
* render no link rather than one that 404s.
12+
*/
13+
export function resolveDocsUrl(docsPath: string | undefined): string | null {
14+
if (!docsPath || docsPath === NO_COCKPIT_DOCS_LINK) return null;
15+
if (/^https?:\/\//.test(docsPath)) return docsPath;
16+
17+
const baseUrl = (
18+
process.env['NEXT_PUBLIC_COCKPIT_DOCS_BASE_URL'] ?? 'https://threadplane.ai'
19+
).replace(/\/$/, '');
20+
21+
return `${baseUrl}${docsPath.startsWith('/') ? docsPath : `/${docsPath}`}`;
22+
}

apps/cockpit/src/lib/route-resolution.spec.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -176,11 +176,11 @@ describe('getCapabilityPresentation', () => {
176176

177177
expect(getCapabilityPresentation(docsEntry)).toMatchObject({
178178
kind: 'docs-only',
179-
docsPath: '/docs/deep-agents/getting-started/overview/overview/python',
179+
docsPath: '',
180180
});
181181
expect(getCapabilityPresentation(capabilityEntry)).toMatchObject({
182182
kind: 'capability',
183-
docsPath: '/docs/langgraph/core-capabilities/streaming/overview/python',
183+
docsPath: '/docs/langgraph/guides/streaming',
184184
promptAssetPaths: ['cockpit/langgraph/streaming/python/prompts/streaming.md'],
185185
codeAssetPaths: [
186186
'cockpit/langgraph/streaming/angular/src/app/streaming.component.ts',
@@ -237,7 +237,7 @@ describe('getCapabilityPresentation', () => {
237237

238238
expect(presentation).toMatchObject({
239239
kind: 'capability',
240-
docsPath: '/docs/langgraph/core-capabilities/durable-execution/overview/python',
240+
docsPath: '/docs/langgraph/guides/persistence',
241241
docsAssetPaths: ['cockpit/langgraph/durable-execution/python/docs/guide.md'],
242242
});
243243
});
@@ -255,7 +255,7 @@ describe('getCapabilityPresentation', () => {
255255

256256
expect(presentation).toMatchObject({
257257
kind: 'capability',
258-
docsPath: '/docs/render/core-capabilities/spec-rendering/overview/python',
258+
docsPath: '/docs/render/guides/specs',
259259
});
260260
});
261261

@@ -272,7 +272,7 @@ describe('getCapabilityPresentation', () => {
272272

273273
expect(presentation).toMatchObject({
274274
kind: 'capability',
275-
docsPath: '/docs/chat/core-capabilities/messages/overview/python',
275+
docsPath: '/docs/chat/concepts/message-model',
276276
});
277277
});
278278

0 commit comments

Comments
 (0)