@allocator-one/harness-cli 1.14.0 → 1.15.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.
@@ -20,10 +20,12 @@ var UpgradeRequiredError = class extends HttpError {
20
20
  }
21
21
  };
22
22
  var TransportError = class extends Error {
23
- constructor(message) {
23
+ constructor(message, code = null) {
24
24
  super(message);
25
+ this.code = code;
25
26
  this.name = "TransportError";
26
27
  }
28
+ code;
27
29
  };
28
30
  var MalformedResponseError = class extends TransportError {
29
31
  constructor(message) {
@@ -56,6 +58,27 @@ var pkg = JSON.parse(
56
58
  readFileSync(new URL("../package.json", import.meta.url), "utf8")
57
59
  );
58
60
  var CLI_VERSION = pkg.version;
61
+ var CLI_PACKAGE = pkg.name;
62
+
63
+ // src/core/expert.ts
64
+ var EXPERT_TOKEN_PREFIX = "pxc_";
65
+ var EXPERT_ASSIGNMENTS_PATH = "/api/v1/expert/assignments";
66
+ function isExpertToken(token) {
67
+ return token.startsWith(EXPERT_TOKEN_PREFIX);
68
+ }
69
+ var EXPERT_REACH = "an outside expert's pxc_ credential only lists its own assignments and reads and ticks the gates it owns on them: assignments, conditions <issue> and gate satisfy|unsatisfy (assignment_list, condition_list and gate_set over MCP)";
70
+ var EXPERT_LAPSED = "the pxc_ credential is not valid here: it has expired (it lasts at most 30 days) or was revoked, its seat was revoked or has ended, the account is suspended, its TOTP enrolment changed, or it was minted for another host. If your seat is still open, mint a new one in your expert view";
71
+ var NOT_AN_EXPERT = "this credential is a member's login or an API token, not an outside expert's pxc_ credential";
72
+ function explainExpertRefusal(error, token) {
73
+ if (!(error instanceof HttpError) || error instanceof UpgradeRequiredError) return error;
74
+ const code = error.body["error"];
75
+ let hint;
76
+ if (error.status === 403 && code === "expert_bound_credential") hint = EXPERT_REACH;
77
+ else if (error.status === 403 && code === "expert_credential_required") hint = NOT_AN_EXPERT;
78
+ else if (error.status === 401 && isExpertToken(token)) hint = EXPERT_LAPSED;
79
+ if (hint === void 0) return error;
80
+ return new HttpError(error.status, `${error.message} \u2014 ${hint}`, error.body, error.retryAfter);
81
+ }
59
82
 
60
83
  // src/core/client.ts
61
84
  var RECEIVE_TIMEOUT_MS = 3e4;
@@ -89,10 +112,15 @@ var Client = class {
89
112
  async get(path2, params) {
90
113
  return this.json(await this.raw("GET", path2, { params }));
91
114
  }
115
+ /**
116
+ * `actor: false` keeps a configured actor out of the body, for endpoints
117
+ * whose body is closed: they refuse a field they do not name, and record
118
+ * the credential's own person instead.
119
+ */
92
120
  async post(path2, body = {}, options = {}) {
93
121
  return this.json(
94
122
  await this.raw("POST", path2, {
95
- json: this.withActor(body),
123
+ json: options.actor === false ? body : this.withActor(body),
96
124
  idempotencyKey: options.idempotencyKey
97
125
  })
98
126
  );
@@ -122,16 +150,18 @@ var Client = class {
122
150
  try {
123
151
  return new Uint8Array(await response.arrayBuffer());
124
152
  } catch (error) {
125
- throw new TransportError(transportMessage(error));
153
+ throw new TransportError(transportMessage(error), transportCode(error));
126
154
  }
127
155
  }
128
156
  /**
129
157
  * The server's API version, for `version`'s server side. The header rides
130
158
  * on every response — the versioning plug runs before auth — so even a
131
- * 401 answers.
159
+ * 401 answers. A pxc_ credential probes the one route it reaches, so the
160
+ * probe is never a refusal in the server's log.
132
161
  */
133
162
  async serverVersion() {
134
- const response = await this.raw("GET", "/api/v1/stats", {});
163
+ const path2 = isExpertToken(this.config.token) ? EXPERT_ASSIGNMENTS_PATH : "/api/v1/stats";
164
+ const response = await this.raw("GET", path2, {});
135
165
  return response.headers.get("x-harness-api-version") ?? "unknown";
136
166
  }
137
167
  // Only an explicitly configured actor is sent, and never over one the
@@ -168,7 +198,7 @@ var Client = class {
168
198
  redirect: "manual"
169
199
  });
170
200
  } catch (error) {
171
- throw new TransportError(transportMessage(error));
201
+ throw new TransportError(transportMessage(error), transportCode(error));
172
202
  }
173
203
  }
174
204
  async json(response) {
@@ -177,7 +207,7 @@ var Client = class {
177
207
  try {
178
208
  text = await response.text();
179
209
  } catch (error) {
180
- throw new TransportError(transportMessage(error));
210
+ throw new TransportError(transportMessage(error), transportCode(error));
181
211
  }
182
212
  if (text === "") return { data: void 0 };
183
213
  let parsed;
@@ -206,6 +236,17 @@ var Client = class {
206
236
  return new HttpError(response.status, message, parsed, response.headers.get("retry-after"));
207
237
  }
208
238
  };
239
+ function transportCode(error) {
240
+ const cause = error instanceof Error ? error.cause : void 0;
241
+ if (typeof cause !== "object" || cause === null) return null;
242
+ const code = cause.code;
243
+ if (typeof code === "string") return code;
244
+ const errors = cause.errors;
245
+ if (!Array.isArray(errors)) return null;
246
+ const codes = new Set(errors.map((inner) => inner?.code));
247
+ const [only] = codes;
248
+ return codes.size === 1 && typeof only === "string" ? only : null;
249
+ }
209
250
  function transportMessage(error) {
210
251
  if (error instanceof Error) {
211
252
  if (error.name === "TimeoutError" || error.name === "AbortError") return "request timed out";
@@ -302,6 +343,10 @@ function activityEntry(entry) {
302
343
  return activityStateEvent(entry);
303
344
  case "assignee_event":
304
345
  return activityAssigneeEvent(entry);
346
+ case "condition_event":
347
+ return activityConditionEvent(entry);
348
+ case "field_event":
349
+ return activityFieldEvent(entry);
305
350
  default:
306
351
  throw new CommandError(`Unknown activity entry kind: ${clean(entry["kind"])}`);
307
352
  }
@@ -314,7 +359,7 @@ function activityComment(entry) {
314
359
  if (typeof deleted !== "boolean") throw new CommandError("Invalid comment deleted flag.");
315
360
  const body = activityCommentBody(comment2["body"], deleted);
316
361
  const text = cleanMultiline(body).split("\n").map((line) => " " + line).join("\n");
317
- return `[${clean(requiredString(entry["occurred_at"], "activity occurrence time"))}] ${clean(author)} (${clean(requiredString(comment2["id"], "comment id"))}):
362
+ return `[${occurredAt(entry)}] ${clean(author)} (${clean(requiredString(comment2["id"], "comment id"))}):
318
363
  ${text}
319
364
  `;
320
365
  }
@@ -324,7 +369,7 @@ function activityCommentBody(value, deleted) {
324
369
  return requiredString(value, "comment body");
325
370
  }
326
371
  function activityStateEvent(entry) {
327
- const actor = activityActor(requiredRecord(entry["actor"], "activity actor"));
372
+ const actor = activityWho(entry);
328
373
  const provenance = activityProvenance(requiredRecord(entry["provenance"], "activity provenance"));
329
374
  let description;
330
375
  switch (entry["action"]) {
@@ -340,11 +385,11 @@ function activityStateEvent(entry) {
340
385
  default:
341
386
  throw new CommandError(`Unknown activity action: ${clean(entry["action"])}`);
342
387
  }
343
- return `[${clean(requiredString(entry["occurred_at"], "activity occurrence time"))}] ${description} \xB7 ${provenance}
388
+ return `[${occurredAt(entry)}] ${description} \xB7 ${provenance}
344
389
  `;
345
390
  }
346
391
  function activityAssigneeEvent(entry) {
347
- const actor = activityActor(requiredRecord(entry["actor"], "activity actor"));
392
+ const actor = activityWho(entry);
348
393
  const provenance = activityProvenance(requiredRecord(entry["provenance"], "activity provenance"));
349
394
  let description;
350
395
  switch (entry["action"]) {
@@ -360,9 +405,130 @@ function activityAssigneeEvent(entry) {
360
405
  default:
361
406
  throw new CommandError(`Unknown activity action: ${clean(entry["action"])}`);
362
407
  }
363
- return `[${clean(requiredString(entry["occurred_at"], "activity occurrence time"))}] ${description} - ${provenance}
408
+ return `[${occurredAt(entry)}] ${description} - ${provenance}
409
+ `;
410
+ }
411
+ function activityConditionEvent(entry) {
412
+ const actor = activityWho(entry);
413
+ const provenance = activityProvenance(requiredRecord(entry["provenance"], "activity provenance"));
414
+ const condition = conditionSubject(requiredRecord(entry["condition"], "activity condition"));
415
+ let description;
416
+ switch (entry["action"]) {
417
+ case "added":
418
+ description = `${actor} added ${condition}`;
419
+ break;
420
+ case "changed":
421
+ description = `${actor} changed ${condition}`;
422
+ break;
423
+ case "removed":
424
+ description = `${actor} removed ${condition}`;
425
+ break;
426
+ case "satisfied":
427
+ description = `${actor} satisfied ${condition}`;
428
+ break;
429
+ case "unsatisfied":
430
+ description = `${actor} marked ${condition} unsatisfied`;
431
+ break;
432
+ default:
433
+ throw new CommandError(`Unknown activity action: ${clean(entry["action"])}`);
434
+ }
435
+ return `[${occurredAt(entry)}] ${description} \xB7 ${provenance}
364
436
  `;
365
437
  }
438
+ function conditionSubject(condition) {
439
+ const label = clean(condition["label"]);
440
+ switch (condition["kind"]) {
441
+ case "gate":
442
+ return label === "" ? "the gate" : `the gate "${label}"`;
443
+ case "not_before": {
444
+ const at = clean(condition["not_before"]);
445
+ return at === "" ? "the not-before date" : `the not-before date ${at}`;
446
+ }
447
+ case "event":
448
+ return label === "" ? "the wait condition" : `the system event "${label}"`;
449
+ default:
450
+ return "the wait condition";
451
+ }
452
+ }
453
+ function activityFieldEvent(entry) {
454
+ const actor = activityWho(entry);
455
+ const provenance = activityProvenance(requiredRecord(entry["provenance"], "activity provenance"));
456
+ const change = fieldChange(requiredString(entry["field"], "activity field"), entry["from"], entry["to"]);
457
+ return `[${occurredAt(entry)}] ${actor} ${change} \xB7 ${provenance}
458
+ `;
459
+ }
460
+ function fieldChange(field3, from, to) {
461
+ switch (field3) {
462
+ case "priority":
463
+ return `changed priority from ${priorityName(from)} to ${priorityName(to)}`;
464
+ case "title":
465
+ return `renamed the issue from \u201C${activityTitle(from)}\u201D to \u201C${activityTitle(to)}\u201D`;
466
+ case "description":
467
+ return "edited the description";
468
+ case "labels":
469
+ return labelsChange(to);
470
+ case "parent":
471
+ return parentChange(from, to);
472
+ case "archived":
473
+ if (typeof to !== "boolean") throw new CommandError("Invalid activity archived value.");
474
+ return to ? "archived the issue" : "unarchived the issue";
475
+ default:
476
+ return `changed the ${clean(field3)} field`;
477
+ }
478
+ }
479
+ var ACTIVITY_PRIORITIES = {
480
+ 0: "No priority",
481
+ 1: "Urgent",
482
+ 2: "High",
483
+ 3: "Medium",
484
+ 4: "Low"
485
+ };
486
+ function priorityName(value) {
487
+ if (typeof value !== "number" || !Number.isInteger(value)) throw new CommandError("Invalid activity priority.");
488
+ return ACTIVITY_PRIORITIES[value] ?? String(value);
489
+ }
490
+ function activityTitle(value) {
491
+ if (typeof value !== "string") throw new CommandError("Invalid activity title.");
492
+ return clean(value);
493
+ }
494
+ function labelsChange(to) {
495
+ const change = requiredRecord(to, "activity labels");
496
+ const added = labelNames(change["added"]);
497
+ const removed = labelNames(change["removed"]);
498
+ const parts = [
499
+ ...added.length > 0 ? [`added ${labelList(added)}`] : [],
500
+ ...removed.length > 0 ? [`removed ${labelList(removed)}`] : []
501
+ ];
502
+ return parts.length === 0 ? "changed the labels" : parts.join(" and ");
503
+ }
504
+ function labelNames(value) {
505
+ if (!Array.isArray(value)) throw new CommandError("Invalid activity labels.");
506
+ return value.map((label) => clean(requiredString(requiredRecord(label, "activity labels")["name"], "activity labels")));
507
+ }
508
+ function labelList(names) {
509
+ return `${names.length === 1 ? "label" : "labels"} ${names.join(", ")}`;
510
+ }
511
+ function parentChange(from, to) {
512
+ if (from === null && to === null) return "changed the parent";
513
+ if (from === null) return `set the parent to ${parentName(to)}`;
514
+ if (to === null) return `removed the parent ${parentName(from)}`;
515
+ return `changed the parent from ${parentName(from)} to ${parentName(to)}`;
516
+ }
517
+ function parentName(value) {
518
+ const parent = requiredRecord(value, "activity parent");
519
+ return optionalLabel(parent["identifier"], "an unknown issue", "activity parent identifier");
520
+ }
521
+ function activityWho(entry) {
522
+ const actor = activityActor(requiredRecord(entry["actor"], "activity actor"));
523
+ const label = entry["actor_label"];
524
+ if (label === void 0 || label === null || label === "") return actor;
525
+ if (typeof label !== "string") throw new CommandError("Invalid activity actor label.");
526
+ const cleaned = clean(label);
527
+ return cleaned === "" ? actor : `${actor} via \u201C${cleaned}\u201D`;
528
+ }
529
+ function occurredAt(entry) {
530
+ return clean(requiredString(entry["occurred_at"], "activity occurrence time"));
531
+ }
366
532
  function assigneeName(value) {
367
533
  const assignee = requiredRecord(value, "activity assignee");
368
534
  if (assignee["kind"] === "unknown") return "Unknown assignee";
@@ -392,24 +558,24 @@ function activityProvenance(provenance) {
392
558
  throw new CommandError(`Unknown activity provenance: ${clean(provenance["kind"])}`);
393
559
  }
394
560
  }
395
- function stateName(value, field2) {
396
- return optionalLabel(requiredRecord(value, field2)["name"], "Unknown state", `${field2} name`);
561
+ function stateName(value, field3) {
562
+ return optionalLabel(requiredRecord(value, field3)["name"], "Unknown state", `${field3} name`);
397
563
  }
398
- function nullableStateName(value, field2) {
399
- return value === null ? "Unknown state" : stateName(value, field2);
564
+ function nullableStateName(value, field3) {
565
+ return value === null ? "Unknown state" : stateName(value, field3);
400
566
  }
401
- function optionalLabel(value, fallback, field2) {
567
+ function optionalLabel(value, fallback, field3) {
402
568
  if (value === null || value === "") return fallback;
403
- return clean(requiredString(value, field2));
569
+ return clean(requiredString(value, field3));
404
570
  }
405
- function requiredRecord(value, field2) {
571
+ function requiredRecord(value, field3) {
406
572
  if (typeof value !== "object" || value === null || Array.isArray(value)) {
407
- throw new CommandError(`Invalid ${field2}.`);
573
+ throw new CommandError(`Invalid ${field3}.`);
408
574
  }
409
575
  return value;
410
576
  }
411
- function requiredString(value, field2) {
412
- if (typeof value !== "string" || value === "") throw new CommandError(`Invalid ${field2}.`);
577
+ function requiredString(value, field3) {
578
+ if (typeof value !== "string" || value === "") throw new CommandError(`Invalid ${field3}.`);
413
579
  return value;
414
580
  }
415
581
  function stats(data) {
@@ -446,18 +612,25 @@ function waitingLine(issue) {
446
612
  return `yes, on ${open} of ${conditions.length} wait condition${conditions.length === 1 ? "" : "s"}`;
447
613
  }
448
614
  function conditionsSection(conditions) {
449
- const list2 = records(conditions);
450
- if (list2.length === 0) return "";
451
- return "\nWait conditions:\n" + list2.map((condition) => ` ${conditionLine(condition)}
615
+ const list3 = records(conditions);
616
+ if (list3.length === 0) return "";
617
+ return "\nWait conditions:\n" + list3.map((condition) => ` ${conditionLine(condition)}
452
618
  `).join("");
453
619
  }
454
- function conditionLine(condition) {
620
+ function conditionLine(condition, options = {}) {
455
621
  const satisfied = condition["satisfied"] === true;
456
622
  let what;
457
623
  switch (condition["kind"]) {
458
- case "gate":
459
- what = `gate "${clean(condition["label"])}"`;
624
+ case "gate": {
625
+ const owner = condition["owner"];
626
+ let ownedBy = "";
627
+ if (typeof owner === "object" && owner !== null) {
628
+ const record = owner;
629
+ ownedBy = options.expertView === true && record["kind"] === "expert" ? "you" : ownerText(record);
630
+ }
631
+ what = `gate ${gateName(condition)}${ownedBy === "" ? "" : ` owned by ${ownedBy}`}`;
460
632
  break;
633
+ }
461
634
  case "not_before":
462
635
  what = `not before ${clean(condition["not_before"])}`;
463
636
  break;
@@ -469,11 +642,78 @@ function conditionLine(condition) {
469
642
  }
470
643
  let when = "";
471
644
  if (satisfied && typeof condition["satisfied_at"] === "string") {
472
- const by = clean(path(condition, "satisfied_by", "name"));
473
- when = ` (satisfied ${clean(condition["satisfied_at"])}${by === "" ? "" : ` by ${by}`})`;
645
+ let by = clean(path(condition, "satisfied_by", "name"));
646
+ if (by === "" && condition["satisfied_by_kind"] === "expert") by = "an outside expert";
647
+ let role = typeof condition["satisfied_as_role"] === "string" ? roleName(condition["satisfied_as_role"]) : "";
648
+ switch (path(condition, "satisfied_by", "kind")) {
649
+ case "you":
650
+ by = "you";
651
+ break;
652
+ case "role": {
653
+ const as = path(condition, "satisfied_by", "role");
654
+ if (typeof as === "string" && as !== "") role = roleName(as);
655
+ break;
656
+ }
657
+ case "expert":
658
+ by = "an outside expert";
659
+ break;
660
+ case "member":
661
+ by = "a member";
662
+ break;
663
+ }
664
+ when = ` (satisfied ${clean(condition["satisfied_at"])}${by === "" ? "" : ` by ${by}`}${role === "" ? "" : ` as ${role}`})`;
474
665
  }
475
666
  return `${satisfied ? "[x]" : "[ ]"} ${what}${when} \xB7 ${clean(condition["id"])}`;
476
667
  }
668
+ function gateName(gate) {
669
+ const key = clean(gate["key"]);
670
+ return `"${clean(gate["label"])}"${key === "" ? "" : ` [${key}]`}`;
671
+ }
672
+ function ownerText(owner) {
673
+ const label = clean(owner["label"]) || (owner["kind"] === "role" && typeof owner["role"] === "string" ? roleName(owner["role"]) : "");
674
+ const ref = clean(owner["ref"]);
675
+ if (owner["kind"] === "expert") {
676
+ const email = label || ref.replace(/^expert:/, "");
677
+ return email === "" ? "" : `${email} (expert)`;
678
+ }
679
+ if (label === "") return ref;
680
+ return ref === "" || ref === label ? label : `${label} (${ref})`;
681
+ }
682
+ function expertSummary(seat) {
683
+ const role = typeof seat.role === "string" && seat.role !== "" ? roleName(seat.role) : "Outside expert";
684
+ const status = clean(seat.plan?.status);
685
+ const plan = seat.plan === null ? ", no plan" : ` on plan ${clean(seat.plan.id)}${status === "" ? "" : ` (${status})`}`;
686
+ const steps = seat.assigned_steps;
687
+ const count = steps === 0 ? "no assigned steps" : `${steps} assigned step${steps === 1 ? "" : "s"}`;
688
+ return `${role}${plan} \xB7 ${count}`;
689
+ }
690
+ function expertAssignments(steps) {
691
+ return steps.map((step) => "\n" + assignedStep(step)).join("");
692
+ }
693
+ function assignedStep(step) {
694
+ const blockers = typeof step["open_blockers"] === "number" ? step["open_blockers"] : 0;
695
+ const fields = [
696
+ ["State", path(step, "state", "name")],
697
+ ["Due", step["due_date"]],
698
+ ["Blocked", blockers > 0 ? `yes, by ${blockers} open issue${blockers === 1 ? "" : "s"}` : null]
699
+ ];
700
+ const fieldLines = fields.filter(([, value]) => value !== null && value !== void 0 && value !== "").map(([label, value]) => ` ${(label + ":").padEnd(12)}${clean(value)}
701
+ `).join("");
702
+ const gates = records(step["gates"]);
703
+ const gateLines = gates.length === 0 ? " Gates: none of yours\n" : " Gates:\n" + gates.map((gate) => ` ${conditionLine(gate, { expertView: true })}
704
+ `).join("");
705
+ const description = step["description"];
706
+ const descriptionLines = typeof description === "string" && description !== "" ? " Description:\n" + indent(indent(description)) + "\n" : "";
707
+ return `${clean(step["identifier"])} \xB7 ${clean(step["title"])}
708
+ ${fieldLines}${gateLines}${descriptionLines}`;
709
+ }
710
+ var ROLE_NAMES = { gp: "GP" };
711
+ function roleName(role) {
712
+ const slug = clean(role);
713
+ if (Object.hasOwn(ROLE_NAMES, slug)) return ROLE_NAMES[slug];
714
+ const words = slug.replace(/_/g, " ");
715
+ return words.charAt(0).toUpperCase() + words.slice(1);
716
+ }
477
717
  var LIVE_PLAN_STATUSES = ["released", "running", "needs_attention"];
478
718
  function planDetail(plan) {
479
719
  const steps = records(plan["steps"]);
@@ -483,10 +723,10 @@ function planDetail(plan) {
483
723
  ["Budgets", planBudgetsLine(plan)],
484
724
  ["Estimate", planEstimateLine(plan["estimate"])]
485
725
  ];
486
- const fieldLines = fields.filter((field2) => field2[1] !== null).map(([label, value]) => ` ${(label + ":").padEnd(12)}${value}
726
+ const fieldLines = fields.filter((field3) => field3[1] !== null).map(([label, value]) => ` ${(label + ":").padEnd(12)}${value}
487
727
  `).join("");
488
728
  return `Plan ${clean(plan["id"])} \xB7 ${clean(plan["status"])}
489
- ` + fieldLines + planStepsSection(steps, str(plan["status"])) + planEdgesSection(plan["edges"]) + planConditionsSection(steps) + planHeldSection(plan["held_steps"]);
729
+ ` + fieldLines + planStepsSection(steps, str(plan["status"])) + planEdgesSection(plan["edges"]) + planConditionsSection(steps) + planWaitingSection(steps) + planHeldSection(plan["held_steps"]);
490
730
  }
491
731
  function planRootLine(root) {
492
732
  if (typeof root !== "object" || root === null) return null;
@@ -560,9 +800,9 @@ function stepStatus(step, planStatus) {
560
800
  return "-";
561
801
  }
562
802
  function planEdgesSection(edges) {
563
- const list2 = records(edges);
564
- if (list2.length === 0) return "";
565
- return "\nEdges (blocker \u2192 blocked):\n" + list2.map((edge) => ` ${clean(edge["from"])} \u2192 ${clean(edge["to"])}
803
+ const list3 = records(edges);
804
+ if (list3.length === 0) return "";
805
+ return "\nEdges (blocker \u2192 blocked):\n" + list3.map((edge) => ` ${clean(edge["from"])} \u2192 ${clean(edge["to"])}
566
806
  `).join("");
567
807
  }
568
808
  function planConditionsSection(steps) {
@@ -574,6 +814,32 @@ function planConditionsSection(steps) {
574
814
  return "\nWait conditions:\n" + lines.map(([identifier, line]) => ` ${padGraphemes(identifier, width)} ${line}
575
815
  `).join("");
576
816
  }
817
+ function planWaitingSection(steps) {
818
+ const lines = steps.flatMap(
819
+ (step) => records(step["waiting_for"]).map((wait) => [clean(step["identifier"]), waitLine(wait)])
820
+ );
821
+ if (lines.length === 0) return "";
822
+ const width = Math.max(...lines.map(([identifier]) => graphemeLength(identifier)));
823
+ return "\nWaiting for:\n" + lines.map(([identifier, line]) => ` ${padGraphemes(identifier, width)} ${line}
824
+ `).join("");
825
+ }
826
+ function waitLine(wait) {
827
+ const name = gateName(wait);
828
+ const owner = wait["owner"];
829
+ if (typeof owner !== "object" || owner === null) return `${name} \xB7 no owner, anyone with standing can tick`;
830
+ const holders = wait["holders"];
831
+ let who;
832
+ if (!Array.isArray(holders)) who = "who can tick is not known";
833
+ else if (holders.length === 0) who = "nobody can tick";
834
+ else who = `can tick: ${records(holders).map(holderText).join(", ")}`;
835
+ return `${name} \xB7 ${ownerText(owner)} \xB7 ${who}`;
836
+ }
837
+ function holderText(holder) {
838
+ const name = clean(holder["name"]);
839
+ if (holder["kind"] !== "expert") return name;
840
+ const organization = clean(holder["organization"]);
841
+ return `${name} (expert${organization === "" ? "" : `, ${organization}`})`;
842
+ }
577
843
  function planHeldSection(heldSteps) {
578
844
  if (!Array.isArray(heldSteps) || heldSteps.length === 0) return "";
579
845
  return `
@@ -588,10 +854,10 @@ function errorDetails(body) {
588
854
  }
589
855
  function changesetDetails(details) {
590
856
  if (typeof details !== "object" || details === null || Array.isArray(details)) return "";
591
- return Object.entries(details).map(([field2, messages]) => {
857
+ return Object.entries(details).map(([field3, messages]) => {
592
858
  const text = Array.isArray(messages) ? messages.map(String).join("; ") : String(messages);
593
859
  return `
594
- ${clean(field2)}: ${clean(text)}`;
860
+ ${clean(field3)}: ${clean(text)}`;
595
861
  }).join("");
596
862
  }
597
863
  function violationDetails(violations) {
@@ -716,7 +982,7 @@ var SOURCES = [
716
982
  var MAX_IMAGES_PER_SHOW = 20;
717
983
  var MAX_ATTACHMENT_BYTES = 25e6;
718
984
  function refs(issue) {
719
- const texts = SOURCES.map(([field2, source]) => [issue[field2], source]);
985
+ const texts = SOURCES.map(([field3, source]) => [issue[field3], source]);
720
986
  const comments2 = Array.isArray(issue["comments"]) ? issue["comments"] : [];
721
987
  for (const comment2 of comments2) texts.push([comment2["body"], "comment"]);
722
988
  const found = [];
@@ -829,8 +1095,8 @@ function write(path2, bytes) {
829
1095
  }
830
1096
 
831
1097
  // src/core/pagination.ts
832
- function notices(meta, requestedPageSize, records2) {
833
- return capNotice(meta, requestedPageSize) + truncationNotice(meta, records2);
1098
+ function notices(meta, requestedPageSize, records5) {
1099
+ return capNotice(meta, requestedPageSize) + truncationNotice(meta, records5);
834
1100
  }
835
1101
  function capNotice(meta, requestedPageSize) {
836
1102
  const served = meta?.page_size;
@@ -840,12 +1106,12 @@ function capNotice(meta, requestedPageSize) {
840
1106
  }
841
1107
  return "";
842
1108
  }
843
- function truncationNotice(meta, records2) {
1109
+ function truncationNotice(meta, records5) {
844
1110
  const total = meta?.total;
845
1111
  const page = meta?.page;
846
1112
  const pages = meta?.total_pages;
847
1113
  if (typeof total !== "number" || typeof page !== "number" || typeof pages !== "number") return "";
848
- const shown = records2.length;
1114
+ const shown = records5.length;
849
1115
  if (shown < total) {
850
1116
  return `harness: showing ${shown} of ${total} (page ${page} of ${pages}) \u2014 pass --all for the rest, or --page to step through.
851
1117
  `;
@@ -856,20 +1122,20 @@ async function fetchEveryPage(fetchPage) {
856
1122
  const first = await fetchPage(1);
857
1123
  const totalPages = pageCount(first.meta);
858
1124
  if (totalPages === null) return first.data;
859
- let records2 = first.data;
1125
+ let records5 = first.data;
860
1126
  for (let page = 2; page <= totalPages; page++) {
861
1127
  const body = await fetchPage(page);
862
- records2 = dedupe([...records2, ...body.data]);
1128
+ records5 = dedupe([...records5, ...body.data]);
863
1129
  }
864
- return records2;
1130
+ return records5;
865
1131
  }
866
1132
  function pageCount(meta) {
867
1133
  const total = meta?.total_pages;
868
1134
  return typeof total === "number" && total > 0 ? total : null;
869
1135
  }
870
- function dedupe(records2) {
1136
+ function dedupe(records5) {
871
1137
  const seen = /* @__PURE__ */ new Set();
872
- return records2.filter((record) => {
1138
+ return records5.filter((record) => {
873
1139
  const id = record["id"];
874
1140
  const key = id === void 0 || id === null ? `struct:${stableKey(record)}` : `id:${stableKey(id)}`;
875
1141
  if (seen.has(key)) return false;
@@ -908,9 +1174,9 @@ function requireIdentifiers(identifiers) {
908
1174
  async function ready(ctx, opts) {
909
1175
  if (opts.claim === true) return claimNext(ctx, opts);
910
1176
  const params = { ...listParams(ctx, opts), ready: true };
911
- const { records: records2, notice } = await fetchIssues(ctx, params, opts);
1177
+ const { records: records5, notice } = await fetchIssues(ctx, params, opts);
912
1178
  const rank = (issue) => PRIORITY_RANK[issue["priority"]] ?? 4;
913
- const sorted = markAgentSteps([...records2].sort((a, b) => rank(a) - rank(b)));
1179
+ const sorted = markAgentSteps([...records5].sort((a, b) => rank(a) - rank(b)));
914
1180
  return { json: sorted, human: issuesTable(sorted, { markAgentSteps: true }), notice };
915
1181
  }
916
1182
  function markAgentSteps(issues) {
@@ -965,13 +1231,13 @@ function isIssueRecord(value) {
965
1231
  }
966
1232
  async function blocked(ctx, opts) {
967
1233
  const params = { ...listParams(ctx, opts), blocked: true };
968
- const { records: records2, notice } = await fetchIssues(ctx, params, opts);
969
- return { json: records2, human: issuesTable(records2), notice };
1234
+ const { records: records5, notice } = await fetchIssues(ctx, params, opts);
1235
+ return { json: records5, human: issuesTable(records5), notice };
970
1236
  }
971
1237
  async function list(ctx, opts) {
972
1238
  const params = listParams(ctx, opts);
973
- const { records: records2, notice } = await fetchIssues(ctx, params, opts);
974
- return { json: records2, human: issuesTable(records2), notice };
1239
+ const { records: records5, notice } = await fetchIssues(ctx, params, opts);
1240
+ return { json: records5, human: issuesTable(records5), notice };
975
1241
  }
976
1242
  async function search(ctx, queryWords, opts) {
977
1243
  if (queryWords.length === 0) throw new UsageError("usage: harness search <query>");
@@ -1002,9 +1268,14 @@ async function show(ctx, identifier, opts) {
1002
1268
  return notice === "" ? { json: json2, human } : { json: json2, human, notice };
1003
1269
  }
1004
1270
  var ACTIVITY_MAX_LIMIT = 100;
1271
+ var ACTIVITY_INCLUDE = "condition_events,field_events";
1272
+ var ACTIVITY_INCLUDE_WITHOUT_FIELDS = "condition_events";
1273
+ var ACTIVITY_WITHOUT_FIELDS_NOTICE = "harness: this server doesn't record field changes yet; showing activity without them.\n";
1005
1274
  async function activity(ctx, identifier, opts) {
1006
1275
  requireIdentifiers([identifier]);
1007
- let page = await activityPage(ctx, identifier, opts.limit);
1276
+ const first = await firstActivityPage(ctx, identifier, opts.limit);
1277
+ const include = first.include;
1278
+ let { page, notice } = first;
1008
1279
  let entries = page.entries;
1009
1280
  if (opts.all === true) {
1010
1281
  const pages = [page.entries];
@@ -1015,17 +1286,38 @@ async function activity(ctx, identifier, opts) {
1015
1286
  throw new CommandError("Activity response repeated a cursor.");
1016
1287
  }
1017
1288
  seenCursors.add(cursor);
1018
- page = await activityPage(ctx, identifier, opts.limit, cursor);
1289
+ page = await activityPage(ctx, identifier, include, opts.limit, cursor);
1019
1290
  pages.push(page.entries);
1020
1291
  }
1021
1292
  entries = pages.reverse().flat();
1022
1293
  }
1023
- const notice = opts.all !== true && page.hasMore ? `harness: showing the newest ${entries.length} activity entries \u2014 pass --all for older activity.
1024
- ` : void 0;
1025
- return { json: entries, human: activityEntries(entries), notice };
1294
+ if (opts.all !== true && page.hasMore) {
1295
+ notice += `harness: showing the newest ${entries.length} activity entries \u2014 pass --all for older activity.
1296
+ `;
1297
+ }
1298
+ return { json: entries, human: activityEntries(entries), notice: notice === "" ? void 0 : notice };
1026
1299
  }
1027
- async function activityPage(ctx, identifier, limit, before) {
1028
- const body = await ctx.client.get(`/api/v1/issues/${identifier}/activity`, { limit, before });
1300
+ async function firstActivityPage(ctx, identifier, limit) {
1301
+ try {
1302
+ const page2 = await activityPage(ctx, identifier, ACTIVITY_INCLUDE, limit);
1303
+ return { page: page2, include: ACTIVITY_INCLUDE, notice: "" };
1304
+ } catch (error) {
1305
+ if (!refusesInclude(error)) throw error;
1306
+ }
1307
+ const page = await activityPage(ctx, identifier, ACTIVITY_INCLUDE_WITHOUT_FIELDS, limit);
1308
+ return { page, include: ACTIVITY_INCLUDE_WITHOUT_FIELDS, notice: ACTIVITY_WITHOUT_FIELDS_NOTICE };
1309
+ }
1310
+ function refusesInclude(error) {
1311
+ if (!(error instanceof HttpError) || error.status !== 400) return false;
1312
+ const message = error.body["message"];
1313
+ return typeof message === "string" && /\binclude\b/.test(message) && /\b(condition_events|field_events)\b|\baccepts only\b/.test(message);
1314
+ }
1315
+ async function activityPage(ctx, identifier, include, limit, before) {
1316
+ const body = await ctx.client.get(`/api/v1/issues/${identifier}/activity`, {
1317
+ limit,
1318
+ before,
1319
+ include
1320
+ });
1029
1321
  if (!Array.isArray(body.data)) throw new CommandError("Activity response data is not an array.");
1030
1322
  if (body.data.some((entry) => typeof entry !== "object" || entry === null || Array.isArray(entry))) {
1031
1323
  throw new CommandError("Activity response contains a non-object entry.");
@@ -1243,7 +1535,7 @@ async function close(ctx, identifiers, opts) {
1243
1535
  results.push({ identifier, ok: true, response });
1244
1536
  } catch (error) {
1245
1537
  if (error instanceof HttpError || error instanceof TransportError) {
1246
- results.push({ identifier, ok: false, error });
1538
+ results.push({ identifier, ok: false, error: explainExpertRefusal(error, ctx.config.token) });
1247
1539
  } else {
1248
1540
  throw error;
1249
1541
  }
@@ -1293,7 +1585,7 @@ async function archive(ctx, identifiers, opts) {
1293
1585
  results.push({ identifier, ok: true, issue: body.data });
1294
1586
  } catch (error) {
1295
1587
  if (error instanceof HttpError || error instanceof TransportError) {
1296
- results.push({ identifier, ok: false, error });
1588
+ results.push({ identifier, ok: false, error: explainExpertRefusal(error, ctx.config.token) });
1297
1589
  } else {
1298
1590
  throw error;
1299
1591
  }
@@ -1674,15 +1966,15 @@ async function fetchIssues(ctx, params, opts) {
1674
1966
  throw new UsageError("--all reads every page from the first \u2014 pass --all or --page, not both");
1675
1967
  }
1676
1968
  if (opts.all === true) {
1677
- const records3 = await fetchEveryPage(
1969
+ const records6 = await fetchEveryPage(
1678
1970
  (page) => ctx.client.get("/api/v1/issues", { ...params, page })
1679
1971
  );
1680
- return { records: records3, notice: "" };
1972
+ return { records: records6, notice: "" };
1681
1973
  }
1682
1974
  const body = await ctx.client.get("/api/v1/issues", params);
1683
- const records2 = body.data;
1975
+ const records5 = body.data;
1684
1976
  const requested = typeof params["page_size"] === "number" ? params["page_size"] : void 0;
1685
- return { records: records2, notice: notices(body.meta, requested, records2) };
1977
+ return { records: records5, notice: notices(body.meta, requested, records5) };
1686
1978
  }
1687
1979
  function listParams(ctx, opts) {
1688
1980
  if (opts.type !== void 0 && opts.label !== void 0) {
@@ -1755,8 +2047,8 @@ function withTeam(params, ctx, opts) {
1755
2047
  function putParam(params, key, value) {
1756
2048
  return value === void 0 ? params : { ...params, [key]: value };
1757
2049
  }
1758
- function putOpt(body, field2, value) {
1759
- return value === void 0 ? body : { ...body, [field2]: value };
2050
+ function putOpt(body, field3, value) {
2051
+ return value === void 0 ? body : { ...body, [field3]: value };
1760
2052
  }
1761
2053
  function putPresent(body, key, value) {
1762
2054
  if (value === null || value === void 0 || value === false) return body;
@@ -1808,8 +2100,8 @@ async function paginatedRead(ctx, path2, params, opts) {
1808
2100
  throw new UsageError("--all reads every page from the first \u2014 pass --all or --page, not both");
1809
2101
  }
1810
2102
  if (opts.all === true) {
1811
- const records2 = await fetchEveryPage((page) => ctx.client.get(path2, { ...params, page }));
1812
- return { records: records2, notice: "" };
2103
+ const records5 = await fetchEveryPage((page) => ctx.client.get(path2, { ...params, page }));
2104
+ return { records: records5, notice: "" };
1813
2105
  }
1814
2106
  const body = await ctx.client.get(path2, params);
1815
2107
  const requested = typeof params["page_size"] === "number" ? params["page_size"] : void 0;
@@ -1819,10 +2111,10 @@ async function projectList(ctx, opts) {
1819
2111
  let params = { page_size: opts.limit ?? 50, page: opts.page ?? 1 };
1820
2112
  if (opts.search !== void 0) params = { ...params, search: opts.search };
1821
2113
  if (opts.state !== void 0) params = { ...params, state: opts.state };
1822
- const { records: records2, notice } = await paginatedRead(ctx, "/api/v1/projects", params, opts);
1823
- const human = records2.length === 0 ? "No projects.\n" : table(
2114
+ const { records: records5, notice } = await paginatedRead(ctx, "/api/v1/projects", params, opts);
2115
+ const human = records5.length === 0 ? "No projects.\n" : table(
1824
2116
  ["ID", "STATE", "LEAD", "TARGET", "NAME"],
1825
- records2.map((project) => [
2117
+ records5.map((project) => [
1826
2118
  str3(project["id"]),
1827
2119
  str3(project["state"]) || "-",
1828
2120
  str3(field(project, "lead", "name")) || "-",
@@ -1830,7 +2122,7 @@ async function projectList(ctx, opts) {
1830
2122
  str3(project["name"])
1831
2123
  ])
1832
2124
  );
1833
- return { json: records2, human, notice };
2125
+ return { json: records5, human, notice };
1834
2126
  }
1835
2127
  async function projectShow(ctx, id) {
1836
2128
  requireSafeId("project", id);
@@ -1921,12 +2213,12 @@ async function milestoneCreate(ctx, opts) {
1921
2213
  async function docList(ctx, opts) {
1922
2214
  let params = { page_size: opts.limit ?? 50, page: opts.page ?? 1 };
1923
2215
  if (opts.search !== void 0) params = { ...params, search: opts.search };
1924
- const { records: records2, notice } = await paginatedRead(ctx, "/api/v1/documents", params, opts);
1925
- const human = records2.length === 0 ? "No documents.\n" : table(
2216
+ const { records: records5, notice } = await paginatedRead(ctx, "/api/v1/documents", params, opts);
2217
+ const human = records5.length === 0 ? "No documents.\n" : table(
1926
2218
  ["ID", "UPDATED", "TITLE"],
1927
- records2.map((doc) => [str3(doc["id"]), str3(doc["updated_at"]) || "-", str3(doc["title"])])
2219
+ records5.map((doc) => [str3(doc["id"]), str3(doc["updated_at"]) || "-", str3(doc["title"])])
1928
2220
  );
1929
- return { json: records2, human, notice };
2221
+ return { json: records5, human, notice };
1930
2222
  }
1931
2223
  async function docShow(ctx, id) {
1932
2224
  requireSafeId("document", id);
@@ -2012,7 +2304,7 @@ async function linkRm(ctx, issue, idOrUrl) {
2012
2304
  return { json: { deleted: link["id"] }, human: `Removed ${clean(link["url"])} from ${issue}
2013
2305
  ` };
2014
2306
  }
2015
- async function labelList(ctx) {
2307
+ async function labelList2(ctx) {
2016
2308
  const body = await ctx.client.get("/api/v1/labels");
2017
2309
  const human = body.data.length === 0 ? "No labels.\n" : table(
2018
2310
  ["NAME", "COLOR", "ISSUES", "ID"],
@@ -2215,6 +2507,1112 @@ function indent2(text) {
2215
2507
  return cleanMultiline(text).split("\n").map((line) => " " + line).join("\n");
2216
2508
  }
2217
2509
 
2510
+ // src/commands/operator-catalog.ts
2511
+ var SCHEMA_VIEWS = ["input", "result"];
2512
+ var MAX_QUERY_BYTES = 200;
2513
+ var MAX_CATALOG_PAGES = 200;
2514
+ var MAX_SCHEMA_PAGES = 1e3;
2515
+ var SCHEMA_ENCODING = "json_fragments_v1";
2516
+ var CAPABILITY_ID_FORMAT = /^[A-Za-z0-9_.:-]{1,200}$/;
2517
+ var APPROVAL_NOTE = "Write and send capabilities, and any proposal, need a person's approval in the app before they take effect.\n";
2518
+ var REFUSALS = {
2519
+ operator_disabled: "nothing here works until it is switched back on",
2520
+ operator_not_permitted: "this account has no admin or member role in the organization the credential is bound to",
2521
+ actor_required: "this credential acts as no person: sign in with `harness login`, or use an API token minted for a user",
2522
+ run_bound_credential: "a run-bound credential can't read Operator's catalog",
2523
+ forbidden: "Operator needs the operator:read scope (aone_ API tokens must be minted with it) and an admin or member role"
2524
+ };
2525
+ async function toolsList(ctx, opts) {
2526
+ requireToolsOpts(opts);
2527
+ const { items, meta } = await capabilityPages(ctx, {}, opts);
2528
+ return {
2529
+ json: items,
2530
+ human: items.length === 0 ? "Operator can use no capabilities here.\n" : capabilitiesTable(items, false),
2531
+ notice: pagingNotice(items, meta, opts, "capabilities")
2532
+ };
2533
+ }
2534
+ async function toolsSearch(ctx, query, opts) {
2535
+ requireSearchQuery(query);
2536
+ requireToolsOpts(opts);
2537
+ const { items, meta } = await capabilityPages(ctx, { q: query }, opts);
2538
+ return {
2539
+ json: items,
2540
+ human: items.length === 0 ? `No capability Operator can use matches "${clean(query)}".
2541
+ ` : capabilitiesTable(items, true),
2542
+ notice: pagingNotice(items, meta, opts, "matches")
2543
+ };
2544
+ }
2545
+ async function toolsDomains(ctx) {
2546
+ const body = await operatorRequest(() => ctx.client.get("/api/v1/operator/capabilities/domains"));
2547
+ const domains = records2(body.data, "domains");
2548
+ return {
2549
+ json: domains,
2550
+ human: domains.length === 0 ? "Operator can use no capabilities here.\n" : table(
2551
+ ["DOMAIN", "CAPABILITIES"],
2552
+ domains.map((domain) => [str4(domain["domain"]), str4(domain["count"])])
2553
+ )
2554
+ };
2555
+ }
2556
+ async function toolShow(ctx, id, opts) {
2557
+ requireCapabilityId(id);
2558
+ const view = requireSchemaView(opts.schema);
2559
+ const fragments = [];
2560
+ let capability = {};
2561
+ let fragmentCount = null;
2562
+ for (let page = 1; ; page++) {
2563
+ const body = await operatorRequest(
2564
+ () => ctx.client.get(`/api/v1/operator/capabilities/${id}`, { schema: view, page }),
2565
+ `Operator can't use a capability ${id} here: it doesn't exist, or it is disabled or switched off`
2566
+ );
2567
+ const data = body.data;
2568
+ if (!isRecord(data)) throw new CommandError("The capability response carried no capability.");
2569
+ if (data["id"] !== id || data["schema"] !== view || data["page"] !== page) {
2570
+ throw new CommandError(
2571
+ `The capability response is not page ${page} of ${id}'s ${view} schema (it says page ${clean(data["page"])} of ${clean(data["id"])}'s ${clean(data["schema"])} schema).`
2572
+ );
2573
+ }
2574
+ if (data["schema_encoding"] !== SCHEMA_ENCODING) {
2575
+ throw new CommandError(
2576
+ `The capability's schema comes in an encoding this CLI does not read (${clean(data["schema_encoding"])}) \u2014 upgrade the CLI.`
2577
+ );
2578
+ }
2579
+ const count = data["fragment_count"];
2580
+ if (typeof count !== "number" || !Number.isInteger(count) || count < 1) {
2581
+ throw new CommandError(`The capability's schema page ${page} carries no valid fragment count.`);
2582
+ }
2583
+ if (fragmentCount !== null && count !== fragmentCount) {
2584
+ throw new CommandError(
2585
+ `The capability's schema pages disagree on the fragment count (${fragmentCount}, then ${count} on page ${page}).`
2586
+ );
2587
+ }
2588
+ fragmentCount = count;
2589
+ const pageFragments = data["fragments"];
2590
+ if (!Array.isArray(pageFragments) || !pageFragments.every((fragment) => typeof fragment === "string")) {
2591
+ throw new CommandError(`The capability's schema page ${page} carried no fragments.`);
2592
+ }
2593
+ fragments.push(...pageFragments);
2594
+ capability = data;
2595
+ const next = data["next_page"];
2596
+ if (next === null || next === void 0) break;
2597
+ if (next !== page + 1 || next > MAX_SCHEMA_PAGES) {
2598
+ throw new CommandError(`The capability's schema page ${page} names an invalid next page (${clean(next)}).`);
2599
+ }
2600
+ }
2601
+ if (fragments.length !== fragmentCount) {
2602
+ throw new CommandError(
2603
+ `The capability's schema came back incomplete: ${fragments.length} of ${fragmentCount} fragments.`
2604
+ );
2605
+ }
2606
+ let schema;
2607
+ try {
2608
+ schema = JSON.parse(fragments.join(""));
2609
+ } catch {
2610
+ throw new CommandError("The capability's schema fragments do not join into valid JSON.");
2611
+ }
2612
+ const shown = { ...withoutSchemaPaging(capability), [`${view}_schema`]: schema };
2613
+ return { json: shown, human: capabilityDetail(shown, view, schema) };
2614
+ }
2615
+ async function skillsList(ctx) {
2616
+ const body = await operatorRequest(() => ctx.client.get("/api/v1/operator/skills"));
2617
+ const skills = records2(body.data, "skills").map((skill) => ({
2618
+ id: skill["id"],
2619
+ title: skill["title"],
2620
+ invocation_name: skill["invocation_name"],
2621
+ content_sha256: skill["content_sha256"]
2622
+ }));
2623
+ return {
2624
+ json: skills,
2625
+ human: skills.length === 0 ? "No skills are assigned to Operator in this organization.\n" : table(
2626
+ ["INVOCATION", "TITLE", "SHA-256", "ID"],
2627
+ skills.map((skill) => [
2628
+ str4(skill.invocation_name),
2629
+ truncate(skill.title),
2630
+ str4(skill.content_sha256),
2631
+ str4(skill.id)
2632
+ ])
2633
+ )
2634
+ };
2635
+ }
2636
+ function requireCapabilityId(id) {
2637
+ if (!CAPABILITY_ID_FORMAT.test(id) || /^\.+$/.test(id)) {
2638
+ throw new UsageError(`not a capability id: ${clean(id)} (e.g. crm.party.search)`);
2639
+ }
2640
+ if (id === "domains") {
2641
+ throw new UsageError("domains is not a capability id \u2014 `harness tools domains` lists the domains");
2642
+ }
2643
+ }
2644
+ function requireSearchQuery(query) {
2645
+ if (query.trim() === "") throw new UsageError("the search query is empty \u2014 say what to search for");
2646
+ if (Buffer.byteLength(query, "utf8") > MAX_QUERY_BYTES) {
2647
+ throw new UsageError(`the search query is over ${MAX_QUERY_BYTES} bytes \u2014 shorten it`);
2648
+ }
2649
+ }
2650
+ function requireToolsOpts(opts) {
2651
+ if (opts.domain !== void 0 && opts.domain.trim() === "") {
2652
+ throw new UsageError("the domain is empty \u2014 name one, or leave it out for every domain");
2653
+ }
2654
+ if (opts.cursor !== void 0 && opts.cursor.trim() === "") {
2655
+ throw new UsageError("the cursor is empty \u2014 pass a page's cursor, or leave it out for the first page");
2656
+ }
2657
+ }
2658
+ function requireSchemaView(view) {
2659
+ if (view === void 0) return "input";
2660
+ if (SCHEMA_VIEWS.includes(view)) return view;
2661
+ throw new UsageError(`--schema must be input or result, not ${clean(view)}`);
2662
+ }
2663
+ async function capabilityPages(ctx, base, opts) {
2664
+ const params = { ...base, domain: opts.domain };
2665
+ let page = await capabilityPage(ctx, { ...params, cursor: opts.cursor }, CALLER_CURSOR);
2666
+ const items = [...page.items];
2667
+ if (opts.all === true) {
2668
+ const seen = new Set(opts.cursor === void 0 ? [] : [opts.cursor]);
2669
+ let pages = 1;
2670
+ while (page.nextCursor !== null) {
2671
+ if (seen.has(page.nextCursor)) throw new CommandError("The capabilities response repeated a cursor.");
2672
+ if (pages >= MAX_CATALOG_PAGES) {
2673
+ throw new CommandError(`The capabilities listing ran past ${MAX_CATALOG_PAGES} pages; stopped there.`);
2674
+ }
2675
+ seen.add(page.nextCursor);
2676
+ page = await capabilityPage(ctx, { ...params, cursor: page.nextCursor }, WALK_CURSOR);
2677
+ pages++;
2678
+ items.push(...page.items);
2679
+ }
2680
+ }
2681
+ return { items: dedupeById(items), meta: page.meta };
2682
+ }
2683
+ var CALLER_CURSOR = "start again without the cursor";
2684
+ var WALK_CURSOR = "the listing changed or its cursor expired while following pages; run it again";
2685
+ async function capabilityPage(ctx, params, staleCursor) {
2686
+ const body = await operatorRequest(
2687
+ () => ctx.client.get("/api/v1/operator/capabilities", params),
2688
+ void 0,
2689
+ staleCursor
2690
+ );
2691
+ const items = records2(body.data, "capabilities");
2692
+ const meta = body.meta;
2693
+ if (!isRecord(meta)) throw new CommandError("The capabilities response has no pagination metadata.");
2694
+ const nextCursor = meta.next_cursor;
2695
+ if (nextCursor !== null && (typeof nextCursor !== "string" || nextCursor === "")) {
2696
+ throw new CommandError("The capabilities response has an invalid next_cursor.");
2697
+ }
2698
+ if (nextCursor !== null && items.length === 0) {
2699
+ throw new CommandError("The capabilities response says there are more pages but holds no capabilities.");
2700
+ }
2701
+ return { items, meta, nextCursor };
2702
+ }
2703
+ async function operatorRequest(send, notFound2 = "this Harness server has no Operator API yet", staleCursor = CALLER_CURSOR) {
2704
+ try {
2705
+ return await send();
2706
+ } catch (error) {
2707
+ throw explain(error, notFound2, staleCursor);
2708
+ }
2709
+ }
2710
+ function explain(error, notFound2, staleCursor) {
2711
+ if (!(error instanceof HttpError)) return error;
2712
+ const code = typeof error.body["error"] === "string" ? error.body["error"] : "";
2713
+ let hint;
2714
+ if (error.status === 403) hint = Object.hasOwn(REFUSALS, code) ? REFUSALS[code] : void 0;
2715
+ else if (error.status === 404) hint = notFound2;
2716
+ else if (error.status === 400 && code === "invalid_cursor") hint = staleCursor;
2717
+ else if (error.status === 400 && /\bdomain\b/.test(error.message)) {
2718
+ hint = "no such domain; listing without one shows each capability's domain";
2719
+ }
2720
+ if (hint === void 0) return error;
2721
+ return new HttpError(error.status, `${error.message} \u2014 ${hint}`, error.body, error.retryAfter);
2722
+ }
2723
+ function capabilitiesTable(items, search2) {
2724
+ const headers = ["ID", "KIND", "DOMAIN", "SUMMARY"];
2725
+ const rows = items.map((item) => [
2726
+ str4(item["id"]),
2727
+ kindName(item),
2728
+ str4(item["domain"]),
2729
+ truncate(item["summary"])
2730
+ ]);
2731
+ const table2 = search2 ? table(
2732
+ ["SCORE", ...headers],
2733
+ rows.map((row, index) => [str4(items[index]["score"]), ...row])
2734
+ ) : table(headers, rows);
2735
+ return table2 + (items.some(needsApproval) ? "\n" + APPROVAL_NOTE : "");
2736
+ }
2737
+ function pagingNotice(items, meta, opts, noun) {
2738
+ if (opts.all === true || typeof meta.next_cursor !== "string") return void 0;
2739
+ const of = typeof meta.count === "number" ? ` of ${meta.count}` : "";
2740
+ return `harness: showing ${items.length}${of} ${noun} \u2014 pass --all for every page, or --cursor ${clean(meta.next_cursor)} for the next one.
2741
+ `;
2742
+ }
2743
+ function capabilityDetail(capability, view, schema) {
2744
+ const kind = kindName(capability);
2745
+ const line = (label, value) => ` ${(label + ":").padEnd(10)}${clean(value)}
2746
+ `;
2747
+ const description = cleanMultiline(str4(capability["description"]));
2748
+ return `${clean(capability["id"])}
2749
+ ` + line("Domain", str4(capability["domain"])) + line("Kind", kind === "send" ? "send \u2014 delivered beyond the platform" : kind) + line("Mode", `${str4(capability["mode"])} (effect: ${str4(capability["effect"])})`) + line("Summary", str4(capability["summary"])) + line("Approval", approval(capability)) + (description === "" ? "" : `
2750
+ Description:
2751
+ ${indent3(description)}
2752
+ `) + `
2753
+ ${view === "input" ? "Input" : "Result"} schema:
2754
+ ${indent3(cleanMultiline(JSON.stringify(schema, null, 2)))}
2755
+ `;
2756
+ }
2757
+ function needsApproval(capability) {
2758
+ return capability["kind"] === "write" || capability["kind"] === "send" || capability["mode"] === "proposal";
2759
+ }
2760
+ function approval(capability) {
2761
+ if (needsApproval(capability)) return "a person approves it in the app before it takes effect";
2762
+ if (capability["kind"] === "read") return "not needed for a read";
2763
+ const kind = kindName(capability);
2764
+ return kind === "unknown" ? "unknown: the server named no kind" : `unknown for a capability of kind ${kind}`;
2765
+ }
2766
+ function kindName(capability) {
2767
+ const kind = capability["kind"];
2768
+ return typeof kind === "string" && kind.trim() !== "" ? kind : "unknown";
2769
+ }
2770
+ var SCHEMA_PAGING_FIELDS = /* @__PURE__ */ new Set(["schema", "schema_encoding", "fragments", "fragment_count", "page", "next_page"]);
2771
+ function withoutSchemaPaging(capability) {
2772
+ return Object.fromEntries(Object.entries(capability).filter(([key]) => !SCHEMA_PAGING_FIELDS.has(key)));
2773
+ }
2774
+ function records2(value, what) {
2775
+ if (!Array.isArray(value) || !value.every(isRecord)) {
2776
+ throw new CommandError(`The ${what} response carried no list.`);
2777
+ }
2778
+ return value;
2779
+ }
2780
+ function dedupeById(items) {
2781
+ const seen = /* @__PURE__ */ new Set();
2782
+ return items.filter((item) => {
2783
+ if (seen.has(item["id"])) return false;
2784
+ seen.add(item["id"]);
2785
+ return true;
2786
+ });
2787
+ }
2788
+ function indent3(text) {
2789
+ return text.split("\n").map((line) => " " + line).join("\n");
2790
+ }
2791
+ function isRecord(value) {
2792
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2793
+ }
2794
+ function str4(value) {
2795
+ return value === null || value === void 0 ? "" : String(value);
2796
+ }
2797
+
2798
+ // src/commands/operator-jobs.ts
2799
+ var JOB_STATES = [
2800
+ "draft",
2801
+ "planning",
2802
+ "ready",
2803
+ "running",
2804
+ "waiting_for_approval",
2805
+ "waiting_for_human",
2806
+ "paused",
2807
+ "failed",
2808
+ "needs_reconciliation",
2809
+ "completed",
2810
+ "cancelled"
2811
+ ];
2812
+ var JOB_STATUS_ALIASES = ["open", "waiting", "done"];
2813
+ var JOBS_MAX_LIMIT = 50;
2814
+ var JOBS_MAX_PAGES = 100;
2815
+ var EVENTS_PAGE_LIMIT = 200;
2816
+ var WATCH_INTERVAL_MS = 2e3;
2817
+ var WATCH_WAITING_INTERVAL_MS = 1e4;
2818
+ var WATCH_RETRIES = 3;
2819
+ var RETRY_BASE_MS = 1e3;
2820
+ var RETRY_AFTER_CAP_MS = 3e5;
2821
+ var EXIT_WAITING_FOR_PERSON = 4;
2822
+ var EXIT_JOB_ENDED = 5;
2823
+ var EXIT_INTERRUPTED = 130;
2824
+ var INTERRUPTED = "interrupted";
2825
+ var READER_GONE = "reader-gone";
2826
+ var ENDED_STATES = /* @__PURE__ */ new Set(["completed", "failed", "cancelled"]);
2827
+ var CHANGE_SET_FIELDS = [
2828
+ "manifest_id",
2829
+ "version",
2830
+ "digest",
2831
+ "status",
2832
+ "plan_version",
2833
+ "max_effect_count",
2834
+ "actions"
2835
+ ];
2836
+ var CHANGE_SET_ACTION_FIELDS = [
2837
+ "id",
2838
+ "action_type",
2839
+ "action_version",
2840
+ "display_order",
2841
+ "effect_count",
2842
+ "required_capability_ids",
2843
+ "digest"
2844
+ ];
2845
+ var WITHHELD_FIELDS = /* @__PURE__ */ new Set([
2846
+ "objective",
2847
+ "objective_text",
2848
+ "instruction",
2849
+ "instructions",
2850
+ "description",
2851
+ "result",
2852
+ "results",
2853
+ "outputs",
2854
+ "investigation",
2855
+ "read_material",
2856
+ "public_research",
2857
+ "source_review",
2858
+ "review_actions",
2859
+ "manifest_actions",
2860
+ "recipe",
2861
+ "lua",
2862
+ "lua_source"
2863
+ ]);
2864
+ var CODE = /^[a-z0-9_.:-]{1,64}$/;
2865
+ var TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/;
2866
+ function requireJobsArgs(opts) {
2867
+ if (opts.status !== void 0) normalizeStatus(opts.status);
2868
+ if (opts.limit !== void 0 && (!Number.isInteger(opts.limit) || opts.limit < 1 || opts.limit > JOBS_MAX_LIMIT)) {
2869
+ throw new UsageError(`--limit takes an integer from 1 to ${JOBS_MAX_LIMIT}`);
2870
+ }
2871
+ }
2872
+ function normalizeStatus(status) {
2873
+ const tokens = status.split(",").map((token) => token.trim().toLowerCase()).filter((token) => token !== "");
2874
+ const known = [...JOB_STATES, ...JOB_STATUS_ALIASES];
2875
+ const unknown = tokens.filter((token) => !known.includes(token));
2876
+ if (tokens.length === 0 || unknown.length > 0) {
2877
+ throw new UsageError(
2878
+ `${tokens.length === 0 ? "--status is empty" : `unknown job status: ${unknown.join(", ")}`} \u2014 pass job states (${JOB_STATES.join(", ")}) or ${JOB_STATUS_ALIASES.join(", ")}, comma-separated`
2879
+ );
2880
+ }
2881
+ return tokens.join(",");
2882
+ }
2883
+ async function jobsList(ctx, opts) {
2884
+ requireJobsArgs(opts);
2885
+ const params = {
2886
+ status: opts.status === void 0 ? void 0 : normalizeStatus(opts.status),
2887
+ mine: opts.mine === true ? true : void 0,
2888
+ // Reading everything: full pages, so the walk takes as few requests as it can.
2889
+ limit: opts.limit ?? (opts.all === true ? JOBS_MAX_LIMIT : void 0)
2890
+ };
2891
+ let page = await jobsPage(ctx, params, void 0);
2892
+ let jobs = page.jobs;
2893
+ if (opts.all !== true) {
2894
+ if (!page.hasMore) return { json: jobs, human: jobsTable(jobs) };
2895
+ if (jobs.length === 0) {
2896
+ return {
2897
+ json: jobs,
2898
+ human: "No Operator jobs you can see on this page.\n",
2899
+ notice: "harness: older jobs may exist past this page \u2014 pass --all to read on.\n"
2900
+ };
2901
+ }
2902
+ return {
2903
+ json: jobs,
2904
+ human: jobsTable(jobs),
2905
+ notice: `harness: showing the newest ${plural(jobs.length, "Operator job")} \u2014 pass --all for older ones.
2906
+ `
2907
+ };
2908
+ }
2909
+ const seen = /* @__PURE__ */ new Set();
2910
+ let pages = 1;
2911
+ while (page.hasMore && pages < JOBS_MAX_PAGES) {
2912
+ const cursor = page.nextCursor;
2913
+ if (seen.has(cursor)) throw new CommandError("The jobs response repeated a cursor.");
2914
+ seen.add(cursor);
2915
+ page = await jobsPage(ctx, params, cursor);
2916
+ pages += 1;
2917
+ jobs = [...jobs, ...page.jobs];
2918
+ }
2919
+ jobs = uniqueById(jobs);
2920
+ if (!page.hasMore) return { json: jobs, human: jobsTable(jobs) };
2921
+ return {
2922
+ json: jobs,
2923
+ human: jobsTable(jobs),
2924
+ notice: `harness: stopped after ${JOBS_MAX_PAGES} pages (${plural(jobs.length, "job")}) without reaching the end \u2014 narrow the list with --status or --mine.
2925
+ `,
2926
+ exitCode: 1
2927
+ };
2928
+ }
2929
+ async function jobsPage(ctx, params, before) {
2930
+ const body = await explained(
2931
+ () => ctx.client.get("/api/v1/operator/jobs", { ...params, before }),
2932
+ { notFound: "this Harness server has no Operator API yet" }
2933
+ );
2934
+ const jobs = objects(body.data, "jobs").map(withheld);
2935
+ const meta = body.meta;
2936
+ if (typeof meta !== "object" || meta === null || typeof meta["has_more"] !== "boolean") {
2937
+ throw new CommandError("The jobs response has no paging metadata.");
2938
+ }
2939
+ if (meta["has_more"] !== true) return { jobs, hasMore: false, nextCursor: null };
2940
+ const cursor = meta["next_cursor"];
2941
+ if (typeof cursor !== "string" || cursor === "") {
2942
+ throw new CommandError("The jobs response says more exist but gives no cursor.");
2943
+ }
2944
+ return { jobs, hasMore: true, nextCursor: cursor };
2945
+ }
2946
+ function requireJobId(id) {
2947
+ if (!isUuid(id)) throw new UsageError(`not an Operator job id: ${id} (expected the job's UUID)`);
2948
+ }
2949
+ async function jobShow(ctx, id, opts = {}) {
2950
+ requireJobId(id);
2951
+ const body = await explained(() => ctx.client.get(`/api/v1/operator/jobs/${id}`), {
2952
+ notFound: missingJob(id)
2953
+ });
2954
+ if (!isRecord2(body.data)) throw new CommandError("The job response carried no job.");
2955
+ const job = withheld(body.data);
2956
+ if (opts.changes !== true && opts.people !== true) return { json: job, human: jobDetail(job) };
2957
+ const document = Object.fromEntries(
2958
+ ["id", "state", "waiting_on", "url", "approval_url"].map((key) => [key, job[key] ?? null])
2959
+ );
2960
+ const sections = [];
2961
+ let notice;
2962
+ if (opts.changes === true) {
2963
+ const changeSet = await jobChangeSet(ctx, id);
2964
+ document["change_set"] = changeSet;
2965
+ sections.push(changesText(id, job, changeSet));
2966
+ notice = changeSetRace(job, changeSet);
2967
+ }
2968
+ if (opts.people === true) {
2969
+ const people = jobPeople(job);
2970
+ document["people"] = people;
2971
+ sections.push(peopleText(id, people));
2972
+ }
2973
+ return { json: document, human: sections.join("\n"), ...notice === void 0 ? {} : { notice } };
2974
+ }
2975
+ function changeSetRace(job, changeSet) {
2976
+ const pending = field2(job, "pending_approval", "manifest_id");
2977
+ if (typeof pending !== "string") return void 0;
2978
+ const read = changeSet === null ? null : changeSet["manifest_id"];
2979
+ if (read === pending) return void 0;
2980
+ return `harness: the job waits for approval of change set ${clean(pending)}, but the change set read next is ${read === null ? "none" : clean(read)}: the job changed between the two reads, so run it again.
2981
+ `;
2982
+ }
2983
+ async function jobChangeSet(ctx, id) {
2984
+ const body = await explained(() => ctx.client.get(`/api/v1/operator/jobs/${id}/changes`), {
2985
+ notFound: missingJob(id)
2986
+ });
2987
+ if (body.data === null) return null;
2988
+ if (!isRecord2(body.data)) throw new CommandError("The change set response carried no change set.");
2989
+ return {
2990
+ ...pick(body.data, CHANGE_SET_FIELDS),
2991
+ actions: records3(body.data["actions"]).map((action) => pick(action, CHANGE_SET_ACTION_FIELDS))
2992
+ };
2993
+ }
2994
+ function jobPeople(job) {
2995
+ const participants = records3(field2(job, "people", "participants"));
2996
+ const owner = isRecord2(job["owner"]) ? job["owner"] : null;
2997
+ const names = /* @__PURE__ */ new Map();
2998
+ for (const named of owner === null ? participants : [...participants, owner]) {
2999
+ if (typeof named["user_id"] === "string" && typeof named["name"] === "string" && named["name"] !== "") {
3000
+ if (!names.has(named["user_id"])) names.set(named["user_id"], named["name"]);
3001
+ }
3002
+ }
3003
+ const person = (userId) => typeof userId === "string" && userId !== "" ? { user_id: userId, name: names.get(userId) ?? null } : null;
3004
+ const some = (people) => people.filter((entry) => entry !== null);
3005
+ const approvers = field2(job, "people", "approver_user_ids");
3006
+ return {
3007
+ requester: person(job["requester_user_id"]),
3008
+ execution_principal: person(job["execution_principal_user_id"]),
3009
+ owner: owner === null ? null : person(owner["user_id"]),
3010
+ approvers: some((Array.isArray(approvers) ? approvers : []).map(person)),
3011
+ collaborators: some(participants.filter((entry) => entry["role"] === "collaborator").map((entry) => person(entry["user_id"])))
3012
+ };
3013
+ }
3014
+ async function jobWatch(ctx, id, opts) {
3015
+ requireJobId(id);
3016
+ if (opts.after !== void 0 && (!Number.isInteger(opts.after) || opts.after < 0)) {
3017
+ throw new UsageError("--after takes a non-negative event sequence");
3018
+ }
3019
+ const emit = opts.onLine ?? (() => {
3020
+ });
3021
+ const sleep = opts.sleep ?? abortableSleep;
3022
+ const signal = opts.signal;
3023
+ let after = opts.after ?? 0;
3024
+ const events = [];
3025
+ let job = null;
3026
+ let shown = null;
3027
+ let shownApprovalUrl = null;
3028
+ const document = (outcome, extra = {}) => ({
3029
+ id,
3030
+ outcome,
3031
+ ...extra,
3032
+ job: job === null ? null : jobStateJson(job),
3033
+ ...opts.collectEvents === true ? { events } : {},
3034
+ next_after: after
3035
+ });
3036
+ const stopped = (reason) => reason === READER_GONE ? (
3037
+ // Nobody is reading any more: end quietly.
3038
+ { json: document("stopped"), human: "", exitCode: 0 }
3039
+ ) : { json: document("interrupted"), human: "", notice: resumeLine(id, after), exitCode: EXIT_INTERRUPTED };
3040
+ for (; ; ) {
3041
+ if (aborted(signal)) return stopped(signal?.reason);
3042
+ let page;
3043
+ try {
3044
+ page = await withRetries(() => eventsPage(ctx, id, after), sleep, signal);
3045
+ } catch (error) {
3046
+ if (aborted(signal)) return stopped(signal?.reason);
3047
+ if (!transient(error)) throw error;
3048
+ const message = failureMessage(error);
3049
+ return {
3050
+ json: document("error", {
3051
+ error: { message, status: error instanceof HttpError ? error.status : null }
3052
+ }),
3053
+ human: "",
3054
+ notice: `harness: ${message}
3055
+ ` + resumeLine(id, after),
3056
+ exitCode: error instanceof TransportError ? 3 : 1
3057
+ };
3058
+ }
3059
+ for (const event of page.events) {
3060
+ if (opts.collectEvents === true) events.push(eventJson(event));
3061
+ emit(eventLine(event));
3062
+ }
3063
+ after = page.nextAfter;
3064
+ job = page.job;
3065
+ if (page.hasMore) continue;
3066
+ const state = str5(job["state"]);
3067
+ const waitingOn = typeof job["waiting_on"] === "string" ? job["waiting_on"] : null;
3068
+ if (shown === null || shown.state !== state || shown.waitingOn !== waitingOn) {
3069
+ emit(stateLine(shown?.state ?? null, state, waitingOn));
3070
+ shown = { state, waitingOn };
3071
+ }
3072
+ const approvalUrl = typeof job["approval_url"] === "string" ? job["approval_url"] : null;
3073
+ if (opts.throughWaits === true && approvalUrl !== null && approvalUrl !== shownApprovalUrl) {
3074
+ emit(`approve in the app: ${clean(approvalUrl)}
3075
+ `);
3076
+ }
3077
+ shownApprovalUrl = approvalUrl;
3078
+ let outcome = null;
3079
+ if (ENDED_STATES.has(state)) outcome = state;
3080
+ else if (waitingOn !== null && opts.throughWaits !== true) outcome = "waiting";
3081
+ if (outcome !== null) {
3082
+ return {
3083
+ json: document(outcome),
3084
+ human: outcomeText(id, outcome, job),
3085
+ exitCode: outcome === "completed" ? 0 : outcome === "waiting" ? EXIT_WAITING_FOR_PERSON : EXIT_JOB_ENDED
3086
+ };
3087
+ }
3088
+ if (aborted(signal)) return stopped(signal?.reason);
3089
+ await sleep(waitingOn !== null || state === "paused" ? WATCH_WAITING_INTERVAL_MS : WATCH_INTERVAL_MS, signal);
3090
+ }
3091
+ }
3092
+ function resumeLine(id, after) {
3093
+ return `harness: stopped watching job ${id}; resume with: harness watch ${id} --after ${after}
3094
+ `;
3095
+ }
3096
+ function watchStopSignal(stdout = process.stdout, signals = process) {
3097
+ const controller = new AbortController();
3098
+ const readerGone = () => controller.abort(READER_GONE);
3099
+ stdout.once("error", readerGone);
3100
+ stdout.once("close", readerGone);
3101
+ signals.once("SIGINT", () => controller.abort(INTERRUPTED));
3102
+ return controller.signal;
3103
+ }
3104
+ function aborted(signal) {
3105
+ return signal?.aborted === true;
3106
+ }
3107
+ function abortableSleep(ms, signal) {
3108
+ return new Promise((resolve) => {
3109
+ if (aborted(signal)) {
3110
+ resolve();
3111
+ return;
3112
+ }
3113
+ const done = () => {
3114
+ clearTimeout(timer);
3115
+ signal?.removeEventListener("abort", done);
3116
+ resolve();
3117
+ };
3118
+ const timer = setTimeout(done, ms);
3119
+ signal?.addEventListener("abort", done, { once: true });
3120
+ });
3121
+ }
3122
+ function transient(error) {
3123
+ if (error instanceof TransportError) return true;
3124
+ return error instanceof HttpError && !(error instanceof UpgradeRequiredError) && (error.status >= 500 || error.status === 429);
3125
+ }
3126
+ async function withRetries(send, sleep, signal) {
3127
+ for (let attempt = 0; ; attempt++) {
3128
+ try {
3129
+ return await send();
3130
+ } catch (error) {
3131
+ if (attempt >= WATCH_RETRIES || !transient(error) || aborted(signal)) throw error;
3132
+ await sleep(retryDelay(error, attempt), signal);
3133
+ if (aborted(signal)) throw error;
3134
+ }
3135
+ }
3136
+ }
3137
+ function retryDelay(error, attempt) {
3138
+ const backoff = RETRY_BASE_MS * 2 ** attempt;
3139
+ const asked = error instanceof HttpError ? retryAfterMs(error.retryAfter) : null;
3140
+ return asked === null ? backoff : Math.max(backoff, Math.min(asked, RETRY_AFTER_CAP_MS));
3141
+ }
3142
+ function retryAfterMs(header) {
3143
+ if (header === null) return null;
3144
+ const value = header.trim();
3145
+ if (/^\d+$/.test(value)) return Number.parseInt(value, 10) * 1e3;
3146
+ const at = Date.parse(value);
3147
+ return Number.isNaN(at) ? null : Math.max(0, at - Date.now());
3148
+ }
3149
+ function failureMessage(error) {
3150
+ if (error instanceof TransportError) return `cannot reach the API: ${clean(error.message)}`;
3151
+ return `${clean(error.message)} (HTTP ${error.status})${errorDetails(error.body)}`;
3152
+ }
3153
+ async function eventsPage(ctx, id, after) {
3154
+ const body = await explained(
3155
+ () => ctx.client.get(`/api/v1/operator/jobs/${id}/events`, {
3156
+ after,
3157
+ limit: EVENTS_PAGE_LIMIT
3158
+ }),
3159
+ { notFound: missingJob(id) }
3160
+ );
3161
+ const events = objects(body.data, "events");
3162
+ const meta = body.meta;
3163
+ if (typeof meta !== "object" || meta === null) throw new CommandError("The events response has no metadata.");
3164
+ const nextAfter = meta["next_after"];
3165
+ const hasMore = meta["has_more"];
3166
+ const job = meta["job"];
3167
+ if (typeof nextAfter !== "number" || !Number.isInteger(nextAfter) || nextAfter < after) {
3168
+ throw new CommandError("The events response has an invalid next_after.");
3169
+ }
3170
+ if (typeof hasMore !== "boolean") throw new CommandError("The events response has an invalid has_more.");
3171
+ if (!isRecord2(job) || typeof job["state"] !== "string") {
3172
+ throw new CommandError("The events response does not carry the job's state.");
3173
+ }
3174
+ if (hasMore && nextAfter === after) {
3175
+ throw new CommandError("The events response says more exist but did not move its cursor.");
3176
+ }
3177
+ return { events, nextAfter, hasMore, job };
3178
+ }
3179
+ var REFUSAL_HINTS = {
3180
+ operator_disabled: "nothing can be read until Operator is switched back on for this organization",
3181
+ operator_not_permitted: "only the organization's admins and members use Operator; guests and people outside it cannot",
3182
+ actor_required: "the credential acts as no person: sign in with `harness login`, or use an API token a person created",
3183
+ forbidden: "the credential lacks the operator:read scope (`harness login` carries it; an aone_ token must be created with it, bound to this organization), or you are a guest or outside the organization",
3184
+ run_bound_credential: "a run-bound credential cannot read Operator jobs; sign in with `harness login`"
3185
+ };
3186
+ function missingJob(id) {
3187
+ return `no Operator job ${id} you can see: it does not exist, is in another organization, or is not shared with you (or this Harness server has no Operator API yet)`;
3188
+ }
3189
+ async function explained(send, why) {
3190
+ try {
3191
+ return await send();
3192
+ } catch (error) {
3193
+ if (!(error instanceof HttpError)) throw error;
3194
+ const code = error.body["error"];
3195
+ let hint;
3196
+ if (error.status === 403 && typeof code === "string") hint = REFUSAL_HINTS[code];
3197
+ if (error.status === 404) hint = why.notFound;
3198
+ if (error.status === 400 && code === "invalid_cursor") hint = "run the list again from the start";
3199
+ if (hint === void 0) throw error;
3200
+ throw new HttpError(error.status, `${error.message} \u2014 ${hint}`, error.body, error.retryAfter);
3201
+ }
3202
+ }
3203
+ function jobsTable(jobs) {
3204
+ if (jobs.length === 0) return "No Operator jobs.\n";
3205
+ const rows = jobs.map((job) => [
3206
+ str5(job["id"]),
3207
+ str5(job["state"]) || "-",
3208
+ str5(job["waiting_on"]) || "-",
3209
+ str5(job["objective_kind"]) || "-",
3210
+ personName(job["owner"]),
3211
+ str5(field2(job, "plan_step", "issue")) || "-",
3212
+ str5(job["updated_at"]) || "-"
3213
+ ]);
3214
+ const table2 = table(["ID", "STATE", "WAITING ON", "KIND", "OWNER", "PLAN STEP", "UPDATED"], rows);
3215
+ const approvals = jobs.filter((job) => typeof job["approval_url"] === "string");
3216
+ if (approvals.length === 0) return table2;
3217
+ return table2 + "\nWaiting for approval. Approve in the app; the CLI cannot:\n" + approvals.map((job) => ` ${clean(job["id"])} ${clean(job["approval_url"])}
3218
+ `).join("");
3219
+ }
3220
+ function jobDetail(job) {
3221
+ const waitingOn = str5(job["waiting_on"]);
3222
+ const header = `Operator job ${clean(job["id"])} \xB7 ${clean(job["state"])}` + (waitingOn === "" ? "" : ` \xB7 waiting on ${clean(waitingOn)}`) + "\n";
3223
+ const people = records3(field2(job, "people", "participants"));
3224
+ const fields = [
3225
+ ["Kind", str5(job["objective_kind"]) || "-"],
3226
+ ["Role", str5(job["viewer_role"]) || null],
3227
+ ["Owner", isRecord2(job["owner"]) ? personName(job["owner"]) : null],
3228
+ ["Plan step", planStepLine(job["plan_step"])],
3229
+ ["Plan", planLine(job["plan"])],
3230
+ ["Progress", progressLine(job["progress"])],
3231
+ ["Window", windowLine(job["window"])],
3232
+ ["Spend", spendLine(job["spend"])],
3233
+ ["Verification", str5(field2(job, "verification", "status")) || null],
3234
+ ["Effects", effectsLine(job["effects"])],
3235
+ ["Skill", skillLine(job["skill"])],
3236
+ ["Grant", grantLine(job["grant"], people)],
3237
+ ["Created", str5(job["inserted_at"]) || null],
3238
+ ["Updated", str5(job["updated_at"]) || null],
3239
+ ["Workbench", str5(job["url"]) || null]
3240
+ ];
3241
+ return header + labelled(fields) + approvalSection(job["pending_approval"], people) + listSection("Blockers", records3(job["blockers"]), (item) => `${str5(item["kind"])}: ${str5(item["state"])}`) + listSection("Artifacts", records3(job["artifacts"]), (item) => `${str5(item["id"])} ${str5(item["url"])}`) + peopleSection(people) + listSection(
3242
+ "Procedures",
3243
+ records3(job["procedures"]),
3244
+ (procedure) => `${str5(procedure["name"])} v${str5(procedure["current_version"])} ${str5(procedure["id"])}`
3245
+ ) + controlsLine(job["viewer_can"]) + "\nThe objective's text is not shown: the API never returns it, because it can quote what Operator read. Open the workbench link to read it.\n";
3246
+ }
3247
+ function labelled(fields, width = 15) {
3248
+ return fields.filter((entry) => entry[1] !== null).map(([label, value]) => ` ${(label + ":").padEnd(width)}${clean(value)}
3249
+ `).join("");
3250
+ }
3251
+ function planStepLine(step) {
3252
+ if (!isRecord2(step)) return null;
3253
+ return `${str5(step["issue"])} (plan ${str5(step["plan_id"])})`;
3254
+ }
3255
+ function planLine(plan) {
3256
+ if (!isRecord2(plan)) return "none yet";
3257
+ const count = plan["capability_count"];
3258
+ return `version ${str5(plan["version"])} \xB7 instructions v${str5(plan["instruction_version"])} \xB7 ${str5(count)} capabilit${count === 1 ? "y" : "ies"}`;
3259
+ }
3260
+ function progressLine(progress) {
3261
+ if (!isRecord2(progress)) return null;
3262
+ const parts = [str5(progress["stage"]), str5(progress["situation"])].filter((part) => part !== "");
3263
+ if (progress["needs_viewer"] === true) parts.push("needs you");
3264
+ return parts.length === 0 ? null : parts.join(" \xB7 ");
3265
+ }
3266
+ function windowLine(window) {
3267
+ if (!isRecord2(window)) return null;
3268
+ const left = duration(window["remaining_ms"]);
3269
+ const budget = duration(window["budget_ms"]);
3270
+ const sequence = window["sequence"];
3271
+ return `${left ?? "?"} left${budget === null ? "" : ` of ${budget}`} \xB7 ${str5(window["state"])}` + (typeof sequence === "number" ? ` (window ${sequence})` : "");
3272
+ }
3273
+ function spendLine(spend) {
3274
+ if (!isRecord2(spend)) return null;
3275
+ const spent = `${str5(spend["spent_cents"]) || "0"} cents spent`;
3276
+ if (typeof spend["plan_budget_cents"] !== "number") return spent;
3277
+ const remaining = spend["plan_remaining_cents"];
3278
+ return `${spent} \xB7 plan budget ${spend["plan_budget_cents"]} cents` + (typeof remaining === "number" ? `, ${remaining} cents left` : "");
3279
+ }
3280
+ function effectsLine(effects) {
3281
+ if (!isRecord2(effects)) return null;
3282
+ return ["staged", "succeeded", "failed", "unknown"].map((key) => `${str5(effects[key]) || "0"} ${key}`).join(" \xB7 ");
3283
+ }
3284
+ function skillLine(skill) {
3285
+ if (!isRecord2(skill)) return null;
3286
+ return `${str5(skill["title"])} (${str5(skill["invocation_name"])}) \xB7 sha256 ${str5(skill["content_sha256"])} \xB7 pinned ${str5(skill["pinned_at"])}`;
3287
+ }
3288
+ function grantLine(grant, people) {
3289
+ if (!isRecord2(grant)) return null;
3290
+ const approvedAt = str5(grant["approved_at"]);
3291
+ const expiresAt = str5(grant["expires_at"]);
3292
+ return `${str5(grant["status"])} v${str5(grant["version"])} \xB7 approved by ${nameOf(grant["approver_user_id"], people)}` + (approvedAt === "" ? "" : ` at ${approvedAt}`) + ` \xB7 ${expiresAt === "" ? "no expiry" : `expires ${expiresAt}`}`;
3293
+ }
3294
+ function approvalSection(pending, people) {
3295
+ if (!isRecord2(pending)) return "";
3296
+ const approvers = Array.isArray(pending["approver_user_ids"]) ? pending["approver_user_ids"].map((id) => nameOf(id, people)).join(", ") : "";
3297
+ const canApprove = pending["viewer_can_approve"] === true ? "you can approve" : "you cannot approve it";
3298
+ return "\nWaiting for approval. Approve it in the app; the CLI cannot:\n" + labelled([
3299
+ [
3300
+ "Change set",
3301
+ `${str5(pending["manifest_id"])} v${str5(pending["version"])} \xB7 ${str5(pending["action_count"])} actions \xB7 up to ${str5(pending["max_effect_count"])} effects \xB7 plan version ${str5(pending["plan_version"])}`
3302
+ ],
3303
+ ["Digest", str5(pending["digest"]) || null],
3304
+ ["Approvers", approvers === "" ? canApprove : `${approvers} \xB7 ${canApprove}`],
3305
+ ["Approve at", str5(pending["url"]) || null]
3306
+ ]);
3307
+ }
3308
+ function peopleSection(people) {
3309
+ if (people.length === 0) return "";
3310
+ const rows = people.map((person) => [
3311
+ str5(person["name"]) || "(no name)",
3312
+ str5(person["role"]),
3313
+ str5(person["user_id"])
3314
+ ]);
3315
+ const lines = table(["", "", ""], rows).split("\n").slice(1, -1);
3316
+ return "\nPeople:\n" + lines.map((line) => ` ${line}
3317
+ `).join("");
3318
+ }
3319
+ function changesText(id, job, changeSet) {
3320
+ if (changeSet === null) return `No pending change set: Operator has proposed nothing for job ${id} yet.
3321
+ `;
3322
+ const approvalUrl = str5(job["approval_url"]);
3323
+ const rows = records3(changeSet["actions"]).map((action) => [
3324
+ str5(action["display_order"]) || "-",
3325
+ str5(action["action_type"]),
3326
+ str5(action["action_version"]),
3327
+ str5(action["effect_count"]),
3328
+ (Array.isArray(action["required_capability_ids"]) ? action["required_capability_ids"].map(str5).join(", ") : "") || "-",
3329
+ str5(action["digest"]),
3330
+ str5(action["id"])
3331
+ ]);
3332
+ const actions = rows.length === 0 ? "\nActions: none\n" : "\nActions:\n" + table(["#", "ACTION", "VERSION", "EFFECTS", "CAPABILITIES", "DIGEST", "ID"], rows).split("\n").slice(0, -1).map((line) => ` ${line}
3333
+ `).join("");
3334
+ return `Change set of Operator job ${id} \xB7 ${clean(changeSet["status"])}
3335
+ ` + labelled([
3336
+ [
3337
+ "Change set",
3338
+ `${str5(changeSet["manifest_id"])} v${str5(changeSet["version"])} \xB7 plan version ${str5(changeSet["plan_version"])} \xB7 up to ${str5(changeSet["max_effect_count"])} effects`
3339
+ ],
3340
+ ["Digest", str5(changeSet["digest"]) || null],
3341
+ approvalUrl === "" ? ["Workbench", str5(job["url"]) || null] : ["Approve at", approvalUrl]
3342
+ ]) + actions + "\nValues and targets are not shown: review them in the app at the " + (approvalUrl === "" ? "workbench link.\n" : "approval link. The CLI cannot approve.\n");
3343
+ }
3344
+ function peopleText(id, people) {
3345
+ const one = (entry) => isRecord2(entry) ? str5(entry["name"]) || str5(entry["user_id"]) : "-";
3346
+ const many = (entries) => (Array.isArray(entries) ? entries.map(one).join(", ") : "") || "none";
3347
+ return `People on Operator job ${id}:
3348
+ ` + labelled(
3349
+ [
3350
+ ["Requester", one(people["requester"])],
3351
+ ["Execution principal", one(people["execution_principal"])],
3352
+ ["Owner", one(people["owner"])],
3353
+ ["Approvers", many(people["approvers"])],
3354
+ ["Collaborators", many(people["collaborators"])]
3355
+ ],
3356
+ 22
3357
+ );
3358
+ }
3359
+ function listSection(title, items, line) {
3360
+ if (items.length === 0) return "";
3361
+ return `
3362
+ ${title}:
3363
+ ` + items.map((item) => ` ${clean(line(item))}
3364
+ `).join("");
3365
+ }
3366
+ function controlsLine(viewerCan) {
3367
+ const controls = isRecord2(viewerCan) ? ["steer", "cancel", "continue", "pause", "save_procedure"].filter((key) => viewerCan[key] === true) : [];
3368
+ return `
3369
+ You can, in the workbench: ${controls.length === 0 ? "nothing right now" : controls.join(", ")}
3370
+ `;
3371
+ }
3372
+ function eventLine(event) {
3373
+ const actor = isRecord2(event["actor"]) ? event["actor"] : {};
3374
+ const actorKind = safeValue(actor["kind"]) || "?";
3375
+ const actorId = safeValue(actor["delegated_grant_id"] ?? actor["user_id"]);
3376
+ const data = isRecord2(event["data"]) ? Object.entries(event["data"]).map(([key, value]) => `${clean(key)}=${safeValue(value) || "\u2026"}`) : [];
3377
+ return `#${clean(event["sequence"])} ${clean(event["occurred_at"])} ${clean(event["kind"])} ${actorId === "" ? actorKind : `${actorKind} ${actorId}`}` + (data.length === 0 ? "" : ` ${data.join(" ")}`) + "\n";
3378
+ }
3379
+ function eventJson(event) {
3380
+ const actor = isRecord2(event["actor"]) ? event["actor"] : {};
3381
+ const data = isRecord2(event["data"]) ? event["data"] : {};
3382
+ return {
3383
+ sequence: typeof event["sequence"] === "number" ? event["sequence"] : null,
3384
+ kind: safeValue(event["kind"]) || null,
3385
+ occurred_at: typeof event["occurred_at"] === "string" && TIMESTAMP.test(event["occurred_at"]) ? event["occurred_at"] : null,
3386
+ actor: Object.fromEntries(
3387
+ ["kind", "user_id", "delegated_grant_id"].filter((key) => key in actor && (actor[key] === null || safeValue(actor[key]) !== "")).map((key) => [key, actor[key]])
3388
+ ),
3389
+ data: Object.fromEntries(Object.entries(data).filter(([key, value]) => CODE.test(key) && safeValue(value) !== ""))
3390
+ };
3391
+ }
3392
+ function jobStateJson(job) {
3393
+ return Object.fromEntries(
3394
+ ["state", "waiting_on", "updated_at", "url", "approval_url"].filter((key) => key in job).map((key) => [key, job[key]])
3395
+ );
3396
+ }
3397
+ function stateLine(previous, state, waitingOn) {
3398
+ const now = clean(state) + (waitingOn === null ? "" : `, waiting on ${clean(waitingOn)}`);
3399
+ return previous === null || previous === state ? `state: ${now}
3400
+ ` : `state: ${clean(previous)} \u2192 ${now}
3401
+ `;
3402
+ }
3403
+ function outcomeText(id, outcome, job) {
3404
+ const url = clean(job["url"]);
3405
+ switch (outcome) {
3406
+ case "completed":
3407
+ return `Job ${id} completed.
3408
+ Workbench: ${url}
3409
+ `;
3410
+ case "failed":
3411
+ return `Job ${id} failed.
3412
+ Workbench: ${url}
3413
+ `;
3414
+ case "cancelled":
3415
+ return `Job ${id} was cancelled.
3416
+ Workbench: ${url}
3417
+ `;
3418
+ case "waiting": {
3419
+ const waitingOn = job["waiting_on"];
3420
+ if (waitingOn === "approval") {
3421
+ const link = clean(job["approval_url"]) || url;
3422
+ return `Job ${id} is waiting for approval. Approve it in the app; the CLI cannot:
3423
+ ${link}
3424
+ `;
3425
+ }
3426
+ const what = waitingOn === "person" ? "a person" : clean(waitingOn);
3427
+ return `Job ${id} is waiting on ${what}; a person has to act in the workbench:
3428
+ ${url}
3429
+ `;
3430
+ }
3431
+ }
3432
+ }
3433
+ function withheld(job) {
3434
+ return Object.fromEntries(
3435
+ Object.entries(job).filter(([key]) => !WITHHELD_FIELDS.has(key) && !key.startsWith("knowledge"))
3436
+ );
3437
+ }
3438
+ function pick(record, keys) {
3439
+ return Object.fromEntries(keys.filter((key) => key in record).map((key) => [key, record[key]]));
3440
+ }
3441
+ function objects(value, what) {
3442
+ if (!Array.isArray(value) || !value.every(isRecord2)) {
3443
+ throw new CommandError(`The ${what} response data is not a list of objects.`);
3444
+ }
3445
+ return value;
3446
+ }
3447
+ function plural(count, noun) {
3448
+ return `${count} ${noun}${count === 1 ? "" : "s"}`;
3449
+ }
3450
+ function uniqueById(jobs) {
3451
+ const seen = /* @__PURE__ */ new Set();
3452
+ return jobs.filter((job) => {
3453
+ const id = str5(job["id"]);
3454
+ if (seen.has(id)) return false;
3455
+ seen.add(id);
3456
+ return true;
3457
+ });
3458
+ }
3459
+ function personName(person) {
3460
+ if (!isRecord2(person)) return "-";
3461
+ return str5(person["name"]) || str5(person["user_id"]) || "-";
3462
+ }
3463
+ function nameOf(userId, people) {
3464
+ const id = str5(userId);
3465
+ const person = people.find((candidate) => candidate["user_id"] === id);
3466
+ return (person === void 0 ? "" : str5(person["name"])) || id || "?";
3467
+ }
3468
+ function duration(ms) {
3469
+ if (typeof ms !== "number" || !Number.isFinite(ms)) return null;
3470
+ const seconds = Math.max(0, Math.floor(ms / 1e3));
3471
+ const hours = Math.floor(seconds / 3600);
3472
+ const minutes = Math.floor(seconds % 3600 / 60);
3473
+ const rest = seconds % 60;
3474
+ if (hours > 0) return `${hours}h ${String(minutes).padStart(2, "0")}m`;
3475
+ if (minutes > 0) return `${minutes}m ${String(rest).padStart(2, "0")}s`;
3476
+ return `${rest}s`;
3477
+ }
3478
+ function safeValue(value) {
3479
+ if (typeof value === "number" && Number.isFinite(value)) return String(value);
3480
+ if (typeof value === "boolean") return String(value);
3481
+ if (typeof value === "string" && CODE.test(value)) return value;
3482
+ return "";
3483
+ }
3484
+ function field2(record, ...keys) {
3485
+ let current = record;
3486
+ for (const key of keys) {
3487
+ if (!isRecord2(current)) return null;
3488
+ current = current[key];
3489
+ }
3490
+ return current ?? null;
3491
+ }
3492
+ function records3(value) {
3493
+ return Array.isArray(value) ? value.filter(isRecord2) : [];
3494
+ }
3495
+ function isRecord2(value) {
3496
+ return typeof value === "object" && value !== null && !Array.isArray(value);
3497
+ }
3498
+ function str5(value) {
3499
+ return value === null || value === void 0 ? "" : String(value);
3500
+ }
3501
+
3502
+ // src/commands/operator-write.ts
3503
+ var ACCESS_HINTS = {
3504
+ ...REFUSAL_HINTS,
3505
+ operator_disabled: "nothing can be changed until Operator is switched back on for this organization",
3506
+ actor_required: "the credential acts as no person: use a browser login, or an API token a person created",
3507
+ forbidden: "the credential lacks the operator:write scope (a browser login carries it; an aone_ token must be created with it, bound to this organization), or you are a guest or outside the organization",
3508
+ run_bound_credential: "a run-bound credential cannot change Operator jobs; use a browser login"
3509
+ };
3510
+ var BAD_REQUEST_HINT = "nothing changed: the server refused a field this CLI sent, so this CLI and the server disagree about the request \u2014 update the CLI";
3511
+ var NEVER_SENT = /* @__PURE__ */ new Set([
3512
+ "ENOTFOUND",
3513
+ "EAI_AGAIN",
3514
+ "EAI_FAIL",
3515
+ "ECONNREFUSED",
3516
+ "ENETUNREACH",
3517
+ "EHOSTUNREACH",
3518
+ "EHOSTDOWN",
3519
+ "EADDRNOTAVAIL",
3520
+ "UND_ERR_CONNECT_TIMEOUT",
3521
+ "DEPTH_ZERO_SELF_SIGNED_CERT",
3522
+ "SELF_SIGNED_CERT_IN_CHAIN",
3523
+ "UNABLE_TO_VERIFY_LEAF_SIGNATURE",
3524
+ "UNABLE_TO_GET_ISSUER_CERT",
3525
+ "UNABLE_TO_GET_ISSUER_CERT_LOCALLY",
3526
+ "CERT_HAS_EXPIRED",
3527
+ "CERT_NOT_YET_VALID",
3528
+ "CERT_REVOKED",
3529
+ "CERT_UNTRUSTED",
3530
+ "CERT_REJECTED",
3531
+ "HOSTNAME_MISMATCH",
3532
+ "ERR_TLS_CERT_ALTNAME_INVALID",
3533
+ "ERR_SSL_WRONG_VERSION_NUMBER"
3534
+ ]);
3535
+ function neverSent(error) {
3536
+ return !(error instanceof MalformedResponseError) && error.code !== null && NEVER_SENT.has(error.code);
3537
+ }
3538
+ function outcomeUnknown(why, write2) {
3539
+ return new CommandError(
3540
+ `Outcome unknown: ${why}, so ${write2.mayHave}. It is not retried; check ${write2.check} before trying again.`
3541
+ );
3542
+ }
3543
+ async function sendOnce(ctx, path2, body, write2) {
3544
+ try {
3545
+ return await ctx.client.post(path2, body, { actor: false });
3546
+ } catch (error) {
3547
+ if (error instanceof TransportError) {
3548
+ if (neverSent(error)) {
3549
+ throw new TransportError(
3550
+ `${error.message} \u2014 the request never reached the server, so nothing changed`,
3551
+ error.code
3552
+ );
3553
+ }
3554
+ throw outcomeUnknown(
3555
+ error instanceof MalformedResponseError ? `the answer could not be read (${error.message})` : `the request failed (${error.message}) after it may have reached the server`,
3556
+ write2
3557
+ );
3558
+ }
3559
+ if (error instanceof HttpError && !(error instanceof UpgradeRequiredError) && error.status >= 500) {
3560
+ throw new HttpError(
3561
+ error.status,
3562
+ `${error.message} \u2014 outcome unknown: ${write2.mayHave}. It is not retried; check ${write2.check} before trying again`,
3563
+ error.body,
3564
+ error.retryAfter
3565
+ );
3566
+ }
3567
+ throw error;
3568
+ }
3569
+ }
3570
+ function explainRefusal(error, notFound2, hints) {
3571
+ if (!(error instanceof HttpError) || error instanceof UpgradeRequiredError) return error;
3572
+ const code = typeof error.body["error"] === "string" ? error.body["error"] : "";
3573
+ const own = Object.hasOwn(hints, code) ? hints[code] : void 0;
3574
+ let hint;
3575
+ if (error.status >= 500) hint = own;
3576
+ else if (error.status === 404) hint = notFound2;
3577
+ else if (own !== void 0) hint = own;
3578
+ else if (error.status === 403 && Object.hasOwn(ACCESS_HINTS, code)) hint = ACCESS_HINTS[code];
3579
+ else if (error.status === 400 && code === "bad_request") hint = BAD_REQUEST_HINT;
3580
+ if (hint === void 0) return error;
3581
+ const message = error.status >= 500 && typeof error.body["message"] === "string" ? error.body["message"] : error.message;
3582
+ return new HttpError(error.status, `${message} \u2014 ${hint}`, error.body, error.retryAfter);
3583
+ }
3584
+
3585
+ // src/commands/operator-continue.ts
3586
+ async function jobContinue(ctx, id) {
3587
+ requireJobId(id);
3588
+ const write2 = { mayHave: "the job may have been continued", check: `\`job ${id}\`` };
3589
+ let data;
3590
+ try {
3591
+ data = (await sendOnce(ctx, `/api/v1/operator/jobs/${id}/continue`, {}, write2)).data;
3592
+ } catch (error) {
3593
+ throw explainRefusal(error, notFound(id), refusals(id));
3594
+ }
3595
+ if (typeof data !== "object" || data === null || Array.isArray(data)) {
3596
+ throw outcomeUnknown("the answer carried no job", write2);
3597
+ }
3598
+ const job = withheld(data);
3599
+ return { json: job, human: `Continued Operator job ${id}.
3600
+
3601
+ ` + jobDetail(job) };
3602
+ }
3603
+ function notFound(id) {
3604
+ return `${missingJob(id)}. If you could see the job until now, it may have gone out of sight just as it was continued: check \`job ${id}\` before continuing it again`;
3605
+ }
3606
+ function refusals(id) {
3607
+ return {
3608
+ principal_required: "only the person the job runs as (its execution principal) can continue it",
3609
+ invalid_state: `the job has nothing to continue in its current state; \`job ${id}\` shows where it stands`,
3610
+ reconciliation_required: "reconcile its partial effects in the app first, from the job's workbench link",
3611
+ principal_inactive: "the person the job runs as lost the rights to run it, so it can't continue until those are restored",
3612
+ deliveries_unsettled: `a delivery is still sending, or its outcome is unknown: if \`job ${id}\` shows it waiting on delivery, settle it in the app; otherwise continue it once the delivery has sent`
3613
+ };
3614
+ }
3615
+
2218
3616
  // src/core/textinput.ts
2219
3617
  import { readFileSync as readFileSync3, statSync as statSync3 } from "fs";
2220
3618
  import { isatty } from "tty";
@@ -2334,14 +3732,379 @@ function describe(error) {
2334
3732
  return error instanceof Error ? error.message : String(error);
2335
3733
  }
2336
3734
 
3735
+ // src/commands/operator-controls.ts
3736
+ var STEER_MAX_CHARACTERS = 1e4;
3737
+ var DONE = { steer: "steered", cancel: "cancelled", pause: "paused" };
3738
+ function requireInstruction(instruction) {
3739
+ const trimmed = instruction.trim();
3740
+ if (trimmed === "") throw new UsageError("the instruction is empty \u2014 say what the job should change");
3741
+ const length = graphemeLength(trimmed);
3742
+ if (length > STEER_MAX_CHARACTERS) {
3743
+ throw new UsageError(
3744
+ `the instruction is ${length} characters once trimmed; a job takes at most ${STEER_MAX_CHARACTERS}`
3745
+ );
3746
+ }
3747
+ return trimmed;
3748
+ }
3749
+ function instructionFrom(words, file) {
3750
+ if (file === void 0) return requireInstruction(words.join(" "));
3751
+ if (words.length > 0) {
3752
+ throw new UsageError(
3753
+ "instruction words and --instruction-file both given \u2014 pass one; there is no precedence between them"
3754
+ );
3755
+ }
3756
+ return requireInstruction(resolveTextSources([{ name: "instruction", inline: void 0, file }])["instruction"] ?? "");
3757
+ }
3758
+ async function jobSteer(ctx, id, instruction) {
3759
+ requireJobId(id);
3760
+ const job = await control(ctx, id, "steer", { instruction: requireInstruction(instruction) });
3761
+ return {
3762
+ json: job,
3763
+ human: `Steered Operator job ${id}: the instruction is posted in its room, attributed to you. Work it changes needs a fresh approval (earlier approvals no longer apply), and a run in progress stops to take it.
3764
+
3765
+ ` + jobDetail(job)
3766
+ };
3767
+ }
3768
+ async function jobCancel(ctx, id) {
3769
+ requireJobId(id);
3770
+ const job = await control(ctx, id, "cancel", {});
3771
+ const effects = effectsLine(job["effects"]);
3772
+ return {
3773
+ json: job,
3774
+ human: `Cancelled Operator job ${id}. ` + (effects === null ? "Committed effects stay committed; the answer carried no counts.\n" : `Committed effects stay committed: ${clean(effects)} (staged effects that never ran count as unknown).
3775
+ `) + "\n" + jobDetail(job)
3776
+ };
3777
+ }
3778
+ async function jobPause(ctx, id) {
3779
+ requireJobId(id);
3780
+ const job = await control(ctx, id, "pause", {});
3781
+ return {
3782
+ json: job,
3783
+ human: `Paused Operator job ${id}. Its active-time budget is kept; continue it to run again.
3784
+
3785
+ ` + jobDetail(job)
3786
+ };
3787
+ }
3788
+ async function control(ctx, id, verb, body) {
3789
+ const write2 = { mayHave: `the job may have been ${DONE[verb]}`, check: `\`job ${id}\`` };
3790
+ let data;
3791
+ try {
3792
+ data = (await sendOnce(ctx, `/api/v1/operator/jobs/${id}/${verb}`, body, write2)).data;
3793
+ } catch (error) {
3794
+ throw explained2(error, id, verb);
3795
+ }
3796
+ if (!isRecord3(data)) throw outcomeUnknown("the answer carried no job", write2);
3797
+ return withheld(data);
3798
+ }
3799
+ function explained2(error, id, verb) {
3800
+ if (!(error instanceof HttpError) || error.status >= 500) return error;
3801
+ const hint = refusalHint(error, id, verb);
3802
+ if (hint === void 0) return error;
3803
+ return new HttpError(error.status, `${error.message} \u2014 ${hint}`, error.body, error.retryAfter);
3804
+ }
3805
+ function refusalHint(error, id, verb) {
3806
+ const code = typeof error.body["error"] === "string" ? error.body["error"] : "";
3807
+ const job = `\`job ${id}\``;
3808
+ switch (error.status) {
3809
+ case 400:
3810
+ return code === "bad_request" ? BAD_REQUEST_HINT : void 0;
3811
+ case 403:
3812
+ if (code !== "principal_required") return ACCESS_HINTS[code];
3813
+ return verb === "pause" ? "only the job's execution principal may pause it; as its owner or a collaborator you can steer it instead, which stops its current run" : `only the job's execution principal may ${verb} it; ask them to`;
3814
+ case 404:
3815
+ return missingJob(id);
3816
+ case 409:
3817
+ return conflictHint(code, verb, job);
3818
+ case 422:
3819
+ if (code === "invalid_instruction") {
3820
+ return `pass the instruction as text of 1 to ${STEER_MAX_CHARACTERS} characters once trimmed`;
3821
+ }
3822
+ return code === "validation_error" && verb === "steer" ? "the job's room refused the instruction, so nothing was posted" : void 0;
3823
+ default:
3824
+ return void 0;
3825
+ }
3826
+ }
3827
+ function conflictHint(code, verb, job) {
3828
+ switch (code) {
3829
+ case "invalid_state":
3830
+ return {
3831
+ steer: `the app doesn't offer Steer for the job now: it has finished, failed, waits for approval or needs reconciliation \u2014 ${job} shows where it stands`,
3832
+ cancel: `the job has already ended \u2014 ${job} shows how`,
3833
+ pause: `only a running job can be paused \u2014 ${job} shows where it stands`
3834
+ }[verb];
3835
+ case "reconciliation_required":
3836
+ return `its partial effects need reconciling first: reconcile them in the app (${job} has the workbench link)`;
3837
+ case "deliveries_unsettled":
3838
+ return `a delivery is still sending, or its outcome is unknown: if ${job} shows it waiting on delivery, settle that in the app (the workbench link is there); otherwise ${verb} it again once the delivery has sent`;
3839
+ case "principal_inactive":
3840
+ return "nothing changed: the job's execution principal needs their admin or member role back first";
3841
+ case "cancellation_scope_changed":
3842
+ return `the job was not cancelled; read it with ${job} and decide again`;
3843
+ default:
3844
+ return void 0;
3845
+ }
3846
+ }
3847
+ function isRecord3(value) {
3848
+ return typeof value === "object" && value !== null && !Array.isArray(value);
3849
+ }
3850
+
3851
+ // src/commands/operator-procedures.ts
3852
+ var PROCEDURE_FIELDS = [
3853
+ "id",
3854
+ "name",
3855
+ "job_id",
3856
+ "job_url",
3857
+ "current_version",
3858
+ "parameter_schema",
3859
+ "versions",
3860
+ "inserted_at",
3861
+ "updated_at"
3862
+ ];
3863
+ var PROCEDURE_VERSION_FIELDS = [
3864
+ "version",
3865
+ "recipe_digest",
3866
+ "capability_ids",
3867
+ "action_roster",
3868
+ "inserted_at"
3869
+ ];
3870
+ var RUN_NOTE = "A run is a new job: fresh inputs, your current rights and a change set that needs its own approval. Nothing of the saved job carries over. Approving happens in the app; the CLI cannot approve.\n";
3871
+ function requireProcedureId(id) {
3872
+ if (!isUuid(id)) throw new UsageError(`not an Operator procedure id: ${id} (expected the procedure's UUID)`);
3873
+ }
3874
+ function missingProcedure(id) {
3875
+ return `no Operator procedure ${id} you can see: it does not exist or was deleted, is in another organization, was saved from a job you can't see, or was saved for the whole organization rather than a job (or this Harness server has no procedures API yet)`;
3876
+ }
3877
+ function requireProcedureName(name) {
3878
+ if (name === void 0 || name.trim() === "") {
3879
+ throw new UsageError('a procedure needs a name: procedure save <job-id> --name "\u2026"');
3880
+ }
3881
+ return name;
3882
+ }
3883
+ async function procedureSave(ctx, jobId, name) {
3884
+ requireJobId(jobId);
3885
+ const named = requireProcedureName(name);
3886
+ const write2 = {
3887
+ mayHave: "a procedure may have been saved",
3888
+ check: `the job's procedures with \`job ${jobId}\``
3889
+ };
3890
+ let data;
3891
+ try {
3892
+ data = (await sendOnce(ctx, `/api/v1/operator/jobs/${jobId}/procedures`, { name: named }, write2)).data;
3893
+ } catch (error) {
3894
+ throw explainRefusal(error, missingJob(jobId), {
3895
+ owner_required: "only the job's owner can save it as a procedure; ask them to save it",
3896
+ not_saveable: "only a job that completed with its work verified, and has work to repeat, can be saved",
3897
+ validation_error: "the name must be plain text, without control characters"
3898
+ });
3899
+ }
3900
+ if (!isRecord4(data) || typeof data["id"] !== "string" || data["id"] === "") {
3901
+ throw outcomeUnknown("the answer named no procedure", write2);
3902
+ }
3903
+ const saved = `Saved job ${jobId} as procedure ${clean(data["id"])}`;
3904
+ if (!Array.isArray(data["versions"])) {
3905
+ return {
3906
+ json: pick2(data, ["id", "url"]),
3907
+ human: `${saved}, but it can't be read back as you.
3908
+ ` + labelled([["Workbench", str6(data["url"]) || null]])
3909
+ };
3910
+ }
3911
+ const procedure = procedureJson(data);
3912
+ return { json: procedure, human: `${saved}.
3913
+
3914
+ ` + procedureDetail(procedure) };
3915
+ }
3916
+ async function procedureShow(ctx, id) {
3917
+ requireProcedureId(id);
3918
+ const body = await explained(() => ctx.client.get(`/api/v1/operator/procedures/${id}`), {
3919
+ notFound: missingProcedure(id)
3920
+ });
3921
+ if (!isRecord4(body.data)) throw new CommandError("The procedure response carried no procedure.");
3922
+ const procedure = procedureJson(body.data);
3923
+ return { json: procedure, human: procedureDetail(procedure) };
3924
+ }
3925
+ function prepareProcedureRun(id, opts) {
3926
+ requireProcedureId(id);
3927
+ const body = {};
3928
+ const version2 = runVersion(opts.version);
3929
+ if (version2 !== void 0) body["version"] = version2;
3930
+ const given = [
3931
+ opts.param !== void 0 && opts.param.length > 0 ? "--param" : null,
3932
+ opts.paramsFile !== void 0 ? "--params-file" : null,
3933
+ opts.parameters !== void 0 ? "parameters" : null
3934
+ ].filter((source) => source !== null);
3935
+ if (given.length > 1) {
3936
+ throw new UsageError(`${given.join(" and ")} both given \u2014 pass the parameters one way; there is no precedence`);
3937
+ }
3938
+ if (opts.parameters !== void 0 && !isRecord4(opts.parameters)) {
3939
+ throw new UsageError("parameters must be one object of names and values");
3940
+ }
3941
+ const fromFlags = opts.param !== void 0 && opts.param.length > 0 ? parseParams(opts.param) : void 0;
3942
+ if (opts.group !== void 0) body["group_id"] = bareId(opts.group, "--group");
3943
+ if (opts.source !== void 0) body["source_resource_id"] = bareId(opts.source, "--source");
3944
+ const parameters = fromFlags ?? opts.parameters ?? (opts.paramsFile === void 0 ? void 0 : readParamsFile(opts.paramsFile));
3945
+ if (parameters !== void 0) body["parameters"] = parameters;
3946
+ return { id, body };
3947
+ }
3948
+ async function startProcedureRun(ctx, request) {
3949
+ const { id } = request;
3950
+ const write2 = { mayHave: "a job may have been started", check: "`jobs` for the new job" };
3951
+ let data;
3952
+ try {
3953
+ data = (await sendOnce(ctx, `/api/v1/operator/procedures/${id}/runs`, request.body, write2)).data;
3954
+ } catch (error) {
3955
+ throw explainRefusal(error, missingProcedure(id), {
3956
+ version_conflict: `the procedure has a newer current version (\`procedure show ${id}\` names it): run that one, or run without a version to take the current one`,
3957
+ concurrent_change: `read the procedure again (\`procedure show ${id}\`) before running it again`,
3958
+ invalid_rerun: `check the parameters against the procedure's parameter schema (\`procedure show ${id}\`), and that your current rights cover the run`,
3959
+ // The server refuses with it before the run starts.
3960
+ service_unavailable: "nothing was started; try again later"
3961
+ });
3962
+ }
3963
+ if (!isRecord4(data) || typeof data["id"] !== "string" || data["id"] === "") {
3964
+ throw outcomeUnknown("the answer named no job", write2);
3965
+ }
3966
+ const started = `Started Operator job ${clean(data["id"])} from procedure ${id}`;
3967
+ if (typeof data["state"] !== "string") {
3968
+ return {
3969
+ json: pick2(data, ["id", "url"]),
3970
+ human: `${started}, but it can't be read back as you.
3971
+ ` + labelled([["Workbench", str6(data["url"]) || null]]) + "\n" + RUN_NOTE
3972
+ };
3973
+ }
3974
+ const job = withheld(data);
3975
+ const waitingOn = str6(job["waiting_on"]);
3976
+ return {
3977
+ json: job,
3978
+ human: `${started}.
3979
+ ` + labelled([
3980
+ ["State", str6(job["state"]) + (waitingOn === "" ? "" : ` \xB7 waiting on ${waitingOn}`)],
3981
+ ["Workbench", str6(job["url"]) || null],
3982
+ ["Approve at", str6(job["approval_url"]) || null]
3983
+ ]) + "\n" + RUN_NOTE
3984
+ };
3985
+ }
3986
+ async function procedureRun(ctx, id, opts) {
3987
+ return startProcedureRun(ctx, prepareProcedureRun(id, opts));
3988
+ }
3989
+ function runVersion(version2) {
3990
+ if (version2 === void 0) return void 0;
3991
+ const text = String(version2).trim();
3992
+ const parsed = typeof version2 === "number" ? version2 : /^\d+$/.test(text) ? Number(text) : Number.NaN;
3993
+ if (!Number.isSafeInteger(parsed) || parsed < 1) {
3994
+ throw new UsageError(`--version takes a positive integer, the version expected to be current (got ${text})`);
3995
+ }
3996
+ return parsed;
3997
+ }
3998
+ function parseParams(entries) {
3999
+ const parsed = [];
4000
+ const names = /* @__PURE__ */ new Set();
4001
+ for (const entry of entries) {
4002
+ const equals = entry.indexOf("=");
4003
+ if (equals === -1) throw new UsageError(`--param ${entry}: expected name=value, or name:=<json> for a JSON value`);
4004
+ const typed = equals > 0 && entry[equals - 1] === ":";
4005
+ const name = entry.slice(0, typed ? equals - 1 : equals);
4006
+ const raw = entry.slice(equals + 1);
4007
+ if (name.trim() === "") throw new UsageError(`--param ${entry}: the parameter has no name`);
4008
+ if (name !== name.trim()) {
4009
+ throw new UsageError(`--param ${entry}: the name starts or ends with whitespace \u2014 write it without`);
4010
+ }
4011
+ if (names.has(name)) throw new UsageError(`--param ${name} given twice \u2014 pass each parameter once`);
4012
+ names.add(name);
4013
+ parsed.push([name, typed ? jsonValue(raw, `--param ${entry}`) : raw]);
4014
+ }
4015
+ return Object.fromEntries(parsed);
4016
+ }
4017
+ function jsonValue(text, what) {
4018
+ try {
4019
+ return JSON.parse(text);
4020
+ } catch (error) {
4021
+ throw new UsageError(`${what}: not valid JSON after := (${firstLine(error)}); quote a string as name=value`);
4022
+ }
4023
+ }
4024
+ function readParamsFile(path2) {
4025
+ const text = readFileOption("--params-file", path2);
4026
+ let parsed;
4027
+ try {
4028
+ parsed = JSON.parse(text);
4029
+ } catch (error) {
4030
+ throw new UsageError(`--params-file ${path2}: not valid JSON (${firstLine(error)})`);
4031
+ }
4032
+ if (!isRecord4(parsed)) {
4033
+ throw new UsageError(`--params-file ${path2}: the parameters are one JSON object of names and values`);
4034
+ }
4035
+ return parsed;
4036
+ }
4037
+ function bareId(value, flag) {
4038
+ if (value.trim() === "") throw new UsageError(`${flag} is empty`);
4039
+ if (value !== value.trim()) throw new UsageError(`${flag} starts or ends with whitespace \u2014 pass the id alone`);
4040
+ return value;
4041
+ }
4042
+ function procedureJson(data) {
4043
+ const procedure = pick2(data, PROCEDURE_FIELDS);
4044
+ if ("versions" in procedure) {
4045
+ procedure["versions"] = records4(procedure["versions"]).map((version2) => pick2(version2, PROCEDURE_VERSION_FIELDS));
4046
+ }
4047
+ return procedure;
4048
+ }
4049
+ function procedureDetail(procedure) {
4050
+ const current = procedure["current_version"];
4051
+ const versions = records4(procedure["versions"]).map(
4052
+ (version2) => ` ${clean(`v${str6(version2["version"])}${version2["version"] === current ? " (current)" : ""} ${str6(version2["inserted_at"])} recipe ${str6(version2["recipe_digest"])}`)}
4053
+ ${"Capabilities:".padEnd(15)}${clean(list2(version2["capability_ids"]))}
4054
+ ${"Actions:".padEnd(15)}${clean(list2(version2["action_roster"]))}
4055
+ `
4056
+ );
4057
+ return `Procedure ${clean(procedure["name"])} \xB7 v${clean(current)} \xB7 ${clean(procedure["id"])}
4058
+ ` + labelled([
4059
+ ["Saved from", `job ${str6(procedure["job_id"])}`],
4060
+ ["Workbench", str6(procedure["job_url"]) || null],
4061
+ ["Created", str6(procedure["inserted_at"]) || null],
4062
+ ["Updated", str6(procedure["updated_at"]) || null]
4063
+ ]) + (versions.length === 0 ? "\nVersions: none\n" : "\nVersions:\n" + versions.join("")) + parametersSection(procedure["parameter_schema"]) + "\n" + RUN_NOTE;
4064
+ }
4065
+ function parametersSection(schema) {
4066
+ if (!isRecord4(schema) || Object.keys(schema).length === 0) return "\nParameters a run takes: none\n";
4067
+ const lines = JSON.stringify(schema, null, 2).split("\n").map((line) => ` ${clean(line)}
4068
+ `).join("");
4069
+ const scoped = isRecord4(schema["scoped_inputs"]) ? schema["scoped_inputs"]["required"] : void 0;
4070
+ const scopes = Array.isArray(scoped) ? scoped.map(str6) : [];
4071
+ let needs = "";
4072
+ if (scopes.includes("contact_reconciliation")) {
4073
+ needs = "Its runs also need --group <contact-group-id> and --source <uploaded-file-id> (contact_reconciliation).\n";
4074
+ } else if (scopes.length > 0) {
4075
+ needs = `Its runs also need inputs resolved afresh: ${clean(scopes.join(", "))}.
4076
+ `;
4077
+ }
4078
+ return "\nParameters a run takes (JSON schema):\n" + lines + needs;
4079
+ }
4080
+ function list2(value) {
4081
+ return (Array.isArray(value) ? value.map(str6).join(", ") : "") || "none";
4082
+ }
4083
+ function pick2(record, keys) {
4084
+ return Object.fromEntries(keys.filter((key) => key in record).map((key) => [key, record[key]]));
4085
+ }
4086
+ function records4(value) {
4087
+ return Array.isArray(value) ? value.filter(isRecord4) : [];
4088
+ }
4089
+ function isRecord4(value) {
4090
+ return typeof value === "object" && value !== null && !Array.isArray(value);
4091
+ }
4092
+ function str6(value) {
4093
+ return value === null || value === void 0 ? "" : String(value);
4094
+ }
4095
+ function firstLine(error) {
4096
+ return (error instanceof Error ? error.message : String(error)).split("\n")[0] ?? "";
4097
+ }
4098
+
2337
4099
  // src/commands/plans.ts
2338
4100
  import { extname as extname2 } from "path";
2339
4101
  import { parseDocument } from "yaml";
2340
4102
  var PLAN_EXECUTORS = ["none", "operator"];
2341
4103
  var CONDITION_KINDS = ["not_before", "gate", "event"];
4104
+ var GATE_OWNER_ROLES = ["gp", "compliance_officer", "valuer", "accountant", "auditor", "counsel"];
2342
4105
  function parsePlanDocument(text, source) {
2343
4106
  const parsed = extname2(source).toLowerCase() === ".json" ? parseJson(text, source) : parseYamlDocument(text, source);
2344
- if (!isRecord(parsed)) {
4107
+ if (!isRecord5(parsed)) {
2345
4108
  throw new UsageError(`${source}: a plan file holds one object (a mapping), not a list or a scalar`);
2346
4109
  }
2347
4110
  return parsed;
@@ -2350,18 +4113,18 @@ function parseJson(text, source) {
2350
4113
  try {
2351
4114
  return JSON.parse(text);
2352
4115
  } catch (error) {
2353
- throw new UsageError(`${source}: not valid JSON (${firstLine(error)})`);
4116
+ throw new UsageError(`${source}: not valid JSON (${firstLine2(error)})`);
2354
4117
  }
2355
4118
  }
2356
4119
  function parseYamlDocument(text, source) {
2357
4120
  const document = parseDocument(text, { resolveKnownTags: false });
2358
4121
  const problem = document.errors[0] ?? document.warnings[0];
2359
- if (problem !== void 0) throw new UsageError(`${source}: not valid YAML (${firstLine(problem)})`);
4122
+ if (problem !== void 0) throw new UsageError(`${source}: not valid YAML (${firstLine2(problem)})`);
2360
4123
  let value;
2361
4124
  try {
2362
4125
  value = document.toJS();
2363
4126
  } catch (error) {
2364
- throw new UsageError(`${source}: not valid YAML (${firstLine(error)})`);
4127
+ throw new UsageError(`${source}: not valid YAML (${firstLine2(error)})`);
2365
4128
  }
2366
4129
  if (!jsonSafe(value)) {
2367
4130
  throw new UsageError(`${source}: holds a value JSON cannot carry (.inf or .nan) \u2014 write a number or a string`);
@@ -2374,7 +4137,7 @@ function jsonSafe(value) {
2374
4137
  if (typeof value === "object" && value !== null) return Object.values(value).every(jsonSafe);
2375
4138
  return true;
2376
4139
  }
2377
- function firstLine(error) {
4140
+ function firstLine2(error) {
2378
4141
  return (error instanceof Error ? error.message : String(error)).split("\n")[0] ?? "";
2379
4142
  }
2380
4143
  function readPlanFile(path2, flag = "--file") {
@@ -2391,7 +4154,7 @@ async function planCreate(ctx, plan, opts) {
2391
4154
  throw explainMissing(error, `no team ${team} here, or this Harness server has no plan API yet`);
2392
4155
  }
2393
4156
  const created = body.data;
2394
- const id = str4(created["id"]);
4157
+ const id = str7(created["id"]);
2395
4158
  return {
2396
4159
  json: created,
2397
4160
  human: `Created plan ${clean(id)} (${clean(created["status"]) || "held"})
@@ -2428,7 +4191,7 @@ async function planRelease(ctx, id, opts) {
2428
4191
  if (opts.spendBudgetCents !== void 0) body["spend_budget_cents"] = opts.spendBudgetCents;
2429
4192
  const plan = await planRequest(id, () => ctx.client.post(`/api/v1/plans/${id}/release`, body));
2430
4193
  const revision = plan["revision"] ?? {};
2431
- const ready2 = Array.isArray(plan["steps"]) ? plan["steps"].filter((step) => isRecord(step) && step["ready"] === true).length : 0;
4194
+ const ready2 = Array.isArray(plan["steps"]) ? plan["steps"].filter((step) => isRecord5(step) && step["ready"] === true).length : 0;
2432
4195
  return {
2433
4196
  json: plan,
2434
4197
  human: `Released plan ${clean(id)} as revision ${clean(revision["number"]) || "?"}: ${ready2} ${ready2 === 1 ? "step" : "steps"} ready now
@@ -2447,23 +4210,22 @@ async function planHold(ctx, id) {
2447
4210
  async function gateSet(ctx, issue, gate, satisfied) {
2448
4211
  requireIdentifiers([issue]);
2449
4212
  const wanted = gate.trim();
2450
- if (wanted === "") throw new UsageError("name the gate by its label or its condition id");
2451
- let conditions;
4213
+ if (wanted === "") throw new UsageError("name the gate by its key, its label or its condition id");
4214
+ const expert = isExpertToken(ctx.config.token);
4215
+ const list3 = await readConditions(ctx, issue, expert);
4216
+ const target = findGate(list3, issue, wanted, expert);
4217
+ const id = str7(target["id"]);
4218
+ let body;
2452
4219
  try {
2453
- conditions = (await ctx.client.get(`/api/v1/issues/${issue}/conditions`)).data;
4220
+ body = await ctx.client.put(`/api/v1/issues/${issue}/conditions/${id}/satisfaction`, { satisfied });
2454
4221
  } catch (error) {
2455
- throw explainMissing(error, `no issue ${issue} here, or this Harness server has no wait conditions yet`);
4222
+ throw await explainNotGateOwner(ctx, error, target, issue, satisfied);
2456
4223
  }
2457
- if (!Array.isArray(conditions)) throw new CommandError("The conditions response carried no list.");
2458
- const list2 = conditions.filter(isRecord);
2459
- const target = findGate(list2, issue, wanted);
2460
- const id = str4(target["id"]);
2461
- const body = await ctx.client.put(`/api/v1/issues/${issue}/conditions/${id}/satisfaction`, {
2462
- satisfied
2463
- });
2464
- const others = list2.filter((condition) => condition["id"] !== id && condition["satisfied"] !== true).length;
4224
+ const others = list3.filter((condition) => condition["id"] !== id && condition["satisfied"] !== true).length;
2465
4225
  let tail = "";
2466
- if (satisfied) {
4226
+ if (satisfied && expert) {
4227
+ tail = others > 0 ? ` \u2014 ${others} other gate${others === 1 ? "" : "s"} of yours on it ${others === 1 ? "is" : "are"} open` : "";
4228
+ } else if (satisfied) {
2467
4229
  tail = others > 0 ? ` \u2014 it still waits on ${others} other condition${others === 1 ? "" : "s"}` : " \u2014 no other wait condition holds it";
2468
4230
  }
2469
4231
  return {
@@ -2472,11 +4234,41 @@ async function gateSet(ctx, issue, gate, satisfied) {
2472
4234
  `
2473
4235
  };
2474
4236
  }
4237
+ async function conditionList(ctx, issue) {
4238
+ requireIdentifiers([issue]);
4239
+ const expert = isExpertToken(ctx.config.token);
4240
+ const list3 = await readConditions(ctx, issue, expert);
4241
+ let human;
4242
+ if (list3.length === 0) {
4243
+ human = expert ? `You own no gate on ${issue}.
4244
+ ` : `No wait conditions on ${issue}.
4245
+ `;
4246
+ } else {
4247
+ human = (expert ? `Your gates on ${issue}:
4248
+ ` : `Wait conditions on ${issue}:
4249
+ `) + list3.map((condition) => ` ${conditionLine(condition, { expertView: expert })}
4250
+ `).join("");
4251
+ }
4252
+ return { json: list3, human };
4253
+ }
4254
+ async function readConditions(ctx, issue, expert) {
4255
+ let conditions;
4256
+ try {
4257
+ conditions = (await ctx.client.get(`/api/v1/issues/${issue}/conditions`)).data;
4258
+ } catch (error) {
4259
+ throw explainMissing(
4260
+ error,
4261
+ expert ? `no step ${issue} assigned to you: a pxc_ credential reads only its own steps` : `no issue ${issue} here, or this Harness server has no wait conditions yet`
4262
+ );
4263
+ }
4264
+ if (!Array.isArray(conditions)) throw new CommandError("The conditions response carried no list.");
4265
+ return conditions.filter(isRecord5);
4266
+ }
2475
4267
  function requirePlanId(id) {
2476
4268
  if (!isUuid(id)) throw new UsageError(`not a plan id: ${id} (expected the plan's UUID)`);
2477
4269
  }
2478
4270
  function requireDocument(value, what) {
2479
- if (!isRecord(value)) throw new UsageError(what);
4271
+ if (!isRecord5(value)) throw new UsageError(what);
2480
4272
  }
2481
4273
  async function planRequest(id, send) {
2482
4274
  try {
@@ -2489,39 +4281,97 @@ function explainMissing(error, why) {
2489
4281
  if (!(error instanceof HttpError) || error.status !== 404) return error;
2490
4282
  return new HttpError(404, `${error.message} \u2014 ${why}`, error.body, error.retryAfter);
2491
4283
  }
2492
- function findGate(conditions, issue, wanted) {
2493
- const gates = conditions.filter((condition) => condition["kind"] === "gate");
2494
- const label = (condition) => str4(condition["label"]).trim();
2495
- if (isUuid(wanted) && !gates.some((condition) => label(condition) === wanted)) {
2496
- const byId = conditions.find((condition) => str4(condition["id"]).toLowerCase() === wanted.toLowerCase());
2497
- if (byId === void 0) throw new CommandError(`No condition ${wanted} on ${issue}.`);
2498
- if (byId["kind"] !== "gate") {
4284
+ async function explainNotGateOwner(ctx, error, gate, issue, satisfied) {
4285
+ if (!(error instanceof HttpError) || error.status !== 403 || error.body["reason"] !== "not_gate_owner") return error;
4286
+ let current;
4287
+ let currentOwner = error.body["owner"];
4288
+ if (currentOwner !== null && !isRecord5(currentOwner)) {
4289
+ try {
4290
+ const fresh = (await ctx.client.get(`/api/v1/issues/${issue}/conditions`)).data;
4291
+ if (Array.isArray(fresh)) current = fresh.filter(isRecord5).find((condition) => condition["id"] === gate["id"]);
4292
+ } catch {
4293
+ }
4294
+ currentOwner = current?.["owner"];
4295
+ }
4296
+ const owner = isRecord5(currentOwner) ? ownerText(currentOwner) : "";
4297
+ const subject = `its owner${owner === "" ? "" : `, ${owner},`}`;
4298
+ const named = `gate "${clean((current ?? gate)["label"])}" on ${issue}`;
4299
+ const message = satisfied ? `Cannot satisfy ${named}: only ${subject} can tick it` : `Cannot un-satisfy ${named}: only ${subject} or Harness staff can untick it`;
4300
+ return new HttpError(error.status, message, error.body, error.retryAfter);
4301
+ }
4302
+ var gateLabel = (condition) => str7(condition["label"]).trim();
4303
+ var gateKey = (condition) => str7(condition["key"]);
4304
+ function exactMatches(conditions, wanted) {
4305
+ const id = isUuid(wanted) ? wanted.toLowerCase() : null;
4306
+ return conditions.filter((condition) => {
4307
+ if (id !== null && str7(condition["id"]).toLowerCase() === id) return true;
4308
+ if (condition["kind"] !== "gate") return false;
4309
+ const key = gateKey(condition);
4310
+ return key !== "" && key === (id ?? wanted) || gateLabel(condition) === wanted;
4311
+ });
4312
+ }
4313
+ function ambiguous(conditions, issue, wanted, matches) {
4314
+ const listed = matches.map((condition) => {
4315
+ const what = condition["kind"] === "gate" ? `gate ${gateName({ label: gateLabel(condition), key: gateKey(condition) })}` : `${clean(condition["kind"])} condition`;
4316
+ return `${clean(condition["id"])} (${what})`;
4317
+ }).join(", ");
4318
+ const unnameable = matches.map((condition) => str7(condition["id"])).filter((id) => exactMatches(conditions, id).length !== 1).map((id) => clean(id));
4319
+ const caveat = unnameable.length === 0 ? "" : ` (${unnameable.join(", ")} ${unnameable.length === 1 ? "is" : "are"} also another gate's key or label, so ${unnameable.length === 1 ? "it" : "they"} cannot be named by id)`;
4320
+ return new CommandError(
4321
+ `several gates on ${issue} match "${wanted}": ${listed} \u2014 pass the condition id of the one you mean${caveat}`
4322
+ );
4323
+ }
4324
+ function findGate(conditions, issue, wanted, expert = false) {
4325
+ const exact = exactMatches(conditions, wanted);
4326
+ if (exact.length > 1) throw ambiguous(conditions, issue, wanted, exact);
4327
+ if (exact.length === 1) {
4328
+ const only = exact[0];
4329
+ if (only["kind"] !== "gate") {
2499
4330
  throw new CommandError(
2500
- `condition ${wanted} on ${issue} is a ${clean(byId["kind"])} condition \u2014 only a gate is satisfied by hand`
4331
+ `condition ${wanted} on ${issue} is a ${clean(only["kind"])} condition \u2014 only a gate is satisfied by hand`
2501
4332
  );
2502
4333
  }
2503
- return byId;
2504
- }
2505
- let matches = gates.filter((condition) => label(condition) === wanted);
2506
- if (matches.length === 0) {
2507
- matches = gates.filter((condition) => label(condition).toLowerCase() === wanted.toLowerCase());
4334
+ return only;
2508
4335
  }
2509
- if (matches.length === 1) return matches[0];
2510
- if (matches.length > 1) {
2511
- const ids = matches.map((condition) => clean(condition["id"])).join(", ");
2512
- throw new CommandError(`several gates on ${issue} match "${wanted}" \u2014 pass the condition id: ${ids}`);
4336
+ const gates = conditions.filter((condition) => condition["kind"] === "gate");
4337
+ const folded = wanted.toLowerCase();
4338
+ const loose = gates.filter(
4339
+ (condition) => gateLabel(condition).toLowerCase() === folded || gateKey(condition) !== "" && gateKey(condition) === folded
4340
+ );
4341
+ if (loose.length > 1) throw ambiguous(conditions, issue, wanted, loose);
4342
+ if (loose.length === 1) return loose[0];
4343
+ if (isUuid(wanted)) throw new CommandError(`No condition ${wanted} on ${issue}${expert ? " that you own" : ""}.`);
4344
+ const none = expert ? `No gate of yours on ${issue}` : `No gate on ${issue}`;
4345
+ if (gates.length === 0) {
4346
+ throw new CommandError(`${none} matches "${wanted}" \u2014 ${expert ? "you own no gate on it" : "it has no gates"}`);
2513
4347
  }
2514
- if (gates.length === 0) throw new CommandError(`No gate on ${issue} matches "${wanted}" \u2014 it has no gates`);
2515
- const labels = gates.map((condition) => `"${clean(label(condition))}"`).join(", ");
2516
- throw new CommandError(`No gate on ${issue} matches "${wanted}". Its gates: ${labels}`);
4348
+ const names = gates.map((condition) => gateName({ label: gateLabel(condition), key: gateKey(condition) })).join(", ");
4349
+ throw new CommandError(`${none} matches "${wanted}". ${expert ? "Your" : "Its"} gates: ${names}`);
2517
4350
  }
2518
- function isRecord(value) {
4351
+ function isRecord5(value) {
2519
4352
  return typeof value === "object" && value !== null && !Array.isArray(value);
2520
4353
  }
2521
- function str4(value) {
4354
+ function str7(value) {
2522
4355
  return value === null || value === void 0 ? "" : String(value);
2523
4356
  }
2524
4357
 
4358
+ // src/commands/expert.ts
4359
+ async function assignments(ctx) {
4360
+ const data = (await ctx.client.get(EXPERT_ASSIGNMENTS_PATH)).data;
4361
+ if (!isRecord6(data) || !Array.isArray(data["assignments"])) {
4362
+ throw new CommandError("The assignments response carried no list.");
4363
+ }
4364
+ const steps = data["assignments"].filter(isRecord6);
4365
+ const expert = isRecord6(data["expert"]) ? data["expert"] : {};
4366
+ const plan = isRecord6(data["plan"]) ? { id: data["plan"]["id"], status: data["plan"]["status"] } : null;
4367
+ const header = expertSummary({ role: expert["role"], plan, assigned_steps: steps.length });
4368
+ return { json: data, human: `${header}
4369
+ ${expertAssignments(steps)}` };
4370
+ }
4371
+ function isRecord6(value) {
4372
+ return typeof value === "object" && value !== null && !Array.isArray(value);
4373
+ }
4374
+
2525
4375
  // src/core/credentials.ts
2526
4376
  import { chmodSync as chmodSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync4, renameSync as renameSync2, rmSync as rmSync2, writeFileSync as writeFileSync2 } from "fs";
2527
4377
  import { homedir } from "os";
@@ -2590,13 +4440,13 @@ function readRepoConfig(path2) {
2590
4440
  const record = parsed;
2591
4441
  const config = { path: path2 };
2592
4442
  const warnings = [];
2593
- for (const field2 of ["team", "url", "org"]) {
2594
- const value = record[field2];
4443
+ for (const field3 of ["team", "url", "org"]) {
4444
+ const value = record[field3];
2595
4445
  if (value === void 0) continue;
2596
4446
  if (typeof value === "string" && value.trim() !== "") {
2597
- config[field2] = value.trim();
4447
+ config[field3] = value.trim();
2598
4448
  } else {
2599
- warnings.push(`harness: ignoring "${field2}" in ${path2} \u2014 expected a non-empty string`);
4449
+ warnings.push(`harness: ignoring "${field3}" in ${path2} \u2014 expected a non-empty string`);
2600
4450
  }
2601
4451
  }
2602
4452
  return { config, warnings };
@@ -2753,6 +4603,9 @@ harness:read scope \u2014 plus harness:write only where the workflow
2753
4603
  creates or closes \u2014 set an expiry, and export it:
2754
4604
 
2755
4605
  export HARNESS_API_TOKEN=aone_...
4606
+
4607
+ An outside expert uses the pxc_ credential minted in their expert view:
4608
+ export it the same way, or paste it into \`harness login --no-browser\`.
2756
4609
  `;
2757
4610
  }
2758
4611
  function blankToNull(value) {
@@ -2768,9 +4621,15 @@ export {
2768
4621
  LocalError,
2769
4622
  CommandError,
2770
4623
  CLI_VERSION,
4624
+ CLI_PACKAGE,
4625
+ EXPERT_ASSIGNMENTS_PATH,
4626
+ isExpertToken,
4627
+ EXPERT_LAPSED,
4628
+ explainExpertRefusal,
2771
4629
  Client,
2772
4630
  clean,
2773
4631
  json,
4632
+ expertSummary,
2774
4633
  errorDetails,
2775
4634
  MAX_IDEMPOTENCY_KEY_BYTES,
2776
4635
  IDEMPOTENCY_KEY_FORMAT,
@@ -2820,7 +4679,7 @@ export {
2820
4679
  linkAdd,
2821
4680
  linkUpdate,
2822
4681
  linkRm,
2823
- labelList,
4682
+ labelList2 as labelList,
2824
4683
  labelCreate,
2825
4684
  teamCreate,
2826
4685
  teamList,
@@ -2838,9 +4697,45 @@ export {
2838
4697
  memberAdd,
2839
4698
  memberSetRole,
2840
4699
  upload,
4700
+ toolsList,
4701
+ toolsSearch,
4702
+ toolsDomains,
4703
+ toolShow,
4704
+ skillsList,
4705
+ requireCapabilityId,
4706
+ requireSearchQuery,
4707
+ requireToolsOpts,
4708
+ requireSchemaView,
4709
+ JOB_STATES,
4710
+ JOB_STATUS_ALIASES,
4711
+ JOBS_MAX_LIMIT,
4712
+ JOBS_MAX_PAGES,
4713
+ EXIT_WAITING_FOR_PERSON,
4714
+ EXIT_JOB_ENDED,
4715
+ EXIT_INTERRUPTED,
4716
+ requireJobsArgs,
4717
+ jobsList,
4718
+ requireJobId,
4719
+ jobShow,
4720
+ jobWatch,
4721
+ watchStopSignal,
4722
+ jobContinue,
2841
4723
  resolveTextSources,
4724
+ STEER_MAX_CHARACTERS,
4725
+ instructionFrom,
4726
+ jobSteer,
4727
+ jobCancel,
4728
+ jobPause,
4729
+ requireProcedureId,
4730
+ requireProcedureName,
4731
+ procedureSave,
4732
+ procedureShow,
4733
+ prepareProcedureRun,
4734
+ startProcedureRun,
4735
+ procedureRun,
2842
4736
  PLAN_EXECUTORS,
2843
4737
  CONDITION_KINDS,
4738
+ GATE_OWNER_ROLES,
2844
4739
  readPlanFile,
2845
4740
  planCreate,
2846
4741
  planShow,
@@ -2848,7 +4743,9 @@ export {
2848
4743
  planRelease,
2849
4744
  planHold,
2850
4745
  gateSet,
4746
+ conditionList,
2851
4747
  requirePlanId,
4748
+ assignments,
2852
4749
  defaultCredentialsPath,
2853
4750
  readCredentials,
2854
4751
  writeCredentials,