Skip to content
This repository was archived by the owner on Jul 16, 2026. It is now read-only.

test(dashboard): regression guard for workspace path bug fixed in #109 - #111

Open
jgluck96 wants to merge 46 commits into
mainfrom
test/dashboard-workspace-path-guard
Open

jgluck96 wants to merge 46 commits into
mainfrom
test/dashboard-workspace-path-guard

Conversation

@jgluck96

@jgluck96 jgluck96 commented Jun 5, 2026 •

Copy link
Copy Markdown

What

Source-grep regression tests that pin the fix from #109 (dashboard reading pending-questions.md, build_log.md, and notes from the workspace, not the repo root).

Why

Without a guard, a future refactor could silently re-introduce the bug where:

  • get_pending_count() reads <repo>/pending-questions.md ("none open") instead of the live workspace file
  • get_score() reads a stale or missing repo-root build_log.md

These tests fail on the pre-#109 source and pass on the fix branch — verified locally.

Dependency

Should be merged after or together with #109 (the fix). Can be merged independently without breaking anything, since the test only guards the fixed shape.

Test plan

  • python3 tests/dashboard-workspace-path.test.py passes
  • CI runs it as part of tsc + tests (clean install)

🤖 Generated with Claude Code


Note: This PR is now based on fix/dashboard-workspace-paths (PR #109). CI should pass on this branch because the fix is present. Merge #109 first, then this PR will auto-retarget to main.

jgluck96 and others added 30 commits June 1, 2026 14:23
Adds the same env-var-driven UI override that sutando's web-server has
(see josh/migrate-to-react on sonichi/sutando, commit c350a10). With
this in place, stando can serve any UI bundle by exporting:

  CLIENT_DIST_DIR=/abs/path/to/dist bash src/startup.sh

before launching the voice agent. Without the env var, behavior is
unchanged — stando serves its bundled client/dist/ as before.

Phase 1.7 of the OSS sync roadmap. Unblocks the workflow where
production stando deployments override the default UI with a private
branded dist while developer / demo builds still serve the stando
default UI from client/.

Mechanics ported verbatim from sutando:
- resolveClientDistDir() function with tilde expansion
- module-level `let CLIENT_DIST_DIR = resolveClientDistDir()` so
  existing references keep working
- per-call re-resolve inside startWebServer() so tests + dynamic env
  changes pick up the latest value
- JSDoc updated to document the override

Submodule auto-init block from sutando's startup.sh deliberately NOT
ported — stando's client/ is still vendored (not a submodule), and
the goal is independence from OSS UI updates, not auto-sync.

Co-authored-by: Cursor <cursoragent@cursor.com>
Ports sutando/tests/client-dist-dir-override.test.ts verbatim so the
private repo's pluggability contract stays in lockstep with OSS. Same
3 cases: custom dir override serves, default fallback serves bundled
UI, path traversal blocked.

All 3 pass against the new resolveClientDistDir() in src/web-server.ts.

Co-authored-by: Cursor <cursoragent@cursor.com>
The React frontend (client/) is moved out of this repo into the private
AG2Platform/stando-ui repo (full history preserved via git subtree split)
and plugs back in via CLIENT_DIST_DIR — the same pluggability seam ported
in #68. Stando's backend and the UI now ship and version independently;
the private UI no longer has to track the OSS sutando UI.

Removals:
- client/ (97 files) — now lives at AG2Platform/stando-ui
- package.json: build:client / dev:client scripts
- pnpm-workspace.yaml: client workspace member (lockfile re-pruned)

Rewiring so everything still works:
- web-server.ts: default dist resolves to ../client/dist (present only in
  the packaged app, staged by build-app.sh); a dev checkout sets
  CLIENT_DIST_DIR or gets a 503 with a build hint. Docs/JSDoc updated.
- startup.sh: auto-detects a built sibling ../stando-ui/dist when
  CLIENT_DIST_DIR is unset; otherwise warns. Override always wins.
- app/build-app.sh: sources the UI from CLIENT_DIST_DIR or a sibling
  stando-ui checkout (builds it), stages it at the bundle's client/dist
  path so the shipped app stays self-contained with no runtime env var.
- .github/workflows/release.yml: checks out + builds AG2Platform/stando-ui
  and passes its dist via CLIENT_DIST_DIR. REQUIRES a new repo secret
  STANDO_UI_TOKEN (PAT with read access to the private UI repo) — the
  default GITHUB_TOKEN can't reach another repo.
- docs/WIRE.md: the wire contract any UI must honor (ported from OSS).
- .env.example / CONTRIBUTING.md / .gitignore: documented + cleaned up.

Verified: tsc clean (pre-existing @ai-sdk/openai error unrelated), the
CLIENT_DIST_DIR test passes 3/3, frozen lockfile installs, and the server
serves the real stando-ui dist end-to-end (200 + hashed assets).

Co-authored-by: Cursor <cursoragent@cursor.com>
refactor: extract conversation UI to private stando-ui repo
Phase 5.2 of the OSS→stando backend reconciliation. Ports the one
genuinely-missing, infra-backed web-server endpoint from OSS sutando.

/presenter — exposes the presenter-mode sentinel
(state/presenter-mode.sentinel, written by scripts/presenter-mode.sh and
already polled by the Discord/Slack/Telegram bridges) to the React UI.
The UI was already designed for it: the existing /voice-mode comment
references "the presenter badge poll to render the composite 3-mode
badge", but the endpoint didn't exist. Returns { active, expiresAt };
past/garbage/missing expiry → inactive (matches the bridges' is_active
checks). Reads via statePath('state/presenter-mode.sentinel'), the same
workspace path resolve_workspace() gives the bridges.

Deliberately NOT ported (OSS-specific, no stando infra / feature
decisions, not endpoint hardening): /chat (standalone chat page),
/overlays + /api/overlays (overlay-apps manager — no overlay infra in
stando), /paidsubscriptions* (subscription-scanner web UI — skill exists
but no web rendering layer).

Also removes tests/tasks-api.test.ts: it imported
../client/src/lib/tasks-api.ts, which PR #69 moved to the stando-ui repo.
The test is orphaned in stando (code gone) — coverage belongs in
stando-ui now.

Verified: tsc clean; new tests/web-server-presenter.test.ts 4/4; full TS
suite 244/244.

Co-authored-by: Cursor <cursoragent@cursor.com>
Phase 5.3 of the OSS->stando backend reconciliation. agent-api.py's route
set already matches OSS 1:1 and from_agent sanitization already exists, so
the only genuine gap was get_task_result(): it checked live results/ and
tasks/ but not results/archive/<YYYY-MM>/.

task-bridge archives a result within ~seconds of delivery, so a web UI
GET /result/<id> poll can arrive after the move and 404 a task that
actually completed. Port the OSS archive-glob fallback (newest month
first); /tasks/active already scanned the same layout, so this just brings
/result/ to parity. Live results/ still takes precedence over the archive.

Preserves stando's deliberate divergences (resolve_workspace/state_dir
path layout left untouched).

