@pixlcore/xycli 1.0.0 → 1.0.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.
package/docs/help.md CHANGED
@@ -231,7 +231,7 @@ xy sync ./ --up events,plugins --delete events
231
231
 
232
232
  **Objects with a `stock` or `marketplace` property are always ignored by deletion**, including during dry runs. They do not need local source files, and setup inclusion flags cannot override this protection.
233
233
 
234
- **The scanned directories must contain a complete inventory of the other objects you intend to keep for every type selected for deletion.** Delete mode is not limited to previously synced objects. A missing file, incomplete export, or unavailable mount can cause unintended deletions of your own definitions. Verify that all source directories are available and inspect a dry run before removing `--dry`. Source warnings or scan errors stop the run before any updates or deletions begin.
234
+ **The scanned directories must contain a complete inventory of the other objects you intend to keep for every type selected for deletion.** Delete mode is not limited to previously synced objects. A missing file, incomplete export, or unavailable mount can cause unintended deletions of your own definitions. Verify that all source directories are available and inspect a dry run before removing `--dry`. Source warnings or scan errors stop the run before any updates begin. If an update or sync-tracking API operation fails, earlier successful updates remain in place, but the entire delete pass is skipped for that run.
235
235
 
236
236
  **Completion commands and notifications**
237
237
 
@@ -250,7 +250,7 @@ xy sync ./ --up events,plugins \
250
250
 
251
251
  Completion commands run in a local shell in the first base directory. Deletions alone do not trigger them. Event notifications receive `input.data.errors` and `input.data.warnings`.
252
252
 
253
- The configured API Key needs the appropriate resource permissions and `update_state` for sync tracking updates. Notification actions also need their usual permissions. Earlier successful changes are not rolled back if a later operation fails.
253
+ The configured API Key needs the appropriate resource permissions and `update_state` for sync tracking updates. Notification actions also need their usual permissions. Earlier successful changes are not rolled back if a later operation fails. Retry the sync after correcting an error; previously applied updates compare equal, and deletion remains disabled until the pre-delete phase completes without warnings or errors.
254
254
 
255
255
  Sync warnings and errors exit with status `1`, including warnings or errors during dry runs and completion-command errors. Completion commands and notifications can finish before the process exits. Runs with no warnings or errors exit with status `0`. Scan warnings stop the run before updates and can trigger notifications.
256
256
 
package/docs/sync.md CHANGED
@@ -472,7 +472,9 @@ Inspect the entire deletion list and confirm that the directory and remote inven
472
472
  xy sync ./ --up events,plugins,categories --down false --delete events
473
473
  ```
474
474
 
475
- Deleting an Event does not automatically remove its dependencies. Remote API rules still apply, including restrictions on active jobs and references. Successful earlier updates or deletions are not rolled back if a later deletion fails.
475
+ Deleting an Event does not automatically remove its dependencies. Remote API rules still apply, including restrictions on active jobs and references.
476
+
477
+ Deletion only begins after every update has completed and the sync-tracking state has been written without warnings or errors. If either phase fails, the entire delete pass is skipped for that run. Earlier successful updates remain in place, so correct the error and retry; definitions already updated will compare equal. Once deletion begins, successful earlier deletions are not rolled back if a later deletion fails.
476
478
 
477
479
  An unavailable base directory stops the scan, but an existing empty directory can look like an empty inventory. Do not treat that guard as protection against an empty mount, accidental file removal, or missing exports of your own definitions.
478
480
 
package/lib/sync.js CHANGED
@@ -353,6 +353,14 @@ module.exports = {
353
353
  // update state db
354
354
  await this.syncState(state);
355
355
 
356
+ // Deletion is the destructive final phase, so only begin it after every
357
+ // update and the sync-state write completed cleanly. Earlier successful
358
+ // updates are intentionally left in place and will compare equal on retry.
359
+ if (sconfig.delete && (this.errors.length || this.warnings.length)) {
360
+ this.logSyncWarning("Delete pass skipped because the sync encountered earlier warnings or errors.");
361
+ return await this.completeSync();
362
+ }
363
+
356
364
  // now handle deletes
357
365
  if (!sconfig.delete) return await this.completeSync();
358
366
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pixlcore/xycli",
3
- "version": "1.0.0",
3
+ "version": "1.0.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",
package/test/README.md CHANGED
@@ -8,7 +8,7 @@ Use Node.js 22 or newer and install the CLI dependencies with `npm install`.
8
8
 
9
9
  Start xyOps at `http://localhost:5522` and configure an administrator API key using your normal CLI config file or the `XYOPS_API_KEY` environment variable. The tests read the same configuration as `xy`, including `XYOPS_BASE_URL` overrides. They refuse to run against a different server address.
