@harperfast/harper 5.3.0-beta.1 → 5.3.0-beta.2
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/components/DESIGN.md +421 -0
- package/components/mcp/DESIGN.md +109 -0
- package/components/mcp/audit.ts +21 -17
- package/config/DESIGN.md +306 -0
- package/dataLayer/DESIGN.md +179 -0
- package/dataLayer/restoreMarker.ts +92 -25
- package/dist/components/mcp/audit.d.ts +2 -1
- package/dist/components/mcp/audit.js +21 -17
- package/dist/components/mcp/audit.js.map +1 -1
- package/dist/dataLayer/restoreMarker.d.ts +21 -8
- package/dist/dataLayer/restoreMarker.js +94 -27
- package/dist/dataLayer/restoreMarker.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +6 -1
- package/dist/index.js.map +1 -1
- package/dist/resources/RecordEncoder.js +25 -5
- package/dist/resources/RecordEncoder.js.map +1 -1
- package/dist/resources/Table.js +48 -2
- package/dist/resources/Table.js.map +1 -1
- package/dist/resources/analytics/write.d.ts +3 -0
- package/dist/resources/analytics/write.js +39 -13
- package/dist/resources/analytics/write.js.map +1 -1
- package/dist/resources/crdt.d.ts +10 -0
- package/dist/resources/crdt.js +22 -0
- package/dist/resources/crdt.js.map +1 -1
- package/dist/resources/databases.js +1 -1
- package/dist/resources/databases.js.map +1 -1
- package/dist/resources/indexes/HierarchicalNavigableSmallWorld.d.ts +2 -2
- package/dist/resources/indexes/HierarchicalNavigableSmallWorld.js +43 -24
- package/dist/resources/indexes/HierarchicalNavigableSmallWorld.js.map +1 -1
- package/dist/resources/indexes/fullTextDerivedIndex.d.ts +81 -0
- package/dist/resources/indexes/fullTextDerivedIndex.js +1004 -0
- package/dist/resources/indexes/fullTextDerivedIndex.js.map +1 -0
- package/dist/resources/indexes/fullTextNativeBinding.d.ts +78 -0
- package/dist/resources/indexes/fullTextNativeBinding.js +85 -0
- package/dist/resources/indexes/fullTextNativeBinding.js.map +1 -0
- package/dist/resources/indexes/nativeFullTextDerivedIndexLifecycle.d.ts +24 -0
- package/dist/resources/indexes/nativeFullTextDerivedIndexLifecycle.js +149 -0
- package/dist/resources/indexes/nativeFullTextDerivedIndexLifecycle.js.map +1 -0
- package/dist/resources/recordLockCoordinator.d.ts +5 -5
- package/dist/resources/recordLockCoordinator.js +48 -16
- package/dist/resources/recordLockCoordinator.js.map +1 -1
- package/dist/resources/transactionBroadcast.js +4 -6
- package/dist/resources/transactionBroadcast.js.map +1 -1
- package/dist/security/auth.js +59 -23
- package/dist/security/auth.js.map +1 -1
- package/dist/security/deferredAuthentication.d.ts +11 -0
- package/dist/security/deferredAuthentication.js +25 -3
- package/dist/security/deferredAuthentication.js.map +1 -1
- package/dist/security/jsLoader.js +3 -0
- package/dist/security/jsLoader.js.map +1 -1
- package/dist/server/REST.js +6 -3
- package/dist/server/REST.js.map +1 -1
- package/dist/server/mqtt.js +5 -1
- package/dist/server/mqtt.js.map +1 -1
- package/dist/server/serverHelpers/serverUtilities.d.ts +3 -3
- package/dist/server/serverHelpers/serverUtilities.js +14 -3
- package/dist/server/serverHelpers/serverUtilities.js.map +1 -1
- package/dist/server/serverHelpers/uwsServer.js +4 -1
- package/dist/server/serverHelpers/uwsServer.js.map +1 -1
- package/dist/server/serverHelpers/webSocketCloseReason.d.ts +2 -0
- package/dist/server/serverHelpers/webSocketCloseReason.js +29 -0
- package/dist/server/serverHelpers/webSocketCloseReason.js.map +1 -0
- package/dist/utility/logging/harper_logger.js +1 -0
- package/dist/utility/logging/harper_logger.js.map +1 -1
- package/index.ts +3 -0
- package/npm-shrinkwrap.json +104 -104
- package/package.json +6 -5
- package/resources/DESIGN.md +568 -3
- package/resources/RecordEncoder.ts +27 -5
- package/resources/Table.ts +49 -3
- package/resources/analytics/DESIGN.md +38 -0
- package/resources/analytics/write.ts +40 -14
- package/resources/crdt.ts +22 -0
- package/resources/databases.ts +1 -1
- package/resources/indexes/DESIGN.md +833 -0
- package/resources/indexes/HierarchicalNavigableSmallWorld.ts +44 -25
- package/resources/indexes/fullTextDerivedIndex.ts +1165 -0
- package/resources/indexes/fullTextNativeBinding.ts +146 -0
- package/resources/indexes/nativeFullTextDerivedIndexLifecycle.ts +181 -0
- package/resources/record-locks.md +1407 -0
- package/resources/recordLockCoordinator.ts +61 -22
- package/resources/scheduler/DESIGN.md +40 -0
- package/resources/transactionBroadcast.ts +4 -4
- package/security/DESIGN.md +175 -0
- package/security/auth.ts +53 -24
- package/security/deferredAuthentication.ts +24 -2
- package/security/jsLoader.ts +3 -0
- package/server/DESIGN.md +264 -0
- package/server/REST.ts +6 -3
- package/server/mqtt.ts +6 -4
- package/server/serverHelpers/serverUtilities.ts +14 -3
- package/server/serverHelpers/uwsServer.ts +4 -1
- package/server/serverHelpers/webSocketCloseReason.ts +25 -0
- package/studio/web/assets/{Chat-D3j-1yY1.js → Chat-DADFFGe_.js} +1 -1
- package/studio/web/assets/{FloatingChat-BxJGYcfB.js → FloatingChat-D_mI-rZ7.js} +3 -3
- package/studio/web/assets/{apiToken-CT55oWOe.js → apiToken-c2NiSDHa.js} +1 -1
- package/studio/web/assets/{applications-D9Ct9_vm.js → applications-DktUqh7G.js} +1 -1
- package/studio/web/assets/{cssMode-DV8H7VwA.js → cssMode-Cs_75Xhw.js} +1 -1
- package/studio/web/assets/{editor-uatc0unt.js → editor-19b-Y1IN.js} +1 -1
- package/studio/web/assets/{html-Bm6D6paN.js → html-DiYEQMpB.js} +1 -1
- package/studio/web/assets/{htmlMode-CEn7tpLG.js → htmlMode-CmR0y7P_.js} +1 -1
- package/studio/web/assets/{index-BIXW6Pu4.js → index-Dm0rfkJ7.js} +5 -5
- package/studio/web/assets/{index.lazy-UI7L-Vrk.js → index.lazy-7vqt2CC3.js} +1 -1
- package/studio/web/assets/{javascript-CJ0G3AFZ.js → javascript-BWtCFuOt.js} +1 -1
- package/studio/web/assets/{jsonMode-DQADAYEa.js → jsonMode-Buzzbv9y.js} +1 -1
- package/studio/web/assets/{languageServices-CAQJXWcI.js → languageServices-SqsFWfTM.js} +1 -1
- package/studio/web/assets/{lspLanguageFeatures-CCQ8P5sY.js → lspLanguageFeatures-EMV5cmjo.js} +1 -1
- package/studio/web/assets/{notifications-Cvb3P1lB.js → notifications-CAB-LZWT.js} +1 -1
- package/studio/web/assets/{notifications-BbxTU6Aw.js → notifications-DRzmSRxM.js} +1 -1
- package/studio/web/assets/{profile-Yyb7gsvL.js → profile-BNKAl79n.js} +1 -1
- package/studio/web/assets/{regions-OgjGHlU5.js → regions-CUow_Zw2.js} +1 -1
- package/studio/web/assets/{register-6qwNEOY3.js → register-Dkt3WUMp.js} +2 -2
- package/studio/web/assets/{setComponentFile-BilDMtgB.js → setComponentFile-BZRfMD0N.js} +1 -1
- package/studio/web/assets/{setup-J6qJ7OIU.js → setup-D_yiEPO2.js} +2 -2
- package/studio/web/assets/{status-0RWGcfyD.js → status-DhHh1Ge-.js} +1 -1
- package/studio/web/assets/{toggleHighContrast-BIn-vErT.js → toggleHighContrast-D7L1PDtV.js} +1 -1
- package/studio/web/assets/{tsMode-DgUXku4d.js → tsMode-CCwLk1YS.js} +1 -1
- package/studio/web/assets/{typescript-C9orXcsM.js → typescript-BP1j1mjn.js} +1 -1
- package/studio/web/assets/{useEntityRestURL-BEoXXbUB.js → useEntityRestURL-D7bnYxLw.js} +1 -1
- package/studio/web/assets/{workers-JVzSDmgx.js → workers-tOuCNT17.js} +1 -1
- package/studio/web/assets/{xml-Cq-S8S4X.js → xml-BSG_3mQT.js} +1 -1
- package/studio/web/assets/{yaml-sfoRdh1M.js → yaml-DxiLprBB.js} +1 -1
- package/studio/web/index.html +1 -1
- package/utility/DESIGN.md +55 -0
- package/utility/logging/harper_logger.ts +1 -0
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
# components/ — Design notes
|
|
2
|
+
|
|
3
|
+
Component deploys, the load lifecycle and packaging.
|
|
4
|
+
|
|
5
|
+
**Read this when:** touching `deploy_component`, `prepareApplication`, `EntryHandler`, `packageComponent.ts` or `deploymentRecorder.ts`.
|
|
6
|
+
|
|
7
|
+
Index of every design note: [DESIGN.md](../DESIGN.md).
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## A deploy builds off to the side, is validated, and only then goes live
|
|
12
|
+
|
|
13
|
+
`deploy_component` builds the replacement at `.deploy-staging/<deploymentId>/<component>`, runs the
|
|
14
|
+
load validation against _that_ tree, and only then activates it. Activation is one compensating
|
|
15
|
+
transaction over two effects: the live tree moves into `.deploy-aside`, then the candidate is renamed
|
|
16
|
+
into the live path.
|
|
17
|
+
|
|
18
|
+
**Both renames wait out a holder, and the wait happens with the previous version in place.** Windows
|
|
19
|
+
refuses a rename outright (`EPERM`) while anything holds a handle in the source tree, which is what
|
|
20
|
+
`deploy_component` hit on the Windows nightly, on the swap itself. The holder was never identified —
|
|
21
|
+
every Harper-held handle on the candidate is closed before the swap, so it is something outside the
|
|
22
|
+
process, but that is inference, not evidence. `renameThroughTransientHolder` retries every rename in
|
|
23
|
+
the activation transaction and its recovery with capped exponential backoff against a five-second
|
|
24
|
+
deadline. The deadline is per top-level rename, not per activation: a redeploy held up the whole way
|
|
25
|
+
spends up to five seconds each on the move-aside, the swap, and the compensating restore. A rename
|
|
26
|
+
performed from inside a backoff shares its caller's deadline rather than opening a fourth.
|
|
27
|
+
|
|
28
|
+
The swap's backoff is not a plain sleep: it renames the aside back to the live path, waits there, and
|
|
29
|
+
displaces it again for the next attempt, so the component is missing for one rename rather than for the
|
|
30
|
+
budget. Note what that does and does not buy. Watchers are NOT the beneficiary — `Scope` pauses every
|
|
31
|
+
`EntryHandler` for the duration of a deploy, so an absent tree is never reported as `unlinkDir`, and
|
|
32
|
+
the post-deploy resume diffs against the finished tree. What the put-back protects is everything that
|
|
33
|
+
reads the live path directly: a component reading its own files, a lazily imported module, a concurrent
|
|
34
|
+
scan of the components root, and any thread whose pause the best-effort deploy broadcast did not reach.
|
|
35
|
+
|
|
36
|
+
The retry set is `EPERM`/`EACCES`/`EBUSY` only: a destination that exists is structural state nothing
|
|
37
|
+
here clears between attempts, and `settleInterruptedActivation` already fails that case closed rather
|
|
38
|
+
than guessing which tree is current.
|
|
39
|
+
|
|
40
|
+
The ordering is the design. Two things used to be wrong in a way each other hid:
|
|
41
|
+
|
|
42
|
+
- **The live tree was moved aside first**, so the component was broken for the whole extract +
|
|
43
|
+
`npm install`. Worse than unavailable — the live path held the _new_ code before its dependencies were
|
|
44
|
+
installed, so requests during a deploy hit an unrunnable tree. `stage-swap-availability.test.ts`
|
|
45
|
+
samples the live path through a deliberately blocked install and fails against the old ordering.
|
|
46
|
+
- **Validation ran after the swap committed**, so a component that installed cleanly but threw at load
|
|
47
|
+
went live anyway while the operation returned an error. Validation is now a callback preparation
|
|
48
|
+
invokes between build and activation, so a rejected candidate is never published. Note this is a
|
|
49
|
+
load-error PROBE, not a safety guarantee: it executes the component's own top-level code with
|
|
50
|
+
incomplete side-effect isolation. It also remains a no-op on the main thread, and the operations API
|
|
51
|
+
deploys on the main thread — so operator deploys are still unvalidated, exactly as before. Fixing that
|
|
52
|
+
is separate work; this only fixed the order.
|
|
53
|
+
**Root config is deliberately NOT part of this transaction.** It is still written before the build and
|
|
54
|
+
never rolled back, so `installApplications()` can reinstall a rejected release at the next restart —
|
|
55
|
+
unchanged from before this change. Making config an effect of the activation was implemented and then
|
|
56
|
+
pulled back out: it kept surfacing durability and locking problems that had nothing to do with the tree
|
|
57
|
+
swap (a memoized config object a disk write does not refresh, `atomicWriteFile` not fsyncing, writers that
|
|
58
|
+
do not share the publication lock). It is tracked as its own step so the tree half can land on its own
|
|
59
|
+
evidence.
|
|
60
|
+
|
|
61
|
+
### Staging a build now and activating it later
|
|
62
|
+
|
|
63
|
+
`deploy_component { activate: false }` stops after certification, leaving a dormant artifact; a later
|
|
64
|
+
`deploy_component { project, deployment_id }` swaps that artifact in without resolving, fetching or
|
|
65
|
+
installing anything. Four things make the delay safe, and each of them exists because the immediate deploy
|
|
66
|
+
did not need it:
|
|
67
|
+
|
|
68
|
+
- **The artifact directory is named by the PUBLIC deployment id**, not by the deploy-lifecycle token. The
|
|
69
|
+
two are separate on purpose: `DeployLifecycle` de-duplicates starts by the id a start announces and
|
|
70
|
+
releases watcher suppression on the first matching end, so two overlapping activations of one artifact
|
|
71
|
+
sharing that id would count as one owner. `prepareApplication` therefore takes `artifactId` and lets
|
|
72
|
+
`broadcastDeployStart` keep minting a fresh token per invocation.
|
|
73
|
+
- **`.artifact.json` is mandatory and versioned**, written before `.complete` so the marker vouches for it.
|
|
74
|
+
It carries the root-config entry the build would have published (explicitly `null` for a payload deploy,
|
|
75
|
+
which owns none), `installationIsOpaque`, and the isolation intent that was admitted — everything a later
|
|
76
|
+
activation cannot re-derive. An _optional_ record could not distinguish a payload build from a package
|
|
77
|
+
build whose record was lost, so a missing, malformed or wrong-version one is refused rather than defaulted.
|
|
78
|
+
- **Claiming an id is exclusive.** `buildCandidateApplication` used to tolerate an existing deployment
|
|
79
|
+
directory because a fresh UUID could not collide; a public id can be repeated by an operator or by a
|
|
80
|
+
redelivered replication, so a claim now rejects another component's directory and any directory carrying
|
|
81
|
+
`.complete`, and rebuilds only over an uncertified partial of its _own_ component. Ownership is published
|
|
82
|
+
as part of the claim — the `.component` sidecar is written right after the exclusive `mkdir`, not at
|
|
83
|
+
certification — because `buildCandidateApplication` can spend minutes resolving and packing before any
|
|
84
|
+
tree exists to infer an owner from. For that whole window the directory answered to nobody, and an empty
|
|
85
|
+
`readdir` is indistinguishable from an abandoned claim, so a second component could delete a build that
|
|
86
|
+
was still running. **Emptiness is not a verdict:** an unattributed directory is refused, never reclaimed,
|
|
87
|
+
which is the same reading recovery already gives it. The id this request names is also pinned
|
|
88
|
+
through the preparation preamble, so retention cannot evict the artifact the request is about to use —
|
|
89
|
+
which it otherwise would, immediately, at `deployment_stagingRetention_maxCount: 0`. The contract is
|
|
90
|
+
bounded: an id names one artifact _while that artifact exists_. Activation consumes it (the swap is a
|
|
91
|
+
rename) and retention can prune it, after which the id is free again.
|
|
92
|
+
- **Staging owns its bytes.** A `file:<directory>` source is refused, and so is any symlink in the built
|
|
93
|
+
tree resolving outside it (bar the `node_modules/harper`/`harperdb` links the loader owns and repairs).
|
|
94
|
+
Certification fsyncs the tree but follows no links, and the post-swap relocation repair leaves external
|
|
95
|
+
targets alone — so a link out of the build is a hole in "activate exactly the bytes that were certified"
|
|
96
|
+
that only a delay makes reachable. **`.complete` is a durability marker over the bytes, not a seal on
|
|
97
|
+
them:** nothing stops a dormant artifact being edited while it waits, so the link rule and the load
|
|
98
|
+
validation are both re-run at activation rather than trusted from the marker. Content tampering is still
|
|
99
|
+
not detected — that needs a manifest the marker is bound to, and the load validation that would catch a
|
|
100
|
+
broken entry point is a no-op on the main thread until #2315 step 2.
|
|
101
|
+
|
|
102
|
+
Certification moved out of `activateCandidateApplication` and up into `prepareApplication` for the same
|
|
103
|
+
reason: `markCandidateComplete` fsyncs the whole candidate tree, and a delayed activation must not re-walk
|
|
104
|
+
a `node_modules` it certified at build time while holding the preparation lock. The swap primitive's
|
|
105
|
+
contract is now "the candidate is already certified", and its direct callers — including tests — certify
|
|
106
|
+
first.
|
|
107
|
+
|
|
108
|
+
**Mixed-version clusters are a known, accepted hazard.** A stage replicates as an ordinary
|
|
109
|
+
`deploy_component`, and a peer running a build without this change ignores the unknown `activate: false`
|
|
110
|
+
and deploys immediately, so it serves a release the operator asked only to stage. Nothing in core or in
|
|
111
|
+
harper-pro's replicator carries a peer version or capability, so the origin cannot refuse in advance; a
|
|
112
|
+
node that staged returns `staged: true` in its response instead, and the origin fails the stage naming any
|
|
113
|
+
peer that did not confirm. That is detection after the fact, not prevention — the accepted trade is
|
|
114
|
+
recorded on #2315, and the documented prerequisite is to upgrade every node before staging.
|
|
115
|
+
|
|
116
|
+
### Recovering an interrupted activation
|
|
117
|
+
|
|
118
|
+
Every control file is dot-prefixed — `.activation.json`, `.artifact.json`, `.component`, `.complete`,
|
|
119
|
+
`.unsettled` — because
|
|
120
|
+
a deployment directory holds the candidate tree under the _component's_ own name beside them, and
|
|
121
|
+
`isJoinableComponentName` rejects a leading dot. An undotted control file shares that namespace: a component
|
|
122
|
+
named `activation.json` would put its tree on the journal path and activate with no journal at all, and one
|
|
123
|
+
named `unsettled` would make every settle throw. `assertApplicationConfig` rejects any name
|
|
124
|
+
`isJoinableComponentName` rejects, so the collision is unreachable from a root-config key as well as from a
|
|
125
|
+
deploy. Ownership inferred from a directory name is validated the same way, so a control file cannot
|
|
126
|
+
impersonate a component either.
|
|
127
|
+
|
|
128
|
+
A journal-LESS staging directory is not ambiguous: it is what a successful settlement leaves when its
|
|
129
|
+
best-effort sweep fails. Both the deploy path and boot recovery pass over one rather than failing it closed,
|
|
130
|
+
because a verdict written there would outlive the deployment and, once its sidecar became readable again, be
|
|
131
|
+
attributed to a live component that never held an unsettled activation.
|
|
132
|
+
|
|
133
|
+
An `.activation.json` journal is written beside the candidate — with a `.complete` marker recording that
|
|
134
|
+
build _and_ validation both succeeded — before the first rename, so `recoverInterruptedActivations()` can
|
|
135
|
+
settle a crash at any boundary. Both go to a temp name, are fsynced, then linked into place, so the final
|
|
136
|
+
name never exists with partial contents; the candidate's own contents are fsynced before `.complete` is
|
|
137
|
+
written, since `.complete` is what vouches for them. Recovery runs before `installApplications()`, which
|
|
138
|
+
installs whatever the root config names and would otherwise reinstall over a half-swapped candidate.
|
|
139
|
+
|
|
140
|
+
**Every settlement outcome clears an earlier failed recovery's `.unsettled`, including the one that returns a
|
|
141
|
+
staged artifact to dormant.** That branch returns before the settled tail, so it has to clear the verdict
|
|
142
|
+
itself: an artifact left carrying a stale marker is refused by `deployment_id` and then deleted by the next
|
|
143
|
+
retention pass as a stale unsettled build, which is the opposite of returning it to dormant. It also needs a
|
|
144
|
+
durability barrier the tail does not, because the tail removes the whole deployment directory afterwards and
|
|
145
|
+
this branch keeps it: with both unlinks flushed by one sync at the end, a crash can persist the journal's
|
|
146
|
+
removal and not the marker's, leaving a verdict no settlement will ever revisit — settlement keys on the
|
|
147
|
+
journal. The marker's removal is therefore flushed before the journal's — and because Windows cannot fsync a
|
|
148
|
+
directory, the ordering cannot be the only defence: the residue pass treats a DESCRIBED artifact carrying a
|
|
149
|
+
verdict but no journal as settled rather than disposable, clears the marker, and retains it. `fail()` only
|
|
150
|
+
ever writes `.unsettled` beside a journal it keeps, so a marker without one says settlement finished and only
|
|
151
|
+
the marker's own removal was lost. An undescribed build in that state stays disposable, which is the rule
|
|
152
|
+
that predates staging.
|
|
153
|
+
|
|
154
|
+
**Keeping the activation journal after a failed root-config undo only changes the outcome for a first-ever
|
|
155
|
+
deploy.** Compensation has already put an existing component's tree back and taken its rollback record with
|
|
156
|
+
it, so the next settle reads live-plus-candidate-with-no-record and returns the artifact to dormant whatever
|
|
157
|
+
the journal says — holding it there defers the same verdict to the next start and leaves the artifact
|
|
158
|
+
unusable until then. Only a first deploy leaves the live path absent, which recovery reads as a roll forward.
|
|
159
|
+
Config is stranded either way for an existing component; that is the durable-config window #2315 step 3
|
|
160
|
+
closes, not something the journal can cover.
|
|
161
|
+
|
|
162
|
+
**The same window costs isolation, not just a version string.** A staged artifact records the isolation the
|
|
163
|
+
build admitted in its `.artifact.json`, and an activation publishes that with the rest of its root-config
|
|
164
|
+
entry between B1 and the commit. `rollForward()` publishes nothing, so a crash after the roll-forward state
|
|
165
|
+
exists but before that publish brings the certified artifact up under the previous release's config — and a
|
|
166
|
+
component staged to run isolated comes back NON-ISOLATED, with nothing in the operation reporting it.
|
|
167
|
+
Isolation is a containment boundary, so weigh that window by this rather than by the version mismatch.
|
|
168
|
+
|
|
169
|
+
The journal is consulted **first**, and the legacy in-place extraction recovery enforces that itself: it
|
|
170
|
+
refuses to restore a rollback record while an unsettled journal is attributable to that component — by its
|
|
171
|
+
own `component` field OR by the deployment's ownership sidecar, whichever can be read, because restoring is
|
|
172
|
+
the destructive step and takes the conservative union while settlement keeps the precise intersection.
|
|
173
|
+
Ordering settlement
|
|
174
|
+
ahead of it is not enough, because a worker can be respawned mid-activation with no settlement in front of
|
|
175
|
+
it, and settlement that _fails_ deliberately keeps the journal while the same boot carries on. The refusal
|
|
176
|
+
is scoped to the branch that actually restores a tree — a record that was already retired has nothing to
|
|
177
|
+
restore, so that component still loads. Where no journal is attributable to the component, the legacy pass applies
|
|
178
|
+
unchanged: a crash in that path also leaves an in-progress aside with the live tree present, and retiring
|
|
179
|
+
it there would keep a half-written tree instead of restoring the good one.
|
|
180
|
+
|
|
181
|
+
Settlement runs on **every thread**, not only main, for the same reason. It is safe anywhere because each
|
|
182
|
+
deployment is settled under the cross-process component preparation lock and the pass is idempotent. It
|
|
183
|
+
_probes_ for that lock — a 250 ms try, no renewal, matching the legacy boot probe — rather than queueing:
|
|
184
|
+
it runs before every component load on every thread, so waiting behind a live deploy's `npm install` would
|
|
185
|
+
load no components at all until that install finished. A held lock means a live deploy, and a live deploy
|
|
186
|
+
settles its own journal.
|
|
187
|
+
|
|
188
|
+
Ambiguity exists mainly while the live path is absent, and there `.complete` is the roll-forward authority:
|
|
189
|
+
without it the candidate was never validated, so the committed tree in the aside wins. Live-present with a
|
|
190
|
+
candidate is normally pre-swap (or already rolled back) — discard the candidate — **unless a rollback
|
|
191
|
+
record shows the live tree had already been moved aside**. Then whatever is at the live path was recreated
|
|
192
|
+
afterwards by something else, and settling either way would destroy both the committed tree and the
|
|
193
|
+
validated candidate, so that component fails closed with both still on disk.
|
|
194
|
+
Live-present without a candidate is a lost tail — finish forward; never revert a completed activation.
|
|
195
|
+
Neither a live tree nor a rollback record is unrecoverable, so that component fails closed rather than
|
|
196
|
+
guessing. Every branch is idempotent, so a crash _during_ recovery is settled by the next run, and
|
|
197
|
+
failures are per component so one unsettleable component does not stop healthy siblings loading.
|
|
198
|
+
|
|
199
|
+
Directory fsync is best-effort by necessity — Node cannot fsync a directory on Windows — so the protocol
|
|
200
|
+
never depends on it. Roll-forward requires the journal, the candidate and `.complete` to all be
|
|
201
|
+
observable, which means a lost directory update degrades to a roll back rather than to a wrong decision.
|
|
202
|
+
|
|
203
|
+
Retiring the rollback record only marks the displaced tree disposable; both the activation path and
|
|
204
|
+
recovery then sweep it, or the components root would grow by a whole component version per deploy. The
|
|
205
|
+
retire is **correctness, not hygiene** — that marker is what stops the legacy pass treating the record as
|
|
206
|
+
authoritative once the journal is gone — so a failure to retire propagates and the component fails closed
|
|
207
|
+
with its journal intact. Only the sweep itself is best-effort, because it costs disk rather than a wrong
|
|
208
|
+
decision. For the same reason, a swap whose rename cannot be confirmed on storage skips both the retire
|
|
209
|
+
and the journal removal: the journal is what would carry the activation forward after a power loss.
|
|
210
|
+
|
|
211
|
+
Three limits are deliberate and tracked separately: activation is two renames, so the live _pathname_ is
|
|
212
|
+
briefly absent (in-memory resources are unaffected, but a component that opens its own files during a
|
|
213
|
+
request can still see a gap); validation does not run on the main-thread deploy path; and config
|
|
214
|
+
publication is not yet an effect of this transaction, as above.
|
|
215
|
+
|
|
216
|
+
### Retention of dormant staged builds
|
|
217
|
+
|
|
218
|
+
A journal-less deployment directory holding `.complete` and the owner's tree is a **dormant build**: built
|
|
219
|
+
and validated, activated by nobody. Recovery used to remove every owned journal-less directory; it now keeps
|
|
220
|
+
dormant builds and bounds them per component to `deployment_stagingRetention_maxCount` (default 5, 0 keeps
|
|
221
|
+
none), newest by `.complete` mtime, ties broken by deployment id so concurrent passes pick the same victims.
|
|
222
|
+
Everything else journal-less — a partial tree, a directory whose tree already moved live, a stale
|
|
223
|
+
`.unsettled` — is still residue and still removed. Nothing here produces a dormant build yet beyond the crash
|
|
224
|
+
window between `.complete` and the journal; #2315 step 6 (deploy from an existing aside) is the producer this
|
|
225
|
+
bound exists for.
|
|
226
|
+
|
|
227
|
+
Removal is decided **only under the owner's preparation lock**: activation writes `.complete` moments
|
|
228
|
+
before its journal while holding that lock, so an unlocked read of "complete, no journal" is a candidate, not
|
|
229
|
+
a verdict. Boot recovery catalogues dormant builds unlocked, then reconciles each owner once: if any
|
|
230
|
+
catalogued directory has acquired a journal since the scan, or the owner is over its bound, it takes the lock
|
|
231
|
+
and re-reads only that owner's catalogued directories — never the whole staging root, which sibling threads
|
|
232
|
+
are probing — settling any journal that appeared (a deploy that published one and died mid-swap would
|
|
233
|
+
otherwise leave the component unloadable until the next start) and bounding what is still dormant. The
|
|
234
|
+
residue branch re-classifies under its lock too, since the `.complete` a deploy wrote before dying can land
|
|
235
|
+
while the scan waits for the lock. A build created after the scan waits for the next pass. It used to take the lock per journal-less directory, which was one-shot because the directory was
|
|
236
|
+
removed — doing that for retained builds on every pass made a healthy component lose the 250 ms probe to its
|
|
237
|
+
sibling threads at boot and be deferred with nothing in progress. A lock a live deploy holds is still recorded as
|
|
238
|
+
that same deferral: "do not delete" is not "safe to load". The deploy path prunes inside the settlement scan
|
|
239
|
+
it already runs under the lock, before building, so a deploy pays one traversal of the staging root.
|
|
240
|
+
`dropComponentDirectory` reclaims the dropped component's dormant builds, since no later deploy of that
|
|
241
|
+
name will. Only ENOENT is absence; any other read error keeps the entry and moves on. Pruning is disk
|
|
242
|
+
hygiene: it never fails a component closed and never replaces a deploy's own error, so the bound is
|
|
243
|
+
best-effort under filesystem failure and is not a storage quota — journaled, unsettled and unowned
|
|
244
|
+
directories are preserved by design and can still fill a volume.
|
|
245
|
+
|
|
246
|
+
## Component preparation is serialized across worker threads
|
|
247
|
+
|
|
248
|
+
`prepareApplication()` performs one transaction per component: build the replacement, validate it, then swap it in (see "A deploy builds off to the side" below). Deploy operations can execute on worker threads as well as main, so a module-local promise queue is insufficient—each worker has its own module registry. `withComponentPreparationLock()` (`components/componentPreparationLock.ts`) instead acquires an atomic filesystem lock keyed by the absolute component path. The deprecated `install_node_modules` operation uses the same lock, so it cannot run npm concurrently with a deploy.
|
|
249
|
+
|
|
250
|
+
The deploy lifecycle broadcast deliberately sits _outside_ the lock. Overlapping requests therefore increment the existing per-component lifecycle refcount before queueing; watchers remain suppressed continuously until the final queued preparation ends. The lock itself covers credential materialization, extraction, and installation. Its fully-written owner record is published with an atomic rename, so contenders never observe a partially initialized lock. A preparation caller never steals a lock from a known-live owner based on elapsed wall time: installs can be long-running and clocks can jump. Locks from a dead process are reclaimed, and a same-process contender asks the main thread whether the owning worker still exists so a worker crash does not wedge that component until Harper restarts. The boot-time bulk-recovery probe is deliberately different: it never renews its 250 ms deadline, even behind another live recovery, so it can defer that component and let the worker bind its listener.
|
|
251
|
+
|
|
252
|
+
A plugin load that begins while its component is being deployed waits for that lifecycle to end before
|
|
253
|
+
starting `handleApplication`; if a deploy begins during the load, the plugin timeout counts only active,
|
|
254
|
+
unpaused load time. This prevents a long install from looking like a hung plugin while its entry handlers
|
|
255
|
+
are deliberately paused against the intermediate tree.
|
|
256
|
+
|
|
257
|
+
### The component load lock is keyed by plugin type, so a plugin's promise is everyone's clock
|
|
258
|
+
|
|
259
|
+
`sequentiallyHandleApplication` (`components/componentLoader.ts`) holds a cross-thread lock keyed by the
|
|
260
|
+
plugin TYPE name — `graphqlSchema`, `rest`, … — not by the component. That is deliberate. Plugin modules
|
|
261
|
+
are per-thread singletons carrying module-level state (`server/http.ts`'s `universalHeaders` ownership
|
|
262
|
+
array, `resources/graphql.ts`'s `knownGraphQLDirectives`, the scheduler's register-inside-the-lock
|
|
263
|
+
contract), and applications load _concurrently_: `serializeComponentLoad` serializes per application
|
|
264
|
+
name and all applications go into one `Promise.all`. Without this key two applications' `handleApplication`
|
|
265
|
+
for the same plugin would interleave on a single thread, not merely across threads.
|
|
266
|
+
|
|
267
|
+
The price of that key is that whatever a plugin does inside the lock is paid by every other application.
|
|
268
|
+
So a plugin must return a promise that settles with its real outcome: the `withDeployAwareTimeout`
|
|
269
|
+
watchdog exists for a _hang_, never as the reporting path for a failure the plugin already diagnosed. A
|
|
270
|
+
success-only wait is what turned one unparseable schema into 30s of instance-wide gating per broken
|
|
271
|
+
component (#1917). `Scope.waitForInitialLoads()` is that promise — it resolves once the entry handler's
|
|
272
|
+
initial scan and every operation that scan started have completed, and rejects with the first failure,
|
|
273
|
+
after draining the rest so no sibling operation outlives the lock. The watchdog can still cut that drain
|
|
274
|
+
short, so the serialization the lock buys is bounded by the timeout rather than absolute.
|
|
275
|
+
|
|
276
|
+
Extraction renames an existing component aside before writing the replacement and keeps it until
|
|
277
|
+
dependency installation and metadata verification complete. Any preparation failure atomically
|
|
278
|
+
renames the partial tree into hidden staging before restoring the prior tree, so a live writer cannot
|
|
279
|
+
wedge rollback with `ENOTEMPTY`; cleanup completes while the same-component lock is still held.
|
|
280
|
+
On non-root POSIX systems, rollback uses a mode-`000` placeholder to keep that writer out between
|
|
281
|
+
retries. Before moving or removing it, rollback verifies the placeholder's device/inode identity and
|
|
282
|
+
restores owner permissions because a cross-parent directory move updates `..` and requires write
|
|
283
|
+
permission on the moved directory.
|
|
284
|
+
The aside name is itself the recovery record: an `.in-progress-*` directory or symlink preserves a
|
|
285
|
+
previous tree, while an `.in-progress-*-prior-absent` file records that a first deploy must remove a
|
|
286
|
+
partial live tree after a crash. A sibling `.retired-*` marker records that the replacement committed.
|
|
287
|
+
Cleanup removes the recovery record before its marker, so an interrupted cleanup cannot make obsolete
|
|
288
|
+
state recoverable.
|
|
289
|
+
Component loading recovers unretired interrupted deploys before scanning the component root, and
|
|
290
|
+
preparation repeats recovery under the same-component lock before reading runtime metadata. A full
|
|
291
|
+
`drop_component` writes retirement markers before deleting the live tree and keeps its filesystem,
|
|
292
|
+
and configuration mutations under that lock, so cleanup residue cannot resurrect a dropped
|
|
293
|
+
component and a concurrent deploy cannot interleave with the drop. Peer replication begins after
|
|
294
|
+
the local lock is released, and each peer serializes its own drop independently. Full-component drops
|
|
295
|
+
rename the live tree into staging before best-effort cleanup, avoiding an in-place recursive-delete
|
|
296
|
+
race with the running worker. Recovery is durable across a process crash. It relies on rename/create
|
|
297
|
+
ordering rather than `fsync`, so a host power loss can lose the marker.
|
|
298
|
+
|
|
299
|
+
A package-manager timeout must not release this lock while npm descendants are still mutating `node_modules`. POSIX spawns therefore run in a dedicated process group; timeout sends the group `SIGTERM`, escalates to `SIGKILL`, and waits for exit before rejecting. Windows uses `taskkill /T /F` for the equivalent process-tree termination. `manageThreads` tracks each spawned process tree by its owning Harper thread and force-terminates it if that worker exits, preventing detached installers from surviving a worker restart or Harper shutdown. `SIGKILL`/`taskkill` only queue termination, so a worker's dead-owner reclamation (above) waits for that thread's tracked process groups to be confirmed gone, not merely signaled—otherwise a replacement preparation could start while the old writer might still be alive. A process group a dead worker's own event loop spawned is never reaped from another thread, so it persists as a zombie rather than fully disappearing; since a zombie can no longer touch the filesystem, confirmation treats a zombie the same as a fully reaped exit.
|
|
300
|
+
|
|
301
|
+
Boot's `harper-application-lock.json` records an application configuration only after preparation fulfills. Recording at queue time would make a failed install look complete and suppress its retry on the next boot.
|
|
302
|
+
|
|
303
|
+
Every npm install Harper invokes directly — automatic component installation and the deprecated
|
|
304
|
+
`install_node_modules` operation alike — composes its arguments in `packageManagerInstallArguments()`,
|
|
305
|
+
which is production-only and adds `--omit=dev --no-audit --no-fund`. `--no-audit` is load-bearing, not
|
|
306
|
+
hygiene: npm 10 puts even a `file:` link into its audit bulk request, and the registry's answer to that
|
|
307
|
+
is unbounded from Harper's side. The operation accepts the established `install_allow_scripts`
|
|
308
|
+
spelling (and `allowInstallScripts` for compatibility), defaulting to its historical `true`; false
|
|
309
|
+
reaches the shared builder and adds `--ignore-scripts`.
|
|
310
|
+
`installApplication()` skips the package-manager child entirely when the root manifest declares no
|
|
311
|
+
production dependencies, non-empty workspaces, or enabled install lifecycle. An explicitly selected
|
|
312
|
+
non-npm manager still runs so it can discover workspace configuration outside `package.json`, and it
|
|
313
|
+
retains its own install defaults. A configured `install_command` remains the explicit escape hatch for
|
|
314
|
+
build-time tooling, but not for lifecycle-script policy: unless `install_allow_scripts` is true, its
|
|
315
|
+
spawn gets `npm_config_ignore_scripts=true`, which covers npm nested anywhere in the command without
|
|
316
|
+
adding an argument that could break non-npm tooling. The setting uses npm's configuration namespace;
|
|
317
|
+
other package managers that consume `npm_config_*` options can honor it too. When the policy is
|
|
318
|
+
omitted, Harper warns that package lifecycle scripts—including `npm run` pre/post hooks—are suppressed
|
|
319
|
+
and names both the operations-API and root-config opt-ins.
|
|
320
|
+
`readInstalledPackageMetadata()` must use the same automatic-work predicate so a
|
|
321
|
+
dev-only npm manifest does not force a restart on every redeploy for lacking a lockfile while an
|
|
322
|
+
explicit non-npm workspace install still does. Absolute local archives are classified before
|
|
323
|
+
package-protocol detection: a Windows drive letter's colon is path syntax, not an npm protocol. File
|
|
324
|
+
type detection remains asynchronous in extraction. Bare absolute Windows directory inputs retain
|
|
325
|
+
npm's copy/pack behavior rather than becoming live links; explicit `file:` and relative directory
|
|
326
|
+
inputs retain their existing symlink behavior.
|
|
327
|
+
|
|
328
|
+
## Peer-side deploy_component payload read: retryable blob stalls and `Readable.from()` cancellation
|
|
329
|
+
|
|
330
|
+
`readPayloadBlobWithRetry` (`components/deploymentRecorder.ts`) wraps the peer's read of a replicated `hdb_deployment` row's `payload_blob` so a transient 503 `BlobReadError` (`BLOB_UNAVAILABLE_STATUS`, `resources/blob.ts`) — content bytes not arriving within `blobReadTimeout`, e.g. a parked blob send on the origin — retries instead of failing the whole deploy. Two non-obvious constraints shaped the design:
|
|
331
|
+
|
|
332
|
+
- **Retry is only safe before any byte has reached the consumer.** Once a chunk is handed downstream, re-opening `Blob.stream()` from byte 0 would duplicate it (there's no cheap way to resume from an arbitrary offset across a fresh stream without also plumbing `Blob.slice()`, which was out of scope for this fix). So the helper retries only while the current attempt has yielded nothing yet; a stall after partial content fails immediately, same as before this existed.
|
|
333
|
+
- **Backpressure and cancellation are two different problems, and both are easy to get wrong with a hand-rolled `ReadableStream`.** An early version wrapped the retry loop in `new ReadableStream({ async start(controller) { for await (...) controller.enqueue(chunk) } })` — this eagerly drains `streamFactory()` regardless of `controller.desiredSize`, defeating the whole point of not buffering a multi-GB payload in memory. Switching to an `async function*` consumed via `Readable.from()` restores real backpressure (the generator only resumes when the consumer wants more, matching how the un-wrapped `Blob.stream()` behaved pre-fix). But `Readable.from()`'s `return()`-on-destroy cancellation only takes effect at the generator's _next_ `yield` — while the loop is stuck retrying (no `yield` reached yet), destroying the `Readable` does nothing until the loop naturally exits, verified empirically (a generator that never yields kept retrying long after `.destroy()`). The fix: thread the constructed `Readable` back into the generator via a mutable cell (`readable` doesn't exist until after `Readable.from()` returns, so it can't be closed over directly) and check `.destroyed` explicitly at each loop iteration and after each backoff sleep.
|
|
334
|
+
|
|
335
|
+
## A dangling symlink silently truncates the deploy tarball (`components/packageComponent.ts`)
|
|
336
|
+
|
|
337
|
+
Packaging uses `tar-fs.pack(dir, { dereference: true })` by default (`skip_symlinks` off).
|
|
338
|
+
tar-fs's own walker calls `fs.stat` (not `lstat`) on every discovered entry when dereferencing; a
|
|
339
|
+
dangling symlink's target throws `ENOENT`, and tar-fs's `statAll` loop treats _any_ `ENOENT` from
|
|
340
|
+
a walk-discovered (not explicitly-requested) entry as end-of-stream — it calls `pack.finalize()`
|
|
341
|
+
immediately, silently dropping every entry still queued (BFS order) after the link. No error is
|
|
342
|
+
ever emitted, so `packStream.on('error', ...)` never fires and `deploy_component` reports success
|
|
343
|
+
on a truncated archive. `scanPackageDirectory()` now pre-walks the tree once (async) to build a
|
|
344
|
+
skip-set of dangling symlinks, which `streamPackagedDirectory`'s `tar.pack({ ignore })` consults via
|
|
345
|
+
a synchronous `Set.has()` — **`ignore` is called synchronously by tar-fs with no Promise support**,
|
|
346
|
+
so any fix here has to resolve the dangling set _before_ constructing `tar.pack`, not from inside
|
|
347
|
+
the callback (an earlier draft used `lstatSync`/`statSync` per entry there, which would have added
|
|
348
|
+
blocking I/O to a path that also runs inline on the Harper server's event loop via the
|
|
349
|
+
`package_component` operation). The scan recurses into _valid_ symlinked directories the same way
|
|
350
|
+
tar-fs's dereferenced walk does (readdir through the link), since a dangling symlink nested inside
|
|
351
|
+
one is just as capable of tripping the same early-finalize — skipping recursion into symlinked
|
|
352
|
+
dirs there would silently reintroduce the bug for that case. Circular directory symlinks are not
|
|
353
|
+
guarded against (in the scan or in tar-fs's own pack walk); that's a pre-existing tar-fs limitation
|
|
354
|
+
this fix doesn't attempt to solve. `deploy_component`/`package_component` still never validate that
|
|
355
|
+
declared entry points (`jsResource`/`graphqlSchema`) survived extraction — a truncation from some
|
|
356
|
+
other future cause would still report success silently; that's a deferred, separate fix.
|
|
357
|
+
|
|
358
|
+
## Deploy watcher generations preserve logical entry events
|
|
359
|
+
|
|
360
|
+
Component deploys pause each scope's `EntryHandler` while the component directory is replaced. A
|
|
361
|
+
new chokidar instance then performs a cold-style initial scan, which reports every surviving path
|
|
362
|
+
as `add`/`addDir` and cannot report paths that disappeared. Exposing those raw scan events changed
|
|
363
|
+
the public `scope.handleEntry()` contract in #1806: consumers could no longer distinguish an
|
|
364
|
+
unchanged file from a changed one, and deletions vanished entirely.
|
|
365
|
+
|
|
366
|
+
`EntryHandler` therefore owns the deploy boundary. It retains a compact snapshot of matching paths
|
|
367
|
+
(entry kind, URL path, and a SHA-256 content digest for files), assigns each watcher a monotonically
|
|
368
|
+
increasing generation, and compares the resumed generation's scan with the pre-pause snapshot. The
|
|
369
|
+
comparison emits only logical `add`, `change`, `unlink`, `addDir`, and `unlinkDir` events; unchanged
|
|
370
|
+
entries remain silent. File contents are still read once for the event payload and are not retained
|
|
371
|
+
in the snapshot. Reads and readiness are generation-scoped, and a per-path sequence prevents a slow
|
|
372
|
+
read from an obsolete event from overwriting a newer state. Missing paths are synthesized as unlink
|
|
373
|
+
events only after the resumed scan and all of its reads complete.
|
|
374
|
+
|
|
375
|
+
Every watcher recreation uses the same comparison. The first generation compares against an empty
|
|
376
|
+
snapshot and therefore retains its cold-load `add` behavior; deploy resume, configuration updates,
|
|
377
|
+
and polling recovery compare against the last completed generation. This keeps file identity intact
|
|
378
|
+
when watcher recovery could otherwise replay stale modules as new and ensures an update racing a
|
|
379
|
+
deploy scan cannot discard its removals. New component deploys still use `Application#isNewComponent`
|
|
380
|
+
to mark a restart as required for #674; other existing-component redeploys request a restart only when
|
|
381
|
+
their logical entry, loaded runtime, or configuration changes require one.
|
|
382
|
+
|
|
383
|
+
## Restart-free deploys require proof of runtime equivalence
|
|
384
|
+
|
|
385
|
+
`EntryHandler` intentionally observes only the files a component declares in its `files` option. It
|
|
386
|
+
cannot prove that the JavaScript runtime is unchanged: a watched `resources.js` can import an
|
|
387
|
+
unwatched `lib/db.js`, and installed dependencies live under the watcher's ignored `node_modules`
|
|
388
|
+
tree. Conversely, hashing the entire extracted tree treats unused source and generated caches as
|
|
389
|
+
runtime changes and collapses restart-free deploys back into unconditional restarts.
|
|
390
|
+
|
|
391
|
+
Runtime equivalence is therefore layered. A deploy can remain restart-free only when all three
|
|
392
|
+
layers it uses are proven equivalent:
|
|
393
|
+
|
|
394
|
+
- `EntryHandler` compares consumer-visible watched entries.
|
|
395
|
+
- `ApplicationScope` records the file URL and load-time digest of every application-local module
|
|
396
|
+
that Harper's VM or compartment loader reads, including application-local package imports and
|
|
397
|
+
package self-references, together with every application-local resolution edge. After a deploy,
|
|
398
|
+
those exact logical paths are re-read and each edge is resolved again against the replacement tree
|
|
399
|
+
after evicting Node's matching resolution-cache entry. Adding a higher-priority `foo.js` ahead of a
|
|
400
|
+
previously resolved `foo.json` is therefore a runtime change even when `foo.json` itself is
|
|
401
|
+
byte-identical. For import-only package exports that Node's CommonJS resolver cannot resolve, the
|
|
402
|
+
package manifest itself is recorded as a runtime input so an exports-map retarget is also observable.
|
|
403
|
+
- The deploy pipeline compares dependency metadata at the same preparation stage: the previous
|
|
404
|
+
installed tree before extraction versus the replacement tree after installation.
|
|
405
|
+
|
|
406
|
+
Loader or installer paths that Harper cannot observe are conservative. Full native module loading,
|
|
407
|
+
custom install commands, enabled install scripts, payloads that already contain `node_modules`, and
|
|
408
|
+
installs without deterministic lock evidence mark the runtime opaque; an existing component using an
|
|
409
|
+
opaque path requires a restart on redeploy. `harper deploy` omits `node_modules` by default; callers of
|
|
410
|
+
`package_component` that want restart-free comparison must likewise set `skip_node_modules: true`.
|
|
411
|
+
Npm dependencies delegated from the default VM loader to Node's native loader are instead covered
|
|
412
|
+
by the installed package/lock comparison—otherwise the default `dependencyLoader: auto` mode would
|
|
413
|
+
make nearly every application opaque. Explicit `dependencyLoader: native` remains authoritative;
|
|
414
|
+
an application-local import delegated by that setting marks the runtime opaque. `package.json` is compared as parsed JSON so formatting and
|
|
415
|
+
key order are irrelevant, while lockfiles remain exact installed-tree evidence. A module first
|
|
416
|
+
loaded while a deploy is in flight also invalidates the old runtime rather than letting a mixed
|
|
417
|
+
generation appear equivalent. This is deliberately proof-oriented: an unused new local file need
|
|
418
|
+
not restart a fully observed runtime, but a changed or missing imported helper, changed resolution
|
|
419
|
+
input, changed dependency evidence, or any genuinely opaque runtime does. Entry changes themselves
|
|
420
|
+
remain consumer-directed: the static plugin applies asset changes incrementally, while executable
|
|
421
|
+
consumers such as `jsResource` request a restart on their logical `change` or `unlink` events.
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# components/mcp/ — Design notes
|
|
2
|
+
|
|
3
|
+
The MCP Streamable-HTTP surface.
|
|
4
|
+
|
|
5
|
+
**Read this when:** adding or changing an MCP method, tool, prompt or profile.
|
|
6
|
+
|
|
7
|
+
Index of every design note: [DESIGN.md](../../DESIGN.md).
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## MCP protocol surface (`components/mcp/`)
|
|
12
|
+
|
|
13
|
+
The MCP Streamable-HTTP transport (spec `2025-06-18`) is served at `/mcp` under **two profiles**: an
|
|
14
|
+
_operations_ profile (mounted on the Fastify operations server) and an _application_ profile (mounted on
|
|
15
|
+
the Harper application HTTP server). Both share `transport.ts` (the JSON-RPC dispatcher) but differ in what
|
|
16
|
+
they expose — operations surfaces management operations as tools; application surfaces exported
|
|
17
|
+
Resources/tables. Profile gating runs throughout (`completeResourceArgument`, prompt visibility, tool
|
|
18
|
+
`visibleTo`), so when adding a method, decide which profile(s) it belongs to rather than assuming both.
|
|
19
|
+
|
|
20
|
+
A handful of design points are non-obvious and easy to break:
|
|
21
|
+
|
|
22
|
+
- **Per-call POST SSE streaming has a close-before-subscribe race.** A `tools/call` that opts into streaming
|
|
23
|
+
(`Accept: text/event-stream`) gets an `IterableEventQueue` whose frames the adapter consumes via the event
|
|
24
|
+
API (`on('data')` / `once('close')`), **not** `for await` — the async iterator does not terminate on
|
|
25
|
+
`'close'`. The streaming tool handler is therefore dispatched inside a `setImmediate` (a _detached_,
|
|
26
|
+
deferred task) so the adapter's consumer attaches **before** any frame is produced. Without the defer, a
|
|
27
|
+
fast handler emits its final frame + `close` synchronously; the queue buffers `'data'` but not `'close'`,
|
|
28
|
+
and the stream hangs. Any handler on this path must check `signal.aborted` first (cancellation can land
|
|
29
|
+
before the deferred task runs).
|
|
30
|
+
|
|
31
|
+
- **Server→client requests are correlated across workers.** `serverRequests.ts` lets a streaming `tools/call`
|
|
32
|
+
call _back_ into the client (`sampling/createMessage`, `elicitation/create`, `roots/list`) and await the
|
|
33
|
+
reply. The request frame rides **the call's POST SSE stream**; the client's response is a _fresh POST_ that
|
|
34
|
+
can land on **any worker**. The pending-promise registry is per-worker, so a response with no local match
|
|
35
|
+
is fanned out over ITC (`MCP_CLIENT_RESPONSE`) and the worker holding the promise resolves it (mirrors
|
|
36
|
+
`components/status/crossThread.ts`). Request ids are `srv-${randomUUID()}` — **not** a per-worker counter,
|
|
37
|
+
which would collide on `(sessionId, id)` across workers and misroute responses. Methods are capability-gated
|
|
38
|
+
(`METHOD_CAPABILITY`) against the client capabilities captured at `initialize`; the registry is bounded
|
|
39
|
+
(timeout + high-water-mark) so a non-responding client can't leak promises.
|
|
40
|
+
|
|
41
|
+
- **Application tools must be rebuilt after JS resources register, not just on schema changes.** The
|
|
42
|
+
application-profile tool scan (`registerApplicationTools`) runs at MCP component boot and on schema-change
|
|
43
|
+
ITC events — both of which fire while the `@table` classes register, **before** the `jsResource` plugin
|
|
44
|
+
registers the component's exported `class X extends tables.X` subclass. That subclass is the object the
|
|
45
|
+
registry ends up holding (REST routes to it) and the only place author opt-ins (`static mcpTools`/
|
|
46
|
+
`mcpPrompts`) live, so a scan that ran earlier sees only the base table class and misses them (#1448). The
|
|
47
|
+
fix: `jsResource` fires `signalResourcesRegistered()` (a deliberately **local-only**, non-ITC signal in
|
|
48
|
+
`utility/signalling.ts`, backed by `resourceHandler` in `server/itc/serverHandlers.js` — each worker
|
|
49
|
+
registers its own JS resources, so the rebuild belongs in that worker) after registration; `listChanged`
|
|
50
|
+
subscribes and re-runs the scan. Consequence: the verb tools (`create_*` etc.) now bind to the subclass and
|
|
51
|
+
honor its `post`/`patch` overrides, matching REST — previously they bound to the base table class and
|
|
52
|
+
silently bypassed those overrides. Advertised CRUD output schemas are still table-derived, so an overridden
|
|
53
|
+
write verb whose return diverges from `{ id }`/`{ ok }`/`{ deleted }` advertises a subset shape (the in-use
|
|
54
|
+
SDK tolerates supersets; tightening per-override envelopes is sibling-issue work — see the `derive.ts`
|
|
55
|
+
envelope note).
|
|
56
|
+
|
|
57
|
+
- **Resource subscriptions are row-backed via the audit log.** `resources/subscribe` resolves the URI to a
|
|
58
|
+
Resource and drives `Table.subscribe` off the audit-store `'committed'` path (same machinery as the
|
|
59
|
+
"Audit-store `'committed'` notification batching" section above). The targeting is the subtle part:
|
|
60
|
+
`getMatch` returns the matched Resource plus the remaining path on `relativeURL`, and `subscribeToResource`
|
|
61
|
+
sets **both** `request.id` (the record key, or `undefined`) **and** `request.isCollection` from it. A record
|
|
62
|
+
URI (`…/WorkItem/42`) watches that record; a collection URI (`…/WorkItem`, what `resources/list` advertises)
|
|
63
|
+
watches the whole table. `new RequestTarget(path)` parses an id out of the path on its own, so _both_ fields
|
|
64
|
+
must be overridden — otherwise a collection URI silently watches a phantom record named after the resource
|
|
65
|
+
and receives nothing. `harper://*` pseudo-resources are **list-changed-only** (not row-backed). Subscriptions
|
|
66
|
+
use `omitCurrent` (notify on change, not a retained snapshot — the notification just says "re-read this").
|
|
67
|
+
|
|
68
|
+
- **Subscribe requires a live GET stream; teardown is asymmetric.** `resources/subscribe` rejects (`-32602`)
|
|
69
|
+
if no GET SSE stream has registered the session — the audit-log iterator has nowhere to deliver, and there'd
|
|
70
|
+
be no `RegisteredSession` close hook to stop it. The GET `'close'` handler drops **subscriptions only**
|
|
71
|
+
(`dropSessionSubscriptions`), _not_ pending server requests: those ride the per-call POST stream, so a normal
|
|
72
|
+
GET reconnect must not reject an in-flight `ctx.serverRequest`. A `DELETE` (explicit session teardown) drops
|
|
73
|
+
**both**, because it may arrive with no open GET stream.
|
|
74
|
+
|
|
75
|
+
- **SSE resumability (`Last-Event-ID`).** Every GET-channel frame goes through `pushSessionFrame`, which
|
|
76
|
+
assigns a monotonic event id and appends to a bounded per-session `replayBuffer`. On reconnect with a
|
|
77
|
+
`Last-Event-ID` header, `replaySince` re-sends only the frames after that id. The event-id sequence **and**
|
|
78
|
+
the buffer carry across a supersede (a fresh GET replacing the old one for the same session id), so ids stay
|
|
79
|
+
monotonic and no frame is lost across a reconnect.
|
|
80
|
+
|
|
81
|
+
- **Test seams avoid loading thread/audit machinery in unit tests.** `_setSubscribeImplForTest`
|
|
82
|
+
(`resources.ts`) and `_setItcForTest` (`serverRequests.ts`) inject fakes so the unit suite needn't spin up
|
|
83
|
+
the audit log or ITC. Consequence: the subscribe **targeting** logic (`id`/`isCollection` derivation) is
|
|
84
|
+
_bypassed_ by the seam and is therefore covered at the **integration** level (`sse-listchanged.test.ts` N3
|
|
85
|
+
record / N4 collection), not in unit tests.
|
|
86
|
+
|
|
87
|
+
Two related traps: the create/schema-update path's exclusive `update-attributes` lock is a
|
|
88
|
+
synchronous bounded wait (`acquireUpdateAttributesLock` in `Table.ts`: brief hot spin, then
|
|
89
|
+
`Atomics.wait` backoff, retryable `ServerError` after the 10s `UPDATE_ATTRIBUTES_LOCK_TIMEOUT` — harper#2251; it used to be
|
|
90
|
+
an unbounded `while (!tryLock()) {}` spin that pinned a worker core forever if the holder never
|
|
91
|
+
released). Release is structural — `table()` releases in a single `finally` and `dropTable` uses
|
|
92
|
+
`withUpdateAttributesLock` — so a throw inside the locked window cannot leak the lock (regression
|
|
93
|
+
suite: `unitTests/resources/updateAttributesLock.test.js`). Because the acquire can now throw,
|
|
94
|
+
`table()` takes the RocksDB lock _before_ it mutates the live `Table` (attributes, class metadata,
|
|
95
|
+
index handles): losing the race then leaves this worker's in-memory schema exactly as it found it,
|
|
96
|
+
and moving any mutation above that acquire reintroduces schema drift the catalog never saw. LMDB
|
|
97
|
+
keeps the lazy acquire — its `exclusiveLock()` is an environment-wide write transaction that cannot
|
|
98
|
+
time out, so taking it eagerly would stall every write to the database on an unchanged reload. A
|
|
99
|
+
successful acquire that waited past `UPDATE_ATTRIBUTES_LOCK_SLOW_WAIT` (1s) warns once, since
|
|
100
|
+
contention is otherwise invisible until it becomes a timeout. The locked
|
|
101
|
+
sections MUST stay synchronous: the wait blocks the event loop, so an awaited operation inside
|
|
102
|
+
one would stall a concurrent acquirer to its deadline. And dropping then recreating a
|
|
103
|
+
same-named table within one process requires @harperfast/rocksdb-js >= the column-family
|
|
104
|
+
eviction fix (1.4.3 / rocksdb-js#<main PR>): older bindings keep the dropped column family's
|
|
105
|
+
by-name registry entry alive whenever other worker threads hold handles, so the recreate
|
|
106
|
+
silently reuses a dangling handle and every write fails with "Invalid column family specified
|
|
107
|
+
in write batch", poisoning the whole database env until restart. The regression suite for all
|
|
108
|
+
of this is `unitTests/resources/dropTableGhost.test.js` (it fails by design on pre-fix
|
|
109
|
+
bindings).
|
package/components/mcp/audit.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* (session id, profile, tool name).
|
|
7
7
|
*
|
|
8
8
|
* Argument summarization runs through a redaction step that drops anything
|
|
9
|
-
* that looks like a credential (
|
|
9
|
+
* that looks like a credential (secret/password/token). Operators who need
|
|
10
10
|
* stricter PII handling configure `mcp.audit.argumentRedactor` to a custom
|
|
11
11
|
* function via a future component-author hook (v1.1).
|
|
12
12
|
*/
|
|
@@ -25,17 +25,17 @@ export interface AuditEntry {
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
const REDACTION_PATTERN = /(secret|password|token|api[-_]?key|credentials?|auth)/i;
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
28
|
+
// Both secret tools carry all three fields, not just the ones each declares: MCP forwards arguments
|
|
29
|
+
// as-is and audits them after the handler, so a field the tool rejects is still logged. Mirrors what
|
|
30
|
+
// the REST operations log strips unconditionally (UNLOGGABLE_OPERATION_FIELDS).
|
|
31
|
+
const SECRET_VALUE_FIELDS: ReadonlySet<string> = new Set(['value', 'values', 'envelope']);
|
|
32
|
+
// Per tool, not global: `value`/`values` are ordinary params on many other operations.
|
|
33
|
+
const REDACTION_FIELDS_BY_TOOL = new Map<string, ReadonlySet<string>>([
|
|
34
|
+
['set_secret', SECRET_VALUE_FIELDS],
|
|
35
|
+
['set_env_value', SECRET_VALUE_FIELDS],
|
|
36
|
+
['add_ssh_key', new Set(['key'])],
|
|
37
|
+
['update_ssh_key', new Set(['key'])],
|
|
38
|
+
]);
|
|
39
39
|
const REDACTION_PLACEHOLDER = '[redacted]';
|
|
40
40
|
const MAX_REDACTION_DEPTH = 10;
|
|
41
41
|
|
|
@@ -46,23 +46,27 @@ const MAX_REDACTION_DEPTH = 10;
|
|
|
46
46
|
* a credential buried below the depth limit cannot leak. Returns a shallow
|
|
47
47
|
* clone — the caller's payload is never mutated.
|
|
48
48
|
*/
|
|
49
|
-
export function redactArgs(value: unknown, depth = 0,
|
|
49
|
+
export function redactArgs(value: unknown, depth = 0, redactionFields?: ReadonlySet<string>): unknown {
|
|
50
50
|
if (value === null || typeof value !== 'object') return value;
|
|
51
51
|
if (depth > MAX_REDACTION_DEPTH) return REDACTION_PLACEHOLDER;
|
|
52
52
|
if (Array.isArray(value)) {
|
|
53
|
-
return value.map((v) => redactArgs(v, depth + 1,
|
|
53
|
+
return value.map((v) => redactArgs(v, depth + 1, redactionFields));
|
|
54
54
|
}
|
|
55
55
|
const out: Record<string, unknown> = {};
|
|
56
56
|
for (const [k, v] of Object.entries(value)) {
|
|
57
|
-
if (REDACTION_PATTERN.test(k) || (
|
|
57
|
+
if (REDACTION_PATTERN.test(k) || redactionFields?.has(k.toLowerCase())) {
|
|
58
58
|
out[k] = REDACTION_PLACEHOLDER;
|
|
59
59
|
} else {
|
|
60
|
-
out[k] = redactArgs(v, depth + 1,
|
|
60
|
+
out[k] = redactArgs(v, depth + 1, redactionFields);
|
|
61
61
|
}
|
|
62
62
|
}
|
|
63
63
|
return out;
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
export function redactArgsForTool(value: unknown, tool: string): unknown {
|
|
67
|
+
return redactArgs(value, 0, REDACTION_FIELDS_BY_TOOL.get(tool));
|
|
68
|
+
}
|
|
69
|
+
|
|
66
70
|
/** Mask a session id for logging — first 8 chars, suffix elided. */
|
|
67
71
|
export function maskSessionId(id: string): string {
|
|
68
72
|
if (typeof id !== 'string' || id.length <= 8) return id;
|
|
@@ -79,7 +83,7 @@ export function emitAuditEntry(entry: AuditEntry): void {
|
|
|
79
83
|
const masked = {
|
|
80
84
|
...entry,
|
|
81
85
|
sessionId: maskSessionId(entry.sessionId),
|
|
82
|
-
args:
|
|
86
|
+
args: redactArgsForTool(entry.args, entry.tool),
|
|
83
87
|
};
|
|
84
88
|
harperLogger.info({ category: 'mcp.audit', ...masked });
|
|
85
89
|
} catch (err) {
|