harnery 0.45.1 → 0.46.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/dist/commander.d.ts +13 -0
- package/dist/commander.d.ts.map +1 -1
- package/dist/commands/admission.d.ts.map +1 -1
- package/dist/commands/admission.js +214 -27
- package/dist/commands/agents.d.ts +4 -0
- package/dist/commands/agents.d.ts.map +1 -1
- package/dist/commands/agents.js +102 -25
- package/dist/commands/browse.d.ts +3 -0
- package/dist/commands/browse.d.ts.map +1 -1
- package/dist/commands/browse.js +172 -8
- package/dist/commands/fetch.js +1 -0
- package/dist/commands/ledger-v3.d.ts.map +1 -1
- package/dist/commands/ledger-v3.js +8 -2
- package/dist/commands/presence.d.ts.map +1 -1
- package/dist/commands/presence.js +40 -0
- package/dist/commands/qa-run.d.ts.map +1 -1
- package/dist/commands/qa-run.js +4 -3
- package/dist/commands/review-pack.d.ts.map +1 -1
- package/dist/commands/review-pack.js +33 -6
- package/dist/commands/workflow.d.ts.map +1 -1
- package/dist/commands/workflow.js +6 -45
- package/dist/core/agents/coord-client.d.ts.map +1 -1
- package/dist/core/agents/coord-client.js +0 -18
- package/dist/core/agents/index.d.ts +1 -0
- package/dist/core/agents/index.d.ts.map +1 -1
- package/dist/core/agents/index.js +1 -0
- package/dist/core/agents/live-authority-v3.d.ts.map +1 -1
- package/dist/core/agents/live-authority-v3.js +11 -4
- package/dist/core/agents/render/session-context.d.ts +0 -6
- package/dist/core/agents/render/session-context.d.ts.map +1 -1
- package/dist/core/agents/render/session-context.js +1 -6
- package/dist/core/agents/session-events.d.ts.map +1 -1
- package/dist/core/agents/session-events.js +12 -4
- package/dist/core/agents/state/heartbeat-reader.d.ts +3 -0
- package/dist/core/agents/state/heartbeat-reader.d.ts.map +1 -1
- package/dist/core/agents/state/live-coordination-view.d.ts +3 -1
- package/dist/core/agents/state/live-coordination-view.d.ts.map +1 -1
- package/dist/core/agents/state/live-coordination-view.js +6 -2
- package/dist/core/agents/state/parent-reference-contract.d.ts +16 -0
- package/dist/core/agents/state/parent-reference-contract.d.ts.map +1 -0
- package/dist/core/agents/state/parent-reference-contract.js +1 -0
- package/dist/core/agents/state/parent-reference.d.ts +18 -0
- package/dist/core/agents/state/parent-reference.d.ts.map +1 -0
- package/dist/core/agents/state/parent-reference.js +119 -0
- package/dist/core/agents/state/pidmap.js +2 -2
- package/dist/core/agents/state/proc-start.d.ts +20 -31
- package/dist/core/agents/state/proc-start.d.ts.map +1 -1
- package/dist/core/agents/state/proc-start.js +37 -70
- package/dist/core/agents/state/service-process.d.ts +61 -0
- package/dist/core/agents/state/service-process.d.ts.map +1 -0
- package/dist/core/agents/state/service-process.js +143 -0
- package/dist/core/config.d.ts +39 -0
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js +63 -0
- package/dist/core/context/index.d.ts +6 -4
- package/dist/core/context/index.d.ts.map +1 -1
- package/dist/core/context/index.js +194 -256
- package/dist/core/context/observation.d.ts +9 -9
- package/dist/core/context/observation.d.ts.map +1 -1
- package/dist/core/context/observation.js +37 -6
- package/dist/core/diagnostics/path-identity.d.ts +1 -1
- package/dist/core/diagnostics/path-identity.d.ts.map +1 -1
- package/dist/core/diagnostics/path-identity.js +51 -6
- package/dist/core/events/v3/canonical.js +43 -14
- package/dist/core/events/v3/index.d.ts +1 -1
- package/dist/core/events/v3/index.d.ts.map +1 -1
- package/dist/core/events/v3/index.js +1 -1
- package/dist/core/events/v3/producers/recorder.d.ts +34 -0
- package/dist/core/events/v3/producers/recorder.d.ts.map +1 -1
- package/dist/core/events/v3/producers/recorder.js +117 -13
- package/dist/core/events/v3/reader.d.ts.map +1 -1
- package/dist/core/events/v3/reader.js +53 -13
- package/dist/core/events/v3/runtime-build.d.ts +14 -0
- package/dist/core/events/v3/runtime-build.d.ts.map +1 -1
- package/dist/core/events/v3/runtime-build.js +44 -0
- package/dist/core/events/v3/validate.d.ts.map +1 -1
- package/dist/core/events/v3/validate.js +36 -1
- package/dist/core/governor/index.d.ts +2 -1
- package/dist/core/governor/index.d.ts.map +1 -1
- package/dist/core/governor/index.js +2 -1
- package/dist/core/governor/service-read.d.ts +3 -10
- package/dist/core/governor/service-read.d.ts.map +1 -1
- package/dist/core/governor/service-read.js +10 -94
- package/dist/core/governor/service.d.ts +3 -7
- package/dist/core/governor/service.d.ts.map +1 -1
- package/dist/core/governor/service.js +17 -182
- package/dist/core/hooks/adapter/native-parent.d.ts +23 -0
- package/dist/core/hooks/adapter/native-parent.d.ts.map +1 -0
- package/dist/core/hooks/adapter/native-parent.js +91 -0
- package/dist/core/hooks/cli.js +79 -4
- package/dist/core/hooks/resolve/owner.d.ts +0 -11
- package/dist/core/hooks/resolve/owner.d.ts.map +1 -1
- package/dist/core/hooks/resolve/owner.js +2 -25
- package/dist/core/hooks/shell-admission.d.ts +84 -0
- package/dist/core/hooks/shell-admission.d.ts.map +1 -0
- package/dist/core/hooks/shell-admission.js +622 -0
- package/dist/core/hooks/shell-commit-tree.d.ts +2 -0
- package/dist/core/hooks/shell-commit-tree.d.ts.map +1 -0
- package/dist/core/hooks/shell-commit-tree.js +104 -0
- package/dist/core/inbox/contract.d.ts +12 -0
- package/dist/core/inbox/contract.d.ts.map +1 -1
- package/dist/core/inbox/contract.js +16 -0
- package/dist/core/inbox/peer-messages.d.ts +41 -16
- package/dist/core/inbox/peer-messages.d.ts.map +1 -1
- package/dist/core/inbox/peer-messages.js +372 -15
- package/dist/core/inbox/service.d.ts +8 -1
- package/dist/core/inbox/service.d.ts.map +1 -1
- package/dist/core/inbox/service.js +83 -3
- package/dist/core/inbox/targets.d.ts +17 -3
- package/dist/core/inbox/targets.d.ts.map +1 -1
- package/dist/core/inbox/targets.js +77 -2
- package/dist/core/presence/git.d.ts +18 -12
- package/dist/core/presence/git.d.ts.map +1 -1
- package/dist/core/presence/git.js +32 -38
- package/dist/core/presence/index.d.ts +2 -2
- package/dist/core/presence/index.d.ts.map +1 -1
- package/dist/core/presence/index.js +8 -6
- package/dist/core/presence/mail.d.ts +133 -0
- package/dist/core/presence/mail.d.ts.map +1 -0
- package/dist/core/presence/mail.js +464 -0
- package/dist/core/resources/sampler.js +2 -2
- package/dist/core/resources/service-status.d.ts +2 -14
- package/dist/core/resources/service-status.d.ts.map +1 -1
- package/dist/core/resources/service-status.js +3 -23
- package/dist/core/resources/service.d.ts +3 -7
- package/dist/core/resources/service.d.ts.map +1 -1
- package/dist/core/resources/service.js +12 -101
- package/dist/core/resources/status.d.ts.map +1 -1
- package/dist/core/resources/status.js +3 -2
- package/dist/core/semantic/contract.d.ts +5 -5
- package/dist/core/semantic/service.d.ts.map +1 -1
- package/dist/core/semantic/service.js +9 -2
- package/dist/core/servers/index.d.ts +0 -2
- package/dist/core/servers/index.d.ts.map +1 -1
- package/dist/core/servers/index.js +3 -18
- package/dist/core/storage/modes.d.ts +4 -0
- package/dist/core/storage/modes.d.ts.map +1 -1
- package/dist/core/storage/modes.js +4 -0
- package/dist/core/storage/segments.d.ts.map +1 -1
- package/dist/core/storage/segments.js +5 -4
- package/dist/core/supervisor/service.d.ts +4 -7
- package/dist/core/supervisor/service.d.ts.map +1 -1
- package/dist/core/supervisor/service.js +23 -122
- package/dist/core/supervisor/services.js +2 -2
- package/dist/core/supervisor/status.d.ts +3 -17
- package/dist/core/supervisor/status.d.ts.map +1 -1
- package/dist/core/supervisor/status.js +7 -70
- package/dist/core/workflow/engine.js +2 -1
- package/dist/core/workflow/index.d.ts +3 -4
- package/dist/core/workflow/index.d.ts.map +1 -1
- package/dist/core/workflow/index.js +2 -2
- package/dist/core/workflow/proof.d.ts.map +1 -1
- package/dist/core/workflow/proof.js +30 -74
- package/dist/core/workflow/resume-lock.d.ts +17 -0
- package/dist/core/workflow/resume-lock.d.ts.map +1 -0
- package/dist/core/workflow/resume-lock.js +158 -0
- package/dist/core/workflow/run-state.d.ts +0 -21
- package/dist/core/workflow/run-state.d.ts.map +1 -1
- package/dist/core/workflow/run-state.js +3 -174
- package/dist/core/workflow/workspaces/leases.d.ts +4 -0
- package/dist/core/workflow/workspaces/leases.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/leases.js +1 -1
- package/dist/core/workflow/workspaces/local-git.js +91 -210
- package/dist/core/workflow/workspaces/ownership.d.ts +0 -7
- package/dist/core/workflow/workspaces/ownership.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/ownership.js +3 -130
- package/dist/core/workflow/workspaces/reclaim.d.ts +4 -7
- package/dist/core/workflow/workspaces/reclaim.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/reclaim.js +0 -4
- package/dist/core/workflow/workspaces/recovery.d.ts +1 -1
- package/dist/core/workflow/workspaces/recovery.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/recovery.js +1 -1
- package/dist/core/workflow/workspaces/state.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/state.js +1 -4
- package/dist/lib/admission-history.d.ts +96 -0
- package/dist/lib/admission-history.d.ts.map +1 -0
- package/dist/lib/admission-history.js +196 -0
- package/dist/lib/admission.d.ts +56 -7
- package/dist/lib/admission.d.ts.map +1 -1
- package/dist/lib/admission.js +161 -19
- package/dist/lib/browser/client.d.ts +18 -0
- package/dist/lib/browser/client.d.ts.map +1 -1
- package/dist/lib/browser/client.js +100 -12
- package/dist/lib/browser/content-checks.d.ts +3 -1
- package/dist/lib/browser/content-checks.d.ts.map +1 -1
- package/dist/lib/browser/content-checks.js +10 -2
- package/dist/lib/browser/critique.d.ts +32 -0
- package/dist/lib/browser/critique.d.ts.map +1 -1
- package/dist/lib/browser/critique.js +72 -3
- package/dist/lib/browser/geometry.d.ts +2 -0
- package/dist/lib/browser/geometry.d.ts.map +1 -1
- package/dist/lib/browser/geometry.js +61 -15
- package/dist/lib/browser/index.d.ts +1 -1
- package/dist/lib/browser/index.d.ts.map +1 -1
- package/dist/lib/browser/index.js +1 -1
- package/dist/lib/browser/page-review-contracts.d.ts +6 -1
- package/dist/lib/browser/page-review-contracts.d.ts.map +1 -1
- package/dist/lib/browser/page-review-contracts.js +6 -1
- package/dist/lib/browser/page-review-judge.d.ts +3 -1
- package/dist/lib/browser/page-review-judge.d.ts.map +1 -1
- package/dist/lib/browser/page-review-judge.js +41 -11
- package/dist/lib/browser/page-review-pack.d.ts +3 -0
- package/dist/lib/browser/page-review-pack.d.ts.map +1 -1
- package/dist/lib/browser/page-review-pack.js +5 -2
- package/dist/lib/browser/qa-run-contracts.d.ts +11 -0
- package/dist/lib/browser/qa-run-contracts.d.ts.map +1 -1
- package/dist/lib/browser/qa-run-contracts.js +11 -4
- package/dist/lib/browser/qa-run.d.ts.map +1 -1
- package/dist/lib/browser/qa-run.js +73 -16
- package/dist/lib/browser/runts.d.ts +4 -2
- package/dist/lib/browser/runts.d.ts.map +1 -1
- package/dist/lib/browser/runts.js +84 -33
- package/dist/lib/browser/windows-chrome.d.ts +14 -0
- package/dist/lib/browser/windows-chrome.d.ts.map +1 -1
- package/dist/lib/browser/windows-chrome.js +42 -0
- package/dist/lib/browser/windows-login-sync.d.ts +110 -0
- package/dist/lib/browser/windows-login-sync.d.ts.map +1 -0
- package/dist/lib/browser/windows-login-sync.js +171 -0
- package/dist/lib/cookies/client.d.ts +30 -5
- package/dist/lib/cookies/client.d.ts.map +1 -1
- package/dist/lib/cookies/client.js +55 -10
- package/dist/lib/cookies/index.d.ts +1 -1
- package/dist/lib/cookies/index.d.ts.map +1 -1
- package/dist/lib/cookies/index.js +1 -1
- package/dist/lib/docs-metadata-sync.d.ts.map +1 -1
- package/dist/lib/docs-metadata-sync.js +41 -2
- package/dist/lib/durable-job.d.ts +8 -0
- package/dist/lib/durable-job.d.ts.map +1 -1
- package/dist/lib/durable-job.js +10 -0
- package/dist/lib/http/client.d.ts +9 -8
- package/dist/lib/http/client.d.ts.map +1 -1
- package/dist/lib/http/client.js +44 -2
- package/dist/lib/instructions/templates.js +4 -4
- package/package.json +2 -2
- package/src/commander.ts +13 -0
- package/src/commands/admission.ts +272 -29
- package/src/commands/agents.ts +132 -27
- package/src/commands/browse.ts +208 -6
- package/src/commands/fetch.ts +1 -0
- package/src/commands/ledger-v3.ts +8 -2
- package/src/commands/presence.ts +49 -0
- package/src/commands/qa-run.ts +4 -3
- package/src/commands/review-pack.ts +41 -5
- package/src/commands/workflow.ts +8 -58
- package/src/core/agents/coord-client.ts +0 -20
- package/src/core/agents/index.ts +6 -0
- package/src/core/agents/live-authority-v3.ts +11 -4
- package/src/core/agents/render/session-context.ts +1 -7
- package/src/core/agents/session-events.ts +18 -3
- package/src/core/agents/state/heartbeat-reader.ts +3 -0
- package/src/core/agents/state/live-coordination-view.ts +13 -6
- package/src/core/agents/state/parent-reference-contract.ts +14 -0
- package/src/core/agents/state/parent-reference.ts +179 -0
- package/src/core/agents/state/pidmap.ts +2 -2
- package/src/core/agents/state/proc-start.ts +41 -69
- package/src/core/agents/state/service-process.ts +217 -0
- package/src/core/config.ts +85 -0
- package/src/core/context/index.ts +207 -272
- package/src/core/context/observation.ts +40 -9
- package/src/core/diagnostics/path-identity.ts +52 -6
- package/src/core/events/v3/canonical.ts +46 -13
- package/src/core/events/v3/index.ts +3 -0
- package/src/core/events/v3/producers/recorder.ts +151 -13
- package/src/core/events/v3/reader.ts +55 -11
- package/src/core/events/v3/runtime-build.ts +62 -0
- package/src/core/events/v3/validate.ts +42 -1
- package/src/core/governor/index.ts +1 -2
- package/src/core/governor/service-read.ts +15 -114
- package/src/core/governor/service.ts +36 -220
- package/src/core/hooks/adapter/native-parent.ts +109 -0
- package/src/core/hooks/cli.ts +83 -4
- package/src/core/hooks/resolve/owner.ts +2 -25
- package/src/core/hooks/shell-admission.ts +634 -0
- package/src/core/hooks/shell-commit-tree.ts +102 -0
- package/src/core/inbox/contract.ts +18 -0
- package/src/core/inbox/peer-messages.ts +501 -15
- package/src/core/inbox/service.ts +108 -3
- package/src/core/inbox/targets.ts +94 -3
- package/src/core/presence/git.ts +45 -37
- package/src/core/presence/index.ts +16 -8
- package/src/core/presence/mail.ts +620 -0
- package/src/core/resources/sampler.ts +3 -2
- package/src/core/resources/service-status.ts +6 -35
- package/src/core/resources/service.ts +23 -133
- package/src/core/resources/status.ts +3 -2
- package/src/core/semantic/service.ts +8 -2
- package/src/core/servers/index.ts +3 -23
- package/src/core/storage/modes.ts +6 -0
- package/src/core/storage/segments.ts +5 -4
- package/src/core/supervisor/service.ts +33 -155
- package/src/core/supervisor/services.ts +2 -2
- package/src/core/supervisor/status.ts +10 -85
- package/src/core/workflow/engine.ts +1 -1
- package/src/core/workflow/index.ts +2 -13
- package/src/core/workflow/proof.ts +35 -83
- package/src/core/workflow/resume-lock.ts +179 -0
- package/src/core/workflow/run-state.ts +3 -207
- package/src/core/workflow/workspaces/leases.ts +1 -1
- package/src/core/workflow/workspaces/local-git.ts +120 -239
- package/src/core/workflow/workspaces/ownership.ts +3 -158
- package/src/core/workflow/workspaces/reclaim.ts +3 -9
- package/src/core/workflow/workspaces/recovery.ts +1 -1
- package/src/core/workflow/workspaces/state.ts +1 -5
- package/src/lib/admission-history.ts +267 -0
- package/src/lib/admission.ts +217 -19
- package/src/lib/browser/client.ts +147 -35
- package/src/lib/browser/content-checks.ts +14 -3
- package/src/lib/browser/critique.ts +113 -3
- package/src/lib/browser/geometry.ts +63 -14
- package/src/lib/browser/index.ts +5 -0
- package/src/lib/browser/page-review-contracts.ts +6 -1
- package/src/lib/browser/page-review-judge.ts +58 -13
- package/src/lib/browser/page-review-pack.ts +13 -2
- package/src/lib/browser/qa-run-contracts.ts +30 -4
- package/src/lib/browser/qa-run.ts +78 -17
- package/src/lib/browser/runts.ts +85 -37
- package/src/lib/browser/windows-chrome.ts +50 -0
- package/src/lib/browser/windows-login-sync.ts +275 -0
- package/src/lib/cookies/client.ts +62 -10
- package/src/lib/cookies/index.ts +3 -0
- package/src/lib/docs-metadata-sync.ts +44 -2
- package/src/lib/durable-job.ts +15 -0
- package/src/lib/http/client.ts +63 -1
- package/src/lib/instructions/templates.ts +4 -4
- package/dist/core/agents/state/process-handle.d.ts +0 -26
- package/dist/core/agents/state/process-handle.d.ts.map +0 -1
- package/dist/core/agents/state/process-handle.js +0 -121
- package/dist/core/workflow/recovery-audit.d.ts +0 -30
- package/dist/core/workflow/recovery-audit.d.ts.map +0 -1
- package/dist/core/workflow/recovery-audit.js +0 -116
- package/src/core/agents/state/process-handle.ts +0 -165
- package/src/core/workflow/recovery-audit.ts +0 -181
|
@@ -0,0 +1,620 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-machine peer messages (ADR 0222). A ping whose recipient lives on
|
|
3
|
+
* another machine is queued here and published as part of this machine's
|
|
4
|
+
* mail ref, `refs/harnery/mail/<machine>`, on the same origin the presence
|
|
5
|
+
* refs use. Each machine writes only its own ref, so force-pushing it never
|
|
6
|
+
* races another writer.
|
|
7
|
+
*
|
|
8
|
+
* The ref is a parentless commit whose message is one JSON blob:
|
|
9
|
+
* outbox — messages this machine sent that the recipient machine has not
|
|
10
|
+
* acknowledged yet;
|
|
11
|
+
* acks — ids of messages this machine imported from other machines.
|
|
12
|
+
*
|
|
13
|
+
* The recipient machine imports envelopes addressed to it into the ordinary
|
|
14
|
+
* per-instance inbox (keeping the sender's message id, so a re-import is a
|
|
15
|
+
* no-op) and acknowledges them in its own ref. The sender drops an
|
|
16
|
+
* acknowledged message from its outbox; the recipient stops acknowledging
|
|
17
|
+
* once the sender's ref no longer lists it. Delivery is therefore
|
|
18
|
+
* at-least-once into a deduplicating inbox.
|
|
19
|
+
*
|
|
20
|
+
* Local state is one append-only log, `.harnery/mail/log.jsonl`, updated
|
|
21
|
+
* under the durable-history lease so concurrent hooks on one machine agree.
|
|
22
|
+
* Repo access is the trust boundary, exactly as for presence: anyone who can
|
|
23
|
+
* push to origin can publish a mail ref.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { createHash } from "node:crypto";
|
|
27
|
+
import {
|
|
28
|
+
existsSync,
|
|
29
|
+
mkdirSync,
|
|
30
|
+
readFileSync,
|
|
31
|
+
renameSync,
|
|
32
|
+
statSync,
|
|
33
|
+
utimesSync,
|
|
34
|
+
writeFileSync,
|
|
35
|
+
} from "node:fs";
|
|
36
|
+
import { join } from "node:path";
|
|
37
|
+
import { resolveMachineLabel } from "../../lib/machine.ts";
|
|
38
|
+
import { presenceEnabled } from "../config.ts";
|
|
39
|
+
import type { HarneryInboxMessageRecordV1 } from "../inbox/contract.ts";
|
|
40
|
+
import {
|
|
41
|
+
HARNERY_INBOX_RECORD_SCHEMA,
|
|
42
|
+
HarneryInboxCapacityError,
|
|
43
|
+
HarneryInboxMessageConflictError,
|
|
44
|
+
HarneryInboxMessageHistoryMissingError,
|
|
45
|
+
} from "../inbox/contract.ts";
|
|
46
|
+
import type { HarneryInboxService } from "../inbox/service.ts";
|
|
47
|
+
import { readCrashSafeJsonlFile, updateCrashSafeJsonlFile } from "../storage/durable-history.ts";
|
|
48
|
+
import {
|
|
49
|
+
hasOrigin,
|
|
50
|
+
MAIL_REF_PREFIX,
|
|
51
|
+
pushMachineRef,
|
|
52
|
+
readMachineRefs,
|
|
53
|
+
sanitizeRefComponent,
|
|
54
|
+
writePresenceCommit,
|
|
55
|
+
} from "./git.ts";
|
|
56
|
+
|
|
57
|
+
export const HARNERY_MAIL_ENVELOPE_SCHEMA = "harnery.mail-envelope/v1" as const;
|
|
58
|
+
const MAIL_LOG_SCHEMA = "harnery.mail-log/v1" as const;
|
|
59
|
+
|
|
60
|
+
/** An unacknowledged message stops being offered after this long. */
|
|
61
|
+
export const MAIL_TTL_MS = 14 * 24 * 60 * 60_000;
|
|
62
|
+
/** While a message is still unacknowledged, re-push at least this often, so a
|
|
63
|
+
* detached push that failed heals without anyone noticing. */
|
|
64
|
+
const MAIL_KEEPALIVE_SECS = 600;
|
|
65
|
+
const MAX_OUTBOX = 100;
|
|
66
|
+
/** Hook-driven imports run at most this often (the presence fetch is 60s). */
|
|
67
|
+
const IMPORT_INTERVAL_SECS = 30;
|
|
68
|
+
const MAX_LOG_RECORDS = 20_000;
|
|
69
|
+
const MAX_LOG_RECORD_BYTES = 16_384;
|
|
70
|
+
|
|
71
|
+
export interface HarneryMailEnvelopeV1 {
|
|
72
|
+
schema: typeof HARNERY_MAIL_ENVELOPE_SCHEMA;
|
|
73
|
+
message_id: string;
|
|
74
|
+
from_machine: string;
|
|
75
|
+
to_machine: string;
|
|
76
|
+
sender_instance_id: string | null;
|
|
77
|
+
sender_display_name: string;
|
|
78
|
+
recipient_instance_id: string;
|
|
79
|
+
recipient_display_name: string;
|
|
80
|
+
created_at: string;
|
|
81
|
+
body: string;
|
|
82
|
+
body_bytes: number;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface HarneryMailBlobV1 {
|
|
86
|
+
v: 1;
|
|
87
|
+
machine: string;
|
|
88
|
+
published_at: string;
|
|
89
|
+
outbox: HarneryMailEnvelopeV1[];
|
|
90
|
+
acks: string[];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
type MailLogRecord =
|
|
94
|
+
| {
|
|
95
|
+
schema: typeof MAIL_LOG_SCHEMA;
|
|
96
|
+
kind: "outbound";
|
|
97
|
+
at: string;
|
|
98
|
+
envelope: HarneryMailEnvelopeV1;
|
|
99
|
+
}
|
|
100
|
+
| { schema: typeof MAIL_LOG_SCHEMA; kind: "delivered"; at: string; message_id: string }
|
|
101
|
+
| { schema: typeof MAIL_LOG_SCHEMA; kind: "expired"; at: string; message_id: string }
|
|
102
|
+
| {
|
|
103
|
+
schema: typeof MAIL_LOG_SCHEMA;
|
|
104
|
+
kind: "imported";
|
|
105
|
+
at: string;
|
|
106
|
+
message_id: string;
|
|
107
|
+
from_machine: string;
|
|
108
|
+
sender_instance_id: string | null;
|
|
109
|
+
sender_display_name: string;
|
|
110
|
+
}
|
|
111
|
+
| { schema: typeof MAIL_LOG_SCHEMA; kind: "released"; at: string; message_id: string };
|
|
112
|
+
|
|
113
|
+
interface MailProjection {
|
|
114
|
+
/** Retained outbound identities, including acknowledged and expired mail. */
|
|
115
|
+
sent: Map<
|
|
116
|
+
string,
|
|
117
|
+
{ envelope: HarneryMailEnvelopeV1; delivery_state: HarneryMailQueueReceipt["delivery_state"] }
|
|
118
|
+
>;
|
|
119
|
+
/** Sent, neither acknowledged nor expired, oldest first. */
|
|
120
|
+
outbox: Array<{ at: string; envelope: HarneryMailEnvelopeV1 }>;
|
|
121
|
+
/** Imported and still acknowledged, keyed by message id. */
|
|
122
|
+
acks: Map<string, { at: string; from_machine: string }>;
|
|
123
|
+
/** Every message id this machine has imported, released or not. */
|
|
124
|
+
imported: Set<string>;
|
|
125
|
+
/** Senders seen in imported mail, so a reply resolves after they go idle. */
|
|
126
|
+
senders: Map<string, { name: string; machine: string; at: string }>;
|
|
127
|
+
delivered: number;
|
|
128
|
+
expired: number;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** This machine's mail identity: the same sanitized label as its ref. */
|
|
132
|
+
export function mailMachine(): string {
|
|
133
|
+
return sanitizeRefComponent(resolveMachineLabel());
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function mailDir(coordRoot: string): string {
|
|
137
|
+
return join(coordRoot, ".harnery", "mail");
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function logPath(coordRoot: string): string {
|
|
141
|
+
return join(mailDir(coordRoot), "log.jsonl");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const LOG_OPTIONS = { max_record_bytes: MAX_LOG_RECORD_BYTES, max_records: MAX_LOG_RECORDS };
|
|
145
|
+
|
|
146
|
+
function stamp(now: Date): string {
|
|
147
|
+
return now.toISOString();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function projectMail(records: readonly MailLogRecord[]): MailProjection {
|
|
151
|
+
const sent: MailProjection["sent"] = new Map();
|
|
152
|
+
const outbound = new Map<string, { at: string; envelope: HarneryMailEnvelopeV1 }>();
|
|
153
|
+
const acks = new Map<string, { at: string; from_machine: string }>();
|
|
154
|
+
const imported = new Set<string>();
|
|
155
|
+
const senders = new Map<string, { name: string; machine: string; at: string }>();
|
|
156
|
+
let delivered = 0;
|
|
157
|
+
let expired = 0;
|
|
158
|
+
for (const record of records) {
|
|
159
|
+
if (record?.schema !== MAIL_LOG_SCHEMA) continue;
|
|
160
|
+
switch (record.kind) {
|
|
161
|
+
case "outbound":
|
|
162
|
+
outbound.set(record.envelope.message_id, { at: record.at, envelope: record.envelope });
|
|
163
|
+
sent.set(record.envelope.message_id, {
|
|
164
|
+
envelope: record.envelope,
|
|
165
|
+
delivery_state: "pending",
|
|
166
|
+
});
|
|
167
|
+
break;
|
|
168
|
+
case "delivered":
|
|
169
|
+
if (outbound.delete(record.message_id)) delivered++;
|
|
170
|
+
if (sent.has(record.message_id)) sent.get(record.message_id)!.delivery_state = "delivered";
|
|
171
|
+
break;
|
|
172
|
+
case "expired":
|
|
173
|
+
if (outbound.delete(record.message_id)) expired++;
|
|
174
|
+
if (sent.has(record.message_id)) sent.get(record.message_id)!.delivery_state = "expired";
|
|
175
|
+
break;
|
|
176
|
+
case "imported":
|
|
177
|
+
imported.add(record.message_id);
|
|
178
|
+
acks.set(record.message_id, { at: record.at, from_machine: record.from_machine });
|
|
179
|
+
if (record.sender_instance_id) {
|
|
180
|
+
senders.set(record.sender_instance_id, {
|
|
181
|
+
name: record.sender_display_name,
|
|
182
|
+
machine: record.from_machine,
|
|
183
|
+
at: record.at,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
break;
|
|
187
|
+
case "released":
|
|
188
|
+
acks.delete(record.message_id);
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return { sent, outbox: [...outbound.values()], acks, imported, senders, delivered, expired };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function readMailLog(coordRoot: string): MailLogRecord[] {
|
|
196
|
+
try {
|
|
197
|
+
return readCrashSafeJsonlFile<MailLogRecord>(logPath(coordRoot), LOG_OPTIONS);
|
|
198
|
+
} catch {
|
|
199
|
+
return [];
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function updateMailLog<R>(
|
|
204
|
+
coordRoot: string,
|
|
205
|
+
update: (projection: MailProjection) => { append?: MailLogRecord[]; result: R },
|
|
206
|
+
): R {
|
|
207
|
+
mkdirSync(mailDir(coordRoot), { recursive: true });
|
|
208
|
+
return updateCrashSafeJsonlFile<MailLogRecord, R>(logPath(coordRoot), LOG_OPTIONS, (records) =>
|
|
209
|
+
update(projectMail(records)),
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export interface QueueMailInput {
|
|
214
|
+
messageId: string;
|
|
215
|
+
replayOnly?: boolean;
|
|
216
|
+
toMachine: string;
|
|
217
|
+
senderInstanceId: string | null;
|
|
218
|
+
senderName: string;
|
|
219
|
+
recipientInstanceId: string;
|
|
220
|
+
recipientName: string;
|
|
221
|
+
body: string;
|
|
222
|
+
maxBodyBytes: number;
|
|
223
|
+
now?: Date;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export interface HarneryMailQueueReceipt {
|
|
227
|
+
envelope: HarneryMailEnvelopeV1;
|
|
228
|
+
state: "queued" | "duplicate";
|
|
229
|
+
delivery_state: "pending" | "delivered" | "expired";
|
|
230
|
+
synced: true;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Queue one message for another machine. The caller publishes afterwards. */
|
|
234
|
+
export function queueMail(coordRoot: string, input: QueueMailInput): HarneryMailQueueReceipt {
|
|
235
|
+
if (input.replayOnly !== undefined && typeof input.replayOnly !== "boolean") {
|
|
236
|
+
throw new Error("invalid replayOnly flag");
|
|
237
|
+
}
|
|
238
|
+
if (
|
|
239
|
+
typeof input.messageId !== "string" ||
|
|
240
|
+
!/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/.test(input.messageId)
|
|
241
|
+
) {
|
|
242
|
+
throw new Error("invalid message id");
|
|
243
|
+
}
|
|
244
|
+
const bodyBytes = Buffer.byteLength(input.body);
|
|
245
|
+
if (bodyBytes === 0 || bodyBytes > input.maxBodyBytes) {
|
|
246
|
+
throw new HarneryInboxCapacityError("message_body_limit", bodyBytes, input.maxBodyBytes);
|
|
247
|
+
}
|
|
248
|
+
const now = input.now ?? new Date();
|
|
249
|
+
const envelope: HarneryMailEnvelopeV1 = {
|
|
250
|
+
schema: HARNERY_MAIL_ENVELOPE_SCHEMA,
|
|
251
|
+
message_id: input.messageId,
|
|
252
|
+
from_machine: mailMachine(),
|
|
253
|
+
to_machine: sanitizeRefComponent(input.toMachine),
|
|
254
|
+
sender_instance_id: input.senderInstanceId,
|
|
255
|
+
sender_display_name: input.senderName,
|
|
256
|
+
recipient_instance_id: input.recipientInstanceId,
|
|
257
|
+
recipient_display_name: input.recipientName,
|
|
258
|
+
created_at: stamp(now),
|
|
259
|
+
body: input.body,
|
|
260
|
+
body_bytes: bodyBytes,
|
|
261
|
+
};
|
|
262
|
+
return updateMailLog<HarneryMailQueueReceipt>(coordRoot, (projection) => {
|
|
263
|
+
const existing = projection.sent.get(envelope.message_id);
|
|
264
|
+
if (existing) {
|
|
265
|
+
const fields = [
|
|
266
|
+
"from_machine",
|
|
267
|
+
"to_machine",
|
|
268
|
+
"sender_instance_id",
|
|
269
|
+
"sender_display_name",
|
|
270
|
+
"recipient_instance_id",
|
|
271
|
+
"recipient_display_name",
|
|
272
|
+
"body",
|
|
273
|
+
"body_bytes",
|
|
274
|
+
] as const;
|
|
275
|
+
if (fields.some((field) => existing.envelope[field] !== envelope[field])) {
|
|
276
|
+
throw new HarneryInboxMessageConflictError(envelope.message_id);
|
|
277
|
+
}
|
|
278
|
+
return {
|
|
279
|
+
result: {
|
|
280
|
+
envelope: existing.envelope,
|
|
281
|
+
state: "duplicate",
|
|
282
|
+
delivery_state: existing.delivery_state,
|
|
283
|
+
synced: true,
|
|
284
|
+
},
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
if (input.replayOnly) {
|
|
288
|
+
throw new HarneryInboxMessageHistoryMissingError(envelope.message_id);
|
|
289
|
+
}
|
|
290
|
+
if (projection.outbox.length >= MAX_OUTBOX) {
|
|
291
|
+
throw new HarneryInboxCapacityError(
|
|
292
|
+
"pending_count_limit",
|
|
293
|
+
projection.outbox.length,
|
|
294
|
+
MAX_OUTBOX,
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
return {
|
|
298
|
+
append: [{ schema: MAIL_LOG_SCHEMA, kind: "outbound", at: stamp(now), envelope }],
|
|
299
|
+
result: { envelope, state: "queued", delivery_state: "pending", synced: true },
|
|
300
|
+
};
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export type MailPublishResult =
|
|
305
|
+
| { status: "published"; machine: string; outbox: number; acks: number; sha: string }
|
|
306
|
+
| { status: "skipped"; reason: string }
|
|
307
|
+
| { status: "error"; error: string };
|
|
308
|
+
|
|
309
|
+
interface MailPublishState {
|
|
310
|
+
hash?: string;
|
|
311
|
+
pushed_at?: string;
|
|
312
|
+
empty?: boolean;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function statePath(coordRoot: string): string {
|
|
316
|
+
return join(mailDir(coordRoot), "publish-state.json");
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function readState(coordRoot: string): MailPublishState {
|
|
320
|
+
try {
|
|
321
|
+
return JSON.parse(readFileSync(statePath(coordRoot), "utf8")) as MailPublishState;
|
|
322
|
+
} catch {
|
|
323
|
+
return {};
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function writeState(coordRoot: string, state: MailPublishState): void {
|
|
328
|
+
try {
|
|
329
|
+
mkdirSync(mailDir(coordRoot), { recursive: true });
|
|
330
|
+
const p = statePath(coordRoot);
|
|
331
|
+
const tmp = `${p}.tmp.${process.pid}`;
|
|
332
|
+
writeFileSync(tmp, JSON.stringify(state), "utf8");
|
|
333
|
+
renameSync(tmp, p);
|
|
334
|
+
} catch {
|
|
335
|
+
/* best-effort: the keepalive re-push covers a lost state write */
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** Expire stale outbound mail, then return the blob this machine should publish. */
|
|
340
|
+
function currentBlob(coordRoot: string, now: Date): HarneryMailBlobV1 {
|
|
341
|
+
const projection = updateMailLog(coordRoot, (p) => {
|
|
342
|
+
const cutoff = now.getTime() - MAIL_TTL_MS;
|
|
343
|
+
const stale = p.outbox.filter(({ at }) => Date.parse(at) < cutoff);
|
|
344
|
+
return {
|
|
345
|
+
append: stale.map(({ envelope }) => ({
|
|
346
|
+
schema: MAIL_LOG_SCHEMA,
|
|
347
|
+
kind: "expired" as const,
|
|
348
|
+
at: stamp(now),
|
|
349
|
+
message_id: envelope.message_id,
|
|
350
|
+
})),
|
|
351
|
+
result: { ...p, outbox: p.outbox.filter(({ at }) => Date.parse(at) >= cutoff) },
|
|
352
|
+
};
|
|
353
|
+
});
|
|
354
|
+
return {
|
|
355
|
+
v: 1,
|
|
356
|
+
machine: mailMachine(),
|
|
357
|
+
published_at: stamp(now),
|
|
358
|
+
outbox: projection.outbox.map(({ envelope }) => envelope),
|
|
359
|
+
acks: [...projection.acks.keys()].sort(),
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Publish this machine's mail ref when its content changed, or as a keepalive
|
|
365
|
+
* while messages are still unacknowledged. A machine that never sent or
|
|
366
|
+
* received cross-machine mail never creates the ref.
|
|
367
|
+
*/
|
|
368
|
+
export function publishMail(
|
|
369
|
+
coordRoot: string,
|
|
370
|
+
opts: { force?: boolean; sync?: boolean; now?: Date } = {},
|
|
371
|
+
): MailPublishResult {
|
|
372
|
+
try {
|
|
373
|
+
if (!presenceEnabled(coordRoot)) return { status: "skipped", reason: "disabled" };
|
|
374
|
+
// A machine that never sent or received cross-machine mail has no log;
|
|
375
|
+
// the hook path stops here without spawning git.
|
|
376
|
+
if (!existsSync(logPath(coordRoot))) return { status: "skipped", reason: "no mail" };
|
|
377
|
+
if (!hasOrigin(coordRoot)) return { status: "skipped", reason: "no origin remote" };
|
|
378
|
+
const now = opts.now ?? new Date();
|
|
379
|
+
const blob = currentBlob(coordRoot, now);
|
|
380
|
+
const empty = blob.outbox.length === 0 && blob.acks.length === 0;
|
|
381
|
+
const hash = createHash("sha256")
|
|
382
|
+
.update(JSON.stringify([blob.outbox.map((e) => e.message_id), blob.acks]))
|
|
383
|
+
.digest("hex");
|
|
384
|
+
const state = readState(coordRoot);
|
|
385
|
+
if (!opts.force) {
|
|
386
|
+
if (empty && (state.empty ?? true)) return { status: "skipped", reason: "nothing to carry" };
|
|
387
|
+
const pushedAt = Date.parse(state.pushed_at ?? "");
|
|
388
|
+
const sincePush = Number.isFinite(pushedAt)
|
|
389
|
+
? (now.getTime() - pushedAt) / 1000
|
|
390
|
+
: Number.POSITIVE_INFINITY;
|
|
391
|
+
if (state.hash === hash && (blob.outbox.length === 0 || sincePush < MAIL_KEEPALIVE_SECS)) {
|
|
392
|
+
return { status: "skipped", reason: "unchanged" };
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
const sha = writePresenceCommit(coordRoot, JSON.stringify(blob));
|
|
396
|
+
if (!sha) return { status: "error", error: "commit-tree failed" };
|
|
397
|
+
const push = pushMachineRef(coordRoot, MAIL_REF_PREFIX, blob.machine, sha, {
|
|
398
|
+
sync: opts.sync,
|
|
399
|
+
});
|
|
400
|
+
if (!push.ok) return { status: "error", error: push.error ?? "push failed" };
|
|
401
|
+
writeState(coordRoot, { hash, pushed_at: stamp(now), empty });
|
|
402
|
+
return {
|
|
403
|
+
status: "published",
|
|
404
|
+
machine: blob.machine,
|
|
405
|
+
outbox: blob.outbox.length,
|
|
406
|
+
acks: blob.acks.length,
|
|
407
|
+
sha,
|
|
408
|
+
};
|
|
409
|
+
} catch (e) {
|
|
410
|
+
return { status: "error", error: e instanceof Error ? e.message : String(e) };
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function parseBlob(machine: string, message: string): HarneryMailBlobV1 | null {
|
|
415
|
+
try {
|
|
416
|
+
const blob = JSON.parse(message) as HarneryMailBlobV1;
|
|
417
|
+
if (blob?.v !== 1 || sanitizeRefComponent(String(blob.machine)) !== machine) return null;
|
|
418
|
+
if (!Number.isFinite(Date.parse(blob.published_at))) return null;
|
|
419
|
+
if (!Array.isArray(blob.outbox) || !Array.isArray(blob.acks)) return null;
|
|
420
|
+
return blob;
|
|
421
|
+
} catch {
|
|
422
|
+
return null;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function validEnvelope(envelope: HarneryMailEnvelopeV1, from: string, self: string): boolean {
|
|
427
|
+
return (
|
|
428
|
+
envelope?.schema === HARNERY_MAIL_ENVELOPE_SCHEMA &&
|
|
429
|
+
envelope.from_machine === from &&
|
|
430
|
+
envelope.to_machine === self &&
|
|
431
|
+
typeof envelope.message_id === "string" &&
|
|
432
|
+
typeof envelope.recipient_instance_id === "string" &&
|
|
433
|
+
typeof envelope.body === "string" &&
|
|
434
|
+
Buffer.byteLength(envelope.body) === envelope.body_bytes
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
export interface MailImportResult {
|
|
439
|
+
received: number;
|
|
440
|
+
delivered: number;
|
|
441
|
+
released: number;
|
|
442
|
+
rejected: number;
|
|
443
|
+
published?: MailPublishResult["status"];
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Apply every locally-fetched peer mail ref: import envelopes addressed to
|
|
448
|
+
* this machine, record acknowledgements of this machine's outbox, and stop
|
|
449
|
+
* acknowledging messages their sender has dropped. Reads only local refs;
|
|
450
|
+
* `fetchPresence` brings them in. Never throws.
|
|
451
|
+
*/
|
|
452
|
+
export function importMail(
|
|
453
|
+
coordRoot: string,
|
|
454
|
+
inbox: HarneryInboxService,
|
|
455
|
+
opts: { now?: Date; publish?: boolean; throttle?: boolean } = {},
|
|
456
|
+
): MailImportResult {
|
|
457
|
+
const result: MailImportResult = { received: 0, delivered: 0, released: 0, rejected: 0 };
|
|
458
|
+
try {
|
|
459
|
+
if (!presenceEnabled(coordRoot)) return result;
|
|
460
|
+
const now = opts.now ?? new Date();
|
|
461
|
+
if (opts.throttle && !importDue(coordRoot, now)) return result;
|
|
462
|
+
const self = mailMachine();
|
|
463
|
+
const peers = readMachineRefs(coordRoot, MAIL_REF_PREFIX)
|
|
464
|
+
.filter(({ machine }) => machine !== self)
|
|
465
|
+
.map(({ machine, message }) => ({ machine, blob: parseBlob(machine, message) }))
|
|
466
|
+
.filter((peer): peer is { machine: string; blob: HarneryMailBlobV1 } => peer.blob !== null);
|
|
467
|
+
if (peers.length === 0) return result;
|
|
468
|
+
|
|
469
|
+
updateMailLog(coordRoot, (projection) => {
|
|
470
|
+
const append: MailLogRecord[] = [];
|
|
471
|
+
const pending = new Map(projection.outbox.map((o) => [o.envelope.message_id, o.envelope]));
|
|
472
|
+
for (const { machine, blob } of peers) {
|
|
473
|
+
const listed = new Set<string>();
|
|
474
|
+
for (const envelope of blob.outbox) {
|
|
475
|
+
if (!validEnvelope(envelope, machine, self)) continue;
|
|
476
|
+
listed.add(envelope.message_id);
|
|
477
|
+
if (projection.imported.has(envelope.message_id)) continue;
|
|
478
|
+
if (!receive(inbox, envelope, result)) continue;
|
|
479
|
+
append.push({
|
|
480
|
+
schema: MAIL_LOG_SCHEMA,
|
|
481
|
+
kind: "imported",
|
|
482
|
+
at: stamp(now),
|
|
483
|
+
message_id: envelope.message_id,
|
|
484
|
+
from_machine: machine,
|
|
485
|
+
sender_instance_id: envelope.sender_instance_id,
|
|
486
|
+
sender_display_name: envelope.sender_display_name,
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
for (const id of blob.acks) {
|
|
490
|
+
if (pending.get(id)?.to_machine !== machine) continue;
|
|
491
|
+
pending.delete(id);
|
|
492
|
+
append.push({
|
|
493
|
+
schema: MAIL_LOG_SCHEMA,
|
|
494
|
+
kind: "delivered",
|
|
495
|
+
at: stamp(now),
|
|
496
|
+
message_id: id,
|
|
497
|
+
});
|
|
498
|
+
result.delivered++;
|
|
499
|
+
}
|
|
500
|
+
const publishedAt = Date.parse(blob.published_at);
|
|
501
|
+
for (const [id, ack] of projection.acks) {
|
|
502
|
+
if (ack.from_machine !== machine || listed.has(id)) continue;
|
|
503
|
+
if (publishedAt <= Date.parse(ack.at)) continue;
|
|
504
|
+
append.push({
|
|
505
|
+
schema: MAIL_LOG_SCHEMA,
|
|
506
|
+
kind: "released",
|
|
507
|
+
at: stamp(now),
|
|
508
|
+
message_id: id,
|
|
509
|
+
});
|
|
510
|
+
result.released++;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
return { append, result: undefined };
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
if (opts.publish !== false && result.received + result.delivered + result.released > 0) {
|
|
517
|
+
result.published = publishMail(coordRoot, { now }).status;
|
|
518
|
+
}
|
|
519
|
+
} catch {
|
|
520
|
+
/* mail must never break a hook */
|
|
521
|
+
}
|
|
522
|
+
return result;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/** Hooks import at most once per interval: refs only change when the
|
|
526
|
+
* throttled presence fetch lands, so checking every prompt spawns git for
|
|
527
|
+
* nothing. The stamp is touched first so concurrent hooks do not stampede. */
|
|
528
|
+
function importDue(coordRoot: string, now: Date): boolean {
|
|
529
|
+
const path = join(mailDir(coordRoot), "import-stamp");
|
|
530
|
+
try {
|
|
531
|
+
if ((now.getTime() - statSync(path).mtimeMs) / 1000 < IMPORT_INTERVAL_SECS) return false;
|
|
532
|
+
} catch {
|
|
533
|
+
/* no stamp yet → import */
|
|
534
|
+
}
|
|
535
|
+
try {
|
|
536
|
+
mkdirSync(mailDir(coordRoot), { recursive: true });
|
|
537
|
+
writeFileSync(path, "", "utf8");
|
|
538
|
+
utimesSync(path, now, now);
|
|
539
|
+
} catch {
|
|
540
|
+
/* best-effort */
|
|
541
|
+
}
|
|
542
|
+
return true;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/** Inbox append for one envelope. A full inbox leaves the message unacknowledged
|
|
546
|
+
* so the sender keeps offering it; a malformed one is acknowledged and dropped. */
|
|
547
|
+
function receive(
|
|
548
|
+
inbox: HarneryInboxService,
|
|
549
|
+
envelope: HarneryMailEnvelopeV1,
|
|
550
|
+
result: MailImportResult,
|
|
551
|
+
): boolean {
|
|
552
|
+
const record: HarneryInboxMessageRecordV1 = {
|
|
553
|
+
schema: HARNERY_INBOX_RECORD_SCHEMA,
|
|
554
|
+
kind: "message",
|
|
555
|
+
message_id: envelope.message_id,
|
|
556
|
+
sender_instance_id: envelope.sender_instance_id,
|
|
557
|
+
sender_display_name: envelope.sender_display_name,
|
|
558
|
+
sender_machine: envelope.from_machine,
|
|
559
|
+
recipient_instance_id: envelope.recipient_instance_id,
|
|
560
|
+
recipient_display_name: envelope.recipient_display_name,
|
|
561
|
+
created_at: envelope.created_at,
|
|
562
|
+
body: envelope.body,
|
|
563
|
+
body_bytes: envelope.body_bytes,
|
|
564
|
+
};
|
|
565
|
+
try {
|
|
566
|
+
if (inbox.receive(record) === "received") result.received++;
|
|
567
|
+
return true;
|
|
568
|
+
} catch (error) {
|
|
569
|
+
if (error instanceof HarneryInboxCapacityError && error.reason_code !== "message_body_limit") {
|
|
570
|
+
return false;
|
|
571
|
+
}
|
|
572
|
+
result.rejected++;
|
|
573
|
+
return true;
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
export interface RemoteSender {
|
|
578
|
+
instance_id: string;
|
|
579
|
+
name: string;
|
|
580
|
+
machine: string;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/** Senders this machine has received mail from, so a reply can find them even
|
|
584
|
+
* after their machine's presence goes quiet. */
|
|
585
|
+
export function knownRemoteSenders(coordRoot: string): RemoteSender[] {
|
|
586
|
+
return [...projectMail(readMailLog(coordRoot)).senders].map(([instance_id, s]) => ({
|
|
587
|
+
instance_id,
|
|
588
|
+
name: s.name,
|
|
589
|
+
machine: s.machine,
|
|
590
|
+
}));
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
export interface MailStatus {
|
|
594
|
+
machine: string;
|
|
595
|
+
outbox: Array<{
|
|
596
|
+
message_id: string;
|
|
597
|
+
to_machine: string;
|
|
598
|
+
recipient: string;
|
|
599
|
+
queued_at: string;
|
|
600
|
+
}>;
|
|
601
|
+
acks: number;
|
|
602
|
+
delivered: number;
|
|
603
|
+
expired: number;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
export function mailStatus(coordRoot: string): MailStatus {
|
|
607
|
+
const projection = projectMail(readMailLog(coordRoot));
|
|
608
|
+
return {
|
|
609
|
+
machine: mailMachine(),
|
|
610
|
+
outbox: projection.outbox.map(({ at, envelope }) => ({
|
|
611
|
+
message_id: envelope.message_id,
|
|
612
|
+
to_machine: envelope.to_machine,
|
|
613
|
+
recipient: envelope.recipient_display_name,
|
|
614
|
+
queued_at: at,
|
|
615
|
+
})),
|
|
616
|
+
acks: projection.acks.size,
|
|
617
|
+
delivered: projection.delivered,
|
|
618
|
+
expired: projection.expired,
|
|
619
|
+
};
|
|
620
|
+
}
|
|
@@ -5,7 +5,7 @@ import { basename, join } from "node:path";
|
|
|
5
5
|
import { performance } from "node:perf_hooks";
|
|
6
6
|
import { readLiveCoordinationRows } from "../agents/state/live-coordination-view.ts";
|
|
7
7
|
import { parsePidmapRow } from "../agents/state/pidmap.ts";
|
|
8
|
-
import {
|
|
8
|
+
import { verifyProcessGeneration } from "../agents/state/proc-start.ts";
|
|
9
9
|
import {
|
|
10
10
|
RESOURCE_SNAPSHOT_SCHEMA_VERSION,
|
|
11
11
|
type ResourceProcessGroup,
|
|
@@ -386,7 +386,8 @@ function readOwnershipAnchors(
|
|
|
386
386
|
if (!byPid.has(pid)) continue;
|
|
387
387
|
try {
|
|
388
388
|
const row = parsePidmapRow(readFileSync(join(directory, entry), "utf8"));
|
|
389
|
-
if (!row.instanceId ||
|
|
389
|
+
if (!row.instanceId || verifyProcessGeneration(pid, row.startToken) === "different")
|
|
390
|
+
continue;
|
|
390
391
|
anchors.set(pid, { kind: "agent", id: row.instanceId, source: "pid-map" });
|
|
391
392
|
} catch {
|
|
392
393
|
// An unreadable or racing row cannot prove ownership.
|