Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
44a6ed4
SCALE-SEAM (95) — element state: two read/write pairs, and why the ma…
claude Sep 4, 2026
121c095
SCALE-SEAM (96) — the as-built question's aggregate reader, and a wit…
claude Sep 4, 2026
2f09a5c
Merge already-squashed (95) branch history
claude Sep 4, 2026
0472f97
SCALE-SEAM (97) — the undo stack, and a destination that looked right…
claude Sep 4, 2026
66ab67a
Merge already-squashed (96) branch history
claude Sep 4, 2026
58525e1
Correct the undo/redo republish docs — all three sites, not just the …
claude Sep 4, 2026
75f8597
SCALE-SEAM (98) — detailing carriers, and a field map total over one …
claude Sep 4, 2026
f2926a5
Merge already-squashed (97) branch history
claude Sep 4, 2026
83e149e
SCALE-SEAM (99) — the content shelf, and a destination header that wa…
claude Sep 4, 2026
17103db
Merge already-squashed (98) branch history
claude Sep 4, 2026
2a8f46f
SCALE-SEAM (100) — the element-connection pair, and a destination nam…
claude Sep 4, 2026
6d68219
Merge already-squashed (99) branch history
claude Sep 4, 2026
370a1f8
Merge already-squashed (95)-(100) branch history
claude Sep 4, 2026
f5d6751
R22-ENTITLEMENT ⑤ — an agency review comment becomes an RFI somebody …
claude Sep 4, 2026
5c79d56
Merge branch history — reconcile after the (95)-(100) squashes
claude Sep 4, 2026
d826115
R22-ENTITLEMENT ⑤ review: promotion claims the comment atomically, an…
claude Sep 4, 2026
9f048eb
Merge branch history after the #434 squash — content already identical
claude Sep 4, 2026
c437a7f
R24-REPORTS-BY-MOMENT — a finished pack can be sent, not only downloaded
claude Sep 4, 2026
87cc0e8
Review round on #435: five findings, all verified real, all fixed
claude Sep 4, 2026
67dfc36
Second review round on #435: the port fix is the root cause my first …
claude Sep 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,28 @@ All notable changes to Massing. Releases are signed, auto-updating desktop build
(Windows / macOS / Linux); the updater always serves the latest. Format loosely follows
[Keep a Changelog](https://keepachangelog.com/).

## Unreleased — R24-REPORTS-BY-MOMENT: a finished pack can be sent, not only downloaded

`POST /projects/{pid}/jobs/{job_id}/deliver` emails any finished job's artifact to named
recipients, surfaced as **Send** beside **Download** in the job tray.

**The roadmap named the wrong blocker, one layer too high.** It said this "still wants a delivery
surface and SMTP" — both of which already shipped: `mailer.py` sends real mail, and the notification
digest is a working assemble-then-send surface. What was actually missing was smaller and more
specific: **the mailer could not carry a file**. `build_message` gained attachments, and the order
matters — `add_alternative` must run before `add_attachment` or Python refuses outright, which the
test asserts rather than assumes.

Refusals mirror the download route exactly (404 wrong project, 409 while queued/running, 404 with no
artifact), so a caller does not learn two answers to "is this artifact ready", plus two of its own:
an empty recipient list is 422 rather than a silent success, and over 15 MB is 413 rather than a
per-recipient error from a server that would have bounced it. A deployment with no SMTP configured
returns 200 with every recipient `disabled`, so the UI reads `smtp_configured` before claiming a
send. The delivery is audited — a file leaving the system is what an audit log is for.

**Not shipped, deliberately: the SCHEDULED half.** There is no scheduler of any kind in this tree,
so choosing in-process versus external cron is a deployment decision, not a wiring task.

## v0.3.1143 (2026-09-01) — SCALE-SEAM ㉝, Last-Planner onto schedule.ts

Six methods out of `client.ts` into the existing `apps/web/src/api/schedule.ts` mixin
Expand Down
Empty file modified apps/web/public/wasm/web-ifc-mt.wasm
100644 → 100755
Empty file.
Empty file modified apps/web/public/wasm/web-ifc.wasm
100644 → 100755
Empty file.
10 changes: 10 additions & 0 deletions apps/web/src/api/routines.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,5 +115,15 @@ export function withRoutines<TBase extends Ctor<HttpCore>>(Base: TBase) {
jobArtifactUrl(pid: string, jobId: string): string {
return this.url(`/projects/${pid}/jobs/${jobId}/artifact`);
}
/** R24-REPORTS-BY-MOMENT — mail a finished job's artifact to recipients: the "shared, not just
* downloaded" half. Same refusals as the artifact URL above (404 / 409 while running / 404 with
* no artifact), plus 422 on no recipients and 413 over the 15 MB cap. On a deployment with no
* SMTP configured this SUCCEEDS with every recipient reported `disabled` — check
* `smtp_configured` before telling the user it was sent. */
deliverJobArtifact(pid: string, jobId: string, to: string[], note = "") {
return this.json<{ smtp_configured: boolean; filename: string; bytes: number;
results: Record<string, string[]> }>(
`/projects/${pid}/jobs/${jobId}/deliver`, { method: "POST", body: JSON.stringify({ to, note }) });
}
};
}
2 changes: 1 addition & 1 deletion apps/web/src/api/surface.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ describe("the API client's public surface", () => {
"escalationsScan", "sendDigest", "notificationStream", // 18 overdue / digest
"reviewModelVersion", "modelVersions", "versionDiff", // 19 publish history
"importClashXlsx", "importClashXml", // 20 clash import
"enqueueJob", "jobs", "jobArtifactUrl", // 21 job tray
"enqueueJob", "jobs", "jobArtifactUrl", "deliverJobArtifact", // 21 job tray
"projects", "createProject", "importBundle", // 22 project catalog
"integrations", "license", "capabilities", // 23 deploy entitle
"siteContext", "parcelAnalyze", "parcelsScreen", // 24 land around site
Expand Down
17 changes: 17 additions & 0 deletions apps/web/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2171,6 +2171,23 @@ const _jobs = _embed ? null : mountJobTray({
host: toolbar,
fetch: () => (projectId ? api.jobs(projectId, 25) : Promise.resolve([])),
artifactUrl: (j) => api.jobArtifactUrl(projectId!, j.id),
// R24-REPORTS-BY-MOMENT — "shared, not just downloaded". `prompt` rather than a modal on purpose:
// the recipient list is the whole input, and a dialog for one text field is chrome. An
// unconfigured deployment answers 200 with every recipient `disabled`, which is why the notice
// below reads `smtp_configured` instead of assuming a 200 means the mail went.
onSend: (j) => {
const to = window.prompt("Email this artifact to (comma-separated addresses):", "");
if (to === null) return;
const addrs = to.split(",").map((a) => a.trim()).filter(Boolean);
if (!addrs.length) { notify("No recipients — nothing sent.", "error"); return; }
void api.deliverJobArtifact(projectId!, j.id, addrs)
.then((r) => notify(
r.smtp_configured
? `${r.filename} sent to ${(r.results.sent ?? []).length} of ${addrs.length}`
: "Email is not configured on this server — nothing was sent.",
r.smtp_configured && (r.results.sent ?? []).length ? "success" : "error"))
.catch((e: Error) => notify(`Send failed — ${e.message}`, "error"));
},
// The completion notice is the point of the tray: it is what makes leaving safe.
onSettled: (j) => notify(
j.state === "error" ? `${jobLabel(j.kind)} failed — ${j.error ?? "no detail"}` : `${jobLabel(j.kind)} finished`,
Expand Down
28 changes: 28 additions & 0 deletions apps/web/src/ui/jobTray.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,4 +305,32 @@ describe("the tray is actually reachable", () => {
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
expect(btn().getAttribute("aria-expanded")).toBe("false");
});

/**
* R24-REPORTS-BY-MOMENT — the send affordance is gated on there being a file, exactly like the
* download link beside it. Asserted rather than assumed because the two gates are written
* separately, and a "Send" on a running job offers to mail something that does not exist yet.
*/
it("offers Send only on rows that actually have an artifact", () => {
const host = document.createElement("div");
const sent: string[] = [];
renderJobTray(host, [
J({ id: "running", state: "running" }),
J({ id: "noart", state: "done", result: {} }),
J({ id: "ready", state: "done", result: { artifact_key: "k" } }),
], { onSend: (j) => sent.push(j.id) });

const buttons = [...host.querySelectorAll("button")].filter((b) => b.textContent === "Send");
expect(buttons.length).toBe(1);
buttons[0]!.click();
expect(sent).toEqual(["ready"]);
});

/** Omitting `onSend` must offer no button at all — the same contract `artifactUrl` already has,
* so a host that cannot deliver does not show a control that would throw. */
it("offers no Send affordance when onSend is omitted", () => {
const host = document.createElement("div");
renderJobTray(host, [J({ state: "done", result: { artifact_key: "k" } })], {});
expect([...host.querySelectorAll("button")].some((b) => b.textContent === "Send")).toBe(false);
});
});
20 changes: 20 additions & 0 deletions apps/web/src/ui/jobTray.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,9 @@ const STATE_COLOR: Record<JobState, string> = {
export interface JobTrayOpts {
/** Absolute href for a finished job's artifact. Omitted → no download affordance is offered. */
artifactUrl?: (j: Job) => string;
/** R24-REPORTS-BY-MOMENT — mail a finished artifact to recipients ("shared, not just
* downloaded"). Omitted → no send affordance, exactly like `artifactUrl`. */
onSend?: (j: Job) => void;
/** Remove a finished/failed row from view. Client-side only — the server keeps its history. */
onDismiss?: (j: Job) => void;
}
Expand Down Expand Up @@ -206,6 +209,20 @@ export function renderJobTray(host: HTMLElement, jobs: readonly Job[], opts: Job
row.appendChild(a);
}

// Sending sits beside downloading because they answer the same question — "the pack is ready,
// now what" — and a report pack that can only be downloaded still has to be forwarded by hand.
// Gated on `hasArtifact` for the same reason the link is: there is nothing to send until there
// is a file.
if (opts.onSend && hasArtifact(j)) {
const b = document.createElement("button");
b.type = "button";
b.textContent = "Send";
b.title = "Email this artifact to recipients";
b.style.cssText = "font-size:11px;flex:0 0 auto";
b.onclick = () => opts.onSend!(j);
row.appendChild(b);
}

// Only finished rows can be dismissed. Hiding a running job would leave work in flight with no
// way back to it, which is the exact failure the tray exists to fix.
if (opts.onDismiss && !isActive(j)) {
Expand Down Expand Up @@ -312,6 +329,8 @@ export function mountJobTray(opts: {
host: HTMLElement;
fetch: () => Promise<Job[]>;
artifactUrl?: (j: Job) => string;
/** R24-REPORTS-BY-MOMENT — see JobTrayOpts.onSend. Passed straight through to each row. */
onSend?: (j: Job) => void;
onSettled?: (j: Job) => void;
/**
* R24-RUNS-INBOX — open the run history. A footer row rather than a header button, because the
Expand Down Expand Up @@ -359,6 +378,7 @@ export function mountJobTray(opts: {
if (!panel.hidden) {
renderJobTray(panel, shown, {
artifactUrl: opts.artifactUrl,
onSend: opts.onSend,
onDismiss: (j) => { dismissed.add(j.id); draw(); },
});
if (opts.onHistory) {
Expand Down
18 changes: 14 additions & 4 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -2062,10 +2062,20 @@ refute one, so this goes first even though it is the least visible.
heading below. `reportMoments.test.ts` reads `reports.py` and fails the build if a package names an
id the server no longer defines; without that, a renamed report shortens a package silently on the
Friday it is due.
**Still open: "scheduled and shared, not just downloaded."** Assemble is a job
(`report_package` in `services/api/src/aec_api/jobs.py`, **Assemble** in `apps/web/src/reportCenter.ts`).
Making it a *scheduled deliverable* — sent to a recipient on a date — still wants a delivery surface
and SMTP. The Job row is already the record that a pack ran.
**SHARED shipped; SCHEDULED still open — and the blocker was never the one written here.** This
entry said making a pack a scheduled deliverable "still wants a delivery surface and SMTP".
**Both already existed** when that was written: `services/api/src/aec_api/mailer.py` sends real mail
(stdlib `smtplib`, a Settings "Test connection" button), and `POST …/notifications/digest` is a
working assemble-then-send surface returning a per-recipient status map. What was actually missing
was one size smaller — **the mailer could not carry a file**. `POST …/jobs/{job_id}/deliver` now
mails any finished job's artifact (`services/api/test_artifact_deliver.py`), surfaced as **Send**
beside **Download** in the job tray. *Naming the blocker one layer too high is what let it sit: the
two named things were present, so every look confirmed the entry and nobody checked the layer below.*
**What genuinely remains is SCHEDULED, and it needs a runner.** There is no scheduler of any kind in
this tree — no APScheduler, no croniter, no cron — so the existing digest is admin-triggered and
nothing runs on a date. Choosing in-process versus external cron hitting an endpoint is a
**deployment decision with different operational consequences, not a wiring task**, which is why it
is not taken here. The Job row is already the record that a pack ran.
- **R24-TERMS** *(S)* — the remaining long tail (element/component and estimate/budget/cost pairs
are a user decision; storey/floor settled v0.3.945).

Expand Down
Loading