@mrpatronz/nexusflow 2.2.1 → 2.3.1

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.
Files changed (141) hide show
  1. package/README.md +80 -17
  2. package/dist/agent/AntigravityCliAdapter.d.ts +14 -1
  3. package/dist/agent/AntigravityCliAdapter.d.ts.map +1 -1
  4. package/dist/agent/AntigravityCliAdapter.js +84 -4
  5. package/dist/agent/AntigravityCliAdapter.js.map +1 -1
  6. package/dist/agent/AntigravityCliAdapter.test.d.ts +2 -0
  7. package/dist/agent/AntigravityCliAdapter.test.d.ts.map +1 -0
  8. package/dist/agent/AntigravityCliAdapter.test.js +222 -0
  9. package/dist/agent/AntigravityCliAdapter.test.js.map +1 -0
  10. package/dist/agent/adapters.d.ts.map +1 -1
  11. package/dist/agent/adapters.js +19 -1
  12. package/dist/agent/adapters.js.map +1 -1
  13. package/dist/commands/ui.d.ts +1 -0
  14. package/dist/commands/ui.d.ts.map +1 -1
  15. package/dist/commands/ui.js +24 -13
  16. package/dist/commands/ui.js.map +1 -1
  17. package/dist/commands/ui.test.d.ts +2 -0
  18. package/dist/commands/ui.test.d.ts.map +1 -0
  19. package/dist/commands/ui.test.js +58 -0
  20. package/dist/commands/ui.test.js.map +1 -0
  21. package/dist/core/workspace-skills-workflow.integration.test.d.ts +2 -0
  22. package/dist/core/workspace-skills-workflow.integration.test.d.ts.map +1 -0
  23. package/dist/core/workspace-skills-workflow.integration.test.js +162 -0
  24. package/dist/core/workspace-skills-workflow.integration.test.js.map +1 -0
  25. package/dist/core/worktree.test.js +1 -1
  26. package/dist/core/worktree.test.js.map +1 -1
  27. package/dist/generators/index.d.ts.map +1 -1
  28. package/dist/generators/index.js +4 -8
  29. package/dist/generators/index.js.map +1 -1
  30. package/dist/generators/skills-generator.d.ts +2 -2
  31. package/dist/generators/skills-generator.d.ts.map +1 -1
  32. package/dist/generators/skills-generator.js +84 -89
  33. package/dist/generators/skills-generator.js.map +1 -1
  34. package/dist/generators/skills-generator.test.d.ts +2 -0
  35. package/dist/generators/skills-generator.test.d.ts.map +1 -0
  36. package/dist/generators/skills-generator.test.js +79 -0
  37. package/dist/generators/skills-generator.test.js.map +1 -0
  38. package/dist/gui/assets/AgentsPage-C7wGK7q-.js +3 -0
  39. package/dist/gui/assets/{DashboardPage-CUspfCap.js → DashboardPage-DqZRdOF3.js} +1 -1
  40. package/dist/gui/assets/{GettingStartedPage-BqtgARZW.js → GettingStartedPage-BJvrh67p.js} +1 -1
  41. package/dist/gui/assets/ProjectsPage-B0VFV1vX.js +1 -0
  42. package/dist/gui/assets/ScaffoldRepoInline-DSt_6QLb.js +1 -0
  43. package/dist/gui/assets/SettingsPage-DO5HasvS.js +1 -0
  44. package/dist/gui/assets/SkillsPage-CvRIKdsQ.js +6 -0
  45. package/dist/gui/assets/StartWorkPage-CK5QF6Oa.js +1 -0
  46. package/dist/gui/assets/{StrategiesPage-D0Y6A4Tw.js → StrategiesPage-CWMrYI29.js} +2 -2
  47. package/dist/gui/assets/WorkspaceLauncher-EIY_u44S.js +1 -0
  48. package/dist/gui/assets/WorkspacesPage-CHUfqrA6.js +6 -0
  49. package/dist/gui/assets/alert-dialog-CroDe9HW.js +1 -0
  50. package/dist/gui/assets/bot-wH0FKlic.js +1 -0
  51. package/dist/gui/assets/chevron-down-DDmhvRLr.js +1 -0
  52. package/dist/gui/assets/code-xml-Chu1p81J.js +1 -0
  53. package/dist/gui/assets/index-Bq5NL5AJ.css +2 -0
  54. package/dist/gui/assets/index-CcIOIZ24.js +52 -0
  55. package/dist/gui/assets/{spinner-DXt6mnJk.js → spinner-BCEaGbib.js} +1 -1
  56. package/dist/gui/assets/terminal-gK6BMl3E.js +1 -0
  57. package/dist/gui/assets/{textarea-BLU5HXmZ.js → textarea-D3-elOAa.js} +1 -1
  58. package/dist/gui/assets/{useAriaLabelledBy-CjNlctb9.js → useAriaLabelledBy-DmwmZIqb.js} +1 -1
  59. package/dist/gui/assets/useCompositeListItem-CZnlZ-eF.js +1 -0
  60. package/dist/gui/assets/useValueChanged-BfI2s7NX.js +1 -0
  61. package/dist/gui/assets/zap-FQrDnrgb.js +1 -0
  62. package/dist/gui/index.html +6 -4
  63. package/dist/resources/agents-catalog.d.ts +7 -0
  64. package/dist/resources/agents-catalog.d.ts.map +1 -0
  65. package/dist/resources/agents-catalog.js +141 -0
  66. package/dist/resources/agents-catalog.js.map +1 -0
  67. package/dist/resources/agents-catalog.test.d.ts +2 -0
  68. package/dist/resources/agents-catalog.test.d.ts.map +1 -0
  69. package/dist/resources/agents-catalog.test.js +50 -0
  70. package/dist/resources/agents-catalog.test.js.map +1 -0
  71. package/dist/resources/codex-agent.d.ts +6 -0
  72. package/dist/resources/codex-agent.d.ts.map +1 -0
  73. package/dist/resources/codex-agent.js +55 -0
  74. package/dist/resources/codex-agent.js.map +1 -0
  75. package/dist/resources/codex-agent.test.d.ts +2 -0
  76. package/dist/resources/codex-agent.test.d.ts.map +1 -0
  77. package/dist/resources/codex-agent.test.js +27 -0
  78. package/dist/resources/codex-agent.test.js.map +1 -0
  79. package/dist/resources/contracts.d.ts +116 -0
  80. package/dist/resources/contracts.d.ts.map +1 -0
  81. package/dist/resources/contracts.js +103 -0
  82. package/dist/resources/contracts.js.map +1 -0
  83. package/dist/resources/fs-safety.d.ts +11 -0
  84. package/dist/resources/fs-safety.d.ts.map +1 -0
  85. package/dist/resources/fs-safety.js +77 -0
  86. package/dist/resources/fs-safety.js.map +1 -0
  87. package/dist/resources/materializer.d.ts +17 -0
  88. package/dist/resources/materializer.d.ts.map +1 -0
  89. package/dist/resources/materializer.js +439 -0
  90. package/dist/resources/materializer.js.map +1 -0
  91. package/dist/resources/materializer.test.d.ts +2 -0
  92. package/dist/resources/materializer.test.d.ts.map +1 -0
  93. package/dist/resources/materializer.test.js +149 -0
  94. package/dist/resources/materializer.test.js.map +1 -0
  95. package/dist/resources/service.d.ts +8 -0
  96. package/dist/resources/service.d.ts.map +1 -0
  97. package/dist/resources/service.js +45 -0
  98. package/dist/resources/service.js.map +1 -0
  99. package/dist/resources/service.test.d.ts +2 -0
  100. package/dist/resources/service.test.d.ts.map +1 -0
  101. package/dist/resources/service.test.js +58 -0
  102. package/dist/resources/service.test.js.map +1 -0
  103. package/dist/server.d.ts +5 -0
  104. package/dist/server.d.ts.map +1 -1
  105. package/dist/server.js +310 -4
  106. package/dist/server.js.map +1 -1
  107. package/dist/server.test.js +349 -1
  108. package/dist/server.test.js.map +1 -1
  109. package/dist/types.d.ts +107 -0
  110. package/dist/types.d.ts.map +1 -1
  111. package/dist/utils/resources.d.ts +2 -1
  112. package/dist/utils/resources.d.ts.map +1 -1
  113. package/dist/utils/resources.js +4 -3
  114. package/dist/utils/resources.js.map +1 -1
  115. package/dist/utils/session-finder.d.ts +6 -0
  116. package/dist/utils/session-finder.d.ts.map +1 -1
  117. package/dist/utils/session-finder.js +56 -0
  118. package/dist/utils/session-finder.js.map +1 -1
  119. package/dist/utils/session-finder.test.js +34 -1
  120. package/dist/utils/session-finder.test.js.map +1 -1
  121. package/dist/utils/skills-catalog.d.ts +77 -0
  122. package/dist/utils/skills-catalog.d.ts.map +1 -0
  123. package/dist/utils/skills-catalog.js +871 -0
  124. package/dist/utils/skills-catalog.js.map +1 -0
  125. package/dist/utils/skills-catalog.test.d.ts +2 -0
  126. package/dist/utils/skills-catalog.test.d.ts.map +1 -0
  127. package/dist/utils/skills-catalog.test.js +281 -0
  128. package/dist/utils/skills-catalog.test.js.map +1 -0
  129. package/package.json +4 -1
  130. package/dist/gui/assets/ProjectsPage-UBoQxI2G.js +0 -1
  131. package/dist/gui/assets/ScaffoldRepoInline-DqoourJe.js +0 -1
  132. package/dist/gui/assets/SettingsPage-BZl24tqD.js +0 -1
  133. package/dist/gui/assets/StartWorkPage-BGNFdBfj.js +0 -1
  134. package/dist/gui/assets/WorkspaceLauncher-Cxv0O_wt.js +0 -1
  135. package/dist/gui/assets/WorkspacesPage-Cvb1rOeh.js +0 -6
  136. package/dist/gui/assets/index-CllPiiFL.js +0 -52
  137. package/dist/gui/assets/index-DGCBhMAn.css +0 -2
  138. package/dist/gui/assets/useCompositeListItem-DWGFLKqA.js +0 -1
  139. /package/dist/{resources → static-resources}/workflows/plan-implement-review.md +0 -0
  140. /package/dist/{resources → static-resources}/workflows/research-verify.md +0 -0
  141. /package/dist/{resources → static-resources}/workflows/solo-developer.md +0 -0
