@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 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 the schedules that call this destination. Its id starts ep_.",
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. Its destination, data and HTTP settings stay unless you give new ones.",
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 filters = correlationFilters(args);
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).length > 0
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 current = parseResponse("schedules.read", await active.client.request("schedules_read", options.id)).schedule;
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) => active.client.requestDetailed("schedules_replace", scheduleId, request, conditionalHeaders(args)),
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 HTTP`,
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.2.3",
11
- "@greatwait/contracts": "0.2.3"
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.2.3"
51
+ "version": "0.3.0"
52
52
  }