@3minapi/cli 0.0.0-stage → 0.22.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.
Files changed (100) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +82 -2
  3. package/dist/bin.js +54 -0
  4. package/dist/cli.js +241 -0
  5. package/dist/commands/auth.js +573 -0
  6. package/dist/commands/common.js +48 -0
  7. package/dist/commands/dashboard.js +298 -0
  8. package/dist/commands/delete.js +167 -0
  9. package/dist/commands/doctor.js +326 -0
  10. package/dist/commands/guide.js +84 -0
  11. package/dist/commands/index.js +28 -0
  12. package/dist/commands/init.js +248 -0
  13. package/dist/commands/logs.js +154 -0
  14. package/dist/commands/new.js +109 -0
  15. package/dist/commands/paths.js +64 -0
  16. package/dist/commands/reload.js +32 -0
  17. package/dist/commands/results.js +445 -0
  18. package/dist/commands/start.js +443 -0
  19. package/dist/commands/stop.js +68 -0
  20. package/dist/commands/tasks.js +93 -0
  21. package/dist/commands/worker.js +178 -0
  22. package/dist/core/api.js +132 -0
  23. package/dist/core/config.js +188 -0
  24. package/dist/core/control-db.js +462 -0
  25. package/dist/core/credentials.js +75 -0
  26. package/dist/core/domain/constants.js +121 -0
  27. package/dist/core/domain/contract.js +102 -0
  28. package/dist/core/domain/errors.js +57 -0
  29. package/dist/core/domain/index.js +3 -0
  30. package/dist/core/domain/types.js +1 -0
  31. package/dist/core/error-report.js +103 -0
  32. package/dist/core/host/detach.js +49 -0
  33. package/dist/core/host/live-connection.js +270 -0
  34. package/dist/core/host/live-receiver.js +264 -0
  35. package/dist/core/host/module.js +1 -0
  36. package/dist/core/host/poller.js +113 -0
  37. package/dist/core/host/pull-step.js +54 -0
  38. package/dist/core/host/receiver.js +1 -0
  39. package/dist/core/host/reload.js +34 -0
  40. package/dist/core/host/runner-lock.js +245 -0
  41. package/dist/core/host/session.js +84 -0
  42. package/dist/core/host/usage.js +20 -0
  43. package/dist/core/log-read.js +59 -0
  44. package/dist/core/logger.js +206 -0
  45. package/dist/core/names.js +27 -0
  46. package/dist/core/network.js +10 -0
  47. package/dist/core/open-browser.js +26 -0
  48. package/dist/core/output.js +68 -0
  49. package/dist/core/paths.js +26 -0
  50. package/dist/core/project.js +85 -0
  51. package/dist/core/prompt.js +140 -0
  52. package/dist/core/redact.js +45 -0
  53. package/dist/core/secure-fs.js +50 -0
  54. package/dist/core/text-file.js +12 -0
  55. package/dist/core/text.js +27 -0
  56. package/dist/core/time.js +84 -0
  57. package/dist/core/update-notice.js +142 -0
  58. package/dist/core/version.js +16 -0
  59. package/dist/core/warnings.js +10 -0
  60. package/dist/help.js +144 -0
  61. package/dist/modules/dashboard/assets.js +411 -0
  62. package/dist/modules/dashboard/data.js +565 -0
  63. package/dist/modules/dashboard/guard.js +75 -0
  64. package/dist/modules/dashboard/lock.js +93 -0
  65. package/dist/modules/dashboard/server.js +295 -0
  66. package/dist/modules/dashboard/views/html.js +114 -0
  67. package/dist/modules/dashboard/views/layout.js +59 -0
  68. package/dist/modules/dashboard/views/logs.js +122 -0
  69. package/dist/modules/dashboard/views/overview.js +316 -0
  70. package/dist/modules/dashboard/views/runs.js +486 -0
  71. package/dist/modules/dashboard/views/workers.js +157 -0
  72. package/dist/modules/workers/builtin/host-identity.js +59 -0
  73. package/dist/modules/workers/builtin/results-worker.js +37 -0
  74. package/dist/modules/workers/local-server.js +244 -0
  75. package/dist/modules/workers/module.js +98 -0
  76. package/dist/modules/workers/reload-result.js +16 -0
  77. package/dist/modules/workers/repeat-scheduler.js +165 -0
  78. package/dist/modules/workers/results/missing-paths.js +66 -0
  79. package/dist/modules/workers/results/query.js +175 -0
  80. package/dist/modules/workers/results/reader-input.js +134 -0
  81. package/dist/modules/workers/results/reader.js +430 -0
  82. package/dist/modules/workers/results/results-child.js +64 -0
  83. package/dist/modules/workers/results/shape.js +212 -0
  84. package/dist/modules/workers/results/store.js +336 -0
  85. package/dist/modules/workers/results/substitute.js +30 -0
  86. package/dist/modules/workers/runtime/env.js +43 -0
  87. package/dist/modules/workers/runtime/error-chain.js +109 -0
  88. package/dist/modules/workers/runtime/executor.js +289 -0
  89. package/dist/modules/workers/runtime/inspect.js +53 -0
  90. package/dist/modules/workers/runtime/protocol.js +1 -0
  91. package/dist/modules/workers/runtime/resolve.js +30 -0
  92. package/dist/modules/workers/runtime/schema.js +85 -0
  93. package/dist/modules/workers/runtime/thread.js +117 -0
  94. package/dist/modules/workers/start.js +265 -0
  95. package/dist/modules/workers/sync.js +241 -0
  96. package/dist/modules/workers/task-runner.js +892 -0
  97. package/dist/modules/workers/template.js +100 -0
  98. package/dist/modules/workers/worker-set.js +122 -0
  99. package/dist/modules/workers/workers.js +66 -0
  100. package/package.json +48 -5