10
10
 
11
- Use an xyOps version that supports restoring API key IDs, hashes, and masks through `create_api_key`. The Server tests need a connected server, and the Monitor tests also need a server group. The log tests expect the standard log columns and debug entries in the current xyOps log.
11
+ Use an xyOps version that supports restoring API key IDs, hashes, and masks through `create_api_key`. The Server and Job tests need a connected server, the Job tests need the enabled stock Shell Plugin, and the Monitor tests also need a server group. The log tests expect the standard log columns and debug entries in the current xyOps log.
12
12
 
13
13
  ## Running Tests
14
14
 
@@ -55,6 +55,8 @@ node --test test/config.test.js test/sync-unit.test.js
55
55
  | `categories` | CRUD, actions and limits, JSON input, validation, pagination, help |
56
56
  | `channels` | CRUD, recipients, indexed edits, filters, validation, help |
57
57
  | `config` | Offline user-file updates, inherited-setting isolation, missing-file creation, dotted edits, permissions, and read-only display |
58
+ | `events` | CRUD, dependency filters, nested edits, triggers, actions, limits, fields, validation, and help |
59
+ | `jobs` | Event launch, parameter and input overrides, completed reports, output logs, search, rerun, deletion, validation, and help |
58
60
  | `log` | Log searches, latest rows, matching, column selection, native output |
59
61
  | `markdown` | Terminal rendering, inline formatting inside list items, typographic bullets, and output margins |
60
62
  | `monitors` | CRUD, groups, evaluator requests, type conversions, validation, help |
@@ -65,7 +67,7 @@ node --test test/config.test.js test/sync-unit.test.js
65
67
  | `servers` | Active and recently offline lists, local filters, installer generation, historical search, live and historical Server details, charts, processes, connections, pagination, validation, help |
66
68
  | `system` | Admin dashboard, conductors, connected users, export, maintenance, optimization, rate resets, diagnostics, broadcasts, help |
67
69
  | `sync` | Setup export, isolated filesystem discovery, external property files, dry-run, upsync, downsync, composite global-state governance, no-op detection, and guarded delete mode |
68
- | `sync-unit` | Offline PID locking and stale recovery, up-only deletion guards, stock/Marketplace deletion protection, inherited settings and CLI overrides, warning/error exit status, duplicates, quiet/dry runs, completion failures, and notification delivery after warnings or errors |
70
+ | `sync-unit` | Offline PID locking and stale recovery, up-only deletion guards, pre-delete failure blocking, stock/Marketplace deletion protection, inherited settings and CLI overrides, warning/error exit status, duplicates, quiet/dry runs, completion failures, and notification delivery after warnings or errors |
69
71
  | `tags` | CRUD, metadata search, sparse future-proof updates, JSON input, validation, pagination, help |
70
72
  | `tickets` | Search, CRUD, number resolution, comments, attachment uploads/downloads, Events, related Job lookup, sparse updates, validation, help |
71
73
  | `toast` | Warning-box word wrapping and terminal-width limits |
@@ -80,7 +82,7 @@ Pagination checks that require saved events, completed jobs, or alert history re
80
82
 
81
83
  The mutating suites create uniquely named disposable objects and remove them in cleanup blocks, including after assertion failures. Test events have no active triggers, channels are never invoked, monitor sources use constants, and Plugin definitions are never executed or attached to other objects. Temporary files live in private operating-system temp directories and are removed by native test hooks.
82
84
 
83
- The Marketplace suite installs and upgrades `pixlcore/xyplug-ai`, then deletes it without executing or attaching it to an Event. The suite requires this product to be uninstalled when the test begins and removes its own installation during cleanup. Ticket fixtures remain in `draft` for their entire lifecycle so assigned test users never receive email notifications.
85
+ The Job suite creates one enabled Event with no triggers, launches short-lived Shell Plugin Jobs only through explicit test commands, and deletes the completed Jobs and Event. The Marketplace suite installs and upgrades `pixlcore/xyplug-ai`, then deletes it without executing or attaching it to an Event. The suite requires this product to be uninstalled when the test begins and removes its own installation during cleanup. Ticket fixtures remain in `draft` for their entire lifecycle so assigned test users never receive email notifications.
84
86
 
