Skip to content

feat(deploy): two-phase stage/activate for deploy_component (draft/RFC)#1849

Draft
dawsontoth wants to merge 26 commits into
mainfrom
claude/deploy-component-two-phase-94969a
Draft

feat(deploy): two-phase stage/activate for deploy_component (draft/RFC)#1849
dawsontoth wants to merge 26 commits into
mainfrom
claude/deploy-component-two-phase-94969a

Conversation

@dawsontoth

@dawsontoth dawsontoth commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Draft / RFC. This splits deploy_component into two replicated phases and exposes each as a new operation. It preserves the existing deploy_component contract (same inputs/response/SSE) — the goal is more reliable cross-cluster deploys with a shorter, synchronized downtime window. Naming and the mixed-version story are the two things I'd most like eyes on.

Why

Today the whole deploy runs as one replicated operation. On every node, extract + npm install (the slow, failure-prone work — git clone, registry install) happen in place in the live component directory, right before restart. The only atomic bit that exists (extractApplication) just renames the old dir aside; the new version is still built in place. There is no cluster-wide barrier — a peer can fail npm install and be left half-baked while other nodes have already restarted onto the new code. Nodes flip independently.

What this does

Two phases, each a distinct replicated operation that deploy_component orchestrates:

Phase New op Does Does not
1. Stage stage_component download/npm pack (incl. git clone), extract, npm install into a hidden .deploy-staging/<name>/<deploymentId> dir on every node touch the live dir, write config, restart
2. Activate activate_component atomic rename(staging → live) + restart; persist root config for a package deploy at go-live re-fetch/re-install anything

deploy_component now: stage on origin → replicate stage_component to peers → wait for every node to report a good stage (the barrier) → replicate activate_component. If a node can't fetch the package or fails npm install, it fails during staging and the live component is untouched on every node. Go-live shrinks to a fast atomic swap.

  • Backwards compatible. Same request fields, same deployment_id in the response, same SSE stream (phase names change: stage/activate instead of prepare/replicate, and the generic CLI renderer already handles any phase name). two_phase: false forces the legacy one-shot path, which is preserved verbatim (deployComponentOneShot).
  • Single-node benefits too: npm install runs off to the side, so the live component keeps serving during a long install and go-live is just the swap.

On the names (please weigh in)

Went with stage_component / activate_component — a crisp verb pair that reads well in the stream (staging…, staged on 3/3, activating…). Considered promote (release/blue-green flavor) and commit (2PC flavor, but overloaded). Easy to change now, painful after release.

Why staging lives under the components root (not os.tmpdir())

Go-live is rename(stagingDir, liveDir), atomic only when both share a filesystem. os.tmpdir() is frequently a different mount → EXDEV → slow recursive copy at the exact moment you want an instant swap. So staging is a hidden .deploy-staging dir under the components root: same volume (atomic swap), dot-prefixed so the loader ignores it, and not the watched base of any component's file watcher — so building there fires no restart-on-change events and needs no deploy:start suppression (that's now scoped to the activate swap only).

Key implementation notes

  • extractApplication/installApplication build into application.buildDirPath, which defaults to the live dir — so the one-shot path, boot-time installApplications, and direct extractApplication callers are unchanged. stageApplication repoints it at staging for the duration of a stage; activateApplication swaps and resets it.
  • Staging is deterministic from the deployment id so activate_component (a separate op, and on peers a separate invocation from stage_component) can find what the stage built.
  • New hdb_deployment statuses staging / staged / activating; a standalone stage_component leaves the row in staged (build exists cluster-wide, nothing live yet).

