pi-amq 0.1.1
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/CHANGELOG.md +66 -0
- package/LICENSE +21 -0
- package/README.md +92 -0
- package/THIRD_PARTY_NOTICES.md +33 -0
- package/dist/extension/guidance.d.ts +25 -0
- package/dist/extension/guidance.d.ts.map +1 -0
- package/dist/extension/guidance.js +121 -0
- package/dist/extension/guidance.js.map +1 -0
- package/dist/extension/guide.d.ts +30 -0
- package/dist/extension/guide.d.ts.map +1 -0
- package/dist/extension/guide.js +128 -0
- package/dist/extension/guide.js.map +1 -0
- package/dist/extension/index.d.ts +4 -0
- package/dist/extension/index.d.ts.map +1 -0
- package/dist/extension/index.js +6 -0
- package/dist/extension/index.js.map +1 -0
- package/dist/extension/runtime.d.ts +48 -0
- package/dist/extension/runtime.d.ts.map +1 -0
- package/dist/extension/runtime.js +1095 -0
- package/dist/extension/runtime.js.map +1 -0
- package/dist/extension/tools.d.ts +12 -0
- package/dist/extension/tools.d.ts.map +1 -0
- package/dist/extension/tools.js +198 -0
- package/dist/extension/tools.js.map +1 -0
- package/dist/extension/worker-runtime.d.ts +34 -0
- package/dist/extension/worker-runtime.d.ts.map +1 -0
- package/dist/extension/worker-runtime.js +974 -0
- package/dist/extension/worker-runtime.js.map +1 -0
- package/dist/extension/worker-tools.d.ts +140 -0
- package/dist/extension/worker-tools.d.ts.map +1 -0
- package/dist/extension/worker-tools.js +217 -0
- package/dist/extension/worker-tools.js.map +1 -0
- package/dist/integration/confirmed-bind-onboarding.d.ts +44 -0
- package/dist/integration/confirmed-bind-onboarding.d.ts.map +1 -0
- package/dist/integration/confirmed-bind-onboarding.js +199 -0
- package/dist/integration/confirmed-bind-onboarding.js.map +1 -0
- package/dist/integration/fleet-view.d.ts +66 -0
- package/dist/integration/fleet-view.d.ts.map +1 -0
- package/dist/integration/fleet-view.js +238 -0
- package/dist/integration/fleet-view.js.map +1 -0
- package/dist/integration/fresh-session-dispatcher.d.ts +66 -0
- package/dist/integration/fresh-session-dispatcher.d.ts.map +1 -0
- package/dist/integration/fresh-session-dispatcher.js +103 -0
- package/dist/integration/fresh-session-dispatcher.js.map +1 -0
- package/dist/integration/launch-preview.d.ts +31 -0
- package/dist/integration/launch-preview.d.ts.map +1 -0
- package/dist/integration/launch-preview.js +325 -0
- package/dist/integration/launch-preview.js.map +1 -0
- package/dist/integration/transport-port.d.ts +39 -0
- package/dist/integration/transport-port.d.ts.map +1 -0
- package/dist/integration/transport-port.js +2 -0
- package/dist/integration/transport-port.js.map +1 -0
- package/dist/integration/worker-completion-notifier.d.ts +22 -0
- package/dist/integration/worker-completion-notifier.d.ts.map +1 -0
- package/dist/integration/worker-completion-notifier.js +81 -0
- package/dist/integration/worker-completion-notifier.js.map +1 -0
- package/dist/transport/amq-cli-adapter.d.ts +41 -0
- package/dist/transport/amq-cli-adapter.d.ts.map +1 -0
- package/dist/transport/amq-cli-adapter.js +291 -0
- package/dist/transport/amq-cli-adapter.js.map +1 -0
- package/dist/transport/amq-message.d.ts +47 -0
- package/dist/transport/amq-message.d.ts.map +1 -0
- package/dist/transport/amq-message.js +173 -0
- package/dist/transport/amq-message.js.map +1 -0
- package/dist/transport/amq-operations.d.ts +105 -0
- package/dist/transport/amq-operations.d.ts.map +1 -0
- package/dist/transport/amq-operations.js +320 -0
- package/dist/transport/amq-operations.js.map +1 -0
- package/dist/transport/atomic-json.d.ts +18 -0
- package/dist/transport/atomic-json.d.ts.map +1 -0
- package/dist/transport/atomic-json.js +90 -0
- package/dist/transport/atomic-json.js.map +1 -0
- package/dist/transport/binding-resolver.d.ts +46 -0
- package/dist/transport/binding-resolver.d.ts.map +1 -0
- package/dist/transport/binding-resolver.js +389 -0
- package/dist/transport/binding-resolver.js.map +1 -0
- package/dist/transport/bridge-journal.d.ts +100 -0
- package/dist/transport/bridge-journal.d.ts.map +1 -0
- package/dist/transport/bridge-journal.js +459 -0
- package/dist/transport/bridge-journal.js.map +1 -0
- package/dist/transport/dlq-retry-audit.d.ts +28 -0
- package/dist/transport/dlq-retry-audit.d.ts.map +1 -0
- package/dist/transport/dlq-retry-audit.js +66 -0
- package/dist/transport/dlq-retry-audit.js.map +1 -0
- package/dist/transport/inbox-pump.d.ts +81 -0
- package/dist/transport/inbox-pump.d.ts.map +1 -0
- package/dist/transport/inbox-pump.js +558 -0
- package/dist/transport/inbox-pump.js.map +1 -0
- package/dist/transport/index.d.ts +12 -0
- package/dist/transport/index.d.ts.map +1 -0
- package/dist/transport/index.js +12 -0
- package/dist/transport/index.js.map +1 -0
- package/dist/transport/presence-projector.d.ts +29 -0
- package/dist/transport/presence-projector.d.ts.map +1 -0
- package/dist/transport/presence-projector.js +73 -0
- package/dist/transport/presence-projector.js.map +1 -0
- package/dist/transport/priority-delivery-scheduler.d.ts +85 -0
- package/dist/transport/priority-delivery-scheduler.d.ts.map +1 -0
- package/dist/transport/priority-delivery-scheduler.js +210 -0
- package/dist/transport/priority-delivery-scheduler.js.map +1 -0
- package/dist/transport/receiver-lease.d.ts +102 -0
- package/dist/transport/receiver-lease.d.ts.map +1 -0
- package/dist/transport/receiver-lease.js +778 -0
- package/dist/transport/receiver-lease.js.map +1 -0
- package/dist/transport/runtime-status.d.ts +48 -0
- package/dist/transport/runtime-status.d.ts.map +1 -0
- package/dist/transport/runtime-status.js +71 -0
- package/dist/transport/runtime-status.js.map +1 -0
- package/dist/workflow/atomic-storage.d.ts +12 -0
- package/dist/workflow/atomic-storage.d.ts.map +1 -0
- package/dist/workflow/atomic-storage.js +104 -0
- package/dist/workflow/atomic-storage.js.map +1 -0
- package/dist/workflow/budget.d.ts +18 -0
- package/dist/workflow/budget.d.ts.map +1 -0
- package/dist/workflow/budget.js +74 -0
- package/dist/workflow/budget.js.map +1 -0
- package/dist/workflow/index.d.ts +17 -0
- package/dist/workflow/index.d.ts.map +1 -0
- package/dist/workflow/index.js +17 -0
- package/dist/workflow/index.js.map +1 -0
- package/dist/workflow/lifecycle-projection.d.ts +18 -0
- package/dist/workflow/lifecycle-projection.d.ts.map +1 -0
- package/dist/workflow/lifecycle-projection.js +22 -0
- package/dist/workflow/lifecycle-projection.js.map +1 -0
- package/dist/workflow/model-fallback.d.ts +2 -0
- package/dist/workflow/model-fallback.d.ts.map +1 -0
- package/dist/workflow/model-fallback.js +17 -0
- package/dist/workflow/model-fallback.js.map +1 -0
- package/dist/workflow/pi-session-fork.d.ts +18 -0
- package/dist/workflow/pi-session-fork.d.ts.map +1 -0
- package/dist/workflow/pi-session-fork.js +80 -0
- package/dist/workflow/pi-session-fork.js.map +1 -0
- package/dist/workflow/run-artifacts.d.ts +5 -0
- package/dist/workflow/run-artifacts.d.ts.map +1 -0
- package/dist/workflow/run-artifacts.js +25 -0
- package/dist/workflow/run-artifacts.js.map +1 -0
- package/dist/workflow/run-cleanup.d.ts +70 -0
- package/dist/workflow/run-cleanup.d.ts.map +1 -0
- package/dist/workflow/run-cleanup.js +222 -0
- package/dist/workflow/run-cleanup.js.map +1 -0
- package/dist/workflow/run-gate.d.ts +43 -0
- package/dist/workflow/run-gate.d.ts.map +1 -0
- package/dist/workflow/run-gate.js +70 -0
- package/dist/workflow/run-gate.js.map +1 -0
- package/dist/workflow/run-store.d.ts +208 -0
- package/dist/workflow/run-store.d.ts.map +1 -0
- package/dist/workflow/run-store.js +2965 -0
- package/dist/workflow/run-store.js.map +1 -0
- package/dist/workflow/spawn-confirmation.d.ts +44 -0
- package/dist/workflow/spawn-confirmation.d.ts.map +1 -0
- package/dist/workflow/spawn-confirmation.js +228 -0
- package/dist/workflow/spawn-confirmation.js.map +1 -0
- package/dist/workflow/system-process-inspector.d.ts +9 -0
- package/dist/workflow/system-process-inspector.d.ts.map +1 -0
- package/dist/workflow/system-process-inspector.js +129 -0
- package/dist/workflow/system-process-inspector.js.map +1 -0
- package/dist/workflow/worker-child-extension.d.ts +3 -0
- package/dist/workflow/worker-child-extension.d.ts.map +1 -0
- package/dist/workflow/worker-child-extension.js +182 -0
- package/dist/workflow/worker-child-extension.js.map +1 -0
- package/dist/workflow/worker-gate.d.ts +74 -0
- package/dist/workflow/worker-gate.d.ts.map +1 -0
- package/dist/workflow/worker-gate.js +165 -0
- package/dist/workflow/worker-gate.js.map +1 -0
- package/dist/workflow/worker-launch.d.ts +65 -0
- package/dist/workflow/worker-launch.d.ts.map +1 -0
- package/dist/workflow/worker-launch.js +338 -0
- package/dist/workflow/worker-launch.js.map +1 -0
- package/dist/workflow/worker-model-catalog.d.ts +36 -0
- package/dist/workflow/worker-model-catalog.d.ts.map +1 -0
- package/dist/workflow/worker-model-catalog.js +105 -0
- package/dist/workflow/worker-model-catalog.js.map +1 -0
- package/dist/workflow/worker-process-supervisor.d.ts +110 -0
- package/dist/workflow/worker-process-supervisor.d.ts.map +1 -0
- package/dist/workflow/worker-process-supervisor.js +1188 -0
- package/dist/workflow/worker-process-supervisor.js.map +1 -0
- package/dist/workflow/worker-prompt-capsule.d.ts +32 -0
- package/dist/workflow/worker-prompt-capsule.d.ts.map +1 -0
- package/dist/workflow/worker-prompt-capsule.js +52 -0
- package/dist/workflow/worker-prompt-capsule.js.map +1 -0
- package/dist/workflow/worker-role-presets.d.ts +38 -0
- package/dist/workflow/worker-role-presets.d.ts.map +1 -0
- package/dist/workflow/worker-role-presets.js +201 -0
- package/dist/workflow/worker-role-presets.js.map +1 -0
- package/dist/workflow/workflow-doctor.d.ts +57 -0
- package/dist/workflow/workflow-doctor.d.ts.map +1 -0
- package/dist/workflow/workflow-doctor.js +289 -0
- package/dist/workflow/workflow-doctor.js.map +1 -0
- package/dist/workflow/workflow-lease.d.ts +125 -0
- package/dist/workflow/workflow-lease.d.ts.map +1 -0
- package/dist/workflow/workflow-lease.js +689 -0
- package/dist/workflow/workflow-lease.js.map +1 -0
- package/dist/workflow/workflow-types.d.ts +335 -0
- package/dist/workflow/workflow-types.d.ts.map +1 -0
- package/dist/workflow/workflow-types.js +11 -0
- package/dist/workflow/workflow-types.js.map +1 -0
- package/dist/workflow/workspace-paths.d.ts +4 -0
- package/dist/workflow/workspace-paths.d.ts.map +1 -0
- package/dist/workflow/workspace-paths.js +22 -0
- package/dist/workflow/workspace-paths.js.map +1 -0
- package/docs/conformance.md +116 -0
- package/docs/demo-transcript.md +34 -0
- package/docs/design.md +1481 -0
- package/docs/operator-guide.md +100 -0
- package/extension/guidance.ts +162 -0
- package/extension/guide.ts +155 -0
- package/extension/index.ts +8 -0
- package/extension/runtime.ts +1224 -0
- package/extension/tools.ts +221 -0
- package/extension/worker-runtime.ts +1150 -0
- package/extension/worker-tools.ts +276 -0
- package/integration/confirmed-bind-onboarding.ts +296 -0
- package/integration/fleet-view.ts +286 -0
- package/integration/fresh-session-dispatcher.ts +166 -0
- package/integration/launch-preview.ts +396 -0
- package/integration/transport-port.ts +29 -0
- package/integration/worker-completion-notifier.ts +98 -0
- package/package.json +55 -0
- package/roles/oracle.md +9 -0
- package/roles/researcher.md +8 -0
- package/roles/reviewer.md +9 -0
- package/roles/scout.md +8 -0
- package/roles/worker.md +10 -0
- package/scripts/demo-two-terminal.sh +59 -0
- package/skills/coordination-patterns/SKILL.md +41 -0
- package/skills/coordination-patterns/references/supervised-worker-fan-out.md +12 -0
- package/skills/coordination-patterns/references/today-mail-team.md +36 -0
- package/transport/amq-cli-adapter.ts +381 -0
- package/transport/amq-message.ts +218 -0
- package/transport/amq-operations.ts +482 -0
- package/transport/atomic-json.ts +115 -0
- package/transport/binding-resolver.ts +565 -0
- package/transport/bridge-journal.ts +588 -0
- package/transport/dlq-retry-audit.ts +97 -0
- package/transport/inbox-pump.ts +729 -0
- package/transport/index.ts +11 -0
- package/transport/presence-projector.ts +104 -0
- package/transport/priority-delivery-scheduler.ts +328 -0
- package/transport/receiver-lease.ts +945 -0
- package/transport/runtime-status.ts +112 -0
- package/workflow/.gitkeep +1 -0
- package/workflow/atomic-storage.ts +120 -0
- package/workflow/budget.ts +90 -0
- package/workflow/index.ts +16 -0
- package/workflow/lifecycle-projection.ts +37 -0
- package/workflow/model-fallback.ts +17 -0
- package/workflow/pi-session-fork.ts +96 -0
- package/workflow/run-artifacts.ts +36 -0
- package/workflow/run-cleanup.ts +315 -0
- package/workflow/run-gate.ts +122 -0
- package/workflow/run-store.ts +3890 -0
- package/workflow/spawn-confirmation.ts +305 -0
- package/workflow/system-process-inspector.ts +142 -0
- package/workflow/worker-child-extension.ts +193 -0
- package/workflow/worker-gate.ts +238 -0
- package/workflow/worker-launch.ts +485 -0
- package/workflow/worker-model-catalog.ts +165 -0
- package/workflow/worker-process-supervisor.ts +1504 -0
- package/workflow/worker-prompt-capsule.ts +81 -0
- package/workflow/worker-role-presets.ts +241 -0
- package/workflow/workflow-doctor.ts +400 -0
- package/workflow/workflow-lease.ts +905 -0
- package/workflow/workflow-types.ts +367 -0
- package/workflow/workspace-paths.ts +32 -0
|
@@ -0,0 +1,588 @@
|
|
|
1
|
+
import { mkdir, readdir } from "node:fs/promises";
|
|
2
|
+
import { isAbsolute, join, normalize } from "node:path";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
readJson,
|
|
6
|
+
writeAtomicJson,
|
|
7
|
+
type AtomicFaultInjector,
|
|
8
|
+
} from "./atomic-json.js";
|
|
9
|
+
import { ReceiverLeaseManager } from "./receiver-lease.js";
|
|
10
|
+
|
|
11
|
+
const RECORD_KEYS = [
|
|
12
|
+
"schema",
|
|
13
|
+
"id",
|
|
14
|
+
"attempt",
|
|
15
|
+
"phase",
|
|
16
|
+
"binding",
|
|
17
|
+
"leaseGeneration",
|
|
18
|
+
"targetSessionId",
|
|
19
|
+
"delivery",
|
|
20
|
+
"claimedAt",
|
|
21
|
+
"receiptWarning",
|
|
22
|
+
"updatedAt",
|
|
23
|
+
] as const;
|
|
24
|
+
const BINDING_KEYS = ["rootId", "handle"] as const;
|
|
25
|
+
|
|
26
|
+
export type BridgePhase = "pending" | "injected" | "dlq";
|
|
27
|
+
export type BridgeDelivery = "steer" | "followUp" | "nextTurn" | "fresh-session";
|
|
28
|
+
|
|
29
|
+
export interface BridgeBinding {
|
|
30
|
+
readonly rootId: string;
|
|
31
|
+
readonly handle: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface BridgeJournalRecordV1 {
|
|
35
|
+
readonly schema: 1;
|
|
36
|
+
readonly id: string;
|
|
37
|
+
readonly attempt: number;
|
|
38
|
+
readonly phase: BridgePhase;
|
|
39
|
+
readonly binding: BridgeBinding;
|
|
40
|
+
readonly leaseGeneration: string;
|
|
41
|
+
readonly targetSessionId: string | null;
|
|
42
|
+
readonly delivery: BridgeDelivery | null;
|
|
43
|
+
readonly claimedAt: string | null;
|
|
44
|
+
readonly receiptWarning: string | null;
|
|
45
|
+
readonly updatedAt: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface DiskProjectionEvidence {
|
|
49
|
+
readonly messageId: string;
|
|
50
|
+
readonly sessionId: string;
|
|
51
|
+
readonly diskVisible: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface BridgeRetryAuditEvidence {
|
|
55
|
+
readonly auditId: string;
|
|
56
|
+
readonly originalMessageId: string;
|
|
57
|
+
readonly retryCount: number;
|
|
58
|
+
readonly retryState: "delivered";
|
|
59
|
+
readonly visibleMessageId: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface BridgeAttemptHistoryV1 {
|
|
63
|
+
readonly schema: "pi-amq-attempt-history/v1";
|
|
64
|
+
readonly record: BridgeJournalRecordV1;
|
|
65
|
+
readonly retryAudit: BridgeRetryAuditEvidence;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface BridgeJournalOptions {
|
|
69
|
+
readonly namespaceDirectory: string;
|
|
70
|
+
readonly leaseManager: ReceiverLeaseManager;
|
|
71
|
+
readonly now?: () => Date;
|
|
72
|
+
readonly atomicFault?: AtomicFaultInjector;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface CreatePendingInput {
|
|
76
|
+
readonly id: string;
|
|
77
|
+
readonly attempt: number;
|
|
78
|
+
readonly binding: BridgeBinding;
|
|
79
|
+
readonly leaseGeneration: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export type BridgeRecoveryPlan =
|
|
83
|
+
| { readonly kind: "exact-read"; readonly emitDrainedReceipt: boolean }
|
|
84
|
+
| { readonly kind: "mark-injected" }
|
|
85
|
+
| { readonly kind: "mark-dlq" }
|
|
86
|
+
| { readonly kind: "terminal-dlq" }
|
|
87
|
+
| { readonly kind: "already-injected" }
|
|
88
|
+
| { readonly kind: "needs-attention"; readonly reason: "message-not-found" }
|
|
89
|
+
| { readonly kind: "diagnostic"; readonly reason: "injected-projection-missing" };
|
|
90
|
+
|
|
91
|
+
export interface BridgeRecoveryFacts {
|
|
92
|
+
readonly amqLocation: "new" | "cur" | "missing" | "dlq";
|
|
93
|
+
readonly projection?: DiskProjectionEvidence;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Generation-fenced durable intent across the AMQ-to-pi delivery boundary. */
|
|
97
|
+
export class BridgeJournal {
|
|
98
|
+
readonly #namespaceDirectory: string;
|
|
99
|
+
readonly #leaseManager: ReceiverLeaseManager;
|
|
100
|
+
readonly #now: () => Date;
|
|
101
|
+
readonly #atomicFault: AtomicFaultInjector | undefined;
|
|
102
|
+
|
|
103
|
+
constructor(options: BridgeJournalOptions) {
|
|
104
|
+
if (!isAbsolute(options.namespaceDirectory)) {
|
|
105
|
+
throw new TypeError("Bridge journal namespace must be absolute");
|
|
106
|
+
}
|
|
107
|
+
this.#namespaceDirectory = normalize(options.namespaceDirectory);
|
|
108
|
+
this.#leaseManager = options.leaseManager;
|
|
109
|
+
this.#now = options.now ?? (() => new Date());
|
|
110
|
+
this.#atomicFault = options.atomicFault;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async createPending(input: CreatePendingInput): Promise<BridgeJournalRecordV1> {
|
|
114
|
+
validateMessageId(input.id);
|
|
115
|
+
requirePositiveInteger(input.attempt, "bridge attempt");
|
|
116
|
+
const binding = parseBinding(input.binding);
|
|
117
|
+
requireNonEmpty(input.leaseGeneration, "lease generation");
|
|
118
|
+
return this.#leaseManager.withCurrentLease(input.leaseGeneration, async () => {
|
|
119
|
+
await this.#initialize();
|
|
120
|
+
const record: BridgeJournalRecordV1 = {
|
|
121
|
+
schema: 1,
|
|
122
|
+
id: input.id,
|
|
123
|
+
attempt: input.attempt,
|
|
124
|
+
phase: "pending",
|
|
125
|
+
binding,
|
|
126
|
+
leaseGeneration: input.leaseGeneration,
|
|
127
|
+
targetSessionId: null,
|
|
128
|
+
delivery: null,
|
|
129
|
+
claimedAt: null,
|
|
130
|
+
receiptWarning: null,
|
|
131
|
+
updatedAt: this.#now().toISOString(),
|
|
132
|
+
};
|
|
133
|
+
await writeAtomicJson(this.#path(input.id), record, {
|
|
134
|
+
createOnly: true,
|
|
135
|
+
...(this.#atomicFault ? { fault: this.#atomicFault } : {}),
|
|
136
|
+
});
|
|
137
|
+
return record;
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async read(id: string): Promise<BridgeJournalRecordV1 | undefined> {
|
|
142
|
+
validateMessageId(id);
|
|
143
|
+
const raw = await readJson(this.#path(id));
|
|
144
|
+
return raw === undefined ? undefined : parseBridgeJournalRecordV1(raw);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async list(): Promise<readonly BridgeJournalRecordV1[]> {
|
|
148
|
+
let entries: string[];
|
|
149
|
+
try {
|
|
150
|
+
entries = await readdir(join(this.#namespaceDirectory, "journal"));
|
|
151
|
+
} catch (error) {
|
|
152
|
+
if (isNodeError(error) && error.code === "ENOENT") return [];
|
|
153
|
+
throw error;
|
|
154
|
+
}
|
|
155
|
+
const records: BridgeJournalRecordV1[] = [];
|
|
156
|
+
for (const entry of entries.sort()) {
|
|
157
|
+
if (!entry.endsWith(".json") || entry.startsWith(".")) continue;
|
|
158
|
+
const raw = await readJson(join(this.#namespaceDirectory, "journal", entry));
|
|
159
|
+
if (raw === undefined) continue;
|
|
160
|
+
records.push(parseBridgeJournalRecordV1(raw));
|
|
161
|
+
}
|
|
162
|
+
return records.sort((left, right) => left.id.localeCompare(right.id));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async readHistory(id: string, attempt: number): Promise<BridgeAttemptHistoryV1 | undefined> {
|
|
166
|
+
validateMessageId(id);
|
|
167
|
+
requirePositiveInteger(attempt, "bridge history attempt");
|
|
168
|
+
const raw = await readJson(this.#historyPath(id, attempt));
|
|
169
|
+
return raw === undefined ? undefined : parseBridgeAttemptHistoryV1(raw);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async adoptPending(
|
|
173
|
+
id: string,
|
|
174
|
+
generation: string,
|
|
175
|
+
binding: BridgeBinding,
|
|
176
|
+
): Promise<BridgeJournalRecordV1> {
|
|
177
|
+
validateMessageId(id);
|
|
178
|
+
requireNonEmpty(generation, "lease generation");
|
|
179
|
+
const expectedBinding = parseBinding(binding);
|
|
180
|
+
return this.#leaseManager.withCurrentLease(generation, async () => {
|
|
181
|
+
const record = await this.read(id);
|
|
182
|
+
if (!record) throw new Error(`Bridge journal record ${id} does not exist`);
|
|
183
|
+
if (record.phase !== "pending") {
|
|
184
|
+
throw new Error(`Only a pending bridge journal record can be adopted, got ${record.phase}`);
|
|
185
|
+
}
|
|
186
|
+
if (record.binding.rootId !== expectedBinding.rootId
|
|
187
|
+
|| record.binding.handle !== expectedBinding.handle) {
|
|
188
|
+
throw new Error("Pending bridge journal binding does not match the current receiver");
|
|
189
|
+
}
|
|
190
|
+
if (record.leaseGeneration === generation) return record;
|
|
191
|
+
const adopted = parseBridgeJournalRecordV1({
|
|
192
|
+
...record,
|
|
193
|
+
leaseGeneration: generation,
|
|
194
|
+
updatedAt: this.#now().toISOString(),
|
|
195
|
+
});
|
|
196
|
+
await writeAtomicJson(this.#path(id), adopted, {
|
|
197
|
+
...(this.#atomicFault ? { fault: this.#atomicFault } : {}),
|
|
198
|
+
});
|
|
199
|
+
return adopted;
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async recordClaim(
|
|
204
|
+
id: string,
|
|
205
|
+
generation: string,
|
|
206
|
+
claim: { readonly claimedAt: string; readonly receiptWarning?: string | null },
|
|
207
|
+
): Promise<BridgeJournalRecordV1> {
|
|
208
|
+
const claimedAt = requireIso(claim.claimedAt, "claimedAt");
|
|
209
|
+
const receiptWarning = claim.receiptWarning === undefined
|
|
210
|
+
? undefined
|
|
211
|
+
: optionalString(claim.receiptWarning, "receiptWarning");
|
|
212
|
+
return this.#mutatePending(id, generation, (record) => ({
|
|
213
|
+
...record,
|
|
214
|
+
claimedAt,
|
|
215
|
+
...(receiptWarning === undefined ? {} : { receiptWarning }),
|
|
216
|
+
updatedAt: this.#now().toISOString(),
|
|
217
|
+
}));
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async setTarget(
|
|
221
|
+
id: string,
|
|
222
|
+
generation: string,
|
|
223
|
+
target: { readonly targetSessionId: string; readonly delivery: BridgeDelivery },
|
|
224
|
+
): Promise<BridgeJournalRecordV1> {
|
|
225
|
+
const targetSessionId = requireNonEmpty(target.targetSessionId, "target session ID");
|
|
226
|
+
const delivery = parseDelivery(target.delivery);
|
|
227
|
+
return this.#mutatePending(id, generation, (record) => ({
|
|
228
|
+
...record,
|
|
229
|
+
targetSessionId,
|
|
230
|
+
delivery,
|
|
231
|
+
updatedAt: this.#now().toISOString(),
|
|
232
|
+
}));
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async markInjected(
|
|
236
|
+
id: string,
|
|
237
|
+
generation: string,
|
|
238
|
+
evidence: DiskProjectionEvidence,
|
|
239
|
+
): Promise<BridgeJournalRecordV1> {
|
|
240
|
+
return this.#mutatePending(id, generation, (record) => {
|
|
241
|
+
if (!matchingDiskProjection(record, evidence)) {
|
|
242
|
+
throw new Error("Injected transition requires matching disk-visible pi projection evidence");
|
|
243
|
+
}
|
|
244
|
+
return {
|
|
245
|
+
...record,
|
|
246
|
+
phase: "injected",
|
|
247
|
+
updatedAt: this.#now().toISOString(),
|
|
248
|
+
};
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async markDlq(
|
|
253
|
+
id: string,
|
|
254
|
+
generation: string,
|
|
255
|
+
evidence: { readonly messageId: string; readonly diagnostic: string },
|
|
256
|
+
): Promise<BridgeJournalRecordV1> {
|
|
257
|
+
requireNonEmpty(evidence.diagnostic, "DLQ diagnostic");
|
|
258
|
+
return this.#mutatePending(id, generation, (record) => {
|
|
259
|
+
if (evidence.messageId !== record.id) {
|
|
260
|
+
throw new Error("DLQ evidence message ID does not match the journal record");
|
|
261
|
+
}
|
|
262
|
+
return {
|
|
263
|
+
...record,
|
|
264
|
+
phase: "dlq",
|
|
265
|
+
updatedAt: this.#now().toISOString(),
|
|
266
|
+
};
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async promoteVerifiedRetry(
|
|
271
|
+
id: string,
|
|
272
|
+
generation: string,
|
|
273
|
+
evidence: BridgeRetryAuditEvidence,
|
|
274
|
+
): Promise<BridgeJournalRecordV1> {
|
|
275
|
+
validateMessageId(id);
|
|
276
|
+
requireNonEmpty(generation, "lease generation");
|
|
277
|
+
const audit = parseBridgeRetryAuditEvidence(evidence);
|
|
278
|
+
if (audit.originalMessageId !== id || audit.visibleMessageId !== id) {
|
|
279
|
+
throw new Error("DLQ retry requires matching original and visibly redelivered message IDs");
|
|
280
|
+
}
|
|
281
|
+
return this.#leaseManager.withCurrentLease(generation, async () => {
|
|
282
|
+
const record = await this.read(id);
|
|
283
|
+
if (!record) throw new Error(`Bridge journal record ${id} does not exist`);
|
|
284
|
+
if (record.leaseGeneration !== generation) {
|
|
285
|
+
throw new Error("Bridge journal lease generation does not match the current retry");
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (record.phase === "pending") {
|
|
289
|
+
const history = record.attempt > 1
|
|
290
|
+
? await this.readHistory(id, record.attempt - 1)
|
|
291
|
+
: undefined;
|
|
292
|
+
if (history && sameRetryAudit(history.retryAudit, audit)) return record;
|
|
293
|
+
throw new Error(`Bridge journal pending attempt ${record.attempt} cannot be promoted again`);
|
|
294
|
+
}
|
|
295
|
+
if (record.phase !== "dlq") {
|
|
296
|
+
throw new Error(`Bridge journal ${record.phase} phase cannot accept a DLQ retry`);
|
|
297
|
+
}
|
|
298
|
+
if (audit.retryCount !== record.attempt) {
|
|
299
|
+
if (await this.#retryAuditWasConsumed(id, audit.auditId)) {
|
|
300
|
+
throw new Error(`AMQ retry audit ${audit.auditId} was already consumed by an earlier bridge attempt`);
|
|
301
|
+
}
|
|
302
|
+
throw new Error(
|
|
303
|
+
`AMQ retry count ${audit.retryCount} does not match terminal bridge attempt ${record.attempt}`,
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
await this.#initialize();
|
|
308
|
+
await mkdir(join(this.#namespaceDirectory, "history"), { recursive: true, mode: 0o700 });
|
|
309
|
+
const history: BridgeAttemptHistoryV1 = {
|
|
310
|
+
schema: "pi-amq-attempt-history/v1",
|
|
311
|
+
record,
|
|
312
|
+
retryAudit: audit,
|
|
313
|
+
};
|
|
314
|
+
const historyPath = this.#historyPath(id, record.attempt);
|
|
315
|
+
const existing = await readJson(historyPath);
|
|
316
|
+
if (existing === undefined) {
|
|
317
|
+
if (await this.#retryAuditWasConsumed(id, audit.auditId)) {
|
|
318
|
+
throw new Error(`AMQ retry audit ${audit.auditId} was already consumed by an earlier bridge attempt`);
|
|
319
|
+
}
|
|
320
|
+
await writeAtomicJson(historyPath, history, {
|
|
321
|
+
createOnly: true,
|
|
322
|
+
...(this.#atomicFault ? { fault: this.#atomicFault } : {}),
|
|
323
|
+
});
|
|
324
|
+
} else {
|
|
325
|
+
const parsed = parseBridgeAttemptHistoryV1(existing);
|
|
326
|
+
if (!sameArchivedAttempt(parsed.record, record)
|
|
327
|
+
|| !sameRetryAudit(parsed.retryAudit, audit)) {
|
|
328
|
+
throw new Error(`Bridge attempt history ${record.attempt} conflicts with the verified retry audit`);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const pending: BridgeJournalRecordV1 = {
|
|
333
|
+
schema: 1,
|
|
334
|
+
id,
|
|
335
|
+
attempt: record.attempt + 1,
|
|
336
|
+
phase: "pending",
|
|
337
|
+
binding: record.binding,
|
|
338
|
+
leaseGeneration: generation,
|
|
339
|
+
targetSessionId: null,
|
|
340
|
+
delivery: null,
|
|
341
|
+
claimedAt: null,
|
|
342
|
+
receiptWarning: null,
|
|
343
|
+
updatedAt: this.#now().toISOString(),
|
|
344
|
+
};
|
|
345
|
+
await writeAtomicJson(this.#path(id), pending, {
|
|
346
|
+
...(this.#atomicFault ? { fault: this.#atomicFault } : {}),
|
|
347
|
+
});
|
|
348
|
+
return pending;
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
async #mutatePending(
|
|
353
|
+
id: string,
|
|
354
|
+
generation: string,
|
|
355
|
+
mutation: (record: BridgeJournalRecordV1) => BridgeJournalRecordV1,
|
|
356
|
+
): Promise<BridgeJournalRecordV1> {
|
|
357
|
+
validateMessageId(id);
|
|
358
|
+
requireNonEmpty(generation, "lease generation");
|
|
359
|
+
return this.#leaseManager.withCurrentLease(generation, async () => {
|
|
360
|
+
const record = await this.read(id);
|
|
361
|
+
if (!record) throw new Error(`Bridge journal record ${id} does not exist`);
|
|
362
|
+
if (record.leaseGeneration !== generation) {
|
|
363
|
+
throw new Error("Bridge journal lease generation does not match the current mutation");
|
|
364
|
+
}
|
|
365
|
+
if (record.phase !== "pending") {
|
|
366
|
+
throw new Error(`Bridge journal ${record.phase} phase is terminal for attempt ${record.attempt}`);
|
|
367
|
+
}
|
|
368
|
+
const updated = parseBridgeJournalRecordV1(mutation(record));
|
|
369
|
+
await writeAtomicJson(this.#path(id), updated, {
|
|
370
|
+
...(this.#atomicFault ? { fault: this.#atomicFault } : {}),
|
|
371
|
+
});
|
|
372
|
+
return updated;
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
async #initialize(): Promise<void> {
|
|
377
|
+
await mkdir(join(this.#namespaceDirectory, "journal"), { recursive: true, mode: 0o700 });
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
#path(id: string): string {
|
|
381
|
+
return join(this.#namespaceDirectory, "journal", `${id}.json`);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
#historyPath(id: string, attempt: number): string {
|
|
385
|
+
return join(this.#namespaceDirectory, "history", `${id}.attempt-${attempt}.json`);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
async #retryAuditWasConsumed(id: string, auditId: string): Promise<boolean> {
|
|
389
|
+
let entries: string[];
|
|
390
|
+
try {
|
|
391
|
+
entries = await readdir(join(this.#namespaceDirectory, "history"));
|
|
392
|
+
} catch (error) {
|
|
393
|
+
if (isNodeError(error) && error.code === "ENOENT") return false;
|
|
394
|
+
throw error;
|
|
395
|
+
}
|
|
396
|
+
for (const entry of entries) {
|
|
397
|
+
if (!entry.startsWith(`${id}.attempt-`) || !entry.endsWith(".json")) continue;
|
|
398
|
+
const raw = await readJson(join(this.#namespaceDirectory, "history", entry));
|
|
399
|
+
if (raw && parseBridgeAttemptHistoryV1(raw).retryAudit.auditId === auditId) return true;
|
|
400
|
+
}
|
|
401
|
+
return false;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
export function planBridgeRecovery(
|
|
406
|
+
record: BridgeJournalRecordV1,
|
|
407
|
+
facts: BridgeRecoveryFacts,
|
|
408
|
+
): BridgeRecoveryPlan {
|
|
409
|
+
const parsed = parseBridgeJournalRecordV1(record);
|
|
410
|
+
if (parsed.phase === "dlq") return { kind: "terminal-dlq" };
|
|
411
|
+
if (parsed.phase === "injected") {
|
|
412
|
+
return facts.projection && matchingDiskProjection(parsed, facts.projection)
|
|
413
|
+
? { kind: "already-injected" }
|
|
414
|
+
: { kind: "diagnostic", reason: "injected-projection-missing" };
|
|
415
|
+
}
|
|
416
|
+
if (facts.amqLocation === "dlq") return { kind: "mark-dlq" };
|
|
417
|
+
if (facts.projection && matchingDiskProjection(parsed, facts.projection)) {
|
|
418
|
+
return { kind: "mark-injected" };
|
|
419
|
+
}
|
|
420
|
+
if (facts.amqLocation === "new") return { kind: "exact-read", emitDrainedReceipt: true };
|
|
421
|
+
if (facts.amqLocation === "cur") return { kind: "exact-read", emitDrainedReceipt: false };
|
|
422
|
+
return { kind: "needs-attention", reason: "message-not-found" };
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
export function parseBridgeJournalRecordV1(value: unknown): BridgeJournalRecordV1 {
|
|
426
|
+
const object = strictRecord(value, RECORD_KEYS, "bridge journal record");
|
|
427
|
+
if (numeric(object, "schema") !== 1) throw new Error("bridge journal schema must be 1");
|
|
428
|
+
const phase = object.phase;
|
|
429
|
+
if (phase !== "pending" && phase !== "injected" && phase !== "dlq") {
|
|
430
|
+
throw new Error("bridge journal phase is invalid");
|
|
431
|
+
}
|
|
432
|
+
const targetSessionId = optionalString(object.targetSessionId, "targetSessionId");
|
|
433
|
+
const delivery = object.delivery === null ? null : parseDelivery(object.delivery);
|
|
434
|
+
if ((targetSessionId === null) !== (delivery === null)) {
|
|
435
|
+
throw new Error("targetSessionId and delivery must be present together");
|
|
436
|
+
}
|
|
437
|
+
if (phase === "injected" && targetSessionId === null) {
|
|
438
|
+
throw new Error("injected bridge journal record requires a target session");
|
|
439
|
+
}
|
|
440
|
+
return {
|
|
441
|
+
schema: 1,
|
|
442
|
+
id: parseMessageId(object.id),
|
|
443
|
+
attempt: requirePositiveInteger(numeric(object, "attempt"), "bridge attempt"),
|
|
444
|
+
phase,
|
|
445
|
+
binding: parseBinding(object.binding),
|
|
446
|
+
leaseGeneration: string(object, "leaseGeneration"),
|
|
447
|
+
targetSessionId,
|
|
448
|
+
delivery,
|
|
449
|
+
claimedAt: optionalIso(object.claimedAt, "claimedAt"),
|
|
450
|
+
receiptWarning: optionalString(object.receiptWarning, "receiptWarning"),
|
|
451
|
+
updatedAt: requireIso(string(object, "updatedAt"), "updatedAt"),
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
export function parseBridgeAttemptHistoryV1(value: unknown): BridgeAttemptHistoryV1 {
|
|
456
|
+
const object = strictRecord(value, ["schema", "record", "retryAudit"], "bridge attempt history");
|
|
457
|
+
if (object.schema !== "pi-amq-attempt-history/v1") {
|
|
458
|
+
throw new Error("bridge attempt history schema is invalid");
|
|
459
|
+
}
|
|
460
|
+
const record = parseBridgeJournalRecordV1(object.record);
|
|
461
|
+
if (record.phase !== "dlq") throw new Error("bridge attempt history must archive a terminal DLQ record");
|
|
462
|
+
return {
|
|
463
|
+
schema: "pi-amq-attempt-history/v1",
|
|
464
|
+
record,
|
|
465
|
+
retryAudit: parseBridgeRetryAuditEvidence(object.retryAudit),
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function parseBridgeRetryAuditEvidence(value: unknown): BridgeRetryAuditEvidence {
|
|
470
|
+
const object = strictRecord(
|
|
471
|
+
value,
|
|
472
|
+
["auditId", "originalMessageId", "retryCount", "retryState", "visibleMessageId"],
|
|
473
|
+
"bridge retry audit",
|
|
474
|
+
);
|
|
475
|
+
if (object.retryState !== "delivered") throw new Error("AMQ retry audit must be delivered");
|
|
476
|
+
return {
|
|
477
|
+
auditId: string(object, "auditId"),
|
|
478
|
+
originalMessageId: parseMessageId(object.originalMessageId),
|
|
479
|
+
retryCount: requirePositiveInteger(numeric(object, "retryCount"), "AMQ retry count"),
|
|
480
|
+
retryState: "delivered",
|
|
481
|
+
visibleMessageId: parseMessageId(object.visibleMessageId),
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function sameRetryAudit(left: BridgeRetryAuditEvidence, right: BridgeRetryAuditEvidence): boolean {
|
|
486
|
+
return left.auditId === right.auditId
|
|
487
|
+
&& left.originalMessageId === right.originalMessageId
|
|
488
|
+
&& left.retryCount === right.retryCount
|
|
489
|
+
&& left.retryState === right.retryState
|
|
490
|
+
&& left.visibleMessageId === right.visibleMessageId;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function sameArchivedAttempt(left: BridgeJournalRecordV1, right: BridgeJournalRecordV1): boolean {
|
|
494
|
+
return JSON.stringify(left) === JSON.stringify(right);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function matchingDiskProjection(
|
|
498
|
+
record: BridgeJournalRecordV1,
|
|
499
|
+
evidence: DiskProjectionEvidence,
|
|
500
|
+
): boolean {
|
|
501
|
+
return evidence.diskVisible
|
|
502
|
+
&& evidence.messageId === record.id
|
|
503
|
+
&& record.targetSessionId !== null
|
|
504
|
+
&& evidence.sessionId === record.targetSessionId;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function parseBinding(value: unknown): BridgeBinding {
|
|
508
|
+
const object = strictRecord(value, BINDING_KEYS, "bridge binding");
|
|
509
|
+
return {
|
|
510
|
+
rootId: string(object, "rootId"),
|
|
511
|
+
handle: string(object, "handle"),
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function parseDelivery(value: unknown): BridgeDelivery {
|
|
516
|
+
if (value === "steer" || value === "followUp" || value === "nextTurn" || value === "fresh-session") {
|
|
517
|
+
return value;
|
|
518
|
+
}
|
|
519
|
+
throw new Error("bridge delivery is invalid");
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function parseMessageId(value: unknown): string {
|
|
523
|
+
if (typeof value !== "string") throw new Error("bridge message ID must be a string");
|
|
524
|
+
validateMessageId(value);
|
|
525
|
+
return value;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function validateMessageId(id: string): void {
|
|
529
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,254}$/.test(id)) {
|
|
530
|
+
throw new TypeError("Bridge message ID is not safe for the extension namespace");
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function strictRecord(
|
|
535
|
+
value: unknown,
|
|
536
|
+
allowed: readonly string[],
|
|
537
|
+
name: string,
|
|
538
|
+
): Record<string, unknown> {
|
|
539
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
540
|
+
throw new Error(`${name} must be an object`);
|
|
541
|
+
}
|
|
542
|
+
const object = value as Record<string, unknown>;
|
|
543
|
+
const unknown = Object.keys(object).filter((key) => !allowed.includes(key));
|
|
544
|
+
if (unknown.length > 0) throw new Error(`${name} contains unknown fields: ${unknown.join(", ")}`);
|
|
545
|
+
return object;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function string(object: Record<string, unknown>, key: string): string {
|
|
549
|
+
const value = object[key];
|
|
550
|
+
if (typeof value !== "string" || value.length === 0) throw new Error(`${key} must be a non-empty string`);
|
|
551
|
+
return value;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function numeric(object: Record<string, unknown>, key: string): number {
|
|
555
|
+
const value = object[key];
|
|
556
|
+
if (typeof value !== "number" || !Number.isFinite(value)) throw new Error(`${key} must be a number`);
|
|
557
|
+
return value;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function optionalString(value: unknown, name: string): string | null {
|
|
561
|
+
if (value === null) return null;
|
|
562
|
+
if (typeof value !== "string" || value.length === 0) throw new Error(`${name} must be null or a non-empty string`);
|
|
563
|
+
return value;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function optionalIso(value: unknown, name: string): string | null {
|
|
567
|
+
const stringValue = optionalString(value, name);
|
|
568
|
+
return stringValue === null ? null : requireIso(stringValue, name);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function requireIso(value: string, name: string): string {
|
|
572
|
+
if (!Number.isFinite(Date.parse(value))) throw new Error(`${name} must be an ISO timestamp`);
|
|
573
|
+
return value;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function requirePositiveInteger(value: number, name: string): number {
|
|
577
|
+
if (!Number.isSafeInteger(value) || value <= 0) throw new TypeError(`${name} must be a positive safe integer`);
|
|
578
|
+
return value;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function requireNonEmpty(value: string, name: string): string {
|
|
582
|
+
if (value.length === 0) throw new TypeError(`${name} is required`);
|
|
583
|
+
return value;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function isNodeError(error: unknown): error is NodeJS.ErrnoException {
|
|
587
|
+
return error instanceof Error && "code" in error;
|
|
588
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { AmqCommandResult, AmqRunOptions } from "./amq-cli-adapter.js";
|
|
2
|
+
import type { BridgeRetryAuditEvidence } from "./bridge-journal.js";
|
|
3
|
+
|
|
4
|
+
interface JsonRunner {
|
|
5
|
+
runJson<T = unknown>(
|
|
6
|
+
argv: readonly string[],
|
|
7
|
+
options?: AmqRunOptions<T>,
|
|
8
|
+
): Promise<AmqCommandResult<T>>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface DlqRetryAuditVerifier {
|
|
12
|
+
verify(
|
|
13
|
+
visibleMessageId: string,
|
|
14
|
+
signal?: AbortSignal,
|
|
15
|
+
): Promise<readonly BridgeRetryAuditEvidence[]>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface DlqListAudit {
|
|
19
|
+
readonly id: string;
|
|
20
|
+
readonly originalId: string;
|
|
21
|
+
readonly retryCount: number;
|
|
22
|
+
readonly retryState: string;
|
|
23
|
+
readonly retryDelivered: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Reads only AMQ's durable DLQ audit; visible redelivery is supplied by the inbox candidate. */
|
|
27
|
+
export class AmqDlqRetryAuditVerifier implements DlqRetryAuditVerifier {
|
|
28
|
+
readonly #runner: JsonRunner;
|
|
29
|
+
readonly #root: string;
|
|
30
|
+
readonly #handle: string;
|
|
31
|
+
|
|
32
|
+
constructor(options: { readonly runner: JsonRunner; readonly root: string; readonly handle: string }) {
|
|
33
|
+
this.#runner = options.runner;
|
|
34
|
+
this.#root = options.root;
|
|
35
|
+
this.#handle = options.handle;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async verify(
|
|
39
|
+
visibleMessageId: string,
|
|
40
|
+
signal?: AbortSignal,
|
|
41
|
+
): Promise<readonly BridgeRetryAuditEvidence[]> {
|
|
42
|
+
const result = await this.#runner.runJson(
|
|
43
|
+
["dlq", "list", "--root", this.#root, "--me", this.#handle, "--cur"],
|
|
44
|
+
{ ...(signal ? { signal } : {}), parse: parseDlqListAudits },
|
|
45
|
+
);
|
|
46
|
+
if (!result.ok || !result.json) return [];
|
|
47
|
+
return result.json
|
|
48
|
+
.filter((item) => item.originalId === visibleMessageId
|
|
49
|
+
&& item.retryState === "delivered"
|
|
50
|
+
&& item.retryDelivered
|
|
51
|
+
&& item.retryCount > 0)
|
|
52
|
+
.map((item) => ({
|
|
53
|
+
auditId: item.id,
|
|
54
|
+
originalMessageId: item.originalId,
|
|
55
|
+
retryCount: item.retryCount,
|
|
56
|
+
retryState: "delivered" as const,
|
|
57
|
+
visibleMessageId,
|
|
58
|
+
}));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function parseDlqListAudits(value: unknown): readonly DlqListAudit[] {
|
|
63
|
+
if (!Array.isArray(value)) throw new Error("amq dlq list --json must return an array");
|
|
64
|
+
return value.map((item, index) => {
|
|
65
|
+
const object = record(item, `AMQ DLQ list item ${index}`);
|
|
66
|
+
return {
|
|
67
|
+
id: string(object, "id"),
|
|
68
|
+
originalId: string(object, "original_id"),
|
|
69
|
+
retryCount: nonNegativeInteger(object, "retry_count"),
|
|
70
|
+
retryState: string(object, "retry_state"),
|
|
71
|
+
retryDelivered: boolean(object, "retry_delivered"),
|
|
72
|
+
};
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function record(value: unknown, name: string): Record<string, unknown> {
|
|
77
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${name} must be an object`);
|
|
78
|
+
return value as Record<string, unknown>;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function string(object: Record<string, unknown>, key: string): string {
|
|
82
|
+
const value = object[key];
|
|
83
|
+
if (typeof value !== "string" || value.length === 0) throw new Error(`${key} must be a non-empty string`);
|
|
84
|
+
return value;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function nonNegativeInteger(object: Record<string, unknown>, key: string): number {
|
|
88
|
+
const value = object[key];
|
|
89
|
+
if (!Number.isSafeInteger(value) || (value as number) < 0) throw new Error(`${key} must be a non-negative integer`);
|
|
90
|
+
return value as number;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function boolean(object: Record<string, unknown>, key: string): boolean {
|
|
94
|
+
const value = object[key];
|
|
95
|
+
if (typeof value !== "boolean") throw new Error(`${key} must be a boolean`);
|
|
96
|
+
return value;
|
|
97
|
+
}
|