85
87
  API key tests keep plaintext secrets in memory. Export files contain the stored hash and mask, and are deleted after the test. Captured CLI output is omitted from process failure messages because it can contain credentials.
86
88
 
@@ -0,0 +1,187 @@
1
+ const { test } = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const { loadTestConfig, createCheck, xy, json, call: apiCall, cleanupFixtures } = require('./helpers/common.js');
4
+
5
+ test('events', async t => {
6
+ loadTestConfig();
7
+ const check = createCheck(t);
8
+
9
+ // Keep the Event disabled for its entire lifecycle. Its schedule, actions,
10
+ // limits, and source are saved definitions only and can never launch a Job.
11
+ const stamp = Date.now();
12
+ const id = 'cli_event_test_' + stamp;
13
+ const title = 'CLI Event Test ' + stamp;
14
+ const action = { type: 'email', enabled: true, condition: 'success', users: ['admin'] };
15
+ const limit = { type: 'time', enabled: true, duration: 300, abort: true };
16
+ const field = { id: 'message', title: 'Message', type: 'text', value: 'Hello' };
17
+ const script = 'echo "Event fixture ' + stamp + '"';
18
+ let category;
19
+ let plugin;
20
+ let group;
21
+
22
+ try {
23
+ await check('local server has Event dependencies', async () => {
24
+ const data = await apiCall('getMultiple', { lists: 'categories,plugins,groups' });
25
+ category = data.categories.find(item => item.enabled) || data.categories[0];
26
+ plugin = data.plugins.find(item => item.id == 'shellplug') || data.plugins.find(item => item.type == 'event');
27
+ group = data.groups[0];
28
+ assert.ok(category, 'At least one Category is required');
29
+ assert.ok(plugin, 'At least one Event Plugin is required');
30
+ assert.ok(group, 'At least one Server Group is required');
31
+ });
32
+
33
+ await check('dry creation builds a complete Event request', () => {
34
+ const req = json([
35
+ 'event', 'create', '--id', id, '--title', title,
36
+ '--enabled', 'false', '--category', category.id, '--plugin', plugin.id,
37
+ '--targets', JSON.stringify([group.id]), '--algo', 'random',
38
+ '--notes', 'Original notes', '--params.script', script,
39
+ '--cron', '15 4 * * *', '--action', JSON.stringify(action),
40
+ '--limit', JSON.stringify(limit), '--field', JSON.stringify(field), '--dry'
41
+ ]);
42
+ assert.equal(req.id, id);
43
+ assert.equal(req.enabled, false);
44
+ assert.equal(req.category, category.id);
45
+ assert.equal(req.plugin, plugin.id);
46
+ assert.deepEqual(req.targets, [group.id]);
47
+ assert.equal(req.params.script, script);
48
+ assert.ok(req.triggers.some(trigger => trigger.type == 'manual' && trigger.enabled));
49
+ assert.ok(req.triggers.some(trigger => trigger.type == 'schedule' && trigger.enabled));
50
+ assert.deepEqual(req.actions.at(-1), action);
51
+ assert.deepEqual(req.limits.at(-1), limit);
52
+ assert.deepEqual(req.fields.at(-1), field);
53
+ });
54
+
55
+ await check('dry creation does not persist', () => assert.deepEqual(json(['events', '--id', id]), []));
56
+
57
+ xy([
58
+ 'event', 'create', '--id', id, '--title', title,
59
+ '--enabled', 'false', '--category', category.id, '--plugin', plugin.id,
60
+ '--targets', JSON.stringify([group.id]), '--algo', 'random',
61
+ '--notes', 'Original notes', '--params.script', script,
62
+ '--cron', '15 4 * * *', '--action', JSON.stringify(action),
63
+ '--limit', JSON.stringify(limit), '--field', JSON.stringify(field)
64
+ ]);
65
+ const created = json(['event', id]);
66
+
67
+ await check('create persists the complete Event definition', () => {
68
+ assert.equal(created.id, id);
69
+ assert.equal(created.title, title);
70
+ assert.equal(created.enabled, false);
71
+ assert.equal(created.category, category.id);
72
+ assert.equal(created.plugin, plugin.id);
73
+ assert.deepEqual(created.targets, [group.id]);
74
+ assert.equal(created.params.script, script);
75
+ assert.equal(created.notes, 'Original notes');
76
+ assert.equal(created.revision, 1);
77
+ assert.deepEqual(created.actions.at(-1), action);
78
+ assert.deepEqual(created.limits.at(-1), limit);
79
+ assert.deepEqual(created.fields.at(-1), field);
80
+ });
81
+
82
+ await check('exact ID, named ID, and fuzzy title get the Event', () => {
83
+ assert.deepEqual(json(['event', id]), created);
84
+ assert.equal(json(['event', 'get', '--id', id]).id, id);
85
+ assert.equal(json(['event', 'get', '--title', title.toLowerCase()]).id, id);
86
+ });
87
+
88
+ await check('search and dependency filters find only the fixture', () => {
89
+ assert.deepEqual(json(['events', title, '--category', category.id, '--plugin', plugin.id, '--target', group.id]), [created]);
90
+ assert.deepEqual(json(['event', 'list', id, '--enabled', 'true']), []);
91
+ });
92
+
93
+ await check('human list and detail render Event configuration', () => {
94
+ const list = xy(['events', title]);
95
+ const detail = xy(['event', id]);
96
+ assert.match(list, /FILTERED EVENTS/);
97
+ assert.match(list, /Other Commands:/);
98
+ for (const label of ['Event Summary', 'Event ID', 'Event Notes', 'Original notes', 'Triggers', 'Actions', 'Limits', 'User Parameters']) {
99
+ assert.ok(detail.toLowerCase().includes(label.toLowerCase()), label);
100
+ }
101
+ });
102
+
103
+ await check('dry update is sparse and preserves the saved revision', () => {
104
+ assert.deepEqual(json(['event', 'update', id, '--notes', 'Preview', '--dry']), { notes: 'Preview', id: id });
105
+ assert.equal(json(['event', id]).revision, 1);
106
+ });
107
+
108
+ const actionIndex = created.actions.length - 1;
109
+ const limitIndex = created.limits.length - 1;
110
+ const fieldIndex = created.fields.length - 1;
111
+ const updatedScript = 'echo "Updated Event fixture ' + stamp + '"';
112
+ xy([
113
+ 'event', 'update', id, '--title', title + ' Updated',
114
+ '--notes', 'Updated notes', '--params.script', updatedScript,
115
+ '--actions.' + actionIndex + '.condition', 'complete',
116
+ '--limits.' + limitIndex + '.duration', '600',
117
+ '--fields.' + fieldIndex + '.value', 'Updated'
118
+ ]);
119
+ let updated = json(['event', id]);
120
+
121
+ await check('dotted update preserves untouched Event settings', () => {
122
+ assert.equal(updated.title, title + ' Updated');
123
+ assert.equal(updated.notes, 'Updated notes');
124
+ assert.equal(updated.params.script, updatedScript);
125
+ assert.equal(updated.actions[actionIndex].condition, 'complete');
126
+ assert.equal(updated.actions[actionIndex].users[0], 'admin');
127
+ assert.equal(updated.limits[limitIndex].duration, 600);
128
+ assert.equal(updated.fields[fieldIndex].value, 'Updated');
129
+ assert.equal(updated.enabled, false);
130
+ assert.equal(updated.revision, 2);
131
+ });
132
+
133
+ const originalTriggerCount = updated.triggers.length;
134
+ const delay = { type: 'delay', enabled: true, duration: 60 };
135
+ xy(['event', 'update', id, '--trigger', JSON.stringify(delay)]);
136
+ updated = json(['event', id]);
137
+
138
+ await check('singular trigger appends to the saved trigger list', () => {
139
+ assert.equal(updated.triggers.length, originalTriggerCount + 1);
140
+ assert.deepEqual(updated.triggers.at(-1), delay);
141
+ });
142
+
143
+ const revision = updated.revision;
144
+ for (const args of [
145
+ ['update', id],
146
+ ['update', id, '--actions.99.enabled', 'true'],
147
+ ['update', id, '--actions.0.__proto__.polluted', 'true'],
148
+ ['update', id, '--unknown', 'value'],
149
+ ['delete', title + ' Updated', '--confirm']
150
+ ]) {
151
+ await check('reject Event ' + args.slice(0, 4).join(' '), () => xy(['event', ...args], { fail: true }));
152
+ }
153
+
154
+ await check('rejected requests leave the Event unchanged', () => assert.equal(json(['event', id]).revision, revision));
155
+
156
+ await check('duplicate Event ID error is surfaced', () => {
157
+ assert.match(xy(['event', 'create', '--id', id, '--title', title, '--enabled', 'false', '--params.script', script], { fail: true }), /already exists/i);
158
+ });
159
+
160
+ await check('unconfirmed delete shows the Event and warning toast', () => {
161
+ for (const args of [['delete', id], ['delete', id, '--confirm', 'maybe']]) {
162
+ const out = xy(['event', ...args]);
163
+ assert.match(out, /DELETE EVENT/);
164
+ assert.match(out, new RegExp(id));
165
+ assert.match(out, /Please confirm the delete/);
166
+ }
167
+ });
168
+
169
+ await check('dry delete sends only the Event ID', () => {
170
+ assert.deepEqual(json(['event', 'delete', id, '--confirm', '--dry']), { id: id });
171
+ assert.equal(json(['event', id]).id, id);
172
+ });
173
+
174
+ xy(['event', 'delete', id, '--confirm']);
175
+ await check('confirmed delete removes the Event', () => assert.deepEqual(json(['events', '--id', id]), []));
176
+ await check('get after deletion fails', () => xy(['event', id], { fail: true }));
177
+
178
+ for (const topic of ['events', 'event', 'event get', 'event create', 'event update', 'event delete', 'event run', 'run']) {
179
+ await check('help ' + topic, () => assert.ok(xy(['help', ...topic.split(' ')]).length > 100));
180
+ }
181
+ }
182
+ finally {
183
+ await cleanupFixtures([
184
+ { list: 'events', method: 'deleteEvent', match: item => item.id == id || (item.title || '').startsWith(title) }
185
+ ]);
186
+ }
187
+ });
@@ -0,0 +1,205 @@
1
+ const { test } = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const sdk = require('@pixlcore/xyops-sdk');
4
+ const { loadTestConfig, createCheck, xy, json, call: apiCall, cleanupFixtures } = require('./helpers/common.js');
5
+
6
+ test('jobs', async t => {
7
+ loadTestConfig();
8
+ const check = createCheck(t);
9
+
10
+ // This suite launches short-lived Shell Plugin Jobs, waits for each one to
11
+ // become final, and records every Job ID immediately for exact cleanup.
12
+ const stamp = Date.now();
13
+ const eventID = 'cli_job_test_' + stamp;
14
+ const eventTitle = 'CLI Job Test ' + stamp;
15
+ const marker = 'XYCLI_JOB_TEST_' + stamp;
16
+ const script = 'echo "' + marker + '"';
17
+ const jobIDs = new Set();
18
+ let eventCreated = false;
19
+
20
+ async function loadJob(id) {
21
+ const result = await sdk.api.getJob({ id: id, remove: 'timelines' });
22
+ if (result.err || !result.data || !result.data.job) return null;
23
+ return result.data.job;
24
+ }
25
+
26
+ async function waitForFinalJob(id, timeout = 20000) {
27
+ const expires = Date.now() + timeout;
28
+ while (Date.now() < expires) {
29
+ const job = await loadJob(id);
30
+ if (job && job.final) return job;
31
+ await new Promise(resolve => setTimeout(resolve, 200));
32
+ }
33
+ throw new Error('Timed out waiting for Job to complete: ' + id);
34
+ }
35
+
36
+ function launchedJobID(output) {
37
+ const match = output.match(/Successfully launched job: #(\w+)/i);
38
+ assert.ok(match, 'CLI printed the launched Job ID');
39
+ jobIDs.add(match[1]);
40
+ return match[1];
41
+ }
42
+
43
+ async function cleanupJobs() {
44
+ const errors = [];
45
+ for (const id of jobIDs) {
46
+ try {
47
+ let job = await loadJob(id);
48
+ if (!job) continue;
49
+ if (!job.final) {
50
+ await sdk.api.abortJob({ id: id });
51
+ job = await waitForFinalJob(id, 10000);
52
+ }
53
+ const result = await sdk.api.deleteJob({ id: id });
54
+ if (result.err) throw result.err;
55
+ }
56
+ catch (error) { errors.push(error); }
57
+ }
58
+ if (errors.length) throw new AggregateError(errors, 'Disposable Job cleanup failed.');
59
+ }
60
+
61
+ try {
62
+ let category;
63
+ let plugin;
64
+ let serverID;
65
+
66
+ await check('local server can run a Shell Plugin Job', async () => {
67
+ const data = await apiCall('getMultiple', { lists: 'categories,plugins', servers: 1 });
68
+ category = data.categories.find(item => item.enabled) || data.categories[0];
69
+ plugin = data.plugins.find(item => item.id == 'shellplug' && item.enabled);
70
+ serverID = Object.keys(data.servers || {})[0];
71
+ assert.ok(category && category.enabled, 'An enabled Category is required');
72
+ assert.ok(plugin, 'The enabled stock shellplug Event Plugin is required');
73
+ assert.ok(serverID, 'A connected Server is required');
74
+ });
75
+
76
+ // Mark cleanup eligibility before creation. This also covers the rare case
77
+ // where the server commits the Event but the client loses the response.
78
+ eventCreated = true;
79
+ await apiCall('createEvent', {
80
+ id: eventID,
81
+ title: eventTitle,
82
+ enabled: true,
83
+ category: category.id,
84
+ plugin: plugin.id,
85
+ params: { script: script },
86
+ fields: [],
87
+ tags: [],
88
+ targets: [serverID],
89
+ algo: 'random',
90
+ notes: 'Disposable CLI Job test Event',
91
+ actions: [],
92
+ limits: [],
93
+ triggers: [{ type: 'manual', enabled: true }]
94
+ });
95
+
96
+ await check('dry run previews overrides without launching a Job', () => {
97
+ const req = json(['run', eventID, '--params.script', script, '--input.data.marker', marker, '--dry']);
98
+ assert.equal(req.id, eventID);
99
+ assert.equal(req.params.script, script);
100
+ assert.equal(req.input.data.marker, marker);
101
+ });
102
+
103
+ const launchOutput = xy(['run', eventID, '--params.script', script, '--input.data.marker', marker]);
104
+ const firstJobID = launchedJobID(launchOutput);
105
+ let firstJob = await waitForFinalJob(firstJobID);
106
+
107
+ await check('run launches a successful Event-backed Job', () => {
108
+ assert.match(launchOutput, /Watch job live:/);
109
+ assert.equal(firstJob.id, firstJobID);
110
+ assert.equal(firstJob.event, eventID);
111
+ assert.equal(firstJob.plugin, plugin.id);
112
+ assert.equal(firstJob.server, serverID);
113
+ assert.equal(firstJob.final, true);
114
+ assert.equal(firstJob.code || 0, 0);
115
+ assert.equal(firstJob.params.script, script);
116
+ assert.equal(firstJob.input.data.marker, marker);
117
+ assert.match(firstJob.output || '', new RegExp(marker));
118
+ });
119
+
120
+ await check('Job get supports JSON and human reports', () => {
121
+ assert.deepEqual(json(['job', firstJobID]), firstJob);
122
+ const out = xy(['job', firstJobID]);
123
+ assert.match(out, /JOB SUMMARY/);
124
+ assert.match(out, new RegExp(firstJobID));
125
+ assert.match(out, /Job completed successfully/i);
126
+ assert.match(out, /Other Commands:/);
127
+ });
128
+
129
+ await check('verbose Job detail and Job log include process output', () => {
130
+ assert.match(xy(['job', firstJobID, '--verbose']), new RegExp(marker));
131
+ assert.match(xy(['job', 'log', firstJobID]), new RegExp(marker));
132
+ });
133
+
134
+ await check('completed Job search finds the launched Job', () => {
135
+ const out = xy(['jobs', '--event', eventID]);
136
+ assert.match(out, /JOB SEARCH RESULTS/);
137
+ assert.match(out, new RegExp(firstJobID));
138
+ assert.match(out, new RegExp(eventTitle));
139
+ });
140
+
141
+ await check('dry rerun previews the original Job settings', () => {
142
+ const req = json(['job', 'run', firstJobID, '--dry']);
143
+ assert.equal(req.id, eventID);
144
+ assert.equal(req.event, eventID);
145
+ assert.equal(req.params.script, script);
146
+ assert.equal(req.input.data.marker, marker);
147
+ });
148
+
149
+ const rerunOutput = xy(['job', 'run', firstJobID]);
150
+ const secondJobID = launchedJobID(rerunOutput);
151
+ const secondJob = await waitForFinalJob(secondJobID);
152
+
153
+ await check('Job rerun launches a distinct successful Job', () => {
154
+ assert.notEqual(secondJob.id, firstJobID);
155
+ assert.equal(secondJob.event, eventID);
156
+ assert.equal(secondJob.code || 0, 0);
157
+ assert.equal(secondJob.params.script, script);
158
+ assert.equal(secondJob.input.data.marker, marker);
159
+ assert.match(secondJob.output || '', new RegExp(marker));
160
+ });
161
+
162
+ await check('completed Job refuses active-only operations', () => {
163
+ assert.match(xy(['job', 'abort', firstJobID], { fail: true }), /already completed/i);
164
+ assert.match(xy(['job', 'resume', firstJobID], { fail: true }), /already completed/i);
165
+ });
166
+
167
+ await check('unconfirmed Job delete shows its target and warning', () => {
168
+ const out = xy(['job', 'delete', firstJobID]);
169
+ assert.match(out, /DELETE JOB/);
170
+ assert.match(out, new RegExp(firstJobID));
171
+ assert.match(out, /Please confirm the delete/);
172
+ });
173
+
174
+ await check('dry Job delete leaves the completed Job intact', async () => {
175
+ assert.deepEqual(json(['job', 'delete', firstJobID, '--confirm', '--dry']), { id: firstJobID });
176
+ assert.ok(await loadJob(firstJobID));
177
+ });
178
+
179
+ for (const id of [firstJobID, secondJobID]) {
180
+ xy(['job', 'delete', id, '--confirm']);
181
+ }
182
+
183
+ await check('confirmed Job delete removes both completed Jobs', async () => {
184
+ assert.equal(await loadJob(firstJobID), null);
185
+ assert.equal(await loadJob(secondJobID), null);
186
+ jobIDs.delete(firstJobID);
187
+ jobIDs.delete(secondJobID);
188
+ });
189
+
190
+ await apiCall('deleteEvent', { id: eventID });
191
+ eventCreated = false;
192
+
193
+ for (const topic of ['jobs', 'job', 'job get', 'job log', 'job run', 'job resume', 'job abort', 'job delete']) {
194
+ await check('help ' + topic, () => assert.ok(xy(['help', ...topic.split(' ')]).length > 100));
195
+ }
196
+ }
197
+ finally {
198
+ await cleanupJobs();
199
+ if (eventCreated) {
200
+ await cleanupFixtures([
201
+ { list: 'events', method: 'deleteEvent', match: item => item.id == eventID || (item.title || '').startsWith(eventTitle) }
202
+ ]);
203
+ }
204
+ }
205
+ });
@@ -217,6 +217,26 @@ test('sync API errors preserve failure status while still sending both notificat
217
217
  }
218
218
  });
219
219
 
220
+ test('sync skips the entire delete pass after an update or state API failure', t => {
221
+ const category = { id: 'fixture', title: 'Fixture', notes: 'Remote' };
222
+ const orphan = { id: 'orphan', title: 'Orphan', notes: 'Delete candidate' };
223
+
224
+ for (const failAPI of ['update_category', 'update_global_state']) {
225
+ const result = runSync(t, {
226
+ categories: [category, orphan],
227
+ source: { ...category, notes: 'Local' },
228
+ failAPI,
229
+ args: { up: 'categories', delete: 'categories' }
230
+ });
231
+
232
+ assert.equal(result.status, 1);
233
+ assert.equal(result.report.errors.length, 1);
234
+ assert.match(result.report.warnings[0], /Delete pass skipped/);
235
+ assert.equal(result.report.calls.includes('update_global_state'), true);
236
+ assert.deepEqual(result.report.requests.filter(req => req.method.startsWith('delete_')), []);
237
+ }
238
+ });
239
+
220
240
  test('sync completion-command errors also exit nonzero and notify', t => {
221
241
  const category = { id: 'fixture', title: 'Fixture', notes: 'Remote' };
222
242
  const result = runSync(t, {