feat(aws-control): make a running backup a fact the server owns - #7778
Conversation
UX Review (Fable 5) — ✅ PASSUX-level review of UX-Verdict: PASS Durable server-owned run state fixes the real UX lie — an idle row over a live backup — and refusals now name a next step. Suggestions
[UX-REVIEWED] ae601aa |
Design Review (Fable 5) — ✅ PASSDesign-level review of Design-Verdict: PASS Server-owned run state is the correct root-cause fix; every design compromise (dedupe-key-as-identity, nightly gap, no cancel) is deliberate, tested, and tracked upstream. [DESIGN-REVIEWED] ae601aa |
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsFINDING -- src/kiro_crew/apps/builtins/aws_control/backend/routes.py:1290 -- False positive or not applicable? A repository writer can comment: |
First Principles Review (Fable 5) — 🟡 CONCERNSPremise-level review of Verification is done. Key checks: First-Principles-Verdict: CONCERNS The fix is real and cause-level, but the nightly backup is still a running backup the server does not own — the thesis's one counted unfixed sibling. What this change shipsIntent: stop a running backup's existence from living only in the browser tab that started it — a FIX (double paid uploads, idle rows over live work) delivered by integrating the pre-existing Job SDK.
Watch
[FIRST-PRINCIPLES-REVIEWED] ae601aa |
Opus 4.8 Review — ✅ no blocking findingsReviewed Review detailsBased on my verification: No findings that block the merge. FINDING — src/kiro_crew/apps/builtins/aws_control/backend/routes.py:441 — [OPUS-REVIEWED] ae601aa Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
2d262fe to
87827e6
Compare
87827e6 to
db3a888
Compare
db3a888 to
8160ec1
Compare
8160ec1 to
393417d
Compare
393417d to
5a2441c
Compare
1d80f2a to
5755691
Compare
5755691 to
fb83e44
Compare
fb83e44 to
2fac845
Compare
Starting a backup executed it inside the request and returned its terminal
record, so "a backup of mine is running" lived only in the React component
that started it. Unmounting that component destroyed the only record of it
while the upload kept going, and coming back showed an idle row mid-backup.
Under the drive's rail that unmount is what switching panes does, so the
window is routine rather than incidental.
The route now claims a durable Job SDK run and returns its id. The rows
follow it through `GET /backup/{account}`, whose `jobs` block is filtered to
this account server-side -- not through the app-scoped `_jobs` surface, which
withholds `dedupe_key` and so cannot answer "is a backup running for THIS
account". A fresh mount adopts a run it did not start, and a run left
non-terminal by a gateway that is gone resolves to `interrupted` rather than
being served as running.
The runner is a plain `def` and has a test to keep it one: `JobSDK._execute`
discards the runner's return value, so an `async def` would return a
coroutine nobody awaits -- the body would never run and the record would
settle on `done` for a backup that never happened. P1 has no `params`
channel, so the account comes from the run's own `dedupe_key`, and
profile/region/bucket are re-resolved per run through a new sync
`accounts.resolve_account_profile_cached` rather than carried. `asyncio.run`
in the worker is not the alternative: a second event loop in a worker thread
is the #4800 failure this package already carries a `LoopBoundLock` to avoid.
Authorization gained a gate rather than moving one. `backup._authorize_upload`
now runs BEFORE drive discovery as well as immediately before every upload,
because `find_drive` resolves the bucket through the AWS tagging API -- so the
old order issued live requests on the owner's credentials while refusing the
work. The two calls answer different questions: the first decides whether we
may touch AWS at all, the second decides whether the bytes may leave after a
build that takes minutes, during which consent can be withdrawn. Both audit
through the same helper, so a refusal on either always leaves a SEL record.
Refusals now name a cause the owner can act on. Of the codes this path
answers, only `aws_call_failed` rewards a retry, so the generic line promises
nothing and lapsed consent, a missing drive and an absent job runtime each
say what to do instead.
The app's own terminal ledger is untouched: the Job SDK records that a run
existed and how it ended, and what a backup produced stays where it already
lived, served by `GET /backup/{account}` as `runs`.
2fac845 to
ae601aa
Compare
buluoray
left a comment
There was a problem hiding this comment.
Verdict: 0 blocking, 2 non-blocking. The ownership move from client to server holds up under scrutiny — the run becomes a durable, server-owned record and every failure/restart/concurrency path is covered by the pre-existing Job SDK, which is genuinely at base.
What I verified
Read against head ae601aaca79fcb7f3f35ffc0dd2204a8e7466abf (SDK read at base 4098c32, where apps/job_sdk.py already lives — P1 is merged, not stacked):
- Restart recovery (a): an orphaned run does NOT wedge the feature.
list_active(job_sdk.py:1221) returns any non-terminal record from disk with no live/origin filter, so recovery depends on reconcile. It is wired twice: the app's own_register_job_runnerscallssdk.reconcile()aton_startup(hooks.py), and the gateway callsreconcile_all()after the whole enable loop (hooks_integration.py:597). A dead process'srunningrecord is flipped tointerrupted. Pinned bytest_on_startup_resolves_an_orphan_before_the_ui_can_adopt_itandtest_a_run_left_running_by_a_dead_process_is_resolved(assertINTERRUPTED+list_active()==[]). - Concurrency exclusivity (b): genuinely atomic, not a read-then-write race.
JobSDK.start(job_sdk.py:926) does the_keys.get(key)check and the_keys[key]=run_idclaim inside oneself._lockcritical section with no I/O; a second start adopts and returns the existing id. - Flag cleared on failure/exception (c):
_execute'sfinally(job_sdk.py:~1180) always calls_write_terminaland pops the dedupe key from_keys— success, failure, exception, and undriven-coroutine paths all clear it. A lost terminal write is caught byreconcile. - Durability (d): file-per-run via
atomic_write(job_sdk.py:680); the PR body's "durable, server-owned record" claim matches the code. - Authorization not weakened:
_authorize_upload(backup.py) runs in-worker before drive discovery and again beforeput_file, re-checking live account, app-enabled, S3 grant, and that the grant names this account.test_a_run_started_off_the_generic_surface_is_still_consent_gateddrives the real gate with consent withdrawn and assertsput_file.assert_not_called(). - Account never crosses to client:
_job_view(routes.py) omitsdedupe_key;test_the_account_never_reaches_the_clientguards it. - Frontend/i18n (f): the six new
backup_*strings go throughen.json+ 11 locales; CI Automated Rule Check / Inclusive Language / De-Amazon / Frontend Lint all green. Nightly path correctly attributescaller=CALLER_SCHEDULED.
Findings
- routes.py:1290 (non-blocking) — cross-account truncation can hide a
lastFailedbanner._account_jobsreadssdk.list_recent(kind, limit=20)then filtersr.dedupe_key == account, butlist_recent(job_sdk.py:1227) is cross-account. With ≥20 more-recent runs of the same kind on other accounts, this account's genuine most-recent failed run falls outside the window →newest is None→ the failure caption silently disappears. Consequence: display-only degradation of a new secondary surface — the run record itself persists and active-run tracking is unaffected — which is why this is non-blocking (and why GPT 5.6 and Opus 4.8 independently rated it so). Suggestion: scope the recency scan to the account (page until this account's newest run is found, or query account-scoped) rather than a fixed cross-account 20-run window. - hooks.py (nightly loop, non-blocking) — nightly run bypasses the SDK.
_run_oncecallsrun_snapshot_backupdirectly rather thansdk.start, so a nightly run is invisible to the row and undeduped against a manual click; on the exact nightly+manual pairing the double-paid-upload harm this PR targets survives. Routing nightly throughsdk.startneeds a caller channel P1 lacks (CALLER_OWNERhardcoded in the runner), so this reads as accepted-and-deferred. Suggestion: track it as a named follow-up issue rather than leaving it silent (matches the First Principles CONCERNS).
What I could not verify
- End-to-end gateway restart on a live process — verified through reconcile logic + unit tests only, not a running gateway.
- The 12px caption rendering of
{{reason}}/ raw botocore text (UX Review advisory suggestions) — cosmetic, not visually verified. - Whether AGENTS.md requires a
docs/system-specs/update for the new app-scopedjobs/lastFailedendpoint shape; I did not re-read AGENTS.md line-by-line, but the SDK spec pre-exists and the PR Scope + Detect-changed-surface CI gates are green.
Problem / Motivation
Starting a backup in AWS Control executed the whole thing inside the HTTP
request and returned its terminal record. So the fact that a backup of mine is
running existed in exactly one place: the
runMut.isPendingflag on the Reactcomponent that started it.
Unmount that component -- switch a tab, hit the breadcrumb, reload the page --
and the fact was destroyed while the upload kept going. Come back, and the row
said "Back up now" over a backup that was still streaming to S3. Two tabs
disagreed with each other, and a click in the second one started a second paid
upload of the same archive.
There was no backup run identifier and no server-side registry of in-flight
runs anywhere in that path.
Why it matters
A snapshot or sessions backup takes minutes and costs money to upload. The
owner is watching it, and the UI answered their only question -- "is it still
going?" -- by guessing from state it had already thrown away. Three concrete
outcomes: an idle-looking row over live work, a double upload from a second
click, and a gateway restart mid-backup leaving nothing that says the run ever
existed.
What changed (motivation -> approach -> change)
The run becomes a durable, server-owned record.
POST /backup/{account}/runnow claims a Job SDK run and returns its id instead of blocking on the outcome;
BackupSection's rows follow it on the shared_jobssurface. A fresh mountadopts a run it did not start, and
sdk.reconcile()resolves a run leftnon-terminal by a process that is gone to
interruptedinstead of serving it asrunning.
The runner is a plain
def, and that constraint shapes everything below it.JobSDK._executecalls the runner and discards its return value, andregister()validates the kind but not the callable. Anasync defrunnerwould therefore hand back a coroutine nobody awaits: the body would never
execute, nothing would raise, and the record would settle on
donefor a backupthat never happened.
test_the_runner_is_not_a_coroutine_functionpins this.That rules out three coroutines the old resolution path used:
accounts.resolve_account_profile,aws_consent.probe_identity, andaws_consent.refuse_and_log.asyncio.runinside the worker is not the wayaround it -- a second event loop in a worker thread is precisely the #4800
failure this package already documents at
accounts.py:62("a module-globalasyncio primitive binds to the import-time loop and raises when acquired from
another"), which is why
_snapshot_lockis aLoopBoundLockat all. So theresolution path is sync end to end:
dedupe_key(see below);(profile, region)comes from a new syncaccounts.resolve_account_profile_cached(), which reads the snapshot the loopalready built and returns
Nonewhen it is absent or past its TTL. Theprofile-selection policy is extracted into one
_pick_profile()that both theasync and sync readers call, so the decision of which credentials an
operation runs under cannot exist in two drifting copies;
storage.find_drive(already sync).Authorization does not move, and the generic entrance is gated more strictly
than the app's own. Once
app.jsoncarries"jobs": true,POST /_jobs/snapshot/startis reachable and does not run this app's_require_drivepre-flight. That is not a hole: the gate has always been
backup._authorize_upload, which runs immediately before everyput_file(
backup.py:266and:446, one per runner, no other call site) and checks thelive
sts:GetCallerIdentityagainst the target account, that the app is stillenabled, that the S3 grant matches this profile and region, and that the
recorded grant's own
accountfield is this account -- a check the route's_consent(which callsaws_consent.authorize) does not make. So the pre-flightis now a fast-fail nicety that keeps specific, localisable 409s for an
unreconnected account, unconfirmed S3, or a missing drive; the authorization
argument rests on the worker.
test_a_run_started_off_the_generic_surface_is_still_consent_gatedproves itwith no route guard involved.
dedupe_keycarries the account, which is identity as well as dedupe.Calling this out so a reviewer does not have to find it. P1 deliberately has no
paramschannel -- a runner receives its handle and nothing else -- andrun_idis minted insidestart(), so the route cannot stash parametersunder the id before the worker launches. The account is the honest carrier on
its own merits: it is exactly this run's concurrency identity, since two
snapshot backups of one account must not both perform the paid upload. The
SDK's index is
(kind, dedupe_key), so a snapshot and a sessions backup of thesame account still run independently. It is also the only field a runner can
read without touching a private attribute (
get()is public; the key iswithheld from
_public_viewand never logged by the SDK). Errors deliberatelydo not quote it back. Re-resolving profile/region/bucket per run rather than
carrying them is not a workaround either -- it is the rule this app already
documents for its nightly loop: the drive is tag-discovered per run rather than
trusted from memory.
The terminal ledger is untouched. The Job SDK records that a run existed and
how it ended; what a backup produced -- key, size, when -- stays in the app's
own state via
_record_run, andGET /backup/{account}still serves it asruns. This consumer already had its own result channel, so P1 cutting thepayload channels costs it nothing.
Frontend.
BackupSectionkeeps its existing['aws-control', 'backup', account]query and derives each row's busy state fromthe account-scoped
jobsblock that query already returns. No shared hook and nonew module: an earlier revision added a
useAppJobinwebsite/src/app-sdk/, andmoving the read onto the app's own endpoint left it with zero consumers, so it was
deleted rather than kept (see below). The query polls at 3s only while a run is in
flight, and a start invalidates it immediately so the row turns over without
waiting out the gap.
A run is per-account, so the read is too.
The shared
_jobs/activesurface is app-scoped by construction -- one app, onekind, every run -- and it withholds
dedupe_keyfrom its public view on purpose,so a browser cannot tell which account a listed run belongs to. An app whose work
is per-account therefore needs an account-scoped read, and providing one is the
APP's job rather than the SDK's.
GET /backup/{account}is already account-scopedand already what the page reads for that account, so the in-flight run belongs in
its payload:
_account_jobsfilters ondedupe_keyserver-side, where the field isavailable, and the key still never crosses to the client.
This is not a workaround for a missing SDK feature. Whether the SDK should grow a
scope concept is open (#7590),
and the sibling PR pins the withholding with
test_a_failed_run_does_not_echo_its_dedupe_key-- the SDK does not echo acaller-supplied string, by design.
Reading the app-scoped surface instead was a real bug, not a hypothetical: with two
connected accounts, account A's running snapshot rendered account B's row as
"Backing up..." and disabled B's own button, so it blocked a legitimate action
rather than merely mis-rendering. The Sage audit predicted exactly this when it
established
dedupe_keyas write-only across the HTTP surface; this is thatprediction confirmed by the first real integration, and it is
recorded on #7590.
A failed run says so.
The durable record's most useful new information is that a run failed and why, and
the app's own
runsledger only gains an entry when an upload SUCCEEDS -- so afailed run left it untouched and the row simply stopped spinning, which is itself a
false statement about what happened. The payload now carries
lastFailedper kindfor this account, and the row renders the reason.
runMut.isErrorgets a line too,for a start the route refuses outright.
Two details that decide whether that helps or misleads. Only the NEWEST terminal run
may speak, and only when it did not succeed: reporting the first non-
doneruninstead would skip past a newer success, so a fail-then-retry showed "last run
failed" directly above the fresh success the ledger had just recorded -- the row
contradicting itself, and persisting until the failure aged out of the window. And
the error is clamped to 180 characters in the view, because the SDK stores up to
2000 and the row renders it in a 12px caption, where an expired-credential botocore
message would blow the line out. The full text stays on the run record.
_job_viewis a separate projection on purpose, not an undeduplicated copy.It answers a different question from
job_routes._public_view, and the two alreadydiffer in fields today rather than only in principle:
_public_viewservescancellable,cancellingand the fullerror, while this one omits both cancelfields -- the backup runners declare no cancellability, so they would be permanently
false -- and clamps
errorfor the caption that renders it._public_viewalsotakes a required
cancellingset read from the SDK's live table, which thisendpoint has no reason to compute.
Sharing one projection across two endpoints with different contracts is how a field
leaks into one because the other needed it, and the field at stake here is
dedupe_key-- whose leaking IS the defect this endpoint exists to fix.test_the_account_never_reaches_the_clientguards that on this side, which makesthis a deliberately separate contract with its own proof. Secondarily,
_public_viewis private to P1, so importing it would stop P1 changing its ownprojection without breaking a consumer it does not know it has.
Authorization precedes any remote work, discovery included.
The job runner resolved the drive with
storage.find_drivebefore calling_authorize_upload, andfind_drivereaches the AWS tagging API -- so a run whoseconsent had been withdrawn issued live requests on the owner's credentials while in
the act of refusing the work, and the error shape or timing would tell an
unauthorized caller whether a bucket exists. The gate takes no bucket, so nothing
forced it to wait; it now runs first. This is IN ADDITION to the pre-upload
re-check, not instead of it, and collapsing the two would trade a real check for
tidiness: they answer different questions at different times. The new one decides
whether we may touch AWS at all. The old one decides whether the bytes may leave --
which matters because an archive build runs for minutes inside a worker thread, and
consent can be withdrawn, the app disabled, or the profile repointed DURING that
build. Only a check immediately before
put_filecan see that. The testasserts the recorded CALL SEQUENCE is exactly
["gate"]rather than asserting arefusal, because "it raised" is compatible with having already probed. Reversing the
two lines reddens it with
['discovery', 'gate'].The polling fix traded paid calls for an unreachable feature, and both belong in
the record. Making the remote half opt-in behind
?remote=1removed hundreds ofAWS calls per session, but the disclosure that is the ONLY way to request remote
data was itself gated on
data.remote-- so the control that enables the fetchrendered only if the fetch had already happened, and the stored-archive list and
Restore were unreachable for the entire session. The disclosure now renders whenever
status data exists, with a test that it is reachable and openable on a first paint
with
remote: null, which is the state the bug lived in. One existing assertionchanged with it, called out here rather than quietly edited to make new code pass: a
test required the disclosure to be HIDDEN when
remoteErrorwas set, and thatinvariant is what produced the regression. Under opt-in a
remoteErrorcan onlyexist because the control already requested the data, so "errored and hidden"
describes a state the running app cannot reach -- and worse, it hid the control at
exactly the moment the owner needed it to collapse and retry. The assertion now
requires the control to REMAIN available.
Refusals that name a cause, not just a failure.
The route answers
aws_consent_requiredanddrive_missingwith codes precisely sothe UI can localise them, so the row maps each to its own cause-and-next-step line
through a literal-key map (the convention already used for
KIND_LABEL_KEYhere andUPDATE_ERROR_KEYSinAboutPanel.tsx, which keeps every key visible to theextractor and the parity gate). Collapsing them into one generic string left the
owner guessing which of several repairs to attempt.
invalid_accountisdeliberately NOT mapped: the account comes from the page rather than from anything
the owner types, so the start button is not where a malformed one surfaces, and a
bespoke string there would be UI for a state this control cannot produce.
The asymmetry with
drive_missingis the reason rather than an exception to it.Under the rail,
DrivePaneGatemounts this section only when the drive exists, sodrive_missingat the click is a RACE -- the drive deleted between the panerendering and the press. Rare is not impossible, and a message for a reachable race
is not UI for an unreachable state, which is exactly what separates it from
invalid_account.jobs_unavailableno longer says "right now" -- it means the job runtime was neverregistered, so implying that waiting helps was the same false-hope defect in softer
form.
runIdis returned for a reason, and the reason is not the app's own row.The row follows server state, so it never reads
runId. The POST returns it becausea reply that says
started: truewithout saying WHAT started cannot be verified byanyone -- and the capture harness uses exactly that to assert the run the client
follows is the run the POST created, before and after a re-mount. That assertion is
this PR's evidence that adoption is causal rather than coincidental.
"Try again" only where a retry can clear the refusal.
The start-refusal line would otherwise have promised a retry for every failure, and
of the codes this path answers only one rewards it. Enumerated from the route rather
than special-cased:
aws_call_failedis a transient 502 from a live AWS call, and atransport-level
http_5xxis the same shape -- those get "Try again". Lapsed S3consent, no drive yet, a malformed account and an absent job runtime all need the
owner to do something ELSE, so the generic line promises nothing and
jobs_unavailablesays the service is unavailable. Defaulting to "try again" andexcepting one code was the wrong way round; the honest default is to promise nothing.
Telling the owner to take an action that cannot succeed is the UI asserting
something untrue, which is the class this change exists to close -- and it would have
been introduced BY this change rather than inherited, so fixing it finishes the line
rather than widening the diff. One test per branch: transient invites the retry, a
must-act-first refusal does not, and the runtime-absent case says so specifically.
The reason text is clamped for the caption at 180 characters and MARKED when cut,
because a sentence truncated with no sign of it reads as a complete thought that
happens to be ungrammatical. A short error is passed through untouched.
Polling does not cost AWS calls.
Because the page now polls this endpoint while a run is in flight, the remote
listing had to become opt-in. Its remote half tag-discovers the bucket on every
call and then lists the archive, so a minutes-long backup polled every 3s would
have fired hundreds of paid AWS round trips to learn
jobs.active, a fact theserver already holds in memory.
GET /backup/{account}now returns the cheap halfby default and the archive only for
?remote=1, which the page asks for exactlywhen the stored-archive list is open -- the same condition it already gated the
display behind. The poll is otherwise a local read.
useAppJobis deleted rather than kept.It was written for this one consumer against the app-scoped surface. Once the page
reads its own account-scoped payload the hook has no caller, and a shared frontend
module with no consumer is speculative generality on a surface that is hard to
withdraw later. Its test file goes with it. The page uses its existing
['aws-control', 'backup', account]query, which already fetched this data and nowpolls only while a run is in flight.
Not fixed here, and stated rather than left to be found.
(kind, dedupe_key)dedupe only protects paths that go through the SDK. The nightlyloop in
hooks.pystill callsrun_snapshot_backupdirectly, so nightly plus amanual click can both upload for one account, and a nightly run is invisible to the
row for the same reason -- it has no SDK record to read. Migrating it is its own
change with its own design question (what a scheduled run should do when the gateway
was down at its hour), so it is filed as
#7783 rather than widened into
this PR.
A refused upload is auditable, not merely a failed run.
Moving the upload off the request path moved the authorization decision off the
audited path with it.
routes._audithas already recordedsuccessfulby thetime the request returns a run id, and the Job SDK only records that the run
failed-- so a real authorization denial produced no SEL denial anywhere, and areader scanning for denials saw nothing. Every refusal in
_authorize_uploadnowgoes through
_refuse_upload, which emitssel().log_api_accesscarrying theaccount and the specific reason, then raises so the run still ends
failed.Three choices inside that worth stating. It is emitted at the DECISION, not in the
Job SDK runner as first suggested, because the nightly loop in
hooks.pyreachesthe same gate without a job at all and a runner-level catch would leave that path
unaudited. It reuses this app's existing event shape rather than a second
convention for the same kind of decision -- importing
routeswas not an option,since
routes.pyimportsbackup, so it goes through the sharedsel()accessor.And the five access decisions record
deniedwhile teardown recordsfailed:every refusal leaves a trace, because one covered path among several would make the
rest look like non-events, but a routine restart filed as
deniedwould sit besidea withdrawn consent and devalue every real denial. Both values come from the
vocabulary
sel.pydocuments for that field.Who triggered it is threaded, not guessed.
Covering the nightly path is precisely what made a hardcoded
callera lie: anunattended run refused at 03:00 would have been recorded against the dashboard
owner, a person who was not there. That is the same defect as everywhere else here
-- a record asserting something nobody observed -- committed at the audit layer
instead of the record layer.
Flattening both paths to a neutral
app:aws-controlwould fix the lie by throwingaway the truth on the interactive path, where the owner really did trigger the work.
So
calleris threaded from each entry point instead: the Job SDK runner passesCALLER_OWNERbecause a job exists only because an owner asked through anowner-gated route, and the nightly loop passes
CALLER_SCHEDULED. It is a REQUIREDkeyword all the way down to
_authorize_upload, with no default, so a call siteadded later cannot inherit whichever guess happened to be written first -- and the
guess written first here was the interactive one.
The capture harness, and a pre-existing defect in it.
website/scripts/capture-aws-control.mjsgains a backup fixture that behaves likea server which CLAIMED a run -- the
jobsblock is mutable and the start handlerwrites it -- plus the causal phase described below. It also honours
?remote=1, sothe harness exercises the same paid-call split the product ships.
Two things this section previously described are now gone, and a reviewer who read
the earlier version will look for them. Main's
be2ee9474(#7801) rebuilt thedrive as an in-app rail and rewrote this harness for the new IA, which removed both:
website/scripts/lib/aws-control-fixtures.mjsmodule is deleted, notre-argued. It was extracted so one module answered both the stills and the clip;
main's rewrite carries its own inline fixtures and has no reason to import it, so
the module has no consumer left. A shared module whose only caller went away gets
deleted -- the same call as
useAppJobearlier in this PR.usage.sectionsfixture repair is dropped: main fixed the identical defectindependently, and its own comment now records that
sectionsis required byDriveUsage. Nothing is owed here; the correction simply belongs to main.The causal phase is re-derived against the rail rather than ported. It clicks
Back up now, then leaves the pane and returns via the rail, and asserts the run
the row follows is byte-equal to the id the start POST returned. The restructure
made this evidence stronger rather than merely different: switching panes now
UNMOUNTS
BackupSectionoutright, so "the indicator is not component state" isdemonstrated by a real unmount instead of a synthetic re-mount, and the pane tree
being keyed by the selected account reinforces the account-scoped premise this
change argues for. A fourth assertion checks the account never crosses to the
client, testing the account ids the fixture actually uses and the
dedupe_keyfieldname -- an earlier version scanned for any twelve-digit run of characters, which the
32-character run id matched, so it flagged its own fixture and would have passed a
real leak just as happily. Both are mutation-checked under Tests.
Dependency and ordering
The P1 Job SDK this consumes has merged. Because the repo squash-merges, that was
confirmed by CONTENT rather than by SHA ancestry:
origin/maincarriesapps/job_sdk.pyandapps/job_routes.pywithSTARTINGandreconcile_allresolving, and this branch is rebased onto it.
merge-base --is-ancestoragainstthe old base correctly reports false, which is what a squash does to ancestry and
not a sign anything is missing.
A sibling PR, #7737
(
fix(apps): three places the Job SDK asserted a fact it never verified), fixesthree SDK defects found by auditing the SDK against real consumers -- including
the one that bears directly on this change:
register()accepts anasync defrunner and the run records
DONEwhile the body never runs.This PR is not blocked on #7737, and the ordering is safe either way. The
async defhazard is neutralised here consumer-side: both runners are plaindef, andtest_the_runner_is_not_a_coroutine_functionfails if that everchanges. If #7737 lands first this guard becomes belt-and-braces; if this lands
first the guard is what holds. Neither PR touches the other's files.
Tests
Every new test was confirmed to fail before the change and pass after, by
stashing only the source files and keeping the tests. Before:
20 failed, 73 passed, 10 errors. After the rebase onto main:500 passedacross the ninerelated backend suites, and
151 passedacross the six frontend files.test/test_aws_control_backup_job.py(new, 28 cases):runIdand does not perform the backup in the request;jobs_unavailable, anda refused claim is 503
backup_start_failed;resolves its work function at call time rather than capturing it;
dedupe_key, and re-discovers thedrive rather than trusting a carried name;
error and touch no AWS: absent (
dedupe_keydefaults to"") and awell-formed string that is not an account id;
put_fileisnever reached;
originbecomesinterruptedanddrops out of
list_active; reconcile is idempotent so the gateway's ownpost-enable-loop pass finds nothing left;
on_startupregisters both kinds, does so even when the nightly task isalready live (a re-enable brings a fresh SDK with an empty runner table), and
survives a context with no job runtime;
app.jsondeclares thejobsgrant;GET /backup/{account}still serves it.
website/src/apps/aws-control/DrivePage.test.tsx(63 cases, extended): adoption ona fresh mount from the account-scoped payload, per-kind scoping with the sibling row
left usable, a failed run rendering its reason while the button stays enabled, and
an absent
jobsentry reading as idle rather than unknown.website/src/apps/aws-control/DrivePage.test.tsx: two new cases -- a backupshows as running on a fresh mount with no click in this session, and only the
kind that is running is adopted, leaving the sibling row usable.
Four existing tests in
test/test_aws_control_routes.pypinned the removedblocking contract (
ran: true, the 409/502 runner-error mapping) and wererewritten to the new one, not deleted.
The capture harness asserts rather than just saving PNGs, so a stale bundle or a
changed surface fails the run instead of quietly photographing the old page: the
idle row is enabled, the adopted row is disabled and its label contains "Backing
up", the sibling row stays enabled, and nothing overlays the row. It exits
non-zero on any mismatch -- which is how the fixture defect above was found.
51 assertions pass, 0 mismatches.
Nine of those are the causal phase, and the load-bearing one was mutation-checked
rather than assumed: making the stub return an id different from the run it starts
turns
causal run in flight is the one the POST returnedandcausal same run after the re-mountred and the harness exits 1. Without that check the assertioncould have compared a value against itself and passed while proving nothing.
TestEveryUploadRefusalIsAuditedcovers the audit fix per refusal path rather thanonce -- five parametrised access decisions plus teardown -- because an audited
consent refusal beside a silent identity mismatch would make the mismatch look
like a non-event. Each case puts every other check in a passing state so it fails
exactly one, and asserts the outcome, the account and the specific reason reach the
record. A ratchet,
test_no_refusal_path_bypasses_the_audited_helper, reads thefunction's own source and fails on any
raisethat does not go through_refuse_upload, so a path added later cannot be silent -- which is the failuremode that made the original defect invisible. Mutation-checked: replacing one
audited refusal with a bare
raisereddens both the ratchet and that path's case.Gates:
flake8,black --check,tsc --noEmitandeslintclean on the changedfiles, and
jscpdreports 0 clones (extending the existing harness rather thanadding a second one is what keeps it there -- the duplication threshold is 0%).
Manual verification
Captured, with one limit stated below rather than left for a reader to find.
The behaviour cannot be filmed against an unprovisioned account, and the reason is
mechanical rather than a matter of effort. Through the generic
_jobs/{kind}/startroute a run is created for real, but the worker's first act after flipping to
RUNNINGisresolve_account_profile_cached, an in-memory read that returnsNonewith no account snapshot and raises immediately. The wholeRUNNING-to-FAILEDwindow is twoatomic_writes and a dict lookup -- single-digitmilliseconds -- while the page polls at 3000ms, so no poll can catch it in
flight. A live film of the navigate-away-and-back beat needs a provisioned AWS
account with a tag-discovered drive and a backup still uploading across the
navigation.
The screenshots below take the other route: the stub holds
_jobs/activein astate a live unprovisioned gateway cannot hold still. That is legitimate here
because the thing under test is the UI following a server-owned record, and the
stub supplies exactly the server's half of that -- it does not fake a backup.
Screenshots / video
Two frames of the same backup rows, from the repo's mocked-API harness
(
website/scripts/capture-aws-control.mjs), with no gateway, no credentials andno AWS call anywhere in the capture. The only difference between them is what
/api/apps/aws-control/_jobs/activeanswers: nothing in the first, onerunningsnapshot run in the second.
The two frames are nearly identical, and the whole delta is one control. It is
the button at the right of the first row, Memory & workspace -- the row that
carries the
snapshotkind. It reads Back up now and is enabled in the firstframe; it reads Backing up... and is disabled in the second.
The strongest thing in the pair is the row that did not change.
Sessions archiveoffers Back up now, enabled, in BOTH frames. So the busy state isscoped to one run and is not a page-level or section-level disable -- the host
indexes a run by
(kind, dedupe_key), and the second frame is what that indexinglooks like from the outside. Without this, a reviewer would have to take the
scoping on faith.
The clip carries the one thing no still can: that a navigation happened.
backup-adoption-flow.mp4 -- 29s, silent, same mocked harness
Six beats, answered by the same fixtures as the stills: the rows idle; Back up
now clicked; the row reading Backing up...; the Backup section left entirely
for the drive root; the section re-entered; and the row still reading Backing
up... on that fresh mount.
The footage is of THIS diff's route as a measured fact rather than as a consequence
of the build steps having been correct: before filming, the recorder counts the
route strings in the served
AwsControlPage-*.jschunk and requires_jobs/active= 0 and/backup/> 0. An earlier cut of this clip was recordedagainst
_jobs/active, which this diff deletes -- identical pixels, but evidence ofa path that is no longer here. Counting the shipped bundle is what makes the
difference checkable instead of asserted.
The click is causal, not staged. The stub mints the run and returns its id the way
_handle_backup_rundoes, and the run the row then follows is that same run. Thatpart is not visible in the frames -- a run id does not render -- so it is
asserted by the capture harness rather than claimed here:
causal the run the client follows IS the one the POST returnedreads the id out of theGET /backup/{account}response the browser actually receives and compares it towhat the POST answered, before and after the re-mount, alongside a check that the
account does NOT cross to the client. What the clip carries is the flow; what the
harness carries is the identity. The alternative -- swapping a run in beside a click
that did not create it -- would have depicted a sequence rather than the code path,
so it was rejected.
What the media does and does not establish: the frames and the clip exercise the
hook and the row against a stubbed API. Nothing here is a real backup -- no AWS
account, no S3 call, nothing uploaded. A still also cannot show the spinner
turning; what it shows is the disabled control and its label.
One thing that looks wrong at a glance and is not: in the second frame Not
backed up yet still sits under a row reading Backing up.... That is the design
-- the Job SDK records that a run exists, while the app's own ledger records what a
run produced, and the ledger gains its entry only when the upload finishes.
End-to-end durability is not what these frames prove. A gateway restart resolving
an interrupted run, the runner's refusals, and the consent gate are covered by
test/test_aws_control_backup_job.py.Related Issues
Spec:
docs/system-specs/features/app-sdk-durable-jobs-and-view-state.md,sections "Current SDK boundary" and "AWS Control backups". Sibling:
#7737 (not a blocker -- see
Dependency and ordering).
Any other suggestions on the work
Five things deliberately left out of this diff, each with its reasoning:
_jobs/activecannot be filtered by account.dedupe_keyis withheldfrom
_public_view, so the client cannot tell whose run it is. Driving twoaccounts' snapshot backups concurrently would show one account's run as
running on the other's page. Not fixable client-side without serving
dedupe_key, which is an SDK change and out of scope here. Narrow today: thenightly loop runs against the registry-default account only, and is not
SDK-routed (see 2).
run_snapshot_backupdirectly. Consequence:a nightly backup does not appear as a job in the UI, and nightly can overlap
a manual click on the same account, which the dedupe key would otherwise
prevent. Routing it through
sdk.startis a few lines and worth doing; keptout of this PR to keep the diff to the surface the DoD names.
rendered nothing for a failed
runMuteither. Showing it needs a new i18n keyacross 13 locales plus the parity and ratchet gates -- a small, separate
change.
cancellableis left at its default ofFalse. Neither backup runnerpolls
handle.cancelled; the only stop signal they honour is the teardownevent checked in
_authorize_upload, which is not a cancel checkpoint. TheSDK cannot verify the app's assertion, so claiming
Truewould put a Cancelbutton in front of the owner that does nothing. The UI hides it instead.
useAppJobhook in the app-sdk; moving the read onto the app's ownaccount-scoped endpoint left it with no consumer, so it was deleted rather
than published. A shared module is worth adding when a second consumer exists.
Checklist
this consumer; no doc change needed in this PR