556 lines
39 KiB
Markdown
556 lines
39 KiB
Markdown
# stackchain-dashboard
|
|
|
|
Live AI-driven adaptive UI for the Stackchain AI Lab Gitea experience.
|
|
|
|
## Local quickstart
|
|
|
|
Python 3.11 or newer is recommended. Create an isolated environment and install
|
|
the pinned dependencies:
|
|
|
|
```bash
|
|
python3 -m venv .venv
|
|
source .venv/bin/activate
|
|
python3 -m pip install -r requirements.txt
|
|
```
|
|
|
|
Point the dashboard at the Gitea server root (without `/api/v1`) and provide a
|
|
token that can read dashboard data, update the authenticated user's notification
|
|
threads, create and self-assign issues, discover, claim, and release issue assignments,
|
|
list repository labels and open milestones, set or clear due dates on assigned issues, create issue comments, close assigned issues,
|
|
inspect/comment on assigned pull
|
|
requests, merge assigned pull requests, and submit pull-request reviews.
|
|
Assigned-issue and assigned-pull-request comments can include one PNG, JPEG, or WebP screenshot; each mobile composer automatically optimizes oversized screenshots on-device to fit the 2 MB upload boundary while leaving already-valid files unchanged.
|
|
For online delivery, the screenshot uploads before the comment is posted—to the exact assigned issue or pull request—and produces one Markdown comment; validation or upload
|
|
failures keep both the typed comment and removable preview available for retry. Offline screenshot comments
|
|
admit their text and image bytes to IndexedDB before confirmation, keep only bounded metadata in
|
|
localStorage, and use checkpointed upload/comment identities so reconnect retries cannot duplicate
|
|
either stage. The mobile **New issue**
|
|
sheet accepts the same image formats and stores the screenshot with its account-bound
|
|
outbox capture. Durable admission writes the complete screenshot capture as a binary Blob to IndexedDB
|
|
before confirmation; localStorage keeps only bounded attachment metadata, avoiding base64
|
|
quota pressure and synchronous multi-megabyte writes. Online and background delivery send the
|
|
original bytes as multipart form data, avoiding the roughly 33% base64 wire expansion. Existing
|
|
queued base64 screenshot payloads remain readable and are converted only at delivery time.
|
|
Delivery creates the issue exactly once, then uploads and comments with
|
|
the image; after a partial failure, retry resumes with the confirmed issue instead of
|
|
creating a duplicate.
|
|
Pull-request replies and mobile My Work issue and PR comments use Gitea's
|
|
issue-comment API. In issue, pull-request, and unread-update conversations, typing
|
|
at least two characters after `@` offers repository-scoped teammate suggestions;
|
|
touch or keyboard selection inserts the login without leaving the draft. Mention
|
|
lookup failure never blocks literal text or comment delivery. Mobile issue capture requires issue
|
|
creation and assignment permission. A fresh capture never silently targets the first repository:
|
|
the operator must explicitly choose one, either from the paginated browser or through the bounded
|
|
authenticated repository search. Search results include only visible repository identities, stale
|
|
responses cannot replace a newer query, and search failure leaves the draft and browse fallback
|
|
intact. Saved drafts restore their exact repository even when it is outside the first page. Once a
|
|
repository and meaningful title are selected,
|
|
the New issue sheet checks for similar open issues in that repository. Candidate links keep
|
|
the draft intact; the first create attempt pauses until the operator reviews them or explicitly
|
|
chooses **Create anyway**. This check is advisory and never blocks offline capture or capture
|
|
when search is unavailable. The sheet can also optionally select an open
|
|
repository milestone and due date; the dashboard validates both and sends them with
|
|
self-assignment in the single create request, so planned work appears in its release
|
|
lane immediately. On a cold offline launch, **Save for filing** stores up to 20
|
|
account-bound title/description captures without selecting a repository or entering the
|
|
mutation outbox. Drafts marks them **Needs filing**; after a fresh reconnect confirms
|
|
the same Gitea login, **Choose repository** restores the capture to the normal planning
|
|
and durable delivery flow. A different or unconfirmed account can only copy or discard
|
|
the private content. Issue capture and authored mobile actions (issue
|
|
comments, pull-request comments, notification replies, and reviews) persist per-draft
|
|
idempotency keys, so retrying after a timeout, reload, process restart, or handoff to
|
|
another worker replays a confirmed result instead of posting duplicate content. The ordered,
|
|
five-item Today plan syncs across the operator's devices. **Plan Today** also stores available minutes
|
|
and a per-item estimate with the account-scoped plan, continuously showing planned/free or over-capacity
|
|
time. An over-capacity plan requires a second explicit save, legacy plans migrate with unestimated work,
|
|
and an active Today session shows the current estimate plus estimated remaining runway. Adding an issue through **Plan Today**
|
|
first previews its Gitea dependencies: unresolved blockers are listed with links and require the
|
|
explicit **Add blocked item anyway** override, while an unavailable dependency lookup is reported
|
|
as unknown rather than unblocked. Starting a Today work session also
|
|
stores an account-bound checkpoint on the current device. After a reload or installed-app
|
|
restart, **Resume Today** reopens the saved item (or the next surviving item if work changed);
|
|
**Comment & next** on that current issue or pull request posts the handoff online or admits it
|
|
to durable account-bound delivery, then removes the item only from Today and opens the next
|
|
one without closing or merging it. **Reply & next** provides the same one-action continuation
|
|
for the current unread-update conversation. It deliberately leaves the notification unread;
|
|
an open, unassigned issue update also offers **Take ownership & start**, which checks Today capacity
|
|
before assignment, preserves the unread update, adds and syncs the owned issue to Today, checkpoints
|
|
the session, and opens the issue. The adjacent **Take ownership** action remains available for
|
|
claim-only triage, and a local start failure opens the now-owned issue with truthful recovery guidance.
|
|
**Mark read & next** remains the explicit acknowledgement path. Delivery or local-admission
|
|
failure preserves both the reply draft and checkpoint. Finishing or choosing **End session** clears only the checkpoint and leaves the Today plan
|
|
unchanged. Another or unconfirmed account cannot see or resume it. Server revisions prevent delayed
|
|
responses from replacing a newer plan; same-account browser tabs exchange fresh snapshots,
|
|
and reconnecting or returning to the dashboard refreshes server truth after replaying queued
|
|
offline operations. Planning edits can remain offline for up to 30 days. After that, the
|
|
expired edit is discarded visibly and the account plan is kept rather than replaying stale
|
|
intent. The server retains no more than 4,096 operation receipts per account and removes
|
|
receipts older than the same 30-day window; client base revisions keep a pruned replay from
|
|
changing a newer Today or Later plan. After a healthy, fully paginated
|
|
My Work refresh proves that an item is complete or otherwise no longer eligible, Stackchain
|
|
queues an idempotent retirement before removing it locally; partial and degraded refreshes
|
|
leave the plan unchanged, and offline retirements replay after reconnect. When
|
|
**Keep My Work available offline** is enabled, every issue or pull request in the bounded
|
|
Today queue is warmed automatically after a healthy authenticated refresh and as soon as
|
|
it is added. The readiness indicator reports saved, pending, and retryable items; unchanged
|
|
`updated_at` revisions make no detail request, transient failures retain the prior copy,
|
|
and pull-request diffs remain online-only. Saved unread-update conversations remain
|
|
triageable offline: **Queue read & next** writes an account-bound, notification-ID-
|
|
deduplicated acknowledgement to the durable background delivery system, removes the
|
|
update from the local queue immediately, and opens the next saved conversation. A cold
|
|
offline reload suppresses acknowledgements still waiting to sync; reconnect uses the
|
|
authenticated notification-read endpoint and keeps transient failures queued. Every foreground
|
|
same-origin dashboard API request also has a 15-second browser deadline, including fresh-
|
|
authorization and step-up retries. Read timeouts settle with retry guidance even if the browser's
|
|
fetch ignores abort; mutation timeouts instead tell the operator to refresh and verify the server
|
|
outcome before retrying. Caller cancellation still takes precedence, and cross-origin fetches are
|
|
not changed by this session boundary. Installed-app
|
|
navigations are also deadline-bounded: after four seconds without a network response,
|
|
Stackchain aborts the request and opens the cached dashboard shell. If the shell has not
|
|
been installed yet, it returns explicit HTTP 504 reconnect guidance instead of hanging.
|
|
Live dashboard refreshes use the server's per-section freshness windows: healthy sections
|
|
refresh at their earliest deadline while a fully degraded snapshot waits for its reported
|
|
cooldown. HTTP `Retry-After` delays are honored up to five minutes, and transport failures
|
|
back off from eight seconds to a one-minute cap. Reconnect, foreground return, and explicit
|
|
refresh still run immediately; a successful response resets transport backoff.
|
|
Closed-app
|
|
delivery requests are deadline-bounded: a stalled identity, CSRF, or mutation request is
|
|
aborted after 15 seconds, its durable claim returns to the queue, and the next sync retries
|
|
with the unchanged idempotency key. Each finite, lane-fair drain admits at most 70 records
|
|
and runs up to three deliveries concurrently, but claims a record only when a delivery slot
|
|
is ready. Claims have unique fencing tokens and are renewed before every network stage;
|
|
completion, release, failure, and delivery checkpoints are token-fenced so an expired worker
|
|
cannot alter a newer crash-recovery claim. Device purge cancels an active drain before closing
|
|
private outbox storage. Results are coordinated through a bounded SQLite ledger. Set `STACKCHAIN_STATE_DIR` to a
|
|
persistent, writable service directory (or set `STACKCHAIN_IDEMPOTENCY_DB` to an explicit
|
|
SQLite path); the local default is `.stackchain-state/idempotency.sqlite3`. Ledger reads and
|
|
writes run outside the request event loop, and lock admission is bounded to 100 ms by
|
|
default. Tune it with `STACKCHAIN_IDEMPOTENCY_LOCK_TIMEOUT_SECONDS`; keep the value below
|
|
route deadlines. During a controlled shutdown, the server gives in-flight authored mutations
|
|
five seconds to finish and persist their ledger result before it closes the Gitea transport.
|
|
Set `STACKCHAIN_AUTHORED_ACTION_SHUTDOWN_GRACE_SECONDS` to match the service manager's
|
|
shutdown budget; when that deadline expires, remaining operations are cancelled and their
|
|
pending reservations continue to fail closed rather than being retried automatically.
|
|
Reservation contention returns retryable HTTP 503 with `Retry-After: 1`.
|
|
If contention occurs after the upstream mutation, the dashboard fails closed with
|
|
`Retry-After: 5` and asks the caller to verify the result before retrying. Direct API callers
|
|
should preserve the `Idempotency-Key` header with the unchanged route and payload until a
|
|
`201` response is confirmed. Closing an assigned issue, native Comment,
|
|
Approve, and Request changes reviews, and assigned-PR merge require repository
|
|
write permission. Native Comment, Approve, and Request changes reviews support
|
|
head-scoped draft comments anchored to changed lines; the dashboard validates each
|
|
comment path and submits the summary, decision, and inline comments in one review
|
|
request. On phone-width review sheets, changed lines wrap inside a stable old/new
|
|
line-number gutter by default so long source lines remain readable and commentable
|
|
without horizontal panning. **Lines wrapped** toggles back to whitespace-preserving
|
|
horizontal inspection, and an explicit choice persists on the device across files,
|
|
review-sheet reopen, and reload. Desktop review diffs remain horizontally scrollable
|
|
until wrapping is explicitly enabled. The dashboard rechecks the current pull-request head, CI success, draft
|
|
state, and mergeability immediately before every merge.
|
|
Serve the dashboard only to trusted users on its own origin; cross-origin API
|
|
access is intentionally disabled. Authentication defaults to fail-closed
|
|
`operator` mode. It requires two independent secrets of at least 24 characters;
|
|
missing, short, reused, or invalid configuration leaves `/healthz` available but
|
|
returns HTTP 503 before any Gitea access. The access token is entered at `/login`;
|
|
the browser receives only a short-lived signed session and CSRF proof. Generate
|
|
both values in your secret manager (for example, `openssl rand -hex 32`) and inject
|
|
them at runtime—never commit them:
|
|
|
|
```bash
|
|
export GITEA_URL='https://forge.example.com'
|
|
export GITEA_TOKEN='<read-notification-and-issue-write-token>'
|
|
export STACKCHAIN_DASHBOARD_AUTH_MODE='operator'
|
|
export STACKCHAIN_DASHBOARD_ACCESS_TOKEN='<operator-sign-in-secret>'
|
|
export STACKCHAIN_DASHBOARD_SESSION_SECRET='<independent-cookie-signing-secret>'
|
|
# Optional; defaults to STACKCHAIN_STATE_DIR/sessions.sqlite3.
|
|
export STACKCHAIN_SESSION_DB='/var/lib/stackchain-dashboard/sessions.sqlite3'
|
|
# Optional; defaults to STACKCHAIN_STATE_DIR/security-events.sqlite3.
|
|
export STACKCHAIN_SECURITY_EVENT_DB='/var/lib/stackchain-dashboard/security-events.sqlite3'
|
|
# Recommended behind a proxy; WebAuthn assertions must match these public values.
|
|
export STACKCHAIN_PASSKEY_RP_ID='forge.example.com'
|
|
export STACKCHAIN_PASSKEY_ORIGIN='https://forge.example.com'
|
|
# Optional; defaults to eight hours.
|
|
export STACKCHAIN_DASHBOARD_SESSION_TTL_SECONDS=28800
|
|
# Optional; explicit pointer, keyboard, or touch activity renews this idle window.
|
|
# Background polling and queued delivery do not. Defaults to 15 minutes.
|
|
export STACKCHAIN_DASHBOARD_IDLE_TIMEOUT_SECONDS=900
|
|
# Optional sign-in throttle: five failures per five minutes, up to 10,000 sources.
|
|
export STACKCHAIN_LOGIN_MAX_FAILURES=5
|
|
export STACKCHAIN_LOGIN_WINDOW_SECONDS=300
|
|
export STACKCHAIN_LOGIN_MAX_ENTRIES=10000
|
|
# Public passkey ceremonies share the same window and durable source ledger.
|
|
export STACKCHAIN_PASSKEY_OPTIONS_MAX_ATTEMPTS=10
|
|
# Bound live one-time challenges even if anonymous clients rotate addresses.
|
|
export STACKCHAIN_PASSKEY_MAX_CHALLENGES_PER_SOURCE=10
|
|
export STACKCHAIN_PASSKEY_MAX_CHALLENGES=10000
|
|
# Optional; defaults to STACKCHAIN_STATE_DIR/login-attempts.sqlite3.
|
|
export STACKCHAIN_LOGIN_ATTEMPT_DB='/var/lib/stackchain-dashboard/login-attempts.sqlite3'
|
|
# Trust forwarding headers only from these immediate reverse-proxy networks.
|
|
export STACKCHAIN_TRUSTED_PROXY_CIDRS='127.0.0.0/8'
|
|
# Optional Web Push. Generate a VAPID key pair outside the repo and inject it.
|
|
# The feature stays disabled unless all three values are present. Privacy-safe update
|
|
# alerts offer Mark read and Tomorrow; Tomorrow syncs the unread item to Later at
|
|
# 09:00 in the device's local timezone without opening the dashboard.
|
|
export STACKCHAIN_VAPID_PUBLIC_KEY='<url-safe-public-key>'
|
|
export STACKCHAIN_VAPID_PRIVATE_KEY='<private-key-from-secret-manager>'
|
|
export STACKCHAIN_VAPID_SUBJECT='mailto:ops@example.com'
|
|
# Optional; defaults to a 30-second poll, 10-second endpoint deadline,
|
|
# 8 concurrently dispatched devices, 60-second renewable cross-worker lease,
|
|
# and STACKCHAIN_STATE_DIR/push-subscriptions.sqlite3. Threads stay ordered
|
|
# within each device; one slow device does not delay healthy devices behind it.
|
|
export STACKCHAIN_PUSH_POLL_SECONDS=30
|
|
export STACKCHAIN_PUSH_SEND_TIMEOUT_SECONDS=10
|
|
export STACKCHAIN_PUSH_MAX_CONCURRENCY=8
|
|
export STACKCHAIN_PUSH_LEASE_SECONDS=60
|
|
export STACKCHAIN_PUSH_DB='/var/lib/stackchain-dashboard/push-subscriptions.sqlite3'
|
|
uvicorn src.main:app --host 127.0.0.1 --port 8000
|
|
```
|
|
|
|
Token and passkey sign-in failures are scoped to a hashed canonical client address and
|
|
persisted across workers and restarts. Public passkey option issuance has a separate
|
|
fixed-window admission budget in the same ledger, and live challenges are bounded per
|
|
source and globally in the session registry. Once either sign-in budget is exhausted,
|
|
the server returns `429` with `Retry-After`; a successful sign-in clears that source's
|
|
failure state. Expired source records and challenges are pruned, and both ledgers are
|
|
size-bounded. Keep their SQLite files on shared writable storage. `X-Forwarded-For` is
|
|
ignored unless the immediate peer is inside `STACKCHAIN_TRUSTED_PROXY_CIDRS`; list only
|
|
networks you operate. Without that setting, a reverse proxy is safely treated as one
|
|
shared source.
|
|
|
|
Inbound API mutations are admitted through a body-size boundary before FastAPI
|
|
parses JSON: sign-in is capped at 16 KiB and other `POST`, `PUT`, and `PATCH`
|
|
requests under `/api/v1/` are capped at 64 KiB. Both declared and streamed bodies
|
|
are counted. Oversized requests receive a compact, non-cacheable HTTP 413 response;
|
|
validation errors expose field locations and messages but never echo submitted
|
|
values or validation context. Preserve these limits at the reverse proxy or enforce
|
|
equal or tighter upstream limits.
|
|
|
|
Each signed cookie includes an opaque session identifier whose hash and expiry are
|
|
kept in the SQLite session registry. Keep that registry on persistent, writable
|
|
storage shared by all dashboard workers. Operators name a device at sign-in and can
|
|
open **Active devices** to review creation/expiry times, identify the current device,
|
|
and revoke one remote session without interrupting other trusted devices. The API
|
|
exposes only independent management IDs and bounded labels—never cookie values,
|
|
session hashes, CSRF proofs, or source addresses. The registry also stores each
|
|
session's last explicit activity. Existing two-column registries are migrated in
|
|
place, their live sessions remain valid, and their idle clock starts at migration.
|
|
|
|
The same sheet includes **Security activity**, a reverse-chronological journal of
|
|
successful token/passkey sign-ins, passkey enrollments, sign-outs, remote device
|
|
revocations, issue closures, and pull-request merges. The separate SQLite journal
|
|
retains at most 10,000 events for 90 days and stores only bounded device labels and
|
|
action targets. It never stores access tokens, cookies, session/CSRF values,
|
|
credential IDs, public keys, challenges, attestation data, raw network addresses,
|
|
request bodies, or comment content. Keep its database on the same class of persistent,
|
|
writable storage as the session registry. Before passkey credential creation, session
|
|
revocation, or issue closure, the journal durably reserves a pending event; if that
|
|
reservation fails, the consequential action does not begin. A successful action
|
|
remains truthfully reported even if its event cannot immediately be finalized, and
|
|
the activity sheet marks that durable record as **Outcome confirmation pending**.
|
|
|
|
After token bootstrap, **Active devices → Add a passkey for this device** enrolls a
|
|
WebAuthn credential with required user verification. That device can then sign in
|
|
and authorize high-impact actions with its biometric/PIN gesture. The access token
|
|
remains the recovery fallback for browsers without WebAuthn or devices without an
|
|
enrolled credential. Registration and authentication challenges are exact-purpose,
|
|
single-use, and short-lived. **Enrolled passkeys** lists every durable credential,
|
|
including those whose original session has expired or signed out, using only its
|
|
bounded label, enrollment time, and active/current status. Removing one requires a
|
|
single-use fresh authorization bound to that exact management ID. A remote active
|
|
session linked to the credential is revoked atomically; unrelated credentials and
|
|
sessions remain valid. Removing the current device's passkey keeps its current
|
|
session active, so the sheet warns that the recovery token will be required after
|
|
sign-out. Remotely revoking an enrolled device also deletes its passkey; signing out
|
|
normally keeps the passkey available for the next sign-in.
|
|
|
|
High-impact actions—merging a pull request, closing an assigned issue, revoking a
|
|
remote device, or signing out every device—require a passkey assertion or the
|
|
operator access token again.
|
|
The server issues a random 90-second grant bound to the active session, exact action,
|
|
and exact target. Only its digest is stored, and the grant is consumed atomically on
|
|
first use. Expired, replayed, cross-session, and target-substituted grants fail before
|
|
Gitea or session state is changed. The browser preserves the pending request and
|
|
retries it once after the built-in mobile/keyboard-accessible authorization prompt.
|
|
|
|
After 15 minutes without pointer, keyboard, or touch activity (configurable through
|
|
`STACKCHAIN_DASHBOARD_IDLE_TIMEOUT_SECONDS`), the server rejects the session even if
|
|
polling or background delivery continued. The dashboard locks and background outbox
|
|
delivery pauses, but drafts, Today/Later state, queued mutations, and caches remain on
|
|
the device; signing in resumes the existing account-bound work. If an active session
|
|
expires, the first authenticated API rejection replaces the
|
|
dashboard with sign-in and explains that private drafts remain on the device; signing
|
|
in again resumes account-bound queued delivery. Expiry recovery does not clear offline
|
|
state. A selectively or globally revoked device instead receives a bounded revocation
|
|
reason: on its next server contact, Stackchain clears its owned local/session storage,
|
|
private outbox database, and dashboard caches before enabling sign-in. Revocation
|
|
blocks server access immediately, but no web application can erase a device that
|
|
remains offline forever. **Sign out & clear this device** revokes only the current
|
|
session before clearing browser state, so a copied cookie cannot be replayed
|
|
afterward; other signed-in devices remain active. **Sign out all devices** is a
|
|
separately confirmed lost-device safety action that atomically revokes every existing
|
|
operator session before clearing the current browser and returning to sign-in.
|
|
Registry read or write failures return a sanitized HTTP 503 before Gitea is contacted.
|
|
|
|
Terminate TLS at the trusted reverse proxy: session cookies are deliberately
|
|
`Secure`, `HttpOnly`, `SameSite=Strict`, and scoped to the deployment subpath.
|
|
Every response also defines the browser execution boundary with a Content Security
|
|
Policy that allows scripts only from the dashboard origin, denies framing and
|
|
plugins, and blocks unused browser capabilities. Keep these response headers when
|
|
proxying; do not add inline scripts or broaden `script-src`. The dashboard bootstrap
|
|
is assembled in source order into one content-addressed JavaScript response. Dashboard
|
|
HTML and the offline worker reference that exact fingerprint, while the runtime receives
|
|
immutable caching and HTML/worker responses remain revalidated. The stylesheet and
|
|
fingerprinted runtime are same-origin assets included atomically in the offline PWA shell.
|
|
Use **Sign out & clear this device** on shared devices; it clears Stackchain's
|
|
offline snapshots, drafts, outboxes, background IndexedDB, and PWA caches without
|
|
removing unrelated forge preferences. Rotate either dashboard secret by replacing
|
|
the injected value and restarting the service; changing the signing secret expires
|
|
all sessions immediately. The signing key is never derived from the access token.
|
|
|
|
For direct, unproxied local development only, set
|
|
`STACKCHAIN_DASHBOARD_AUTH_MODE=insecure-local` and open
|
|
`http://127.0.0.1:8000/`. This mode checks the network peer and rejects non-loopback
|
|
clients; do not use it behind a reverse proxy, whose loopback connection would hide
|
|
the browser's peer address. To verify the backend and its Gitea connection in
|
|
operator mode, sign in and request `http://127.0.0.1:8000/api/v1/context`; a
|
|
successful response is JSON containing
|
|
`user`, `repos`, `issues`, and `pull_requests`. Press `Ctrl/Cmd+K` in the dashboard
|
|
to search commands plus issues and pull requests across every repository visible to
|
|
the configured Gitea token. Remote search starts after two characters, is debounced,
|
|
and keeps local commands usable if Gitea search is unavailable. Selecting a remote
|
|
result opens a mobile-safe preview without discarding the search query. Open
|
|
unassigned issues can be claimed in place and handed into My Work after Gitea
|
|
confirms the assignment. A closed issue can be reopened, self-assigned, added to
|
|
Today, and resumed through the same capacity-guarded flow; pull requests remain
|
|
read-only with a safe canonical Gitea link. The bounded APIs are available at
|
|
`GET /api/v1/search?q=<query>&limit=<1-25>`,
|
|
`GET /api/v1/repos/<owner>/<repo>/issues/<number>/preview?kind=issue|pull`, and
|
|
`PATCH /api/v1/repos/<owner>/<repo>/issues/<number>/reopen`. Never commit the token
|
|
or place it in a tracked configuration file.
|
|
|
|
For service monitoring, GET `/healthz` is a liveness check that confirms the
|
|
API process is running and does not contact Gitea. GET `/readyz` is the
|
|
readiness check: it validates the configured Gitea credentials and returns
|
|
HTTP 503 with an error when Gitea is unavailable or authentication fails.
|
|
|
|
The application-lifetime Gitea transport bounds aggregate upstream traffic and
|
|
coalesces identical concurrent GETs without caching completed responses. By
|
|
default, at most eight requests run at once, with one slot reserved for authored
|
|
mutations so polling and detail-read bursts cannot starve comments, reviews, or
|
|
other writes. Requests that cannot enter within 250 ms fail as retryable HTTP
|
|
503 responses. Tune these limits with `GITEA_MAX_CONCURRENCY` (minimum `2`) and
|
|
`GITEA_ADMISSION_TIMEOUT_SECONDS` (minimum `0.001`); keep the admission timeout
|
|
below the route deadlines. Streaming diff reads share the same read capacity,
|
|
while POST, PATCH, PUT, and DELETE requests are never coalesced.
|
|
|
|
Live snapshots expose independent `context`, `events`, and `notifications`
|
|
revision tokens. Snapshot content, freshness/backoff metadata, and revisions are published
|
|
atomically through a private SQLite store shared by all application workers. An expiring
|
|
refresh lease ensures only one worker loads currently due sections; other workers serve the
|
|
same stale snapshot while that refresh runs, and can recover an abandoned lease after expiry.
|
|
Set `STACKCHAIN_LIVE_SNAPSHOT_DB` to override the default
|
|
`STACKCHAIN_STATE_DIR/live-snapshot.sqlite3`; keep the containing directory on private,
|
|
worker-shared writable storage. The database and directory are restricted to the service
|
|
account and never contain the Gitea token. Find Work uses the same worker-shared pattern:
|
|
`STACKCHAIN_AVAILABLE_ISSUE_SNAPSHOT_DB` overrides
|
|
`STACKCHAIN_STATE_DIR/available-issue-snapshot.sqlite3`. One expiring lease bounds each
|
|
catalog scan across the deployment, shared retry metadata prevents worker-by-worker retry
|
|
bursts, and confirmed claims are removed from every worker's retained catalog. Releasing
|
|
an assignment invalidates the shared catalog so the newly available issue can be discovered
|
|
by the next authoritative scan. Each bounded opaque revision token includes the
|
|
store generation, so a token from a different deployment or before replacement of the store
|
|
cannot suppress different content. The browser sends its known tokens on later polls, so
|
|
`/api/v1/live` can omit unchanged section bodies while still returning current freshness and
|
|
retry metadata. The client retains omitted data and only rebuilds or persists the sections
|
|
that changed. Malformed or oversized tokens are rejected before any upstream work.
|
|
Confirmed notification acknowledgements update this worker's live view immediately, then
|
|
maintain the shared snapshot outside the async request loop. Bulk acknowledgements remove
|
|
all successful notification IDs in one SQLite transaction and advance the notification
|
|
revision once. If shared-store lock admission fails after Gitea confirms the mutation, the
|
|
API keeps the confirmed result and process-local filtering rather than falsely reporting the
|
|
upstream write as failed; a later shared refresh reconciles the cache.
|
|
|
|
## Offline mobile shell
|
|
|
|
At phone widths, a persistent bottom task dock keeps **Work**, **Find**, **New**,
|
|
**Search**, and **Drafts** thumb-reachable. It reuses the existing sheets and My Work
|
|
filter, preserves the selected release lane, shows the current draft count, respects
|
|
the device safe area, and moves out of the way while a full-screen task is open.
|
|
Desktop layout is unchanged.
|
|
|
|
My Work also has an account-synced **Later** queue. **Later today** defers an item for four
|
|
hours, while **Tomorrow** returns it at 09:00 in the device's local timezone. **Choose date & time**
|
|
accepts a valid future local date and time and returns the item at that exact instant; the picker
|
|
shows the device timezone and rejects empty, normalized, invalid, or past values before saving.
|
|
Deferred items leave normal and Attention queues without marking notifications read
|
|
or changing any Gitea issue or pull request. They automatically return to their
|
|
existing priority position at the wake time. **Start now** atomically admits a deferred
|
|
item to Today, removes its Later record, and opens that exact item in a resumable Today
|
|
session; full or unavailable Today storage leaves the deferral intact for retry. Items
|
|
already in Today are opened without duplication. **Bring back now** still restores an
|
|
item early without starting it. Later wake times are scoped to the confirmed Gitea login and synchronize
|
|
across signed-in tabs and devices. Offline changes apply immediately, survive reload,
|
|
and replay after reconnect. Each queued edit carries the account revision it was based
|
|
on; if another device has since changed the same item, Stackchain keeps the newer
|
|
account plan and reports the conflict instead of silently overwriting it. Unrelated
|
|
items continue syncing in the same batch. `STACKCHAIN_LATER_DB` can override the
|
|
default durable store at `.stackchain-state/later.sqlite3`.
|
|
|
|
After one successful online load, the installed dashboard precaches a versioned,
|
|
subpath-scoped application shell. During a network outage or a dashboard HTTP
|
|
`500`, `502`, `503`, or `504` response, navigation falls back to that shell when
|
|
it is available so local issue and review drafts remain reachable. Authentication
|
|
and other client-error responses are never hidden. A visible, accessible offline
|
|
or server-outage notice distinguishes cached mode from live Gitea data, and the
|
|
dashboard keeps polling until it can restore its live snapshot automatically.
|
|
|
|
Users can explicitly enable **Keep My Work available offline**. Each healthy live
|
|
refresh then stores a seven-day, versioned snapshot containing only the signed-in
|
|
user identity and queue-card metadata for issues, pull requests, unread updates,
|
|
and pagination totals. For each opened Today issue or pull request, Stackchain additionally retains an allowlisted detail record with its body and
|
|
newest 20 comments. Requested-review records also retain the head SHA, CI state,
|
|
newest 20 prior reviews, and at most 50 sanitized file previews with 400 diff lines
|
|
per file. A previously opened unread update is retained by notification
|
|
identity with allowlisted subject context and its newest 20 conversation messages.
|
|
The snapshot and each account-bound detail are committed as separate IndexedDB records,
|
|
so large review previews do not consume the small synchronous localStorage quota or require
|
|
rewriting the whole cache. Only the opt-in preference remains in localStorage. Existing v1
|
|
localStorage payloads migrate after a successful IndexedDB commit. The detail cache remains
|
|
limited to ten records across all detail kinds; credentials, repository catalogs, events, raw
|
|
patches, and complete API responses are excluded.
|
|
A cold offline launch labels the saved time. Cached Today details open in the existing
|
|
phone sheet, where comments can enter the account-bound durable outbox. For issue and
|
|
non-review pull details, planning, assignment, review, merge, and close controls remain disabled
|
|
until reconnection. Cached requested reviews open in the existing review sheet so file progress, notes,
|
|
summary, decision, and inline-comment drafts remain usable under the saved head SHA.
|
|
**Queue review for reconnect** durably stores that complete SHA-bound review with one
|
|
idempotency key. Reconnect or Background Sync submits it exactly once; transient failures
|
|
retain the queue entry, while stale-head and invalid-inline-comment responses move it to
|
|
Drafts as **Needs attention** without deleting feedback. Confirmed delivery removes the
|
|
queued operation and its matching SHA-scoped draft and progress.
|
|
Cached unread updates use the same phone conversation sheet and replies enter the
|
|
account-bound durable outbox, while mark read, ownership, deferral, and older-message loading remain disabled until reconnection.
|
|
Cards without a saved detail explain that reconnection is required. **Clear offline
|
|
work data**, opt-out, sign-out, remote revocation, and absolute session expiry delete the
|
|
IndexedDB records; seven-day expiry removes stale records automatically.
|
|
|
|
API responses and mutations are never cached by the service worker. New issue captures,
|
|
issue comments, pull-request comments, and unread-update replies use bounded local
|
|
outboxes when connectivity or a retryable server failure prevents delivery. Issue
|
|
captures and authored messages are also mirrored into account-bound IndexedDB lanes
|
|
and registered with Background Sync, so a supporting installed browser can deliver
|
|
new issues, issue comments, pull-request comments, unread-update replies, and completed
|
|
pull-request reviews after
|
|
every dashboard client has closed. The worker verifies the current Gitea login, shares
|
|
an atomic delivery claim with the foreground path, and preserves the original
|
|
idempotency key. Installed browsers can explicitly enable **Notify me when queued
|
|
work finishes**; permission is requested only from that user gesture and the choice
|
|
is stored for the confirmed account in the private background outbox database.
|
|
Successful deliveries produce privacy-safe receipts that open the created issue or
|
|
source conversation, while permanent validation failures open Drafts for recovery.
|
|
Authentication expiry during a worker mutation is recoverable: the worker releases the
|
|
claim without creating an Attention receipt, stops that drain, and keeps the original
|
|
idempotency key. Loading the authenticated dashboard after signing in asks the worker
|
|
to resume queued delivery automatically.
|
|
Notification text never includes issue titles, comment bodies, or validation details.
|
|
The preference is off by default, unsupported or denied browsers retain foreground
|
|
reconciliation, and **Sign out & clear this device** removes the account-bound choice.
|
|
Browsers without
|
|
IndexedDB or Background Sync keep the foreground reconnect behavior. Drafts shows
|
|
queued and needs-attention messages with explicit send/discard controls; reopening the
|
|
dashboard reconciles worker completions and permanent failures into the visible outbox.
|
|
Foreground delivery flushes messages sequentially with their original idempotency keys.
|
|
Tabs coordinate each queue through the browser lock manager (with an expiring local
|
|
lease fallback), so an installed PWA and a browser tab cannot submit the same operation
|
|
concurrently. Queue changes are broadcast to other tabs and refresh Drafts without
|
|
polling; an abandoned lease can be reclaimed after expiry. State-sensitive actions such
|
|
as reviews, merges, closures, labels, milestones, and assignments are never queued.
|
|
Service-worker upgrades are atomic and remove only older
|
|
`stackchain-dashboard-*` caches, preserving unrelated caches on the same origin.
|
|
|
|
Run the test suite with:
|
|
|
|
```bash
|
|
python3 -m pytest tests/ -q
|
|
```
|
|
|
|
## Autonomous release worker
|
|
|
|
The deterministic issue-to-release engine lives at `src/release_engine.py`.
|
|
It discovers or targets one Gitea issue, verifies its claim, creates an issue
|
|
branch, invokes a configured coding agent, runs tests, pushes, and opens a
|
|
linked pull request. Merge remains gated by CI/review; the existing release
|
|
workflow drafts the release candidate after merge.
|
|
|
|
Plan against live Gitea without mutating anything:
|
|
|
|
```bash
|
|
python3 -m src.release_engine \
|
|
--repo stackchain/stackchain-dashboard \
|
|
--agent timmy \
|
|
--issue 14 \
|
|
--dry-run
|
|
```
|
|
|
|
Execute one ticket after implementing and committing its change directly on
|
|
the deterministic branch printed by `--dry-run`:
|
|
|
|
```bash
|
|
export GITEA_TOKEN='<scoped-token>'
|
|
export RELEASE_AGENT_COMMAND=true
|
|
python3 -m src.release_engine \
|
|
--repo stackchain/stackchain-dashboard \
|
|
--agent timmy \
|
|
--issue 19 \
|
|
--agent-timeout 1800 \
|
|
--test-timeout 900 \
|
|
--termination-grace 5 \
|
|
--agent-env AGENT_CONFIG_HOME \
|
|
--test-command 'python3 -m pytest tests/ -q'
|
|
```
|
|
|
|
`GITEA_TOKEN` needs issue and repository write scopes. The engine verifies the
|
|
claim, reruns the test command, pushes the branch, and opens a PR containing
|
|
`Closes #19` plus test evidence. Replace `19` with the selected issue number;
|
|
do not run without `--issue` when processing a preselected ticket.
|
|
|
|
Agent and test commands use POSIX argument quoting and run as argument vectors,
|
|
not through a shell. Issue context is available to the agent only through
|
|
`RELEASE_ISSUE_NUMBER`, `RELEASE_ISSUE_TITLE`, `RELEASE_ISSUE_BODY`,
|
|
`RELEASE_REPO`, and `RELEASE_BRANCH`; do not put issue placeholders in command
|
|
text. Shell substitutions, redirects, and pipelines are intentionally not
|
|
interpreted. Put any trusted shell workflow in a reviewed wrapper script and
|
|
configure that script as `RELEASE_AGENT_COMMAND` instead.
|
|
|
|
Every child process receives an explicit least-privilege environment rather
|
|
than inheriting the worker's credentials. Coding agents receive a minimal
|
|
runtime environment plus the five `RELEASE_*` context values above. Tests and
|
|
Git receive only the minimal runtime environment, so `GITEA_TOKEN` and
|
|
unrelated parent secrets remain in the release-engine process. If an agent
|
|
runtime needs another non-secret setting, allow it explicitly with a repeated
|
|
`--agent-env NAME` option or the comma-separated `RELEASE_AGENT_ENV` variable.
|
|
`GITEA_TOKEN`, reserved `RELEASE_*` names, and malformed names are rejected
|
|
before the issue is claimed. Secret-bearing values must not be allowlisted.
|
|
|
|
Agent and test deadlines are independent. On expiry, the engine terminates the
|
|
command's entire process group, escalates from `SIGTERM` to `SIGKILL` after the
|
|
configured grace period, and records `agent_timed_out` or `tests_timed_out`
|
|
without advancing the release. The equivalent environment variables are
|
|
`RELEASE_AGENT_TIMEOUT`, `RELEASE_TEST_TIMEOUT`, and
|
|
`RELEASE_TERMINATION_GRACE`; every value must be positive.
|
|
|
|
Durable state defaults to `.release-engine/state.json`. Successful checkpoints
|
|
record the repository, issue branch, and exact commit SHA. A restart resumes at
|
|
tests, push, or PR creation only after validating that identity and a clean
|
|
worktree; a pushed checkpoint also verifies the remote branch SHA. Invocations
|
|
are serialized by an OS-backed, non-blocking lease at
|
|
`.release-engine/run.lock` under the canonical repository path. If an hourly
|
|
run overlaps a manual or slow prior run, the contender exits with `release run
|
|
already active` and owner diagnostics before Gitea discovery or any Git/agent
|
|
work. The kernel releases ownership when the process exits, so never delete a
|
|
lock file to recover from a crash; stale file metadata cannot retain the lease.
|
|
Checkpoint replacement uses writer-unique, flushed temporary files and an
|
|
atomic rename, preventing concurrent writers from colliding or exposing partial
|
|
JSON. Full behavior and safety gates are documented in
|
|
[`docs/release-engine-spec.md`](docs/release-engine-spec.md).
|