@straiffi/archon 1.0.13 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -40,6 +40,7 @@ import { JiraPlanningReferenceError, resolveJiraPlanningDescription } from './li
40
40
  import { getBundlePullRequestRecord, hasOpenBundlePullRequest, serializeBundlePullRequest, shouldSyncBundlePullRequest, upsertBundlePullRequest, } from './lib/bundlePullRequests.js';
41
41
  import { countInboundProjectLinks, getEffectiveProject, getProjectById, getProjectTicketStats, hasProjectRunIde, listProjects, replaceProjectLinks, serializeProject, updateProjectActiveTarget, validateProjectPayload, validateRepoPath, } from './lib/projects.js';
42
42
  import { getActiveRunStatus, getActiveRunStatusForProject, getPreviewStatusForProject, hasRunConfig, isRunning, openIde, openTicketIdeInWorkspace, resolveProjectRunContextKey, runProject, runSetupCommandsInWorkspace, runTicketInWorkspace, stopAllTickets, stopProjectRun, stopTicket } from './lib/run.js';
43
+ import { clearAllTestSessions, createTestSession, deleteTestSession, discoverTestsForSession, doesTestFileExistForSession, findProjectTestCommand, getTestSession, isTestSelectionSupported, startTestSessionRun, stopTestSessionRun } from './lib/testSessions.js';
43
44
  import { getActiveBundleStageChangeHead, undoTicketStage } from './lib/ticketUndo.js';
44
45
  import { closeAllTerminalSessions, createProjectTerminalSession, createTerminalSessionForWorkspace, destroyTerminalSessionById, registerTerminalSocketHandlers, } from './lib/terminal.js';
45
46
  import { countBundleTickets, createTicketRecord, createBundle, deleteBundle, ensureProjectRootBundle, autoParkTicketIfStale, clearAutoParkDismissalIfNeeded, assignTicketDoneOrder, assignTicketLaneOrder, getBundle, getBundleByName, getBundleByBranch, getBundleRepresentativeTicketContext, isProjectRootBundle, getTicket, isBundledTicket, listTicketsInRunContext, listBundles, listBoardTicketEnrichment, listTickets, resolveTicketBranch, resolveTicketTool, } from './lib/tickets.js';
@@ -697,6 +698,121 @@ const resolveProjectTargetGitWorkspace = (project) => {
697
698
  }),
698
699
  };
699
700
  };
