Skip to content

feat(composer): non-image file attachments — materialized to disk, path-noted in the prompt - #899

Closed
Damian-Szczepanski wants to merge 1 commit into
open-mercato:mainfrom
Damian-Szczepanski:feat/file-attachments
Closed

Damian-Szczepanski wants to merge 1 commit into
open-mercato:mainfrom
Damian-Szczepanski:feat/file-attachments

Conversation

@Damian-Szczepanski

Copy link
Copy Markdown

Motivation

The composer accepts only image/* — a CSV export, a log, or any data file dropped on it is silently ignored (screenFiles skips non-images by design). There is currently no way to hand the agent a data file from the cockpit; the workaround is describing a file path in prose.

This PR lets the user attach any file. Images keep their existing inline-base64 path untouched; non-image files extend the #357 pasted-attachments mechanism instead of inventing a new one.

Design

Wire. A new files: [{ name, mediaType?, data }] field (same 4×5 MB bounds as images) on POST /runs, POST /runs/:id/messages and POST /runs/:id/continue. mediaType is advisory; the (sanitized) name is the value — a bing-export.csv must stay recognizable on disk and in the path note.

Server, messages/continue. Files are materialized up front into the run's existing .ai/cezar/runs/<runId>-images/ dir (original filename, sanitized to a URL-routable charset; collisions suffixed -2, -3… via the same wx exclusive-create guard as persistImage), and the #357 path note is appended to the message TEXT. Riding in the text means the three-rung delivery ladder (live session → queued fold → starting-state buffer), restart recovery, and the folded-task bound all inherit the attachment without any rung learning a new field.

Server, task create. Files travel as StartRunInput.files; startRun materializes them once and records URLs in a new taskFiles record field — deliberately separate from taskImages, because hydrateQueuedInput re-encodes taskImages into inline image blocks at dequeue, and a CSV must never become an image block. At execute, taskFiles joins startAttachments for the opening-prompt note; the note no longer requires image blocks to be present (previously gated on images?.length).

Serving. The existing /runs/:id/images/:file route already answers unknown extensions with application/octet-stream, so persisted files are downloadable as-is.

Web. The composer accepts any file type (paperclip + drag-drop; paste stays image-only). Images and files share one pending array and the legacy 4-attachment cap; non-image files render as a named chip instead of a thumbnail, and splitAttachments separates the wire fields at submit. The onSubmit seam stays two-arg when no files are attached, so hosts that predate the third parameter observe the exact legacy signature.

Tests

  • pasted-attachments.test.ts: task-start file materializes under its sanitized name, is recorded in taskFiles (not taskImages), its path lands in the opening prompt with zero image blocks, and the base64 payload never enters the event log; persistUserFile traversal/sanitization/collision cases.
  • queued-messages.test.ts: a stacked file rides the queued text as a path note; a file-only message passes the emptiness refine.
  • Composer/web: intake accepts non-images, shared cap naming, splitAttachments shapes, no-preview chip encoding.
  • Full suite: no new failures vs main on my machine (the pre-existing WSL-environment failures are identical on both branches); npm run typecheck and npm run build (incl. check:pack) pass.

Known v1 gap

Files added while EDITING a stacked message are dropped (queuedMessagePatchSchema is unchanged — zod strips the unknown key). Files attached at post time survive edits, since the path note lives in the message text. Called out in code comments; happy to extend the PATCH path if you want it in scope.

Follow-up available

A second, deliberately separate commit (branch feat/attachments-library on my fork) adds a per-project .ai/cezar/attachments/ folder collecting a copy of every user upload (content-deduped, best-effort, written-never-required per AGENTS.md). Kept out of this PR to keep the core reviewable — say the word if you'd like it included.

🤖 Generated with Claude Code

…th-noted in the prompt (#file-attachments)

The composer accepted only image/* — a CSV or log dropped on it was silently
ignored, with no way to hand the agent a data file from the cockpit.

Any file type is now accepted (same 4x5MB caps, shared with images). Images keep
their inline base64 path; non-image files extend the #357 mechanism instead:
the server materializes them under .ai/cezar/runs/<runId>-images/ (original
filename, sanitized; collisions suffixed) and appends the absolute-path note to
the message text. Riding in the TEXT means the delivery ladder (live session,
queued fold, starting-state buffer), restart recovery and the folded task bound
all inherit the attachment without learning a new field.

Task-start files travel as StartRunInput.files and are recorded on the run as
taskFiles - deliberately separate from taskImages, which hydration re-encodes
into image blocks at dequeue (a CSV must never become an image block). The
opening-prompt note no longer requires image blocks to be present.

Known v1 gap: files added while EDITING a stacked message are dropped (the PATCH
schema is unchanged); files attached at post time survive edits via the text.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@CLAassistant

CLAassistant commented Aug 17, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@pat-lewczuk pat-lewczuk self-assigned this Aug 19, 2026
@pat-lewczuk pat-lewczuk added the in-progress Cezar agent is actively working this issue label Aug 19, 2026
@pat-lewczuk

Copy link
Copy Markdown
Collaborator

🤖 om-auto-review-pr started by @pat-lewczuk at 2026-08-19T07:20:46Z. Other auto-skills will skip this PR until the lock is released.

@pat-lewczuk pat-lewczuk left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔍 Code Review: feat(composer): non-image file attachments — materialized to disk, path-noted in the prompt

🎯 Summary

This PR extends the composer so any file — not just an image — can be handed to an agent. Non-image attachments are base64-uploaded on a new files field of POST /runs, POST /runs/:id/messages and POST /runs/:id/continue, materialized under the run's existing .ai/cezar/runs/<runId>-images/ directory, and referenced by absolute path through the existing #357 path note rather than being inlined as model content. Reviewed scope: the contract (packages/contract/src/runs.ts), the three server routes plus the new record field (packages/cezar/src/server/server.ts, packages/cezar/src/runs/store.ts), the materialization and prompt-assembly changes in packages/cezar/src/workflows/run.ts, and the composer/web intake and wiring under packages/web/src/.

The design choices here are genuinely good and worth calling out. Riding the path note inside the message text means the three-rung delivery ladder, the queued fold, the starting-state buffer and restart recovery all inherit attachments without a single rung learning a new field — that is the cheapest correct place to put it. Keeping taskFiles deliberately separate from taskImages is exactly right, because hydrateQueuedInput re-encodes taskImages into inline image blocks at dequeue and a CSV must never become an image block; the code comment says so and the test asserts it. persistFile is careful in the ways this repo asks for: basename() before anything else, a strict [A-Za-z0-9._-] charset so the serving route's :file param round-trips, leading dots stripped so .env cannot survive as a dotfile, a bounded collision loop, and the same wx exclusive-create guard persistImage uses. Moving the path note out of the if (images.length) block so a file-only task still gets it is the right fix and is covered by a test. The onSubmit two-arg call when no files are attached is a thoughtful piece of backward compatibility.

Two majors keep this from an approval. The first is a disk leak: attachments are written before the request is known to be acceptable, so every rejection path after that point permanently orphans up to 4 × ~5 MB. The second is that a file attached to a new task leaves no trace the user can see anywhere in the cockpit. Neither is architectural — both are contained fixes on top of a sound design.

Verdict

request changes — driven by two majors: POST /runs/:id/messages and POST /runs/:id/continue materialize attachments to disk before the request is accepted, so every subsequent rejection path leaks the files with nothing referencing them (an error response that writes unbounded bytes to disk, which CODE_REVIEW.md classifies as major); and a task-start file attachment is invisible in the cockpit, because taskFiles is a store-only field that never reaches the contract, is rendered nowhere, and is excluded from the "attached to the task" note event. The full validation gate is green, there are no blockers, and no protected surface in BACKWARD_COMPATIBILITY.md is broken.

🧪 Validation Gate

Command Status Notes
npm run typecheck ✅ PASS Clean across contract, api-client, server and web.
npm test ✅ PASS 324 test files, 6099 tests, 0 failures — including contract-parity*.test.ts and bc-route-inventory.test.ts. I did not reproduce the WSL-environment failures the PR description mentions; this branch is fully green here.
npm run test:unit ✅ PASS 36 pass, 0 fail.
npm run build ✅ PASS tsc → dist/, vite → packages/cezar/web/dist/, and the check:pack tarball gate all pass.
npm run test:package ✅ PASS 15 pass, 0 fail — the release tarball installs and the dry-run CLI workflow runs.

Findings

⚠️ Major

1. packages/cezar/src/server/server.ts:3727 (and the same shape at :3869) — attachments are written to disk before the request is accepted, so every rejection path after that point orphans them permanently.

In POST /runs/:id/messages, persistUserFile runs at line 3727, before any of the acceptance decisions below it: too many queued messages (3759), too many queued images (3763), prompt too long (3769) and session closed (3779) all return an error after the bytes are already on disk, with nothing in runs.json or in any queued message referencing them. POST /runs/:id/continue has the same shape — files are persisted at 3869 and continueRun can still answer 409 at 3888.

The concrete failure: post 4 × 5 MB of files to a run whose session has closed. The response is 409 session closed, the user's message never lands, and 20 MB stays under .ai/cezar/runs/<id>-images/ until the run is deleted. Nothing rate-limits or bounds that, so repeating the rejected request is an unbounded disk write through an endpoint that reports failure — the "unbounded input reaching files or processes" case CODE_REVIEW.md names as major. Note that images do not have this problem, which is what makes the asymmetry worth fixing rather than accepting: pasted images are persisted inside the manager only once a message is actually accepted, so a rejected image message writes nothing.

Fix: add a best-effort discard helper next to persistUserFile (an rmSync(path, { force: true }) per entry, swallowing failures the way dropOrphanImages already does) and call it on every post-persistence rejection in both routes, so a request that does not land leaves no bytes behind. The existing dropOrphanImages at packages/cezar/src/workflows/run.ts:1813 is the precedent for both the best-effort tolerance and the "never delete a still-referenced URL" care.

2. packages/cezar/src/workflows/run.ts:2819 and packages/contract/src/runs.ts:148 — a file attached to a new task is invisible in the cockpit.

taskImages reaches the user through three paths: it is on the contract's apiRunSchema (packages/contract/src/runs.ts:148), mainTranscriptSections renders it as the thread's first bubble (packages/web/src/routes/task-thread/session-transcript.tsx:78), and runAgentStep emits a N screenshots attached to the task note event (packages/cezar/src/workflows/run.ts:2819). taskFiles has none of them: it is added to runRecordSchema only (packages/cezar/src/runs/store.ts:142), never reaches the contract, is rendered nowhere, and the note event at 2819 is still gated on images?.length, so a file-only task emits nothing.

The concrete failure: attach bing-export.csv to a new task and submit. The agent does receive the path note in its opening prompt — that part works — but the thread shows only the typed text. The user gets no confirmation the file arrived, no filename, and no way to find or download it afterwards. That is the primary entry point for this feature, and it is the one place where the same action with an image gives full feedback.

Fix: at minimum, widen the note event at 2819 so a file-only or mixed attachment set also emits one, naming the files (1 file attached to the task: bing-export.csv) — that is the cheapest change, matches what images already do, and lands the record in the transcript the user reads. Ideally, also surface taskFiles on the contract's apiRunSchema and render a named chip beside the task bubble's thumbnails, reusing the chip the composer already draws for a pending non-image attachment; the serving route at server.ts:4058 already answers with application/octet-stream, so the file is downloadable as soon as its URL is exposed. If the chip rendering is out of scope for v1, say so in the PR description alongside the existing "Known v1 gap" entry, but the note event should not wait.

🔹 Minor

3. packages/cezar/src/server/server.ts:611 and :803 — the file-input shape is declared three times.

fileInputSchema now exists in packages/contract/src/runs.ts:612, again as a module-local const at server.ts:803, and a third time inlined into startRunSchema at server.ts:611 (which cannot reference the local one, since that is declared 190 lines later). AGENTS.md § The HTTP API is explicit: "Every request and response shape is a zod schema in packages/contract … never declare one in server.ts or in the api-client." Three copies of the same bounds is exactly the drift risk that rule exists to prevent — widen data in one place and the other two silently keep the old cap.

Fix: import fileInputSchema from @open-mercato/cezar-contract (server.ts already imports runHistoryQuerySchema and runIdParamSchema from there) and use that single value in startRunSchema, messageSchema and continueSchema, dropping the local declaration. The pre-existing local imageInputSchema is not a precedent to extend — it is the thing this rule was written about.

4. packages/cezar/src/server/server.ts:4065 — the served content-type is now chosen from a user-controlled filename, with no nosniff.

Before this PR the only files in <id>-images/ were written by persistImage, whose extension is derived from a validated image media_type, so the IMAGE_TYPES lookup at 4065 could only ever see an extension the server itself minted. Now the extension comes from the uploader's filename, so a caller who posts a file named payload.gif containing HTML gets it served back from the cockpit's own origin as image/gif, with no X-Content-Type-Options: nosniff and no Content-Disposition. The IMAGE_TYPES map is narrow (png/jpg/webp/gif only, no svg), so this is hardening rather than a live XSS, and the same-origin cockpit is the only consumer — but the input class changed and the response headers did not.

Fix: add 'x-content-type-options': 'nosniff' to the response headers at server.ts:4066, and consider content-disposition: attachment for anything that falls through to application/octet-stream. One line, no behavior cost for the existing thumbnail rendering.

5. packages/cezar/src/workflows/run.ts:771startRun silently drops files whose persistence fails, while the other two routes answer 500.

POST /runs/:id/messages (server.ts:3730) and POST /runs/:id/continue (server.ts:3872) both return 500 attachments could not be saved to disk when every persistUserFile call returns null. startRun filters the same nulls at line 774 and, when nothing survives, simply does not write taskFiles — the run starts as though no file had ever been attached, with no error and no note. The same failure therefore produces a loud error on two paths and silence on the third, which is the path the user is most likely to use.

Fix: at least emit a note event or record the failure so the user learns their attachment did not land; if startRun cannot signal failure to the route without a wider change, say so in a comment at 771 so the asymmetry reads as deliberate rather than missed.

6. BACKWARD_COMPATIBILITY.md § 3 — the new persisted RunRecord field is undocumented.

taskFiles (packages/cezar/src/runs/store.ts:142) is a new field on runs.json, a file § 3 describes as "written by one version, read by the next, and hand-editable by design". That section carries a bullet for every other additive record field — inputTokens/outputTokens for #737, diffStat.repointed for #751, queuedMessages for #472 — and CODE_REVIEW.md routes persisted-field changes through that document. The change itself is correctly additive and optional, so nothing is broken; the record of it is just missing.

Fix: add a § 3 bullet for runs.json → taskFiles, stating that it is optional, that absence reads as no attachments, and — the part worth writing down — why it is separate from taskImages (hydration re-encodes taskImages into inline image blocks at dequeue, so a non-image URL must never be added to that list).

7. packages/cezar/src/server/server.ts:3869 — the /continue files path has no test.

The PR covers the task-start path (pasted-attachments.test.ts) and the queued/messages path (queued-messages.test.ts) well, but POST /runs/:id/continue gained the same up-front materialization and note-building at 3869–3876 with nothing exercising it. It is also the path with the subtly different text assembly (parsed.data.text?.trim() on an optional field, versus .trim() on a defaulted one at 3733), which is precisely the kind of near-duplicate AGENTS.md § "Find every construction site" warns drifts apart.

Fix: add a case alongside the existing continue tests asserting that a file passed to /continue is materialized and that the resulting continuation text carries the path note, including the text-absent case where the note becomes the whole prompt.

8. packages/cezar/src/workflows/run.ts:1813 — a file attached to a stacked message is never cleaned up when that message is edited or removed.

dropOrphanImages deletes attachments a queued-message edit or deletion orphaned, and the #472 spec asks for exactly that ("the orphaned files under <id>-images/ are deleted best-effort at the same time"). Files cannot participate: they are referenced only from inside the message text, so nothing records which file belongs to which stacked entry, and candidates can never contain their URLs. Editing away a path note therefore leaves the file behind for the life of the run.

This one follows directly from the "no new field on the queued message" design the PR chose deliberately, and a leftover file is harmless and goes with the run, so I am not asking for the field to be added. Fix: note the limitation in a comment at dropOrphanImages so the next reader does not assume files are covered, and mention it beside the existing "Known v1 gap" in the PR description.

💅 Nit

9. packages/web/src/components/composer/composer-images.ts — the module and its identifiers now describe more than images.

MAX_IMAGES, MAX_IMAGE_BYTES, PendingImage, fileToPendingImage and the module name itself now govern all attachments, and the user-facing strings were correctly updated to say "attachment" while the code names were not. The header comment acknowledges this. Renaming is churn across several files and is entirely the author's call; if it stays, the current comment is enough.

10. packages/cezar/src/server/server.ts:3798 — the queued-message PATCH refine message no longer matches its POST sibling.

POST /runs/:id/messages now rejects with message needs text, an image or a file (:3765 via the schema at :815), while the PATCH on a stacked message still says message needs text or at least one image. Since PATCH genuinely does not accept files (the documented v1 gap), the old wording is not wrong — but the two errors now describe different vocabularies for the same concept and a user hitting both will read it as an inconsistency.

💥 Breaking Changes

  • No exported/public symbol removed or renamed without a deprecation path — the change is purely additive: fileInputSchema/FileInput are new exports, and no existing export changed shape.
  • No function signature changed in a breaking way — ComposerProps.onSubmit gained an optional third parameter and the composer deliberately calls it with two arguments when no files are attached, so hosts that predate the parameter observe the exact legacy signature; ContinueAction.continueWith and pendingPlanOf gained optional trailing parameters the same way.
  • No required type field removed or narrowed — files is .default([]) on messageInputSchema and .optional() everywhere else, so every existing request body still parses unchanged.
  • No HTTP route URL removed or renamed; no method changed — bc-route-inventory.test.ts and route-parity.test.ts are green.
  • No field removed or retyped in an existing response shape — no response schema was touched.
  • No event or message name renamed or removed; no payload field removed — the NDJSON event vocabulary is unchanged, and the test asserts the base64 payload never enters the event log.
  • No CLI command or flag renamed or removed.
  • No database table or column renamed or removed — taskFiles is a new optional field on RunRecord, so pre-existing runs.json files still parse, as § 3 requires. See minor 6 for the missing documentation of it.
  • No config key renamed and no default changed silently.
  • Where a contract had to change, the old surface keeps working — the one user-visible wording change is the messageInputSchema refine message (message needs text or at least one imagemessage needs text, an image or a file), which is an error string rather than a protected response shape, and the intake it describes only widened.

One behavior change worth naming explicitly, though it is not a compatibility break: the composer's file input dropped accept="image/*" and screenFiles no longer discards non-images, so dragging a text file onto the composer now attaches it where it was previously ignored. That is the point of the PR, paste correctly stays image-only (composer.tsx:293), and the 4 × 5 MB caps are unchanged — but it is the line QA should exercise.

🧪 Test Coverage

What the tests actually cover is solid. pasted-attachments.test.ts drives a real task start end to end: it asserts the file lands under its sanitized-but-recognizable name (bing export (sierpien).csvbing-export-sierpien-.csv), that the URL is recorded in taskFiles and that taskImages stays undefined — the assertion that actually protects the "a CSV must never become an image block" invariant — that the on-disk bytes round-trip, that the opening prompt carries the path note with zero image blocks, and that the base64 payload never reaches the NDJSON event log. The persistUserFile unit test covers the three sanitization cases that matter: traversal (../../etc/passwdpasswd), collision suffixing (raport.csvraport-2.csv), the dotfile case (.envenv) and the all-hostile fallback (???attachment). queued-messages.test.ts covers both the stacked path note and the file-only message passing the emptiness refine. On the web side, composer-images.test.ts covers intake of non-images, the shared cap, the no-preview chip encoding, the untyped-file application/octet-stream fallback and both splitAttachments shapes.

The gaps, in the order I would add them:

  1. POST /runs/:id/continue with files — no test at all, per minor 7. Add it next to the existing continue tests: one case asserting materialization plus the path note in the continuation text, and one text-absent case where the note becomes the whole prompt.
  2. The rejection-path cleanup from major 1 — once the discard helper exists, assert that a 409 session closed and a 400 prompt too long on /messages each leave <id>-images/ with no new file. Without that assertion the leak can silently come back.
  3. The note event from major 2 — assert that a file-only task emits an "attached to the task" note naming the file, so the user-visible acknowledgement is pinned by a test rather than by review.
  4. queued-messages.test.ts stubs persistUserFile on a fake RunManager, which is the right call for a route test, but it means no test exercises the route against the real manager. The pasted-attachments.test.ts suite covers the real persistFile for the task-start path, so the risk is small — worth knowing rather than worth blocking on.

One required check is still pending at the time of this review. license/cla is PENDING — the CLA has not been signed for this PR, and only @Damian-Szczepanski can clear it. It is not a CI run, so there is nothing for this automation to wait on or report later; this review covers the code, and the CLA gate holds the merge independently of it. No other check is reported on this head, and branch protection is not readable on main, so every reported check is treated as required.

@pat-lewczuk pat-lewczuk added changes-requested Reviewer requested changes feature New capability priority-medium Ordinary bug or feature risk-high Wide blast radius, review deeply needs-qa Requires manual QA before merge labels Aug 19, 2026
@pat-lewczuk

Copy link
Copy Markdown
Collaborator

🤖 om-auto-review-pr — 🏷️ label rationale

  • changes-requested — the review found two majors that must be resolved before merge: attachments are materialized to disk before POST /runs/:id/messages and POST /runs/:id/continue accept the request, so every rejection path orphans up to 20 MB with nothing referencing it; and a file attached to a new task leaves no trace the user can see, because taskFiles never reaches the contract, is rendered nowhere, and is excluded from the "attached to the task" note event.
  • feature — this PR adds a new user-facing capability (attaching non-image files in the composer) rather than fixing existing behavior.
  • 🧪 needs-qa — the composer's intake widened (the file input dropped accept="image/*" and drag-drop no longer discards non-images) and a new named-chip attachment row was added, so a human should exercise attach, drag-drop, paste, the 4-attachment cap and the removal interaction in a real browser.
  • 🔹 priority-medium — an ordinary new feature with no outage, data-loss or security incident behind it, and nothing release-blocking depends on it.
  • ⚠️ risk-high — the change touches shared contract surfaces (packages/contract request schemas on three routes), adds a persisted RunRecord field that outlives the version that wrote it, and writes user-controlled filenames to disk from a request body, which is a wider blast radius than an ordinary single-area change.

@pat-lewczuk

Copy link
Copy Markdown
Collaborator

Thanks @Damian-Szczepanski — review found actionable items, so I'm handing this PR back to you for the next pass. When the updates are pushed, re-request review and the automation can pick it up from the latest head.

Two things worth flagging beyond the review itself:

  • The full validation gate is green on this head here — npm run typecheck, npm test (324 files / 6099 tests), npm run test:unit, npm run build (incl. check:pack) and npm run test:package all pass with no failures. I could not reproduce the WSL-environment failures your description mentions, so nothing is being attributed to the environment.
  • license/cla is still pending; only you can clear that, and it gates the merge independently of this review.

The two majors are both contained fixes on top of a design I think is right — the delivery-ladder-through-text approach and the deliberate taskFiles/taskImages split are the good calls here, not things I want changed.

@pat-lewczuk pat-lewczuk removed the in-progress Cezar agent is actively working this issue label Aug 19, 2026
@pat-lewczuk

Copy link
Copy Markdown
Collaborator

🤖 om-auto-review-pr completed: CHANGES REQUESTED. Lock released.

Full code review posted with the complete validation gate green (typecheck, npm test 6099/6099, test:unit, build incl. check:pack, test:package). Findings: 0 blockers, 2 majors, 6 minors, 2 nits. Labels set to changes-requested + feature + needs-qa + priority-medium + risk-high, and the PR is reassigned to @Damian-Szczepanski.

autofix: skipped (fork PR — carrying the work forward would mean opening a replacement PR in the main repo and closing this one, which is the maintainer's call, not the automation's; the fixes are described precisely enough in the review to apply on the fork branch).

@pat-lewczuk

Copy link
Copy Markdown
Collaborator

Superseded by #929 — the same branch cherry-picked onto current main, plus its follow-up commit, already approved. #929 is a strict superset. Close #899 pointing at #929 so the author isn't fixing a dead branch.

@Damian-Szczepanski
Damian-Szczepanski deleted the feat/file-attachments branch September 4, 2026 11:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

changes-requested Reviewer requested changes feature New capability needs-qa Requires manual QA before merge priority-medium Ordinary bug or feature risk-high Wide blast radius, review deeply

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants