@pixlcore/xycli 1.2.0 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,7 +2,7 @@ name: Offline Tests
2
2
 
3
3
  on:
4
4
  push:
5
- pull_request:
5
+ tags: [ '*' ]
6
6
 
7
7
  permissions:
8
8
  contents: read
package/LICENSE.md ADDED
@@ -0,0 +1,28 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2026 PixlCore LLC & CONTRIBUTORS.
4
+
5
+ Redistribution and use in source and binary forms, with or without
6
+ modification, are permitted provided that the following conditions are met:
7
+
8
+ 1. Redistributions of source code must retain the above copyright notice, this
9
+ list of conditions and the following disclaimer.
10
+
11
+ 2. Redistributions in binary form must reproduce the above copyright notice,
12
+ this list of conditions and the following disclaimer in the documentation
13
+ and/or other materials provided with the distribution.
14
+
15
+ 3. Neither the name of the copyright holder nor the names of its
16
+ contributors may be used to endorse or promote products derived from
17
+ this software without specific prior written permission.
18
+
19
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
23
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
24
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
25
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
27
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
package/README.md CHANGED
@@ -243,7 +243,7 @@ xy job JOB_ID
243
243
  xy job log JOB_ID --download ./job-output.log
244
244
  ```
245
245
 
246
- You can also rerun a previous job with `xy job run JOB_ID --follow`, or resume a workflow job where supported. Job reports bring together results, performance, output, files, and workflow details.
246
+ You can also rerun a previous job with `xy job run JOB_ID --follow`, or resume a workflow job where supported. When a job you are following fails and is retried, the CLI automatically follows the retry, continuing until the job succeeds or no further retry is scheduled. Job reports bring together results, performance, output, files, and workflow details.
247
247
 
248
248
  - **Command reference:** [Job searches](docs/help.md#jobs) and [Job operations](docs/help.md#job).
249
249
  - **Learn more about xyOps:** [xyOps Events and job execution](https://docs.xyops.io/events) and [xyOps Job data](https://docs.xyops.io/data#job).
package/lib/jobs.js CHANGED
@@ -173,7 +173,7 @@ module.exports = {
173
173
  // check if job is still active
174
174
  if (job.final) {
175
175
  // job must have completed, hop over to job details
176
- return await this.cmd_get_job();
176
+ return await this.cmd_get_job(true);
177
177
  }
178
178
 
179
179
  // print def list of job summary data
@@ -231,10 +231,10 @@ module.exports = {
231
231
 
232
232
  // this.printJobCompletionBanner(job);
233
233
  // delete this.activeJobs[this.args.id];
234
- await this.cmd_get_job();
234
+ await this.cmd_get_job(true);
235
235
  },
236
236
 
237
- async cmd_get_job() {
237
+ async cmd_get_job(follow = false) {
238
238
  // get job progress or result
239
239
  if (this.args.other && this.args.other.length && !this.args.id) {
240
240
  this.args.id = this.args.other.shift();
@@ -255,6 +255,24 @@ module.exports = {
255
255
  return await this.cmd_stream_job();
256
256
  }
257
257
 
258
+ // A live follow continues through retries using the completed record,
259
+ // which includes additional jobs that may be absent from stream updates.
260
+ // Ordinary lookups of completed jobs still display the requested attempt.
261
+ if (follow && job.code && Array.isArray(job.jobs)) {
262
+ var retry = job.jobs.find( item => item && item.id && (item.reason === 'retry') );
263
+ if (retry) {
264
+ if (!this.format.match(/json/)) {
265
+ this.printJobCompletionBanner(job);
266
+ println( "\n " + yellow.bold("Following retry: ") + this.getNiceJob(retry.id) );
267
+ }
268
+
269
+ // Reuse the stream entry point so queued retries and attempts that
270
+ // already finished before we attached are handled the same way.
271
+ this.args.id = retry.id;
272
+ return await this.cmd_stream_job();
273
+ }
274
+ }
275
+
258
276
  if (this.format.match(/json/)) {
259
277
  return this.jsonOutput(job);
260
278
  }
package/lib/mcp.js CHANGED
@@ -44,6 +44,9 @@ function configuration(config, args) {
44
44
  }
45
45
 
46
46
  async function main(argv) {
47
+ // Give the long-running MCP server a recognizable name in process lists.
48
+ process.title = 'xyOps MCP Server';
49
+
47
50
  const { values, positionals } = parseArgs({ args: argv, allowPositionals: true, options: {
48
51
  port: { type: 'string' }, host: { type: 'string' }, 'read-only': { type: 'boolean' },
49
52
  'tls-cert': { type: 'string' }, 'tls-key': { type: 'string' },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pixlcore/xycli",
3
- "version": "1.2.0",
3
+ "version": "1.2.1",
4
4
  "description": "A complete command-line interface for the xyOps workflow automation system.",
5
5
  "author": "Joseph Huckaby <jhuckaby@pixlcore.com>",
6
6
  "homepage": "https://github.com/pixlcore/xycli",
@@ -21,7 +21,7 @@
21
21
  ],
22
22
  "scripts": {
23
23
  "test": "node test.js",
24
- "test:offline": "node --test test/config.test.js test/help.test.js test/markdown.test.js test/sync-buckets-unit.test.js test/sync-syshook-unit.test.js test/sync-unit.test.js test/toast.test.js test/upgrade.test.js test/utils-unit.test.js test/mcp-tools-unit.test.js test/mcp-stdio-unit.test.js test/mcp-http-unit.test.js",
24
+ "test:offline": "node --test test/config.test.js test/help.test.js test/jobs-follow-unit.test.js test/markdown.test.js test/sync-buckets-unit.test.js test/sync-syshook-unit.test.js test/sync-unit.test.js test/toast.test.js test/upgrade.test.js test/utils-unit.test.js test/mcp-tools-unit.test.js test/mcp-stdio-unit.test.js test/mcp-http-unit.test.js",
25
25
  "changelog": "node tools/changelog.js"
26
26
  },
27
27
  "overrides": {
package/test/README.md CHANGED
@@ -59,6 +59,7 @@ node --test test/config.test.js test/sync-buckets-unit.test.js test/sync-unit.te
59
59
  | `config` | Offline user-file updates, inherited-setting isolation, missing-file creation, dotted edits, permissions, and read-only display |
60
60
  | `events` | CRUD, dependency filters, nested edits, triggers, actions, limits, fields, validation, and help |
61
61
  | `jobs` | Event launch, parameter and input overrides, completed reports, output logs, search, rerun, deletion, validation, and help |
62
+ | `jobs-follow-unit` | Offline live-follow retry chains, queued and already-completed retries, completed-job lookups, and stopping without a retry |
62
63
  | `log` | Log searches, latest rows, matching, column selection, native output |
63
64
  | `markdown` | Terminal rendering, inline formatting inside list items, typographic bullets, and output margins |
64
65
  | `monitors` | CRUD, groups, evaluator requests, type conversions, validation, help |
@@ -0,0 +1,107 @@
1
+ const { test } = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const cli = require('pixl-cli');
4
+ const jobs = require('../lib/jobs.js');
5
+
6
+ cli.global();
7
+
8
+ function makeFollower(t, records) {
9
+ // Exercise the real command transitions with an offline SDK stub. Streaming
10
+ // finishes an attempt, but only getJob exposes its additional-job links.
11
+ const streamed = [];
12
+ const results = [];
13
+ const context = {
14
+ ...jobs,
15
+ args: { id: records[0].id },
16
+ format: 'json',
17
+ async getMultiple() {},
18
+ api: {
19
+ async getJob({ id }) {
20
+ const job = records.find( item => item.id === id );
21
+ assert.ok(job, 'Unexpected job lookup: ' + id);
22
+ return { data: { job: { ...job } } };
23
+ },
24
+ async streamJob({ id }, callback) {
25
+ const job = records.find( item => item.id === id );
26
+ streamed.push(id);
27
+ job.final = true;
28
+ callback({ id, final: true, code: job.code });
29
+ return {};
30
+ }
31
+ },
32
+ jsonOutput(job) { results.push(job.id); },
33
+ die(message) { throw new Error(message); },
34
+ color() { return cli.chalk.white; },
35
+ printBoxList() {}
36
+ };
37
+
38
+ // Keep terminal rendering out of these tests, including progress timers.
39
+ for (const name of ['start', 'update', 'end']) t.mock.method(cli.progress, name, () => {});
40
+ for (const name of ['print', 'println', 'verboseln']) t.mock.method(global, name, () => {});
41
+ for (const name of ['getNiceJob', 'getNiceJobEvent', 'getNiceCategory', 'getNicePlugin',
42
+ 'getNiceTargets', 'getNiceServer', 'getNiceAlgo', 'getNiceJobSource',
43
+ 'getRelativeDateTime', 'getNiceJobState']) {
44
+ context[name] = () => '';
45
+ }
46
+
47
+ return { context, streamed, results };
48
+ }
49
+
50
+ test('live follow crosses completed and queued retries, ignoring unrelated jobs', async t => {
51
+ const { context, streamed, results } = makeFollower(t, [
52
+ { id: 'initial', code: 1, jobs: [{ id: 'other', reason: 'action' }, { id: 'retry1', reason: 'retry' }] },
53
+ { id: 'retry1', final: true, code: 'warning', jobs: [{ id: 'retry2', reason: 'retry' }] },
54
+ { id: 'retry2', state: 'queued', code: 0 }
55
+ ]);
56
+
57
+ await context.cmd_stream_job();
58
+ assert.deepEqual(streamed, ['initial', 'retry2']);
59
+ assert.deepEqual(results, ['retry2']);
60
+ });
61
+
62
+ test('directly opening an active job also follows its retry', async t => {
63
+ const { context, streamed, results } = makeFollower(t, [
64
+ { id: 'initial', code: 1, jobs: [{ id: 'retry', reason: 'retry' }] },
65
+ { id: 'retry', code: 2 }
66
+ ]);
67
+
68
+ await context.cmd_get_job();
69
+ assert.deepEqual(streamed, ['initial', 'retry']);
70
+ assert.deepEqual(results, ['retry']);
71
+ });
72
+
73
+ test('follow catches retries when the initial job finishes before streaming starts', async t => {
74
+ const { context, streamed, results } = makeFollower(t, [
75
+ { id: 'initial', final: true, code: 1, jobs: [{ id: 'retry', reason: 'retry' }] },
76
+ { id: 'retry', code: 0 }
77
+ ]);
78
+
79
+ await context.cmd_stream_job();
80
+ assert.deepEqual(streamed, ['retry']);
81
+ assert.deepEqual(results, ['retry']);
82
+ });
83
+
84
+ test('opening a completed job preserves the requested attempt', async t => {
85
+ const { context, streamed, results } = makeFollower(t, [
86
+ { id: 'initial', final: true, code: 1, jobs: [{ id: 'retry', reason: 'retry' }] }
87
+ ]);
88
+
89
+ await context.cmd_get_job();
90
+ assert.deepEqual(streamed, []);
91
+ assert.deepEqual(results, ['initial']);
92
+ });
93
+
94
+ test('follow stops on success or failure without a valid retry link', async t => {
95
+ for (const record of [
96
+ { code: 0, jobs: [{ id: 'other', reason: 'retry' }] },
97
+ { code: 1 },
98
+ { code: 1, jobs: [] },
99
+ { code: 1, jobs: {} },
100
+ { code: 1, jobs: [null, { reason: 'retry' }, { id: 'other', reason: 'action' }] }
101
+ ]) {
102
+ const { context, streamed, results } = makeFollower(t, [{ id: 'initial', ...record }]);
103
+ await context.cmd_stream_job();
104
+ assert.deepEqual(streamed, ['initial']);
105
+ assert.deepEqual(results, ['initial']);
106
+ }
107
+ });