Verified: py_compile clean; new tests/agent-api-result-archive.test.py
3/3; full Python suite 49/49; agent-api-task-field-injection still green.

Co-authored-by: Cursor <cursoragent@cursor.com>
Port the only clean OSS sutando win from the voice-agent.ts reconciliation
(Phase 5.4): add the "[BLANK_AUDIO]" token to the FILLERS-ARE-NOT-REQUESTS
backchannel list so a transport-emitted blank-audio marker is ignored
rather than (mis)interpreted as a directive that triggers `work`.

The rest of OSS's voice-agent.ts diff was reviewed and intentionally NOT
ported: stando is ahead on multi-provider voice/STT + the cloud gateway,
uses state-paths.js (not workspace_default.js) by design, and the
read_selection deictic prompt depends on a tool stando doesn't have.

Co-authored-by: Cursor <cursoragent@cursor.com>
…points

feat(web-server): /presenter endpoint (Phase 5.2)
fix(agent-api): /result finds archived results (Phase 5.3)
feat(voice-agent): port [BLANK_AUDIO] filler token (Phase 5.4)
Phase 5.5 of the OSS→private sync. Ports `src/single_instance.py`
verbatim from OSS sutando and adopts it in the three bridges so a
duplicate process (the canonical case: `startup.sh` racing
Sutando.app's launchd agent at boot) exits cleanly instead of running
side-by-side and double-delivering tasks / racing API tokens.

Mechanism: OS-level `fcntl.LOCK_EX | LOCK_NB` on a workspace-local
lock file (`<workspace>/state/locks/<name>.lock`). Auto-released by
the OS when the holder process dies (no manual cleanup). Contended
acquirers exit 0 (NOT 1) so launchd KeepAlive doesn't restart-loop
on the second instance.

Adoption is one line per bridge:
  - telegram-bridge.py / slack-bridge.py: first line of `main()`.
  - discord-bridge.py: at the top of the `client.run(...)` branch
    (NOT the `send` REST-shortcut branch — `dm-result.py`-style
    one-shot sends must keep working alongside a running bridge).

Verified:
  - tests/single-instance.test.py — 4 cases (lock-create, contention
    exits 0, lock-release on holder death, distinct names don't
    contend).
  - Existing bridge tests still pass (telegram-tofu, telegram-allowlist,
    slack-access, discord-allowlist).
  - `pnpm typecheck` clean (no TS surface touched).

This phase explicitly chose only single_instance.py from the 12
OSS-only files; the rest (result_channel_key, task_archive,
voice-key, voice-config*, send_allowlist, discord_config,
overlay-manager-ui, morning-briefing, obsidian-mirror) are either
covered inline in stando, conflict with stando's architecture, or
are net-new features that deserve their own dedicated phase.

Co-authored-by: Cursor <cursoragent@cursor.com>
feat(bridges): single-instance flock guard for telegram/discord/slack (Phase 5.5)
…ilure no-restart

Phase 5.7 of the OSS→private sync. Three targeted hardenings ported from
OSS sutando's health-check.py, classified after a full per-function diff
audit (the larger voice-transport stuck-CONNECTING rework + sutando-app
auto-launch were deliberately deferred to their own phase).

1. check_discord_voice() — on-demand process detector for
   skills/discord-voice/scripts/discord-voice-server.ts. Mirrors the
   existing conversation-server pattern: soft `warn` when down (the
   steady state — process spawns per voice session and is SIGTERM'd by
   `dismiss`), `ok` + stale-vs-source check when running. Detail
   contains "running" in both states so the dashboard service filter
   surfaces it either way.

2. check_notes_split_brain() — fires when both <repo>/notes/ and
   <workspace>/notes/ exist with overlapping .md filenames. Edits to
   one side become invisible to the other; the probe surfaces the
   overlap + the migrate-script hint. No-op when the two paths resolve
   to the same dir (single-dir installs).

3. main() --fix branch — short-circuit when a discord-bridge /
   telegram-bridge detail mentions LoginFailure or "token invalid":
   print "regenerate at discord.com/developers/applications (no
   restart)" instead of running subprocess.Popen against a known-bad
   credential. Pre-port, --fix would crash a fresh bridge against the
   same revoked token AND (if launchd is also managing one) create a
   duplicate process — the exact failure mode the Phase 5.5 single-
   instance lock now catches, but cheaper to avoid up front.

Verified: - tests/health-check-phase-57.test.py — 9 cases (3× discord-voice
    states, 3× notes-split-brain states incl. same-path no-op, 2×
    LoginFailure short-circuit + non-regression for normal stale
    restart, 1× run_all_checks() wiring smoke).
  - Existing tests/health-check-stuck-loop.test.py +
    tests/health-check-emit-task.test.py still pass.
  - tests/single-instance.test.py (Phase 5.5) still passes.
  - `pnpm run typecheck` clean (no TS surface touched).
Co-authored-by: Cursor <cursoragent@cursor.com>
feat(health-check): discord-voice + notes split-brain probes, LoginFailure no-restart (Phase 5.7)
Three coordinated changes that close the REST-fallback file-delivery
gap and remove the drift hazard between the WS-bridge and dm-result.

