From 7802eff4cd72efd1d73c7851040a7196af4a0835 Mon Sep 17 00:00:00 2001 From: Erik Thorelli Date: Wed, 7 Oct 2026 15:08:13 -0700 Subject: [PATCH 1/2] Say whose an evening is in the contract, list only our evenings' speakers, and hold the CLI to core's contract --- .github/workflows/cli.yaml | 11 --- .github/workflows/core.yaml | 4 + cli/src/format.ts | 19 ++++- cli/src/schemas.ts | 8 +- cli/tests/cli.test.ts | 27 ++++++ cli/tests/contract.test.ts | 31 ------- cli/tests/fixtures.ts | 2 + core/README.md | 22 +++-- core/src/contract.ts | 22 ++++- core/src/errors.ts | 4 +- core/src/events.ts | 10 ++- core/src/mappers.ts | 8 ++ core/src/rows.ts | 50 +++++------ core/src/speakers.ts | 8 +- core/tests/contract.test.ts | 76 +++++++++++++---- core/tests/mappers.test.ts | 1 + core/tests/parity.test.ts | 36 ++++++-- core/tests/repositories.test.ts | 1 + .../skills/evening-briefing/SKILL.md | 1 + .../allthings/skills/find-evenings/SKILL.md | 2 +- web/src/v1/data.ts | 2 +- web/tests/parity.test.ts | 82 ++++++++++++------- web/tests/worker.test.ts | 81 ++++++++++++++++-- 23 files changed, 358 insertions(+), 150 deletions(-) delete mode 100644 cli/tests/contract.test.ts diff --git a/.github/workflows/cli.yaml b/.github/workflows/cli.yaml index 9cd7bc7e..a8d99944 100644 --- a/.github/workflows/cli.yaml +++ b/.github/workflows/cli.yaml @@ -5,18 +5,12 @@ on: branches: ["main"] paths: - "cli/**" - - "app/src/lib/public-api/**" - - "app/package.json" - - "app/bun.lock" - ".github/workflows/cli.yaml" - ".github/scripts/bun-install.sh" pull_request: types: [opened, synchronize, reopened] paths: - "cli/**" - - "app/src/lib/public-api/**" - - "app/package.json" - - "app/bun.lock" - ".github/workflows/cli.yaml" - ".github/scripts/bun-install.sh" @@ -38,11 +32,6 @@ jobs: with: bun-version: 1.3.14 - # The contract test compares the CLI's schemas with the app's. - - name: 馃摜 Download app deps - working-directory: app - run: bash "$GITHUB_WORKSPACE/.github/scripts/bun-install.sh" - - name: 馃摜 Download CLI deps working-directory: cli run: bash "$GITHUB_WORKSPACE/.github/scripts/bun-install.sh" diff --git a/.github/workflows/core.yaml b/.github/workflows/core.yaml index 1ca11ccd..af1636a5 100644 --- a/.github/workflows/core.yaml +++ b/.github/workflows/core.yaml @@ -5,6 +5,8 @@ on: branches: ["main"] paths: - "core/**" + # tests/contract.test.ts holds the CLI's copy of the contract to core's. + - "cli/src/**" - "docs/event-formats.md" - "infra/scripts/site-sync.ts" - "infra/scripts/login-role.ts" @@ -20,6 +22,8 @@ on: types: [opened, synchronize, reopened] paths: - "core/**" + # tests/contract.test.ts holds the CLI's copy of the contract to core's. + - "cli/src/**" - "docs/event-formats.md" - "infra/scripts/site-sync.ts" - "infra/scripts/login-role.ts" diff --git a/cli/src/format.ts b/cli/src/format.ts index 675d1705..a2be4e61 100644 --- a/cli/src/format.ts +++ b/cli/src/format.ts @@ -8,6 +8,17 @@ export function createStyler(color: boolean) { color ? styleText(style, text, { validateStream: false }) : text; } +/** + * "shared 路 by Mastra" for an evening we share, as the site marks it, or + * "shared" when who organizes it isn't said. + */ +function sharedBy(event: EventSummary): string | null { + if (event.curation !== "shared") return null; + return event.organizer === null + ? "shared" + : `shared 路 by ${event.organizer.name}`; +} + function formatWhere(event: EventSummary): string | null { return event.venue?.name ?? event.venue?.address ?? null; } @@ -48,8 +59,9 @@ export function formatEventList( ? "" : ` ${style("yellow", `[${event.status}]`)}`; const where = formatWhere(event); + const shared = sharedBy(event); return [ - `${style("bold", event.name)}${status}`, + `${style("bold", event.name)}${status}${shared ? ` ${style("dim", shared)}` : ""}`, ` ${withTimeZone(event)}${where ? ` 路 ${where}` : ""}`, event.rsvpUrl && event.status !== "past" ? ` ${style("dim", "rsvp")} ${event.rsvpUrl}` @@ -65,9 +77,11 @@ export function formatEventList( export function formatEvent(event: Event, color: boolean): string { const style = createStyler(color); + const shared = sharedBy(event); const lines = [ style("bold", event.name), event.tagline, + ...(shared === null ? [] : [style("dim", shared)]), "", `${style("dim", "when ")} ${withTimeZone(event)}`, ]; @@ -81,6 +95,9 @@ export function formatEvent(event: Event, color: boolean): string { lines.push(`${style("dim", "video")} ${event.recordingUrl}`); } lines.push(`${style("dim", "page ")} ${event.url}`); + if (event.organizer?.url) { + lines.push(`${style("dim", "by ")} ${event.organizer.url}`); + } for (const talk of event.talks) { lines.push("", style("bold", talk.title)); const speakers = talk.speakers diff --git a/cli/src/schemas.ts b/cli/src/schemas.ts index 1577f53f..ea410996 100644 --- a/cli/src/schemas.ts +++ b/cli/src/schemas.ts @@ -2,7 +2,9 @@ import { z } from "zod"; /** * The public allthings contract, as served by https://allthings.dev/mcp. - * Mirrors app/src/lib/public-api/schemas.ts; a contract test keeps them equal. + * Mirrors core's (core/src/contract.ts), the one source of it; + * core/tests/contract.test.ts holds the two to the same JSON Schema and the + * same verdicts. */ export const eventStatusSchema = z.enum(["upcoming", "live", "past"]); @@ -28,6 +30,10 @@ export const eventSummarySchema = z.object({ rsvpUrl: z.httpUrl().nullable(), recordingUrl: z.httpUrl().nullable(), isHackathon: z.boolean(), + curation: z.enum(["ours", "shared"]), + organizer: z + .object({ name: z.string(), url: z.httpUrl().nullable() }) + .nullable(), }); export const talkSpeakerSchema = z.object({ diff --git a/cli/tests/cli.test.ts b/cli/tests/cli.test.ts index fe8fa023..6dd33b0a 100644 --- a/cli/tests/cli.test.ts +++ b/cli/tests/cli.test.ts @@ -86,6 +86,33 @@ describe("allthings", () => { expect(stdout).toContain("rsvp https://lu.ma/event/evt-1"); }); + test("marks an evening we share with who organizes it", async () => { + const shared = { + ...summary, + curation: "shared" as const, + organizer: { name: "Mastra", url: "https://mastra.ai/" }, + }; + const client = fakeClient({ + listEvents: async () => [shared], + getEvent: async () => ({ ...event, ...shared }), + }); + expect((await exec(["events"], { client })).stdout).toContain( + "Effect San Francisco [upcoming] shared 路 by Mastra", + ); + const { stdout } = await exec(["event", summary.slug], { client }); + expect(stdout).toContain("shared 路 by Mastra"); + expect(stdout).toContain("by https://mastra.ai/"); + // Ours says nothing of it. + expect((await exec(["events"])).stdout).not.toContain("shared"); + // Shared without a named organizer still says so. + const unnamed = fakeClient({ + listEvents: async () => [{ ...shared, organizer: null }], + }); + expect((await exec(["events"], { client: unnamed })).stdout).toContain( + "Effect San Francisco [upcoming] shared\n", + ); + }); + test("--json prints exactly the public contract with no color", async () => { const { stdout } = await exec(["event", summary.slug, "--json"], { isTTY: true, diff --git a/cli/tests/contract.test.ts b/cli/tests/contract.test.ts deleted file mode 100644 index 275bc7bd..00000000 --- a/cli/tests/contract.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { z } from "zod"; -import * as server from "../../app/src/lib/public-api/schemas.ts"; -import { eventNotFoundMessage } from "../../app/src/lib/public-api/errors.ts"; -import { isEventNotFound } from "../src/client.ts"; -import * as cli from "../src/schemas.ts"; - -/** Descriptions are documentation for agents; the shapes must match exactly. */ -function shape(schema: unknown): unknown { - const json = z.toJSONSchema(schema as z.ZodType); - return JSON.parse( - JSON.stringify(json, (key, value) => - key === "description" ? undefined : value, - ), - ); -} - -describe("public contract", () => { - test.each([ - ["eventSummarySchema"], - ["eventSchema"], - ["speakerSchema"], - ["communitySchema"], - ] as const)("the CLI's %s matches the server's", (name) => { - expect(shape(cli[name])).toEqual(shape(server[name])); - }); - - test("the CLI recognizes the server's event-not-found message", () => { - expect(isEventNotFound(eventNotFoundMessage("no-such-event"))).toBe(true); - }); -}); diff --git a/cli/tests/fixtures.ts b/cli/tests/fixtures.ts index a549153d..50cc3681 100644 --- a/cli/tests/fixtures.ts +++ b/cli/tests/fixtures.ts @@ -19,6 +19,8 @@ export const summary: EventSummary = { rsvpUrl: "https://lu.ma/event/evt-1", recordingUrl: null, isHackathon: false, + curation: "ours", + organizer: null, }; export const event: Event = { diff --git a/core/README.md b/core/README.md index af2e6789..9e82247b 100644 --- a/core/README.md +++ b/core/README.md @@ -38,7 +38,7 @@ statement from it. `latestAppearanceFirst`). /people is our evenings' at any time; a person's page, every evening's; /about counts ours once over; the sitemap lists everyone at any; the speakers list (`list_speakers`, - `/api/v1/speakers`) counts any evening's talks once `ended`. + `/api/v1/speakers`) counts our evenings' talks once `ended`. Each surface still reads in one statement, since from a Worker every round trip costs more than the query, and builds it from these fragments. What a @@ -64,11 +64,14 @@ seeded database at one instant: the same evenings in the same order from `list_events`, `/api/v1/events`, the feed, /events and home; the same lineup from `get_event`, `/api/v1/events/:id` and the event page, by every link; the same people and talks from `list_speakers`, `/api/v1/speakers`, -/people, people's pages, /about and the sitemap. Where they disagree today -a test says so (`test.failing`), with what settles it: +/people, people's pages, /about and the sitemap; and whose each evening is, +from `list_events` and /events. -- `list_speakers` and `/api/v1/speakers` list the speakers of evenings we - only share. +The public contract is `src/contract.ts`, the one source of it. The CLI's +zod copy (`cli/src/schemas.ts`) is held to it by `tests/contract.test.ts`, +which the core workflow runs whenever `cli/src` changes. The old app's +schemas and answers are legacy: where tests still compare the Worker with +the app, it is on the fields the app has. ## Luma sync @@ -432,8 +435,13 @@ What follows from it: Home's hero is always our next evening, and its photos are of ours. - Its page says who organizes it, linked to their site, and has no "your hosts". Its structured data names the organizer, not us. -- /about's numbers count our evenings alone, and the people page lists who - was on stage at ours; people from a shared evening are on its page. +- /about's numbers count our evenings alone, and the people page and the + speakers list (`list_speakers`, `/api/v1/speakers`) list who was on + stage at ours; people from a shared evening are on its page. +- The MCP tools say whose an evening is: `curation` (`ours` or `shared`) + and `organizer` (its name and site; null for ours), and the CLI marks a + shared one "shared 路 by Mastra". The v1 API publishes `curation` and + `organizedBy`. - Completeness asks a shared evening for no organizers, MC or topic. - Promotion drafts recommend it, by its organizer, and say why; it gets no Luma description or Meetup listing of ours. diff --git a/core/src/contract.ts b/core/src/contract.ts index f8f94c6c..bb84f1c8 100644 --- a/core/src/contract.ts +++ b/core/src/contract.ts @@ -1,10 +1,11 @@ import { Schema } from "effect"; /** - * The public allthings contract: the shapes served by the MCP server and the - * public API, and consumed by the CLI. These Effect schemas will replace the - * zod ones in app/src/lib/public-api/schemas.ts; until then a contract test - * holds them to the same shapes, the same validation and today's live data. + * The public allthings contract: the shapes served by the MCP server, and + * consumed by the CLI and agents. This is the one source of it: the CLI's + * zod copy (cli/src/schemas.ts) is held to it by tests/contract.test.ts, + * the same JSON Schema and the same verdicts. The app's zod schemas + * (app/src/lib/public-api/schemas.ts) are legacy and no longer a reference. */ // zod's `domain` regex, so hosts are judged exactly as `z.httpUrl()` judges them. @@ -65,6 +66,12 @@ export const PersonLinks = Schema.Struct({ linkedin: Schema.NullOr(HttpUrl), }); +/** Whose evening it is. */ +export const Curation = Schema.Literals(["ours", "shared"]).annotate({ + description: + "Whose evening it is: one of allthings' own (ours), or someone else's evening we share with the community (shared).", +}); + export const EventSummary = Schema.Struct({ slug: Schema.String.annotate({ description: "Stable identifier; pass it to get_event.", @@ -93,6 +100,13 @@ export const EventSummary = Schema.Struct({ }), recordingUrl: Schema.NullOr(HttpUrl), isHackathon: Schema.Boolean, + curation: Curation, + organizer: Schema.NullOr( + Schema.Struct({ name: Schema.String, url: Schema.NullOr(HttpUrl) }), + ).annotate({ + description: + "Who organizes an evening we share, and their site; null for ours.", + }), }); export const TalkSpeaker = Schema.Struct({ diff --git a/core/src/errors.ts b/core/src/errors.ts index a4af22bd..3d1f0674 100644 --- a/core/src/errors.ts +++ b/core/src/errors.ts @@ -16,8 +16,8 @@ export class EventNotFound extends Schema.TaggedError()( { slug: Schema.String }, ) { /** - * The text clients see. The CLI maps it to its "not found" exit code, so it - * must stay identical to app/src/lib/public-api/errors.ts. + * The text clients see. The CLI maps it to its "not found" exit code + * (cli/src/client.ts); tests/contract.test.ts holds the two together. */ override get message(): string { return `No published event has the slug "${this.slug}". Use list_events to find one.`; diff --git a/core/src/events.ts b/core/src/events.ts index 709bef28..8ba7d355 100644 --- a/core/src/events.ts +++ b/core/src/events.ts @@ -12,7 +12,12 @@ import { } from "./catalog.ts"; import { type DataSourceError, EventNotFound } from "./errors.ts"; import * as Rows from "./rows.ts"; -import { imageJson, orDataSourceError, profileJson } from "./sql.ts"; +import { + curationJson, + imageJson, + orDataSourceError, + profileJson, +} from "./sql.ts"; /** * Published events. Drafts never leave this service: they are filtered in SQL, @@ -43,7 +48,8 @@ const make = Effect.gen(function* () { e.street_address AS "streetAddress", e.short_location AS "shortLocation", e.full_address AS "fullAddress", e.luma_event_id AS "lumaEventId", e.recording_url AS "recordingUrl", e.is_hackathon AS "isHackathon", - ${imageJson("e.preview_image")} AS "previewImage"`); + ${imageJson("e.preview_image")} AS "previewImage", + ${curationJson("e")} AS curation`); const listPublished = SqlSchema.findAll({ Request: Schema.Void, diff --git a/core/src/mappers.ts b/core/src/mappers.ts index b6694430..08b3a902 100644 --- a/core/src/mappers.ts +++ b/core/src/mappers.ts @@ -103,6 +103,14 @@ export function toEventSummary( rsvpUrl: rsvpUrl(event.lumaEventId), recordingUrl: httpUrlOrNull(event.recordingUrl), isHackathon: event.isHackathon, + curation: event.curation.kind, + organizer: + event.curation.kind === "shared" + ? { + name: event.curation.organizer.name, + url: httpUrlOrNull(event.curation.organizer.websiteUrl), + } + : null, }; } diff --git a/core/src/rows.ts b/core/src/rows.ts index c1be9e38..65d0cd6d 100644 --- a/core/src/rows.ts +++ b/core/src/rows.ts @@ -20,7 +20,32 @@ export const Image = Schema.Struct({ version: Schema.String, }); -/** A published `events` row with its preview image, if it has one. */ +/** + * Who organizes a shared event: a company (`events.organized_by`, a + * `sponsors` row), with its own site and handles, where pages link out. + */ +export const Organizer = Schema.Struct({ + name: Schema.String, + websiteUrl: Schema.NullOr(Schema.String), + twitterHandle: Schema.NullOr(Schema.String), + blueskyHandle: Schema.NullOr(Schema.String), + linkedinHandle: Schema.NullOr(Schema.String), +}); + +/** + * Whose evening an event is (`events.curation`): ours, or someone else's we + * share with our community because we think it's good, with who organizes + * it. The database holds the two together. + */ +export const Curation = Schema.Union([ + Schema.Struct({ kind: Schema.Literal("ours") }), + Schema.Struct({ kind: Schema.Literal("shared"), organizer: Organizer }), +]); + +/** + * A published `events` row with its preview image, if it has one, and whose + * evening it is. + */ export const Event = Schema.Struct({ id: Schema.String, slug: Schema.String, @@ -35,6 +60,7 @@ export const Event = Schema.Struct({ recordingUrl: Schema.NullOr(Schema.String), isHackathon: Schema.Boolean, previewImage: Schema.NullOr(Image), + curation: Curation, }); /** A `profiles` row as a speaker: who they are and where to find them. */ @@ -63,28 +89,6 @@ export const EventProgram = Schema.Literals([ "hackathon", ]); -/** - * Who organizes a shared event: a company (`events.organized_by`, a - * `sponsors` row), with its own site and handles, where pages link out. - */ -export const Organizer = Schema.Struct({ - name: Schema.String, - websiteUrl: Schema.NullOr(Schema.String), - twitterHandle: Schema.NullOr(Schema.String), - blueskyHandle: Schema.NullOr(Schema.String), - linkedinHandle: Schema.NullOr(Schema.String), -}); - -/** - * Whose evening an event is (`events.curation`): ours, or someone else's we - * share with our community because we think it's good, with who organizes - * it. The database holds the two together. - */ -export const Curation = Schema.Union([ - Schema.Struct({ kind: Schema.Literal("ours") }), - Schema.Struct({ kind: Schema.Literal("shared"), organizer: Organizer }), -]); - /** How a talk is held (`talks.format`): see src/people.ts. */ export const TalkFormat = Schema.Literals(["talk", "panel", "fireside"]); diff --git a/core/src/speakers.ts b/core/src/speakers.ts index 0d16e7be..fd57a915 100644 --- a/core/src/speakers.ts +++ b/core/src/speakers.ts @@ -98,8 +98,10 @@ export const toDirectory = ( const make = Effect.gen(function* () { const sql = yield* SqlClient; - // Talks at any published evening join the directory once it is over, as - // eventStatus has it: not at its last instant, while it is still live. + // Talks at our published evenings join the directory once the evening + // is over, as eventStatus has it: not at its last instant, while it is + // still live. An evening we only share is someone else's: its speakers + // aren't allthings speakers. const findRows = SqlSchema.findAll({ Request: Schema.DateTimeUtcFromDate, Result: Rows.DirectoryRow, @@ -110,7 +112,7 @@ const make = Effect.gen(function* () { e.id AS "eventId", e.name AS "eventName", e.slug AS "eventSlug", e.start_date AS "eventStart" FROM ${talkAppearances(sql, { - whose: "any", + whose: "ours", when: { ended: now }, })} a JOIN profiles p ON p.id = a.profile_id diff --git a/core/tests/contract.test.ts b/core/tests/contract.test.ts index 28b62f03..ed50433c 100644 --- a/core/tests/contract.test.ts +++ b/core/tests/contract.test.ts @@ -1,19 +1,25 @@ import { describe, expect, test } from "bun:test"; import { Schema } from "effect"; import { z } from "zod"; -import * as server from "../../app/src/lib/public-api/schemas.ts"; +import { isEventNotFound } from "../../cli/src/client.ts"; +import * as cli from "../../cli/src/schemas.ts"; import * as core from "../src/contract.ts"; +import { EventNotFound } from "../src/errors.ts"; import community from "./fixtures/community.json"; import eventDetails from "./fixtures/event-details.json"; import events from "./fixtures/events.json"; import speakers from "./fixtures/speakers.json"; /** - * Holds the Effect contract to the zod one the app serves today: the same - * JSON Schema (which is what MCP clients and agents see), the same verdicts on - * valid and invalid values, and lossless decoding of live responses. + * Holds the CLI's zod copy of the contract (cli/src/schemas.ts) to the + * contract itself: the same JSON Schema (which is what MCP clients and + * agents see), the same verdicts on valid and invalid values, and lossless + * decoding of live responses. The app's zod schemas are legacy and no longer + * a reference. * - * The fixtures are live MCP responses captured from allthingsweb.dev. + * The fixtures are live MCP responses captured from allthingsweb.dev + * before events said whose they are; each is read with the fields it lacks + * as they were then, every evening ours. */ type Pair = readonly [ @@ -23,10 +29,10 @@ type Pair = readonly [ ]; const pairs: ReadonlyArray = [ - ["EventSummary", core.EventSummary, server.eventSummarySchema], - ["Event", core.Event, server.eventSchema], - ["Speaker", core.Speaker, server.speakerSchema], - ["Community", core.Community, server.communitySchema], + ["EventSummary", core.EventSummary, cli.eventSummarySchema], + ["Event", core.Event, cli.eventSchema], + ["Speaker", core.Speaker, cli.speakerSchema], + ["Community", core.Community, cli.communitySchema], ]; type Json = null | boolean | number | string | Json[] | { [key: string]: Json }; @@ -87,16 +93,35 @@ function zodVerdict(schema: z.ZodType, input: unknown): Verdict { return result.success ? { ok: true, value: result.data } : { ok: false }; } +/** A captured event with whose it is, as every evening then was: ours. */ +const asOurs = (event: object) => ({ + ...event, + curation: "ours", + organizer: null, +}); + +/** + * `schema` without its descriptions. They are what agents read, and the + * MCP server serves them from this contract; the CLI's copy needs only the + * shapes. + */ +const shape = (schema: Json): Json => + JSON.parse( + JSON.stringify(schema, (key, value: Json) => + key === "description" ? undefined : value, + ), + ); + describe("JSON Schema", () => { - test.each(pairs)("%s matches the served zod schema", (_, effect, zod) => { - expect(effectJsonSchema(effect)).toEqual(zodJsonSchema(zod)); + test.each(pairs)("%s has the CLI's zod schema's shape", (_, effect, zod) => { + expect(shape(effectJsonSchema(effect))).toEqual(shape(zodJsonSchema(zod))); }); }); describe("live responses", () => { const live: ReadonlyArray]> = [ - ["list_events", pairs[0]!, events.events], - ["get_event", pairs[1]!, eventDetails.events], + ["list_events", pairs[0]!, events.events.map(asOurs)], + ["get_event", pairs[1]!, eventDetails.events.map(asOurs)], ["list_speakers", pairs[2]!, speakers.speakers], ["get_community", pairs[3]!, [community]], ]; @@ -116,7 +141,7 @@ describe("live responses", () => { }); describe("verdicts", () => { - const base = events.events[0]!; + const base = asOurs(events.events[0]!); const [, summary, summaryZod] = pairs[0]!; const withField = (key: string, value: unknown) => ({ ...base, @@ -130,6 +155,21 @@ describe("verdicts", () => { ["an unknown extra key", withField("extra", 1)], ["a missing key", without("tagline")], ["a null venue", withField("venue", null)], + ["a shared evening", withField("curation", "shared")], + ["an unknown curation", withField("curation", "theirs")], + [ + "an organizer with a site", + withField("organizer", { name: "Mastra", url: "https://mastra.ai/" }), + ], + [ + "an organizer without a site", + withField("organizer", { name: "Mastra", url: null }), + ], + ["an organizer without a name", withField("organizer", { url: null })], + [ + "an organizer's site that isn't a URL", + withField("organizer", { name: "Mastra", url: "mastra" }), + ], ["a venue with a missing name", withField("venue", { address: null })], ["an unknown status", withField("status", "cancelled")], ["another time zone", withField("timeZone", "UTC")], @@ -192,3 +232,11 @@ describe("verdicts", () => { expect(effectVerdict(summary, withField("url", url)).ok).toBe(false); }); }); + +describe("errors", () => { + test("the CLI recognizes get_event's not-found message", () => { + expect( + isEventNotFound(new EventNotFound({ slug: "no-such-event" }).message), + ).toBe(true); + }); +}); diff --git a/core/tests/mappers.test.ts b/core/tests/mappers.test.ts index b433a252..3b98d3d8 100644 --- a/core/tests/mappers.test.ts +++ b/core/tests/mappers.test.ts @@ -22,6 +22,7 @@ const event: Rows.Event = { recordingUrl: null, isHackathon: false, previewImage: null, + curation: { kind: "ours" }, }; describe("httpUrlOrNull", () => { diff --git a/core/tests/parity.test.ts b/core/tests/parity.test.ts index 8fddbca5..904c93ee 100644 --- a/core/tests/parity.test.ts +++ b/core/tests/parity.test.ts @@ -16,8 +16,14 @@ import { /** * Runs the app's own MCP tools, wired to its own drizzle queries as * app/src/app/mcp/route.ts wires them, and core's repositories and mappers - * against one seeded database, and requires the same answers. This is what - * lets the Worker replace the app without clients noticing. + * against one seeded database, and requires the same answers on what the + * app answers. This is what let the Worker replace the app without clients + * noticing. + * + * The app is legacy and no longer the reference for the contract: it never + * says whose an evening is, and lists speakers by its own rule. Its events + * are compared on every field it has; list_speakers is the contract's alone + * (web/tests/parity.test.ts holds it to the Worker's other surfaces). * * The app's modules are loaded at runtime rather than imported: the app's own * compiler checks them, and core's stricter settings would reject code that is @@ -142,6 +148,18 @@ const ignoringAttachOrder = (event: Contract.Event): Contract.Event => ({ hosts: canonical(event.hosts), }); +/** An event as the app answers it: without whose it is. */ +const theAppsFields = ({ + curation: _curation, + organizer: _organizer, + ...fields +}: E) => fields; + +/** list_events as the app answers it. */ +type AppEventList = { + readonly events: Array>; +}; + afterAll(() => db.close()); describe("core answers as the app's MCP tools do", () => { @@ -159,9 +177,9 @@ describe("core answers as the app's MCP tools do", () => { ), ); expect(summaries.length).toBeGreaterThan(0); - expect(valid(EventList, { events: summaries })).toEqual( - valid(EventList, appResult.structuredContent), - ); + expect( + valid(EventList, { events: summaries }).events.map(theAppsFields), + ).toEqual((appResult.structuredContent as AppEventList).events); }, ); @@ -171,8 +189,8 @@ describe("core answers as the app's MCP tools do", () => { const events = Mappers.selectEvents(rows, "all", now) .slice(0, 2) .map((row) => Mappers.toEventSummary(row, origin, now)); - expect(valid(EventList, { events })).toEqual( - valid(EventList, appResult.structuredContent), + expect(valid(EventList, { events }).events.map(theAppsFields)).toEqual( + (appResult.structuredContent as AppEventList).events, ); }); @@ -193,8 +211,8 @@ describe("core answers as the app's MCP tools do", () => { Effect.map((found) => valid(Contract.Event, found)), ), ); - expect(ignoringAttachOrder(event)).toEqual( - ignoringAttachOrder(valid(Contract.Event, appResult.structuredContent)), + expect(theAppsFields(ignoringAttachOrder(event))).toEqual( + ignoringAttachOrder(appResult.structuredContent as Contract.Event), ); }); diff --git a/core/tests/repositories.test.ts b/core/tests/repositories.test.ts index 5b419455..c8e7633e 100644 --- a/core/tests/repositories.test.ts +++ b/core/tests/repositories.test.ts @@ -183,6 +183,7 @@ describe("Events", () => { recordingUrl: "https://www.youtube.com/watch?v=abc123", isHackathon: false, previewImage: cover, + curation: { kind: "ours" }, }); expect( published.find((event) => event.slug === "2026-10-03-hack-day"), diff --git a/plugins/allthings/skills/evening-briefing/SKILL.md b/plugins/allthings/skills/evening-briefing/SKILL.md index 90e36596..f0ccd84a 100644 --- a/plugins/allthings/skills/evening-briefing/SKILL.md +++ b/plugins/allthings/skills/evening-briefing/SKILL.md @@ -6,6 +6,7 @@ description: Brief someone before an allthings evening in San Francisco: who is 1. Identify the evening. If the person names it loosely, call `list_events` (upcoming first, then past) and match on name or date; confirm when more than one fits. 2. Call `get_event` with its slug. 3. Write a short briefing: + - Whose it is: if `curation` is `"shared"`, say it is `organizer`'s evening, which allthings shares, and link their site when there is one. - When and where: the local date and time (America/Los_Angeles) and the venue's name and address. Hosts sometimes use a different floor or entrance than the street address suggests, so point the person to the `rsvpUrl` page for check-in details. - Talks: each talk's title, who gives it and their title, and a sentence or two on what it covers, taken from its description. - Who to meet: the speakers, with their public links, and the hosting company. diff --git a/plugins/allthings/skills/find-evenings/SKILL.md b/plugins/allthings/skills/find-evenings/SKILL.md index f18845f2..2db4f590 100644 --- a/plugins/allthings/skills/find-evenings/SKILL.md +++ b/plugins/allthings/skills/find-evenings/SKILL.md @@ -3,7 +3,7 @@ name: find-evenings description: Find upcoming allthings evenings in San Francisco (talks, open floors, socials and hackathons for people who build software) and help someone pick one. Use when someone asks about web development, JavaScript, TypeScript, React or AI engineering meetups, hackathons or tech events in San Francisco or the Bay Area. --- -allthings is an open community for people who build software in San Francisco, from someone a month into their first job to the maintainers of libraries you install every day. Evenings are free; the hosting company gives the space, food and drinks. Each evening is named allthings/, always lowercase: use the name the tools return. +allthings is an open community for people who build software in San Francisco, from someone a month into their first job to the maintainers of libraries you install every day. Evenings are free; the hosting company gives the space, food and drinks. Each of our evenings is named allthings/, always lowercase: use the name the tools return. An evening with `curation: "shared"` is someone else's that allthings shares with the community: name it as written and say who organizes it (`organizer`). 1. Call `list_events` with `when: "upcoming"`. Evenings come soonest first, and one happening now is included with `status: "live"`. 2. If nothing is upcoming, say so plainly, point to the calendar from `get_community` (`links.events`), and offer recent evenings (`when: "past"`) to show what they are like. diff --git a/web/src/v1/data.ts b/web/src/v1/data.ts index b9a68d96..284fa7d0 100644 --- a/web/src/v1/data.ts +++ b/web/src/v1/data.ts @@ -252,7 +252,7 @@ const make = Effect.gen(function* () { ) AS profile, t.id AS "talkId" FROM ${talkAppearances(sql, { - whose: "any", + whose: "ours", when: { ended: now }, })} a JOIN profiles p ON p.id = a.profile_id diff --git a/web/tests/parity.test.ts b/web/tests/parity.test.ts index 5e3b9578..2999796f 100644 --- a/web/tests/parity.test.ts +++ b/web/tests/parity.test.ts @@ -37,9 +37,9 @@ import { mcpRequest, rpcMessage } from "./support/http.ts"; * compared by id, since the API and the pages name an evening by different * slugs. * - * A `test.failing` is a disagreement found when this test was written, - * each with the decision that resolves it; it fails once the surfaces - * agree, so the fix updates it. + * A disagreement found when this test was written was a `test.failing`, + * with the decision that resolved it; each is settled now, and the + * surfaces agree on all of it. */ const origin = "https://allthings.dev"; @@ -159,6 +159,8 @@ interface ToolResult { interface Summary { readonly slug: string; readonly status: Contract.EventStatus; + readonly curation: "ours" | "shared"; + readonly organizer: { readonly name: string } | null; } interface McpEvent extends Summary { @@ -357,6 +359,28 @@ describe("which evenings there are, and in what order", () => { ); }); + test("list_events says whose each evening is, as /events does", async () => { + const view = await evenings(); + const pages = new Map( + [...view.ahead, ...view.past].map((e) => [ + idOf(e.slug), + e.curation.kind === "shared" + ? { curation: "shared", organizer: e.curation.organizer.name } + : { curation: "ours", organizer: null }, + ]), + ); + const listed = await surfaces.listEvents("all"); + expect(listed.filter((e) => e.curation === "shared").length).toBe(2); + for (const event of listed) { + const id = idOf(event.slug); + expect({ id, ...pages.get(id) }).toEqual({ + id, + curation: event.curation, + organizer: event.organizer?.name ?? null, + }); + } + }); + test("every surface gives an evening the same status", async () => { const view = await evenings(); const pages = new Map( @@ -613,37 +637,33 @@ describe("who has been on stage", () => { } }); - // D5: allthings speakers are those of our evenings, as /people lists - // them. Today list_speakers lists Mastra's speaker, whose evening we only - // share. - test.failing( - "everyone list_speakers lists is on /people with those talks", - async () => { - const view = await people(); - const onPeople = new Map( - [...view.organizers, ...view.speakers, ...view.coHosts].map((p) => [ - p.name, - p, - ]), + // allthings speakers are those of our evenings, as /people lists them: + // never Mastra's speaker, whose evening we only share. + test("everyone list_speakers lists is on /people with those talks", async () => { + const view = await people(); + const onPeople = new Map( + [...view.organizers, ...view.speakers, ...view.coHosts].map((p) => [ + p.name, + p, + ]), + ); + for (const speaker of await surfaces.listSpeakers()) { + const listed = onPeople.get(speaker.name); + expect(listed?.name).toBe(speaker.name); + const given = (listed?.parts ?? []).flatMap((part) => + part.kind === "talk" + ? [`${part.title} @ ${idOf(part.evening.slug)}`] + : [], ); - for (const speaker of await surfaces.listSpeakers()) { - const listed = onPeople.get(speaker.name); - expect(listed?.name).toBe(speaker.name); - const given = (listed?.parts ?? []).flatMap((part) => - part.kind === "talk" - ? [`${part.title} @ ${idOf(part.evening.slug)}`] - : [], - ); - for (const talk of speaker.talks) { - expect(given).toContain(`${talk.title} @ ${idOf(talk.eventSlug)}`); - } + for (const talk of speaker.talks) { + expect(given).toContain(`${talk.title} @ ${idOf(talk.eventSlug)}`); } - }, - ); + } + }); - // D5: /about counts the speakers of our evenings that are over, which - // list_speakers lists once it leaves out the evenings we only share. - test.failing("/about counts the speakers list_speakers lists", async () => { + // /about counts the speakers of our evenings that are over, which + // list_speakers lists. + test("/about counts the speakers list_speakers lists", async () => { const about = await surfaces.read( About.use((repository) => repository.read([], photoOrigin)), ); diff --git a/web/tests/worker.test.ts b/web/tests/worker.test.ts index 452df91d..48c103d8 100644 --- a/web/tests/worker.test.ts +++ b/web/tests/worker.test.ts @@ -20,10 +20,15 @@ import { bundleBudgets, hyperdriveTo, testStack } from "./support/stack.ts"; /** * The Worker, bundled as it deploys and running in workerd, against the app - * it replaces. Both read one database: the production schema (core's + * it replaced. Both read one database: the production schema (core's * migrations) with core's seed, in PGlite. The app reads it in process; the * Worker connects over TCP with @effect/sql-pg through its Hyperdrive * binding, as it deploys. + * + * The app is legacy and no longer the reference for the contract: it never + * says whose an evening is (`curation`, `organizer`). The Worker's MCP + * answers are compared with it on every other field; core's contract and + * web/tests/parity.test.ts hold the rest. */ const origin = "https://allthingsweb.dev"; @@ -136,8 +141,60 @@ type ToolMessage = { }; }; -/** A tools/call answer, an event's attached lists in canonical order. */ -function toolAnswer(body: string): unknown { +/** The fields the contract has and the legacy app never had. */ +const sinceTheApp = ["curation", "organizer"]; + +/** JSON Schema `schema` without {@link sinceTheApp}'s properties. */ +function schemaAsTheAppHasIt(schema: Json): Json { + if (Array.isArray(schema)) return schema.map(schemaAsTheAppHasIt); + if (schema === null || typeof schema !== "object") return schema; + return Object.fromEntries( + Object.entries(schema).map(([key, value]) => { + if (key === "properties" && value !== null && typeof value === "object") { + return [ + key, + schemaAsTheAppHasIt( + Object.fromEntries( + Object.entries(value).filter( + ([property]) => !sinceTheApp.includes(property), + ), + ), + ), + ]; + } + if (key === "required" && Array.isArray(value)) { + return [ + key, + value.filter( + (property) => + typeof property !== "string" || !sinceTheApp.includes(property), + ), + ]; + } + return [key, schemaAsTheAppHasIt(value)]; + }), + ); +} + +/** `value` without {@link sinceTheApp}, wherever an event is in it. */ +function asTheAppHasIt(value: Json): Json { + if (Array.isArray(value)) return value.map(asTheAppHasIt); + if (value === null || typeof value !== "object") return value; + return Object.fromEntries( + Object.entries(value) + .filter(([key]) => !sinceTheApp.includes(key)) + .map(([key, field]) => [key, asTheAppHasIt(field)]), + ); +} + +/** + * A tools/call answer, an event's attached lists in canonical order, its + * content shaped by `shape` (in its JSON text too). + */ +function toolAnswer( + body: string, + shape: (value: Json) => Json = (value) => value, +): unknown { const message = rpcMessage(body) as ToolMessage; const result = message.result; if (result?.structuredContent === undefined) return message; @@ -145,11 +202,11 @@ function toolAnswer(body: string): unknown { ...message, result: { ...result, - structuredContent: ignoringAttachOrder(result.structuredContent), + structuredContent: shape(ignoringAttachOrder(result.structuredContent)), content: result.content?.map((part) => ({ ...part, text: JSON.stringify( - ignoringAttachOrder(JSON.parse(part.text) as Json), + shape(ignoringAttachOrder(JSON.parse(part.text) as Json)), null, 2, ), @@ -342,11 +399,15 @@ describe("MCP", () => { expect(JSON.stringify(served.map(withoutOutput))).not.toMatch( /all things/i, ); - // Output schemas come from core's Effect contract: the same JSON Schema, - // as core's contract test defines sameness. + // Output schemas come from core's Effect contract: the same JSON Schema + // as the app's, as core's contract test defines sameness, but for the + // fields the app never had. expect( - served.map((tool) => normalizeJsonSchema(tool.outputSchema)), + served.map((tool) => + schemaAsTheAppHasIt(normalizeJsonSchema(tool.outputSchema)), + ), ).toEqual(today.map((tool) => normalizeJsonSchema(tool.outputSchema))); + expect(JSON.stringify(served[0]?.outputSchema)).toContain('"curation"'); }); const calls: ReadonlyArray]> = [ @@ -385,7 +446,9 @@ describe("MCP", () => { const [actual, expected] = await mcp(Web, call(name, args)); expect(actual.status).toBe(expected.status); expect(actual.contentType).toBe(expected.contentType); - expect(toolAnswer(actual.body)).toEqual(toolAnswer(expected.body)); + expect(toolAnswer(actual.body, asTheAppHasIt)).toEqual( + toolAnswer(expected.body), + ); }); } From 2c069b6855a85e44a184e5da36c8e8e7db1f1b07 Mon Sep 17 00:00:00 2001 From: Erik Thorelli Date: Wed, 7 Oct 2026 16:14:12 -0700 Subject: [PATCH 2/2] Install the CLI's deps in core's CI for the contract test, and pin that an organizer's site is judged as every URL field is --- .github/workflows/core.yaml | 5 +++++ core/tests/contract.test.ts | 18 +++++++++++++++--- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/.github/workflows/core.yaml b/.github/workflows/core.yaml index af1636a5..33f78564 100644 --- a/.github/workflows/core.yaml +++ b/.github/workflows/core.yaml @@ -85,6 +85,11 @@ jobs: working-directory: core run: bash "$GITHUB_WORKSPACE/.github/scripts/bun-install.sh" + # tests/contract.test.ts reads the CLI's copy of the contract. + - name: 馃摜 Download CLI deps + working-directory: cli + run: bash "$GITHUB_WORKSPACE/.github/scripts/bun-install.sh" + - name: 馃拕 Format check working-directory: core run: bun run check diff --git a/core/tests/contract.test.ts b/core/tests/contract.test.ts index ed50433c..06b8c3ff 100644 --- a/core/tests/contract.test.ts +++ b/core/tests/contract.test.ts @@ -222,15 +222,27 @@ describe("verdicts", () => { ); }); - // zod trims and strips tabs and newlines; served URLs must already be clean. - test.each([ + // zod trims and strips tabs and newlines; served URLs must already be + // clean, so the contract rejects them and the CLI, which only reads what + // the server serves, lets them through: every URL field alike, an + // organizer's site too. + const unclean = [ " https://allthings.dev", "https://allthings.dev\n", "https://all\tthings.dev", - ])("url %j is normalized by zod but rejected here", (url) => { + ]; + test.each(unclean)("url %j is normalized by zod but rejected here", (url) => { expect(zodVerdict(summaryZod, withField("url", url)).ok).toBe(true); expect(effectVerdict(summary, withField("url", url)).ok).toBe(false); }); + test.each(unclean)( + "an organizer's site %j is normalized by zod but rejected here", + (url) => { + const input = withField("organizer", { name: "Mastra", url }); + expect(zodVerdict(summaryZod, input).ok).toBe(true); + expect(effectVerdict(summary, input).ok).toBe(false); + }, + ); }); describe("errors", () => {