feat: retry planning sync automatically (#365)
CI / lint (pull_request) Successful in 43s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-09 03:00:12 +00:00
1 parent 6dfc02158d
commit 901691a93b
13 files changed
+226 -28

No files matched your search

+27
View File
@@ -1,3 +1,5 @@
import sqlite3
import httpx
import pytest
@@ -5,6 +7,31 @@ from src import main
from src.later_store import LaterStore
@pytest.mark.anyio
async def test_transient_later_store_failure_tells_clients_when_to_retry(monkeypatch):
async def user():
return {"login": "timmy"}
class BusyStore:
def apply(self, *_args, **_kwargs):
raise sqlite3.OperationalError("database is busy")
monkeypatch.setattr(main, "current_user", user)
monkeypatch.setattr(main, "_later_store", lambda: BusyStore())
payload = main.LaterOperation(
operation_id="retry-me",
action="defer",
item_id="issue:r:1:",
wake_at="2026-08-10T09:00:00.000Z",
)
with pytest.raises(main.HTTPException) as raised:
await main.update_later_plan(payload)
assert raised.value.status_code == 503
assert raised.value.headers == {"Retry-After": "1"}
def test_deferrals_are_durable_revisioned_idempotent_and_account_scoped(tmp_path):
path = tmp_path / "later.sqlite3"
store = LaterStore(path)
+29 -2
View File
@@ -83,7 +83,34 @@ sync.enqueue('restore','issue:r:2:');
"wake_at": None,
}
],
"status": "pending",
"status": "retrying",
}
def test_retry_is_single_flight_and_does_not_cross_account_boundary():
script = f"""
const createLaterSync = require({json.dumps(str(LATER_SYNC))});
const values=new Map(); const timers=[]; const requests=[]; let login='timmy';
const sync=createLaterSync({{
storage:{{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}},
getLogin:()=>login,createOperationId:()=> 'stable-later-op',
setTimer:(callback,delay)=>{{timers.push({{callback,delay}});return timers.length}},clearTimer:()=>{{}},
fetchJson:async (_url,options={{}})=>{{requests.push(options.method||'GET');throw new Error('offline')}},
onRemoteRecords:()=>{{}},onStatus:()=>{{}},
}});
sync.enqueue('defer','issue:r:2:','2026-08-10T09:00:00.000Z');
(async()=>{{
const first=sync.flush(); const same=sync.flush(); await Promise.all([first,same]);
const scheduledBeforeSwitch=timers.length;
login='alexander'; await timers[0].callback();
process.stdout.write(JSON.stringify({{scheduledBeforeSwitch,timers:timers.length,requests,pendingForAlexander:sync.pending()}}));
}})();
"""
assert run_node(script) == {
"scheduledBeforeSwitch": 1,
"timers": 1,
"requests": ["GET"],
"pendingForAlexander": [],
}
@@ -206,5 +233,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
def test_later_sync_ships_atomically_in_the_offline_shell():
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
assert "stackchain-dashboard-shell-v48" in source
assert "stackchain-dashboard-shell-v49" in source
assert "BASE + 'static/later-sync.js'" in source
+1 -1
View File
@@ -137,4 +137,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers():
assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css
assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css
assert ".markdown-content a { min-height:44px;" in css
assert "stackchain-dashboard-shell-v48" in worker
assert "stackchain-dashboard-shell-v49" in worker
+1 -1
View File
@@ -35,4 +35,4 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset():
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
assert "stackchain-dashboard-shell-v48" in worker
assert "stackchain-dashboard-shell-v49" in worker
+4 -4
View File
@@ -108,7 +108,7 @@ async function dispatchNotificationClick(route) {{
def test_navigation_deadline_ships_in_a_new_shell_cache():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v48" in source
assert "stackchain-dashboard-shell-v49" in source
assert "BASE + 'static/dashboard.css'" in source
assert "BASE + 'static/dashboard.js'" in source
assert "BASE + 'static/install-app.js'" in source
@@ -117,21 +117,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache():
def test_today_convergence_ships_in_a_new_shell_cache():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v48" in source
assert "stackchain-dashboard-shell-v49" in source
assert "BASE + 'static/today-sync.js'" in source
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v48" in source
assert "stackchain-dashboard-shell-v49" in source
assert "BASE + 'static/mobile-search-viewport.js'" in source
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
source = WORKER.read_text()
assert "stackchain-dashboard-shell-v48" in source
assert "stackchain-dashboard-shell-v49" in source
assert "BASE + 'static/update-ownership.js'" in source
+24
View File
@@ -1,3 +1,5 @@
import sqlite3
import pytest
import httpx
@@ -5,6 +7,28 @@ from src import main
from src.today_store import TodayPlanFull, TodayStore
@pytest.mark.anyio
async def test_transient_today_store_failure_tells_clients_when_to_retry(monkeypatch):
async def user():
return {"login": "timmy"}
class BusyStore:
def apply(self, *_args, **_kwargs):
raise sqlite3.OperationalError("database is locked")
monkeypatch.setattr(main, "current_user", user)
monkeypatch.setattr(main, "_today_store", lambda: BusyStore())
payload = main.TodayOperation(
operation_id="retry-me", action="add", item_id="issue:r:1:"
)
with pytest.raises(main.HTTPException) as raised:
await main.update_today_plan(payload)
assert raised.value.status_code == 503
assert raised.value.headers == {"Retry-After": "1"}
def test_operations_are_durable_ordered_idempotent_and_account_scoped(tmp_path):
path = tmp_path / "today.sqlite3"
store = TodayStore(path, limit=3)
+40 -1
View File
@@ -83,10 +83,49 @@ sync.enqueue('move', 'issue:r:2:', 'up');
"item_id": "issue:r:2:",
"direction": "up",
}],
"status": "pending",
"status": "retrying",
}
def test_retryable_failure_replays_automatically_once_with_same_operation_id():
script = f"""
const createTodaySync = require({json.dumps(str(TODAY_SYNC))});
const values = new Map(); const timers = []; const statuses = []; const patches = [];
let attempts = 0;
const sync = createTodaySync({{
storage: {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}},
getLogin:()=> 'timmy', createOperationId:()=> 'stable-op',
setTimer:(callback,delay)=>{{timers.push({{callback,delay}});return timers.length}}, clearTimer:()=>{{}},
fetchJson:async (_url,options={{}})=>{{
attempts += 1;
if (attempts === 1) {{ const error = new Error('busy'); error.status=503; error.retryAfter=2; throw error; }}
if (options.method) patches.push(JSON.parse(options.body));
return options.method ? {{revision:1,ids:['issue:r:2:']}} : {{revision:0,ids:[]}};
}},
onRemoteIds:()=>{{}}, onStatus:(state,detail)=>statuses.push([state,detail?.delayMs||null]),
}});
sync.enqueue('add','issue:r:2:');
(async()=>{{
await sync.flush();
const scheduled = timers.map(timer=>timer.delay);
await timers[0].callback();
process.stdout.write(JSON.stringify({{scheduled,attempts,patches,pending:sync.pending(),statuses}}));
}})();
"""
result = json.loads(
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True).stdout
)
assert result["scheduled"] == [2000]
assert result["attempts"] == 3
assert result["patches"] == [{
"operation_id": "stable-op", "action": "add", "item_id": "issue:r:2:", "direction": None,
}]
assert result["pending"] == []
assert ["retrying", 2000] in result["statuses"]
assert result["statuses"][-1] == ["saved", None]
def test_duplicate_pending_retirements_collapse_to_one_effective_remove():
script = f"""
const createTodaySync = require({json.dumps(str(TODAY_SYNC))});
+9
View File
@@ -16,6 +16,15 @@ def run_node(script):
).stdout
@pytest.mark.anyio
async def test_dashboard_surfaces_automatic_planning_retry_guidance():
html = await dashboard()
assert "error.retryAfter = retryAfter === null ? undefined : Number(retryAfter)" in html
assert "Today saved on this device · retrying" in html
assert "Later saved on this device · retrying" in html
def test_today_queue_is_account_scoped_ordered_unique_and_bounded():
script = f"""
const createTodayWork = require({json.dumps(str(TODAY_WORK))});