1. src/send_allowlist.py (new, verbatim port from OSS sutando)
   - Extracts SEND_ALLOWED_ROOTS, SEND_ALLOWED_PREFIXES, and the
     realpath-based is_path_sendable() sanitizer into one module.
   - Identical policy values to the prior inline copy in
     discord-bridge.py — this is a pure refactor, not a tightening
     or loosening.
   - Per the liususan091219 review on the OSS PR sonichi#1029: keeping the
     policy as a copy in each consumer drifts even with "keep in
     sync" comments. Pre-extract on stando, the dm-result inline
     copy was already missing the notes/Desktop/Documents roots
     that discord-bridge had.

2. src/dm-result.py: multipart upload path
   - Adds _send_message_with_files() (multipart/form-data POST to
     /channels/{id}/messages with payload_json + files[N] parts).
   - send_dm() now extracts [file:|send:|attach:] markers,
     allowlist-checks each path via the shared is_path_sendable,
     chunks the text as before, then uploads sendable files in
     batches of 10 (Discord per-message cap).
   - Pre-port: dm-result stripped markers and logged them as
     dropped because REST multipart wasn't implemented. Owner
     missed every file delivered when voice was disconnected.
   - Filename sanitization mirrors discord-bridge's
     _safe_attachment_basename (CR/LF/quote -> _) so a filename
     can't inject its own multipart headers.

3. src/discord-bridge.py: adopt the shared helper
   - Replaces inline SEND_ALLOWED_ROOTS / SEND_ALLOWED_PREFIXES /
     _is_path_sendable with `from send_allowlist import ...`.
   - Local name `_is_path_sendable` preserved as an alias to the
     shared function so every existing sink call site (4 sites
     across DM/voice/proactive paths) stays unchanged.

Tests
-----
- tests/send-allowlist.test.py (new): policy semantics (nonexistent
  rejects, directory rejects, allowed prefix matches, arbitrary
  rejects, symlink/realpath collapse defeats path-injection,
  dot-dot traversal rejects) + identity-level drift guards that
  both consumers reference the SAME tuple/function objects (a copy
  that equal-compared at module-load would silently drift later) +
  the documented-prefix set guard.
- tests/dm-result-upload.test.py (new): 5 regression guards for
  the new upload flow — sendable file -> text+multipart, disallowed
  file -> text-only no upload, file-only body -> multipart with
  empty content, >10 files -> 2 batches at Discord cap, multipart
  body shape (boundary + payload_json + files[N] parts + filename
  sanitization on the wire).
