start-command 0.39.2 → 0.41.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.
- package/CHANGELOG.md +26 -0
- package/eslint.config.mjs +32 -0
- package/package.json +9 -10
- package/src/bin/cli.js +18 -6
- package/src/lib/args-parser.js +33 -0
- package/src/lib/command-stream.js +19 -5
- package/src/lib/execution-chain.js +2 -2
- package/src/lib/execution-store.js +62 -44
- package/src/lib/formal-ai-cli.js +237 -0
- package/src/lib/formal-ai-client.js +166 -0
- package/src/lib/formal-ai-policy.js +501 -0
- package/src/lib/formal-ai-runtime.js +304 -0
- package/src/lib/formal-ai-tool.js +182 -0
- package/src/lib/formal-ai.js +329 -0
- package/src/lib/log-sanitizer.js +16 -137
- package/src/lib/usage.js +3 -1
- package/test/ci-workflow-invariants.js +18 -0
- package/test/command-stream-log.js +85 -0
- package/test/dependency-freshness.mjs +552 -0
- package/test/execution-store-clink.js +152 -0
- package/test/execution-store.js +49 -5
- package/test/fixtures/executions-lino-objects-codec-0.4.0.lino +1 -0
- package/test/fixtures/executions-lino-objects-codec-0.9.3.lino +26 -0
- package/test/fixtures/formal-ai/fake-server.cjs +17 -0
- package/test/fixtures/formal-ai/helpers.cjs +189 -0
- package/test/fixtures/formal-ai/receipts.json +776 -0
- package/test/fixtures/formal-ai/replay.cjs +144 -0
- package/test/javascript-first-ci.js +29 -0
- package/test/log-sanitizer.js +116 -47
- package/test/regression-187.js +4 -0
- package/test/regression-224.js +4 -2
- package/test/regression-231.js +20 -0
- package/test/regression-255-cli.js +294 -0
- package/test/regression-255-e2e.js +90 -0
- package/test/regression-255-loop.js +475 -0
- package/test/regression-255-policy.js +436 -0
- package/test/release-git.mjs +2 -1
- package/test/shared-golden.js +3 -0
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI side of the experimental `--use-formal-ai` mode (issue #255): resolves
|
|
3
|
+
* the bounds and the runtime, tracks the execution like a direct run (start
|
|
4
|
+
* and finish blocks, log, execution record, START_* environment) and writes
|
|
5
|
+
* the evidence of the run into its log.
|
|
6
|
+
*
|
|
7
|
+
* The log stays local: this mode never creates issues or uploads logs.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const process = require('process');
|
|
11
|
+
const { EXIT, resolveBounds } = require('./formal-ai-policy');
|
|
12
|
+
const runtimeLib = require('./formal-ai-runtime');
|
|
13
|
+
const { createClient } = require('./formal-ai-client');
|
|
14
|
+
const { runFormalAiTask } = require('./formal-ai');
|
|
15
|
+
const { ExecutionRecord } = require('./execution-store');
|
|
16
|
+
const { createStartBlock, createFinishBlock } = require('./output-blocks');
|
|
17
|
+
const { appendLogFile } = require('./isolation-log-utils');
|
|
18
|
+
const launchIdentity = require('./launch-identity');
|
|
19
|
+
|
|
20
|
+
const EVIDENCE_SCHEMA = 'start-formal-ai-evidence/1';
|
|
21
|
+
const PREFIX = '[formal-ai]';
|
|
22
|
+
const { AbortController } = globalThis;
|
|
23
|
+
|
|
24
|
+
function timestamp() {
|
|
25
|
+
return new Date().toISOString().replace('T', ' ').substring(0, 23);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Text of the request: the argv after `--`, joined by single spaces. It is
|
|
30
|
+
* only ever sent to Formal AI, never to a shell.
|
|
31
|
+
* @param {string[]} rawCommand - Parsed command argv
|
|
32
|
+
* @returns {string}
|
|
33
|
+
*/
|
|
34
|
+
function requestText(rawCommand) {
|
|
35
|
+
return rawCommand.join(' ').trim();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Run `--use-formal-ai`.
|
|
40
|
+
* @param {object} options
|
|
41
|
+
* @param {string} options.request - Request text
|
|
42
|
+
* @param {string} options.sessionId - Execution UUID
|
|
43
|
+
* @param {object} options.launch - prepareLaunch() result
|
|
44
|
+
* @param {object|null} options.store - ExecutionStore, or null
|
|
45
|
+
* @param {object} [options.config] - CLI configuration
|
|
46
|
+
* @param {object} [options.env] - Environment (process.env)
|
|
47
|
+
* @param {string} [options.cwd] - Working directory (process.cwd())
|
|
48
|
+
* @param {object} [options.out] - stdout stream
|
|
49
|
+
* @param {object} [options.err] - stderr stream
|
|
50
|
+
* @returns {Promise<number>} Exit code
|
|
51
|
+
*/
|
|
52
|
+
async function runFormalAiCli(options) {
|
|
53
|
+
const {
|
|
54
|
+
request,
|
|
55
|
+
sessionId,
|
|
56
|
+
launch,
|
|
57
|
+
store,
|
|
58
|
+
config = {},
|
|
59
|
+
env = process.env,
|
|
60
|
+
cwd = process.cwd(),
|
|
61
|
+
out = process.stdout,
|
|
62
|
+
err = process.stderr,
|
|
63
|
+
} = options;
|
|
64
|
+
const note = (text) =>
|
|
65
|
+
err.write(`${text.replace(/^/gm, `${PREFIX} `).replace(/ +$/gm, '')}\n`);
|
|
66
|
+
let bounds;
|
|
67
|
+
try {
|
|
68
|
+
bounds = resolveBounds(env);
|
|
69
|
+
} catch (error) {
|
|
70
|
+
note(`Error: ${error.message}`);
|
|
71
|
+
return error.exitCode || EXIT.CONFIG;
|
|
72
|
+
}
|
|
73
|
+
if (!request) {
|
|
74
|
+
note('Error: --use-formal-ai needs a request after --');
|
|
75
|
+
return EXIT.USAGE;
|
|
76
|
+
}
|
|
77
|
+
if (request.length > bounds.maxRequestChars) {
|
|
78
|
+
note(
|
|
79
|
+
`Error: the request has ${request.length} characters; START_FORMAL_AI_MAX_REQUEST_CHARS is ${bounds.maxRequestChars}`
|
|
80
|
+
);
|
|
81
|
+
return EXIT.USAGE;
|
|
82
|
+
}
|
|
83
|
+
const deadline = Date.now() + bounds.timeoutMs;
|
|
84
|
+
let runtime;
|
|
85
|
+
try {
|
|
86
|
+
runtime = await runtimeLib.startRuntime({ env, bounds });
|
|
87
|
+
} catch (error) {
|
|
88
|
+
note(`Error: ${error.message}`);
|
|
89
|
+
note(
|
|
90
|
+
'No command was run: --use-formal-ai never falls back to aliases, a shell or an LLM.'
|
|
91
|
+
);
|
|
92
|
+
return error.exitCode || EXIT.UNAVAILABLE;
|
|
93
|
+
}
|
|
94
|
+
note(`runtime: ${runtimeLib.describeIdentity(runtime.identity)}`);
|
|
95
|
+
note(`effects: ${bounds.effects}`);
|
|
96
|
+
|
|
97
|
+
const logPath = launch.logPath;
|
|
98
|
+
const startTime = timestamp();
|
|
99
|
+
const startMs = Date.now();
|
|
100
|
+
const record = store
|
|
101
|
+
? new ExecutionRecord({
|
|
102
|
+
uuid: sessionId,
|
|
103
|
+
command: request,
|
|
104
|
+
logPath,
|
|
105
|
+
shell: 'formal-ai',
|
|
106
|
+
workingDirectory: cwd,
|
|
107
|
+
options: {
|
|
108
|
+
formalAi: {
|
|
109
|
+
request,
|
|
110
|
+
effects: bounds.effects,
|
|
111
|
+
runtime: runtime.identity,
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
})
|
|
115
|
+
: null;
|
|
116
|
+
if (record) {
|
|
117
|
+
launchIdentity.applyLaunch(record, launch);
|
|
118
|
+
launchIdentity.claimDirectLaunch(store, record, launch, config);
|
|
119
|
+
}
|
|
120
|
+
const header = [
|
|
121
|
+
'=== Start Command Log ===',
|
|
122
|
+
`Timestamp: ${startTime}`,
|
|
123
|
+
`Session ID: ${sessionId}`,
|
|
124
|
+
'Mode: --use-formal-ai (experimental)',
|
|
125
|
+
`Request: ${request}`,
|
|
126
|
+
`Formal AI: ${runtimeLib.describeIdentity(runtime.identity)}`,
|
|
127
|
+
`Effects: ${bounds.effects}`,
|
|
128
|
+
`Platform: ${process.platform}`,
|
|
129
|
+
`Working Directory: ${cwd}`,
|
|
130
|
+
'='.repeat(50),
|
|
131
|
+
'',
|
|
132
|
+
'',
|
|
133
|
+
].join('\n');
|
|
134
|
+
launchIdentity.writeLaunchLog(logPath, header, launch, record);
|
|
135
|
+
const logLine = (text) => appendLogFile(logPath, text);
|
|
136
|
+
|
|
137
|
+
out.write(
|
|
138
|
+
`${createStartBlock({ sessionId, timestamp: startTime, command: `--use-formal-ai -- ${request}` })}\n\n`
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
const evidence = {
|
|
142
|
+
schema: EVIDENCE_SCHEMA,
|
|
143
|
+
sessionId,
|
|
144
|
+
runtime: runtime.identity,
|
|
145
|
+
};
|
|
146
|
+
const controller = new AbortController();
|
|
147
|
+
let finalized = false;
|
|
148
|
+
const finalize = (exitCode) => {
|
|
149
|
+
if (finalized) {
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
finalized = true;
|
|
153
|
+
runtime.stop();
|
|
154
|
+
if (!evidence.outcome) {
|
|
155
|
+
evidence.outcome = {
|
|
156
|
+
status: 'cancelled',
|
|
157
|
+
exitCode,
|
|
158
|
+
message: 'start exited before the task finished',
|
|
159
|
+
remainingObligations: [
|
|
160
|
+
'the task was interrupted; later steps did not run',
|
|
161
|
+
],
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
evidence.durationMs = Date.now() - startMs;
|
|
165
|
+
const endTime = timestamp();
|
|
166
|
+
logLine(
|
|
167
|
+
`\n=== Formal AI Evidence ===\n${JSON.stringify(evidence, null, 2)}\n` +
|
|
168
|
+
`\n${'='.repeat(50)}\nFinished: ${endTime}\nExit Code: ${exitCode}\n`
|
|
169
|
+
);
|
|
170
|
+
if (record) {
|
|
171
|
+
record.options.formalAi.outcome = evidence.outcome.status;
|
|
172
|
+
record.complete(exitCode);
|
|
173
|
+
try {
|
|
174
|
+
store.save(record);
|
|
175
|
+
} catch (error) {
|
|
176
|
+
if (config.verbose) {
|
|
177
|
+
note(
|
|
178
|
+
`[Tracking] Warning: Could not save execution record: ${error.message}`
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return endTime;
|
|
184
|
+
};
|
|
185
|
+
// Signals end start through process.exit() in the CLI; finish the
|
|
186
|
+
// evidence, stop the tool's process group and the runtime synchronously.
|
|
187
|
+
const onExit = (code) => {
|
|
188
|
+
if (evidence.activeKill) {
|
|
189
|
+
evidence.activeKill('SIGKILL');
|
|
190
|
+
}
|
|
191
|
+
finalize(code);
|
|
192
|
+
};
|
|
193
|
+
process.on('exit', onExit);
|
|
194
|
+
try {
|
|
195
|
+
const client = createClient({ baseUrl: runtime.baseUrl, bounds, deadline });
|
|
196
|
+
await runFormalAiTask({
|
|
197
|
+
request,
|
|
198
|
+
cwd,
|
|
199
|
+
env: launchIdentity.directLaunchEnv(sessionId, launch),
|
|
200
|
+
bounds,
|
|
201
|
+
client,
|
|
202
|
+
deadline,
|
|
203
|
+
signal: controller.signal,
|
|
204
|
+
evidence,
|
|
205
|
+
io: {
|
|
206
|
+
stdout: out,
|
|
207
|
+
stderr: err,
|
|
208
|
+
note: (text) => {
|
|
209
|
+
note(text);
|
|
210
|
+
logLine(`${PREFIX} ${text}\n`);
|
|
211
|
+
},
|
|
212
|
+
},
|
|
213
|
+
});
|
|
214
|
+
evidence.formalAiRequests = client.requests;
|
|
215
|
+
} catch (error) {
|
|
216
|
+
evidence.outcome = {
|
|
217
|
+
status: 'internal-error',
|
|
218
|
+
exitCode: 1,
|
|
219
|
+
message: error.message,
|
|
220
|
+
remainingObligations: ['start failed; the task state is unknown'],
|
|
221
|
+
};
|
|
222
|
+
} finally {
|
|
223
|
+
process.removeListener('exit', onExit);
|
|
224
|
+
}
|
|
225
|
+
const { outcome } = evidence;
|
|
226
|
+
note(`${outcome.status}: ${outcome.message}`);
|
|
227
|
+
for (const obligation of outcome.remainingObligations) {
|
|
228
|
+
note(`remaining: ${obligation}`);
|
|
229
|
+
}
|
|
230
|
+
const endTime = finalize(outcome.exitCode);
|
|
231
|
+
out.write(
|
|
232
|
+
`\n${createFinishBlock({ sessionId, timestamp: endTime, exitCode: outcome.exitCode, logPath, durationMs: evidence.durationMs })}\n`
|
|
233
|
+
);
|
|
234
|
+
return outcome.exitCode;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
module.exports = { EVIDENCE_SCHEMA, requestText, runFormalAiCli };
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal client of the OpenAI-compatible agent-mode endpoint of Formal AI
|
|
3
|
+
* (`POST /api/openai/v1/chat/completions`), used by `--use-formal-ai`
|
|
4
|
+
* (issue #255). Only the `bash` function tool is offered: Formal AI picks the
|
|
5
|
+
* command, start decides whether and how it runs.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const { EXIT } = require('./formal-ai-policy');
|
|
9
|
+
const { API_PREFIX } = require('./formal-ai-runtime');
|
|
10
|
+
|
|
11
|
+
const { AbortSignal } = globalThis;
|
|
12
|
+
|
|
13
|
+
const TOOL_NAMES = new Set(['bash', 'shell', 'run_command']);
|
|
14
|
+
const BASH_TOOL = Object.freeze({
|
|
15
|
+
type: 'function',
|
|
16
|
+
function: {
|
|
17
|
+
name: 'bash',
|
|
18
|
+
description:
|
|
19
|
+
'Run one command in the working directory. Pipes, redirects, globs and variables are not supported.',
|
|
20
|
+
parameters: {
|
|
21
|
+
type: 'object',
|
|
22
|
+
properties: { command: { type: 'string' } },
|
|
23
|
+
required: ['command'],
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
class ProtocolError extends Error {
|
|
29
|
+
constructor(message, exitCode = EXIT.PROTOCOL) {
|
|
30
|
+
super(message);
|
|
31
|
+
this.exitCode = exitCode;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Normalize one chat completion into what the loop needs.
|
|
37
|
+
* @param {object} body - Parsed JSON response
|
|
38
|
+
* @returns {{content: string, finishReason: string|null, toolCalls: object[], handler: string|null}}
|
|
39
|
+
*/
|
|
40
|
+
function parseCompletion(body) {
|
|
41
|
+
const choice = body && Array.isArray(body.choices) ? body.choices[0] : null;
|
|
42
|
+
if (!choice || typeof choice.message !== 'object' || !choice.message) {
|
|
43
|
+
throw new ProtocolError('Formal AI response has no choices[0].message');
|
|
44
|
+
}
|
|
45
|
+
const { message } = choice;
|
|
46
|
+
const steps = Array.isArray(message.thinking_steps)
|
|
47
|
+
? message.thinking_steps
|
|
48
|
+
: [];
|
|
49
|
+
const dispatch = steps.find((s) => s && s.step === 'dispatch_handler');
|
|
50
|
+
const toolCalls = (message.tool_calls || []).map((call) => {
|
|
51
|
+
const name = call && call.function && call.function.name;
|
|
52
|
+
if (!TOOL_NAMES.has(name)) {
|
|
53
|
+
throw new ProtocolError(`Formal AI called an unsupported tool "${name}"`);
|
|
54
|
+
}
|
|
55
|
+
let args;
|
|
56
|
+
try {
|
|
57
|
+
args = JSON.parse(call.function.arguments || '{}');
|
|
58
|
+
} catch {
|
|
59
|
+
throw new ProtocolError(`Formal AI sent non-JSON arguments for ${name}`);
|
|
60
|
+
}
|
|
61
|
+
const command = args.command ?? args.cmd;
|
|
62
|
+
if (typeof command !== 'string' || !command.trim()) {
|
|
63
|
+
throw new ProtocolError(`Formal AI called ${name} without a command`);
|
|
64
|
+
}
|
|
65
|
+
return { id: call.id || null, name, command };
|
|
66
|
+
});
|
|
67
|
+
return {
|
|
68
|
+
content: typeof message.content === 'string' ? message.content : '',
|
|
69
|
+
finishReason: choice.finish_reason || null,
|
|
70
|
+
toolCalls,
|
|
71
|
+
handler: dispatch ? String(dispatch.detail) : null,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Create a client bound to one runtime.
|
|
77
|
+
* @param {object} options - {baseUrl, bounds, deadline, token}
|
|
78
|
+
* @returns {{complete: Function, requests: number}}
|
|
79
|
+
*/
|
|
80
|
+
function createClient({ baseUrl, bounds, deadline, token = null }) {
|
|
81
|
+
const client = {
|
|
82
|
+
requests: 0,
|
|
83
|
+
async complete(messages, { signal } = {}) {
|
|
84
|
+
let lastError = null;
|
|
85
|
+
for (let attempt = 0; attempt <= bounds.maxRetries; attempt++) {
|
|
86
|
+
const remaining = deadline - Date.now();
|
|
87
|
+
if (remaining <= 0) {
|
|
88
|
+
throw new ProtocolError(
|
|
89
|
+
'time budget exhausted while waiting for Formal AI',
|
|
90
|
+
EXIT.BUDGET
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
client.requests++;
|
|
94
|
+
try {
|
|
95
|
+
const response = await fetch(
|
|
96
|
+
`${baseUrl}${API_PREFIX}/chat/completions`,
|
|
97
|
+
{
|
|
98
|
+
method: 'POST',
|
|
99
|
+
headers: {
|
|
100
|
+
'content-type': 'application/json',
|
|
101
|
+
...(token ? { authorization: `Bearer ${token}` } : {}),
|
|
102
|
+
},
|
|
103
|
+
body: JSON.stringify({
|
|
104
|
+
model: 'formal-ai',
|
|
105
|
+
messages,
|
|
106
|
+
tools: [BASH_TOOL],
|
|
107
|
+
}),
|
|
108
|
+
signal: signal
|
|
109
|
+
? AbortSignal.any([signal, AbortSignal.timeout(remaining)])
|
|
110
|
+
: AbortSignal.timeout(remaining),
|
|
111
|
+
}
|
|
112
|
+
);
|
|
113
|
+
const text = await response.text();
|
|
114
|
+
if (response.status >= 500) {
|
|
115
|
+
lastError = new ProtocolError(
|
|
116
|
+
`Formal AI returned HTTP ${response.status}`
|
|
117
|
+
);
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (!response.ok) {
|
|
121
|
+
throw new ProtocolError(
|
|
122
|
+
`Formal AI returned HTTP ${response.status}: ${text.slice(0, 300)}`
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
let body;
|
|
126
|
+
try {
|
|
127
|
+
body = JSON.parse(text);
|
|
128
|
+
} catch {
|
|
129
|
+
throw new ProtocolError('Formal AI returned a non-JSON response');
|
|
130
|
+
}
|
|
131
|
+
return { raw: body, ...parseCompletion(body) };
|
|
132
|
+
} catch (error) {
|
|
133
|
+
if (error instanceof ProtocolError) {
|
|
134
|
+
throw error;
|
|
135
|
+
}
|
|
136
|
+
if (signal && signal.aborted) {
|
|
137
|
+
throw error;
|
|
138
|
+
}
|
|
139
|
+
if (error.name === 'TimeoutError') {
|
|
140
|
+
throw new ProtocolError(
|
|
141
|
+
'time budget exhausted while waiting for Formal AI',
|
|
142
|
+
EXIT.BUDGET
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
lastError = new ProtocolError(
|
|
146
|
+
`Formal AI request failed: ${error.message}`,
|
|
147
|
+
EXIT.UNAVAILABLE
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (bounds.maxRetries > 0) {
|
|
152
|
+
lastError.message += ` (after ${bounds.maxRetries} retr${bounds.maxRetries === 1 ? 'y' : 'ies'})`;
|
|
153
|
+
}
|
|
154
|
+
throw lastError;
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
return client;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
module.exports = {
|
|
161
|
+
BASH_TOOL,
|
|
162
|
+
ProtocolError,
|
|
163
|
+
TOOL_NAMES,
|
|
164
|
+
createClient,
|
|
165
|
+
parseCompletion,
|
|
166
|
+
};
|