Skip to content

feat: meeting attachments (issue #16) - #17

Merged
pretyflaco merged 4 commits into
pretyflaco:mainfrom
k9ert:kn/attachments
Aug 17, 2026
Merged

feat: meeting attachments (issue #16)#17
pretyflaco merged 4 commits into
pretyflaco:mainfrom
k9ert:kn/attachments

Conversation

@k9ert

@k9ert k9ert commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Implements the attachment-folder workflow from #16. Plan and rationale: #16 (comment)

All parts (A–E) are in. Still draft pending an end-to-end run on a real recording.

Part A — millet (prerequisite, merged + released)

pretyflaco/millet#28, millet-pipeline 0.15.0/0.15.1. millet sync pushes a session's attachments/ subdirectory into the team repo verbatim, bypassing its suffix allowlist and descriptive-rename map — which would otherwise have turned an attached slides.pdf into transcript.pdf and dropped every image, office document and video.

Part B — server (dd70229)

  • New vezir/server/attachments.py: POST / GET / GET <name> under /api/sessions/{id}/attachments.
  • Storage is the filesystem — no DB column, no migration: sessions_dir/<id>/attachments/, exactly the directory the worker hands to millet sync. A test asserts that path agrees with millet.sync.ATTACHMENTS_SUBDIR and that _collect_files() picks it up, so the two repos can't drift apart silently.
  • Deliberately not served through /artifact/{id}/{name}: that route rejects /, and user filenames would collide with millet's canonical artifact names in its flat namespace.
  • Safety: filenames flattened to a single component (traversal, C:\… paths, control characters, length cap that keeps the extension); _N suffixes on collision; symlinks never listed or served; team + personal-session visibility checked against the job row before any filesystem access; a failed request removes everything it wrote.
  • Caps mirror millet's (50 files / 100 MB per session, per-file max_upload_bytes()) via config.max_attachments_per_session() and config.max_attachment_bytes_total() — accepting more would store files that sync silently drops.
  • sessions._enforce_team_visibility promoted to enforce_team_visibility; millet-pipeline floor raised to >=0.15.0.

Parts C–E — client (dce327c)

scribe. Recording starts by printing a fixed staging folder, ~/vezir-attachments/ (VEZIR_ATTACHMENTS_DIR) — one well-known path rather than one per recording, since dropping files starts before the recording's own directory name is settled. When recording stops, scribe lists what it found and waits for Enter as a last chance to add more; the pause is skipped when stdin is not a TTY (scribe is documented for headless/ssh use) or with the new --no-pause flag. Ctrl-C or EOF at that prompt continues into the upload rather than discarding an already-recorded meeting. Once the session id is known the files are POSTed, then moved into that recording's own attachments/ so the staging folder is empty next time. A failed POST warns and leaves the files staged; a failed move warns. Neither fails the meeting upload.

TUI. Attachments appear as marked rows in the detail screen's artifacts table and open through the existing ArtifactScreen (inline text, OS opener for binaries, save-to-disk), which gained an is_attachment flag selecting the attachment endpoint. A server without the route degrades to "no attachments" instead of blanking the view.

pull. download_session_artifacts() also fetches attachments into <meeting>/attachments/, names kept verbatim — pull is the "share a meeting without git" path and would otherwise omit what the git archive carries. A name arriving from the wire with a path in it is rejected before anything is written to the user's disk.

Plus VezirClient.list_attachments / download_attachment / save_attachment, README section and env-var rows, CHANGELOG entry, version 0.13.0.

Accepted limitations (also in the CHANGELOG)

  • An attachment uploaded after the worker's sync step has already run misses that push. Attachments are sent seconds after the audio and sync runs minutes later, so this is rare.
  • Attachments are not fed to summarization (out of scope for v1, per the issue).

Tests

47 new tests across tests/test_attachments.py (server round trip, storage path, cross-team and personal 404s, hostile filenames, symlinks, collisions, all three caps and their cleanup), tests/test_scribe_attachments.py (staging folder, pause behavior incl. non-TTY and Ctrl-C, upload-then-move, failure handling, request shape) and tests/test_attachments_client.py (API methods, pull, TUI row marking and dispatch).

Full suite: 953 passed (906 before); ruff check . clean.

Not done here

  • End-to-end run: record with a file staged, confirm it opens in the TUI and lands in the team repo after sync.

🤖 Generated with Claude Code

k9ert and others added 3 commits August 15, 2026 15:45
.agents/.claude/_bmad/.history stay local. C6b probes the host for the
millet CLI, breaking the all-clean doctor assertion off darwin/PATH.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
POST/GET/GET-download /api/sessions/<id>/attachments. Storage is the
filesystem: sessions_dir/<id>/attachments/ is exactly what millet sync
gets, and millet 0.15.0 pushes that subdir verbatim -- no DB column, no
migration, no extra sync work.

Not on /artifact/<id>/<name>: it rejects "/" and user filenames collide
with millet's canonical artifact names in that flat namespace.

Filenames flattened to one safe component (traversal, windows paths,
control chars, length), collisions get _N suffixes, symlinks never
listed or served. Caps mirror millet's (50 files / 100 MB per session,
per-file max_upload_bytes) so nothing accepted here is dropped at sync;
a failed request removes everything it wrote.

sessions._enforce_team_visibility -> enforce_team_visibility (now used
across modules). millet-pipeline floor >=0.15.0. 19 new tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… parts C-E) — 0.13.0