@@ -0,0 +1,486 @@
1
+ import { BIN } from "../../../core/domain/index.js";
2
+ import { formatBytes } from "../../../core/text.js";
3
+ import { readPayloadSlice } from "../../workers/results/reader.js";
4
+ import { childPath } from "../../workers/results/shape.js";
5
+ import { RUN_RANGES, RUN_STATES, encodeCursor, isRepeat } from "../data.js";
6
+ import { badge, command, copyButton, duration, html, qs, shortTaskId, tag, time, workerName, workerText } from "./html.js";
7
+ import { logTable } from "./logs.js";
8
+ import { rangeTabs } from "./overview.js";
9
+ export const INLINE_RESULT_BYTES = 64 * 1024;
10
+ export const ENTRIES_PER_PAGE = 100;
11
+ const REPLY_LABEL = {
12
+ pending: 'not sent yet',
13
+ result: 'result',
14
+ substitute: 'summary (too large)',
15
+ failed: 'error',
16
+ rejected: 'rejected by server',
17
+ none: 'not sent (local)'
18
+ };
19
+ function replyLabel(reply, repeat) {
20
+ if (repeat && reply === 'none')
21
+ return 'progress only';
22
+ return REPLY_LABEL[reply] ?? reply;
23
+ }
24
+ function runCountText(r) {
25
+ const n = r.runs ?? r.saved_runs ?? 0;
26
+ const max = r.repeat ? (r.repeat.max_runs ?? '∞') : null;
27
+ return max === null ? `↻ ${n} runs` : `↻ ${n}/${max} runs`;
28
+ }
29
+ export function runsPage(page, filter, workers, ctx) {
30
+ const { runs, next, paged, firstNo } = page;
31
+ const keep = { worker: filter.worker, state: filter.state };
32
+ const here = { ...keep, range: filter.range };
33
+ return html `
34
+ <div class="head">
35
+ <h1>Runs</h1>
36
+ ${rangeTabs('/runs', filter.range, keep, RUN_RANGES)}
37
+ </div>
38
+ <form class="filters" method="get" action="/runs" data-autosubmit>
39
+ <input type="hidden" name="range" value="${filter.range}" />
40
+ <label
41
+ >Worker
42
+ <select name="worker">
43
+ <option value="">All</option>
44
+ ${workers.map((w) => html `<option value="${w}" ${w === filter.worker ? html ` selected` : ''}>
45
+ ${workerText(w)}
46
+ </option>`)}
47
+ </select>
48
+ </label>
49
+ <label
50
+ >Status
51
+ <select name="state">
52
+ <option value="">All</option>
53
+ ${RUN_STATES.map((s) => html `<option value="${s}" ${s === filter.state ? html ` selected` : ''}>${s}</option>`)}
54
+ </select>
55
+ </label>
56
+ <noscript><button type="submit">Apply</button></noscript>
57
+ </form>
58
+ ${runs.length === 0
59
+ ? html `<p class="muted">No runs match.</p>`
60
+ : html `<div class="scroll">
61
+ <table class="table runs">
62
+ <thead>
63
+ <tr>
64
+ <th class="num">No</th>
65
+ <th>Task</th>
66
+ <th>Worker</th>
67
+ <th>Status</th>
68
+ <th class="num">Duration</th>
69
+ <th class="num">Size</th>
70
+ <th>Reply</th>
71
+ <th>Time</th>
72
+ </tr>
73
+ </thead>
74
+ <tbody>
75
+ ${runs.map((r, i) => html `<tr>
76
+ <td class="num muted">${firstNo + i}</td>
77
+ <td class="task">
78
+ <a href="/runs/${encodeURIComponent(r.task_id)}" title="${r.task_id}"
79
+ ><code>${shortTaskId(r.task_id)}</code></a
80
+ >${isRepeat(r)
81
+ ? html ` ${tag(runCountText(r), 'A repeating task — every run is saved; open it to see the runs')}`
82
+ : ''}${r.in === 'results'
83
+ ? html ` ${tag('result only', `No run history on host ${ctx.host} (pruned with tasks prune, or never recorded here); the saved result remains`)}`
84
+ : ''}
85
+ </td>
86
+ <td>${workerName(r.worker)}</td>
87
+ <td>${badge(r.state)}</td>
88
+ <td class="num">${duration(r.duration_ms)}</td>
89
+ <td class="num">${r.bytes === null ? '—' : formatBytes(r.bytes)}</td>
90
+ <td>
91
+ ${r.reply === null
92
+ ? html `<span class="muted">—</span>`
93
+ : replyLabel(r.reply, isRepeat(r))}
94
+ </td>
95
+ <td>
96
+ ${time(r.at)}${r.last_run_at !== null
97
+ ? html `<br /><span class="muted">last run ${time(r.last_run_at)}</span>`
98
+ : ''}
99
+ </td>
100
+ </tr>`)}
101
+ </tbody>
102
+ </table>
103
+ </div>
104
+ ${paged || next
105
+ ? html `<nav class="pager pager-runs" aria-label="Pages">
106
+ <span>${paged ? html `<a href="/runs${qs(here)}">← Newest</a>` : ''}</span>
107
+ <span
108
+ >${next
109
+ ? html `<a
110
+ href="/runs${qs({
111
+ ...here,
112
+ after: encodeCursor(next),
113
+ no: firstNo + runs.length
114
+ })}"
115
+ >Older →</a
116
+ >`
117
+ : html `<span class="muted">No older runs</span>`}</span
118
+ >
119
+ </nav>`
120
+ : ''}`}
121
+ `;
122
+ }
123
+ export function runPage(d, view, log, ctx) {
124
+ const r = d.run;
125
+ const base = `/runs/${encodeURIComponent(r.task_id)}`;
126
+ return html `
127
+ <p class="crumbs"><a href="/runs">Runs</a> / <code>${r.task_id}</code></p>
128
+ <div class="head"><h1>${workerName(r.worker)} ${badge(r.state)}</h1></div>
129
+ <div class="grid">
130
+ <section class="panel">
131
+ <h2>Run</h2>
132
+ <dl class="kv">
133
+ <dt>Task</dt>
134
+ <dd><code>${r.task_id}</code> ${copyButton(r.task_id)}</dd>
135
+ <dt>Received</dt>
136
+ <dd>${received(r, ctx)}</dd>
137
+ <dt>Started</dt>
138
+ <dd>${time(r.started_at)}</dd>
139
+ <dt>Finished</dt>
140
+ <dd>${time(r.finished_at)}</dd>
141
+ <dt>Duration</dt>
142
+ <dd>${duration(r.duration_ms)}</dd>
143
+ ${r.delivery_attempt !== null
144
+ ? html `<dt>Delivery</dt>
145
+ <dd>attempt ${r.delivery_attempt} · ran ${r.run_count} time(s)</dd>`
146
+ : ''}
147
+ ${lastError(r)}
148
+ </dl>
149
+ </section>
150
+ <section class="panel">
151
+ <h2>Saved result</h2>
152
+ ${d.result
153
+ ? html `${savedKv(d.result, false)}
154
+ <p>${command(`${BIN} worker results show ${r.task_id}`, ctx)}</p>`
155
+ : html `<p class="muted">
156
+ ${r.state === 'running'
157
+ ? 'Not finished yet.'
158
+ : 'No result saved for this task (cancelled, failed before running, a built-in reader task, or pruned from data.db).'}
159
+ </p>`}
160
+ </section>
161
+ </div>
162
+ ${d.result ? resultViewer(base, d.result.payload, d.result.bytes, d.result.status, view) : ''}
163
+ ${logPanel('Log for this task', log)}
164
+ `;
165
+ }
166
+ export function repeatPage(run, page, paged, ctx) {
167
+ const base = `/runs/${encodeURIComponent(run.task_id)}`;
168
+ const { runs, runsNext, total } = page;
169
+ const newest = runs[0]?.run_no;
170
+ const oldest = runs.at(-1)?.run_no;
171
+ return html `
172
+ <p class="crumbs"><a href="/runs">Runs</a> / <code>${run.task_id}</code></p>
173
+ <div class="head"><h1>${workerName(run.worker)} ${badge(run.state)}</h1></div>
174
+ <section class="panel">
175
+ <h2>Repeat</h2>
176
+ <dl class="kv">
177
+ <dt>Task</dt>
178
+ <dd><code>${run.task_id}</code> ${copyButton(run.task_id)}</dd>
179
+ ${run.repeat
180
+ ? html `<dt>Every</dt>
181
+ <dd>${run.repeat.interval_seconds}s</dd>
182
+ <dt>Runs</dt>
183
+ <dd>
184
+ ${run.runs ?? 0}/${run.repeat.max_runs ?? '∞'}${run.failed
185
+ ? ` · ${run.failed} failed`
186
+ : ''}
187
+ </dd>`
188
+ : html `<dt>Runs</dt>
189
+ <dd>${total} saved</dd>`}
190
+ <dt>Received</dt>
191
+ <dd>${received(run, ctx)}</dd>
192
+ ${run.repeat
193
+ ? html `<dt>Last run</dt>
194
+ <dd>${time(run.last_run_at)}</dd>
195
+ ${run.state === 'running'
196
+ ? html `<dt>Next run</dt>
197
+ <dd>${time(run.next_at)}</dd>`
198
+ : ''}`
199
+ : ''}
200
+ ${lastError(run)}
201
+ </dl>
202
+ <p>${command(`${BIN} worker results show ${run.task_id} --run <n>`, ctx)}</p>
203
+ </section>
204
+ <section class="panel">
205
+ <h2>
206
+ Runs${newest !== undefined
207
+ ? html ` <span class="muted"
208
+ >— run ${oldest}–${newest} of ${total} saved, newest first</span
209
+ >`
210
+ : ''}
211
+ </h2>
212
+ ${runs.length === 0
213
+ ? html `<p class="muted">No run saved yet.</p>`
214
+ : html `<div class="scroll">
215
+ <table class="table runs-of-task">
216
+ <thead>
217
+ <tr>
218
+ <th class="num">Run</th>
219
+ <th>Status</th>
220
+ <th>Saved</th>
221
+ <th class="num">Size</th>
222
+ <th>Preview</th>
223
+ </tr>
224
+ </thead>
225
+ <tbody>
226
+ ${runs.map((x) => {
227
+ const href = `${base}/${x.run_no}`;
228
+ return html `<tr>
229
+ <td class="num"><a href="${href}">${x.run_no}</a></td>
230
+ <td>${badge(x.status)}</td>
231
+ <td>${time(Date.parse(x.created_at))}</td>
232
+ <td class="num">${formatBytes(x.bytes)}</td>
233
+ <td class="preview">
234
+ <a href="${href}" tabindex="-1"
235
+ >${x.large
236
+ ? html `<span class="muted">large result — open to read it in parts</span>`
237
+ : html `<code${x.status === 'failed' ? html ` class="failed"` : ''}
238
+ >${x.preview}</code
239
+ >`}</a
240
+ >
241
+ </td>
242
+ </tr>`;
243
+ })}
244
+ </tbody>
245
+ </table>
246
+ </div>`}
247
+ ${paged || runsNext !== null
248
+ ? html `<nav class="pager pager-runs" aria-label="Pages">
249
+ <span>${paged ? html `<a href="${base}">← Newest</a>` : ''}</span>
250
+ <span
251
+ >${runsNext !== null
252
+ ? html `<a href="${base}${qs({ runs_before: runsNext })}">Older →</a>`
253
+ : html `<span class="muted">No older runs</span>`}</span
254
+ >
255
+ </nav>`
256
+ : ''}
257
+ </section>
258
+ `;
259
+ }
260
+ export function runOfTaskPage(run, runNo, d, view, log, ctx) {
261
+ const task = `/runs/${encodeURIComponent(run.task_id)}`;
262
+ const base = `${task}/${runNo}`;
263
+ const nav = html `<nav class="pager run-nav" aria-label="Runs">
264
+ <span>${d.prev !== null ? html `<a href="${task}/${d.prev}">← run ${d.prev}</a>` : ''}</span>
265
+ <span><a href="${task}">All runs</a></span>
266
+ <span>${d.next !== null ? html `<a href="${task}/${d.next}">run ${d.next} →</a>` : ''}</span>
267
+ </nav>`;
268
+ return html `
269
+ <p class="crumbs">
270
+ <a href="/runs">Runs</a> /
271
+ <a href="${task}" title="${run.task_id}"><code>${shortTaskId(run.task_id)}</code></a> / run
272
+ ${runNo}
273
+ </p>
274
+ <div class="head">
275
+ <h1>${workerName(run.worker)} · run ${runNo} ${d.result ? badge(d.result.status) : ''}</h1>
276
+ </div>
277
+ ${nav}
278
+ ${d.result
279
+ ? html `<section class="panel">
280
+ <h2>Run ${runNo}</h2>
281
+ ${savedKv(d.result, true)}
282
+ <p>${command(`${BIN} worker results show ${run.task_id} --run ${runNo}`, ctx)}</p>
283
+ </section>
284
+ ${resultViewer(base, d.result.payload, d.result.bytes, d.result.status, view)}
285
+ ${log ? logPanel(`Log for run ${runNo}`, log) : ''}`
286
+ : html `<section class="panel">
287
+ <p class="error">No saved run ${runNo} for this task.</p>
288
+ <p class="muted">
289
+ It has not run yet, it stopped before saving a result, or it was pruned from data.db.
290
+ ${d.prev !== null || d.next !== null ? 'The nearest saved runs are linked above.' : ''}
291
+ </p>
292
+ </section>`}
293
+ `;
294
+ }
295
+ function received(r, ctx) {
296
+ return r.in === 'results'
297
+ ? html `<span class="muted">no run history on host ${ctx.host}</span>`
298
+ : time(r.at);
299
+ }
300
+ function lastError(r) {
301
+ return r.last_error
302
+ ? html `<dt>Error</dt>
303
+ <dd class="error">${r.last_error}</dd>`
304
+ : '';
305
+ }
306
+ function savedKv(result, repeat) {
307
+ return html `<dl class="kv">
308
+ <dt>Status</dt>
309
+ <dd>${result.status}</dd>
310
+ <dt>Saved</dt>
311
+ <dd>${time(Date.parse(result.created_at))}</dd>
312
+ <dt>Size</dt>
313
+ <dd>${formatBytes(result.bytes)}</dd>
314
+ <dt>Reply</dt>
315
+ <dd>${replyLabel(result.reply, repeat)}</dd>
316
+ <dt>Received via</dt>
317
+ <dd>${result.source}</dd>
318
+ ${result.shape_hash
319
+ ? html `<dt>Shape</dt>
320
+ <dd><code>${result.shape_hash}</code></dd>`
321
+ : ''}
322
+ </dl>`;
323
+ }
324
+ function logPanel(title, log) {
325
+ return html `<section class="panel">
326
+ <h2>${title}</h2>
327
+ ${log.error
328
+ ? html `<p class="error">Could not read the log: ${log.error}</p>`
329
+ : log.lines.length > 0
330
+ ? logTable(log.lines, { channel: true, task: false })
331
+ : log.deleted
332
+ ? html `<p class="muted">
333
+ No log files from this run's dates remain — most likely deleted after
334
+ <code>logRetentionDays</code> (default 30) or when the logs passed
335
+ <code>logMaxBytes</code>.
336
+ </p>`
337
+ : html `<p class="muted">
338
+ No log lines for this run (older parts of that day's log may have been deleted).
339
+ </p>`}
340
+ </section>`;
341
+ }
342
+ function resultViewer(base, payload, bytes, status, view) {
343
+ const title = status === 'failed' ? 'Error' : 'Result';
344
+ if (bytes <= INLINE_RESULT_BYTES && view.path === '$' && view.offset === 0) {
345
+ let pretty;
346
+ let broken = null;
347
+ try {
348
+ pretty = JSON.stringify(JSON.parse(payload), null, 2);
349
+ }
350
+ catch (err) {
351
+ pretty = payload;
352
+ broken = err.message;
353
+ }
354
+ return html `<section class="panel">
355
+ <h2>${title}</h2>
356
+ ${broken
357
+ ? html `<p class="error">
358
+ The saved result is not valid JSON (it may be corrupted): ${broken}
359
+ </p>`
360
+ : ''}
361
+ <pre class="code json">${pretty}</pre>
362
+ </section>`;
363
+ }
364
+ let read = readPayloadSlice(payload, { ...view, limit: ENTRIES_PER_PAGE, summarize });
365
+ if (read.ok && read.result.kind === 'text')
366
+ read = readPayloadSlice(payload, view);
367
+ const crumbs = pathCrumbs(view.path, base);
368
+ if (!read.ok) {
369
+ return html `<section class="panel">
370
+ <h2>${title}</h2>
371
+ ${crumbs}
372
+ <p class="error">${read.message}</p>
373
+ </section>`;
374
+ }
375
+ const res = read.result;
376
+ const pager = html `<p class="pager">
377
+ ${res.offset > 0
378
+ ? html `<a
379
+ href="${base}${qs({
380
+ path: view.path,
381
+ offset: Math.max(0, res.offset - (res.kind === 'text' ? res.count : ENTRIES_PER_PAGE))
382
+ })}"
383
+ >← Previous</a
384
+ >`
385
+ : ''}
386
+ <span class="muted">${res.kind} · ${res.offset}–${res.offset + res.count} of ${res.total}</span>
387
+ ${res.next !== null
388
+ ? html `<a href="${base}${qs({ path: view.path, offset: res.next })}">Next →</a>`
389
+ : ''}
390
+ </p>`;
391
+ let body;
392
+ if (res.kind === 'array' || res.kind === 'object') {
393
+ const entries = res.kind === 'array'
394
+ ? res.items.map((v, i) => [
395
+ String(res.offset + i),
396
+ `${view.path}[${res.offset + i}]`,
397
+ v
398
+ ])
399
+ : Object.entries(res.items).map(([k, v]) => [
400
+ k,
401
+ childPath(view.path, k),
402
+ v
403
+ ]);
404
+ body = html `<div class="scroll">
405
+ <table class="table entries">
406
+ <tbody>
407
+ ${entries.map(([key, p, v]) => html `<tr>
408
+ <th scope="row">
409
+ ${v instanceof Folded || (v !== null && typeof v === 'object')
410
+ ? html `<a href="${base}${qs({ path: p })}">${key}</a>`
411
+ : key}
412
+ </th>
413
+ <td><pre class="code">${preview(v)}</pre></td>
414
+ </tr>`)}
415
+ </tbody>
416
+ </table>
417
+ </div>`;
418
+ }
419
+ else if (res.kind === 'text') {
420
+ body = html `<pre class="code">${res.text ?? ''}</pre>`;
421
+ }
422
+ else {
423
+ body = html `<pre class="code">${JSON.stringify(res.value)}</pre>`;
424
+ }
425
+ return html `<section class="panel">
426
+ <h2>${title} <span class="muted">${formatBytes(bytes)} — shown in parts</span></h2>
427
+ ${crumbs}${pager}${body}${pager}
428
+ </section>`;
429
+ }
430
+ class Folded {
431
+ text;
432
+ constructor(text) {
433
+ this.text = text;
434
+ }
435
+ toJSON() {
436
+ return this.text;
437
+ }
438
+ }
439
+ const FOLD_CHARS = 600;
440
+ const TEXT_PREVIEW_CHARS = 2000;
441
+ function summarize(v) {
442
+ if (Array.isArray(v)) {
443
+ return JSON.stringify(v).length <= FOLD_CHARS ? v : new Folded(`[ ${v.length} items ]`);
444
+ }
445
+ if (v !== null && typeof v === 'object') {
446
+ return JSON.stringify(v).length <= FOLD_CHARS
447
+ ? v
448
+ : new Folded(`{ ${Object.keys(v).length} keys }`);
449
+ }
450
+ if (typeof v === 'string' && v.length > TEXT_PREVIEW_CHARS) {
451
+ return new Folded(`${JSON.stringify(v.slice(0, TEXT_PREVIEW_CHARS))}… (${v.length} characters)`);
452
+ }
453
+ return v;
454
+ }
455
+ function preview(v) {
456
+ if (v instanceof Folded)
457
+ return v.text;
458
+ return v !== null && typeof v === 'object' ? JSON.stringify(v, null, 2) : JSON.stringify(v);
459
+ }
460
+ function pathCrumbs(path, base) {
461
+ const cuts = ['$'];
462
+ let i = 1;
463
+ while (i < path.length) {
464
+ if (path[i] === '[')
465
+ i = path.indexOf(']', i) + 1;
466
+ else if (path[i] === '.' && path[i + 1] === '"') {
467
+ let j = i + 2;
468
+ while (j < path.length && path[j] !== '"')
469
+ j += path[j] === '\\' ? 2 : 1;
470
+ i = j + 1;
471
+ }
472
+ else {
473
+ i++;
474
+ while (i < path.length && path[i] !== '.' && path[i] !== '[')
475
+ i++;
476
+ }
477
+ if (i <= 0)
478
+ break;
479
+ cuts.push(path.slice(0, i));
480
+ }
481
+ return html `<p class="crumbs path">
482
+ ${cuts.map((c, n) => n === cuts.length - 1
483
+ ? html `<code>${c}</code>`
484
+ : html `<a href="${base}${qs({ path: c })}"><code>${c}</code></a> › `)}
485
+ </p>`;
486
+ }
@@ -0,0 +1,157 @@
1
+ import { pathToFileURL } from 'node:url';
2
+ import { BIN } from "../../../core/domain/index.js";
3
+ import { formatBytes } from "../../../core/text.js";
4
+ import { formatInterval } from "../../../core/time.js";
5
+ import { BUILTIN_WORKER_LABEL, badge, command, copyButton, hostPath, html, tag, time } from "./html.js";
6
+ export function vscodeLink(file) {
7
+ return `vscode://file${pathToFileURL(file).pathname}`;
8
+ }
9
+ export function workersPage(v, ctx) {
10
+ const sync = v.lastSync;
11
+ return html `
12
+ <div class="head">
13
+ <h1>Workers</h1>
14
+ ${v.dir ? html `<p class="path">${hostPath(v.dir, ctx)}</p>` : ''}
15
+ </div>
16
+ <p class="note">
17
+ ${sync
18
+ ? html `Description, timeout and input schema are as of the last sync (${time(sync.at)}).
19
+ Files are not loaded here — loading a worker runs its top-level code.`
20
+ : html `No sync record in the recent logs — description, timeout and input schema appear
21
+ after <code>${BIN} start</code> or <code>${BIN} reload</code>.`}
22
+ After editing a file, apply it to a running start with ${command(`${BIN} reload`, ctx)}
23
+ </p>
24
+ ${v.error
25
+ ? html `<p class="error">Cannot read the workers folder: ${v.error}</p>`
26
+ : v.dirMissing
27
+ ? html `<p class="muted">
28
+ ${v.dir ? 'The workers folder does not exist.' : 'This host has no project.'} Run
29
+ ${command(`${BIN} init`, ctx)} (creates ~/3minapi) or
30
+ ${command(`${BIN} init <folder>`, ctx)}.
31
+ </p>`
32
+ : v.files.length === 0
33
+ ? html `<p class="muted">
34
+ No worker files. Create one with ${command(`${BIN} worker new <name>`, ctx)}
35
+ </p>`
36
+ : html `<div class="scroll">
37
+ <table class="table">
38
+ <thead>
39
+ <tr>
40
+ <th class="num">No</th>
41
+ <th>Worker</th>
42
+ <th>Description</th>
43
+ <th class="num">Timeout</th>
44
+ <th>Input schema</th>
45
+ <th>Last sync</th>
46
+ <th>File</th>
47
+ </tr>
48
+ </thead>
49
+ <tbody>
50
+ ${v.files.map((w, i) => {
51
+ const meta = sync?.local?.get(w.name) ?? null;
52
+ const result = sync?.results.get(w.name);
53
+ const rejected = sync?.rejected.get(w.name);
54
+ const file = w.files[0];
55
+ return html `<tr>
56
+ <td class="num muted">${i + 1}</td>
57
+ <td>
58
+ <a href="/workers/${encodeURIComponent(w.name)}"><code>${w.name}</code></a>
59
+ </td>
60
+ <td>
61
+ ${meta?.description ?? html `<span class="muted">—</span>`}${meta?.message
62
+ ? html `<div class="error">${meta.message}</div>`
63
+ : ''}
64
+ </td>
65
+ <td class="num">
66
+ ${meta?.timeout_ms
67
+ ? formatInterval(meta.timeout_ms)
68
+ : html `<span class="muted">default</span>`}
69
+ </td>
70
+ <td>
71
+ ${meta
72
+ ? meta.input_schema
73
+ ? 'yes'
74
+ : 'no'
75
+ : html `<span class="muted">—</span>`}
76
+ </td>
77
+ <td>
78
+ ${syncBadge(result, rejected) ??
79
+ (sync
80
+ ? html `<span class="muted">not sent</span>`
81
+ : html `<span class="muted">—</span>`)}${sync &&
82
+ file &&
83
+ file.modifiedAt > sync.at
84
+ ? html ` ${tag('changed', 'The file changed after the last sync')}`
85
+ : ''}
86
+ </td>
87
+ <td class="files">
88
+ ${w.files.map((f) => html `<div>
89
+ ${formatBytes(f.bytes)}${ctx.remote
90
+ ? html ` · <span class="muted">on host ${ctx.host}</span>`
91
+ : html ` ·
92
+ <a href="${vscodeLink(f.file)}">Open in VS Code</a>
93
+ ${copyButton(f.file, 'Copy path')}`}
94
+ </div>`)}${w.files.length > 1
95
+ ? html `<div class="error">
96
+ More than one file with this name — keep only one.
97
+ </div>`
98
+ : ''}
99
+ </td>
100
+ </tr>`;
101
+ })}
102
+ </tbody>
103
+ </table>
104
+ </div>`}
105
+ ${v.builtin ? builtinPanel(v.builtin, v.lastSync, ctx) : ''}
106
+ `;
107
+ }
108
+ function builtinPanel(name, sync, ctx) {
109
+ const result = sync?.results.get(name);
110
+ const rejected = sync?.rejected.get(name);
111
+ return html `<section class="panel">
112
+ <h2>Built-in ${syncBadge(result, rejected) ?? ''}</h2>
113
+ <dl class="kv">
114
+ <dt>${BUILTIN_WORKER_LABEL}</dt>
115
+ <dd>
116
+ Part of 3minapi, not a file — it cannot be edited. Remote AIs use it to read the results
117
+ saved on host ${ctx.host} (describe, query, read).
118
+ <div class="muted">
119
+ Registered as <code>${name}</code> — the name you see in remote AIs and in the start
120
+ output.
121
+ </div>
122
+ </dd>
123
+ </dl>
124
+ </section>`;
125
+ }
126
+ function syncBadge(result, rejected) {
127
+ if (rejected)
128
+ return badge(rejected, 'fail');
129
+ if (result)
130
+ return badge(result, 'ok');
131
+ return null;
132
+ }
133
+ export function workerCodePage(name, files, ctx) {
134
+ return html `
135
+ <p class="crumbs"><a href="/workers">Workers</a> / <code>${name}</code></p>
136
+ <div class="head">
137
+ <h1><code>${name}</code></h1>
138
+ </div>
139
+ <p class="note">
140
+ Read-only view of the file as text. Edit it in your editor, then run
141
+ ${command(`${BIN} reload`, ctx)}
142
+ </p>
143
+ ${files.map((f) => html `<section class="panel">
144
+ <h2 class="path"><code>${f.file}</code></h2>
145
+ <p>
146
+ ${ctx.remote
147
+ ? html `<span class="muted">on host ${ctx.host}</span>`
148
+ : html `<a href="${vscodeLink(f.file)}">Open in VS Code</a> ${copyButton(f.file, 'Copy path')}`}
149
+ <span class="muted">${formatBytes(f.bytes)}</span>
150
+ </p>
151
+ <pre class="code source">${f.text}</pre>
152
+ ${f.truncated
153
+ ? html `<p class="muted">Showing the first part only — the file is larger.</p>`
154
+ : ''}
155
+ </section>`)}
156
+ `;
157
+ }