agentxchain 2.46.0 → 2.46.2
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/dashboard/app.js +6 -0
- package/dashboard/components/coordinator-timeouts.js +220 -0
- package/dashboard/components/timeouts.js +201 -0
- package/dashboard/index.html +2 -0
- package/package.json +1 -1
- package/scripts/publish-from-tag.sh +33 -4
- package/src/commands/init.js +1 -0
- package/src/commands/migrate.js +1 -0
- package/src/commands/status.js +49 -0
- package/src/lib/approval-policy.js +139 -0
- package/src/lib/blocked-state.js +11 -0
- package/src/lib/dashboard/bridge-server.js +14 -0
- package/src/lib/dashboard/coordinator-timeout-status.js +139 -0
- package/src/lib/dashboard/timeout-status.js +201 -0
- package/src/lib/governed-state.js +373 -25
- package/src/lib/normalized-config.js +123 -0
- package/src/lib/reference-conformance-adapter.js +1 -0
- package/src/lib/repo-observer.js +132 -1
- package/src/lib/report.js +323 -6
- package/src/lib/schema.js +47 -0
- package/src/lib/timeout-evaluator.js +234 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Approval Policy evaluator — pure library for conditional auto-approval.
|
|
3
|
+
*
|
|
4
|
+
* Sits between the gate evaluator and the state machine. When a gate returns
|
|
5
|
+
* 'awaiting_human_approval', this module evaluates the configured approval_policy
|
|
6
|
+
* to determine whether the gate should auto-approve or pause for human approval.
|
|
7
|
+
*
|
|
8
|
+
* Invariants:
|
|
9
|
+
* - Only evaluates when gateResult.action === 'awaiting_human_approval'
|
|
10
|
+
* - Can only relax the human-approval requirement, never override gate failures
|
|
11
|
+
* - --auto-approve on run overrides this entirely (handled in run.js)
|
|
12
|
+
* - Absent approval_policy → always require_human (backwards compatible)
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Evaluate approval policy for a gate result.
|
|
17
|
+
*
|
|
18
|
+
* @param {object} params
|
|
19
|
+
* @param {object} params.gateResult - from evaluatePhaseExit or evaluateRunCompletion
|
|
20
|
+
* @param {'phase_transition'|'run_completion'} params.gateType
|
|
21
|
+
* @param {object} params.state - current run state
|
|
22
|
+
* @param {object} params.config - normalized config
|
|
23
|
+
* @returns {{ action: 'auto_approve'|'require_human', matched_rule: object|null, reason: string }}
|
|
24
|
+
*/
|
|
25
|
+
export function evaluateApprovalPolicy({ gateResult, gateType, state, config }) {
|
|
26
|
+
const policy = config?.approval_policy;
|
|
27
|
+
|
|
28
|
+
// No policy configured → always require human
|
|
29
|
+
if (!policy) {
|
|
30
|
+
return { action: 'require_human', matched_rule: null, reason: 'no approval_policy configured' };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (gateType === 'run_completion') {
|
|
34
|
+
return evaluateRunCompletionPolicy({ gateResult, state, config, policy });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return evaluatePhaseTransitionPolicy({ gateResult, state, config, policy });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function evaluateRunCompletionPolicy({ gateResult, state, config, policy }) {
|
|
41
|
+
const rc = policy.run_completion;
|
|
42
|
+
if (!rc || !rc.action) {
|
|
43
|
+
return { action: 'require_human', matched_rule: null, reason: 'no run_completion policy' };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (rc.action === 'require_human') {
|
|
47
|
+
return { action: 'require_human', matched_rule: rc, reason: 'run_completion policy requires human approval' };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// action === 'auto_approve' — check conditions
|
|
51
|
+
if (rc.when) {
|
|
52
|
+
const conditionResult = checkConditions(rc.when, { gateResult, state, config });
|
|
53
|
+
if (!conditionResult.ok) {
|
|
54
|
+
return { action: 'require_human', matched_rule: rc, reason: conditionResult.reason };
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return { action: 'auto_approve', matched_rule: rc, reason: 'run_completion policy auto-approved' };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function evaluatePhaseTransitionPolicy({ gateResult, state, config, policy }) {
|
|
62
|
+
const pt = policy.phase_transitions;
|
|
63
|
+
if (!pt) {
|
|
64
|
+
return { action: 'require_human', matched_rule: null, reason: 'no phase_transitions policy' };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const fromPhase = state.phase;
|
|
68
|
+
const toPhase = gateResult.next_phase;
|
|
69
|
+
|
|
70
|
+
// Check rules (first match wins)
|
|
71
|
+
if (Array.isArray(pt.rules)) {
|
|
72
|
+
for (const rule of pt.rules) {
|
|
73
|
+
if (ruleMatches(rule, fromPhase, toPhase)) {
|
|
74
|
+
if (rule.action === 'require_human') {
|
|
75
|
+
return { action: 'require_human', matched_rule: rule, reason: `rule matched: ${fromPhase} → ${toPhase} requires human` };
|
|
76
|
+
}
|
|
77
|
+
// action === 'auto_approve' — check conditions
|
|
78
|
+
if (rule.when) {
|
|
79
|
+
const conditionResult = checkConditions(rule.when, { gateResult, state, config });
|
|
80
|
+
if (!conditionResult.ok) {
|
|
81
|
+
return { action: 'require_human', matched_rule: rule, reason: conditionResult.reason };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return { action: 'auto_approve', matched_rule: rule, reason: `rule matched: ${fromPhase} → ${toPhase} auto-approved` };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// No rule matched → use default
|
|
90
|
+
const defaultAction = pt.default || 'require_human';
|
|
91
|
+
return { action: defaultAction, matched_rule: null, reason: `default: ${defaultAction}` };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function ruleMatches(rule, fromPhase, toPhase) {
|
|
95
|
+
if (rule.from_phase && rule.from_phase !== fromPhase) return false;
|
|
96
|
+
if (rule.to_phase && rule.to_phase !== toPhase) return false;
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Check when conditions. Returns { ok, reason }.
|
|
102
|
+
*/
|
|
103
|
+
function checkConditions(when, { gateResult, state, config }) {
|
|
104
|
+
// gate_passed: gate structural predicates must have passed
|
|
105
|
+
if (when.gate_passed === true && !gateResult.passed) {
|
|
106
|
+
return { ok: false, reason: 'condition gate_passed not met: gate did not pass structural predicates' };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// roles_participated: specified roles must have accepted turns in the current phase
|
|
110
|
+
if (Array.isArray(when.roles_participated) && when.roles_participated.length > 0) {
|
|
111
|
+
const phase = state.phase;
|
|
112
|
+
const history = Array.isArray(state.history) ? state.history : [];
|
|
113
|
+
for (const roleId of when.roles_participated) {
|
|
114
|
+
const participated = history.some(
|
|
115
|
+
turn => turn.phase === phase && turn.role === roleId,
|
|
116
|
+
);
|
|
117
|
+
if (!participated) {
|
|
118
|
+
return { ok: false, reason: `condition roles_participated not met: role "${roleId}" has no accepted turn in phase "${phase}"` };
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// all_phases_visited: every routing phase must appear in history
|
|
124
|
+
if (when.all_phases_visited === true) {
|
|
125
|
+
const routingPhases = Object.keys(config.routing || {});
|
|
126
|
+
const visitedPhases = new Set(
|
|
127
|
+
(Array.isArray(state.history) ? state.history : []).map(t => t.phase),
|
|
128
|
+
);
|
|
129
|
+
// Also include current phase
|
|
130
|
+
visitedPhases.add(state.phase);
|
|
131
|
+
for (const phase of routingPhases) {
|
|
132
|
+
if (!visitedPhases.has(phase)) {
|
|
133
|
+
return { ok: false, reason: `condition all_phases_visited not met: phase "${phase}" never visited` };
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return { ok: true, reason: 'all conditions met' };
|
|
139
|
+
}
|
package/src/lib/blocked-state.js
CHANGED
|
@@ -182,6 +182,17 @@ export function deriveRecoveryDescriptor(state, config = null) {
|
|
|
182
182
|
};
|
|
183
183
|
}
|
|
184
184
|
|
|
185
|
+
if (state.blocked_on.startsWith('timeout:')) {
|
|
186
|
+
const scope = state.blocked_on.slice('timeout:'.length).trim() || 'unknown';
|
|
187
|
+
return {
|
|
188
|
+
typed_reason: 'timeout',
|
|
189
|
+
owner: 'operator',
|
|
190
|
+
recovery_action: 'agentxchain resume',
|
|
191
|
+
turn_retained: false,
|
|
192
|
+
detail: `${scope} timeout exceeded`,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
185
196
|
return {
|
|
186
197
|
typed_reason: 'unknown_block',
|
|
187
198
|
owner: 'human',
|
|
@@ -18,8 +18,10 @@ import { readResource } from './state-reader.js';
|
|
|
18
18
|
import { FileWatcher } from './file-watcher.js';
|
|
19
19
|
import { approvePendingDashboardGate } from './actions.js';
|
|
20
20
|
import { readCoordinatorBlockerSnapshot } from './coordinator-blockers.js';
|
|
21
|
+
import { readCoordinatorTimeoutStatus } from './coordinator-timeout-status.js';
|
|
21
22
|
import { readWorkflowKitArtifacts } from './workflow-kit-artifacts.js';
|
|
22
23
|
import { readConnectorHealthSnapshot } from './connectors.js';
|
|
24
|
+
import { readTimeoutStatus } from './timeout-status.js';
|
|
23
25
|
import { queryRunHistory } from '../run-history.js';
|
|
24
26
|
|
|
25
27
|
const MIME_TYPES = {
|
|
@@ -282,6 +284,12 @@ export function createBridgeServer({ agentxchainDir, dashboardDir, port = 3847 }
|
|
|
282
284
|
return;
|
|
283
285
|
}
|
|
284
286
|
|
|
287
|
+
if (pathname === '/api/coordinator/timeouts') {
|
|
288
|
+
const result = readCoordinatorTimeoutStatus(workspacePath);
|
|
289
|
+
writeJson(res, result.status, result.body);
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
|
|
285
293
|
if (pathname === '/api/workflow-kit-artifacts') {
|
|
286
294
|
const result = readWorkflowKitArtifacts(workspacePath);
|
|
287
295
|
writeJson(res, result.status, result.body);
|
|
@@ -294,6 +302,12 @@ export function createBridgeServer({ agentxchainDir, dashboardDir, port = 3847 }
|
|
|
294
302
|
return;
|
|
295
303
|
}
|
|
296
304
|
|
|
305
|
+
if (pathname === '/api/timeouts') {
|
|
306
|
+
const result = readTimeoutStatus(workspacePath);
|
|
307
|
+
writeJson(res, result.status, result.body);
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
|
|
297
311
|
if (pathname === '/api/run-history') {
|
|
298
312
|
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
299
313
|
const limit = url.searchParams.get('limit') ? parseInt(url.searchParams.get('limit'), 10) : undefined;
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { join } from 'path';
|
|
2
|
+
import { loadProjectContext, loadProjectState } from '../config.js';
|
|
3
|
+
import { loadCoordinatorConfig } from '../coordinator-config.js';
|
|
4
|
+
import { loadCoordinatorState } from '../coordinator-state.js';
|
|
5
|
+
import { readJsonlFile } from './state-reader.js';
|
|
6
|
+
import { buildTimeoutConfigSummary, evaluateDashboardTimeoutPressure, extractTimeoutEvents } from './timeout-status.js';
|
|
7
|
+
|
|
8
|
+
function getCoordinatorConfigErrorResponse(errors) {
|
|
9
|
+
const issueList = Array.isArray(errors) ? errors : [];
|
|
10
|
+
const missing = issueList.some((error) => typeof error === 'string' && error.startsWith('config_missing:'));
|
|
11
|
+
|
|
12
|
+
return {
|
|
13
|
+
ok: false,
|
|
14
|
+
status: missing ? 404 : 422,
|
|
15
|
+
body: {
|
|
16
|
+
ok: false,
|
|
17
|
+
code: missing ? 'coordinator_config_missing' : 'coordinator_config_invalid',
|
|
18
|
+
error: missing
|
|
19
|
+
? 'Coordinator config not found. Run `agentxchain multi init` first.'
|
|
20
|
+
: 'Coordinator config is invalid.',
|
|
21
|
+
errors: issueList,
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function emptyLiveTimeouts() {
|
|
27
|
+
return { exceeded: [], warnings: [] };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function readRepoTimeoutSnapshot(repoId, repo, repoRun) {
|
|
31
|
+
const context = loadProjectContext(repo.resolved_path);
|
|
32
|
+
if (!context) {
|
|
33
|
+
return {
|
|
34
|
+
repo_id: repoId,
|
|
35
|
+
path: repo.path,
|
|
36
|
+
run_id: repoRun?.run_id ?? null,
|
|
37
|
+
status: repoRun?.status ?? null,
|
|
38
|
+
phase: repoRun?.phase ?? null,
|
|
39
|
+
configured: false,
|
|
40
|
+
config: null,
|
|
41
|
+
live: null,
|
|
42
|
+
events: [],
|
|
43
|
+
error: {
|
|
44
|
+
code: 'repo_config_missing',
|
|
45
|
+
error: `Repo "${repoId}" config could not be loaded.`,
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const state = loadProjectState(context.root, context.config);
|
|
51
|
+
const agentxchainDir = join(context.root, '.agentxchain');
|
|
52
|
+
const events = extractTimeoutEvents(readJsonlFile(agentxchainDir, 'decision-ledger.jsonl'));
|
|
53
|
+
const configured = Boolean(context.config.timeouts);
|
|
54
|
+
|
|
55
|
+
if (!state) {
|
|
56
|
+
return {
|
|
57
|
+
repo_id: repoId,
|
|
58
|
+
path: repo.path,
|
|
59
|
+
run_id: repoRun?.run_id ?? null,
|
|
60
|
+
status: repoRun?.status ?? null,
|
|
61
|
+
phase: repoRun?.phase ?? null,
|
|
62
|
+
configured,
|
|
63
|
+
config: buildTimeoutConfigSummary(context.config.timeouts, context.config.routing),
|
|
64
|
+
live: configured ? emptyLiveTimeouts() : null,
|
|
65
|
+
events,
|
|
66
|
+
error: {
|
|
67
|
+
code: 'repo_state_missing',
|
|
68
|
+
error: `Repo "${repoId}" governed state is missing.`,
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const live = configured
|
|
74
|
+
? evaluateDashboardTimeoutPressure(context.config, state, new Date())
|
|
75
|
+
: null;
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
repo_id: repoId,
|
|
79
|
+
path: repo.path,
|
|
80
|
+
run_id: state.run_id ?? repoRun?.run_id ?? null,
|
|
81
|
+
status: state.status ?? repoRun?.status ?? null,
|
|
82
|
+
phase: state.phase ?? repoRun?.phase ?? null,
|
|
83
|
+
configured,
|
|
84
|
+
config: buildTimeoutConfigSummary(context.config.timeouts, context.config.routing),
|
|
85
|
+
live,
|
|
86
|
+
events,
|
|
87
|
+
error: null,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function readCoordinatorTimeoutStatus(workspacePath) {
|
|
92
|
+
const configResult = loadCoordinatorConfig(workspacePath);
|
|
93
|
+
if (!configResult.ok) {
|
|
94
|
+
return getCoordinatorConfigErrorResponse(configResult.errors);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const coordinatorState = loadCoordinatorState(workspacePath);
|
|
98
|
+
if (!coordinatorState) {
|
|
99
|
+
return {
|
|
100
|
+
ok: false,
|
|
101
|
+
status: 404,
|
|
102
|
+
body: {
|
|
103
|
+
ok: false,
|
|
104
|
+
code: 'coordinator_state_missing',
|
|
105
|
+
error: 'Coordinator state not found. Run `agentxchain multi init` first.',
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const coordinatorDir = join(workspacePath, '.agentxchain', 'multirepo');
|
|
111
|
+
const coordinatorEvents = extractTimeoutEvents(readJsonlFile(coordinatorDir, 'decision-ledger.jsonl'));
|
|
112
|
+
const repos = configResult.config.repo_order.map((repoId) => (
|
|
113
|
+
readRepoTimeoutSnapshot(repoId, configResult.config.repos[repoId], coordinatorState.repo_runs?.[repoId] ?? null)
|
|
114
|
+
));
|
|
115
|
+
|
|
116
|
+
const summary = {
|
|
117
|
+
repo_count: repos.length,
|
|
118
|
+
configured_repo_count: repos.filter((repo) => repo.configured).length,
|
|
119
|
+
repos_with_live_exceeded: repos.filter((repo) => (repo.live?.exceeded?.length || 0) > 0).length,
|
|
120
|
+
repos_with_live_warnings: repos.filter((repo) => (repo.live?.warnings?.length || 0) > 0).length,
|
|
121
|
+
repo_event_count: repos.reduce((sum, repo) => sum + repo.events.length, 0),
|
|
122
|
+
coordinator_event_count: coordinatorEvents.length,
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
ok: true,
|
|
127
|
+
status: 200,
|
|
128
|
+
body: {
|
|
129
|
+
ok: true,
|
|
130
|
+
super_run_id: coordinatorState.super_run_id ?? null,
|
|
131
|
+
status: coordinatorState.status ?? null,
|
|
132
|
+
phase: coordinatorState.phase ?? null,
|
|
133
|
+
blocked_reason: coordinatorState.blocked_reason ?? null,
|
|
134
|
+
summary,
|
|
135
|
+
coordinator_events: coordinatorEvents,
|
|
136
|
+
repos,
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Timeout status — computed dashboard endpoint.
|
|
3
|
+
*
|
|
4
|
+
* Reads agentxchain.json config, .agentxchain/state.json, and the decision
|
|
5
|
+
* ledger to derive live timeout pressure and persisted timeout events.
|
|
6
|
+
*
|
|
7
|
+
* See: TIMEOUT_DASHBOARD_SURFACE_SPEC.md
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { join } from 'path';
|
|
11
|
+
import { loadProjectContext, loadProjectState } from '../config.js';
|
|
12
|
+
import { evaluateTimeouts } from '../timeout-evaluator.js';
|
|
13
|
+
import { readJsonlFile } from './state-reader.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Extract timeout events from raw decision-ledger entries.
|
|
17
|
+
* Mirrors the shape of report.js extractTimeoutEventDigest but operates
|
|
18
|
+
* on in-memory ledger array rather than export artifacts.
|
|
19
|
+
*/
|
|
20
|
+
export function extractTimeoutEvents(ledgerEntries) {
|
|
21
|
+
if (!Array.isArray(ledgerEntries) || ledgerEntries.length === 0) return [];
|
|
22
|
+
return ledgerEntries
|
|
23
|
+
.filter((d) => typeof d?.type === 'string' && d.type.startsWith('timeout'))
|
|
24
|
+
.map((d) => ({
|
|
25
|
+
type: d.type,
|
|
26
|
+
scope: d.scope || null,
|
|
27
|
+
phase: d.phase || null,
|
|
28
|
+
turn_id: d.turn_id || null,
|
|
29
|
+
limit_minutes: typeof d.limit_minutes === 'number' ? d.limit_minutes : null,
|
|
30
|
+
elapsed_minutes: typeof d.elapsed_minutes === 'number' ? d.elapsed_minutes : null,
|
|
31
|
+
exceeded_by_minutes: typeof d.exceeded_by_minutes === 'number' ? d.exceeded_by_minutes : null,
|
|
32
|
+
action: d.action || null,
|
|
33
|
+
timestamp: d.timestamp || null,
|
|
34
|
+
}));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Flatten per-phase routing timeout overrides into a display-friendly array.
|
|
39
|
+
*/
|
|
40
|
+
export function flattenPhaseOverrides(routing) {
|
|
41
|
+
if (!routing) return [];
|
|
42
|
+
const overrides = [];
|
|
43
|
+
for (const [phase, route] of Object.entries(routing)) {
|
|
44
|
+
if (route.timeout_minutes || route.timeout_action) {
|
|
45
|
+
overrides.push({
|
|
46
|
+
phase,
|
|
47
|
+
limit_minutes: typeof route.timeout_minutes === 'number' ? route.timeout_minutes : null,
|
|
48
|
+
action: route.timeout_action || null,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return overrides;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Read timeout status for the dashboard.
|
|
57
|
+
*
|
|
58
|
+
* @param {string} workspacePath — project root (parent of .agentxchain/)
|
|
59
|
+
* @returns {{ ok: boolean, status: number, body: object }}
|
|
60
|
+
*/
|
|
61
|
+
export function buildTimeoutConfigSummary(timeouts, routing) {
|
|
62
|
+
if (!timeouts) return null;
|
|
63
|
+
return {
|
|
64
|
+
per_turn_minutes: timeouts.per_turn_minutes || null,
|
|
65
|
+
per_phase_minutes: timeouts.per_phase_minutes || null,
|
|
66
|
+
per_run_minutes: timeouts.per_run_minutes || null,
|
|
67
|
+
action: timeouts.action || 'escalate',
|
|
68
|
+
phase_overrides: flattenPhaseOverrides(routing),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function emptyLiveTimeouts() {
|
|
73
|
+
return { exceeded: [], warnings: [] };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function getActiveTurns(state) {
|
|
77
|
+
if (!state?.active_turns || typeof state.active_turns !== 'object' || Array.isArray(state.active_turns)) {
|
|
78
|
+
return [];
|
|
79
|
+
}
|
|
80
|
+
return Object.values(state.active_turns).filter((turn) => turn && typeof turn === 'object');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function annotateTurnTimeout(result, state, turn) {
|
|
84
|
+
return {
|
|
85
|
+
...result,
|
|
86
|
+
phase: result.phase || state?.phase || null,
|
|
87
|
+
turn_id: turn?.turn_id || null,
|
|
88
|
+
role_id: turn?.assigned_role || null,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function evaluateDashboardTimeoutPressure(config, state, now = new Date()) {
|
|
93
|
+
if (!config?.timeouts || state?.status !== 'active') {
|
|
94
|
+
return emptyLiveTimeouts();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const base = evaluateTimeouts({ config, state, now });
|
|
98
|
+
const live = {
|
|
99
|
+
exceeded: [...base.exceeded],
|
|
100
|
+
warnings: [...base.warnings],
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
for (const turn of getActiveTurns(state)) {
|
|
104
|
+
const turnEval = evaluateTimeouts({ config, state, turn, now });
|
|
105
|
+
for (const item of turnEval.exceeded) {
|
|
106
|
+
if (item.scope === 'turn') {
|
|
107
|
+
live.exceeded.push(annotateTurnTimeout(item, state, turn));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
for (const item of turnEval.warnings) {
|
|
111
|
+
if (item.scope === 'turn') {
|
|
112
|
+
live.warnings.push(annotateTurnTimeout(item, state, turn));
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return live;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function loadDashboardTimeoutContext(workspacePath) {
|
|
121
|
+
const context = loadProjectContext(workspacePath);
|
|
122
|
+
if (!context || context.config?.protocol_mode !== 'governed') {
|
|
123
|
+
return {
|
|
124
|
+
ok: false,
|
|
125
|
+
status: 404,
|
|
126
|
+
body: {
|
|
127
|
+
ok: false,
|
|
128
|
+
code: 'config_missing',
|
|
129
|
+
error: 'Project config not found. Run `agentxchain init --governed` first.',
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const { root, config } = context;
|
|
135
|
+
const state = loadProjectState(root, config);
|
|
136
|
+
const agentxchainDir = join(root, '.agentxchain');
|
|
137
|
+
if (!state) {
|
|
138
|
+
return {
|
|
139
|
+
ok: false,
|
|
140
|
+
status: 404,
|
|
141
|
+
body: {
|
|
142
|
+
ok: false,
|
|
143
|
+
code: 'state_missing',
|
|
144
|
+
error: 'Run state not found. Run `agentxchain init --governed` first.',
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
ok: true,
|
|
151
|
+
root,
|
|
152
|
+
config,
|
|
153
|
+
state,
|
|
154
|
+
agentxchainDir,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function readTimeoutStatus(workspacePath) {
|
|
159
|
+
const contextResult = loadDashboardTimeoutContext(workspacePath);
|
|
160
|
+
if (!contextResult.ok) {
|
|
161
|
+
return contextResult;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const { config, state, agentxchainDir } = contextResult;
|
|
165
|
+
const timeouts = config.timeouts;
|
|
166
|
+
if (!timeouts) {
|
|
167
|
+
return {
|
|
168
|
+
ok: true,
|
|
169
|
+
status: 200,
|
|
170
|
+
body: {
|
|
171
|
+
ok: true,
|
|
172
|
+
configured: false,
|
|
173
|
+
config: null,
|
|
174
|
+
live: null,
|
|
175
|
+
events: [],
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Config summary
|
|
181
|
+
const configSummary = buildTimeoutConfigSummary(timeouts, config.routing);
|
|
182
|
+
|
|
183
|
+
// Live timeout evaluation — only meaningful when the run is active
|
|
184
|
+
const live = evaluateDashboardTimeoutPressure(config, state, new Date());
|
|
185
|
+
|
|
186
|
+
// Persisted timeout events from the decision ledger
|
|
187
|
+
const ledger = readJsonlFile(agentxchainDir, 'decision-ledger.jsonl');
|
|
188
|
+
const events = extractTimeoutEvents(ledger);
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
ok: true,
|
|
192
|
+
status: 200,
|
|
193
|
+
body: {
|
|
194
|
+
ok: true,
|
|
195
|
+
configured: true,
|
|
196
|
+
config: configSummary,
|
|
197
|
+
live,
|
|
198
|
+
events,
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
}
|