scribe: fixed staging folder ~/vezir-attachments (VEZIR_ATTACHMENTS_DIR)
printed at record start; after stop it lists staged files and waits for
Enter (skipped without a TTY or with --no-pause; Ctrl-C/EOF continues
rather than discarding a recorded meeting), POSTs them once the session
id is known, then moves them into the recording's own attachments/.
Failures warn and leave the files staged; the meeting upload never fails
because of them.

api: list/download/save_attachment. TUI: marked attachment rows in the
detail table, opened via ArtifactScreen(is_attachment=True). pull:
attachments fetched into <meeting>/attachments/, names verbatim, wire
names with a path rejected.

README + CHANGELOG; version 0.13.0. 28 new tests, suite 925 -> 953.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@pretyflaco

Copy link
Copy Markdown
Owner

Verdict: approve-with-comments — validation is strong end-to-end; one scope question (TUI record screen) and one minor edge case below.

Tested by temporarily deploying this branch to our server (saray, now vezir 0.13.0) and putting a client on the branch too. Millet was on the released 0.15.1 throughout.

Verified working

Automated (against real millet 0.15.1, not a stub):

  • The 47 new tests pass locally.
  • Server round-trip: /uploadPOST/GET/GET-download …/attachments, storage at sessions/<id>/attachments/ (0600), cross-team + personal-session 404s, hostile-filename flattening, collision _N, symlink refusal, all three caps.
  • Cross-repo seam: real millet.sync._collect_files() picks up attachments/<name> verbatim — slides.pdf is not renamed to transcript.pdf, and non-allowlist suffixes (.png, .pptx) survive. This is the boundary the whole design rests on, and it holds against millet 0.15.1.
  • Attachment → git archive: driving the real millet.sync.sync_session() on a session dir shaped like the server's output, pushing to a local bare repo, then cloning — meetings/<date>_<meeting>/attachments/slides.pdf lands verbatim, no transcript.pdf clobber.
  • Client upload_attachments / list_attachments / pull-into-<meeting>/attachments/ logic.

Live end-to-end (saray 0.13.0 + client 0.13.0):

  • vezir scribe: staging folder announced, post-recording pause correctly listed the staged file and waited for Enter, uploaded (server logged attachments stored: count=1 bytes=17 names=['testslides.pdf'], POST … 200), then moved it into the recording's attachments/.
  • TUI: the attachment shows as a marked row in the detail screen and opens through the attachment endpoint (GET …/attachments/testslides.pdf 200).

