ninja-terminals 2.3.0 → 2.3.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/CLAUDE.md +81 -0
- package/ORCHESTRATOR-PROMPT.md +91 -19
- package/README.md +25 -2
- package/agent-send.js +395 -0
- package/cli.js +31 -14
- package/lib/nameGenerator.ts +101 -0
- package/lib/pre-dispatch.js +14 -4
- package/lib/runtime-session.js +337 -0
- package/lib/status-detect.js +68 -4
- package/mcp-server.js +267 -25
- package/ninja-claude-visual.js +13 -0
- package/ninja-codex-visual.js +258 -0
- package/ninja-codex.js +474 -0
- package/ninja-ensure.js +333 -0
- package/ninja-gate.js +340 -0
- package/ninja-login.js +171 -0
- package/ninja-logout.js +42 -0
- package/ninja-visual.js +125 -0
- package/ninja-whoami.js +29 -0
- package/package.json +26 -3
- package/prompts/orchestrator.md +3 -292
- package/public/app.js +197 -4
- package/public/log-viewer.html +463 -0
- package/public/style.css +64 -0
- package/server.js +335 -32
package/ninja-ensure.js
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const { spawn, execSync } = require('child_process');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const os = require('os');
|
|
8
|
+
const {
|
|
9
|
+
SESSION_FILE,
|
|
10
|
+
SESSION_DIR,
|
|
11
|
+
readRuntimeSession,
|
|
12
|
+
healthCheckSession,
|
|
13
|
+
writeRuntimeSession,
|
|
14
|
+
requestJson,
|
|
15
|
+
} = require('./lib/runtime-session');
|
|
16
|
+
|
|
17
|
+
const LOG_FILE = path.join(SESSION_DIR, 'ninja-server.log');
|
|
18
|
+
const PROJECT_ROOT = __dirname;
|
|
19
|
+
|
|
20
|
+
const PROBE_PORTS = [3300, 3301, 3302, 3303, 3304, 3305, 3306, 3307, 3308, 3309, 3310];
|
|
21
|
+
const AUTH_WAIT_MS = 15000;
|
|
22
|
+
const AUTH_POLL_MS = 500;
|
|
23
|
+
|
|
24
|
+
const USAGE = `
|
|
25
|
+
ninja-ensure — Start, discover, or recover a dispatch-ready Ninja Terminal runtime
|
|
26
|
+
|
|
27
|
+
Usage:
|
|
28
|
+
ninja-ensure Start/discover/recover, open browser, wait for auth
|
|
29
|
+
ninja-ensure --no-open Start/discover/recover, don't open browser
|
|
30
|
+
ninja-ensure --allow-no-auth Allow success without authToken (dispatch will fail)
|
|
31
|
+
ninja-ensure --dry-run Report what would happen, no action
|
|
32
|
+
ninja-ensure --help Show this help
|
|
33
|
+
|
|
34
|
+
Dispatch-ready means:
|
|
35
|
+
- Ninja Terminal server is running and healthy
|
|
36
|
+
- Session file exists with correct URL
|
|
37
|
+
- authToken is available for CLI dispatch (ninja-dispatch, agent-send)
|
|
38
|
+
|
|
39
|
+
If authToken is missing, run: ninja-login
|
|
40
|
+
|
|
41
|
+
Probed ports: ${PROBE_PORTS.join(', ')}
|
|
42
|
+
Also probes NINJA_PORT if set.
|
|
43
|
+
|
|
44
|
+
Session file: ${SESSION_FILE}
|
|
45
|
+
`;
|
|
46
|
+
|
|
47
|
+
function log(msg) {
|
|
48
|
+
console.log(msg);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function chromeHasUrl(url) {
|
|
52
|
+
if (process.platform !== 'darwin') return false;
|
|
53
|
+
try {
|
|
54
|
+
const script = `
|
|
55
|
+
tell application "Google Chrome"
|
|
56
|
+
repeat with w in windows
|
|
57
|
+
repeat with t in tabs of w
|
|
58
|
+
if URL of t contains "${url}" then return "found"
|
|
59
|
+
end repeat
|
|
60
|
+
end repeat
|
|
61
|
+
end tell
|
|
62
|
+
return "not_found"
|
|
63
|
+
`;
|
|
64
|
+
const result = execSync(`osascript -e '${script}'`, { encoding: 'utf8', timeout: 3000 }).trim();
|
|
65
|
+
return result === 'found';
|
|
66
|
+
} catch {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function openBrowser(url) {
|
|
72
|
+
if (process.platform === 'darwin') {
|
|
73
|
+
if (chromeHasUrl(url)) {
|
|
74
|
+
log('Browser already has Ninja Terminal open');
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
execSync(`open -a "Google Chrome" "${url}"`, { stdio: 'ignore' });
|
|
79
|
+
return true;
|
|
80
|
+
} catch {
|
|
81
|
+
try {
|
|
82
|
+
execSync(`open "${url}"`, { stdio: 'ignore' });
|
|
83
|
+
return true;
|
|
84
|
+
} catch {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const cmd = process.platform === 'win32' ? 'start' : 'xdg-open';
|
|
90
|
+
try {
|
|
91
|
+
execSync(`${cmd} "${url}"`, { stdio: 'ignore', shell: true });
|
|
92
|
+
return true;
|
|
93
|
+
} catch {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function getTokenFromSources(session) {
|
|
99
|
+
if (process.env.NINJA_AUTH_TOKEN) return process.env.NINJA_AUTH_TOKEN;
|
|
100
|
+
if (session?.authToken) return session.authToken;
|
|
101
|
+
const tokenPath = path.join(os.homedir(), '.ninja', 'token');
|
|
102
|
+
if (fs.existsSync(tokenPath)) {
|
|
103
|
+
return fs.readFileSync(tokenPath, 'utf8').trim();
|
|
104
|
+
}
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function waitForAuthToken(timeoutMs = AUTH_WAIT_MS, pollMs = AUTH_POLL_MS) {
|
|
109
|
+
const deadline = Date.now() + timeoutMs;
|
|
110
|
+
while (Date.now() < deadline) {
|
|
111
|
+
const session = readRuntimeSession();
|
|
112
|
+
const token = getTokenFromSources(session);
|
|
113
|
+
if (token) return token;
|
|
114
|
+
await new Promise(r => setTimeout(r, pollMs));
|
|
115
|
+
}
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function printTerminalStatus(session, token) {
|
|
120
|
+
if (!token) return;
|
|
121
|
+
try {
|
|
122
|
+
const res = await requestJson({
|
|
123
|
+
host: session.host || 'localhost',
|
|
124
|
+
port: session.port,
|
|
125
|
+
path: '/api/terminals',
|
|
126
|
+
token,
|
|
127
|
+
});
|
|
128
|
+
if (res.statusCode === 200 && Array.isArray(res.body)) {
|
|
129
|
+
for (const t of res.body) {
|
|
130
|
+
const parts = [`T${t.id}`, t.label || '', t.status || 'unknown'];
|
|
131
|
+
if (t.taskName) parts.push(`— ${t.taskName}`);
|
|
132
|
+
log(parts.filter(Boolean).join(' '));
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
} catch {
|
|
136
|
+
// Ignore — summary not critical
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function probePort(port, host = 'localhost') {
|
|
141
|
+
const session = { host, port };
|
|
142
|
+
const health = await healthCheckSession(session, 2000);
|
|
143
|
+
if (health.ok && health.response?.status === 'ok' && typeof health.response?.terminals === 'number') {
|
|
144
|
+
return { port, host, terminals: health.response.terminals };
|
|
145
|
+
}
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function probeForOrphanedServer() {
|
|
150
|
+
const portsToProbe = [...PROBE_PORTS];
|
|
151
|
+
|
|
152
|
+
if (process.env.NINJA_PORT) {
|
|
153
|
+
const envPort = parseInt(process.env.NINJA_PORT, 10);
|
|
154
|
+
if (!isNaN(envPort) && !portsToProbe.includes(envPort)) {
|
|
155
|
+
portsToProbe.unshift(envPort);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const results = await Promise.all(portsToProbe.map(port => probePort(port)));
|
|
160
|
+
const found = results.find(r => r !== null);
|
|
161
|
+
return found || null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function recoverSessionFile(port, host = 'localhost') {
|
|
165
|
+
const url = `http://${host}:${port}`;
|
|
166
|
+
const session = writeRuntimeSession({
|
|
167
|
+
port,
|
|
168
|
+
url,
|
|
169
|
+
cwd: process.cwd(),
|
|
170
|
+
terminals: 4,
|
|
171
|
+
command: 'ninja-ensure',
|
|
172
|
+
recovered: true,
|
|
173
|
+
recoveredAt: new Date().toISOString(),
|
|
174
|
+
pid: null, // Don't claim a pid we don't own
|
|
175
|
+
});
|
|
176
|
+
return session;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function waitForSession(timeoutMs = 20000, pollMs = 300) {
|
|
180
|
+
const deadline = Date.now() + timeoutMs;
|
|
181
|
+
while (Date.now() < deadline) {
|
|
182
|
+
const session = readRuntimeSession();
|
|
183
|
+
if (session?.port) {
|
|
184
|
+
const health = await healthCheckSession(session);
|
|
185
|
+
if (health.ok) return session;
|
|
186
|
+
}
|
|
187
|
+
await new Promise(r => setTimeout(r, pollMs));
|
|
188
|
+
}
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function startServer() {
|
|
193
|
+
fs.mkdirSync(SESSION_DIR, { recursive: true });
|
|
194
|
+
const logStream = fs.openSync(LOG_FILE, 'a');
|
|
195
|
+
|
|
196
|
+
const child = spawn('node', ['server.js'], {
|
|
197
|
+
cwd: PROJECT_ROOT,
|
|
198
|
+
env: {
|
|
199
|
+
...process.env,
|
|
200
|
+
NINJA_OPEN_BROWSER: 'false',
|
|
201
|
+
},
|
|
202
|
+
detached: true,
|
|
203
|
+
stdio: ['ignore', logStream, logStream],
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
child.unref();
|
|
207
|
+
fs.closeSync(logStream);
|
|
208
|
+
|
|
209
|
+
log(`Started server (pid ${child.pid}), waiting for ready...`);
|
|
210
|
+
log(`Logs: ${LOG_FILE}`);
|
|
211
|
+
|
|
212
|
+
const session = await waitForSession(20000);
|
|
213
|
+
if (!session) {
|
|
214
|
+
throw new Error('Server failed to start within 20 seconds. Check logs: ' + LOG_FILE);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return session;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function main() {
|
|
221
|
+
const args = process.argv.slice(2);
|
|
222
|
+
|
|
223
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
224
|
+
console.log(USAGE);
|
|
225
|
+
process.exit(0);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const noOpen = args.includes('--no-open');
|
|
229
|
+
const dryRun = args.includes('--dry-run');
|
|
230
|
+
const allowNoAuth = args.includes('--allow-no-auth');
|
|
231
|
+
|
|
232
|
+
// Step 1: Check existing session file
|
|
233
|
+
const existingSession = readRuntimeSession();
|
|
234
|
+
let session = null;
|
|
235
|
+
let action = 'none';
|
|
236
|
+
|
|
237
|
+
if (existingSession?.port) {
|
|
238
|
+
const health = await healthCheckSession(existingSession);
|
|
239
|
+
if (health.ok) {
|
|
240
|
+
session = existingSession;
|
|
241
|
+
action = 'reuse';
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Step 2: If no healthy session, probe for orphaned server
|
|
246
|
+
if (!session) {
|
|
247
|
+
log('No healthy session file. Probing for orphaned Ninja server...');
|
|
248
|
+
const orphaned = await probeForOrphanedServer();
|
|
249
|
+
if (orphaned) {
|
|
250
|
+
action = 'recover';
|
|
251
|
+
if (!dryRun) {
|
|
252
|
+
session = recoverSessionFile(orphaned.port, orphaned.host);
|
|
253
|
+
log(`Recovered orphaned runtime: http://${orphaned.host}:${orphaned.port}`);
|
|
254
|
+
} else {
|
|
255
|
+
session = { port: orphaned.port, host: orphaned.host, url: `http://${orphaned.host}:${orphaned.port}` };
|
|
256
|
+
}
|
|
257
|
+
} else {
|
|
258
|
+
action = 'start';
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Dry run reporting
|
|
263
|
+
if (dryRun) {
|
|
264
|
+
const existingToken = getTokenFromSources(existingSession);
|
|
265
|
+
if (action === 'reuse') {
|
|
266
|
+
log(`[dry-run] Would reuse existing runtime at ${existingSession.url}`);
|
|
267
|
+
log(`[dry-run] authToken present: ${!!existingToken}`);
|
|
268
|
+
} else if (action === 'recover') {
|
|
269
|
+
log(`[dry-run] Would recover orphaned runtime at ${session.url}`);
|
|
270
|
+
log(`[dry-run] Would repair session file: ${SESSION_FILE}`);
|
|
271
|
+
log(`[dry-run] authToken will be missing (browser sync required)`);
|
|
272
|
+
} else {
|
|
273
|
+
log(`[dry-run] No runtime found on probed ports`);
|
|
274
|
+
log(`[dry-run] Would start new server from ${PROJECT_ROOT}`);
|
|
275
|
+
}
|
|
276
|
+
log(`[dry-run] Would open browser: ${!noOpen}`);
|
|
277
|
+
process.exit(0);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Step 3: Start new server if needed
|
|
281
|
+
if (action === 'start') {
|
|
282
|
+
session = await startServer();
|
|
283
|
+
log(`Server ready on ${session.url}`);
|
|
284
|
+
} else if (action === 'reuse') {
|
|
285
|
+
log(`Runtime already active: ${session.url}`);
|
|
286
|
+
}
|
|
287
|
+
// action === 'recover' already logged above
|
|
288
|
+
|
|
289
|
+
// Step 4: Open browser
|
|
290
|
+
if (!noOpen) {
|
|
291
|
+
const opened = openBrowser(session.url);
|
|
292
|
+
if (opened) {
|
|
293
|
+
log(`Opened: ${session.url}`);
|
|
294
|
+
} else {
|
|
295
|
+
log(`Browser open failed — navigate manually to ${session.url}`);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
log(`Session file: ${SESSION_FILE}`);
|
|
300
|
+
|
|
301
|
+
// Step 5: Check dispatch readiness (authToken)
|
|
302
|
+
let token = getTokenFromSources(session);
|
|
303
|
+
|
|
304
|
+
if (!token && !noOpen) {
|
|
305
|
+
log('Waiting for auth token... (run ninja-login if this hangs)');
|
|
306
|
+
token = await waitForAuthToken(AUTH_WAIT_MS, AUTH_POLL_MS);
|
|
307
|
+
if (token) {
|
|
308
|
+
session = readRuntimeSession(); // Re-read with updated token
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (token) {
|
|
313
|
+
log('Dispatch ready');
|
|
314
|
+
await printTerminalStatus(session, token);
|
|
315
|
+
process.exit(0);
|
|
316
|
+
} else {
|
|
317
|
+
if (allowNoAuth) {
|
|
318
|
+
log('Warning: authToken missing. Run: ninja-login');
|
|
319
|
+
log('Runtime is running but CLI dispatch is NOT ready');
|
|
320
|
+
process.exit(0);
|
|
321
|
+
} else {
|
|
322
|
+
log('Error: authToken missing — dispatch will fail');
|
|
323
|
+
log('Open browser and login, then rerun ninja-ensure');
|
|
324
|
+
log('Or use --allow-no-auth to skip this check');
|
|
325
|
+
process.exit(1);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
main().catch((err) => {
|
|
331
|
+
console.error(`Error: ${err.message}`);
|
|
332
|
+
process.exit(1);
|
|
333
|
+
});
|
package/ninja-gate.js
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const {
|
|
6
|
+
SESSION_FILE,
|
|
7
|
+
LEDGER_FILE,
|
|
8
|
+
VERIFICATION_LEDGER_FILE,
|
|
9
|
+
VISUAL_LEDGER_FILE,
|
|
10
|
+
readRuntimeSession,
|
|
11
|
+
readAuthToken,
|
|
12
|
+
healthCheckSession,
|
|
13
|
+
requestJson,
|
|
14
|
+
} = require('./lib/runtime-session');
|
|
15
|
+
|
|
16
|
+
const USAGE = `
|
|
17
|
+
Usage:
|
|
18
|
+
ninja-gate [--since <duration|iso>] [--json]
|
|
19
|
+
|
|
20
|
+
Validates Ninja Terminal evidence without relying on Claude hooks.
|
|
21
|
+
|
|
22
|
+
Checks:
|
|
23
|
+
- Runtime session exists and /health passes
|
|
24
|
+
- At least one successful dispatch exists after --since
|
|
25
|
+
- pre-dispatch visual exists between --since and latest dispatch
|
|
26
|
+
- verification exists after latest dispatch
|
|
27
|
+
- post-output/final-visual exists after latest verification
|
|
28
|
+
- dispatched terminals have terminal taskStatus done/blocked/error when API is available
|
|
29
|
+
|
|
30
|
+
Examples:
|
|
31
|
+
ninja-gate --since 20m
|
|
32
|
+
ninja-gate --since 2026-05-03T17:00:00.000Z --json
|
|
33
|
+
`;
|
|
34
|
+
|
|
35
|
+
const COMPLETE_TASK_STATES = ['done', 'blocked', 'error'];
|
|
36
|
+
const POST_VISUAL_STAGES = ['post-output', 'final-visual'];
|
|
37
|
+
|
|
38
|
+
function readNdjson(file) {
|
|
39
|
+
try {
|
|
40
|
+
const raw = fs.readFileSync(file, 'utf8');
|
|
41
|
+
return raw.trim().split('\n').filter(Boolean).map((line) => {
|
|
42
|
+
try {
|
|
43
|
+
return JSON.parse(line);
|
|
44
|
+
} catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}).filter(Boolean);
|
|
48
|
+
} catch {
|
|
49
|
+
return [];
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function parseSinceArg(value, now = Date.now()) {
|
|
54
|
+
if (!value) return new Date(now - 20 * 60 * 1000).toISOString();
|
|
55
|
+
|
|
56
|
+
const duration = String(value).trim().match(/^(\d+)(ms|s|m|h|d)$/i);
|
|
57
|
+
if (duration) {
|
|
58
|
+
const amount = Number.parseInt(duration[1], 10);
|
|
59
|
+
const unit = duration[2].toLowerCase();
|
|
60
|
+
const factors = {
|
|
61
|
+
ms: 1,
|
|
62
|
+
s: 1000,
|
|
63
|
+
m: 60 * 1000,
|
|
64
|
+
h: 60 * 60 * 1000,
|
|
65
|
+
d: 24 * 60 * 60 * 1000,
|
|
66
|
+
};
|
|
67
|
+
return new Date(now - amount * factors[unit]).toISOString();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const parsed = Date.parse(value);
|
|
71
|
+
if (!Number.isNaN(parsed)) return new Date(parsed).toISOString();
|
|
72
|
+
|
|
73
|
+
throw new Error(`Invalid --since value: ${value}`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function isAfter(timestamp, afterTs) {
|
|
77
|
+
return Boolean(timestamp && timestamp > afterTs);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function isBetween(timestamp, startTs, endTs) {
|
|
81
|
+
return Boolean(timestamp && timestamp > startTs && timestamp < endTs);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function getTaskStatusTimestamp(status) {
|
|
85
|
+
return status?.updatedAt || status?.taskStatusUpdatedAt || status?.timestamp || null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function evaluateEvidence({ sinceTs, dispatchEntries, verificationEntries, visualEntries, taskStatuses }) {
|
|
89
|
+
const failures = [];
|
|
90
|
+
const warnings = [];
|
|
91
|
+
|
|
92
|
+
const successfulDispatches = dispatchEntries
|
|
93
|
+
.filter(e => e.success === true && isAfter(e.timestamp, sinceTs))
|
|
94
|
+
.sort((a, b) => String(a.timestamp).localeCompare(String(b.timestamp)));
|
|
95
|
+
|
|
96
|
+
if (successfulDispatches.length === 0) {
|
|
97
|
+
failures.push(`No successful dispatch found after ${sinceTs}`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const latestDispatch = successfulDispatches[successfulDispatches.length - 1] || null;
|
|
101
|
+
const dispatchedTerminalIds = [...new Set(successfulDispatches.map(e => String(e.terminalId)).filter(Boolean))];
|
|
102
|
+
const latestDispatchByTerminalId = new Map();
|
|
103
|
+
for (const dispatch of successfulDispatches) {
|
|
104
|
+
const terminalId = String(dispatch.terminalId);
|
|
105
|
+
const previous = latestDispatchByTerminalId.get(terminalId);
|
|
106
|
+
if (!previous || String(dispatch.timestamp).localeCompare(String(previous.timestamp)) > 0) {
|
|
107
|
+
latestDispatchByTerminalId.set(terminalId, dispatch);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
let preVisual = null;
|
|
112
|
+
let latestVerification = null;
|
|
113
|
+
let postVisual = null;
|
|
114
|
+
|
|
115
|
+
if (latestDispatch) {
|
|
116
|
+
preVisual = visualEntries
|
|
117
|
+
.filter(e => e.stage === 'pre-dispatch' && isBetween(e.timestamp, sinceTs, latestDispatch.timestamp))
|
|
118
|
+
.sort((a, b) => String(a.timestamp).localeCompare(String(b.timestamp)))
|
|
119
|
+
.at(-1) || null;
|
|
120
|
+
|
|
121
|
+
if (!preVisual) {
|
|
122
|
+
failures.push('Missing pre-dispatch visual entry between --since and latest dispatch');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
latestVerification = verificationEntries
|
|
126
|
+
.filter(e => isAfter(e.timestamp, latestDispatch.timestamp))
|
|
127
|
+
.sort((a, b) => String(a.timestamp).localeCompare(String(b.timestamp)))
|
|
128
|
+
.at(-1) || null;
|
|
129
|
+
|
|
130
|
+
if (!latestVerification) {
|
|
131
|
+
failures.push('Missing verification entry after latest dispatch');
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (latestVerification) {
|
|
136
|
+
postVisual = visualEntries
|
|
137
|
+
.filter(e => POST_VISUAL_STAGES.includes(e.stage) && isAfter(e.timestamp, latestVerification.timestamp))
|
|
138
|
+
.sort((a, b) => String(a.timestamp).localeCompare(String(b.timestamp)))
|
|
139
|
+
.at(-1) || null;
|
|
140
|
+
|
|
141
|
+
if (!postVisual) {
|
|
142
|
+
failures.push('Missing post-output/final-visual entry after latest verification');
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const taskStatusById = new Map();
|
|
147
|
+
for (const status of taskStatuses || []) {
|
|
148
|
+
if (status && status.id !== undefined) taskStatusById.set(String(status.id), status);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const incompleteTaskStatuses = [];
|
|
152
|
+
if (taskStatuses === null) {
|
|
153
|
+
warnings.push('Task status API unavailable; skipped semantic task status check');
|
|
154
|
+
} else {
|
|
155
|
+
for (const terminalId of dispatchedTerminalIds) {
|
|
156
|
+
const status = taskStatusById.get(String(terminalId));
|
|
157
|
+
if (!status) {
|
|
158
|
+
incompleteTaskStatuses.push({ terminalId, taskStatus: 'missing' });
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const state = status.taskStatus || status.state || 'unknown';
|
|
163
|
+
if (!COMPLETE_TASK_STATES.includes(state)) {
|
|
164
|
+
incompleteTaskStatuses.push({ terminalId, taskStatus: state });
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const taskUpdatedAt = getTaskStatusTimestamp(status);
|
|
169
|
+
const terminalDispatch = latestDispatchByTerminalId.get(String(terminalId)) || latestDispatch;
|
|
170
|
+
if (terminalDispatch && (!taskUpdatedAt || taskUpdatedAt <= terminalDispatch.timestamp)) {
|
|
171
|
+
incompleteTaskStatuses.push({ terminalId, taskStatus: `${state} (stale)` });
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (incompleteTaskStatuses.length > 0) {
|
|
176
|
+
failures.push(`Incomplete task status for terminal(s): ${
|
|
177
|
+
incompleteTaskStatuses.map(t => `T${t.terminalId}=${t.taskStatus}`).join(', ')
|
|
178
|
+
}`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
ok: failures.length === 0,
|
|
184
|
+
sinceTs,
|
|
185
|
+
failures,
|
|
186
|
+
warnings,
|
|
187
|
+
summary: {
|
|
188
|
+
successfulDispatches: successfulDispatches.length,
|
|
189
|
+
latestDispatch,
|
|
190
|
+
latestVerification,
|
|
191
|
+
preVisual,
|
|
192
|
+
postVisual,
|
|
193
|
+
dispatchedTerminalIds,
|
|
194
|
+
latestDispatchByTerminalId: Object.fromEntries(latestDispatchByTerminalId),
|
|
195
|
+
incompleteTaskStatuses,
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function parseArgs(argv) {
|
|
201
|
+
const opts = {
|
|
202
|
+
since: null,
|
|
203
|
+
json: false,
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
for (let i = 0; i < argv.length; i++) {
|
|
207
|
+
const arg = argv[i];
|
|
208
|
+
if (arg === '--help' || arg === '-h') {
|
|
209
|
+
opts.help = true;
|
|
210
|
+
} else if (arg === '--json') {
|
|
211
|
+
opts.json = true;
|
|
212
|
+
} else if (arg === '--since') {
|
|
213
|
+
opts.since = argv[++i];
|
|
214
|
+
if (!opts.since) throw new Error('--since requires a value');
|
|
215
|
+
} else {
|
|
216
|
+
throw new Error(`Unknown argument: ${arg}`);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return opts;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function fetchTaskStatuses(session, token) {
|
|
224
|
+
if (!token) return null;
|
|
225
|
+
const res = await requestJson({
|
|
226
|
+
host: session.host || 'localhost',
|
|
227
|
+
port: session.port,
|
|
228
|
+
path: '/api/terminals/task-status',
|
|
229
|
+
token,
|
|
230
|
+
timeoutMs: 3000,
|
|
231
|
+
});
|
|
232
|
+
if (res.statusCode !== 200 || !Array.isArray(res.body)) return null;
|
|
233
|
+
return res.body;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function printHuman(result, runtime) {
|
|
237
|
+
if (result.ok) {
|
|
238
|
+
console.log('Ninja gate: PASS');
|
|
239
|
+
} else {
|
|
240
|
+
console.log('Ninja gate: FAIL');
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
console.log(`Since: ${result.sinceTs}`);
|
|
244
|
+
if (runtime?.url) console.log(`Runtime: ${runtime.url}`);
|
|
245
|
+
|
|
246
|
+
const latestDispatch = result.summary.latestDispatch;
|
|
247
|
+
if (latestDispatch) {
|
|
248
|
+
console.log(`Latest dispatch: ${latestDispatch.timestamp} T${latestDispatch.terminalId}`);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const latestVerification = result.summary.latestVerification;
|
|
252
|
+
if (latestVerification) {
|
|
253
|
+
console.log(`Latest verification: ${latestVerification.timestamp} ${latestVerification.type || ''}`.trim());
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (result.summary.preVisual) {
|
|
257
|
+
console.log(`Pre visual: ${result.summary.preVisual.timestamp} ${result.summary.preVisual.stage}`);
|
|
258
|
+
}
|
|
259
|
+
if (result.summary.postVisual) {
|
|
260
|
+
console.log(`Post visual: ${result.summary.postVisual.timestamp} ${result.summary.postVisual.stage}`);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (result.warnings.length > 0) {
|
|
264
|
+
console.log('\nWarnings:');
|
|
265
|
+
for (const warning of result.warnings) console.log(`- ${warning}`);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (result.failures.length > 0) {
|
|
269
|
+
console.log('\nMissing evidence:');
|
|
270
|
+
for (const failure of result.failures) console.log(`- ${failure}`);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async function runCli(argv = process.argv.slice(2)) {
|
|
275
|
+
const opts = parseArgs(argv);
|
|
276
|
+
if (opts.help) {
|
|
277
|
+
console.log(USAGE);
|
|
278
|
+
return 0;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const sinceTs = parseSinceArg(opts.since);
|
|
282
|
+
const session = readRuntimeSession();
|
|
283
|
+
const token = process.env.NINJA_AUTH_TOKEN || session?.authToken || readAuthToken();
|
|
284
|
+
const runtimeFailures = [];
|
|
285
|
+
let health = null;
|
|
286
|
+
let taskStatuses = null;
|
|
287
|
+
|
|
288
|
+
if (!session || !session.port) {
|
|
289
|
+
runtimeFailures.push(`No runtime session found at ${SESSION_FILE}`);
|
|
290
|
+
} else {
|
|
291
|
+
health = await healthCheckSession(session);
|
|
292
|
+
if (!health.ok) {
|
|
293
|
+
runtimeFailures.push(`Runtime health check failed: ${health.error}`);
|
|
294
|
+
} else {
|
|
295
|
+
taskStatuses = await fetchTaskStatuses(session, token);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const result = evaluateEvidence({
|
|
300
|
+
sinceTs,
|
|
301
|
+
dispatchEntries: readNdjson(LEDGER_FILE),
|
|
302
|
+
verificationEntries: readNdjson(VERIFICATION_LEDGER_FILE),
|
|
303
|
+
visualEntries: readNdjson(VISUAL_LEDGER_FILE),
|
|
304
|
+
taskStatuses,
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
result.failures.unshift(...runtimeFailures);
|
|
308
|
+
result.ok = result.failures.length === 0;
|
|
309
|
+
result.runtime = {
|
|
310
|
+
sessionFile: SESSION_FILE,
|
|
311
|
+
url: session?.url || null,
|
|
312
|
+
health: health?.ok === true ? 'ok' : 'failed',
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
if (opts.json) {
|
|
316
|
+
console.log(JSON.stringify(result, null, 2));
|
|
317
|
+
} else {
|
|
318
|
+
printHuman(result, session);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return result.ok ? 0 : 1;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (require.main === module) {
|
|
325
|
+
runCli().then((code) => {
|
|
326
|
+
process.exit(code);
|
|
327
|
+
}).catch((err) => {
|
|
328
|
+
console.error(`Error: ${err.message}`);
|
|
329
|
+
process.exit(1);
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
module.exports = {
|
|
334
|
+
COMPLETE_TASK_STATES,
|
|
335
|
+
POST_VISUAL_STAGES,
|
|
336
|
+
evaluateEvidence,
|
|
337
|
+
getTaskStatusTimestamp,
|
|
338
|
+
parseSinceArg,
|
|
339
|
+
runCli,
|
|
340
|
+
};
|