@@ -2,12 +2,15 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
2
  import * as fs from 'node:fs/promises';
3
3
  import * as path from 'node:path';
4
4
  import { execa } from 'execa';
5
- import { AgentTurnGate, app, dispatchAgentInput, isAllowedUpdateUrl } from './server.js';
5
+ import { AgentTurnGate, app, canOfferClaudeDesktopTransfer, dispatchAgentInput, isAllowedUpdateUrl } from './server.js';
6
6
  import * as workspace from './core/workspace.js';
7
7
  import * as config from './core/config.js';
8
8
  import * as analyzers from './analyzers/index.js';
9
9
  import * as generators from './generators/index.js';
10
10
  import * as workflows from './utils/workflows.js';
11
+ import * as skillsCatalog from './utils/skills-catalog.js';
12
+ import * as agentsCatalog from './resources/agents-catalog.js';
13
+ import * as resourceService from './resources/service.js';
11
14
  import * as detectAi from './utils/detect-ai.js';
12
15
  import * as newRepo from './core/new-repo.js';
13
16
  import * as orchestration from './orchestration/index.js';
@@ -23,6 +26,16 @@ vi.mock('./utils/update-check.js');
23
26
  vi.mock('./analyzers/index.js');
24
27
  vi.mock('./generators/index.js');
