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,41 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: coordination-patterns
|
|
3
|
+
description: Coordinates role-separated implementation and independent review over existing AMQ peers or supervised pi workers. Use when a task benefits from bounded fan-out, explicit acceptance criteria, fresh-evidence audit, and driver-owned synthesis. Skip when the work is smaller than the coordination overhead.
|
|
4
|
+
compatibility: Existing-peer mail needs a verified AMQ roster. Local supervised workers need the pi-amq WorkerRuntime and do not require AMQ binding.
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Coordination Patterns
|
|
8
|
+
|
|
9
|
+
Use this skill for either coordination flavor below. It is advisory: adapt or stop when evidence changes, while preserving role, evidence, and verification boundaries.
|
|
10
|
+
|
|
11
|
+
## Driver posture
|
|
12
|
+
|
|
13
|
+
The driver owns decomposition, authority, collection, synthesis, and final acceptance. Workers return bounded candidates and evidence; their prose never completes a task by itself. Prefer a few well-separated workers over broad speculative fan-out. Observe stall facts without inventing a nudge, retry, or cancellation policy.
|
|
14
|
+
|
|
15
|
+
## Existing-peer mail team
|
|
16
|
+
|
|
17
|
+
1. Confirm that the needed peers already exist in the AMQ roster. Do not invent or spawn a worker.
|
|
18
|
+
2. Give each task one explicit role charter, bounded scope, acceptance criteria, constraints, and expected evidence.
|
|
19
|
+
3. Dispatch the task as `kind=todo` through the existing AMQ transport.
|
|
20
|
+
4. Require the implementer to report outcome, evidence, unrun checks, and residual risk by replying to the exact task envelope.
|
|
21
|
+
5. Re-run decisive checks and inspect the named evidence independently in the driver context.
|
|
22
|
+
6. Assign audit to a different context from implementation. The auditor reads the authoritative change and obtains fresh evidence.
|
|
23
|
+
7. Record approve/reject by replying in the implementation task thread. That thread is the authorization ledger; a detached status message is not approval.
|
|
24
|
+
|
|
25
|
+
Read [the current mail-team few-shot](references/today-mail-team.md) when a concrete sequence is useful.
|
|
26
|
+
|
|
27
|
+
## Supervised worker fan-out
|
|
28
|
+
|
|
29
|
+
1. Split only independent evidence or implementation packets with explicit acceptance criteria and hard constraints.
|
|
30
|
+
2. Pick the narrowest named role: scout maps, worker implements, reviewer audits, oracle resolves ambiguity, and researcher sources evidence. Explicit difficulty or capabilities override role defaults; model selection remains mechanical against the live catalog.
|
|
31
|
+
3. Call `spawn_worker` once per bounded packet. Declare role, goal, acceptance, constraints, every budget dimension, and any report/check/approval facts. Use RPC when steering or mid-run interaction is expected; JSON is one-shot.
|
|
32
|
+
4. Use `list_workers` for authoritative state, Gate blockers, revision delivery, budget, and stall facts.
|
|
33
|
+
5. Use `collect` for candidates and evidence. Collection is an observation; `done` requires every declared Gate fact.
|
|
34
|
+
6. Use `revise_contract` for contract changes and `steer` only for conversation. Revisions arrive after generation-correlated `agent_settled` without interrupting in-flight inference.
|
|
35
|
+
7. Keep synthesis and final judgment in the driver context. If independent review matters, spawn an auditor with source-based acceptance criteria.
|
|
36
|
+
|
|
37
|
+
Read [the supervised worker few-shot](references/supervised-worker-fan-out.md) before running a multi-worker sequence.
|
|
38
|
+
|
|
39
|
+
## Boundary
|
|
40
|
+
|
|
41
|
+
The runtime exposes atomic facts and verbs, not a workflow engine. Contract, Ledger, Lease/launch, Gate, and Delivery are the five governance domains; `WorkerLaunchAuthority` enforces grants under Lease/launch and a request is never a grant. `blocked` is reserved for the RPC child `wait_for_input` fact with a reason and correlation; natural-language waiting is not inferred, blocked workers do not accrue stalls, and only exact-correlated steer, revision, or peer delivery wakes them. Named roles are Contract-default declarations, not ambient addresses or teams. A worker may message only an exact `message_worker:<workerId-or-role>` directional grant; a role must resolve uniquely, the supervisor waits for the recipient inference boundary, and both endpoint projections share the correlated Ledger fact. The runtime has no peer-topology mutation, automatic retry, nudge, pipeline, scheduling preset, or workflow DSL. Coordination examples do not create runtime policy.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Supervised worker fan-out
|
|
2
|
+
|
|
3
|
+
User goal: inspect an implementation, repair the proven defect, and obtain an independent audit.
|
|
4
|
+
|
|
5
|
+
1. The driver calls `spawn_worker` for a read-only investigator with `difficulty=medium`, a narrow workspace grant, explicit limited/unlimited values for every worker budget dimension, and acceptance criteria requiring exact file evidence and a falsifiable reproducer.
|
|
6
|
+
2. After `collect`, the driver inspects the cited seam and calls `spawn_worker` for one implementer. Its acceptance criteria name the behavior, targeted test, and allowed files. The driver keeps the implementer's selected model for the later audit floor.
|
|
7
|
+
3. If acceptance changes while the implementer is running, the driver calls `revise_contract`; `steer` is conversation and never changes the contract. The ledger shows `pending`; the supervisor delivers the revised canonical capsule only after generation-correlated `agent_settled`, then records `delivered`.
|
|
8
|
+
4. The driver calls `collect`. A candidate report can remain `running` when evidence, `report_schema`, `acceptance_check`, budget, or human approval blocks the Gate.
|
|
9
|
+
5. The driver calls `spawn_worker` for an auditor with the implementer's exact `audits_worker_id`; the runtime floors auditor capability at that implementer's model. The auditor receives read-only authority and acceptance criteria requiring fresh evidence, not agreement with the implementer.
|
|
10
|
+
6. The driver reruns the decisive check, reconciles any ambiguity explicitly, and owns the final response.
|
|
11
|
+
|
|
12
|
+
At every step, `list_workers` is the authoritative view. `stall.silent` and `stall.no_progress` are running-only observations; neither authorizes nudge, redelivery, retry, or cancellation. `blocked` means the RPC child declared `wait_for_input` with a reason and correlation, never a guess from prose or silence; wake it only with the exact correlation on steer, revision, or granted peer delivery. Contract, Ledger, Lease/launch, Gate, and Delivery remain separate; requested capability is not a launch grant.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Existing-peer mail team few-shot
|
|
2
|
+
|
|
3
|
+
This example uses only peers already present in the AMQ roster.
|
|
4
|
+
|
|
5
|
+
## Objective
|
|
6
|
+
|
|
7
|
+
Implement a bounded transport change and obtain an independent sign-off.
|
|
8
|
+
|
|
9
|
+
## Driver dispatches implementation
|
|
10
|
+
|
|
11
|
+
Send one `kind=todo` task to the implementer with:
|
|
12
|
+
|
|
13
|
+
- role charter: implement the named transport behavior; do not review your own result;
|
|
14
|
+
- scope: exact files or module ownership;
|
|
15
|
+
- acceptance criteria: observable outcomes and failure semantics;
|
|
16
|
+
- hard constraints: authority boundaries, prohibited mutations, and stop conditions; and
|
|
17
|
+
- evidence: exact tests, logs, or artifacts the report must cite.
|
|
18
|
+
|
|
19
|
+
The implementer replies to that exact envelope with the outcome, fresh test evidence, unrun checks, and residual risk. A status-only message is progress, not completion.
|
|
20
|
+
|
|
21
|
+
## Driver verifies and dispatches audit
|
|
22
|
+
|
|
23
|
+
The driver inspects the change and reruns the decisive checks instead of forwarding the implementer's claims. Then send a separate `kind=todo` task to an auditor in a different context with:
|
|
24
|
+
|
|
25
|
+
- role charter: audit correctness and contract compliance, not implementation style;
|
|
26
|
+
- the authoritative diff or artifact paths;
|
|
27
|
+
- the same acceptance criteria and hard constraints; and
|
|
28
|
+
- a required approve/reject verdict backed by fresh evidence.
|
|
29
|
+
|
|
30
|
+
The implementer and auditor are never the same context.
|
|
31
|
+
|
|
32
|
+
This existing-peer mail pattern does not create worker-runtime state. When the local worker tools are used instead, preserve the five-domain split—Contract, Ledger, Lease/launch, Gate, and Delivery—and never treat a requested capability as a launch grant. A worker is `blocked` only after a correlated typed wait fact; running-only stall observations do not imply action.
|
|
33
|
+
|
|
34
|
+
## Threaded authorization
|
|
35
|
+
|
|
36
|
+
After resolving audit findings, reply approve/reject in the original implementation task thread. The verdict reply is the authorization record for that task. Detached mail, presence, drained receipts, and worker self-reports do not authorize landing.
|
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { chmod, mkdtemp, open, rmdir, unlink } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { isAbsolute, join } from "node:path";
|
|
5
|
+
|
|
6
|
+
const DEFAULT_OUTPUT_BOUND = 1024 * 1024;
|
|
7
|
+
const DEFAULT_BODY_FILE_THRESHOLD = 8 * 1024;
|
|
8
|
+
|
|
9
|
+
export type AmqExitCategory =
|
|
10
|
+
| "success"
|
|
11
|
+
| "error"
|
|
12
|
+
| "usage"
|
|
13
|
+
| "not-found"
|
|
14
|
+
| "timeout"
|
|
15
|
+
| "context-mismatch"
|
|
16
|
+
| "invalid-json"
|
|
17
|
+
| "output-limit"
|
|
18
|
+
| "cancelled"
|
|
19
|
+
| "spawn-error"
|
|
20
|
+
| "process-crash"
|
|
21
|
+
| "cleanup-failed";
|
|
22
|
+
|
|
23
|
+
export interface AmqCliAdapterOptions {
|
|
24
|
+
readonly binaryPath: string;
|
|
25
|
+
readonly maxStdoutBytes?: number;
|
|
26
|
+
readonly maxStderrBytes?: number;
|
|
27
|
+
readonly bodyFileThresholdBytes?: number;
|
|
28
|
+
readonly tempDirectory?: string;
|
|
29
|
+
readonly env?: Readonly<NodeJS.ProcessEnv>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface AmqRunOptions<T> {
|
|
33
|
+
readonly cwd?: string;
|
|
34
|
+
readonly env?: Readonly<NodeJS.ProcessEnv>;
|
|
35
|
+
readonly signal?: AbortSignal;
|
|
36
|
+
readonly parse?: (value: unknown) => T;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface AmqCommandResult<T> {
|
|
40
|
+
readonly ok: boolean;
|
|
41
|
+
readonly category: AmqExitCategory;
|
|
42
|
+
readonly exitCode: number | null;
|
|
43
|
+
readonly signal: NodeJS.Signals | null;
|
|
44
|
+
readonly stdout: string;
|
|
45
|
+
readonly stderr: string;
|
|
46
|
+
readonly stdoutTruncated: boolean;
|
|
47
|
+
readonly stderrTruncated: boolean;
|
|
48
|
+
readonly json?: T;
|
|
49
|
+
readonly jsonError?: Error;
|
|
50
|
+
readonly processError?: Error;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface ProcessCapture {
|
|
54
|
+
readonly exitCode: number | null;
|
|
55
|
+
readonly signal: NodeJS.Signals | null;
|
|
56
|
+
readonly stdout: string;
|
|
57
|
+
readonly stderr: string;
|
|
58
|
+
readonly stdoutTruncated: boolean;
|
|
59
|
+
readonly stderrTruncated: boolean;
|
|
60
|
+
readonly aborted: boolean;
|
|
61
|
+
readonly processError?: Error;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface TemporaryBody {
|
|
65
|
+
readonly directory: string;
|
|
66
|
+
readonly path: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The sole process boundary for AMQ commands.
|
|
71
|
+
*
|
|
72
|
+
* It never invokes a shell, never infers routing identity, and always preserves
|
|
73
|
+
* bounded stdout for callers to inspect even when AMQ exits nonzero.
|
|
74
|
+
*/
|
|
75
|
+
export class AmqCliAdapter {
|
|
76
|
+
readonly #binaryPath: string;
|
|
77
|
+
readonly #maxStdoutBytes: number;
|
|
78
|
+
readonly #maxStderrBytes: number;
|
|
79
|
+
readonly #bodyFileThresholdBytes: number;
|
|
80
|
+
readonly #tempDirectory: string;
|
|
81
|
+
readonly #env: NodeJS.ProcessEnv;
|
|
82
|
+
|
|
83
|
+
constructor(options: AmqCliAdapterOptions) {
|
|
84
|
+
if (!isAbsolute(options.binaryPath)) {
|
|
85
|
+
throw new TypeError("AMQ binary path must be absolute");
|
|
86
|
+
}
|
|
87
|
+
this.#binaryPath = options.binaryPath;
|
|
88
|
+
this.#maxStdoutBytes = positiveInteger(
|
|
89
|
+
options.maxStdoutBytes ?? DEFAULT_OUTPUT_BOUND,
|
|
90
|
+
"maxStdoutBytes",
|
|
91
|
+
);
|
|
92
|
+
this.#maxStderrBytes = positiveInteger(
|
|
93
|
+
options.maxStderrBytes ?? DEFAULT_OUTPUT_BOUND,
|
|
94
|
+
"maxStderrBytes",
|
|
95
|
+
);
|
|
96
|
+
this.#bodyFileThresholdBytes = nonNegativeInteger(
|
|
97
|
+
options.bodyFileThresholdBytes ?? DEFAULT_BODY_FILE_THRESHOLD,
|
|
98
|
+
"bodyFileThresholdBytes",
|
|
99
|
+
);
|
|
100
|
+
this.#tempDirectory = options.tempDirectory ?? tmpdir();
|
|
101
|
+
this.#env = { ...process.env, ...options.env };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async runJson<T = unknown>(
|
|
105
|
+
argv: readonly string[],
|
|
106
|
+
options: AmqRunOptions<T> = {},
|
|
107
|
+
): Promise<AmqCommandResult<T>> {
|
|
108
|
+
validateArgv(argv);
|
|
109
|
+
const jsonArgv = argv.includes("--json") ? [...argv] : [...argv, "--json"];
|
|
110
|
+
const capture = await this.#execute(jsonArgv, options);
|
|
111
|
+
return parseJsonCapture(capture, options.parse);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async runJsonWithBody<T = unknown>(
|
|
115
|
+
argv: readonly string[],
|
|
116
|
+
body: string,
|
|
117
|
+
options: AmqRunOptions<T> = {},
|
|
118
|
+
): Promise<AmqCommandResult<T>> {
|
|
119
|
+
validateArgv(argv);
|
|
120
|
+
const requiresFile =
|
|
121
|
+
body.includes("\0") || Buffer.byteLength(body) > this.#bodyFileThresholdBytes;
|
|
122
|
+
if (!requiresFile) {
|
|
123
|
+
return this.runJson([...argv, "--body", body], options);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const temporary = await createTemporaryBody(this.#tempDirectory, body);
|
|
127
|
+
let result: AmqCommandResult<T> | undefined;
|
|
128
|
+
let executionError: Error | undefined;
|
|
129
|
+
try {
|
|
130
|
+
result = await this.runJson([...argv, "--body", `@${temporary.path}`], options);
|
|
131
|
+
} catch (error) {
|
|
132
|
+
executionError = toError(error);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const cleanupError = await cleanupTemporaryBody(temporary);
|
|
136
|
+
if (executionError) {
|
|
137
|
+
if (cleanupError) {
|
|
138
|
+
throw new AggregateError(
|
|
139
|
+
[executionError, cleanupError],
|
|
140
|
+
"AMQ command and temporary body cleanup both failed",
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
throw executionError;
|
|
144
|
+
}
|
|
145
|
+
if (!result) throw new Error("AMQ command completed without a result");
|
|
146
|
+
if (!cleanupError) return result;
|
|
147
|
+
return {
|
|
148
|
+
...result,
|
|
149
|
+
ok: false,
|
|
150
|
+
category: "cleanup-failed",
|
|
151
|
+
processError: cleanupError,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async #execute<T>(
|
|
156
|
+
argv: readonly string[],
|
|
157
|
+
options: AmqRunOptions<T>,
|
|
158
|
+
): Promise<ProcessCapture> {
|
|
159
|
+
if (options.signal?.aborted) {
|
|
160
|
+
return emptyCapture(true);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return new Promise((resolve) => {
|
|
164
|
+
const stdout = new BoundedCapture(this.#maxStdoutBytes);
|
|
165
|
+
const stderr = new BoundedCapture(this.#maxStderrBytes);
|
|
166
|
+
let child;
|
|
167
|
+
try {
|
|
168
|
+
child = spawn(this.#binaryPath, argv, {
|
|
169
|
+
cwd: options.cwd,
|
|
170
|
+
env: { ...this.#env, ...options.env },
|
|
171
|
+
shell: false,
|
|
172
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
173
|
+
windowsHide: true,
|
|
174
|
+
});
|
|
175
|
+
} catch (error) {
|
|
176
|
+
resolve({ ...emptyCapture(false), processError: toError(error) });
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
let settled = false;
|
|
181
|
+
let aborted = false;
|
|
182
|
+
let processError: Error | undefined;
|
|
183
|
+
let outputLimitExceeded = false;
|
|
184
|
+
|
|
185
|
+
const stopForOutputLimit = (): void => {
|
|
186
|
+
if (outputLimitExceeded) return;
|
|
187
|
+
outputLimitExceeded = true;
|
|
188
|
+
child.kill("SIGKILL");
|
|
189
|
+
};
|
|
190
|
+
child.stdout.on("data", (chunk: Buffer) => {
|
|
191
|
+
if (stdout.append(chunk)) stopForOutputLimit();
|
|
192
|
+
});
|
|
193
|
+
child.stderr.on("data", (chunk: Buffer) => {
|
|
194
|
+
if (stderr.append(chunk)) stopForOutputLimit();
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
const onAbort = (): void => {
|
|
198
|
+
aborted = true;
|
|
199
|
+
child.kill("SIGTERM");
|
|
200
|
+
};
|
|
201
|
+
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
202
|
+
|
|
203
|
+
const settle = (exitCode: number | null, signal: NodeJS.Signals | null): void => {
|
|
204
|
+
if (settled) return;
|
|
205
|
+
settled = true;
|
|
206
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
207
|
+
resolve({
|
|
208
|
+
exitCode,
|
|
209
|
+
signal,
|
|
210
|
+
stdout: stdout.value(),
|
|
211
|
+
stderr: stderr.value(),
|
|
212
|
+
stdoutTruncated: stdout.truncated,
|
|
213
|
+
stderrTruncated: stderr.truncated,
|
|
214
|
+
aborted,
|
|
215
|
+
...(processError ? { processError } : {}),
|
|
216
|
+
});
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
child.once("error", (error) => {
|
|
220
|
+
processError = error;
|
|
221
|
+
});
|
|
222
|
+
child.once("close", settle);
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
class BoundedCapture {
|
|
228
|
+
readonly #limit: number;
|
|
229
|
+
readonly #chunks: Buffer[] = [];
|
|
230
|
+
#capturedBytes = 0;
|
|
231
|
+
truncated = false;
|
|
232
|
+
|
|
233
|
+
constructor(limit: number) {
|
|
234
|
+
this.#limit = limit;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
append(chunk: Buffer): boolean {
|
|
238
|
+
const remaining = this.#limit - this.#capturedBytes;
|
|
239
|
+
if (remaining > 0) {
|
|
240
|
+
const captured = chunk.length <= remaining ? chunk : chunk.subarray(0, remaining);
|
|
241
|
+
this.#chunks.push(Buffer.from(captured));
|
|
242
|
+
this.#capturedBytes += captured.length;
|
|
243
|
+
}
|
|
244
|
+
if (chunk.length > remaining) this.truncated = true;
|
|
245
|
+
return this.truncated;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
value(): string {
|
|
249
|
+
return Buffer.concat(this.#chunks, this.#capturedBytes).toString("utf8");
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function parseJsonCapture<T>(
|
|
254
|
+
capture: ProcessCapture,
|
|
255
|
+
parser: ((value: unknown) => T) | undefined,
|
|
256
|
+
): AmqCommandResult<T> {
|
|
257
|
+
let json: T | undefined;
|
|
258
|
+
let jsonError: Error | undefined;
|
|
259
|
+
try {
|
|
260
|
+
const raw = capture.stdout.trim();
|
|
261
|
+
if (raw.length === 0) throw new Error("AMQ returned empty JSON stdout");
|
|
262
|
+
const value: unknown = JSON.parse(raw);
|
|
263
|
+
json = parser ? parser(value) : (value as T);
|
|
264
|
+
} catch (error) {
|
|
265
|
+
jsonError = toError(error);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const category = categorize(capture, jsonError);
|
|
269
|
+
return {
|
|
270
|
+
ok: category === "success",
|
|
271
|
+
category,
|
|
272
|
+
exitCode: capture.exitCode,
|
|
273
|
+
signal: capture.signal,
|
|
274
|
+
stdout: capture.stdout,
|
|
275
|
+
stderr: capture.stderr,
|
|
276
|
+
stdoutTruncated: capture.stdoutTruncated,
|
|
277
|
+
stderrTruncated: capture.stderrTruncated,
|
|
278
|
+
...(json === undefined ? {} : { json }),
|
|
279
|
+
...(jsonError ? { jsonError } : {}),
|
|
280
|
+
...(capture.processError ? { processError: capture.processError } : {}),
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function categorize(capture: ProcessCapture, jsonError: Error | undefined): AmqExitCategory {
|
|
285
|
+
if (capture.aborted) return "cancelled";
|
|
286
|
+
if (capture.stdoutTruncated || capture.stderrTruncated) return "output-limit";
|
|
287
|
+
if (capture.processError) return "spawn-error";
|
|
288
|
+
if (capture.exitCode === null) return "process-crash";
|
|
289
|
+
switch (capture.exitCode) {
|
|
290
|
+
case 0:
|
|
291
|
+
return jsonError ? "invalid-json" : "success";
|
|
292
|
+
case 1:
|
|
293
|
+
return "error";
|
|
294
|
+
case 2:
|
|
295
|
+
return "usage";
|
|
296
|
+
case 3:
|
|
297
|
+
return "not-found";
|
|
298
|
+
case 4:
|
|
299
|
+
return "timeout";
|
|
300
|
+
case 5:
|
|
301
|
+
return "context-mismatch";
|
|
302
|
+
default:
|
|
303
|
+
return "error";
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async function createTemporaryBody(parent: string, body: string): Promise<TemporaryBody> {
|
|
308
|
+
const directory = await mkdtemp(join(parent, "pi-amq-body-"));
|
|
309
|
+
await chmod(directory, 0o700);
|
|
310
|
+
const path = join(directory, "body");
|
|
311
|
+
try {
|
|
312
|
+
const handle = await open(path, "wx", 0o600);
|
|
313
|
+
try {
|
|
314
|
+
await handle.writeFile(body, "utf8");
|
|
315
|
+
} finally {
|
|
316
|
+
await handle.close();
|
|
317
|
+
}
|
|
318
|
+
await chmod(path, 0o600);
|
|
319
|
+
return { directory, path };
|
|
320
|
+
} catch (error) {
|
|
321
|
+
await cleanupTemporaryBody({ directory, path });
|
|
322
|
+
throw error;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async function cleanupTemporaryBody(temporary: TemporaryBody): Promise<Error | undefined> {
|
|
327
|
+
const errors: Error[] = [];
|
|
328
|
+
try {
|
|
329
|
+
await unlink(temporary.path);
|
|
330
|
+
} catch (error) {
|
|
331
|
+
if (!isMissing(error)) errors.push(toError(error));
|
|
332
|
+
}
|
|
333
|
+
try {
|
|
334
|
+
await rmdir(temporary.directory);
|
|
335
|
+
} catch (error) {
|
|
336
|
+
if (!isMissing(error)) errors.push(toError(error));
|
|
337
|
+
}
|
|
338
|
+
if (errors.length === 0) return undefined;
|
|
339
|
+
return errors.length === 1 ? errors[0] : new AggregateError(errors, "temporary AMQ body cleanup failed");
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function validateArgv(argv: readonly string[]): void {
|
|
343
|
+
if (argv.length === 0) throw new TypeError("AMQ argv must include a command");
|
|
344
|
+
for (const argument of argv) {
|
|
345
|
+
if (argument.includes("\0")) throw new TypeError("AMQ argv cannot contain NUL bytes");
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function positiveInteger(value: number, name: string): number {
|
|
350
|
+
if (!Number.isSafeInteger(value) || value <= 0) {
|
|
351
|
+
throw new TypeError(`${name} must be a positive safe integer`);
|
|
352
|
+
}
|
|
353
|
+
return value;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function nonNegativeInteger(value: number, name: string): number {
|
|
357
|
+
if (!Number.isSafeInteger(value) || value < 0) {
|
|
358
|
+
throw new TypeError(`${name} must be a non-negative safe integer`);
|
|
359
|
+
}
|
|
360
|
+
return value;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function emptyCapture(aborted: boolean): ProcessCapture {
|
|
364
|
+
return {
|
|
365
|
+
exitCode: null,
|
|
366
|
+
signal: null,
|
|
367
|
+
stdout: "",
|
|
368
|
+
stderr: "",
|
|
369
|
+
stdoutTruncated: false,
|
|
370
|
+
stderrTruncated: false,
|
|
371
|
+
aborted,
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function isMissing(error: unknown): boolean {
|
|
376
|
+
return error instanceof Error && "code" in error && error.code === "ENOENT";
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function toError(error: unknown): Error {
|
|
380
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
381
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
export type AmqPriority = "urgent" | "normal" | "low";
|
|
2
|
+
|
|
3
|
+
export interface AmqHeaderV1 {
|
|
4
|
+
readonly schema: 1;
|
|
5
|
+
readonly id: string;
|
|
6
|
+
readonly from: string;
|
|
7
|
+
readonly to: readonly string[];
|
|
8
|
+
readonly thread: string;
|
|
9
|
+
readonly subject: string;
|
|
10
|
+
readonly created: string;
|
|
11
|
+
readonly priority: AmqPriority;
|
|
12
|
+
readonly kind: string;
|
|
13
|
+
readonly labels: readonly string[];
|
|
14
|
+
readonly context: Readonly<Record<string, unknown>>;
|
|
15
|
+
readonly replyTo: string;
|
|
16
|
+
readonly replyProject: string;
|
|
17
|
+
readonly fromProject: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface AmqMessage {
|
|
21
|
+
readonly header: AmqHeaderV1;
|
|
22
|
+
readonly body: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface WatchCandidate {
|
|
26
|
+
readonly id: string;
|
|
27
|
+
readonly priority: AmqPriority;
|
|
28
|
+
readonly created: string;
|
|
29
|
+
readonly parseError?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface WatchCohort {
|
|
33
|
+
readonly event: "existing" | "new_message" | "timeout";
|
|
34
|
+
readonly messages: readonly WatchCandidate[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type AmqReadOutcome =
|
|
38
|
+
| { readonly kind: "message"; readonly message: AmqMessage }
|
|
39
|
+
| { readonly kind: "dlq"; readonly id: string; readonly diagnostic: string };
|
|
40
|
+
|
|
41
|
+
const PRIORITY_RANK: Readonly<Record<AmqPriority, number>> = {
|
|
42
|
+
urgent: 0,
|
|
43
|
+
normal: 1,
|
|
44
|
+
low: 2,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const VALID_KINDS = new Set([
|
|
48
|
+
"",
|
|
49
|
+
"brainstorm",
|
|
50
|
+
"review_request",
|
|
51
|
+
"review_response",
|
|
52
|
+
"question",
|
|
53
|
+
"answer",
|
|
54
|
+
"decision",
|
|
55
|
+
"status",
|
|
56
|
+
"todo",
|
|
57
|
+
]);
|
|
58
|
+
|
|
59
|
+
export function parseWatchCohort(value: unknown): WatchCohort {
|
|
60
|
+
const object = record(value, "amq watch --json");
|
|
61
|
+
const event = object.event;
|
|
62
|
+
if (event !== "existing" && event !== "new_message" && event !== "timeout") {
|
|
63
|
+
throw new Error("amq watch event is invalid");
|
|
64
|
+
}
|
|
65
|
+
const rawMessages = object.messages ?? [];
|
|
66
|
+
if (!Array.isArray(rawMessages)) throw new Error("amq watch messages must be an array");
|
|
67
|
+
return {
|
|
68
|
+
event,
|
|
69
|
+
messages: rawMessages.map(parseWatchCandidate),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function parseMessageList(value: unknown): readonly WatchCandidate[] {
|
|
74
|
+
if (!Array.isArray(value)) throw new Error("amq list --json must return an array");
|
|
75
|
+
return value.map(parseWatchCandidate);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function parseWatchCandidate(value: unknown): WatchCandidate {
|
|
79
|
+
const object = record(value, "AMQ message header projection");
|
|
80
|
+
const id = messageId(object.id);
|
|
81
|
+
const parseError = optionalString(object.parse_error, "parse_error");
|
|
82
|
+
const created = stringOrEmpty(object.created, "created");
|
|
83
|
+
const rawPriority = stringOrEmpty(object.priority, "priority");
|
|
84
|
+
if (!parseError) requireIso(created, "created");
|
|
85
|
+
return {
|
|
86
|
+
id,
|
|
87
|
+
priority: parsePriority(rawPriority),
|
|
88
|
+
created,
|
|
89
|
+
...(parseError ? { parseError } : {}),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function parseAmqReadOutcome(value: unknown): AmqReadOutcome {
|
|
94
|
+
const object = record(value, "amq read --json");
|
|
95
|
+
if (object.moved_to_dlq === true) {
|
|
96
|
+
return {
|
|
97
|
+
kind: "dlq",
|
|
98
|
+
id: messageId(object.id),
|
|
99
|
+
diagnostic: requiredString(object.parse_error, "parse_error"),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
const header = parseAmqHeaderV1(object.header);
|
|
103
|
+
const body = object.body;
|
|
104
|
+
if (typeof body !== "string") throw new Error("amq read body must be a string");
|
|
105
|
+
return { kind: "message", message: { header, body } };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function parseAmqHeaderV1(value: unknown): AmqHeaderV1 {
|
|
109
|
+
const object = record(value, "AMQ header");
|
|
110
|
+
if (number(object.schema, "schema") !== 1) throw new Error("AMQ header schema must be 1");
|
|
111
|
+
const kind = stringOrEmpty(object.kind, "kind");
|
|
112
|
+
if (!VALID_KINDS.has(kind)) throw new Error(`AMQ header kind is invalid: ${kind}`);
|
|
113
|
+
const context = object.context === undefined
|
|
114
|
+
? {}
|
|
115
|
+
: record(object.context, "AMQ header context");
|
|
116
|
+
const header: AmqHeaderV1 = {
|
|
117
|
+
schema: 1,
|
|
118
|
+
id: messageId(object.id),
|
|
119
|
+
from: requiredString(object.from, "from"),
|
|
120
|
+
to: stringArray(object.to, "to", false),
|
|
121
|
+
thread: requiredString(object.thread, "thread"),
|
|
122
|
+
subject: stringOrEmpty(object.subject, "subject"),
|
|
123
|
+
created: requireIso(requiredString(object.created, "created"), "created"),
|
|
124
|
+
priority: parsePriority(stringOrEmpty(object.priority, "priority")),
|
|
125
|
+
kind,
|
|
126
|
+
labels: stringArray(object.labels ?? [], "labels", true),
|
|
127
|
+
context,
|
|
128
|
+
replyTo: stringOrEmpty(object.reply_to, "reply_to"),
|
|
129
|
+
replyProject: stringOrEmpty(object.reply_project, "reply_project"),
|
|
130
|
+
fromProject: stringOrEmpty(object.from_project, "from_project"),
|
|
131
|
+
};
|
|
132
|
+
return header;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function sortAndDeduplicateCandidates(
|
|
136
|
+
candidates: readonly WatchCandidate[],
|
|
137
|
+
): readonly WatchCandidate[] {
|
|
138
|
+
const sorted = [...candidates].sort(compareIncoming);
|
|
139
|
+
const seen = new Set<string>();
|
|
140
|
+
return sorted.filter((candidate) => {
|
|
141
|
+
if (seen.has(candidate.id)) return false;
|
|
142
|
+
seen.add(candidate.id);
|
|
143
|
+
return true;
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function sortMessages(messages: readonly AmqMessage[]): readonly AmqMessage[] {
|
|
148
|
+
return [...messages].sort((left, right) => compareIncoming(
|
|
149
|
+
{ id: left.header.id, priority: left.header.priority, created: left.header.created },
|
|
150
|
+
{ id: right.header.id, priority: right.header.priority, created: right.header.created },
|
|
151
|
+
));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function compareIncoming(left: WatchCandidate, right: WatchCandidate): number {
|
|
155
|
+
const invalidOrder = Number(Boolean(right.parseError)) - Number(Boolean(left.parseError));
|
|
156
|
+
if (invalidOrder !== 0) return invalidOrder;
|
|
157
|
+
const priorityOrder = PRIORITY_RANK[left.priority] - PRIORITY_RANK[right.priority];
|
|
158
|
+
if (priorityOrder !== 0) return priorityOrder;
|
|
159
|
+
const createdOrder = left.created.localeCompare(right.created);
|
|
160
|
+
return createdOrder !== 0 ? createdOrder : left.id.localeCompare(right.id);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function parsePriority(value: string): AmqPriority {
|
|
164
|
+
if (value === "" || value === "normal") return "normal";
|
|
165
|
+
if (value === "urgent" || value === "low") return value;
|
|
166
|
+
throw new Error(`AMQ priority is invalid: ${value}`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function messageId(value: unknown): string {
|
|
170
|
+
const id = requiredString(value, "id");
|
|
171
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,254}$/.test(id)) {
|
|
172
|
+
throw new Error("AMQ message ID is not safe for the extension namespace");
|
|
173
|
+
}
|
|
174
|
+
return id;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function record(value: unknown, name: string): Record<string, unknown> {
|
|
178
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
179
|
+
throw new Error(`${name} must be an object`);
|
|
180
|
+
}
|
|
181
|
+
return value as Record<string, unknown>;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function requiredString(value: unknown, name: string): string {
|
|
185
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
186
|
+
throw new Error(`${name} must be a non-empty string`);
|
|
187
|
+
}
|
|
188
|
+
return value;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function optionalString(value: unknown, name: string): string | undefined {
|
|
192
|
+
if (value === undefined || value === "") return undefined;
|
|
193
|
+
if (typeof value !== "string") throw new Error(`${name} must be a string`);
|
|
194
|
+
return value;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function stringOrEmpty(value: unknown, name: string): string {
|
|
198
|
+
if (value === undefined) return "";
|
|
199
|
+
if (typeof value !== "string") throw new Error(`${name} must be a string`);
|
|
200
|
+
return value;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function stringArray(value: unknown, name: string, allowEmpty: boolean): readonly string[] {
|
|
204
|
+
if (!Array.isArray(value) || (!allowEmpty && value.length === 0)) {
|
|
205
|
+
throw new Error(`${name} must be ${allowEmpty ? "an" : "a non-empty"} array`);
|
|
206
|
+
}
|
|
207
|
+
return value.map((item, index) => requiredString(item, `${name}[${index}]`));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function number(value: unknown, name: string): number {
|
|
211
|
+
if (typeof value !== "number" || !Number.isFinite(value)) throw new Error(`${name} must be a number`);
|
|
212
|
+
return value;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function requireIso(value: string, name: string): string {
|
|
216
|
+
if (!Number.isFinite(Date.parse(value))) throw new Error(`${name} must be an ISO timestamp`);
|
|
217
|
+
return value;
|
|
218
|
+
}
|