- tests/discord-bridge-allowlist.test.py (updated): structural
  sanitizer regression guard now inspects send_allowlist.py for
  the body (#1-#4) and verifies discord-bridge.py imports +
  aliases the helper (#5) before re-checking the discord.File()
  gate (#6). Same CodeQL-style guarantees; follows the
  implementation.

Verification
------------
- python3 -m py_compile on all three modified source files: OK
- New tests: 14 cases, all green.
- Existing regression sweep: discord-bridge-{allowlist,
  file-markers, file-send-allowlist, pending-replies, prose-marker,
  reply-chunks, state-prefetch, access-tier}, dm-result-send-dm,
  bridge-marker-no-leak, result-markers, discord-chunker,
  single-instance, agent-api-result-archive, health-check-phase-57,
  task-bridge-isvoice, web-server-presenter,
  client-dist-dir-override -> 19 Python + 15 TS, all green.
- `pnpm typecheck` clean.

Phase 5.9 of the OSS->private sync. No behavioral change to the
WS-connected bridge — the alias preserves every existing call
site. The user-visible win is on the REST-fallback path: files
emitted while voice is disconnected now actually reach the DM.

Co-authored-by: Cursor <cursoragent@cursor.com>
feat(dm-result): multipart upload + shared send_allowlist (Phase 5.9)
Three coordinated changes that close the OSS->stando port of the
owner-resolution unification started in Phase 5.9. Pre-Phase-5.10
both `dm-result.py:_resolve_owner_id` and
`discord-bridge.py:_poll_proactive` carried inline copies of the
same env -> tierMap -> bot-filter walk chain. The drift class
@liususan091219 flagged on OSS PR sonichi#1029 — and that bit production
in sonichi#846 — was waiting to recur. This PR funnels both sites through
one helper.

1. src/discord_config.py (new, verbatim port from OSS sutando)
   - load_config / save_config / resolve_owner_id / auto_seed_if_missing.
   - resolve_owner_id chain: env override -> discord-config.json[owner]
     -> discord-config.json[tierMap] -> access.json[owner] (legacy)
     -> access.json[tierMap] (legacy sonichi#846 path). Returns None when
     exhausted; helper deliberately does NOT fall through to
     allowFrom[0] (the sonichi#1147 bug path) — callers do that step paired
     with their own bot-filter walk.
   - auto_seed_if_missing: idempotent. On first boot writes
     <workspace>/state/discord-config.json seeded from access.json.
     Emits WARN when seed has to fall back to allowFrom[0] so the
     operator catches a mis-seed.

2. src/dm-result.py: rewire _resolve_owner_id
   - Replaces the ~57 line inline chain with a delegate-to-helper
     pattern. Helper handles steps 1-5; bot-filter REST walk stays
     here (helper is sync-only, so async/sync split between the
     two callers stays clean).

3. src/discord-bridge.py: rewire _poll_proactive owner block + boot
   - on_ready now calls discord_config.auto_seed_if_missing on the
     parsed access.json. Idempotent; non-fatal on error.
   - Owner-resolution block in poll_proactive replaces ~42 inline
     lines with a discord_config.resolve_owner_id call followed by
     the existing async bot-filter walk. Preserves the list-order
     fix (`load_allowed()` set->list change from the 2026-05-18
     msze_/qingyunwu mis-route).

Net effect on diff with OSS upstream
------------------------------------
- discord-bridge.py: -42 +30 in the proactive block (closer to OSS).
- dm-result.py: -57 +30 in the resolver (closer to OSS).
- discord_config.py: byte-identical to OSS (zero conflict surface
  going forward).
- Total: ~70 lines of stando-specific inline chain replaced with
  delegate-to-shared-helper calls. The diff against upstream
  SHRINKS on the two existing files. Pattern matches Phase 5.9's
  send_allowlist extract.

Tests
-----
- tests/discord-config.test.py (new, 15 cases): resolution chain
  semantics (env / workspace owner / workspace tierMap / legacy
  owner / legacy tierMap / exhaustion -> None / stale-tier-for-
  delisted-user safety), file IO safety (load on missing, load on
  corrupt JSON, save_config atomic replace, auto_seed idempotency,
  auto_seed legacy-owner path, auto_seed allowFrom[0]-fallback
  WARN), and drift guards that both consumers import the helper
  AND call the right entry points.
- tests/dm-result-send-dm.test.py (updated): `_with_access_json`
  now also isolates `discord_config.config_path()` so the resolver
  doesn't consult the dev's live workspace discord-config.json
  (which would short-circuit the legacy chain the tests pin).

Verification
------------
- python3 -m py_compile on all three modified source files: OK.
- New tests: 15 cases, all green.
- Full Phase 5 regression sweep: 23 Python tests green
  (discord-bridge-{allowlist, file-markers, file-send-allowlist,
  pending-replies, prose-marker, reply-chunks, state-prefetch,
  access-tier, discord-state-detection, welcome-on-first-post,
  reply-directive, mod-judge, mod-judge-actions}, dm-result-{send-dm,
  upload}, discord-config, send-allowlist, bridge-marker-no-leak,
  result-markers, discord-chunker, single-instance,
  agent-api-result-archive, health-check-phase-57).
- `pnpm typecheck` clean.

Phase 5.10 of the OSS->private sync. Behavioral note: stando's
workspace already contains a `discord-config.json` (OSS code seeded
it during an earlier run); the helper picks it up immediately. For
fresh installs, auto_seed_if_missing runs on the first bridge
on_ready and seeds the file idempotently.

Co-authored-by: Cursor <cursoragent@cursor.com>
feat(discord-config): shared owner-resolution helper (Phase 5.10)
Stando's telegram-bridge and discord-bridge both use atomic-claim-by-
rename for proactive-delivery — `results/proactive-*.txt` is renamed
to `proactive-*.sending` before the actual send to prevent same-tick
double-deliveries between concurrent poll iterations. But if the
bridge process crashes between the rename and the send (SIGKILL,
OOM, unhandled exception in the send path), the `.sending` file is
orphaned in `results/` — no poll iteration looks at `.sending`
suffixes, so the owner's proactive notification is silently dropped
until manual intervention.

This PR ports OSS's startup recovery sweep
(`_recover_orphan_sending_files`) to both bridges. On bridge boot,
scan `results/` for orphan `proactive-*.sending` files and rename
them back to `*.txt` so the next poll iteration picks them up.

Changes
-------
- src/telegram-bridge.py: add `_recover_orphan_sending_files` (37
  lines) before `main()`; call it from `main()` after
  `_single_instance_acquire`, before the polling loop.
- src/discord-bridge.py: add the same function (47 lines, with the
  richer docstring from OSS) before `on_ready`; call it from
  `on_ready` after the Phase 5.10 auto-seed block, before the
  poll-task creation.

Functions are byte-identical to OSS sutando's versions (modulo the
"Mirrors discord-bridge.py" / "Mirrors telegram-bridge.py" docstring
swap, which points each at the other for cross-bridge consistency).
Pattern matches the OSS comment chain.

Tests (tests/bridge-orphan-sending-recovery.test.py, 9 cases)
-------------------------------------------------------------
1. Behavior parity — both bridges recover 2 orphan files end-to-end.
2. Drift guards — both bridges DEFINE the function AND call it from
   their startup paths (regex-checked against `main()` and
   `on_ready()`).
3. Selectivity — only `proactive-*.sending` is touched; `task-*.sending`,
   `proactive-*.txt.tmp`, and existing `.txt` files pass through.
4. Collision safety — if `<name>.sending` AND `<name>.txt` both exist,
   the .sending is left in place (no clobber).
5. Idempotency — second call with no orphans returns 0; missing
   RESULTS_DIR also returns 0 (boot before any results materialize).

Minimal-divergence accounting
-----------------------------
- Diff impact on telegram-bridge.py: -0 +56. Pure additive.
  Closes a stando→OSS divergence point: OSS had this function,
  stando didn't.
- Diff impact on discord-bridge.py: -0 +59. Pure additive.
  Closes a stando→OSS divergence point: OSS had this function,
  stando didn't.
- Function bodies are byte-identical to OSS upstream. Zero conflict
  surface against future OSS pulls.
- Net effect: stando's bridges are now byte-closer to OSS on a
  ~115-line gap that previously had no equivalent code on the
  stando side.

Verification
------------
- python3 -m py_compile on both modified bridges: OK.
- New tests: 9 cases, all green.
- Full Phase 5 regression sweep: 27 Python tests green
  (discord-bridge-{allowlist, file-markers, file-send-allowlist,
  pending-replies, prose-marker, reply-chunks, state-prefetch,
  access-tier, discord-state-detection, welcome-on-first-post,
  reply-directive}, dm-result-{send-dm, upload}, discord-config,
  send-allowlist, bridge-marker-no-leak, result-markers,
  discord-chunker, single-instance, telegram-bridge-{allowlist,
  access, tofu, forward-attribution, proactive-owner-resolution},
  agent-api-result-archive, health-check-phase-57, plus this PR's
  new test).
- `pnpm typecheck` clean.

Phase 5.11 of the OSS->private sync. The other OSS additions to
telegram-bridge.py audited in this phase (the `task_id` parameter
on `send_reply` and the `task_archive.find_task_file` import) were
NOT ported: the first is an additive enhancement that mostly
benefits OSS's outbox_log telemetry rather than stando's
_emit_channel_metric pipeline, and the second is a dependency of
the `claim_task` rename pattern stando does not use. Both can be
revisited if the underlying systems converge later.

Co-authored-by: Cursor <cursoragent@cursor.com>
feat(bridges): recover orphan .sending files on startup (Phase 5.11)
….12)

Extends the recovery sweep from Phase 5.11 to slack-bridge.py, closing
the same orphan-`.sending` bug class on every proactive-delivery surface
(telegram, discord, slack). When the bridge crashes between the atomic
claim-rename (`proactive-*.txt` → `.sending`) and the actual send, the
.sending file sits orphaned in `results/` because no poll iteration ever
looks at .sending suffixes — the owner notification is silently dropped.

Changes:
- `src/slack-bridge.py`: add `_recover_orphan_sending_files()` (byte-
  identical to telegram/discord versions modulo the docstring's
  cross-reference) and call it from `main()` immediately after the
  single-instance lock, before any threads spin up.
- `tests/bridge-orphan-sending-recovery.test.py`: extend coverage to
  slack — add basic recovery, selectivity, collision-safety, and
  idempotent/missing-dir variants; rename the drift-guard tests to
  cover all three bridges; stub `slack_bolt` + dummy SLACK_*_TOKEN
  env so the module imports under test without the real library.

Per the minimal-divergence directive, only the additive recovery
function is ported in this PR. Two other portable hardenings surfaced
during the audit are queued as follow-ups:
- access-cache for sonichi#899 (slack-bridge access.json external-deletion fix)
- `send_allowlist` helper extract (parallel to Phase 5.9 for discord)

Tests: 55/55 Python tests pass (12/12 in the orphan-recovery file,
4 of which are net-new for slack). TypeScript typecheck clean.

Phase 5.12 of the OSS → private sync.

Co-authored-by: Cursor <cursoragent@cursor.com>
feat(slack-bridge): recover orphan .sending files on startup (Phase 5.12)
…ase 5.13, sonichi#899)

Before this PR, deleting ~/.claude/channels/slack/access.json out from
under a running slack-bridge — Sutando.app's Settings UI revoke flow,
accidental rm, sync conflict — caused the next inbound Slack DM to fall
straight into TOFU and silently overwrite the prior `tofuOwner` /
`tierMap` / manually-added `allowFrom` entries with a fresh single-user
payload. Effectively re-onboarded whoever sent the next message as the
new owner.

Fix: mirror the file into an in-memory cache on every successful read,
and consult the cache before genuine TOFU. If access.json is missing
but the cache holds a valid prior state (tofuOwner present), restore
the file from cache instead of TOFUing. Genuine first-time TOFU still
works when the cache is empty.

OSS sutando has had this guard for some time; we now match.

Changes:
- `src/slack-bridge.py`:
  - Add `_access_cache`, `_access_cache_mtime`, `_access_cache_lock`
    module-level state.
  - Add `_update_access_cache(data)` — writes-through to the lock-
    guarded cache, captures the file's mtime so `load_tier_map` can
    short-circuit on a hot path.
  - Add `_restore_access_from_cache()` — writes the cached payload
    back to ACCESS_FILE at 0o600 perms, only when cache has tofuOwner.
    Fail-open on per-call errors.
  - `load_allowed`: call `_update_access_cache(data)` after successful
    read.
  - `load_tier_map`: consult cache first (mtime-validated), fall back
    to re-read + update on miss.
  - `tofu_onboard`: try `_restore_access_from_cache()` before genuine
    TOFU; call `_update_access_cache(payload)` after writing.

Tests:
- New `tests/slack-bridge-access-cache.test.py` (10 tests):
  - load_allowed populates cache w/ mtime
  - load_tier_map cache hit (mtime match) vs miss (mtime change)
  - _restore_access_from_cache writes back at 0o600
  - _restore refuses without tofuOwner / empty cache (corruption guard)
  - tofu_onboard recovers from external deletion (the sonichi#899 case)
  - tofu_onboard genuine first-time TOFU when cache empty
  - drift guards: module surface + source structural call-site checks
- `tests/slack-bridge-access.test.py`: bump tofu_onboard regex budget
  2000 → 4000 chars (function grew by docstring + restore-check; same
  pattern this test already used twice for _write_task). Pre-existing
  structural guard, unchanged semantics.

Verification:
- 56/56 Python tests pass (was 55; +1 net-new test file).
- TypeScript typecheck clean.
- `python3 -m py_compile src/slack-bridge.py` clean.

Phase 5.13 of the OSS → private sync. Closes one of the two queued
follow-ups from PR #80; `send_allowlist` extract for slack still
queued as Phase 5.14 candidate.

Co-authored-by: Cursor <cursoragent@cursor.com>
…cache

fix(slack-bridge): cache access.json to survive external deletion (Phase 5.13, sonichi#899)
…urvives restarts (Phase 5.14)

Completes the Phase 5.13 sonichi#899 cache port. The cache machinery only
protects against external deletion of `access.json` if the cache is
actually populated. Phase 5.13 added the cache + restore plumbing but
missed the 1-line OSS pairing that primes the cache on bridge startup
— without it, `_access_cache` stays `None` until the first successful
`load_allowed()` call. If that first call happens to be the access
check for an inbound DM that arrives AFTER an external deletion (file
deleted while the bridge was down, then a message lands), the cache
is still `None`, `_restore_access_from_cache()` returns False, and
`tofu_onboard()` silently overwrites the prior owner — exactly the
regression sonichi#899 was meant to prevent.

OSS sutando has had this priming since the original sonichi#899 fix; the
Phase 5.13 PR ported the cache surface but not the startup wiring.

Changes:
- `src/slack-bridge.py`: in `main()`, call `load_allowed()` immediately
  after `_recover_orphan_sending_files()` and before any threads spin
  up. Reads access.json into the in-memory cache while the file is
  still on disk, so the very first inbound DM after a restart can
  detect and recover from external deletions.
- `tests/slack-bridge-access-cache.test.py`: new
  `test_main_primes_access_cache_at_startup` structural guard. Verifies
  `main()` contains `load_allowed()` AND that it appears AFTER
  `_recover_orphan_sending_files()` in source order. A future PR that
  drops the priming or reorders it would silently regress sonichi#899.

Audit summary:
- Original Phase 5.14 scope was a `send_allowlist` helper extract for
  slack-bridge. Audit found OSS sutando still has the SAME inline
  allowlist as stando — the extract was done only for the discord
  family (discord-bridge + dm-result). Extracting slack would have
  ADDED divergence, violating the minimal-divergence rule. Scope
  was redirected to the actual remaining gap: the missing sonichi#899
  priming call. After this PR, slack-bridge has converged with OSS
  structurally — no extant net-portable wins.

Verification:
- 56/56 Python tests pass (no test count change; cache-test file
  grew by 1 net-new test).
- TypeScript typecheck clean.
- `python3 -m py_compile src/slack-bridge.py` clean.

Phase 5.14 of the OSS → private sync. Closes the slack-bridge backlog.

Co-authored-by: Cursor <cursoragent@cursor.com>
…lowlist

fix(slack-bridge): prime access cache at startup so sonichi#899 fix survives restarts (Phase 5.14)
….15)

`src/outbox_log.py` is the audit log every bridge appends to after a
successful outbound delivery. Stando has had the module + tests for
some time, and `slack-bridge.py` already records to it on every reply
(line 575) — but the data was invisible. Dashboard had no reader and
no rendering card, so the writes accumulated as a JSONL file no one
read. OSS sutando ships both pieces; this PR ports the missing half.

Changes:
- `src/dashboard.py`:
  - Add `get_outbox(limit: int = 10) -> list[dict]` reader. Imports
    `outbox_log` lazily so its failure cannot break dashboard rendering
    (telemetry is a courtesy, never load-bearing); returns `[]` on any
    exception.
  - Add the Outbox card to the render flow between Capabilities Matrix
    and Keyboard Shortcuts. Hidden when empty via `if outbox:` — fresh
    installs and bridges that haven't recorded anything yet shouldn't
    see a stub card. Renders channel-typed emojis (slack/discord/
    telegram/imessage/whatsapp/email/x), truncated timestamps,
    recipient labels, and 80-char body previews — identical shape to
    OSS sutando.

- `tests/dashboard-outbox.test.py` (new, 6 tests):
  - Happy path: append entries via outbox_log, read back via get_outbox.
  - Limit param honored + default (10) applied.
  - Fresh-install (no file) returns [] not exception.
  - Fault tolerance: stub outbox_log.read_recent to raise → get_outbox
    returns [] without propagating the exception.
  - Structural source guard: `if outbox:` gate on the render block.
  - get_outbox is a module-level callable (not a closure / hidden symbol).

Audit context:
- Inventory of remaining src/ Python files identified `dashboard.py`
  as a 117-line divergence with one OSS-only def: `get_outbox`. Other
  diff lines are deliberate stando-specific path-resolution (state_paths
  vs workspace_default, personal_path signature, DASHBOARD_PORT env
  override) — those were NOT touched per the minimal-divergence rule.
- Writers beyond slack-bridge (discord-bridge had ~4 outbox_log.append
  sites in OSS) are deferred — they'd be much larger ports and require
  audit of which call sites are still relevant in stando's flow.

Verification:
- `python3 tests/dashboard-outbox.test.py` → 6/6 pass.
- Full Python regression sweep → 57/57 pass (was 56; +1 net-new file).
- TypeScript typecheck clean.
- Smoke test of the live reader pulled 5 prior entries from the
  existing outbox file — confirms the wiring is correct.

Phase 5.15 of the OSS → private sync.

Co-authored-by: Cursor <cursoragent@cursor.com>
feat(dashboard): add outbox card to surface bridge audit log (Phase 5.15)
Phase 5.15 added the dashboard reader + Outbox card. Slack writes were
already populating outbox_log, so slack deliveries became visible right
away. Discord deliveries still didn't, because discord-bridge.py had
no `outbox_log.append(...)` calls. Ported the three OSS sites that
cover discord's main delivery paths.

Sites ported (each `try/import outbox_log/append/except Exception: pass`
to keep telemetry fail-open and non-load-bearing):

1. `poll_results` reply path — every channel.send() reply (DM or guild
   channel). channel_type derived from `discord.DMChannel` check;
   recipient_label = "<recipient> DM" or "#<channel-name>".
2. Proactive owner-DM path — every dm.send() proactive notification.
   channel_type="discord_dm"; recipient = owner_id; recipient_label
   from fetched user.name.
3. dm-fallback channel-redirect path — every target_channel.send()
   when a [channel: <id>] marker routes a result to a non-originating
   channel. channel_type="discord_channel"; recipient = the resolved
   target_channel_id (NOT the owner_id), so the dashboard reflects
   where the message actually landed.

Two stando-specific send sites were NOT ported and don't have OSS
equivalents:
- L2225 welcome message to new guild user (stando-only feature)
- L2705 cap-hit reply for cloud-cap accounting (stando-only)

Adding outbox writes for those would be a feature addition, not a
port — out of scope for the minimal-divergence sync.

Tests (new tests/discord-bridge-outbox.test.py, 5 cases):
- Structural: exactly 3 append sites, each wrapped in
  `try/import outbox_log/append/except Exception: pass`.
- Variable binding: each append carries channel_type, recipient,
  recipient_label, body, task_id.
- Channel-type vocabulary stays in dashboard._channel_icon's known set
  (catches typos that would silently render the default → arrow).
- Round-trip: writes via outbox_log are read back via the dashboard
  reader added in Phase 5.15.
- Drift guard: every append is preceded by a _chunk_for_discord send
  loop within 30 lines (prevents future PR from recording empty-body
  audit rows when the bridge only sent files).

Verification:
- 58/58 Python tests pass (was 57; +1 net-new file).
- TypeScript typecheck clean.
- `python3 -m py_compile src/discord-bridge.py` clean.

Phase 5.16 of the OSS → private sync. Discord deliveries now show up
alongside slack in the dashboard Outbox card.

Co-authored-by: Cursor <cursoragent@cursor.com>
jgluck96 and others added 13 commits June 1, 2026 21:25
…x-records

feat(discord-bridge): record outbox audit on every delivery (Phase 5.16)
….17)

Closes bridge-family symmetry: Phase 5.15 added the reader (dashboard
Outbox card), Phase 5.16 added discord-bridge writes; this phase ports
the OSS telegram-bridge write site.

Changes:
- `src/telegram-bridge.py`: `send_reply(chat_id, text)` gains a
  `task_id: str | None = None` parameter. After the chunked text send,
  `outbox_log.append(channel_type='telegram', recipient=str(chat_id),
  body=clean_text, task_id=task_id)` is called inside a try/except —
  telemetry is best-effort and must never block a reply. The task-reply
  call site in `main()` passes `task_id=task_id` through; the proactive
  caller continues to omit it (matches OSS behavior).

- `tests/telegram-bridge-outbox.test.py` (new, 7 tests):
  - Structural: exactly 1 append site, wrapped in try/except.
  - Signature: send_reply accepts task_id with None default.
  - Caller: task-reply site plumbs task_id=task_id through.
  - Variable-binding: append carries channel_type, recipient, body, task_id.
  - channel_type is "telegram" (matches dashboard _channel_icon map → ✈️).
  - Round-trip: append → outbox_log → dashboard.get_outbox (isolated tmp workspace).
  - Positioning: append follows text-send loop, inside if clean_text: gate.

27/27 Python tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ox-records

feat(telegram-bridge): record outbox audit on every reply (Phase 5.17)
…ase 5.18)

Closes the bridge-family outbox coverage gap. Phase 5.16 added
outbox_log.append calls to discord-bridge.py for live-bridge deliveries;
this phase ports the OSS dm-result.py write site for the REST-fallback
path used when the live bridge isn't connected. Without this PR, an
offline-bridge owner DM would land successfully on Discord but never
appear in the dashboard's Outbox card alongside live-bridge rows —
silently missing every delivery during bridge downtime.

Changes:
- `src/dm-result.py`: after the success-state print "sent to DM"
  and before the final `return True` in `send_dm`, append an outbox
  audit row wrapped in `try / import outbox_log / append / except
  Exception: pass`. channel_type="discord_dm" matches discord-bridge's
  live-bridge vocabulary so both surfaces render the same 💬 icon in
  the dashboard card. recipient_label is the constant
  "owner DM (via dm-result.py)" — a deliberate divergence from
  discord-bridge's per-user label, used as an audit-trail marker
  letting operators distinguish REST-fallback rows from live-bridge
  rows at a glance.

- `tests/dm-result-outbox.test.py` (new, 6 tests):
  - Structural: exactly 1 append site, fail-open wrapped.
  - Channel-type vocabulary: must be `discord_dm` (matches discord-
    bridge writes so both surfaces share the same icon).
  - Variable-binding: required kwargs present (channel_type, recipient,
    body, recipient_label).
  - Recipient-label marker: must be the dm-result audit marker so
    operators can distinguish surfaces.
  - Positioning: append sits AFTER the "sent to DM" success print
    and BEFORE the final `return True` (catches a refactor that
    hoists it above an error branch — would record rows for unsent
    messages — or moves it past the return — unreachable).
  - Round-trip: append → outbox_log → dashboard.get_outbox reader
    picks it up with the right channel_type + marker.

Verification:
- 60/60 Python tests pass (was 59; +1 net-new file).
- TypeScript typecheck clean.
- `python3 -m py_compile src/dm-result.py` clean.

Phase 5.18 of the OSS → private sync. Bridge → outbox → dashboard
pipeline is now feature-complete across slack + discord (live bridge +
REST fallback) + telegram.

Co-authored-by: Cursor <cursoragent@cursor.com>
feat(dm-result): record outbox audit on REST-fallback DM delivery (Phase 5.18)
…ando)

`pending-questions.md` mixes two formats:
  - structured: `## Title` + `- **Status:** unanswered`
  - free-form prose: `## Title` + plain prose body (deleted when resolved,
    not marked done)

Before this PR, `get_waiting_questions()` required an explicit Status
field, so the free-form prose style was silently dropped — a question
typed directly by the user would never trigger a notification, a Discord
DM ping, or a voice result. Effectively, anyone using the documented
prose convention was invisible to the notifier.

OSS sutando fixed this by treating a missing Status field as default-
unanswered (matching the documented "sections are deleted when resolved"
behavior), while still skipping sections whose Status is explicitly
"resolved" / "done" / "answered". This PR ports that change verbatim —
the function body is now byte-identical to OSS.

Tests cover:
  1. free-form prose → reported as waiting (regression target)
  2. explicit `Status: unanswered` → reported
  3. explicit `Status: Waiting` → reported
  4. explicit `Status: resolved/done/answered` → skipped (5 variants)
  5. empty title → skipped (defensive)
  6. mixed file (real-world layout) → only unanswered sections reported
  7. empty file → empty list, no crash

Verification: 61/61 Python tests pass (60 prior + 1 new), TS typecheck clean.
Co-authored-by: Cursor <cursoragent@cursor.com>
…ions-no-status-field

Phase 5.19: notify on no-Status-field pending questions (sync OSS sutando)
Replaces the single-table conversation-store schema with per-surface
tables (voice / phone / discord_voice) plus an idempotent migration
from the legacy schema, mirroring OSS sutando. Stando's `.app`-bundle-
compatible lazy `require('node:sqlite')` pattern is preserved (OSS
uses a static import).

Public API is unchanged — `recordConversation(role, text, sessionId)`,
`recordSessionBoundary(reason, sessionId)`, and `recordSession(metrics)`
keep identical signatures so all existing callers (voice-agent.ts,
task-bridge.ts, skills/phone-conversation/.../conversation-server.ts)
continue to work without rewiring. Three additive exports land:
  - sourceFromRole(role)       — role-prefix → table-keyed source
  - kindFromRole(role)         — legacy role → per-surface kind taxonomy
  - recordToolCall(source, name, durationMs, sessionId)  — per-tool-call
    sink so tool invocations land in the surface table immediately
    (kind='tool_call') instead of being batched at session end via
    sessions.tool_calls JSON. Survives crashes, kill -9, ngrok drops.

Schema details:
  - Surface tables (voice/phone/discord_voice): identical shape, each
    holds utterances + tool calls in one chronological stream
    (id INTEGER PRIMARY KEY ordering; kind discriminates row type).
  - sessions/session_events tables retained — different concerns.
  - sessions.tool_calls and sessions.events JSON columns dropped post-
    init (the atom rows now live in surface tables / session_events).
  - New views: v_voice, v_phone, v_discord_voice, v_sessions — thin
    wrappers adding a human-readable `time` column for DB Browser.
  - Backwards-compat `conversation` VIEW that unions the three surface
    tables under the legacy `role` column name so external scripts
    (query-conversation.sh, regression-search) keep working without
    source edits.

Migration:
  - Runs once on first init when legacy `conversation` or `tool_calls`
    tables are present and any surface table is empty.
  - Single transaction; failure rolls back and logs (best-effort).
  - NULL ts_unix rows are dropped (can't satisfy NOT NULL surface
    schema; no other column carries time).
  - Idempotent — second open is a no-op (legacy tables already dropped).
  - Also backfills discord_voice from sessions.tool_calls JSON via
    json_each (discord-voice never wrote to the standalone tool_calls
    table in the legacy schema).

Stando-specific divergence from OSS (intentional, documented):
  - `import type { DatabaseSync }` + lazy `require('node:sqlite')` in
    init(). Older bundled Node runtimes gate node:sqlite behind
    --experimental-sqlite; a static import would throw
    ERR_UNKNOWN_BUILTIN_MODULE and crash every process that imports
    conversation-store. The lazy path lets the mirror degrade to a
    no-op while the text conversation.log keeps working.
  - Issue numbers (sonichi#1052 etc.) replaced with neutral references
    ("the per-surface refactor") so comments don't carry OSS-specific
    PR ids into stando.

Test coverage (tests/conversation-store-per-surface.test.ts, 20 cases):
  - sourceFromRole + kindFromRole routing contract (incl. core-agent
    → agent suffix-matching, edge: 'shared-user' is not prefixed).
  - recordConversation routes by role-prefix; rows don't leak across
    surface tables.
  - recordToolCall writes kind='tool_call' to the named surface table;
    durationMs=null lands as NULL.
  - recordSessionBoundary writes SESSION_END row.
  - recordSession's session_events fan-out filters duplicate-prefix
    events (user:/sutando:/tool_call:/etc) — surface tables are canonical.
  - End-to-end migration via child-process runner: realistic pre-
    refactor seed (conversation + tool_calls + sessions w/ JSON cols)
    → assert per-surface row counts, kinds, post-migration view shape.
  - Migration idempotency: second open adds only the trigger row, no
    duplicate backfill.
  - Backwards-compat view: SELECT FROM conversation still works.
  - SessionMetrics back-compat: legacy toolCalls field is silently
    accepted (interface stability for existing callers).

Verification: 264/264 TS tests pass (244 prior + 20 new), 61/61 Python
tests pass, TS typecheck clean.

Why minimal divergence: the functional code in src/conversation-store.ts
is now identical to OSS sutando modulo the documented lazy-require
divergence + neutral-issue-number wording. Future OSS changes to this
file should merge cleanly without semantic conflicts.

Co-authored-by: Cursor <cursoragent@cursor.com>
…store-per-surface

Phase 5.20: conversation-store per-surface tables (sync OSS sutando)
Task files place `task:` last, with the user-supplied body following.
`parse_priority_from_text` scans line-by-line for `priority:` headers.
Before this PR it stopped only on `---` or a blank line — not on `task:`.
A forged body line `priority: urgent` after `task:` was still matched,
escalating priority via the task-body injection vector.

OSS sutando closes this by stopping at the first `task:` line (everything
after is opaque body). This PR ports that one-line behavioral change
verbatim. The function body is now identical to OSS.

Also updates the existing task-priority.test.py header fixture to place
`priority:` before `task:` (the writer convention), and adds
task-priority-stop-at-task-delimiter.test.py with 6 regression cases.

Verification: 62/62 Python tests pass.
Co-authored-by: Cursor <cursoragent@cursor.com>
…-stop-at-task

Phase 5.21: stop parse_priority at task: delimiter (sync OSS sutando)
…t repo

dashboard.py passed REPO_DIR as the workspace fallback to shared_personal_path()
and personal_path(). When SUTANDO_MEMORY_DIR is unset, these helpers fell back
to REPO_DIR — so get_pending_count() was reading the stub repo
pending-questions.md ("none open") instead of the live workspace file, and
get_score() / get_use_case_matrix() were reading a stale or missing build_log.

Fix:
- get_pending_count(): use state_path() directly (runtime state, not synced)
- get_score(), get_use_case_matrix(): shared_personal_path("build_log.md") with
  no explicit workspace arg so it falls back to resolve_workspace() not REPO_DIR
- _resolve_note_path(), /notes handler: same pattern for notes dir

Intentional REPO_DIR uses (personal_path for stand-avatar + stand-identity)
are preserved — identity assets can legitimately live in the repo's gitignored
directory.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Source-grep tests pin that dashboard.py reads runtime state (pending
questions, build log, notes) from the workspace via state_path() /
shared_personal_path() without REPO_DIR fallback, not from the repo root.

Without this guard, a future refactor could silently re-introduce the bug
where get_pending_count() reads the stale repo stub ("none open") rather than
the live ~/.sutando/workspace/pending-questions.md.

Tests fail on the unfixed source (pre-#109) and pass on the fixed branch.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

@bassilkhilo-ag2 bassilkhilo-ag2 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test file (tests/dashboard-workspace-path.test.py) is correct and the regression guard logic is sound.

Blocker: branch includes stale client/ tree

This branch was forked before the client extraction landed. The PR diff contains the entire client/ directory (90+ files), which was removed from this repo and moved to the private stando-ui repo. Merging as-is would re-introduce it.

Fix: rebase test/dashboard-workspace-path-guard onto current main — the client/ files will disappear from the diff and only the test file will remain.

The test itself is good; just needs a clean rebase.

@jgluck96
jgluck96 changed the base branch from main to fix/dashboard-workspace-paths June 8, 2026 15:07
@jgluck96
jgluck96 changed the base branch from fix/dashboard-workspace-paths to main June 8, 2026 15:14
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants