All green — this was the final verification pass; everything from the four completed tasks was already in the working tree and verified. **Phase 115 — Doc drafts: Discard + DELETE route + title fix — verification report** - Verified all 4 task deliverables present: DELETE route (`app/api/doc_drafts.py`), Discard UI (`doc-edit.html` + `doc-edit.js` + `.discard-draft` CSS), title fix (`defaultDocTitle(wrap)` pairing + `saveAsDoc` call site), and all test pins (integration, frontend unit, E2E). No code changes needed. - **Completion criteria:** 1. ✅ Orphaned draft discardable from edit screen; row gone — `test_delete_removes_row_and_invalidates_token` (204 → GET 404), unknown-token 404, admin-gate 403 on all routes, E2E `test_discard_draft_from_edit_screen` all pass. 2. ✅ Title after retry redo = redone answer's own question — E2E `test_save_title_is_the_redo_question_after_retry` passes. 3. ✅ Push flow byte-identical — `git diff` shows only the new DELETE route + module docstring; all 7 existing push tests green. 4. ✅ `uv run pytest --cov=app` → **2457 passed**, app coverage **99%** (>90%); `uv run pytest tests/e2e/test_save_doc_session.py -v --no-cov` → **4 passed**; `uv run ruff check .` → clean; `uv run pyright` → 0 errors. 5. ⏳ Commit + phase-dir move left to the harness (per executor rules, no `git` run; all changes left in the working tree). - No defects found; no deviations. - Next pending phase: none in `todo/` other than this one (`115_doc_draft_discard` is the last).
357 lines
14 KiB
JavaScript
357 lines
14 KiB
JavaScript
/* Brain of Reese — doc edit screen (phase 59, task 06).
|
|
*
|
|
* The standalone, admin-gated flow page
|
|
* ``/doc-edit.html?draft=<token>``: load the draft the chat page's
|
|
* "Save as doc" action (task 05) just created, edit the three fields
|
|
* (title, in-repo path, markdown body), and push — the commit lands on
|
|
* the .env-configured docs branch of the .env-configured repo, and the
|
|
* owner opens the PR themselves (D3: no PR tooling anywhere).
|
|
*
|
|
* The page is static; the API is the authority. The whoami gate is the
|
|
* ``sources-gate`` pattern (phases 16/35/50): anonymous visitors see
|
|
* the sign-in gate and NO /api/doc-drafts call is made (the draft
|
|
* endpoints are admin-only regardless — no draft data can leak through
|
|
* the page).
|
|
*
|
|
* Boot (admin): read ``?draft=<token>`` — missing → the error banner
|
|
* "No draft specified."; a malformed (non-uuid) token is treated as
|
|
* unknown → "Draft not found." with NO fetch (the shared.js malformed-
|
|
* token precedent) — then ``GET /api/doc-drafts/<token>``: 404 →
|
|
* "Draft not found.", any other non-2xx → the server's detail line, a
|
|
* network failure → the fixed one-line copy. On 200 the three fields
|
|
* are filled with VALUES (``.value`` only — never as markup; the body
|
|
* is user-derived markdown).
|
|
*
|
|
* Push (the §7.4 never-stale lifecycle, on #push-doc-btn):
|
|
* 1. client-side sanity FIRST — non-empty title/body, no ".." in the
|
|
* path (the server re-runs its guard-rails and is the authority;
|
|
* the browser's native ``required`` is the first line, these the
|
|
* second);
|
|
* 2. the button disables + relabels "Pushing…" and the live region
|
|
* says "Pushing…";
|
|
* 3. ``PUT /api/doc-drafts/<token>`` with all three fields — the
|
|
* push endpoint (task 04) commits the ROW's title/path/body, so
|
|
* the current edits must land on the row first (an unsaved edit
|
|
* would otherwise push the stale text);
|
|
* 4. ``POST /api/doc-drafts/<token>/push``:
|
|
* • 200 → the live region: `Pushed to <branch> — commit <sha7>.`
|
|
* (the full sha comes from the API, the first seven chars are
|
|
* shown — a re-push after further edits is a NEW commit on the
|
|
* same branch, the D3 ASSUMPTION); the button re-enables with
|
|
* its idle label;
|
|
* • non-2xx → the #push-error banner with the API's detail — for
|
|
* a git failure (502) that is git's stderr, trimmed to its
|
|
* first meaningful lines; the fields are PRESERVED (the fix is
|
|
* an edit, not a re-type) and the button re-enables;
|
|
* • network failure → the fixed one-line copy, same recovery.
|
|
*
|
|
* Discard (phase 115, task 02 — the page's one destructive action,
|
|
* on #discard-draft): a native ``confirm()`` FIRST (destructive +
|
|
* irreversible — no undo exists; the shell's alertdialog pattern is
|
|
* page-local to the SPA's Sources view, not a shared asset), then
|
|
* ``DELETE /api/doc-drafts/<token>`` (the same uuid4 token the GET/
|
|
* PUT ran on — the screen's credential; the router is admin-gated
|
|
* regardless):
|
|
* • 204 → ``location.assign("/")`` — back to the chat (the draft
|
|
* has no other home: no drafts list exists);
|
|
* • non-204 (a 404 race — the row vanished under us) → the
|
|
* #push-error inline banner with the server's detail (422
|
|
* shape-aware), the stale success line cleared (one claim at a
|
|
* time), NO navigation, no crash;
|
|
* • network failure → the fixed one-line copy, same recovery.
|
|
* The button runs the §7.4 in-flight lifecycle (disable +
|
|
* "Discarding…" while the DELETE is out; restored in the finally —
|
|
* never stale).
|
|
*
|
|
* The shared header module loads through this script's own relative
|
|
* import ("./header.js") — a hoisted import evaluated before this body
|
|
* runs (single-evaluation design: no direct <script> tag; esbuild
|
|
* inlines it into the page bundle in the image build). The slim flow
|
|
* page carries no nav / auth pair / steering panel, so initSharedHeader
|
|
* would settle nothing — the import exists for the CACHED whoami
|
|
* (fetchIsAdmin) the gate runs on and for the Containerfile stage-1
|
|
* contract (every page module imports ./header.js).
|
|
*/
|
|
|
|
import { fetchIsAdmin } from "./header.js";
|
|
|
|
/* ---------- page elements (doc-edit.html, task 06) ---------- */
|
|
const gateEl = document.querySelector("#doc-edit-gate");
|
|
const contentEl = document.querySelector("#doc-edit-content");
|
|
const formEl = document.querySelector("#doc-edit-form");
|
|
const titleInput = document.querySelector("#draft-title");
|
|
const pathInput = document.querySelector("#draft-path");
|
|
const bodyInput = document.querySelector("#draft-body");
|
|
const pushBtn = document.querySelector("#push-doc-btn");
|
|
const statusEl = document.querySelector("#push-status");
|
|
const errorEl = document.querySelector("#push-error");
|
|
|
|
/* The button's idle label (restored in the finally — never stale). */
|
|
const IDLE_LABEL = "Push to docs branch";
|
|
|
|
/* The URL credential's shape — a uuid4 token (task 05 navigated with
|
|
it). A non-uuid value is unknown, full stop: "Draft not found." with
|
|
no fetch (the shared.js malformed-token precedent — a 422
|
|
validation line would be framework noise, not a house message). */
|
|
const UUID_RE =
|
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
|
|
/* Set by boot once whoami says admin and the token is present — the
|
|
push handler refuses to run without it (the form is unusable in
|
|
that state anyway: "No draft specified." is on the banner). */
|
|
let draftToken = null;
|
|
|
|
/* ---------- feedback channels (§7.4 never stale) ---------- */
|
|
|
|
/* The polite live region (role="status"): the push lifecycle line —
|
|
textContent only (the branch/sha are server data). */
|
|
function setStatus(message) {
|
|
if (statusEl) statusEl.textContent = message;
|
|
}
|
|
|
|
/* The error banner (role="alert"): shown with a message, hidden on a
|
|
fresh attempt. */
|
|
function showError(message) {
|
|
if (errorEl) {
|
|
errorEl.textContent = message;
|
|
errorEl.hidden = false;
|
|
}
|
|
}
|
|
|
|
function clearError() {
|
|
if (errorEl) errorEl.hidden = true;
|
|
}
|
|
|
|
/* FastAPI error bodies: a string detail or the validation-error array
|
|
(the first entry's msg is the human line). Same extraction as
|
|
git-sources.js — 422 shape-aware. */
|
|
async function apiDetail(r, fallback) {
|
|
try {
|
|
const data = await r.json();
|
|
if (Array.isArray(data.detail) && data.detail[0] && data.detail[0].msg) {
|
|
return String(data.detail[0].msg);
|
|
}
|
|
if (typeof data.detail === "string" && data.detail) return data.detail;
|
|
} catch {
|
|
/* non-JSON error body */
|
|
}
|
|
return fallback;
|
|
}
|
|
|
|
/* Git's stderr, trimmed to its first meaningful lines (task 06):
|
|
blank lines and the "hint:" chatter are dropped, at most three
|
|
lines are kept — the banner stays one compact line, and a
|
|
single-line detail (409 unconfigured, 422 guard-rail) passes
|
|
through untouched. */
|
|
function trimGitDetail(detail) {
|
|
const lines = String(detail)
|
|
.split("\n")
|
|
.map((l) => l.trim())
|
|
.filter((l) => l && !l.startsWith("hint:"));
|
|
return lines.slice(0, 3).join(" ") || "The push failed.";
|
|
}
|
|
|
|
/* ---------- load (GET /api/doc-drafts/<token>) ----------
|
|
* The 200 body fills the three fields — VALUES only (input.value /
|
|
* textarea.value), never as markup: the body is user-derived markdown
|
|
* and the title/path may contain anything but markup. */
|
|
async function loadDraft(token) {
|
|
let r;
|
|
try {
|
|
r = await fetch(`/api/doc-drafts/${token}`);
|
|
} catch {
|
|
showError("Could not reach the server — is the app running?");
|
|
return;
|
|
}
|
|
if (r.status === 404) {
|
|
showError("Draft not found.");
|
|
return;
|
|
}
|
|
if (!r.ok) {
|
|
showError(await apiDetail(r, `The server could not load the draft (${r.status}).`));
|
|
return;
|
|
}
|
|
let draft;
|
|
try {
|
|
draft = await r.json();
|
|
} catch {
|
|
showError("The server sent an unreadable draft — try again.");
|
|
return;
|
|
}
|
|
if (titleInput) titleInput.value = draft.title;
|
|
if (pathInput) pathInput.value = draft.path;
|
|
if (bodyInput) bodyInput.value = draft.body;
|
|
}
|
|
|
|
/* ---------- push (PUT the edits, then POST /push) ----------
|
|
* The push endpoint commits the ROW's title/path/body, so the current
|
|
* field values are PUT first (all three — a partial PUT would keep a
|
|
* stale field) and the push runs only once that lands. Every failure
|
|
* path lands the error banner (the server's detail line — git's
|
|
* stderr, trimmed, for 502s) and re-enables the button in the
|
|
* finally: never stale, success OR failure. */
|
|
function wirePush() {
|
|
if (!formEl || !pushBtn) return;
|
|
formEl.addEventListener("submit", async (e) => {
|
|
e.preventDefault();
|
|
if (!draftToken) {
|
|
showError("No draft specified.");
|
|
return;
|
|
}
|
|
// Client-side sanity (the server is the authority — it re-runs the
|
|
// guard-rails): non-empty title/body, no ".." in the path. The
|
|
// browser's native `required` is the first line, these the second
|
|
// (whitespace-only values included).
|
|
const title = titleInput.value.trim();
|
|
const path = pathInput.value.trim();
|
|
const body = bodyInput.value.trim();
|
|
if (!title) {
|
|
showError("Enter a title for the doc.");
|
|
titleInput.focus();
|
|
return;
|
|
}
|
|
if (!body) {
|
|
showError("The doc body must not be empty.");
|
|
bodyInput.focus();
|
|
return;
|
|
}
|
|
if (path.includes("..")) {
|
|
showError("The path must not contain '..'.");
|
|
pathInput.focus();
|
|
return;
|
|
}
|
|
|
|
clearError(); // a new attempt starts clean
|
|
setStatus("Pushing…");
|
|
pushBtn.disabled = true; // §7.4: one push per click
|
|
pushBtn.textContent = "Pushing…";
|
|
try {
|
|
const put = await fetch(`/api/doc-drafts/${draftToken}`, {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ title, path, body }),
|
|
});
|
|
if (!put.ok) {
|
|
// 404 (the row vanished) / 422 (a field no longer passes the
|
|
// guard-rails) — the server line, the edits preserved, and
|
|
// any stale success line cleared (one claim at a time).
|
|
setStatus("");
|
|
showError(await apiDetail(put, "Could not save the doc edits — try again."));
|
|
return;
|
|
}
|
|
const r = await fetch(`/api/doc-drafts/${draftToken}/push`, {
|
|
method: "POST",
|
|
});
|
|
if (!r.ok) {
|
|
// 409 (repo unconfigured), 422 (the stored path), 502 (git's
|
|
// stderr) — the detail is the actionable line, trimmed to its
|
|
// first meaningful lines; the fields are preserved and the
|
|
// stale success line (if any) is cleared.
|
|
setStatus("");
|
|
showError(trimGitDetail(await apiDetail(r, "The push failed — try again.")));
|
|
return;
|
|
}
|
|
const pushed = await r.json();
|
|
// The full sha comes from the API; the first seven chars are the
|
|
// display value (a re-push after further edits is a NEW commit —
|
|
// the D3 ASSUMPTION — so the button re-enables for it).
|
|
setStatus(
|
|
`Pushed to ${pushed.branch} — commit ${String(pushed.commit_sha).slice(0, 7)}.`,
|
|
);
|
|
} catch {
|
|
setStatus("");
|
|
showError("Could not reach the server — is the app running?");
|
|
} finally {
|
|
pushBtn.disabled = false; // never stale — success OR failure
|
|
pushBtn.textContent = IDLE_LABEL;
|
|
}
|
|
});
|
|
}
|
|
|
|
wirePush();
|
|
|
|
/* ---------- discard (DELETE /api/doc-drafts/<token> — phase 115) ----------
|
|
* The page's one destructive action: confirm → DELETE the row (the
|
|
* same uuid4 token the GET/PUT ran on) → 204 → back to the chat (the
|
|
* draft has no other home — no drafts list exists). Every failure
|
|
* lands the inline error banner (the server's detail — 422
|
|
* shape-aware) and does NOT navigate: never stale, no crash. */
|
|
const DISCARD_LABEL = "Discard draft";
|
|
|
|
function wireDiscard() {
|
|
const discardBtn = document.querySelector("#discard-draft");
|
|
if (!discardBtn) return;
|
|
discardBtn.addEventListener("click", async () => {
|
|
if (!draftToken) {
|
|
showError("No draft specified.");
|
|
return;
|
|
}
|
|
// Destructive + irreversible — no undo exists. The native
|
|
// confirm() is the approved dialog for this one action on the slim
|
|
// flow page (phase 115 task 02 ASSUMPTION — the shell's
|
|
// alertdialog is page-local to the Sources view, not a shared
|
|
// asset).
|
|
if (!confirm("Discard this draft? This cannot be undone.")) return;
|
|
discardBtn.disabled = true; // §7.4: one discard per click
|
|
discardBtn.textContent = "Discarding…";
|
|
try {
|
|
const r = await fetch(`/api/doc-drafts/${draftToken}`, {
|
|
method: "DELETE",
|
|
});
|
|
if (r.status === 204) {
|
|
// The row is gone — the chat page is the draft's only other
|
|
// home.
|
|
location.assign("/");
|
|
return;
|
|
}
|
|
// Non-204 (a 404 race — the row is gone — or any other failure):
|
|
// the server line in the inline banner, the stale success line
|
|
// cleared (one claim at a time), NO navigation, no crash.
|
|
setStatus("");
|
|
showError(await apiDetail(r, `Could not discard the draft (${r.status}).`));
|
|
} catch {
|
|
setStatus("");
|
|
showError("Could not reach the server — is the app running?");
|
|
} finally {
|
|
discardBtn.disabled = false; // never stale — success OR failure
|
|
discardBtn.textContent = DISCARD_LABEL;
|
|
}
|
|
});
|
|
}
|
|
|
|
wireDiscard();
|
|
|
|
/* ---------- boot ----------
|
|
* The admin gate FIRST (the sources-gate pattern — one cached
|
|
* whoami): anonymous visitors get the gate and NO /api/doc-drafts
|
|
* call (the endpoints are admin-only regardless — no draft data
|
|
* leaks through the page). The admin gets the form, then the draft
|
|
* load from ?draft=<token>. */
|
|
(async () => {
|
|
const admin = await fetchIsAdmin();
|
|
if (!admin) {
|
|
if (gateEl) gateEl.hidden = false;
|
|
if (contentEl) contentEl.hidden = true; // ships hidden — stays hidden
|
|
return;
|
|
}
|
|
if (gateEl) gateEl.hidden = true;
|
|
if (contentEl) contentEl.hidden = false;
|
|
|
|
// The URL credential (task 05's navigation: the 201 token).
|
|
const token = new URLSearchParams(window.location.search).get("draft");
|
|
if (!token) {
|
|
showError("No draft specified.");
|
|
return;
|
|
}
|
|
if (!UUID_RE.test(token)) {
|
|
// A non-uuid token is unknown — no fetch (the shared.js
|
|
// malformed-token precedent: a 422 validation line is framework
|
|
// noise, not a house message).
|
|
showError("Draft not found.");
|
|
return;
|
|
}
|
|
draftToken = token;
|
|
await loadDraft(token);
|
|
if (titleInput) titleInput.focus(); // land the caret in the first field
|
|
})();
|