@junoflow/cli 0.12.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +56 -0
- package/package.json +2 -2
- package/src/commands/job-run.js +363 -0
- package/src/exit.js +3 -0
- package/src/index.js +43 -0
- package/verbs.json +14 -0
package/README.md
CHANGED
|
@@ -178,6 +178,17 @@ The group's working-copy commands (`integration init`, `gen-types`,
|
|
|
178
178
|
| `juno webhook write` | `write_webhook` | `id` | change |
|
|
179
179
|
| `juno webhook fire` | `fire_webhook` | `id` | act |
|
|
180
180
|
|
|
181
|
+
**`job`** (external jobs)
|
|
182
|
+
|
|
183
|
+
| Command | Tool | Positional | Effect |
|
|
184
|
+
| ------------------------------------ | -------------- | ---------- | ------ |
|
|
185
|
+
| `juno job list` | `list_jobs` | — | read |
|
|
186
|
+
| `juno job get-runs` | `get_job_runs` | `id` | read |
|
|
187
|
+
| `juno job write` | `write_job` | `id` | change |
|
|
188
|
+
| `juno job delete` | `delete_job` | `id` | act |
|
|
189
|
+
| `juno job run [<url>] -- <command>…` | none | ping URL | act |
|
|
190
|
+
| `juno job ping [<url>]` | none | ping URL | act |
|
|
191
|
+
|
|
181
192
|
**`credential`**
|
|
182
193
|
|
|
183
194
|
| Command | Tool | Positional | Effect |
|
|
@@ -327,6 +338,51 @@ exits 1 without sending anything otherwise. Any other message sent to a session
|
|
|
327
338
|
waiting for an approval denies it, and the assistant sees the message as the
|
|
328
339
|
reason.
|
|
329
340
|
|
|
341
|
+
### `juno job run`
|
|
342
|
+
|
|
343
|
+
Wraps a scheduled command so its runs are reported to an external job's ping URL
|
|
344
|
+
(`juno job write` answers it as `url`):
|
|
345
|
+
|
|
346
|
+
```bash
|
|
347
|
+
juno job run https://juno.example/ping/<token> -- ./backup.sh --full
|
|
348
|
+
juno job run --url-file .secrets/juno-jobs/backup -- ./backup.sh --full
|
|
349
|
+
JUNO_JOB_URL=https://juno.example/ping/<token> juno job run -- python3 check.py
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
The command runs exactly as it would unwrapped: same stdin, the same stdout and
|
|
353
|
+
stderr bytes as they arrive, the same exit status (`128 + N` when killed by
|
|
354
|
+
signal `N`; 127, or 126 for a file that is not executable, when it cannot be
|
|
355
|
+
started). Everything after `--` is the command's, flags included. Juno gets a
|
|
356
|
+
`start`, then the exit status with the last 64 KiB of stdout and stderr, paired
|
|
357
|
+
by a run id. The URL comes from the argument, else `--url-file` (keeps the token
|
|
358
|
+
out of `ps`), else `$JUNO_JOB_URL`; nothing else is read — no instance config,
|
|
359
|
+
no API key. A report that fails is retried once and then dropped silently
|
|
360
|
+
(`--verbose` prints one line): Juno turns a run it never heard about into an
|
|
361
|
+
overdue finding by itself. `SIGINT`, `SIGTERM` and `SIGHUP` are passed on to the
|
|
362
|
+
command.
|
|
363
|
+
|
|
364
|
+
The wrapper never times a command out: a run that hangs stays open, and the job
|
|
365
|
+
goes down once its start plus grace has passed. A wrapper killed outright leaves
|
|
366
|
+
the run open the same way.
|
|
367
|
+
|
|
368
|
+
### `juno job ping`
|
|
369
|
+
|
|
370
|
+
Sends one report, for a task with no command to wrap:
|
|
371
|
+
|
|
372
|
+
```bash
|
|
373
|
+
juno job ping --url-file .secrets/juno-jobs/digest # success
|
|
374
|
+
juno job ping --url-file .secrets/juno-jobs/digest --start --rid r1
|
|
375
|
+
juno job ping --url-file .secrets/juno-jobs/digest --exit 3 --rid r1
|
|
376
|
+
juno job ping --url-file .secrets/juno-jobs/digest --fail
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
The URL comes from the same three places as `juno job run`'s. `--log` sends a
|
|
380
|
+
log report, and `--rid` pairs a start with its finish. A report that still fails
|
|
381
|
+
after one retry exits 1 with the reason.
|
|
382
|
+
|
|
383
|
+
See [External jobs](https://boivie.github.io/juno/external-jobs/) for creating
|
|
384
|
+
the job, the ping protocol, and systemd and crontab examples.
|
|
385
|
+
|
|
330
386
|
### `juno credential set`
|
|
331
387
|
|
|
332
388
|
```bash
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@junoflow/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"description": "Develop Juno flows, integrations and apps, and operate a Juno instance from the command line",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
"node": ">=20"
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
|
-
"@junoflow/eslint-plugin": "0.
|
|
43
|
+
"@junoflow/eslint-plugin": "0.14.0",
|
|
44
44
|
"@typescript-eslint/parser": "^8.0.0"
|
|
45
45
|
},
|
|
46
46
|
"devDependencies": {
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
import { spawn as nodeSpawn } from 'node:child_process';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { readFile as nodeReadFile } from 'node:fs/promises';
|
|
4
|
+
import { constants } from 'node:os';
|
|
5
|
+
import { resolve } from 'node:path';
|
|
6
|
+
import { parseArgs } from 'node:util';
|
|
7
|
+
|
|
8
|
+
import { UsageError } from '../errors.js';
|
|
9
|
+
import { EXIT_FAILURE, EXIT_OK } from '../exit.js';
|
|
10
|
+
|
|
11
|
+
export const JOB_RUN_USAGE =
|
|
12
|
+
'juno job run [<url>] [--url-file <path>] [--verbose] -- <command> [args…]';
|
|
13
|
+
|
|
14
|
+
/** The tail of each stream a run reports; Juno keeps the same. */
|
|
15
|
+
export const TAIL_BYTES = 64 << 10;
|
|
16
|
+
|
|
17
|
+
/** The signals the wrapper passes on to the command instead of obeying. */
|
|
18
|
+
const FORWARDED = ['SIGINT', 'SIGTERM', 'SIGHUP'];
|
|
19
|
+
|
|
20
|
+
const DEFAULT_TIMEOUTS = {
|
|
21
|
+
start: 5000,
|
|
22
|
+
finish: 10000,
|
|
23
|
+
retryDelay: 1000,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* `juno job run` runs a command and reports it to an external job's ping URL
|
|
28
|
+
* (plan-external-jobs-2-wrapper-and-tools.md, Decisions 3–5). Its first rule
|
|
29
|
+
* is transparency: the command gets the same stdin, and the wrapper's stdout,
|
|
30
|
+
* stderr and exit status are the command's, byte for byte — a scheduler such
|
|
31
|
+
* as OpenClaw delivers a task's stdout as its message. Juno gets a `start`,
|
|
32
|
+
* then the exit status with the last 64 KiB of each stream. A report that
|
|
33
|
+
* fails is dropped: a run Juno never hears about becomes an overdue finding
|
|
34
|
+
* on its own, so the wrapper never queues and never fails the command.
|
|
35
|
+
*
|
|
36
|
+
* It reads no Juno config and no API key; the URL is all it needs. argv is
|
|
37
|
+
* everything after `job run`, `--` included. The exit code is returned, not
|
|
38
|
+
* passed to process.exit, so stdout is flushed before the process ends.
|
|
39
|
+
* deps is injectable for the tests; the default binds the real ones.
|
|
40
|
+
*/
|
|
41
|
+
export async function jobRun(argv, io, deps = {}) {
|
|
42
|
+
const {
|
|
43
|
+
spawn = nodeSpawn,
|
|
44
|
+
fetch = globalThis.fetch,
|
|
45
|
+
env = process.env,
|
|
46
|
+
readFile = nodeReadFile,
|
|
47
|
+
stdout = process.stdout,
|
|
48
|
+
stderr = process.stderr,
|
|
49
|
+
signals = process,
|
|
50
|
+
timeouts = DEFAULT_TIMEOUTS,
|
|
51
|
+
} = deps;
|
|
52
|
+
|
|
53
|
+
const dash = argv.indexOf('--');
|
|
54
|
+
const command = dash === -1 ? [] : argv.slice(dash + 1);
|
|
55
|
+
const own = dash === -1 ? argv : argv.slice(0, dash);
|
|
56
|
+
let parsed;
|
|
57
|
+
try {
|
|
58
|
+
parsed = parseArgs({
|
|
59
|
+
args: own,
|
|
60
|
+
allowPositionals: true,
|
|
61
|
+
options: {
|
|
62
|
+
'url-file': { type: 'string' },
|
|
63
|
+
verbose: { type: 'boolean', default: false },
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
} catch (err) {
|
|
67
|
+
throw new UsageError(
|
|
68
|
+
`juno job run: ${err.message}\nusage: ${JOB_RUN_USAGE}`
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
if (command.length === 0) {
|
|
72
|
+
throw new UsageError(
|
|
73
|
+
`juno job run: missing -- <command>\nusage: ${JOB_RUN_USAGE}`
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
if (parsed.positionals.length > 1) {
|
|
77
|
+
throw new UsageError(
|
|
78
|
+
`juno job run: one URL before --, got ${parsed.positionals.length} arguments\nusage: ${JOB_RUN_USAGE}`
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
const url = await pingURL('job run', JOB_RUN_USAGE, {
|
|
82
|
+
positional: parsed.positionals[0],
|
|
83
|
+
file: parsed.values['url-file'],
|
|
84
|
+
env,
|
|
85
|
+
readFile,
|
|
86
|
+
cwd: io.cwd,
|
|
87
|
+
});
|
|
88
|
+
const verbose = parsed.values.verbose;
|
|
89
|
+
|
|
90
|
+
const rid = randomUUID();
|
|
91
|
+
await report(fetch, endpoint(url, 'start', rid), {
|
|
92
|
+
method: 'GET',
|
|
93
|
+
timeout: timeouts.start,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const out = new Tail();
|
|
97
|
+
const err = new Tail();
|
|
98
|
+
const outcome = await runCommand(command, {
|
|
99
|
+
spawn,
|
|
100
|
+
signals,
|
|
101
|
+
onStdout: (chunk) => out.push(chunk),
|
|
102
|
+
onStderr: (chunk) => err.push(chunk),
|
|
103
|
+
stdout,
|
|
104
|
+
stderr,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
let suffix;
|
|
108
|
+
let exitCode;
|
|
109
|
+
if (outcome.startError) {
|
|
110
|
+
const line = `juno job run: ${command[0]}: ${outcome.startError.message}`;
|
|
111
|
+
io.error(line);
|
|
112
|
+
err.push(Buffer.from(`${line}\n`));
|
|
113
|
+
suffix = 'fail';
|
|
114
|
+
exitCode = outcome.startError.code === 'EACCES' ? 126 : 127;
|
|
115
|
+
} else if (outcome.signal) {
|
|
116
|
+
suffix = 'fail';
|
|
117
|
+
exitCode = 128 + (constants.signals[outcome.signal] ?? 0);
|
|
118
|
+
} else {
|
|
119
|
+
suffix = String(outcome.code);
|
|
120
|
+
exitCode = outcome.code;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const finish = () => {
|
|
124
|
+
const body = new FormData();
|
|
125
|
+
body.append('stdout', new Blob([out.bytes()]), 'stdout');
|
|
126
|
+
body.append('stderr', new Blob([err.bytes()]), 'stderr');
|
|
127
|
+
return report(fetch, endpoint(url, suffix, rid), {
|
|
128
|
+
method: 'POST',
|
|
129
|
+
body,
|
|
130
|
+
timeout: timeouts.finish,
|
|
131
|
+
});
|
|
132
|
+
};
|
|
133
|
+
let failure = await finish();
|
|
134
|
+
if (failure) {
|
|
135
|
+
await new Promise((resolve) => setTimeout(resolve, timeouts.retryDelay));
|
|
136
|
+
failure = await finish();
|
|
137
|
+
}
|
|
138
|
+
if (failure && verbose) {
|
|
139
|
+
io.error(`juno job run: could not report to Juno: ${failure}`);
|
|
140
|
+
}
|
|
141
|
+
return exitCode;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* pingURL is the URL from the first source that yields one: the positional,
|
|
146
|
+
* then --url-file (its contents, trimmed), then $JUNO_JOB_URL. The two
|
|
147
|
+
* indirect sources keep the token off the command line, where `ps` and the
|
|
148
|
+
* scheduler's job definition would show it. command and usage name the verb
|
|
149
|
+
* in its refusals.
|
|
150
|
+
*/
|
|
151
|
+
async function pingURL(
|
|
152
|
+
command,
|
|
153
|
+
usage,
|
|
154
|
+
{ positional, file, env, readFile, cwd = '.' }
|
|
155
|
+
) {
|
|
156
|
+
if (positional?.trim()) return positional.trim();
|
|
157
|
+
if (file !== undefined) {
|
|
158
|
+
let text;
|
|
159
|
+
try {
|
|
160
|
+
text = await readFile(resolve(cwd, file), 'utf8');
|
|
161
|
+
} catch (err) {
|
|
162
|
+
throw new UsageError(
|
|
163
|
+
`juno ${command}: --url-file ${file}: ${err.message}`
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
if (text.trim()) return text.trim();
|
|
167
|
+
}
|
|
168
|
+
if (env.JUNO_JOB_URL?.trim()) return env.JUNO_JOB_URL.trim();
|
|
169
|
+
throw new UsageError(
|
|
170
|
+
`juno ${command}: no ping URL — pass it, --url-file or JUNO_JOB_URL\nusage: ${usage}`
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* endpoint is `<url>/<suffix>?rid=<rid>`, used as given otherwise: no /ping/
|
|
176
|
+
* check, so a Healthchecks URL works too. A query the URL already carries is
|
|
177
|
+
* kept; without a rid there is no `rid` parameter.
|
|
178
|
+
*/
|
|
179
|
+
export function endpoint(url, suffix, rid) {
|
|
180
|
+
const q = url.indexOf('?');
|
|
181
|
+
const path = (q === -1 ? url : url.slice(0, q)).replace(/\/+$/, '');
|
|
182
|
+
const params = [];
|
|
183
|
+
if (q !== -1 && q < url.length - 1) params.push(url.slice(q + 1));
|
|
184
|
+
if (rid) params.push(`rid=${encodeURIComponent(rid)}`);
|
|
185
|
+
return params.length
|
|
186
|
+
? `${path}/${suffix}?${params.join('&')}`
|
|
187
|
+
: `${path}/${suffix}`;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* report sends one ping and answers why it failed, or undefined when it did
|
|
192
|
+
* not. It never throws: a ping that fails changes nothing about the command.
|
|
193
|
+
*/
|
|
194
|
+
async function report(fetch, url, { method, body, timeout }) {
|
|
195
|
+
try {
|
|
196
|
+
const res = await fetch(url, {
|
|
197
|
+
method,
|
|
198
|
+
body,
|
|
199
|
+
signal: AbortSignal.timeout(timeout),
|
|
200
|
+
});
|
|
201
|
+
await res.arrayBuffer().catch(() => {});
|
|
202
|
+
return res.ok ? undefined : `HTTP ${res.status}`;
|
|
203
|
+
} catch (err) {
|
|
204
|
+
return err?.cause?.message ?? err?.message ?? String(err);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* runCommand spawns argv with stdin inherited and stdout and stderr piped,
|
|
210
|
+
* with no shell, and copies each chunk to the wrapper's own stream as it
|
|
211
|
+
* arrives. It resolves with `{code}`, `{signal}` or, when the command could
|
|
212
|
+
* not be started, `{startError}`. While it runs, SIGINT, SIGTERM and SIGHUP
|
|
213
|
+
* are passed on to the command, and do not end the wrapper.
|
|
214
|
+
*/
|
|
215
|
+
function runCommand(
|
|
216
|
+
argv,
|
|
217
|
+
{ spawn, signals, onStdout, onStderr, stdout, stderr }
|
|
218
|
+
) {
|
|
219
|
+
return new Promise((resolve) => {
|
|
220
|
+
let child;
|
|
221
|
+
try {
|
|
222
|
+
child = spawn(argv[0], argv.slice(1), {
|
|
223
|
+
stdio: ['inherit', 'pipe', 'pipe'],
|
|
224
|
+
});
|
|
225
|
+
} catch (err) {
|
|
226
|
+
resolve({ startError: err });
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const forward = Object.fromEntries(
|
|
231
|
+
FORWARDED.map((sig) => [
|
|
232
|
+
sig,
|
|
233
|
+
() => {
|
|
234
|
+
try {
|
|
235
|
+
child.kill(sig);
|
|
236
|
+
} catch {
|
|
237
|
+
// Already gone: its exit is on its way.
|
|
238
|
+
}
|
|
239
|
+
},
|
|
240
|
+
])
|
|
241
|
+
);
|
|
242
|
+
for (const [sig, fn] of Object.entries(forward)) signals.on(sig, fn);
|
|
243
|
+
let settled = false;
|
|
244
|
+
const settle = (result) => {
|
|
245
|
+
if (settled) return;
|
|
246
|
+
settled = true;
|
|
247
|
+
for (const [sig, fn] of Object.entries(forward)) signals.off(sig, fn);
|
|
248
|
+
resolve(result);
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
child.stdout.on('data', onStdout);
|
|
252
|
+
child.stderr.on('data', onStderr);
|
|
253
|
+
child.stdout.pipe(stdout, { end: false });
|
|
254
|
+
child.stderr.pipe(stderr, { end: false });
|
|
255
|
+
|
|
256
|
+
child.on('error', (err) => {
|
|
257
|
+
// A spawn failure leaves no process behind; any later error is about
|
|
258
|
+
// one that did start, whose close still comes.
|
|
259
|
+
if (child.pid === undefined) settle({ startError: err });
|
|
260
|
+
});
|
|
261
|
+
child.on('close', (code, signal) => {
|
|
262
|
+
settle(signal ? { signal } : { code });
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Tail keeps the last TAIL_BYTES of a stream as the chunks that arrived,
|
|
269
|
+
* dropping whole chunks from the front once over the limit and cutting the
|
|
270
|
+
* first one kept, so a long run costs at most a little over 64 KiB.
|
|
271
|
+
*/
|
|
272
|
+
export class Tail {
|
|
273
|
+
#chunks = [];
|
|
274
|
+
#size = 0;
|
|
275
|
+
|
|
276
|
+
push(chunk) {
|
|
277
|
+
this.#chunks.push(chunk);
|
|
278
|
+
this.#size += chunk.length;
|
|
279
|
+
while (this.#size - this.#chunks[0].length >= TAIL_BYTES) {
|
|
280
|
+
this.#size -= this.#chunks.shift().length;
|
|
281
|
+
}
|
|
282
|
+
if (this.#size > TAIL_BYTES) {
|
|
283
|
+
const cut = this.#size - TAIL_BYTES;
|
|
284
|
+
this.#chunks[0] = this.#chunks[0].subarray(cut);
|
|
285
|
+
this.#size -= cut;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
bytes() {
|
|
290
|
+
return Buffer.concat(this.#chunks, this.#size);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export const JOB_PING_USAGE =
|
|
295
|
+
'juno job ping [<url>] [--url-file <path>] [--start | --fail | --exit <0-255> | --log] [--rid <id>]';
|
|
296
|
+
|
|
297
|
+
const PING_KINDS = ['start', 'fail', 'exit', 'log'];
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* `juno job ping` sends one report to an external job's ping URL, for a task
|
|
301
|
+
* `job run` cannot wrap — one whose scheduler runs no command, or whose start
|
|
302
|
+
* and finish happen in different places. Without a flag it reports success;
|
|
303
|
+
* `--start`, `--fail`, `--exit <n>` and `--log` send the other reports, and
|
|
304
|
+
* `--rid` pairs a start with its finish. It sends no output.
|
|
305
|
+
*
|
|
306
|
+
* It finds the URL as `job run` does and, like it, reads no Juno config and no
|
|
307
|
+
* API key. Unlike `job run` it has nothing else to do, so a report that still
|
|
308
|
+
* fails after one retry is exit 1 with the reason, not silence: the caller
|
|
309
|
+
* asked for exactly this and can `|| true` it.
|
|
310
|
+
*/
|
|
311
|
+
export async function jobPing(args, io, deps = {}) {
|
|
312
|
+
const {
|
|
313
|
+
fetch = globalThis.fetch,
|
|
314
|
+
env = process.env,
|
|
315
|
+
readFile = nodeReadFile,
|
|
316
|
+
timeouts = DEFAULT_TIMEOUTS,
|
|
317
|
+
} = deps;
|
|
318
|
+
const usage = (msg) =>
|
|
319
|
+
new UsageError(`juno job ping: ${msg}\nusage: ${JOB_PING_USAGE}`);
|
|
320
|
+
|
|
321
|
+
if (args._.length > 1) {
|
|
322
|
+
throw usage(`one URL, got ${args._.length} arguments`);
|
|
323
|
+
}
|
|
324
|
+
const kinds = PING_KINDS.filter(
|
|
325
|
+
(k) => args[k] !== undefined && args[k] !== false
|
|
326
|
+
);
|
|
327
|
+
if (kinds.length > 1) {
|
|
328
|
+
throw usage(`--${kinds.join(' and --')} are different reports; pick one`);
|
|
329
|
+
}
|
|
330
|
+
let suffix = 'success';
|
|
331
|
+
if (kinds[0] === 'exit') {
|
|
332
|
+
const n = args.exit.trim();
|
|
333
|
+
if (!/^\d{1,3}$/.test(n) || Number(n) > 255) {
|
|
334
|
+
throw usage(`--exit takes a status from 0 to 255, got "${args.exit}"`);
|
|
335
|
+
}
|
|
336
|
+
suffix = String(Number(n));
|
|
337
|
+
} else if (kinds[0]) {
|
|
338
|
+
suffix = kinds[0];
|
|
339
|
+
}
|
|
340
|
+
const url = await pingURL('job ping', JOB_PING_USAGE, {
|
|
341
|
+
positional: args._[0],
|
|
342
|
+
file: args['url-file'],
|
|
343
|
+
env,
|
|
344
|
+
readFile,
|
|
345
|
+
cwd: io.cwd,
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
const send = () =>
|
|
349
|
+
report(fetch, endpoint(url, suffix, args.rid), {
|
|
350
|
+
method: 'GET',
|
|
351
|
+
timeout: timeouts.finish,
|
|
352
|
+
});
|
|
353
|
+
let failure = await send();
|
|
354
|
+
if (failure) {
|
|
355
|
+
await new Promise((resolve) => setTimeout(resolve, timeouts.retryDelay));
|
|
356
|
+
failure = await send();
|
|
357
|
+
}
|
|
358
|
+
if (failure) {
|
|
359
|
+
io.error(`juno job ping: could not report to Juno: ${failure}`);
|
|
360
|
+
return EXIT_FAILURE;
|
|
361
|
+
}
|
|
362
|
+
return EXIT_OK;
|
|
363
|
+
}
|
package/src/exit.js
CHANGED
|
@@ -12,6 +12,9 @@
|
|
|
12
12
|
* 409 because the active version is not the copy's base_version
|
|
13
13
|
* 5 `juno ask` / `session approve`: the turn is still running (pending)
|
|
14
14
|
* 6 `juno ask` / `session approve`: the assistant is awaiting input
|
|
15
|
+
*
|
|
16
|
+
* `juno job run` is the exception once its own arguments are accepted: it
|
|
17
|
+
* exits with the wrapped command's status, as a shell would report it.
|
|
15
18
|
*/
|
|
16
19
|
export const EXIT_OK = 0;
|
|
17
20
|
export const EXIT_FAILURE = 1;
|
package/src/index.js
CHANGED
|
@@ -29,6 +29,12 @@ import { FLOW_INIT_USAGE, flowInit } from './commands/flow-init.js';
|
|
|
29
29
|
import { FLOW_PULL_USAGE, flowPull } from './commands/flow-pull.js';
|
|
30
30
|
import { GEN_TYPES_USAGE, genTypes } from './commands/gen-types.js';
|
|
31
31
|
import { INIT_USAGE, init } from './commands/init.js';
|
|
32
|
+
import {
|
|
33
|
+
JOB_PING_USAGE,
|
|
34
|
+
JOB_RUN_USAGE,
|
|
35
|
+
jobPing,
|
|
36
|
+
jobRun,
|
|
37
|
+
} from './commands/job-run.js';
|
|
32
38
|
import { PULL_USAGE, pull } from './commands/pull.js';
|
|
33
39
|
import { SKILL_USAGE, skill } from './commands/skill.js';
|
|
34
40
|
import {
|
|
@@ -102,6 +108,32 @@ const NON_TOOL = {
|
|
|
102
108
|
},
|
|
103
109
|
usage: CREDENTIAL_SET_USAGE,
|
|
104
110
|
},
|
|
111
|
+
// raw: everything after `--` is the wrapped command's, verbatim, so the
|
|
112
|
+
// argv goes to the handler unparsed (see runRow). No --url or --token: the
|
|
113
|
+
// wrapper reads no Juno config, only the ping URL.
|
|
114
|
+
'job run': {
|
|
115
|
+
run: (argv, io) => jobRun(argv, io, { env: io.env }),
|
|
116
|
+
options: {
|
|
117
|
+
'url-file': { type: 'string' },
|
|
118
|
+
verbose: { type: 'boolean', default: false },
|
|
119
|
+
},
|
|
120
|
+
usage: JOB_RUN_USAGE,
|
|
121
|
+
raw: true,
|
|
122
|
+
},
|
|
123
|
+
// No --url or --token either: one report to the ping URL, found as job run
|
|
124
|
+
// finds it.
|
|
125
|
+
'job ping': {
|
|
126
|
+
run: (args, io) => jobPing(args, io, { env: io.env }),
|
|
127
|
+
options: {
|
|
128
|
+
'url-file': { type: 'string' },
|
|
129
|
+
start: { type: 'boolean' },
|
|
130
|
+
fail: { type: 'boolean' },
|
|
131
|
+
exit: { type: 'string' },
|
|
132
|
+
log: { type: 'boolean' },
|
|
133
|
+
rid: { type: 'string' },
|
|
134
|
+
},
|
|
135
|
+
usage: JOB_PING_USAGE,
|
|
136
|
+
},
|
|
105
137
|
'config set': { run: configSet, options: {}, usage: CONFIG_SET_USAGE },
|
|
106
138
|
'agent-skill install': {
|
|
107
139
|
run: agentSkillInstall,
|
|
@@ -405,6 +437,17 @@ async function showGroupHelp(group, args, io, code) {
|
|
|
405
437
|
function runRow(row, argv, leading, io) {
|
|
406
438
|
if (!row.tool) {
|
|
407
439
|
const handler = handlerFor(row);
|
|
440
|
+
if (handler.raw) {
|
|
441
|
+
// Only the words before `--` are the command's own; a --help after it
|
|
442
|
+
// belongs to the wrapped program.
|
|
443
|
+
const dash = argv.indexOf('--');
|
|
444
|
+
const own = dash === -1 ? argv : argv.slice(0, dash);
|
|
445
|
+
if (own.includes('--help') || own.includes('-h')) {
|
|
446
|
+
io.log(nonToolHelp(row, handler));
|
|
447
|
+
return EXIT_OK;
|
|
448
|
+
}
|
|
449
|
+
return handler.run(argv, io);
|
|
450
|
+
}
|
|
408
451
|
const run =
|
|
409
452
|
row.working_copy && !handler.positional
|
|
410
453
|
? (args, io) => {
|
package/verbs.json
CHANGED
|
@@ -221,6 +221,10 @@
|
|
|
221
221
|
},
|
|
222
222
|
{ "command": "webhook write", "tool": "write_webhook", "id_field": "id" },
|
|
223
223
|
{ "command": "webhook fire", "tool": "fire_webhook", "id_field": "id" },
|
|
224
|
+
{ "command": "job list", "tool": "list_jobs" },
|
|
225
|
+
{ "command": "job get-runs", "tool": "get_job_runs", "id_field": "id" },
|
|
226
|
+
{ "command": "job write", "tool": "write_job", "id_field": "id" },
|
|
227
|
+
{ "command": "job delete", "tool": "delete_job", "id_field": "id" },
|
|
224
228
|
{ "command": "credential list", "tool": "list_credentials" },
|
|
225
229
|
{ "command": "person list", "tool": "list_people" },
|
|
226
230
|
{ "command": "person get", "tool": "get_person", "id_field": "id" },
|
|
@@ -271,6 +275,16 @@
|
|
|
271
275
|
"effect": "act",
|
|
272
276
|
"summary": "Approve the tool call an assistant session is waiting on"
|
|
273
277
|
},
|
|
278
|
+
{
|
|
279
|
+
"command": "job run",
|
|
280
|
+
"effect": "act",
|
|
281
|
+
"summary": "Run a command and report it to an external job's ping URL"
|
|
282
|
+
},
|
|
283
|
+
{
|
|
284
|
+
"command": "job ping",
|
|
285
|
+
"effect": "act",
|
|
286
|
+
"summary": "Send one report (success, start, fail, an exit status or a log) to an external job's ping URL"
|
|
287
|
+
},
|
|
274
288
|
{
|
|
275
289
|
"command": "credential set",
|
|
276
290
|
"effect": "act",
|