omp-conductor 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cli.ts CHANGED
@@ -36,6 +36,7 @@ import {
36
36
  startHerdrFleet,
37
37
  } from "./fleet.ts";
38
38
  import { formatGraphSetup, graphRepos, writeGraphSetup, type GraphSetupWrite } from "./graph.ts";
39
+ import { readBaseChain } from "./gitops.ts";
39
40
  import {
40
41
  clearRecord,
41
42
  DEFAULT_PORT,
@@ -48,6 +49,7 @@ import {
48
49
  } from "./lifecycle.ts";
49
50
  import { STALL_MARKER_FILE } from "./orchestrator-tick.ts";
50
51
  import { digestDedupeKey } from "./reports.ts";
52
+ import { digestDue } from "./digest-schedule.ts";
51
53
  import {
52
54
  briefPathForProject,
53
55
  policyPathForProject,
@@ -61,7 +63,7 @@ import { formatVerbLedgerEntry } from "./verbs/ledger.ts";
61
63
  import { makeTracker } from "./tracker/github.ts";
62
64
  import { githubVerbActions } from "./verbs/actions.ts";
63
65
  import { handleVerbCall, type VerbChannel } from "./verbs/server.ts";
64
- import { REPORT_KINDS, VERB_NAMES } from "./types.ts";
66
+ import { REPORT_KINDS, DEFAULT_REPORT_POLICY, VERB_NAMES } from "./types.ts";
65
67
  import type { ProjectConfig, ReportKind } from "./types.ts";
66
68
  import { formatUnblock, unblockIssue } from "./unblock.ts";
67
69
  import { DEFAULT_DEPS, drainAndRestart, upgradeConductor, type UpgradeDeps } from "./upgrade.ts";
@@ -102,6 +104,8 @@ usage:
102
104
  omp-conductor release-pane [--project NAME]
103
105
  omp-conductor tail <issue> [--project NAME]
104
106
  omp-conductor extend <issue> --turns N [--project NAME]
107
+ omp-conductor worker pause <issue> [--project NAME]
108
+ omp-conductor worker resume <issue> [--project NAME]
105
109
  omp-conductor unblock <issue> [--force] [--no-requeue] [--project NAME]
106
110
  omp-conductor verb <conductor_*> [--project NAME] [--arg k=v ...]
107
111
  omp-conductor daemon [--once] [--port N] [--project NAME]
@@ -170,6 +174,9 @@ usage:
170
174
  transcript has stopped growing.
171
175
  extend monotonically raise a live run's turn ceiling without restarting its
172
176
  session. Refuses settled runs and values at or below its current cap.
177
+ worker cooperatively pause one live worker at harness idle, then resume the
178
+ same session with a continuation prompt. Its wall clock is frozen
179
+ while parked. Distinct from fleet-level pause/resume.
173
180
  unblock clear <issue>'s blocked and failed labels so the next tick can claim
174
181
  it again — the supported way back for an escalation you answered,
175
182
  and why the brief's "never hand-edit a state label" rule can stay
@@ -784,6 +791,50 @@ try {
784
791
  break;
785
792
  }
786
793
 
794
+ case "worker": {
795
+ const sub = argv[1];
796
+ if (sub !== "pause" && sub !== "resume") {
797
+ process.stderr.write(
798
+ "omp-conductor: worker needs pause or resume, then an issue number\n",
799
+ );
800
+ process.exit(2);
801
+ }
802
+ const issue = issueArg("worker", argv[2]);
803
+ const project = findProject(loadConfig(), flag(argv, "project"));
804
+ const daemon = livingDaemon();
805
+ if (daemon === undefined) throw new Error("daemon is not running");
806
+ if (daemon.project !== undefined && daemon.project !== project.name) {
807
+ throw new Error(
808
+ `daemon serves project "${daemon.project}", not requested project "${project.name}"`,
809
+ );
810
+ }
811
+ const response = await fetch(
812
+ `http://127.0.0.1:${daemon.port}/runs/${issue}/${sub}`,
813
+ {
814
+ method: "PUT",
815
+ headers: { "content-type": "application/json" },
816
+ body: JSON.stringify({ project: project.name }),
817
+ },
818
+ );
819
+ const payload = (await response.json()) as {
820
+ error?: unknown;
821
+ runId?: unknown;
822
+ phase?: unknown;
823
+ };
824
+ if (!response.ok) {
825
+ throw new Error(
826
+ typeof payload.error === "string" ? payload.error : `daemon returned HTTP ${response.status}`,
827
+ );
828
+ }
829
+ if (typeof payload.runId !== "string" || typeof payload.phase !== "string") {
830
+ throw new Error("daemon returned an invalid worker-control response");
831
+ }
832
+ process.stdout.write(
833
+ `#${issue} worker ${payload.phase} (run ${payload.runId})\n`,
834
+ );
835
+ break;
836
+ }
837
+
787
838
  case "unblock": {
788
839
  const issue = issueArg("unblock", argv[1]);
789
840
  const cfg = loadConfig();
@@ -873,6 +924,7 @@ try {
873
924
  pausedAt,
874
925
  log: (m) => process.stderr.write(`${m}\n`),
875
926
  now: () => Date.now(),
927
+ chain: { readBaseChain },
876
928
  },
877
929
  channel,
878
930
  { verb: name, args },
@@ -914,15 +966,51 @@ try {
914
966
  const store = openStore(dbPath());
915
967
  try {
916
968
  const at = Date.now();
969
+ // #229: the policy decides what may interrupt the phone. A kind the
970
+ // policy defers is refused here rather than silently turning into a
971
+ // page, or a digest going out off-schedule.
972
+ const policy = project.reporting;
973
+ if (kind === "digest") {
974
+ const digestPolicy = policy?.digest ?? { cadence: "per-tick" };
975
+ if (digestPolicy.cadence === "none") {
976
+ process.stderr.write(`omp-conductor: report: digest cadence is "none" for this project\n`);
977
+ process.exit(2);
978
+ }
979
+ if (digestPolicy.cadence === "daily" && digestPolicy.at !== undefined) {
980
+ const lastKey = store.lastDigestDedupeKey(project.name);
981
+ const lastDay = lastKey === undefined ? undefined : lastKey.slice("digest:".length);
982
+ if (!digestDue(policy ?? DEFAULT_REPORT_POLICY, lastDay, at)) {
983
+ process.stderr.write(
984
+ `omp-conductor: report: the daily digest is not due until ${digestPolicy.at}` +
985
+ `${digestPolicy.timezone === undefined ? "" : ` ${digestPolicy.timezone}`}\n`,
986
+ );
987
+ process.exit(2);
988
+ }
989
+ }
990
+ }
991
+ if (kind === "material") {
992
+ const interruptOn = policy?.interruptOn;
993
+ if (interruptOn !== undefined && !interruptOn.includes("material")) {
994
+ process.stderr.write(
995
+ "omp-conductor: report: material updates are digest-only under this reporting policy; fold this into the next digest (--kind digest)\n",
996
+ );
997
+ process.exit(2);
998
+ }
999
+ }
917
1000
  const { report, deduped } = store.enqueueReport({
918
1001
  project: project.name,
919
1002
  kind,
920
1003
  body,
921
1004
  // Only the digest is at-most-once. A material report describes one
922
1005
  // event as it happens, and two of those in a day are two events.
923
- ...(kind === "digest" ? { dedupeKey: digestDedupeKey(at) } : {}),
1006
+ ...(kind === "digest"
1007
+ ? { dedupeKey: digestDedupeKey(at, project.reporting?.digest?.timezone) }
1008
+ : {}),
924
1009
  at,
925
1010
  });
1011
+ // The held notices an accepted digest re-surfaces are now owed by it,
1012
+ // whatever the model goes on to write.
1013
+ if (kind === "digest") store.markNoticesDigested(project.name, at);
926
1014
  process.stdout.write(
927
1015
  deduped
928
1016
  ? `today's digest was already handed over as report ${report.id} (${report.state}) — nothing queued\n` +
package/src/config.ts CHANGED
@@ -24,6 +24,7 @@ import {
24
24
  DEFAULT_AUTHORITY,
25
25
  DEFAULT_CAPS,
26
26
  DEFAULT_PROJECT_POLICY,
27
+ DEFAULT_REPORT_POLICY,
27
28
  DEFAULT_REPORT_SCOPE,
28
29
  DENIED_RELEASE_GRANTS,
29
30
  DRAFT_POLICIES,
@@ -34,8 +35,12 @@ import {
34
35
  RELEASE_REQUIREMENTS,
35
36
  RELEASE_SHAPES,
36
37
  REPORT_SCOPES,
38
+ INTERRUPT_CATEGORIES,
39
+ DIGEST_CADENCES,
37
40
  type Caps,
38
41
  type ConductorConfig,
42
+ type DigestCadence,
43
+ type InterruptCategory,
39
44
  type MergePreconditions,
40
45
  type PlanUsageCap,
41
46
  type ProjectConfig,
@@ -43,6 +48,7 @@ import {
43
48
  type ReleasePreconditions,
44
49
  type ReleaseRequirement,
45
50
  type ReportScope,
51
+ type ReportingPolicy,
46
52
  type RepoTarget,
47
53
  type ResolvedGrants,
48
54
  } from "./types.ts";
@@ -64,6 +70,8 @@ const CAP_KEYS = Object.keys(DEFAULT_CAPS) as (keyof Caps)[];
64
70
 
65
71
  /** Quoted for error messages, from the same data the guards below read. */
66
72
  const REPORT_SCOPE_LIST = quoteList(REPORT_SCOPES);
73
+ const INTERRUPT_CATEGORY_LIST = quoteList(INTERRUPT_CATEGORIES);
74
+ const DIGEST_CADENCE_LIST = quoteList(DIGEST_CADENCES);
67
75
  const AUTHORITY_HOLDER_LIST = quoteList(AUTHORITY_HOLDERS);
68
76
  const ORCHESTRATOR_MODE_LIST = quoteList(ORCHESTRATOR_MODES);
69
77
  const RELEASE_SHAPE_LIST = quoteList(RELEASE_SHAPES);
@@ -482,34 +490,173 @@ function normalizeProject(
482
490
  * that silently resolved to `"material"` would read as configured on the day the
483
491
  * operator meant to turn the volume down, and the config would keep lying.
484
492
  */
485
- function normalizeReporting(parsed: unknown, label: string, problems: string[]): ProjectConfig["reporting"] {
486
- if (parsed === undefined) return { scope: DEFAULT_REPORT_SCOPE };
493
+ /** The legacy `reporting.scope` presets, materialised as explicit policies.
494
+ * Kept separate from {@link DEFAULT_REPORT_POLICY} (the "no key on disk"
495
+ * default, which must stay `material`): each preset records `scopePreset` so
496
+ * the tick prompt can keep saying the exact legacy words (#229). Exported so
497
+ * the setup wizard and the config validator agree on one mapping. */
498
+ export const SCOPE_PRESETS: Record<ReportScope, ReportingPolicy> = {
499
+ material: {
500
+ interruptOn: [...INTERRUPT_CATEGORIES],
501
+ digest: { cadence: "per-tick" },
502
+ scopePreset: "material",
503
+ },
504
+ decisions: {
505
+ interruptOn: ["tier2", "decision-needed", "fleet-stopped"],
506
+ digest: { cadence: "per-tick" },
507
+ scopePreset: "decisions",
508
+ },
509
+ escalations: {
510
+ interruptOn: ["tier2", "fleet-stopped"],
511
+ digest: { cadence: "daily" },
512
+ scopePreset: "escalations",
513
+ },
514
+ };
515
+
516
+ function defaultReporting(): ReportingPolicy {
517
+ return { ...DEFAULT_REPORT_POLICY, interruptOn: [...DEFAULT_REPORT_POLICY.interruptOn] };
518
+ }
519
+
520
+ /** The 24-hour `HH:MM` shape `digest.at` must take. */
521
+ const DIGEST_AT = /^([01]\d|2[0-3]):[0-5]\d$/;
522
+
523
+ /**
524
+ * The reporting policy: a legacy `scope` preset, or the explicit
525
+ * `interruptOn` + `digest` form. The two forms are mutually exclusive — a
526
+ * preset IS a policy, so configuring alongside it says one thing and means
527
+ * another (#229).
528
+ */
529
+ function normalizeReporting(parsed: unknown, label: string, problems: string[]): ReportingPolicy {
530
+ if (parsed === undefined) return defaultReporting();
487
531
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
488
- problems.push(`${label}: reporting must be an object with a "scope" of ${REPORT_SCOPE_LIST}`);
489
- return { scope: DEFAULT_REPORT_SCOPE };
532
+ problems.push(`${label}: reporting must be an object with a "scope" preset or explicit "interruptOn"/"digest"`);
533
+ return defaultReporting();
490
534
  }
491
535
  const raw = parsed as Raw;
492
-
493
- const unknownKeys = Object.keys(raw).filter((k) => k !== "scope");
536
+ const keys = Object.keys(raw);
537
+ // `scopePreset` is written by a fully-normalised policy (the setup wizard
538
+ // saves presets materialised); `scope` is the legacy form. Both are known.
539
+ const known = ["scope", "interruptOn", "digest", "scopePreset"];
540
+ const unknownKeys = keys.filter((k) => !known.includes(k));
494
541
  if (unknownKeys.length > 0) {
495
- // Stricter than caps, which tolerate a retired key: `reporting` has exactly
496
- // one member, so an unrecognised key here is a typo every time, and the
497
- // block that ignores it looks configured either way.
498
542
  problems.push(`${label}: reporting has unknown key(s): ${unknownKeys.join(", ")}`);
499
543
  }
500
-
501
- return {
502
- scope: pickLiteral(
544
+ const hasScope = raw["scope"] !== undefined;
545
+ const hasExplicit = keys.includes("interruptOn") || keys.includes("digest");
546
+ if (hasScope && hasExplicit) {
547
+ problems.push(`${label}: reporting.scope is a preset — remove it when configuring interruptOn/digest explicitly`);
548
+ return defaultReporting();
549
+ }
550
+ if (hasScope || (!hasExplicit && keys.length === 0)) {
551
+ const scope = pickLiteral(
503
552
  raw["scope"],
504
553
  REPORT_SCOPES,
505
554
  DEFAULT_REPORT_SCOPE,
506
555
  `${label}: reporting.scope`,
507
556
  REPORT_SCOPE_LIST,
508
557
  problems,
509
- ),
558
+ );
559
+ const preset = SCOPE_PRESETS[scope] ?? SCOPE_PRESETS[DEFAULT_REPORT_SCOPE];
560
+ return {
561
+ interruptOn: [...preset.interruptOn],
562
+ digest: { ...preset.digest },
563
+ scopePreset: preset.scopePreset,
564
+ };
565
+ }
566
+
567
+ // A fully-normalised policy (what the setup wizard writes and saveConfig
568
+ // round-trips) has interruptOn/digest and may carry scopePreset; keep that
569
+ // back-annotation so the tick prompt can still speak the legacy words.
570
+ const storedPreset = raw["scopePreset"];
571
+ const scopePreset =
572
+ typeof storedPreset === "string" && (REPORT_SCOPES as readonly string[]).includes(storedPreset)
573
+ ? (storedPreset as ReportScope)
574
+ : undefined;
575
+
576
+ return {
577
+ interruptOn: normalizeInterruptOn(raw["interruptOn"], label, problems),
578
+ digest: normalizeDigest(raw["digest"], label, problems),
579
+ ...(scopePreset === undefined ? {} : { scopePreset }),
510
580
  };
511
581
  }
512
582
 
583
+ function normalizeInterruptOn(parsed: unknown, label: string, problems: string[]): InterruptCategory[] {
584
+ const fallback = [...INTERRUPT_CATEGORIES];
585
+ if (parsed === undefined) {
586
+ problems.push(`${label}: reporting.interruptOn is required in the explicit form (or use reporting.scope)`);
587
+ return fallback;
588
+ }
589
+ if (!Array.isArray(parsed)) {
590
+ problems.push(`${label}: reporting.interruptOn must be an array of ${INTERRUPT_CATEGORY_LIST}`);
591
+ return fallback;
592
+ }
593
+ const out: InterruptCategory[] = [];
594
+ for (const item of parsed) {
595
+ if (typeof item !== "string" || !(INTERRUPT_CATEGORIES as readonly string[]).includes(item)) {
596
+ problems.push(`${label}: reporting.interruptOn has unknown category ${JSON.stringify(item)} — one of ${INTERRUPT_CATEGORY_LIST}`);
597
+ continue;
598
+ }
599
+ const category = item as InterruptCategory;
600
+ if (!out.includes(category)) out.push(category);
601
+ }
602
+ if (out.length === 0) {
603
+ problems.push(`${label}: reporting.interruptOn must name at least one category`);
604
+ return fallback;
605
+ }
606
+ return out;
607
+ }
608
+
609
+ function normalizeDigest(parsed: unknown, label: string, problems: string[]): ReportingPolicy["digest"] {
610
+ const fallback: ReportingPolicy["digest"] = { cadence: "per-tick" };
611
+ if (parsed === undefined) {
612
+ problems.push(`${label}: reporting.digest is required in the explicit form (or use reporting.scope)`);
613
+ return fallback;
614
+ }
615
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
616
+ problems.push(`${label}: reporting.digest must be an object with a "cadence" of ${DIGEST_CADENCE_LIST}`);
617
+ return fallback;
618
+ }
619
+ const raw = parsed as Raw;
620
+ const unknownKeys = Object.keys(raw).filter((k) => !["cadence", "at", "timezone"].includes(k));
621
+ if (unknownKeys.length > 0) {
622
+ problems.push(`${label}: reporting.digest has unknown key(s): ${unknownKeys.join(", ")}`);
623
+ }
624
+ const cadence = pickLiteral(
625
+ raw["cadence"],
626
+ DIGEST_CADENCES,
627
+ "per-tick",
628
+ `${label}: reporting.digest.cadence`,
629
+ DIGEST_CADENCE_LIST,
630
+ problems,
631
+ );
632
+ const digest: ReportingPolicy["digest"] = { cadence };
633
+ if (cadence !== "daily") {
634
+ if (raw["at"] !== undefined) problems.push(`${label}: reporting.digest.at is only valid with cadence "daily"`);
635
+ if (raw["timezone"] !== undefined) problems.push(`${label}: reporting.digest.timezone is only valid with cadence "daily"`);
636
+ return digest;
637
+ }
638
+ if (raw["at"] !== undefined) {
639
+ if (typeof raw["at"] !== "string" || !DIGEST_AT.test(raw["at"])) {
640
+ problems.push(`${label}: reporting.digest.at must be a 24h HH:MM time`);
641
+ } else {
642
+ digest.at = raw["at"];
643
+ }
644
+ }
645
+ if (raw["timezone"] !== undefined) {
646
+ if (typeof raw["timezone"] !== "string") {
647
+ problems.push(`${label}: reporting.digest.timezone must be a string`);
648
+ } else {
649
+ try {
650
+ new Intl.DateTimeFormat("en-GB", { timeZone: raw["timezone"] });
651
+ digest.timezone = raw["timezone"];
652
+ } catch {
653
+ problems.push(`${label}: reporting.digest.timezone is not a known IANA timezone`);
654
+ }
655
+ }
656
+ }
657
+ return digest;
658
+ }
659
+
513
660
  /**
514
661
  * Who triages escalations, and how they are delivered when nobody answers.
515
662
  *
@@ -853,12 +1000,37 @@ function normalizeRepos(parsed: unknown, label: string, problems: string[]): Rec
853
1000
  };
854
1001
  const graph = normalizeGraphProject(value?.["graphProject"], `${label}: routing.repos.${key}`, problems);
855
1002
  if (graph !== undefined) target.graphProject = graph;
1003
+ const migrations = normalizeMigrationsDir(value?.["migrations"], `${label}: routing.repos.${key}`, problems);
1004
+ if (migrations !== undefined) target.migrations = { dir: migrations };
856
1005
  repos[key] = target;
857
1006
  }
858
1007
 
859
1008
  return repos;
860
1009
  }
861
1010
 
1011
+ /**
1012
+ * The ordered-migration-chain directory (#227), or `undefined` when the repo
1013
+ * opts out of the chain check.
1014
+ *
1015
+ * Repo-relative, no leading `/`, and no `..` segment — the value is read in one
1016
+ * process and used in another against the base branch's tree, so anything that
1017
+ * is not plainly a directory name is a guess the guard must not make.
1018
+ */
1019
+ function normalizeMigrationsDir(parsed: unknown, label: string, problems: string[]): string | undefined {
1020
+ if (parsed === undefined) return undefined;
1021
+ const raw = typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? (parsed as Raw) : undefined;
1022
+ const dir = raw?.["dir"];
1023
+ if (!nonEmptyString(dir)) {
1024
+ problems.push(`${label}.migrations.dir must be a non-empty string`);
1025
+ return undefined;
1026
+ }
1027
+ if (dir.startsWith("/") || dir.split("/").includes("..")) {
1028
+ problems.push(`${label}.migrations.dir must be a repo-relative directory`);
1029
+ return undefined;
1030
+ }
1031
+ return dir;
1032
+ }
1033
+
862
1034
  /**
863
1035
  * The path of the index-only clone whose code graph this repo's workers query,
864
1036
  * or `undefined` when the repo has none.