@minionry/sdk 0.4.23
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/LICENSE +14 -0
- package/README.md +8 -0
- package/dist/base64.d.ts +2 -0
- package/dist/base64.js +15 -0
- package/dist/cache.d.ts +22 -0
- package/dist/cache.js +79 -0
- package/dist/cdn/v1.js +16 -0
- package/dist/detect.d.ts +8 -0
- package/dist/detect.js +73 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +11 -0
- package/dist/keyboard.d.ts +6 -0
- package/dist/keyboard.js +39 -0
- package/dist/protocol.d.ts +61 -0
- package/dist/protocol.js +15 -0
- package/dist/provider.d.ts +3 -0
- package/dist/provider.js +1503 -0
- package/dist/transport.d.ts +73 -0
- package/dist/transport.js +1 -0
- package/dist/transports/mock.d.ts +29 -0
- package/dist/transports/mock.js +4286 -0
- package/dist/transports/post-message.d.ts +11 -0
- package/dist/transports/post-message.js +294 -0
- package/dist/types.d.ts +1967 -0
- package/dist/types.js +85 -0
- package/package.json +27 -0
|
@@ -0,0 +1,4286 @@
|
|
|
1
|
+
import { createProvider } from '../provider.js';
|
|
2
|
+
import { AGENT_RUN_LIMITS, AGENT_RUN_MODEL_SELECTIONS, MinionryError, SCOPES } from '../types.js';
|
|
3
|
+
import { base64ToBytes, bytesToBase64 } from '../base64.js';
|
|
4
|
+
const DEFAULT_SPACE = { id: 'space-mock', name: 'Mock Space' };
|
|
5
|
+
const DEFAULT_USER = { id: 'user-mock', name: 'Mock User', role: 'owner' };
|
|
6
|
+
const DEFAULT_THEME = {
|
|
7
|
+
mode: 'light',
|
|
8
|
+
tokens: {
|
|
9
|
+
background: '#faf9f5',
|
|
10
|
+
surface: '#f0efe8',
|
|
11
|
+
foreground: '#141413',
|
|
12
|
+
foregroundMuted: '#6b6a68',
|
|
13
|
+
accent: '#ae5630',
|
|
14
|
+
border: 'rgba(20, 20, 19, 0.1)',
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
const DEFAULT_LOCALE = {
|
|
18
|
+
locale: 'en',
|
|
19
|
+
dir: 'ltr',
|
|
20
|
+
};
|
|
21
|
+
const MOCK_FEATURES = [
|
|
22
|
+
'agents',
|
|
23
|
+
'apps',
|
|
24
|
+
'pm',
|
|
25
|
+
'files',
|
|
26
|
+
'storage',
|
|
27
|
+
'notifications',
|
|
28
|
+
'mock',
|
|
29
|
+
'role',
|
|
30
|
+
'streamResume',
|
|
31
|
+
'theme',
|
|
32
|
+
'browser',
|
|
33
|
+
'agentsRichStream',
|
|
34
|
+
'composer',
|
|
35
|
+
'browserAutomate',
|
|
36
|
+
'browserView',
|
|
37
|
+
'git',
|
|
38
|
+
'terminal',
|
|
39
|
+
'appTools',
|
|
40
|
+
'quality',
|
|
41
|
+
'locale',
|
|
42
|
+
'browserOps',
|
|
43
|
+
'inference',
|
|
44
|
+
'appEndpoints',
|
|
45
|
+
'agentsAppEndpoint',
|
|
46
|
+
'qualityFindingState',
|
|
47
|
+
'agentsModelSelection',
|
|
48
|
+
'qualitySchedules',
|
|
49
|
+
];
|
|
50
|
+
const DEFAULT_BROWSER_TABS = [
|
|
51
|
+
{
|
|
52
|
+
id: 'tab-mock-1',
|
|
53
|
+
url: 'https://minionry.com/',
|
|
54
|
+
title: 'Minionry',
|
|
55
|
+
isLoading: false,
|
|
56
|
+
canGoBack: false,
|
|
57
|
+
canGoForward: false,
|
|
58
|
+
},
|
|
59
|
+
];
|
|
60
|
+
const MOCK_SCREENSHOT_PNG = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
|
|
61
|
+
const MOCK_SKILLS = [
|
|
62
|
+
{ name: 'code-review', displayName: '/code-review', description: 'Comprehensive code review of the current changes.', source: 'platform' },
|
|
63
|
+
{ name: 'board', displayName: '/board', description: 'Convert the current chat conversation into a PM board with issues.', source: 'platform' },
|
|
64
|
+
];
|
|
65
|
+
const MOCK_SNIPPETS = [
|
|
66
|
+
{ name: 'standup', aliases: ['su'], description: 'Daily standup update template', origin: 'personal', preview: 'Yesterday: … Today: … Blockers: …', bodyLength: 42 },
|
|
67
|
+
];
|
|
68
|
+
const MOCK_INPUT_BATCH_MAX = 64;
|
|
69
|
+
const BOARD_STATUSES = ['todo', 'in_progress', 'done'];
|
|
70
|
+
const MOCK_ISSUE_CREATED = '2026-01-01';
|
|
71
|
+
const MOCK_ID_PREFIXES = {
|
|
72
|
+
issue: 'IS',
|
|
73
|
+
epic: 'EP',
|
|
74
|
+
bug: 'BG',
|
|
75
|
+
task: 'TASK',
|
|
76
|
+
};
|
|
77
|
+
const WORKSPACE_PATH_PREFIX = 'workspace:';
|
|
78
|
+
const resolveFilesReadScope = (path) => path.startsWith(WORKSPACE_PATH_PREFIX) ? 'files:workspace:read' : 'files:app';
|
|
79
|
+
const resolveFilesWriteScope = (path) => path.startsWith(WORKSPACE_PATH_PREFIX) ? 'files:workspace:write' : 'files:app';
|
|
80
|
+
const FILES_READ_PATH_METHODS = new Set([
|
|
81
|
+
'files.read',
|
|
82
|
+
'files.readEntry',
|
|
83
|
+
'files.list',
|
|
84
|
+
'files.search',
|
|
85
|
+
'files.definition',
|
|
86
|
+
'files.subscribeChanges',
|
|
87
|
+
'files.downloadStart',
|
|
88
|
+
]);
|
|
89
|
+
const FILES_WRITE_PATH_METHODS = new Set([
|
|
90
|
+
'files.write',
|
|
91
|
+
'files.delete',
|
|
92
|
+
'files.mkdir',
|
|
93
|
+
'files.rename',
|
|
94
|
+
'files.uploadStart',
|
|
95
|
+
]);
|
|
96
|
+
const METHOD_SCOPES = {
|
|
97
|
+
features: null,
|
|
98
|
+
connect: null,
|
|
99
|
+
disconnect: null,
|
|
100
|
+
'auth.getToken': 'identity',
|
|
101
|
+
'agents.run': 'agents:run',
|
|
102
|
+
'agents.result': 'agents:run',
|
|
103
|
+
'agents.sessions.list': 'agents:sessions',
|
|
104
|
+
'agents.sessions.create': 'agents:sessions',
|
|
105
|
+
'agents.sessions.attach': 'agents:sessions',
|
|
106
|
+
'agents.sessions.close': 'agents:sessions',
|
|
107
|
+
'agents.sessions.rename': 'agents:sessions',
|
|
108
|
+
'agents.sessions.history': 'agents:sessions',
|
|
109
|
+
'agents.sessions.replay': 'agents:sessions',
|
|
110
|
+
'agents.sessions.approve': 'agents:sessions',
|
|
111
|
+
'agents.sessions.answerQuestion': 'agents:sessions',
|
|
112
|
+
'agents.sessions.prompt': 'agents:sessions',
|
|
113
|
+
'agents.sessions.stop': 'agents:sessions',
|
|
114
|
+
'apps.open': null,
|
|
115
|
+
cancel: null,
|
|
116
|
+
'pm.createBoard': 'pm:write',
|
|
117
|
+
'pm.getBoard': 'pm:read',
|
|
118
|
+
'pm.listBoards': 'pm:read',
|
|
119
|
+
'pm.createIssue': 'pm:write',
|
|
120
|
+
'pm.updateIssue': 'pm:write',
|
|
121
|
+
'pm.deleteIssue': 'pm:write',
|
|
122
|
+
'pm.updateBoard': 'pm:write',
|
|
123
|
+
'pm.subscribeBoard': 'pm:read',
|
|
124
|
+
'stream.resume': null,
|
|
125
|
+
'pm.execute': 'pm:execute',
|
|
126
|
+
'pm.pause': null,
|
|
127
|
+
'pm.resume': null,
|
|
128
|
+
'pm.stop': null,
|
|
129
|
+
'pm.schedules.list': 'pm:schedule',
|
|
130
|
+
'pm.schedules.create': 'pm:schedule',
|
|
131
|
+
'pm.schedules.update': 'pm:schedule',
|
|
132
|
+
'pm.schedules.delete': 'pm:schedule',
|
|
133
|
+
'files.read': 'files:app',
|
|
134
|
+
'files.readEntry': 'files:app',
|
|
135
|
+
'files.write': 'files:app',
|
|
136
|
+
'files.list': 'files:app',
|
|
137
|
+
'files.delete': 'files:app',
|
|
138
|
+
'files.mkdir': 'files:app',
|
|
139
|
+
'files.rename': 'files:app',
|
|
140
|
+
'files.search': 'files:app',
|
|
141
|
+
'files.definition': 'files:app',
|
|
142
|
+
'files.subscribeChanges': 'files:app',
|
|
143
|
+
'files.uploadStart': 'files:app',
|
|
144
|
+
'files.uploadChunk': 'files:app',
|
|
145
|
+
'files.uploadStatus': 'files:app',
|
|
146
|
+
'files.uploadCancel': 'files:app',
|
|
147
|
+
'files.downloadStart': 'files:app',
|
|
148
|
+
'files.downloadChunk': 'files:app',
|
|
149
|
+
'files.downloadCancel': 'files:app',
|
|
150
|
+
'storage.get': 'storage',
|
|
151
|
+
'storage.set': 'storage',
|
|
152
|
+
'storage.delete': 'storage',
|
|
153
|
+
'storage.keys': 'storage',
|
|
154
|
+
'notifications.request': 'notifications',
|
|
155
|
+
'notifications.status': 'notifications',
|
|
156
|
+
'notifications.revoke': 'notifications',
|
|
157
|
+
'theme.get': null,
|
|
158
|
+
'locale.get': null,
|
|
159
|
+
'browser.tabs.list': 'browser:read',
|
|
160
|
+
'browser.tabs.subscribe': 'browser:read',
|
|
161
|
+
'browser.navigate': 'browser:control',
|
|
162
|
+
'browser.open': 'browser:control',
|
|
163
|
+
'browser.close': 'browser:control',
|
|
164
|
+
'browser.select': 'browser:control',
|
|
165
|
+
'browser.snapshot': 'browser:control',
|
|
166
|
+
'browser.screenshot': 'browser:control',
|
|
167
|
+
'browser.evaluate': 'browser:control',
|
|
168
|
+
'browser.automate': 'browser:control',
|
|
169
|
+
'browser.automate.approve': 'browser:control',
|
|
170
|
+
'browser.back': 'browser:control',
|
|
171
|
+
'browser.forward': 'browser:control',
|
|
172
|
+
'browser.reload': 'browser:control',
|
|
173
|
+
'browser.stop': 'browser:control',
|
|
174
|
+
'browser.view.attach': 'browser:control',
|
|
175
|
+
'browser.view.answer': 'browser:control',
|
|
176
|
+
'browser.view.ice': 'browser:control',
|
|
177
|
+
'browser.view.state': 'browser:control',
|
|
178
|
+
'browser.view.input': 'browser:control',
|
|
179
|
+
'browser.view.bounds': 'browser:control',
|
|
180
|
+
'browser.preflight': 'browser:read',
|
|
181
|
+
'browser.resumeDriver': 'browser:control',
|
|
182
|
+
'browser.setDeviceProfile': 'browser:control',
|
|
183
|
+
'composer.autocomplete': 'files:workspace:read',
|
|
184
|
+
'composer.skills': 'settings:read',
|
|
185
|
+
'composer.snippets': 'settings:read',
|
|
186
|
+
'composer.settings': 'settings:read',
|
|
187
|
+
'terminal.sessions.list': 'terminal:read',
|
|
188
|
+
'terminal.sessions.subscribe': 'terminal:read',
|
|
189
|
+
'terminal.attach': 'terminal:read',
|
|
190
|
+
'terminal.open': 'terminal:exec',
|
|
191
|
+
'terminal.write': 'terminal:exec',
|
|
192
|
+
'terminal.resize': 'terminal:exec',
|
|
193
|
+
'terminal.close': 'terminal:exec',
|
|
194
|
+
'git.status': 'git:read',
|
|
195
|
+
'git.log': 'git:read',
|
|
196
|
+
'git.diff': 'git:read',
|
|
197
|
+
'git.show': 'git:read',
|
|
198
|
+
'git.commitDiff': 'git:read',
|
|
199
|
+
'git.branches.list': 'git:read',
|
|
200
|
+
'git.tags.list': 'git:read',
|
|
201
|
+
'git.remote.info': 'git:read',
|
|
202
|
+
'git.repos.discover': 'git:read',
|
|
203
|
+
'git.worktrees.list': 'git:read',
|
|
204
|
+
'git.merge.preview': 'git:read',
|
|
205
|
+
'git.pr.generateDescription': 'git:read',
|
|
206
|
+
'git.branches.create': 'git:write',
|
|
207
|
+
'git.branches.delete': 'git:write',
|
|
208
|
+
'git.tags.create': 'git:write',
|
|
209
|
+
'git.worktrees.create': 'git:write',
|
|
210
|
+
'git.worktrees.remove': 'git:write',
|
|
211
|
+
'git.worktrees.switchActive': 'git:write',
|
|
212
|
+
'git.worktrees.subscribe': 'git:read',
|
|
213
|
+
'git.merge.run': 'git:write',
|
|
214
|
+
'git.merge.abort': 'git:write',
|
|
215
|
+
'git.merge.complete': 'git:write',
|
|
216
|
+
'git.merge.stashPop': 'git:write',
|
|
217
|
+
'git.merge.discardBlockers': 'git:write',
|
|
218
|
+
'git.stage': 'git:write',
|
|
219
|
+
'git.unstage': 'git:write',
|
|
220
|
+
'git.commit': 'git:write',
|
|
221
|
+
'git.generateCommitMessage': 'git:write',
|
|
222
|
+
'git.checkout': 'git:write',
|
|
223
|
+
'git.tags.push': 'git:remote',
|
|
224
|
+
'git.pr.create': 'git:remote',
|
|
225
|
+
'git.push': 'git:remote',
|
|
226
|
+
'git.pull': 'git:remote',
|
|
227
|
+
'git.subscribeBranchChanged': 'git:read',
|
|
228
|
+
'git.subscribeCommitSuggestion': 'git:read',
|
|
229
|
+
'tools.register': 'tools:serve',
|
|
230
|
+
'tools.result': 'tools:serve',
|
|
231
|
+
'quality.state': 'quality:read',
|
|
232
|
+
'quality.detectTools': 'quality:read',
|
|
233
|
+
'quality.result': 'quality:read',
|
|
234
|
+
'quality.settings': 'quality:read',
|
|
235
|
+
'quality.subscribeChanges': 'quality:read',
|
|
236
|
+
'quality.run': 'quality:run',
|
|
237
|
+
'quality.cancel': 'quality:run',
|
|
238
|
+
'quality.installTools': 'quality:run',
|
|
239
|
+
'quality.saveDirectories': 'quality:run',
|
|
240
|
+
'quality.setSettings': 'quality:run',
|
|
241
|
+
'inference.chat': 'inference:run',
|
|
242
|
+
'inference.result': 'inference:run',
|
|
243
|
+
'endpoints.register': 'endpoints:register',
|
|
244
|
+
'endpoints.unregister': 'endpoints:register',
|
|
245
|
+
'endpoints.list': 'endpoints:register',
|
|
246
|
+
'quality.setFindingState': 'quality:run',
|
|
247
|
+
'quality.schedules.list': 'quality:read',
|
|
248
|
+
'quality.schedules.create': 'quality:run',
|
|
249
|
+
'quality.schedules.update': 'quality:run',
|
|
250
|
+
'quality.schedules.delete': 'quality:run',
|
|
251
|
+
};
|
|
252
|
+
const MOCK_ENDPOINT_APP_ID = 'mock-app';
|
|
253
|
+
const MOCK_ENDPOINT_ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,31}$/;
|
|
254
|
+
const MOCK_ENDPOINT_LABEL_CHARS = 80;
|
|
255
|
+
const MOCK_ENDPOINT_MODEL_CHARS = 200;
|
|
256
|
+
const MOCK_ENDPOINT_API_KEY_BYTES = 4096;
|
|
257
|
+
const MOCK_ENDPOINT_URL_BYTES = 2048;
|
|
258
|
+
const MOCK_ENDPOINT_DEFAULT_TTL_MS = 3_600_000;
|
|
259
|
+
const MOCK_ENDPOINT_ENGINES = ['hermes'];
|
|
260
|
+
const MOCK_ENDPOINT_REGISTER_SHAPE = '{ id, label, engine, baseUrl, apiKey, model, contextLength?, ttlMs? }';
|
|
261
|
+
const MOCK_ENDPOINT_REGISTER_KEYS = new Set([
|
|
262
|
+
'id',
|
|
263
|
+
'label',
|
|
264
|
+
'engine',
|
|
265
|
+
'baseUrl',
|
|
266
|
+
'apiKey',
|
|
267
|
+
'model',
|
|
268
|
+
'contextLength',
|
|
269
|
+
'ttlMs',
|
|
270
|
+
]);
|
|
271
|
+
const MOCK_CONTROL_CHARS = /[\u0000-\u001f\u007f]/;
|
|
272
|
+
const MOCK_WHITESPACE_OR_CONTROL = /[\s\u0000-\u001f\u007f]/;
|
|
273
|
+
const MOCK_LOCAL_ONLY_SUFFIXES = ['localhost', 'local', 'internal', 'home.arpa'];
|
|
274
|
+
const MOCK_RUN_PARAMS_SHAPE = '{ prompt, title?, model?, modelSelection?, systemPrompt?, budget?: { maxTurns?, maxSeconds? }, sessionId? }';
|
|
275
|
+
const MOCK_RUN_PARAM_KEYS = new Set([
|
|
276
|
+
'prompt',
|
|
277
|
+
'title',
|
|
278
|
+
'model',
|
|
279
|
+
'modelSelection',
|
|
280
|
+
'systemPrompt',
|
|
281
|
+
'budget',
|
|
282
|
+
'sessionId',
|
|
283
|
+
]);
|
|
284
|
+
const MOCK_RUN_BUDGET_KEYS = new Set(['maxTurns', 'maxSeconds']);
|
|
285
|
+
const utf8ByteLength = (value) => new TextEncoder().encode(value).length;
|
|
286
|
+
function mockValidateEndpointUrl(value) {
|
|
287
|
+
let url;
|
|
288
|
+
try {
|
|
289
|
+
url = new URL(value);
|
|
290
|
+
}
|
|
291
|
+
catch {
|
|
292
|
+
return { ok: false, reason: 'is not a valid absolute URL' };
|
|
293
|
+
}
|
|
294
|
+
if (url.protocol !== 'https:')
|
|
295
|
+
return { ok: false, reason: 'must use https' };
|
|
296
|
+
if (url.username !== '' || url.password !== '')
|
|
297
|
+
return { ok: false, reason: 'must not carry credentials' };
|
|
298
|
+
if (url.search !== '' || url.href.includes('?'))
|
|
299
|
+
return { ok: false, reason: 'must not carry a query' };
|
|
300
|
+
if (url.hash !== '' || url.href.includes('#'))
|
|
301
|
+
return { ok: false, reason: 'must not carry a fragment' };
|
|
302
|
+
const name = url.hostname.replace(/\.+$/, '').toLowerCase();
|
|
303
|
+
if (name === '')
|
|
304
|
+
return { ok: false, reason: 'must name a host' };
|
|
305
|
+
if (name.startsWith('[') || /^\d{1,3}(\.\d{1,3}){3}$/.test(name)) {
|
|
306
|
+
return { ok: false, reason: 'must name a hostname, not an IP address' };
|
|
307
|
+
}
|
|
308
|
+
if (MOCK_LOCAL_ONLY_SUFFIXES.some((suffix) => name === suffix || name.endsWith(`.${suffix}`))) {
|
|
309
|
+
return { ok: false, reason: 'must not name a local-network host' };
|
|
310
|
+
}
|
|
311
|
+
const canonical = `${url.origin}${url.pathname === '/' ? '' : url.pathname}`;
|
|
312
|
+
if (utf8ByteLength(canonical) > MOCK_ENDPOINT_URL_BYTES) {
|
|
313
|
+
return { ok: false, reason: `must be at most ${MOCK_ENDPOINT_URL_BYTES} bytes` };
|
|
314
|
+
}
|
|
315
|
+
return { ok: true };
|
|
316
|
+
}
|
|
317
|
+
const MOCK_TOOL_CALL_TIMEOUT_MS = 30_000;
|
|
318
|
+
const MOCK_TOOL_CALL_TIMEOUT_MAX_MS = 120_000;
|
|
319
|
+
const MOCK_GIT_MAIN_WORKTREE_PATH = '/workspace';
|
|
320
|
+
const MOCK_GIT_DEFAULT_BRANCH = 'main';
|
|
321
|
+
const MOCK_GIT_REMOTE_URL = 'https://github.com/mock-org/mock-repo.git';
|
|
322
|
+
const MOCK_TERMINAL_SCROLLBACK_MAX = 256 * 1024;
|
|
323
|
+
const MOCK_PTY_BURST_CHUNKS = 200;
|
|
324
|
+
const MOCK_QUALITY_ROOT_PATH = '.';
|
|
325
|
+
const MOCK_QUALITY_SEEDED_AT = '2026-04-13T18:29:50.235Z';
|
|
326
|
+
const MOCK_QUALITY_BASELINE_AT = '2026-04-12T11:02:17.410Z';
|
|
327
|
+
const MOCK_QUALITY_NA_PATH = 'docs';
|
|
328
|
+
const MOCK_QUALITY_PARTIAL_PATH = 'packages/legacy';
|
|
329
|
+
const MOCK_QUALITY_AGENT_TURN_PROMPT = 'quality: agent turn';
|
|
330
|
+
const MOCK_QUALITY_WORKTREE_TURN_PROMPT = 'quality: worktree turn';
|
|
331
|
+
const MOCK_QUALITY_TURN_FILE = 'src/services/report-builder.ts';
|
|
332
|
+
const MOCK_QUALITY_TOOLS = [
|
|
333
|
+
{ name: 'eslint', installed: true, installCommand: 'npm install -D eslint', category: 'linter' },
|
|
334
|
+
{ name: 'prettier', installed: true, installCommand: 'npm install -D prettier', category: 'formatter' },
|
|
335
|
+
{ name: 'typescript', installed: true, installCommand: 'npm install -D typescript', category: 'general' },
|
|
336
|
+
{ name: 'biome', installed: false, installCommand: 'npm install -D @biomejs/biome', category: 'linter' },
|
|
337
|
+
{ name: 'jscpd', installed: false, installCommand: 'npm install -D jscpd', category: 'general' },
|
|
338
|
+
{ name: 'pip-audit', installed: false, installCommand: 'pip install pip-audit', category: 'security' },
|
|
339
|
+
];
|
|
340
|
+
const MOCK_QUALITY_ECOSYSTEM = ['node', 'python'];
|
|
341
|
+
const MOCK_QUALITY_FINDINGS = [
|
|
342
|
+
{
|
|
343
|
+
severity: 'high',
|
|
344
|
+
category: 'secrets',
|
|
345
|
+
file: 'src/config/analytics.ts',
|
|
346
|
+
line: 7,
|
|
347
|
+
title: 'generic-api-key',
|
|
348
|
+
description: 'Detected a Generic API Key, potentially exposing access to various services and sensitive operations.',
|
|
349
|
+
suggestion: 'Move the value to the environment and rotate it — it is already in git history.',
|
|
350
|
+
fingerprint: 'eaaf9634c34ff05fcb3378ffb5e68fa1947ef88ac35cdc75d011850cba70375b',
|
|
351
|
+
introduced: true,
|
|
352
|
+
},
|
|
353
|
+
{
|
|
354
|
+
severity: 'high',
|
|
355
|
+
category: 'dependencies',
|
|
356
|
+
file: 'package-lock.json',
|
|
357
|
+
line: null,
|
|
358
|
+
title: 'serialize-anything <2.1.4 — prototype pollution (GHSA-8q2v-w5xj-3rpc)',
|
|
359
|
+
description: 'npm audit reports advisory GHSA-8q2v-w5xj-3rpc (high) against serialize-anything 2.0.3; fixed in 2.1.4.',
|
|
360
|
+
suggestion: 'npm update serialize-anything',
|
|
361
|
+
fingerprint: '48acfa3b78b839a91211c2b529dee95a9347e064043e93a3cbbf6c9dd14c3d64',
|
|
362
|
+
introduced: true,
|
|
363
|
+
},
|
|
364
|
+
{
|
|
365
|
+
severity: 'high',
|
|
366
|
+
category: 'complexity',
|
|
367
|
+
file: 'src/services/report-builder.ts',
|
|
368
|
+
line: 214,
|
|
369
|
+
title: 'buildReport() has cyclomatic complexity 21',
|
|
370
|
+
description: 'Complexity 21 exceeds the threshold of 10. Rank: D.',
|
|
371
|
+
status: 'confirmed',
|
|
372
|
+
confidence: 0.92,
|
|
373
|
+
triageRationale: '21 real branches, no dead paths.',
|
|
374
|
+
fingerprint: 'f3b867d61d68176490529d8d31ddd5c1cea0edd896272d5195e228758a0aabab',
|
|
375
|
+
introduced: false,
|
|
376
|
+
},
|
|
377
|
+
{
|
|
378
|
+
severity: 'medium',
|
|
379
|
+
category: 'file-length',
|
|
380
|
+
file: 'src/services/report-builder.ts',
|
|
381
|
+
line: null,
|
|
382
|
+
title: 'report-builder.ts is 812 lines',
|
|
383
|
+
description: 'Exceeds the 600-line module threshold.',
|
|
384
|
+
status: 'confirmed',
|
|
385
|
+
fingerprint: '692f9acde06f364ab152df3ef1a534129b96ae01e5f6abdf9e536fd5a7692b8b',
|
|
386
|
+
introduced: false,
|
|
387
|
+
},
|
|
388
|
+
{
|
|
389
|
+
severity: 'medium',
|
|
390
|
+
category: 'security',
|
|
391
|
+
file: 'src/routes/export.ts',
|
|
392
|
+
line: 58,
|
|
393
|
+
title: 'User-supplied filename reaches the filesystem unvalidated',
|
|
394
|
+
description: 'The `name` query param is joined onto a directory without normalization.',
|
|
395
|
+
suggestion: 'Reject any name containing a path separator before joining.',
|
|
396
|
+
status: 'uncertain',
|
|
397
|
+
confidence: 0.41,
|
|
398
|
+
triageRationale: 'The caller may normalize upstream — not verified.',
|
|
399
|
+
fingerprint: '5b3892962cb1f2731d3dfcb4ada8c39d3424182dde4d173ccfdee4f99609745c',
|
|
400
|
+
introduced: false,
|
|
401
|
+
},
|
|
402
|
+
{
|
|
403
|
+
severity: 'medium',
|
|
404
|
+
category: 'duplication',
|
|
405
|
+
file: 'src/services/summary-builder.ts',
|
|
406
|
+
line: 118,
|
|
407
|
+
title: 'Duplicated block (18 lines, 96 tokens)',
|
|
408
|
+
description: 'src/services/summary-builder.ts:118–135 duplicates src/services/report-builder.ts:340–357.',
|
|
409
|
+
suggestion: 'Extract the shared row-mapping into one helper both builders call.',
|
|
410
|
+
status: 'confirmed',
|
|
411
|
+
confidence: 0.88,
|
|
412
|
+
triageRationale: 'A verbatim clone, not a coincidental resemblance.',
|
|
413
|
+
fingerprint: '7648ed24afef95de19a59a26fb47bbb5feaa9b8a929bd96dbb8acbcd392f59eb',
|
|
414
|
+
introduced: false,
|
|
415
|
+
},
|
|
416
|
+
{
|
|
417
|
+
severity: 'low',
|
|
418
|
+
category: 'tech-debt',
|
|
419
|
+
file: 'src/hooks/useTrend.ts',
|
|
420
|
+
line: 12,
|
|
421
|
+
title: 'TODO: replace the placeholder trend window',
|
|
422
|
+
description: 'Marker left since the initial implementation.',
|
|
423
|
+
status: 'likely-fp',
|
|
424
|
+
confidence: 0.78,
|
|
425
|
+
triageRationale: 'An honest note about a deliberate default, not debt.',
|
|
426
|
+
fingerprint: '43905119f9df837bb902c93a225f8d645f309fb0731ea29be2aa89c171764517',
|
|
427
|
+
introduced: false,
|
|
428
|
+
},
|
|
429
|
+
];
|
|
430
|
+
const MOCK_QUALITY_CODE_REVIEW = [
|
|
431
|
+
{
|
|
432
|
+
severity: 'high',
|
|
433
|
+
category: 'architecture',
|
|
434
|
+
file: 'src/services/report-builder.ts',
|
|
435
|
+
line: null,
|
|
436
|
+
title: 'Report assembly and persistence are the same unit',
|
|
437
|
+
description: 'Assembly and persistence cannot be exercised separately.',
|
|
438
|
+
suggestion: 'Extract a ReportWriter so assembly can be tested without I/O.',
|
|
439
|
+
evidence: 'buildReport() calls writeFile() directly at three points.',
|
|
440
|
+
verified: true,
|
|
441
|
+
verificationNote: 'Confirmed against the current file.',
|
|
442
|
+
fingerprint: '6aaa6a35b6b751d2cb4a036fef9c851025790c33e7f0d871620ac7c39bafcb3e',
|
|
443
|
+
introduced: false,
|
|
444
|
+
},
|
|
445
|
+
];
|
|
446
|
+
const MOCK_QUALITY_TURN_FINDING = {
|
|
447
|
+
severity: 'low',
|
|
448
|
+
category: 'lint',
|
|
449
|
+
file: MOCK_QUALITY_TURN_FILE,
|
|
450
|
+
line: 231,
|
|
451
|
+
title: 'no-unused-vars',
|
|
452
|
+
description: "'legacyTotals' is assigned a value but never used.",
|
|
453
|
+
fingerprint: '82e9506cdeed63aac266450c76fa3e6d13b762386383140584cce360f5108fe2',
|
|
454
|
+
introduced: true,
|
|
455
|
+
};
|
|
456
|
+
const mockQualityFindings = (source, withBaseline) => source.map(({ introduced, ...finding }) => (withBaseline ? { ...finding, introduced } : finding));
|
|
457
|
+
const MOCK_QUALITY_AGENT_COST = (sampledAt) => ({
|
|
458
|
+
available: true,
|
|
459
|
+
sampledAt,
|
|
460
|
+
sampledTurns: 61,
|
|
461
|
+
truncated: false,
|
|
462
|
+
attributedTurns: 19,
|
|
463
|
+
unattributed: { missingOutputTokens: 2, outsideSpaceRoot: 4, unknownWriteTools: 1 },
|
|
464
|
+
unreadableFiles: 0,
|
|
465
|
+
files: [
|
|
466
|
+
{ file: 'src/services/report-builder.ts', turns: 9, medianOutputTokens: 6_420, medianDurationMs: 212_000 },
|
|
467
|
+
{ file: 'src/services/summary-builder.ts', turns: 3, reason: '3 turns; 5 needed' },
|
|
468
|
+
],
|
|
469
|
+
comparison: {
|
|
470
|
+
withStructuralFindings: { turns: 12, medianOutputTokens: 5_880 },
|
|
471
|
+
withoutStructuralFindings: { turns: 7, reason: '7 turns; 10 needed' },
|
|
472
|
+
},
|
|
473
|
+
});
|
|
474
|
+
const MOCK_QUALITY_COMMIT = {
|
|
475
|
+
sha: '3f9c2a7e1b5d4c6f8a0e9b2d7c1f4a6e8b3d5c7f',
|
|
476
|
+
branch: 'main',
|
|
477
|
+
dirty: true,
|
|
478
|
+
};
|
|
479
|
+
const MOCK_QUALITY_STRUCTURAL_SCOPE = () => ({ files: 96, extensions: ['.py', '.ts', '.tsx'] });
|
|
480
|
+
const MOCK_QUALITY_CHANGE_ACTIVITY = (root) => ({
|
|
481
|
+
available: true,
|
|
482
|
+
windowDays: 90,
|
|
483
|
+
commitsScanned: root ? 143 : 37,
|
|
484
|
+
truncated: false,
|
|
485
|
+
files: {
|
|
486
|
+
'src/services/report-builder.ts': { commits: 17, linesChanged: 1_204, lastChangedAt: '2026-04-12T16:41:03.000Z' },
|
|
487
|
+
'src/index.ts': { commits: 11, linesChanged: 402, lastChangedAt: '2026-04-13T09:20:31.000Z' },
|
|
488
|
+
...(root ? { 'package-lock.json': { commits: 9, linesChanged: 2_876, lastChangedAt: '2026-04-10T14:55:00.000Z' } } : {}),
|
|
489
|
+
'src/routes/index.ts': { commits: 8, linesChanged: 265, lastChangedAt: '2026-04-08T17:30:12.000Z' },
|
|
490
|
+
'src/services/summary-builder.ts': { commits: 6, linesChanged: 318, lastChangedAt: '2026-04-09T10:12:47.000Z' },
|
|
491
|
+
'src/config/analytics.ts': { commits: 2, linesChanged: 41, lastChangedAt: '2026-04-11T08:03:19.000Z' },
|
|
492
|
+
},
|
|
493
|
+
});
|
|
494
|
+
const mockQualityBand = (score) => score >= 90 ? 'A' : score >= 80 ? 'B' : score >= 70 ? 'C' : 'F';
|
|
495
|
+
const mockQualityReport = (path, timestamp, withReview, baselineAt) => {
|
|
496
|
+
const root = path === MOCK_QUALITY_ROOT_PATH;
|
|
497
|
+
const findings = mockQualityFindings(root ? MOCK_QUALITY_FINDINGS : MOCK_QUALITY_FINDINGS.filter((finding) => finding.category !== 'dependencies'), baselineAt !== undefined);
|
|
498
|
+
return {
|
|
499
|
+
path,
|
|
500
|
+
overall: 87,
|
|
501
|
+
grade: 'B+',
|
|
502
|
+
dimensions: [
|
|
503
|
+
{
|
|
504
|
+
name: 'security',
|
|
505
|
+
score: 94,
|
|
506
|
+
grade: 'A',
|
|
507
|
+
rationale: root
|
|
508
|
+
? 'One leaked key, one vulnerable dependency, one unverified input-handling finding.'
|
|
509
|
+
: 'One leaked key, one unverified input-handling finding.',
|
|
510
|
+
available: true,
|
|
511
|
+
findingCount: root ? 3 : 2,
|
|
512
|
+
worstSeverity: 'high',
|
|
513
|
+
},
|
|
514
|
+
{
|
|
515
|
+
name: 'reliability',
|
|
516
|
+
score: 88,
|
|
517
|
+
grade: 'B+',
|
|
518
|
+
rationale: 'Lint and build clean; one complexity hot spot.',
|
|
519
|
+
available: true,
|
|
520
|
+
findingCount: 1,
|
|
521
|
+
worstSeverity: 'high',
|
|
522
|
+
},
|
|
523
|
+
{
|
|
524
|
+
name: 'maintainability',
|
|
525
|
+
score: 81,
|
|
526
|
+
grade: 'B-',
|
|
527
|
+
rationale: 'One oversized module carries the debt, and a block of it is duplicated.',
|
|
528
|
+
available: true,
|
|
529
|
+
findingCount: 3,
|
|
530
|
+
worstSeverity: 'medium',
|
|
531
|
+
},
|
|
532
|
+
],
|
|
533
|
+
categories: [
|
|
534
|
+
{ name: 'Linting', score: 100, available: true, issueCount: 0 },
|
|
535
|
+
{ name: 'Build', score: 100, available: true, issueCount: 0 },
|
|
536
|
+
{ name: 'complexity', score: 88, available: true, issueCount: 1 },
|
|
537
|
+
{ name: 'file-length', score: 82, available: true, issueCount: 1 },
|
|
538
|
+
{ name: 'security', score: 94, available: true, issueCount: 1 },
|
|
539
|
+
{ name: 'Duplication', score: 90, available: true, issueCount: 1 },
|
|
540
|
+
{ name: 'Secrets', score: 80, available: true, issueCount: 1 },
|
|
541
|
+
root
|
|
542
|
+
? { name: 'Dependencies', score: 85, available: true, issueCount: 1 }
|
|
543
|
+
: {
|
|
544
|
+
name: 'Dependencies',
|
|
545
|
+
score: 0,
|
|
546
|
+
available: false,
|
|
547
|
+
issueCount: 0,
|
|
548
|
+
reason: `npm audit needs a lockfile and ${path} has no package-lock.json — the audit did not run.`,
|
|
549
|
+
},
|
|
550
|
+
],
|
|
551
|
+
findings,
|
|
552
|
+
codeReview: withReview ? mockQualityFindings(MOCK_QUALITY_CODE_REVIEW, baselineAt !== undefined) : [],
|
|
553
|
+
analyzedFiles: 128,
|
|
554
|
+
totalLines: 24_680,
|
|
555
|
+
timestamp,
|
|
556
|
+
ecosystem: [...MOCK_QUALITY_ECOSYSTEM],
|
|
557
|
+
scoreBreakdown: {
|
|
558
|
+
penaltyDensity: 1.3,
|
|
559
|
+
totalPenalty: root ? 52.7 : 47.3,
|
|
560
|
+
issueDensity: root ? 0.24 : 0.2,
|
|
561
|
+
kloc: 24.7,
|
|
562
|
+
categoryPenalties: [
|
|
563
|
+
{ category: 'complexity', score: 88, grade: 'B+', penalty: 14.4, findingCount: 1 },
|
|
564
|
+
{ category: 'file-length', score: 82, grade: 'B-', penalty: 11.2, findingCount: 1 },
|
|
565
|
+
{ category: 'security', score: 94, grade: 'A', penalty: 6.5, findingCount: 1 },
|
|
566
|
+
{ category: 'duplication', score: 90, grade: 'A', penalty: 4.1, findingCount: 1 },
|
|
567
|
+
{ category: 'secrets', score: 80, grade: 'B', penalty: 11.1, findingCount: 1 },
|
|
568
|
+
...(root ? [{ category: 'dependencies', score: 85, grade: 'B', penalty: 5.4, findingCount: 1 }] : []),
|
|
569
|
+
],
|
|
570
|
+
},
|
|
571
|
+
gate: { passed: true, failingConditions: [] },
|
|
572
|
+
gradeRationale: 'No critical findings; the high-severity hot spot and the leaked key hold the grade at B+.',
|
|
573
|
+
readiness: {
|
|
574
|
+
level: 'develop',
|
|
575
|
+
headline: 'Ready for development',
|
|
576
|
+
rationale: 'Nothing blocking, but the leaked key and the hot spot should land before a release cut.',
|
|
577
|
+
blockers: [
|
|
578
|
+
'src/config/analytics.ts:7 — generic-api-key',
|
|
579
|
+
'src/services/report-builder.ts:214 — buildReport() has cyclomatic complexity 21',
|
|
580
|
+
],
|
|
581
|
+
},
|
|
582
|
+
triage: { total: 5, confirmed: 3, likelyFalsePositive: 1, uncertain: 1 },
|
|
583
|
+
scanDurationMs: 42_000,
|
|
584
|
+
...(withReview ? { reviewDurationMs: 96_000 } : {}),
|
|
585
|
+
...(baselineAt === undefined ? {} : { baselineAt }),
|
|
586
|
+
agentCost: root
|
|
587
|
+
? MOCK_QUALITY_AGENT_COST(timestamp)
|
|
588
|
+
: { available: false, reason: 'mock: .minionry/history/ could not be read — the Space history is missing or unreadable.' },
|
|
589
|
+
structuralScope: MOCK_QUALITY_STRUCTURAL_SCOPE(),
|
|
590
|
+
changeActivity: MOCK_QUALITY_CHANGE_ACTIVITY(root),
|
|
591
|
+
commit: { ...MOCK_QUALITY_COMMIT },
|
|
592
|
+
};
|
|
593
|
+
};
|
|
594
|
+
const mockQualityNaReport = (path, timestamp) => {
|
|
595
|
+
const noEcosystem = `No ecosystem was detected in ${path}, so no analyzer ran.`;
|
|
596
|
+
const unavailable = (name, reason) => ({
|
|
597
|
+
name,
|
|
598
|
+
score: 0,
|
|
599
|
+
available: false,
|
|
600
|
+
issueCount: 0,
|
|
601
|
+
reason,
|
|
602
|
+
});
|
|
603
|
+
return {
|
|
604
|
+
path,
|
|
605
|
+
overall: null,
|
|
606
|
+
grade: 'N/A',
|
|
607
|
+
dimensions: ['security', 'reliability', 'maintainability'].map((name) => ({
|
|
608
|
+
name,
|
|
609
|
+
score: null,
|
|
610
|
+
grade: 'N/A',
|
|
611
|
+
rationale: 'No tooling was available to evaluate this directory.',
|
|
612
|
+
available: false,
|
|
613
|
+
findingCount: 0,
|
|
614
|
+
worstSeverity: null,
|
|
615
|
+
})),
|
|
616
|
+
categories: [
|
|
617
|
+
unavailable('Linting', noEcosystem),
|
|
618
|
+
unavailable('Build', noEcosystem),
|
|
619
|
+
unavailable('complexity', noEcosystem),
|
|
620
|
+
unavailable('file-length', noEcosystem),
|
|
621
|
+
unavailable('Duplication', 'jscpd is not installed (npm install -D jscpd).'),
|
|
622
|
+
unavailable('Secrets', 'gitleaks is not on PATH — https://github.com/gitleaks/gitleaks#installing'),
|
|
623
|
+
unavailable('Dependencies', 'No lockfile was found — npm audit, pip-audit and cargo audit each need one.'),
|
|
624
|
+
],
|
|
625
|
+
findings: [],
|
|
626
|
+
codeReview: [],
|
|
627
|
+
analyzedFiles: 0,
|
|
628
|
+
totalLines: 0,
|
|
629
|
+
timestamp,
|
|
630
|
+
ecosystem: [],
|
|
631
|
+
gradeRationale: 'No tooling was available, so no grade could be computed.',
|
|
632
|
+
readiness: {
|
|
633
|
+
level: 'insufficient-data',
|
|
634
|
+
headline: 'Not enough data',
|
|
635
|
+
rationale: 'No dimension was available and no files were analyzed — nothing was evaluated.',
|
|
636
|
+
blockers: [],
|
|
637
|
+
},
|
|
638
|
+
scanDurationMs: 1_200,
|
|
639
|
+
agentCost: {
|
|
640
|
+
available: true,
|
|
641
|
+
sampledAt: timestamp,
|
|
642
|
+
sampledTurns: 61,
|
|
643
|
+
truncated: false,
|
|
644
|
+
attributedTurns: 0,
|
|
645
|
+
unattributed: { missingOutputTokens: 0, outsideSpaceRoot: 0, unknownWriteTools: 0 },
|
|
646
|
+
unreadableFiles: 0,
|
|
647
|
+
files: [],
|
|
648
|
+
comparison: {
|
|
649
|
+
withStructuralFindings: { turns: 0, reason: '0 turns; 10 needed' },
|
|
650
|
+
withoutStructuralFindings: { turns: 0, reason: '0 turns; 10 needed' },
|
|
651
|
+
},
|
|
652
|
+
},
|
|
653
|
+
changeActivity: {
|
|
654
|
+
available: false,
|
|
655
|
+
reason: `mock: ${path} is not inside a git work tree — the change-activity sample did not run.`,
|
|
656
|
+
windowDays: 90,
|
|
657
|
+
commitsScanned: 0,
|
|
658
|
+
truncated: false,
|
|
659
|
+
files: {},
|
|
660
|
+
},
|
|
661
|
+
};
|
|
662
|
+
};
|
|
663
|
+
const mockQualityAgentTurnReport = (path, timestamp, baselineAt) => ({
|
|
664
|
+
path,
|
|
665
|
+
overall: 79,
|
|
666
|
+
grade: 'C+',
|
|
667
|
+
dimensions: [
|
|
668
|
+
{
|
|
669
|
+
name: 'security',
|
|
670
|
+
score: 100,
|
|
671
|
+
grade: 'A+',
|
|
672
|
+
rationale: 'No security findings in the files this turn wrote.',
|
|
673
|
+
available: true,
|
|
674
|
+
findingCount: 0,
|
|
675
|
+
worstSeverity: null,
|
|
676
|
+
},
|
|
677
|
+
{
|
|
678
|
+
name: 'reliability',
|
|
679
|
+
score: 84,
|
|
680
|
+
grade: 'B',
|
|
681
|
+
rationale: 'One new lint hit; the complexity hot spot is unchanged.',
|
|
682
|
+
available: true,
|
|
683
|
+
findingCount: 2,
|
|
684
|
+
worstSeverity: 'high',
|
|
685
|
+
},
|
|
686
|
+
{
|
|
687
|
+
name: 'maintainability',
|
|
688
|
+
score: 78,
|
|
689
|
+
grade: 'C+',
|
|
690
|
+
rationale: 'The oversized module is still oversized.',
|
|
691
|
+
available: true,
|
|
692
|
+
findingCount: 1,
|
|
693
|
+
worstSeverity: 'medium',
|
|
694
|
+
},
|
|
695
|
+
],
|
|
696
|
+
categories: [
|
|
697
|
+
{ name: 'Linting', score: 92, available: true, issueCount: 1 },
|
|
698
|
+
{ name: 'Build', score: 100, available: true, issueCount: 0 },
|
|
699
|
+
{ name: 'complexity', score: 88, available: true, issueCount: 1 },
|
|
700
|
+
{ name: 'file-length', score: 82, available: true, issueCount: 1 },
|
|
701
|
+
],
|
|
702
|
+
findings: mockQualityFindings([MOCK_QUALITY_TURN_FINDING, ...MOCK_QUALITY_FINDINGS.filter((finding) => finding.file === MOCK_QUALITY_TURN_FILE)], baselineAt !== undefined),
|
|
703
|
+
codeReview: [],
|
|
704
|
+
analyzedFiles: 1,
|
|
705
|
+
totalLines: 812,
|
|
706
|
+
timestamp,
|
|
707
|
+
ecosystem: [...MOCK_QUALITY_ECOSYSTEM],
|
|
708
|
+
gradeRationale: 'Scoped to the files this turn wrote — not a grade for the directory.',
|
|
709
|
+
scanDurationMs: 3_400,
|
|
710
|
+
...(baselineAt === undefined ? {} : { baselineAt }),
|
|
711
|
+
structuralScope: { files: 1, extensions: ['.ts'] },
|
|
712
|
+
});
|
|
713
|
+
const mockQualityHistoryEntry = (report) => ({
|
|
714
|
+
timestamp: report.timestamp,
|
|
715
|
+
overall: report.overall,
|
|
716
|
+
grade: report.grade,
|
|
717
|
+
issueDensity: report.scoreBreakdown?.issueDensity,
|
|
718
|
+
categoryScores: report.categories
|
|
719
|
+
.filter((category) => category.available)
|
|
720
|
+
.map((category) => ({
|
|
721
|
+
category: category.name,
|
|
722
|
+
score: category.score,
|
|
723
|
+
grade: mockQualityBand(category.score),
|
|
724
|
+
})),
|
|
725
|
+
dimensionScores: Object.fromEntries(report.dimensions.map((dimension) => [dimension.name, { score: dimension.score, grade: dimension.grade }])),
|
|
726
|
+
directories: [{ path: report.path, score: report.overall, grade: report.grade }],
|
|
727
|
+
scanDurationMs: report.scanDurationMs,
|
|
728
|
+
reviewDurationMs: report.reviewDurationMs,
|
|
729
|
+
...(report.commit === undefined ? {} : { commit: { ...report.commit } }),
|
|
730
|
+
});
|
|
731
|
+
const MOCK_QUALITY_FINGERPRINTS_PER_CALL = 1_000;
|
|
732
|
+
const MOCK_QUALITY_REASON_MAX = 500;
|
|
733
|
+
export function createMockTransport(config = {}) {
|
|
734
|
+
const space = { ...DEFAULT_SPACE, ...config.space };
|
|
735
|
+
const user = config.user === null ? null : { ...DEFAULT_USER, ...config.user };
|
|
736
|
+
const denied = new Set(config.deniedScopes ?? []);
|
|
737
|
+
const forcedErrors = config.errors ?? {};
|
|
738
|
+
const theme = {
|
|
739
|
+
...DEFAULT_THEME,
|
|
740
|
+
...config.theme,
|
|
741
|
+
tokens: { ...DEFAULT_THEME.tokens, ...config.theme?.tokens },
|
|
742
|
+
};
|
|
743
|
+
const locale = { ...DEFAULT_LOCALE, ...config.locale };
|
|
744
|
+
const granted = new Set();
|
|
745
|
+
let notificationGranted = false;
|
|
746
|
+
const files = new Map(Object.entries(config.files ?? {}));
|
|
747
|
+
const dirs = new Set();
|
|
748
|
+
const timestamps = new Map();
|
|
749
|
+
const uploads = new Map();
|
|
750
|
+
const downloads = new Map();
|
|
751
|
+
const storage = new Map();
|
|
752
|
+
for (const [key, value] of Object.entries(config.storage ?? {}))
|
|
753
|
+
storage.set(key, structuredClone(value));
|
|
754
|
+
const runsByRequestId = new Map();
|
|
755
|
+
const runsByRunId = new Map();
|
|
756
|
+
const inferenceRunsByRequestId = new Map();
|
|
757
|
+
const inferenceRunsByRunId = new Map();
|
|
758
|
+
const endpoints = new Map();
|
|
759
|
+
const runSessions = new Map();
|
|
760
|
+
const boards = new Map();
|
|
761
|
+
const subscriptions = new Map();
|
|
762
|
+
const execRunsByBoardId = new Map();
|
|
763
|
+
const execRunsByRunId = new Map();
|
|
764
|
+
const execRunsByRequestId = new Map();
|
|
765
|
+
const schedules = new Map();
|
|
766
|
+
const fileChangeSubscriptions = new Map();
|
|
767
|
+
const browserTabs = DEFAULT_BROWSER_TABS.map((tab) => ({ ...tab }));
|
|
768
|
+
let currentTabId = browserTabs[0]?.id ?? null;
|
|
769
|
+
const browserTabSubscriptions = new Set();
|
|
770
|
+
let browserEpoch = 1;
|
|
771
|
+
const automations = new Map();
|
|
772
|
+
const views = new Map();
|
|
773
|
+
const sessions = new Map();
|
|
774
|
+
const gitCommits = [];
|
|
775
|
+
const gitBranches = new Map();
|
|
776
|
+
const gitTags = new Map();
|
|
777
|
+
const gitWorktrees = new Map();
|
|
778
|
+
let gitActiveWorktreePath = null;
|
|
779
|
+
const gitWorktreeSubscriptions = new Set();
|
|
780
|
+
const gitBranchChangeSubscriptions = new Set();
|
|
781
|
+
const gitCommitSuggestionSubscriptions = new Set();
|
|
782
|
+
const ptySessions = new Map();
|
|
783
|
+
const ptyAttachByRequestId = new Map();
|
|
784
|
+
const terminalSessionSubscriptions = new Set();
|
|
785
|
+
const toolRegistrations = new Map();
|
|
786
|
+
const toolCalls = new Map();
|
|
787
|
+
let toolCallCounter = 0;
|
|
788
|
+
let qualityDirectories = [
|
|
789
|
+
{ path: MOCK_QUALITY_ROOT_PATH, label: 'Workspace' },
|
|
790
|
+
{ path: MOCK_QUALITY_NA_PATH, label: 'Docs' },
|
|
791
|
+
{ path: MOCK_QUALITY_PARTIAL_PATH, label: 'Legacy' },
|
|
792
|
+
];
|
|
793
|
+
const qualityReports = new Map();
|
|
794
|
+
const qualityHistory = [];
|
|
795
|
+
let qualityPendingResults = [];
|
|
796
|
+
const qualityRunsByPath = new Map();
|
|
797
|
+
const qualityRunsByRequestId = new Map();
|
|
798
|
+
const qualityResults = new Map();
|
|
799
|
+
const qualityChangeSubscriptions = new Set();
|
|
800
|
+
const qualitySchedules = [];
|
|
801
|
+
let qualityScheduleCounter = 0;
|
|
802
|
+
const qualityInstalledTools = new Map();
|
|
803
|
+
const qualitySettings = {
|
|
804
|
+
engine: 'claude-code',
|
|
805
|
+
model: 'default',
|
|
806
|
+
effortLevel: 'auto',
|
|
807
|
+
fastMode: false,
|
|
808
|
+
agentNotes: true,
|
|
809
|
+
};
|
|
810
|
+
let qualityPostSessionFired = false;
|
|
811
|
+
const qualitySuppressions = new Map();
|
|
812
|
+
{
|
|
813
|
+
const seeded = mockQualityReport(MOCK_QUALITY_ROOT_PATH, MOCK_QUALITY_SEEDED_AT, true, MOCK_QUALITY_BASELINE_AT);
|
|
814
|
+
qualityReports.set(MOCK_QUALITY_ROOT_PATH, seeded);
|
|
815
|
+
qualityHistory.push(mockQualityHistoryEntry(seeded));
|
|
816
|
+
qualityPendingResults = [
|
|
817
|
+
{
|
|
818
|
+
kind: 'codeReview',
|
|
819
|
+
path: MOCK_QUALITY_ROOT_PATH,
|
|
820
|
+
completedAt: MOCK_QUALITY_SEEDED_AT,
|
|
821
|
+
findings: MOCK_QUALITY_CODE_REVIEW.map((finding) => ({ ...finding })),
|
|
822
|
+
},
|
|
823
|
+
];
|
|
824
|
+
}
|
|
825
|
+
let handlers;
|
|
826
|
+
let closed = false;
|
|
827
|
+
let seq = 0;
|
|
828
|
+
const streamSeq = new Map();
|
|
829
|
+
const streamLog = new Map();
|
|
830
|
+
const timers = new Set();
|
|
831
|
+
const deliver = (fn) => {
|
|
832
|
+
queueMicrotask(() => {
|
|
833
|
+
if (!closed)
|
|
834
|
+
fn();
|
|
835
|
+
});
|
|
836
|
+
};
|
|
837
|
+
const later = (fn) => {
|
|
838
|
+
const id = setTimeout(() => {
|
|
839
|
+
timers.delete(id);
|
|
840
|
+
if (!closed)
|
|
841
|
+
fn();
|
|
842
|
+
}, 0);
|
|
843
|
+
timers.add(id);
|
|
844
|
+
};
|
|
845
|
+
const respond = (requestId, result) => {
|
|
846
|
+
deliver(() => handlers?.onResponse({ requestId, ok: true, result }));
|
|
847
|
+
};
|
|
848
|
+
const fail = (requestId, error) => {
|
|
849
|
+
deliver(() => handlers?.onResponse({ requestId, ok: false, error }));
|
|
850
|
+
};
|
|
851
|
+
const emit = (requestId, event, payload) => {
|
|
852
|
+
const eventSeq = (streamSeq.get(requestId) ?? 0) + 1;
|
|
853
|
+
streamSeq.set(requestId, eventSeq);
|
|
854
|
+
const logged = payload !== undefined ? { seq: eventSeq, event, payload } : { seq: eventSeq, event };
|
|
855
|
+
const log = streamLog.get(requestId);
|
|
856
|
+
if (log)
|
|
857
|
+
log.push(logged);
|
|
858
|
+
else
|
|
859
|
+
streamLog.set(requestId, [logged]);
|
|
860
|
+
deliver(() => handlers?.onEvent({ requestId, event, payload, seq: eventSeq }));
|
|
861
|
+
};
|
|
862
|
+
const notify = (event, payload) => {
|
|
863
|
+
deliver(() => handlers?.onNotification({ event, payload }));
|
|
864
|
+
};
|
|
865
|
+
const emitEphemeral = (requestId, event, payload) => {
|
|
866
|
+
const eventSeq = (streamSeq.get(requestId) ?? 0) + 1;
|
|
867
|
+
streamSeq.set(requestId, eventSeq);
|
|
868
|
+
deliver(() => handlers?.onEvent({ requestId, event, payload, seq: eventSeq }));
|
|
869
|
+
};
|
|
870
|
+
const asRecord = (params, method) => {
|
|
871
|
+
if (typeof params !== 'object' || params === null) {
|
|
872
|
+
throw new MinionryError('BAD_REQUEST', `${method}: params must be an object`);
|
|
873
|
+
}
|
|
874
|
+
return params;
|
|
875
|
+
};
|
|
876
|
+
const asString = (value, name, method) => {
|
|
877
|
+
if (typeof value !== 'string') {
|
|
878
|
+
throw new MinionryError('BAD_REQUEST', `${method}: '${name}' must be a string`);
|
|
879
|
+
}
|
|
880
|
+
return value;
|
|
881
|
+
};
|
|
882
|
+
const mockScheduleTiming = (raw, method) => {
|
|
883
|
+
if (typeof raw !== 'object' || raw === null) {
|
|
884
|
+
throw new MinionryError('BAD_REQUEST', `${method}: timing must be an object`);
|
|
885
|
+
}
|
|
886
|
+
const t = raw;
|
|
887
|
+
if (t.kind === 'once' && typeof t.at === 'string' && !Number.isNaN(Date.parse(t.at))) {
|
|
888
|
+
return { kind: 'once', at: t.at };
|
|
889
|
+
}
|
|
890
|
+
if (t.kind === 'interval' && typeof t.everyMs === 'number' && Number.isFinite(t.everyMs)) {
|
|
891
|
+
return { kind: 'interval', everyMs: t.everyMs };
|
|
892
|
+
}
|
|
893
|
+
if (t.kind === 'continuous')
|
|
894
|
+
return { kind: 'continuous' };
|
|
895
|
+
throw new MinionryError('BAD_REQUEST', `${method}: invalid timing`);
|
|
896
|
+
};
|
|
897
|
+
const mockNextFireAt = (timing, from) => {
|
|
898
|
+
if (timing.kind === 'once') {
|
|
899
|
+
const at = new Date(timing.at);
|
|
900
|
+
return at.getTime() >= from.getTime() ? at.toISOString() : null;
|
|
901
|
+
}
|
|
902
|
+
if (timing.kind === 'interval')
|
|
903
|
+
return new Date(from.getTime() + timing.everyMs).toISOString();
|
|
904
|
+
return null;
|
|
905
|
+
};
|
|
906
|
+
const settleWaiters = (run) => {
|
|
907
|
+
if (run.status === 'running')
|
|
908
|
+
return;
|
|
909
|
+
const result = { status: run.status, text: run.text };
|
|
910
|
+
for (const waiter of run.waiters)
|
|
911
|
+
respond(waiter, result);
|
|
912
|
+
run.waiters = [];
|
|
913
|
+
};
|
|
914
|
+
const settleRunSession = (run) => {
|
|
915
|
+
const session = sessions.get(run.sessionId);
|
|
916
|
+
if (!session)
|
|
917
|
+
return;
|
|
918
|
+
session.isExecuting = false;
|
|
919
|
+
session.lastActivityAt = new Date().toISOString();
|
|
920
|
+
};
|
|
921
|
+
const advanceRun = (run) => {
|
|
922
|
+
if (run.status !== 'running')
|
|
923
|
+
return;
|
|
924
|
+
const step = run.steps.shift();
|
|
925
|
+
if (!step) {
|
|
926
|
+
run.status = 'done';
|
|
927
|
+
emit(run.requestId, 'status', { status: 'done' });
|
|
928
|
+
settleWaiters(run);
|
|
929
|
+
settleRunSession(run);
|
|
930
|
+
if (run.agentTurn !== undefined) {
|
|
931
|
+
publishQualityAgentTurn(run.agentTurn);
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
934
|
+
if (!qualityPostSessionFired && qualityChangeSubscriptions.size > 0) {
|
|
935
|
+
qualityPostSessionFired = true;
|
|
936
|
+
const report = qualityReports.get(MOCK_QUALITY_ROOT_PATH) ??
|
|
937
|
+
mockQualityReport(MOCK_QUALITY_ROOT_PATH, new Date().toISOString(), true, undefined);
|
|
938
|
+
publishQualityChange({ kind: 'postSession', path: MOCK_QUALITY_ROOT_PATH, report });
|
|
939
|
+
}
|
|
940
|
+
return;
|
|
941
|
+
}
|
|
942
|
+
if (step.event === 'output')
|
|
943
|
+
run.text += step.payload.text;
|
|
944
|
+
emit(run.requestId, step.event, step.payload);
|
|
945
|
+
later(() => advanceRun(run));
|
|
946
|
+
};
|
|
947
|
+
const cancelRun = (run) => {
|
|
948
|
+
if (run.status !== 'running')
|
|
949
|
+
return;
|
|
950
|
+
run.status = 'cancelled';
|
|
951
|
+
run.steps = [];
|
|
952
|
+
emit(run.requestId, 'status', { status: 'cancelled' });
|
|
953
|
+
settleWaiters(run);
|
|
954
|
+
settleRunSession(run);
|
|
955
|
+
};
|
|
956
|
+
const isLiveEndpoint = (endpoint, now) => Date.parse(endpoint.expiresAt) > now;
|
|
957
|
+
const refuseUnknownKeys = (record, allowed, what, shape) => {
|
|
958
|
+
const unknown = Object.keys(record).filter((key) => !allowed.has(key));
|
|
959
|
+
if (unknown.length > 0) {
|
|
960
|
+
throw new MinionryError('BAD_REQUEST', `${what}: unknown param${unknown.length > 1 ? 's' : ''} ${unknown.map((key) => `'${key}'`).join(', ')} — expected ${shape}`);
|
|
961
|
+
}
|
|
962
|
+
};
|
|
963
|
+
const strictRecord = (params, method, shape, keys) => {
|
|
964
|
+
if (typeof params !== 'object' || params === null || Array.isArray(params)) {
|
|
965
|
+
throw new MinionryError('BAD_REQUEST', `${method} requires params ${shape}`);
|
|
966
|
+
}
|
|
967
|
+
const record = params;
|
|
968
|
+
refuseUnknownKeys(record, keys, method, shape);
|
|
969
|
+
return record;
|
|
970
|
+
};
|
|
971
|
+
const endpointId = (raw, method) => {
|
|
972
|
+
if (typeof raw !== 'string' || !MOCK_ENDPOINT_ID_PATTERN.test(raw)) {
|
|
973
|
+
throw new MinionryError('BAD_REQUEST', `${method}: id must match ${MOCK_ENDPOINT_ID_PATTERN.source}`);
|
|
974
|
+
}
|
|
975
|
+
return raw;
|
|
976
|
+
};
|
|
977
|
+
const endpointText = (raw, field, max) => {
|
|
978
|
+
if (typeof raw !== 'string' || raw.trim() === '' || raw.length > max || MOCK_CONTROL_CHARS.test(raw)) {
|
|
979
|
+
throw new MinionryError('BAD_REQUEST', `endpoints.register: ${field} must be a non-empty string of at most ${max} characters, without control characters`);
|
|
980
|
+
}
|
|
981
|
+
return raw.trim();
|
|
982
|
+
};
|
|
983
|
+
const optionalPositiveInteger = (raw, field) => {
|
|
984
|
+
if (raw === undefined || raw === null)
|
|
985
|
+
return undefined;
|
|
986
|
+
if (typeof raw !== 'number' || !Number.isSafeInteger(raw) || raw <= 0) {
|
|
987
|
+
throw new MinionryError('BAD_REQUEST', `endpoints.register: ${field} must be a positive integer when present`);
|
|
988
|
+
}
|
|
989
|
+
return raw;
|
|
990
|
+
};
|
|
991
|
+
const admitEndpointModel = (model) => {
|
|
992
|
+
const now = Date.now();
|
|
993
|
+
const endpoint = [...endpoints.values()].find((candidate) => candidate.modelValue === model && isLiveEndpoint(candidate, now));
|
|
994
|
+
if (!endpoint) {
|
|
995
|
+
throw new MinionryError('MODEL_NOT_ALLOWED', `agents.run: model '${model}' is not a live endpoint this app registered in this Space — register one with endpoints.register and pass its modelValue`);
|
|
996
|
+
}
|
|
997
|
+
return endpoint;
|
|
998
|
+
};
|
|
999
|
+
const admitModelSelection = (raw) => {
|
|
1000
|
+
if (raw === undefined)
|
|
1001
|
+
return undefined;
|
|
1002
|
+
const name = AGENT_RUN_MODEL_SELECTIONS.find((known) => known === raw);
|
|
1003
|
+
if (!name) {
|
|
1004
|
+
const known = AGENT_RUN_MODEL_SELECTIONS.map((value) => `'${value}'`).join(', ');
|
|
1005
|
+
throw new MinionryError('BAD_REQUEST', `agents.run modelSelection must be one of ${known} when present`);
|
|
1006
|
+
}
|
|
1007
|
+
if (!granted.has('quality:read')) {
|
|
1008
|
+
throw new MinionryError('SCOPE_DENIED', `agents.run: modelSelection '${name}' needs the quality:read scope`);
|
|
1009
|
+
}
|
|
1010
|
+
if (qualitySettings.engine !== 'claude-code') {
|
|
1011
|
+
throw new MinionryError('ENGINE_UNAVAILABLE', `agents.run: the Quality model runs on ${qualitySettings.engine}, and only Claude Code can run an app session with tools — choose a Claude Code model for Quality`);
|
|
1012
|
+
}
|
|
1013
|
+
if (qualitySettings.model.startsWith('custom:claude-code:')) {
|
|
1014
|
+
throw new MinionryError('MODEL_NOT_ALLOWED', `agents.run: the Quality model '${qualitySettings.model}' is not a Claude Code model added on this machine — choose another model for Quality`);
|
|
1015
|
+
}
|
|
1016
|
+
return name;
|
|
1017
|
+
};
|
|
1018
|
+
const runParams = (params) => {
|
|
1019
|
+
const record = strictRecord(params, 'agents.run', MOCK_RUN_PARAMS_SHAPE, MOCK_RUN_PARAM_KEYS);
|
|
1020
|
+
const prompt = asString(record.prompt, 'prompt', 'agents.run');
|
|
1021
|
+
const title = typeof record.title === 'string' ? record.title : undefined;
|
|
1022
|
+
const optional = (field) => {
|
|
1023
|
+
const value = record[field];
|
|
1024
|
+
if (value === undefined)
|
|
1025
|
+
return undefined;
|
|
1026
|
+
if (typeof value !== 'string' || value === '') {
|
|
1027
|
+
throw new MinionryError('BAD_REQUEST', `agents.run ${field} must be a non-empty string when present`);
|
|
1028
|
+
}
|
|
1029
|
+
return value;
|
|
1030
|
+
};
|
|
1031
|
+
const model = optional('model');
|
|
1032
|
+
const sessionId = optional('sessionId');
|
|
1033
|
+
const { systemPrompt, budget, modelSelection } = record;
|
|
1034
|
+
if (systemPrompt !== undefined && (typeof systemPrompt !== 'string' || systemPrompt.length > AGENT_RUN_LIMITS.maxSystemPromptChars)) {
|
|
1035
|
+
throw new MinionryError('BAD_REQUEST', `agents.run systemPrompt must be a string of at most ${AGENT_RUN_LIMITS.maxSystemPromptChars} characters`);
|
|
1036
|
+
}
|
|
1037
|
+
if (budget !== undefined) {
|
|
1038
|
+
if (typeof budget !== 'object' || budget === null || Array.isArray(budget)) {
|
|
1039
|
+
throw new MinionryError('BAD_REQUEST', 'agents.run budget must be an object { maxTurns?, maxSeconds? }');
|
|
1040
|
+
}
|
|
1041
|
+
const ceilings = budget;
|
|
1042
|
+
refuseUnknownKeys(ceilings, MOCK_RUN_BUDGET_KEYS, 'agents.run budget', '{ maxTurns?, maxSeconds? }');
|
|
1043
|
+
for (const field of ['maxTurns', 'maxSeconds']) {
|
|
1044
|
+
const value = ceilings[field];
|
|
1045
|
+
if (value !== undefined && (typeof value !== 'number' || !Number.isSafeInteger(value) || value <= 0)) {
|
|
1046
|
+
throw new MinionryError('BAD_REQUEST', `agents.run budget.${field} must be a positive integer when present`);
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
const choosesModel = model !== undefined || modelSelection !== undefined;
|
|
1051
|
+
if (sessionId !== undefined && (choosesModel || systemPrompt !== undefined || budget !== undefined)) {
|
|
1052
|
+
throw new MinionryError('BAD_REQUEST', 'agents.run with sessionId continues that session on its own model, systemPrompt and budget — omit model, modelSelection, systemPrompt and budget');
|
|
1053
|
+
}
|
|
1054
|
+
if (model !== undefined && modelSelection !== undefined) {
|
|
1055
|
+
throw new MinionryError('BAD_REQUEST', 'agents.run takes model or modelSelection, not both');
|
|
1056
|
+
}
|
|
1057
|
+
if (model === undefined && (systemPrompt !== undefined || budget !== undefined)) {
|
|
1058
|
+
throw new MinionryError('BAD_REQUEST', 'agents.run systemPrompt and budget apply only to a run on an app-registered model — pass model');
|
|
1059
|
+
}
|
|
1060
|
+
admitModelSelection(modelSelection);
|
|
1061
|
+
return { prompt, title, model, sessionId };
|
|
1062
|
+
};
|
|
1063
|
+
const endpointRunSteps = (endpoint, prompt, turn) => {
|
|
1064
|
+
const at = Date.now();
|
|
1065
|
+
const echoed = prompt.length > 120 ? `${prompt.slice(0, 119)}…` : prompt;
|
|
1066
|
+
const chunks = [`(mock ${endpoint.engine} · ${endpoint.model}) `, `Turn ${turn}: ${echoed}\n`, 'Done (mock).'];
|
|
1067
|
+
return [
|
|
1068
|
+
{ event: 'status', payload: { status: 'running' } },
|
|
1069
|
+
{ event: 'timelineEvent', payload: { kind: 'status', at, executing: true, turn } },
|
|
1070
|
+
...chunks.flatMap((text) => [
|
|
1071
|
+
{ event: 'output', payload: { text } },
|
|
1072
|
+
{ event: 'timelineEvent', payload: { kind: 'text', at, entryId: `mock-text-${turn}`, delta: text } },
|
|
1073
|
+
]),
|
|
1074
|
+
{ event: 'timelineEvent', payload: { kind: 'status', at, executing: false, turn } },
|
|
1075
|
+
];
|
|
1076
|
+
};
|
|
1077
|
+
const MOCK_INFERENCE_USAGE = { inputTokens: 64, outputTokens: 16 };
|
|
1078
|
+
const inferenceResultOf = (run) => ({
|
|
1079
|
+
status: run.status === 'running' ? 'done' : run.status,
|
|
1080
|
+
text: run.text,
|
|
1081
|
+
engine: run.engine,
|
|
1082
|
+
...(run.model ? { model: run.model } : {}),
|
|
1083
|
+
...(run.status === 'done' ? { usage: { ...MOCK_INFERENCE_USAGE } } : {}),
|
|
1084
|
+
});
|
|
1085
|
+
const settleInference = (run) => {
|
|
1086
|
+
emit(run.requestId, 'status', { status: run.status });
|
|
1087
|
+
for (const waiter of run.waiters)
|
|
1088
|
+
respond(waiter, inferenceResultOf(run));
|
|
1089
|
+
run.waiters = [];
|
|
1090
|
+
};
|
|
1091
|
+
const advanceInferenceRun = (run) => {
|
|
1092
|
+
if (run.status !== 'running')
|
|
1093
|
+
return;
|
|
1094
|
+
const delta = run.deltas.shift();
|
|
1095
|
+
if (delta === undefined) {
|
|
1096
|
+
emit(run.requestId, 'usage', { ...MOCK_INFERENCE_USAGE });
|
|
1097
|
+
run.status = 'done';
|
|
1098
|
+
settleInference(run);
|
|
1099
|
+
return;
|
|
1100
|
+
}
|
|
1101
|
+
run.text += delta;
|
|
1102
|
+
emit(run.requestId, 'delta', { text: delta });
|
|
1103
|
+
later(() => advanceInferenceRun(run));
|
|
1104
|
+
};
|
|
1105
|
+
const cancelInferenceRun = (run) => {
|
|
1106
|
+
if (run.status !== 'running')
|
|
1107
|
+
return;
|
|
1108
|
+
run.status = 'cancelled';
|
|
1109
|
+
run.deltas = [];
|
|
1110
|
+
settleInference(run);
|
|
1111
|
+
};
|
|
1112
|
+
const mockSessionSummary = (session) => ({
|
|
1113
|
+
id: session.id,
|
|
1114
|
+
name: session.name,
|
|
1115
|
+
createdAt: session.createdAt,
|
|
1116
|
+
lastActivityAt: session.lastActivityAt,
|
|
1117
|
+
isExecuting: session.isExecuting,
|
|
1118
|
+
});
|
|
1119
|
+
const boardIssue = (issue) => ({
|
|
1120
|
+
id: issue.id,
|
|
1121
|
+
title: issue.title,
|
|
1122
|
+
status: issue.status,
|
|
1123
|
+
type: issue.type,
|
|
1124
|
+
priority: issue.priority,
|
|
1125
|
+
labels: [...issue.labels],
|
|
1126
|
+
});
|
|
1127
|
+
const boardSnapshot = (board) => ({
|
|
1128
|
+
boardId: board.boardId,
|
|
1129
|
+
title: board.title,
|
|
1130
|
+
statuses: [...board.statuses],
|
|
1131
|
+
issues: board.issues.map(boardIssue),
|
|
1132
|
+
});
|
|
1133
|
+
const boardSummary = (board) => ({
|
|
1134
|
+
id: board.boardId,
|
|
1135
|
+
title: board.title,
|
|
1136
|
+
status: board.completedAt ? 'completed' : board.status,
|
|
1137
|
+
goal: board.goal,
|
|
1138
|
+
created: board.createdAt,
|
|
1139
|
+
completedAt: board.completedAt,
|
|
1140
|
+
});
|
|
1141
|
+
const mockIssue = (id, title, priority) => ({
|
|
1142
|
+
id,
|
|
1143
|
+
title,
|
|
1144
|
+
type: 'issue',
|
|
1145
|
+
status: BOARD_STATUSES[0],
|
|
1146
|
+
priority,
|
|
1147
|
+
estimate: null,
|
|
1148
|
+
labels: [],
|
|
1149
|
+
epicId: null,
|
|
1150
|
+
blockedBy: [],
|
|
1151
|
+
blocks: [],
|
|
1152
|
+
description: '',
|
|
1153
|
+
acceptanceCriteria: [],
|
|
1154
|
+
created: MOCK_ISSUE_CREATED,
|
|
1155
|
+
updated: null,
|
|
1156
|
+
});
|
|
1157
|
+
const requireBoard = (params, method) => {
|
|
1158
|
+
const raw = asRecord(params, method);
|
|
1159
|
+
const boardId = asString(raw.boardId, 'boardId', method);
|
|
1160
|
+
const board = boards.get(boardId);
|
|
1161
|
+
if (!board)
|
|
1162
|
+
throw new MinionryError('BAD_REQUEST', `${method}: unknown boardId '${boardId}'`);
|
|
1163
|
+
return { board, raw };
|
|
1164
|
+
};
|
|
1165
|
+
const requireIssue = (board, issueId, method) => {
|
|
1166
|
+
const issue = board.issues.find((candidate) => candidate.id === issueId);
|
|
1167
|
+
if (!issue) {
|
|
1168
|
+
throw new MinionryError('BAD_REQUEST', `${method}: unknown issueId '${issueId}' on board '${board.boardId}'`);
|
|
1169
|
+
}
|
|
1170
|
+
return issue;
|
|
1171
|
+
};
|
|
1172
|
+
const nextIssueId = (board, prefix) => {
|
|
1173
|
+
let max = 0;
|
|
1174
|
+
for (const issue of board.issues) {
|
|
1175
|
+
const match = issue.id.match(new RegExp(`^${prefix}-(\\d+)$`));
|
|
1176
|
+
if (match)
|
|
1177
|
+
max = Math.max(max, Number.parseInt(match[1], 10));
|
|
1178
|
+
}
|
|
1179
|
+
return `${prefix}-${max + 1}`;
|
|
1180
|
+
};
|
|
1181
|
+
const asStringList = (value, name, method) => {
|
|
1182
|
+
if (!Array.isArray(value) || value.some((entry) => typeof entry !== 'string')) {
|
|
1183
|
+
throw new MinionryError('BAD_REQUEST', `${method}: '${name}' must be an array of strings`);
|
|
1184
|
+
}
|
|
1185
|
+
return [...value];
|
|
1186
|
+
};
|
|
1187
|
+
const applyIssuePatch = (board, issue, raw, method) => {
|
|
1188
|
+
const fields = asRecord(raw, method);
|
|
1189
|
+
const issueRefs = (value, name) => {
|
|
1190
|
+
const ids = asStringList(value, name, method);
|
|
1191
|
+
for (const id of ids)
|
|
1192
|
+
requireIssue(board, id, method);
|
|
1193
|
+
return ids;
|
|
1194
|
+
};
|
|
1195
|
+
if (fields.title !== undefined)
|
|
1196
|
+
issue.title = asString(fields.title, 'title', method);
|
|
1197
|
+
if (fields.status !== undefined)
|
|
1198
|
+
issue.status = asString(fields.status, 'status', method);
|
|
1199
|
+
if (fields.priority !== undefined)
|
|
1200
|
+
issue.priority = asString(fields.priority, 'priority', method);
|
|
1201
|
+
if (fields.estimate !== undefined) {
|
|
1202
|
+
const estimate = fields.estimate;
|
|
1203
|
+
if (estimate !== null && typeof estimate !== 'number' && typeof estimate !== 'string') {
|
|
1204
|
+
throw new MinionryError('BAD_REQUEST', `${method}: 'estimate' must be a number, string, or null`);
|
|
1205
|
+
}
|
|
1206
|
+
issue.estimate = estimate;
|
|
1207
|
+
}
|
|
1208
|
+
if (fields.labels !== undefined)
|
|
1209
|
+
issue.labels = asStringList(fields.labels, 'labels', method);
|
|
1210
|
+
if (fields.epicId !== undefined) {
|
|
1211
|
+
const epicId = fields.epicId;
|
|
1212
|
+
if (epicId !== null && typeof epicId !== 'string') {
|
|
1213
|
+
throw new MinionryError('BAD_REQUEST', `${method}: 'epicId' must be an issue id or null`);
|
|
1214
|
+
}
|
|
1215
|
+
if (epicId !== null)
|
|
1216
|
+
requireIssue(board, epicId, method);
|
|
1217
|
+
issue.epicId = epicId;
|
|
1218
|
+
}
|
|
1219
|
+
if (fields.blockedBy !== undefined)
|
|
1220
|
+
issue.blockedBy = issueRefs(fields.blockedBy, 'blockedBy');
|
|
1221
|
+
if (fields.blocks !== undefined)
|
|
1222
|
+
issue.blocks = issueRefs(fields.blocks, 'blocks');
|
|
1223
|
+
};
|
|
1224
|
+
const publishBoard = (board) => {
|
|
1225
|
+
for (const requestId of board.subscribers)
|
|
1226
|
+
emit(requestId, 'boardUpdate', boardSnapshot(board));
|
|
1227
|
+
};
|
|
1228
|
+
const progressBoard = (board) => {
|
|
1229
|
+
const issue = board.issues.find((candidate) => candidate.status !== 'done');
|
|
1230
|
+
if (!issue)
|
|
1231
|
+
return;
|
|
1232
|
+
const column = board.statuses.indexOf(issue.status);
|
|
1233
|
+
issue.status = board.statuses[Math.min(column + 1, board.statuses.length - 1)];
|
|
1234
|
+
if (board.issues.every((candidate) => candidate.status === 'done')) {
|
|
1235
|
+
board.completedAt ??= new Date().toISOString();
|
|
1236
|
+
}
|
|
1237
|
+
publishBoard(board);
|
|
1238
|
+
later(() => progressBoard(board));
|
|
1239
|
+
};
|
|
1240
|
+
const emitToRun = (run, event, payload) => {
|
|
1241
|
+
for (const requestId of run.requestIds)
|
|
1242
|
+
emit(requestId, event, payload);
|
|
1243
|
+
};
|
|
1244
|
+
const advanceExec = (run) => {
|
|
1245
|
+
if (run.status !== 'running')
|
|
1246
|
+
return;
|
|
1247
|
+
const board = boards.get(run.boardId);
|
|
1248
|
+
if (!board || run.issueIndex >= board.issues.length) {
|
|
1249
|
+
run.status = 'done';
|
|
1250
|
+
emitToRun(run, 'complete', { reason: 'All issues complete (mock)' });
|
|
1251
|
+
return;
|
|
1252
|
+
}
|
|
1253
|
+
const issue = board.issues[run.issueIndex];
|
|
1254
|
+
emitToRun(run, 'issueStarted', { issueId: issue.id, title: issue.title });
|
|
1255
|
+
emitToRun(run, 'output', { issueId: issue.id, text: `Working on ${issue.title} (mock)...\n` });
|
|
1256
|
+
emitToRun(run, 'toolUse', { issueId: issue.id, tool: 'write_file', input: { path: `${issue.id}.md` } });
|
|
1257
|
+
emitToRun(run, 'tokens', {
|
|
1258
|
+
issueId: issue.id,
|
|
1259
|
+
inputTokens: 100,
|
|
1260
|
+
outputTokens: 40,
|
|
1261
|
+
cacheCreationTokens: 0,
|
|
1262
|
+
cacheReadTokens: 0,
|
|
1263
|
+
currentTurnInputTokens: 100,
|
|
1264
|
+
isFinal: true,
|
|
1265
|
+
});
|
|
1266
|
+
issue.status = 'done';
|
|
1267
|
+
emitToRun(run, 'issueComplete', { issueId: issue.id });
|
|
1268
|
+
publishBoard(board);
|
|
1269
|
+
run.issueIndex += 1;
|
|
1270
|
+
later(() => advanceExec(run));
|
|
1271
|
+
};
|
|
1272
|
+
const notifyMockFileChanged = (path, kind) => {
|
|
1273
|
+
for (const [requestId, root] of fileChangeSubscriptions) {
|
|
1274
|
+
const prefix = root === '' || root === '.' ? '' : `${root.replace(/\/+$/, '')}/`;
|
|
1275
|
+
if (prefix && !path.startsWith(prefix))
|
|
1276
|
+
continue;
|
|
1277
|
+
emit(requestId, 'changed', { path: prefix ? path.slice(prefix.length) : path, kind });
|
|
1278
|
+
}
|
|
1279
|
+
};
|
|
1280
|
+
const browserTabsPayload = () => ({
|
|
1281
|
+
tabs: browserTabs.map((tab) => ({ ...tab })),
|
|
1282
|
+
currentTabId,
|
|
1283
|
+
});
|
|
1284
|
+
const publishBrowserTabs = () => {
|
|
1285
|
+
for (const requestId of browserTabSubscriptions)
|
|
1286
|
+
emit(requestId, 'tabsChanged', browserTabsPayload());
|
|
1287
|
+
for (const view of views.values())
|
|
1288
|
+
publishViewNavigation(view);
|
|
1289
|
+
};
|
|
1290
|
+
const publishViewNavigation = (view) => {
|
|
1291
|
+
if (view.detached)
|
|
1292
|
+
return;
|
|
1293
|
+
const tab = browserTabs.find((candidate) => candidate.id === view.tabId);
|
|
1294
|
+
if (!tab)
|
|
1295
|
+
return;
|
|
1296
|
+
emit(view.requestId, 'navigationState', {
|
|
1297
|
+
tabId: tab.id,
|
|
1298
|
+
url: tab.url,
|
|
1299
|
+
title: tab.title,
|
|
1300
|
+
isLoading: tab.isLoading,
|
|
1301
|
+
canGoBack: tab.canGoBack,
|
|
1302
|
+
canGoForward: tab.canGoForward,
|
|
1303
|
+
});
|
|
1304
|
+
};
|
|
1305
|
+
const requireView = (params, method) => {
|
|
1306
|
+
const sessionId = asString(asRecord(params, method).sessionId, 'sessionId', method);
|
|
1307
|
+
for (const view of views.values()) {
|
|
1308
|
+
if (view.sessionId === sessionId && !view.detached)
|
|
1309
|
+
return view;
|
|
1310
|
+
}
|
|
1311
|
+
throw new MinionryError('BAD_REQUEST', `${method}: no live view session '${sessionId}'`);
|
|
1312
|
+
};
|
|
1313
|
+
const retireView = (view) => {
|
|
1314
|
+
view.detached = true;
|
|
1315
|
+
views.delete(view.requestId);
|
|
1316
|
+
streamSeq.delete(view.requestId);
|
|
1317
|
+
streamLog.delete(view.requestId);
|
|
1318
|
+
};
|
|
1319
|
+
const historyTarget = (params, method) => {
|
|
1320
|
+
const record = params === undefined || params === null ? {} : asRecord(params, method);
|
|
1321
|
+
const tabId = record.tabId === undefined || record.tabId === null
|
|
1322
|
+
? currentTabId
|
|
1323
|
+
: asString(record.tabId, 'tabId', method);
|
|
1324
|
+
if (tabId === null)
|
|
1325
|
+
throw new MinionryError('BAD_REQUEST', `${method}: no tab is open`);
|
|
1326
|
+
return requireBrowserTab(tabId, method);
|
|
1327
|
+
};
|
|
1328
|
+
const moveHistory = (params, method) => {
|
|
1329
|
+
const tab = historyTarget(params, method);
|
|
1330
|
+
if (method === 'browser.back') {
|
|
1331
|
+
if (!tab.canGoBack)
|
|
1332
|
+
return null;
|
|
1333
|
+
tab.canGoBack = false;
|
|
1334
|
+
tab.canGoForward = true;
|
|
1335
|
+
}
|
|
1336
|
+
else {
|
|
1337
|
+
if (!tab.canGoForward)
|
|
1338
|
+
return null;
|
|
1339
|
+
tab.canGoForward = false;
|
|
1340
|
+
tab.canGoBack = true;
|
|
1341
|
+
}
|
|
1342
|
+
browserEpoch += 1;
|
|
1343
|
+
publishBrowserTabs();
|
|
1344
|
+
return null;
|
|
1345
|
+
};
|
|
1346
|
+
const requireBrowserTab = (tabId, method) => {
|
|
1347
|
+
const tab = browserTabs.find((candidate) => candidate.id === tabId);
|
|
1348
|
+
if (!tab)
|
|
1349
|
+
throw new MinionryError('BAD_REQUEST', `${method}: unknown tabId '${tabId}'`);
|
|
1350
|
+
return tab;
|
|
1351
|
+
};
|
|
1352
|
+
const originOf = (url) => {
|
|
1353
|
+
try {
|
|
1354
|
+
return new URL(url).origin;
|
|
1355
|
+
}
|
|
1356
|
+
catch {
|
|
1357
|
+
return undefined;
|
|
1358
|
+
}
|
|
1359
|
+
};
|
|
1360
|
+
const MOCK_ENGINES = ['claude-code', 'codex', 'hermes'];
|
|
1361
|
+
const MOCK_CODEX_MODEL_IDS = ['gpt-5.1-codex-max', 'gpt-5-codex'];
|
|
1362
|
+
const mockEngineForModel = (model) => {
|
|
1363
|
+
if (MOCK_CODEX_MODEL_IDS.includes(model))
|
|
1364
|
+
return 'codex';
|
|
1365
|
+
if (model.includes('/'))
|
|
1366
|
+
return 'hermes';
|
|
1367
|
+
return 'claude-code';
|
|
1368
|
+
};
|
|
1369
|
+
const MOCK_DEFAULT_ENGINE = 'claude-code';
|
|
1370
|
+
const MOCK_DEFAULT_MODEL = 'opus';
|
|
1371
|
+
const MOCK_EFFORT_LEVELS = {
|
|
1372
|
+
'claude-code': ['auto', 'low', 'medium', 'high', 'xhigh', 'max'],
|
|
1373
|
+
codex: ['auto', 'minimal', 'low', 'medium', 'high', 'xhigh'],
|
|
1374
|
+
hermes: [],
|
|
1375
|
+
};
|
|
1376
|
+
const MOCK_INFERENCE_ENGINES = ['claude-code'];
|
|
1377
|
+
const mockInferenceMessages = (raw) => {
|
|
1378
|
+
const valid = Array.isArray(raw) &&
|
|
1379
|
+
raw.length > 0 &&
|
|
1380
|
+
raw.length <= 64 &&
|
|
1381
|
+
raw.every((m) => typeof m === 'object' &&
|
|
1382
|
+
m !== null &&
|
|
1383
|
+
(m.role === 'user' || m.role === 'assistant') &&
|
|
1384
|
+
typeof m.content === 'string') &&
|
|
1385
|
+
raw[raw.length - 1].role === 'user';
|
|
1386
|
+
if (!valid) {
|
|
1387
|
+
throw new MinionryError('BAD_REQUEST', "inference.chat: messages must be 1–64 { role: 'user' | 'assistant', content } turns ending on a 'user' turn");
|
|
1388
|
+
}
|
|
1389
|
+
return raw;
|
|
1390
|
+
};
|
|
1391
|
+
const MOCK_MODEL_CATALOG = [
|
|
1392
|
+
{ value: 'default', engine: 'claude-code', label: 'Default', description: "The engine's own default model", isDefault: true },
|
|
1393
|
+
{ value: 'fable', engine: 'claude-code', label: 'Fable', description: 'Most capable, for the hardest work' },
|
|
1394
|
+
{ value: 'opus', engine: 'claude-code', label: 'Opus', description: 'Frontier reasoning for complex tasks' },
|
|
1395
|
+
{ value: 'sonnet', engine: 'claude-code', label: 'Sonnet', description: 'Balanced speed and capability' },
|
|
1396
|
+
{ value: 'haiku', engine: 'claude-code', label: 'Haiku', description: 'Fastest, for simple tasks' },
|
|
1397
|
+
{ value: 'gpt-5.1-codex-max', engine: 'codex', label: 'GPT-5.1 Codex Max', description: "Codex's most capable model", isDefault: true },
|
|
1398
|
+
{ value: 'gpt-5-codex', engine: 'codex', label: 'GPT-5 Codex', description: 'Faster Codex model' },
|
|
1399
|
+
{ value: 'z-ai/glm-5.2', engine: 'hermes', label: 'GLM 5.2', isDefault: true },
|
|
1400
|
+
{ value: 'moonshotai/kimi-k3', engine: 'hermes', label: 'Kimi K3' },
|
|
1401
|
+
];
|
|
1402
|
+
const stampTarget = (run, step) => step.event === 'step' ? { ...step.payload, ...run.target } : step.payload;
|
|
1403
|
+
const advanceAutomation = (run) => {
|
|
1404
|
+
if (run.status !== 'running')
|
|
1405
|
+
return;
|
|
1406
|
+
const step = run.steps.shift();
|
|
1407
|
+
if (!step) {
|
|
1408
|
+
run.status = 'done';
|
|
1409
|
+
automations.delete(run.requestId);
|
|
1410
|
+
return;
|
|
1411
|
+
}
|
|
1412
|
+
emit(run.requestId, step.event, stampTarget(run, step));
|
|
1413
|
+
if (step.event === 'done') {
|
|
1414
|
+
run.status = 'done';
|
|
1415
|
+
automations.delete(run.requestId);
|
|
1416
|
+
return;
|
|
1417
|
+
}
|
|
1418
|
+
later(() => advanceAutomation(run));
|
|
1419
|
+
};
|
|
1420
|
+
const proposeAutomationAction = (run) => {
|
|
1421
|
+
if (run.status !== 'running')
|
|
1422
|
+
return;
|
|
1423
|
+
const proposalId = `proposal-mock-${++seq}`;
|
|
1424
|
+
run.pendingProposalId = proposalId;
|
|
1425
|
+
run.status = 'awaiting-approval';
|
|
1426
|
+
emit(run.requestId, 'proposed', { proposalId, summary: 'Click "Continue" on the page (mock)' });
|
|
1427
|
+
};
|
|
1428
|
+
const mockSnapshotRoot = (tab) => ({
|
|
1429
|
+
ref: `${tab.id}-e1`,
|
|
1430
|
+
role: 'RootWebArea',
|
|
1431
|
+
name: tab.title,
|
|
1432
|
+
children: [
|
|
1433
|
+
{ ref: `${tab.id}-e2`, role: 'heading', name: tab.title, level: 1, children: [] },
|
|
1434
|
+
{ ref: `${tab.id}-e3`, role: 'link', name: 'Mock link', value: tab.url, children: [] },
|
|
1435
|
+
],
|
|
1436
|
+
});
|
|
1437
|
+
const mintGitHash = () => `mock${(++seq).toString(16).padStart(36, '0')}`;
|
|
1438
|
+
const gitShortHash = (hash) => hash.slice(0, 7);
|
|
1439
|
+
const findGitCommit = (hash) => gitCommits.find((commit) => commit.hash === hash || commit.shortHash === hash);
|
|
1440
|
+
const resolveGitWorktree = (path, method) => {
|
|
1441
|
+
const target = path ?? gitActiveWorktreePath ?? MOCK_GIT_MAIN_WORKTREE_PATH;
|
|
1442
|
+
const worktree = gitWorktrees.get(target);
|
|
1443
|
+
if (!worktree)
|
|
1444
|
+
throw new MinionryError('BAD_REQUEST', `${method}: unknown worktree '${target}'`);
|
|
1445
|
+
return worktree;
|
|
1446
|
+
};
|
|
1447
|
+
const optionalWorktreeField = (value, method) => value === undefined || value === null ? undefined : asString(value, 'worktree', method);
|
|
1448
|
+
const requireWorktree = (params, method) => {
|
|
1449
|
+
const record = asRecord(params, method);
|
|
1450
|
+
return { worktree: resolveGitWorktree(optionalWorktreeField(record.worktree, method), method), record };
|
|
1451
|
+
};
|
|
1452
|
+
const gitWorktreeStatus = (worktree) => ({
|
|
1453
|
+
staged: worktree.staged.length,
|
|
1454
|
+
unstaged: worktree.unstaged.length,
|
|
1455
|
+
untracked: worktree.untracked.length,
|
|
1456
|
+
ahead: 0,
|
|
1457
|
+
behind: 0,
|
|
1458
|
+
hasUpstream: gitBranches.get(worktree.branch)?.upstream !== undefined,
|
|
1459
|
+
});
|
|
1460
|
+
const gitWorktreeInfo = (worktree) => ({
|
|
1461
|
+
path: worktree.path,
|
|
1462
|
+
branch: worktree.branch,
|
|
1463
|
+
head: gitBranches.get(worktree.branch)?.head ?? '',
|
|
1464
|
+
isMain: worktree.isMain,
|
|
1465
|
+
isBare: false,
|
|
1466
|
+
status: gitWorktreeStatus(worktree),
|
|
1467
|
+
});
|
|
1468
|
+
const gitWorktreesSnapshot = () => ({
|
|
1469
|
+
worktrees: [...gitWorktrees.values()].map(gitWorktreeInfo),
|
|
1470
|
+
activeWorktreePath: gitActiveWorktreePath,
|
|
1471
|
+
});
|
|
1472
|
+
const publishGitWorktrees = () => {
|
|
1473
|
+
for (const requestId of gitWorktreeSubscriptions)
|
|
1474
|
+
emit(requestId, 'worktreesChanged', gitWorktreesSnapshot());
|
|
1475
|
+
};
|
|
1476
|
+
const publishGitBranchChanged = (worktree) => {
|
|
1477
|
+
for (const requestId of gitBranchChangeSubscriptions) {
|
|
1478
|
+
emit(requestId, 'branchChanged', { worktreePath: worktree.path, branch: worktree.branch });
|
|
1479
|
+
}
|
|
1480
|
+
};
|
|
1481
|
+
const gitStageFile = (worktree, path, method) => {
|
|
1482
|
+
const fromUntracked = worktree.untracked.findIndex((file) => file.path === path);
|
|
1483
|
+
if (fromUntracked !== -1) {
|
|
1484
|
+
const [file] = worktree.untracked.splice(fromUntracked, 1);
|
|
1485
|
+
worktree.staged.push({ ...file, status: 'A', staged: true });
|
|
1486
|
+
return;
|
|
1487
|
+
}
|
|
1488
|
+
const fromUnstaged = worktree.unstaged.findIndex((file) => file.path === path);
|
|
1489
|
+
if (fromUnstaged !== -1) {
|
|
1490
|
+
const [file] = worktree.unstaged.splice(fromUnstaged, 1);
|
|
1491
|
+
worktree.staged.push({ ...file, staged: true });
|
|
1492
|
+
return;
|
|
1493
|
+
}
|
|
1494
|
+
throw new MinionryError('BAD_REQUEST', `${method}: '${path}' has no pending changes in this worktree`);
|
|
1495
|
+
};
|
|
1496
|
+
const gitUnstageFile = (worktree, path, method) => {
|
|
1497
|
+
const index = worktree.staged.findIndex((file) => file.path === path);
|
|
1498
|
+
if (index === -1)
|
|
1499
|
+
throw new MinionryError('BAD_REQUEST', `${method}: '${path}' is not staged`);
|
|
1500
|
+
const [file] = worktree.staged.splice(index, 1);
|
|
1501
|
+
if (file.status === 'A')
|
|
1502
|
+
worktree.untracked.push({ ...file, status: '?', staged: false });
|
|
1503
|
+
else
|
|
1504
|
+
worktree.unstaged.push({ ...file, staged: false });
|
|
1505
|
+
};
|
|
1506
|
+
const gitInitialCommitHash = mintGitHash();
|
|
1507
|
+
gitCommits.push({
|
|
1508
|
+
hash: gitInitialCommitHash,
|
|
1509
|
+
shortHash: gitShortHash(gitInitialCommitHash),
|
|
1510
|
+
subject: 'Initial commit (mock)',
|
|
1511
|
+
body: '',
|
|
1512
|
+
author: user?.name ?? 'Mock User',
|
|
1513
|
+
date: new Date().toISOString(),
|
|
1514
|
+
files: [],
|
|
1515
|
+
});
|
|
1516
|
+
gitBranches.set(MOCK_GIT_DEFAULT_BRANCH, {
|
|
1517
|
+
name: MOCK_GIT_DEFAULT_BRANCH,
|
|
1518
|
+
head: gitInitialCommitHash,
|
|
1519
|
+
isRemote: false,
|
|
1520
|
+
upstream: `origin/${MOCK_GIT_DEFAULT_BRANCH}`,
|
|
1521
|
+
});
|
|
1522
|
+
gitWorktrees.set(MOCK_GIT_MAIN_WORKTREE_PATH, {
|
|
1523
|
+
path: MOCK_GIT_MAIN_WORKTREE_PATH,
|
|
1524
|
+
branch: MOCK_GIT_DEFAULT_BRANCH,
|
|
1525
|
+
isMain: true,
|
|
1526
|
+
staged: [],
|
|
1527
|
+
unstaged: [],
|
|
1528
|
+
untracked: [{ path: 'NOTES.md', status: '?', staged: false }],
|
|
1529
|
+
});
|
|
1530
|
+
const mockPtySessionSummary = (session) => ({
|
|
1531
|
+
id: session.id,
|
|
1532
|
+
shell: session.shell,
|
|
1533
|
+
cwd: session.cwd,
|
|
1534
|
+
cols: session.cols,
|
|
1535
|
+
rows: session.rows,
|
|
1536
|
+
createdAt: session.createdAt,
|
|
1537
|
+
lastActivityAt: session.lastActivityAt,
|
|
1538
|
+
});
|
|
1539
|
+
const emitToPty = (session, event, payload) => {
|
|
1540
|
+
for (const requestId of session.attachedRequestIds)
|
|
1541
|
+
emitEphemeral(requestId, event, payload);
|
|
1542
|
+
};
|
|
1543
|
+
const notifyTerminalSessionChanged = (change) => {
|
|
1544
|
+
for (const requestId of terminalSessionSubscriptions)
|
|
1545
|
+
emit(requestId, 'sessionChanged', change);
|
|
1546
|
+
};
|
|
1547
|
+
const appendPtyScrollback = (session, data) => {
|
|
1548
|
+
const combined = session.scrollback + data;
|
|
1549
|
+
session.scrollback =
|
|
1550
|
+
combined.length > MOCK_TERMINAL_SCROLLBACK_MAX ? combined.slice(combined.length - MOCK_TERMINAL_SCROLLBACK_MAX) : combined;
|
|
1551
|
+
};
|
|
1552
|
+
const closePtySession = (session, exitCode) => {
|
|
1553
|
+
if (session.closed)
|
|
1554
|
+
return;
|
|
1555
|
+
session.closed = true;
|
|
1556
|
+
emitToPty(session, 'exit', { exitCode });
|
|
1557
|
+
session.attachedRequestIds.clear();
|
|
1558
|
+
ptySessions.delete(session.id);
|
|
1559
|
+
notifyTerminalSessionChanged({ kind: 'closed', id: session.id });
|
|
1560
|
+
};
|
|
1561
|
+
const triggerPtyBurst = (session) => {
|
|
1562
|
+
for (let i = 0; i < MOCK_PTY_BURST_CHUNKS; i++) {
|
|
1563
|
+
const chunk = `burst line ${i} (mock)\n`;
|
|
1564
|
+
appendPtyScrollback(session, chunk);
|
|
1565
|
+
emitToPty(session, 'output', { data: chunk });
|
|
1566
|
+
}
|
|
1567
|
+
};
|
|
1568
|
+
const takeToolCall = (callId) => {
|
|
1569
|
+
const call = toolCalls.get(callId);
|
|
1570
|
+
if (!call)
|
|
1571
|
+
return undefined;
|
|
1572
|
+
toolCalls.delete(callId);
|
|
1573
|
+
clearTimeout(call.timer);
|
|
1574
|
+
timers.delete(call.timer);
|
|
1575
|
+
return call;
|
|
1576
|
+
};
|
|
1577
|
+
const retireToolRegistration = (requestId) => {
|
|
1578
|
+
if (!toolRegistrations.delete(requestId))
|
|
1579
|
+
return;
|
|
1580
|
+
for (const pending of [...toolCalls.values()]) {
|
|
1581
|
+
if (pending.registrationId !== requestId)
|
|
1582
|
+
continue;
|
|
1583
|
+
const call = takeToolCall(pending.callId);
|
|
1584
|
+
call?.fail(new MinionryError('APP_UNAVAILABLE', `mock: the app stopped serving '${pending.tool}' before it answered`));
|
|
1585
|
+
}
|
|
1586
|
+
};
|
|
1587
|
+
const sendToolCancel = (callId, registrationId) => {
|
|
1588
|
+
deliver(() => handlers?.onToolCall?.({ op: 'cancel', callId, requestId: registrationId }));
|
|
1589
|
+
};
|
|
1590
|
+
const toolsController = {
|
|
1591
|
+
list: () => [...toolRegistrations.values()].flatMap((registration) => [...registration.tools.values()].map((spec) => ({
|
|
1592
|
+
...spec,
|
|
1593
|
+
registrationId: registration.requestId,
|
|
1594
|
+
profile: registration.profile,
|
|
1595
|
+
}))),
|
|
1596
|
+
call: (tool, input, opts) => new Promise((resolve, reject) => {
|
|
1597
|
+
const registration = [...toolRegistrations.values()].find((candidate) => candidate.tools.has(tool));
|
|
1598
|
+
const spec = registration?.tools.get(tool);
|
|
1599
|
+
if (!registration || !spec || !handlers?.onToolCall) {
|
|
1600
|
+
reject(new MinionryError('APP_UNAVAILABLE', `mock: no app is serving a tool named '${tool}'`));
|
|
1601
|
+
return;
|
|
1602
|
+
}
|
|
1603
|
+
const callId = `tc-${++toolCallCounter}`;
|
|
1604
|
+
const timeoutMs = Math.min(opts?.timeoutMs ?? spec.timeoutMs ?? MOCK_TOOL_CALL_TIMEOUT_MS, MOCK_TOOL_CALL_TIMEOUT_MAX_MS);
|
|
1605
|
+
const timer = setTimeout(() => {
|
|
1606
|
+
const call = takeToolCall(callId);
|
|
1607
|
+
if (!call)
|
|
1608
|
+
return;
|
|
1609
|
+
sendToolCancel(callId, registration.requestId);
|
|
1610
|
+
call.fail(new MinionryError('APP_TIMEOUT', `mock: '${tool}' did not answer within ${timeoutMs}ms`));
|
|
1611
|
+
}, timeoutMs);
|
|
1612
|
+
timers.add(timer);
|
|
1613
|
+
toolCalls.set(callId, {
|
|
1614
|
+
callId,
|
|
1615
|
+
registrationId: registration.requestId,
|
|
1616
|
+
tool,
|
|
1617
|
+
settle: resolve,
|
|
1618
|
+
fail: reject,
|
|
1619
|
+
timer,
|
|
1620
|
+
});
|
|
1621
|
+
deliver(() => handlers?.onToolCall?.({ op: 'call', callId, requestId: registration.requestId, tool, input }));
|
|
1622
|
+
}),
|
|
1623
|
+
cancel: (callId) => {
|
|
1624
|
+
const call = takeToolCall(callId);
|
|
1625
|
+
if (!call)
|
|
1626
|
+
return;
|
|
1627
|
+
sendToolCancel(callId, call.registrationId);
|
|
1628
|
+
call.fail(new MinionryError('CANCELLED', `mock: call '${callId}' to '${call.tool}' was cancelled by the host`));
|
|
1629
|
+
},
|
|
1630
|
+
};
|
|
1631
|
+
const qualitySuppressionKey = (path, fingerprint) => `${path}|${fingerprint}`;
|
|
1632
|
+
const stampQualityFindings = (path, findings) => findings.map((finding) => {
|
|
1633
|
+
const entry = finding.fingerprint === undefined ? undefined : qualitySuppressions.get(qualitySuppressionKey(path, finding.fingerprint));
|
|
1634
|
+
return entry ? { ...finding, dismissal: { reason: entry.reason, dismissedAt: entry.dismissedAt } } : { ...finding };
|
|
1635
|
+
});
|
|
1636
|
+
const stampQualityReport = (report) => ({
|
|
1637
|
+
...report,
|
|
1638
|
+
findings: stampQualityFindings(report.path, report.findings),
|
|
1639
|
+
codeReview: stampQualityFindings(report.path, report.codeReview),
|
|
1640
|
+
});
|
|
1641
|
+
const stampQualityResult = (result) => result.report === null ? result : { ...result, report: stampQualityReport(result.report) };
|
|
1642
|
+
const publishQualityChange = (change) => {
|
|
1643
|
+
const stamped = change.kind === 'results' || change.kind === 'postSession' || change.kind === 'agentTurn'
|
|
1644
|
+
? { ...change, report: stampQualityReport(change.report) }
|
|
1645
|
+
: change;
|
|
1646
|
+
for (const requestId of qualityChangeSubscriptions)
|
|
1647
|
+
emit(requestId, 'changed', stamped);
|
|
1648
|
+
};
|
|
1649
|
+
const publishQualityAgentTurn = (form) => {
|
|
1650
|
+
const timestamp = new Date().toISOString();
|
|
1651
|
+
const changedFiles = [MOCK_QUALITY_TURN_FILE];
|
|
1652
|
+
if (form === 'worktree') {
|
|
1653
|
+
const report = mockQualityAgentTurnReport(MOCK_QUALITY_ROOT_PATH, timestamp, undefined);
|
|
1654
|
+
publishQualityChange({ kind: 'agentTurn', path: MOCK_QUALITY_ROOT_PATH, worktree: true, changedFiles, report });
|
|
1655
|
+
return;
|
|
1656
|
+
}
|
|
1657
|
+
const baselineAt = qualityReports.get(MOCK_QUALITY_ROOT_PATH)?.timestamp;
|
|
1658
|
+
const report = mockQualityAgentTurnReport(MOCK_QUALITY_ROOT_PATH, timestamp, baselineAt);
|
|
1659
|
+
publishQualityChange({ kind: 'agentTurn', path: MOCK_QUALITY_ROOT_PATH, changedFiles, report });
|
|
1660
|
+
};
|
|
1661
|
+
const qualityPathOf = (value, method) => {
|
|
1662
|
+
if (value === undefined || value === null)
|
|
1663
|
+
return MOCK_QUALITY_ROOT_PATH;
|
|
1664
|
+
const raw = asString(value, 'path', method).trim();
|
|
1665
|
+
if (raw === '')
|
|
1666
|
+
return MOCK_QUALITY_ROOT_PATH;
|
|
1667
|
+
if (raw.startsWith('/') || raw.split('/').includes('..')) {
|
|
1668
|
+
throw new MinionryError('BAD_REQUEST', `${method}: '${raw}' resolves outside the working directory`);
|
|
1669
|
+
}
|
|
1670
|
+
return raw;
|
|
1671
|
+
};
|
|
1672
|
+
const cloneQualitySchedule = (schedule) => ({
|
|
1673
|
+
...schedule,
|
|
1674
|
+
timing: schedule.timing.kind === 'daily' && schedule.timing.days ? { ...schedule.timing, days: [...schedule.timing.days] } : { ...schedule.timing },
|
|
1675
|
+
...(schedule.lastFire ? { lastFire: { ...schedule.lastFire } } : {}),
|
|
1676
|
+
history: schedule.history.map((entry) => ({ ...entry })),
|
|
1677
|
+
});
|
|
1678
|
+
const isValidTimeZone = (value) => {
|
|
1679
|
+
if (typeof value !== 'string' || value === '')
|
|
1680
|
+
return false;
|
|
1681
|
+
try {
|
|
1682
|
+
new Intl.DateTimeFormat('en-US', { timeZone: value });
|
|
1683
|
+
return true;
|
|
1684
|
+
}
|
|
1685
|
+
catch {
|
|
1686
|
+
return false;
|
|
1687
|
+
}
|
|
1688
|
+
};
|
|
1689
|
+
const qualityTimingOf = (value, method) => {
|
|
1690
|
+
const raw = asRecord(value, method);
|
|
1691
|
+
if (raw.kind === 'interval') {
|
|
1692
|
+
if (typeof raw.everyMs !== 'number' || !Number.isFinite(raw.everyMs) || raw.everyMs <= 0) {
|
|
1693
|
+
throw new MinionryError('BAD_REQUEST', `${method}: timing.everyMs must be a positive number`);
|
|
1694
|
+
}
|
|
1695
|
+
return { kind: 'interval', everyMs: Math.max(raw.everyMs, 60_000) };
|
|
1696
|
+
}
|
|
1697
|
+
if (raw.kind !== 'daily')
|
|
1698
|
+
throw new MinionryError('BAD_REQUEST', `${method}: timing.kind must be 'daily' or 'interval'`);
|
|
1699
|
+
if (typeof raw.time !== 'string' || !/^([01]\d|2[0-3]):[0-5]\d$/.test(raw.time)) {
|
|
1700
|
+
throw new MinionryError('BAD_REQUEST', `${method}: timing.time must be 24-hour HH:MM`);
|
|
1701
|
+
}
|
|
1702
|
+
if (!isValidTimeZone(raw.timeZone))
|
|
1703
|
+
throw new MinionryError('BAD_REQUEST', `${method}: timing.timeZone must be an IANA zone name`);
|
|
1704
|
+
const timing = { kind: 'daily', time: raw.time, timeZone: raw.timeZone };
|
|
1705
|
+
if (raw.days !== undefined) {
|
|
1706
|
+
if (!Array.isArray(raw.days) || raw.days.length === 0 || raw.days.some((day) => !Number.isInteger(day) || day < 0 || day > 6)) {
|
|
1707
|
+
throw new MinionryError('BAD_REQUEST', `${method}: timing.days must be a non-empty array of weekdays 0 (Sunday) … 6 (Saturday)`);
|
|
1708
|
+
}
|
|
1709
|
+
timing.days = [...new Set(raw.days)].sort((a, b) => a - b);
|
|
1710
|
+
}
|
|
1711
|
+
return timing;
|
|
1712
|
+
};
|
|
1713
|
+
const zonedParts = (instant, timeZone) => {
|
|
1714
|
+
const parts = new Intl.DateTimeFormat('en-US', {
|
|
1715
|
+
timeZone,
|
|
1716
|
+
hourCycle: 'h23',
|
|
1717
|
+
year: 'numeric',
|
|
1718
|
+
month: '2-digit',
|
|
1719
|
+
day: '2-digit',
|
|
1720
|
+
hour: '2-digit',
|
|
1721
|
+
minute: '2-digit',
|
|
1722
|
+
second: '2-digit',
|
|
1723
|
+
weekday: 'short',
|
|
1724
|
+
}).formatToParts(new Date(instant));
|
|
1725
|
+
const read = (type) => parts.find((part) => part.type === type)?.value ?? '0';
|
|
1726
|
+
const weekdays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
|
1727
|
+
return {
|
|
1728
|
+
y: Number(read('year')),
|
|
1729
|
+
m: Number(read('month')),
|
|
1730
|
+
d: Number(read('day')),
|
|
1731
|
+
hh: Number(read('hour')) % 24,
|
|
1732
|
+
mm: Number(read('minute')),
|
|
1733
|
+
ss: Number(read('second')),
|
|
1734
|
+
weekday: Math.max(0, weekdays.indexOf(read('weekday'))),
|
|
1735
|
+
};
|
|
1736
|
+
};
|
|
1737
|
+
const zoneOffsetMs = (instant, timeZone) => {
|
|
1738
|
+
const p = zonedParts(instant, timeZone);
|
|
1739
|
+
return Date.UTC(p.y, p.m - 1, p.d, p.hh, p.mm, p.ss) - instant;
|
|
1740
|
+
};
|
|
1741
|
+
const zonedWallTimeToInstant = (y, m, d, hh, mm, timeZone) => {
|
|
1742
|
+
const guess = Date.UTC(y, m - 1, d, hh, mm);
|
|
1743
|
+
const first = guess - zoneOffsetMs(guess, timeZone);
|
|
1744
|
+
const offset = zoneOffsetMs(first, timeZone);
|
|
1745
|
+
return guess - offset;
|
|
1746
|
+
};
|
|
1747
|
+
const nextQualityFireAt = (timing, from) => {
|
|
1748
|
+
if (timing.kind === 'interval')
|
|
1749
|
+
return new Date(from + timing.everyMs).toISOString();
|
|
1750
|
+
const [hh, mm] = timing.time.split(':').map(Number);
|
|
1751
|
+
const today = zonedParts(from, timing.timeZone);
|
|
1752
|
+
for (let offset = 0; offset <= 8; offset++) {
|
|
1753
|
+
const day = new Date(Date.UTC(today.y, today.m - 1, today.d + offset, 12));
|
|
1754
|
+
const candidate = zonedWallTimeToInstant(day.getUTCFullYear(), day.getUTCMonth() + 1, day.getUTCDate(), hh, mm, timing.timeZone);
|
|
1755
|
+
if (candidate <= from)
|
|
1756
|
+
continue;
|
|
1757
|
+
if (timing.days && !timing.days.includes(zonedParts(candidate, timing.timeZone).weekday))
|
|
1758
|
+
continue;
|
|
1759
|
+
return new Date(candidate).toISOString();
|
|
1760
|
+
}
|
|
1761
|
+
return new Date(from + 7 * 24 * 60 * 60 * 1000).toISOString();
|
|
1762
|
+
};
|
|
1763
|
+
const publishQualitySchedules = () => {
|
|
1764
|
+
publishQualityChange({ kind: 'schedules', schedules: qualitySchedules.map(cloneQualitySchedule) });
|
|
1765
|
+
};
|
|
1766
|
+
const qualityScheduleById = (value, method) => {
|
|
1767
|
+
const id = asString(value, 'scheduleId', method);
|
|
1768
|
+
const schedule = qualitySchedules.find((entry) => entry.id === id);
|
|
1769
|
+
if (!schedule)
|
|
1770
|
+
throw new MinionryError('BAD_REQUEST', `${method}: schedule '${id}' not found in this Space`);
|
|
1771
|
+
return schedule;
|
|
1772
|
+
};
|
|
1773
|
+
const qualityToolState = (path) => {
|
|
1774
|
+
let installed = qualityInstalledTools.get(path);
|
|
1775
|
+
if (!installed) {
|
|
1776
|
+
installed = new Set(MOCK_QUALITY_TOOLS.filter((tool) => tool.installed).map((tool) => tool.name));
|
|
1777
|
+
qualityInstalledTools.set(path, installed);
|
|
1778
|
+
}
|
|
1779
|
+
return installed;
|
|
1780
|
+
};
|
|
1781
|
+
const qualityToolsFor = (path) => {
|
|
1782
|
+
if (path === MOCK_QUALITY_NA_PATH)
|
|
1783
|
+
return { path, tools: [], ecosystem: [] };
|
|
1784
|
+
const installed = qualityToolState(path);
|
|
1785
|
+
return {
|
|
1786
|
+
path,
|
|
1787
|
+
tools: MOCK_QUALITY_TOOLS.map((tool) => ({ ...tool, installed: installed.has(tool.name) })),
|
|
1788
|
+
ecosystem: [...MOCK_QUALITY_ECOSYSTEM],
|
|
1789
|
+
};
|
|
1790
|
+
};
|
|
1791
|
+
const emitToQualityRun = (run, event, payload) => {
|
|
1792
|
+
for (const requestId of run.requestIds)
|
|
1793
|
+
emit(requestId, event, payload);
|
|
1794
|
+
};
|
|
1795
|
+
const settleQualityWaiters = (run, result) => {
|
|
1796
|
+
const stamped = stampQualityResult(result);
|
|
1797
|
+
for (const waiter of run.waiters)
|
|
1798
|
+
respond(waiter, stamped);
|
|
1799
|
+
run.waiters = [];
|
|
1800
|
+
};
|
|
1801
|
+
const qualityRunSteps = (startedAt, sessionId) => {
|
|
1802
|
+
const script = [
|
|
1803
|
+
['detecting', 'Detecting tools', undefined],
|
|
1804
|
+
['scanning', 'Running quality checks', 'tsc --noEmit, 18s elapsed'],
|
|
1805
|
+
['reviewing', 'AI deep review', 'reviewing 3 of 5 files'],
|
|
1806
|
+
['complete', 'Complete', undefined],
|
|
1807
|
+
];
|
|
1808
|
+
return script.flatMap(([phase, step, detail], index) => {
|
|
1809
|
+
const withSession = phase === 'reviewing' && sessionId !== undefined ? { sessionId } : {};
|
|
1810
|
+
return [
|
|
1811
|
+
{ event: 'phase', phase, payload: { phase, ...withSession } },
|
|
1812
|
+
...(phase === 'reviewing' && sessionId !== undefined
|
|
1813
|
+
? [{ event: 'session', phase, payload: { sessionId } }]
|
|
1814
|
+
: []),
|
|
1815
|
+
{
|
|
1816
|
+
event: 'progress',
|
|
1817
|
+
phase,
|
|
1818
|
+
payload: {
|
|
1819
|
+
phase,
|
|
1820
|
+
step,
|
|
1821
|
+
current: index + 1,
|
|
1822
|
+
total: script.length,
|
|
1823
|
+
...(detail === undefined ? {} : { detail }),
|
|
1824
|
+
etaMs: 138_000,
|
|
1825
|
+
startedAt,
|
|
1826
|
+
...withSession,
|
|
1827
|
+
},
|
|
1828
|
+
},
|
|
1829
|
+
];
|
|
1830
|
+
});
|
|
1831
|
+
};
|
|
1832
|
+
const finishQualityRun = (run) => {
|
|
1833
|
+
const partial = run.path === MOCK_QUALITY_PARTIAL_PATH;
|
|
1834
|
+
const timestamp = new Date().toISOString();
|
|
1835
|
+
const previous = qualityReports.get(run.path);
|
|
1836
|
+
const baselineAt = previous?.findings.some((finding) => finding.fingerprint !== undefined)
|
|
1837
|
+
? previous.timestamp
|
|
1838
|
+
: undefined;
|
|
1839
|
+
const baseReport = run.path === MOCK_QUALITY_NA_PATH
|
|
1840
|
+
? mockQualityNaReport(run.path, timestamp)
|
|
1841
|
+
: mockQualityReport(run.path, timestamp, !partial, baselineAt);
|
|
1842
|
+
const report = !partial && run.sessionId !== undefined ? { ...baseReport, reviewSessionId: run.sessionId } : baseReport;
|
|
1843
|
+
qualityReports.set(run.path, report);
|
|
1844
|
+
qualityHistory.push(mockQualityHistoryEntry(report));
|
|
1845
|
+
run.status = partial ? 'partial' : 'ok';
|
|
1846
|
+
const result = {
|
|
1847
|
+
path: run.path,
|
|
1848
|
+
status: run.status,
|
|
1849
|
+
report,
|
|
1850
|
+
...(run.sessionId !== undefined ? { sessionId: run.sessionId } : {}),
|
|
1851
|
+
legs: partial
|
|
1852
|
+
? {
|
|
1853
|
+
scan: { status: 'ok' },
|
|
1854
|
+
review: {
|
|
1855
|
+
status: 'failed',
|
|
1856
|
+
code: 'CLAUDE_NOT_INSTALLED',
|
|
1857
|
+
message: `mock: no coding-agent binary for '${run.path}' — the scan leg still produced a report`,
|
|
1858
|
+
sessionId: run.sessionId,
|
|
1859
|
+
},
|
|
1860
|
+
}
|
|
1861
|
+
: { scan: { status: 'ok' }, review: { status: 'ok', sessionId: run.sessionId } },
|
|
1862
|
+
};
|
|
1863
|
+
qualityResults.set(run.path, result);
|
|
1864
|
+
qualityRunsByPath.delete(run.path);
|
|
1865
|
+
settleQualityWaiters(run, result);
|
|
1866
|
+
publishQualityChange({ kind: 'results', path: run.path, report });
|
|
1867
|
+
};
|
|
1868
|
+
const advanceQualityRun = (run) => {
|
|
1869
|
+
if (run.status !== 'running')
|
|
1870
|
+
return;
|
|
1871
|
+
const step = run.steps.shift();
|
|
1872
|
+
if (!step) {
|
|
1873
|
+
finishQualityRun(run);
|
|
1874
|
+
return;
|
|
1875
|
+
}
|
|
1876
|
+
if (step.event === 'phase')
|
|
1877
|
+
run.kind = step.phase === 'reviewing' ? 'reviewing' : 'scanning';
|
|
1878
|
+
emitToQualityRun(run, step.event, step.payload);
|
|
1879
|
+
later(() => advanceQualityRun(run));
|
|
1880
|
+
};
|
|
1881
|
+
const cancelQualityRun = (run) => {
|
|
1882
|
+
if (run.status !== 'running')
|
|
1883
|
+
return;
|
|
1884
|
+
run.status = 'cancelled';
|
|
1885
|
+
run.steps = [];
|
|
1886
|
+
const result = {
|
|
1887
|
+
path: run.path,
|
|
1888
|
+
status: 'cancelled',
|
|
1889
|
+
report: qualityReports.get(run.path) ?? null,
|
|
1890
|
+
legs: { scan: { status: 'skipped' }, review: { status: 'skipped' } },
|
|
1891
|
+
};
|
|
1892
|
+
qualityResults.set(run.path, result);
|
|
1893
|
+
qualityRunsByPath.delete(run.path);
|
|
1894
|
+
settleQualityWaiters(run, result);
|
|
1895
|
+
};
|
|
1896
|
+
const DEFER = Symbol('mock-defer');
|
|
1897
|
+
const methodHandlers = {
|
|
1898
|
+
features: () => [...MOCK_FEATURES],
|
|
1899
|
+
connect: (params) => {
|
|
1900
|
+
const scopes = asRecord(params, 'connect').scopes;
|
|
1901
|
+
if (!Array.isArray(scopes) || scopes.some((scope) => typeof scope !== 'string')) {
|
|
1902
|
+
throw new MinionryError('BAD_REQUEST', 'connect: scopes must be an array of scope strings');
|
|
1903
|
+
}
|
|
1904
|
+
const requested = [...new Set(scopes)];
|
|
1905
|
+
for (const scope of requested) {
|
|
1906
|
+
if (SCOPES.includes(scope) && !denied.has(scope)) {
|
|
1907
|
+
granted.add(scope);
|
|
1908
|
+
}
|
|
1909
|
+
}
|
|
1910
|
+
return {
|
|
1911
|
+
space: { ...space },
|
|
1912
|
+
user: user === null ? null : { ...user },
|
|
1913
|
+
grants: requested.filter((scope) => granted.has(scope)),
|
|
1914
|
+
viewOnly: user === null,
|
|
1915
|
+
};
|
|
1916
|
+
},
|
|
1917
|
+
disconnect: () => {
|
|
1918
|
+
granted.clear();
|
|
1919
|
+
for (const board of boards.values())
|
|
1920
|
+
board.subscribers.clear();
|
|
1921
|
+
for (const requestId of subscriptions.keys()) {
|
|
1922
|
+
streamSeq.delete(requestId);
|
|
1923
|
+
streamLog.delete(requestId);
|
|
1924
|
+
}
|
|
1925
|
+
subscriptions.clear();
|
|
1926
|
+
for (const requestId of fileChangeSubscriptions.keys()) {
|
|
1927
|
+
streamSeq.delete(requestId);
|
|
1928
|
+
streamLog.delete(requestId);
|
|
1929
|
+
}
|
|
1930
|
+
fileChangeSubscriptions.clear();
|
|
1931
|
+
for (const requestId of browserTabSubscriptions) {
|
|
1932
|
+
streamSeq.delete(requestId);
|
|
1933
|
+
streamLog.delete(requestId);
|
|
1934
|
+
}
|
|
1935
|
+
browserTabSubscriptions.clear();
|
|
1936
|
+
for (const run of automations.values()) {
|
|
1937
|
+
run.status = 'cancelled';
|
|
1938
|
+
run.pendingProposalId = null;
|
|
1939
|
+
run.steps = [];
|
|
1940
|
+
streamSeq.delete(run.requestId);
|
|
1941
|
+
streamLog.delete(run.requestId);
|
|
1942
|
+
}
|
|
1943
|
+
automations.clear();
|
|
1944
|
+
for (const view of [...views.values()])
|
|
1945
|
+
retireView(view);
|
|
1946
|
+
for (const requestId of gitWorktreeSubscriptions) {
|
|
1947
|
+
streamSeq.delete(requestId);
|
|
1948
|
+
streamLog.delete(requestId);
|
|
1949
|
+
}
|
|
1950
|
+
gitWorktreeSubscriptions.clear();
|
|
1951
|
+
for (const requestId of gitBranchChangeSubscriptions) {
|
|
1952
|
+
streamSeq.delete(requestId);
|
|
1953
|
+
streamLog.delete(requestId);
|
|
1954
|
+
}
|
|
1955
|
+
gitBranchChangeSubscriptions.clear();
|
|
1956
|
+
for (const requestId of terminalSessionSubscriptions) {
|
|
1957
|
+
streamSeq.delete(requestId);
|
|
1958
|
+
streamLog.delete(requestId);
|
|
1959
|
+
}
|
|
1960
|
+
terminalSessionSubscriptions.clear();
|
|
1961
|
+
for (const [requestId, session] of ptyAttachByRequestId) {
|
|
1962
|
+
session.attachedRequestIds.delete(requestId);
|
|
1963
|
+
streamSeq.delete(requestId);
|
|
1964
|
+
}
|
|
1965
|
+
ptyAttachByRequestId.clear();
|
|
1966
|
+
for (const requestId of qualityChangeSubscriptions) {
|
|
1967
|
+
streamSeq.delete(requestId);
|
|
1968
|
+
streamLog.delete(requestId);
|
|
1969
|
+
}
|
|
1970
|
+
qualityChangeSubscriptions.clear();
|
|
1971
|
+
for (const [requestId, run] of qualityRunsByRequestId) {
|
|
1972
|
+
run.requestIds.delete(requestId);
|
|
1973
|
+
streamSeq.delete(requestId);
|
|
1974
|
+
streamLog.delete(requestId);
|
|
1975
|
+
}
|
|
1976
|
+
qualityRunsByRequestId.clear();
|
|
1977
|
+
for (const requestId of [...toolRegistrations.keys()])
|
|
1978
|
+
retireToolRegistration(requestId);
|
|
1979
|
+
notify('disconnect');
|
|
1980
|
+
return null;
|
|
1981
|
+
},
|
|
1982
|
+
'auth.getToken': () => `mock-token-${space.id}`,
|
|
1983
|
+
'agents.run': (params, requestId) => {
|
|
1984
|
+
const { prompt, title, model, sessionId: continuedId } = runParams(params);
|
|
1985
|
+
let session;
|
|
1986
|
+
let endpoint;
|
|
1987
|
+
if (continuedId !== undefined) {
|
|
1988
|
+
session = runSessions.get(continuedId);
|
|
1989
|
+
if (!session) {
|
|
1990
|
+
throw new MinionryError('BAD_REQUEST', `unknown sessionId '${continuedId}' — agents.run continues only an open session this app started with agents.run in this Space`);
|
|
1991
|
+
}
|
|
1992
|
+
if (session.run.status === 'running') {
|
|
1993
|
+
throw new MinionryError('BAD_REQUEST', `session_busy: session '${session.id}' is still running a turn — wait for its result() before continuing it`);
|
|
1994
|
+
}
|
|
1995
|
+
if (session.modelValue !== undefined)
|
|
1996
|
+
endpoint = admitEndpointModel(session.modelValue);
|
|
1997
|
+
}
|
|
1998
|
+
else if (model !== undefined) {
|
|
1999
|
+
endpoint = admitEndpointModel(model);
|
|
2000
|
+
}
|
|
2001
|
+
const turn = (session?.turns ?? 0) + 1;
|
|
2002
|
+
const runNumber = ++seq;
|
|
2003
|
+
const runId = `run-mock-${runNumber}`;
|
|
2004
|
+
const sessionId = session?.id ?? `session-mock-${++seq}`;
|
|
2005
|
+
const startedAtIso = new Date().toISOString();
|
|
2006
|
+
const tab = sessions.get(sessionId);
|
|
2007
|
+
if (tab) {
|
|
2008
|
+
tab.isExecuting = true;
|
|
2009
|
+
tab.lastActivityAt = startedAtIso;
|
|
2010
|
+
}
|
|
2011
|
+
else {
|
|
2012
|
+
sessions.set(sessionId, {
|
|
2013
|
+
id: sessionId,
|
|
2014
|
+
name: title?.trim() || `Run ${runNumber}`,
|
|
2015
|
+
createdAt: startedAtIso,
|
|
2016
|
+
lastActivityAt: startedAtIso,
|
|
2017
|
+
isExecuting: true,
|
|
2018
|
+
entries: [],
|
|
2019
|
+
seq: 0,
|
|
2020
|
+
});
|
|
2021
|
+
}
|
|
2022
|
+
const startedAt = Date.now();
|
|
2023
|
+
const run = {
|
|
2024
|
+
runId,
|
|
2025
|
+
requestId,
|
|
2026
|
+
sessionId,
|
|
2027
|
+
status: 'running',
|
|
2028
|
+
text: '',
|
|
2029
|
+
steps: endpoint ? endpointRunSteps(endpoint, prompt, turn) : [
|
|
2030
|
+
{ event: 'status', payload: { status: 'running' } },
|
|
2031
|
+
{
|
|
2032
|
+
event: 'timelineEvent',
|
|
2033
|
+
payload: { kind: 'status', at: startedAt, executing: true, turn },
|
|
2034
|
+
},
|
|
2035
|
+
{
|
|
2036
|
+
event: 'timelineEvent',
|
|
2037
|
+
payload: {
|
|
2038
|
+
kind: 'thinking',
|
|
2039
|
+
at: startedAt,
|
|
2040
|
+
entryId: 'mock-thinking-1',
|
|
2041
|
+
delta: 'Reviewing the request (mock)...',
|
|
2042
|
+
},
|
|
2043
|
+
},
|
|
2044
|
+
{ event: 'toolUse', payload: { tool: 'plan', input: { prompt } } },
|
|
2045
|
+
{
|
|
2046
|
+
event: 'timelineEvent',
|
|
2047
|
+
payload: {
|
|
2048
|
+
kind: 'tool_use',
|
|
2049
|
+
at: startedAt,
|
|
2050
|
+
entryId: 'mock-tool-1',
|
|
2051
|
+
toolName: 'plan',
|
|
2052
|
+
input: { prompt },
|
|
2053
|
+
},
|
|
2054
|
+
},
|
|
2055
|
+
{
|
|
2056
|
+
event: 'timelineEvent',
|
|
2057
|
+
payload: {
|
|
2058
|
+
kind: 'tool_result',
|
|
2059
|
+
at: startedAt,
|
|
2060
|
+
entryId: 'mock-tool-1',
|
|
2061
|
+
ok: true,
|
|
2062
|
+
result: 'Planned (mock).',
|
|
2063
|
+
},
|
|
2064
|
+
},
|
|
2065
|
+
{ event: 'output', payload: { text: `Working on: ${prompt}\n` } },
|
|
2066
|
+
{
|
|
2067
|
+
event: 'timelineEvent',
|
|
2068
|
+
payload: {
|
|
2069
|
+
kind: 'text',
|
|
2070
|
+
at: startedAt,
|
|
2071
|
+
entryId: 'mock-text-1',
|
|
2072
|
+
delta: `Working on: ${prompt}\n`,
|
|
2073
|
+
},
|
|
2074
|
+
},
|
|
2075
|
+
{ event: 'output', payload: { text: 'Done (mock).' } },
|
|
2076
|
+
{
|
|
2077
|
+
event: 'timelineEvent',
|
|
2078
|
+
payload: { kind: 'text', at: startedAt, entryId: 'mock-text-1', delta: 'Done (mock).' },
|
|
2079
|
+
},
|
|
2080
|
+
{
|
|
2081
|
+
event: 'timelineEvent',
|
|
2082
|
+
payload: {
|
|
2083
|
+
kind: 'tokens',
|
|
2084
|
+
at: startedAt,
|
|
2085
|
+
usage: {
|
|
2086
|
+
inputTokens: 120,
|
|
2087
|
+
outputTokens: 48,
|
|
2088
|
+
cacheCreationTokens: 0,
|
|
2089
|
+
cacheReadTokens: 0,
|
|
2090
|
+
currentTurnInputTokens: 120,
|
|
2091
|
+
isFinal: true,
|
|
2092
|
+
},
|
|
2093
|
+
},
|
|
2094
|
+
},
|
|
2095
|
+
{
|
|
2096
|
+
event: 'timelineEvent',
|
|
2097
|
+
payload: { kind: 'status', at: startedAt, executing: false, turn },
|
|
2098
|
+
},
|
|
2099
|
+
],
|
|
2100
|
+
waiters: [],
|
|
2101
|
+
...(prompt.trim() === MOCK_QUALITY_AGENT_TURN_PROMPT
|
|
2102
|
+
? { agentTurn: 'directory' }
|
|
2103
|
+
: prompt.trim() === MOCK_QUALITY_WORKTREE_TURN_PROMPT
|
|
2104
|
+
? { agentTurn: 'worktree' }
|
|
2105
|
+
: {}),
|
|
2106
|
+
};
|
|
2107
|
+
runsByRequestId.set(requestId, run);
|
|
2108
|
+
runsByRunId.set(runId, run);
|
|
2109
|
+
if (session) {
|
|
2110
|
+
session.turns = turn;
|
|
2111
|
+
session.run = run;
|
|
2112
|
+
}
|
|
2113
|
+
else {
|
|
2114
|
+
session = { id: sessionId, turns: turn, run, ...(endpoint ? { modelValue: endpoint.modelValue } : {}) };
|
|
2115
|
+
runSessions.set(session.id, session);
|
|
2116
|
+
}
|
|
2117
|
+
later(() => advanceRun(run));
|
|
2118
|
+
return { runId, sessionId };
|
|
2119
|
+
},
|
|
2120
|
+
'agents.result': (params, requestId) => {
|
|
2121
|
+
const runId = asString(asRecord(params, 'agents.result').runId, 'runId', 'agents.result');
|
|
2122
|
+
const run = runsByRunId.get(runId);
|
|
2123
|
+
if (!run)
|
|
2124
|
+
throw new MinionryError('BAD_REQUEST', `agents.result: unknown runId '${runId}'`);
|
|
2125
|
+
if (run.status === 'running') {
|
|
2126
|
+
run.waiters.push(requestId);
|
|
2127
|
+
return DEFER;
|
|
2128
|
+
}
|
|
2129
|
+
const result = { status: run.status, text: run.text };
|
|
2130
|
+
return result;
|
|
2131
|
+
},
|
|
2132
|
+
'inference.chat': (params, requestId) => {
|
|
2133
|
+
const record = asRecord(params, 'inference.chat');
|
|
2134
|
+
const messages = mockInferenceMessages(record.messages);
|
|
2135
|
+
if (record.system !== undefined && (typeof record.system !== 'string' || record.system.length > 32_000)) {
|
|
2136
|
+
throw new MinionryError('BAD_REQUEST', 'inference.chat: system must be a string of at most 32000 characters');
|
|
2137
|
+
}
|
|
2138
|
+
const engine = record.engine === undefined || record.engine === null ? undefined : asString(record.engine, 'engine', 'inference.chat');
|
|
2139
|
+
if (engine !== undefined && !MOCK_ENGINES.includes(engine)) {
|
|
2140
|
+
throw new MinionryError('BAD_REQUEST', `inference.chat: unknown engine '${engine}' — expected one of ${MOCK_ENGINES.join(', ')}`);
|
|
2141
|
+
}
|
|
2142
|
+
const model = record.model === undefined || record.model === null ? undefined : asString(record.model, 'model', 'inference.chat');
|
|
2143
|
+
if (model === '')
|
|
2144
|
+
throw new MinionryError('BAD_REQUEST', 'inference.chat: model must be a non-empty string when present');
|
|
2145
|
+
const resolvedEngine = engine ?? (model ? mockEngineForModel(model) : MOCK_DEFAULT_ENGINE);
|
|
2146
|
+
if (model && mockEngineForModel(model) !== resolvedEngine) {
|
|
2147
|
+
throw new MinionryError('BAD_REQUEST', `inference.chat: model '${model}' is not a ${resolvedEngine} model — it belongs to ${mockEngineForModel(model)}`);
|
|
2148
|
+
}
|
|
2149
|
+
if (!MOCK_INFERENCE_ENGINES.includes(resolvedEngine)) {
|
|
2150
|
+
throw new MinionryError('UNSUPPORTED', `inference.chat: ${resolvedEngine} cannot run a tool-less completion on this host`);
|
|
2151
|
+
}
|
|
2152
|
+
const effortLevel = record.effortLevel === undefined || record.effortLevel === null
|
|
2153
|
+
? undefined
|
|
2154
|
+
: asString(record.effortLevel, 'effortLevel', 'inference.chat');
|
|
2155
|
+
if (effortLevel !== undefined && effortLevel !== 'auto' && !MOCK_EFFORT_LEVELS[resolvedEngine]?.includes(effortLevel)) {
|
|
2156
|
+
throw new MinionryError('BAD_REQUEST', `inference.chat: '${effortLevel}' is not a ${resolvedEngine} effort level`);
|
|
2157
|
+
}
|
|
2158
|
+
const resolvedModel = model ?? (mockEngineForModel(MOCK_DEFAULT_MODEL) === resolvedEngine ? MOCK_DEFAULT_MODEL : undefined);
|
|
2159
|
+
const prompt = messages[messages.length - 1]?.content.trim() ?? '';
|
|
2160
|
+
const runId = `inference-mock-${++seq}`;
|
|
2161
|
+
const run = {
|
|
2162
|
+
runId,
|
|
2163
|
+
requestId,
|
|
2164
|
+
status: 'running',
|
|
2165
|
+
engine: resolvedEngine,
|
|
2166
|
+
...(resolvedModel ? { model: resolvedModel } : {}),
|
|
2167
|
+
text: '',
|
|
2168
|
+
deltas: [
|
|
2169
|
+
`(mock ${resolvedEngine}${resolvedModel ? ` · ${resolvedModel}` : ''}) `,
|
|
2170
|
+
prompt.length > 120 ? `${prompt.slice(0, 119)}…` : prompt,
|
|
2171
|
+
],
|
|
2172
|
+
waiters: [],
|
|
2173
|
+
};
|
|
2174
|
+
inferenceRunsByRequestId.set(requestId, run);
|
|
2175
|
+
inferenceRunsByRunId.set(runId, run);
|
|
2176
|
+
later(() => {
|
|
2177
|
+
if (run.status !== 'running')
|
|
2178
|
+
return;
|
|
2179
|
+
emit(requestId, 'status', { status: 'running' });
|
|
2180
|
+
advanceInferenceRun(run);
|
|
2181
|
+
});
|
|
2182
|
+
return { runId };
|
|
2183
|
+
},
|
|
2184
|
+
'inference.result': (params, requestId) => {
|
|
2185
|
+
const runId = asString(asRecord(params, 'inference.result').runId, 'runId', 'inference.result');
|
|
2186
|
+
const run = inferenceRunsByRunId.get(runId);
|
|
2187
|
+
if (!run)
|
|
2188
|
+
throw new MinionryError('BAD_REQUEST', `inference.result: unknown runId '${runId}'`);
|
|
2189
|
+
if (run.status === 'running') {
|
|
2190
|
+
run.waiters.push(requestId);
|
|
2191
|
+
return DEFER;
|
|
2192
|
+
}
|
|
2193
|
+
return inferenceResultOf(run);
|
|
2194
|
+
},
|
|
2195
|
+
'endpoints.register': (params) => {
|
|
2196
|
+
const record = strictRecord(params, 'endpoints.register', MOCK_ENDPOINT_REGISTER_SHAPE, MOCK_ENDPOINT_REGISTER_KEYS);
|
|
2197
|
+
const id = endpointId(record.id, 'endpoints.register');
|
|
2198
|
+
const label = endpointText(record.label, 'label', MOCK_ENDPOINT_LABEL_CHARS);
|
|
2199
|
+
if (typeof record.engine !== 'string' || !MOCK_ENDPOINT_ENGINES.includes(record.engine)) {
|
|
2200
|
+
throw new MinionryError('BAD_REQUEST', `endpoints.register: engine must be one of ${MOCK_ENDPOINT_ENGINES.join(', ')}`);
|
|
2201
|
+
}
|
|
2202
|
+
const { baseUrl, apiKey } = record;
|
|
2203
|
+
if (typeof baseUrl !== 'string' || utf8ByteLength(baseUrl) > MOCK_ENDPOINT_URL_BYTES) {
|
|
2204
|
+
throw new MinionryError('BAD_REQUEST', `endpoints.register: baseUrl must be a string of at most ${MOCK_ENDPOINT_URL_BYTES} bytes`);
|
|
2205
|
+
}
|
|
2206
|
+
if (typeof apiKey !== 'string' ||
|
|
2207
|
+
apiKey === '' ||
|
|
2208
|
+
utf8ByteLength(apiKey) > MOCK_ENDPOINT_API_KEY_BYTES ||
|
|
2209
|
+
MOCK_WHITESPACE_OR_CONTROL.test(apiKey)) {
|
|
2210
|
+
throw new MinionryError('BAD_REQUEST', `endpoints.register: apiKey is required — a string of at most ${MOCK_ENDPOINT_API_KEY_BYTES} bytes, without whitespace or control characters`);
|
|
2211
|
+
}
|
|
2212
|
+
const model = endpointText(record.model, 'model', MOCK_ENDPOINT_MODEL_CHARS);
|
|
2213
|
+
const contextLength = optionalPositiveInteger(record.contextLength, 'contextLength');
|
|
2214
|
+
const ttlMs = Math.min(optionalPositiveInteger(record.ttlMs, 'ttlMs') ?? MOCK_ENDPOINT_DEFAULT_TTL_MS, AGENT_RUN_LIMITS.maxTtlMs);
|
|
2215
|
+
const verdict = mockValidateEndpointUrl(baseUrl);
|
|
2216
|
+
if (!verdict.ok)
|
|
2217
|
+
throw new MinionryError('ENDPOINT_INVALID', `endpoints.register: baseUrl ${verdict.reason}`);
|
|
2218
|
+
const now = Date.now();
|
|
2219
|
+
const others = [...endpoints.values()].filter((candidate) => candidate.id !== id && isLiveEndpoint(candidate, now));
|
|
2220
|
+
if (others.length >= AGENT_RUN_LIMITS.endpointsPerApp) {
|
|
2221
|
+
throw new MinionryError('QUOTA_EXCEEDED', `endpoints.register: this app already holds ${AGENT_RUN_LIMITS.endpointsPerApp} endpoints in this Space — unregister one first`);
|
|
2222
|
+
}
|
|
2223
|
+
const registered = {
|
|
2224
|
+
id,
|
|
2225
|
+
label,
|
|
2226
|
+
engine: 'hermes',
|
|
2227
|
+
modelValue: `custom:${record.engine}:app-${MOCK_ENDPOINT_APP_ID}-${id}`,
|
|
2228
|
+
model,
|
|
2229
|
+
...(contextLength !== undefined ? { contextLength } : {}),
|
|
2230
|
+
expiresAt: new Date(now + ttlMs).toISOString(),
|
|
2231
|
+
};
|
|
2232
|
+
endpoints.set(id, registered);
|
|
2233
|
+
return { ...registered };
|
|
2234
|
+
},
|
|
2235
|
+
'endpoints.unregister': (params) => {
|
|
2236
|
+
const record = strictRecord(params, 'endpoints.unregister', '{ id }', new Set(['id']));
|
|
2237
|
+
const id = endpointId(record.id, 'endpoints.unregister');
|
|
2238
|
+
const endpoint = endpoints.get(id);
|
|
2239
|
+
if (!endpoint) {
|
|
2240
|
+
throw new MinionryError('BAD_REQUEST', `unknown endpoint id '${id}' — endpoints.unregister removes only this app's endpoints in this Space`);
|
|
2241
|
+
}
|
|
2242
|
+
endpoints.delete(id);
|
|
2243
|
+
for (const session of runSessions.values()) {
|
|
2244
|
+
if (session.modelValue === endpoint.modelValue)
|
|
2245
|
+
cancelRun(session.run);
|
|
2246
|
+
}
|
|
2247
|
+
return null;
|
|
2248
|
+
},
|
|
2249
|
+
'endpoints.list': () => {
|
|
2250
|
+
const now = Date.now();
|
|
2251
|
+
return [...endpoints.values()].filter((endpoint) => isLiveEndpoint(endpoint, now)).map((endpoint) => ({ ...endpoint }));
|
|
2252
|
+
},
|
|
2253
|
+
'agents.sessions.list': () => [...sessions.values()].map(mockSessionSummary),
|
|
2254
|
+
'agents.sessions.create': (params) => {
|
|
2255
|
+
const record = asRecord(params, 'agents.sessions.create');
|
|
2256
|
+
const name = typeof record.name === 'string' ? record.name : undefined;
|
|
2257
|
+
const id = `session-mock-${++seq}`;
|
|
2258
|
+
const now = new Date().toISOString();
|
|
2259
|
+
const session = {
|
|
2260
|
+
id,
|
|
2261
|
+
name: name ?? `Session ${sessions.size + 1}`,
|
|
2262
|
+
createdAt: now,
|
|
2263
|
+
lastActivityAt: now,
|
|
2264
|
+
isExecuting: false,
|
|
2265
|
+
entries: [],
|
|
2266
|
+
seq: 0,
|
|
2267
|
+
};
|
|
2268
|
+
sessions.set(id, session);
|
|
2269
|
+
return mockSessionSummary(session);
|
|
2270
|
+
},
|
|
2271
|
+
'agents.sessions.attach': (params) => {
|
|
2272
|
+
const sessionId = asString(asRecord(params, 'agents.sessions.attach').sessionId, 'sessionId', 'agents.sessions.attach');
|
|
2273
|
+
const session = sessions.get(sessionId);
|
|
2274
|
+
if (!session)
|
|
2275
|
+
throw new MinionryError('BAD_REQUEST', `agents.sessions.attach: unknown sessionId '${sessionId}'`);
|
|
2276
|
+
return mockSessionSummary(session);
|
|
2277
|
+
},
|
|
2278
|
+
'agents.sessions.close': (params) => {
|
|
2279
|
+
const sessionId = asString(asRecord(params, 'agents.sessions.close').sessionId, 'sessionId', 'agents.sessions.close');
|
|
2280
|
+
if (!sessions.delete(sessionId)) {
|
|
2281
|
+
throw new MinionryError('BAD_REQUEST', `agents.sessions.close: unknown sessionId '${sessionId}'`);
|
|
2282
|
+
}
|
|
2283
|
+
return null;
|
|
2284
|
+
},
|
|
2285
|
+
'agents.sessions.rename': (params) => {
|
|
2286
|
+
const record = asRecord(params, 'agents.sessions.rename');
|
|
2287
|
+
const sessionId = asString(record.sessionId, 'sessionId', 'agents.sessions.rename');
|
|
2288
|
+
const session = sessions.get(sessionId);
|
|
2289
|
+
if (!session)
|
|
2290
|
+
throw new MinionryError('BAD_REQUEST', `agents.sessions.rename: unknown sessionId '${sessionId}'`);
|
|
2291
|
+
session.name = asString(record.name, 'name', 'agents.sessions.rename');
|
|
2292
|
+
return mockSessionSummary(session);
|
|
2293
|
+
},
|
|
2294
|
+
'agents.sessions.history': (params) => {
|
|
2295
|
+
const sessionId = asString(asRecord(params, 'agents.sessions.history').sessionId, 'sessionId', 'agents.sessions.history');
|
|
2296
|
+
const session = sessions.get(sessionId);
|
|
2297
|
+
if (!session)
|
|
2298
|
+
throw new MinionryError('BAD_REQUEST', `agents.sessions.history: unknown sessionId '${sessionId}'`);
|
|
2299
|
+
return { sessionId, entries: [...session.entries], seq: session.seq };
|
|
2300
|
+
},
|
|
2301
|
+
'agents.sessions.replay': (params) => {
|
|
2302
|
+
const record = asRecord(params, 'agents.sessions.replay');
|
|
2303
|
+
const sessionId = asString(record.sessionId, 'sessionId', 'agents.sessions.replay');
|
|
2304
|
+
const session = sessions.get(sessionId);
|
|
2305
|
+
if (!session)
|
|
2306
|
+
throw new MinionryError('BAD_REQUEST', `agents.sessions.replay: unknown sessionId '${sessionId}'`);
|
|
2307
|
+
const afterSeq = record.afterSeq;
|
|
2308
|
+
if (typeof afterSeq !== 'number' || !Number.isInteger(afterSeq) || afterSeq < 0) {
|
|
2309
|
+
throw new MinionryError('BAD_REQUEST', 'agents.sessions.replay: afterSeq must be a non-negative integer');
|
|
2310
|
+
}
|
|
2311
|
+
return { sessionId, fromSeq: afterSeq, events: [], currentSeq: session.seq };
|
|
2312
|
+
},
|
|
2313
|
+
'agents.sessions.approve': (params) => {
|
|
2314
|
+
const record = asRecord(params, 'agents.sessions.approve');
|
|
2315
|
+
const sessionId = asString(record.sessionId, 'sessionId', 'agents.sessions.approve');
|
|
2316
|
+
if (typeof record.approved !== 'boolean') {
|
|
2317
|
+
throw new MinionryError('BAD_REQUEST', 'agents.sessions.approve: approved must be a boolean');
|
|
2318
|
+
}
|
|
2319
|
+
if (!sessions.has(sessionId)) {
|
|
2320
|
+
throw new MinionryError('BAD_REQUEST', `agents.sessions.approve: unknown sessionId '${sessionId}'`);
|
|
2321
|
+
}
|
|
2322
|
+
return null;
|
|
2323
|
+
},
|
|
2324
|
+
'agents.sessions.answerQuestion': (params) => {
|
|
2325
|
+
const record = asRecord(params, 'agents.sessions.answerQuestion');
|
|
2326
|
+
const sessionId = asString(record.sessionId, 'sessionId', 'agents.sessions.answerQuestion');
|
|
2327
|
+
asString(record.toolUseId, 'toolUseId', 'agents.sessions.answerQuestion');
|
|
2328
|
+
if (typeof record.answers !== 'object' || record.answers === null || Array.isArray(record.answers)) {
|
|
2329
|
+
throw new MinionryError('BAD_REQUEST', 'agents.sessions.answerQuestion: answers must be an object');
|
|
2330
|
+
}
|
|
2331
|
+
if (!sessions.has(sessionId)) {
|
|
2332
|
+
throw new MinionryError('BAD_REQUEST', `agents.sessions.answerQuestion: unknown sessionId '${sessionId}'`);
|
|
2333
|
+
}
|
|
2334
|
+
return null;
|
|
2335
|
+
},
|
|
2336
|
+
'agents.sessions.prompt': (params) => {
|
|
2337
|
+
const record = asRecord(params, 'agents.sessions.prompt');
|
|
2338
|
+
const sessionId = asString(record.sessionId, 'sessionId', 'agents.sessions.prompt');
|
|
2339
|
+
const prompt = asString(record.prompt, 'prompt', 'agents.sessions.prompt');
|
|
2340
|
+
const attachments = Array.isArray(record.attachments)
|
|
2341
|
+
? record.attachments.map((ref, i) => {
|
|
2342
|
+
const path = asString(ref?.path, 'path', `agents.sessions.prompt attachments[${i}]`);
|
|
2343
|
+
return { fileName: path.split('/').pop() ?? path, isImage: false };
|
|
2344
|
+
})
|
|
2345
|
+
: undefined;
|
|
2346
|
+
const session = sessions.get(sessionId);
|
|
2347
|
+
if (!session)
|
|
2348
|
+
throw new MinionryError('BAD_REQUEST', `agents.sessions.prompt: unknown sessionId '${sessionId}'`);
|
|
2349
|
+
const now = new Date().toISOString();
|
|
2350
|
+
session.lastActivityAt = now;
|
|
2351
|
+
session.entries.push({
|
|
2352
|
+
id: `entry-${sessionId}-${++session.seq}`,
|
|
2353
|
+
kind: 'user',
|
|
2354
|
+
at: Date.now(),
|
|
2355
|
+
text: prompt,
|
|
2356
|
+
...(attachments ? { attachments } : {}),
|
|
2357
|
+
});
|
|
2358
|
+
session.entries.push({
|
|
2359
|
+
id: `entry-${sessionId}-${++session.seq}`,
|
|
2360
|
+
kind: 'text',
|
|
2361
|
+
at: Date.now(),
|
|
2362
|
+
text: `Working on: ${prompt} (mock)`,
|
|
2363
|
+
open: false,
|
|
2364
|
+
});
|
|
2365
|
+
return { queued: false };
|
|
2366
|
+
},
|
|
2367
|
+
'agents.sessions.stop': (params) => {
|
|
2368
|
+
const sessionId = asString(asRecord(params, 'agents.sessions.stop').sessionId, 'sessionId', 'agents.sessions.stop');
|
|
2369
|
+
const session = sessions.get(sessionId);
|
|
2370
|
+
if (!session)
|
|
2371
|
+
throw new MinionryError('BAD_REQUEST', `agents.sessions.stop: unknown sessionId '${sessionId}'`);
|
|
2372
|
+
session.isExecuting = false;
|
|
2373
|
+
return null;
|
|
2374
|
+
},
|
|
2375
|
+
'apps.open': (params) => {
|
|
2376
|
+
const record = asRecord(params, 'apps.open');
|
|
2377
|
+
const app = asString(record.app, 'app', 'apps.open');
|
|
2378
|
+
if (granted.size === 0) {
|
|
2379
|
+
throw new MinionryError('SCOPE_DENIED', 'apps.open: no connected app session — call connect() first');
|
|
2380
|
+
}
|
|
2381
|
+
if (app === 'agents') {
|
|
2382
|
+
const openParams = record.params;
|
|
2383
|
+
if (typeof openParams !== 'object' || openParams === null || Array.isArray(openParams)) {
|
|
2384
|
+
throw new MinionryError('BAD_REQUEST', "apps.open: 'params' must be an object for the 'agents' target");
|
|
2385
|
+
}
|
|
2386
|
+
const sessionId = asString(openParams.sessionId, 'params.sessionId', 'apps.open');
|
|
2387
|
+
const known = sessions.has(sessionId) ||
|
|
2388
|
+
runSessions.has(sessionId) ||
|
|
2389
|
+
[...automations.values()].some((run) => run.target.sessionId === sessionId) ||
|
|
2390
|
+
[...qualityRunsByPath.values()].some((run) => run.sessionId === sessionId) ||
|
|
2391
|
+
[...qualityResults.values()].some((result) => result.sessionId === sessionId) ||
|
|
2392
|
+
[...qualityReports.values()].some((report) => report.reviewSessionId === sessionId);
|
|
2393
|
+
if (!known)
|
|
2394
|
+
throw new MinionryError('BAD_REQUEST', `apps.open: unknown sessionId '${sessionId}'`);
|
|
2395
|
+
return null;
|
|
2396
|
+
}
|
|
2397
|
+
if (app === 'browser') {
|
|
2398
|
+
if (!granted.has('browser:read')) {
|
|
2399
|
+
throw new MinionryError('SCOPE_DENIED', "apps.open: the 'browser' target requires scope 'browser:read'");
|
|
2400
|
+
}
|
|
2401
|
+
const openParams = record.params;
|
|
2402
|
+
if (typeof openParams !== 'object' || openParams === null || Array.isArray(openParams)) {
|
|
2403
|
+
throw new MinionryError('BAD_REQUEST', "apps.open: 'params' must be an object for the 'browser' target");
|
|
2404
|
+
}
|
|
2405
|
+
const tabId = asString(openParams.tabId, 'params.tabId', 'apps.open');
|
|
2406
|
+
if (tabId === '') {
|
|
2407
|
+
throw new MinionryError('BAD_REQUEST', "apps.open: 'params.tabId' must be a non-empty string");
|
|
2408
|
+
}
|
|
2409
|
+
currentTabId = requireBrowserTab(tabId, 'apps.open').id;
|
|
2410
|
+
publishBrowserTabs();
|
|
2411
|
+
return null;
|
|
2412
|
+
}
|
|
2413
|
+
if (app !== 'files') {
|
|
2414
|
+
throw new MinionryError('SCOPE_DENIED', `apps.open: unknown app '${app}' (fail closed)`);
|
|
2415
|
+
}
|
|
2416
|
+
if (!granted.has('files:workspace:read')) {
|
|
2417
|
+
throw new MinionryError('SCOPE_DENIED', "apps.open: the 'files' target requires scope 'files:workspace:read'");
|
|
2418
|
+
}
|
|
2419
|
+
const openParams = record.params;
|
|
2420
|
+
if (typeof openParams !== 'object' || openParams === null || Array.isArray(openParams)) {
|
|
2421
|
+
throw new MinionryError('BAD_REQUEST', "apps.open: 'params' must be an object for the 'files' target");
|
|
2422
|
+
}
|
|
2423
|
+
const path = asString(openParams.path, 'params.path', 'apps.open');
|
|
2424
|
+
if (!path.startsWith('workspace:/')) {
|
|
2425
|
+
throw new MinionryError('BAD_REQUEST', "apps.open: 'params.path' must use canonical workspace addressing ('workspace:/…')");
|
|
2426
|
+
}
|
|
2427
|
+
const line = openParams.line;
|
|
2428
|
+
if (line !== undefined && (typeof line !== 'number' || !Number.isInteger(line) || line < 1)) {
|
|
2429
|
+
throw new MinionryError('BAD_REQUEST', "apps.open: 'params.line' must be a positive integer");
|
|
2430
|
+
}
|
|
2431
|
+
return null;
|
|
2432
|
+
},
|
|
2433
|
+
cancel: (params) => {
|
|
2434
|
+
const requestId = asString(asRecord(params, 'cancel').requestId, 'requestId', 'cancel');
|
|
2435
|
+
const run = runsByRequestId.get(requestId);
|
|
2436
|
+
if (run) {
|
|
2437
|
+
cancelRun(run);
|
|
2438
|
+
return null;
|
|
2439
|
+
}
|
|
2440
|
+
const inferenceRun = inferenceRunsByRequestId.get(requestId);
|
|
2441
|
+
if (inferenceRun) {
|
|
2442
|
+
cancelInferenceRun(inferenceRun);
|
|
2443
|
+
return null;
|
|
2444
|
+
}
|
|
2445
|
+
const board = subscriptions.get(requestId);
|
|
2446
|
+
if (board) {
|
|
2447
|
+
subscriptions.delete(requestId);
|
|
2448
|
+
board.subscribers.delete(requestId);
|
|
2449
|
+
streamSeq.delete(requestId);
|
|
2450
|
+
streamLog.delete(requestId);
|
|
2451
|
+
return null;
|
|
2452
|
+
}
|
|
2453
|
+
const execRun = execRunsByRequestId.get(requestId);
|
|
2454
|
+
if (execRun) {
|
|
2455
|
+
execRun.requestIds.delete(requestId);
|
|
2456
|
+
execRunsByRequestId.delete(requestId);
|
|
2457
|
+
streamSeq.delete(requestId);
|
|
2458
|
+
streamLog.delete(requestId);
|
|
2459
|
+
}
|
|
2460
|
+
if (fileChangeSubscriptions.delete(requestId)) {
|
|
2461
|
+
streamSeq.delete(requestId);
|
|
2462
|
+
streamLog.delete(requestId);
|
|
2463
|
+
}
|
|
2464
|
+
if (browserTabSubscriptions.delete(requestId)) {
|
|
2465
|
+
streamSeq.delete(requestId);
|
|
2466
|
+
streamLog.delete(requestId);
|
|
2467
|
+
}
|
|
2468
|
+
const automation = automations.get(requestId);
|
|
2469
|
+
if (automation) {
|
|
2470
|
+
automation.status = 'cancelled';
|
|
2471
|
+
automation.pendingProposalId = null;
|
|
2472
|
+
automation.steps = [];
|
|
2473
|
+
automations.delete(requestId);
|
|
2474
|
+
streamSeq.delete(requestId);
|
|
2475
|
+
streamLog.delete(requestId);
|
|
2476
|
+
}
|
|
2477
|
+
const view = views.get(requestId);
|
|
2478
|
+
if (view)
|
|
2479
|
+
retireView(view);
|
|
2480
|
+
if (gitWorktreeSubscriptions.delete(requestId)) {
|
|
2481
|
+
streamSeq.delete(requestId);
|
|
2482
|
+
streamLog.delete(requestId);
|
|
2483
|
+
}
|
|
2484
|
+
if (gitBranchChangeSubscriptions.delete(requestId)) {
|
|
2485
|
+
streamSeq.delete(requestId);
|
|
2486
|
+
streamLog.delete(requestId);
|
|
2487
|
+
}
|
|
2488
|
+
if (gitCommitSuggestionSubscriptions.delete(requestId)) {
|
|
2489
|
+
streamSeq.delete(requestId);
|
|
2490
|
+
streamLog.delete(requestId);
|
|
2491
|
+
}
|
|
2492
|
+
if (terminalSessionSubscriptions.delete(requestId)) {
|
|
2493
|
+
streamSeq.delete(requestId);
|
|
2494
|
+
streamLog.delete(requestId);
|
|
2495
|
+
}
|
|
2496
|
+
const ptySession = ptyAttachByRequestId.get(requestId);
|
|
2497
|
+
if (ptySession) {
|
|
2498
|
+
ptySession.attachedRequestIds.delete(requestId);
|
|
2499
|
+
ptyAttachByRequestId.delete(requestId);
|
|
2500
|
+
streamSeq.delete(requestId);
|
|
2501
|
+
}
|
|
2502
|
+
if (qualityChangeSubscriptions.delete(requestId)) {
|
|
2503
|
+
streamSeq.delete(requestId);
|
|
2504
|
+
streamLog.delete(requestId);
|
|
2505
|
+
}
|
|
2506
|
+
const qualityRun = qualityRunsByRequestId.get(requestId);
|
|
2507
|
+
if (qualityRun) {
|
|
2508
|
+
qualityRun.requestIds.delete(requestId);
|
|
2509
|
+
qualityRunsByRequestId.delete(requestId);
|
|
2510
|
+
streamSeq.delete(requestId);
|
|
2511
|
+
streamLog.delete(requestId);
|
|
2512
|
+
}
|
|
2513
|
+
retireToolRegistration(requestId);
|
|
2514
|
+
return null;
|
|
2515
|
+
},
|
|
2516
|
+
'pm.createBoard': (params) => {
|
|
2517
|
+
const prompt = asString(asRecord(params, 'pm.createBoard').prompt, 'prompt', 'pm.createBoard');
|
|
2518
|
+
const boardId = `board-mock-${++seq}`;
|
|
2519
|
+
const board = {
|
|
2520
|
+
boardId,
|
|
2521
|
+
title: prompt.trim().slice(0, 60) || 'Mock board',
|
|
2522
|
+
goal: prompt.trim() || 'Mock board',
|
|
2523
|
+
status: 'active',
|
|
2524
|
+
statuses: [...BOARD_STATUSES],
|
|
2525
|
+
issues: [
|
|
2526
|
+
mockIssue('IS-1', 'Plan', 'P1'),
|
|
2527
|
+
mockIssue('IS-2', 'Implement', 'P1'),
|
|
2528
|
+
mockIssue('IS-3', 'Verify', 'P2'),
|
|
2529
|
+
],
|
|
2530
|
+
subscribers: new Set(),
|
|
2531
|
+
createdAt: new Date().toISOString(),
|
|
2532
|
+
completedAt: null,
|
|
2533
|
+
};
|
|
2534
|
+
boards.set(boardId, board);
|
|
2535
|
+
later(() => progressBoard(board));
|
|
2536
|
+
return { boardId };
|
|
2537
|
+
},
|
|
2538
|
+
'pm.listBoards': () => Array.from(boards.values()).map(boardSummary),
|
|
2539
|
+
'pm.getBoard': (params) => {
|
|
2540
|
+
const boardId = asString(asRecord(params, 'pm.getBoard').boardId, 'boardId', 'pm.getBoard');
|
|
2541
|
+
const board = boards.get(boardId);
|
|
2542
|
+
if (!board)
|
|
2543
|
+
throw new MinionryError('BAD_REQUEST', `pm.getBoard: unknown boardId '${boardId}'`);
|
|
2544
|
+
return boardSnapshot(board);
|
|
2545
|
+
},
|
|
2546
|
+
'pm.createIssue': (params) => {
|
|
2547
|
+
const method = 'pm.createIssue';
|
|
2548
|
+
const { board, raw } = requireBoard(params, method);
|
|
2549
|
+
const title = asString(raw.title, 'title', method);
|
|
2550
|
+
const type = raw.type === undefined ? 'issue' : asString(raw.type, 'type', method);
|
|
2551
|
+
const prefix = MOCK_ID_PREFIXES[type];
|
|
2552
|
+
if (!prefix) {
|
|
2553
|
+
throw new MinionryError('BAD_REQUEST', `${method}: 'type' must be one of ${Object.keys(MOCK_ID_PREFIXES).join(', ')}`);
|
|
2554
|
+
}
|
|
2555
|
+
const issue = mockIssue(nextIssueId(board, prefix), title, 'P2');
|
|
2556
|
+
issue.type = type;
|
|
2557
|
+
if (raw.description !== undefined)
|
|
2558
|
+
issue.description = asString(raw.description, 'description', method);
|
|
2559
|
+
if (raw.acceptanceCriteria !== undefined) {
|
|
2560
|
+
issue.acceptanceCriteria = asStringList(raw.acceptanceCriteria, 'acceptanceCriteria', method).map((text) => ({
|
|
2561
|
+
text,
|
|
2562
|
+
checked: false,
|
|
2563
|
+
}));
|
|
2564
|
+
}
|
|
2565
|
+
applyIssuePatch(board, issue, raw, method);
|
|
2566
|
+
board.issues.push(issue);
|
|
2567
|
+
publishBoard(board);
|
|
2568
|
+
return { ...issue };
|
|
2569
|
+
},
|
|
2570
|
+
'pm.updateIssue': (params) => {
|
|
2571
|
+
const method = 'pm.updateIssue';
|
|
2572
|
+
const { board, raw } = requireBoard(params, method);
|
|
2573
|
+
const issue = requireIssue(board, asString(raw.issueId, 'issueId', method), method);
|
|
2574
|
+
applyIssuePatch(board, issue, raw.fields, method);
|
|
2575
|
+
issue.updated = MOCK_ISSUE_CREATED;
|
|
2576
|
+
publishBoard(board);
|
|
2577
|
+
return { ...issue };
|
|
2578
|
+
},
|
|
2579
|
+
'pm.deleteIssue': (params) => {
|
|
2580
|
+
const method = 'pm.deleteIssue';
|
|
2581
|
+
const { board, raw } = requireBoard(params, method);
|
|
2582
|
+
const issueId = asString(raw.issueId, 'issueId', method);
|
|
2583
|
+
requireIssue(board, issueId, method);
|
|
2584
|
+
board.issues = board.issues.filter((candidate) => candidate.id !== issueId);
|
|
2585
|
+
publishBoard(board);
|
|
2586
|
+
return { deleted: true, issueId };
|
|
2587
|
+
},
|
|
2588
|
+
'pm.updateBoard': (params) => {
|
|
2589
|
+
const method = 'pm.updateBoard';
|
|
2590
|
+
const { board, raw } = requireBoard(params, method);
|
|
2591
|
+
const fields = asRecord(raw.fields, method);
|
|
2592
|
+
if (fields.title !== undefined)
|
|
2593
|
+
board.title = asString(fields.title, 'title', method);
|
|
2594
|
+
if (fields.goal !== undefined)
|
|
2595
|
+
board.goal = asString(fields.goal, 'goal', method);
|
|
2596
|
+
if (fields.status !== undefined) {
|
|
2597
|
+
const status = asString(fields.status, 'status', method);
|
|
2598
|
+
if (status !== 'draft' && status !== 'active' && status !== 'completed') {
|
|
2599
|
+
throw new MinionryError('BAD_REQUEST', `${method}: 'status' must be draft, active, or completed`);
|
|
2600
|
+
}
|
|
2601
|
+
board.status = status;
|
|
2602
|
+
}
|
|
2603
|
+
publishBoard(board);
|
|
2604
|
+
return boardSummary(board);
|
|
2605
|
+
},
|
|
2606
|
+
'pm.execute': (params, requestId) => {
|
|
2607
|
+
const boardId = asString(asRecord(params, 'pm.execute').boardId, 'boardId', 'pm.execute');
|
|
2608
|
+
const board = boards.get(boardId);
|
|
2609
|
+
if (!board)
|
|
2610
|
+
throw new MinionryError('BAD_REQUEST', `pm.execute: unknown boardId '${boardId}'`);
|
|
2611
|
+
let run = execRunsByBoardId.get(boardId);
|
|
2612
|
+
if (!run || run.status === 'done' || run.status === 'stopped') {
|
|
2613
|
+
run = { runId: `boardrun-mock-${++seq}`, boardId, status: 'running', requestIds: new Set(), issueIndex: 0 };
|
|
2614
|
+
execRunsByBoardId.set(boardId, run);
|
|
2615
|
+
execRunsByRunId.set(run.runId, run);
|
|
2616
|
+
later(() => advanceExec(run));
|
|
2617
|
+
}
|
|
2618
|
+
run.requestIds.add(requestId);
|
|
2619
|
+
execRunsByRequestId.set(requestId, run);
|
|
2620
|
+
return { runId: run.runId };
|
|
2621
|
+
},
|
|
2622
|
+
'pm.pause': (params) => {
|
|
2623
|
+
const runId = asString(asRecord(params, 'pm.pause').runId, 'runId', 'pm.pause');
|
|
2624
|
+
const run = execRunsByRunId.get(runId);
|
|
2625
|
+
if (!run)
|
|
2626
|
+
throw new MinionryError('BAD_REQUEST', `pm.pause: unknown runId '${runId}'`);
|
|
2627
|
+
if (run.status === 'running')
|
|
2628
|
+
run.status = 'paused';
|
|
2629
|
+
return null;
|
|
2630
|
+
},
|
|
2631
|
+
'pm.resume': (params) => {
|
|
2632
|
+
const runId = asString(asRecord(params, 'pm.resume').runId, 'runId', 'pm.resume');
|
|
2633
|
+
const run = execRunsByRunId.get(runId);
|
|
2634
|
+
if (!run)
|
|
2635
|
+
throw new MinionryError('BAD_REQUEST', `pm.resume: unknown runId '${runId}'`);
|
|
2636
|
+
if (run.status === 'paused') {
|
|
2637
|
+
run.status = 'running';
|
|
2638
|
+
later(() => advanceExec(run));
|
|
2639
|
+
}
|
|
2640
|
+
return null;
|
|
2641
|
+
},
|
|
2642
|
+
'pm.stop': (params) => {
|
|
2643
|
+
const runId = asString(asRecord(params, 'pm.stop').runId, 'runId', 'pm.stop');
|
|
2644
|
+
const run = execRunsByRunId.get(runId);
|
|
2645
|
+
if (!run)
|
|
2646
|
+
throw new MinionryError('BAD_REQUEST', `pm.stop: unknown runId '${runId}'`);
|
|
2647
|
+
if (run.status === 'running' || run.status === 'paused') {
|
|
2648
|
+
run.status = 'stopped';
|
|
2649
|
+
emitToRun(run, 'complete', { reason: 'Stopped by app' });
|
|
2650
|
+
}
|
|
2651
|
+
return null;
|
|
2652
|
+
},
|
|
2653
|
+
'pm.schedules.list': () => ({ schedules: [...schedules.values()].map((s) => ({ ...s })) }),
|
|
2654
|
+
'pm.schedules.create': (params) => {
|
|
2655
|
+
const record = asRecord(params, 'pm.schedules.create');
|
|
2656
|
+
const source = asRecord(record.source, 'pm.schedules.create');
|
|
2657
|
+
if (source.kind !== 'template-json' || typeof source.content !== 'string' || source.content === '') {
|
|
2658
|
+
throw new MinionryError('BAD_REQUEST', "pm.schedules.create: source must be { kind: 'template-json', content }");
|
|
2659
|
+
}
|
|
2660
|
+
const timing = mockScheduleTiming(record.timing, 'pm.schedules.create');
|
|
2661
|
+
const id = `sched-mock-${++seq}`;
|
|
2662
|
+
const now = new Date();
|
|
2663
|
+
const snapshot = {
|
|
2664
|
+
id,
|
|
2665
|
+
createdAt: now.toISOString(),
|
|
2666
|
+
source: { kind: 'template-json' },
|
|
2667
|
+
timing,
|
|
2668
|
+
enabled: record.enabled === undefined ? true : record.enabled === true,
|
|
2669
|
+
nextFireAt: mockNextFireAt(timing, now),
|
|
2670
|
+
history: [],
|
|
2671
|
+
};
|
|
2672
|
+
if (typeof record.name === 'string')
|
|
2673
|
+
snapshot.name = record.name;
|
|
2674
|
+
if (record.input !== undefined)
|
|
2675
|
+
snapshot.input = record.input;
|
|
2676
|
+
schedules.set(id, snapshot);
|
|
2677
|
+
return { ...snapshot };
|
|
2678
|
+
},
|
|
2679
|
+
'pm.schedules.update': (params) => {
|
|
2680
|
+
const record = asRecord(params, 'pm.schedules.update');
|
|
2681
|
+
const scheduleId = asString(record.scheduleId, 'scheduleId', 'pm.schedules.update');
|
|
2682
|
+
const existing = schedules.get(scheduleId);
|
|
2683
|
+
if (!existing)
|
|
2684
|
+
throw new MinionryError('BAD_REQUEST', `pm.schedules.update: unknown scheduleId '${scheduleId}'`);
|
|
2685
|
+
const patch = record.patch === undefined ? {} : asRecord(record.patch, 'pm.schedules.update');
|
|
2686
|
+
const updated = { ...existing };
|
|
2687
|
+
if ('name' in patch)
|
|
2688
|
+
updated.name = typeof patch.name === 'string' ? patch.name : undefined;
|
|
2689
|
+
if ('enabled' in patch)
|
|
2690
|
+
updated.enabled = patch.enabled === true;
|
|
2691
|
+
if ('input' in patch)
|
|
2692
|
+
updated.input = patch.input === null ? undefined : patch.input;
|
|
2693
|
+
if ('timing' in patch) {
|
|
2694
|
+
updated.timing = mockScheduleTiming(patch.timing, 'pm.schedules.update');
|
|
2695
|
+
updated.nextFireAt = mockNextFireAt(updated.timing, new Date());
|
|
2696
|
+
}
|
|
2697
|
+
schedules.set(scheduleId, updated);
|
|
2698
|
+
return { ...updated };
|
|
2699
|
+
},
|
|
2700
|
+
'pm.schedules.delete': (params) => {
|
|
2701
|
+
const scheduleId = asString(asRecord(params, 'pm.schedules.delete').scheduleId, 'scheduleId', 'pm.schedules.delete');
|
|
2702
|
+
schedules.delete(scheduleId);
|
|
2703
|
+
return null;
|
|
2704
|
+
},
|
|
2705
|
+
'stream.resume': (params) => {
|
|
2706
|
+
const record = asRecord(params, 'stream.resume');
|
|
2707
|
+
const requestId = asString(record.requestId, 'requestId', 'stream.resume');
|
|
2708
|
+
const rawAfterSeq = record.afterSeq;
|
|
2709
|
+
if (rawAfterSeq !== undefined && (typeof rawAfterSeq !== 'number' || !Number.isInteger(rawAfterSeq) || rawAfterSeq < 0)) {
|
|
2710
|
+
throw new MinionryError('BAD_REQUEST', 'stream.resume: afterSeq must be a non-negative integer when present');
|
|
2711
|
+
}
|
|
2712
|
+
const afterSeq = rawAfterSeq ?? 0;
|
|
2713
|
+
const currentSeq = streamSeq.get(requestId) ?? 0;
|
|
2714
|
+
const log = streamLog.get(requestId);
|
|
2715
|
+
if (!log) {
|
|
2716
|
+
if (runsByRequestId.has(requestId) ||
|
|
2717
|
+
inferenceRunsByRequestId.has(requestId) ||
|
|
2718
|
+
subscriptions.has(requestId) ||
|
|
2719
|
+
execRunsByRequestId.has(requestId) ||
|
|
2720
|
+
toolRegistrations.has(requestId)) {
|
|
2721
|
+
return { requestId, fromSeq: afterSeq, events: [], currentSeq: 0 };
|
|
2722
|
+
}
|
|
2723
|
+
throw new MinionryError('STREAM_GAP', `stream.resume: '${requestId}' is not a stream this mock host is holding — reconcile with a snapshot read`);
|
|
2724
|
+
}
|
|
2725
|
+
return {
|
|
2726
|
+
requestId,
|
|
2727
|
+
fromSeq: afterSeq,
|
|
2728
|
+
events: log.filter((entry) => entry.seq > afterSeq),
|
|
2729
|
+
currentSeq,
|
|
2730
|
+
};
|
|
2731
|
+
},
|
|
2732
|
+
'pm.subscribeBoard': (params, requestId) => {
|
|
2733
|
+
const boardId = asString(asRecord(params, 'pm.subscribeBoard').boardId, 'boardId', 'pm.subscribeBoard');
|
|
2734
|
+
const board = boards.get(boardId);
|
|
2735
|
+
if (!board)
|
|
2736
|
+
throw new MinionryError('BAD_REQUEST', `pm.subscribeBoard: unknown boardId '${boardId}'`);
|
|
2737
|
+
board.subscribers.add(requestId);
|
|
2738
|
+
subscriptions.set(requestId, board);
|
|
2739
|
+
emit(requestId, 'boardUpdate', boardSnapshot(board));
|
|
2740
|
+
return null;
|
|
2741
|
+
},
|
|
2742
|
+
'files.read': (params) => {
|
|
2743
|
+
const path = asString(asRecord(params, 'files.read').path, 'path', 'files.read');
|
|
2744
|
+
const content = files.get(path);
|
|
2745
|
+
if (content === undefined)
|
|
2746
|
+
throw new MinionryError('BAD_REQUEST', `files.read: no such file '${path}'`);
|
|
2747
|
+
return content;
|
|
2748
|
+
},
|
|
2749
|
+
'files.readEntry': (params) => {
|
|
2750
|
+
const path = asString(asRecord(params, 'files.readEntry').path, 'path', 'files.readEntry');
|
|
2751
|
+
const content = files.get(path);
|
|
2752
|
+
if (content === undefined)
|
|
2753
|
+
throw new MinionryError('BAD_REQUEST', `files.readEntry: no such file '${path}'`);
|
|
2754
|
+
return { kind: 'text', content };
|
|
2755
|
+
},
|
|
2756
|
+
'files.write': (params) => {
|
|
2757
|
+
const record = asRecord(params, 'files.write');
|
|
2758
|
+
const path = asString(record.path, 'path', 'files.write');
|
|
2759
|
+
files.set(path, asString(record.content, 'content', 'files.write'));
|
|
2760
|
+
timestamps.set(path, new Date().toISOString());
|
|
2761
|
+
notifyMockFileChanged(path, 'changed');
|
|
2762
|
+
return null;
|
|
2763
|
+
},
|
|
2764
|
+
'files.list': (params) => {
|
|
2765
|
+
const path = asString(asRecord(params, 'files.list').path, 'path', 'files.list');
|
|
2766
|
+
const prefix = path === '' || path === '.' ? '' : `${path.replace(/\/+$/, '')}/`;
|
|
2767
|
+
const children = new Map();
|
|
2768
|
+
for (const key of files.keys()) {
|
|
2769
|
+
if (!key.startsWith(prefix))
|
|
2770
|
+
continue;
|
|
2771
|
+
const rest = key.slice(prefix.length);
|
|
2772
|
+
if (!rest)
|
|
2773
|
+
continue;
|
|
2774
|
+
const slash = rest.indexOf('/');
|
|
2775
|
+
if (slash === -1)
|
|
2776
|
+
children.set(rest, children.get(rest) ?? false);
|
|
2777
|
+
else
|
|
2778
|
+
children.set(rest.slice(0, slash), true);
|
|
2779
|
+
}
|
|
2780
|
+
for (const dirPath of dirs) {
|
|
2781
|
+
if (!dirPath.startsWith(prefix))
|
|
2782
|
+
continue;
|
|
2783
|
+
const rest = dirPath.slice(prefix.length);
|
|
2784
|
+
if (!rest)
|
|
2785
|
+
continue;
|
|
2786
|
+
const slash = rest.indexOf('/');
|
|
2787
|
+
children.set(slash === -1 ? rest : rest.slice(0, slash), true);
|
|
2788
|
+
}
|
|
2789
|
+
return [...children]
|
|
2790
|
+
.map(([name, dir]) => {
|
|
2791
|
+
const fullPath = `${prefix}${name}`;
|
|
2792
|
+
const content = dir ? undefined : files.get(fullPath);
|
|
2793
|
+
return {
|
|
2794
|
+
name,
|
|
2795
|
+
dir,
|
|
2796
|
+
kind: dir ? 'directory' : 'file',
|
|
2797
|
+
size: content === undefined ? undefined : new TextEncoder().encode(content).length,
|
|
2798
|
+
modifiedAt: timestamps.get(fullPath),
|
|
2799
|
+
};
|
|
2800
|
+
})
|
|
2801
|
+
.sort((a, b) => (a.name < b.name ? -1 : 1));
|
|
2802
|
+
},
|
|
2803
|
+
'files.delete': (params) => {
|
|
2804
|
+
const path = asString(asRecord(params, 'files.delete').path, 'path', 'files.delete');
|
|
2805
|
+
files.delete(path);
|
|
2806
|
+
dirs.delete(path);
|
|
2807
|
+
timestamps.delete(path);
|
|
2808
|
+
const prefix = `${path}/`;
|
|
2809
|
+
for (const key of [...files.keys()]) {
|
|
2810
|
+
if (!key.startsWith(prefix))
|
|
2811
|
+
continue;
|
|
2812
|
+
files.delete(key);
|
|
2813
|
+
timestamps.delete(key);
|
|
2814
|
+
}
|
|
2815
|
+
for (const dirPath of [...dirs]) {
|
|
2816
|
+
if (dirPath.startsWith(prefix))
|
|
2817
|
+
dirs.delete(dirPath);
|
|
2818
|
+
}
|
|
2819
|
+
return null;
|
|
2820
|
+
},
|
|
2821
|
+
'files.mkdir': (params) => {
|
|
2822
|
+
const path = asString(asRecord(params, 'files.mkdir').path, 'path', 'files.mkdir');
|
|
2823
|
+
dirs.add(path);
|
|
2824
|
+
timestamps.set(path, new Date().toISOString());
|
|
2825
|
+
return null;
|
|
2826
|
+
},
|
|
2827
|
+
'files.rename': (params) => {
|
|
2828
|
+
const record = asRecord(params, 'files.rename');
|
|
2829
|
+
const from = asString(record.path, 'path', 'files.rename');
|
|
2830
|
+
const to = asString(record.newPath, 'newPath', 'files.rename');
|
|
2831
|
+
const fromPrefix = `${from}/`;
|
|
2832
|
+
const exists = files.has(from) ||
|
|
2833
|
+
dirs.has(from) ||
|
|
2834
|
+
[...files.keys(), ...dirs].some((key) => key.startsWith(fromPrefix));
|
|
2835
|
+
if (!exists) {
|
|
2836
|
+
throw new MinionryError('BAD_REQUEST', `files.rename: no such file or directory '${from}'`);
|
|
2837
|
+
}
|
|
2838
|
+
const rewrite = (key) => (key === from ? to : `${to}${key.slice(from.length)}`);
|
|
2839
|
+
const moveTimestamp = (oldKey, newKey) => {
|
|
2840
|
+
const ts = timestamps.get(oldKey);
|
|
2841
|
+
timestamps.delete(oldKey);
|
|
2842
|
+
if (ts !== undefined)
|
|
2843
|
+
timestamps.set(newKey, ts);
|
|
2844
|
+
};
|
|
2845
|
+
for (const key of [...files.keys()]) {
|
|
2846
|
+
if (key !== from && !key.startsWith(fromPrefix))
|
|
2847
|
+
continue;
|
|
2848
|
+
const content = files.get(key);
|
|
2849
|
+
files.delete(key);
|
|
2850
|
+
const newKey = rewrite(key);
|
|
2851
|
+
files.set(newKey, content);
|
|
2852
|
+
moveTimestamp(key, newKey);
|
|
2853
|
+
}
|
|
2854
|
+
for (const key of [...dirs]) {
|
|
2855
|
+
if (key !== from && !key.startsWith(fromPrefix))
|
|
2856
|
+
continue;
|
|
2857
|
+
dirs.delete(key);
|
|
2858
|
+
const newKey = rewrite(key);
|
|
2859
|
+
dirs.add(newKey);
|
|
2860
|
+
moveTimestamp(key, newKey);
|
|
2861
|
+
}
|
|
2862
|
+
return null;
|
|
2863
|
+
},
|
|
2864
|
+
'files.uploadStart': (params) => {
|
|
2865
|
+
const record = asRecord(params, 'files.uploadStart');
|
|
2866
|
+
const path = asString(record.path, 'path', 'files.uploadStart');
|
|
2867
|
+
const totalChunks = record.totalChunks;
|
|
2868
|
+
if (typeof totalChunks !== 'number' || !Number.isInteger(totalChunks) || totalChunks < 1) {
|
|
2869
|
+
throw new MinionryError('BAD_REQUEST', 'files.uploadStart: totalChunks must be a positive integer');
|
|
2870
|
+
}
|
|
2871
|
+
const overwrite = record.overwrite === true;
|
|
2872
|
+
if (dirs.has(path)) {
|
|
2873
|
+
throw new MinionryError('BAD_REQUEST', `files.uploadStart: '${path}' is a directory — cannot upload over it`);
|
|
2874
|
+
}
|
|
2875
|
+
if (files.has(path) && !overwrite) {
|
|
2876
|
+
throw new MinionryError('BAD_REQUEST', `files.uploadStart: '${path}' already exists — pass overwrite to replace it`);
|
|
2877
|
+
}
|
|
2878
|
+
const uploadId = `upload-mock-${++seq}`;
|
|
2879
|
+
uploads.set(uploadId, {
|
|
2880
|
+
path,
|
|
2881
|
+
overwrite,
|
|
2882
|
+
totalChunks,
|
|
2883
|
+
chunks: new Array(totalChunks),
|
|
2884
|
+
nextExpectedChunk: 0,
|
|
2885
|
+
bytesWritten: 0,
|
|
2886
|
+
scope: resolveFilesWriteScope(path),
|
|
2887
|
+
});
|
|
2888
|
+
return { uploadId, chunkSize: 256 * 1024, nextExpectedChunk: 0 };
|
|
2889
|
+
},
|
|
2890
|
+
'files.uploadChunk': (params) => {
|
|
2891
|
+
const record = asRecord(params, 'files.uploadChunk');
|
|
2892
|
+
const uploadId = asString(record.uploadId, 'uploadId', 'files.uploadChunk');
|
|
2893
|
+
const chunkIndex = record.chunkIndex;
|
|
2894
|
+
if (typeof chunkIndex !== 'number' || !Number.isInteger(chunkIndex) || chunkIndex < 0) {
|
|
2895
|
+
throw new MinionryError('BAD_REQUEST', 'files.uploadChunk: chunkIndex must be a non-negative integer');
|
|
2896
|
+
}
|
|
2897
|
+
const content = asString(record.content, 'content', 'files.uploadChunk');
|
|
2898
|
+
const session = uploads.get(uploadId);
|
|
2899
|
+
if (!session)
|
|
2900
|
+
throw new MinionryError('BAD_REQUEST', `files.uploadChunk: unknown uploadId '${uploadId}'`);
|
|
2901
|
+
if (chunkIndex < session.nextExpectedChunk) {
|
|
2902
|
+
return { chunkIndex, bytesWritten: session.bytesWritten };
|
|
2903
|
+
}
|
|
2904
|
+
if (chunkIndex !== session.nextExpectedChunk) {
|
|
2905
|
+
throw new MinionryError('BAD_REQUEST', `files.uploadChunk: expected chunk ${session.nextExpectedChunk}, got ${chunkIndex} — chunks must arrive in order`);
|
|
2906
|
+
}
|
|
2907
|
+
if (chunkIndex >= session.totalChunks) {
|
|
2908
|
+
throw new MinionryError('BAD_REQUEST', `files.uploadChunk: upload '${uploadId}' already has all ${session.totalChunks} chunks`);
|
|
2909
|
+
}
|
|
2910
|
+
session.chunks[chunkIndex] = base64ToBytes(content);
|
|
2911
|
+
session.nextExpectedChunk += 1;
|
|
2912
|
+
session.bytesWritten += session.chunks[chunkIndex].byteLength;
|
|
2913
|
+
if (session.nextExpectedChunk === session.totalChunks) {
|
|
2914
|
+
const joined = new Uint8Array(session.bytesWritten);
|
|
2915
|
+
let offset = 0;
|
|
2916
|
+
for (const chunk of session.chunks) {
|
|
2917
|
+
joined.set(chunk, offset);
|
|
2918
|
+
offset += chunk.byteLength;
|
|
2919
|
+
}
|
|
2920
|
+
files.set(session.path, new TextDecoder().decode(joined));
|
|
2921
|
+
timestamps.set(session.path, new Date().toISOString());
|
|
2922
|
+
uploads.delete(uploadId);
|
|
2923
|
+
}
|
|
2924
|
+
return { chunkIndex, bytesWritten: session.bytesWritten };
|
|
2925
|
+
},
|
|
2926
|
+
'files.uploadStatus': (params) => {
|
|
2927
|
+
const uploadId = asString(asRecord(params, 'files.uploadStatus').uploadId, 'uploadId', 'files.uploadStatus');
|
|
2928
|
+
const session = uploads.get(uploadId);
|
|
2929
|
+
if (!session)
|
|
2930
|
+
throw new MinionryError('BAD_REQUEST', `files.uploadStatus: unknown uploadId '${uploadId}'`);
|
|
2931
|
+
return { nextExpectedChunk: session.nextExpectedChunk };
|
|
2932
|
+
},
|
|
2933
|
+
'files.uploadCancel': (params) => {
|
|
2934
|
+
const uploadId = asString(asRecord(params, 'files.uploadCancel').uploadId, 'uploadId', 'files.uploadCancel');
|
|
2935
|
+
uploads.delete(uploadId);
|
|
2936
|
+
return null;
|
|
2937
|
+
},
|
|
2938
|
+
'files.downloadStart': (params) => {
|
|
2939
|
+
const record = asRecord(params, 'files.downloadStart');
|
|
2940
|
+
const path = asString(record.path, 'path', 'files.downloadStart');
|
|
2941
|
+
if (dirs.has(path) || [...files.keys()].some((key) => key.startsWith(`${path}/`))) {
|
|
2942
|
+
throw new MinionryError('BAD_REQUEST', "files.downloadStart: directory (zip) download isn't modeled in mock mode — test this path against a real host");
|
|
2943
|
+
}
|
|
2944
|
+
const content = files.get(path);
|
|
2945
|
+
if (content === undefined)
|
|
2946
|
+
throw new MinionryError('BAD_REQUEST', `files.downloadStart: no such file '${path}'`);
|
|
2947
|
+
const bytes = new TextEncoder().encode(content);
|
|
2948
|
+
const chunkSize = 256 * 1024;
|
|
2949
|
+
const totalChunks = Math.max(1, Math.ceil(bytes.byteLength / chunkSize));
|
|
2950
|
+
const downloadId = `download-mock-${++seq}`;
|
|
2951
|
+
downloads.set(downloadId, {
|
|
2952
|
+
content: bytesToBase64(bytes),
|
|
2953
|
+
fileSize: bytes.byteLength,
|
|
2954
|
+
totalChunks,
|
|
2955
|
+
mimeType: 'text/plain',
|
|
2956
|
+
isZip: false,
|
|
2957
|
+
scope: resolveFilesReadScope(path),
|
|
2958
|
+
});
|
|
2959
|
+
return {
|
|
2960
|
+
downloadId,
|
|
2961
|
+
fileSize: bytes.byteLength,
|
|
2962
|
+
totalChunks,
|
|
2963
|
+
chunkSize,
|
|
2964
|
+
mimeType: 'text/plain',
|
|
2965
|
+
isZip: false,
|
|
2966
|
+
};
|
|
2967
|
+
},
|
|
2968
|
+
'files.downloadChunk': (params) => {
|
|
2969
|
+
const record = asRecord(params, 'files.downloadChunk');
|
|
2970
|
+
const downloadId = asString(record.downloadId, 'downloadId', 'files.downloadChunk');
|
|
2971
|
+
const chunkIndex = record.chunkIndex;
|
|
2972
|
+
if (typeof chunkIndex !== 'number' || !Number.isInteger(chunkIndex) || chunkIndex < 0) {
|
|
2973
|
+
throw new MinionryError('BAD_REQUEST', 'files.downloadChunk: chunkIndex must be a non-negative integer');
|
|
2974
|
+
}
|
|
2975
|
+
const session = downloads.get(downloadId);
|
|
2976
|
+
if (!session)
|
|
2977
|
+
throw new MinionryError('BAD_REQUEST', `files.downloadChunk: unknown downloadId '${downloadId}'`);
|
|
2978
|
+
if (chunkIndex >= session.totalChunks) {
|
|
2979
|
+
throw new MinionryError('BAD_REQUEST', `files.downloadChunk: download '${downloadId}' has only ${session.totalChunks} chunks`);
|
|
2980
|
+
}
|
|
2981
|
+
const bytes = base64ToBytes(session.content);
|
|
2982
|
+
const chunkSize = 256 * 1024;
|
|
2983
|
+
const from = chunkIndex * chunkSize;
|
|
2984
|
+
const to = Math.min(from + chunkSize, bytes.byteLength);
|
|
2985
|
+
return { content: bytesToBase64(bytes.subarray(from, to)) };
|
|
2986
|
+
},
|
|
2987
|
+
'files.downloadCancel': (params) => {
|
|
2988
|
+
const downloadId = asString(asRecord(params, 'files.downloadCancel').downloadId, 'downloadId', 'files.downloadCancel');
|
|
2989
|
+
downloads.delete(downloadId);
|
|
2990
|
+
return null;
|
|
2991
|
+
},
|
|
2992
|
+
'files.search': (params, requestId) => {
|
|
2993
|
+
const record = asRecord(params, 'files.search');
|
|
2994
|
+
const path = asString(record.path, 'path', 'files.search');
|
|
2995
|
+
const query = asString(record.query, 'query', 'files.search');
|
|
2996
|
+
const options = typeof record.options === 'object' && record.options !== null ? record.options : {};
|
|
2997
|
+
const caseSensitive = options.caseSensitive === true;
|
|
2998
|
+
const regex = options.regex === true;
|
|
2999
|
+
const prefix = path === '' || path === '.' ? '' : `${path.replace(/\/+$/, '')}/`;
|
|
3000
|
+
const pattern = regex ? new RegExp(query, caseSensitive ? '' : 'i') : undefined;
|
|
3001
|
+
const needle = caseSensitive ? query : query.toLowerCase();
|
|
3002
|
+
const matches = [];
|
|
3003
|
+
for (const [filePath, content] of files) {
|
|
3004
|
+
if (prefix && !filePath.startsWith(prefix))
|
|
3005
|
+
continue;
|
|
3006
|
+
const relPath = prefix ? filePath.slice(prefix.length) : filePath;
|
|
3007
|
+
content.split('\n').forEach((line, index) => {
|
|
3008
|
+
const column = pattern ? line.search(pattern) : (caseSensitive ? line : line.toLowerCase()).indexOf(needle);
|
|
3009
|
+
if (column === -1)
|
|
3010
|
+
return;
|
|
3011
|
+
matches.push({ path: relPath, line: index + 1, column: column + 1, lineContent: line, contextBefore: [], contextAfter: [] });
|
|
3012
|
+
});
|
|
3013
|
+
}
|
|
3014
|
+
later(() => {
|
|
3015
|
+
if (matches.length > 0)
|
|
3016
|
+
emit(requestId, 'results', { matches });
|
|
3017
|
+
emit(requestId, 'complete', {
|
|
3018
|
+
totalMatches: matches.length,
|
|
3019
|
+
fileCount: new Set(matches.map((m) => m.path)).size,
|
|
3020
|
+
truncated: false,
|
|
3021
|
+
});
|
|
3022
|
+
});
|
|
3023
|
+
return null;
|
|
3024
|
+
},
|
|
3025
|
+
'files.definition': (params) => {
|
|
3026
|
+
const record = asRecord(params, 'files.definition');
|
|
3027
|
+
const path = asString(record.path, 'path', 'files.definition');
|
|
3028
|
+
const symbolName = asString(record.symbolName, 'symbolName', 'files.definition');
|
|
3029
|
+
const escaped = symbolName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
3030
|
+
const pattern = new RegExp(`\\b(function|const|let|var|class|interface|type|enum)\\s+${escaped}\\b`);
|
|
3031
|
+
const prefix = path === '' || path === '.' ? '' : `${path.replace(/\/+$/, '')}/`;
|
|
3032
|
+
const definitions = [];
|
|
3033
|
+
for (const [filePath, content] of files) {
|
|
3034
|
+
if (prefix && !filePath.startsWith(prefix))
|
|
3035
|
+
continue;
|
|
3036
|
+
const relPath = prefix ? filePath.slice(prefix.length) : filePath;
|
|
3037
|
+
content.split('\n').forEach((line, index) => {
|
|
3038
|
+
const match = pattern.exec(line);
|
|
3039
|
+
if (!match)
|
|
3040
|
+
return;
|
|
3041
|
+
definitions.push({ path: relPath, line: index + 1, column: match.index + 1, lineContent: line, kind: 'function' });
|
|
3042
|
+
});
|
|
3043
|
+
}
|
|
3044
|
+
return definitions;
|
|
3045
|
+
},
|
|
3046
|
+
'files.subscribeChanges': (params, requestId) => {
|
|
3047
|
+
const path = asString(asRecord(params, 'files.subscribeChanges').path, 'path', 'files.subscribeChanges');
|
|
3048
|
+
fileChangeSubscriptions.set(requestId, path);
|
|
3049
|
+
return { subscribed: true };
|
|
3050
|
+
},
|
|
3051
|
+
'storage.get': (params) => {
|
|
3052
|
+
const value = storage.get(asString(asRecord(params, 'storage.get').key, 'key', 'storage.get'));
|
|
3053
|
+
return value === undefined ? undefined : structuredClone(value);
|
|
3054
|
+
},
|
|
3055
|
+
'storage.set': (params) => {
|
|
3056
|
+
const record = asRecord(params, 'storage.set');
|
|
3057
|
+
const key = asString(record.key, 'key', 'storage.set');
|
|
3058
|
+
let value;
|
|
3059
|
+
try {
|
|
3060
|
+
value = structuredClone(record.value);
|
|
3061
|
+
}
|
|
3062
|
+
catch {
|
|
3063
|
+
throw new MinionryError('BAD_REQUEST', 'storage.set: value must be structured-cloneable');
|
|
3064
|
+
}
|
|
3065
|
+
storage.set(key, value);
|
|
3066
|
+
return null;
|
|
3067
|
+
},
|
|
3068
|
+
'storage.delete': (params) => {
|
|
3069
|
+
storage.delete(asString(asRecord(params, 'storage.delete').key, 'key', 'storage.delete'));
|
|
3070
|
+
return null;
|
|
3071
|
+
},
|
|
3072
|
+
'storage.keys': () => [...storage.keys()],
|
|
3073
|
+
'notifications.request': () => {
|
|
3074
|
+
notificationGranted = true;
|
|
3075
|
+
return null;
|
|
3076
|
+
},
|
|
3077
|
+
'notifications.status': () => ({ granted: notificationGranted }),
|
|
3078
|
+
'notifications.revoke': () => {
|
|
3079
|
+
notificationGranted = false;
|
|
3080
|
+
return null;
|
|
3081
|
+
},
|
|
3082
|
+
'theme.get': () => ({ ...theme, tokens: { ...theme.tokens } }),
|
|
3083
|
+
'locale.get': () => ({ ...locale }),
|
|
3084
|
+
'browser.tabs.list': () => browserTabsPayload(),
|
|
3085
|
+
'browser.tabs.subscribe': (_params, requestId) => {
|
|
3086
|
+
browserTabSubscriptions.add(requestId);
|
|
3087
|
+
return { subscribed: true };
|
|
3088
|
+
},
|
|
3089
|
+
'browser.navigate': (params) => {
|
|
3090
|
+
const record = asRecord(params, 'browser.navigate');
|
|
3091
|
+
const url = asString(record.url, 'url', 'browser.navigate');
|
|
3092
|
+
const tabId = typeof record.tabId === 'string' ? record.tabId : currentTabId;
|
|
3093
|
+
if (tabId === null)
|
|
3094
|
+
throw new MinionryError('BAD_REQUEST', 'browser.navigate: no tab is open to navigate');
|
|
3095
|
+
const tab = requireBrowserTab(tabId, 'browser.navigate');
|
|
3096
|
+
tab.canGoBack = true;
|
|
3097
|
+
tab.url = url;
|
|
3098
|
+
tab.title = originOf(url) ?? url;
|
|
3099
|
+
browserEpoch += 1;
|
|
3100
|
+
publishBrowserTabs();
|
|
3101
|
+
return null;
|
|
3102
|
+
},
|
|
3103
|
+
'browser.open': (params) => {
|
|
3104
|
+
const record = asRecord(params, 'browser.open');
|
|
3105
|
+
const url = asString(record.url, 'url', 'browser.open');
|
|
3106
|
+
const origin = originOf(url);
|
|
3107
|
+
if (record.pinnedApp === true && origin !== undefined) {
|
|
3108
|
+
const existing = browserTabs.find((tab) => originOf(tab.url) === origin);
|
|
3109
|
+
if (existing) {
|
|
3110
|
+
currentTabId = existing.id;
|
|
3111
|
+
publishBrowserTabs();
|
|
3112
|
+
return { tabId: existing.id };
|
|
3113
|
+
}
|
|
3114
|
+
}
|
|
3115
|
+
const tabId = `tab-mock-${++seq}`;
|
|
3116
|
+
browserTabs.push({
|
|
3117
|
+
id: tabId,
|
|
3118
|
+
url,
|
|
3119
|
+
title: origin ?? url,
|
|
3120
|
+
isLoading: false,
|
|
3121
|
+
canGoBack: false,
|
|
3122
|
+
canGoForward: false,
|
|
3123
|
+
});
|
|
3124
|
+
currentTabId = tabId;
|
|
3125
|
+
browserEpoch += 1;
|
|
3126
|
+
publishBrowserTabs();
|
|
3127
|
+
return { tabId };
|
|
3128
|
+
},
|
|
3129
|
+
'browser.close': (params) => {
|
|
3130
|
+
const tabId = asString(asRecord(params, 'browser.close').tabId, 'tabId', 'browser.close');
|
|
3131
|
+
const index = browserTabs.findIndex((tab) => tab.id === tabId);
|
|
3132
|
+
if (index === -1)
|
|
3133
|
+
throw new MinionryError('BAD_REQUEST', `browser.close: unknown tabId '${tabId}'`);
|
|
3134
|
+
browserTabs.splice(index, 1);
|
|
3135
|
+
if (currentTabId === tabId)
|
|
3136
|
+
currentTabId = browserTabs[Math.min(index, browserTabs.length - 1)]?.id ?? null;
|
|
3137
|
+
publishBrowserTabs();
|
|
3138
|
+
return null;
|
|
3139
|
+
},
|
|
3140
|
+
'browser.select': (params) => {
|
|
3141
|
+
const tabId = asString(asRecord(params, 'browser.select').tabId, 'tabId', 'browser.select');
|
|
3142
|
+
currentTabId = requireBrowserTab(tabId, 'browser.select').id;
|
|
3143
|
+
publishBrowserTabs();
|
|
3144
|
+
return null;
|
|
3145
|
+
},
|
|
3146
|
+
'browser.back': (params) => moveHistory(params, 'browser.back'),
|
|
3147
|
+
'browser.forward': (params) => moveHistory(params, 'browser.forward'),
|
|
3148
|
+
'browser.reload': (params) => {
|
|
3149
|
+
const tab = historyTarget(params, 'browser.reload');
|
|
3150
|
+
tab.isLoading = true;
|
|
3151
|
+
browserEpoch += 1;
|
|
3152
|
+
publishBrowserTabs();
|
|
3153
|
+
later(() => {
|
|
3154
|
+
tab.isLoading = false;
|
|
3155
|
+
publishBrowserTabs();
|
|
3156
|
+
});
|
|
3157
|
+
return null;
|
|
3158
|
+
},
|
|
3159
|
+
'browser.stop': (params) => {
|
|
3160
|
+
const tab = historyTarget(params, 'browser.stop');
|
|
3161
|
+
tab.isLoading = false;
|
|
3162
|
+
publishBrowserTabs();
|
|
3163
|
+
return null;
|
|
3164
|
+
},
|
|
3165
|
+
'browser.preflight': () => ({ ok: true }),
|
|
3166
|
+
'browser.resumeDriver': (params) => {
|
|
3167
|
+
const tabId = asString(asRecord(params, 'browser.resumeDriver').tabId, 'tabId', 'browser.resumeDriver');
|
|
3168
|
+
requireBrowserTab(tabId, 'browser.resumeDriver');
|
|
3169
|
+
return null;
|
|
3170
|
+
},
|
|
3171
|
+
'browser.setDeviceProfile': (params) => {
|
|
3172
|
+
const record = asRecord(params, 'browser.setDeviceProfile');
|
|
3173
|
+
const tabId = asString(record.tabId, 'tabId', 'browser.setDeviceProfile');
|
|
3174
|
+
if (record.formFactor !== 'desktop' && record.formFactor !== 'mobile') {
|
|
3175
|
+
throw new MinionryError('BAD_REQUEST', "browser.setDeviceProfile: formFactor must be 'desktop' or 'mobile'");
|
|
3176
|
+
}
|
|
3177
|
+
const tab = requireBrowserTab(tabId, 'browser.setDeviceProfile');
|
|
3178
|
+
tab.deviceProfile =
|
|
3179
|
+
record.formFactor === 'mobile'
|
|
3180
|
+
? { formFactor: 'mobile', width: 390, height: 844, label: 'Mobile' }
|
|
3181
|
+
: { formFactor: 'desktop', width: 1280, height: 720, label: 'Desktop' };
|
|
3182
|
+
publishBrowserTabs();
|
|
3183
|
+
return null;
|
|
3184
|
+
},
|
|
3185
|
+
'browser.view.attach': (params, requestId) => {
|
|
3186
|
+
const record = params === undefined || params === null ? {} : asRecord(params, 'browser.view.attach');
|
|
3187
|
+
const tabId = record.tabId === undefined || record.tabId === null
|
|
3188
|
+
? currentTabId
|
|
3189
|
+
: asString(record.tabId, 'tabId', 'browser.view.attach');
|
|
3190
|
+
if (tabId === null) {
|
|
3191
|
+
throw new MinionryError('BAD_REQUEST', 'browser.view.attach: no tab is open to stream');
|
|
3192
|
+
}
|
|
3193
|
+
const tab = requireBrowserTab(tabId, 'browser.view.attach');
|
|
3194
|
+
const view = {
|
|
3195
|
+
sessionId: `view-mock-${++seq}`,
|
|
3196
|
+
requestId,
|
|
3197
|
+
tabId: tab.id,
|
|
3198
|
+
detached: false,
|
|
3199
|
+
};
|
|
3200
|
+
views.set(requestId, view);
|
|
3201
|
+
later(() => {
|
|
3202
|
+
if (view.detached)
|
|
3203
|
+
return;
|
|
3204
|
+
emit(requestId, 'connectionState', { state: 'negotiating' });
|
|
3205
|
+
publishViewNavigation(view);
|
|
3206
|
+
emit(requestId, 'driverState', { tabId: view.tabId, state: 'idle', since: 0 });
|
|
3207
|
+
});
|
|
3208
|
+
return { sessionId: view.sessionId, tabId: view.tabId };
|
|
3209
|
+
},
|
|
3210
|
+
'browser.view.answer': (params) => {
|
|
3211
|
+
requireView(params, 'browser.view.answer');
|
|
3212
|
+
asString(asRecord(params, 'browser.view.answer').sdp, 'sdp', 'browser.view.answer');
|
|
3213
|
+
return null;
|
|
3214
|
+
},
|
|
3215
|
+
'browser.view.ice': (params) => {
|
|
3216
|
+
requireView(params, 'browser.view.ice');
|
|
3217
|
+
const candidate = asRecord(params, 'browser.view.ice').candidate;
|
|
3218
|
+
if (candidate !== null && (typeof candidate !== 'object' || candidate === undefined)) {
|
|
3219
|
+
throw new MinionryError('BAD_REQUEST', 'browser.view.ice: candidate must be an object or null');
|
|
3220
|
+
}
|
|
3221
|
+
return null;
|
|
3222
|
+
},
|
|
3223
|
+
'browser.view.state': (params) => {
|
|
3224
|
+
const view = requireView(params, 'browser.view.state');
|
|
3225
|
+
const state = asString(asRecord(params, 'browser.view.state').state, 'state', 'browser.view.state');
|
|
3226
|
+
if (!['negotiating', 'connected', 'failed', 'closed'].includes(state)) {
|
|
3227
|
+
throw new MinionryError('BAD_REQUEST', `browser.view.state: unknown state '${state}'`);
|
|
3228
|
+
}
|
|
3229
|
+
emit(view.requestId, 'connectionState', { state });
|
|
3230
|
+
if (state === 'closed')
|
|
3231
|
+
retireView(view);
|
|
3232
|
+
return null;
|
|
3233
|
+
},
|
|
3234
|
+
'browser.view.input': (params) => {
|
|
3235
|
+
const view = requireView(params, 'browser.view.input');
|
|
3236
|
+
const raw = asRecord(params, 'browser.view.input').input;
|
|
3237
|
+
const batch = Array.isArray(raw) ? raw : [raw];
|
|
3238
|
+
if (batch.length === 0 || batch.length > MOCK_INPUT_BATCH_MAX) {
|
|
3239
|
+
throw new MinionryError('BAD_REQUEST', `browser.view.input: input must be 1–${MOCK_INPUT_BATCH_MAX} envelopes`);
|
|
3240
|
+
}
|
|
3241
|
+
const bad = batch.find((entry) => typeof entry !== 'object' || entry === null || typeof entry.kind !== 'string');
|
|
3242
|
+
if (bad !== undefined) {
|
|
3243
|
+
emit(view.requestId, 'inputRejected', { kind: 'unknown', reason: 'malformed', message: 'invalid input envelope' });
|
|
3244
|
+
return { delivered: false, reason: 'malformed' };
|
|
3245
|
+
}
|
|
3246
|
+
return { delivered: true };
|
|
3247
|
+
},
|
|
3248
|
+
'browser.view.bounds': (params) => {
|
|
3249
|
+
requireView(params, 'browser.view.bounds');
|
|
3250
|
+
const bounds = asRecord(params, 'browser.view.bounds').bounds;
|
|
3251
|
+
const record = bounds === undefined || bounds === null ? null : asRecord(bounds, 'browser.view.bounds');
|
|
3252
|
+
if (!record || typeof record.width !== 'number' || typeof record.height !== 'number') {
|
|
3253
|
+
throw new MinionryError('BAD_REQUEST', 'browser.view.bounds: bounds requires numeric { width, height }');
|
|
3254
|
+
}
|
|
3255
|
+
return null;
|
|
3256
|
+
},
|
|
3257
|
+
'browser.snapshot': (params) => {
|
|
3258
|
+
const tabId = asString(asRecord(params, 'browser.snapshot').tabId, 'tabId', 'browser.snapshot');
|
|
3259
|
+
const tab = requireBrowserTab(tabId, 'browser.snapshot');
|
|
3260
|
+
return {
|
|
3261
|
+
url: tab.url,
|
|
3262
|
+
title: tab.title,
|
|
3263
|
+
epoch: browserEpoch,
|
|
3264
|
+
root: mockSnapshotRoot(tab),
|
|
3265
|
+
};
|
|
3266
|
+
},
|
|
3267
|
+
'browser.screenshot': (params) => {
|
|
3268
|
+
const record = asRecord(params, 'browser.screenshot');
|
|
3269
|
+
const tabId = asString(record.tabId, 'tabId', 'browser.screenshot');
|
|
3270
|
+
requireBrowserTab(tabId, 'browser.screenshot');
|
|
3271
|
+
return { format: record.format === 'jpeg' ? 'jpeg' : 'png', data: MOCK_SCREENSHOT_PNG };
|
|
3272
|
+
},
|
|
3273
|
+
'browser.evaluate': (params) => {
|
|
3274
|
+
const record = asRecord(params, 'browser.evaluate');
|
|
3275
|
+
const tabId = asString(record.tabId, 'tabId', 'browser.evaluate');
|
|
3276
|
+
asString(record.expr, 'expr', 'browser.evaluate');
|
|
3277
|
+
requireBrowserTab(tabId, 'browser.evaluate');
|
|
3278
|
+
return { value: null, type: 'object' };
|
|
3279
|
+
},
|
|
3280
|
+
'browser.automate': (params, requestId) => {
|
|
3281
|
+
const record = asRecord(params, 'browser.automate');
|
|
3282
|
+
const prompt = asString(record.prompt, 'prompt', 'browser.automate');
|
|
3283
|
+
if (prompt.trim() === '') {
|
|
3284
|
+
throw new MinionryError('BAD_REQUEST', 'browser.automate: prompt must be a non-empty string');
|
|
3285
|
+
}
|
|
3286
|
+
let engine;
|
|
3287
|
+
if (record.engine !== undefined && record.engine !== null) {
|
|
3288
|
+
engine = asString(record.engine, 'engine', 'browser.automate');
|
|
3289
|
+
if (!MOCK_ENGINES.includes(engine)) {
|
|
3290
|
+
throw new MinionryError('BAD_REQUEST', `browser.automate: unknown engine '${engine}' — expected one of ${MOCK_ENGINES.join(', ')}`);
|
|
3291
|
+
}
|
|
3292
|
+
}
|
|
3293
|
+
let model;
|
|
3294
|
+
if (record.model !== undefined && record.model !== null) {
|
|
3295
|
+
model = asString(record.model, 'model', 'browser.automate');
|
|
3296
|
+
if (model === '') {
|
|
3297
|
+
throw new MinionryError('BAD_REQUEST', 'browser.automate: model must be a non-empty string when present');
|
|
3298
|
+
}
|
|
3299
|
+
}
|
|
3300
|
+
const resolvedEngine = engine ?? (model ? mockEngineForModel(model) : MOCK_DEFAULT_ENGINE);
|
|
3301
|
+
if (model && mockEngineForModel(model) !== resolvedEngine) {
|
|
3302
|
+
throw new MinionryError('BAD_REQUEST', `browser.automate: model '${model}' is not a ${resolvedEngine} model — it belongs to ${mockEngineForModel(model)}`);
|
|
3303
|
+
}
|
|
3304
|
+
const resolvedModel = model ?? (mockEngineForModel(MOCK_DEFAULT_MODEL) === resolvedEngine ? MOCK_DEFAULT_MODEL : undefined);
|
|
3305
|
+
if (record.tabId !== undefined && record.tabId !== null) {
|
|
3306
|
+
requireBrowserTab(asString(record.tabId, 'tabId', 'browser.automate'), 'browser.automate');
|
|
3307
|
+
}
|
|
3308
|
+
const taskId = `automation-mock-${++seq}`;
|
|
3309
|
+
const sessionId = `agent-mock-${++seq}`;
|
|
3310
|
+
const sessionName = prompt.split('\n').find((line) => line.trim() !== '')?.trim() ?? prompt;
|
|
3311
|
+
const target = { engine: resolvedEngine, ...(resolvedModel ? { model: resolvedModel } : {}), sessionId };
|
|
3312
|
+
const run = {
|
|
3313
|
+
taskId,
|
|
3314
|
+
requestId,
|
|
3315
|
+
status: 'running',
|
|
3316
|
+
pendingProposalId: null,
|
|
3317
|
+
steps: [
|
|
3318
|
+
{ event: 'step', payload: { phase: 'plan', detail: `1. Read the page\n2. ${prompt}` } },
|
|
3319
|
+
{ event: 'step', payload: { phase: 'observe', detail: 'Read the page (mock)' } },
|
|
3320
|
+
],
|
|
3321
|
+
target,
|
|
3322
|
+
};
|
|
3323
|
+
automations.set(requestId, run);
|
|
3324
|
+
later(() => emit(run.requestId, 'session', { sessionId, name: sessionName }));
|
|
3325
|
+
const toProposal = () => {
|
|
3326
|
+
if (run.status !== 'running')
|
|
3327
|
+
return;
|
|
3328
|
+
if (run.steps.length === 0) {
|
|
3329
|
+
proposeAutomationAction(run);
|
|
3330
|
+
return;
|
|
3331
|
+
}
|
|
3332
|
+
const step = run.steps.shift();
|
|
3333
|
+
emit(run.requestId, step.event, stampTarget(run, step));
|
|
3334
|
+
later(toProposal);
|
|
3335
|
+
};
|
|
3336
|
+
later(toProposal);
|
|
3337
|
+
return { taskId };
|
|
3338
|
+
},
|
|
3339
|
+
'browser.automate.approve': (params) => {
|
|
3340
|
+
const record = asRecord(params, 'browser.automate.approve');
|
|
3341
|
+
const proposalId = asString(record.proposalId, 'proposalId', 'browser.automate.approve');
|
|
3342
|
+
const verdict = record.verdict === undefined ? 'approve' : record.verdict;
|
|
3343
|
+
if (verdict !== 'approve' && verdict !== 'edit' && verdict !== 'deny') {
|
|
3344
|
+
throw new MinionryError('BAD_REQUEST', "browser.automate.approve: verdict must be 'approve', 'edit' or 'deny' when present");
|
|
3345
|
+
}
|
|
3346
|
+
const run = [...automations.values()].find((candidate) => candidate.pendingProposalId === proposalId);
|
|
3347
|
+
if (!run) {
|
|
3348
|
+
throw new MinionryError('BAD_REQUEST', `browser.automate.approve: no proposal '${proposalId}' is awaiting a verdict`);
|
|
3349
|
+
}
|
|
3350
|
+
run.pendingProposalId = null;
|
|
3351
|
+
run.status = 'running';
|
|
3352
|
+
if (verdict === 'deny') {
|
|
3353
|
+
run.steps = [{ event: 'done', payload: { status: 'cancelled' } }];
|
|
3354
|
+
}
|
|
3355
|
+
else {
|
|
3356
|
+
const edited = verdict === 'edit' && typeof record.editedText === 'string' ? record.editedText : null;
|
|
3357
|
+
run.steps = [
|
|
3358
|
+
{
|
|
3359
|
+
event: 'step',
|
|
3360
|
+
payload: {
|
|
3361
|
+
phase: 'act',
|
|
3362
|
+
detail: edited === null ? 'Clicked "Continue" (mock)' : `Typed "${edited}" (mock)`,
|
|
3363
|
+
proposalId,
|
|
3364
|
+
},
|
|
3365
|
+
},
|
|
3366
|
+
{ event: 'step', payload: { phase: 'extract', detail: 'Read the resulting page (mock)' } },
|
|
3367
|
+
{ event: 'done', payload: { status: 'success', result: `Completed: ${run.taskId} (mock)` } },
|
|
3368
|
+
];
|
|
3369
|
+
}
|
|
3370
|
+
later(() => advanceAutomation(run));
|
|
3371
|
+
return null;
|
|
3372
|
+
},
|
|
3373
|
+
'composer.autocomplete': (params) => {
|
|
3374
|
+
const query = asString(asRecord(params, 'composer.autocomplete').query, 'query', 'composer.autocomplete').toLowerCase();
|
|
3375
|
+
return [...files.keys()]
|
|
3376
|
+
.filter((path) => query === '' || path.toLowerCase().includes(query))
|
|
3377
|
+
.slice(0, 15)
|
|
3378
|
+
.map((path) => ({
|
|
3379
|
+
value: path,
|
|
3380
|
+
label: path,
|
|
3381
|
+
isDirectory: false,
|
|
3382
|
+
isRecent: false,
|
|
3383
|
+
fileType: path.split('.').pop() ?? '',
|
|
3384
|
+
matchedIndices: [],
|
|
3385
|
+
}));
|
|
3386
|
+
},
|
|
3387
|
+
'composer.skills': () => MOCK_SKILLS.map((s) => ({ ...s })),
|
|
3388
|
+
'composer.snippets': (params) => {
|
|
3389
|
+
const record = asRecord(params, 'composer.snippets');
|
|
3390
|
+
const query = typeof record.query === 'string' ? record.query.trim().toLowerCase() : '';
|
|
3391
|
+
const all = MOCK_SNIPPETS.map((s) => ({ ...s, aliases: [...s.aliases] }));
|
|
3392
|
+
if (query === '')
|
|
3393
|
+
return all;
|
|
3394
|
+
return all.filter((s) => s.name.includes(query) || s.description.toLowerCase().includes(query));
|
|
3395
|
+
},
|
|
3396
|
+
'composer.settings': () => ({
|
|
3397
|
+
engines: [...MOCK_ENGINES],
|
|
3398
|
+
defaults: { engine: MOCK_DEFAULT_ENGINE, model: MOCK_DEFAULT_MODEL, effortLevel: 'auto', fastMode: false },
|
|
3399
|
+
models: MOCK_MODEL_CATALOG.map((entry) => ({ ...entry })),
|
|
3400
|
+
modelsStatus: { fetchedAt: new Date().toISOString(), stale: false, refreshing: false },
|
|
3401
|
+
effortLevels: Object.fromEntries(Object.entries(MOCK_EFFORT_LEVELS).map(([id, levels]) => [id, [...levels]])),
|
|
3402
|
+
inferenceEngines: [...MOCK_INFERENCE_ENGINES],
|
|
3403
|
+
}),
|
|
3404
|
+
'terminal.sessions.list': () => [...ptySessions.values()].map(mockPtySessionSummary),
|
|
3405
|
+
'terminal.sessions.subscribe': (_params, requestId) => {
|
|
3406
|
+
terminalSessionSubscriptions.add(requestId);
|
|
3407
|
+
return { subscribed: true };
|
|
3408
|
+
},
|
|
3409
|
+
'terminal.open': (params) => {
|
|
3410
|
+
const record = asRecord(params, 'terminal.open');
|
|
3411
|
+
const cols = record.cols;
|
|
3412
|
+
const rows = record.rows;
|
|
3413
|
+
if (typeof cols !== 'number' || !Number.isInteger(cols) || cols < 1) {
|
|
3414
|
+
throw new MinionryError('BAD_REQUEST', 'terminal.open: cols must be a positive integer');
|
|
3415
|
+
}
|
|
3416
|
+
if (typeof rows !== 'number' || !Number.isInteger(rows) || rows < 1) {
|
|
3417
|
+
throw new MinionryError('BAD_REQUEST', 'terminal.open: rows must be a positive integer');
|
|
3418
|
+
}
|
|
3419
|
+
const cwd = record.cwd === undefined ? '/workspace' : asString(record.cwd, 'cwd', 'terminal.open');
|
|
3420
|
+
const id = `pty-mock-${++seq}`;
|
|
3421
|
+
const now = new Date().toISOString();
|
|
3422
|
+
const session = {
|
|
3423
|
+
id,
|
|
3424
|
+
shell: 'mock-shell',
|
|
3425
|
+
cwd,
|
|
3426
|
+
cols,
|
|
3427
|
+
rows,
|
|
3428
|
+
platform: 'linux',
|
|
3429
|
+
createdAt: now,
|
|
3430
|
+
lastActivityAt: now,
|
|
3431
|
+
scrollback: `Mock shell ready (${id})\n$ `,
|
|
3432
|
+
closed: false,
|
|
3433
|
+
attachedRequestIds: new Set(),
|
|
3434
|
+
};
|
|
3435
|
+
ptySessions.set(id, session);
|
|
3436
|
+
notifyTerminalSessionChanged({ kind: 'opened', session: mockPtySessionSummary(session) });
|
|
3437
|
+
return { id, shell: session.shell, cwd, cols, rows, platform: session.platform };
|
|
3438
|
+
},
|
|
3439
|
+
'terminal.attach': (params, requestId) => {
|
|
3440
|
+
const id = asString(asRecord(params, 'terminal.attach').id, 'id', 'terminal.attach');
|
|
3441
|
+
const session = ptySessions.get(id);
|
|
3442
|
+
if (!session || session.closed)
|
|
3443
|
+
throw new MinionryError('BAD_REQUEST', `terminal.attach: unknown session '${id}'`);
|
|
3444
|
+
session.attachedRequestIds.add(requestId);
|
|
3445
|
+
ptyAttachByRequestId.set(requestId, session);
|
|
3446
|
+
later(() => {
|
|
3447
|
+
if (!session.attachedRequestIds.has(requestId))
|
|
3448
|
+
return;
|
|
3449
|
+
emitEphemeral(requestId, 'scrollback', { data: session.scrollback });
|
|
3450
|
+
});
|
|
3451
|
+
return { id };
|
|
3452
|
+
},
|
|
3453
|
+
'terminal.write': (params) => {
|
|
3454
|
+
const record = asRecord(params, 'terminal.write');
|
|
3455
|
+
const id = asString(record.id, 'id', 'terminal.write');
|
|
3456
|
+
const data = asString(record.data, 'data', 'terminal.write');
|
|
3457
|
+
const session = ptySessions.get(id);
|
|
3458
|
+
if (!session || session.closed)
|
|
3459
|
+
throw new MinionryError('BAD_REQUEST', `terminal.write: unknown session '${id}'`);
|
|
3460
|
+
session.lastActivityAt = new Date().toISOString();
|
|
3461
|
+
appendPtyScrollback(session, data);
|
|
3462
|
+
emitToPty(session, 'output', { data });
|
|
3463
|
+
const command = data.replace(/[\r\n]+$/, '');
|
|
3464
|
+
if (command === 'burst')
|
|
3465
|
+
triggerPtyBurst(session);
|
|
3466
|
+
else if (command === 'exit')
|
|
3467
|
+
closePtySession(session, 0);
|
|
3468
|
+
return null;
|
|
3469
|
+
},
|
|
3470
|
+
'terminal.resize': (params) => {
|
|
3471
|
+
const record = asRecord(params, 'terminal.resize');
|
|
3472
|
+
const id = asString(record.id, 'id', 'terminal.resize');
|
|
3473
|
+
const session = ptySessions.get(id);
|
|
3474
|
+
if (!session || session.closed)
|
|
3475
|
+
throw new MinionryError('BAD_REQUEST', `terminal.resize: unknown session '${id}'`);
|
|
3476
|
+
const cols = record.cols;
|
|
3477
|
+
const rows = record.rows;
|
|
3478
|
+
if (typeof cols !== 'number' || !Number.isInteger(cols) || cols < 1) {
|
|
3479
|
+
throw new MinionryError('BAD_REQUEST', 'terminal.resize: cols must be a positive integer');
|
|
3480
|
+
}
|
|
3481
|
+
if (typeof rows !== 'number' || !Number.isInteger(rows) || rows < 1) {
|
|
3482
|
+
throw new MinionryError('BAD_REQUEST', 'terminal.resize: rows must be a positive integer');
|
|
3483
|
+
}
|
|
3484
|
+
session.cols = cols;
|
|
3485
|
+
session.rows = rows;
|
|
3486
|
+
return null;
|
|
3487
|
+
},
|
|
3488
|
+
'terminal.close': (params) => {
|
|
3489
|
+
const id = asString(asRecord(params, 'terminal.close').id, 'id', 'terminal.close');
|
|
3490
|
+
const session = ptySessions.get(id);
|
|
3491
|
+
if (session)
|
|
3492
|
+
closePtySession(session, 0);
|
|
3493
|
+
return null;
|
|
3494
|
+
},
|
|
3495
|
+
'git.status': (params) => {
|
|
3496
|
+
const { worktree } = requireWorktree(params, 'git.status');
|
|
3497
|
+
const branch = gitBranches.get(worktree.branch);
|
|
3498
|
+
return {
|
|
3499
|
+
branch: worktree.branch,
|
|
3500
|
+
isDirty: worktree.staged.length + worktree.unstaged.length + worktree.untracked.length > 0,
|
|
3501
|
+
staged: worktree.staged.map((file) => ({ ...file })),
|
|
3502
|
+
unstaged: worktree.unstaged.map((file) => ({ ...file })),
|
|
3503
|
+
untracked: worktree.untracked.map((file) => ({ ...file })),
|
|
3504
|
+
ahead: 0,
|
|
3505
|
+
behind: 0,
|
|
3506
|
+
hasUpstream: branch?.upstream !== undefined,
|
|
3507
|
+
};
|
|
3508
|
+
},
|
|
3509
|
+
'git.log': (params) => {
|
|
3510
|
+
const { record } = requireWorktree(params, 'git.log');
|
|
3511
|
+
const limitRaw = record.limit;
|
|
3512
|
+
if (limitRaw !== undefined && (typeof limitRaw !== 'number' || !Number.isInteger(limitRaw) || limitRaw < 1)) {
|
|
3513
|
+
throw new MinionryError('BAD_REQUEST', 'git.log: limit must be a positive integer when present');
|
|
3514
|
+
}
|
|
3515
|
+
const skipRaw = record.skip;
|
|
3516
|
+
if (skipRaw !== undefined && (typeof skipRaw !== 'number' || !Number.isInteger(skipRaw) || skipRaw < 0)) {
|
|
3517
|
+
throw new MinionryError('BAD_REQUEST', 'git.log: skip must be a non-negative integer when present');
|
|
3518
|
+
}
|
|
3519
|
+
const limit = limitRaw ?? 20;
|
|
3520
|
+
const skip = skipRaw ?? 0;
|
|
3521
|
+
const search = record.search === undefined || record.search === null ? undefined : asString(record.search, 'search', 'git.log');
|
|
3522
|
+
const filtered = search ? gitCommits.filter((commit) => commit.subject.toLowerCase().includes(search.toLowerCase())) : gitCommits;
|
|
3523
|
+
const page = filtered.slice(skip, skip + limit);
|
|
3524
|
+
const result = {
|
|
3525
|
+
entries: page.map(({ hash, shortHash, subject, author, date }) => ({ hash, shortHash, subject, author, date })),
|
|
3526
|
+
hasMore: skip + limit < filtered.length,
|
|
3527
|
+
skip,
|
|
3528
|
+
};
|
|
3529
|
+
if (search !== undefined)
|
|
3530
|
+
result.search = search;
|
|
3531
|
+
return result;
|
|
3532
|
+
},
|
|
3533
|
+
'git.diff': (params) => {
|
|
3534
|
+
const { worktree, record } = requireWorktree(params, 'git.diff');
|
|
3535
|
+
const path = asString(record.path, 'path', 'git.diff');
|
|
3536
|
+
const staged = record.staged === true;
|
|
3537
|
+
const pending = [...worktree.staged, ...worktree.unstaged, ...worktree.untracked];
|
|
3538
|
+
if (!pending.some((file) => file.path === path)) {
|
|
3539
|
+
throw new MinionryError('BAD_REQUEST', `git.diff: '${path}' has no pending changes in this worktree`);
|
|
3540
|
+
}
|
|
3541
|
+
return {
|
|
3542
|
+
path,
|
|
3543
|
+
original: `// ${path} (mock) — before\n`,
|
|
3544
|
+
modified: `// ${path} (mock) — after\n`,
|
|
3545
|
+
staged,
|
|
3546
|
+
};
|
|
3547
|
+
},
|
|
3548
|
+
'git.show': (params) => {
|
|
3549
|
+
const hash = asString(asRecord(params, 'git.show').hash, 'hash', 'git.show');
|
|
3550
|
+
const commit = findGitCommit(hash);
|
|
3551
|
+
if (!commit)
|
|
3552
|
+
throw new MinionryError('BAD_REQUEST', `git.show: unknown commit '${hash}'`);
|
|
3553
|
+
return {
|
|
3554
|
+
hash: commit.hash,
|
|
3555
|
+
shortHash: commit.shortHash,
|
|
3556
|
+
subject: commit.subject,
|
|
3557
|
+
body: commit.body,
|
|
3558
|
+
author: commit.author,
|
|
3559
|
+
date: commit.date,
|
|
3560
|
+
files: commit.files.map((file) => ({ ...file })),
|
|
3561
|
+
};
|
|
3562
|
+
},
|
|
3563
|
+
'git.commitDiff': (params) => {
|
|
3564
|
+
const record = asRecord(params, 'git.commitDiff');
|
|
3565
|
+
const hash = asString(record.hash, 'hash', 'git.commitDiff');
|
|
3566
|
+
const path = asString(record.path, 'path', 'git.commitDiff');
|
|
3567
|
+
const commit = findGitCommit(hash);
|
|
3568
|
+
if (!commit)
|
|
3569
|
+
throw new MinionryError('BAD_REQUEST', `git.commitDiff: unknown commit '${hash}'`);
|
|
3570
|
+
if (!commit.files.some((file) => file.path === path)) {
|
|
3571
|
+
throw new MinionryError('BAD_REQUEST', `git.commitDiff: '${path}' was not changed in commit '${hash}'`);
|
|
3572
|
+
}
|
|
3573
|
+
return {
|
|
3574
|
+
hash: commit.hash,
|
|
3575
|
+
path,
|
|
3576
|
+
original: `// ${path} (mock) — before ${commit.shortHash}\n`,
|
|
3577
|
+
modified: `// ${path} (mock) — at ${commit.shortHash}\n`,
|
|
3578
|
+
};
|
|
3579
|
+
},
|
|
3580
|
+
'git.branches.list': (params) => {
|
|
3581
|
+
const { worktree } = requireWorktree(params, 'git.branches.list');
|
|
3582
|
+
const branches = [...gitBranches.values()].map((branch) => {
|
|
3583
|
+
const headCommit = findGitCommit(branch.head);
|
|
3584
|
+
return {
|
|
3585
|
+
name: branch.name,
|
|
3586
|
+
shortHash: headCommit?.shortHash ?? gitShortHash(branch.head),
|
|
3587
|
+
isRemote: branch.isRemote,
|
|
3588
|
+
isCurrent: branch.name === worktree.branch,
|
|
3589
|
+
upstream: branch.upstream,
|
|
3590
|
+
lastCommitDate: headCommit?.date,
|
|
3591
|
+
ahead: 0,
|
|
3592
|
+
behind: 0,
|
|
3593
|
+
};
|
|
3594
|
+
});
|
|
3595
|
+
return { branches, current: worktree.branch };
|
|
3596
|
+
},
|
|
3597
|
+
'git.branches.create': (params) => {
|
|
3598
|
+
const { worktree, record } = requireWorktree(params, 'git.branches.create');
|
|
3599
|
+
const name = asString(record.name, 'name', 'git.branches.create');
|
|
3600
|
+
if (gitBranches.has(name))
|
|
3601
|
+
throw new MinionryError('BAD_REQUEST', `git.branches.create: branch '${name}' already exists`);
|
|
3602
|
+
const startPoint = record.startPoint === undefined ? undefined : asString(record.startPoint, 'startPoint', 'git.branches.create');
|
|
3603
|
+
let head;
|
|
3604
|
+
if (startPoint === undefined) {
|
|
3605
|
+
head = gitBranches.get(worktree.branch)?.head ?? gitCommits[0]?.hash ?? mintGitHash();
|
|
3606
|
+
}
|
|
3607
|
+
else {
|
|
3608
|
+
head = gitBranches.get(startPoint)?.head ?? findGitCommit(startPoint)?.hash ?? '';
|
|
3609
|
+
if (!head)
|
|
3610
|
+
throw new MinionryError('BAD_REQUEST', `git.branches.create: unknown startPoint '${startPoint}'`);
|
|
3611
|
+
}
|
|
3612
|
+
gitBranches.set(name, { name, head, isRemote: false });
|
|
3613
|
+
if (record.checkout === true) {
|
|
3614
|
+
worktree.branch = name;
|
|
3615
|
+
publishGitBranchChanged(worktree);
|
|
3616
|
+
}
|
|
3617
|
+
return { name, hash: head };
|
|
3618
|
+
},
|
|
3619
|
+
'git.branches.delete': (params) => {
|
|
3620
|
+
const { record } = requireWorktree(params, 'git.branches.delete');
|
|
3621
|
+
const name = asString(record.name, 'name', 'git.branches.delete');
|
|
3622
|
+
if (!gitBranches.has(name))
|
|
3623
|
+
throw new MinionryError('BAD_REQUEST', `git.branches.delete: unknown branch '${name}'`);
|
|
3624
|
+
const force = record.force === true;
|
|
3625
|
+
const checkedOut = [...gitWorktrees.values()].some((candidate) => candidate.branch === name);
|
|
3626
|
+
if (checkedOut && !force) {
|
|
3627
|
+
throw new MinionryError('BAD_REQUEST', `git.branches.delete: '${name}' is checked out in a worktree — pass force to delete anyway`);
|
|
3628
|
+
}
|
|
3629
|
+
gitBranches.delete(name);
|
|
3630
|
+
return null;
|
|
3631
|
+
},
|
|
3632
|
+
'git.tags.list': () => ({
|
|
3633
|
+
tags: [...gitTags.values()].map((tag) => ({ name: tag.name, shortHash: gitShortHash(tag.hash), date: tag.date, message: tag.message })),
|
|
3634
|
+
}),
|
|
3635
|
+
'git.tags.create': (params) => {
|
|
3636
|
+
const { worktree, record } = requireWorktree(params, 'git.tags.create');
|
|
3637
|
+
const name = asString(record.name, 'name', 'git.tags.create');
|
|
3638
|
+
if (gitTags.has(name))
|
|
3639
|
+
throw new MinionryError('BAD_REQUEST', `git.tags.create: tag '${name}' already exists`);
|
|
3640
|
+
const commitArg = record.commit === undefined ? undefined : asString(record.commit, 'commit', 'git.tags.create');
|
|
3641
|
+
const hash = commitArg === undefined
|
|
3642
|
+
? gitBranches.get(worktree.branch)?.head
|
|
3643
|
+
: (findGitCommit(commitArg)?.hash ?? gitBranches.get(commitArg)?.head);
|
|
3644
|
+
if (!hash)
|
|
3645
|
+
throw new MinionryError('BAD_REQUEST', `git.tags.create: unknown commit '${commitArg}'`);
|
|
3646
|
+
const message = record.message === undefined ? '' : asString(record.message, 'message', 'git.tags.create');
|
|
3647
|
+
gitTags.set(name, { name, hash, message, date: new Date().toISOString() });
|
|
3648
|
+
return { name, hash };
|
|
3649
|
+
},
|
|
3650
|
+
'git.tags.push': (params) => {
|
|
3651
|
+
const record = asRecord(params, 'git.tags.push');
|
|
3652
|
+
const name = record.name === undefined || record.name === null ? undefined : asString(record.name, 'name', 'git.tags.push');
|
|
3653
|
+
const all = record.all === true;
|
|
3654
|
+
if (!all && name === undefined)
|
|
3655
|
+
throw new MinionryError('BAD_REQUEST', 'git.tags.push: pass a name or all: true');
|
|
3656
|
+
if (name !== undefined && !gitTags.has(name))
|
|
3657
|
+
throw new MinionryError('BAD_REQUEST', `git.tags.push: unknown tag '${name}'`);
|
|
3658
|
+
return { name: name ?? '*', output: `Pushed ${all ? 'all tags' : `tag '${name}'`} to origin (mock)` };
|
|
3659
|
+
},
|
|
3660
|
+
'git.remote.info': (params) => {
|
|
3661
|
+
const { worktree } = requireWorktree(params, 'git.remote.info');
|
|
3662
|
+
return {
|
|
3663
|
+
hasRemote: true,
|
|
3664
|
+
remoteUrl: MOCK_GIT_REMOTE_URL,
|
|
3665
|
+
provider: 'github',
|
|
3666
|
+
defaultBranch: MOCK_GIT_DEFAULT_BRANCH,
|
|
3667
|
+
currentBranch: worktree.branch,
|
|
3668
|
+
hasGhCli: true,
|
|
3669
|
+
ghCliAuthenticated: true,
|
|
3670
|
+
ghCliBinary: 'gh',
|
|
3671
|
+
remoteBranches: [...gitBranches.keys()],
|
|
3672
|
+
preferredBaseBranch: MOCK_GIT_DEFAULT_BRANCH,
|
|
3673
|
+
};
|
|
3674
|
+
},
|
|
3675
|
+
'git.repos.discover': () => ({
|
|
3676
|
+
repos: [{ path: MOCK_GIT_MAIN_WORKTREE_PATH, name: 'mock-repo', branch: gitWorktrees.get(MOCK_GIT_MAIN_WORKTREE_PATH)?.branch }],
|
|
3677
|
+
rootIsGitRepo: true,
|
|
3678
|
+
}),
|
|
3679
|
+
'git.worktrees.list': () => gitWorktreesSnapshot(),
|
|
3680
|
+
'git.worktrees.create': (params) => {
|
|
3681
|
+
const record = asRecord(params, 'git.worktrees.create');
|
|
3682
|
+
const branchName = asString(record.branchName, 'branchName', 'git.worktrees.create');
|
|
3683
|
+
const baseBranch = record.baseBranch === undefined ? undefined : asString(record.baseBranch, 'baseBranch', 'git.worktrees.create');
|
|
3684
|
+
const path = record.path === undefined ? `/workspace-worktrees/${branchName}` : asString(record.path, 'path', 'git.worktrees.create');
|
|
3685
|
+
if (gitWorktrees.has(path))
|
|
3686
|
+
throw new MinionryError('BAD_REQUEST', `git.worktrees.create: '${path}' already has a worktree`);
|
|
3687
|
+
let branch = gitBranches.get(branchName);
|
|
3688
|
+
if (!branch) {
|
|
3689
|
+
if (baseBranch !== undefined && !gitBranches.has(baseBranch)) {
|
|
3690
|
+
throw new MinionryError('BAD_REQUEST', `git.worktrees.create: unknown baseBranch '${baseBranch}'`);
|
|
3691
|
+
}
|
|
3692
|
+
const base = gitBranches.get(baseBranch ?? MOCK_GIT_DEFAULT_BRANCH);
|
|
3693
|
+
branch = { name: branchName, head: base?.head ?? gitCommits[0]?.hash ?? mintGitHash(), isRemote: false };
|
|
3694
|
+
gitBranches.set(branchName, branch);
|
|
3695
|
+
}
|
|
3696
|
+
const worktree = { path, branch: branchName, isMain: false, staged: [], unstaged: [], untracked: [] };
|
|
3697
|
+
gitWorktrees.set(path, worktree);
|
|
3698
|
+
if (record.assign === true)
|
|
3699
|
+
gitActiveWorktreePath = path;
|
|
3700
|
+
publishGitWorktrees();
|
|
3701
|
+
return { path, branch: branchName, head: branch.head };
|
|
3702
|
+
},
|
|
3703
|
+
'git.worktrees.remove': (params) => {
|
|
3704
|
+
const record = asRecord(params, 'git.worktrees.remove');
|
|
3705
|
+
const path = asString(record.path, 'path', 'git.worktrees.remove');
|
|
3706
|
+
const worktree = gitWorktrees.get(path);
|
|
3707
|
+
if (!worktree)
|
|
3708
|
+
throw new MinionryError('BAD_REQUEST', `git.worktrees.remove: unknown worktree '${path}'`);
|
|
3709
|
+
if (worktree.isMain)
|
|
3710
|
+
throw new MinionryError('BAD_REQUEST', 'git.worktrees.remove: the main worktree cannot be removed');
|
|
3711
|
+
gitWorktrees.delete(path);
|
|
3712
|
+
if (gitActiveWorktreePath === path)
|
|
3713
|
+
gitActiveWorktreePath = null;
|
|
3714
|
+
if (record.deleteBranch === true)
|
|
3715
|
+
gitBranches.delete(worktree.branch);
|
|
3716
|
+
publishGitWorktrees();
|
|
3717
|
+
return null;
|
|
3718
|
+
},
|
|
3719
|
+
'git.worktrees.switchActive': (params) => {
|
|
3720
|
+
const record = asRecord(params, 'git.worktrees.switchActive');
|
|
3721
|
+
const path = record.path === undefined || record.path === null ? null : asString(record.path, 'path', 'git.worktrees.switchActive');
|
|
3722
|
+
if (path !== null && !gitWorktrees.has(path)) {
|
|
3723
|
+
throw new MinionryError('BAD_REQUEST', `git.worktrees.switchActive: unknown worktree '${path}'`);
|
|
3724
|
+
}
|
|
3725
|
+
gitActiveWorktreePath = path;
|
|
3726
|
+
const active = gitWorktrees.get(path ?? MOCK_GIT_MAIN_WORKTREE_PATH);
|
|
3727
|
+
publishGitWorktrees();
|
|
3728
|
+
if (active)
|
|
3729
|
+
publishGitBranchChanged(active);
|
|
3730
|
+
return { path: gitActiveWorktreePath, branch: active?.branch ?? null };
|
|
3731
|
+
},
|
|
3732
|
+
'git.worktrees.subscribe': (_params, requestId) => {
|
|
3733
|
+
gitWorktreeSubscriptions.add(requestId);
|
|
3734
|
+
return { subscribed: true };
|
|
3735
|
+
},
|
|
3736
|
+
'git.merge.preview': (params) => {
|
|
3737
|
+
const record = asRecord(params, 'git.merge.preview');
|
|
3738
|
+
const sourceBranch = asString(record.sourceBranch, 'sourceBranch', 'git.merge.preview');
|
|
3739
|
+
const targetBranch = asString(record.targetBranch, 'targetBranch', 'git.merge.preview');
|
|
3740
|
+
if (!gitBranches.has(sourceBranch))
|
|
3741
|
+
throw new MinionryError('BAD_REQUEST', `git.merge.preview: unknown sourceBranch '${sourceBranch}'`);
|
|
3742
|
+
if (!gitBranches.has(targetBranch))
|
|
3743
|
+
throw new MinionryError('BAD_REQUEST', `git.merge.preview: unknown targetBranch '${targetBranch}'`);
|
|
3744
|
+
return { clean: true, conflicts: [], stat: '0 files changed (mock)', commits: [], ahead: 0 };
|
|
3745
|
+
},
|
|
3746
|
+
'git.merge.run': (params) => {
|
|
3747
|
+
const record = asRecord(params, 'git.merge.run');
|
|
3748
|
+
const sourceBranch = asString(record.sourceBranch, 'sourceBranch', 'git.merge.run');
|
|
3749
|
+
const targetBranch = asString(record.targetBranch, 'targetBranch', 'git.merge.run');
|
|
3750
|
+
const strategy = record.strategy;
|
|
3751
|
+
if (strategy !== 'merge' && strategy !== 'squash' && strategy !== 'rebase') {
|
|
3752
|
+
throw new MinionryError('BAD_REQUEST', "git.merge.run: strategy must be 'merge', 'squash', or 'rebase'");
|
|
3753
|
+
}
|
|
3754
|
+
if (!gitBranches.has(sourceBranch))
|
|
3755
|
+
throw new MinionryError('BAD_REQUEST', `git.merge.run: unknown sourceBranch '${sourceBranch}'`);
|
|
3756
|
+
const target = gitBranches.get(targetBranch);
|
|
3757
|
+
if (!target)
|
|
3758
|
+
throw new MinionryError('BAD_REQUEST', `git.merge.run: unknown targetBranch '${targetBranch}'`);
|
|
3759
|
+
const message = record.commitMessage === undefined
|
|
3760
|
+
? `Merge '${sourceBranch}' into '${targetBranch}' (mock)`
|
|
3761
|
+
: asString(record.commitMessage, 'commitMessage', 'git.merge.run');
|
|
3762
|
+
const hash = mintGitHash();
|
|
3763
|
+
gitCommits.unshift({
|
|
3764
|
+
hash,
|
|
3765
|
+
shortHash: gitShortHash(hash),
|
|
3766
|
+
subject: message,
|
|
3767
|
+
body: '',
|
|
3768
|
+
author: user?.name ?? 'Mock User',
|
|
3769
|
+
date: new Date().toISOString(),
|
|
3770
|
+
files: [],
|
|
3771
|
+
});
|
|
3772
|
+
target.head = hash;
|
|
3773
|
+
return { success: true, mergeCommit: hash };
|
|
3774
|
+
},
|
|
3775
|
+
'git.merge.abort': () => ({ aborted: true }),
|
|
3776
|
+
'git.merge.complete': () => ({
|
|
3777
|
+
success: true,
|
|
3778
|
+
mergeCommit: gitCommits[0]?.hash ?? mintGitHash(),
|
|
3779
|
+
}),
|
|
3780
|
+
'git.merge.stashPop': (params) => {
|
|
3781
|
+
const record = asRecord(params, 'git.merge.stashPop');
|
|
3782
|
+
asString(record.stashSha, 'stashSha', 'git.merge.stashPop');
|
|
3783
|
+
return { success: true };
|
|
3784
|
+
},
|
|
3785
|
+
'git.merge.discardBlockers': (params) => {
|
|
3786
|
+
const record = asRecord(params, 'git.merge.discardBlockers');
|
|
3787
|
+
const paths = asStringList(record.paths, 'paths', 'git.merge.discardBlockers');
|
|
3788
|
+
return { success: true, paths };
|
|
3789
|
+
},
|
|
3790
|
+
'git.pr.generateDescription': (params) => {
|
|
3791
|
+
const { worktree } = requireWorktree(params, 'git.pr.generateDescription');
|
|
3792
|
+
return {
|
|
3793
|
+
title: `${worktree.branch} (mock)`,
|
|
3794
|
+
body: `Generated description for '${worktree.branch}' (mock).`,
|
|
3795
|
+
};
|
|
3796
|
+
},
|
|
3797
|
+
'git.pr.create': (params) => {
|
|
3798
|
+
const record = asRecord(params, 'git.pr.create');
|
|
3799
|
+
asString(record.title, 'title', 'git.pr.create');
|
|
3800
|
+
const prNumber = ++seq;
|
|
3801
|
+
return {
|
|
3802
|
+
url: `${MOCK_GIT_REMOTE_URL.replace(/\.git$/, '')}/pull/${prNumber}`,
|
|
3803
|
+
method: 'gh',
|
|
3804
|
+
prNumber,
|
|
3805
|
+
};
|
|
3806
|
+
},
|
|
3807
|
+
'git.stage': (params) => {
|
|
3808
|
+
const { worktree, record } = requireWorktree(params, 'git.stage');
|
|
3809
|
+
const stageAll = record.stageAll === true;
|
|
3810
|
+
const paths = stageAll
|
|
3811
|
+
? [...worktree.unstaged, ...worktree.untracked].map((file) => file.path)
|
|
3812
|
+
: asStringList(record.paths, 'paths', 'git.stage');
|
|
3813
|
+
for (const path of paths)
|
|
3814
|
+
gitStageFile(worktree, path, 'git.stage');
|
|
3815
|
+
return { paths };
|
|
3816
|
+
},
|
|
3817
|
+
'git.unstage': (params) => {
|
|
3818
|
+
const { worktree, record } = requireWorktree(params, 'git.unstage');
|
|
3819
|
+
const paths = asStringList(record.paths, 'paths', 'git.unstage');
|
|
3820
|
+
for (const path of paths)
|
|
3821
|
+
gitUnstageFile(worktree, path, 'git.unstage');
|
|
3822
|
+
return { paths };
|
|
3823
|
+
},
|
|
3824
|
+
'git.commit': (params) => {
|
|
3825
|
+
const { worktree, record } = requireWorktree(params, 'git.commit');
|
|
3826
|
+
const message = asString(record.message, 'message', 'git.commit');
|
|
3827
|
+
if (worktree.staged.length === 0)
|
|
3828
|
+
throw new MinionryError('BAD_REQUEST', 'git.commit: nothing staged to commit');
|
|
3829
|
+
const hash = mintGitHash();
|
|
3830
|
+
const files = worktree.staged.map((file) => ({
|
|
3831
|
+
path: file.path,
|
|
3832
|
+
status: file.status,
|
|
3833
|
+
additions: file.status === 'D' ? 0 : 1,
|
|
3834
|
+
deletions: file.status === 'D' ? 1 : 0,
|
|
3835
|
+
}));
|
|
3836
|
+
gitCommits.unshift({
|
|
3837
|
+
hash,
|
|
3838
|
+
shortHash: gitShortHash(hash),
|
|
3839
|
+
subject: message,
|
|
3840
|
+
body: '',
|
|
3841
|
+
author: user?.name ?? 'Mock User',
|
|
3842
|
+
date: new Date().toISOString(),
|
|
3843
|
+
files,
|
|
3844
|
+
});
|
|
3845
|
+
const branch = gitBranches.get(worktree.branch);
|
|
3846
|
+
if (branch)
|
|
3847
|
+
branch.head = hash;
|
|
3848
|
+
worktree.staged = [];
|
|
3849
|
+
return { hash, message };
|
|
3850
|
+
},
|
|
3851
|
+
'git.generateCommitMessage': (params) => {
|
|
3852
|
+
const { worktree } = requireWorktree(params, 'git.generateCommitMessage');
|
|
3853
|
+
const total = worktree.staged.length;
|
|
3854
|
+
if (total === 0)
|
|
3855
|
+
throw new MinionryError('BAD_REQUEST', 'git.generateCommitMessage: nothing staged to summarize');
|
|
3856
|
+
return { message: `Update ${total} file${total === 1 ? '' : 's'} (mock)` };
|
|
3857
|
+
},
|
|
3858
|
+
'git.checkout': (params) => {
|
|
3859
|
+
const { worktree, record } = requireWorktree(params, 'git.checkout');
|
|
3860
|
+
const branchName = asString(record.branch, 'branch', 'git.checkout');
|
|
3861
|
+
const previous = worktree.branch;
|
|
3862
|
+
if (record.create === true) {
|
|
3863
|
+
if (gitBranches.has(branchName))
|
|
3864
|
+
throw new MinionryError('BAD_REQUEST', `git.checkout: branch '${branchName}' already exists`);
|
|
3865
|
+
const startPoint = record.startPoint === undefined ? undefined : asString(record.startPoint, 'startPoint', 'git.checkout');
|
|
3866
|
+
const base = gitBranches.get(startPoint ?? previous);
|
|
3867
|
+
if (startPoint !== undefined && !base)
|
|
3868
|
+
throw new MinionryError('BAD_REQUEST', `git.checkout: unknown startPoint '${startPoint}'`);
|
|
3869
|
+
gitBranches.set(branchName, { name: branchName, head: base?.head ?? gitCommits[0]?.hash ?? mintGitHash(), isRemote: false });
|
|
3870
|
+
}
|
|
3871
|
+
else if (!gitBranches.has(branchName)) {
|
|
3872
|
+
throw new MinionryError('BAD_REQUEST', `git.checkout: unknown branch '${branchName}'`);
|
|
3873
|
+
}
|
|
3874
|
+
worktree.branch = branchName;
|
|
3875
|
+
publishGitBranchChanged(worktree);
|
|
3876
|
+
return { branch: branchName, previous };
|
|
3877
|
+
},
|
|
3878
|
+
'git.push': (params) => {
|
|
3879
|
+
const { worktree, record } = requireWorktree(params, 'git.push');
|
|
3880
|
+
const setUpstream = record.setUpstream === true;
|
|
3881
|
+
const branch = gitBranches.get(worktree.branch);
|
|
3882
|
+
if (branch && setUpstream)
|
|
3883
|
+
branch.upstream = `origin/${worktree.branch}`;
|
|
3884
|
+
return { output: `Pushed '${worktree.branch}' to origin (mock)`, upstream: branch?.upstream };
|
|
3885
|
+
},
|
|
3886
|
+
'git.pull': (params) => {
|
|
3887
|
+
requireWorktree(params, 'git.pull');
|
|
3888
|
+
return { output: 'Already up to date (mock)' };
|
|
3889
|
+
},
|
|
3890
|
+
'git.subscribeBranchChanged': (_params, requestId) => {
|
|
3891
|
+
gitBranchChangeSubscriptions.add(requestId);
|
|
3892
|
+
return { subscribed: true };
|
|
3893
|
+
},
|
|
3894
|
+
'git.subscribeCommitSuggestion': (_params, requestId) => {
|
|
3895
|
+
gitCommitSuggestionSubscriptions.add(requestId);
|
|
3896
|
+
return { subscribed: true };
|
|
3897
|
+
},
|
|
3898
|
+
'tools.register': (params, requestId) => {
|
|
3899
|
+
const record = asRecord(params, 'tools.register');
|
|
3900
|
+
const rawTools = record.tools;
|
|
3901
|
+
if (!Array.isArray(rawTools) || rawTools.length === 0) {
|
|
3902
|
+
throw new MinionryError('BAD_REQUEST', 'tools.register: tools must be a non-empty array');
|
|
3903
|
+
}
|
|
3904
|
+
const rawProfile = record.profile;
|
|
3905
|
+
if (rawProfile !== undefined && rawProfile !== 'browser') {
|
|
3906
|
+
throw new MinionryError('BAD_REQUEST', `tools.register: unknown profile '${String(rawProfile)}' (fail closed)`);
|
|
3907
|
+
}
|
|
3908
|
+
const tools = new Map();
|
|
3909
|
+
for (const raw of rawTools) {
|
|
3910
|
+
const entry = asRecord(raw, 'tools.register');
|
|
3911
|
+
const name = asString(entry.name, 'tools[].name', 'tools.register');
|
|
3912
|
+
if (name.length === 0 || name.includes('__')) {
|
|
3913
|
+
throw new MinionryError('BAD_REQUEST', `tools.register: invalid tool name '${name}' — must be non-empty and contain no '__'`);
|
|
3914
|
+
}
|
|
3915
|
+
if (tools.has(name))
|
|
3916
|
+
throw new MinionryError('BAD_REQUEST', `tools.register: duplicate tool name '${name}'`);
|
|
3917
|
+
for (const live of toolRegistrations.values()) {
|
|
3918
|
+
if (live.tools.has(name)) {
|
|
3919
|
+
throw new MinionryError('BAD_REQUEST', `tools.register: '${name}' is already served by registration '${live.requestId}'`);
|
|
3920
|
+
}
|
|
3921
|
+
}
|
|
3922
|
+
const description = asString(entry.description, 'tools[].description', 'tools.register');
|
|
3923
|
+
if (typeof entry.inputSchema !== 'object' || entry.inputSchema === null || Array.isArray(entry.inputSchema)) {
|
|
3924
|
+
throw new MinionryError('BAD_REQUEST', `tools.register: '${name}' needs an inputSchema object`);
|
|
3925
|
+
}
|
|
3926
|
+
const timeoutMs = entry.timeoutMs;
|
|
3927
|
+
if (timeoutMs !== undefined && (typeof timeoutMs !== 'number' || !Number.isInteger(timeoutMs) || timeoutMs <= 0)) {
|
|
3928
|
+
throw new MinionryError('BAD_REQUEST', `tools.register: '${name}' timeoutMs must be a positive integer`);
|
|
3929
|
+
}
|
|
3930
|
+
tools.set(name, {
|
|
3931
|
+
name,
|
|
3932
|
+
description,
|
|
3933
|
+
inputSchema: entry.inputSchema,
|
|
3934
|
+
timeoutMs: timeoutMs,
|
|
3935
|
+
});
|
|
3936
|
+
}
|
|
3937
|
+
toolRegistrations.set(requestId, { requestId, tools, profile: rawProfile });
|
|
3938
|
+
config.tools?.onRegister?.(toolsController);
|
|
3939
|
+
return { registered: true };
|
|
3940
|
+
},
|
|
3941
|
+
'tools.result': (params) => {
|
|
3942
|
+
const record = asRecord(params, 'tools.result');
|
|
3943
|
+
const callId = asString(record.callId, 'callId', 'tools.result');
|
|
3944
|
+
let failure;
|
|
3945
|
+
if (record.ok === false) {
|
|
3946
|
+
const error = asRecord(record.error, 'tools.result');
|
|
3947
|
+
failure = new MinionryError(asString(error.code, 'error.code', 'tools.result'), asString(error.message, 'error.message', 'tools.result'));
|
|
3948
|
+
}
|
|
3949
|
+
else if (record.ok !== true) {
|
|
3950
|
+
throw new MinionryError('BAD_REQUEST', "tools.result: 'ok' must be a boolean");
|
|
3951
|
+
}
|
|
3952
|
+
const call = takeToolCall(callId);
|
|
3953
|
+
if (!call)
|
|
3954
|
+
return null;
|
|
3955
|
+
if (failure)
|
|
3956
|
+
call.fail(failure);
|
|
3957
|
+
else
|
|
3958
|
+
call.settle(record.result);
|
|
3959
|
+
return null;
|
|
3960
|
+
},
|
|
3961
|
+
'quality.state': () => {
|
|
3962
|
+
const drained = qualityPendingResults;
|
|
3963
|
+
qualityPendingResults = [];
|
|
3964
|
+
return {
|
|
3965
|
+
directories: qualityDirectories.map((directory) => ({ ...directory })),
|
|
3966
|
+
reports: Object.fromEntries([...qualityReports].map(([path, report]) => [path, stampQualityReport(report)])),
|
|
3967
|
+
history: qualityHistory.map((entry) => ({ ...entry })),
|
|
3968
|
+
activeOperations: [...qualityRunsByPath.values()].map((run) => ({
|
|
3969
|
+
kind: run.kind,
|
|
3970
|
+
path: run.path,
|
|
3971
|
+
startedAt: run.startedAt,
|
|
3972
|
+
...(run.kind === 'reviewing' && run.sessionId !== undefined ? { sessionId: run.sessionId } : {}),
|
|
3973
|
+
})),
|
|
3974
|
+
pendingResults: drained.map((pending) => pending.kind === 'scanResults'
|
|
3975
|
+
? { ...pending, report: stampQualityReport(pending.report) }
|
|
3976
|
+
: { ...pending, findings: stampQualityFindings(pending.path, pending.findings) }),
|
|
3977
|
+
schedules: qualitySchedules.map(cloneQualitySchedule),
|
|
3978
|
+
};
|
|
3979
|
+
},
|
|
3980
|
+
'quality.detectTools': (params) => qualityToolsFor(qualityPathOf(asRecord(params, 'quality.detectTools').path, 'quality.detectTools')),
|
|
3981
|
+
'quality.result': (params, requestId) => {
|
|
3982
|
+
const path = qualityPathOf(asRecord(params, 'quality.result').path, 'quality.result');
|
|
3983
|
+
const run = qualityRunsByPath.get(path);
|
|
3984
|
+
if (run && run.status === 'running') {
|
|
3985
|
+
run.waiters.push(requestId);
|
|
3986
|
+
return DEFER;
|
|
3987
|
+
}
|
|
3988
|
+
const result = qualityResults.get(path);
|
|
3989
|
+
if (!result)
|
|
3990
|
+
throw new MinionryError('BAD_REQUEST', `quality.result: no run for path '${path}'`);
|
|
3991
|
+
return stampQualityResult(result);
|
|
3992
|
+
},
|
|
3993
|
+
'quality.settings': () => ({ ...qualitySettings }),
|
|
3994
|
+
'quality.subscribeChanges': (_params, requestId) => {
|
|
3995
|
+
qualityChangeSubscriptions.add(requestId);
|
|
3996
|
+
return { subscribed: true };
|
|
3997
|
+
},
|
|
3998
|
+
'quality.run': (params, requestId) => {
|
|
3999
|
+
const path = qualityPathOf(asRecord(params, 'quality.run').path, 'quality.run');
|
|
4000
|
+
const existing = qualityRunsByPath.get(path);
|
|
4001
|
+
if (existing) {
|
|
4002
|
+
existing.requestIds.add(requestId);
|
|
4003
|
+
qualityRunsByRequestId.set(requestId, existing);
|
|
4004
|
+
return { path, attached: true };
|
|
4005
|
+
}
|
|
4006
|
+
const sessionId = path === MOCK_QUALITY_PARTIAL_PATH ? undefined : `quality-review-mock-${path}`;
|
|
4007
|
+
const run = {
|
|
4008
|
+
path,
|
|
4009
|
+
status: 'running',
|
|
4010
|
+
kind: 'scanning',
|
|
4011
|
+
startedAt: new Date().toISOString(),
|
|
4012
|
+
requestIds: new Set([requestId]),
|
|
4013
|
+
steps: qualityRunSteps(Date.now(), sessionId),
|
|
4014
|
+
waiters: [],
|
|
4015
|
+
...(sessionId === undefined ? {} : { sessionId }),
|
|
4016
|
+
};
|
|
4017
|
+
qualityRunsByPath.set(path, run);
|
|
4018
|
+
qualityRunsByRequestId.set(requestId, run);
|
|
4019
|
+
later(() => advanceQualityRun(run));
|
|
4020
|
+
return { path, attached: false };
|
|
4021
|
+
},
|
|
4022
|
+
'quality.cancel': (params) => {
|
|
4023
|
+
const path = qualityPathOf(asRecord(params, 'quality.cancel').path, 'quality.cancel');
|
|
4024
|
+
const run = qualityRunsByPath.get(path);
|
|
4025
|
+
if (run)
|
|
4026
|
+
cancelQualityRun(run);
|
|
4027
|
+
return null;
|
|
4028
|
+
},
|
|
4029
|
+
'quality.installTools': (params) => {
|
|
4030
|
+
const record = asRecord(params, 'quality.installTools');
|
|
4031
|
+
const path = qualityPathOf(record.path, 'quality.installTools');
|
|
4032
|
+
if (qualityRunsByPath.has(path)) {
|
|
4033
|
+
throw new MinionryError('OPERATION_IN_PROGRESS', `quality.installTools: an operation already holds '${path}'`);
|
|
4034
|
+
}
|
|
4035
|
+
const roster = qualityToolsFor(path);
|
|
4036
|
+
const requested = record.tools === undefined
|
|
4037
|
+
? roster.tools.filter((tool) => !tool.installed).map((tool) => tool.name)
|
|
4038
|
+
: asStringList(record.tools, 'tools', 'quality.installTools');
|
|
4039
|
+
const installed = qualityToolState(path);
|
|
4040
|
+
for (const name of requested) {
|
|
4041
|
+
if (!roster.tools.some((tool) => tool.name === name)) {
|
|
4042
|
+
throw new MinionryError('BAD_REQUEST', `quality.installTools: unknown tool '${name}' for '${path}'`);
|
|
4043
|
+
}
|
|
4044
|
+
installed.add(name);
|
|
4045
|
+
}
|
|
4046
|
+
const result = qualityToolsFor(path);
|
|
4047
|
+
publishQualityChange({ kind: 'tools', path, tools: result.tools, ecosystem: result.ecosystem });
|
|
4048
|
+
return result;
|
|
4049
|
+
},
|
|
4050
|
+
'quality.saveDirectories': (params) => {
|
|
4051
|
+
const raw = asRecord(params, 'quality.saveDirectories').directories;
|
|
4052
|
+
if (!Array.isArray(raw)) {
|
|
4053
|
+
throw new MinionryError('BAD_REQUEST', 'quality.saveDirectories: directories must be an array');
|
|
4054
|
+
}
|
|
4055
|
+
const next = raw.map((entry) => {
|
|
4056
|
+
const directory = asRecord(entry, 'quality.saveDirectories');
|
|
4057
|
+
return {
|
|
4058
|
+
path: qualityPathOf(directory.path, 'quality.saveDirectories'),
|
|
4059
|
+
label: asString(directory.label, 'directories[].label', 'quality.saveDirectories'),
|
|
4060
|
+
};
|
|
4061
|
+
});
|
|
4062
|
+
qualityDirectories = next;
|
|
4063
|
+
publishQualityChange({ kind: 'directories', directories: next.map((directory) => ({ ...directory })) });
|
|
4064
|
+
return null;
|
|
4065
|
+
},
|
|
4066
|
+
'quality.setSettings': (params) => {
|
|
4067
|
+
const record = asRecord(params, 'quality.setSettings');
|
|
4068
|
+
if (record.engine !== undefined)
|
|
4069
|
+
qualitySettings.engine = asString(record.engine, 'engine', 'quality.setSettings');
|
|
4070
|
+
if (record.model !== undefined)
|
|
4071
|
+
qualitySettings.model = asString(record.model, 'model', 'quality.setSettings');
|
|
4072
|
+
if (record.effortLevel !== undefined) {
|
|
4073
|
+
qualitySettings.effortLevel = asString(record.effortLevel, 'effortLevel', 'quality.setSettings');
|
|
4074
|
+
}
|
|
4075
|
+
if (record.fastMode !== undefined) {
|
|
4076
|
+
if (typeof record.fastMode !== 'boolean') {
|
|
4077
|
+
throw new MinionryError('BAD_REQUEST', "quality.setSettings: 'fastMode' must be a boolean");
|
|
4078
|
+
}
|
|
4079
|
+
qualitySettings.fastMode = record.fastMode;
|
|
4080
|
+
}
|
|
4081
|
+
if (record.agentNotes !== undefined) {
|
|
4082
|
+
if (typeof record.agentNotes !== 'boolean') {
|
|
4083
|
+
throw new MinionryError('BAD_REQUEST', 'quality.setSettings: agentNotes must be a boolean');
|
|
4084
|
+
}
|
|
4085
|
+
qualitySettings.agentNotes = record.agentNotes;
|
|
4086
|
+
}
|
|
4087
|
+
return { ...qualitySettings };
|
|
4088
|
+
},
|
|
4089
|
+
'quality.schedules.list': () => ({ schedules: qualitySchedules.map(cloneQualitySchedule) }),
|
|
4090
|
+
'quality.schedules.create': (params) => {
|
|
4091
|
+
const method = 'quality.schedules.create';
|
|
4092
|
+
const record = asRecord(params, method);
|
|
4093
|
+
const path = qualityPathOf(record.path, method);
|
|
4094
|
+
const timing = qualityTimingOf(record.timing, method);
|
|
4095
|
+
if (record.enabled !== undefined && typeof record.enabled !== 'boolean') {
|
|
4096
|
+
throw new MinionryError('BAD_REQUEST', `${method}: enabled must be a boolean when present`);
|
|
4097
|
+
}
|
|
4098
|
+
const enabled = record.enabled !== false;
|
|
4099
|
+
const now = Date.now();
|
|
4100
|
+
qualityScheduleCounter += 1;
|
|
4101
|
+
const schedule = {
|
|
4102
|
+
id: `qsched-mock${qualityScheduleCounter}`,
|
|
4103
|
+
createdAt: new Date(now).toISOString(),
|
|
4104
|
+
...(typeof record.name === 'string' ? { name: record.name } : {}),
|
|
4105
|
+
path,
|
|
4106
|
+
timing,
|
|
4107
|
+
enabled,
|
|
4108
|
+
nextFireAt: enabled ? nextQualityFireAt(timing, now) : null,
|
|
4109
|
+
history: [],
|
|
4110
|
+
};
|
|
4111
|
+
qualitySchedules.push(schedule);
|
|
4112
|
+
publishQualitySchedules();
|
|
4113
|
+
return cloneQualitySchedule(schedule);
|
|
4114
|
+
},
|
|
4115
|
+
'quality.schedules.update': (params) => {
|
|
4116
|
+
const method = 'quality.schedules.update';
|
|
4117
|
+
const record = asRecord(params, method);
|
|
4118
|
+
const schedule = qualityScheduleById(record.scheduleId, method);
|
|
4119
|
+
const patch = record.patch === undefined ? {} : asRecord(record.patch, method);
|
|
4120
|
+
if ('path' in patch)
|
|
4121
|
+
throw new MinionryError('BAD_REQUEST', `${method}: path is immutable — delete and re-create to move a schedule`);
|
|
4122
|
+
if (patch.name !== undefined)
|
|
4123
|
+
schedule.name = asString(patch.name, 'patch.name', method);
|
|
4124
|
+
if (patch.enabled !== undefined) {
|
|
4125
|
+
if (typeof patch.enabled !== 'boolean')
|
|
4126
|
+
throw new MinionryError('BAD_REQUEST', `${method}: patch.enabled must be a boolean`);
|
|
4127
|
+
schedule.enabled = patch.enabled;
|
|
4128
|
+
}
|
|
4129
|
+
if (patch.timing !== undefined)
|
|
4130
|
+
schedule.timing = qualityTimingOf(patch.timing, method);
|
|
4131
|
+
schedule.nextFireAt = schedule.enabled ? nextQualityFireAt(schedule.timing, Date.now()) : null;
|
|
4132
|
+
publishQualitySchedules();
|
|
4133
|
+
return cloneQualitySchedule(schedule);
|
|
4134
|
+
},
|
|
4135
|
+
'quality.schedules.delete': (params) => {
|
|
4136
|
+
const method = 'quality.schedules.delete';
|
|
4137
|
+
const schedule = qualityScheduleById(asRecord(params, method).scheduleId, method);
|
|
4138
|
+
qualitySchedules.splice(qualitySchedules.indexOf(schedule), 1);
|
|
4139
|
+
publishQualitySchedules();
|
|
4140
|
+
return null;
|
|
4141
|
+
},
|
|
4142
|
+
'quality.setFindingState': (params) => {
|
|
4143
|
+
const method = 'quality.setFindingState';
|
|
4144
|
+
const record = asRecord(params, method);
|
|
4145
|
+
if (typeof record.path !== 'string' || record.path.trim() === '') {
|
|
4146
|
+
throw new MinionryError('BAD_REQUEST', `${method}: path is required (a directory relative to the working directory)`);
|
|
4147
|
+
}
|
|
4148
|
+
const path = record.path.trim();
|
|
4149
|
+
if (path.startsWith('/') || path.split('/').includes('..')) {
|
|
4150
|
+
throw new MinionryError('BAD_REQUEST', `${method}: path '${path}' resolves outside the working directory`);
|
|
4151
|
+
}
|
|
4152
|
+
const raw = record.fingerprints;
|
|
4153
|
+
if (!Array.isArray(raw) || raw.length === 0 || raw.some((entry) => typeof entry !== 'string' || entry === '')) {
|
|
4154
|
+
throw new MinionryError('BAD_REQUEST', `${method}: fingerprints must be a non-empty array of non-empty strings`);
|
|
4155
|
+
}
|
|
4156
|
+
if (raw.length > MOCK_QUALITY_FINGERPRINTS_PER_CALL) {
|
|
4157
|
+
throw new MinionryError('BAD_REQUEST', `${method}: fingerprints holds ${raw.length} entries; at most ${MOCK_QUALITY_FINGERPRINTS_PER_CALL} per call`);
|
|
4158
|
+
}
|
|
4159
|
+
const fingerprints = [...new Set(raw)];
|
|
4160
|
+
if (record.state !== 'dismissed' && record.state !== 'active') {
|
|
4161
|
+
throw new MinionryError('BAD_REQUEST', `${method}: state must be 'dismissed' or 'active'`);
|
|
4162
|
+
}
|
|
4163
|
+
const report = qualityReports.get(path);
|
|
4164
|
+
if (record.state === 'active') {
|
|
4165
|
+
let applied = 0;
|
|
4166
|
+
for (const fingerprint of fingerprints) {
|
|
4167
|
+
if (qualitySuppressions.delete(qualitySuppressionKey(path, fingerprint)))
|
|
4168
|
+
applied++;
|
|
4169
|
+
}
|
|
4170
|
+
if (report)
|
|
4171
|
+
publishQualityChange({ kind: 'results', path, report });
|
|
4172
|
+
return { applied, dropped: 0 };
|
|
4173
|
+
}
|
|
4174
|
+
const reason = typeof record.reason === 'string' ? record.reason.trim() : '';
|
|
4175
|
+
if (reason === '' || reason.length > MOCK_QUALITY_REASON_MAX) {
|
|
4176
|
+
throw new MinionryError('BAD_REQUEST', `${method}: reason is required to dismiss (1–${MOCK_QUALITY_REASON_MAX} characters after trimming)`);
|
|
4177
|
+
}
|
|
4178
|
+
const known = new Map();
|
|
4179
|
+
for (const finding of [...(report?.findings ?? []), ...(report?.codeReview ?? [])]) {
|
|
4180
|
+
if (finding.fingerprint !== undefined && !known.has(finding.fingerprint))
|
|
4181
|
+
known.set(finding.fingerprint, finding);
|
|
4182
|
+
}
|
|
4183
|
+
const unknown = fingerprints.filter((fingerprint) => !known.has(fingerprint));
|
|
4184
|
+
if (!report || unknown.length > 0) {
|
|
4185
|
+
throw new MinionryError('BAD_REQUEST', `${method}: unknown fingerprint(s) for '${path}': ${unknown.join(', ')}`);
|
|
4186
|
+
}
|
|
4187
|
+
const dismissedAt = new Date().toISOString();
|
|
4188
|
+
let applied = 0;
|
|
4189
|
+
for (const fingerprint of fingerprints) {
|
|
4190
|
+
const finding = known.get(fingerprint);
|
|
4191
|
+
if (!finding)
|
|
4192
|
+
continue;
|
|
4193
|
+
qualitySuppressions.set(qualitySuppressionKey(path, fingerprint), {
|
|
4194
|
+
fingerprint,
|
|
4195
|
+
path,
|
|
4196
|
+
reason,
|
|
4197
|
+
dismissedAt,
|
|
4198
|
+
title: finding.title,
|
|
4199
|
+
file: finding.file,
|
|
4200
|
+
});
|
|
4201
|
+
applied++;
|
|
4202
|
+
}
|
|
4203
|
+
publishQualityChange({ kind: 'results', path, report });
|
|
4204
|
+
return { applied, dropped: 0 };
|
|
4205
|
+
},
|
|
4206
|
+
};
|
|
4207
|
+
const scopeParams = (params) => typeof params === 'object' && params !== null && !Array.isArray(params) ? params : {};
|
|
4208
|
+
const resolveScope = (method, params) => {
|
|
4209
|
+
if (FILES_READ_PATH_METHODS.has(method)) {
|
|
4210
|
+
const path = scopeParams(params).path;
|
|
4211
|
+
return typeof path === 'string' ? resolveFilesReadScope(path) : 'files:app';
|
|
4212
|
+
}
|
|
4213
|
+
if (FILES_WRITE_PATH_METHODS.has(method)) {
|
|
4214
|
+
const path = scopeParams(params).path;
|
|
4215
|
+
return typeof path === 'string' ? resolveFilesWriteScope(path) : 'files:app';
|
|
4216
|
+
}
|
|
4217
|
+
if (method === 'files.uploadChunk' || method === 'files.uploadStatus' || method === 'files.uploadCancel') {
|
|
4218
|
+
const uploadId = scopeParams(params).uploadId;
|
|
4219
|
+
return typeof uploadId === 'string' ? (uploads.get(uploadId)?.scope ?? null) : null;
|
|
4220
|
+
}
|
|
4221
|
+
if (method === 'files.downloadChunk' || method === 'files.downloadCancel') {
|
|
4222
|
+
const downloadId = scopeParams(params).downloadId;
|
|
4223
|
+
return typeof downloadId === 'string' ? (downloads.get(downloadId)?.scope ?? null) : null;
|
|
4224
|
+
}
|
|
4225
|
+
return METHOD_SCOPES[method];
|
|
4226
|
+
};
|
|
4227
|
+
const dispatch = (frame) => {
|
|
4228
|
+
const { requestId, method } = frame;
|
|
4229
|
+
const forced = forcedErrors[method];
|
|
4230
|
+
if (forced !== undefined) {
|
|
4231
|
+
fail(requestId, { code: forced, message: `mock: forced '${forced}' for ${method} (MockConfig.errors)` });
|
|
4232
|
+
return;
|
|
4233
|
+
}
|
|
4234
|
+
if (!Object.prototype.hasOwnProperty.call(METHOD_SCOPES, method)) {
|
|
4235
|
+
fail(requestId, { code: 'SCOPE_DENIED', message: `mock: unknown method '${String(method)}' (fail closed)` });
|
|
4236
|
+
return;
|
|
4237
|
+
}
|
|
4238
|
+
const scope = resolveScope(method, frame.params);
|
|
4239
|
+
if (scope !== null && !granted.has(scope)) {
|
|
4240
|
+
const hint = granted.size === 0 ? 'call connect() first' : 'not granted on connect';
|
|
4241
|
+
fail(requestId, { code: 'SCOPE_DENIED', message: `mock: ${method} requires scope '${scope}' — ${hint}` });
|
|
4242
|
+
return;
|
|
4243
|
+
}
|
|
4244
|
+
try {
|
|
4245
|
+
const result = methodHandlers[method](frame.params, requestId);
|
|
4246
|
+
if (result !== DEFER)
|
|
4247
|
+
respond(requestId, result);
|
|
4248
|
+
}
|
|
4249
|
+
catch (cause) {
|
|
4250
|
+
if (cause instanceof MinionryError)
|
|
4251
|
+
fail(requestId, { code: cause.code, message: cause.message });
|
|
4252
|
+
else
|
|
4253
|
+
fail(requestId, { code: 'INTERNAL', message: `mock: ${method} handler threw (${String(cause)})` });
|
|
4254
|
+
}
|
|
4255
|
+
};
|
|
4256
|
+
return {
|
|
4257
|
+
start(startHandlers) {
|
|
4258
|
+
if (closed)
|
|
4259
|
+
throw new Error('mock transport is closed');
|
|
4260
|
+
if (handlers)
|
|
4261
|
+
throw new Error('mock transport already started — create one transport per provider');
|
|
4262
|
+
handlers = startHandlers;
|
|
4263
|
+
},
|
|
4264
|
+
send(frame) {
|
|
4265
|
+
if (!handlers)
|
|
4266
|
+
throw new Error('mock transport not started');
|
|
4267
|
+
if (closed)
|
|
4268
|
+
throw new Error('mock transport is closed');
|
|
4269
|
+
dispatch(frame);
|
|
4270
|
+
},
|
|
4271
|
+
close() {
|
|
4272
|
+
if (closed)
|
|
4273
|
+
return;
|
|
4274
|
+
closed = true;
|
|
4275
|
+
for (const id of timers)
|
|
4276
|
+
clearTimeout(id);
|
|
4277
|
+
timers.clear();
|
|
4278
|
+
for (const requestId of [...toolRegistrations.keys()])
|
|
4279
|
+
retireToolRegistration(requestId);
|
|
4280
|
+
handlers?.onClose();
|
|
4281
|
+
},
|
|
4282
|
+
};
|
|
4283
|
+
}
|
|
4284
|
+
export function mock(config = {}) {
|
|
4285
|
+
return createProvider(createMockTransport(config));
|
|
4286
|
+
}
|