@openforge-app/plugin-sdk 0.2.10 → 0.3.0
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.
- package/README.md +220 -8
- package/dist/domain.d.ts +50 -1
- package/dist/fileIcons.js +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/markdown.js +7 -1
- package/dist/mermaid.d.ts +13 -0
- package/dist/mermaid.js +221 -0
- package/dist/mermaidZoom.d.ts +23 -0
- package/dist/mermaidZoom.js +52 -0
- package/dist/publicEntrypoints.d.mts +1 -0
- package/dist/publicEntrypoints.mjs +23 -22
- package/dist/publicUiExports.mjs +10 -22
- package/dist/registryValidation.d.mts +11 -0
- package/dist/registryValidation.mjs +30 -0
- package/dist/sanitize.d.ts +2 -0
- package/dist/sanitize.js +131 -0
- package/dist/testing/commonApiFake.d.ts +4 -2
- package/dist/testing/commonApiFake.js +247 -4
- package/dist/testing/contracts.d.ts +20 -1
- package/dist/testing/registryFake.d.ts +2 -1
- package/dist/testing/registryFake.js +3 -0
- package/dist/testing/support.d.ts +3 -1
- package/dist/testing/support.js +7 -0
- package/dist/types.d.ts +40 -9
- package/dist/ui/MarkdownContent.svelte +46 -0
- package/dist/ui/MermaidDiagramPreview.svelte +216 -0
- package/dist/ui/Modal.svelte +6 -1
- package/dist/ui/PluginSidebarLink.svelte +1 -7
- package/dist/ui/icons/video.svg +3 -0
- package/package.json +14 -5
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export const MIN_MERMAID_ZOOM = 0.25;
|
|
2
|
+
export const MAX_MERMAID_ZOOM = 4;
|
|
3
|
+
export const MERMAID_ZOOM_STEP = 0.25;
|
|
4
|
+
export const FIT_MERMAID_ZOOM = { mode: 'fit' };
|
|
5
|
+
function isPositiveSize(size) {
|
|
6
|
+
return Number.isFinite(size.width)
|
|
7
|
+
&& Number.isFinite(size.height)
|
|
8
|
+
&& size.width > 0
|
|
9
|
+
&& size.height > 0;
|
|
10
|
+
}
|
|
11
|
+
function clampManualScale(scale) {
|
|
12
|
+
if (!Number.isFinite(scale))
|
|
13
|
+
return 1;
|
|
14
|
+
return Math.min(MAX_MERMAID_ZOOM, Math.max(MIN_MERMAID_ZOOM, scale));
|
|
15
|
+
}
|
|
16
|
+
function normalizeScale(scale) {
|
|
17
|
+
return Math.round(scale * 10_000) / 10_000;
|
|
18
|
+
}
|
|
19
|
+
export function calculateMermaidFitScale(content, viewport) {
|
|
20
|
+
if (!isPositiveSize(content) || !isPositiveSize(viewport))
|
|
21
|
+
return null;
|
|
22
|
+
return Math.min(viewport.width / content.width, viewport.height / content.height);
|
|
23
|
+
}
|
|
24
|
+
export function createManualMermaidZoom(scale) {
|
|
25
|
+
return { mode: 'manual', scale: clampManualScale(scale) };
|
|
26
|
+
}
|
|
27
|
+
export function resolveMermaidZoomScale(state, fitScale) {
|
|
28
|
+
if (state.mode === 'manual')
|
|
29
|
+
return state.scale;
|
|
30
|
+
return fitScale && fitScale > 0 ? fitScale : 1;
|
|
31
|
+
}
|
|
32
|
+
export function zoomMermaidIn(state, fitScale) {
|
|
33
|
+
const currentScale = resolveMermaidZoomScale(state, fitScale);
|
|
34
|
+
return createManualMermaidZoom(normalizeScale(currentScale + MERMAID_ZOOM_STEP));
|
|
35
|
+
}
|
|
36
|
+
export function zoomMermaidOut(state, fitScale) {
|
|
37
|
+
const currentScale = resolveMermaidZoomScale(state, fitScale);
|
|
38
|
+
return createManualMermaidZoom(normalizeScale(currentScale - MERMAID_ZOOM_STEP));
|
|
39
|
+
}
|
|
40
|
+
export function resetMermaidZoom() {
|
|
41
|
+
return createManualMermaidZoom(1);
|
|
42
|
+
}
|
|
43
|
+
export function canZoomMermaidIn(state, fitScale) {
|
|
44
|
+
return resolveMermaidZoomScale(state, fitScale) < MAX_MERMAID_ZOOM;
|
|
45
|
+
}
|
|
46
|
+
export function canZoomMermaidOut(state, fitScale) {
|
|
47
|
+
return resolveMermaidZoomScale(state, fitScale) > MIN_MERMAID_ZOOM;
|
|
48
|
+
}
|
|
49
|
+
export function formatMermaidZoomLabel(state, fitScale) {
|
|
50
|
+
const percentage = Math.round(resolveMermaidZoomScale(state, fitScale) * 100);
|
|
51
|
+
return state.mode === 'fit' ? `Fit (${percentage}%)` : `${percentage}%`;
|
|
52
|
+
}
|
|
@@ -20,6 +20,7 @@ export function createOpenForgePluginSdkPackageExports(): Record<
|
|
|
20
20
|
|
|
21
21
|
export function createOpenForgePluginSdkTypeScriptPaths(): Record<string, [string]>
|
|
22
22
|
|
|
23
|
+
export function loadOpenForgePluginSdkTypeScriptPaths(workspaceRoot: string): Promise<Record<string, unknown>>
|
|
23
24
|
export function assertOpenForgePluginSdkEntrypointRegistries(registries: {
|
|
24
25
|
packageExports: unknown
|
|
25
26
|
typeScriptPaths: unknown
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { readFile } from 'node:fs/promises'
|
|
2
|
+
import { resolve } from 'node:path'
|
|
3
|
+
import { assertRegistryMatchesCanonicalManifest } from './registryValidation.mjs'
|
|
2
4
|
import { OPENFORGE_PLUGIN_SDK_PUBLIC_UI_EXPORTS } from './publicUiExports.mjs'
|
|
3
5
|
|
|
4
6
|
const PLUGIN_SDK_PACKAGE_NAME = '@openforge-app/plugin-sdk'
|
|
@@ -77,27 +79,26 @@ export function createOpenForgePluginSdkTypeScriptPaths() {
|
|
|
77
79
|
)
|
|
78
80
|
}
|
|
79
81
|
|
|
80
|
-
export function
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
function assertRegistryMatches(registryName, actual, expected) {
|
|
86
|
-
if (!actual || typeof actual !== 'object' || Array.isArray(actual)) {
|
|
87
|
-
throw new Error(`Plugin SDK ${registryName} must be an object`)
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
const missingOrMismatched = Object.entries(expected)
|
|
91
|
-
.filter(([key, value]) => !isDeepStrictEqual(actual[key], value))
|
|
92
|
-
.map(([key]) => key)
|
|
93
|
-
const unexpected = Object.keys(actual).filter((key) => !(key in expected))
|
|
94
|
-
|
|
95
|
-
if (missingOrMismatched.length === 0 && unexpected.length === 0) return
|
|
82
|
+
export async function loadOpenForgePluginSdkTypeScriptPaths(workspaceRoot) {
|
|
83
|
+
const typeScriptConfig = JSON.parse(
|
|
84
|
+
(await readFile(resolve(workspaceRoot, 'tsconfig.json'), 'utf8')).replace(/\/\*[\s\S]*?\*\//g, ''),
|
|
85
|
+
)
|
|
96
86
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
87
|
+
return Object.fromEntries(
|
|
88
|
+
Object.entries(typeScriptConfig.compilerOptions.paths)
|
|
89
|
+
.filter(([specifier]) => specifier === PLUGIN_SDK_PACKAGE_NAME || specifier.startsWith(`${PLUGIN_SDK_PACKAGE_NAME}/`)),
|
|
90
|
+
)
|
|
91
|
+
}
|
|
101
92
|
|
|
102
|
-
|
|
93
|
+
export function assertOpenForgePluginSdkEntrypointRegistries({ packageExports, typeScriptPaths }) {
|
|
94
|
+
assertRegistryMatchesCanonicalManifest({
|
|
95
|
+
registryName: 'Plugin SDK package exports',
|
|
96
|
+
actual: packageExports,
|
|
97
|
+
expected: createOpenForgePluginSdkPackageExports(),
|
|
98
|
+
})
|
|
99
|
+
assertRegistryMatchesCanonicalManifest({
|
|
100
|
+
registryName: 'Plugin SDK root TypeScript paths',
|
|
101
|
+
actual: typeScriptPaths,
|
|
102
|
+
expected: createOpenForgePluginSdkTypeScriptPaths(),
|
|
103
|
+
})
|
|
103
104
|
}
|
package/dist/publicUiExports.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { assertRegistryMatchesCanonicalManifest } from './registryValidation.mjs'
|
|
2
|
+
|
|
1
3
|
const PLUGIN_SDK_PACKAGE_NAME = '@openforge-app/plugin-sdk'
|
|
2
4
|
|
|
3
5
|
const PUBLIC_UI_COMPONENT_NAMES = Object.freeze([
|
|
@@ -38,26 +40,12 @@ export function createOpenForgePluginSdkPublicUiPackageExports() {
|
|
|
38
40
|
}
|
|
39
41
|
|
|
40
42
|
export function assertOpenForgePluginSdkPublicUiPackageExports(packageExports) {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
)
|
|
49
|
-
const expectedEntries = Object.entries(expected)
|
|
50
|
-
const missingOrMismatched = expectedEntries
|
|
51
|
-
.filter(([subpath, distPath]) => actual[subpath] !== distPath)
|
|
52
|
-
.map(([subpath, distPath]) => `${subpath} -> ${distPath}`)
|
|
53
|
-
const unexpected = Object.keys(actual).filter((subpath) => !(subpath in expected))
|
|
54
|
-
|
|
55
|
-
if (missingOrMismatched.length === 0 && unexpected.length === 0) return
|
|
56
|
-
|
|
57
|
-
const details = [
|
|
58
|
-
missingOrMismatched.length > 0 ? `missing or mismatched: ${missingOrMismatched.join(', ')}` : null,
|
|
59
|
-
unexpected.length > 0 ? `not in the canonical manifest: ${unexpected.join(', ')}` : null,
|
|
60
|
-
].filter(Boolean)
|
|
61
|
-
|
|
62
|
-
throw new Error(`Plugin SDK public UI exports drifted from the canonical manifest (${details.join('; ')})`)
|
|
43
|
+
assertRegistryMatchesCanonicalManifest({
|
|
44
|
+
registryName: 'Plugin SDK public UI exports',
|
|
45
|
+
actual: packageExports,
|
|
46
|
+
expected: createOpenForgePluginSdkPublicUiPackageExports(),
|
|
47
|
+
invalidRegistryMessage: 'Plugin SDK package.json must define an exports object',
|
|
48
|
+
includeActualEntry: ([subpath]) => subpath.startsWith('./ui/'),
|
|
49
|
+
formatMissingOrMismatched: ([subpath, distPath]) => `${subpath} -> ${distPath}`,
|
|
50
|
+
})
|
|
63
51
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
type Registry = Record<string, unknown>
|
|
2
|
+
type RegistryEntry = [string, unknown]
|
|
3
|
+
|
|
4
|
+
export function assertRegistryMatchesCanonicalManifest(options: {
|
|
5
|
+
registryName: string
|
|
6
|
+
actual: unknown
|
|
7
|
+
expected: Registry
|
|
8
|
+
invalidRegistryMessage?: string
|
|
9
|
+
includeActualEntry?: (entry: RegistryEntry) => boolean
|
|
10
|
+
formatMissingOrMismatched?: (entry: RegistryEntry) => string
|
|
11
|
+
}): void
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { isDeepStrictEqual } from 'node:util'
|
|
2
|
+
|
|
3
|
+
export function assertRegistryMatchesCanonicalManifest({
|
|
4
|
+
registryName,
|
|
5
|
+
actual,
|
|
6
|
+
expected,
|
|
7
|
+
invalidRegistryMessage = `${registryName} must be an object`,
|
|
8
|
+
includeActualEntry = () => true,
|
|
9
|
+
formatMissingOrMismatched = ([key]) => key,
|
|
10
|
+
}) {
|
|
11
|
+
if (!actual || typeof actual !== 'object' || Array.isArray(actual)) {
|
|
12
|
+
throw new Error(invalidRegistryMessage)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const comparableActual = Object.fromEntries(Object.entries(actual).filter(includeActualEntry))
|
|
16
|
+
|
|
17
|
+
const missingOrMismatched = Object.entries(expected)
|
|
18
|
+
.filter(([key, value]) => !isDeepStrictEqual(comparableActual[key], value))
|
|
19
|
+
.map(formatMissingOrMismatched)
|
|
20
|
+
const unexpected = Object.keys(comparableActual).filter((key) => !(key in expected))
|
|
21
|
+
|
|
22
|
+
if (missingOrMismatched.length === 0 && unexpected.length === 0) return
|
|
23
|
+
|
|
24
|
+
const details = [
|
|
25
|
+
missingOrMismatched.length > 0 ? `missing or mismatched: ${missingOrMismatched.join(', ')}` : null,
|
|
26
|
+
unexpected.length > 0 ? `not in the canonical manifest: ${unexpected.join(', ')}` : null,
|
|
27
|
+
].filter(Boolean)
|
|
28
|
+
|
|
29
|
+
throw new Error(`${registryName} drifted from the canonical manifest (${details.join('; ')})`)
|
|
30
|
+
}
|
package/dist/sanitize.d.ts
CHANGED
package/dist/sanitize.js
CHANGED
|
@@ -11,3 +11,134 @@ export function sanitizeHtml(dirty) {
|
|
|
11
11
|
FORBID_ATTR: ['style'],
|
|
12
12
|
});
|
|
13
13
|
}
|
|
14
|
+
const SAFE_MERMAID_STYLE_PROPERTIES = new Set([
|
|
15
|
+
'color',
|
|
16
|
+
'dominant-baseline',
|
|
17
|
+
'fill',
|
|
18
|
+
'fill-opacity',
|
|
19
|
+
'font-family',
|
|
20
|
+
'font-size',
|
|
21
|
+
'font-style',
|
|
22
|
+
'font-weight',
|
|
23
|
+
'max-width',
|
|
24
|
+
'opacity',
|
|
25
|
+
'stroke',
|
|
26
|
+
'stroke-dasharray',
|
|
27
|
+
'stroke-dashoffset',
|
|
28
|
+
'stroke-linecap',
|
|
29
|
+
'stroke-linejoin',
|
|
30
|
+
'stroke-opacity',
|
|
31
|
+
'stroke-width',
|
|
32
|
+
'text-align',
|
|
33
|
+
'text-anchor',
|
|
34
|
+
'white-space',
|
|
35
|
+
]);
|
|
36
|
+
function getSafeMermaidStyleDeclarations(style) {
|
|
37
|
+
const safeDeclarations = [];
|
|
38
|
+
for (const property of Array.from(style)) {
|
|
39
|
+
if (!SAFE_MERMAID_STYLE_PROPERTIES.has(property))
|
|
40
|
+
continue;
|
|
41
|
+
const value = style.getPropertyValue(property);
|
|
42
|
+
if (/url\s*\(|expression\s*\(|javascript:|data:|@import|var\s*\(/i.test(value))
|
|
43
|
+
continue;
|
|
44
|
+
const priority = style.getPropertyPriority(property);
|
|
45
|
+
safeDeclarations.push(`${property}: ${value}${priority ? ` !${priority}` : ''}`);
|
|
46
|
+
}
|
|
47
|
+
return safeDeclarations;
|
|
48
|
+
}
|
|
49
|
+
function removeCssAtRules(css) {
|
|
50
|
+
let result = '';
|
|
51
|
+
let index = 0;
|
|
52
|
+
while (index < css.length) {
|
|
53
|
+
if (css[index] !== '@') {
|
|
54
|
+
result += css[index++];
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
let depth = 0;
|
|
58
|
+
let quote = '';
|
|
59
|
+
for (; index < css.length; index++) {
|
|
60
|
+
const character = css[index];
|
|
61
|
+
if (quote) {
|
|
62
|
+
if (character === '\\')
|
|
63
|
+
index++;
|
|
64
|
+
else if (character === quote)
|
|
65
|
+
quote = '';
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (character === '"' || character === "'") {
|
|
69
|
+
quote = character;
|
|
70
|
+
}
|
|
71
|
+
else if (character === '{') {
|
|
72
|
+
depth++;
|
|
73
|
+
}
|
|
74
|
+
else if (character === '}') {
|
|
75
|
+
if (--depth <= 0) {
|
|
76
|
+
index++;
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
else if (character === ';' && depth === 0) {
|
|
81
|
+
index++;
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return result;
|
|
87
|
+
}
|
|
88
|
+
function sanitizeMermaidStylesheet(css, styleParser) {
|
|
89
|
+
const withoutComments = css.replace(/\/\*[\s\S]*?\*\//g, '');
|
|
90
|
+
const safeRuleSource = removeCssAtRules(withoutComments);
|
|
91
|
+
const rules = [];
|
|
92
|
+
const rulePattern = /([^{}]+)\{([^{}]*)\}/g;
|
|
93
|
+
let consumedUntil = 0;
|
|
94
|
+
let match;
|
|
95
|
+
while ((match = rulePattern.exec(safeRuleSource)) !== null) {
|
|
96
|
+
if (safeRuleSource.slice(consumedUntil, match.index).trim())
|
|
97
|
+
return '';
|
|
98
|
+
consumedUntil = rulePattern.lastIndex;
|
|
99
|
+
const selector = match[1].trim();
|
|
100
|
+
if (!selector || /[{}@\\]/.test(selector))
|
|
101
|
+
continue;
|
|
102
|
+
styleParser.setAttribute('style', match[2]);
|
|
103
|
+
const declarations = getSafeMermaidStyleDeclarations(styleParser.style);
|
|
104
|
+
if (declarations.length > 0)
|
|
105
|
+
rules.push(`${selector} { ${declarations.join('; ')} }`);
|
|
106
|
+
}
|
|
107
|
+
if (safeRuleSource.slice(consumedUntil).trim())
|
|
108
|
+
return '';
|
|
109
|
+
return rules.join('\n');
|
|
110
|
+
}
|
|
111
|
+
function sanitizeMermaidStyles(svg) {
|
|
112
|
+
const template = document.createElement('template');
|
|
113
|
+
template.innerHTML = svg;
|
|
114
|
+
const styleParser = document.createElement('span');
|
|
115
|
+
for (const element of template.content.querySelectorAll('[style]')) {
|
|
116
|
+
styleParser.setAttribute('style', element.getAttribute('style') ?? '');
|
|
117
|
+
const safeDeclarations = getSafeMermaidStyleDeclarations(styleParser.style);
|
|
118
|
+
if (safeDeclarations.length > 0) {
|
|
119
|
+
element.setAttribute('style', safeDeclarations.join('; '));
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
element.removeAttribute('style');
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
for (const style of template.content.querySelectorAll('style')) {
|
|
126
|
+
const sanitized = sanitizeMermaidStylesheet(style.textContent ?? '', styleParser);
|
|
127
|
+
if (sanitized) {
|
|
128
|
+
style.textContent = sanitized;
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
style.remove();
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return template.innerHTML;
|
|
135
|
+
}
|
|
136
|
+
/** Sanitize generated Mermaid SVG before it is inserted into the document. */
|
|
137
|
+
export function sanitizeMermaidSvg(dirty) {
|
|
138
|
+
const sanitized = DOMPurify.sanitize(dirty, {
|
|
139
|
+
USE_PROFILES: { svg: true, svgFilters: true },
|
|
140
|
+
FORBID_TAGS: ['script', 'foreignObject', 'iframe', 'object', 'embed', 'image', 'a'],
|
|
141
|
+
FORBID_ATTR: ['href', 'xlink:href', 'target'],
|
|
142
|
+
});
|
|
143
|
+
return sanitizeMermaidStyles(sanitized);
|
|
144
|
+
}
|
|
@@ -1,14 +1,16 @@
|
|
|
1
|
-
import type { BackendOpenForgeAPI, FrontendOpenForgeAPI, OpenForgeCommonAPI } from '../types';
|
|
1
|
+
import type { BackendOpenForgeAPI, FrontendOpenForgeAPI, TaskChangeEvent, OpenForgeCommonAPI } from '../types';
|
|
2
2
|
import { type TestingRegistryServices } from './support.js';
|
|
3
3
|
import type { TestingCommandContribution, TestingEventListenerContribution } from './contracts';
|
|
4
|
-
export type TestingCommonApi = OpenForgeCommonAPI & Pick<FrontendOpenForgeAPI, 'navigation'>;
|
|
4
|
+
export type TestingCommonApi = Omit<OpenForgeCommonAPI, 'tasks'> & Pick<FrontendOpenForgeAPI, 'tasks' | 'navigation'>;
|
|
5
5
|
export declare class TestingCommonApiFake {
|
|
6
6
|
private readonly services;
|
|
7
7
|
private readonly commands;
|
|
8
8
|
private readonly eventListeners;
|
|
9
9
|
private readonly eventHandlers;
|
|
10
|
+
private readonly taskChangeHandlers;
|
|
10
11
|
private eventListenerSequence;
|
|
11
12
|
constructor(services: TestingRegistryServices);
|
|
13
|
+
emitTaskChange(event: TaskChangeEvent): void;
|
|
12
14
|
createApi(): TestingCommonApi;
|
|
13
15
|
createBackendApi(): TestingCommonApi & Pick<BackendOpenForgeAPI, 'fs'>;
|
|
14
16
|
getSnapshot(): {
|
|
@@ -2,6 +2,39 @@ import { MAX_AGENT_SESSION_PAGE_SIZE, resolveExternalTextFileChunkSize } from '.
|
|
|
2
2
|
import { assertFunction, assertTitle, commandDescriptor, createDisposable, isJsonValue, normalizeAgentCommandMetadata, } from './support.js';
|
|
3
3
|
const UTF8_ENCODER = new TextEncoder();
|
|
4
4
|
const UTF8_DECODER = new TextDecoder('utf-8', { fatal: true });
|
|
5
|
+
function readTestingUserDataDir(files, directoryPath) {
|
|
6
|
+
const prefix = directoryPath ? `${directoryPath}/` : '';
|
|
7
|
+
const entries = new Map();
|
|
8
|
+
for (const [filePath, content] of files) {
|
|
9
|
+
if (!filePath.startsWith(prefix))
|
|
10
|
+
continue;
|
|
11
|
+
const childPath = filePath.slice(prefix.length);
|
|
12
|
+
const separatorIndex = childPath.indexOf('/');
|
|
13
|
+
const name = separatorIndex === -1 ? childPath : childPath.slice(0, separatorIndex);
|
|
14
|
+
if (!name)
|
|
15
|
+
continue;
|
|
16
|
+
entries.set(name, separatorIndex === -1
|
|
17
|
+
? {
|
|
18
|
+
name,
|
|
19
|
+
path: `${prefix}${name}`,
|
|
20
|
+
isDir: false,
|
|
21
|
+
size: UTF8_ENCODER.encode(content).byteLength,
|
|
22
|
+
modifiedAt: null,
|
|
23
|
+
}
|
|
24
|
+
: {
|
|
25
|
+
name,
|
|
26
|
+
path: `${prefix}${name}`,
|
|
27
|
+
isDir: true,
|
|
28
|
+
size: null,
|
|
29
|
+
modifiedAt: null,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
return [...entries.values()].sort((left, right) => {
|
|
33
|
+
if (left.isDir !== right.isDir)
|
|
34
|
+
return left.isDir ? -1 : 1;
|
|
35
|
+
return left.name < right.name ? -1 : left.name > right.name ? 1 : 0;
|
|
36
|
+
});
|
|
37
|
+
}
|
|
5
38
|
const TERMINAL_AGENT_SESSION_STATUSES = new Set(['completed', 'failed', 'interrupted']);
|
|
6
39
|
function encodeAgentSessionCursor(payload) {
|
|
7
40
|
const bytes = UTF8_ENCODER.encode(JSON.stringify(payload));
|
|
@@ -82,15 +115,172 @@ function* splitExternalTextFile(content, maxBytes) {
|
|
|
82
115
|
if (chunk.length > 0)
|
|
83
116
|
yield chunk;
|
|
84
117
|
}
|
|
118
|
+
function isTestingImageReferenceDefinition(line) {
|
|
119
|
+
const separator = line.indexOf(':');
|
|
120
|
+
if (separator < 0)
|
|
121
|
+
return false;
|
|
122
|
+
const marker = line.slice(0, separator);
|
|
123
|
+
const value = line.slice(separator + 1);
|
|
124
|
+
const imageNumber = marker.startsWith('[image#') && marker.endsWith(']')
|
|
125
|
+
? marker.slice('[image#'.length, -1)
|
|
126
|
+
: null;
|
|
127
|
+
return imageNumber !== null
|
|
128
|
+
&& imageNumber.length > 0
|
|
129
|
+
&& /^\d+$/u.test(imageNumber)
|
|
130
|
+
&& value.trimStart().startsWith('data:image/')
|
|
131
|
+
&& value.includes(';base64,');
|
|
132
|
+
}
|
|
133
|
+
function testingTaskPromptPreview(task) {
|
|
134
|
+
const lines = task.initial_prompt
|
|
135
|
+
.split(/\r?\n/u)
|
|
136
|
+
.filter(line => !isTestingImageReferenceDefinition(line));
|
|
137
|
+
while (lines.at(-1)?.trim() === '')
|
|
138
|
+
lines.pop();
|
|
139
|
+
return [...lines.join('\n')].slice(0, 120).join('');
|
|
140
|
+
}
|
|
141
|
+
function testingTaskTitle(task, preview) {
|
|
142
|
+
const explicitTitle = task.title?.trim();
|
|
143
|
+
if (explicitTitle)
|
|
144
|
+
return explicitTitle;
|
|
145
|
+
const fallback = preview.split(/\r?\n/u).map(line => line.trim()).find(Boolean) || task.id;
|
|
146
|
+
return [...fallback].slice(0, 120).join('');
|
|
147
|
+
}
|
|
148
|
+
function taskReference(task) {
|
|
149
|
+
const preview = testingTaskPromptPreview(task);
|
|
150
|
+
if (!task.project_id)
|
|
151
|
+
throw new Error(`Task ${task.id} must belong to a project`);
|
|
152
|
+
return {
|
|
153
|
+
id: task.id,
|
|
154
|
+
status: task.status,
|
|
155
|
+
projectId: task.project_id,
|
|
156
|
+
title: testingTaskTitle(task, preview),
|
|
157
|
+
dependsOn: [...task.depends_on],
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
function taskSummary(task, labels = []) {
|
|
161
|
+
return {
|
|
162
|
+
...taskReference(task),
|
|
163
|
+
createdAt: task.created_at,
|
|
164
|
+
updatedAt: task.updated_at,
|
|
165
|
+
promptPreview: testingTaskPromptPreview(task),
|
|
166
|
+
labels: [...labels],
|
|
167
|
+
sourceTicketUrl: task.source_ticket_url,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
function taskDetail(task, labels = []) {
|
|
171
|
+
return {
|
|
172
|
+
...taskSummary(task, labels),
|
|
173
|
+
prompt: task.initial_prompt,
|
|
174
|
+
agent: task.agent,
|
|
175
|
+
permissionMode: task.permission_mode,
|
|
176
|
+
worktreeSource: task.worktree_source,
|
|
177
|
+
worktreeBranch: task.worktree_branch,
|
|
178
|
+
titleSource: task.title_source,
|
|
179
|
+
titleGeneratedAt: task.title_generated_at,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
function asciiLowercase(value) {
|
|
183
|
+
return value.replace(/[A-Z]/gu, character => character.toLowerCase());
|
|
184
|
+
}
|
|
185
|
+
function compareCompletedTasks(left, right) {
|
|
186
|
+
if (left.updated_at !== right.updated_at)
|
|
187
|
+
return right.updated_at - left.updated_at;
|
|
188
|
+
if (left.id === right.id)
|
|
189
|
+
return 0;
|
|
190
|
+
return left.id > right.id ? -1 : 1;
|
|
191
|
+
}
|
|
192
|
+
function testingCompletedTaskScope(projectId, query) {
|
|
193
|
+
return {
|
|
194
|
+
projectId,
|
|
195
|
+
search: asciiLowercase(query.search?.trim() ?? ''),
|
|
196
|
+
labels: [...new Set((query.labels ?? [])
|
|
197
|
+
.map(name => name.trim().toLowerCase())
|
|
198
|
+
.filter(Boolean))].sort(),
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
function encodeTestingCompletedTaskCursor(cursor) {
|
|
202
|
+
return `testing:${encodeURIComponent(JSON.stringify(cursor))}`;
|
|
203
|
+
}
|
|
204
|
+
function decodeTestingCompletedTaskCursor(encoded, scope) {
|
|
205
|
+
try {
|
|
206
|
+
if (!encoded.startsWith('testing:'))
|
|
207
|
+
throw new Error('wrong cursor format');
|
|
208
|
+
const cursor = JSON.parse(decodeURIComponent(encoded.slice('testing:'.length)));
|
|
209
|
+
if (cursor.version !== 1
|
|
210
|
+
|| !Number.isSafeInteger(cursor.updatedAt)
|
|
211
|
+
|| typeof cursor.id !== 'string'
|
|
212
|
+
|| JSON.stringify(cursor.scope) !== JSON.stringify(scope)) {
|
|
213
|
+
throw new Error('invalid cursor payload');
|
|
214
|
+
}
|
|
215
|
+
return cursor;
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
throw new Error('Invalid Task cursor');
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
function listTestingCompletedTasks(allTasks, projectId, query = {}, labelsByTaskId = new Map()) {
|
|
222
|
+
if (!projectId.trim())
|
|
223
|
+
throw new RangeError('projectId is required');
|
|
224
|
+
const submittedLabels = query.labels ?? [];
|
|
225
|
+
if (submittedLabels.length > 20) {
|
|
226
|
+
throw new RangeError('Completed Task reads support at most 20 Task Label filters');
|
|
227
|
+
}
|
|
228
|
+
if (submittedLabels.some(name => [...name.trim()].length > 40)) {
|
|
229
|
+
throw new RangeError('Completed Task Label filters must be 40 characters or fewer');
|
|
230
|
+
}
|
|
231
|
+
if ([...(query.search?.trim() ?? '')].length > 200) {
|
|
232
|
+
throw new RangeError('Completed Task search must be 200 characters or fewer');
|
|
233
|
+
}
|
|
234
|
+
const scope = testingCompletedTaskScope(projectId, query);
|
|
235
|
+
const labels = new Set(scope.labels);
|
|
236
|
+
const cursor = query.cursor ? decodeTestingCompletedTaskCursor(query.cursor, scope) : null;
|
|
237
|
+
const matching = allTasks
|
|
238
|
+
.filter(task => task.status === 'done' && task.project_id === projectId)
|
|
239
|
+
.filter(task => {
|
|
240
|
+
const summary = taskSummary(task, labelsByTaskId.get(task.id));
|
|
241
|
+
return !scope.search || [summary.id, summary.title, summary.promptPreview]
|
|
242
|
+
.some(value => asciiLowercase(value).includes(scope.search));
|
|
243
|
+
})
|
|
244
|
+
.filter(task => {
|
|
245
|
+
if (labels.size === 0)
|
|
246
|
+
return true;
|
|
247
|
+
const names = taskSummary(task, labelsByTaskId.get(task.id))
|
|
248
|
+
.labels.map(label => label.name.toLowerCase());
|
|
249
|
+
return [...labels].every(label => names.includes(label));
|
|
250
|
+
})
|
|
251
|
+
.sort(compareCompletedTasks);
|
|
252
|
+
const remaining = cursor
|
|
253
|
+
? matching.filter(task => task.updated_at < cursor.updatedAt
|
|
254
|
+
|| (task.updated_at === cursor.updatedAt && task.id < cursor.id))
|
|
255
|
+
: matching;
|
|
256
|
+
const pageTasks = remaining.slice(0, 50);
|
|
257
|
+
const tasks = pageTasks.map(task => taskSummary(task, labelsByTaskId.get(task.id)));
|
|
258
|
+
const last = pageTasks.at(-1);
|
|
259
|
+
const nextCursor = remaining.length > 50 && last
|
|
260
|
+
? encodeTestingCompletedTaskCursor({
|
|
261
|
+
version: 1,
|
|
262
|
+
scope,
|
|
263
|
+
updatedAt: last.updated_at,
|
|
264
|
+
id: last.id,
|
|
265
|
+
})
|
|
266
|
+
: null;
|
|
267
|
+
return { tasks, nextCursor };
|
|
268
|
+
}
|
|
85
269
|
export class TestingCommonApiFake {
|
|
86
270
|
services;
|
|
87
271
|
commands = new Map();
|
|
88
272
|
eventListeners = new Map();
|
|
89
273
|
eventHandlers = new Map();
|
|
274
|
+
taskChangeHandlers = new Map();
|
|
90
275
|
eventListenerSequence = 0;
|
|
91
276
|
constructor(services) {
|
|
92
277
|
this.services = services;
|
|
93
278
|
}
|
|
279
|
+
emitTaskChange(event) {
|
|
280
|
+
for (const handler of this.taskChangeHandlers.get(event.projectId) ?? []) {
|
|
281
|
+
handler(event);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
94
284
|
createApi() {
|
|
95
285
|
const api = {
|
|
96
286
|
commands: {
|
|
@@ -205,6 +395,16 @@ export class TestingCommonApiFake {
|
|
|
205
395
|
},
|
|
206
396
|
},
|
|
207
397
|
tasks: {
|
|
398
|
+
onDidChange: (projectId, handler) => {
|
|
399
|
+
const handlers = this.taskChangeHandlers.get(projectId) ?? new Set();
|
|
400
|
+
handlers.add(handler);
|
|
401
|
+
this.taskChangeHandlers.set(projectId, handlers);
|
|
402
|
+
return createDisposable(() => {
|
|
403
|
+
handlers.delete(handler);
|
|
404
|
+
if (handlers.size === 0)
|
|
405
|
+
this.taskChangeHandlers.delete(projectId);
|
|
406
|
+
});
|
|
407
|
+
},
|
|
208
408
|
list: async (request) => {
|
|
209
409
|
const projectId = request?.projectId ?? null;
|
|
210
410
|
const includeDone = request?.includeDone ?? false;
|
|
@@ -212,12 +412,54 @@ export class TestingCommonApiFake {
|
|
|
212
412
|
return this.services.seededTasks.filter((task) => {
|
|
213
413
|
if (projectId !== null && task.project_id !== projectId)
|
|
214
414
|
return false;
|
|
215
|
-
if (!includeDone && task.status === 'done')
|
|
415
|
+
if (projectId !== null && !includeDone && task.status === 'done')
|
|
216
416
|
return false;
|
|
217
417
|
return true;
|
|
218
418
|
});
|
|
219
419
|
},
|
|
220
|
-
get: async () => null,
|
|
420
|
+
get: async (taskId) => this.services.seededTasks.find(task => task.id === taskId) ?? null,
|
|
421
|
+
active: async (projectId) => {
|
|
422
|
+
this.services.calls.taskActiveRequests.push({ projectId });
|
|
423
|
+
const activeTasks = this.services.seededTasks.filter(task => task.project_id === projectId && task.status !== 'done');
|
|
424
|
+
const activeIds = new Set(activeTasks.map(task => task.id));
|
|
425
|
+
const relatedIds = new Set();
|
|
426
|
+
for (const task of this.services.seededTasks) {
|
|
427
|
+
if (activeIds.has(task.id)) {
|
|
428
|
+
for (const dependencyId of task.depends_on)
|
|
429
|
+
relatedIds.add(dependencyId);
|
|
430
|
+
}
|
|
431
|
+
else if (task.depends_on.some(dependencyId => activeIds.has(dependencyId))) {
|
|
432
|
+
relatedIds.add(task.id);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
return {
|
|
436
|
+
tasks: activeTasks.map(task => taskDetail(task, this.services.seededTaskLabelAssignments.get(task.id))),
|
|
437
|
+
related: this.services.seededTasks
|
|
438
|
+
.filter(task => relatedIds.has(task.id) && !activeIds.has(task.id))
|
|
439
|
+
.map(taskReference),
|
|
440
|
+
};
|
|
441
|
+
},
|
|
442
|
+
completed: async (projectId, query = {}) => {
|
|
443
|
+
this.services.calls.taskCompletedRequests.push({ projectId, ...query });
|
|
444
|
+
return listTestingCompletedTasks(this.services.seededTasks, projectId, query, this.services.seededTaskLabelAssignments);
|
|
445
|
+
},
|
|
446
|
+
detail: async (projectId, taskId) => {
|
|
447
|
+
this.services.calls.taskDetailRequests.push({ projectId, taskId });
|
|
448
|
+
const task = this.services.seededTasks.find(candidate => candidate.id === taskId && candidate.project_id === projectId);
|
|
449
|
+
if (!task)
|
|
450
|
+
return null;
|
|
451
|
+
const relatedIds = new Set(task.depends_on);
|
|
452
|
+
for (const candidate of this.services.seededTasks) {
|
|
453
|
+
if (candidate.depends_on.includes(taskId))
|
|
454
|
+
relatedIds.add(candidate.id);
|
|
455
|
+
}
|
|
456
|
+
return {
|
|
457
|
+
task: taskDetail(task, this.services.seededTaskLabelAssignments.get(task.id)),
|
|
458
|
+
related: this.services.seededTasks
|
|
459
|
+
.filter(candidate => relatedIds.has(candidate.id))
|
|
460
|
+
.map(taskReference),
|
|
461
|
+
};
|
|
462
|
+
},
|
|
221
463
|
create: async (request) => {
|
|
222
464
|
this.services.calls.taskCreations.push(request);
|
|
223
465
|
return {
|
|
@@ -301,7 +543,8 @@ export class TestingCommonApiFake {
|
|
|
301
543
|
},
|
|
302
544
|
fs: {
|
|
303
545
|
readDir: async () => [],
|
|
304
|
-
readFile: async (
|
|
546
|
+
readFile: async ({ path }) => this.services.projectFileContents[path]
|
|
547
|
+
?? { type: 'text', content: '', mimeType: null, size: 0 },
|
|
305
548
|
writeFile: async (request) => {
|
|
306
549
|
this.services.calls.fsWrites.push(request);
|
|
307
550
|
},
|
|
@@ -379,7 +622,7 @@ export class TestingCommonApiFake {
|
|
|
379
622
|
userData: {
|
|
380
623
|
readDir: async (request = {}) => {
|
|
381
624
|
this.services.calls.fsUserDataReadDirs.push(request);
|
|
382
|
-
return
|
|
625
|
+
return readTestingUserDataDir(this.services.userDataTextFiles, request.path);
|
|
383
626
|
},
|
|
384
627
|
readTextFile: async (request) => {
|
|
385
628
|
this.services.calls.fsUserDataReads.push(request);
|