omp-conductor 0.13.0 → 0.14.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/plugin.ts CHANGED
@@ -83,10 +83,14 @@ import {
83
83
  DEFAULT_CAPS,
84
84
  DRAFT_POLICIES,
85
85
  RELEASE_REQUIREMENTS,
86
+ INTERRUPT_CATEGORIES,
86
87
  RELEASE_SHAPES,
88
+ WEEKDAYS,
87
89
  type Caps,
88
90
  type ConductorConfig,
89
91
  type OrchestratorMode,
92
+ type InterruptCategory,
93
+ type Weekday,
90
94
  type ProjectConfig,
91
95
  type ProjectPolicy,
92
96
  type ReleaseRequirement,
@@ -230,6 +234,7 @@ async function askValid(
230
234
  throw new Cancelled();
231
235
  }
232
236
 
237
+
233
238
  /** A cap. Unparseable input keeps the current value rather than writing a NaN
234
239
  * the validator would later reject — the operator sees why, immediately. */
235
240
  async function askNumber(ctx: CommandContext, title: string, fallback: number): Promise<number> {
@@ -924,22 +929,143 @@ const askEscalation: AreaAsker = async (ctx, a) => {
924
929
  return next;
925
930
  };
926
931
 
927
- /** How loud the orchestrator is when nobody asked it anything. */
932
+ /** How loud the orchestrator is and when the operator permits interruptions. */
928
933
  const askReporting: AreaAsker = async (ctx, a) => {
929
934
  const reportScope = await askReportScope(ctx, a.reportScope);
930
- if (reportScope !== "quiet") return { ...a, reportScope };
931
- // `quiet` picks the explicit form, whose only free parameter is when the
932
- // daily rollup happens. Blank = whenever the orchestrator composes it.
933
- const at = await ctx.ui.input(
934
- "Daily rollup time, 24h HH:MM (blank = whenever the orchestrator composes it):",
935
- a.quietDigestAt,
935
+ const continuous = "Continuous (24-hour interrupts)";
936
+ const weekly = "Weekly availability window";
937
+ const mode = await ctx.ui.select(
938
+ "Operator availability",
939
+ [
940
+ {
941
+ label: continuous,
942
+ description: "preserve legacy behavior: configured interrupt categories may page at any hour",
943
+ },
944
+ {
945
+ label: weekly,
946
+ description: "hold non-bypass interruptions outside selected local working hours",
947
+ },
948
+ ],
949
+ { initialIndex: a.availability === undefined ? 0 : 1 },
936
950
  );
937
- const trimmed = at?.trim() ?? "";
938
- if (trimmed !== "" && !/^([01]\d|2[0-3]):[0-5]\d$/.test(trimmed)) {
939
- ctx.ui.notify(`"${trimmed}" is not a 24h HH:MM time leaving the digest model-timed.`, "warning");
940
- return { ...a, reportScope };
951
+ if (mode === undefined) throw new Cancelled();
952
+
953
+ const next: SetupAnswers = { ...a, reportScope };
954
+ delete next.preservedReporting;
955
+ delete next.availability;
956
+ const applyDigestSchedule = (raw: string): SetupAnswers => {
957
+ const updated = { ...next };
958
+ delete updated.dailyDigestAt;
959
+ const value = raw.trim().toLowerCase();
960
+ if (value === "per-tick") {
961
+ updated.digestCadence = "per-tick";
962
+ } else if (value === "off" || value === "disabled" || value === "none") {
963
+ updated.digestCadence = "none";
964
+ } else if (value === "model-timed" || value === "daily") {
965
+ updated.digestCadence = "daily";
966
+ } else {
967
+ updated.digestCadence = "daily";
968
+ updated.dailyDigestAt = raw;
969
+ }
970
+ return updated;
971
+ };
972
+ const askDigestSchedule = async (fallback: string): Promise<SetupAnswers> => {
973
+ const shown =
974
+ a.digestCadence === "per-tick"
975
+ ? "per-tick"
976
+ : a.digestCadence === "none"
977
+ ? "off"
978
+ : a.dailyDigestAt ?? (a.digestCadence === "daily" ? "model-timed" : fallback);
979
+ const schedule = await askValid(
980
+ ctx,
981
+ 'Daily rollup time in that timezone / digest cadence ("per-tick", "model-timed", "off", or 24h HH:MM)',
982
+ shown,
983
+ (value) =>
984
+ ["per-tick", "model-timed", "daily", "none", "off", "disabled"].includes(
985
+ value.toLowerCase(),
986
+ ) || /^([01]\d|2[0-3]):[0-5]\d$/.test(value)
987
+ ? undefined
988
+ : 'Use "per-tick", "model-timed", "off", or a 24h HH:MM time.',
989
+ );
990
+ return applyDigestSchedule(schedule);
991
+ };
992
+
993
+ if (mode !== weekly) {
994
+ if (mode !== continuous) {
995
+ ctx.ui.notify(`Unrecognised availability choice "${mode}" — keeping 24-hour interrupts.`, "warning");
996
+ }
997
+ if (reportScope !== "quiet") return next;
998
+ return await askDigestSchedule("model-timed");
941
999
  }
942
- return { ...a, reportScope, ...(trimmed === "" ? {} : { quietDigestAt: trimmed }) };
1000
+
1001
+ const defaultZone =
1002
+ a.reportingTimezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone ?? "UTC";
1003
+ const reportingTimezone = await askValid(
1004
+ ctx,
1005
+ "Operator timezone (IANA, for example Europe/London)",
1006
+ defaultZone,
1007
+ (value) => {
1008
+ try {
1009
+ new Intl.DateTimeFormat("en", { timeZone: value }).format();
1010
+ return undefined;
1011
+ } catch {
1012
+ return `"${value}" is not a known IANA timezone.`;
1013
+ }
1014
+ },
1015
+ );
1016
+ const daysText = await askValid(
1017
+ ctx,
1018
+ "Working weekdays (comma-separated: mon,tue,wed,thu,fri,sat,sun)",
1019
+ a.availability?.days.join(",") ?? "mon,tue,wed,thu,fri",
1020
+ (value) => {
1021
+ const days = value.split(",").map((day) => day.trim().toLowerCase());
1022
+ if (days.length === 0 || days.some((day) => !WEEKDAYS.includes(day as Weekday))) {
1023
+ return `Use only: ${WEEKDAYS.join(",")}.`;
1024
+ }
1025
+ return new Set(days).size === days.length ? undefined : "List each weekday only once.";
1026
+ },
1027
+ );
1028
+ const days = daysText.split(",").map((day) => day.trim().toLowerCase() as Weekday);
1029
+ const start = await askValid(
1030
+ ctx,
1031
+ "Availability starts (24h HH:MM)",
1032
+ a.availability?.start ?? "09:00",
1033
+ (value) => (/^([01]\d|2[0-3]):[0-5]\d$/.test(value) ? undefined : "Use 24h HH:MM."),
1034
+ );
1035
+ const end = await askValid(
1036
+ ctx,
1037
+ "Availability ends (24h HH:MM)",
1038
+ a.availability?.end ?? "17:00",
1039
+ (value) =>
1040
+ !/^([01]\d|2[0-3]):[0-5]\d$/.test(value)
1041
+ ? "Use 24h HH:MM."
1042
+ : value === start
1043
+ ? "Start and end must differ."
1044
+ : undefined,
1045
+ );
1046
+ const bypassText = await askValid(
1047
+ ctx,
1048
+ `Quiet-hours bypass categories (comma-separated; "none" = none; choices: ${INTERRUPT_CATEGORIES.join(",")})`,
1049
+ a.availability === undefined ? "fleet-stopped" : a.availability.bypass.join(",") || "none",
1050
+ (value) => {
1051
+ if (value.toLowerCase() === "none") return undefined;
1052
+ const categories = value.split(",").map((category) => category.trim().toLowerCase());
1053
+ if (categories.some((category) => !INTERRUPT_CATEGORIES.includes(category as InterruptCategory))) {
1054
+ return `Use only: ${INTERRUPT_CATEGORIES.join(",")}, or "none".`;
1055
+ }
1056
+ return new Set(categories).size === categories.length ? undefined : "List each category only once.";
1057
+ },
1058
+ );
1059
+ const bypass =
1060
+ bypassText.toLowerCase() === "none"
1061
+ ? []
1062
+ : bypassText.split(",").map((category) => category.trim().toLowerCase() as InterruptCategory);
1063
+ const scheduled = await askDigestSchedule(end);
1064
+ return {
1065
+ ...scheduled,
1066
+ reportingTimezone,
1067
+ availability: { days, start, end, bypass },
1068
+ };
943
1069
  };
944
1070
 
945
1071
  /** The operator's own brief. Asked last in the full interview, because the
package/src/reports.ts CHANGED
@@ -36,9 +36,21 @@
36
36
  * conclusion leaves something for the next pass to pick up.
37
37
  */
38
38
 
39
+ import { availabilityDisposition, interruptDisposition } from "./availability.ts";
39
40
  import { readTelegramToken, sendTelegram, TelegramSendError } from "./escalate.ts";
40
41
  import { localDayKey } from "./digest-schedule.ts";
41
- import type { Escalation, ProjectConfig, ReportRecord, Store } from "./types.ts";
42
+ import {
43
+ DIGEST_BACKLOG_LIMIT,
44
+ INTERRUPT_CATEGORIES,
45
+ type DigestBacklog,
46
+ type Escalation,
47
+ type HeldNotice,
48
+ type InterruptCategory,
49
+ type ProjectConfig,
50
+ type ReportEnqueue,
51
+ type ReportRecord,
52
+ type Store,
53
+ } from "./types.ts";
42
54
 
43
55
  /**
44
56
  * Attempts before a report is written off. Six attempts across the backoff
@@ -85,6 +97,8 @@ export type ReportSend = (text: string) => Promise<number | undefined>;
85
97
  /** What one pass did, by report id. Returned for the daemon's log and the tests. */
86
98
  export interface ReportDeliveryPass {
87
99
  delivered: string[];
100
+ /** Material reports preserved without sending after policy or availability changed. */
101
+ deferred: string[];
88
102
  /** Known-failed, back in `pending` behind a backoff. */
89
103
  requeued: string[];
90
104
  /** The attempt ended without an answer. Left `sending` and flagged as a
@@ -108,7 +122,8 @@ export interface ReportOutbox {
108
122
  }
109
123
 
110
124
  export interface ReportOutboxDeps {
111
- project: ProjectConfig;
125
+ /** A provider lets a resident daemon apply config edits at the next tick. */
126
+ project: ProjectConfig | (() => ProjectConfig);
112
127
  store: Store;
113
128
  /** A report nobody can deliver escalates through this. Optional so a unit
114
129
  * test can exercise delivery without wiring an escalator. */
@@ -116,6 +131,8 @@ export interface ReportOutboxDeps {
116
131
  send?: ReportSend;
117
132
  now?: () => number;
118
133
  log?: (msg: string) => void;
134
+ /** False while the resident daemon cannot validate live delivery policy. */
135
+ deliveryAllowed?: () => boolean;
119
136
  }
120
137
 
121
138
  /**
@@ -200,6 +217,20 @@ export function formatOpenReports(
200
217
  return lines;
201
218
  }
202
219
 
220
+ /** Durable rows still owed to a future digest. Always rendered in `status`: a
221
+ * quiet line proves the accumulator is empty, while a non-zero line makes loss
222
+ * or backlog visible without asking the session what it remembers. */
223
+ export function formatDigestBacklog(backlog: DigestBacklog, now: number = Date.now()): string[] {
224
+ const age = (at: number | undefined): string =>
225
+ at === undefined ? "" : ` (oldest ${humanAge(Math.max(0, now - at))})`;
226
+ return [
227
+ "",
228
+ "digest backlog",
229
+ ` material events ${backlog.materialCount}${age(backlog.materialOldestAt)}`,
230
+ ` held escalations ${backlog.heldNoticeCount}${age(backlog.heldNoticeOldestAt)}`,
231
+ ];
232
+ }
233
+
203
234
  function openReportDetail(r: ReportRecord, now: number): string {
204
235
  const flat = r.lastError?.replace(/\s+/g, " ").trim() ?? "";
205
236
  const error =
@@ -257,10 +288,115 @@ export function telegramReportSend(p: ProjectConfig): ReportSend {
257
288
  };
258
289
  }
259
290
 
291
+ /** Keep the mechanical catch-up comfortably inside Telegram's report wrapper. */
292
+ const AVAILABILITY_REPORT_BODY_LIMIT = 3_200;
293
+ const AVAILABILITY_REPORT_KEY_PREFIX = "availability/";
294
+
295
+ interface AvailabilityReportMarker {
296
+ categories: InterruptCategory[];
297
+ urgent: boolean;
298
+ }
299
+
300
+ function availabilityReportMarker(report: ReportRecord): AvailabilityReportMarker | undefined {
301
+ const key = report.dedupeKey;
302
+ if (key === undefined || !key.startsWith(AVAILABILITY_REPORT_KEY_PREFIX)) return undefined;
303
+ const parts = key.slice(AVAILABILITY_REPORT_KEY_PREFIX.length).split("/");
304
+ const urgent = parts[0] === "urgent";
305
+ const encoded = (urgent ? parts[1] : parts[0]) ?? "";
306
+ return {
307
+ urgent,
308
+ categories: encoded
309
+ .split(",")
310
+ .filter((category): category is InterruptCategory =>
311
+ INTERRUPT_CATEGORIES.includes(category as InterruptCategory),
312
+ ),
313
+ };
314
+ }
315
+
316
+ function availabilityNoticeLine(notice: HeldNotice): string {
317
+ const flat = (text: string, limit: number): string => {
318
+ const value = text.replace(/\s+/g, " ").trim();
319
+ return value.length <= limit ? value : `${value.slice(0, limit - 1)}…`;
320
+ };
321
+ return (
322
+ `- [${notice.category}] ${flat(notice.summary, 180)} ` +
323
+ `(${new Date(notice.createdAt).toISOString()}) — ${flat(notice.detail, 240)}`
324
+ );
325
+ }
326
+
327
+ /**
328
+ * Hand availability-held notices to the existing durable outbox when the live
329
+ * policy permits them again. Association and report creation are one SQLite
330
+ * transaction, so daemon downtime or a send failure cannot lose a notice.
331
+ */
332
+ export function enqueueAvailableHeldNotices(
333
+ project: ProjectConfig,
334
+ store: Store,
335
+ now: number,
336
+ ): ReportEnqueue | undefined {
337
+ // The Store arbitrates the due-digest lease and this catch-up in the same
338
+ // SQLite transaction. A preflight here would reopen the snapshot race.
339
+ const categories = INTERRUPT_CATEGORIES.filter(
340
+ (category) => interruptDisposition(project.reporting, category, now) === "interrupt",
341
+ );
342
+ const urgentCategories = INTERRUPT_CATEGORIES.filter(
343
+ (category) =>
344
+ availabilityDisposition(project.reporting?.availability, category, now) === "interrupt",
345
+ );
346
+ const urgent = store.undigestedNotices(
347
+ project.name,
348
+ DIGEST_BACKLOG_LIMIT,
349
+ true,
350
+ urgentCategories,
351
+ true,
352
+ );
353
+ const eligible =
354
+ urgent.length > 0
355
+ ? urgent
356
+ : store.undigestedNotices(
357
+ project.name,
358
+ DIGEST_BACKLOG_LIMIT,
359
+ true,
360
+ categories,
361
+ false,
362
+ );
363
+ if (eligible.length === 0) return undefined;
364
+
365
+ const lines = [
366
+ "Working-hours catch-up",
367
+ "These interruptions were held durably while the operator was outside the configured availability window:",
368
+ ];
369
+ const selected: HeldNotice[] = [];
370
+ for (const notice of eligible) {
371
+ const line = availabilityNoticeLine(notice);
372
+ const next = [...lines, line].join("\n");
373
+ if (selected.length > 0 && next.length > AVAILABILITY_REPORT_BODY_LIMIT) break;
374
+ lines.push(line);
375
+ selected.push(notice);
376
+ }
377
+
378
+ const selectedCategories = INTERRUPT_CATEGORIES.filter((category) =>
379
+ selected.some((notice) => notice.category === category),
380
+ );
381
+ const urgentMarker = selected[0]?.urgent === true ? "urgent/" : "";
382
+ return store.enqueueAvailabilityReport(
383
+ {
384
+ project: project.name,
385
+ kind: "digest",
386
+ body: lines.join("\n"),
387
+ dedupeKey: `${AVAILABILITY_REPORT_KEY_PREFIX}${urgentMarker}${selectedCategories.join(",")}/${selected[0]!.id}`,
388
+ at: now,
389
+ },
390
+ selected.map((notice) => notice.id),
391
+ );
392
+ }
393
+
260
394
  export function createReportOutbox(deps: ReportOutboxDeps): ReportOutbox {
261
- const { project, store } = deps;
395
+ const { store } = deps;
396
+ const currentProject =
397
+ typeof deps.project === "function" ? deps.project : (): ProjectConfig => deps.project as ProjectConfig;
262
398
  const now = deps.now ?? Date.now;
263
- const send = deps.send ?? telegramReportSend(project);
399
+ const injectedSend = deps.send;
264
400
  const log = deps.log ?? ((): void => {});
265
401
 
266
402
  /**
@@ -279,6 +415,7 @@ export function createReportOutbox(deps: ReportOutboxDeps): ReportOutbox {
279
415
  * undeliverable report pages exactly once and never every five minutes.
280
416
  */
281
417
  const pageUndeliverable = async (r: ReportRecord, error: string): Promise<void> => {
418
+ const project = currentProject();
282
419
  if (deps.escalate === undefined) return;
283
420
  try {
284
421
  await deps.escalate({
@@ -318,6 +455,64 @@ export function createReportOutbox(deps: ReportOutboxDeps): ReportOutbox {
318
455
  };
319
456
 
320
457
  const attempt = async (r: ReportRecord, pass: ReportDeliveryPass): Promise<void> => {
458
+ const project = currentProject();
459
+ if (deps.deliveryAllowed?.() === false) {
460
+ pass.deferred.push(r.id);
461
+ return;
462
+ }
463
+ const availabilityMarker = availabilityReportMarker(r);
464
+ if (availabilityMarker !== undefined) {
465
+ if (availabilityMarker.categories.length === 0) {
466
+ pass.deferred.push(r.id);
467
+ log(`availability catch-up ${r.id} has no valid category marker and was held fail-closed`);
468
+ return;
469
+ }
470
+ const dispositions = availabilityMarker.categories.map((category) =>
471
+ availabilityMarker.urgent
472
+ ? availabilityDisposition(project.reporting?.availability, category, now())
473
+ : interruptDisposition(project.reporting, category, now()),
474
+ );
475
+ if (dispositions.some((disposition) => disposition !== "interrupt")) {
476
+ if (store.releasePendingAvailabilityReport(r.id, project.name, r.ambiguous)) {
477
+ const reason = dispositions.includes("digest")
478
+ ? "reporting policy changed"
479
+ : "the availability window closed";
480
+ log(`availability catch-up ${r.id} returned to the digest because ${reason}`);
481
+ }
482
+ pass.deferred.push(r.id);
483
+ return;
484
+ }
485
+ }
486
+ if (r.kind === "material") {
487
+ const at = now();
488
+ const disposition = interruptDisposition(project.reporting, "material", at);
489
+ if (disposition !== "interrupt") {
490
+ const firstLine = r.body.split("\n", 1)[0]!;
491
+ const deferred = store.deferPendingReportToNotice(r.id, {
492
+ id: r.id,
493
+ project: project.name,
494
+ category: "material",
495
+ summary: (
496
+ r.ambiguous ? `POSSIBLE REPEAT of report ${r.id}: ${firstLine}` : firstLine
497
+ ).slice(0, 240),
498
+ detail: r.ambiguous
499
+ ? `This report may already have reached Telegram before its outcome was lost.\n\n${r.body}`
500
+ : r.body,
501
+ createdAt: at,
502
+ ...(disposition === "availability" ? { releaseOnAvailable: true } : {}),
503
+ });
504
+ if (deferred) {
505
+ pass.deferred.push(r.id);
506
+ log(
507
+ `report ${r.id} preserved for ${
508
+ disposition === "availability" ? "the next availability window" : "a digest"
509
+ } after reporting policy changed`,
510
+ );
511
+ return;
512
+ }
513
+ }
514
+ }
515
+ const send = injectedSend ?? telegramReportSend(project);
321
516
  const attemptId = crypto.randomUUID();
322
517
  // Losing this race is ordinary: another pass, or another daemon, already
323
518
  // owns the attempt. Returning is what keeps one report from being in flight
@@ -400,12 +595,14 @@ export function createReportOutbox(deps: ReportOutboxDeps): ReportOutbox {
400
595
 
401
596
  return {
402
597
  recover(staleAt: number): ReportRecord[] {
403
- return store.recoverSendingReports(project.name, staleAt, now());
598
+ return store.recoverSendingReports(currentProject().name, staleAt, now());
404
599
  },
405
600
 
406
601
  async deliverDue(): Promise<ReportDeliveryPass> {
602
+ const project = currentProject();
407
603
  const pass: ReportDeliveryPass = {
408
604
  delivered: [],
605
+ deferred: [],
409
606
  requeued: [],
410
607
  uncertain: [],
411
608
  failed: [],