@greatwait/cli 0.2.3 → 0.3.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/dist/bulk.d.ts +6 -0
- package/dist/bulk.js +178 -0
- package/dist/catalog.js +189 -2
- package/dist/commands.js +17 -1
- package/dist/operate.js +142 -9
- package/package.json +3 -3
package/dist/bulk.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { ParsedArguments } from "./args.js";
|
|
2
|
+
import { type CliRuntime } from "./runtime.js";
|
|
3
|
+
import type { CommandResult } from "./output.js";
|
|
4
|
+
type Command = (runtime: CliRuntime, args: ParsedArguments) => Promise<CommandResult>;
|
|
5
|
+
export declare const BULK_COMMANDS: Readonly<Record<string, Command>>;
|
|
6
|
+
export {};
|
package/dist/bulk.js
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { bulkPreviewRequestSchema, bulkConfirmRequestSchema, bulkControlRequestSchema, bulkJobSchema, bulkListRequestSchema, bulkItemListRequestSchema, } from "@greatwait/contracts";
|
|
2
|
+
import { confirmChange } from "./confirm.js";
|
|
3
|
+
import { CliError } from "./runtime.js";
|
|
4
|
+
import { openSession } from "./session.js";
|
|
5
|
+
import { apiUrlOption, readJsonOption, parseResponse, positionals, pageRequest, drainPages, continuationNote, table, } from "./support.js";
|
|
6
|
+
const session = (runtime, args) => openSession(runtime, { apiUrl: apiUrlOption(args) });
|
|
7
|
+
function jobId(runtime, args, command) {
|
|
8
|
+
const [id] = positionals(args, ["<job>"], `${runtime.programName} bulk ${command} <job>`);
|
|
9
|
+
const parsed = bulkJobSchema.shape.id.safeParse(id);
|
|
10
|
+
if (!parsed.success)
|
|
11
|
+
throw new CliError("Give a valid bjo_ job identity from bulk preview", "usage");
|
|
12
|
+
return parsed.data;
|
|
13
|
+
}
|
|
14
|
+
function renderJob(job) {
|
|
15
|
+
const counts = job.counts;
|
|
16
|
+
return {
|
|
17
|
+
data: job,
|
|
18
|
+
human: [
|
|
19
|
+
`Job ${job.id}`,
|
|
20
|
+
`Operation ${job.intent.operation} ${job.intent.target}`,
|
|
21
|
+
`State ${job.state}`,
|
|
22
|
+
`Version ${String(job.version)}`,
|
|
23
|
+
`Selection ${job.intent.selection.kind === "all_matching" ? "all matching results" : "explicit selected IDs"}`,
|
|
24
|
+
`Counts ${job.complete ? "exact" : "incomplete preparation; lower bounds"}`,
|
|
25
|
+
`Matched ${String(counts.matched)}; eligible ${String(counts.eligible)}; ineligible ${String(counts.ineligible)}`,
|
|
26
|
+
`Progress pending ${String(counts.pending)}; running ${String(counts.running)}; succeeded ${String(counts.succeeded)}; no-op ${String(counts.noOp)}; skipped ${String(counts.skipped)}; conflicted ${String(counts.conflicted)}; failed ${String(counts.failed)}; cancelled ${String(counts.cancelled)}`,
|
|
27
|
+
...(job.intentDigest === undefined
|
|
28
|
+
? []
|
|
29
|
+
: [`Intent ${job.intentDigest}`]),
|
|
30
|
+
...(job.previewExpiresAt === undefined
|
|
31
|
+
? []
|
|
32
|
+
: [`Confirm by ${job.previewExpiresAt}`]),
|
|
33
|
+
...(job.executionExpiresAt === undefined
|
|
34
|
+
? []
|
|
35
|
+
: [`Deadline ${job.executionExpiresAt}`]),
|
|
36
|
+
...job.warnings.map((warning) => `Warning ${warning}`),
|
|
37
|
+
`Billing ${job.billing.note}`,
|
|
38
|
+
...(job.billing.currentLimits === undefined
|
|
39
|
+
? []
|
|
40
|
+
: [`Admission ${JSON.stringify(job.billing.currentLimits)}`]),
|
|
41
|
+
].join("\n"),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
const preview = async (runtime, args) => {
|
|
45
|
+
const parsed = bulkPreviewRequestSchema.safeParse(await readJsonOption(runtime, "--intent", args.options["--intent"]));
|
|
46
|
+
if (!parsed.success)
|
|
47
|
+
throw new CliError("--intent must match the canonical bulk operation request", "usage", parsed.error.issues
|
|
48
|
+
.map((issue) => `${issue.path.join(".")}: ${issue.message}`)
|
|
49
|
+
.join("; "));
|
|
50
|
+
const active = await session(runtime, args);
|
|
51
|
+
const job = parseResponse("bulk.preview", await active.client.request("bulk_preview", parsed.data));
|
|
52
|
+
return {
|
|
53
|
+
...renderJob(job),
|
|
54
|
+
nextMove: `Inspect this frozen selection: ${runtime.programName} bulk read ${job.id}; ${runtime.programName} bulk items ${job.id} --all`,
|
|
55
|
+
};
|
|
56
|
+
};
|
|
57
|
+
const read = async (runtime, args) => {
|
|
58
|
+
const id = jobId(runtime, args, "read");
|
|
59
|
+
const active = await session(runtime, args);
|
|
60
|
+
return renderJob(parseResponse("bulk.read", await active.client.request("bulk_read", id)));
|
|
61
|
+
};
|
|
62
|
+
const confirm = async (runtime, args) => {
|
|
63
|
+
const id = jobId(runtime, args, "confirm");
|
|
64
|
+
const parsed = bulkConfirmRequestSchema.safeParse({
|
|
65
|
+
intentDigest: args.options["--intent-digest"],
|
|
66
|
+
confirmed: true,
|
|
67
|
+
acknowledgeDuplicateEffects: args.booleans.has("--acknowledge-duplicate-effects"),
|
|
68
|
+
});
|
|
69
|
+
if (!parsed.success)
|
|
70
|
+
throw new CliError("Give the exact --intent-digest from bulk read", "usage");
|
|
71
|
+
const active = await session(runtime, args);
|
|
72
|
+
const current = parseResponse("bulk.read", await active.client.request("bulk_read", id));
|
|
73
|
+
if (!current.complete || current.intentDigest !== parsed.data.intentDigest)
|
|
74
|
+
throw new CliError("The job is incomplete or the intent digest differs; refresh the preview", "validation");
|
|
75
|
+
if (current.intent.operation === "retry" &&
|
|
76
|
+
!parsed.data.acknowledgeDuplicateEffects)
|
|
77
|
+
throw new CliError("Retry requires --acknowledge-duplicate-effects", "usage", "A receiver may already have acted; explicit redelivery can repeat external effects.");
|
|
78
|
+
const confirmation = await confirmChange(runtime, args, {
|
|
79
|
+
action: `Confirm ${current.intent.operation} for frozen bulk job ${id}.`,
|
|
80
|
+
effect: [
|
|
81
|
+
`authority ${JSON.stringify(current.authority)}`,
|
|
82
|
+
`intent ${parsed.data.intentDigest}`,
|
|
83
|
+
`selection ${JSON.stringify(current.intent.selection)}`,
|
|
84
|
+
`parameters ${JSON.stringify(current.intent.parameters)}`,
|
|
85
|
+
`matched ${String(current.counts.matched)}; eligible ${String(current.counts.eligible)}; ineligible ${String(current.counts.ineligible)}`,
|
|
86
|
+
...current.warnings,
|
|
87
|
+
"Already-sent requests cannot be recalled and ordinary quotas and per-attempt billing apply.",
|
|
88
|
+
],
|
|
89
|
+
});
|
|
90
|
+
const key = args.options["--idempotency-key"] ??
|
|
91
|
+
`greatwait-bulk-${id}-${parsed.data.intentDigest}`;
|
|
92
|
+
runtime.stderr(`Retry key ${key}; retain it with this intent if the response is lost.\n`);
|
|
93
|
+
const job = parseResponse("bulk.confirm", await active.client.request("bulk_confirm", id, parsed.data, {
|
|
94
|
+
"Idempotency-Key": key,
|
|
95
|
+
}));
|
|
96
|
+
return {
|
|
97
|
+
...renderJob(job),
|
|
98
|
+
data: { job, confirmation, idempotencyKey: key },
|
|
99
|
+
nextMove: `Monitor: ${runtime.programName} bulk read ${id}`,
|
|
100
|
+
};
|
|
101
|
+
};
|
|
102
|
+
function control(action) {
|
|
103
|
+
return async (runtime, args) => {
|
|
104
|
+
const id = jobId(runtime, args, action);
|
|
105
|
+
const raw = args.options["--expected-version"];
|
|
106
|
+
const parsed = bulkControlRequestSchema.safeParse({
|
|
107
|
+
expectedVersion: raw === undefined ? undefined : Number(raw),
|
|
108
|
+
});
|
|
109
|
+
if (!parsed.success)
|
|
110
|
+
throw new CliError("Give --expected-version from bulk read", "usage");
|
|
111
|
+
const active = await session(runtime, args);
|
|
112
|
+
const current = parseResponse("bulk.read", await active.client.request("bulk_read", id));
|
|
113
|
+
const confirmation = await confirmChange(runtime, args, {
|
|
114
|
+
action: `${action} remaining work in bulk job ${id}.`,
|
|
115
|
+
effect: [
|
|
116
|
+
`operation ${current.intent.operation}; state ${current.state}; expected version ${String(parsed.data.expectedVersion)}`,
|
|
117
|
+
"This controls the job, not selected schedules. Already-started items may finish; committed effects remain.",
|
|
118
|
+
],
|
|
119
|
+
});
|
|
120
|
+
const job = parseResponse(`bulk.${action}`, await active.client.request(`bulk_${action}`, id, parsed.data));
|
|
121
|
+
return { ...renderJob(job), data: { job, confirmation } };
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
function list(items) {
|
|
125
|
+
return async (runtime, args) => {
|
|
126
|
+
const id = items ? jobId(runtime, args, "items") : undefined;
|
|
127
|
+
const active = await session(runtime, args);
|
|
128
|
+
const operation = items ? "bulk.items" : "bulk.list";
|
|
129
|
+
const state = args.options["--state"];
|
|
130
|
+
const parsed = (items ? bulkItemListRequestSchema : bulkListRequestSchema).safeParse({
|
|
131
|
+
...pageRequest(args),
|
|
132
|
+
...(state === undefined ? {} : { state }),
|
|
133
|
+
});
|
|
134
|
+
if (!parsed.success)
|
|
135
|
+
throw new CliError("Invalid bulk listing page or state", "usage");
|
|
136
|
+
const command = `${runtime.programName} bulk ${items ? `items ${id}` : "list"}${state === undefined ? "" : ` --state ${state}`}`;
|
|
137
|
+
const pages = await drainPages(runtime, args, pageRequest(args), async (request) => {
|
|
138
|
+
const query = { ...request, ...(state === undefined ? {} : { state }) };
|
|
139
|
+
const response = items
|
|
140
|
+
? await active.client.request("bulk_items", id, query)
|
|
141
|
+
: await active.client.request("bulk_list", query);
|
|
142
|
+
const page = parseResponse(operation, response);
|
|
143
|
+
return { ...page, freshness: { kind: "authoritative" } };
|
|
144
|
+
});
|
|
145
|
+
const note = continuationNote(pages, command);
|
|
146
|
+
return {
|
|
147
|
+
data: {
|
|
148
|
+
rows: pages.rows,
|
|
149
|
+
pages: pages.pages,
|
|
150
|
+
ceilingReached: pages.ceilingReached,
|
|
151
|
+
...(pages.nextCursor === undefined
|
|
152
|
+
? {}
|
|
153
|
+
: { nextCursor: pages.nextCursor }),
|
|
154
|
+
},
|
|
155
|
+
human: table(items
|
|
156
|
+
? ["STATE", "REASON", "SCHEDULE", "ITEM"]
|
|
157
|
+
: ["STATE", "OPERATION", "MATCHED", "JOB"], pages.rows.map((row) => "intent" in row
|
|
158
|
+
? [
|
|
159
|
+
row.state,
|
|
160
|
+
row.intent.operation,
|
|
161
|
+
String(row.counts.matched),
|
|
162
|
+
row.id,
|
|
163
|
+
]
|
|
164
|
+
: [row.state, row.reason, row.scheduleId, row.id]), items ? "No items match this page." : "No authorized bulk jobs match."),
|
|
165
|
+
...(note === undefined ? {} : { nextMove: note }),
|
|
166
|
+
};
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
export const BULK_COMMANDS = {
|
|
170
|
+
"bulk preview": preview,
|
|
171
|
+
"bulk confirm": confirm,
|
|
172
|
+
"bulk read": read,
|
|
173
|
+
"bulk list": list(false),
|
|
174
|
+
"bulk items": list(true),
|
|
175
|
+
"bulk pause": control("pause"),
|
|
176
|
+
"bulk resume": control("resume"),
|
|
177
|
+
"bulk cancel": control("cancel"),
|
|
178
|
+
};
|
package/dist/catalog.js
CHANGED
|
@@ -279,6 +279,111 @@ export const MEMBER_COMMANDS = [
|
|
|
279
279
|
},
|
|
280
280
|
];
|
|
281
281
|
export const COMMAND_CATALOG = [
|
|
282
|
+
{
|
|
283
|
+
group: "Bulk operations",
|
|
284
|
+
words: "bulk preview",
|
|
285
|
+
summary: "Prepare an exact all-matching or selected-ID preview for retry, cancel, reschedule, pause or resume.",
|
|
286
|
+
operations: ["bulk.preview"],
|
|
287
|
+
positionals: [],
|
|
288
|
+
options: [
|
|
289
|
+
API_URL,
|
|
290
|
+
{
|
|
291
|
+
flag: "--intent",
|
|
292
|
+
value: "<json|@file|->",
|
|
293
|
+
summary: "Canonical bulk request: operation, target, selection and operation-specific parameters. Preview never sends deliveries or changes schedules.",
|
|
294
|
+
},
|
|
295
|
+
],
|
|
296
|
+
},
|
|
297
|
+
{
|
|
298
|
+
group: "Bulk operations",
|
|
299
|
+
words: "bulk confirm",
|
|
300
|
+
summary: "Confirm the exact frozen preview and start its bounded asynchronous job.",
|
|
301
|
+
operations: ["bulk.read", "bulk.confirm"],
|
|
302
|
+
confirms: true,
|
|
303
|
+
positionals: [
|
|
304
|
+
{ name: "<job>", summary: "The bjo_ identity returned by bulk preview." },
|
|
305
|
+
],
|
|
306
|
+
options: [
|
|
307
|
+
API_URL,
|
|
308
|
+
CONFIRM,
|
|
309
|
+
IDEMPOTENCY,
|
|
310
|
+
{
|
|
311
|
+
flag: "--intent-digest",
|
|
312
|
+
value: "<digest>",
|
|
313
|
+
summary: "Exact digest from the complete unexpired preview.",
|
|
314
|
+
},
|
|
315
|
+
{
|
|
316
|
+
flag: "--acknowledge-duplicate-effects",
|
|
317
|
+
summary: "Explicitly acknowledge retry's potential duplicate receiver effects; required for retry.",
|
|
318
|
+
},
|
|
319
|
+
],
|
|
320
|
+
},
|
|
321
|
+
{
|
|
322
|
+
group: "Bulk operations",
|
|
323
|
+
words: "bulk read",
|
|
324
|
+
summary: "Read preview progress, job counts, limits, warnings and results.",
|
|
325
|
+
operations: ["bulk.read"],
|
|
326
|
+
positionals: [
|
|
327
|
+
{ name: "<job>", summary: "The bjo_ identity returned by bulk preview." },
|
|
328
|
+
],
|
|
329
|
+
options: [API_URL],
|
|
330
|
+
},
|
|
331
|
+
{
|
|
332
|
+
group: "Bulk operations",
|
|
333
|
+
words: "bulk list",
|
|
334
|
+
summary: "List authorized retained bulk jobs in this project and environment.",
|
|
335
|
+
operations: ["bulk.list"],
|
|
336
|
+
positionals: [],
|
|
337
|
+
options: [
|
|
338
|
+
API_URL,
|
|
339
|
+
...PAGE_OPTIONS,
|
|
340
|
+
{
|
|
341
|
+
flag: "--state",
|
|
342
|
+
value: "<state>",
|
|
343
|
+
summary: "Only jobs in this lifecycle state.",
|
|
344
|
+
},
|
|
345
|
+
],
|
|
346
|
+
},
|
|
347
|
+
{
|
|
348
|
+
group: "Bulk operations",
|
|
349
|
+
words: "bulk items",
|
|
350
|
+
summary: "Inspect every frozen target and its durable outcome.",
|
|
351
|
+
operations: ["bulk.items"],
|
|
352
|
+
positionals: [
|
|
353
|
+
{ name: "<job>", summary: "The bjo_ identity returned by bulk preview." },
|
|
354
|
+
],
|
|
355
|
+
options: [
|
|
356
|
+
API_URL,
|
|
357
|
+
...PAGE_OPTIONS,
|
|
358
|
+
{
|
|
359
|
+
flag: "--state",
|
|
360
|
+
value: "<state>",
|
|
361
|
+
summary: "Only items in this state.",
|
|
362
|
+
},
|
|
363
|
+
],
|
|
364
|
+
},
|
|
365
|
+
...["pause", "resume", "cancel"].map((action) => ({
|
|
366
|
+
group: "Bulk operations",
|
|
367
|
+
words: `bulk ${action}`,
|
|
368
|
+
summary: `${action[0].toUpperCase()}${action.slice(1)} remaining bulk-job work; already-started items may finish.`,
|
|
369
|
+
operations: ["bulk.read", `bulk.${action}`],
|
|
370
|
+
confirms: true,
|
|
371
|
+
positionals: [
|
|
372
|
+
{
|
|
373
|
+
name: "<job>",
|
|
374
|
+
summary: "The bjo_ identity returned by bulk preview.",
|
|
375
|
+
},
|
|
376
|
+
],
|
|
377
|
+
options: [
|
|
378
|
+
API_URL,
|
|
379
|
+
CONFIRM,
|
|
380
|
+
{
|
|
381
|
+
flag: "--expected-version",
|
|
382
|
+
value: "<version>",
|
|
383
|
+
summary: "Mandatory job version from bulk read; stale controls conflict.",
|
|
384
|
+
},
|
|
385
|
+
],
|
|
386
|
+
})),
|
|
282
387
|
{
|
|
283
388
|
group: "Planning",
|
|
284
389
|
words: "notifications show",
|
|
@@ -557,6 +662,58 @@ export const COMMAND_CATALOG = [
|
|
|
557
662
|
summary: "Find retained runs, newest first, by host identifier or tag.",
|
|
558
663
|
words: "executions list",
|
|
559
664
|
},
|
|
665
|
+
{
|
|
666
|
+
group: "Schedules",
|
|
667
|
+
operations: ["attempts.search"],
|
|
668
|
+
options: [
|
|
669
|
+
API_URL,
|
|
670
|
+
{
|
|
671
|
+
flag: "--started-from",
|
|
672
|
+
value: "<instant>",
|
|
673
|
+
summary: "Required UTC attempt start, inclusive (RFC 3339).",
|
|
674
|
+
},
|
|
675
|
+
{
|
|
676
|
+
flag: "--started-before",
|
|
677
|
+
value: "<instant>",
|
|
678
|
+
summary: "Required UTC attempt end, exclusive (RFC 3339).",
|
|
679
|
+
},
|
|
680
|
+
{
|
|
681
|
+
flag: "--http-status",
|
|
682
|
+
value: "<status>",
|
|
683
|
+
summary: "Only attempts with this HTTP response status, such as 503.",
|
|
684
|
+
},
|
|
685
|
+
{
|
|
686
|
+
flag: "--status-class",
|
|
687
|
+
value: "<class>",
|
|
688
|
+
summary: "Only one HTTP status class: 1xx through 5xx.",
|
|
689
|
+
},
|
|
690
|
+
{
|
|
691
|
+
flag: "--outcome",
|
|
692
|
+
value: "<outcome>",
|
|
693
|
+
summary: "The matched attempt's outcome, independently of later recovery.",
|
|
694
|
+
},
|
|
695
|
+
{
|
|
696
|
+
flag: "--diagnostic",
|
|
697
|
+
value: "<category>",
|
|
698
|
+
summary: "A classified attempt diagnostic, including transport failures without a response.",
|
|
699
|
+
},
|
|
700
|
+
{
|
|
701
|
+
flag: "--endpoint",
|
|
702
|
+
value: "<destination>",
|
|
703
|
+
summary: "Only attempts for this destination (ep_).",
|
|
704
|
+
},
|
|
705
|
+
{
|
|
706
|
+
flag: "--schedule",
|
|
707
|
+
value: "<schedule>",
|
|
708
|
+
summary: "Only attempts for this schedule (sch_).",
|
|
709
|
+
},
|
|
710
|
+
...CORRELATION_FILTER_OPTIONS,
|
|
711
|
+
...PAGE_OPTIONS,
|
|
712
|
+
],
|
|
713
|
+
positionals: [],
|
|
714
|
+
summary: "Search retained attempts by actual UTC start time, including failures that later recovered.",
|
|
715
|
+
words: "attempts search",
|
|
716
|
+
},
|
|
560
717
|
/* --- Schedules -------------------------------------------------------- */
|
|
561
718
|
{
|
|
562
719
|
group: "Schedules",
|
|
@@ -566,9 +723,29 @@ export const COMMAND_CATALOG = [
|
|
|
566
723
|
...CORRELATION_FILTER_OPTIONS,
|
|
567
724
|
{
|
|
568
725
|
flag: "--endpoint",
|
|
569
|
-
summary: "Only
|
|
726
|
+
summary: "Only schedules that call this destination (ep_), or none for timer-only.",
|
|
570
727
|
value: "<destination>",
|
|
571
728
|
},
|
|
729
|
+
{
|
|
730
|
+
flag: "--status",
|
|
731
|
+
value: "<status>",
|
|
732
|
+
summary: "Active, paused, cancelled or completed schedules.",
|
|
733
|
+
},
|
|
734
|
+
{
|
|
735
|
+
flag: "--recurrence",
|
|
736
|
+
value: "<kind>",
|
|
737
|
+
summary: "One recurrence kind: at, every or cron.",
|
|
738
|
+
},
|
|
739
|
+
{
|
|
740
|
+
flag: "--text",
|
|
741
|
+
value: "<text>",
|
|
742
|
+
summary: "Case-insensitive text in a schedule name, address or identifier.",
|
|
743
|
+
},
|
|
744
|
+
{
|
|
745
|
+
flag: "--due-before",
|
|
746
|
+
value: "<instant>",
|
|
747
|
+
summary: "Only schedules with an unsettled run before this RFC 3339 instant.",
|
|
748
|
+
},
|
|
572
749
|
...PAGE_OPTIONS,
|
|
573
750
|
],
|
|
574
751
|
positionals: [],
|
|
@@ -654,6 +831,11 @@ export const COMMAND_CATALOG = [
|
|
|
654
831
|
summary: "JSON your receiver gets with the call. Inline, @file.json, or - for standard input.",
|
|
655
832
|
value: "<json>",
|
|
656
833
|
},
|
|
834
|
+
{
|
|
835
|
+
flag: "--delivery-timeout",
|
|
836
|
+
value: "<duration>",
|
|
837
|
+
summary: "How long the receiver may take, such as 20s or 5m (whole seconds). Defaults to 5s on create; replace keeps the stored timeout. Response completion allows up to 30m; longer needs existing callback completion. Separate from --timeout for --wait.",
|
|
838
|
+
},
|
|
657
839
|
IDEMPOTENCY,
|
|
658
840
|
{
|
|
659
841
|
flag: "--wait",
|
|
@@ -736,6 +918,11 @@ export const COMMAND_CATALOG = [
|
|
|
736
918
|
summary: "JSON your receiver gets with the call. Inline, @file.json, or - for standard input.",
|
|
737
919
|
value: "<json>",
|
|
738
920
|
},
|
|
921
|
+
{
|
|
922
|
+
flag: "--delivery-timeout",
|
|
923
|
+
value: "<duration>",
|
|
924
|
+
summary: "Override only the receiver timeout, such as 20s or 5m (whole seconds). Keeps completion and overlap settings. Response completion allows up to 30m; existing callback completion allows up to 1d. Omit to preserve the timeout.",
|
|
925
|
+
},
|
|
739
926
|
IF_MATCH,
|
|
740
927
|
CONFIRM,
|
|
741
928
|
],
|
|
@@ -745,7 +932,7 @@ export const COMMAND_CATALOG = [
|
|
|
745
932
|
summary: "The schedule's id, starting sch_, from schedules create or schedules list.",
|
|
746
933
|
},
|
|
747
934
|
],
|
|
748
|
-
summary: "Change when a schedule runs.
|
|
935
|
+
summary: "Change when a schedule runs. Destination, data, HTTP and delivery settings stay unless you give new ones. Timing is replaced; latency class and lateness policy are re-derived; pending occurrences are discarded. A concurrent edit can refuse this change.",
|
|
749
936
|
words: "schedules replace",
|
|
750
937
|
},
|
|
751
938
|
{
|
package/dist/commands.js
CHANGED
|
@@ -803,12 +803,20 @@ const schedulesRead = async (runtime, args) => {
|
|
|
803
803
|
const answer = await session.client.requestDetailed("schedules_read", id);
|
|
804
804
|
const view = responseSchemaFor("schedules.read")?.parse(answer.body);
|
|
805
805
|
const etag = answer.headers["ETag"];
|
|
806
|
+
const failedOneShot = view.schedule.status === "active" &&
|
|
807
|
+
view.schedule.recurrence.kind === "at" &&
|
|
808
|
+
view.occurrence.generation === view.schedule.generation &&
|
|
809
|
+
view.occurrence.state === "terminal_failed";
|
|
806
810
|
return {
|
|
807
811
|
data: {
|
|
808
812
|
...(etag === undefined ? {} : { etag }),
|
|
809
813
|
recurrence: view.schedule.recurrence.kind,
|
|
810
814
|
schedule: view.schedule.id,
|
|
811
815
|
status: view.schedule.status,
|
|
816
|
+
...(view.schedule.delivery === undefined
|
|
817
|
+
? {}
|
|
818
|
+
: { delivery: view.schedule.delivery }),
|
|
819
|
+
...(failedOneShot ? { runState: view.occurrence.state } : {}),
|
|
812
820
|
autoPauseAfterFailures: view.schedule.autoPauseAfterFailures,
|
|
813
821
|
consecutiveFailedRuns: view.schedule.consecutiveFailedRuns,
|
|
814
822
|
autoPauseReason: view.schedule.autoPauseReason,
|
|
@@ -820,7 +828,15 @@ const schedulesRead = async (runtime, args) => {
|
|
|
820
828
|
},
|
|
821
829
|
human: [
|
|
822
830
|
`Schedule ${view.schedule.id}`,
|
|
823
|
-
`Status ${view.schedule.status}`,
|
|
831
|
+
`Status ${failedOneShot ? "failed (schedule active)" : view.schedule.status}`,
|
|
832
|
+
...(failedOneShot
|
|
833
|
+
? ["Next The only run failed. Nothing more is planned."]
|
|
834
|
+
: []),
|
|
835
|
+
...(view.schedule.delivery === undefined
|
|
836
|
+
? []
|
|
837
|
+
: [
|
|
838
|
+
`Delivery ${String(view.schedule.delivery.maxDurationSeconds)}s timeout (${view.schedule.delivery.completion}, overlap ${view.schedule.delivery.onOverlap})`,
|
|
839
|
+
]),
|
|
824
840
|
`Auto-pause ${String(view.schedule.autoPauseAfterFailures ?? 5)}`,
|
|
825
841
|
`Failed runs ${String(view.schedule.consecutiveFailedRuns ?? 0)}`,
|
|
826
842
|
...(view.schedule.autoPauseReason == null
|
package/dist/operate.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { BULK_COMMANDS } from "./bulk.js";
|
|
1
2
|
import { writeFile } from "node:fs/promises";
|
|
2
|
-
import { projectNotificationSettingsUpdateRequestSchema, statusSigningKeyRequestSchema, autoPauseAfterFailuresSchema, httpRequestSchema, externalIdentifierSchema, scheduleTagsSchema, scheduleTagSchema, sanitizedDiagnosticText, diagnosticBodyText, responseBodySentence, requestCaptureSentence, attemptStatusGuidance, endpointHeaderChangesSchema, billingAutoTopUpApplyRequestSchema, billingAutoTopUpPreviewRequestSchema, billingAutoTopUpPreviewSchema, billingCeilingUpdateRequestSchema, billingFundingQuoteRequestSchema, billingFundingQuoteSchema, billingNotificationSettingsUpdateRequestSchema, billingRefundRequestCreateSchema, billingRefundResolutionRequestSchema, billingRedeemRequestSchema, projectBillingQuoteSchema, projectBillingQuoteRequestSchema, } from "@greatwait/contracts";
|
|
3
|
+
import { projectNotificationSettingsUpdateRequestSchema, statusSigningKeyRequestSchema, autoPauseAfterFailuresSchema, deliveryDurationSecondsSchema, httpRequestSchema, externalIdentifierSchema, scheduleTagsSchema, scheduleTagSchema, scheduleSelectionSchema, normalizeScheduleSelection, projectAttemptSearchRequestSchema, normalizeAttemptSelection, sanitizedDiagnosticText, diagnosticBodyText, responseBodySentence, requestCaptureSentence, attemptStatusGuidance, endpointHeaderChangesSchema, billingAutoTopUpApplyRequestSchema, billingAutoTopUpPreviewRequestSchema, billingAutoTopUpPreviewSchema, billingCeilingUpdateRequestSchema, billingFundingQuoteRequestSchema, billingFundingQuoteSchema, billingNotificationSettingsUpdateRequestSchema, billingRefundRequestCreateSchema, billingRefundResolutionRequestSchema, billingRedeemRequestSchema, projectBillingQuoteSchema, projectBillingQuoteRequestSchema, } from "@greatwait/contracts";
|
|
3
4
|
import { parseDuration } from "./args.js";
|
|
4
5
|
import { confirmChange } from "./confirm.js";
|
|
5
6
|
import { redactSecret } from "./output.js";
|
|
@@ -125,6 +126,12 @@ async function scheduleRequest(runtime, args, usage) {
|
|
|
125
126
|
throw new CliError('--auto-pause-after-failures must be a whole number from 1 to 100, or "never"', "usage");
|
|
126
127
|
const latencyClass = args.options["--latency-class"];
|
|
127
128
|
const partitionKey = args.options["--partition-key"];
|
|
129
|
+
const timeoutInput = args.options["--delivery-timeout"];
|
|
130
|
+
const timeout = timeoutInput === undefined
|
|
131
|
+
? undefined
|
|
132
|
+
: deliveryDurationSecondsSchema.safeParse(parseDuration(timeoutInput));
|
|
133
|
+
if (timeout !== undefined && !timeout.success)
|
|
134
|
+
throw new CliError("--delivery-timeout is invalid", "usage", timeout.error.issues.map((issue) => issue.message).join("; "));
|
|
128
135
|
const external_identifier = args.options["--external-identifier"];
|
|
129
136
|
if (external_identifier !== undefined &&
|
|
130
137
|
!externalIdentifierSchema.safeParse(external_identifier).success)
|
|
@@ -138,6 +145,9 @@ async function scheduleRequest(runtime, args, usage) {
|
|
|
138
145
|
...(external_identifier === undefined ? {} : { external_identifier }),
|
|
139
146
|
...(parsedTags?.success ? { tags: parsedTags.data } : {}),
|
|
140
147
|
...(http?.success ? { http: http.data } : {}),
|
|
148
|
+
...(timeout?.success
|
|
149
|
+
? { delivery: { maxDurationSeconds: timeout.data } }
|
|
150
|
+
: {}),
|
|
141
151
|
...(endpoint === undefined ? {} : { endpoint }),
|
|
142
152
|
...(url === undefined ? {} : { url }),
|
|
143
153
|
...(latencyClass === undefined ? {} : { latencyClass }),
|
|
@@ -352,16 +362,92 @@ const executionsList = async (runtime, args) => {
|
|
|
352
362
|
row: (run) => [run.state, run.plannedFor, run.schedule, run.id],
|
|
353
363
|
});
|
|
354
364
|
};
|
|
365
|
+
const attemptsSearch = async (runtime, args) => {
|
|
366
|
+
const from = args.options["--started-from"];
|
|
367
|
+
const before = args.options["--started-before"];
|
|
368
|
+
if (from === undefined || before === undefined)
|
|
369
|
+
throw new CliError("--started-from and --started-before are required UTC timestamps", "usage");
|
|
370
|
+
const parsed = projectAttemptSearchRequestSchema.safeParse({
|
|
371
|
+
...correlationFilters(args),
|
|
372
|
+
startedFrom: parseInstant(from, "--started-from"),
|
|
373
|
+
startedBefore: parseInstant(before, "--started-before"),
|
|
374
|
+
responseStatus: args.options["--http-status"] === undefined
|
|
375
|
+
? undefined
|
|
376
|
+
: Number(args.options["--http-status"]),
|
|
377
|
+
statusClass: args.options["--status-class"],
|
|
378
|
+
outcome: args.options["--outcome"],
|
|
379
|
+
diagnostic: args.options["--diagnostic"],
|
|
380
|
+
endpoint: args.options["--endpoint"],
|
|
381
|
+
schedule: args.options["--schedule"],
|
|
382
|
+
});
|
|
383
|
+
if (!parsed.success)
|
|
384
|
+
throw new CliError("The attempt filters are invalid", "usage", parsed.error.issues
|
|
385
|
+
.map((issue) => `${issue.path.join(".")}: ${issue.message}`)
|
|
386
|
+
.join("; "));
|
|
387
|
+
const active = await session(runtime, args);
|
|
388
|
+
const flags = [
|
|
389
|
+
"--started-from",
|
|
390
|
+
"--started-before",
|
|
391
|
+
"--http-status",
|
|
392
|
+
"--status-class",
|
|
393
|
+
"--outcome",
|
|
394
|
+
"--diagnostic",
|
|
395
|
+
"--endpoint",
|
|
396
|
+
"--schedule",
|
|
397
|
+
];
|
|
398
|
+
return listing(runtime, args, {
|
|
399
|
+
columns: [
|
|
400
|
+
"STARTED (UTC)",
|
|
401
|
+
"ATTEMPT OUTCOME",
|
|
402
|
+
"HTTP / DIAGNOSTIC",
|
|
403
|
+
"RUN OUTCOME",
|
|
404
|
+
"SCHEDULE",
|
|
405
|
+
"RUN",
|
|
406
|
+
"ATTEMPT",
|
|
407
|
+
],
|
|
408
|
+
command: `${runtime.programName} attempts search${flags.flatMap((flag) => (args.options[flag] === undefined ? [] : [` ${flag} '${args.options[flag].replaceAll("'", "'\"'\"'")}'`])).join("")}${correlationCommand(args)}`,
|
|
409
|
+
empty: "No retained attempts match these filters. Older evidence may have expired.",
|
|
410
|
+
read: (request) => page("attempts.search", () => active.client.request("attempts_search", {
|
|
411
|
+
...normalizeAttemptSelection(parsed.data),
|
|
412
|
+
...request,
|
|
413
|
+
})),
|
|
414
|
+
row: (result) => [
|
|
415
|
+
result.attempt.startedAt,
|
|
416
|
+
result.attempt.state ?? "in_flight",
|
|
417
|
+
result.attempt.responseStatus?.toString() ??
|
|
418
|
+
result.attempt.diagnostic ??
|
|
419
|
+
"pending",
|
|
420
|
+
result.occurrence.state,
|
|
421
|
+
result.schedule.id,
|
|
422
|
+
result.occurrence.id,
|
|
423
|
+
result.attempt.id,
|
|
424
|
+
],
|
|
425
|
+
});
|
|
426
|
+
};
|
|
355
427
|
const schedulesList = async (runtime, args) => {
|
|
356
|
-
const
|
|
428
|
+
const parsed = scheduleSelectionSchema.safeParse({
|
|
429
|
+
...correlationFilters(args),
|
|
430
|
+
endpoint: args.options["--endpoint"],
|
|
431
|
+
status: args.options["--status"],
|
|
432
|
+
recurrence: args.options["--recurrence"],
|
|
433
|
+
text: args.options["--text"],
|
|
434
|
+
dueBefore: args.options["--due-before"] === undefined
|
|
435
|
+
? undefined
|
|
436
|
+
: parseInstant(args.options["--due-before"], "--due-before"),
|
|
437
|
+
});
|
|
438
|
+
if (!parsed.success)
|
|
439
|
+
throw new CliError("The schedule filters are invalid", "usage", parsed.error.issues
|
|
440
|
+
.map((issue) => `${issue.path.join(".")}: ${issue.message}`)
|
|
441
|
+
.join("; "));
|
|
442
|
+
const filters = normalizeScheduleSelection(parsed.data);
|
|
357
443
|
const active = await session(runtime, args);
|
|
358
444
|
const endpoint = args.options["--endpoint"];
|
|
359
445
|
return listing(runtime, args, {
|
|
360
446
|
columns: ["STATUS", "NAME", "RECURRENCE", "DELIVERS TO", "SCHEDULE"],
|
|
361
|
-
command: `${runtime.programName} schedules list${endpoint === undefined ? "" : ` --endpoint ${endpoint}`}${correlationCommand(args)}`,
|
|
447
|
+
command: `${runtime.programName} schedules list${endpoint === undefined ? "" : ` --endpoint ${endpoint}`}${correlationCommand(args)}${["--status", "--recurrence", "--text", "--due-before"].flatMap((flag) => (args.options[flag] === undefined ? [] : [` ${flag} '${args.options[flag].replaceAll("'", "'\"'\"'")}'`])).join("")}`,
|
|
362
448
|
// An empty filtered page answers the filter, not the project: "no
|
|
363
449
|
// schedules yet" read as the project being empty when nothing matched.
|
|
364
|
-
empty: Object.keys(filters).
|
|
450
|
+
empty: Object.keys(filters).some((key) => key !== "endpoint")
|
|
365
451
|
? "No schedule matches these filters."
|
|
366
452
|
: endpoint === undefined
|
|
367
453
|
? "This project has no schedules yet."
|
|
@@ -390,11 +476,19 @@ const schedulesList = async (runtime, args) => {
|
|
|
390
476
|
async function scheduleChange(runtime, args, options) {
|
|
391
477
|
const active = await session(runtime, args);
|
|
392
478
|
let confirmation;
|
|
479
|
+
let currentView;
|
|
393
480
|
if (options.confirm !== undefined) {
|
|
394
|
-
const
|
|
481
|
+
const answer = await active.client.requestDetailed("schedules_read", options.id);
|
|
482
|
+
const current = parseResponse("schedules.read", answer.body).schedule;
|
|
483
|
+
currentView = {
|
|
484
|
+
schedule: current,
|
|
485
|
+
...(answer.headers["ETag"] === undefined
|
|
486
|
+
? {}
|
|
487
|
+
: { etag: answer.headers["ETag"] }),
|
|
488
|
+
};
|
|
395
489
|
confirmation = await confirmChange(runtime, args, options.confirm(current));
|
|
396
490
|
}
|
|
397
|
-
const answer = await options.act(active, options.id);
|
|
491
|
+
const answer = await options.act(active, options.id, currentView);
|
|
398
492
|
const view = parseResponse(options.operation, answer.body);
|
|
399
493
|
const nextMove = options.nextMove?.(view.schedule);
|
|
400
494
|
return {
|
|
@@ -426,13 +520,50 @@ const schedulesReplace = async (runtime, args) => {
|
|
|
426
520
|
const [id] = positionals(args, ["<schedule>"], usage);
|
|
427
521
|
const request = await scheduleRequest(runtime, args, usage);
|
|
428
522
|
return scheduleChange(runtime, args, {
|
|
429
|
-
act: (active, scheduleId) =>
|
|
523
|
+
act: (active, scheduleId, current) => {
|
|
524
|
+
if (current === undefined)
|
|
525
|
+
throw new CliError("Replacement needs its schedule read", "internal");
|
|
526
|
+
const delivery = current.schedule.delivery;
|
|
527
|
+
const timeout = args.options["--delivery-timeout"];
|
|
528
|
+
if (timeout !== undefined &&
|
|
529
|
+
current.schedule.endpoint === undefined &&
|
|
530
|
+
request["endpoint"] === undefined &&
|
|
531
|
+
request["url"] === undefined)
|
|
532
|
+
throw new CliError("--delivery-timeout needs a callback destination", "usage", "Give --endpoint or --url when attaching a callback to a timer-only schedule.");
|
|
533
|
+
// The API replaces delivery settings with defaults when callback fields
|
|
534
|
+
// are supplied. Carry all stored settings, including completion/overlap.
|
|
535
|
+
// Naming the retained endpoint also makes a timeout-only change reach
|
|
536
|
+
// that callback replacement path. Fence the read against concurrent edits.
|
|
537
|
+
return active.client.requestDetailed("schedules_replace", scheduleId, {
|
|
538
|
+
...request,
|
|
539
|
+
...(delivery === undefined
|
|
540
|
+
? {}
|
|
541
|
+
: {
|
|
542
|
+
delivery: {
|
|
543
|
+
...delivery,
|
|
544
|
+
...(timeout === undefined
|
|
545
|
+
? {}
|
|
546
|
+
: { maxDurationSeconds: parseDuration(timeout) }),
|
|
547
|
+
},
|
|
548
|
+
}),
|
|
549
|
+
...(timeout !== undefined &&
|
|
550
|
+
request["endpoint"] === undefined &&
|
|
551
|
+
request["url"] === undefined &&
|
|
552
|
+
current.schedule.endpoint !== undefined
|
|
553
|
+
? { endpoint: current.schedule.endpoint }
|
|
554
|
+
: {}),
|
|
555
|
+
}, {
|
|
556
|
+
...(current.etag === undefined ? {} : { "If-Match": current.etag }),
|
|
557
|
+
...conditionalHeaders(args),
|
|
558
|
+
});
|
|
559
|
+
},
|
|
430
560
|
confirm: (current) => ({
|
|
431
561
|
action: `Replace the definition of schedule ${current.id}.`,
|
|
432
562
|
effect: [
|
|
433
563
|
`now ${recurrenceOf(current)}, ${current.status}, version ${String(current.version)}`,
|
|
434
|
-
`after the timing on this command line. Destination, data and
|
|
435
|
-
` settings are kept unless this command line gives new ones.`,
|
|
564
|
+
`after the timing on this command line. Destination, data, HTTP and`,
|
|
565
|
+
` delivery settings are kept unless this command line gives new ones.`,
|
|
566
|
+
`Latency class and lateness policy are re-derived unless supplied.`,
|
|
436
567
|
`Occurrences already planned under the old definition are discarded.`,
|
|
437
568
|
],
|
|
438
569
|
}),
|
|
@@ -1769,6 +1900,7 @@ function notificationPreferenceResult(settings) {
|
|
|
1769
1900
|
/* -------------------------------------------------------------------------- */
|
|
1770
1901
|
/** The operate-and-diagnose commands, keyed by the words that select them. */
|
|
1771
1902
|
export const OPERATE_COMMANDS = {
|
|
1903
|
+
...BULK_COMMANDS,
|
|
1772
1904
|
"notifications show": async (runtime, args) => {
|
|
1773
1905
|
const [project] = positionals(args, ["<project>"], `${runtime.programName} notifications show <project>`);
|
|
1774
1906
|
const active = await session(runtime, args);
|
|
@@ -1887,6 +2019,7 @@ export const OPERATE_COMMANDS = {
|
|
|
1887
2019
|
"schedules cancel": schedulesCancel,
|
|
1888
2020
|
"schedules list": schedulesList,
|
|
1889
2021
|
"executions list": executionsList,
|
|
2022
|
+
"attempts search": attemptsSearch,
|
|
1890
2023
|
"schedules pause": schedulesPause,
|
|
1891
2024
|
"schedules replace": schedulesReplace,
|
|
1892
2025
|
"schedules resume": schedulesResume,
|
package/package.json
CHANGED
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
"url": "https://github.com/flowxo/greatwait/issues"
|
|
8
8
|
},
|
|
9
9
|
"dependencies": {
|
|
10
|
-
"@greatwait/api-client": "0.
|
|
11
|
-
"@greatwait/contracts": "0.
|
|
10
|
+
"@greatwait/api-client": "0.3.0",
|
|
11
|
+
"@greatwait/contracts": "0.3.0"
|
|
12
12
|
},
|
|
13
13
|
"description": "The GreatWait command line: sign in, schedule a call to a URL, and read its delivery outcome over the public API.",
|
|
14
14
|
"engines": {
|
|
@@ -48,5 +48,5 @@
|
|
|
48
48
|
},
|
|
49
49
|
"sideEffects": false,
|
|
50
50
|
"type": "module",
|
|
51
|
-
"version": "0.
|
|
51
|
+
"version": "0.3.0"
|
|
52
52
|
}
|