@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.
@@ -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 { isDeepStrictEqual } from 'node:util'
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 assertOpenForgePluginSdkEntrypointRegistries({ packageExports, typeScriptPaths }) {
81
- assertRegistryMatches('package exports', packageExports, createOpenForgePluginSdkPackageExports())
82
- assertRegistryMatches('root TypeScript paths', typeScriptPaths, createOpenForgePluginSdkTypeScriptPaths())
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
- const details = [
98
- missingOrMismatched.length > 0 ? `missing or mismatched: ${missingOrMismatched.join(', ')}` : null,
99
- unexpected.length > 0 ? `not in the canonical manifest: ${unexpected.join(', ')}` : null,
100
- ].filter(Boolean)
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
- throw new Error(`Plugin SDK ${registryName} drifted from the canonical manifest (${details.join('; ')})`)
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
  }
@@ -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
- if (!packageExports || typeof packageExports !== 'object' || Array.isArray(packageExports)) {
42
- throw new Error('Plugin SDK package.json must define an exports object')
43
- }
44
-
45
- const expected = createOpenForgePluginSdkPublicUiPackageExports()
46
- const actual = Object.fromEntries(
47
- Object.entries(packageExports).filter(([subpath]) => subpath.startsWith('./ui/')),
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
+ }
@@ -4,3 +4,5 @@
4
4
  * Allows safe structural/formatting HTML through.
5
5
  */
6
6
  export declare function sanitizeHtml(dirty: string): string;
7
+ /** Sanitize generated Mermaid SVG before it is inserted into the document. */
8
+ export declare function sanitizeMermaidSvg(dirty: string): string;
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 () => ({ type: 'text', content: '', mimeType: null, size: 0 }),
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);