@junoflow/cli 0.12.0 → 0.13.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 +35 -0
- package/package.json +2 -2
- package/src/commands/job-run.js +276 -0
- package/src/exit.js +3 -0
- package/src/index.js +24 -0
- package/verbs.json +8 -0
package/README.md
CHANGED
|
@@ -178,6 +178,15 @@ 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 run [<url>] -- <command>…` | none | ping URL | act |
|
|
189
|
+
|
|
181
190
|
**`credential`**
|
|
182
191
|
|
|
183
192
|
| Command | Tool | Positional | Effect |
|
|
@@ -327,6 +336,32 @@ exits 1 without sending anything otherwise. Any other message sent to a session
|
|
|
327
336
|
waiting for an approval denies it, and the assistant sees the message as the
|
|
328
337
|
reason.
|
|
329
338
|
|
|
339
|
+
### `juno job run`
|
|
340
|
+
|
|
341
|
+
Wraps a scheduled command so its runs are reported to an external job's ping URL
|
|
342
|
+
(`juno job write` answers it as `url`):
|
|
343
|
+
|
|
344
|
+
```bash
|
|
345
|
+
juno job run https://juno.example/ping/<token> -- ./backup.sh --full
|
|
346
|
+
juno job run --url-file .secrets/juno-jobs/backup -- ./backup.sh --full
|
|
347
|
+
JUNO_JOB_URL=https://juno.example/ping/<token> juno job run -- python3 check.py
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
The command runs exactly as it would unwrapped: same stdin, the same stdout and
|
|
351
|
+
stderr bytes as they arrive, the same exit status (`128 + N` when killed by
|
|
352
|
+
signal `N`; 127, or 126 for a file that is not executable, when it cannot be
|
|
353
|
+
started). Everything after `--` is the command's, flags included. Juno gets a
|
|
354
|
+
`start`, then the exit status with the last 64 KiB of stdout and stderr, paired
|
|
355
|
+
by a run id. The URL comes from the argument, else `--url-file` (keeps the token
|
|
356
|
+
out of `ps`), else `$JUNO_JOB_URL`; nothing else is read — no instance config,
|
|
357
|
+
no API key. A report that fails is retried once and then dropped silently
|
|
358
|
+
(`--verbose` prints one line): Juno turns a run it never heard about into an
|
|
359
|
+
overdue finding by itself. `SIGINT`, `SIGTERM` and `SIGHUP` are passed on to the
|
|
360
|
+
command.
|
|
361
|
+
|
|
362
|
+
See [External jobs](https://boivie.github.io/juno/external-jobs/) for creating
|
|
363
|
+
the job, the ping protocol, and systemd and crontab examples.
|
|
364
|
+
|
|
330
365
|
### `juno credential set`
|
|
331
366
|
|
|
332
367
|
```bash
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@junoflow/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.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.13.0",
|
|
44
44
|
"@typescript-eslint/parser": "^8.0.0"
|
|
45
45
|
},
|
|
46
46
|
"devDependencies": {
|
|
@@ -0,0 +1,276 @@
|
|
|
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
|
+
|
|
10
|
+
export const JOB_RUN_USAGE =
|
|
11
|
+
'juno job run [<url>] [--url-file <path>] [--verbose] -- <command> [args…]';
|
|
12
|
+
|
|
13
|
+
/** The tail of each stream a run reports; Juno keeps the same. */
|
|
14
|
+
export const TAIL_BYTES = 64 << 10;
|
|
15
|
+
|
|
16
|
+
/** The signals the wrapper passes on to the command instead of obeying. */
|
|
17
|
+
const FORWARDED = ['SIGINT', 'SIGTERM', 'SIGHUP'];
|
|
18
|
+
|
|
19
|
+
const DEFAULT_TIMEOUTS = {
|
|
20
|
+
start: 5000,
|
|
21
|
+
finish: 10000,
|
|
22
|
+
retryDelay: 1000,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* `juno job run` runs a command and reports it to an external job's ping URL
|
|
27
|
+
* (plan-external-jobs-2-wrapper-and-tools.md, Decisions 3–5). Its first rule
|
|
28
|
+
* is transparency: the command gets the same stdin, and the wrapper's stdout,
|
|
29
|
+
* stderr and exit status are the command's, byte for byte — a scheduler such
|
|
30
|
+
* as OpenClaw delivers a task's stdout as its message. Juno gets a `start`,
|
|
31
|
+
* then the exit status with the last 64 KiB of each stream. A report that
|
|
32
|
+
* fails is dropped: a run Juno never hears about becomes an overdue finding
|
|
33
|
+
* on its own, so the wrapper never queues and never fails the command.
|
|
34
|
+
*
|
|
35
|
+
* It reads no Juno config and no API key; the URL is all it needs. argv is
|
|
36
|
+
* everything after `job run`, `--` included. The exit code is returned, not
|
|
37
|
+
* passed to process.exit, so stdout is flushed before the process ends.
|
|
38
|
+
* deps is injectable for the tests; the default binds the real ones.
|
|
39
|
+
*/
|
|
40
|
+
export async function jobRun(argv, io, deps = {}) {
|
|
41
|
+
const {
|
|
42
|
+
spawn = nodeSpawn,
|
|
43
|
+
fetch = globalThis.fetch,
|
|
44
|
+
env = process.env,
|
|
45
|
+
readFile = nodeReadFile,
|
|
46
|
+
stdout = process.stdout,
|
|
47
|
+
stderr = process.stderr,
|
|
48
|
+
signals = process,
|
|
49
|
+
timeouts = DEFAULT_TIMEOUTS,
|
|
50
|
+
} = deps;
|
|
51
|
+
|
|
52
|
+
const dash = argv.indexOf('--');
|
|
53
|
+
const command = dash === -1 ? [] : argv.slice(dash + 1);
|
|
54
|
+
const own = dash === -1 ? argv : argv.slice(0, dash);
|
|
55
|
+
let parsed;
|
|
56
|
+
try {
|
|
57
|
+
parsed = parseArgs({
|
|
58
|
+
args: own,
|
|
59
|
+
allowPositionals: true,
|
|
60
|
+
options: {
|
|
61
|
+
'url-file': { type: 'string' },
|
|
62
|
+
verbose: { type: 'boolean', default: false },
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
} catch (err) {
|
|
66
|
+
throw new UsageError(
|
|
67
|
+
`juno job run: ${err.message}\nusage: ${JOB_RUN_USAGE}`
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
if (command.length === 0) {
|
|
71
|
+
throw new UsageError(
|
|
72
|
+
`juno job run: missing -- <command>\nusage: ${JOB_RUN_USAGE}`
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
if (parsed.positionals.length > 1) {
|
|
76
|
+
throw new UsageError(
|
|
77
|
+
`juno job run: one URL before --, got ${parsed.positionals.length} arguments\nusage: ${JOB_RUN_USAGE}`
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
const url = await pingURL(parsed, env, readFile, io.cwd);
|
|
81
|
+
const verbose = parsed.values.verbose;
|
|
82
|
+
|
|
83
|
+
const rid = randomUUID();
|
|
84
|
+
await report(fetch, endpoint(url, 'start', rid), {
|
|
85
|
+
method: 'GET',
|
|
86
|
+
timeout: timeouts.start,
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const out = new Tail();
|
|
90
|
+
const err = new Tail();
|
|
91
|
+
const outcome = await runCommand(command, {
|
|
92
|
+
spawn,
|
|
93
|
+
signals,
|
|
94
|
+
onStdout: (chunk) => out.push(chunk),
|
|
95
|
+
onStderr: (chunk) => err.push(chunk),
|
|
96
|
+
stdout,
|
|
97
|
+
stderr,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
let suffix;
|
|
101
|
+
let exitCode;
|
|
102
|
+
if (outcome.startError) {
|
|
103
|
+
const line = `juno job run: ${command[0]}: ${outcome.startError.message}`;
|
|
104
|
+
io.error(line);
|
|
105
|
+
err.push(Buffer.from(`${line}\n`));
|
|
106
|
+
suffix = 'fail';
|
|
107
|
+
exitCode = outcome.startError.code === 'EACCES' ? 126 : 127;
|
|
108
|
+
} else if (outcome.signal) {
|
|
109
|
+
suffix = 'fail';
|
|
110
|
+
exitCode = 128 + (constants.signals[outcome.signal] ?? 0);
|
|
111
|
+
} else {
|
|
112
|
+
suffix = String(outcome.code);
|
|
113
|
+
exitCode = outcome.code;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const finish = () => {
|
|
117
|
+
const body = new FormData();
|
|
118
|
+
body.append('stdout', new Blob([out.bytes()]), 'stdout');
|
|
119
|
+
body.append('stderr', new Blob([err.bytes()]), 'stderr');
|
|
120
|
+
return report(fetch, endpoint(url, suffix, rid), {
|
|
121
|
+
method: 'POST',
|
|
122
|
+
body,
|
|
123
|
+
timeout: timeouts.finish,
|
|
124
|
+
});
|
|
125
|
+
};
|
|
126
|
+
let failure = await finish();
|
|
127
|
+
if (failure) {
|
|
128
|
+
await new Promise((resolve) => setTimeout(resolve, timeouts.retryDelay));
|
|
129
|
+
failure = await finish();
|
|
130
|
+
}
|
|
131
|
+
if (failure && verbose) {
|
|
132
|
+
io.error(`juno job run: could not report to Juno: ${failure}`);
|
|
133
|
+
}
|
|
134
|
+
return exitCode;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* pingURL is the URL from the first source that yields one: the positional,
|
|
139
|
+
* then --url-file (its contents, trimmed), then $JUNO_JOB_URL. The two
|
|
140
|
+
* indirect sources keep the token off the command line, where `ps` and the
|
|
141
|
+
* scheduler's job definition would show it.
|
|
142
|
+
*/
|
|
143
|
+
async function pingURL(parsed, env, readFile, cwd = '.') {
|
|
144
|
+
const [positional] = parsed.positionals;
|
|
145
|
+
if (positional?.trim()) return positional.trim();
|
|
146
|
+
const file = parsed.values['url-file'];
|
|
147
|
+
if (file !== undefined) {
|
|
148
|
+
let text;
|
|
149
|
+
try {
|
|
150
|
+
text = await readFile(resolve(cwd, file), 'utf8');
|
|
151
|
+
} catch (err) {
|
|
152
|
+
throw new UsageError(`juno job run: --url-file ${file}: ${err.message}`);
|
|
153
|
+
}
|
|
154
|
+
if (text.trim()) return text.trim();
|
|
155
|
+
}
|
|
156
|
+
if (env.JUNO_JOB_URL?.trim()) return env.JUNO_JOB_URL.trim();
|
|
157
|
+
throw new UsageError(
|
|
158
|
+
`juno job run: no ping URL — pass it, --url-file or JUNO_JOB_URL\nusage: ${JOB_RUN_USAGE}`
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* endpoint is `<url>/<suffix>?rid=<rid>`, used as given otherwise: no /ping/
|
|
164
|
+
* check, so a Healthchecks URL works too. A query the URL already carries is
|
|
165
|
+
* kept.
|
|
166
|
+
*/
|
|
167
|
+
export function endpoint(url, suffix, rid) {
|
|
168
|
+
const q = url.indexOf('?');
|
|
169
|
+
const path = (q === -1 ? url : url.slice(0, q)).replace(/\/+$/, '');
|
|
170
|
+
const query = q === -1 ? '' : `${url.slice(q + 1)}&`;
|
|
171
|
+
return `${path}/${suffix}?${query}rid=${encodeURIComponent(rid)}`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* report sends one ping and answers why it failed, or undefined when it did
|
|
176
|
+
* not. It never throws: a ping that fails changes nothing about the command.
|
|
177
|
+
*/
|
|
178
|
+
async function report(fetch, url, { method, body, timeout }) {
|
|
179
|
+
try {
|
|
180
|
+
const res = await fetch(url, {
|
|
181
|
+
method,
|
|
182
|
+
body,
|
|
183
|
+
signal: AbortSignal.timeout(timeout),
|
|
184
|
+
});
|
|
185
|
+
await res.arrayBuffer().catch(() => {});
|
|
186
|
+
return res.ok ? undefined : `HTTP ${res.status}`;
|
|
187
|
+
} catch (err) {
|
|
188
|
+
return err?.cause?.message ?? err?.message ?? String(err);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* runCommand spawns argv with stdin inherited and stdout and stderr piped,
|
|
194
|
+
* with no shell, and copies each chunk to the wrapper's own stream as it
|
|
195
|
+
* arrives. It resolves with `{code}`, `{signal}` or, when the command could
|
|
196
|
+
* not be started, `{startError}`. While it runs, SIGINT, SIGTERM and SIGHUP
|
|
197
|
+
* are passed on to the command, and do not end the wrapper.
|
|
198
|
+
*/
|
|
199
|
+
function runCommand(
|
|
200
|
+
argv,
|
|
201
|
+
{ spawn, signals, onStdout, onStderr, stdout, stderr }
|
|
202
|
+
) {
|
|
203
|
+
return new Promise((resolve) => {
|
|
204
|
+
let child;
|
|
205
|
+
try {
|
|
206
|
+
child = spawn(argv[0], argv.slice(1), {
|
|
207
|
+
stdio: ['inherit', 'pipe', 'pipe'],
|
|
208
|
+
});
|
|
209
|
+
} catch (err) {
|
|
210
|
+
resolve({ startError: err });
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const forward = Object.fromEntries(
|
|
215
|
+
FORWARDED.map((sig) => [
|
|
216
|
+
sig,
|
|
217
|
+
() => {
|
|
218
|
+
try {
|
|
219
|
+
child.kill(sig);
|
|
220
|
+
} catch {
|
|
221
|
+
// Already gone: its exit is on its way.
|
|
222
|
+
}
|
|
223
|
+
},
|
|
224
|
+
])
|
|
225
|
+
);
|
|
226
|
+
for (const [sig, fn] of Object.entries(forward)) signals.on(sig, fn);
|
|
227
|
+
let settled = false;
|
|
228
|
+
const settle = (result) => {
|
|
229
|
+
if (settled) return;
|
|
230
|
+
settled = true;
|
|
231
|
+
for (const [sig, fn] of Object.entries(forward)) signals.off(sig, fn);
|
|
232
|
+
resolve(result);
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
child.stdout.on('data', onStdout);
|
|
236
|
+
child.stderr.on('data', onStderr);
|
|
237
|
+
child.stdout.pipe(stdout, { end: false });
|
|
238
|
+
child.stderr.pipe(stderr, { end: false });
|
|
239
|
+
|
|
240
|
+
child.on('error', (err) => {
|
|
241
|
+
// A spawn failure leaves no process behind; any later error is about
|
|
242
|
+
// one that did start, whose close still comes.
|
|
243
|
+
if (child.pid === undefined) settle({ startError: err });
|
|
244
|
+
});
|
|
245
|
+
child.on('close', (code, signal) => {
|
|
246
|
+
settle(signal ? { signal } : { code });
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Tail keeps the last TAIL_BYTES of a stream as the chunks that arrived,
|
|
253
|
+
* dropping whole chunks from the front once over the limit and cutting the
|
|
254
|
+
* first one kept, so a long run costs at most a little over 64 KiB.
|
|
255
|
+
*/
|
|
256
|
+
export class Tail {
|
|
257
|
+
#chunks = [];
|
|
258
|
+
#size = 0;
|
|
259
|
+
|
|
260
|
+
push(chunk) {
|
|
261
|
+
this.#chunks.push(chunk);
|
|
262
|
+
this.#size += chunk.length;
|
|
263
|
+
while (this.#size - this.#chunks[0].length >= TAIL_BYTES) {
|
|
264
|
+
this.#size -= this.#chunks.shift().length;
|
|
265
|
+
}
|
|
266
|
+
if (this.#size > TAIL_BYTES) {
|
|
267
|
+
const cut = this.#size - TAIL_BYTES;
|
|
268
|
+
this.#chunks[0] = this.#chunks[0].subarray(cut);
|
|
269
|
+
this.#size -= cut;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
bytes() {
|
|
274
|
+
return Buffer.concat(this.#chunks, this.#size);
|
|
275
|
+
}
|
|
276
|
+
}
|
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,7 @@ 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 { JOB_RUN_USAGE, jobRun } from './commands/job-run.js';
|
|
32
33
|
import { PULL_USAGE, pull } from './commands/pull.js';
|
|
33
34
|
import { SKILL_USAGE, skill } from './commands/skill.js';
|
|
34
35
|
import {
|
|
@@ -102,6 +103,18 @@ const NON_TOOL = {
|
|
|
102
103
|
},
|
|
103
104
|
usage: CREDENTIAL_SET_USAGE,
|
|
104
105
|
},
|
|
106
|
+
// raw: everything after `--` is the wrapped command's, verbatim, so the
|
|
107
|
+
// argv goes to the handler unparsed (see runRow). No --url or --token: the
|
|
108
|
+
// wrapper reads no Juno config, only the ping URL.
|
|
109
|
+
'job run': {
|
|
110
|
+
run: (argv, io) => jobRun(argv, io, { env: io.env }),
|
|
111
|
+
options: {
|
|
112
|
+
'url-file': { type: 'string' },
|
|
113
|
+
verbose: { type: 'boolean', default: false },
|
|
114
|
+
},
|
|
115
|
+
usage: JOB_RUN_USAGE,
|
|
116
|
+
raw: true,
|
|
117
|
+
},
|
|
105
118
|
'config set': { run: configSet, options: {}, usage: CONFIG_SET_USAGE },
|
|
106
119
|
'agent-skill install': {
|
|
107
120
|
run: agentSkillInstall,
|
|
@@ -405,6 +418,17 @@ async function showGroupHelp(group, args, io, code) {
|
|
|
405
418
|
function runRow(row, argv, leading, io) {
|
|
406
419
|
if (!row.tool) {
|
|
407
420
|
const handler = handlerFor(row);
|
|
421
|
+
if (handler.raw) {
|
|
422
|
+
// Only the words before `--` are the command's own; a --help after it
|
|
423
|
+
// belongs to the wrapped program.
|
|
424
|
+
const dash = argv.indexOf('--');
|
|
425
|
+
const own = dash === -1 ? argv : argv.slice(0, dash);
|
|
426
|
+
if (own.includes('--help') || own.includes('-h')) {
|
|
427
|
+
io.log(nonToolHelp(row, handler));
|
|
428
|
+
return EXIT_OK;
|
|
429
|
+
}
|
|
430
|
+
return handler.run(argv, io);
|
|
431
|
+
}
|
|
408
432
|
const run =
|
|
409
433
|
row.working_copy && !handler.positional
|
|
410
434
|
? (args, io) => {
|
package/verbs.json
CHANGED
|
@@ -221,6 +221,9 @@
|
|
|
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" },
|
|
224
227
|
{ "command": "credential list", "tool": "list_credentials" },
|
|
225
228
|
{ "command": "person list", "tool": "list_people" },
|
|
226
229
|
{ "command": "person get", "tool": "get_person", "id_field": "id" },
|
|
@@ -271,6 +274,11 @@
|
|
|
271
274
|
"effect": "act",
|
|
272
275
|
"summary": "Approve the tool call an assistant session is waiting on"
|
|
273
276
|
},
|
|
277
|
+
{
|
|
278
|
+
"command": "job run",
|
|
279
|
+
"effect": "act",
|
|
280
|
+
"summary": "Run a command and report it to an external job's ping URL"
|
|
281
|
+
},
|
|
274
282
|
{
|
|
275
283
|
"command": "credential set",
|
|
276
284
|
"effect": "act",
|