agentxchain 2.111.0 → 2.113.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.
@@ -56,7 +56,9 @@ export async function runCommand(opts) {
56
56
  const chainOpts = resolveChainOptions(opts, context.config);
57
57
  if (chainOpts.enabled) {
58
58
  console.log(chalk.cyan.bold('agentxchain run --chain'));
59
- console.log(chalk.dim(` Chain mode: enabled (max ${chainOpts.maxChains} continuations, on: ${chainOpts.chainOn.join(',')}, cooldown: ${chainOpts.cooldownSeconds}s)`));
59
+ const chainParts = [`max ${chainOpts.maxChains} continuations`, `on: ${chainOpts.chainOn.join(',')}`, `cooldown: ${chainOpts.cooldownSeconds}s`];
60
+ if (chainOpts.mission) chainParts.push(`mission: ${chainOpts.mission}`);
61
+ console.log(chalk.dim(` Chain mode: enabled (${chainParts.join(', ')})`));
60
62
  const { exitCode } = await executeChainedRun(context, opts, chainOpts, executeGovernedRun);
61
63
  process.exit(exitCode);
62
64
  }
@@ -30,6 +30,8 @@ import { loadProjectContext, loadProjectState } from '../config.js';
30
30
  import { evaluateApprovalSlaReminders } from '../notification-runner.js';
31
31
  import { readGateActionSnapshot } from './gate-action-reader.js';
32
32
  import { readChainReportSnapshot } from './chain-report-reader.js';
33
+ import { readMissionSnapshot } from './mission-reader.js';
34
+ import { readPlanSnapshot } from './plan-reader.js';
33
35
 
34
36
  const MIME_TYPES = {
35
37
  '.html': 'text/html; charset=utf-8',
@@ -470,6 +472,21 @@ export function createBridgeServer({ agentxchainDir, dashboardDir, port = 3847,
470
472
  return;
471
473
  }
472
474
 
475
+ if (pathname === '/api/missions') {
476
+ const limit = url.searchParams.get('limit') ? parseInt(url.searchParams.get('limit'), 10) : undefined;
477
+ const result = readMissionSnapshot(workspacePath, { limit });
478
+ writeJson(res, result.status, result.body);
479
+ return;
480
+ }
481
+
482
+ if (pathname === '/api/plans') {
483
+ const limit = url.searchParams.get('limit') ? parseInt(url.searchParams.get('limit'), 10) : undefined;
484
+ const missionId = url.searchParams.get('mission') || undefined;
485
+ const result = readPlanSnapshot(workspacePath, { limit, missionId });
486
+ writeJson(res, result.status, result.body);
487
+ return;
488
+ }
489
+
473
490
  if (pathname === '/api/gate-actions') {
474
491
  const result = readGateActionSnapshot(workspacePath);
475
492
  writeJson(res, result.status, result.body);
@@ -8,7 +8,7 @@
8
8
  import { watch, existsSync } from 'fs';
9
9
  import { basename, join } from 'path';
10
10
  import { EventEmitter } from 'events';
11
- import { WATCH_DIRECTORIES, resourceForRelativePath } from './state-reader.js';
11
+ import { WATCH_DIRECTORIES, RECURSIVE_WATCH_DIRECTORIES, resourcesForRelativePath } from './state-reader.js';
12
12
 
13
13
  const DEBOUNCE_MS = 100;
14
14
 
@@ -23,7 +23,7 @@ export class FileWatcher extends EventEmitter {
23
23
  this.#agentxchainDir = agentxchainDir;
24
24
  }
25
25
 
26
- #watchPath(relativeDir) {
26
+ #watchPath(relativeDir, { recursive = false } = {}) {
27
27
  if (this.#watchers.has(relativeDir)) {
28
28
  return;
29
29
  }
@@ -36,28 +36,31 @@ export class FileWatcher extends EventEmitter {
36
36
  }
37
37
 
38
38
  try {
39
- const watcher = watch(watchPath, { recursive: false }, (eventType, filename) => {
39
+ const watcher = watch(watchPath, { recursive }, (eventType, filename) => {
40
40
  if (!filename || this.#closed) return;
41
- const base = basename(filename);
42
- const relativePath = relativeDir ? `${relativeDir}/${base}` : base;
43
- const resource = resourceForRelativePath(relativePath);
41
+ // For recursive watchers, filename includes subdirectory path
42
+ const fileSegment = recursive ? filename.replace(/\\/g, '/') : basename(filename);
43
+ const relativePath = relativeDir ? `${relativeDir}/${fileSegment}` : fileSegment;
44
+ const resources = resourcesForRelativePath(relativePath);
44
45
 
45
- if (!resource) {
46
- if (!relativeDir && base === 'multirepo') {
46
+ if (resources.length === 0) {
47
+ if (!relativeDir && fileSegment === 'multirepo') {
47
48
  this.#watchPath('multirepo');
48
49
  }
49
50
  return;
50
51
  }
51
52
 
52
- if (this.#debounceTimers.has(resource)) {
53
- clearTimeout(this.#debounceTimers.get(resource));
54
- }
55
- this.#debounceTimers.set(resource, setTimeout(() => {
56
- this.#debounceTimers.delete(resource);
57
- if (!this.#closed) {
58
- this.emit('invalidate', { resource });
53
+ for (const resource of resources) {
54
+ if (this.#debounceTimers.has(resource)) {
55
+ clearTimeout(this.#debounceTimers.get(resource));
59
56
  }
60
- }, DEBOUNCE_MS));
57
+ this.#debounceTimers.set(resource, setTimeout(() => {
58
+ this.#debounceTimers.delete(resource);
59
+ if (!this.#closed) {
60
+ this.emit('invalidate', { resource });
61
+ }
62
+ }, DEBOUNCE_MS));
63
+ }
61
64
  });
62
65
 
63
66
  watcher.on('error', (err) => {
@@ -77,6 +80,9 @@ export class FileWatcher extends EventEmitter {
77
80
  for (const relativeDir of WATCH_DIRECTORIES) {
78
81
  this.#watchPath(relativeDir);
79
82
  }
83
+ for (const relativeDir of RECURSIVE_WATCH_DIRECTORIES) {
84
+ this.#watchPath(relativeDir, { recursive: true });
85
+ }
80
86
  }
81
87
 
82
88
  stop() {
@@ -0,0 +1,14 @@
1
+ import { buildMissionListSummary, loadLatestMissionSnapshot } from '../missions.js';
2
+
3
+ export function readMissionSnapshot(workspacePath, { limit } = {}) {
4
+ const missions = buildMissionListSummary(workspacePath, limit);
5
+
6
+ return {
7
+ ok: true,
8
+ status: 200,
9
+ body: {
10
+ latest: loadLatestMissionSnapshot(workspacePath),
11
+ missions,
12
+ },
13
+ };
14
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Plan reader — reads mission plan artifacts for the dashboard bridge.
3
+ *
4
+ * Provides a snapshot of all plans across all missions for the dashboard.
5
+ * Plans are advisory repo-local artifacts; this reader is not protocol-normative.
6
+ */
7
+
8
+ import { existsSync, readdirSync } from 'fs';
9
+ import { join } from 'path';
10
+ import { loadAllPlans, loadLatestPlan } from '../mission-plans.js';
11
+ import { loadAllMissionArtifacts } from '../missions.js';
12
+
13
+ /**
14
+ * Build a dashboard-ready plan snapshot across all missions.
15
+ *
16
+ * Returns newest-first plans with per-workstream launch_status and launch_records.
17
+ * If missionId is provided, returns plans for that mission only.
18
+ *
19
+ * @param {string} workspacePath - project root
20
+ * @param {{ limit?: number, missionId?: string }} options
21
+ * @returns {{ ok: boolean, status: number, body: object }}
22
+ */
23
+ export function readPlanSnapshot(workspacePath, { limit, missionId } = {}) {
24
+ const allPlans = [];
25
+
26
+ if (missionId) {
27
+ const plans = loadAllPlans(workspacePath, missionId);
28
+ allPlans.push(...plans);
29
+ } else {
30
+ const missions = loadAllMissionArtifacts(workspacePath);
31
+ for (const mission of missions) {
32
+ const plans = loadAllPlans(workspacePath, mission.mission_id);
33
+ allPlans.push(...plans);
34
+ }
35
+ }
36
+
37
+ // Sort newest-first across all missions
38
+ allPlans.sort((a, b) => {
39
+ const aTime = new Date(a.created_at || 0).getTime();
40
+ const bTime = new Date(b.created_at || 0).getTime();
41
+ if (bTime !== aTime) return bTime - aTime;
42
+ return (b.plan_id || '').localeCompare(a.plan_id || '');
43
+ });
44
+
45
+ const plans = limit ? allPlans.slice(0, limit) : allPlans;
46
+
47
+ // Derive summary for the latest plan
48
+ const latest = plans[0] || null;
49
+ let latestSummary = null;
50
+ if (latest) {
51
+ latestSummary = buildPlanSummary(latest);
52
+ }
53
+
54
+ return {
55
+ ok: true,
56
+ status: 200,
57
+ body: {
58
+ latest: latestSummary,
59
+ plans: plans.map(buildPlanSummary),
60
+ },
61
+ };
62
+ }
63
+
64
+ /**
65
+ * Build a dashboard-ready summary for a single plan.
66
+ */
67
+ function buildPlanSummary(plan) {
68
+ const workstreams = Array.isArray(plan.workstreams) ? plan.workstreams : [];
69
+ const launchRecords = Array.isArray(plan.launch_records) ? plan.launch_records : [];
70
+
71
+ const statusCounts = {};
72
+ for (const ws of workstreams) {
73
+ const status = ws.launch_status || 'unknown';
74
+ statusCounts[status] = (statusCounts[status] || 0) + 1;
75
+ }
76
+
77
+ return {
78
+ plan_id: plan.plan_id,
79
+ mission_id: plan.mission_id,
80
+ status: plan.status,
81
+ created_at: plan.created_at,
82
+ updated_at: plan.updated_at,
83
+ approved_at: plan.approved_at || null,
84
+ supersedes_plan_id: plan.supersedes_plan_id || null,
85
+ superseded_by_plan_id: plan.superseded_by_plan_id || null,
86
+ input_goal: plan.input?.goal || null,
87
+ workstream_count: workstreams.length,
88
+ launch_record_count: launchRecords.length,
89
+ workstream_status_counts: statusCounts,
90
+ workstreams: workstreams.map((ws) => ({
91
+ workstream_id: ws.workstream_id,
92
+ title: ws.title,
93
+ goal: ws.goal,
94
+ roles: ws.roles,
95
+ phases: ws.phases,
96
+ depends_on: ws.depends_on,
97
+ launch_status: ws.launch_status,
98
+ })),
99
+ launch_records: launchRecords.map((lr) => ({
100
+ workstream_id: lr.workstream_id,
101
+ chain_id: lr.chain_id,
102
+ launched_at: lr.launched_at,
103
+ completed_at: lr.completed_at || null,
104
+ status: lr.status,
105
+ terminal_reason: lr.terminal_reason || null,
106
+ })),
107
+ };
108
+ }
@@ -62,19 +62,39 @@ FILE_TO_RESOURCE[normalizeRelativePath(REPO_DECISIONS_FILE)] = '/api/repo-decisi
62
62
  export const WATCH_DIRECTORIES = [
63
63
  '',
64
64
  MULTIREPO_DIR,
65
+ 'missions',
65
66
  'reports',
66
67
  ];
67
68
 
69
+ /**
70
+ * Directories that require recursive watching because they contain
71
+ * dynamic subdirectories (e.g., missions/plans/<mission_id>/).
72
+ */
73
+ export const RECURSIVE_WATCH_DIRECTORIES = [
74
+ 'missions/plans',
75
+ ];
76
+
68
77
  export function normalizeRelativePath(filePath) {
69
78
  return normalize(filePath).replace(/\\/g, '/').replace(/^\.\/+/, '');
70
79
  }
71
80
 
72
- export function resourceForRelativePath(filePath) {
81
+ export function resourcesForRelativePath(filePath) {
73
82
  const normalized = normalizeRelativePath(filePath);
83
+ if (normalized.startsWith('missions/plans/') && normalized.endsWith('.json')) {
84
+ return ['/api/plans', '/api/missions'];
85
+ }
86
+ if (normalized.startsWith('missions/') && normalized.endsWith('.json')) {
87
+ return ['/api/missions'];
88
+ }
74
89
  if (normalized.startsWith('reports/chain-') && normalized.endsWith('.json')) {
75
- return '/api/chain-reports';
90
+ return ['/api/chain-reports', '/api/missions'];
76
91
  }
77
- return FILE_TO_RESOURCE[normalized] || null;
92
+ return FILE_TO_RESOURCE[normalized] ? [FILE_TO_RESOURCE[normalized]] : [];
93
+ }
94
+
95
+ export function resourceForRelativePath(filePath) {
96
+ const resources = resourcesForRelativePath(filePath);
97
+ return resources[0] || null;
78
98
  }
79
99
 
80
100
  /**