omp-conductor 0.17.0 → 0.18.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/REFERENCE.md +12 -8
- package/package.json +1 -1
- package/schema/config.schema.json +40 -1
- package/src/admission.ts +263 -44
- package/src/ask.ts +39 -3
- package/src/availability.ts +27 -1
- package/src/backups.ts +2 -2
- package/src/briefs/orchestrator.md +1 -0
- package/src/briefs/worker.md +38 -19
- package/src/command-help.ts +8 -1
- package/src/command-manifest.ts +5 -2
- package/src/commands/arm.ts +6 -3
- package/src/commands/message.ts +32 -4
- package/src/commands/watch.ts +62 -3
- package/src/config-schema.ts +53 -0
- package/src/config.ts +97 -1
- package/src/daemon.ts +1479 -1483
- package/src/decisions.ts +51 -6
- package/src/depends-on.ts +261 -1
- package/src/diff-flags.ts +350 -0
- package/src/digest-schedule.ts +37 -0
- package/src/doctor.ts +310 -22
- package/src/escalate.ts +560 -57
- package/src/failure-class.ts +71 -15
- package/src/fleet.ts +189 -34
- package/src/gitops.ts +103 -24
- package/src/graph-health.ts +20 -7
- package/src/graph.ts +313 -68
- package/src/lifecycle.ts +43 -7
- package/src/omp.ts +42 -0
- package/src/orchestrator-tick.ts +430 -162
- package/src/release-policy.ts +177 -5
- package/src/routing.ts +11 -3
- package/src/session-host.ts +16 -0
- package/src/settlement.ts +1728 -0
- package/src/setup-host.ts +193 -4
- package/src/setup-install.ts +91 -30
- package/src/setup-wizard.ts +1257 -78
- package/src/setup.ts +153 -6
- package/src/status-render.ts +36 -4
- package/src/store.ts +411 -17
- package/src/tracker/github.ts +607 -12
- package/src/types.ts +331 -5
- package/src/upgrade.ts +50 -19
- package/src/verbs/actions.ts +66 -18
- package/src/verbs/protocol.ts +45 -0
- package/src/verbs/server.ts +270 -13
- package/src/worker.ts +239 -6
- package/src/worktree.ts +115 -8
- package/systemd/omp-conductor-recover.sh +73 -0
- package/systemd/recover-unit-test.sh +61 -0
package/src/diff-flags.ts
CHANGED
|
@@ -496,6 +496,14 @@ export interface SettlementAudit {
|
|
|
496
496
|
/** The dispatching issue's title and body — the attribution source. */
|
|
497
497
|
issueText: string;
|
|
498
498
|
diff: PrDiff;
|
|
499
|
+
/** The pull request body, or undefined when the tracker could not read one.
|
|
500
|
+
* The claims audited below live here, so absent means no proof commands are
|
|
501
|
+
* extracted — the flag family stays silent, never cries wolf on nothing. */
|
|
502
|
+
prBody?: string;
|
|
503
|
+
/** The run's session transcript (the raw JSONL), or undefined when the run
|
|
504
|
+
* had none or it could not be read. Absent means no claimed command can be
|
|
505
|
+
* checked — same silence, for the same reason. */
|
|
506
|
+
transcript?: string;
|
|
499
507
|
}
|
|
500
508
|
|
|
501
509
|
/**
|
|
@@ -513,6 +521,7 @@ export interface SettlementAudit {
|
|
|
513
521
|
export function analyseSettlement(audit: SettlementAudit): SettlementFlag[] {
|
|
514
522
|
const flags: SettlementFlag[] = [];
|
|
515
523
|
detectWeakening(audit, flags);
|
|
524
|
+
detectClaimedProof(audit, flags);
|
|
516
525
|
return flags;
|
|
517
526
|
}
|
|
518
527
|
|
|
@@ -671,6 +680,347 @@ function scanHunks(
|
|
|
671
680
|
}
|
|
672
681
|
}
|
|
673
682
|
|
|
683
|
+
// ------------------------------------------------------- claimed proof commands
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* The proof claims a PR body makes, checked against the run's transcript.
|
|
687
|
+
*
|
|
688
|
+
* A worker's Verified section is what the orchestrator merges on, and until
|
|
689
|
+
* this check nothing compared its claims against what the session actually ran
|
|
690
|
+
* (#582). The audit is the same family as the test-weakening rules: advisory,
|
|
691
|
+
* never a state change, and resolving ambiguity towards silence. Every PR
|
|
692
|
+
* body carries backticked prose, so the false-positive pressure here is real —
|
|
693
|
+
* an extractor that reads any backtick span in the narrative as a claim would
|
|
694
|
+
* flag every run within a day.
|
|
695
|
+
*
|
|
696
|
+
* Three tolerances make the matcher honest rather than decorative:
|
|
697
|
+
*
|
|
698
|
+
* - Claims are extracted only from a verified section — a `## Verified`-style
|
|
699
|
+
* heading or an inline `Verified:` label — never from the narrative. This is
|
|
700
|
+
* the second named fake of the issue (one-heading parsing), asserted for
|
|
701
|
+
* both shapes below.
|
|
702
|
+
* - Matching is on meaningful shell tokens, not string equality: a claimed
|
|
703
|
+
* `bun test src/worker.test.ts` is satisfied by a transcript invocation of
|
|
704
|
+
* `cd …/omp && bun test src/worker.test.ts src/session-host.test.ts`,
|
|
705
|
+
* because a `cd` pair, shell operators and wrappers are dropped from both
|
|
706
|
+
* sides and the claim's tokens must then be a subset of one invocation's.
|
|
707
|
+
* Flag order is thereby free, which string equality could never be.
|
|
708
|
+
* - A claimed command whose invocation the transcript shows was refused by the
|
|
709
|
+
* shared-host guard is `claimed-proof-blocked`, not `claimed-proof-missing`:
|
|
710
|
+
* the worker *tried* and the guard stopped it (the honest #566/#570 shape,
|
|
711
|
+
* whose own words are "CI owns them"), which is not the same finding as a
|
|
712
|
+
* claim with no attempt at all.
|
|
713
|
+
*/
|
|
714
|
+
|
|
715
|
+
/** The verified-section markers a claim must live under: a heading whose text
|
|
716
|
+
* contains a `veri*`/`proof` root (`## Verified`, `## Verification`,
|
|
717
|
+
* `## How verified`, `## Verification & proof`), or an inline
|
|
718
|
+
* `Verified:` / `**Verified:**` label row. `## Tests` is deliberately not
|
|
719
|
+
* one — a section describing the diff's test impact is narrative, not proof. */
|
|
720
|
+
const VERIFIED_MARKER =
|
|
721
|
+
/^(?:#{1,6}\s+.*\b(?:verif|proof)\w*\b.*$|^\s*(?:\*\*)?(?:Verif(?:ied|ication)|Proof)(?:\*\*)?\s*[:.])/im;
|
|
722
|
+
|
|
723
|
+
/**
|
|
724
|
+
* The slice of a PR body that can carry proof claims: from the first
|
|
725
|
+
* verified-section marker to the end of its block. Claims live under backticks,
|
|
726
|
+
* so the region is what keeps narrative code spans out of the audit — a
|
|
727
|
+
* backticked `retry.modelFallback: true` in a design explanation is commentary,
|
|
728
|
+
* never a command somebody claimed to run.
|
|
729
|
+
*/
|
|
730
|
+
function claimRegion(body: string): string {
|
|
731
|
+
const marker = VERIFIED_MARKER.exec(body);
|
|
732
|
+
if (marker === null) return "";
|
|
733
|
+
const start = marker.index;
|
|
734
|
+
const rest = body.slice(start);
|
|
735
|
+
// The next-heading scan starts after the marker's own match, or `## Verified`
|
|
736
|
+
// would cut its own block to nothing — the marker line is a heading too.
|
|
737
|
+
const after = rest.slice(marker[0].length);
|
|
738
|
+
const next = /^#{1,6}\s+/m.exec(after);
|
|
739
|
+
return next === null ? rest : rest.slice(0, marker[0].length + next.index);
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/** One backticked span that reads as a whole command: at least two shell words
|
|
743
|
+
* and a first word that is an executable (no path separator, or a `./` one).
|
|
744
|
+
* A span that names a single file or a config key is not a command claim. */
|
|
745
|
+
function claimedCommand(span: string): boolean {
|
|
746
|
+
const words = span.split(/\s+/).filter((word) => word.length > 0);
|
|
747
|
+
if (words.length < 2) return false;
|
|
748
|
+
const first = words[0] ?? "";
|
|
749
|
+
return first.startsWith("./") || !first.includes("/");
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/**
|
|
753
|
+
* The commands a PR body claims as proof, each deduplicated by its exact span.
|
|
754
|
+
* Only spans inside the verified region count, and only command-shaped ones
|
|
755
|
+
* — the two filters are what keep `## Verified`-island prose out of the audit.
|
|
756
|
+
*/
|
|
757
|
+
function claimedProofCommands(body: string): string[] {
|
|
758
|
+
const region = claimRegion(body);
|
|
759
|
+
const claims: string[] = [];
|
|
760
|
+
const seen = new Set<string>();
|
|
761
|
+
for (const match of region.matchAll(/`([^`\n]+)`/g)) {
|
|
762
|
+
const span = (match[1] ?? "").trim();
|
|
763
|
+
if (span === "" || seen.has(span) || !claimedCommand(span)) continue;
|
|
764
|
+
seen.add(span);
|
|
765
|
+
claims.push(span);
|
|
766
|
+
}
|
|
767
|
+
return claims;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
/**
|
|
771
|
+
* Split a shell command into its meaningful words: whitespace-separated, quote
|
|
772
|
+
* characters removed so `bun test "src/my file.ts"` stays one word. Deliberately
|
|
773
|
+
* not a shell parser — the wide net is fine because the comparison is a subset
|
|
774
|
+
* in one direction only (see {@link satisfiedBy}).
|
|
775
|
+
*/
|
|
776
|
+
function shellWords(command: string): string[] {
|
|
777
|
+
const words: string[] = [];
|
|
778
|
+
let current = "";
|
|
779
|
+
let quote: string | undefined;
|
|
780
|
+
for (const ch of command) {
|
|
781
|
+
if (quote !== undefined) {
|
|
782
|
+
if (ch === quote) quote = undefined;
|
|
783
|
+
else current += ch;
|
|
784
|
+
continue;
|
|
785
|
+
}
|
|
786
|
+
if (ch === '"' || ch === "'") {
|
|
787
|
+
quote = ch;
|
|
788
|
+
continue;
|
|
789
|
+
}
|
|
790
|
+
if (/\s/.test(ch)) {
|
|
791
|
+
if (current !== "") {
|
|
792
|
+
words.push(current);
|
|
793
|
+
current = "";
|
|
794
|
+
}
|
|
795
|
+
continue;
|
|
796
|
+
}
|
|
797
|
+
current += ch;
|
|
798
|
+
}
|
|
799
|
+
if (current !== "") words.push(current);
|
|
800
|
+
return words;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
/** A token that is shell plumbing rather than a command word: `&&`, `|`,
|
|
804
|
+
* `;`, redirects, grouping — anything made only of the metacharacter
|
|
805
|
+
* alphabet. `2>&1` is kept (it carries a number), so stripping never eats a
|
|
806
|
+
* word that discriminates one command from another. */
|
|
807
|
+
const SHELL_PUNCT = /^[&|;<>()$!`]+$/;
|
|
808
|
+
|
|
809
|
+
/**
|
|
810
|
+
* The meaningful tokens of one command: shell punctuation dropped, and a
|
|
811
|
+
* `cd <dir>` pair consumed as binding context rather than a command word —
|
|
812
|
+
* the tolerated-variant half of the acceptance criteria. Wrapper verbs
|
|
813
|
+
* (`timeout 300`, `env -i`) survive tokenisation because a *transcript* side
|
|
814
|
+
* carrying them never hurts a subset check; only a claimed side spelling them
|
|
815
|
+
* would, which is a shape this fleet's bodies do not produce.
|
|
816
|
+
*/
|
|
817
|
+
function commandTokens(command: string): string[] {
|
|
818
|
+
const words = shellWords(command);
|
|
819
|
+
const tokens: string[] = [];
|
|
820
|
+
for (let i = 0; i < words.length; i++) {
|
|
821
|
+
const word = words[i] ?? "";
|
|
822
|
+
if (word === "cd") {
|
|
823
|
+
i++; // its target is binding context too
|
|
824
|
+
continue;
|
|
825
|
+
}
|
|
826
|
+
if (SHELL_PUNCT.test(word)) continue;
|
|
827
|
+
tokens.push(word);
|
|
828
|
+
}
|
|
829
|
+
return tokens;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
/**
|
|
833
|
+
* The shell-executing tools whose invocations can satisfy a claimed command.
|
|
834
|
+
* The shared-host guard's own invocation prefixes, minus `source` — a dot
|
|
835
|
+
* script has no command text of its own worth matching.
|
|
836
|
+
*/
|
|
837
|
+
const SHELL_TOOLS = new Set(["bash", "sh", "zsh"]);
|
|
838
|
+
|
|
839
|
+
/**
|
|
840
|
+
* Whether a shell command ran in parse-only mode — a leading `bash -n`,
|
|
841
|
+
* `sh -n` after the `cd`-stripping, before the first non-flag token. This is
|
|
842
|
+
* the one how-vs-whether distinction the incident itself turned on: the
|
|
843
|
+
* shared-host guard explicitly permits `bash -n` on a guarded script while
|
|
844
|
+
* executing it is refused, and the *path token appears in both forms*, so a
|
|
845
|
+
* subset match alone would let a parse invocation satisfy a claim of having
|
|
846
|
+
* run the suite. The shared-host refusal wording makes the same explicit
|
|
847
|
+
* ("parsing never executes").
|
|
848
|
+
*/
|
|
849
|
+
function parseOnly(tokens: string[]): boolean {
|
|
850
|
+
const first = tokens[0];
|
|
851
|
+
if (first === undefined || !SHELL_TOOLS.has(first)) return false;
|
|
852
|
+
return tokens.slice(1).includes("-n");
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
/** Whether one claimed command is satisfied by one transcript invocation: the
|
|
856
|
+
* claim's tokens are a subset (order-free, multiplicity-checked) of the
|
|
857
|
+
* invocation's. Subset rather than subsequence is what makes differing flag
|
|
858
|
+
* order harmless; multiplicity is what keeps `bun test a a` from being
|
|
859
|
+
* satisfied by `bun test a`.
|
|
860
|
+
*
|
|
861
|
+
* The one semantic guard on top of the subset: a claim that the worker *ran*
|
|
862
|
+
* a command is never satisfied by an invocation that only parsed it
|
|
863
|
+
* (`bash -n`), for the reason {@link parseOnly} states. A parse claim is
|
|
864
|
+
* still satisfied by a parse invocation. */
|
|
865
|
+
function satisfiedBy(claimTokens: string[], invocationTokens: string[]): boolean {
|
|
866
|
+
if (claimTokens.length === 0 || invocationTokens.length === 0) return false;
|
|
867
|
+
if (parseOnly(invocationTokens) && !parseOnly(claimTokens)) return false;
|
|
868
|
+
const remaining = [...invocationTokens];
|
|
869
|
+
for (const token of claimTokens) {
|
|
870
|
+
const at = remaining.indexOf(token);
|
|
871
|
+
if (at < 0) return false;
|
|
872
|
+
remaining.splice(at, 1);
|
|
873
|
+
}
|
|
874
|
+
return true;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
interface TranscriptCall {
|
|
878
|
+
/** The tool-call id, present when the record carries one — links an
|
|
879
|
+
* invocation to its tool result for the refused-detection below. */
|
|
880
|
+
id?: string;
|
|
881
|
+
/** The command text of the invocation, or undefined when the record's shape
|
|
882
|
+
* carries none (a synthetic/aborted call, a record this parser does not
|
|
883
|
+
* recognise). */
|
|
884
|
+
command?: string;
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
/** The command text a content block or top-level record carries, whatever the
|
|
888
|
+
* harness's spelling for it — `input` (tool_use), `arguments` (toolCall) or a
|
|
889
|
+
* bare `command` — and undefined when the shape is unrecognised. */
|
|
890
|
+
function commandField(record: { readonly [key: string]: unknown }): string | undefined {
|
|
891
|
+
for (const key of ["input", "arguments"] as const) {
|
|
892
|
+
const field = record[key];
|
|
893
|
+
if (field !== null && typeof field === "object") {
|
|
894
|
+
// The harness's unvalidated transcript shapes; read as unknowns, like the
|
|
895
|
+
// daemon's other transcript readers.
|
|
896
|
+
const args = field as { readonly [key: string]: unknown };
|
|
897
|
+
const command = args["command"];
|
|
898
|
+
if (typeof command === "string") return command;
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
const direct = record["command"];
|
|
902
|
+
return typeof direct === "string" ? direct : undefined;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
/**
|
|
906
|
+
* Every shell invocation a transcript records, plus every tool-result that a
|
|
907
|
+
* shared-host guard refusal produced. Defensive, in the module header's sense:
|
|
908
|
+
* the transcript is the harness's file, so both shapes this package already
|
|
909
|
+
* reads (a top-level `{toolName}` event and a `tool_use`/`toolCall` content
|
|
910
|
+
* block inside an assistant message) are recognised, and an unknown shape is
|
|
911
|
+
* skipped rather than TypeErroring the audit.
|
|
912
|
+
*/
|
|
913
|
+
export function transcriptCalls(text: string): { calls: TranscriptCall[]; refusals: Set<string> } {
|
|
914
|
+
const calls: TranscriptCall[] = [];
|
|
915
|
+
const refusals = new Set<string>();
|
|
916
|
+
for (const line of text.split("\n")) {
|
|
917
|
+
if (line.length === 0) continue;
|
|
918
|
+
let row: unknown;
|
|
919
|
+
try {
|
|
920
|
+
row = JSON.parse(line) as unknown;
|
|
921
|
+
} catch {
|
|
922
|
+
continue;
|
|
923
|
+
}
|
|
924
|
+
if (row === null || typeof row !== "object") continue;
|
|
925
|
+
const rec = row as { readonly [key: string]: unknown };
|
|
926
|
+
|
|
927
|
+
const toolName = rec["toolName"];
|
|
928
|
+
if (typeof toolName === "string") {
|
|
929
|
+
if (SHELL_TOOLS.has(toolName)) {
|
|
930
|
+
const id = rec["id"];
|
|
931
|
+
const command = commandField(rec);
|
|
932
|
+
calls.push({
|
|
933
|
+
...(typeof id === "string" ? { id } : {}),
|
|
934
|
+
...(command === undefined ? {} : { command }),
|
|
935
|
+
});
|
|
936
|
+
}
|
|
937
|
+
continue;
|
|
938
|
+
}
|
|
939
|
+
if (rec["type"] !== "message") continue;
|
|
940
|
+
const message = rec["message"];
|
|
941
|
+
if (message === null || typeof message !== "object") continue;
|
|
942
|
+
const m = message as { readonly [key: string]: unknown };
|
|
943
|
+
|
|
944
|
+
const content = m["content"];
|
|
945
|
+
if (Array.isArray(content) && m["role"] === "assistant") {
|
|
946
|
+
for (const part of content) {
|
|
947
|
+
if (part === null || typeof part !== "object") continue;
|
|
948
|
+
const p = part as { readonly [key: string]: unknown };
|
|
949
|
+
const name = p["name"];
|
|
950
|
+
if (typeof name !== "string" || !SHELL_TOOLS.has(name)) continue;
|
|
951
|
+
const id = p["id"];
|
|
952
|
+
const command = commandField(p);
|
|
953
|
+
calls.push({
|
|
954
|
+
...(typeof id === "string" ? { id } : {}),
|
|
955
|
+
...(command === undefined ? {} : { command }),
|
|
956
|
+
});
|
|
957
|
+
}
|
|
958
|
+
continue;
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
// A shared-host refusal arrives as a tool result carrying the guard's
|
|
962
|
+
// own marker. Keyed by the tool-call id so the refused invocation is the
|
|
963
|
+
// one identified, never its neighbour.
|
|
964
|
+
if (m["role"] === "toolResult") {
|
|
965
|
+
const toolCallId = m["toolCallId"];
|
|
966
|
+
if (typeof toolCallId !== "string") continue;
|
|
967
|
+
const parts = Array.isArray(content) ? content : [];
|
|
968
|
+
const refused = parts.some((part): boolean => {
|
|
969
|
+
if (part === null || typeof part !== "object") return false;
|
|
970
|
+
const block = part as { readonly [key: string]: unknown };
|
|
971
|
+
const text = block["text"];
|
|
972
|
+
return typeof text === "string" && text.includes("sharedHostPolicy");
|
|
973
|
+
});
|
|
974
|
+
if (refused) refusals.add(toolCallId);
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
return { calls, refusals };
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
/**
|
|
981
|
+
* The claimed-proof half of the settlement audit: one finding per claimed
|
|
982
|
+
* command whose transcript is silent, and a distinct finding (the honest
|
|
983
|
+
* shape) for one the guard refused. The presence direction — a claim satisfied
|
|
984
|
+
* by any invocation — produces nothing, which is the whole point of the flag
|
|
985
|
+
* existing: silence is what a true Verified section earns.
|
|
986
|
+
*/
|
|
987
|
+
function detectClaimedProof(audit: SettlementAudit, flags: SettlementFlag[]): void {
|
|
988
|
+
if (audit.prBody === undefined || audit.transcript === undefined) return;
|
|
989
|
+
const claims = claimedProofCommands(audit.prBody);
|
|
990
|
+
if (claims.length === 0) return;
|
|
991
|
+
const { calls, refusals } = transcriptCalls(audit.transcript);
|
|
992
|
+
const invocations = calls
|
|
993
|
+
.filter((call) => call.command !== undefined)
|
|
994
|
+
.map((call) => {
|
|
995
|
+
const tracked = call as { id?: string; command: string };
|
|
996
|
+
return { ...tracked, tokens: commandTokens(tracked.command) };
|
|
997
|
+
});
|
|
998
|
+
|
|
999
|
+
for (const claim of claims) {
|
|
1000
|
+
const claimTokens = commandTokens(claim);
|
|
1001
|
+
if (claimTokens.length === 0) continue;
|
|
1002
|
+
const matched = invocations.filter((call) => satisfiedBy(claimTokens, call.tokens));
|
|
1003
|
+
if (matched.length > 0) {
|
|
1004
|
+
// A claimed command the guard refused is an attempted-and-blocked report,
|
|
1005
|
+
// never a fabricated one: the worker's own words were "CI owns them",
|
|
1006
|
+
// and a reviewer should be pointing at the guard, not the worker.
|
|
1007
|
+
if (matched.some((call) => call.id !== undefined && refusals.has(call.id))) {
|
|
1008
|
+
flags.push({
|
|
1009
|
+
kind: "claimed-proof-blocked",
|
|
1010
|
+
file: "(transcript)",
|
|
1011
|
+
detail: `\`${evidence(claim)}\` was attempted, but the shared-host guard refused it (CI owns it)`,
|
|
1012
|
+
});
|
|
1013
|
+
}
|
|
1014
|
+
continue;
|
|
1015
|
+
}
|
|
1016
|
+
flags.push({
|
|
1017
|
+
kind: "claimed-proof-missing",
|
|
1018
|
+
file: "(transcript)",
|
|
1019
|
+
detail: `\`${evidence(claim)}\` is claimed as proof, but no command matching it appears in the run's transcript`,
|
|
1020
|
+
});
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
|
|
674
1024
|
// -------------------------------------------------------------------- printing
|
|
675
1025
|
|
|
676
1026
|
/** Beyond this the report is a wall of text nobody reads; the count still tells
|
package/src/digest-schedule.ts
CHANGED
|
@@ -58,6 +58,43 @@ export function localDayKey(at: number, timezone?: string): string {
|
|
|
58
58
|
return localDigestMinute(at, timezone).day;
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
/** The durable day-marker key for the db-snapshot cadence (#289), mirroring
|
|
62
|
+
* the `digest:<day>` dedupe keys in the report outbox. */
|
|
63
|
+
export function dbSnapshotMarkerKey(dayKey: string): string {
|
|
64
|
+
return `db-snapshot:${dayKey}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Whether the conductor.db snapshot cadence is due now.
|
|
69
|
+
*
|
|
70
|
+
* The store is the ledger — the verb ledger, the decision rows and the run
|
|
71
|
+
* history have no other copy — so it is snapshotted once per day, aligned to
|
|
72
|
+
* the digest tick: a daily digest applies the exact {@link digestDue}
|
|
73
|
+
* predicate (same day boundary, same `at`, same timezone), so the snapshot
|
|
74
|
+
* lands on the same tick the digest goes out. A `none` or `per-tick` digest
|
|
75
|
+
* still leaves the ledger on a daily schedule — durability does not follow
|
|
76
|
+
* the reporting preference — bounded by the policy-zone day key (host zone
|
|
77
|
+
* when the policy carries none), with no `at`. The zone must be the
|
|
78
|
+
* policy's: the caller writes the `db-snapshot:<day>` marker with
|
|
79
|
+
* {@link localDayKey localDayKey(now, policy.digest.timezone)}, so a
|
|
80
|
+
* host-zone read here would disagree with it whenever the two zones fall on
|
|
81
|
+
* different days, and a `{cadence: "per-tick", timezone: "Europe/London"}`
|
|
82
|
+
* policy on a UTC host would re-snapshot on every tick during the offset
|
|
83
|
+
* window, pruning the retained set to nothing.
|
|
84
|
+
*
|
|
85
|
+
* Pure by construction, like {@link digestDue}: the last-run fact arrives as
|
|
86
|
+
* the day key (`db-snapshot:<day>` marker present for today ⟺ last run was
|
|
87
|
+
* today), never a clock the caller read an hour ago.
|
|
88
|
+
*/
|
|
89
|
+
export function dbSnapshotDue(
|
|
90
|
+
policy: Pick<ReportingPolicy, "digest">,
|
|
91
|
+
lastSnapshotDayKey: string | undefined,
|
|
92
|
+
now: number,
|
|
93
|
+
): boolean {
|
|
94
|
+
if (policy.digest.cadence === "daily") return digestDue(policy, lastSnapshotDayKey, now);
|
|
95
|
+
return localDayKey(now, policy.digest.timezone) !== lastSnapshotDayKey;
|
|
96
|
+
}
|
|
97
|
+
|
|
61
98
|
/**
|
|
62
99
|
* Whether the digest is due now.
|
|
63
100
|
*
|