feat: notify pending Human Gate decisions (Closes #1437)
CI / lint (pull_request) Successful in 4m18s
CI / build-release (pull_request) Successful in 8s
CI / browser-journey (pull_request) Failing after 7m57s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-26 16:47:57 +00:00
1 parent ad15f15e3c
commit 7158e00b97
13 files changed
+494 -17

No files matched your search

+21
View File
@@ -88,6 +88,27 @@ console.log(JSON.stringify({values, clears}));
assert result == {"values": [3, 5, 3, 2, 7], "clears": 1}
def test_app_badge_combines_pending_human_gates_with_other_review_channels():
result = run_badge("""
const values = [];
let clears = 0;
const controller = createMobileAppBadge({
control:{checked:true, disabled:false, addEventListener() {}},
status:{textContent:''}, container:{hidden:false},
navigator:{async setAppBadge(value) { values.push(value); }, async clearAppBadge() { clears++; }},
storage:{getItem() { return 'true'; }, setItem() {}, removeItem() {}},
});
controller.start();
await controller.reconcile('updates', 3, true);
await controller.reconcile('human-gates', 2, true);
await controller.reconcile('following', 1, true);
await controller.reconcile('human-gates', 0, true);
console.log(JSON.stringify({values, clears}));
""")
assert result == {"values": [3, 5, 6, 4], "clears": 0}
def test_app_badge_hides_unsupported_device_control_without_touching_storage():
result = run_badge("""
const control = {checked:false, disabled:false, addEventListener() { throw new Error('must not wire'); }};
+31
View File
@@ -34,6 +34,11 @@ const followingControl = {
addEventListener:(_name, callback) => state.followingChange = callback,
};
const followingStatus = {set textContent(value) { state.followingText = value; }, get textContent() { return state.followingText; }};
const humanGateControl = {
checked:false, disabled:false,
addEventListener:(_name, callback) => state.humanGateChange = callback,
};
const humanGateStatus = {set textContent(value) { state.humanGateText = value; }, get textContent() { return state.humanGateText; }};
const quietControl = {checked:false, disabled:false, addEventListener:(_name, callback) => state.quietChange = callback};
const quietStart = {value:'22:00', disabled:false, addEventListener:(_name, callback) => state.quietStartChange = callback};
const quietEnd = {value:'07:00', disabled:false, addEventListener:(_name, callback) => state.quietEndChange = callback};
@@ -52,6 +57,7 @@ const feature = createPushNotifications({
control, status, testControl, deadlineControl, deadlineStatus, deadlineHour, deadlineDays,
startDayControl, startDayStatus, startDayHour,
followingControl, followingStatus,
humanGateControl, humanGateStatus,
quietControl, quietStart, quietEnd, quietStatus,
deadlineSnooze, deadlineSnoozeStatus, deadlineSnoozeReview,
onReviewDeadlines:() => { state.reviewed = true; },
@@ -119,6 +125,31 @@ def test_device_settings_render_and_wire_the_following_alert_preference():
assert "followingStatus:qs('#push-following-status')" in dashboard
def test_human_gate_alert_toggle_is_opt_in_and_wired_for_touch_settings():
result = run_scenario("""
state.server = {available:true,subscribed:true,human_gates_enabled:false,following_enabled:true,public_key:'AQID'};
state.current = existing;
await feature.init();
humanGateControl.checked = true;
await state.humanGateChange();
process.stdout.write(JSON.stringify({requests:state.requests, checked:humanGateControl.checked, following:followingControl.checked, text:state.humanGateText}));
""")
assert result["checked"] is True
assert result["following"] is True
assert result["requests"][-1][0:2] == ["api/v1/push-subscription/human-gates", "PUT"]
assert json.loads(result["requests"][-1][2]) == {"enabled": True}
assert result["text"] == "Human Gate decision alerts enabled for this device."
index = INDEX.read_text()
dashboard = DASHBOARD.read_text()
assert 'for="push-human-gates"' in index
assert 'id="push-human-gates" type="checkbox"' in index
assert 'id="push-human-gates-status" role="status" aria-live="polite"' in index
assert "humanGateControl:qs('#push-human-gates')" in dashboard
assert "humanGateStatus:qs('#push-human-gates-status')" in dashboard
def test_quiet_hours_are_restored_and_saved_as_one_local_schedule():
result = run_scenario("""
state.server = {available:true,subscribed:true,quiet_hours_enabled:true,quiet_hours_start:'21:30',quiet_hours_end:'06:45',quiet_hours_timezone:'America/New_York',public_key:'AQID'};
+89 -4
View File
@@ -63,6 +63,59 @@ def test_following_alert_preferences_are_opt_in_and_checkpoint_each_device(tmp_p
assert store.following_preferences("session-b") == {"enabled": False}
@pytest.mark.anyio
async def test_human_gate_dispatch_is_opt_in_private_deduplicated_and_session_bound(tmp_path):
dispatch = getattr(__import__("src.push_notifications", fromlist=["dispatch_human_gate_changes"]), "dispatch_human_gate_changes", None)
assert callable(dispatch), "Human Gate push dispatcher is missing"
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
for session_id in ("active", "revoked", "disabled"):
store.upsert(session_id, {
"endpoint": f"https://push.example/{session_id}",
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
})
store.set_human_gate_preferences("active", enabled=True)
store.set_human_gate_preferences("revoked", enabled=True)
sent = []
pending_count = 2
async def pending_gates():
return {"complete": True, "count": pending_count, "items": [{
"title": "Secret launch", "artifact_url": "https://secret.example/token",
"candidate_hash": "private-hash",
}]}
async def send(subscription, payload):
sent.append((subscription["endpoint"], json.loads(payload)))
async def statuses(session_ids):
return {item: ("active" if item == "active" else "revoked") for item in session_ids}
configuration = PushConfiguration("public", "private", "mailto:ops@example.com")
assert await dispatch(
store, configuration, pending_gates, send, session_statuses=statuses
) == 1
assert sent == [("https://push.example/active", {
"title": "2 release decisions are waiting",
"body": "Open Human Gates to review them.",
"route": "#/my-work/human-gates",
"tag": "stackchain-human-gates-2",
"human_gate_count": 2,
})]
assert "secret" not in json.dumps(sent).lower()
assert "private-hash" not in json.dumps(sent).lower()
assert await dispatch(
store, configuration, pending_gates, send, session_statuses=statuses
) == 0
pending_count = 0
assert await dispatch(
store, configuration, pending_gates, send, session_statuses=statuses
) == 0
assert store.human_gate_notification_devices()[0].delivered_count == 0
assert len(sent) == 1
assert store.subscription_for_session("revoked") is None
def test_quiet_hours_hold_unread_revisions_then_mark_one_catch_up_delivery(tmp_path):
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
store.upsert("session-a", {
@@ -233,6 +286,31 @@ async def test_authenticated_device_controls_following_alerts_independently(tmp_
assert store.start_day_preferences("session-a")["enabled"] is False
@pytest.mark.anyio
async def test_authenticated_device_controls_human_gate_alerts_independently(tmp_path, monkeypatch):
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
store.upsert("session-a", {
"endpoint": "https://push.example/session-a",
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
})
monkeypatch.setattr(main, "_push_subscription_store", store)
async def management_id(_session):
return "session-a"
monkeypatch.setattr(main.dashboard_auth, "session_management_id", management_id)
request = SimpleNamespace(state=SimpleNamespace(dashboard_session=object()))
payload_type = getattr(main, "HumanGateNotificationPayload", None)
endpoint = getattr(main, "update_human_gate_notifications", None)
assert payload_type is not None and callable(endpoint), "Human Gate push preference API is missing"
result = await endpoint(payload_type(enabled=True), request)
assert result == {"human_gates_enabled": True}
assert (await main.push_status(request))["human_gates_enabled"] is True
assert store.following_preferences("session-a")["enabled"] is False
@pytest.mark.anyio
async def test_authenticated_device_persists_validated_quiet_hours(tmp_path, monkeypatch):
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
@@ -699,6 +777,7 @@ async def test_push_poll_authorizes_delivery_against_managed_session(monkeypatch
monkeypatch.setattr(main.asyncio, "sleep", no_wait)
monkeypatch.setattr(main, "dispatch_unread_updates", stop_after_capture)
monkeypatch.setattr(main, "dispatch_following_changes", hold_dispatch)
monkeypatch.setattr(main, "dispatch_human_gate_changes", hold_dispatch)
monkeypatch.setattr(main, "dispatch_deadline_reminders", hold_dispatch)
monkeypatch.setattr(main, "dispatch_start_day_reminders", hold_dispatch)
@@ -721,8 +800,8 @@ async def test_push_poll_still_dispatches_deadlines_when_unread_dispatch_fails(m
async def no_wait(_seconds):
nonlocal sleeps
sleeps += 1
if sleeps <= 4:
if sleeps == 4:
if sleeps <= 5:
if sleeps == 5:
first_tick.set()
await first_tick.wait()
else:
@@ -741,16 +820,20 @@ async def test_push_poll_still_dispatches_deadlines_when_unread_dispatch_fails(m
async def dispatch_following(*_args, **_kwargs):
calls.append("following")
async def dispatch_human_gates(*_args, **_kwargs):
calls.append("human-gates")
monkeypatch.setattr(main.asyncio, "sleep", no_wait)
monkeypatch.setattr(main, "dispatch_unread_updates", fail_unread)
monkeypatch.setattr(main, "dispatch_following_changes", dispatch_following)
monkeypatch.setattr(main, "dispatch_human_gate_changes", dispatch_human_gates)
monkeypatch.setattr(main, "dispatch_deadline_reminders", dispatch_deadlines)
monkeypatch.setattr(main, "dispatch_start_day_reminders", dispatch_start_day)
with pytest.raises(asyncio.CancelledError):
await main._push_poll_loop()
assert sorted(calls) == ["deadline", "following", "start-day", "unread"]
assert sorted(calls) == ["deadline", "following", "human-gates", "start-day", "unread"]
@pytest.mark.anyio
@@ -776,6 +859,7 @@ async def test_push_poll_deadlines_continue_while_unread_dispatch_is_blocked(mon
monkeypatch.setattr(main.asyncio, "sleep", no_wait)
monkeypatch.setattr(main, "dispatch_unread_updates", blocked_unread)
monkeypatch.setattr(main, "dispatch_following_changes", dispatch_start_day)
monkeypatch.setattr(main, "dispatch_human_gate_changes", dispatch_start_day)
monkeypatch.setattr(main, "dispatch_deadline_reminders", dispatch_deadlines)
monkeypatch.setattr(main, "dispatch_start_day_reminders", dispatch_start_day)
@@ -802,7 +886,7 @@ async def test_push_poll_uses_a_lower_independent_deadline_cadence(monkeypatch):
await main._push_poll_loop()
assert sorted(intervals) == [30.0, 30.0, 600.0, 600.0]
assert sorted(intervals) == [30.0, 30.0, 30.0, 600.0, 600.0]
@pytest.mark.anyio
@@ -1572,6 +1656,7 @@ async def test_authenticated_device_can_subscribe_report_status_and_unsubscribe(
"start_day_timezone": "UTC",
"start_day_reminder_hour": 9,
"following_enabled": False,
"human_gates_enabled": False,
"quiet_hours_enabled": False,
"quiet_hours_start": "22:00",
"quiet_hours_end": "07:00",
+34 -5
View File
@@ -14,7 +14,7 @@ def run_worker_scenario(scenario: str) -> dict:
const fs = require('fs');
const vm = require('vm');
const listeners = {{}};
const state = {{ added: [], addAttempts: [], individuallyAdded: [], failedAdds: [], deleted: [], deletedDatabases: [], claimed: false, skipped: false, fetches: [], puts: [], migrated: [], activationOrder: [], oldCachedAssets: {{}}, sharedRecords: {{}}, todayCommands: {{}}, failTodayCommandPut: false, failSharedPut: false, backgroundFlushes: 0, backgroundResumes: 0, outboxPurges: 0, outboxLifecycle: [], notifications: [], closedNotifications: 0, focused: [], opened: [], appBadges: [], clearedAppBadges: 0, badgeEnabled: false, badgeCounts: {{updates:0, following:0}}, failFetch: false, stallFetch: false, lateFetch: false, fetchAborted: false, fetchStatus: 200, fetchRedirected: false, cachedBody: null }};
const state = {{ added: [], addAttempts: [], individuallyAdded: [], failedAdds: [], deleted: [], deletedDatabases: [], claimed: false, skipped: false, fetches: [], puts: [], migrated: [], activationOrder: [], oldCachedAssets: {{}}, sharedRecords: {{}}, todayCommands: {{}}, failTodayCommandPut: false, failSharedPut: false, backgroundFlushes: 0, backgroundResumes: 0, outboxPurges: 0, outboxLifecycle: [], notifications: [], closedNotifications: 0, focused: [], opened: [], appBadges: [], clearedAppBadges: 0, badgeEnabled: false, badgeCounts: {{updates:0, following:0, 'human-gates':0}}, failFetch: false, stallFetch: false, lateFetch: false, fetchAborted: false, fetchStatus: 200, fetchRedirected: false, cachedBody: null }};
const storedResponses = new Map();
storedResponses.set(
'https://forge.example/dashboard/__offline-session-lease',
@@ -83,7 +83,7 @@ const context = {{
set: async enabled => {{ state.badgeEnabled = enabled; }},
getCounts: async () => ({{...state.badgeCounts}}),
setCount: async (channel, count) => {{ state.badgeCounts[channel] = count; }},
clearCounts: async () => {{ state.badgeCounts = {{updates:0, following:0}}; }},
clearCounts: async () => {{ state.badgeCounts = {{updates:0, following:0, 'human-gates':0}}; }},
}},
addEventListener: (name, handler) => {{ listeners[name] = handler; }},
skipWaiting: async () => {{ state.skipped = true; }},
@@ -935,6 +935,35 @@ def test_inbound_push_renders_generic_update_and_tap_opens_existing_workflow():
assert "must-not-render" not in json.dumps(result["notifications"])
def test_human_gate_push_is_count_only_and_tap_opens_canonical_mobile_review_route():
result = run_worker_scenario(
"""
state.badgeEnabled = true;
await dispatchPush({
title:'must-not-render', body:'secret candidate must-not-render',
tag:'stackchain-human-gates-2', route:'#/my-work/human-gates',
human_gate_count:2, artifact_url:'https://secret.example/token',
});
await dispatchNotificationClick('#/my-work/human-gates', '', null, 'stackchain-human-gates-2');
process.stdout.write(JSON.stringify(state));
"""
)
assert result["notifications"] == [{
"title": "2 release decisions are waiting",
"options": {
"body": "Open Human Gates to review them.",
"tag": "stackchain-human-gates-2",
"data": {"route": "#/my-work/human-gates"},
},
}]
assert result["opened"] == [
"https://forge.example/dashboard/#/my-work/human-gates"
]
assert result["appBadges"] == [2]
assert "secret" not in json.dumps(result["notifications"])
def test_start_day_push_is_private_and_prepare_action_opens_cached_launch_route():
result = run_worker_scenario(
"""
@@ -1023,7 +1052,7 @@ def test_background_badge_combines_following_and_updates_without_channel_overwri
)
assert result["appBadges"] == [2, 5, 4]
assert result["badgeCounts"] == {"updates": 3, "following": 1}
assert result["badgeCounts"] == {"updates": 3, "following": 1, "human-gates": 0}
assert result["clearedAppBadges"] == 0
@@ -1039,7 +1068,7 @@ def test_worker_accepts_only_authenticated_authoritative_badge_channel_counts():
"""
)
assert result["badgeCounts"] == {"updates": 3, "following": 2}
assert result["badgeCounts"] == {"updates": 3, "following": 2, "human-gates": 0}
assert result["appBadges"] == [5]
@@ -1054,7 +1083,7 @@ def test_foreground_channel_sync_invalidates_worker_render_cache_for_next_push()
"""
)
assert result["badgeCounts"] == {"updates": 2, "following": 0}
assert result["badgeCounts"] == {"updates": 2, "following": 0, "human-gates": 0}
assert result["appBadges"] == [2, 2]
+1
View File
@@ -209,6 +209,7 @@ async def test_push_poll_applies_configured_concurrency_to_start_day(
monkeypatch.setattr(main.asyncio, "sleep", no_wait)
monkeypatch.setattr(main, "dispatch_unread_updates", hold_dispatch)
monkeypatch.setattr(main, "dispatch_following_changes", hold_dispatch)
monkeypatch.setattr(main, "dispatch_human_gate_changes", hold_dispatch)
monkeypatch.setattr(main, "dispatch_deadline_reminders", hold_dispatch)
monkeypatch.setattr(main, "dispatch_start_day_reminders", capture_start_day)