Sync leg over real infra: being confirmed by a Blink-team member (Blink has a git sync target; our Twentyone team has none, so that run stayed local-only — sync_enabled=0 — which is why the git push didn't fire there). The mechanism itself is already proven by the automated sync_session test above; will update here once the real-infra run completes.

Question — TUI record screen has no attachment support

The staging-folder + pause + upload flow lives only in the CLI vezir scribe path; vezir/client/tui/record_screen.py has no attachment code. So a TUI-primary user can view attachments but can't attach while recording — they'd have to drop to the CLI. For teams that record mostly via the TUI this is a real gap. Is wiring the TUI record screen in scope for #16, or a deliberate follow-up? Worth a note in "Accepted limitations" either way.

Minor — same-box duplicate edge case

_send_attachments uploads then moves the staged files into the client's recording dir. If someone runs the client and server on the same host and points VEZIR_RECORD_DIR into VEZIR_DATA/sessions, the move collides with the server's own stored copies and produces _N-suffixed duplicates. It doesn't happen in a normal split deployment (client and server dirs differ), but a one-line note or a same-path guard would preempt confusion.

Nice, careful work — the security surface (traversal/symlink/caps/visibility) and the millet-boundary reasoning are thorough.

Review follow-ups on PR pretyflaco#17.

TUI record screen was the gap: it could view attachments but not attach
while recording. It now shows the staging folder + staged count, prompts
with the same last-chance list when recording stops (Enter/Escape
continue, r rescans), and uploads them with the meeting.

Workflow extracted to vezir/client/attachments.py so scribe and the TUI
share one implementation; reporting goes through on_info/on_error so the
CLI prints and the TUI posts messages.

Move-into-recording now skips a byte-identical destination copy: same-box
deployments with VEZIR_RECORD_DIR inside VEZIR_DATA/sessions otherwise
got _2 duplicates of what the server just stored.

4 new tests, suite 953 -> 957.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@k9ert

k9ert commented Aug 16, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the deployment run — especially for driving the real sync_session() against a bare repo rather than trusting the stub. Both points are addressed in d5db6d9.

TUI record screen — wired, not deferred

You're right that it's a real gap, so it's in scope now rather than a follow-up. vezir/client/tui/record_screen.py:

  • the record screen shows the staging folder and how many files are waiting in it, refreshed on a 5 s interval, so a TUI-only user discovers the folder without reading docs;
  • when recording stops, a new AttachmentPromptScreen modal lists the staged files before the upload kicks off — the TUI equivalent of scribe's Enter prompt. Enter/Escape/the button all continue into the upload (never cancel it: the meeting is already recorded), r rescans the folder after you drop something in;
  • _upload_worker sends the attachments once the session id is known and moves them next to the recording, exactly as the CLI does.

To avoid two copies of the workflow drifting, it moved into a shared vezir/client/attachments.py. Reporting goes through on_info/on_error callbacks, so the CLI prints and the TUI posts ServerStatus messages into its event loop. scribe.py keeps only its own surface (the printed announcement and the blocking prompt).

Same-box duplicates — guarded, not just noted

move_staged_into_recording now skips the move when the destination already holds a byte-identical copy (filecmp.cmp(..., shallow=False)), dropping the staged file instead. That covers your scenario — client and server on one host with VEZIR_RECORD_DIR inside VEZIR_DATA/sessions, where the file the server just stored is the move target — without inventing a config flag. There's also a same-path guard (p.resolve() == target.resolve()) so the file is never unlinked when staging and destination are literally the same directory. A differing file with the same name still gets the _N suffix, as before.

Tests

4 new (suite 953 → 957, ruff clean): the prompt's staged-file listing and its always-continue dismissal, the record screen's folder/count line, and the identical-destination dedupe.

Still outstanding from your review: the real-infra sync leg on a team that has a git target. The mechanism is covered by the automated sync_session() test, but I'd rather have your Blink-side run confirm it before this comes out of draft.

@k9ert
k9ert marked this pull request as ready for review August 17, 2026 07:27
@k9ert

k9ert commented Aug 17, 2026

Copy link
Copy Markdown
Contributor Author

Tested and works

@pretyflaco
pretyflaco merged commit 8ddb73f into pretyflaco:main Aug 17, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants