@itookit/dsht 0.3.7 → 0.5.1
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/README.i18n.yaml +2 -2
- package/README.md +31 -11
- package/README.zh.md +31 -11
- package/dist/cli/dsht.js +207 -19
- package/dist/cli/startup.d.ts +40 -0
- package/dist/cli/startup.js +295 -0
- package/dist/cli/trace-summary.d.ts +78 -0
- package/dist/cli/trace-summary.js +241 -0
- package/dist/cli/verifier.d.ts +60 -0
- package/dist/cli/verifier.js +242 -0
- package/dist/contracts.d.ts +344 -0
- package/dist/contracts.js +1 -0
- package/dist/controller/commands.d.ts +47 -0
- package/dist/controller/commands.js +322 -0
- package/dist/controller/connection.d.ts +11 -29
- package/dist/controller/connection.js +26 -60
- package/dist/controller/controller.d.ts +619 -164
- package/dist/controller/controller.js +1420 -141
- package/dist/controller/index.d.ts +8 -1
- package/dist/controller/index.js +5 -0
- package/dist/controller/loop-contract.d.ts +136 -0
- package/dist/controller/loop-contract.js +308 -0
- package/dist/controller/loop-prompts-schema.d.ts +56 -0
- package/dist/controller/loop-prompts-schema.js +144 -0
- package/dist/controller/loop-prompts.d.ts +55 -0
- package/dist/controller/loop-prompts.generated.d.ts +104 -0
- package/dist/controller/loop-prompts.generated.js +185 -0
- package/dist/controller/loop-prompts.js +104 -0
- package/dist/controller/loop-protocols.d.ts +39 -0
- package/dist/controller/loop-protocols.js +115 -0
- package/dist/controller/loop.d.ts +275 -0
- package/dist/controller/loop.js +378 -0
- package/dist/controller/prompts.d.ts +54 -0
- package/dist/controller/prompts.js +162 -0
- package/dist/controller/trace-log.d.ts +45 -0
- package/dist/controller/trace-log.js +144 -0
- package/dist/controller/verifier.d.ts +126 -0
- package/dist/controller/verifier.js +75 -0
- package/dist/cost/index.d.ts +1 -1
- package/dist/cost/index.js +1 -1
- package/dist/cost/ledger.d.ts +0 -1
- package/dist/cost/ledger.js +0 -1
- package/dist/json.d.ts +18 -0
- package/dist/json.js +19 -0
- package/dist/references.d.ts +25 -0
- package/dist/references.js +26 -0
- package/dist/session/connection-view.d.ts +2 -11
- package/dist/session/controller.d.ts +82 -72
- package/dist/session/controller.js +211 -209
- package/dist/session/history.d.ts +9 -1
- package/dist/session/history.js +1 -9
- package/dist/session/index.d.ts +9 -4
- package/dist/session/index.js +7 -3
- package/dist/session/info.d.ts +25 -52
- package/dist/session/info.js +39 -25
- package/dist/session/markdown.js +1 -1
- package/dist/session/math.js +1 -1
- package/dist/session/mutation-gate.d.ts +51 -0
- package/dist/session/mutation-gate.js +73 -0
- package/dist/session/navigation.d.ts +2 -89
- package/dist/session/navigation.js +2 -129
- package/dist/session/peek.d.ts +38 -0
- package/dist/session/peek.js +103 -0
- package/dist/session/references.d.ts +2 -20
- package/dist/session/references.js +1 -26
- package/dist/session/runtime.d.ts +26 -0
- package/dist/session/runtime.js +28 -0
- package/dist/session/telemetry.d.ts +12 -13
- package/dist/session/telemetry.js +27 -58
- package/dist/session/transcript.d.ts +0 -6
- package/dist/session/transcript.js +2 -15
- package/dist/session/types.d.ts +25 -0
- package/dist/session/types.js +0 -1
- package/dist/session-title.d.ts +9 -0
- package/dist/session-title.js +21 -0
- package/dist/shell/controller.d.ts +97 -0
- package/dist/shell/controller.js +158 -0
- package/dist/shell/index.d.ts +5 -0
- package/dist/shell/index.js +3 -0
- package/dist/shell/runner.d.ts +38 -0
- package/dist/shell/runner.js +147 -0
- package/dist/slash/index.d.ts +10 -0
- package/dist/slash/index.js +7 -0
- package/dist/slash/parse.d.ts +166 -0
- package/dist/slash/parse.js +259 -0
- package/dist/slash/pipeline.d.ts +140 -0
- package/dist/slash/pipeline.js +115 -0
- package/dist/slash/registry.d.ts +88 -0
- package/dist/slash/registry.js +177 -0
- package/dist/state.d.ts +14 -4
- package/dist/state.js +3 -2
- package/dist/text.d.ts +28 -0
- package/dist/text.js +55 -0
- package/dist/transport/events.d.ts +104 -0
- package/dist/transport/events.js +149 -0
- package/dist/transport/wire.d.ts +9 -17
- package/dist/transport/wire.js +2 -27
- package/dist/ui/app.js +865 -431
- package/dist/ui/chat/header.js +1 -1
- package/dist/ui/chat/history-view.d.ts +1 -1
- package/dist/ui/chat/history-view.js +1 -1
- package/dist/ui/chat/loop-status.d.ts +11 -0
- package/dist/ui/chat/loop-status.js +28 -0
- package/dist/ui/chat/navigation-model.d.ts +86 -0
- package/dist/ui/chat/navigation-model.js +107 -0
- package/dist/ui/chat/shell-view.d.ts +47 -0
- package/dist/ui/chat/shell-view.js +145 -0
- package/dist/ui/chat/status.d.ts +47 -3
- package/dist/ui/chat/status.js +65 -50
- package/dist/ui/chat/viewport.d.ts +1 -1
- package/dist/ui/dialogs/cost.d.ts +21 -4
- package/dist/ui/dialogs/cost.js +7 -12
- package/dist/ui/dialogs/index.d.ts +22 -5
- package/dist/ui/dialogs/index.js +19 -3
- package/dist/ui/dialogs/loop.d.ts +43 -0
- package/dist/ui/dialogs/loop.js +224 -0
- package/dist/ui/dialogs/peek.d.ts +25 -0
- package/dist/ui/dialogs/peek.js +35 -0
- package/dist/ui/dialogs/picker.d.ts +2 -0
- package/dist/ui/dialogs/picker.js +4 -2
- package/dist/ui/input/mouse.d.ts +12 -2
- package/dist/ui/input/mouse.js +20 -7
- package/dist/ui/input/references.d.ts +1 -1
- package/dist/ui/status/model.d.ts +7 -0
- package/dist/ui/status/model.js +5 -0
- package/dist/ui/theme/index.d.ts +6 -1
- package/dist/ui/theme/index.js +2 -1
- package/package.json +6 -4
- package/dist/ui/commands/parse.d.ts +0 -99
- package/dist/ui/commands/parse.js +0 -126
- package/dist/ui/commands/registry.d.ts +0 -33
- package/dist/ui/commands/registry.js +0 -73
package/dist/cli/dsht.js
CHANGED
|
@@ -6,60 +6,123 @@ import { join } from 'node:path';
|
|
|
6
6
|
import { CostLedger, loadPrices } from "../cost/index.js";
|
|
7
7
|
import { parseArgs } from 'node:util';
|
|
8
8
|
import { mount } from "../ui/mount.js";
|
|
9
|
-
import { ensureDirectory } from "../storage/index.js";
|
|
10
|
-
import {
|
|
9
|
+
import { ensureDirectory, readText } from "../storage/index.js";
|
|
10
|
+
import { runStartup } from "./startup.js";
|
|
11
|
+
import { sessionLabel } from "../session-title.js";
|
|
11
12
|
import { CookieStore, login } from "../transport/auth.js";
|
|
12
13
|
import { Client } from "../transport/client.js";
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
13
15
|
import { historyLimits } from "../session/memory.js";
|
|
16
|
+
import { ProcessVerifier } from "./verifier.js";
|
|
14
17
|
import { Controller } from "../controller/controller.js";
|
|
15
18
|
import { endpoint } from "../transport/endpoint.js";
|
|
16
|
-
import { errorText,
|
|
17
|
-
|
|
19
|
+
import { errorText, object, string } from "../transport/wire.js";
|
|
20
|
+
import { formatTraceSummary, summarizeTrace } from "./trace-summary.js";
|
|
21
|
+
import { safeText } from "../text.js";
|
|
22
|
+
const HELP = `Usage: dsht [options] [list workspaces|list sessions|trace]
|
|
18
23
|
|
|
19
24
|
With no command, choose a workspace and session interactively.
|
|
20
25
|
|
|
21
26
|
--url <url> Host URL, or the dsh web URL with ?token= (DSH_URL)
|
|
22
27
|
--workspace <id> Filter list sessions by workspace
|
|
23
|
-
--session <id>
|
|
28
|
+
--session <id|new> Open a session directly, or create one
|
|
29
|
+
--ws <id|name|path> Select this workspace at startup (default: this directory)
|
|
30
|
+
--command <line> Run this slash command once the session is ready (repeatable)
|
|
31
|
+
--prompt <text> Send this plain prompt once the session is ready
|
|
32
|
+
--wait With --headless, exit when the sent prompt's turn has finished
|
|
33
|
+
--verdict <path> With --prompt/--wait, write the reply's verdict to this file
|
|
34
|
+
--verdict-identity <id> <runId>/<kind>/<step>/<attempt>/<seq> the verdict must declare
|
|
35
|
+
--headless Run --command without the terminal interface, then exit
|
|
36
|
+
--deadline <minutes> Stop the whole loop after this many minutes (DSHT_LOOP_DEADLINE)
|
|
24
37
|
--auth-dir <path> Private cookie directory (or DSHT_AUTH_DIR)
|
|
25
38
|
--history-records <n> Soft history record limit (default 2000)
|
|
26
39
|
--history-mb <n> Soft history payload budget in MiB (default 16)
|
|
27
40
|
--memory-log <path> Append runtime memory samples; a failing log stops itself
|
|
28
41
|
--no-memory-log Disable the runtime memory log (default: enabled)
|
|
29
|
-
--
|
|
42
|
+
--trace <path> Append connection/screen/selection events (default: <state>/trace.log)
|
|
43
|
+
With the trace command, read that file instead of appending to it
|
|
44
|
+
--no-trace Disable the transition trace
|
|
45
|
+
--trace-verbose Quote sanitized child output in verifier failure reasons
|
|
46
|
+
--no-shell Disable ! local commands (DSHT_NO_SHELL=1)
|
|
47
|
+
--json Print machine-readable list or trace output
|
|
48
|
+
--version Print the package version and exit
|
|
30
49
|
--help Show this help
|
|
31
50
|
|
|
32
51
|
The default host is http://127.0.0.1:3080.
|
|
33
52
|
First login: export DSH_TOKEN, or export DSH_URL as the URL printed by dsh web.
|
|
34
53
|
Cookies are saved per server origin and reused on later starts. Tokens are never saved.
|
|
35
54
|
/cost shows the session and today CNY estimates.
|
|
55
|
+
/prompt lists saved shortcut prompts; /prompt TEXT saves one in <state>/prompts.json.
|
|
56
|
+
!command runs on this machine, not on the host, and prints its output in the transcript.
|
|
36
57
|
DSHT_CONFIG_DIR overrides the prices.json directory; DSHT_STATE_DIR overrides usage storage.
|
|
37
58
|
The memory log defaults to <state>/memory.log; DSHT_MEMORY_LOG sets another path or 'off'.
|
|
59
|
+
The transition trace defaults to <state>/trace.log; DSHT_TRACE sets another path or 'off'.
|
|
38
60
|
prices.json overrides the shipped rates and is seeded on first use; every scan re-decides the
|
|
39
61
|
history with the table loaded then, so an edited table reaches past requests on the next scan.
|
|
40
62
|
Examples:
|
|
41
63
|
npx @itookit/dsht
|
|
42
64
|
dsht list workspaces --json
|
|
43
65
|
dsht list sessions --workspace <id> --json
|
|
66
|
+
dsht trace --json
|
|
44
67
|
`;
|
|
68
|
+
/** State root this client reads and writes logs under, honouring the same overrides as the client. */
|
|
69
|
+
function stateRoot() {
|
|
70
|
+
return process.env.DSHT_STATE_DIR ?? join(process.env.XDG_STATE_HOME ?? join(homedir(), '.local', 'state'), 'dsht');
|
|
71
|
+
}
|
|
72
|
+
/** Read one trace file back as a few lines of facts.
|
|
73
|
+
*
|
|
74
|
+
* Needs no host and no credentials: the trace is the client's own record of what it did, and reading
|
|
75
|
+
* it is the whole point of having written it.
|
|
76
|
+
* @param requested - `--trace` value, when given.
|
|
77
|
+
* @param json - Print the summary as JSON instead of lines.
|
|
78
|
+
*/
|
|
79
|
+
async function printTrace(requested, json) {
|
|
80
|
+
const path = requested ?? process.env.DSHT_TRACE ?? join(stateRoot(), 'trace.log');
|
|
81
|
+
const text = await readText(path);
|
|
82
|
+
if (text === undefined) {
|
|
83
|
+
process.stdout.write(`No trace at ${path}\n`);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const summary = summarizeTrace(text.split('\n').filter(line => line !== ''), path);
|
|
87
|
+
process.stdout.write(json ? `${JSON.stringify(summary, null, 2)}\n` : `${formatTraceSummary(summary).join('\n')}\n`);
|
|
88
|
+
}
|
|
45
89
|
async function main() {
|
|
46
90
|
const { values, positionals } = parseArgs({ allowPositionals: true, options: {
|
|
47
91
|
url: { type: 'string', default: process.env.DSH_URL ?? 'http://127.0.0.1:3080' },
|
|
48
92
|
'history-records': { type: 'string' }, 'history-mb': { type: 'string' },
|
|
49
|
-
workspace: { type: 'string' }, session: { type: 'string' }, 'auth-dir': { type: 'string' }, json: { type: 'boolean' }, help: { type: 'boolean' },
|
|
50
|
-
'
|
|
93
|
+
workspace: { type: 'string' }, ws: { type: 'string' }, session: { type: 'string' }, 'auth-dir': { type: 'string' }, json: { type: 'boolean' }, help: { type: 'boolean' }, version: { type: 'boolean' },
|
|
94
|
+
command: { type: 'string', multiple: true }, prompt: { type: 'string' }, wait: { type: 'boolean' },
|
|
95
|
+
verdict: { type: 'string' }, 'verdict-identity': { type: 'string' }, headless: { type: 'boolean' },
|
|
96
|
+
deadline: { type: 'string' },
|
|
97
|
+
'memory-log': { type: 'string' }, 'no-memory-log': { type: 'boolean' }, 'no-shell': { type: 'boolean' },
|
|
98
|
+
trace: { type: 'string' }, 'no-trace': { type: 'boolean' }, 'trace-verbose': { type: 'boolean' },
|
|
51
99
|
} });
|
|
52
100
|
if (values.help) {
|
|
53
101
|
process.stdout.write(HELP);
|
|
54
102
|
return;
|
|
55
103
|
}
|
|
104
|
+
// The version is read from the manifest rather than repeated here, so a release never has to edit
|
|
105
|
+
// a string in this file; like `--help` it needs no host, no credentials and no terminal.
|
|
106
|
+
if (values.version) {
|
|
107
|
+
process.stdout.write(`${await packageVersion()}\n`);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
56
110
|
const list = positionals[0] === 'list' && ['workspaces', 'sessions'].includes(positionals[1] ?? '') && positionals.length === 2;
|
|
57
|
-
|
|
111
|
+
const trace = positionals[0] === 'trace' && positionals.length === 1;
|
|
112
|
+
if (positionals.length && !list && !trace)
|
|
58
113
|
throw new Error('Unknown command. Use --help.');
|
|
59
|
-
if (!list && (values.json || values.workspace))
|
|
60
|
-
throw new Error('--json and --workspace apply to list commands');
|
|
61
|
-
if (list && values.session)
|
|
114
|
+
if (!list && !trace && (values.json || values.workspace))
|
|
115
|
+
throw new Error('--json and --workspace apply to list or trace commands');
|
|
116
|
+
if ((list || trace) && values.session)
|
|
62
117
|
throw new Error('--session applies to interactive mode');
|
|
118
|
+
if ((list || trace) && (values.ws || values.command?.length || values.prompt !== undefined || values.wait || values.headless || values.deadline)) {
|
|
119
|
+
throw new Error('--ws, --command, --prompt, --wait, --deadline and --headless apply to interactive mode');
|
|
120
|
+
}
|
|
121
|
+
// Reading a trace needs no host, no credentials and no terminal, so it runs before any of them.
|
|
122
|
+
if (trace) {
|
|
123
|
+
await printTrace(values.trace, values.json === true);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
63
126
|
const limits = historyLimits(values['history-records'], values['history-mb']);
|
|
64
127
|
const { url, token } = endpoint(values.url, process.env.DSH_TOKEN);
|
|
65
128
|
const store = new CookieStore(values['auth-dir']);
|
|
@@ -91,13 +154,68 @@ async function main() {
|
|
|
91
154
|
const costDirectory = join(stateRoot, 'cost', createHash('sha256').update(new URL(url).origin).digest('hex'));
|
|
92
155
|
const costs = new CostLedger(prices, costDirectory, custom);
|
|
93
156
|
await costs.load();
|
|
94
|
-
if (!process.stdin.isTTY || !process.stdout.isTTY)
|
|
95
|
-
throw new Error('Interactive mode requires a terminal. Use
|
|
96
|
-
|
|
157
|
+
if (!values.headless && (!process.stdin.isTTY || !process.stdout.isTTY)) {
|
|
158
|
+
throw new Error('Interactive mode requires a terminal. Use --headless or list workspaces/list sessions for scripts.');
|
|
159
|
+
}
|
|
160
|
+
const shellEnabled = !values['no-shell'] && process.env.DSHT_NO_SHELL !== '1';
|
|
161
|
+
const localDirectory = process.cwd();
|
|
162
|
+
// A scored review can delegate each round's verdict to a child client, which needs no shared state
|
|
163
|
+
// with this one: it is handed a session and a prompt, and answers through a file.
|
|
164
|
+
const verifier = process.env.DSHT_NO_VERIFY === '1' ? undefined : new ProcessVerifier({
|
|
165
|
+
command: [process.execPath, ...process.execArgv, process.argv[1] ?? fileURLToPath(import.meta.url)],
|
|
166
|
+
url: values.url,
|
|
167
|
+
...(values['auth-dir'] === undefined ? {} : { authDir: values['auth-dir'] }),
|
|
168
|
+
cwd: localDirectory, env: process.env,
|
|
169
|
+
timeoutMs: verifyTimeoutMs(process.env.DSHT_VERIFY_TIMEOUT_MS),
|
|
170
|
+
createSession: (title) => controller.actions.createVerifierSession(title),
|
|
171
|
+
cancelSession: async (sessionId) => { await controller.actions.cancelVerifierSession(sessionId); },
|
|
172
|
+
onLine: line => { if (values.headless)
|
|
173
|
+
log(line); },
|
|
174
|
+
// Off by default: a verifier reason reaches the progress line and the trace, and that log may be
|
|
175
|
+
// pasted into a report, so the child's own words are quoted only when the operator asks.
|
|
176
|
+
verbose: values['trace-verbose'] === true || process.env.DSHT_TRACE_VERBOSE === '1',
|
|
177
|
+
});
|
|
178
|
+
const controller = new Controller({
|
|
179
|
+
base: url, token, initialSession: values.session === 'new' ? undefined : values.session,
|
|
180
|
+
authenticate: client => login(client, token, store),
|
|
181
|
+
localDirectory, verifier,
|
|
182
|
+
// Verdicts belong to this client rather than to the reviewed tree, and the client's own
|
|
183
|
+
// directory is the one place it is always allowed to write; DSHT_VERDICT_ROOT points them
|
|
184
|
+
// elsewhere when the review targets a workspace this machine cannot write.
|
|
185
|
+
verdictRoot: process.env.DSHT_VERDICT_ROOT ?? localDirectory,
|
|
186
|
+
costs, historyLimits: limits, shellEnabled,
|
|
187
|
+
deadlineMs: loopDeadlineMs(values.deadline ?? process.env.DSHT_LOOP_DEADLINE),
|
|
188
|
+
memoryLogPath: memoryLogPath(stateRoot, values['memory-log'], values['no-memory-log']),
|
|
189
|
+
tracePath: tracePath(stateRoot, values.trace, values['no-trace']),
|
|
190
|
+
promptsPath: join(stateRoot, 'prompts.json'),
|
|
191
|
+
});
|
|
192
|
+
const plan = {
|
|
193
|
+
...(values.ws === undefined ? {} : { workspace: values.ws }),
|
|
194
|
+
...(values.session === undefined ? {} : { session: values.session }),
|
|
195
|
+
commands: values.command ?? [],
|
|
196
|
+
...(values.prompt === undefined ? {} : { prompt: values.prompt }),
|
|
197
|
+
...(values.wait === undefined ? {} : { wait: values.wait }),
|
|
198
|
+
...(values.verdict === undefined ? {} : { verdict: { file: values.verdict, identity: requireIdentity(values['verdict-identity']) } }),
|
|
199
|
+
timeoutSeconds: 3600,
|
|
200
|
+
};
|
|
201
|
+
const log = (line) => process.stderr.write(`${line}\n`);
|
|
202
|
+
controller.start();
|
|
203
|
+
if (values.headless) {
|
|
204
|
+
// No renderer: run the plan, follow a started loop to its verdict, and report it as the exit code.
|
|
205
|
+
try {
|
|
206
|
+
const outcome = await runStartup(controller, plan, log);
|
|
207
|
+
// 0 passed, 1 failed, 3 waiting for a person: a script can tell the three apart.
|
|
208
|
+
process.exitCode = outcome === 'failed' ? 1 : outcome === 'needs-human' ? 3 : 0;
|
|
209
|
+
}
|
|
210
|
+
finally {
|
|
211
|
+
await controller.shutdown();
|
|
212
|
+
}
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
97
215
|
const app = mount(controller);
|
|
98
216
|
const terminate = () => app.unmount();
|
|
99
217
|
process.once('SIGTERM', terminate);
|
|
100
|
-
controller.
|
|
218
|
+
void runStartup(controller, plan, log).catch(error => process.stderr.write(`${errorText(error)}\n`));
|
|
101
219
|
try {
|
|
102
220
|
await app.waitUntilExit();
|
|
103
221
|
}
|
|
@@ -106,6 +224,55 @@ async function main() {
|
|
|
106
224
|
await controller.shutdown();
|
|
107
225
|
}
|
|
108
226
|
}
|
|
227
|
+
/** Read the published version from the manifest beside this entry point.
|
|
228
|
+
*
|
|
229
|
+
* The path is relative to this module, so it resolves both in the source tree (`src/cli/`) and in
|
|
230
|
+
* the published build (`dist/cli/`). The version is never repeated as a literal, which is what lets
|
|
231
|
+
* a release touch only `package.json` and the lockfile.
|
|
232
|
+
* @returns The `version` field of `package.json`.
|
|
233
|
+
*/
|
|
234
|
+
async function packageVersion() {
|
|
235
|
+
const manifest = await readText(fileURLToPath(new URL('../../package.json', import.meta.url)));
|
|
236
|
+
if (manifest === undefined)
|
|
237
|
+
throw new Error('package.json is missing beside the client entry point');
|
|
238
|
+
const version = string(object(JSON.parse(manifest)).version);
|
|
239
|
+
if (version === '')
|
|
240
|
+
throw new Error('package.json has no version');
|
|
241
|
+
return version;
|
|
242
|
+
}
|
|
243
|
+
/** The identity a written verdict must declare, refused when the flag that carries it is missing.
|
|
244
|
+
* @param value - `--verdict-identity` value.
|
|
245
|
+
* @returns The identity.
|
|
246
|
+
*/
|
|
247
|
+
function requireIdentity(value) {
|
|
248
|
+
if (value === undefined || value.trim() === '')
|
|
249
|
+
throw new Error('--verdict requires --verdict-identity');
|
|
250
|
+
return value;
|
|
251
|
+
}
|
|
252
|
+
/** Whole-run budget from `--deadline`/`DSHT_LOOP_DEADLINE`, in minutes.
|
|
253
|
+
*
|
|
254
|
+
* Absent means no budget, which keeps the old behaviour for callers that never set one. A value that
|
|
255
|
+
* is not a positive number is refused rather than silently ignored, because a run that was supposed
|
|
256
|
+
* to be bounded and is not is worse than a startup error.
|
|
257
|
+
* @param value - Flag or environment value.
|
|
258
|
+
* @returns The budget in milliseconds, or undefined when unbounded.
|
|
259
|
+
*/
|
|
260
|
+
function loopDeadlineMs(value) {
|
|
261
|
+
if (value === undefined || value.trim() === '')
|
|
262
|
+
return undefined;
|
|
263
|
+
const minutes = Number(value);
|
|
264
|
+
if (!Number.isFinite(minutes) || minutes <= 0)
|
|
265
|
+
throw new Error(`--deadline must be a positive number of minutes: ${value}`);
|
|
266
|
+
return Math.round(minutes * 60_000);
|
|
267
|
+
}
|
|
268
|
+
/** How long one forked verification may run, from a minute count in the environment.
|
|
269
|
+
* @param value - `DSHT_VERIFY_TIMEOUT_MS` when set.
|
|
270
|
+
* @returns The timeout in milliseconds, defaulting to twenty minutes.
|
|
271
|
+
*/
|
|
272
|
+
function verifyTimeoutMs(value) {
|
|
273
|
+
const parsed = Number(value);
|
|
274
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : 20 * 60_000;
|
|
275
|
+
}
|
|
109
276
|
/** Resolve the runtime memory log path: an explicit flag wins, then the environment, then the default.
|
|
110
277
|
* @param stateRoot - Application state root used for the default path.
|
|
111
278
|
* @param requested - `--memory-log` value, when given.
|
|
@@ -113,13 +280,34 @@ async function main() {
|
|
|
113
280
|
* @returns Absolute log path, or undefined when the log is disabled.
|
|
114
281
|
*/
|
|
115
282
|
function memoryLogPath(stateRoot, requested, disabled) {
|
|
283
|
+
return diagnosticLogPath('memory-log', 'memory.log', stateRoot, requested, disabled, process.env.DSHT_MEMORY_LOG);
|
|
284
|
+
}
|
|
285
|
+
/** Resolve the transition trace path: an explicit flag wins, then the environment, then the default.
|
|
286
|
+
* @param stateRoot - Application state root used for the default path.
|
|
287
|
+
* @param requested - `--trace` value, when given.
|
|
288
|
+
* @param disabled - `--no-trace` flag.
|
|
289
|
+
* @returns Absolute trace path, or undefined when the trace is disabled.
|
|
290
|
+
*/
|
|
291
|
+
function tracePath(stateRoot, requested, disabled) {
|
|
292
|
+
return diagnosticLogPath('trace', 'trace.log', stateRoot, requested, disabled, process.env.DSHT_TRACE);
|
|
293
|
+
}
|
|
294
|
+
/** Resolve one diagnostic log path shared by the memory log and the transition trace.
|
|
295
|
+
* @param flag - Long option name, used in the error for an empty value.
|
|
296
|
+
* @param file - Default filename under the state root.
|
|
297
|
+
* @param stateRoot - Application state root.
|
|
298
|
+
* @param requested - Flag value, when given; an empty string is a mistyped flag, not a default.
|
|
299
|
+
* @param disabled - `--no-<flag>` flag.
|
|
300
|
+
* @param environment - Environment override, where `off` disables the log.
|
|
301
|
+
* @returns Absolute log path, or undefined when the log is disabled.
|
|
302
|
+
*/
|
|
303
|
+
function diagnosticLogPath(flag, file, stateRoot, requested, disabled, environment) {
|
|
116
304
|
if (requested !== undefined && requested.trim() === '')
|
|
117
|
-
throw new Error(
|
|
305
|
+
throw new Error(`--${flag} requires a path`);
|
|
118
306
|
if (disabled)
|
|
119
307
|
return undefined;
|
|
120
|
-
const chosen = (requested ??
|
|
308
|
+
const chosen = (requested ?? environment)?.trim();
|
|
121
309
|
if (chosen === undefined || chosen === '')
|
|
122
|
-
return join(stateRoot,
|
|
310
|
+
return join(stateRoot, file);
|
|
123
311
|
return chosen === 'off' ? undefined : chosen;
|
|
124
312
|
}
|
|
125
313
|
main().catch(error => { process.stderr.write(`${errorText(error)}\n`); process.exitCode = 1; });
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/** Startup automation: pick a workspace and a session, then run the requested slash lines.
|
|
2
|
+
*
|
|
3
|
+
* This is the scriptable half of the client — `dsht --ws X --session new --command "…"` — so a
|
|
4
|
+
* review can be launched without typing. It drives the same controller the UI drives, and reads only
|
|
5
|
+
* the outcome of each line: the presentational effects of a command belong to the UI.
|
|
6
|
+
*/
|
|
7
|
+
import { type Controller } from '../controller/index.ts';
|
|
8
|
+
/** What the operator asked the client to do before/while taking over. */
|
|
9
|
+
export interface StartupPlan {
|
|
10
|
+
/** Workspace ID, name or path; absent adopts the directory this client runs in. */
|
|
11
|
+
workspace?: string;
|
|
12
|
+
/** Session to open, or `new`; absent creates one when commands were given. */
|
|
13
|
+
session?: string;
|
|
14
|
+
/** Slash lines to run once the session is ready, in order. */
|
|
15
|
+
commands: readonly string[];
|
|
16
|
+
/** Plain prompt to send after the commands; a forked verifier drives a session this way. */
|
|
17
|
+
prompt?: string;
|
|
18
|
+
/** Wait for that prompt's turn to finish before returning, so the child exits on its own. */
|
|
19
|
+
wait?: boolean;
|
|
20
|
+
/** Where to persist the verdict this session produced, and the identity it must declare. */
|
|
21
|
+
verdict?: VerdictTarget;
|
|
22
|
+
/** Seconds a started loop may run before it is stopped. */
|
|
23
|
+
timeoutSeconds: number;
|
|
24
|
+
}
|
|
25
|
+
/** The verdict file a forked verifier session must leave behind. */
|
|
26
|
+
export interface VerdictTarget {
|
|
27
|
+
/** Absolute path to write. */
|
|
28
|
+
file: string;
|
|
29
|
+
/** `<runId>/<kind>/<step>/<attempt>`, checked against the reply before anything is written. */
|
|
30
|
+
identity: string;
|
|
31
|
+
}
|
|
32
|
+
/** How the startup run ended. */
|
|
33
|
+
export type StartupOutcome = 'passed' | 'idle' | 'failed' | 'needs-human';
|
|
34
|
+
/** Run the plan against a started controller.
|
|
35
|
+
* @param controller - Connected application facade.
|
|
36
|
+
* @param plan - Workspace, session and lines to run.
|
|
37
|
+
* @param log - Progress sink, usually stderr in headless mode.
|
|
38
|
+
* @returns Whether a started loop passed, nothing ran, or the run failed.
|
|
39
|
+
*/
|
|
40
|
+
export declare function runStartup(controller: Controller, plan: StartupPlan, log: (line: string) => void): Promise<StartupOutcome>;
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/** Startup automation: pick a workspace and a session, then run the requested slash lines.
|
|
2
|
+
*
|
|
3
|
+
* This is the scriptable half of the client — `dsht --ws X --session new --command "…"` — so a
|
|
4
|
+
* review can be launched without typing. It drives the same controller the UI drives, and reads only
|
|
5
|
+
* the outcome of each line: the presentational effects of a command belong to the UI.
|
|
6
|
+
*/
|
|
7
|
+
import { runCommand } from "../controller/index.js";
|
|
8
|
+
import { errorText } from "../transport/wire.js";
|
|
9
|
+
import { dirname } from 'node:path';
|
|
10
|
+
import { ensureDirectory, renameFile, writePrivateFile } from "../storage/index.js";
|
|
11
|
+
import { latestAssistantText } from "../controller/loop.js";
|
|
12
|
+
import { parseVerdict } from "../controller/loop-contract.js";
|
|
13
|
+
import { needsHumanLine } from "../controller/verifier.js";
|
|
14
|
+
import { authorize, normalize } from "../slash/index.js";
|
|
15
|
+
const POLL_MS = 250;
|
|
16
|
+
/** How long a connection, session or workspace operation may take. */
|
|
17
|
+
const STEP_TIMEOUT_MS = 30_000;
|
|
18
|
+
/** How long a forked child may wait for the session it was pointed at to stream its snapshot. */
|
|
19
|
+
const PROMPT_SNAPSHOT_TIMEOUT_MS = 180_000;
|
|
20
|
+
/** How long the verdict may take to appear after the turn was reported idle. */
|
|
21
|
+
const VERDICT_GRACE_MS = 5_000;
|
|
22
|
+
/** How often the reply is re-read while that grace lasts. */
|
|
23
|
+
const VERDICT_POLL_MS = 200;
|
|
24
|
+
/** Wait until one condition holds.
|
|
25
|
+
* @param condition - Predicate polled until true.
|
|
26
|
+
* @param what - Human-readable subject for the timeout message.
|
|
27
|
+
* @param timeoutMs - Longest wait.
|
|
28
|
+
*/
|
|
29
|
+
async function until(condition, what, timeoutMs = STEP_TIMEOUT_MS) {
|
|
30
|
+
const deadline = Date.now() + timeoutMs;
|
|
31
|
+
while (!condition()) {
|
|
32
|
+
if (Date.now() >= deadline)
|
|
33
|
+
throw new Error(`Timed out waiting for ${what}`);
|
|
34
|
+
await new Promise(resolve => setTimeout(resolve, POLL_MS));
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/** Run the plan against a started controller.
|
|
38
|
+
* @param controller - Connected application facade.
|
|
39
|
+
* @param plan - Workspace, session and lines to run.
|
|
40
|
+
* @param log - Progress sink, usually stderr in headless mode.
|
|
41
|
+
* @returns Whether a started loop passed, nothing ran, or the run failed.
|
|
42
|
+
*/
|
|
43
|
+
export async function runStartup(controller, plan, log) {
|
|
44
|
+
// `online` alone is too early: the startup picker runs after it and would overwrite a selection
|
|
45
|
+
// made here, which is exactly how a forked verifier used to lose its session.
|
|
46
|
+
await until(() => controller.state.online && controller.queries.connectionSettled, 'the host connection');
|
|
47
|
+
if (plan.workspace !== undefined && !await controller.actions.switchWorkspace(plan.workspace)) {
|
|
48
|
+
throw new Error(`Workspace not found: ${plan.workspace}`);
|
|
49
|
+
}
|
|
50
|
+
// An explicit `new`, or automation with no session named, starts a fresh conversation; the
|
|
51
|
+
// workspace defaults to the directory this client runs in, registered on demand.
|
|
52
|
+
if (plan.session === 'new' || plan.session === undefined && plan.commands.length > 0) {
|
|
53
|
+
await ensureWorkspace(controller, log);
|
|
54
|
+
if (!await controller.actions.createSession())
|
|
55
|
+
throw new Error('Could not create a session');
|
|
56
|
+
}
|
|
57
|
+
else if (plan.session !== undefined) {
|
|
58
|
+
if (!await controller.actions.selectSession(plan.session))
|
|
59
|
+
throw new Error(`Session not found: ${plan.session}`);
|
|
60
|
+
}
|
|
61
|
+
// A selected session is not sendable until its follow snapshot lands.
|
|
62
|
+
if (plan.commands.length) {
|
|
63
|
+
await until(() => controller.state.screen === 'chat' && controller.state.sessionId !== undefined && controller.queries.record.ready, 'the session snapshot');
|
|
64
|
+
}
|
|
65
|
+
// Start-up lines are one-shot action commands; the cancellable port is only used by searches
|
|
66
|
+
// and exports, which have no meaning before the operator is present.
|
|
67
|
+
const port = { run: (_label, operation) => operation(controller.connection.signal()) };
|
|
68
|
+
for (const line of plan.commands) {
|
|
69
|
+
// The scripted half runs the same two stages the composer does after `interpret`: a headless
|
|
70
|
+
// caller has no menus or screens, but it has every application fact `normalize`/`authorize` read.
|
|
71
|
+
const command = normalize({ kind: 'line', line }, {
|
|
72
|
+
sessionSelected: controller.state.sessionId !== undefined,
|
|
73
|
+
question: controller.state.pending[0]?.kind === 'question',
|
|
74
|
+
pending: controller.state.pending.length > 0,
|
|
75
|
+
});
|
|
76
|
+
if (command.kind === 'error')
|
|
77
|
+
throw new Error(command.message);
|
|
78
|
+
const verdict = await authorizeWhenReady(controller, command);
|
|
79
|
+
if (!verdict.allow)
|
|
80
|
+
throw new Error(verdict.error.message);
|
|
81
|
+
const result = await runCommand(controller, verdict.command, port);
|
|
82
|
+
if (result === undefined) {
|
|
83
|
+
const reason = controller.state.lastFailure;
|
|
84
|
+
throw new Error(`Command was not accepted: ${line}${reason ? ` (${reason})` : ''}`);
|
|
85
|
+
}
|
|
86
|
+
if (result.outcome !== 'ok') {
|
|
87
|
+
const failure = result.effects.find(effect => effect.kind === 'error');
|
|
88
|
+
throw new Error(failure?.kind === 'error' ? failure.text : `Command failed (${result.outcome}): ${line}`);
|
|
89
|
+
}
|
|
90
|
+
if (result.disposition === 'retain')
|
|
91
|
+
throw new Error(`Command did not settle: ${line}`);
|
|
92
|
+
for (const effect of result.effects)
|
|
93
|
+
if (effect.kind === 'notice')
|
|
94
|
+
log(effect.text);
|
|
95
|
+
}
|
|
96
|
+
// Both counts are taken before the prompt, because a turn can start and finish between two polls.
|
|
97
|
+
const finishedBefore = controller.queries.turnsCompleted;
|
|
98
|
+
const repliesBefore = controller.queries.record.messages.length;
|
|
99
|
+
if (plan.prompt !== undefined) {
|
|
100
|
+
// Sending a prompt needs the follow snapshot, and a verifier starts the instant a busy turn ends,
|
|
101
|
+
// so this wait is longer than a normal startup step: the host can be slow to open the new stream.
|
|
102
|
+
try {
|
|
103
|
+
await until(() => controller.state.sessionId !== undefined && controller.queries.record.ready, 'the verifier session snapshot', PROMPT_SNAPSHOT_TIMEOUT_MS);
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
// Which of the three conditions failed is the whole diagnosis, so it travels with the error.
|
|
107
|
+
throw new Error(`${errorText(error)} (screen=${controller.state.screen}, session=${controller.state.sessionId ?? 'none'},`
|
|
108
|
+
+ ` snapshot=${String(controller.queries.record.ready)}, online=${String(controller.state.online)})`);
|
|
109
|
+
}
|
|
110
|
+
await controller.actions.prompt(plan.prompt);
|
|
111
|
+
}
|
|
112
|
+
if (controller.queries.loop !== undefined)
|
|
113
|
+
return await waitForLoop(controller, plan.timeoutSeconds * 1000, log);
|
|
114
|
+
if (plan.wait) {
|
|
115
|
+
const outcome = await waitForTurn(controller, finishedBefore, plan.timeoutSeconds * 1000, log);
|
|
116
|
+
if (outcome === 'idle' && plan.verdict !== undefined)
|
|
117
|
+
await writeVerdict(controller, plan.verdict, repliesBefore, log);
|
|
118
|
+
return outcome;
|
|
119
|
+
}
|
|
120
|
+
return 'idle';
|
|
121
|
+
}
|
|
122
|
+
/** Authorize one startup line, waiting out a fact the policy queues behind.
|
|
123
|
+
*
|
|
124
|
+
* There is no composer here to hold a line, and failing a scripted run because the client happened to
|
|
125
|
+
* be mid-turn would make `--command` unusable in exactly the automation it exists for; so the scripted
|
|
126
|
+
* caller waits for the same fact the UI queue waits for, bounded by the step timeout.
|
|
127
|
+
* @param controller - Connected facade whose facts decide.
|
|
128
|
+
* @param command - Normalized line to run.
|
|
129
|
+
* @returns The verdict once the line may run, or the refusal it will never outlive.
|
|
130
|
+
*/
|
|
131
|
+
async function authorizeWhenReady(controller, command) {
|
|
132
|
+
const deadline = Date.now() + STEP_TIMEOUT_MS;
|
|
133
|
+
for (;;) {
|
|
134
|
+
const verdict = authorize(command, {
|
|
135
|
+
sessionSelected: controller.state.sessionId !== undefined,
|
|
136
|
+
pending: controller.state.pending.length > 0,
|
|
137
|
+
during: controller.queries.loop?.active === true ? 'loop' : controller.queries.running ? 'turn' : 'idle',
|
|
138
|
+
foreground: controller.queries.foreground !== undefined,
|
|
139
|
+
});
|
|
140
|
+
if (!verdict.allow || verdict.defer === undefined)
|
|
141
|
+
return verdict;
|
|
142
|
+
if (Date.now() >= deadline)
|
|
143
|
+
throw new Error(`Timed out waiting for the client to be free: ${command.kind}`);
|
|
144
|
+
await new Promise(resolve => setTimeout(resolve, POLL_MS));
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
/** Persist the verdict this session's reply just produced.
|
|
148
|
+
*
|
|
149
|
+
* The agent judges; this client owns the protocol file: the reply is parsed here and written through
|
|
150
|
+
* a temporary file that is renamed into place, so a reader never sees a half-written verdict.
|
|
151
|
+
* A reply with no usable verdict changes nothing — the parent validates whatever is there.
|
|
152
|
+
* @param controller - Connected facade whose transcript holds the reply.
|
|
153
|
+
* @param target - File to write and the identity the verdict must declare.
|
|
154
|
+
* @param repliesBefore - Messages on the transcript before the prompt was sent: only a reply that
|
|
155
|
+
* arrived after it can be this turn's, so an idle from an earlier or replayed turn cannot be read
|
|
156
|
+
* as the verdict.
|
|
157
|
+
* @param log - Progress sink.
|
|
158
|
+
*/
|
|
159
|
+
async function writeVerdict(controller, target, repliesBefore, log) {
|
|
160
|
+
const [, kind = '', step = '', attempt = ''] = target.identity.split('/');
|
|
161
|
+
const expect = { verificationId: target.identity, kind, step: Number(step), attempt: Number(attempt) };
|
|
162
|
+
// The host reports the turn idle just before that reply is committed, so an immediate parse can
|
|
163
|
+
// find nothing; the verdict is only missing once it has had time to arrive. Waiting on an
|
|
164
|
+
// assistant reply that did not exist when the prompt was sent is what ties the verdict to this
|
|
165
|
+
// prompt: the prompt itself only adds a user message, and an earlier turn's reply is behind the
|
|
166
|
+
// baseline.
|
|
167
|
+
const replyArrived = () => controller.queries.record.messages
|
|
168
|
+
.slice(repliesBefore).some(message => message.role === 'Assistant');
|
|
169
|
+
let parsed = replyArrived() ? parseVerdict(latestAssistantText(controller.queries.record.messages), expect) : undefined;
|
|
170
|
+
const deadline = Date.now() + VERDICT_GRACE_MS;
|
|
171
|
+
while (parsed === undefined && Date.now() < deadline) {
|
|
172
|
+
await new Promise(resolve => setTimeout(resolve, VERDICT_POLL_MS));
|
|
173
|
+
if (replyArrived())
|
|
174
|
+
parsed = parseVerdict(latestAssistantText(controller.queries.record.messages), expect);
|
|
175
|
+
}
|
|
176
|
+
if (parsed === undefined) {
|
|
177
|
+
// Say what actually happened: "no reply yet", "no JSON at all" and "JSON that is not this round"
|
|
178
|
+
// are different faults, and one line here is what tells them apart without another live run.
|
|
179
|
+
if (!replyArrived()) {
|
|
180
|
+
log('Turn ended but no reply was committed after the prompt; leaving the verdict file untouched');
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
const tail = latestAssistantText(controller.queries.record.messages).slice(-240).replace(/\s+/g, ' ');
|
|
184
|
+
log(`No parsable verdict in the reply; leaving the verdict file untouched (reply tail: ${tail})`);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
const body = JSON.stringify({
|
|
188
|
+
verificationId: target.identity, kind, step: Number(step), attempt: Number(attempt),
|
|
189
|
+
...(parsed.score === undefined ? {} : { score: parsed.score }),
|
|
190
|
+
...(parsed.status === undefined ? {} : { status: parsed.status }),
|
|
191
|
+
...(parsed.blocked === true ? { blocked: true } : {}),
|
|
192
|
+
...(parsed.evidence === undefined ? {} : { evidence: parsed.evidence }),
|
|
193
|
+
...(parsed.findings === undefined ? {} : { top_findings: parsed.findings }),
|
|
194
|
+
});
|
|
195
|
+
await ensureDirectory(dirname(target.file));
|
|
196
|
+
const temporary = `${target.file}.part`;
|
|
197
|
+
await writePrivateFile(temporary, body);
|
|
198
|
+
await renameFile(temporary, target.file);
|
|
199
|
+
log(`Verdict written to ${target.file}`);
|
|
200
|
+
}
|
|
201
|
+
/** Follow a sent prompt's turn to its end, which is what lets a forked child exit by itself.
|
|
202
|
+
*
|
|
203
|
+
* The host's idle event is the same signal the scored loop trusts: it cannot be missed by polling,
|
|
204
|
+
* and a turn that starts and ends between two polls still counts.
|
|
205
|
+
* @param controller - Connected application facade.
|
|
206
|
+
* @param finishedBefore - Turns completed before the prompt was sent.
|
|
207
|
+
* @param timeoutMs - Longest wait.
|
|
208
|
+
* @param log - Progress sink.
|
|
209
|
+
* @returns `idle` when the turn ended, `failed` on timeout.
|
|
210
|
+
*/
|
|
211
|
+
async function waitForTurn(controller, finishedBefore, timeoutMs, log) {
|
|
212
|
+
const deadline = Date.now() + timeoutMs;
|
|
213
|
+
for (;;) {
|
|
214
|
+
if (controller.queries.turnsCompleted > finishedBefore) {
|
|
215
|
+
log('Turn finished');
|
|
216
|
+
return 'idle';
|
|
217
|
+
}
|
|
218
|
+
// A headless verifier cannot answer an approval or a question: stop and say what is needed,
|
|
219
|
+
// instead of waiting for the deadline and being retried as an infrastructure failure.
|
|
220
|
+
const request = humanRequest(controller);
|
|
221
|
+
if (request !== undefined) {
|
|
222
|
+
log(needsHumanLine(request));
|
|
223
|
+
return 'needs-human';
|
|
224
|
+
}
|
|
225
|
+
if (Date.now() >= deadline) {
|
|
226
|
+
log('Turn timed out');
|
|
227
|
+
return 'failed';
|
|
228
|
+
}
|
|
229
|
+
await new Promise(resolve => setTimeout(resolve, POLL_MS));
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
/** The host interaction this client cannot answer itself, when one is pending.
|
|
233
|
+
* @param controller - Connected facade whose selected session is the verifier's session.
|
|
234
|
+
* @returns The request to report, or undefined when nothing is pending.
|
|
235
|
+
*/
|
|
236
|
+
function humanRequest(controller) {
|
|
237
|
+
const [pending] = controller.state.pending;
|
|
238
|
+
if (pending === undefined)
|
|
239
|
+
return undefined;
|
|
240
|
+
if (pending.kind === 'approval') {
|
|
241
|
+
return { kind: 'approval', text: pending.description === '' ? 'a tool approval is required' : pending.description };
|
|
242
|
+
}
|
|
243
|
+
return { kind: 'question', text: pending.questions[0]?.question ?? 'a question was asked' };
|
|
244
|
+
}
|
|
245
|
+
/** Select the directory this client runs in, registering it when the host does not know it yet. */
|
|
246
|
+
async function ensureWorkspace(controller, log) {
|
|
247
|
+
if (controller.state.workspaceId !== undefined)
|
|
248
|
+
return;
|
|
249
|
+
if (await controller.actions.switchWorkspace(controller.localDirectory))
|
|
250
|
+
return;
|
|
251
|
+
log(`Registering ${controller.localDirectory} as a workspace`);
|
|
252
|
+
if (!await controller.actions.createWorkspace(controller.localDirectory)) {
|
|
253
|
+
throw new Error(`Could not use ${controller.localDirectory} as a workspace`);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
/** Follow a running loop to its terminal phase, reporting progress changes.
|
|
257
|
+
* @returns `passed` when the loop met its threshold, `failed` otherwise.
|
|
258
|
+
*/
|
|
259
|
+
async function waitForLoop(controller, timeoutMs, log) {
|
|
260
|
+
const deadline = Date.now() + timeoutMs;
|
|
261
|
+
let previous = '';
|
|
262
|
+
for (;;) {
|
|
263
|
+
const progress = controller.queries.loop;
|
|
264
|
+
if (progress === undefined || progress.phase !== 'running') {
|
|
265
|
+
// A cancelled loop usually means the connection ended; say so, or the exit code is a mystery.
|
|
266
|
+
const why = controller.state.lastFailure || controller.state.status;
|
|
267
|
+
const interaction = progress?.interaction;
|
|
268
|
+
// `passed` only claims the rounds that ran, so a headless reader is told which ones.
|
|
269
|
+
const scope = progress?.phase === 'passed' ? ` · ${progress.scope}` : '';
|
|
270
|
+
log(`Loop ${progress?.phase ?? 'gone'}${scope}${why ? ` · ${why}` : ''}`
|
|
271
|
+
+ `${interaction === undefined ? '' : ` · ${interaction.kind}: ${interaction.text}`}`
|
|
272
|
+
+ `${progress?.note === undefined ? '' : ` · ${progress.note}`}`);
|
|
273
|
+
if (progress?.phase === 'passed')
|
|
274
|
+
return 'passed';
|
|
275
|
+
// A run stopped on a host request is not a failure: it is waiting for a person, and phase 1
|
|
276
|
+
// deliberately has no in-process answer path.
|
|
277
|
+
return progress?.phase === 'needs-human' ? 'needs-human' : 'failed';
|
|
278
|
+
}
|
|
279
|
+
const label = progress.stepLabel === undefined ? '' : ` · ${progress.stepLabel}`;
|
|
280
|
+
// The note carries why an attempt was decided the way it was — including why verification was
|
|
281
|
+
// unavailable — so a headless run is diagnosable without a renderer.
|
|
282
|
+
const note = progress.note === undefined ? '' : ` · ${progress.note}`;
|
|
283
|
+
const line = `${progress.title}${label} · step ${progress.step}/${progress.to} · attempt ${progress.attempt}/${progress.tries} · best ${progress.best}/${progress.score}${note}`;
|
|
284
|
+
if (line !== previous) {
|
|
285
|
+
previous = line;
|
|
286
|
+
log(line);
|
|
287
|
+
}
|
|
288
|
+
if (Date.now() >= deadline) {
|
|
289
|
+
controller.actions.stopLoop();
|
|
290
|
+
log('Loop timed out and was stopped');
|
|
291
|
+
return 'failed';
|
|
292
|
+
}
|
|
293
|
+
await new Promise(resolve => setTimeout(resolve, POLL_MS));
|
|
294
|
+
}
|
|
295
|
+
}
|