Repository navigation
Stage a component build with activate:false and deploy it later by deployment_id - #2605
Conversation
…ployment id deploy_component gains two optional fields. `activate: false` builds, installs and certifies a release and then stops, leaving a dormant artifact under .deploy-staging; `deployment_id` makes that exact artifact live later, resolving, fetching and installing nothing. Both replicate as ordinary deploys, so every node stages and every node activates, with no cluster barrier. Four things make the delay safe, and each exists only because an immediate deploy did not need it: - The artifact directory is named by the PUBLIC deployment id, while the deploy-lifecycle token stays per-invocation. DeployLifecycle de-duplicates starts by the announced id and releases watcher suppression on the first matching end, so two overlapping activations of one artifact sharing that id would count as one owner. - .artifact.json is mandatory and versioned, written before .complete so the marker vouches for it. It carries the root-config entry the build would have published (explicitly null for a payload deploy), installationIsOpaque, and the admitted isolation intent — everything an activation cannot re-derive. An optional record could not tell a payload build from a package build whose record was lost. - Claiming an id is exclusive. The builder used to tolerate an existing deployment directory because a fresh UUID could not collide; a public id can be repeated, so a claim rejects another component's directory and any carrying .complete, and rebuilds only over an uncertified partial. The named id is also pinned through the preparation preamble, or retention would evict the artifact the request is about to use. - Staging owns its bytes: a file: directory source is refused, and so is any symlink resolving outside the built tree (bar the node_modules/harper links the loader owns). Certification follows no links and relocation repair leaves external targets alone, so a link out of the build is a hole only a delay makes reachable. Certification moves out of activateCandidateApplication into prepareApplication: markCandidateComplete fsyncs the whole candidate tree, and a delayed activation must not re-walk a node_modules it certified at build time while holding the preparation lock. An activation that fails before it commits now returns its artifact to dormant — the journal is removed and its parent synced after compensation restores the live tree — because otherwise the next preparation reads live-plus-candidate as an abandoned activation and deletes the artifact, or (first deploy) rolls it forward unasked. Root config for a staged package deploy is recorded with the artifact rather than published, so a staged release nobody activated cannot leave config naming it; the activation publishes it in the same order a normal deploy does. A payload artifact publishes none, so its isolation admission reads effective configuration rather than the saved bit. Known and accepted (#2315): a peer running a build without this change ignores activate:false and deploys immediately. Nothing in core or harper-pro's replicator carries a peer version, so the origin reports it after the fact — a staging node answers `staged: true`, and the origin fails the stage naming any peer that did not confirm. Refs #2315 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Root config: a delayed package activation published its entry before the swap and never took it back, so a pre-commit failure left config naming a release that was not live — and installApplications() would resolve and install that package from scratch at the next boot, over a component whose certified artifact was sitting beside it. publishRootConfig now returns its own undo, run when the activation fails while it is still compensable. Journal: the journal write sat OUTSIDE the pre-commit failure boundary, so a durable write that landed the file and then failed its parent flush escaped with the journal in place — and the next settlement destroys a certified artifact on that evidence, or rolls a first deploy forward after the operator was told it failed. It is inside the boundary now. Claim: an EEXIST whose owner could not be determined was rm -rf'd. An unattributable directory is either empty or a build in flight that has not written its sidecar yet, and the preparation lock does not span components, so that second case is another component's candidate. Reclaim now requires either emptiness or positive ownership. Isolation: an activation ran the early admission check against CURRENT config, which refuses the activation whose artifact turns isolation off for a component whose isolated state is itself the refusal. admitIsolation already re-derives from the descriptor under the lock; the early call skips activation mode. Links: the loader-owned exemption matched node_modules/harper at any depth, but the loader only repairs the component's own top-level link — so a nested dep/node_modules/harper could point anywhere and still certify. Peer confirmation: the message asserted an unconfirmed peer was serving the release. Unreachable and old look identical from the origin, so it now names both possibilities. Tests: the two compensation cases were vacuous — chmod 000 on the deployment directory made verification throw before any journal was written, so the compensate-then-return-to-dormant path was never exercised. They now block the aside staging directory, which fails the first step INSIDE the boundary. Adds coverage for the config undo, the unattributable claim, and the nested loader-link case, and an install counter to the integration test so "no rebuild" is proven by the install not running rather than by the tree matching. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The previous fixture made the aside staging path a regular file, which makes the preparation preamble's own lstat succeed, set recoveryPending, and throw EEXIST from ensureExtractionStagingDirectory — before verification, before publishRootConfig, before the journal. Every assertion passed vacuously, for the second time. Only one injection reaches the window: a read-only components ROOT. Reads still succeed so verification passes, the deployment directory and the lock root stay writable so the journal is written and later removed, and the rename whose parent is the root fails — B1 for an existing component, B2 for a first deploy. The rejection is asserted to be a permission error and NOT one of verification's own messages, which is what pins the window; the ~5s each case now takes is the rename retry budget, the same evidence from another angle. Also: default `project` from the working directory on a CLI activation, so `harper deploy deployment_id=<id>` is not the one deploy form that demands an explicit project; correct the retention-pin comment, which claimed the bound holds when a pinned build in the eviction tail leaves maxCount+1 until the next preamble; and trim the comments that narrated history or addressed the reviewer rather than stating a constraint. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
chmod is ignored by root and is not how Windows models this, so the read-only root that makes the rename fail does nothing in those environments — and the assert.rejects would then fail spuriously on a CI container running as root. The fixtures now probe whether a read-only directory actually denies this process a write, and skip when it does not, so an environment that cannot host the injection says so rather than passing vacuously or going red. Also trims the integration test's header comment to what the file is for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A staged deploy no longer drops its payload automatically. The tarball was reclaimed in the same terminal write that recorded `staged`, leaving the artifact as the only copy — and staged artifacts share deployment_stagingRetention_maxCount with incidental dormant builds, so the sixth dormant build of that component evicts it. Stage a 50 MB release, stage five more, and the first was unrecoverable with the row still reporting `staged`. An operator can still reclaim it deliberately. Deployment ids are lowercase-only. randomUUID() mints lowercase, but the regex accepted uppercase, and a case-insensitive filesystem resolves an uppercase id to the real directory while every string compare against it fails — the retention pin would not recognise the artifact the request named and could evict it, immediately at maxCount 0. Staging rejects an absolute symlink to its own tree. The target is inside the build, so the ownership walk passed it, but it names .deploy-staging/<id>/…, which activation renames away; relocation repair only re-points links under node_modules, so it would dangle in the release the stage certified. A stage whose peers did not confirm records the row as `staged`, not `failed`. The origin's artifact is dormant and activatable and the error text tells the operator to activate it, so a `failed` row kept it out of list_deployments status=staged. activated_from is registered in hdb_deployment's attribute list, as rollback_of already is, so it appears in describe_table. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A crash between publishing a package artifact's root-config entry and writing the activation journal left config naming the staged release, the live tree still on the previous one, and no journal to settle. The next boot's installApplications() then re-resolves and installs that package identifier from scratch rather than activating the certified artifact sitting in .deploy-staging — the exact substitution this step exists to prevent. The publish now runs inside activateCandidateApplication, immediately after the journal is durable, so the same crash is a roll-forward to the certified bytes. It is still inside the pre-commit boundary, so a failure there is compensated and the entry is taken back. readInstalledPackageMetadata moves inside that boundary too: an I/O error while comparing package metadata would otherwise have skipped the undo. A stage whose replication hard-fails now records `staged` as well — only the "did not confirm" path carried that marker, so a peer that failed outright left the row `failed` for an artifact that is on disk and activatable, and out of list_deployments status=staged. That error also said "was deployed on the origin node" for a request that deployed nothing. The claim's readdir tolerates ENOENT: a failed build under a duplicated id discards its whole directory, which is an empty directory by another name and should not surface as a raw filesystem error. New validation tests use node:assert, per AGENTS.md — matching the surrounding chai file is not an exemption — and cover the uppercase-UUID rejection. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ward from Moving the publish after the journal was not enough: between the journal and B1 the on-disk state is live-present, candidate-present, no rollback record, which settlement reads as an activation that never started — it deletes the deployment directory, and the next boot re-resolves the published package identifier from the registry. The journal alone does not select roll-forward; the displaced-tree record does. So the publish now sits between B1 and the commit rename, the one point where recovery finds live displaced, the candidate complete and the rollback record present, and rolls forward to the certified artifact. The remaining window is the inverse and smaller: a crash after that state exists but before the publish leaves the certified artifact live with config naming the previous release. Closing it needs config to be an effect of the journal, which is #2315 step 3. Staging no longer rejects absolute symlinks under node_modules. It should never have: repairRelocatedDependencyLinks re-points exactly that subtree after the swap, and npm writes absolute junctions there on Windows for a file:/workspace dependency — so a component that deploys immediately could never be staged on Windows. Outside node_modules the rejection stands, since nothing re-points those. A stage that completed locally records `staged` whatever fails afterwards, including a rejection thrown by the replication layer itself, which carried no marker of its own and left the row `failed` for an artifact on disk and activatable. returnToDormant tells the two failures apart: a journal that could not be removed means not retryable, while a removal that could not be flushed means retryable now but resurrectable by power loss. One message claimed the first for both. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…pair Round 6 exempted absolute links under node_modules because npm writes them on Windows for file:/workspace dependencies, and repairRelocatedDependencyLinks re-points exactly that subtree. But that repair runs PAST the commit point and can only warn: it logs and continues when a re-point fails, and logs and skips a whole subtree on EACCES/EMFILE. Relying on it means a component can go live holding a junction to a path that no longer exists while the operation reports "Successfully deployed". So staging refuses every absolute link into its own tree again, and the error now names the remedy and the Windows case, because that is the real cost: such a component deploys immediately but cannot be staged until its links are relative. A refusal naming the path beats a release that loads on POSIX and not on Windows. Also: the activateCandidateApplication docstring still said config is published immediately before calling it, which stopped being true when the publish moved inside; three test comments that restated the code they sat above are gone; and qa701-deployment-payload-ops' copy of TERMINAL_STATUSES, which declares itself a mirror, now includes `staged`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request implements the ability to stage a component build and activate it later (deploy_component { activate: false }). It introduces a mandatory, versioned .artifact.json descriptor to record build decisions, enforces exclusive claims on deployment IDs, and restricts external or absolute symlinks within staged trees to ensure integrity. The activation process is updated to swap the pre-certified artifact into the live path without rebuilding or reinstalling. A review comment suggests optimizing the staging process by caching the result of readlink to avoid duplicate filesystem reads.
assertOwnedArtifactTree validated where a link RESOLVES, not the expression it resolves through. A relative target that climbs out of the candidate and back in — ../../../<id>/<component>/shared — is inside the candidate today and names a path that does not exist once activation renames the tree, because the same expression is then evaluated from components/<component>/. repairRelocated- DependencyLinks cannot rescue it: it scans only node_modules, and its relative() resolves against cwd rather than the link's directory. The walk now tracks the link's depth below the candidate root and refuses any expression that ascends past it. The root-config undo ran one stack frame above the pre-commit boundary, after returnToDormant had already durably retired the journal — so an activation that failed at B2, compensated, and then died left config naming the staged release with no journal to roll forward, and the next boot re-resolved that package from the registry. The undo is now returned by afterJournal and run inside the boundary, before the journal is retired. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…undo fails The depth counter only saw `..` spelled in the link's own target, so an intermediate symlink defeated it: `a/b/up -> ..` followed by `a/b/link -> up/../../../<id>/<component>/shared` never dips the counter negative, and realpath lands inside the candidate, so both gates passed — while after the swap the same expression ascends to the components root and names nothing. Every prefix of the walk is now resolved with symlinks followed as the filesystem follows them, and each one has to still be inside the candidate. Covered by a test built from that counterexample. A root-config undo that itself fails (ENOSPC, EACCES) was logged and then followed by retiring the journal, leaving the old tree live, config naming the staged release, and nothing to roll forward — the substitution the publish ordering exists to prevent, reached by the one path that skipped it. A failed undo now keeps the journal, which is exactly the state it exists for. The stage confirmation read `peer.staged` off entries whose shape belongs to harper-pro's replicator; normalizePeerResult already tolerates two shapes, so assuming a flat body could make every peer in a fully-upgraded cluster read as unconfirmed. It now accepts the marker flat or wrapped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A crash between the durable journal write and the B1 rename leaves live present, the candidate present and no rollback record — which settlement reads as an activation that never started and answers by removing the whole deployment directory. Right for an immediate deploy, whose candidate came from a payload the operator still has. Wrong for a build somebody staged deliberately, whose payload may already have been reclaimed: it destroys the only copy and the operator is told to rebuild something they were told was ready. The descriptor is what distinguishes the two, and it is on disk precisely so recovery can. With one present, recovery now removes only the journal and leaves the artifact dormant — exactly the state deployment_id expects. This closes the crash edge that has been carried since round 4 as "needs step 1's recovery rule to change"; the rule did not need to change, only to notice the descriptor. An unknown, consumed, or foreign deployment id now answers 404 rather than the 500 a bare Error gets from the operations error handler. Naming an artifact that is not there is the caller's mistake, not a server fault. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
1 blocker, unchanged, still carried over from kriszyp's open threads (r4009459237, r4019827364): root config — isolation included — still isn't a journaled, durable effect, so a crash after roll-forward, or a failed config-undo for an existing component, can leave config naming a release that isn't live. Both threads remain unresolved; this push doesn't touch that code path, and only updates the test fixture to assert the divergence explicitly rather than close it. This push's two targeted fixes are correct and each has a new test that fails against the prior commit: link-target splitting now treats |
The peer-confirmation check is the only safety net for the mixed-version hazard #2315 records as accepted — a node running a build that predates staged deploys treats `activate: false` as an ordinary deploy and serves the release — and it had no test, so a regression in it would fail silently. Raised by the repo's review bot. Split the predicate out of deployComponent so it can be tested without a cluster, and cover the three things that matter: a fully-upgraded cluster is not reported as unconfirmed whichever shape harper-pro's replicator returns (flat, `value`, or `body` — assuming flat would misreport every peer), an old peer and a failed peer are both "did not confirm", and a missing or non-array aggregate reports nothing rather than throwing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…s links Two review findings from the staged-deploy protocol. A deployment directory was only attributed once the build wrote its candidate tree or its `.component` sidecar, so for the whole of resolve-and-pack — minutes for a git-reference package — it named nobody. A second component claiming the same id read it as empty and deleted it, then both builds shared the recreated directory. Ownership is now published as part of the claim, and an unattributed directory is refused rather than reclaimed: emptiness cannot distinguish a claim in flight from an abandoned one, and only the claimant's own preparation lock serializes it. `.complete` is a durability marker over the bytes, not a seal on them. A dormant artifact can be edited while it waits, and every check that made it safe to activate ran at stage time, so the ownership/link rule and the load validation are both re-run before the swap. The link rule is the one that must not be skipped: `repairRelocatedDependencyLinks` runs past the commit point and can only warn, so a link planted after staging would otherwise reach the live path with the operation reporting success. Re-running the load validation also gives the activate path the `prepare`/`done` event it emitted a `start` for and never closed. Content tampering is still undetected; that needs a manifest `.complete` is bound to, and the load validation is a no-op on the main thread until #2315 step 2. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…that cannot happen Round-11 review, three findings in the recovery and compensation paths. The branch that returns a staged artifact to dormant returns early and so reached none of the settled tail, including the step that clears an earlier failed recovery's `.unsettled`. An artifact left carrying that marker is refused by `deployment_id` and then deleted by the next retention pass as a stale unsettled build — the opposite of what returning it to dormant is for. The clear is now shared by both paths and keeps its fail-closed semantics. Keeping the journal when the root-config undo failed claimed recovery could roll the certified build forward. It cannot, for a component that already had a tree: compensation has just put that tree back and taken the rollback record with it, so the next settle reads live-plus-candidate-with-no-record and returns the artifact to dormant regardless. The journal is now kept only where it changes the outcome — a first-ever deploy, whose live path is absent — and the warning says which state the operator is in. The config-undo test was vacuous: a read-only root fails B1, so the publish it asserted about never ran. It now fails between the publish and the commit and asserts the publish happened. The undo also restored its snapshot unconditionally, so a `set_configuration` acknowledged while the swap was retrying could be overwritten. It now takes back only what it published, comparing values that went through the same write-and-parse round trip. Claiming an id no longer removes a directory it found absent: the exclusive `mkdir` decides instead, since another component can claim it between that read and the removal. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Round-12 review, two findings on the compensating paths. Settlement's dormant branch removed the journal and then flushed that removal with a throwing sync. A failure there is recorded by the caller writing `.unsettled`, so the artifact ended up marked unsettled with no journal left to settle it: unactivatable, and deleted by the next retention pass as stale. The flush is now best-effort, the same rule the settled tail follows — nothing may throw past the journal removal. An unflushed removal fails in the safe direction, since a power loss resurrects a journal that settles idempotently. The root-config undo compared against this process's config object, which `set_configuration` does not refresh when it writes the file, so the guard compared against a value that predated the very change it exists to protect. It re-reads from disk first. That narrows the window rather than closing it; serializing config publication is #2315 step 3. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ts config entry The comparison and its read-back had no test: every test touching the undo path supplies its own `publishRootConfig` to `prepareApplication` and never reaches the real closure. Split out the way `unconfirmedStagingPeers` was, so both ways it can regress are pinned — comparing against the entry as passed rather than as read back, and reading without refreshing first, which compares against a value that predates the change the guard exists to protect. No behavior change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The dormant return unlinked `.unsettled` and the activation journal and flushed both with one sync at the end. Crash ordering between two unlinks in the same directory is not the order they were issued in, so the journal's removal could persist alone — leaving a verdict no settlement will ever revisit, since settlement keys on the journal, and the next retention pass deletes the build as a stale unsettled one. The marker's removal is now flushed first; a failure there is safe, because the journal survives and the next start settles again. The settled tail needs no such barrier: it removes the whole deployment directory afterwards. This branch keeps it, which is the difference. Windows cannot fsync a directory, so the ordering is unenforced there. Recorded in DESIGN.md, because unlike the rest of this design — where a lost directory update degrades to a roll back — here it degrades to a deleted artifact. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…deleting it The durability barrier added for the dormant return cannot run on Windows, where a directory fsync is a no-op — so crash ordering there can still leave `.unsettled` with no journal, which the residue pass deleted as a stale build. Ordering cannot be the only defence. `fail()` only ever writes `.unsettled` beside a journal it keeps, so a marker with no journal says settlement finished and only the marker's own removal was lost. For a described artifact that is a build somebody staged deliberately, whose payload may already have been reclaimed; the pass now clears the verdict and retains it, which is the idempotent completion of the settlement that wrote it. An undescribed build in that state stays disposable, the rule that predates staging and still has its own coverage. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ding against `dormantBuildAfterClearingVerdict` reported a filesystem fault the same way it reported "not a retainable build" — as `undefined` — and the caller's other branch on that answer deletes. So a marker that would not clear deleted the certified artifact the branch had just been added to protect. The clear and the verdict are now separate: `clearStaleVerdict` says only whether the marker is gone, and a false leaves the artifact and its marker in place for the next pass. Cleared-but-still-not-a-build — incomplete or treeless — falls through to removal as before, so residue is still collected. Covered with a directory-shaped `.unsettled`, the same corruption this code already reasons about for `.activation.json`: it fails the non-recursive removal on every platform while leaving the deployment directory removable, which is what separates retention from deletion. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ing quiet The previous commit stopped an unclearable `.unsettled` from deleting the artifact it was attached to, but returned without recording anything. The marker survives, every worker fails the component closed on it, and main reporting no failure is exactly the split the settled tail throws to avoid: main serves what every worker refuses. The residue path now fails the component too, so both reach the same verdict and the artifact waits for the next pass. That also collapses the duplicate helper: `clearUnsettledVerdict` is the one clear for both paths, and its flush is the barrier the dormant return needed between its two unlinks. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…version string Documentation only; no behavior change. The window between the roll-forward state becoming durable and the root-config publish was recorded as "config names the previous release", which reads like a version mismatch. It is not only that: the staged entry carries the isolation the build admitted, `rollForward()` publishes nothing, and a component staged to run isolated therefore comes back non-isolated after an ordinary crash, with nothing in the operation reporting it. Isolation is a containment boundary, so the window should be weighed by that. Still #2315 step 3's to close; raised again on the review thread that is waiting on a fix-or-gate decision. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Found by exercising a live server: a component whose tree links outside itself, and a `file:` directory asked to be staged, both reached the caller as a bare 500. Neither is a server fault. Staging refusals are the operator's input — the same component deploys immediately — so they are 400; an activation refused because the artifact it named is no longer what was certified is 409, since the artifact exists and belongs to that component. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A second live pass found the rest of the family: claiming an id another component holds, or one that already carries a completed build, or one whose directory is claimed but not yet attributed, and every artifact-descriptor rejection — unreadable, wrong version, wrong component, missing runtime decisions, or an isolation intent that disagrees with what it publishes — all reached the caller as a 500. They are conflicts over on-disk state the caller named, so they are 409, which is what the first two live findings settled for this family. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…s code
`ClientError(message, statusCode)` is the established convention and is already
what `deploymentOperations.ts` uses in this branch for the same kind of
operator-caused refusal. Five hand-rolled `Error & { statusCode?: number }`
helpers here become one import, including the 404 family that predates the
status-code work.
No behavior change: every code re-verified against a live server.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every activation-state refusal was a 404, so "no artifact with that id here" and "the artifact is present but not something this can activate" were indistinguishable to a caller — the reviewer raised it across three rounds, and it is the same question an operator asks after a retry. Only the absent case stays 404 now; a wrong owner, an incomplete build, an unsettled verdict, an in-flight journal, a missing tree and a missing descriptor are 409, matching the rest of that family. `clearUnsettledVerdict` also announced that it had "settled the interrupted activation" when the residue path calls it on a journal-less stale marker, where no activation was settled at all. It now says what it did. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… left behind Two findings from @kriszyp. A backslash is only a separator on Windows. On POSIX it is an ordinary filename character, so splitting a link target on it turned `..\asset` — one legal entry inside the candidate, which keeps resolving there after relocation — into `..` plus `asset`, walked out of the build, and refused a component that never left its own tree. Link targets are now split by the rules of the platform they are on. Confirmed: the new test fails on the previous commit with exactly that false escape. Claiming an id wins an exclusive `mkdir` and then records who owns it, and a failure between the two — a full disk, an EIO on the temp write or its sync — left an unattributed directory that every retry of the same id is refused by, permanently, since unattributed is a deliberate permanent refusal. The claim now takes back the directory it created when it cannot name it, scoped to that directory alone: one found by EEXIST may be another claimant's, and removing that is the race the refusal exists to prevent. The write is a parameter so the failure is testable, the way `publishedEntryStillStands` took its accessors. The config-divergence fixture now asserts the divergence rather than checking only trees and journals: the published entry is pinned as still standing while the previous release is live. Whoever closes #2315 step 3 should find that assertion inverted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
kriszyp
left a comment
There was a problem hiding this comment.
Excellent!
🤖 Reviewed with Codex
deploy_componentgains two optional fields.activate: falsebuilds, installs and certifies a release and then stops, leaving a dormant artifact under.deploy-staging;deployment_idmakes that exact artifact live later, resolving, fetching and installing nothing. The work that takes time and can fail happens when the operator chooses, and the cut-over itself is two directory renames. Both calls replicate as ordinary deploys, so every node stages and every node activates — no cluster barrier, and #2301's stage/activate protocol stays retired.flowchart TB REQ["deploy_component"] REQ -->|"package / payload"| DEP["mode = deploy<br/>publish root config, build, certify"] REQ -->|"package / payload<br/>+ activate: false"| STG["mode = stage (new)<br/>same build, but no file: directory sources<br/>and no links that leave the tree"] REQ -->|"deployment_id"| ACT["mode = activate (new)<br/>verify the artifact and its descriptor<br/>nothing is resolved, fetched or installed"] STG --> CMP2["write .artifact.json, then .complete"] CMP2 --> DORM(["dormant artifact, nothing swapped<br/>.deploy-staging/<deployment_id>/<component><br/>returns status staged + deployment_id"]) DORM -.->|"later, surviving restarts"| ACT DEP --> JRN ACT --> JRN subgraph SWAP["swap"] JRN["write .activation.json"] --> B1["1. live moved to .deploy-aside"] B1 --> PUB1["publish root config (activation only)"] PUB1 --> B2["2. candidate renamed to live — commit point"] end B2 --> DONE["returns status success + deployment_id<br/>(an activation's row also records activated_from)"] classDef added stroke:#2da44e,stroke-width:3px class STG,ACT,CMP2,DORM,PUB1 addedGreen marks what this PR adds; everything else is the path a deploy already took.
The mode is chosen from the request, validated so an activation carries no build inputs, and the activate path branches around every payload, credential and install step — without that branch a replicated activation looks exactly like a blob-backed deploy and every peer waits out
deployment_timeoutfor a payload its row never had.A staged artifact outlives an ordinary deploy of the same component, so staging is also how a release becomes revertible: deploy V1, stage V2, deploy V3, and activating V2's id later puts V2 back. What cannot be recovered is a version that was only ever deployed — its artifact is consumed by the swap and the tree it displaced is swept — which is what #2315 step 5 is for.
Every refusal on these paths carries a status code rather than reaching the caller as a 500, through the same
ClientErrorthe rest of the deployment operations use: 400 for a component that cannot be staged at all (afile:directory source), and 409 for a conflict with what is on disk — a deployment id already taken, an artifact whose descriptor this build cannot activate, one whose tree stopped being what was certified, an incomplete build, an unsettled verdict, an in-flight journal, or another component's artifact. 404 now means one thing only: nothing on this node answers to that id, so a caller can tell "never existed, or already used" from "present, but not activatable".This is step 6 of #2315. Step 4 bounded how many unactivated staged builds accumulate; nothing on
mainproduced one, so its integration test had to plant them. This is the producer.Four things make the delay safe, and each exists only because an immediate deploy did not need it. The artifact directory is named by the public deployment id while the deploy-lifecycle token stays per-invocation, because
DeployLifecyclede-duplicates starts by the announced id and releases watcher suppression on the first matching end — two overlapping activations of one artifact sharing that id would count as one owner. A mandatory versioned.artifact.jsonis written before.complete, carrying the root-config entry the build would have published (explicitlynullfor a payload deploy),installationIsOpaque, and the admitted isolation intent; an optional record could not tell a payload build from a package build whose record was lost. Claiming an id is exclusive, because a public id can be repeated where a minted UUID could not. Claiming an id publishes ownership as part of the claim rather than at certification, and an unattributed directory is refused, never reclaimed: a build can hold an empty directory for minutes while it resolves and packs, so an emptyreaddircannot tell a claim in flight from an abandoned one. And staging owns its bytes: afile:directory source is refused, and so is any symlink whose walk leaves the built tree at any prefix — certification follows no links, and the post-swap relocation repair runs past the commit point and can only warn.Certification moved out of the swap primitive into
prepareApplication, so a delayed activation does not re-walk and re-fsync anode_modulesit certified at build time while holding the preparation lock. An activation that fails before it commits returns its artifact to dormant — journal removed, its parent synced, and the published root-config entry taken back — because otherwise the next preparation reads live-plus-candidate-plus-journal as an abandoned activation and deletes the artifact, or rolls a first deploy forward unasked.For the human reviewer
The planning gate never cleared on one axis, and it is the first thing to rule on.
Mixed-version clusters: detection, not prevention — settled, not open. A stage replicates as an ordinary
deploy_component, so a peer running a pre-5.3 build ignoresactivate: falseand deploys the release immediately. The origin reports it afterwards from astaged: truemarker each node returns; nothing in core or in harper-pro's replicator carries a peer version or capability, so it cannot refuse in advance. The reviewer's alternative — replicate the stage as a distinct operation an old peer rejects as unknown — was declined by @dawsontoth: Harper clusters are kept at consistent versions, the UI will steer operators away from the mixed case, the docs will state the prerequisite, and adding a second wire path to cover a case that does not occur in practice would encumber the 99.9% path with more code that can break. The planning gate'sbetter-alternative-existsverdict on this axis is recorded as overruled by the owner, not carried as open. Reversing it later is a wire-format change, not an API change: the two request fields stay as they are.Where the root-config publish sits, and the window that remains. A package artifact's entry is published between B1 (the live tree moved aside) and the commit rename — the only point where the on-disk state recovery would find rolls forward to the certified artifact. Publishing any earlier, journal or no journal, leaves live present and the candidate present with no rollback record, which settlement reads as an activation that never started: it deletes the artifact and the next boot re-resolves that package from the registry instead. Three review rounds landed on this from different angles before it was right, so it is the first thing to check if you disagree. The window that remains is the inverse — a crash after the roll-forward state exists but before the publish leaves the certified artifact live under the previous release's config, because
rollForward()publishes nothing. That includes its isolation intent, which is a containment boundary rather than a version string: a component staged to run isolated comes back non-isolated after an ordinary crash, with nothing in the operation reporting it. Closing it needs config to be an effect of the journal, which is step 3 — and that, or gating delayed activation to payload artifacts until step 3 lands, is the open call on @kriszyp's thread.A component with absolute or escaping internal symlinks cannot be staged. Staging refuses any link whose walk leaves the built tree at any prefix, including absolute ones under
node_modules— where npm writes them on Windows forfile:/workspace dependencies. The alternative was to trustrepairRelocatedDependencyLinks, but that runs past the commit point and only warns (it logs and continues on a failed re-point, and skips a whole subtree onEACCES/EMFILE), so a component could go live holding a link to a path that no longer exists while the operation reported success. The cost is that such a component deploys immediately but cannot be staged until its links are relative; the error says so. The same rule is re-run at activation rather than trusted from.complete, which is a durability marker over the bytes and not a seal on them — a dormant artifact can be edited while it waits.Staged artifacts share
deployment_stagingRetention_maxCountwith incidental dormant builds, and only the in-flight request is pinned — so an operator's staged release can be aged out by unrelated activity. One filter inpruneDormantBuildsif you want them to have their own lifetime.A dormant artifact's contents are not sealed, only its links are re-checked. Activation re-runs the link rule and the load validation before the swap, but
validateComponentLoadsis a no-op on the main thread and the operations API deploys there, so an artifact whose files were edited while it sat dormant still activates. Detecting that means binding.completeto a manifest or digest of the tree — a whole-tree hash at stage time and again at activation, on a path where that cost is affordable. @kriszyp asked for it; I did the link and load re-checks he named as the minimum and left the manifest out, because what it should cover (every file? the installed tree? the descriptor too?) and how it interacts withrepairRelocatedDependencyLinksrewriting links past the commit point are design questions, not a patch.A deployment id names one artifact only while that artifact exists. Activation consumes it (the swap is a rename) and retention can prune it, after which the id is free to name different bytes. Durable identity after removal would need a tombstone store with its own retention and recovery. Cheap to add later; impossible to remove once operators script against the stronger promise.
A redelivered stage is refused, not answered idempotently. A replayed replication hits the
.completerefusal and that peer records a failed deploy for an artifact that is staged and usable. Idempotent success was available and is what the reviewer preferred. I kept the refusal because_deploymentIdis operator-forgeable, and answering "success" for bytes that were never staged is the worse failure. Backward-compatible to change later.stagedis a terminal status on its own row, and activation gets a second row linked byactivated_from, rather than one row transitioning staged→success. This is what makesdelete_deployment_payloadand SSE tails behave, but it is the shape operators will script against, and it is hard to reverse after release.The config undo is best-effort, and for an existing component a failed undo strands config.
addConfigdoes not fsync and there is no publication lock, so a failed activation restores the previous entry without durability guarantees — that is Staged component deploys: land build-aside-then-swap as a sequence of small PRs #2315 step 3's work, deliberately not started here. It takes back only what it published, re-reading from disk first becauseset_configurationwrites the file without refreshing this process's config object, so a change acknowledged while the swap was retrying is not overwritten by a stale snapshot. That narrows the window rather than closing it. An undo that fails keeps the journal only for a first-ever deploy — the one shape recovery can still roll forward from, because the live path is absent. For a component that already had a tree, compensation has just put that tree back and taken the rollback record with it, so the next settle returns the artifact to dormant whatever the journal says: config names the staged release, the previous one is live, and an operator has to resolve it.A partially successful clustered activation cannot converge by retrying the same id. The origin activates locally before peer fanout, and the swap consumes its staged directory — so if this node activates and a peer fails, retrying the same
deployment_idis refused here (nothing is staged under it any more) before the retry ever reaches the peer that still holds the dormant artifact. The operator's route back is a fresh deploy, not a retry. Making the retry converge means either deferring the local swap until peers confirm, which gives up "every node activates the same way it deploys", or answering adeployment_idthis node already activated as success, which needs the deployment row to be authoritative for what is live. Neither is small; both are cheaper before release than after. A local run turned up the same gap without a cluster: crash a first-ever activation after its journal lands, and recovery rolls the artifact forward at the next boot — the component comes up live from exactly those bytes, and the operator's retry of thatdeployment_idthen answers 404, because nothing is staged under it any more. Their activation succeeded; what they are told is that it never existed.Smaller carried items: concurrent deploys of different components can over-admit the global isolated-worker budget (adjudicated pre-existing — the same race
beforePreparealready had);activated_fromis registered for fresh installs only, matching every other attribute onhdb_deployment; an activation re-walks the artifact tree under the preparation lock, measured by a reviewer at 11,615 directories and 148 links in 380ms warm; every refusal of adeployment_idis a 404, so a caller cannot tell "never existed" from "already used"; and a crash inside the claim window — between the exclusivemkdirand the sidecar write, microseconds with only anlstatbetween them — burns that deployment id and leaves an empty directory nothing reclaims, since refusing an unattributed directory is what removed the only self-heal that path had. A claim that cannot record who owns it now takes back the directory it made, so only a process death in that window — not an ordinary write failure — burns the id. Closing what remains needs claims serialized on the staging root; a staleness threshold would contradict this code's rule against wall-clock verdicts.Verification
Unit tree — run per directory rather than as one
test:unit:main, becauseunitTests/components/applicationSpawn.test.jswedges on this machine at a process-group termination test (it wedges identically at the merge base, and the diff touches zero lines of that code), and.mocharc.jsonsetstimeout: 0, so one hang swallows the whole run. Excluding that file: resources 2500 pass, components 1662 pass / 5 fail, server 960 pass / 10 fail, utility 759 pass, config 314 pass / 1 fail, validation 260 pass / 1 fail, bin 247 pass, sqlTranslator 67, upgrade 19.Every one of those failures is pre-existing. The same three directories were run at the merge base (
3ece37294) for comparison:serverandconfighave identical failure sets, andcomponentsfails 16 at base against 5 here — every branch failure is also a base failure, none the other way. The named failures areRuntimeModuleTracker,nonInteractiveSpawn git credential scoping,uWS UDS adapter, process-group reclaim ordering, package-specifier resolution andRootConfigWatcher; two are explained outright by this environment —validation's isgetDomainSocketPathLengthWarning, which this worktree's own path length trips, anddataLayer's suite fails to load at all withoutHARPER_STORAGE_ENGINE=lmdb.Live server, not only tests. A Harper 5.3.0-alpha.1 instance on an isolated data root, driven through the operations API, with three builds of a component serving
{"build":"V1|V2|V3"}over REST plus a real component (threeas a dependency). A normal deploy returned itsdeployment_id;activate: falseleft V1 serving and wrote.artifact.json/.complete/.componentbeside the tree; the artifact survived a full Harper restart; activating it made V2 live carrying a sentinel file planted in the dormant tree, which is what proves the staged bytes were swapped rather than rebuilt;restart: trueon the activation brought the server back serving V3. On the real component the split was 1.7s to stage, 0.1s to activate, with a sentinel written inside the stage-timenode_modules/threepresent in the live tree.Refusals were exercised the same way: a consumed id, an unknown id, another component's artifact (preserved),
restartwithactivate: false,deployment_idalongsidepackage/payload, a non-booleanactivate, a non-UUID id, an id already holding a completed build, an id another component owns, an empty unattributed claim directory (refused and left untouched — the concurrent-claim fix), an artifact whose tree was removed, and five malformed descriptors including one whose isolation intent disagreed with what it publishes. An artifact tampered with after staging —ln -s /etcplanted in the dormant tree — was refused and preserved. Retention settled at maxCount + the pinned in-flight build and stayed there across further stages;drop_componentreclaimed that component's artifacts and left its neighbour's alone; a stage whose package could not resolve left no residue;delete_deployment_payloadon a staged row succeeded and the artifact still activated afterwards, which is the point of certifying at stage time. SSE phases are symmetric for both calls (prepare/start → prepare/done → replicate → staged|success).Recovery was driven on a real boot by planting crash states and restarting: an interrupted activation returned its artifact to dormant ("Returned the staged build … to dormant after an activation that never moved its live tree aside"), and a described artifact carrying a stale
.unsettledwith no journal had the verdict cleared ("Cleared a stale unsettled verdict … its activation was already settled"). Both artifacts activated successfully afterwards. Two rounds of this exercise are what produced the status codes above; nothing else it found needed a code change.Docs: HarperFast/documentation#670.
Complexity: complicated
Review-Coverage: authored=claude; ran=gemini,codex,cursor-grok,cursor-composer; adjudicated=domain; rounds=20; full=8 @ e7e1a91
Human-Review-Need: 4 (decisions: exported-for-testability, caught-failures-reclaim-crashes-do-not, one-404-class-for-missing-and-consumed, isolation-loss-documented-not-closed, artifact-consumed-by-rename, failed-clear-fails-closed, fail-open-peer-confirmation, dangling-internal-links-fail-closed) @ e7e1a91