25
28
  vi.mock('./utils/workflows.js');
29
+ vi.mock('./utils/skills-catalog.js');
30
+ vi.mock('./resources/agents-catalog.js');
31
+ vi.mock('./resources/service.js', async (importOriginal) => {
32
+ const actual = await importOriginal();
33
+ return {
34
+ ...actual,
35
+ withResourceAdministrationLock: vi.fn((operation) => operation()),
36
+ validateResourceSelections: vi.fn().mockResolvedValue(undefined),
37
+ };
38
+ });
26
39
  vi.mock('./utils/detect-ai.js', () => ({
27
40
  detectAIAssistants: vi.fn().mockResolvedValue([])
28
41
  }));
@@ -404,6 +417,117 @@ describe('Server API Endpoints Unit Tests', () => {
404
417
  }
405
418
  });
406
419
  });
420
+ describe('workspace recent session handoffs', () => {
421
+ const workspacesDir = path.resolve('session-workspaces');
422
+ const workspacePath = path.join(workspacesDir, 'safe-workspace');
423
+ const codexId = '0199a213-81c0-7800-8aa1-bbab2a035a53';
424
+ const claudeId = '0199a213-81c0-7800-8aa1-bbab2a035a54';
425
+ const unauthorizedCodexId = '0199a213-81c0-7800-8aa1-bbab2a035a55';
426
+ it('returns only server-authorized Desktop handoff capabilities', async () => {
427
+ vi.spyOn(config, 'loadConfig').mockResolvedValue({ workspacesDir });
428
+ vi.mocked(fs.realpath).mockImplementation(async (candidate) => path.resolve(String(candidate)));
429
+ vi.mocked(workspace.loadWorkspaceManifest).mockResolvedValue({
430
+ id: 'safe-workspace',
431
+ repos: [path.join(workspacePath, 'repo')],
432
+ workspacePath,
433
+ });
434
+ const find = vi.spyOn(sessionFinder, 'findSessions').mockResolvedValue([
435
+ {
436
+ id: 'copilot-session', assistant: 'copilot', title: 'Newer Copilot task',
437
+ createdAt: '2026-08-16T03:00:00.000Z', updatedAt: '2026-08-16T03:00:00.000Z',
438
+ messageCount: 2, workspacePath,
439
+ },
440
+ {
441
+ id: 'antigravity-session', assistant: 'antigravity', title: 'Newer Antigravity task',
442
+ createdAt: '2026-08-16T02:00:00.000Z', updatedAt: '2026-08-16T02:00:00.000Z',
443
+ messageCount: 2, workspacePath,
444
+ },
445
+ {
446
+ id: unauthorizedCodexId, assistant: 'codex', title: 'Fuzzy Codex task',
447
+ createdAt: '2026-08-16T01:00:00.000Z', updatedAt: '2026-08-16T01:00:00.000Z',
448
+ messageCount: 2, workspacePath,
449
+ },
450
+ {
451
+ id: codexId, assistant: 'codex', title: 'Codex task',
452
+ createdAt: '2026-08-15T00:00:00.000Z', updatedAt: '2026-08-16T00:00:00.000Z',
453
+ messageCount: 4, workspacePath,
454
+ },
455
+ {
456
+ id: claudeId, assistant: 'claude', title: 'Claude task',
457
+ createdAt: '2026-08-14T00:00:00.000Z', updatedAt: '2026-08-15T00:00:00.000Z',
458
+ messageCount: 2, workspacePath,
459
+ },
460
+ ]);
461
+ const codexAuth = vi.spyOn(sessionFinder, 'canOpenCodexSessionInWorkspace')
462
+ .mockImplementation(async (_workspace, _repos, sessionId) => sessionId === codexId);
463
+ const claudeAuth = vi.spyOn(sessionFinder, 'canTransferClaudeSessionInWorkspace').mockResolvedValue(true);
464
+ const platform = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32');
465
+ const architecture = vi.spyOn(process, 'arch', 'get').mockReturnValue('x64');
466
+ vi.stubEnv('ANTHROPIC_API_KEY', '');
467
+ vi.stubEnv('ANTHROPIC_AUTH_TOKEN', '');
468
+ vi.stubEnv('CLAUDE_CODE_USE_BEDROCK', '');
469
+ vi.stubEnv('CLAUDE_CODE_USE_VERTEX', '');
470
+ vi.stubEnv('CLAUDE_CODE_USE_FOUNDRY', '');
471
+ const provider = vi.spyOn(ProviderRegistry, 'getProvider').mockReturnValue({
472
+ isConfigured: () => true,
473
+ });
474
+ try {
475
+ const response = await app.request('/api/workspace/safe-workspace/sessions?limit=2&desktopHandoffOnly=true');
476
+ const body = await response.json();
477
+ expect(response.status).toBe(200);
478
+ expect(body.sessions).toHaveLength(2);
479
+ expect(body.sessions[0].desktopHandoff).toEqual({ targetId: 'codex-desktop', method: 'direct' });
480
+ expect(body.sessions[1].desktopHandoff).toEqual({ targetId: 'claude-desktop', method: 'guided' });
481
+ expect(codexAuth).toHaveBeenCalledWith(workspacePath, [path.join(workspacePath, 'repo')], codexId);
482
+ expect(claudeAuth).toHaveBeenCalledWith(workspacePath, [path.join(workspacePath, 'repo')], claudeId);
483
+ }
484
+ finally {
485
+ find.mockRestore();
486
+ codexAuth.mockRestore();
487
+ claudeAuth.mockRestore();
488
+ provider.mockRestore();
489
+ platform.mockRestore();
490
+ architecture.mockRestore();
491
+ vi.unstubAllEnvs();
492
+ }
493
+ });
494
+ it('omits a discovered session when strong authorization fails', async () => {
495
+ vi.spyOn(config, 'loadConfig').mockResolvedValue({ workspacesDir });
496
+ vi.mocked(fs.realpath).mockImplementation(async (candidate) => path.resolve(String(candidate)));
497
+ vi.mocked(workspace.loadWorkspaceManifest).mockResolvedValue({
498
+ id: 'safe-workspace', repos: [], workspacePath,
499
+ });
500
+ const find = vi.spyOn(sessionFinder, 'findSessions').mockResolvedValue([{
501
+ id: codexId, assistant: 'codex', title: 'Fuzzy match only',
502
+ createdAt: '2026-08-15T00:00:00.000Z', updatedAt: '2026-08-16T00:00:00.000Z',
503
+ messageCount: 2, workspacePath,
504
+ }]);
505
+ const codexAuth = vi.spyOn(sessionFinder, 'canOpenCodexSessionInWorkspace').mockResolvedValue(false);
506
+ try {
507
+ const response = await app.request('/api/workspace/safe-workspace/sessions?limit=3&desktopHandoffOnly=true');
508
+ expect(response.status).toBe(200);
509
+ await expect(response.json()).resolves.toEqual({ sessions: [] });
510
+ }
511
+ finally {
512
+ find.mockRestore();
513
+ codexAuth.mockRestore();
514
+ }
515
+ });
516
+ it('rejects an excessive recent-session limit', async () => {
517
+ const response = await app.request('/api/workspace/safe-workspace/sessions?limit=200');
518
+ expect(response.status).toBe(400);
519
+ await expect(response.json()).resolves.toEqual({ error: 'Session limit must be an integer from 1 to 20.' });
520
+ });
521
+ it('gates Claude Desktop transfer to supported subscription CLI environments', () => {
522
+ const configured = () => true;
523
+ expect(canOfferClaudeDesktopTransfer('win32', 'x64', {}, configured)).toBe(true);
524
+ expect(canOfferClaudeDesktopTransfer('darwin', 'arm64', {}, configured)).toBe(true);
525
+ expect(canOfferClaudeDesktopTransfer('linux', 'x64', {}, configured)).toBe(false);
526
+ expect(canOfferClaudeDesktopTransfer('win32', 'arm64', {}, configured)).toBe(false);
527
+ expect(canOfferClaudeDesktopTransfer('win32', 'x64', { ANTHROPIC_API_KEY: 'configured' }, configured)).toBe(false);
528
+ expect(canOfferClaudeDesktopTransfer('win32', 'x64', {}, () => false)).toBe(false);
529
+ });
530
+ });
407
531
  describe('POST /api/workspace/:id/resume', () => {
408
532
  it('should fail with 400 if command is forbidden', async () => {
409
533
  vi.spyOn(config, 'loadConfig').mockResolvedValue({
@@ -1042,5 +1166,229 @@ describe('Server API Endpoints Unit Tests', () => {
1042
1166
  expect(response.status).toBe(400);
1043
1167
  });
1044
1168
  });
1169
+ describe('Skills & Categories Endpoints', () => {
1170
+ it('GET /api/skills/categories returns default categories', async () => {
1171
+ vi.mocked(skillsCatalog.getSkillCategories).mockResolvedValue([
1172
+ {
1173
+ id: 'pull-requests',
1174
+ name: 'Pull Requests & Review',
1175
+ description: 'PR review skills',
1176
+ icon: 'git-pull-request',
1177
+ color: '#3b82f6',
1178
+ custom: false,
1179
+ isTemplate: true,
1180
+ },
1181
+ ]);
1182
+ const response = await app.request('/api/skills/categories');
1183
+ expect(response.status).toBe(200);
1184
+ const data = await response.json();
1185
+ expect(data.categories).toBeDefined();
1186
+ expect(data.categories.length).toBe(1);
1187
+ expect(data.categories[0].id).toBe('pull-requests');
1188
+ expect(data.categories[0].isTemplate).toBe(true);
1189
+ });
1190
+ it('POST /api/skills/categories creates a custom category', async () => {
1191
+ vi.mocked(skillsCatalog.saveSkillCategory).mockResolvedValue({
1192
+ id: 'serverless-workflows',
1193
+ name: 'Serverless Workflows',
1194
+ description: 'AWS Lambda and Cloudflare workers',
1195
+ icon: 'zap',
1196
+ color: '#ec4899',
1197
+ custom: true,
1198
+ isTemplate: false,
1199
+ });
1200
+ const response = await app.request('/api/skills/categories', {
1201
+ method: 'POST',
1202
+ headers: { 'Content-Type': 'application/json' },
1203
+ body: JSON.stringify({
1204
+ name: 'Serverless Workflows',
1205
+ description: 'AWS Lambda and Cloudflare workers',
1206
+ icon: 'zap',
1207
+ color: '#ec4899',
1208
+ }),
1209
+ });
1210
+ expect(response.status).toBe(200);
1211
+ const data = await response.json();
1212
+ expect(data.success).toBe(true);
1213
+ expect(data.category.id).toBe('serverless-workflows');
1214
+ expect(data.category.custom).toBe(true);
1215
+ });
1216
+ it('GET /api/skills returns all skills', async () => {
1217
+ vi.mocked(skillsCatalog.getAllSkills).mockResolvedValue([
1218
+ {
1219
+ id: 'pr-review-toolkit',
1220
+ name: 'pr-review-toolkit',
1221
+ title: 'PR Review Toolkit',
1222
+ category: 'pull-requests',
1223
+ description: 'Review pull requests',
1224
+ custom: false,
1225
+ content: '# PR Review',
1226
+ },
1227
+ ]);
1228
+ const response = await app.request('/api/skills');
1229
+ expect(response.status).toBe(200);
1230
+ const data = await response.json();
1231
+ expect(data.skills).toBeDefined();
1232
+ expect(data.skills.length).toBe(1);
1233
+ expect(data.skills[0].id).toBe('pr-review-toolkit');
1234
+ });
1235
+ it('POST /api/skills creates a new skill package', async () => {
1236
+ vi.mocked(skillsCatalog.saveSkill).mockResolvedValue({
1237
+ id: 'graphql-linter',
1238
+ name: 'graphql-linter',
1239
+ title: 'GraphQL Schema Linter',
1240
+ category: 'database-migrations',
1241
+ description: 'Validates GraphQL schemas for deprecated fields',
1242
+ custom: true,
1243
+ content: '# GraphQL Schema Linter\n\nRun graphql-inspector validate.',
1244
+ });
1245
+ const response = await app.request('/api/skills', {
1246
+ method: 'POST',
1247
+ headers: { 'Content-Type': 'application/json' },
1248
+ body: JSON.stringify({
1249
+ name: 'graphql-linter',
1250
+ title: 'GraphQL Schema Linter',
1251
+ category: 'database-migrations',
1252
+ description: 'Validates GraphQL schemas for deprecated fields',
1253
+ content: '# GraphQL Schema Linter\n\nRun graphql-inspector validate.',
1254
+ }),
1255
+ });
1256
+ expect(response.status).toBe(200);
1257
+ const data = await response.json();
1258
+ expect(data.success).toBe(true);
1259
+ expect(data.skill.id).toBe('graphql-linter');
1260
+ expect(data.skill.custom).toBe(true);
1261
+ });
1262
+ it('GET and POST /api/agents administer Codex-native agents', async () => {
1263
+ const agent = {
1264
+ id: 'reviewer',
1265
+ name: 'reviewer',
1266
+ category: 'general',
1267
+ description: 'Review a fixed diff.',
1268
+ developerInstructions: 'Review correctness and security.',
1269
+ sandboxMode: 'read-only',
1270
+ custom: true,
1271
+ };
1272
+ vi.mocked(agentsCatalog.getAllAgents).mockResolvedValue([agent]);
1273
+ vi.mocked(agentsCatalog.saveAgent).mockResolvedValue(agent);
1274
+ const listResponse = await app.request('/api/agents');
1275
+ expect(listResponse.status).toBe(200);
1276
+ await expect(listResponse.json()).resolves.toEqual({ agents: [agent] });
1277
+ const saveResponse = await app.request('/api/agents', {
1278
+ method: 'POST',
1279
+ headers: { 'Content-Type': 'application/json' },
1280
+ body: JSON.stringify(agent),
1281
+ });
1282
+ expect(saveResponse.status).toBe(200);
1283
+ expect(agentsCatalog.saveAgent).toHaveBeenCalledWith(agent);
1284
+ });
1285
+ it('blocks deleting resources that are still assigned to a workspace', async () => {
1286
+ const workspacePath = path.resolve('/workspaces/example');
1287
+ vi.mocked(config.loadConfig).mockResolvedValue({ workspacesDir: path.resolve('/workspaces') });
1288
+ vi.mocked(workspace.listWorkspaces).mockResolvedValue([{ id: 'example', workspacePath }]);
1289
+ vi.mocked(fs.realpath).mockImplementation(async (candidate) => path.resolve(String(candidate)));
1290
+ vi.mocked(workspace.loadWorkspaceManifest).mockResolvedValue({ workspacePath });
1291
+ vi.mocked(skillsCatalog.getWorkspaceSkillsConfig).mockResolvedValue({
1292
+ schemaVersion: 1,
1293
+ revision: 1,
1294
+ enabledSkills: ['pr-review-toolkit'],
1295
+ enabledAgents: ['reviewer'],
1296
+ enabledCategories: [],
1297
+ });
1298
+ const skillResponse = await app.request('/api/skills/pr-review-toolkit', { method: 'DELETE' });
1299
+ expect(skillResponse.status).toBe(409);
1300
+ await expect(skillResponse.json()).resolves.toMatchObject({ workspaces: ['example'] });
1301
+ expect(skillsCatalog.deleteSkill).not.toHaveBeenCalled();
1302
+ const agentResponse = await app.request('/api/agents/reviewer', { method: 'DELETE' });
1303
+ expect(agentResponse.status).toBe(409);
1304
+ await expect(agentResponse.json()).resolves.toMatchObject({ workspaces: ['example'] });
1305
+ expect(agentsCatalog.deleteAgent).not.toHaveBeenCalled();
1306
+ });
1307
+ it('requires an exact existing workspace before reading resource assignments', async () => {
1308
+ vi.mocked(config.loadConfig).mockResolvedValue({ workspacesDir: '/workspaces' });
1309
+ vi.mocked(fs.realpath).mockImplementation(async (candidate) => {
1310
+ if (String(candidate).endsWith('missing')) {
1311
+ throw Object.assign(new Error('missing'), { code: 'ENOENT' });
1312
+ }
1313
+ return String(candidate);
1314
+ });
1315
+ const response = await app.request('/api/skills/workspace/missing');
1316
+ expect(response.status).toBe(404);
1317
+ expect(skillsCatalog.getWorkspaceSkillsConfig).not.toHaveBeenCalled();
1318
+ });
1319
+ it('saves versioned skill and agent assignments for an exact workspace', async () => {
1320
+ const workspacePath = path.resolve('/workspaces/example');
1321
+ vi.mocked(config.loadConfig).mockResolvedValue({ workspacesDir: path.resolve('/workspaces') });
1322
+ vi.mocked(fs.realpath).mockImplementation(async (candidate) => path.resolve(String(candidate)));
1323
+ vi.mocked(workspace.loadWorkspaceManifest).mockResolvedValue({ workspacePath });
1324
+ vi.mocked(skillsCatalog.saveWorkspaceSkillsConfig).mockResolvedValue({
1325
+ schemaVersion: 1,
1326
+ revision: 4,
1327
+ enabledSkills: ['pr-review-toolkit'],
1328
+ enabledAgents: ['reviewer'],
1329
+ enabledCategories: [],
1330
+ });
1331
+ const response = await app.request('/api/skills/workspace/example/assign', {
1332
+ method: 'POST',
1333
+ headers: { 'Content-Type': 'application/json' },
1334
+ body: JSON.stringify({
1335
+ expectedRevision: 3,
1336
+ enabledSkills: ['pr-review-toolkit'],
1337
+ enabledAgents: ['reviewer'],
1338
+ enabledCategories: [],
1339
+ }),
1340
+ });
1341
+ expect(response.status).toBe(200);
1342
+ expect(skillsCatalog.saveWorkspaceSkillsConfig).toHaveBeenCalledWith(workspacePath, expect.objectContaining({ enabledSkills: ['pr-review-toolkit'], enabledAgents: ['reviewer'] }), 3);
1343
+ });
1344
+ it('rejects workspace assignments that reference missing catalog resources', async () => {
1345
+ const workspacePath = path.resolve('/workspaces/example');
1346
+ vi.mocked(config.loadConfig).mockResolvedValue({ workspacesDir: path.resolve('/workspaces') });
1347
+ vi.mocked(fs.realpath).mockImplementation(async (candidate) => path.resolve(String(candidate)));
1348
+ vi.mocked(workspace.loadWorkspaceManifest).mockResolvedValue({ workspacePath });
1349
+ vi.mocked(resourceService.validateResourceSelections).mockRejectedValueOnce(new resourceService.ResourceSelectionError(['missing-skill'], ['missing_agent']));
1350
+ const response = await app.request('/api/skills/workspace/example/assign', {
1351
+ method: 'POST',
1352
+ headers: { 'Content-Type': 'application/json' },
1353
+ body: JSON.stringify({
1354
+ expectedRevision: 0,
1355
+ enabledSkills: ['missing-skill'],
1356
+ enabledAgents: ['missing_agent'],
1357
+ enabledCategories: [],
1358
+ }),
1359
+ });
1360
+ expect(response.status).toBe(400);
1361
+ await expect(response.json()).resolves.toMatchObject({
1362
+ missingSkills: ['missing-skill'],
1363
+ missingAgents: ['missing_agent'],
1364
+ });
1365
+ expect(skillsCatalog.saveWorkspaceSkillsConfig).not.toHaveBeenCalled();
1366
+ });
1367
+ it('requires an assignment revision and maps stale revisions to a conflict', async () => {
1368
+ const missingRevision = await app.request('/api/skills/workspace/example/assign', {
1369
+ method: 'POST',
1370
+ headers: { 'Content-Type': 'application/json' },
1371
+ body: JSON.stringify({ enabledSkills: [], enabledAgents: [] }),
1372
+ });
1373
+ expect(missingRevision.status).toBe(400);
1374
+ expect(config.loadConfig).not.toHaveBeenCalled();
1375
+ const workspacePath = path.resolve('/workspaces/example');
1376
+ vi.mocked(config.loadConfig).mockResolvedValue({ workspacesDir: path.resolve('/workspaces') });
1377
+ vi.mocked(fs.realpath).mockImplementation(async (candidate) => path.resolve(String(candidate)));
1378
+ vi.mocked(workspace.loadWorkspaceManifest).mockResolvedValue({ workspacePath });
1379
+ const revisionError = new skillsCatalog.WorkspaceResourceRevisionError(2, 3);
1380
+ revisionError.message = 'Workspace resource configuration changed (expected revision 2, current 3).';
1381
+ vi.mocked(skillsCatalog.saveWorkspaceSkillsConfig).mockRejectedValueOnce(revisionError);
1382
+ const staleRevision = await app.request('/api/skills/workspace/example/assign', {
1383
+ method: 'POST',
1384
+ headers: { 'Content-Type': 'application/json' },
1385
+ body: JSON.stringify({ expectedRevision: 2, enabledSkills: [], enabledAgents: [] }),
1386
+ });
1387
+ expect(staleRevision.status).toBe(409);
1388
+ await expect(staleRevision.json()).resolves.toMatchObject({
1389
+ error: expect.stringContaining('expected revision 2, current 3'),
1390
+ });
1391
+ });
1392
+ });
1045
1393
  });
1046
1394
  //# sourceMappingURL=server.test.js.map