701
+ const stripRepeatedSelfSuffix = (label) => {
702
+ let nextLabel = label.trim();
703
+ while (nextLabel !== '') {
704
+ const match = nextLabel.match(/^(.*?) \(([^()]+)\)$/);
705
+ if (!match) {
706
+ return nextLabel;
707
+ }
708
+ const prefix = match[1]?.trim() ?? '';
709
+ const suffix = match[2]?.trim() ?? '';
710
+ const normalizedPrefix = prefix.toLowerCase();
711
+ const normalizedSuffix = suffix.toLowerCase();
712
+ if (normalizedPrefix === normalizedSuffix) {
713
+ nextLabel = prefix;
714
+ continue;
715
+ }
716
+ if (normalizedPrefix.endsWith(` (${normalizedSuffix})`)) {
717
+ nextLabel = prefix;
718
+ continue;
719
+ }
720
+ return nextLabel;
721
+ }
722
+ return label.trim();
723
+ };
724
+ const stripRepeatedBranchSuffix = (label, branch) => {
725
+ const trimmedLabel = stripRepeatedSelfSuffix(label);
726
+ const trimmedBranch = branch.trim();
727
+ if (trimmedLabel === '' || trimmedBranch === '') {
728
+ return trimmedLabel;
729
+ }
730
+ const suffix = ` (${trimmedBranch})`;
731
+ let nextLabel = trimmedLabel;
732
+ while (nextLabel.toLowerCase().endsWith(suffix.toLowerCase())) {
733
+ nextLabel = nextLabel.slice(0, -suffix.length).trimEnd();
734
+ }
735
+ return nextLabel || trimmedLabel;
736
+ };
737
+ const getBundleTestTargetLabel = (bundle) => {
738
+ const displayName = stripRepeatedSelfSuffix(bundle.name || bundle.branch || '');
739
+ return bundle.branch ? stripRepeatedBranchSuffix(displayName, bundle.branch) : displayName;
740
+ };
741
+ const resolveExplicitProjectTestWorkspace = (project, targetKind, targetBundleId) => {
742
+ if (targetKind === 'bundle') {
743
+ if (!targetBundleId) {
744
+ return { error: 'target_bundle_id is required for bundle test runs', status: 400 };
745
+ }
746
+ const bundle = getBundle(targetBundleId, project.id);
747
+ if (!bundle || isProjectRootBundle(bundle)) {
748
+ return { error: 'Selected bundle target no longer exists', status: 409 };
749
+ }
750
+ const cwd = resolveExistingWorktreePath(bundle.branch, project);
751
+ if (!cwd) {
752
+ return { error: 'Selected bundle target does not have a prepared worktree', status: 409 };
753
+ }
754
+ return {
755
+ project,
756
+ cwd,
757
+ target_kind: 'bundle',
758
+ target_bundle_id: bundle.id,
759
+ target_label: getBundleTestTargetLabel(bundle),
760
+ };
761
+ }
762
+ const workspace = resolveProjectRootGitWorkspace(project);
763
+ if ('error' in workspace) {
764
+ return workspace;
765
+ }
766
+ return {
767
+ project,
768
+ cwd: workspace.cwd,
769
+ target_kind: 'repo_root',
770
+ target_bundle_id: null,
771
+ target_label: `Repo root (${workspace.branch})`,
772
+ };
773
+ };
774
+ const resolveTicketTestWorkspace = (ticket) => {
775
+ const workspace = resolveTicketGitWorkspace(ticket);
776
+ if ('error' in workspace) {
777
+ return workspace;
778
+ }
779
+ const project = workspace.project;
780
+ if (ticket.worktree_bundle_id) {
781
+ const bundle = getBundle(ticket.worktree_bundle_id, project.id);
782
+ if (bundle && !isProjectRootBundle(bundle)) {
783
+ return {
784
+ project,
785
+ cwd: workspace.cwd,
786
+ target_kind: 'bundle',
787
+ target_bundle_id: bundle.id,
788
+ target_label: getBundleTestTargetLabel(bundle),
789
+ };
790
+ }
791
+ }
792
+ return {
793
+ project,
794
+ cwd: workspace.cwd,
795
+ target_kind: 'repo_root',
796
+ target_bundle_id: null,
797
+ target_label: `Repo root (${workspace.branch})`,
798
+ };
799
+ };
800
+ const parseTestTargetSelection = (value) => {
801
+ if (!value || typeof value !== 'object' || Array.isArray(value) || !hasOwn(value, 'kind')) {
802
+ return null;
803
+ }
804
+ const selection = value;
805
+ if (selection.kind === 'all') {
806
+ return { kind: 'all' };
807
+ }
808
+ if (selection.kind === 'file' && typeof selection.path === 'string') {
809
+ return { kind: 'file', path: selection.path };
810
+ }
811
+ if (selection.kind === 'case' && typeof selection.path === 'string' && typeof selection.name === 'string') {
812
+ return { kind: 'case', path: selection.path, name: selection.name };
813
+ }
814
+ return null;
815
+ };
700
816
  const resolveProjectFileSuggestionWorkspace = (project, options = {}) => {
701
817
  const ticketId = options.ticketId?.trim() || null;
702
818
  if (ticketId) {
@@ -3224,7 +3340,7 @@ app.post('/projects', (req, res) => {
3224
3340
  return res.status(400).json({ error: validation.error });
3225
3341
  }
3226
3342
  const projectCount = Number(db.prepare('SELECT COUNT(*) AS count FROM projects').get()?.count ?? 0);
3227
- const { name, repo_path, linked_project_ids, run_setup, run_services, run_ide, preview_service_name, preview_path, preview_capability_mode, helper_model, helper_variant, commit_message_rules, auto_park_stale_tickets, memory_enabled, worktree_sync, } = validation.values;
3343
+ const { name, repo_path, linked_project_ids, run_setup, run_services, test_commands, run_ide, preview_service_name, preview_path, preview_capability_mode, helper_model, helper_variant, commit_message_rules, auto_park_stale_tickets, memory_enabled, worktree_sync, } = validation.values;
3228
3344
  const id = randomUUID();
3229
3345
  db.prepare(`
3230
3346
  INSERT INTO projects (
@@ -3233,6 +3349,7 @@ app.post('/projects', (req, res) => {
3233
3349
  repo_path,
3234
3350
  run_setup,
3235
3351
  run_services,
3352
+ test_commands,
3236
3353
  run_ide,
3237
3354
  preview_service_name,
3238
3355
  preview_path,
@@ -3244,8 +3361,8 @@ app.post('/projects', (req, res) => {
3244
3361
  memory_enabled,
3245
3362
  worktree_sync
3246
3363
  )
3247
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
3248
- `).run(id, name, repo_path, run_setup, run_services, run_ide ?? null, preview_service_name ?? null, preview_path ?? null, preview_capability_mode ?? null, helper_model ?? null, helper_variant ?? null, commit_message_rules ?? null, auto_park_stale_tickets ?? 0, memory_enabled ?? 0, worktree_sync ?? null);
3364
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
3365
+ `).run(id, name, repo_path, run_setup, run_services, test_commands, run_ide ?? null, preview_service_name ?? null, preview_path ?? null, preview_capability_mode ?? null, helper_model ?? null, helper_variant ?? null, commit_message_rules ?? null, auto_park_stale_tickets ?? 0, memory_enabled ?? 0, worktree_sync ?? null);
3249
3366
  replaceProjectLinks(id, linked_project_ids ?? []);
3250
3367
  ensureProjectRootBundle(id);
3251
3368
  if (projectCount === 0) {
@@ -4122,13 +4239,14 @@ app.patch('/projects/:id', (req, res) => {
4122
4239
  });
4123
4240
  }
4124
4241
  }
4125
- const { name, repo_path, linked_project_ids, run_setup, run_services, run_ide, preview_service_name, preview_path, preview_capability_mode, helper_model, helper_variant, commit_message_rules, auto_park_stale_tickets, memory_enabled, worktree_sync, } = validation.values;
4242
+ const { name, repo_path, linked_project_ids, run_setup, run_services, test_commands, run_ide, preview_service_name, preview_path, preview_capability_mode, helper_model, helper_variant, commit_message_rules, auto_park_stale_tickets, memory_enabled, worktree_sync, } = validation.values;
4126
4243
  db.prepare(`
4127
4244
  UPDATE projects SET
4128
4245
  name = ?,
4129
4246
  repo_path = ?,
4130
4247
  run_setup = ?,
4131
4248
  run_services = ?,
4249
+ test_commands = ?,
4132
4250
  run_ide = ?,
4133
4251
  preview_service_name = ?,
4134
4252
  preview_path = ?,
@@ -4141,7 +4259,7 @@ app.patch('/projects/:id', (req, res) => {
4141
4259
  worktree_sync = ?,
4142
4260
  updated_at = CURRENT_TIMESTAMP
4143
4261
  WHERE id = ?
4144
- `).run(name ?? project.name, repo_path ?? project.repo_path, hasOwn(validation.values, 'run_setup') ? run_setup : project.run_setup, hasOwn(validation.values, 'run_services') ? run_services : project.run_services, hasOwn(validation.values, 'run_ide') ? run_ide : project.run_ide, hasOwn(validation.values, 'preview_service_name') ? preview_service_name : project.preview_service_name, hasOwn(validation.values, 'preview_path') ? preview_path : project.preview_path, hasOwn(validation.values, 'preview_capability_mode') ? preview_capability_mode : project.preview_capability_mode, hasOwn(validation.values, 'helper_model') ? helper_model : project.helper_model, hasOwn(validation.values, 'helper_variant') ? helper_variant : project.helper_variant, hasOwn(validation.values, 'commit_message_rules') ? commit_message_rules : project.commit_message_rules, hasOwn(validation.values, 'auto_park_stale_tickets') ? auto_park_stale_tickets : project.auto_park_stale_tickets, hasOwn(validation.values, 'memory_enabled') ? memory_enabled : project.memory_enabled, hasOwn(validation.values, 'worktree_sync') ? worktree_sync : project.worktree_sync, req.params.id);
4262
+ `).run(name ?? project.name, repo_path ?? project.repo_path, hasOwn(validation.values, 'run_setup') ? run_setup : project.run_setup, hasOwn(validation.values, 'run_services') ? run_services : project.run_services, hasOwn(validation.values, 'test_commands') ? test_commands : project.test_commands, hasOwn(validation.values, 'run_ide') ? run_ide : project.run_ide, hasOwn(validation.values, 'preview_service_name') ? preview_service_name : project.preview_service_name, hasOwn(validation.values, 'preview_path') ? preview_path : project.preview_path, hasOwn(validation.values, 'preview_capability_mode') ? preview_capability_mode : project.preview_capability_mode, hasOwn(validation.values, 'helper_model') ? helper_model : project.helper_model, hasOwn(validation.values, 'helper_variant') ? helper_variant : project.helper_variant, hasOwn(validation.values, 'commit_message_rules') ? commit_message_rules : project.commit_message_rules, hasOwn(validation.values, 'auto_park_stale_tickets') ? auto_park_stale_tickets : project.auto_park_stale_tickets, hasOwn(validation.values, 'memory_enabled') ? memory_enabled : project.memory_enabled, hasOwn(validation.values, 'worktree_sync') ? worktree_sync : project.worktree_sync, req.params.id);
4145
4263
  if (hasOwn(validation.values, 'linked_project_ids')) {
4146
4264
  replaceProjectLinks(project.id, linked_project_ids ?? []);
4147
4265
  }
@@ -4902,6 +5020,141 @@ app.post('/projects/:id/run', (req, res) => {
4902
5020
  targetBundleId: workspace.target_bundle_id,
4903
5021
  });
4904
5022
  });
5023
+ app.post('/projects/:id/test-sessions', (req, res) => {
5024
+ const project = getProjectById(req.params.id);
5025
+ if (!project) {
5026
+ return res.status(404).json({ error: 'Not found' });
5027
+ }
5028
+ const body = req.body && typeof req.body === 'object' && !Array.isArray(req.body) ? req.body : null;
5029
+ const targetKind = body?.target_kind === 'bundle'
5030
+ ? 'bundle'
5031
+ : body?.target_kind === 'repo_root'
5032
+ ? 'repo_root'
5033
+ : null;
5034
+ if (!targetKind) {
5035
+ return res.status(400).json({ error: 'target_kind must be repo_root or bundle' });
5036
+ }
5037
+ const targetBundleId = typeof body?.target_bundle_id === 'string' ? body.target_bundle_id : null;
5038
+ const workspace = resolveExplicitProjectTestWorkspace(project, targetKind, targetBundleId);
5039
+ if ('error' in workspace) {
5040
+ return res.status(Number(workspace.status)).json({ error: workspace.error });
5041
+ }
5042
+ return res.status(201).json(createTestSession({
5043
+ projectId: project.id,
5044
+ targetKind: workspace.target_kind,
5045
+ targetBundleId: workspace.target_bundle_id,
5046
+ cwd: workspace.cwd,
5047
+ targetLabel: workspace.target_label,
5048
+ }));
5049
+ });
5050
+ app.post('/tickets/:id/test-sessions', (req, res) => {
5051
+ const ticket = getTicket(req.params.id);
5052
+ if (!ticket) {
5053
+ return res.status(404).json({ error: 'Not found' });
5054
+ }
5055
+ if (!matchesTicketProjectContext(ticket, req)) {
5056
+ return res.status(404).json({ error: 'Not found' });
5057
+ }
5058
+ const workspace = resolveTicketTestWorkspace(ticket);
5059
+ if ('error' in workspace) {
5060
+ return res.status(workspace.status).json({ error: workspace.error });
5061
+ }
5062
+ return res.status(201).json(createTestSession({
5063
+ projectId: workspace.project.id,
5064
+ ticketId: ticket.id,
5065
+ targetKind: workspace.target_kind,
5066
+ targetBundleId: workspace.target_bundle_id,
5067
+ cwd: workspace.cwd,
5068
+ targetLabel: workspace.target_label,
5069
+ }));
5070
+ });
5071
+ app.get('/test-sessions/:id/discovery', (req, res) => {
5072
+ const session = getTestSession(req.params.id);
5073
+ if (!session) {
5074
+ return res.status(404).json({ error: 'Not found' });
5075
+ }
5076
+ const commandId = typeof req.query.command_id === 'string' ? req.query.command_id.trim() : '';
5077
+ let command = null;
5078
+ if (commandId !== '') {
5079
+ const project = getProjectById(session.project_id);
5080
+ if (!project) {
5081
+ return res.status(404).json({ error: 'Project not found' });
5082
+ }
5083
+ command = findProjectTestCommand(project, commandId);
5084
+ if (!command) {
5085
+ return res.status(404).json({ error: 'Test command not found' });
5086
+ }
5087
+ }
5088
+ try {
5089
+ const supportsGranularSelection = command
5090
+ ? isTestSelectionSupported(command, { kind: 'file', path: '' }, session.cwd)
5091
+ : false;
5092
+ return res.json({
5093
+ files: discoverTestsForSession(session.id, command),
5094
+ supports_granular_selection: supportsGranularSelection,
5095
+ });
5096
+ }
5097
+ catch (error) {
5098
+ return res.status(500).json({ error: getErrorMessage(error) });
5099
+ }
5100
+ });
5101
+ app.post('/test-sessions/:id/run', async (req, res) => {
5102
+ const session = getTestSession(req.params.id);
5103
+ if (!session) {
5104
+ return res.status(404).json({ error: 'Not found' });
5105
+ }
5106
+ const project = getProjectById(session.project_id);
5107
+ if (!project) {
5108
+ return res.status(404).json({ error: 'Project not found' });
5109
+ }
5110
+ const body = req.body && typeof req.body === 'object' && !Array.isArray(req.body) ? req.body : null;
5111
+ const commandId = typeof body?.command_id === 'string' ? body.command_id.trim() : '';
5112
+ if (commandId === '') {
5113
+ return res.status(400).json({ error: 'command_id is required' });
5114
+ }
5115
+ const selection = parseTestTargetSelection(body?.selection);
5116
+ if (!selection) {
5117
+ return res.status(400).json({ error: 'selection must be all, file, or case' });
5118
+ }
5119
+ const command = findProjectTestCommand(project, commandId);
5120
+ if (!command) {
5121
+ return res.status(404).json({ error: 'Test command not found' });
5122
+ }
5123
+ if (!isTestSelectionSupported(command, selection, session.cwd)) {
5124
+ return res.status(409).json({ error: 'The selected test command only supports running the full suite.' });
5125
+ }
5126
+ if (selection.kind !== 'all' && !doesTestFileExistForSession(session.id, selection.path, command)) {
5127
+ return res.status(404).json({ error: 'Selected test file no longer exists in this target workspace.' });
5128
+ }
5129
+ try {
5130
+ return res.json(await startTestSessionRun(session.id, project, {
5131
+ command,
5132
+ selection,
5133
+ }, io));
5134
+ }
5135
+ catch (error) {
5136
+ return res.status(409).json({ error: getErrorMessage(error) });
5137
+ }
5138
+ });
5139
+ app.post('/test-sessions/:id/stop', async (req, res) => {
5140
+ const session = getTestSession(req.params.id);
5141
+ if (!session) {
5142
+ return res.status(404).json({ error: 'Not found' });
5143
+ }
5144
+ try {
5145
+ return res.json(await stopTestSessionRun(session.id, io));
5146
+ }
5147
+ catch (error) {
5148
+ return res.status(500).json({ error: getErrorMessage(error) });
5149
+ }
5150
+ });
5151
+ app.delete('/test-sessions/:id', async (req, res) => {
5152
+ const deleted = await deleteTestSession(req.params.id, io);
5153
+ if (!deleted) {
5154
+ return res.status(404).json({ error: 'Not found' });
5155
+ }
5156
+ return res.status(204).send();
5157
+ });
4905
5158
  app.post('/tickets/:id/terminal', (req, res) => {
4906
5159
  const ticket = getTicket(req.params.id);
4907
5160
  if (!ticket) {
@@ -5219,6 +5472,7 @@ export const startServer = ({ port = DEFAULT_PORT, host } = {}) => {
5219
5472
  });
5220
5473
  };
5221
5474
  export const shutdownServer = async (signal) => {
5475
+ await clearAllTestSessions(io);
5222
5476
  await shutdownRealtimeServer({
5223
5477
  signal,
5224
5478
  io,