approval-md 0.3.0 → 0.4.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/README.md +83 -914
- package/SPEC.md +101 -13
- package/cli.js +134 -7
- package/dist/src/adapters/smtp.d.ts +18 -0
- package/dist/src/adapters/smtp.js +24 -3
- package/dist/src/adapters/smtp.js.map +1 -1
- package/dist/src/channels/contract.d.ts +72 -0
- package/dist/src/channels/contract.js +161 -1
- package/dist/src/channels/contract.js.map +1 -1
- package/dist/src/channels/relay-server.d.ts +90 -0
- package/dist/src/channels/relay-server.js +295 -0
- package/dist/src/channels/relay-server.js.map +1 -0
- package/dist/src/channels/relay.d.ts +273 -0
- package/dist/src/channels/relay.js +564 -0
- package/dist/src/channels/relay.js.map +1 -0
- package/dist/src/channels/tagging.d.ts +25 -0
- package/dist/src/channels/tagging.js +63 -6
- package/dist/src/channels/tagging.js.map +1 -1
- package/dist/src/channels/telegram-minimal.d.ts +251 -0
- package/dist/src/channels/telegram-minimal.js +618 -0
- package/dist/src/channels/telegram-minimal.js.map +1 -0
- package/dist/src/channels/telegram-webhook.d.ts +273 -0
- package/dist/src/channels/telegram-webhook.js +536 -0
- package/dist/src/channels/telegram-webhook.js.map +1 -0
- package/dist/src/channels/telegram.d.ts +725 -20
- package/dist/src/channels/telegram.js +1332 -123
- package/dist/src/channels/telegram.js.map +1 -1
- package/dist/src/cli/amend.js +358 -5
- package/dist/src/cli/amend.js.map +1 -1
- package/dist/src/cli/attest.js +43 -6
- package/dist/src/cli/attest.js.map +1 -1
- package/dist/src/cli/audit-card.d.ts +1 -1
- package/dist/src/cli/audit-card.js +40 -1
- package/dist/src/cli/audit-card.js.map +1 -1
- package/dist/src/cli/audit.d.ts +7 -5
- package/dist/src/cli/audit.js +22 -10
- package/dist/src/cli/audit.js.map +1 -1
- package/dist/src/cli/channel-relay.d.ts +108 -0
- package/dist/src/cli/channel-relay.js +334 -0
- package/dist/src/cli/channel-relay.js.map +1 -0
- package/dist/src/cli/channel-telegram-webhook.d.ts +275 -0
- package/dist/src/cli/channel-telegram-webhook.js +716 -0
- package/dist/src/cli/channel-telegram-webhook.js.map +1 -0
- package/dist/src/cli/channel-telegram.d.ts +347 -28
- package/dist/src/cli/channel-telegram.js +648 -56
- package/dist/src/cli/channel-telegram.js.map +1 -1
- package/dist/src/cli/channel.d.ts +1 -1
- package/dist/src/cli/channel.js +5 -1
- package/dist/src/cli/channel.js.map +1 -1
- package/dist/src/cli/daemon.js +37 -5
- package/dist/src/cli/daemon.js.map +1 -1
- package/dist/src/cli/doctor.d.ts +63 -0
- package/dist/src/cli/doctor.js +856 -111
- package/dist/src/cli/doctor.js.map +1 -1
- package/dist/src/cli/execute.d.ts +29 -1
- package/dist/src/cli/execute.js +387 -21
- package/dist/src/cli/execute.js.map +1 -1
- package/dist/src/cli/exit-codes.d.ts +12 -0
- package/dist/src/cli/exit-codes.js +13 -0
- package/dist/src/cli/exit-codes.js.map +1 -1
- package/dist/src/cli/gate.d.ts +2 -0
- package/dist/src/cli/gate.js +233 -2
- package/dist/src/cli/gate.js.map +1 -1
- package/dist/src/cli/help.d.ts +10 -1
- package/dist/src/cli/help.js +283 -54
- package/dist/src/cli/help.js.map +1 -1
- package/dist/src/cli/hook.d.ts +485 -6
- package/dist/src/cli/hook.js +2370 -70
- package/dist/src/cli/hook.js.map +1 -1
- package/dist/src/cli/init.js +16 -7
- package/dist/src/cli/init.js.map +1 -1
- package/dist/src/cli/instructions.js +24 -3
- package/dist/src/cli/instructions.js.map +1 -1
- package/dist/src/cli/log-anchor.d.ts +55 -0
- package/dist/src/cli/log-anchor.js +199 -60
- package/dist/src/cli/log-anchor.js.map +1 -1
- package/dist/src/cli/log-unlock.d.ts +23 -0
- package/dist/src/cli/log-unlock.js +109 -0
- package/dist/src/cli/log-unlock.js.map +1 -0
- package/dist/src/cli/main.d.ts +0 -12
- package/dist/src/cli/main.js +72 -4
- package/dist/src/cli/main.js.map +1 -1
- package/dist/src/cli/muse.d.ts +2 -0
- package/dist/src/cli/muse.js +62 -0
- package/dist/src/cli/muse.js.map +1 -0
- package/dist/src/cli/payload.js +79 -1
- package/dist/src/cli/payload.js.map +1 -1
- package/dist/src/cli/scaffold.js +6 -0
- package/dist/src/cli/scaffold.js.map +1 -1
- package/dist/src/cli/serve.d.ts +53 -0
- package/dist/src/cli/serve.js +270 -0
- package/dist/src/cli/serve.js.map +1 -0
- package/dist/src/cli/up.js +26 -2
- package/dist/src/cli/up.js.map +1 -1
- package/dist/src/cli/verb-registry.d.ts +2 -0
- package/dist/src/cli/verb-registry.js +338 -39
- package/dist/src/cli/verb-registry.js.map +1 -1
- package/dist/src/cli/wordmark.d.ts +2 -2
- package/dist/src/cli/wordmark.js +2 -2
- package/dist/src/core/attest-bootstrap.d.ts +95 -0
- package/dist/src/core/attest-bootstrap.js +135 -0
- package/dist/src/core/attest-bootstrap.js.map +1 -0
- package/dist/src/core/attest.d.ts +20 -3
- package/dist/src/core/attest.js +25 -0
- package/dist/src/core/attest.js.map +1 -1
- package/dist/src/core/audit.d.ts +153 -12
- package/dist/src/core/audit.js +415 -20
- package/dist/src/core/audit.js.map +1 -1
- package/dist/src/core/channel-lease.d.ts +322 -0
- package/dist/src/core/channel-lease.js +663 -0
- package/dist/src/core/channel-lease.js.map +1 -0
- package/dist/src/core/command-class.d.ts +51 -0
- package/dist/src/core/command-class.js +766 -8
- package/dist/src/core/command-class.js.map +1 -1
- package/dist/src/core/daemon-host.d.ts +178 -0
- package/dist/src/core/daemon-host.js +228 -0
- package/dist/src/core/daemon-host.js.map +1 -0
- package/dist/src/core/daemon-identity.d.ts +238 -0
- package/dist/src/core/daemon-identity.js +252 -0
- package/dist/src/core/daemon-identity.js.map +1 -0
- package/dist/src/core/gate.d.ts +364 -6
- package/dist/src/core/gate.js +802 -27
- package/dist/src/core/gate.js.map +1 -1
- package/dist/src/core/harness-version.d.ts +128 -4
- package/dist/src/core/harness-version.js +179 -12
- package/dist/src/core/harness-version.js.map +1 -1
- package/dist/src/core/harness-wait.d.ts +82 -3
- package/dist/src/core/harness-wait.js +89 -3
- package/dist/src/core/harness-wait.js.map +1 -1
- package/dist/src/core/log-lock.d.ts +435 -0
- package/dist/src/core/log-lock.js +1093 -0
- package/dist/src/core/log-lock.js.map +1 -0
- package/dist/src/core/log-subscribe.d.ts +21 -0
- package/dist/src/core/log-subscribe.js +16 -6
- package/dist/src/core/log-subscribe.js.map +1 -1
- package/dist/src/core/log-write-layer.d.ts +55 -0
- package/dist/src/core/log-write-layer.js +91 -0
- package/dist/src/core/log-write-layer.js.map +1 -0
- package/dist/src/core/log.d.ts +130 -22
- package/dist/src/core/log.js +473 -50
- package/dist/src/core/log.js.map +1 -1
- package/dist/src/core/payload-census.d.ts +51 -0
- package/dist/src/core/payload-census.js +125 -2
- package/dist/src/core/payload-census.js.map +1 -1
- package/dist/src/core/payload-store.d.ts +31 -0
- package/dist/src/core/payload-store.js +113 -8
- package/dist/src/core/payload-store.js.map +1 -1
- package/dist/src/core/payload.d.ts +18 -3
- package/dist/src/core/payload.js +20 -4
- package/dist/src/core/payload.js.map +1 -1
- package/dist/src/core/policy-diff.d.ts +10 -0
- package/dist/src/core/policy-diff.js +10 -2
- package/dist/src/core/policy-diff.js.map +1 -1
- package/dist/src/core/policy-explain.d.ts +7 -1
- package/dist/src/core/policy-explain.js +16 -1
- package/dist/src/core/policy-explain.js.map +1 -1
- package/dist/src/core/policy-load.d.ts +24 -0
- package/dist/src/core/policy-load.js.map +1 -1
- package/dist/src/core/policy-match.d.ts +38 -0
- package/dist/src/core/policy-match.js +35 -0
- package/dist/src/core/policy-match.js.map +1 -1
- package/dist/src/core/policy-proposal.d.ts +40 -0
- package/dist/src/core/policy-proposal.js +37 -16
- package/dist/src/core/policy-proposal.js.map +1 -1
- package/dist/src/core/prompt-layout.d.ts +186 -1
- package/dist/src/core/prompt-layout.js +345 -3
- package/dist/src/core/prompt-layout.js.map +1 -1
- package/dist/src/core/read-scope.d.ts +10 -0
- package/dist/src/core/read-scope.js +4 -0
- package/dist/src/core/read-scope.js.map +1 -1
- package/dist/src/core/run-payload.d.ts +160 -0
- package/dist/src/core/run-payload.js +304 -0
- package/dist/src/core/run-payload.js.map +1 -0
- package/dist/src/core/sender-identity.d.ts +59 -11
- package/dist/src/core/sender-identity.js +79 -11
- package/dist/src/core/sender-identity.js.map +1 -1
- package/dist/src/core/state.d.ts +52 -1
- package/dist/src/core/state.js +52 -6
- package/dist/src/core/state.js.map +1 -1
- package/dist/src/core/telegram-config.d.ts +79 -0
- package/dist/src/core/telegram-config.js +108 -0
- package/dist/src/core/telegram-config.js.map +1 -1
- package/dist/src/core/validate.d.ts +20 -6
- package/dist/src/core/validate.js +0 -0
- package/dist/src/core/validate.js.map +1 -1
- package/dist/src/core/verify.d.ts +23 -1
- package/dist/src/core/verify.js +20 -2
- package/dist/src/core/verify.js.map +1 -1
- package/dist/src/daemon/audit.d.ts +80 -5
- package/dist/src/daemon/audit.js +126 -6
- package/dist/src/daemon/audit.js.map +1 -1
- package/dist/src/daemon/daemon.d.ts +174 -2
- package/dist/src/daemon/daemon.js +298 -7
- package/dist/src/daemon/daemon.js.map +1 -1
- package/dist/src/daemon/git-evidence.d.ts +2 -2
- package/dist/src/daemon/git-evidence.js +1 -1
- package/dist/src/daemon/projection.d.ts +13 -2
- package/dist/src/daemon/projection.js +39 -6
- package/dist/src/daemon/projection.js.map +1 -1
- package/dist/src/mcp/server.d.ts +39 -3
- package/dist/src/mcp/server.js +22 -3
- package/dist/src/mcp/server.js.map +1 -1
- package/dist/src/muse/server.d.ts +16 -0
- package/dist/src/muse/server.js +238 -0
- package/dist/src/muse/server.js.map +1 -0
- package/dist/src/serve/archive.d.ts +166 -0
- package/dist/src/serve/archive.js +363 -0
- package/dist/src/serve/archive.js.map +1 -0
- package/dist/src/serve/arguments.d.ts +93 -0
- package/dist/src/serve/arguments.js +228 -0
- package/dist/src/serve/arguments.js.map +1 -0
- package/dist/src/serve/credentials.d.ts +91 -0
- package/dist/src/serve/credentials.js +143 -0
- package/dist/src/serve/credentials.js.map +1 -0
- package/dist/src/serve/follow.d.ts +86 -0
- package/dist/src/serve/follow.js +101 -0
- package/dist/src/serve/follow.js.map +1 -0
- package/dist/src/serve/hook-thread.d.ts +134 -0
- package/dist/src/serve/hook-thread.js +333 -0
- package/dist/src/serve/hook-thread.js.map +1 -0
- package/dist/src/serve/hook-worker.d.ts +134 -0
- package/dist/src/serve/hook-worker.js +173 -0
- package/dist/src/serve/hook-worker.js.map +1 -0
- package/dist/src/serve/server.d.ts +412 -0
- package/dist/src/serve/server.js +1251 -0
- package/dist/src/serve/server.js.map +1 -0
- package/docs/README-extended.md +768 -0
- package/docs/cli-reference.md +2139 -67
- package/package.json +4 -2
- package/schema/event.schema.json +413 -30
- package/schema/fixtures/event/invalid/audit-lock-reclaimed-age-without-created.json +19 -0
- package/schema/fixtures/event/invalid/audit-lock-reclaimed-agent-actor.json +14 -0
- package/schema/fixtures/event/invalid/audit-lock-reclaimed-lockfile-path.json +14 -0
- package/schema/fixtures/event/invalid/audit-lock-reclaimed-operator-cleared-system-actor.json +14 -0
- package/schema/fixtures/event/invalid/audit-lock-reclaimed-unknown-reason.json +14 -0
- package/schema/fixtures/event/invalid/audit-lock-reclaimed-without-age.json +13 -0
- package/schema/fixtures/event/invalid/audit-reviewed-agent-actor.json +6 -1
- package/schema/fixtures/event/invalid/audit-reviewed-bad-payload-hash.json +21 -0
- package/schema/fixtures/event/invalid/audit-reviewed-loved-no-note.json +5 -0
- package/schema/fixtures/event/invalid/audit-reviewed-no-subject-hash.json +19 -0
- package/schema/fixtures/event/invalid/audit-reviewed-no-subject-seq.json +19 -0
- package/schema/fixtures/event/invalid/audit-reviewed-no-verdict-source.json +19 -0
- package/schema/fixtures/event/invalid/audit-reviewed-no-verdict.json +19 -0
- package/schema/fixtures/event/invalid/audit-reviewed-system-actor.json +6 -1
- package/schema/fixtures/event/invalid/audit-sampled-no-policy-hash.json +14 -0
- package/schema/fixtures/event/invalid/daemon-id-malformed.json +15 -0
- package/schema/fixtures/event/invalid/execution-started-truncated-policy-sha256.json +18 -0
- package/schema/fixtures/event/invalid/harness-cap-at-margin.json +16 -0
- package/schema/fixtures/event/invalid/harness-cap-fractional.json +16 -0
- package/schema/fixtures/event/invalid/harness-cap-negative.json +16 -0
- package/schema/fixtures/event/invalid/harness-cap-string.json +16 -0
- package/schema/fixtures/event/invalid/harness-cap-without-harness-execution.json +16 -0
- package/schema/fixtures/event/invalid/harness-cap-zero.json +16 -0
- package/schema/fixtures/event/valid/approval-requested-harness-cap.json +18 -0
- package/schema/fixtures/event/valid/audit-lock-reclaimed-implausible-created.json +18 -0
- package/schema/fixtures/event/valid/audit-lock-reclaimed-legacy.json +14 -0
- package/schema/fixtures/event/valid/audit-lock-reclaimed-unlock.json +19 -0
- package/schema/fixtures/event/valid/audit-lock-reclaimed.json +19 -0
- package/schema/fixtures/event/valid/audit-reviewed-reaction.json +5 -0
- package/schema/fixtures/event/valid/audit-reviewed.json +6 -1
- package/schema/fixtures/event/valid/audit-sampled.json +2 -1
- package/schema/fixtures/event/valid/daemon-identity-declared.json +17 -0
- package/schema/fixtures/event/valid/daemon-identity.json +16 -0
- package/schema/fixtures/event/valid/execution-started-policy-sha256.json +18 -0
- package/schema/fixtures/event/valid/harness-kind-hermes.json +23 -0
- package/schema/fixtures/policy/invalid/daemons-malformed-id.json +10 -0
- package/schema/fixtures/policy/valid/daemons-allowlist.json +19 -0
- package/schema/fixtures/policy-md/invalid/schema-invalid-agent-may-request.md +21 -0
- package/schema/fixtures/policy-md/valid/agent-may-request.md +24 -0
- package/schema/policy.schema.json +102 -6
package/dist/src/core/log.js
CHANGED
|
@@ -42,15 +42,53 @@
|
|
|
42
42
|
* canonicalization minus the `hash` field. One scheme, two inputs — a
|
|
43
43
|
* verifier strips `hash` and re-derives. Appended with a single `write(2)`
|
|
44
44
|
* on a handle opened `O_APPEND`.
|
|
45
|
+
* 5b. **Durable before ok (APRV-440).** The write is followed by `fsync(2)` on
|
|
46
|
+
* the same descriptor, and only then does the append report ok. When this
|
|
47
|
+
* append created the file, the log directory is fsynced too (and the parent
|
|
48
|
+
* of every directory this call created), so the new name survives along with
|
|
49
|
+
* its bytes. Without this, an acknowledged record lived in the page cache
|
|
50
|
+
* only: a platform kill before writeback left the file extended over blocks
|
|
51
|
+
* that were never written, which reads back as a NUL-filled unterminated tail
|
|
52
|
+
* (observed on a hosted tenant on 2026-09-25: an acknowledged attestation
|
|
53
|
+
* lost, 456 NUL bytes in its place). Atomicity against concurrent writers
|
|
54
|
+
* was guarantee 5 all along; durability against the machine dying is this one.
|
|
55
|
+
*
|
|
56
|
+
* 6. **The writing daemon names itself, or is refused (APRV-383).** A record
|
|
57
|
+
* appended by a declaring daemon process carries `daemon`, the id of the
|
|
58
|
+
* instance that wrote it, so a tenant reading a log hosted by somebody else can
|
|
59
|
+
* tell which daemon acted on their behalf. The id comes from this process's own
|
|
60
|
+
* state (`core/daemon-identity.ts`) and never from the caller, and where the
|
|
61
|
+
* attested policy lists the ids that may write, an unlisted one is refused here
|
|
62
|
+
* with nothing written. Every earlier record, carrying no such field, validates
|
|
63
|
+
* and verifies unchanged.
|
|
64
|
+
*
|
|
65
|
+
* 7. **A lock whose holder is provably gone is taken back, and the log says so
|
|
66
|
+
* (APRV-479).** The lockfile carries its holder's record. A writer that has
|
|
67
|
+
* waited out its whole lock timeout judges the holder once, and only a holder
|
|
68
|
+
* provably gone (a dead pid in this process's own pid namespace and boot, or
|
|
69
|
+
* an empty lockfile ten minutes old) loses the lock: the writer claims it
|
|
70
|
+
* with one exclusive `link(2)`, takes it with one `rename(2)` of its own
|
|
71
|
+
* lockfile over the lock's path, and appends `audit.lock_reclaimed` as the
|
|
72
|
+
* first record under it. A live holder's lock, and any holder this process
|
|
73
|
+
* cannot check, is never taken; the refusal names the human verb
|
|
74
|
+
* `approval log unlock`. `core/log-lock.ts` holds the rules.
|
|
45
75
|
*
|
|
46
76
|
* Determinism: `ts` is supplied by the caller. This module never reads the
|
|
47
77
|
* clock, because a hash-relevant field sourced from ambient state would make
|
|
48
|
-
* the log irreproducible.
|
|
78
|
+
* the log irreproducible. The one exception is the record this module writes on
|
|
79
|
+
* its own behalf, the `audit.lock_reclaimed` of guarantee 7: no caller authors
|
|
80
|
+
* it, and its `ts` is assigned at the write boundary like any audit record's.
|
|
81
|
+
* `daemon` is ambient in the same sense and is not a counter-example: a log is reproducible per WRITER, the field says which writer,
|
|
82
|
+
* and a verifier re-derives the hash from the record's own bytes either way.
|
|
49
83
|
*/
|
|
50
84
|
import { createHash } from "node:crypto";
|
|
51
|
-
import { closeSync, fstatSync, mkdirSync, openSync, readSync,
|
|
52
|
-
import { dirname } from "node:path";
|
|
85
|
+
import { closeSync, constants as fsConstants, existsSync, fstatSync, mkdirSync, openSync, readSync, } from "node:fs";
|
|
86
|
+
import { basename, dirname } from "node:path";
|
|
87
|
+
import { DAEMON_APPEND_REFUSAL_CODES, daemonStampForAppend, } from "./daemon-identity.js";
|
|
88
|
+
import { systemClock } from "./clock.js";
|
|
53
89
|
import { canonicalize, JcsError } from "./jcs.js";
|
|
90
|
+
import { createLockFile, finishReclaim, guardTerminationWhileLocked, observeLock, reclaimStaleLock, releaseLockFile, settleTerminationGuards, takeLockForUnlock, } from "./log-lock.js";
|
|
91
|
+
import { appendWriteLayer } from "./log-write-layer.js";
|
|
54
92
|
import { validate } from "./validate.js";
|
|
55
93
|
/** SPEC.md §8: the hash scheme identifier stamped on every v0.1 record. */
|
|
56
94
|
export const ALG = "sha256/jcs";
|
|
@@ -85,9 +123,24 @@ export const APPEND_ERROR_CODES = [
|
|
|
85
123
|
* caller made is stale. Nothing was written.
|
|
86
124
|
*/
|
|
87
125
|
"head-moved",
|
|
126
|
+
/**
|
|
127
|
+
* The two identity refusals of APRV-383, spread in from
|
|
128
|
+
* `core/daemon-identity.ts` where the logic that emits them lives: a daemon that
|
|
129
|
+
* declared an unusable id, and a daemon whose id the attested policy's `daemons`
|
|
130
|
+
* list does not admit. Both leave the file byte-identical, like every member
|
|
131
|
+
* above them, and both are additions to the closed union rather than renames of
|
|
132
|
+
* anything in it.
|
|
133
|
+
*/
|
|
134
|
+
...DAEMON_APPEND_REFUSAL_CODES,
|
|
88
135
|
];
|
|
89
|
-
|
|
90
|
-
|
|
136
|
+
/**
|
|
137
|
+
* How long an append waits for `<log>.lock` before refusing `lock-timeout`, and
|
|
138
|
+
* how often it tries. Exported (APRV-478) so a caller that waits for the lock
|
|
139
|
+
* itself, on the event loop rather than in {@link acquireLock}'s synchronous
|
|
140
|
+
* retry, keeps the same bound and cadence as every other writer.
|
|
141
|
+
*/
|
|
142
|
+
export const DEFAULT_LOCK_TIMEOUT_MS = 2_000;
|
|
143
|
+
export const DEFAULT_LOCK_RETRY_MS = 20;
|
|
91
144
|
/**
|
|
92
145
|
* Strip `hash` and canonicalize. Exported shape is documented on
|
|
93
146
|
* {@link computeRecordHash}; kept separate so hashing and verification cannot
|
|
@@ -138,22 +191,124 @@ function sleepSync(ms) {
|
|
|
138
191
|
const buffer = new Int32Array(new SharedArrayBuffer(4));
|
|
139
192
|
Atomics.wait(buffer, 0, 0, ms);
|
|
140
193
|
}
|
|
194
|
+
/** `O_APPEND` writer flags; `O_EXCL` added for the first try, so creation is known. */
|
|
195
|
+
const APPEND_FLAGS = fsConstants.O_WRONLY | fsConstants.O_APPEND | fsConstants.O_CREAT;
|
|
196
|
+
/** The mode Node's `"a"` opens with; the umask applies as it always did. */
|
|
197
|
+
const APPEND_MODE = 0o666;
|
|
198
|
+
/**
|
|
199
|
+
* Open `logPath` for append and say whether this call created it. `O_EXCL`
|
|
200
|
+
* first: under the append lock no other sanctioned writer can race the create, so
|
|
201
|
+
* `EEXIST` means the file was already there and a plain append open follows.
|
|
202
|
+
*/
|
|
203
|
+
function openForAppend(logPath) {
|
|
204
|
+
const writeLayer = appendWriteLayer();
|
|
205
|
+
try {
|
|
206
|
+
return { fd: writeLayer.open(logPath, APPEND_FLAGS | fsConstants.O_EXCL, APPEND_MODE), created: true };
|
|
207
|
+
}
|
|
208
|
+
catch (cause) {
|
|
209
|
+
if (cause.code !== "EEXIST")
|
|
210
|
+
throw cause;
|
|
211
|
+
}
|
|
212
|
+
return { fd: writeLayer.open(logPath, APPEND_FLAGS, APPEND_MODE), created: false };
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* The errors a directory fsync returns on a filesystem that cannot sync a
|
|
216
|
+
* directory at all (some FUSE, SMB and drvfs mounts). PostgreSQL ignores the
|
|
217
|
+
* same set for the same reason: there, every first append would otherwise
|
|
218
|
+
* report io with record 1 already in the file, inviting a re-run and a
|
|
219
|
+
* duplicate. EIO and everything else still fail the append.
|
|
220
|
+
*/
|
|
221
|
+
const DIRECTORY_FSYNC_UNSUPPORTED = new Set(["EINVAL", "EBADF", "ENOTSUP", "EOPNOTSUPP"]);
|
|
222
|
+
/**
|
|
223
|
+
* fsync a directory, so a name created in it survives a crash. Windows cannot
|
|
224
|
+
* open a directory for this and its filesystems journal names themselves, so it
|
|
225
|
+
* is skipped there, as is a filesystem that reports directory fsync as
|
|
226
|
+
* unsupported; everywhere else a failure is the caller's to report.
|
|
227
|
+
*/
|
|
228
|
+
function fsyncDirectory(dir) {
|
|
229
|
+
if (process.platform === "win32")
|
|
230
|
+
return;
|
|
231
|
+
const writeLayer = appendWriteLayer();
|
|
232
|
+
const fd = writeLayer.open(dir, fsConstants.O_RDONLY, 0);
|
|
233
|
+
try {
|
|
234
|
+
writeLayer.fsync(fd);
|
|
235
|
+
}
|
|
236
|
+
catch (cause) {
|
|
237
|
+
if (!DIRECTORY_FSYNC_UNSUPPORTED.has(cause.code ?? ""))
|
|
238
|
+
throw cause;
|
|
239
|
+
}
|
|
240
|
+
finally {
|
|
241
|
+
try {
|
|
242
|
+
writeLayer.close(fd);
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
// The fsync above is what mattered; a failed close of a read-only
|
|
246
|
+
// directory handle changes nothing on disk.
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* The directories whose entries a first append made new: the log's own
|
|
252
|
+
* directory (which gained the file), and, when `withAppendLock` had to create
|
|
253
|
+
* directories, the parent of each one it created, up to and including the
|
|
254
|
+
* parent of the topmost. Ordered innermost first.
|
|
255
|
+
*/
|
|
256
|
+
function directoriesToSync(logPath, firstCreatedDir) {
|
|
257
|
+
const dirs = [dirname(logPath)];
|
|
258
|
+
if (firstCreatedDir === undefined)
|
|
259
|
+
return dirs;
|
|
260
|
+
const stop = dirname(firstCreatedDir);
|
|
261
|
+
for (let dir = dirs[0]; dir !== stop;) {
|
|
262
|
+
const parent = dirname(dir);
|
|
263
|
+
if (parent === dir)
|
|
264
|
+
break; // the filesystem root: nothing above to sync
|
|
265
|
+
dirs.push(parent);
|
|
266
|
+
dir = parent;
|
|
267
|
+
}
|
|
268
|
+
return dirs;
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* How many times one wait may find, at its end, that the lock moved or that
|
|
272
|
+
* another writer is reclaiming it, and wait again, before it refuses.
|
|
273
|
+
*/
|
|
274
|
+
const MAX_RECLAIM_ROUNDS = 3;
|
|
141
275
|
/**
|
|
142
276
|
* Acquire `<logPath>.lock` by `open(…, "wx")` — atomic create-or-fail, which
|
|
143
|
-
* needs no dependency and works on every platform Node supports
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
277
|
+
* needs no dependency and works on every platform Node supports — and write
|
|
278
|
+
* this process's holder record into it (APRV-479). Retries with a fixed delay
|
|
279
|
+
* until `timeoutMs` elapses.
|
|
280
|
+
*
|
|
281
|
+
* A lock is never stolen from a holder that may be alive: silently breaking
|
|
282
|
+
* someone else's lock is how two writers end up sharing a `seq`. Only once the
|
|
283
|
+
* whole wait has passed does the writer judge the lock, once
|
|
284
|
+
* ({@link reclaimStaleLock}): a holder that is provably gone loses the lock to
|
|
285
|
+
* one atomic claim and take, and this writer then holds it with the reclaim to
|
|
286
|
+
* record first; a lock that moved, or that another writer is reclaiming, sends
|
|
287
|
+
* it back to wait again (at most {@link MAX_RECLAIM_ROUNDS} times); anything
|
|
288
|
+
* else is `lock-timeout`, whose message names the holder, why its lock was
|
|
289
|
+
* kept, and the human command.
|
|
290
|
+
*
|
|
291
|
+
* The termination-signal guard ({@link guardTerminationWhileLocked}) is
|
|
292
|
+
* installed BEFORE each create, so a lockfile never exists in this process
|
|
293
|
+
* without it, and is released and settled at once when the create fails, so
|
|
294
|
+
* the wait runs without it (a signal there kills the process holding nothing).
|
|
147
295
|
*/
|
|
148
|
-
function acquireLock(logPath, timeoutMs, retryMs) {
|
|
296
|
+
function acquireLock(logPath, timeoutMs, retryMs, op) {
|
|
149
297
|
const path = `${logPath}.lock`;
|
|
150
|
-
|
|
298
|
+
let deadline = Date.now() + timeoutMs;
|
|
299
|
+
let rounds = 0;
|
|
300
|
+
let kept;
|
|
301
|
+
// The lockfile as first refused in this wait (APRV-479 R3-8): an empty one
|
|
302
|
+
// is taken only if it is still that file when the wait ends.
|
|
303
|
+
let watched;
|
|
151
304
|
for (;;) {
|
|
305
|
+
const releaseGuard = guardTerminationWhileLocked();
|
|
152
306
|
try {
|
|
153
|
-
|
|
154
|
-
return { ok: true, path };
|
|
307
|
+
return { ok: true, own: createLockFile(path, op), releaseGuard, reclaimed: null };
|
|
155
308
|
}
|
|
156
309
|
catch (cause) {
|
|
310
|
+
releaseGuard();
|
|
311
|
+
settleTerminationGuards();
|
|
157
312
|
const code = cause.code;
|
|
158
313
|
if (code !== "EEXIST") {
|
|
159
314
|
return {
|
|
@@ -161,26 +316,73 @@ function acquireLock(logPath, timeoutMs, retryMs) {
|
|
|
161
316
|
error: { code: "io", message: `lockfile ${path} could not be created: ${errorMessage(cause)}` },
|
|
162
317
|
};
|
|
163
318
|
}
|
|
164
|
-
if (
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
code: "lock-timeout",
|
|
169
|
-
message: `another writer holds ${path}; gave up after ${timeoutMs}ms`,
|
|
170
|
-
},
|
|
171
|
-
};
|
|
172
|
-
}
|
|
319
|
+
if (watched === undefined)
|
|
320
|
+
watched = observeLock(logPath);
|
|
321
|
+
}
|
|
322
|
+
if (Date.now() < deadline) {
|
|
173
323
|
sleepSync(retryMs);
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
if (rounds < MAX_RECLAIM_ROUNDS) {
|
|
327
|
+
rounds += 1;
|
|
328
|
+
const outcome = reclaimStaleLock(logPath, op, {
|
|
329
|
+
recordValid: reclaimRecordValid,
|
|
330
|
+
recordContext: () => reclaimRecordContext(logPath),
|
|
331
|
+
watched: watched ?? null,
|
|
332
|
+
});
|
|
333
|
+
if (outcome.kind === "taken") {
|
|
334
|
+
return { ok: true, own: outcome.own, releaseGuard: outcome.releaseGuard, reclaimed: outcome.note };
|
|
335
|
+
}
|
|
336
|
+
kept = outcome.why;
|
|
337
|
+
if (outcome.kind === "retry") {
|
|
338
|
+
deadline = Date.now() + timeoutMs;
|
|
339
|
+
watched = undefined;
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
174
342
|
}
|
|
343
|
+
const holder = kept === undefined ? "" : ` (${kept})`;
|
|
344
|
+
return {
|
|
345
|
+
ok: false,
|
|
346
|
+
error: {
|
|
347
|
+
code: "lock-timeout",
|
|
348
|
+
// A zero timeout is a single try by request (APRV-478: a caller that
|
|
349
|
+
// waits for the lock on its own event loop), and "gave up after 0ms"
|
|
350
|
+
// would read as a writer that never tried.
|
|
351
|
+
message: timeoutMs <= 0
|
|
352
|
+
? `another writer holds ${path}; this append tried the lock once, as its caller asked, and did not wait${holder}`
|
|
353
|
+
: `another writer holds ${path}; gave up after ${timeoutMs}ms${holder}`,
|
|
354
|
+
},
|
|
355
|
+
};
|
|
175
356
|
}
|
|
176
357
|
}
|
|
177
|
-
|
|
358
|
+
/**
|
|
359
|
+
* Is `<logPath>.lock` present right now (APRV-478)?
|
|
360
|
+
*
|
|
361
|
+
* ADVISORY, and nothing else. It takes no lock, writes nothing, and authorizes
|
|
362
|
+
* nothing: the answer can be stale the instant it is returned. It exists so a
|
|
363
|
+
* caller that must not block its event loop (a harness hook between a grant and
|
|
364
|
+
* its spend, which has to stay answerable to a signal) can wait out another
|
|
365
|
+
* writer by sleeping on a timer, and only then make its one synchronous attempt
|
|
366
|
+
* (`lockTimeoutMs: 0`). That attempt is still {@link acquireLock}'s atomic
|
|
367
|
+
* create-or-fail, and the head is still compared under the lock, so a probe
|
|
368
|
+
* that said "free" and was wrong costs a refused attempt and never a shared
|
|
369
|
+
* `seq`. A probe that cannot stat the path answers `false`, and the attempt
|
|
370
|
+
* then meets the real error.
|
|
371
|
+
*/
|
|
372
|
+
export function appendLockHeld(logPath) {
|
|
373
|
+
return existsSync(`${logPath}.lock`);
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* Remove this process's own lockfile, and only it (APRV-479: a lockfile that is
|
|
377
|
+
* not the one this process wrote is somebody else's). Best effort: the lock is
|
|
378
|
+
* advisory and a failed unlink must not mask the append's own result.
|
|
379
|
+
*/
|
|
380
|
+
function releaseLock(own) {
|
|
178
381
|
try {
|
|
179
|
-
|
|
382
|
+
releaseLockFile(own);
|
|
180
383
|
}
|
|
181
384
|
catch {
|
|
182
|
-
//
|
|
183
|
-
// append's own result.
|
|
385
|
+
// As above.
|
|
184
386
|
}
|
|
185
387
|
}
|
|
186
388
|
/**
|
|
@@ -353,7 +555,8 @@ function readTail(logPath) {
|
|
|
353
555
|
},
|
|
354
556
|
};
|
|
355
557
|
}
|
|
356
|
-
|
|
558
|
+
const ts = record["ts"];
|
|
559
|
+
return { ok: true, tail: typeof ts === "string" ? { seq, hash, ts } : { seq, hash } };
|
|
357
560
|
}
|
|
358
561
|
/**
|
|
359
562
|
* Evaluate the compare-and-append precondition against the tail read under the
|
|
@@ -385,8 +588,13 @@ function headPrecondition(logPath, expected, tail) {
|
|
|
385
588
|
* Build a complete record from caller content plus chain state. Property
|
|
386
589
|
* insertion order is irrelevant to the digest (JCS sorts keys) but is kept
|
|
387
590
|
* readable here for anyone eyeballing the code.
|
|
591
|
+
*
|
|
592
|
+
* `daemon` comes from the writing process and never from `input`, which is why it
|
|
593
|
+
* is a parameter here rather than a member of {@link EventInput}: this function
|
|
594
|
+
* composes a record field by field, so there is no spelling of a caller's input
|
|
595
|
+
* that can put a `daemon` on a record.
|
|
388
596
|
*/
|
|
389
|
-
function buildRecord(input, seq, prev) {
|
|
597
|
+
function buildRecord(input, seq, prev, daemon) {
|
|
390
598
|
const record = {
|
|
391
599
|
seq,
|
|
392
600
|
ts: input.ts,
|
|
@@ -396,6 +604,10 @@ function buildRecord(input, seq, prev) {
|
|
|
396
604
|
prev,
|
|
397
605
|
hash: "",
|
|
398
606
|
};
|
|
607
|
+
// Beside `actor`, because it answers the same question about a different layer:
|
|
608
|
+
// who caused the event, and which process wrote it down.
|
|
609
|
+
if (daemon !== null)
|
|
610
|
+
record.daemon = daemon;
|
|
399
611
|
if (input.task !== undefined)
|
|
400
612
|
record.task = input.task;
|
|
401
613
|
if (input.action_key !== undefined)
|
|
@@ -434,8 +646,17 @@ function buildRecord(input, seq, prev) {
|
|
|
434
646
|
* guarantee that nobody else is appending while it works.
|
|
435
647
|
*/
|
|
436
648
|
export function withAppendLock(logPath, run, options = {}) {
|
|
649
|
+
return lockedRun(logPath, () => run(), options, "hold");
|
|
650
|
+
}
|
|
651
|
+
/**
|
|
652
|
+
* {@link withAppendLock}, also telling `run` the first directory its `mkdir`
|
|
653
|
+
* created (`undefined` when the log's directory already existed), which the
|
|
654
|
+
* append needs to know which directory entries are new (APRV-440).
|
|
655
|
+
*/
|
|
656
|
+
function lockedRun(logPath, run, options, op) {
|
|
657
|
+
let firstCreatedDir;
|
|
437
658
|
try {
|
|
438
|
-
mkdirSync(dirname(logPath), { recursive: true });
|
|
659
|
+
firstCreatedDir = mkdirSync(dirname(logPath), { recursive: true });
|
|
439
660
|
}
|
|
440
661
|
catch (cause) {
|
|
441
662
|
return {
|
|
@@ -446,14 +667,147 @@ export function withAppendLock(logPath, run, options = {}) {
|
|
|
446
667
|
},
|
|
447
668
|
};
|
|
448
669
|
}
|
|
449
|
-
const lock = acquireLock(logPath, options.lockTimeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS, options.lockRetryMs ?? DEFAULT_LOCK_RETRY_MS);
|
|
670
|
+
const lock = acquireLock(logPath, options.lockTimeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS, options.lockRetryMs ?? DEFAULT_LOCK_RETRY_MS, op);
|
|
450
671
|
if (!lock.ok)
|
|
451
672
|
return { ok: false, error: lock.error };
|
|
673
|
+
let reclaimWrote = false;
|
|
452
674
|
try {
|
|
453
|
-
|
|
675
|
+
try {
|
|
676
|
+
// A lock this writer took over from a dead holder is recorded first,
|
|
677
|
+
// before the caller reads the tail (APRV-479).
|
|
678
|
+
if (lock.reclaimed !== null) {
|
|
679
|
+
const recorded = recordReclaim(logPath, lock.reclaimed, "system:log", firstCreatedDir);
|
|
680
|
+
reclaimWrote = recorded.wrote;
|
|
681
|
+
if (!recorded.result.ok)
|
|
682
|
+
return { ok: false, error: recorded.result.error };
|
|
683
|
+
}
|
|
684
|
+
return { ok: true, value: run(firstCreatedDir) };
|
|
685
|
+
}
|
|
686
|
+
finally {
|
|
687
|
+
releaseLock(lock.own);
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
finally {
|
|
691
|
+
lock.releaseGuard();
|
|
692
|
+
if (reclaimWrote)
|
|
693
|
+
noteAppend(logPath);
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
/** The `audit.lock_reclaimed` payload: the lockfile's base name, the reason, the age, and the strictly parsed holder. */
|
|
697
|
+
function reclaimPayload(note) {
|
|
698
|
+
const payload = { lockfile: note.lockfile, reason: note.reason };
|
|
699
|
+
if (note.age_ms !== undefined)
|
|
700
|
+
payload["age_ms"] = note.age_ms;
|
|
701
|
+
if (note.holder !== undefined)
|
|
702
|
+
payload["holder"] = { pid: note.holder.pid, op: note.holder.op, created: note.holder.created };
|
|
703
|
+
return payload;
|
|
704
|
+
}
|
|
705
|
+
/**
|
|
706
|
+
* The write boundary's own verdict on the record a reclaim would append,
|
|
707
|
+
* computed before the reclaim is made: a reclaim whose record would be refused
|
|
708
|
+
* is not made.
|
|
709
|
+
*/
|
|
710
|
+
function reclaimRecordValid(note) {
|
|
711
|
+
try {
|
|
712
|
+
const record = buildRecord({ ts: systemClock(), event: "audit.lock_reclaimed", actor: "system:log", payload: reclaimPayload(note) }, 1, GENESIS_PREV, null);
|
|
713
|
+
return validate("event", record).ok;
|
|
714
|
+
}
|
|
715
|
+
catch {
|
|
716
|
+
return false;
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
/**
|
|
720
|
+
* Whether this writer could append the reclaim record now (APRV-479 R3-3): the
|
|
721
|
+
* two refusals {@link recordReclaim} would meet that the schema check cannot
|
|
722
|
+
* see, its daemon stamp and the log's tail, both read without the lock. A
|
|
723
|
+
* reclaim whose record would be refused is not made: the lock stays for a
|
|
724
|
+
* writer that can record it, and nothing is claimed or appended. When it
|
|
725
|
+
* could, the last record's `ts`, which bounds the holder `created` the record
|
|
726
|
+
* may carry (R3-7).
|
|
727
|
+
*/
|
|
728
|
+
function reclaimRecordContext(logPath) {
|
|
729
|
+
const stamp = daemonStampForAppend();
|
|
730
|
+
if (stamp.kind === "refuse")
|
|
731
|
+
return { ok: false, why: `its records are refused: ${stamp.code}` };
|
|
732
|
+
const tail = readTail(logPath);
|
|
733
|
+
if (!tail.ok) {
|
|
734
|
+
return { ok: false, why: `the log's tail refuses an append (${tail.error.code}), which a human repairs first; \`approval log verify\` shows where` };
|
|
735
|
+
}
|
|
736
|
+
const lastTs = tail.tail.ts === undefined ? Number.NaN : Date.parse(tail.tail.ts);
|
|
737
|
+
return { ok: true, lastTs: Number.isFinite(lastTs) ? lastTs : null };
|
|
738
|
+
}
|
|
739
|
+
/**
|
|
740
|
+
* The record of a lock taken over from a holder that is gone (APRV-479),
|
|
741
|
+
* appended as the first write under the lock that was taken: before the
|
|
742
|
+
* caller's own read of the tail, so the caller's compare-and-append sees it as
|
|
743
|
+
* the moved head it is. Written through the same path as every other record,
|
|
744
|
+
* with no head precondition (it decides nothing from the log) and the same
|
|
745
|
+
* daemon stamp. Only once it is in the log is the stale name removed; when it
|
|
746
|
+
* cannot be appended, nothing else is, and the stale name stays beside the lock
|
|
747
|
+
* as the evidence.
|
|
748
|
+
*/
|
|
749
|
+
function recordReclaim(logPath, note, actor, firstCreatedDir) {
|
|
750
|
+
const stamp = daemonStampForAppend();
|
|
751
|
+
if (stamp.kind === "refuse")
|
|
752
|
+
return { result: fail(stamp.code, stamp.message), wrote: false };
|
|
753
|
+
const daemon = stamp.kind === "stamp" ? stamp.id : null;
|
|
754
|
+
const recorded = appendUnderLock(logPath, { ts: systemClock(), event: "audit.lock_reclaimed", actor, payload: reclaimPayload(note) }, {}, firstCreatedDir, daemon);
|
|
755
|
+
if (recorded.result.ok) {
|
|
756
|
+
finishReclaim(note);
|
|
757
|
+
return recorded;
|
|
758
|
+
}
|
|
759
|
+
return {
|
|
760
|
+
result: {
|
|
761
|
+
ok: false,
|
|
762
|
+
error: {
|
|
763
|
+
...recorded.result.error,
|
|
764
|
+
message: `${recorded.result.error.message} (${note.lockfile} was taken over from a holder that is gone, and the record of that could not be appended, so nothing else was; ${basename(note.stale)} is left beside the lock as the evidence)`,
|
|
765
|
+
},
|
|
766
|
+
},
|
|
767
|
+
wrote: recorded.wrote,
|
|
768
|
+
};
|
|
769
|
+
}
|
|
770
|
+
/**
|
|
771
|
+
* `approval log unlock --pid <n|none>` (APRV-479): a human takes over a lock no
|
|
772
|
+
* writer reclaims (another container, another boot, a holder that cannot be
|
|
773
|
+
* checked), with the same claim and take a writer's reclaim uses, and records
|
|
774
|
+
* it as `audit.lock_reclaimed` (reason `operator-cleared`) under their own
|
|
775
|
+
* `human:` actor as the first
|
|
776
|
+
* record under the lock taken. `pid` must be the pid the lockfile names, or
|
|
777
|
+
* `null` for one that names no holder. Human-only: the CLI resolves the actor,
|
|
778
|
+
* the schema refuses an `agent:` one, and the harness hook classifies the verb
|
|
779
|
+
* `policy.core`.
|
|
780
|
+
*/
|
|
781
|
+
export function unlockAppendLock(logPath, pid, actor) {
|
|
782
|
+
if (!existsSync(logPath)) {
|
|
783
|
+
return { kind: "refused", message: `${logPath} does not exist, so there is no log a record of the unlock could follow; remove ${basename(logPath)}.lock by hand` };
|
|
784
|
+
}
|
|
785
|
+
const context = reclaimRecordContext(logPath);
|
|
786
|
+
if (!context.ok) {
|
|
787
|
+
return { kind: "refused", message: `the record of the unlock could not be appended (${context.why}), so nothing was touched` };
|
|
788
|
+
}
|
|
789
|
+
const taken = takeLockForUnlock(logPath, pid, context.lastTs);
|
|
790
|
+
if (taken.kind === "none")
|
|
791
|
+
return { kind: "none" };
|
|
792
|
+
if (taken.kind === "refused")
|
|
793
|
+
return { kind: "refused", message: taken.why };
|
|
794
|
+
let wrote = false;
|
|
795
|
+
try {
|
|
796
|
+
try {
|
|
797
|
+
const recorded = recordReclaim(logPath, taken.note, actor, undefined);
|
|
798
|
+
wrote = recorded.wrote;
|
|
799
|
+
if (!recorded.result.ok)
|
|
800
|
+
return { kind: "failed", error: recorded.result.error };
|
|
801
|
+
return { kind: "unlocked", record: recorded.result.record, note: taken.note };
|
|
802
|
+
}
|
|
803
|
+
finally {
|
|
804
|
+
releaseLock(taken.own);
|
|
805
|
+
}
|
|
454
806
|
}
|
|
455
807
|
finally {
|
|
456
|
-
|
|
808
|
+
taken.releaseGuard();
|
|
809
|
+
if (wrote)
|
|
810
|
+
noteAppend(logPath);
|
|
457
811
|
}
|
|
458
812
|
}
|
|
459
813
|
/**
|
|
@@ -467,7 +821,11 @@ export function withAppendLock(logPath, run, options = {}) {
|
|
|
467
821
|
* not turn a written record into a failed one, so throws are swallowed.
|
|
468
822
|
*/
|
|
469
823
|
const appendListeners = [];
|
|
470
|
-
/**
|
|
824
|
+
/**
|
|
825
|
+
* Subscribe to appends that put bytes in the file: every successful one, and
|
|
826
|
+
* since APRV-440 one that wrote and then failed to confirm them durable (a
|
|
827
|
+
* short write, a failed fsync). Process-wide, memory-only, additive.
|
|
828
|
+
*/
|
|
471
829
|
export function onLogAppended(listener) {
|
|
472
830
|
appendListeners.push(listener);
|
|
473
831
|
}
|
|
@@ -482,7 +840,44 @@ function noteAppend(logPath) {
|
|
|
482
840
|
}
|
|
483
841
|
}
|
|
484
842
|
export function appendEvent(logPath, input, options = {}) {
|
|
485
|
-
|
|
843
|
+
// Which daemon is writing, and whether it may (APRV-383). Asked of this
|
|
844
|
+
// process's own state rather than of the caller, and asked BEFORE the lock
|
|
845
|
+
// because it reads no log state: taking a lock only to refuse would make every
|
|
846
|
+
// other writer wait for an answer that was never going to touch the file. It is
|
|
847
|
+
// the write boundary in the sense that matters — one code path, nothing written
|
|
848
|
+
// on the refused branch, and the record's own schema pins the field's shape so a
|
|
849
|
+
// malformed id could not land even if this check were absent.
|
|
850
|
+
const stamp = daemonStampForAppend();
|
|
851
|
+
if (stamp.kind === "refuse")
|
|
852
|
+
return fail(stamp.code, stamp.message);
|
|
853
|
+
const daemon = stamp.kind === "stamp" ? stamp.id : null;
|
|
854
|
+
// Set once bytes may reach the file, success or not: the read cache
|
|
855
|
+
// must re-prove a log whose bytes changed even when the append then failed to
|
|
856
|
+
// confirm them durable.
|
|
857
|
+
let wrote = false;
|
|
858
|
+
const held = lockedRun(logPath, (firstCreatedDir) => {
|
|
859
|
+
const appended = appendUnderLock(logPath, input, options, firstCreatedDir, daemon);
|
|
860
|
+
wrote = appended.wrote;
|
|
861
|
+
return appended.result;
|
|
862
|
+
}, options, "append");
|
|
863
|
+
const result = held.ok ? held.value : { ok: false, error: held.error };
|
|
864
|
+
// After the lock is released, for every append that put bytes in the file:
|
|
865
|
+
// the reader cache must not serve a prefix proof anchored before this write.
|
|
866
|
+
if (wrote)
|
|
867
|
+
noteAppend(logPath);
|
|
868
|
+
return result;
|
|
869
|
+
}
|
|
870
|
+
/**
|
|
871
|
+
* One append, with `<logPath>.lock` already held by this process: read the
|
|
872
|
+
* tail, compare the head, build, validate, write, fsync. The body of
|
|
873
|
+
* {@link appendEvent}, shared with the reclaim record (APRV-479) so that record
|
|
874
|
+
* goes through every check any other record does. `wrote` says whether bytes
|
|
875
|
+
* may have reached the file, success or not.
|
|
876
|
+
*/
|
|
877
|
+
function appendUnderLock(logPath, input, options, firstCreatedDir, daemon) {
|
|
878
|
+
let wrote = false;
|
|
879
|
+
const done = (result) => ({ result, wrote });
|
|
880
|
+
const run = () => {
|
|
486
881
|
const tail = readTail(logPath);
|
|
487
882
|
if (!tail.ok)
|
|
488
883
|
return { ok: false, error: tail.error };
|
|
@@ -497,7 +892,7 @@ export function appendEvent(logPath, input, options = {}) {
|
|
|
497
892
|
let record;
|
|
498
893
|
let line;
|
|
499
894
|
try {
|
|
500
|
-
record = buildRecord(input, tail.tail.seq + 1, tail.tail.hash);
|
|
895
|
+
record = buildRecord(input, tail.tail.seq + 1, tail.tail.hash, daemon);
|
|
501
896
|
// Stored line = JCS of the complete record; digest input excluded `hash`.
|
|
502
897
|
line = serializeRecord(record);
|
|
503
898
|
}
|
|
@@ -512,35 +907,63 @@ export function appendEvent(logPath, input, options = {}) {
|
|
|
512
907
|
if (!validation.ok) {
|
|
513
908
|
return fail("validation", `event failed schema validation at the write boundary; log left unchanged`, validation.errors);
|
|
514
909
|
}
|
|
515
|
-
let
|
|
910
|
+
let opened;
|
|
516
911
|
try {
|
|
517
|
-
|
|
912
|
+
opened = openForAppend(logPath);
|
|
518
913
|
}
|
|
519
914
|
catch (cause) {
|
|
520
915
|
return fail("io", `log ${logPath} could not be opened for append: ${errorMessage(cause)}`);
|
|
521
916
|
}
|
|
917
|
+
const { fd, created } = opened;
|
|
918
|
+
const writeLayer = appendWriteLayer();
|
|
919
|
+
const bytes = Buffer.from(`${line}\n`, "utf8");
|
|
522
920
|
try {
|
|
523
921
|
// Single write syscall on an O_APPEND handle: the line lands whole or not at all.
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
922
|
+
// Set BEFORE the write: one that throws may still have put bytes in the
|
|
923
|
+
// file, and a needless re-proof by the read cache costs only time.
|
|
924
|
+
wrote = true;
|
|
925
|
+
let written;
|
|
926
|
+
try {
|
|
927
|
+
written = writeLayer.write(fd, bytes);
|
|
928
|
+
}
|
|
929
|
+
catch (cause) {
|
|
930
|
+
return fail("io", `log ${logPath} could not be written: ${errorMessage(cause)}`);
|
|
931
|
+
}
|
|
932
|
+
if (written !== bytes.length) {
|
|
933
|
+
return fail("io", `log ${logPath} took ${String(written)} of ${String(bytes.length)} bytes of seq ${String(record.seq)}: the write was short, so the file now ends with a torn line and the record was not appended`);
|
|
934
|
+
}
|
|
935
|
+
// Durable before ok (guarantee 5b). Until this returns, the record exists
|
|
936
|
+
// in the page cache only, and a kill before writeback leaves the file
|
|
937
|
+
// extended over unwritten blocks: a NUL-filled tail where the record was.
|
|
938
|
+
try {
|
|
939
|
+
writeLayer.fsync(fd);
|
|
940
|
+
}
|
|
941
|
+
catch (cause) {
|
|
942
|
+
return fail("io", `log ${logPath}: seq ${String(record.seq)} was written but fsync failed (${errorMessage(cause)}), so it is not known to be durable: it may be in the file now and gone after a crash. Re-read the log before deciding anything from it.`);
|
|
943
|
+
}
|
|
528
944
|
}
|
|
529
945
|
finally {
|
|
530
946
|
try {
|
|
531
|
-
|
|
947
|
+
writeLayer.close(fd);
|
|
532
948
|
}
|
|
533
949
|
catch {
|
|
534
|
-
// Nothing actionable
|
|
950
|
+
// Nothing actionable: the bytes are written and, on the ok path, synced.
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
// A new file is a new directory entry, and fsync of the file does not make
|
|
954
|
+
// its NAME durable: without this a crash can keep the bytes and lose the file.
|
|
955
|
+
if (created) {
|
|
956
|
+
for (const dir of directoriesToSync(logPath, firstCreatedDir)) {
|
|
957
|
+
try {
|
|
958
|
+
fsyncDirectory(dir);
|
|
959
|
+
}
|
|
960
|
+
catch (cause) {
|
|
961
|
+
return fail("io", `log ${logPath}: seq ${String(record.seq)} was written and synced but its directory ${dir} could not be (${errorMessage(cause)}), so the newly created file is not known to survive a crash. Re-read the log before deciding anything from it.`);
|
|
962
|
+
}
|
|
535
963
|
}
|
|
536
964
|
}
|
|
537
965
|
return { ok: true, record, line };
|
|
538
|
-
}
|
|
539
|
-
|
|
540
|
-
// After the lock is released and only for a record that actually landed: the
|
|
541
|
-
// reader cache must not serve a prefix proof anchored before this write.
|
|
542
|
-
if (result.ok)
|
|
543
|
-
noteAppend(logPath);
|
|
544
|
-
return result;
|
|
966
|
+
};
|
|
967
|
+
return done(run());
|
|
545
968
|
}
|
|
546
969
|
//# sourceMappingURL=log.js.map
|