Skip to content

Commit c98cb28

Browse files
committed
fix: harden lifecycle and fire run invariants
1 parent 801c748 commit c98cb28

33 files changed

+1144
-43
lines changed

typescript/clients/web-ag-ui/apps/agent-clmm/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
"typecheck": "tsc --noEmit --project tsconfig.eslint.json",
2121
"format": "prettier \"{src,tests}/**/*.{ts,tsx,js,jsx}\" --write",
2222
"format:check": "prettier \"{src,tests}/**/*.{ts,tsx,js,jsx}\" --check",
23+
"transitions:analyze": "tsx scripts/analyze-state-transitions.ts",
2324
"workflow:demo": "tsx --env-file=.env ./scripts/run-camelot-demo.ts",
2425
"wallet:withdraw": "tsx --env-file=.env scripts/manualWithdraw.ts"
2526
},
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import { existsSync, readFileSync } from 'node:fs';
2+
import process from 'node:process';
3+
import { resolve } from 'node:path';
4+
5+
import {
6+
type ClmmTransitionSource,
7+
evaluateTransitionBudget,
8+
parseTransitionLogNdjson,
9+
summarizeTransitionChurn,
10+
type TransitionBudget,
11+
} from '../src/workflow/stateTransitionAnalysis.js';
12+
13+
const DEFAULT_LOG_PATH = './.logs/clmm-state-transitions.ndjson';
14+
15+
type ParsedArgs = {
16+
logPath: string;
17+
budget: TransitionBudget;
18+
json: boolean;
19+
sources: ClmmTransitionSource[];
20+
};
21+
22+
function parseNumberFlag(value: string | undefined, fallback: number): number {
23+
if (!value) {
24+
return fallback;
25+
}
26+
const parsed = Number(value);
27+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
28+
}
29+
30+
function parseSourcesFlag(rawValue: string | undefined): ClmmTransitionSource[] {
31+
if (!rawValue || rawValue === 'reducer') {
32+
return ['threadReducer'];
33+
}
34+
if (rawValue === 'emit') {
35+
return ['applyThreadPatch'];
36+
}
37+
if (rawValue === 'both') {
38+
return ['threadReducer', 'applyThreadPatch'];
39+
}
40+
return ['threadReducer'];
41+
}
42+
43+
function parseArgs(argv: string[]): ParsedArgs {
44+
const args: Record<string, string | undefined> = {};
45+
46+
for (let index = 0; index < argv.length; index += 1) {
47+
const raw = argv[index];
48+
if (!raw.startsWith('--')) {
49+
continue;
50+
}
51+
const withoutPrefix = raw.slice(2);
52+
const [key, inlineValue] = withoutPrefix.split('=', 2);
53+
if (!key) {
54+
continue;
55+
}
56+
if (inlineValue !== undefined) {
57+
args[key] = inlineValue;
58+
continue;
59+
}
60+
const next = argv[index + 1];
61+
if (next && !next.startsWith('--')) {
62+
args[key] = next;
63+
index += 1;
64+
} else {
65+
args[key] = 'true';
66+
}
67+
}
68+
69+
return {
70+
logPath: resolve(args['log'] ?? process.env['CLMM_STATE_TRANSITION_LOG_PATH'] ?? DEFAULT_LOG_PATH),
71+
budget: {
72+
maxInputRequiredEntries: parseNumberFlag(args['max-input-required-entries'], 3),
73+
maxWorkingToInputRequired: parseNumberFlag(args['max-working-to-input-required'], 3),
74+
maxInputRequiredToWorking: parseNumberFlag(args['max-input-required-to-working'], 3),
75+
maxOnboardingRegressions: parseNumberFlag(args['max-onboarding-regressions'], 0),
76+
},
77+
json: args['json'] === 'true',
78+
sources: parseSourcesFlag(args['sources']),
79+
};
80+
}
81+
82+
function printWriterCounts(title: string, counts: Record<string, number>): void {
83+
const rows = Object.entries(counts).sort((left, right) => right[1] - left[1]);
84+
console.log(`${title}:`);
85+
if (rows.length === 0) {
86+
console.log(' (none)');
87+
return;
88+
}
89+
for (const [writer, count] of rows) {
90+
console.log(` ${writer} -> ${count}`);
91+
}
92+
}
93+
94+
function run(): number {
95+
const parsed = parseArgs(process.argv.slice(2));
96+
97+
if (!existsSync(parsed.logPath)) {
98+
console.error(`[clmm-transition-analysis] Log file not found: ${parsed.logPath}`);
99+
return 1;
100+
}
101+
102+
const raw = readFileSync(parsed.logPath, 'utf8');
103+
const entries = parseTransitionLogNdjson(raw);
104+
const summary = summarizeTransitionChurn(entries, { sources: parsed.sources });
105+
const evaluation = evaluateTransitionBudget(summary, parsed.budget);
106+
107+
if (parsed.json) {
108+
console.log(
109+
JSON.stringify(
110+
{
111+
logPath: parsed.logPath,
112+
budget: parsed.budget,
113+
sources: parsed.sources,
114+
summary,
115+
evaluation,
116+
},
117+
null,
118+
2,
119+
),
120+
);
121+
} else {
122+
console.log(`[clmm-transition-analysis] logPath=${parsed.logPath}`);
123+
console.log(`[clmm-transition-analysis] sources=${parsed.sources.join(',')}`);
124+
console.log(`[clmm-transition-analysis] entries=${summary.totalEntries}`);
125+
console.log(
126+
`[clmm-transition-analysis] input-required entries=${summary.transitions.inputRequiredEntries}`,
127+
);
128+
console.log(
129+
`[clmm-transition-analysis] working -> input-required=${summary.transitions.workingToInputRequired}`,
130+
);
131+
console.log(
132+
`[clmm-transition-analysis] input-required -> working=${summary.transitions.inputRequiredToWorking}`,
133+
);
134+
console.log(
135+
`[clmm-transition-analysis] onboarding regressions=${summary.onboardingRegressions.length}`,
136+
);
137+
printWriterCounts(
138+
'[clmm-transition-analysis] writer attribution (working -> input-required)',
139+
summary.transitions.byWriter.workingToInputRequired,
140+
);
141+
printWriterCounts(
142+
'[clmm-transition-analysis] writer attribution (input-required -> working)',
143+
summary.transitions.byWriter.inputRequiredToWorking,
144+
);
145+
146+
if (!evaluation.passes) {
147+
console.log('[clmm-transition-analysis] budget violations:');
148+
for (const violation of evaluation.violations) {
149+
console.log(` - ${violation}`);
150+
}
151+
} else {
152+
console.log('[clmm-transition-analysis] budget check passed');
153+
}
154+
}
155+
156+
return evaluation.passes ? 0 : 1;
157+
}
158+
159+
process.exitCode = run();

0 commit comments

Comments
 (0)