diff --git a/CHANGELOG.md b/CHANGELOG.md index df510061..b7230240 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,23 @@ The dashboard's **What's New** rail reads this file at request time entries. Keep entries terse — one line per item under each version date, grouped by `Added` / `Changed` / `Fixed` / `Removed`. +## [2026-07-16] — v0.26.0 · Artifact Studio and trustworthy completion + +### Added + +- **Artifact Studio ships collaborative, shareable deliverables.** Teams can create and revise rich artifacts, manage revisions and previews, attach source assets, publish token-scoped read-only shares, and govern workspace defaults and API-key access. +- **Completion recommendations expose structured verification state.** Operators can inspect ready, blocked, verifying, unavailable, and stale evidence with per-fact status, observation and retry times, diagnostics, and source links. +- **Authorized operators can deliberately override incomplete verification.** A destructive **Mark done anyway** path lists unresolved reasons and records the structured assessment plus override decision in the issue audit trail. + +### Changed + +- **Completion evidence refreshes when its sources change.** GitHub webhooks, a deduplicated reconciliation job, scheduled sweeps, card visibility, window focus, and a manual action rebuild recommendations from current trusted evidence; realtime invalidation keeps mounted cards synchronized. +- **Automatic completion remains fail-closed.** Only a current `READY` assessment may complete automatically, while stale or unavailable evidence requires explicit operator judgment. + +### Fixed + +- **Webhook retry draining uses BullMQ-safe dedupe keys.** Stable per-attempt job IDs no longer contain BullMQ's reserved colon delimiter, restoring automatic queueing while durable delivery rows remain authoritative in Postgres. + ## [2026-07-16] — v0.25.0 · Native Hermes conversations ### Added diff --git a/DEVLOG.md b/DEVLOG.md index 1b3f19bd..388f3057 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -2,6 +2,24 @@ > Append-only session log. Read at session start. Update at session end. +## 2026-07-16 — v0.26.0 coordinated release preparation + +Release coordination confirmed that AXI-113 merged after v0.25.0 with no other +active release owner. A patch tag would therefore have mislabeled an unreleased +feature, so v0.26.0 is prepared from exact current `main` at +`3ebceb99035a23cd173c6c2bae39c2fd78116e9f`. While release review was running, +AXI-86 merged to `main` as `ae431384a24a6cccc2aa3d8bbfb5364f4098bdc3`; +serialization therefore rebases the release onto that exact newer tip and +includes Artifact Studio alongside AXI-113's structured completion evidence +and AXI-112's live-smoke delivery-drain hotfix. + +Release review found that the What's New feed used only the bracketed calendar +date as its persisted identity, so two same-day releases could collide and the +newer release could incorrectly appear already seen. Changelog entries now +carry a release-unique SemVer identity, account preferences persist the exact +release viewed, legacy users receive one safe unseen notification, and parser +coverage locks in distinct v0.25.0/v0.26.0 behavior on the same date. + ## 2026-07-16 — v0.25.0 live delivery-drain finding The v0.25.0 production smoke found the maintenance delivery drain passing diff --git a/package.json b/package.json index b4c26481..4f9138c3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "forge", - "version": "0.25.0", + "version": "0.26.0", "private": true, "description": "Forge — a fast, minimalist, keyboard-driven project management platform with pluggable agents.", "license": "MIT", diff --git a/prisma/migrations/20260716143000_changelog_seen_release/migration.sql b/prisma/migrations/20260716143000_changelog_seen_release/migration.sql new file mode 100644 index 00000000..92530a21 --- /dev/null +++ b/prisma/migrations/20260716143000_changelog_seen_release/migration.sql @@ -0,0 +1 @@ +ALTER TABLE "User" ADD COLUMN "changelogSeenRelease" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ded621de..55eaca4a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -756,6 +756,9 @@ model User { /// Drives the "unseen entries" dot — when the latest changelog date /// is newer than this, the dot shows. Null = never seen. changelogSeenAt DateTime? + /// Stable release identity last viewed by this user. Unlike the date above, + /// this distinguishes multiple releases shipped on the same calendar day. + changelogSeenRelease String? /// Instance-wide role. Default MEMBER; the bootstrap operator (matching /// `ADMIN_EMAIL`) is promoted to INSTANCE_ADMIN on sign-in and by the /// backfill script. Gates the /admin shell and global AgentProfile diff --git a/src/app/(app)/w/[slug]/dashboard/page.tsx b/src/app/(app)/w/[slug]/dashboard/page.tsx index cbc62c7e..9510baf1 100644 --- a/src/app/(app)/w/[slug]/dashboard/page.tsx +++ b/src/app/(app)/w/[slug]/dashboard/page.tsx @@ -228,7 +228,13 @@ export default function DashboardPage() { id: "whats-new", title: "What's new", defaultWidth: "half", - node: , + node: ( + + ), }, ...(!showPrimarySuggestions ? [ @@ -266,6 +272,7 @@ export default function DashboardPage() { }); }, [ account?.changelogSeenAt, + account?.changelogSeenRelease, flowBreakpoint, showPrimarySuggestions, slug, diff --git a/src/app/(app)/w/[slug]/whats-new/page.tsx b/src/app/(app)/w/[slug]/whats-new/page.tsx index f5732580..6197e9c6 100644 --- a/src/app/(app)/w/[slug]/whats-new/page.tsx +++ b/src/app/(app)/w/[slug]/whats-new/page.tsx @@ -16,6 +16,7 @@ import { cn } from "@/lib/utils"; */ export default function WhatsNewPage() { const { data, isLoading } = trpc.system.changelogFull.useQuery(); + const latestReleaseId = data?.entries[0]?.id; // Opening this page is the "I've seen the changes" signal — stamp it // once on mount so the dashboard tile's unseen dot clears. Invalidate @@ -25,9 +26,9 @@ export default function WhatsNewPage() { onSuccess: () => utils.user.me.invalidate(), }); useEffect(() => { - markSeen.mutate(); + if (latestReleaseId) markSeen.mutate({ releaseId: latestReleaseId }); // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [latestReleaseId]); return ( <> @@ -57,11 +58,9 @@ export default function WhatsNewPage() { ) : ( data.entries.map((entry) => ( -
+
-

