Files
web-terminal/public/sw-push.js
Yaojia Wang 1dd12b035a feat(cockpit): quick wins — sync chip, cost budget guard, digest, recent commits (W3)
Four small, high-delight features that turn passive capture into glanceable signals.

- Sync chip on project cards: ahead/behind vs upstream + last-commit time, folded
  into the existing concurrent per-repo metadata pass (git rev-list --count
  --left-right @{u}...HEAD + git log -1 --format=%ct; no upstream → undefined, no route).
- Cost budget guard: COST_BUDGET_USD env (0 = off); a per-session one-shot latch
  (Session.budgetNotified, cost is monotonic so never re-armed) fires a single push
  on threshold crossing in manager.handleStatusLine; the already-broadcast telemetry
  frame carries the warn (tg-cost-warn styling derived from costUsd>=budget via
  /config/ui — no new ServerMessage). web-push title added to sw-push.js.
- "While you were away" digest: GET /digest?since= → {finished, needsInput, stuck,
  totalCostUsd, sessions[]} aggregate over manager.list(); FE banner on reconnect.
- Recent commits per project: src/http/git-log.ts (NUL-delimited git log → CommitInfo[]),
  GET /projects/log?path= (isValidGitDir), textContent-inert render in project detail.

All git via execFile (no shell) + validated cwd; new routes read-only; commit
messages rendered via textContent. Verified: typecheck + build:web clean, 1904 pass
at --test-timeout=30000 (two default-5s failures are slow-sandbox real-subprocess
timeout flakes — the known ring-buffer test + a new real-git-clone sync test — not
logic regressions).
2026-07-12 21:27:20 +02:00

84 lines
3.0 KiB
JavaScript

/**
* public/sw-push.js — Pure push notification helpers (no SW globals).
*
* This file is imported by public/sw.js (module service worker) and tested
* directly by test/sw-push.test.ts. It must never reference `self`, `clients`,
* or any other SW-specific global — pure functions only.
*
* Implements the §3.3 PushPayload contract (one shape; FE reads `cls`).
* SEC-M6: tag = sessionId so the browser deduplicates (no notification stacking).
* SEC-C1: capability token is forwarded in the decision body; Origin + token
* validation happens server-side at POST /hook/decision.
*/
/** @type {Record<string, string>} */
const TITLES = {
'needs-input': 'Approval Needed',
done: 'Task Complete',
stuck: 'Task Stuck',
budget: 'Cost Budget Reached',
}
/**
* Build a Notification title + options from an incoming push payload.
*
* @param {{ sessionId: string, toolName?: string, detail?: string, token?: string, cls: string }} payload
* @returns {{ title: string, options: Record<string, unknown> }}
*/
export function buildPushNotification(payload) {
const { sessionId, toolName, detail, token, cls } = payload
const title = TITLES[cls] ?? 'Web Terminal'
// Body: prefer explicit detail, fall back to toolName hint, else omit.
const body = detail ?? (toolName ? `Tool: ${toolName}` : undefined)
/** @type {Record<string, unknown>} */
const options = {
tag: sessionId, // SEC-M6: replaces any previous notification for this session
body,
data: { sessionId, token, cls },
requireInteraction: cls === 'needs-input',
// Allow / Deny actions only for needs-input (lock-screen approval, AC-A1.2/A1.3)
actions:
cls === 'needs-input'
? [
{ action: 'allow', title: 'Allow' },
{ action: 'deny', title: 'Deny' },
]
: [],
}
return { title, options }
}
/**
* Resolve a notificationclick event into an opaque result.
* The caller (sw.js) executes the result:
* kind='decision' → POST /hook/decision with result.body (SEC-C1)
* kind='focus' → openWindow / focus existing tab at result.url
*
* @param {{ data: { sessionId?: string, token?: string, cls?: string } | null }} notification
* @param {string | null | undefined} action — event.action from notificationclick
* @returns {{ kind: 'decision', body: string } | { kind: 'focus', url: string }}
*/
export function resolveNotificationClick(notification, action) {
const data = notification?.data ?? {}
const { sessionId = '', token } = /** @type {{ sessionId?: string, token?: string }} */ (data)
if (action === 'allow' || action === 'deny') {
// SEC-C1: include capability token so server can authenticate this decision.
// Origin header is added automatically by fetch() with credentials:'same-origin'.
return {
kind: 'decision',
body: JSON.stringify({ sessionId, decision: action, token }),
}
}
// Default (body click, empty string, undefined, null) → focus/open terminal session
return {
kind: 'focus',
url: `/?session=${encodeURIComponent(sessionId)}`,
}
}