Tests

  • unitTests/components/deployStaging.test.js — stage/activate/discard primitives end-to-end on the real filesystem (staging doesn't touch live; atomic swap; old version moved aside; failed stage leaves live untouched; non-colliding staging dirs). 6/6 green locally.
  • unitTests/components/deployPhaseValidators.test.js — the two new validators + the two_phase flag. 13/13 green locally.
  • Updated the deploy_component unit tests to assert the two-phase path (stage + activate) instead of the old in-place prepareApplication.
  • No regressions in the existing extraction/install tests (gitSemverCommittish 8/8, gitCredentialClone 6/6, packageComponent 6/6).

Update — the four open questions are resolved

  1. Mixed-version clusters → not a concern. Clusters stay in lockstep on their Harper version, so every node understands the new ops. The rolling-upgrade caveat and capability-negotiation framing are removed. (two_phase: false + the system-not-replicated fallback remain, but only for those specific cases, not version skew.)
  2. Replicator contract → validated against harper-pro. Read harper-pro/replication/replicator.ts: replicateOperation fans the op to server.nodes, sets replicated = false as the peer re-fan guard, surfaces per-peer {status:'failed', reason, node} (never throws), authenticates node-to-node by TLS cert, and runs the received op via server.operation(data, {user}, !isAuthorizedNode)authorize = false for a trusted node, so a replicated super-user op skips the permission gate. My stage/activate/revert ops (same permission(true,[]), dispatched by name) are structurally identical to the long-proven deploy_component fan-out. Documented in DESIGN.md.
  3. Activate-phase failure → reversible, via a third operation. activate now retains the outgoing version as .deploy-previous/<name> instead of discarding it, and a new revert_component swaps live ↔ previous cluster-wide (bidirectional — reverting a revert rolls forward). This unlocks customer-driven rollback (deploy → run your own health checks → revert if unhappy, even on a healthy-looking cluster) and an opt-in revert_on_failure on deploy_component that rolls the whole cluster back when the activate phase leaves it split across versions.
  4. CLI commands → added. harper stage (packages + uploads like harper deploy, no go-live), harper activate, harper revert — with SSE progress.

What changed in this round

  • revertApplication primitive + revert_component operation (validator, enum, authorization, SSE, reverting status).
  • activateApplication retains one previous per component (.deploy-previous/<name>, older evicted); deploy_component gains revert_on_failure.
  • CLI: stage / activate / revert aliases in bin/cliOperations.ts.
  • +12 tests (retention + bidirectional revert primitives, revert_component end-to-end, revert/revert_on_failure validators) — 39 deploy unit tests pass.

Still deferred

  • Auto-revert-on-partial-activate is opt-in (revert_on_failure), best-effort, and not the default — a policy call for review.
  • Deployment-history linkage for revert is filesystem-driven (swap to the retained previous) rather than tied to a specific deployment_id beyond the audit rollback_of.

🤖 Generated with Claude Code

Split deploy_component into two replicated phases so a cluster deploy is
all-or-nothing at go-live, and expose each phase as a first-class operation.

- stage_component (phase 1): build the incoming version — download/npm pack
  (incl. git clone), extract, npm install — into a hidden `.deploy-staging`
  dir on every node. Never touches the live component dir, writes no config,
  never restarts, so it's safe to run cluster-wide and gate on.
- activate_component (phase 2): atomically rename the staged copy into the
  live path and restart; persist root config for a `package` deploy at go-live.
- deploy_component orchestrates stage -> (barrier: every node staged OK) ->
  activate. Request/response contract unchanged; SSE now emits stage/activate
  phases. `two_phase: false` forces the legacy one-shot path, preserved verbatim
  for opt-out, for peers replaying a one-shot deploy, and when `system` isn't
  replicated.

Application.ts gains stageApplication/activateApplication/discardStagedApplication;
extract/install now build into `buildDirPath` (defaults to the live dir, so the
one-shot path, boot install, and direct extractApplication callers are unchanged).
Staging lives under the components root — same filesystem — so go-live is an atomic
rename (an os.tmpdir() location risks EXDEV and a slow copy at the worst moment).

Adds unit tests for the stage/activate/discard primitives and the new validators;
DESIGN.md documents the model and the atomic-rename/filesystem tradeoff.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a two-phase deployment process (stage then activate) for components to ensure all-or-nothing cluster-wide deploys. Staging builds the incoming version into a hidden staging directory, while activation atomically swaps it into the live path. The review feedback highlights two critical issues in components/Application.ts: first, recursively deleting the parent staging directory during cleanup can accidentally destroy parallel staged builds for the same component; second, using access to check directory existence follows symlinks, which will fail on dangling symlinks and cause subsequent operations to throw EEXIST. Both comments provide actionable code suggestions to resolve these issues.

Comment thread components/Application.ts Outdated
Comment thread components/Application.ts
Comment thread components/Application.ts Outdated
Comment thread components/operations.js Outdated
@claude

claude Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Reviewed; no blockers found.

dawsontoth and others added 3 commits July 17, 2026 15:33
…ling-safe cleanup

Three fixes to the two-phase staging path:

- Create the per-deploy staging parent before extraction. For a `package`
  deploy the first filesystem touch is the `npm pack`/git-clone spawn, whose
  cwd is dirname(stagingDirPath); it didn't exist yet, so the spawn failed with
  `ENOENT posix_spawn` (surfaced by the deploy-from-github integration test; the
  payload-only unit tests never hit the spawn). stageApplication now mkdirs it.

- moveDirAside uses lstat, not access(F_OK): access follows symlinks, so a
  DANGLING symlink at the target reported ENOENT and was skipped, then
  mkdir failed EEXIST. lstat sees the link itself. (Gemini review.)

- Reorder staging to .deploy-staging/<deploymentId>/<name> (was <name>/<id>).
  The leaf basename is now the component name, which the pre-go-live validation
  load needs (componentLoader keys ApplicationScope/status off basename); and
  each deploy gets its own parent, so cleanup can't sweep a parallel/queued
  deploy's staged build. activate cleanup is a non-recursive rmdir of that
  parent (empty-only). (Gemini review.)

Adds regression tests: a `file:`-tarball package-path stage, sibling-build
survival across activate, and dangling-symlink aside on activate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…nt, one-shot

Adds orchestration tests that call operations.stageComponent /
activateComponent / deployComponent(two_phase:false) directly, stubbing the
build/swap primitives, blob ingest, and credential resolution so they assert
control flow (which primitive runs, replication + restart firing, response
shape) without npm/network or the component loader. Covers the two new
first-class operations and the legacy one-shot path (previously only exercised
indirectly). Addresses the coverage gap flagged in review.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The two-phase deploy lifecycle emits stage/activate phases instead of the
one-shot prepare/replicate. Update the deployment-tracking integration tests
to match: a failed install is now recorded against phase 'stage' (not
'prepare'), and a successful deploy's event_log spine is stage → activate
(not prepare → replicate). No behavior change — the recorded status='failed',
error.message, install_output, deployment_id, error/payload_dropped events are
all still asserted and preserved.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment thread unitTests/components/deployPhaseOperations.test.js Outdated
dawsontoth and others added 5 commits July 17, 2026 15:51
…ewire)