- {entry.heading} -

+

{entry.heading}

{entry.items.length === 0 ? (
diff --git a/src/components/dashboard/whats-new-tile.tsx b/src/components/dashboard/whats-new-tile.tsx index d8ddc7f6..00565fcf 100644 --- a/src/components/dashboard/whats-new-tile.tsx +++ b/src/components/dashboard/whats-new-tile.tsx @@ -3,6 +3,7 @@ import Link from "next/link"; import { Sparkles } from "lucide-react"; import { trpc } from "@/lib/trpc"; import { cn } from "@/lib/utils"; +import { hasUnseenChangelog } from "@/lib/changelog"; /** * What's New tile — a compact list of the most recent CHANGELOG @@ -18,17 +19,23 @@ import { cn } from "@/lib/utils"; export function WhatsNewTile({ slug, seenAt = null, + seenRelease = null, }: { slug: string; /** User's `changelogSeenAt`. When the latest dated entry is newer (or * this is null), an "unseen" dot shows until they open /whats-new. */ seenAt?: string | Date | null; + seenRelease?: string | null; }) { const { data, isLoading } = trpc.system.changelog.useQuery({ limit: 5 }); // Newest dated entry vs. last-seen timestamp → "unseen" indicator. - const latestDate = data?.entries.find((e) => e.date)?.date ?? null; - const hasUnseen = !!latestDate && (!seenAt || new Date(latestDate) > new Date(seenAt)); + const latest = data?.entries.find((entry) => entry.date) ?? null; + // Release identity is authoritative. A null value after this migration is + // intentionally unseen once, including for users who viewed an earlier + // release on the same day; the timestamp remains a legacy fallback only + // for old changelog entries without a release identity. + const hasUnseen = hasUnseenChangelog(latest, seenRelease, seenAt); if (isLoading || !data) { return ( @@ -84,9 +91,9 @@ export function WhatsNewTile({ {rest.length > 0 && (
    - {rest.slice(0, 3).map((entry, i) => ( + {rest.slice(0, 3).map((entry) => (
  • { - markSeen.mutate(undefined, { - onSuccess: () => { - void utils.system.changelog.invalidate(); + if (!latestReleaseId) return; + markSeen.mutate( + { releaseId: latestReleaseId }, + { + onSuccess: () => { + void utils.system.changelog.invalidate(); + void utils.user.me.invalidate(); + }, }, - }); + ); // once on mount // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [latestReleaseId]); const entries = data?.entries ?? []; @@ -44,7 +55,10 @@ export function WhatsNewContent() {
) : entries.length === 0 ? ( - + ) : (
{entries.map((entry, i) => { @@ -52,13 +66,16 @@ export function WhatsNewContent() { // name (version + title) lives after the em-dash; the bracket date // is the timestamp. Bare `## [YYYY-MM-DD]` falls back to the date. const dash = entry.heading?.indexOf(" — ") ?? -1; - const title = dash >= 0 ? entry.heading.slice(dash + 3).trim() : entry.date ?? entry.version; + const title = + dash >= 0 ? entry.heading.slice(dash + 3).trim() : (entry.date ?? entry.version); return ( -
+

{title}

{entry.date && title !== entry.date && ( - {entry.date} + + {entry.date} + )} {i === 0 && ( @@ -73,14 +90,22 @@ export function WhatsNewContent() { const Ico = g.icon; return (
-
+
{g.label}
    {items.map((it, j) => (
  • - + {it.text}
  • ))} diff --git a/src/lib/changelog.ts b/src/lib/changelog.ts new file mode 100644 index 00000000..939fb32c --- /dev/null +++ b/src/lib/changelog.ts @@ -0,0 +1,9 @@ +export function hasUnseenChangelog( + latest: { id: string; release: string | null; date: string | null } | null, + seenRelease: string | null, + seenAt: string | Date | null, +): boolean { + if (!latest) return false; + if (latest.release) return latest.id !== seenRelease; + return !!latest.date && (!seenAt || new Date(latest.date) > new Date(seenAt)); +} diff --git a/src/server/routers/system.ts b/src/server/routers/system.ts index b41df14b..479437f2 100644 --- a/src/server/routers/system.ts +++ b/src/server/routers/system.ts @@ -4,6 +4,7 @@ import { promises as fs } from "node:fs"; import path from "node:path"; import { router, protectedProcedure } from "@/server/trpc"; import { forgeBuildIdentity } from "@/server/build-info"; +import { parseChangelog, type ChangelogEntry } from "@/server/services/changelog-parser"; /** * System-wide read-only data — currently just the parsed CHANGELOG.md @@ -17,22 +18,6 @@ import { forgeBuildIdentity } from "@/server/build-info"; * changes on disk, e.g. a hot-reload during dev). No DB column. */ -interface ChangelogItem { - type: "added" | "changed" | "fixed" | "removed"; - text: string; -} - -interface ChangelogEntry { - version: string; - date: string | null; - /** Original `## [version] — heading-tail` so the page can show - * the full release name as a heading. */ - heading: string; - items: ChangelogItem[]; - /** Raw markdown body for `whats-new/page.tsx` to render. */ - raw: string; -} - interface CachedChangelog { mtimeMs: number; entries: ChangelogEntry[]; @@ -71,74 +56,6 @@ async function readChangelog(): Promise { * raw section is preserved on the entry for the whats-new page to * render with markdown. */ -function parseChangelog(raw: string): ChangelogEntry[] { - const lines = raw.split(/\r?\n/); - const entries: ChangelogEntry[] = []; - let cur: ChangelogEntry | null = null; - let curRaw: string[] = []; - let curSubtype: ChangelogItem["type"] | null = null; - - const flush = () => { - if (cur) { - cur.raw = curRaw.join("\n").trim(); - entries.push(cur); - } - }; - - for (const line of lines) { - if (line.startsWith("## ")) { - const heading = line.slice(3).trim(); - const bracketMatch = heading.match(/^\[([^\]]+)\](?:\s+[—-]\s+(.+))?$/); - const plainMatch = bracketMatch ? null : heading.match(/^(.+?)(?:\s+[—-]\s+(.+))?$/); - const version = (bracketMatch?.[1] ?? plainMatch?.[1])?.trim(); - if (!version) continue; - flush(); - const tail = (bracketMatch?.[2] ?? plainMatch?.[2])?.trim() ?? ""; - // Heuristic: a leading ISO date in version (e.g. `2026-05-04`) - // counts as the date; otherwise keep null. - const dateMatch = version.match(/^\d{4}-\d{2}-\d{2}$/); - cur = { - version, - date: dateMatch ? version : null, - heading: tail ? `${version} — ${tail}` : version, - items: [], - raw: "", - }; - curRaw = []; - curSubtype = null; - continue; - } - - if (!cur) continue; - curRaw.push(line); - - const subMatch = line.match(/^###\s+(Added|Changed|Fixed|Removed)\b/i); - if (subMatch) { - curSubtype = subMatch[1].toLowerCase() as ChangelogItem["type"]; - continue; - } - - const bulletMatch = line.match(/^\s*-\s+(.+?)\s*$/); - if (bulletMatch && curSubtype) { - // First-line text only; if the bullet wraps to subsequent - // lines we'll lose the wrap detail — that's fine for the rail - // (the whats-new page renders the raw body anyway). - const text = bulletMatch[1] - // Strip simple emphasis so the rail stays compact. - .replace(/\*\*([^*]+)\*\*/g, "$1") - .replace(/`([^`]+)`/g, "$1"); - cur.items.push({ type: curSubtype, text }); - } - } - - flush(); - // Drop the "Unreleased" section from the structured entries: it's not a - // shipped release, so it shouldn't head the What's New rail or be counted - // as the latest version (buildInfo.release already filters by date). Its - // content still appears in the full raw body on the /whats-new page. - return entries.filter((e) => e.version.toLowerCase() !== "unreleased"); -} - export const systemRouter = router({ changelog: protectedProcedure .input( @@ -165,15 +82,15 @@ export const systemRouter = router({ /** * Build identity — the precise "what's running" anchor for support + "is my * fix live?". `gitSha` + `buildTime` are baked at `docker compose build` - * (deploy ritual); `release` is the latest curated CHANGELOG date (the - * human-facing version); `version` is the coarse package.json major. + * (deploy ritual); `release` is the latest curated CHANGELOG SemVer; + * `version` is the package.json version. */ buildInfo: protectedProcedure.query(async () => { const cache = await readChangelog(); const build = await forgeBuildIdentity(); return { ...build, - release: cache.entries.find((e) => e.date)?.version ?? null, + release: cache.entries.find((e) => e.date)?.release ?? null, }; }), }); diff --git a/src/server/routers/user.ts b/src/server/routers/user.ts index 118aa047..c7a961d1 100644 --- a/src/server/routers/user.ts +++ b/src/server/routers/user.ts @@ -33,6 +33,7 @@ const ME_SELECT = { dashboardView: true, dashboardPrefs: true, changelogSeenAt: true, + changelogSeenRelease: true, onboardingDismissedAt: true, onboardingSkippedSteps: true, pomodoroEnabled: true, @@ -112,14 +113,16 @@ export const userRouter = router({ }); }), - /** Stamp the changelog as seen now (clears the What's New "unseen" dot). */ - markChangelogSeen: protectedProcedure.mutation(async ({ ctx }) => { - return ctx.db.user.update({ - where: { id: ctx.session.user.id }, - data: { changelogSeenAt: new Date() }, - select: ME_SELECT, - }); - }), + /** Persist the exact release viewed so same-day releases remain distinguishable. */ + markChangelogSeen: protectedProcedure + .input(z.object({ releaseId: z.string().trim().min(1).max(128) })) + .mutation(async ({ ctx, input }) => { + return ctx.db.user.update({ + where: { id: ctx.session.user.id }, + data: { changelogSeenAt: new Date(), changelogSeenRelease: input.releaseId }, + select: ME_SELECT, + }); + }), /** * Per-user Mission Control preferences. Default-tab can be either diff --git a/src/server/services/changelog-parser.ts b/src/server/services/changelog-parser.ts new file mode 100644 index 00000000..ab1a09a9 --- /dev/null +++ b/src/server/services/changelog-parser.ts @@ -0,0 +1,76 @@ +export interface ChangelogItem { + type: "added" | "changed" | "fixed" | "removed"; + text: string; +} + +export interface ChangelogEntry { + /** Stable, release-unique identity used for persistence and React keys. */ + id: string; + version: string; + date: string | null; + release: string | null; + heading: string; + items: ChangelogItem[]; + raw: string; +} + +/** Parse the repository's Keep-a-Changelog release sections. */ +export function parseChangelog(raw: string): ChangelogEntry[] { + const lines = raw.split(/\r?\n/); + const entries: ChangelogEntry[] = []; + let cur: ChangelogEntry | null = null; + let curRaw: string[] = []; + let curSubtype: ChangelogItem["type"] | null = null; + + const flush = () => { + if (cur) { + cur.raw = curRaw.join("\n").trim(); + entries.push(cur); + } + }; + + for (const line of lines) { + if (line.startsWith("## ")) { + const heading = line.slice(3).trim(); + const bracketMatch = heading.match(/^\[([^\]]+)\](?:\s+[—-]\s+(.+))?$/); + const plainMatch = bracketMatch ? null : heading.match(/^(.+?)(?:\s+[—-]\s+(.+))?$/); + const version = (bracketMatch?.[1] ?? plainMatch?.[1])?.trim(); + if (!version) continue; + flush(); + const tail = (bracketMatch?.[2] ?? plainMatch?.[2])?.trim() ?? ""; + const date = /^\d{4}-\d{2}-\d{2}$/.test(version) ? version : null; + const release = tail.match(/\bv\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?\b/)?.[0] ?? null; + const fullHeading = tail ? `${version} — ${tail}` : version; + cur = { + id: release ?? `${version}:${fullHeading}`, + version, + date, + release, + heading: fullHeading, + items: [], + raw: "", + }; + curRaw = []; + curSubtype = null; + continue; + } + + if (!cur) continue; + curRaw.push(line); + const subMatch = line.match(/^###\s+(Added|Changed|Fixed|Removed)\b/i); + if (subMatch) { + curSubtype = subMatch[1].toLowerCase() as ChangelogItem["type"]; + continue; + } + const bulletMatch = line.match(/^\s*-\s+(.+?)\s*$/); + if (bulletMatch && curSubtype) { + cur.items.push({ + type: curSubtype, + text: bulletMatch[1].replace(/\*\*([^*]+)\*\*/g, "$1").replace(/`([^`]+)`/g, "$1"), + }); + } + } + + flush(); + return entries.filter((entry) => entry.version.toLowerCase() !== "unreleased"); +} diff --git a/tests/unit/changelog-parser.test.ts b/tests/unit/changelog-parser.test.ts index 0792c413..262d75a9 100644 --- a/tests/unit/changelog-parser.test.ts +++ b/tests/unit/changelog-parser.test.ts @@ -1,80 +1,35 @@ import { describe, expect, it } from "vitest"; import { promises as fs } from "node:fs"; import path from "node:path"; - -/** - * Smoke test for the CHANGELOG.md parser shipped with the - * `system.changelog` tRPC proc. We exercise the parser against the - * real repo file so a malformed entry surfaces early; the proc - * itself is tested by integration tests. - * - * The parser is intentionally not exported from the module (it lives - * inside `system.ts` next to the cache), so we re-implement the - * parse here in a tiny form-pinned shim. If you change the parser - * shape, mirror the change here — the goal is to lock in the - * Keep-a-Changelog format we expect, not test the exact same code - * twice. - */ - -interface Item { - type: "added" | "changed" | "fixed" | "removed"; - text: string; -} - -function parse(raw: string): Array<{ - version: string; - items: Item[]; -}> { - const lines = raw.split(/\r?\n/); - type Entry = { version: string; items: Item[] }; - const entries: Entry[] = []; - let cur: Entry | null = null; - let curSubtype: Item["type"] | null = null; - for (const line of lines) { - if (line.startsWith("## ")) { - const heading = line.slice(3).trim(); - const bracketMatch = heading.match(/^\[([^\]]+)\](?:\s+[—-]\s+(.+))?$/); - const plainMatch = bracketMatch ? null : heading.match(/^(.+?)(?:\s+[—-]\s+(.+))?$/); - const version = (bracketMatch?.[1] ?? plainMatch?.[1])?.trim(); - if (!version) continue; - cur = { version, items: [] }; - entries.push(cur); - curSubtype = null; - continue; - } - if (!cur) continue; - const sub = line.match(/^###\s+(Added|Changed|Fixed|Removed)\b/i); - if (sub) { - curSubtype = sub[1].toLowerCase() as Item["type"]; - continue; - } - const bullet = line.match(/^\s*-\s+(.+?)\s*$/); - if (bullet && curSubtype) { - cur.items.push({ - type: curSubtype, - text: bullet[1].replace(/\*\*([^*]+)\*\*/g, "$1").replace(/`([^`]+)`/g, "$1"), - }); - } - } - // Mirror system.ts: the "Unreleased" section is not a shipped release, so it - // never heads the structured entries (its body still shows in the raw page). - return entries.filter((e) => e.version.toLowerCase() !== "unreleased"); -} +import { parseChangelog } from "@/server/services/changelog-parser"; +import { hasUnseenChangelog } from "@/lib/changelog"; describe("CHANGELOG.md", () => { - it("parses real repo file into at least one entry with structured items", async () => { - const file = path.join(process.cwd(), "CHANGELOG.md"); - const raw = await fs.readFile(file, "utf8"); - const entries = parse(raw); + it("parses the real repo file into structured, uniquely identified releases", async () => { + const raw = await fs.readFile(path.join(process.cwd(), "CHANGELOG.md"), "utf8"); + const entries = parseChangelog(raw); expect(entries.length).toBeGreaterThan(0); expect(entries[0]?.version).toMatch(/^\d{4}-\d{2}-\d{2}$/); - const totalItems = entries.reduce((n, e) => n + e.items.length, 0); - expect(totalItems).toBeGreaterThan(0); - // Every parsed item has a known type bucket. - for (const e of entries) { - for (const item of e.items) { - expect(["added", "changed", "fixed", "removed"]).toContain(item.type); - } - } + expect(entries[0]?.release).toMatch(/^v\d+\.\d+\.\d+/); + expect(new Set(entries.map((entry) => entry.id)).size).toBe(entries.length); + expect(entries.reduce((count, entry) => count + entry.items.length, 0)).toBeGreaterThan(0); + }); + + it("distinguishes two releases shipped on the same date", () => { + const entries = parseChangelog(` +## [2026-07-16] — v0.26.0 · Newer +### Fixed +- New fix + +## [2026-07-16] — v0.25.0 · Older +### Added +- Old feature +`); + expect(entries.map((entry) => entry.id)).toEqual(["v0.26.0", "v0.25.0"]); + expect(entries[0]?.date).toBe(entries[1]?.date); + expect(hasUnseenChangelog(entries[0]!, entries[1]!.id, new Date("2026-07-16T20:00:00Z"))).toBe( + true, + ); + expect(hasUnseenChangelog(entries[0]!, entries[0]!.id, null)).toBe(false); }); });