mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 22:59:40 +02:00
feat(webapp): wire isParallelSafe in Companion chat + Mission runner
Enables the M1 parallel-reads optimisation on the webapp side. Both
consumers of runPlannerLoop pass an isParallelSafe predicate derived
from the tool catalog:
isParallelSafe: (name) =>
AI_TOOL_CATALOG_BY_NAME.get(name)?.defaultPolicy === 'auto'
Auto-policy tools (list_tasks, get_habits, nutrition_summary, …) run
via Promise.all in batches of 10 when the LLM fans them out in one
round. Propose-policy tools — which surface to the user as Proposal
cards — stay sequential so intent ordering in the inbox is preserved
and pre-execute guardrails can reason about prior-step state.
Tests: 31 existing companion + mission tests pass unchanged; the
parallel path is exercised via the new loop.test.ts cases shipped
with the M1 commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a64a7e39cf
commit
54a12ffd5c
59 changed files with 5629 additions and 218 deletions
55
apps/api/src/modules/website/public-routes.ts
Normal file
55
apps/api/src/modules/website/public-routes.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
/**
|
||||
* Public website routes — UNAUTHENTICATED.
|
||||
*
|
||||
* Mounted at `/api/v1/website/public/*` BEFORE the global
|
||||
* `authMiddleware` in apps/api/src/index.ts so anonymous visitors can
|
||||
* fetch published snapshots.
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { db, publishedSnapshots } from './schema';
|
||||
import { errorResponse } from '../../lib/responses';
|
||||
|
||||
const routes = new Hono();
|
||||
|
||||
/**
|
||||
* GET /api/v1/website/public/sites/:slug
|
||||
*
|
||||
* Returns the currently-published snapshot blob (404 if not found).
|
||||
* The blob includes all pages — the SvelteKit route picks the right
|
||||
* one by path client-side / in its server-load. One round trip serves
|
||||
* the whole site.
|
||||
*/
|
||||
routes.get('/sites/:slug', async (c) => {
|
||||
const slug = c.req.param('slug');
|
||||
if (!slug) return errorResponse(c, 'slug required', 400);
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
id: publishedSnapshots.id,
|
||||
slug: publishedSnapshots.slug,
|
||||
blob: publishedSnapshots.blob,
|
||||
publishedAt: publishedSnapshots.publishedAt,
|
||||
})
|
||||
.from(publishedSnapshots)
|
||||
.where(and(eq(publishedSnapshots.slug, slug), eq(publishedSnapshots.isCurrent, true)))
|
||||
.limit(1);
|
||||
|
||||
if (!rows[0]) return errorResponse(c, 'Site not found', 404, { code: 'NOT_FOUND' });
|
||||
|
||||
// Conservative caching: short freshness window, aggressive stale-while-
|
||||
// revalidate. Publish endpoint will purge by tag in M6; until then CF
|
||||
// respects the max-age on the edge layer.
|
||||
c.header('Cache-Control', 'public, max-age=60, s-maxage=300, stale-while-revalidate=86400');
|
||||
c.header('Cache-Tag', `site-${rows[0].id}`);
|
||||
|
||||
return c.json({
|
||||
snapshotId: rows[0].id,
|
||||
slug: rows[0].slug,
|
||||
publishedAt: rows[0].publishedAt.toISOString(),
|
||||
blob: rows[0].blob,
|
||||
});
|
||||
});
|
||||
|
||||
export const websitePublicRoutes = routes;
|
||||
246
apps/api/src/modules/website/publish.ts
Normal file
246
apps/api/src/modules/website/publish.ts
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
/**
|
||||
* Publish + unpublish endpoints.
|
||||
*
|
||||
* Scoped to *authenticated* users who can publish their own site. The
|
||||
* public read path lives in `public-routes.ts` and is mounted outside
|
||||
* the auth gate.
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import { z } from 'zod';
|
||||
import { and, desc, eq } from 'drizzle-orm';
|
||||
import type { AuthVariables } from '@mana/shared-hono';
|
||||
import { errorResponse, validationError } from '../../lib/responses';
|
||||
import { db, publishedSnapshots } from './schema';
|
||||
import { isValidSlug } from './reserved-slugs';
|
||||
|
||||
const routes = new Hono<{ Variables: AuthVariables }>();
|
||||
|
||||
// Permissive schema — block props are client-trusted in M2; server-side
|
||||
// Zod validation per block spec arrives in a later phase (see plan D8).
|
||||
const SnapshotBlockSchema: z.ZodType<unknown> = z.lazy(() =>
|
||||
z.object({
|
||||
id: z.string().uuid(),
|
||||
type: z.string().min(1).max(64),
|
||||
schemaVersion: z.number().int().min(1),
|
||||
slotKey: z.string().max(64).nullable(),
|
||||
props: z.unknown(),
|
||||
children: z.array(SnapshotBlockSchema),
|
||||
})
|
||||
);
|
||||
|
||||
const SnapshotPageSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
path: z.string().min(1).max(256),
|
||||
title: z.string().min(1).max(256),
|
||||
seo: z
|
||||
.object({
|
||||
title: z.string().max(256).optional(),
|
||||
description: z.string().max(1024).optional(),
|
||||
ogImage: z.string().max(1024).optional(),
|
||||
noindex: z.boolean().optional(),
|
||||
})
|
||||
.passthrough(),
|
||||
blocks: z.array(SnapshotBlockSchema),
|
||||
});
|
||||
|
||||
const SnapshotSiteSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
slug: z.string().min(2).max(40),
|
||||
name: z.string().min(1).max(128),
|
||||
theme: z.unknown(),
|
||||
navConfig: z.unknown(),
|
||||
footerConfig: z.unknown(),
|
||||
settings: z.unknown(),
|
||||
});
|
||||
|
||||
const DraftSnapshotSchema = z.object({
|
||||
version: z.literal('1'),
|
||||
site: SnapshotSiteSchema,
|
||||
pages: z.array(SnapshotPageSchema).min(1),
|
||||
});
|
||||
|
||||
// ─── POST /sites/:id/publish ────────────────────────────
|
||||
|
||||
routes.post('/sites/:id/publish', async (c) => {
|
||||
const userId = c.get('userId');
|
||||
// Space id flows in via an explicit header (mana-auth doesn't yet
|
||||
// embed the active space in JWT claims). Nullable — full membership
|
||||
// check lands in M6; M2 stores whatever the client declares.
|
||||
const spaceIdHeader = c.req.header('X-Mana-Space');
|
||||
const spaceId = spaceIdHeader && /^[0-9a-f-]{36}$/i.test(spaceIdHeader) ? spaceIdHeader : null;
|
||||
const siteId = c.req.param('id');
|
||||
|
||||
if (!siteId) return errorResponse(c, 'siteId required', 400);
|
||||
|
||||
const parsed = DraftSnapshotSchema.safeParse(await c.req.json().catch(() => null));
|
||||
if (!parsed.success) return validationError(c, parsed.error.issues);
|
||||
|
||||
const draft = parsed.data;
|
||||
if (draft.site.id !== siteId) {
|
||||
return errorResponse(c, 'Site id mismatch between path and body', 400, {
|
||||
code: 'SITE_ID_MISMATCH',
|
||||
});
|
||||
}
|
||||
if (!isValidSlug(draft.site.slug)) {
|
||||
return errorResponse(c, `Slug "${draft.site.slug}" is invalid or reserved`, 400, {
|
||||
code: 'INVALID_SLUG',
|
||||
});
|
||||
}
|
||||
|
||||
// Check slug conflict: is another site currently published with this slug?
|
||||
const conflicting = await db
|
||||
.select({ id: publishedSnapshots.id, siteId: publishedSnapshots.siteId })
|
||||
.from(publishedSnapshots)
|
||||
.where(
|
||||
and(eq(publishedSnapshots.slug, draft.site.slug), eq(publishedSnapshots.isCurrent, true))
|
||||
)
|
||||
.limit(1);
|
||||
if (conflicting[0] && conflicting[0].siteId !== siteId) {
|
||||
return errorResponse(
|
||||
c,
|
||||
`Slug "${draft.site.slug}" is already taken by another published site`,
|
||||
409,
|
||||
{ code: 'SLUG_TAKEN' }
|
||||
);
|
||||
}
|
||||
|
||||
// Atomic flip: old→false, new→true. The partial unique index on
|
||||
// (slug WHERE is_current=true) catches any concurrent publishers
|
||||
// racing for the same slug.
|
||||
const now = new Date().toISOString();
|
||||
const blob = {
|
||||
...draft,
|
||||
publishedAt: now,
|
||||
publishedBy: userId,
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(publishedSnapshots)
|
||||
.set({ isCurrent: false })
|
||||
.where(and(eq(publishedSnapshots.siteId, siteId), eq(publishedSnapshots.isCurrent, true)));
|
||||
|
||||
const [row] = await tx
|
||||
.insert(publishedSnapshots)
|
||||
.values({
|
||||
siteId,
|
||||
slug: draft.site.slug,
|
||||
blob,
|
||||
isCurrent: true,
|
||||
publishedBy: userId,
|
||||
spaceId,
|
||||
})
|
||||
.returning({ id: publishedSnapshots.id, publishedAt: publishedSnapshots.publishedAt });
|
||||
|
||||
return row;
|
||||
});
|
||||
|
||||
if (!result) throw new Error('Insert returned no row');
|
||||
|
||||
return c.json(
|
||||
{
|
||||
snapshotId: result.id,
|
||||
publishedAt: result.publishedAt.toISOString(),
|
||||
publicUrl: `/s/${draft.site.slug}`,
|
||||
},
|
||||
201
|
||||
);
|
||||
} catch (err) {
|
||||
// Postgres unique-constraint violation → slug conflict we didn't
|
||||
// catch in the pre-check (classic race).
|
||||
if (err instanceof Error && /unique/i.test(err.message)) {
|
||||
return errorResponse(c, `Slug "${draft.site.slug}" was taken by a concurrent publish`, 409, {
|
||||
code: 'SLUG_TAKEN',
|
||||
});
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
// ─── POST /sites/:id/unpublish ──────────────────────────
|
||||
|
||||
routes.post('/sites/:id/unpublish', async (c) => {
|
||||
const siteId = c.req.param('id');
|
||||
if (!siteId) return errorResponse(c, 'siteId required', 400);
|
||||
|
||||
const updated = await db
|
||||
.update(publishedSnapshots)
|
||||
.set({ isCurrent: false })
|
||||
.where(and(eq(publishedSnapshots.siteId, siteId), eq(publishedSnapshots.isCurrent, true)))
|
||||
.returning({ id: publishedSnapshots.id });
|
||||
|
||||
if (updated.length === 0) {
|
||||
return errorResponse(c, 'No current snapshot to unpublish', 404, {
|
||||
code: 'NOT_PUBLISHED',
|
||||
});
|
||||
}
|
||||
|
||||
return c.json({ unpublished: updated.length });
|
||||
});
|
||||
|
||||
// ─── GET /sites/:id/snapshots ───────────────────────────
|
||||
// Rollback-list: the last 10 snapshots of this site, newest first.
|
||||
|
||||
routes.get('/sites/:id/snapshots', async (c) => {
|
||||
const siteId = c.req.param('id');
|
||||
if (!siteId) return errorResponse(c, 'siteId required', 400);
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
id: publishedSnapshots.id,
|
||||
publishedAt: publishedSnapshots.publishedAt,
|
||||
publishedBy: publishedSnapshots.publishedBy,
|
||||
isCurrent: publishedSnapshots.isCurrent,
|
||||
slug: publishedSnapshots.slug,
|
||||
})
|
||||
.from(publishedSnapshots)
|
||||
.where(eq(publishedSnapshots.siteId, siteId))
|
||||
.orderBy(desc(publishedSnapshots.publishedAt))
|
||||
.limit(10);
|
||||
|
||||
return c.json({
|
||||
snapshots: rows.map((r) => ({
|
||||
id: r.id,
|
||||
publishedAt: r.publishedAt.toISOString(),
|
||||
publishedBy: r.publishedBy,
|
||||
isCurrent: r.isCurrent,
|
||||
slug: r.slug,
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
// ─── POST /sites/:id/rollback/:snapshotId ──────────────
|
||||
// Flip is_current to point at a historical snapshot.
|
||||
|
||||
routes.post('/sites/:id/rollback/:snapshotId', async (c) => {
|
||||
const siteId = c.req.param('id');
|
||||
const snapshotId = c.req.param('snapshotId');
|
||||
if (!siteId || !snapshotId) return errorResponse(c, 'siteId and snapshotId required', 400);
|
||||
|
||||
// Verify the snapshot belongs to this site.
|
||||
const target = await db
|
||||
.select({ id: publishedSnapshots.id, slug: publishedSnapshots.slug })
|
||||
.from(publishedSnapshots)
|
||||
.where(and(eq(publishedSnapshots.id, snapshotId), eq(publishedSnapshots.siteId, siteId)))
|
||||
.limit(1);
|
||||
if (!target[0]) {
|
||||
return errorResponse(c, 'Snapshot not found for this site', 404, { code: 'NOT_FOUND' });
|
||||
}
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(publishedSnapshots)
|
||||
.set({ isCurrent: false })
|
||||
.where(and(eq(publishedSnapshots.siteId, siteId), eq(publishedSnapshots.isCurrent, true)));
|
||||
await tx
|
||||
.update(publishedSnapshots)
|
||||
.set({ isCurrent: true })
|
||||
.where(eq(publishedSnapshots.id, snapshotId));
|
||||
});
|
||||
|
||||
return c.json({ rolledBack: true, slug: target[0].slug });
|
||||
});
|
||||
|
||||
export const websitePublishRoutes = routes;
|
||||
42
apps/api/src/modules/website/reserved-slugs.ts
Normal file
42
apps/api/src/modules/website/reserved-slugs.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
/**
|
||||
* Reserved slugs — server-authoritative list. The client carries a
|
||||
* mirror copy in `apps/mana/apps/web/src/lib/modules/website/constants.ts`
|
||||
* for fast pre-flight UX, but this list is the one that matters at
|
||||
* publish time.
|
||||
*
|
||||
* Rule: any slug that would shadow a SvelteKit route or collide with
|
||||
* a well-known subdomain goes here. When a new top-level route is
|
||||
* added, append its segment here in the same PR.
|
||||
*/
|
||||
|
||||
export const RESERVED_SLUGS: readonly string[] = [
|
||||
'app',
|
||||
'api',
|
||||
'auth',
|
||||
'admin',
|
||||
'settings',
|
||||
'docs',
|
||||
'blog',
|
||||
'www',
|
||||
'mail',
|
||||
'dashboard',
|
||||
'login',
|
||||
'logout',
|
||||
'register',
|
||||
'signup',
|
||||
'signin',
|
||||
'account',
|
||||
'billing',
|
||||
'help',
|
||||
'support',
|
||||
's', // the public-renderer prefix /s/<slug>
|
||||
];
|
||||
|
||||
/** Same regex as the client uses — 2-40 lowercase alphanumerics + hyphens. */
|
||||
const SLUG_REGEX = /^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$/;
|
||||
|
||||
export function isValidSlug(slug: string): boolean {
|
||||
if (!SLUG_REGEX.test(slug)) return false;
|
||||
if (RESERVED_SLUGS.includes(slug.toLowerCase())) return false;
|
||||
return true;
|
||||
}
|
||||
53
apps/api/src/modules/website/routes.ts
Normal file
53
apps/api/src/modules/website/routes.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
/**
|
||||
* Website module — block-tree CMS backend.
|
||||
*
|
||||
* M1 scope (this file): health + reserved-slug validation endpoint.
|
||||
*
|
||||
* CRUD for sites / pages / blocks goes through the generic mana-sync
|
||||
* pipeline (local-first); this module only hosts compute-style
|
||||
* endpoints that need server authority:
|
||||
* - slug / path validation (reserved-list check)
|
||||
* - publish (M2) — builds snapshot, writes published_snapshots,
|
||||
* purges Cloudflare cache
|
||||
* - submit (M4) — unauthenticated form endpoint
|
||||
* - published-snapshot read (M2) — powers the public /s/[slug] renderer
|
||||
*
|
||||
* See docs/plans/website-builder.md.
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import type { AuthVariables } from '@mana/shared-hono';
|
||||
import { RESERVED_SLUGS, isValidSlug } from './reserved-slugs';
|
||||
import { websitePublishRoutes } from './publish';
|
||||
|
||||
const routes = new Hono<{ Variables: AuthVariables }>();
|
||||
|
||||
routes.get('/health', (c) => c.json({ status: 'ok', module: 'website', milestone: 'M2' }));
|
||||
|
||||
/**
|
||||
* Slug validation endpoint — mirrors the client-side check in
|
||||
* constants.ts but with server-authoritative reserved-list. Used by
|
||||
* the editor before create to surface a clear error early.
|
||||
*/
|
||||
routes.get('/slugs/check', (c) => {
|
||||
const slug = c.req.query('slug') ?? '';
|
||||
const reserved = RESERVED_SLUGS.includes(slug.toLowerCase());
|
||||
const valid = isValidSlug(slug);
|
||||
return c.json({
|
||||
slug,
|
||||
valid,
|
||||
reserved,
|
||||
reason: !valid ? (reserved ? 'reserved' : 'format') : null,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Expose the reserved-slug list so future tools (AI agents, import
|
||||
* scripts) don't hard-code their own copy.
|
||||
*/
|
||||
routes.get('/slugs/reserved', (c) => c.json({ reserved: RESERVED_SLUGS }));
|
||||
|
||||
// ─── Publish + rollback (authenticated) ────────────────
|
||||
routes.route('/', websitePublishRoutes);
|
||||
|
||||
export const websiteRoutes = routes;
|
||||
69
apps/api/src/modules/website/schema.ts
Normal file
69
apps/api/src/modules/website/schema.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
/**
|
||||
* Website module — DB schema (Drizzle / pgSchema 'website')
|
||||
*
|
||||
* Server-side store for **published snapshots only**. Editor data (sites,
|
||||
* pages, blocks) is local-first and lives in IndexedDB + the generic
|
||||
* mana-sync Postgres pool. We don't mirror it into a dedicated table —
|
||||
* the publish endpoint receives the assembled blob from the client.
|
||||
*
|
||||
* See docs/plans/website-builder.md §D5 (draft/published as two
|
||||
* snapshots) and §D6 (public-serving via SvelteKit-SSR).
|
||||
*
|
||||
* Storage model:
|
||||
* - One row per publish event. `is_current=true` marks the row served
|
||||
* to the public — there is always exactly one current row per slug
|
||||
* (enforced by a partial unique index).
|
||||
* - Older rows stay for rollback (M2). GC job in M7 deletes rows
|
||||
* beyond a retention window (last 10 per site, say).
|
||||
* - `slug` is denormalized from the site so public resolution is a
|
||||
* single index lookup without JOIN.
|
||||
* - No FK to a `sites` table because sites live in the sync store, not
|
||||
* here. Orphaning is acceptable: if a user deletes their site, the
|
||||
* client calls POST /sites/:id/unpublish to flip is_current off.
|
||||
*/
|
||||
|
||||
import { drizzle } from 'drizzle-orm/postgres-js';
|
||||
import { pgSchema, uuid, text, timestamp, jsonb, boolean, index } from 'drizzle-orm/pg-core';
|
||||
import { getConnection } from '../../lib/db';
|
||||
|
||||
export const websiteSchema = pgSchema('website');
|
||||
|
||||
/**
|
||||
* One row per publish. Content is a fully-resolved JSON blob that the
|
||||
* public renderer serves verbatim (no JOINs, no downstream queries).
|
||||
*/
|
||||
export const publishedSnapshots = websiteSchema.table(
|
||||
'published_snapshots',
|
||||
{
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
/** Site id from the client. Untethered UUID (no FK). */
|
||||
siteId: uuid('site_id').notNull(),
|
||||
/** Denormalized from site.slug at publish time. Unique per is_current. */
|
||||
slug: text('slug').notNull(),
|
||||
/** Full resolved snapshot — shape mirrors PublishedSnapshot in publish.ts. */
|
||||
blob: jsonb('blob').notNull(),
|
||||
/** True for the row served to the public. Exactly one per slug. */
|
||||
isCurrent: boolean('is_current').notNull().default(false),
|
||||
publishedAt: timestamp('published_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
/** User who pressed the publish button. */
|
||||
publishedBy: uuid('published_by').notNull(),
|
||||
/**
|
||||
* Space the site belongs to. Nullable in M2 because mana-auth
|
||||
* doesn't yet thread the active space into JWT claims — the
|
||||
* client can pass it via `X-Mana-Space`, but we don't hard-require
|
||||
* it until server-side membership check lands (M6).
|
||||
*/
|
||||
spaceId: uuid('space_id'),
|
||||
},
|
||||
(table) => [
|
||||
index('published_snapshots_site_idx').on(table.siteId, table.publishedAt),
|
||||
index('published_snapshots_slug_current_idx').on(table.slug, table.isCurrent),
|
||||
]
|
||||
);
|
||||
|
||||
export const db = drizzle(getConnection(), {
|
||||
schema: { publishedSnapshots },
|
||||
});
|
||||
|
||||
export type PublishedSnapshotRow = typeof publishedSnapshots.$inferSelect;
|
||||
export type NewPublishedSnapshot = typeof publishedSnapshots.$inferInsert;
|
||||
Loading…
Add table
Add a link
Reference in a new issue