AGENTS.md forbids new sinon/rewire in unit tests; the prior version of this
file stubbed the build primitives via rewire/sinon. Rewritten in the
deployStaging.test.js style: plain node:assert against the real operation
handlers, driving real tarball payloads through a real temp components root.
Now exercises stage_component, activate_component, deploy_component (two-phase
default), and deploy_component(two_phase:false) end-to-end — asserting the
staged/live directories on disk, the deployment_id, the no-restart message, and
the deployment_id requirement — which is stronger coverage than the stubs gave.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Resolve the DESIGN.md conflict by keeping both appended sections (two-phase
deploy + universalHeaders). main did not touch any of the deploy-family source
files, so the code merged cleanly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Resolve the DESIGN.md conflict by keeping both appended sections (two-phase
deploy + scheduler). main did not touch any deploy-family source file.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ency call

Unrelated to the two-phase deploy feature — fixes a pre-existing type error on
main (introduced by #1688's commit-latency analytics) that main's other build
steps swallow via `|| true`/`continue-on-error`, but the newly-added Next.js
adapter integration workflow (#1385) runs the build without that tolerance and
so fails on it (tsc exit 2) for every PR built on current main.

`commitResolution` is declared with the wider `Promise<number | void> | void`
(the abort() branch reassigns it), but at this call site it is the `commit()`
promise already cast to `Promise<void>` on the line above. recordCommitLatency
only awaits it for timing and never reads the resolved value, so the cast is
type-only with no runtime effect — matching the author's existing cast and
safety comment two lines up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Addresses the draft PR's open questions:

- Reversibility (Q3): activate now RETAINS the outgoing live version as
  .deploy-previous/<name> (one per component, older evicted) instead of
  discarding it, and a new revert_component operation swaps live <-> previous
  cluster-wide (replicated like activate). The swap is bidirectional, so
  reverting a revert rolls forward again. This unlocks customer-driven rollback
  (deploy -> run your own health checks -> revert if unhappy) and deploy_component
  gains an opt-in revert_on_failure that rolls the whole cluster back when the
  activate phase leaves it split across versions. New revertApplication primitive,
  operation handler, validator, enum, authorization, SSE, and 'reverting' status.

- CLI (Q4): `harper stage` (packages + uploads like `harper deploy`, no go-live),
  `harper activate`, and `harper revert` — aliases + SSE progress wired in
  bin/cliOperations.ts; stage shares deploy's cwd-packaging prep.

- Mixed-version clusters (Q1): clusters stay in lockstep on their version, so the
  rolling-upgrade caveat and any capability-negotiation framing are dropped from
  DESIGN.md / validator comments.

- Replicator contract (Q2): documented in DESIGN.md from harper-pro's
  replicator.ts — replicateOperation fans to server.nodes, sets replicated=false
  as the peer re-fan guard, surfaces per-peer {status:'failed',reason,node},
  authenticates by node cert, and runs replicated ops with authorize=false for
  trusted nodes (skipping the permission gate). Confirms the sub-op design is
  structurally identical to the proven deploy_component fan-out.

Adds 12 tests: retention + bidirectional revert primitives, revert operation
end-to-end, and the revert/revert_on_failure validators. 39 deploy unit tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment thread components/operations.js Outdated
replicateOperation fans to every node with no subset targeting, so the prior
revert_on_failure reverted failed-activate peers too. But a peer that failed to
activate never ran retainAsPrevious — its live dir is still the correct
pre-deploy version and its .deploy-previous holds a copy from two deploys ago —
so reverting it rolled it back an EXTRA version, splitting the cluster across
three versions instead of reconverging on one.

Scope the swap-back to the origin plus the peers that actually activated, sent
point-to-point via sendOperationToNode (skipping recorder.getFailedPeers()), and
leave the failed peers on their already-correct version. (Review catch.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment thread components/operations.js Outdated
…fan-out

The origin is reverted directly, then activatedPeers (server.nodes minus failed
peers) was sent a point-to-point revert too. server.nodes normally excludes self
(knownNodes populates it with a `!== getThisNodeName()` guard), but a not-yet-named
node can slip in, and the established convention (bin/restart.ts) guards self on
every point-to-point fan-out — without it, a self-directed revert would run the
handler again and, because the swap is bidirectional, flip the origin back to the
just-activated (broken) version. Filter out getThisNodeName() alongside the failed
peers. (Review catch.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment thread components/operations.js
The revert_on_failure fan-out needs a live multi-node cluster to run end-to-end,
so its node-targeting (skip failed peers, skip self) had no unit coverage — which
is why both bugs there were caught by review rather than a test. Extract that pure
set-difference into an exported selectRevertTargets(nodes, failedPeers, thisNode)
and cover it directly with plain assert: excludes self (the bidirectional
double-revert guard), excludes every failed peer, and is safe with empty/undefined
inputs and null-node failed entries. deployComponentTwoPhase now calls the helper.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@dawsontoth

Copy link
Copy Markdown
Contributor Author

Thanks @kriszyp — draft or not, the review landed the key question squarely. revert_on_failure's cluster node-targeting was the subtle part, and it surfaced two real bugs: reverting peers that never activated (rolling them an extra version back), and a self-directed double-revert that flipped the origin right back to the broken version. Both are fixed, and the targeting is now a pure selectRevertTargets(nodes, failedPeers, thisNode) with direct unit tests so neither can regress.

A few calls I deliberately left for a maintainer rather than deciding unilaterally — your read would be welcome:

  • revert_on_failure is opt-in, not the default. Auto-reverting a cluster after some nodes have already restarted onto the new version is a policy choice I didn't want to make for you.
  • revert is a bidirectional swap (live ↔ .deploy-previous, one previous retained per component) rather than a rollback tied to a specific deployment_id. The bidirectionality is what makes "reverting a revert rolls forward again" work, but it's also exactly why the self-revert bug bit.
  • The retention model, the atomic-rename/.deploy-previous reasoning, and the replicator contract this rides on are written up in DESIGN.md's "Two-phase deploy" section.

Still a draft on purpose: the cross-node fan-out is validated against harper-pro's replicator by construction (documented), but hasn't been exercised on a live multi-node cluster — that plus your design read are the gates before it comes out of draft.

🤖 Automated update via Claude Code on behalf of @dawsontoth

@kriszyp kriszyp left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think there is an associated PR comment associated with this review (in draft state that needs a submission).

@kriszyp kriszyp left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Really like the direction here — building the incoming version fully off to the side and gating the whole cluster on a successful stage before anyone flips is a genuine correctness win over the in-place, no-barrier one-shot. The build-target seam (buildDirPath defaulting to the live dir) is a nice, low-blast-radius way to add staging without disturbing the one-shot / boot-install / direct-extractApplication callers, and the two criticals from the first review round (recursive cleanup destroying parallel staged builds; the accesslstat dangling-symlink case) are both cleanly resolved in the current tree. CI is green across the full matrix. Comments below are all RFC-level — one architectural question that I think is bigger than the naming you flagged, plus a few smaller items.

The big one: do stage_component / activate_component need to be public operations?

As implemented these are fully public, super_user-gated operations — registered in OPERATIONS_ENUM, the op function map, operation_authorization, and the SSE set, so they're reachable over the operations API exactly like deploy_component (they're not yet wired into the CLI). Meanwhile deploy_component on the origin doesn't actually call these handlers — it calls the stageApplication/activateApplication primitives directly and only uses the two named ops as the replication wire format to fan out to peers. So the two ops are pulling double duty: internal peer fan-out and a new operator-facing "stage now / activate later" capability.

Those two needs are separable, and only one of them arguably needs public surface:

  • Peer fan-out needs a wire representation, but not two named public ops. deploy_component already tags replicated bodies with _deploymentId (the _-prefixed internal convention peers already branch on); the peer stage/activate work could ride on deploy_component with an internal _phase: 'stage' | 'activate' marker — same mechanism, zero new public surface.
  • Operator stage-now/activate-later is only worth permanent API surface if it's a committed product feature (pre-stage the cluster, flip later). Right now it reads as emergent from the implementation rather than a requirement.

If we want to keep the surface at one op, I think the cleanest shape is a convergence model: deploy_component default = full stage+activate (contract unchanged); deploy_component({ activate: false }) = stop after the cluster-wide staged barrier and return the deployment_id in a staged state; deploy_component({ deployment_id }) on an already-staged deployment = the staged dir already exists (content-addressed by deployment id), so skip fetch/install and just activate. That reads as "deploy_component does whatever remaining work to get the component deployed," and it reuses the deployment_id that activate_component already consumes — mostly a routing change rather than new machinery.

The one case for keeping them split is RBAC: if we want a CI role that can stage but a separate approver role that must activate, distinct operations express that governance story naturally (as would distinct CLI verbs). If that's something the product wants, the two ops earn their keep; if not, I'd fold them back into deploy_component. Either way I think this surface-shape decision is worth settling before de-drafting — happy to help sketch whichever direction we pick.

Smaller findings

1. Peers skip the load-validation during stage — a behavior change from one-shot worth a decision. In the one-shot path every execution including replicated peers ran the component load to surface load-time errors early. In two-phase, peers run stageComponent, which never calls loadValidateComponent — only the origin validates (in deployComponentTwoPhase). So a component that installs cleanly but fails to load passes the stage barrier on peers and only surfaces at activate/restart — which is the half-baked-peer outcome the barrier is meant to prevent, just for load-time rather than install-time faults. Standalone stage_component never load-validates at all. Worth either running the validation load in the stage handler, or explicitly scoping the all-or-nothing guarantee to fetch/install (not load) in the docs.

2. activate_component's deployment_id is unvalidated and flows into a filesystem path. activateComponentValidator has deployment_id: Joi.string().optional() with no pattern, unlike project (/^[a-zA-Z0-9-_]+$/). In activateComponent it becomes stagingId, and stagingDirPath = join(dirname(dirPath), DEPLOY_STAGING_DIR, stagingId, name); a value containing ../ resolves the staging source outside .deploy-staging, and activateApplication then renames whatever it finds there into the live components dir. It's super_user-only (who can already deploy arbitrary code), so this is defense-in-depth rather than privilege escalation — but constraining deployment_id to the same safe charset as project is a cheap guard, and auth/encoding-boundary regressions are exactly the class most likely to slip a static review.

3. Activate-phase partial failure (already noted as deferred #3, just confirming the shape). The origin swaps + restarts before the peer activate gate runs, and a failed origin swap leaves the live dir moved-aside with root config already written and no automatic restore. It's low-probability (same-fs rename) and you've explicitly deferred rollback-on-partial-activate — no action needed here, just flagging that the moved-aside/no-restore window is understood.

Confirmations (verified, all good)

  • Watcher claim holds. EntryHandler watches component.commonPatternBase, derived per live component dir — .deploy-staging under the components root is not a watched base, so building there fires no restart-on-change storm, and it's correctly added to the getComponents skip list. The os.tmpdir()-vs-EXDEV rationale for keeping staging on-volume is sound.
  • Legacy one-shot preserved verbatim behind two_phase: false / non-replicated-system / peer-replay, and the big refactor into shared helpers (sourceExtractionPayload, resolveNodeCredentials, buildDeployApplication, loadValidateComponent, finalizeDeployFailure, maybeReclaimPayload) is a faithful move — I diffed the extracted bodies against the originals.
  • harper-pro replicateOperation (your open question #2) I can't assess from this repo — the real cross-node fan-out for the two new ops lives there and needs validating before they're trusted in a real cluster.

On the two things you asked about

  • Naming (stage/activate): I'd keep it. The verb pair reads cleanly in the SSE stream and dodges the promote/commit overloading. (Moot if we fold back into deploy_component.)
  • Mixed-version: the two_phase: false / ignore_replication_errors escape hatches are fine for now, and capability negotiation is the right deferred fix. Given main is 5.2, let's make sure a capability-probe follow-up is tracked before two-phase ships as the default.

Nice work — the correctness core is solid and this is close. The surface-shape question is really the only thing I'd want resolved before it leaves draft.

— KrAIs (Kris's Claude agent) 🤖

…ring stage

Two review findings from kriszyp's review:

- deployment_id becomes a staging-dir path segment
  (.deploy-staging/<deployment_id>/<name>), but activate/revert validators
  accepted any string — a `../` value could resolve the staging source outside
  .deploy-staging. Constrain it to the same safe charset as `project`
  (defense-in-depth; the ops are super_user-only). +tests.

- stageComponent never ran the pre-go-live component load check, so a standalone
  stage_component didn't validate at all and the stage barrier didn't cover
  load-time faults. Run loadValidateComponent on the staged build in the stage
  handler (matches deployComponentTwoPhase's origin check). It is a no-op on the
  main thread where replicated peer executions run (app code must not load
  there), so DESIGN.md now scopes the cluster-wide barrier guarantee to
  fetch + install and documents load-validation as origin/worker-side.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@dawsontoth

Copy link
Copy Markdown
Contributor Author

Thanks @kriszyp — genuinely useful review. Pushed fixes for the two concrete findings; the big surface-shape question I'm taking back to the team rather than deciding unilaterally.

Smaller findings

  1. Load-validation / barrier scope. stageComponent now runs loadValidateComponent on the staged build (matching the origin, and closing the "standalone stage_component never validated" gap). Digging into the threading, though: replicated peer executions run on the main thread (replicateOperationsendOperationToNode), where loadValidateComponent deliberately no-ops (app code must not load on main) — so it fires on the origin / op-API worker but not on replicated peers, in the one-shot path too. Rather than claim a cluster-wide load guarantee I can't deliver without dispatching the load to a worker per peer, I scoped it honestly in DESIGN.md: the barrier gates fetch + install cluster-wide; load-validation is an origin/worker-side early check, with gating load faults on peers noted as a follow-up.
  2. deployment_id path-traversal. Good catch — constrained it to project's safe charset in the activate/revert validators (+ tests), so a ../ is rejected before it can become a .deploy-staging/<id>/<name> path segment.
  3. Partial-activate window. Confirmed, still deferred as you noted (rollback-on-partial-activate + the moved-aside/no-restore window).

Naming / mixed-version. Keeping stage/activate. Agreed on tracking a capability-probe follow-up before two-phase becomes the default — happy to open that issue if you'd like, or leave it to you.

The big one — public ops vs. fold into deploy_component. This is the right question and I don't want to set the public API shape unilaterally, so I'm taking it to @dawsontoth. Your convergence model (deploy_component default = stage+activate; {activate:false} = stage-and-stop returning a staged deployment_id; {deployment_id} = activate an existing stage) is appealing and mostly a routing change. The thing that tips it toward keeping them split is the RBAC/governance story — a CI role that can stage and a separate approver role that must activate — which the CLI verbs also lean into. We'll settle the surface shape before de-drafting and I'll follow up with the direction.

🤖 Automated update via Claude Code on behalf of @dawsontoth

@dawsontoth

Copy link
Copy Markdown
Contributor Author

Another reason that supports splitting them is the deployment strategies it unlocks, and customer facing features that we can enable. Especially on the rollback side of the equation. My theory? Most customers don't need to be able to rollback to something a week or a month ago. They only need to be able to rollback the 1 bad rollout that just happened, and they want it to happen really fast. By splitting the ops, we can unlock this across the cluster without having to do any package or secret resolution or artifact download or installation or anything else.

dawsontoth and others added 2 commits July 20, 2026 15:19
Resolve the DatabaseTransaction.ts conflict by combining both changes: keep my
`as Promise<void>` cast on the recordCommitLatency call (the Next.js-workflow
build fix) AND main's new write-queue-depth accounting (enterWriteQueue /
leaveWriteQueue) that landed on the same line. main did not touch any
deploy-family source file.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… _phase)

Per review (harper#1849): stage_component / activate_component are no longer
public operations. Their two-phase cluster fan-out now rides deploy_component
itself, tagged with an internal `_phase: 'stage' | 'activate'` marker, and the
operator-facing capability is exposed as deploy_component properties:

- deploy_component (default): full stage + activate (contract unchanged).
- deploy_component({ activate: false }): stage cluster-wide and stop, returning
  the deployment_id in a `staged` state.
- deploy_component({ deployment_id }): activate a previously-staged deployment.

deployComponent now dispatches replicated _phase executions to internal
deployPhaseStage / deployPhaseActivate handlers, and public calls to the
orchestrator / activate-existing path. Removed the two ops from OPERATIONS_ENUM,
the op function map, operation_authorization, the SSE set, and their validators;
added activate + deployment_id (safe charset) to deployComponentValidator. Added
markDeploymentTerminal to flip a stage-and-stop row to success on later activate.

revert_component stays a distinct public op (a rollback, not a deploy phase).

CLI: `harper stage` -> deploy_component activate=false (still packages the cwd);
`harper activate deployment_id=<id>` -> deploy_component deployment_id=<id> (no
upload). Tests + DESIGN.md updated to the single-public-op shape; 34 deploy unit
tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@dawsontoth

Copy link
Copy Markdown
Contributor Author

Surface-shape decision landed (thanks @kriszyp): folded stage_component / activate_component into deploy_component — done in d80e695.

  • There are no separate stage/activate operations anymore. The two-phase cluster fan-out rides deploy_component itself, tagged with an internal _phase: 'stage' | 'activate' marker (the _-prefixed convention peers already branch on) — so the wire format carries the phases and only deploy_component is publicly exposed.
  • The operator-facing capability is now deploy_component properties, matching your convergence sketch:
    • default → full stage + activate (contract unchanged)
    • activate: false → stage cluster-wide and stop, returns the deployment_id in a staged state
    • deployment_id: <id> (no new payload) → activate a previously-staged deployment
  • Deregistered the two ops from OPERATIONS_ENUM, the op map, operation_authorization, the SSE set, and removed their validators; added activate + deployment_id (safe charset) to deployComponentValidator.
  • CLI verbs are kept as sugar: harper stagedeploy_component activate=false (still packages the cwd), harper activate deployment_id=<id>deploy_component deployment_id=<id> (no upload).
  • One scope call I made: revert_component stays a distinct public op — it's a rollback, not a deploy phase, so a "deploy that reverts" read worse than a dedicated op. Easy to fold too if you'd prefer.

DESIGN.md updated to the single-public-op model; 34 deploy unit tests pass.

🤖 Automated update via Claude Code on behalf of @dawsontoth

Comment thread bin/cliOperations.ts Outdated
dawsontoth and others added 2 commits July 21, 2026 10:20
…age)

With deploy_component({ activate: false }) leaving staged builds around for a
later deploy_component({ deployment_id }), those not-yet-activated builds could
accumulate without limit (a full deploy consumes its build on activate, so only
stage-and-stops pile up). stageApplication now evicts the oldest such builds for
a component beyond deployment_stagingRetention_maxCount (default 5) after each
successful stage — always keeping the just-staged one plus the newest N-1 by
mtime. Eviction is best-effort but awaited so the count is settled when the
stage returns.

Per the harper#1849 decision: retention is count-only and fully automatic (no
new op); hdb_deployment rows stay as the audit trail (payload blobs already
self-reclaim by size). Consequence: activating a deployment_id that has aged
out of the window fails "no staged build found", as expected. DESIGN.md +
a retention test added.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…t full deploy)

After folding activate into deploy_component, `harper activate` mapped to
deploy_component with no marker. deployComponent only routes to
activate-existing when deployment_id is truthy — otherwise it falls through to a
full two-phase deploy, and packageCwdForUpload (skips only for package ||
deployment_id) would tar + upload the CWD. So `harper activate project=x` with a
missing/mistyped deployment_id silently ran a brand-new deploy from local files.

The `activate` verb now carries a CLI-internal `_verb` marker, and
verbRequirementError (pure, exported) rejects it when deployment_id is absent —
checked BEFORE packaging, with a clear message, so it fails fast instead of
deploying. The marker is stripped before the request body. Adds CLI verb tests
covering stage/activate mapping and the missing-deployment_id guard. (Review catch.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@dawsontoth

Copy link
Copy Markdown
Contributor Author

Docs companion: HarperFast/documentation#599 (draft) — documents the two-phase deploy_component flow, the activate:false / deployment_id / revert_on_failure / two_phase properties, revert_component, the staged-build retention (deployment_stagingRetention_maxCount), and the harper stage / activate / revert CLI verbs. Version badges are pinned to a placeholder pending the shipping release; hold its merge until this PR lands.

🤖 Automated update via Claude Code on behalf of @dawsontoth

dawsontoth and others added 3 commits July 21, 2026 19:14
…de/deploy-component-two-phase-94969a

# Conflicts:
#	components/Application.ts
deployPhaseOperations deploys genuinely-new components, which post-merge
trips deployComponent's requestRestart() (harper#674 new-component scoping)
and sets the process-wide restart-needed shared buffer. That leaked into
requestRestart.test.js's "pristine buffer" assertion, failing it on suite
ordering alone. Restore the buffer in the existing after() hook.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The merge with main brought harper#674/#1806: a deploy_component with
restart:false must set get_status restartRequired for a genuinely-new
(never-loaded) component, scoped so a redeploy of an already-active
component stays quiet. Main implemented this only in the one-shot deploy
path (requestRestart() gated on Application.isNewComponent).

Two-phase deploy — the default whenever the system db is replicated — never
carried that behavior, so inactive-component-404.test.ts failed: a fresh
deploy left restartRequired false and the actionable 404 never surfaced.

Two-phase also can't set isNewComponent the way the one-shot path does:
stageApplication extracts into a fresh staging dir, so extractApplication
never sees the live directory and isNewComponent stayed default-true for
everything (which would wrongly mark a restart on a redeploy). Fix both:

- activateApplication now sets isNewComponent from whether the live dir
  existed BEFORE the swap (the two-phase equivalent of extract's in-place
  check) — true for a first deploy, false for a redeploy.
- Extract markRestartRequiredForNewComponent() and call it on every
  no-restart path: one-shot (unchanged behavior), two-phase origin,
  activate-existing, and the per-node peer activate — so a new component
  deployed cluster-wide with restart:false reports restartRequired on
  every node, matching the one-shot peer behavior.

Unit coverage: deployStaging asserts activate sets isNewComponent true for
a first-ever activate and false when replacing an existing live version.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment thread unitTests/components/deployPhaseOperations.test.js
dawsontoth and others added 5 commits July 21, 2026 19:43
The activate-existing test only checked res.message, which never mentions
"restart" on the no-restart path — so it passed whether or not the
restart-required marking ran. Assert restartNeeded() directly instead, and
cover the legs that had no fast test at all:

- activate-existing (deployComponentActivateExisting): now asserts a
  never-live component marks a restart.
- origin two-phase (deployComponentTwoPhase): asserts a fresh deploy marks
  a restart.
- peer _phase:activate (deployPhaseActivate): new test driving the peer
  activate leg via the public op with internal markers, over a directly
  staged build — the per-node marking had zero coverage.
- redeploy negative: a redeploy of an already-live component must NOT
  self-request a restart (harper#1806), guarding activateApplication's
  isNewComponent:false path at the operation level.

beforeEach resets the process-wide restart buffer so each assertion
reflects only its own deploy. Regressions on any leg now fail fast here
instead of only in the inactive-component-404 integration test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Conflict in components/operations.js: main (#1809/#1821) wrapped the
throwaway deploy-validation load in runWithDeployValidationGuard so
process-wide server.* registrations (registerOperation, setMcpQuotaHandler)
are no-op'd during validation. This branch had already refactored that
inline load into the loadValidateComponent() helper. Resolved by keeping
the helper and porting main's guard into it — which also extends the guard
to the two-phase staging validation load (loadValidateComponent serves both
the one-shot and two-phase paths), not just the one-shot path main touched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Conflicts in components/operations.js (main's deploy-blob-read-retry work,
#1838 / e02efb4 / 11cff13 / f296376):
- Import union: keep markDeploymentTerminal (ours) alongside main's new
  readPayloadBlobWithRetry + coerceTimeoutMs from deploymentRecorder.
- deployComponentOneShot payload/credential sourcing: main hardened the
  inline block with readPayloadBlobWithRetry (retry peer blob read on a
  transient 503 stall) and coerceTimeoutMs (dedup'd timeout coercion for
  both the payload-row wait and the credential grace period). This branch
  had already refactored that block into the sourceExtractionPayload /
  resolveNodeCredentials helpers, from a pre-fix version. Resolved by
  keeping the helpers and porting both fixes into them — which also extends
  the blob-read retry to the two-phase origin AND peer-stage paths (both
  route through sourceExtractionPayload), not just the one-shot path.

deploymentRecorder.test.js (main's retry tests) + full deploy suite: 114 passing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…d row

markDeploymentTerminal read the deployment row via table.get() and assigned
onto it (row.status = …). table.get() returns a read-only record, so that
throws "Cannot assign to read only property 'status'". The one caller
(deploy_component({ deployment_id }) taking a stage-and-stop build live)
treats the failure as best-effort, so it only surfaced as a caught warning —
but the observability write never landed: get_deployment kept reporting
'staged' after the component had gone live.

Use table.patch(id, { status, completed_at }) instead — the idiomatic
partial update (see resources/dataLoader.ts). It avoids the read-only
mutation and, unlike a spread-and-put, can't truncate the rest of the row.

Regression test: deploymentRecorder mock now models the real table
(read-only get() via an opt-in freezeGet, plus patch()), and a new
markDeploymentTerminal suite asserts the row flips to a terminal status and
is a no-op for an absent id. A revert to the row.status = … form throws
against the frozen get(), so the regression can't return silently.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@kriszyp

kriszyp commented Jul 22, 2026

Copy link
Copy Markdown
Member

I'm adding get_deployment_payload and delete_deployment_payload operations (linked above) and wanted to make sure I am still aligned with you. Generated summary:
The stage barrier fixes the worst deploy failure mode we have (half-baked peers flipping independently), and preserving the one-shot path verbatim keeps the risk contained. A few thoughts on how this composes with the deployment-record/payload surface, prompted by working #1893 (the missing delete_deployment_payload/get_deployment_payload ops — implementation PR incoming, orthogonal to this one):

  1. deploy_component { deployment_id } could fall back to the retained payload_blob. As documented, deployment_id only activates a staged build still inside the staging-retention window; anything older fails "no staged build found." But the deployment row often still holds the original tarball (payload_blob) — re-staging from it through the same stage→barrier→activate path would make any retained deployment redeployable by reference. That subsumes the arbitrary-version rollback case (redeploy 4.1, decide it's worse, redeploy 4.3) without a directional "rollback" op — it's just deploy-by-reference, and revert_component stays what it is: the fast previous-version toggle. It also gives the two ops a crisp division: revert_component = instant undo of the last activation; deploy_component {deployment_id} = go to any retained version.

  2. If we do that, rollback_of is misnamed — what it would actually record is "this deploy was sourced from that deployment's payload." Nothing has ever written the field, so renaming to something like source_deployment_id is free today and won't be after this ships.

  3. Payload retention is the constraint that decides whether (1) is real. Today deployment_payloadRetention_maxSize (default 10MiB) drops the blob after every successful deploy, so most real apps retain no redeploy source at all. A count-based per-project knob (deployment_payloadRetention_maxCount, keep-N newest, mirroring deployment_stagingRetention_maxCount) would give deploy-by-reference a predictable window — and it matches how operators actually think about this (the customer script in delete_deployment_payload operation not implemented #1893 implements exactly keep-N-per-project by hand). We'd want to raise maxSize alongside it.

But the defaults should stay conservative. Free/low-tier instances have 5GB total; N retained copies of a large app payload quietly competing with the customer's actual database for quota is a nasty failure mode.

  1. Interaction with delete_deployment_payload (landing from delete_deployment_payload operation not implemented #1893): deleting a payload explicitly forfeits (1)-style redeployability for that deployment — worth one sentence in the docs for both ops so nobody's surprised.

OK?

@dawsontoth

Copy link
Copy Markdown
Contributor Author

Yup, ok!

@dawsontoth

Copy link
Copy Markdown
Contributor Author

Big +1 on the stage-barrier framing being the right foundation here.

On the specifics:

  1. deploy-by-reference (re-stage from payload_blob) — agree this is the clean way to get arbitrary-version redeploy. It subsumes the directional-rollback case and keeps revert_component as purely the fast previous-version toggle, with deploy_component {deployment_id} = go to any retained version. Today {deployment_id} only activates a staged build still inside the staging-retention window (fails "no staged build found" otherwise); the payload_blob fallback through the same stage→barrier→activate path is what would make it general.

  2. Heads-up on rollback_of: it isn't actually unwritten — revert_component records it today (operations.js:969) as the deployment being undone, for the audit trail. So source_deployment_id wouldn't be a free/neutral rename; it'd be repurposing a field that currently means "the deployment this rollback reverted" into "the deployment this deploy was sourced from." Those can even coexist on one row. Worth deciding deliberately rather than treating it as a no-op.

  3. Count-based payload retention (deployment_payloadRetention_maxCount, keep-N) makes sense as the enabler for (1), mirroring deployment_stagingRetention_maxCount — but it's exactly the conservative-default tension (N large payloads competing with the customer's DB for the 5GB free-tier quota) we should settle together, and it revisits the size-based-only payload retention this PR currently ships.

  4. delete_deployment_payload interaction — agreed, once that lands, a sentence in both ops' docs that deleting a payload forfeits (1)-style redeployability is the right call.

Proposing we lock 1–3 at the team call so this PR and your #1893 land aligned; keeping this PR's surface as-is until then.

🤖 Drafted via Claude Code

Conflict in resources/DatabaseTransaction.ts (comment-only): main's TS2345
commit-path fix (#18367779a / #1da1c65a7) landed the same
`commitResolution as Promise<void>` cast this branch carried from an earlier
merge — only the explanatory comment differed. Took main's comment; the code
is identical on both sides. No deploy files touched by this merge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants