Redesign Learn, and give it five real videos in Karti's voice
THE PAGE. The anonymous route rendered outside Shell, so it sat flush against
the viewport edge and read as a form rather than a product — which is the first
thing anyone at Prime Intellect sees when the link is shared. It now brings its
own chrome and leads with a hero; the platform track is a numbered course, the
concept tracks are a poster grid, and admin add/archive moved behind one Manage
toggle so they stop competing with the content. Verified in Chrome at 1440 and
393, light and dark: horizontal overflow is 0 in all three access states.
THE VIDEOS. Five ~30s walkthroughs, narrated in Karti's cloned voice through
Chatterbox and cut against real screen capture of the seeded demo book. The
audio is rendered FIRST and its measured duration drives the capture, because a
shot list that runs short leaves the narrator talking over a frozen frame and
one that runs long gets cut mid-sentence. Levels are loudness-normalised so
clips do not jump between videos.
Cap cannot take a programmatic upload — video.karti.ai needs an interactive
login — so PIG serves these itself. A native <video> on this origin needs no
iframe and therefore no CSP frame-src at all; Karti's own Cap recordings still
render through the existing iframe path, which is why the resolver is now a
discriminated union.
THREE THINGS THE VERIFIERS CAUGHT, all of which shipped green:
- createMediaRoutes was never mounted. Every layer landed — migration, seed,
both feeds, the bind mount, the docs — except the one that serves the bytes,
so /media/learn/* fell through to the SPA fallback and answered HTTP 200
text/html. The player showed a black box with working controls and no error.
The tests certified the route factory in isolation, which proves the handler
and says nothing about whether it is wired in. There is now an assertion
against the ASSEMBLED app, and it fails loudly on content-type — the failure
mode is a 200, not a 404.
- A symlink in the media directory escaped the root. resolve() is lexical and
stat() follows links, so the containment check this file's own header
promised did not hold. realpath before the check closes it.
- Vite proxied only /api, so self-hosted playback broke for anyone running the
app the documented way — in the same invisible 200-text/html manner.
Also: a duplicate media slug used to throw from the middle of seedDemo() and
take out every later section; it now reports and skips that one entry. And the
player has an onError state, because content-addressed filenames mean a
re-render deliberately leaves the old row pointing at a file that is gone.
The three DEMO platform rows are dropped — five real recordings supersede them,
and placeholders sitting under real ones made the page read as half-finished to
the audience it is meant to convince. The supply and demand concept rows stay:
there are no real recordings for those tracks yet, and an empty track hides the
shape of the page.
Tests 275, typecheck clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
-- Hand-written, because drizzle-kit rewrites a CHECK by dropping and adding it
|
||||
-- and will happily emit that with no IF EXISTS — which fails on any database
|
||||
-- where the constraint was created under an earlier name.
|
||||
--
|
||||
-- Widening only: 'pig' is the self-hosted provider, whose external_id is a
|
||||
-- media filename rather than a remote video id. Every value already stored
|
||||
-- still satisfies the new list, so this needs no backfill and cannot fail on
|
||||
-- existing rows.
|
||||
--
|
||||
-- The unique key on (track, provider, external_id) is untouched and is what
|
||||
-- makes the self-hosted seed idempotent.
|
||||
ALTER TABLE "learn_resources" DROP CONSTRAINT IF EXISTS "learn_resources_provider_check";--> statement-breakpoint
|
||||
ALTER TABLE "learn_resources" ADD CONSTRAINT "learn_resources_provider_check" CHECK ("learn_resources"."provider" IN ('cap', 'loom', 'youtube_nocookie', 'pig'));
|
||||
@@ -92,6 +92,13 @@
|
||||
"when": 1786655800000,
|
||||
"tag": "0012_viewer_team_role",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 13,
|
||||
"version": "7",
|
||||
"when": 1786700000000,
|
||||
"tag": "0013_learn_self_hosted_provider",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+261
-33
@@ -35,8 +35,16 @@
|
||||
* is more useful than one that opens on a loss, which reads as a broken
|
||||
* product rather than an under-utilised book.
|
||||
*/
|
||||
import { ALLOCATION_STATUSES, quarterBoundsFor, type AllocationStatus } from '@pig/core';
|
||||
import {
|
||||
ALLOCATION_STATUSES,
|
||||
LEARN_MEDIA_PATH_PREFIX,
|
||||
isLearnMediaFilename,
|
||||
quarterBoundsFor,
|
||||
type AllocationStatus,
|
||||
} from '@pig/core';
|
||||
import { and, eq, like, or } from 'drizzle-orm';
|
||||
import { readdir } from 'node:fs/promises';
|
||||
import { resolve } from 'node:path';
|
||||
import { createDatabase } from '../client';
|
||||
import {
|
||||
accounts,
|
||||
@@ -932,37 +940,15 @@ async function seedDemo() {
|
||||
// is the opposite of what a demo seed is for — so the titles are illustrative
|
||||
// and prefixed, and the videos behind them are whatever is actually there.
|
||||
//
|
||||
// The platform rows are `code`-visible: they are what a code-holder with no
|
||||
// account sees. The concept rows are `members`, and the CHECK constraint on
|
||||
// the table would refuse them any other way round.
|
||||
// PLATFORM rows are no longer seeded here. The five real recordings in
|
||||
// HOSTED_LEARN_MANIFEST cover that track now, and leaving three illustrative
|
||||
// Cap rows beneath five genuine ones made the page read as half-placeholder
|
||||
// to the exact audience it is meant to convince.
|
||||
//
|
||||
// The concept rows stay: `supply` and `demand` have no real recordings yet,
|
||||
// and an empty track hides the shape of the page. They are `members`-visible,
|
||||
// which the CHECK constraint enforces anyway — only `platform` may be `code`.
|
||||
const LEARN_RESOURCES = [
|
||||
{
|
||||
track: 'platform' as const,
|
||||
title: `${PREFIX}Your first hour in PIG`,
|
||||
summary: 'Signing in, finding your pipeline, and what the Overview numbers mean.',
|
||||
externalId: '0n6n9p83efnxbs2',
|
||||
visibility: 'code' as const,
|
||||
durationSeconds: 8 * 60 + 40,
|
||||
sortOrder: 10,
|
||||
},
|
||||
{
|
||||
track: 'platform' as const,
|
||||
title: `${PREFIX}Allocations: joining what we bought to what we sold`,
|
||||
summary: 'The one table the product is built around, walked through on the demo book.',
|
||||
externalId: '1rqq9rk4dpp71fd',
|
||||
visibility: 'code' as const,
|
||||
durationSeconds: 12 * 60 + 15,
|
||||
sortOrder: 20,
|
||||
},
|
||||
{
|
||||
track: 'platform' as const,
|
||||
title: `${PREFIX}Reading the margin report without fooling yourself`,
|
||||
summary: 'Why cost is charged against the whole commitment, and what idle capacity costs.',
|
||||
externalId: 'sjqqvthbfma27bm',
|
||||
visibility: 'code' as const,
|
||||
durationSeconds: 9 * 60 + 5,
|
||||
sortOrder: 30,
|
||||
},
|
||||
{
|
||||
track: 'supply' as const,
|
||||
title: `${PREFIX}How neocloud capacity is actually priced`,
|
||||
@@ -1027,6 +1013,12 @@ async function seedDemo() {
|
||||
if (inserted.length) learnAdded += 1;
|
||||
}
|
||||
|
||||
// The self-hosted set, which is real rather than invented — see the long
|
||||
// note on `seedHostedLearn`. Folded into the demo seed so one command gives
|
||||
// a complete Learn page, but kept in its own function with its own flags
|
||||
// because it is not demo data and must not be removed with `--clear`.
|
||||
const hosted = await seedHostedLearn();
|
||||
|
||||
// -------------------------------------------------- agent-derived facts
|
||||
//
|
||||
// Without these the fact-review queue and every provenance tooltip are
|
||||
@@ -1199,11 +1191,247 @@ async function seedDemo() {
|
||||
console.log(
|
||||
` ${LEARN_RESOURCES.length} learn resources (${learnAdded} new) — 3 platform walkthroughs behind the share code`,
|
||||
);
|
||||
console.log(
|
||||
` ${hosted.present} PIG-hosted learn videos (${hosted.added} new)` +
|
||||
`${hosted.missing > 0 ? `, ${hosted.missing} manifest entries with no file yet` : ''}`,
|
||||
);
|
||||
console.log('\nEverything is prefixed "DEMO — ". Remove it with: pnpm db:demo -- --clear');
|
||||
console.log('The PIG-hosted rows are NOT prefixed and survive that. Remove them with: pnpm db:demo -- --clear-hosted');
|
||||
}
|
||||
|
||||
const shouldClear = process.argv.includes('--clear');
|
||||
(shouldClear ? clear() : seedDemo())
|
||||
// ------------------------------------------------------- PIG-hosted learn
|
||||
//
|
||||
// Real videos, served by PIG itself from PIG_MEDIA_DIR — not demo data. They
|
||||
// carry no `DEMO — ` prefix precisely because they are genuine product
|
||||
// walkthroughs, which also means `--clear` leaves them alone; `--clear-hosted`
|
||||
// is their own switch.
|
||||
//
|
||||
// **A row is written only when its file is actually on disk.** A learn row
|
||||
// whose media 404s is worse than a missing row: the card renders, the play
|
||||
// button does nothing, and the feature reads as broken. So the manifest below
|
||||
// declares the curriculum, and the seed inserts the entries it can find.
|
||||
// Running it before the videos are generated is a no-op with a printed list,
|
||||
// and running it again afterwards fills them in.
|
||||
//
|
||||
// **The filename is discovered, not written down.** Files are
|
||||
// content-addressed — `<slug>.<hash>.mp4` — so the hash changes every time a
|
||||
// video is re-rendered, and a manifest carrying the hash would be a file that
|
||||
// has to be edited in lockstep with a render. Instead the slug is the stable
|
||||
// identity and the directory supplies the rest. Idempotency then rests on the
|
||||
// unique key (track, provider, external_id) exactly as the DEMO rows do.
|
||||
//
|
||||
// A re-render produces a NEW hash and therefore a new row; the old row keeps
|
||||
// pointing at a file that is no longer there. That is reported rather than
|
||||
// resolved automatically, because deleting rows on the strength of a missing
|
||||
// file would empty the curriculum the first time someone ran this with the
|
||||
// media directory unmounted.
|
||||
|
||||
interface HostedLearnEntry {
|
||||
/** Stable identity. Also the filename stem: `<slug>.<hash>.<ext>`. */
|
||||
slug: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
track: 'supply' | 'demand' | 'platform';
|
||||
visibility: 'members' | 'code';
|
||||
durationSeconds: number;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
export const HOSTED_LEARN_MANIFEST: readonly HostedLearnEntry[] = [
|
||||
{
|
||||
slug: 'overview-and-margin',
|
||||
title: 'Overview and the margin question',
|
||||
summary:
|
||||
'Which contracted capacity is sold, at what margin, and what is idle right now — and why cost is charged against the full commitment.',
|
||||
track: 'platform',
|
||||
visibility: 'code',
|
||||
durationSeconds: 29,
|
||||
sortOrder: 1,
|
||||
},
|
||||
{
|
||||
slug: 'quarterly-calendar',
|
||||
title: 'The quarterly calendar',
|
||||
summary:
|
||||
'What closes, what renews, what expires and when capacity lands — with export authorisations and compliance artefacts at the top.',
|
||||
track: 'platform',
|
||||
visibility: 'code',
|
||||
durationSeconds: 30,
|
||||
sortOrder: 2,
|
||||
},
|
||||
{
|
||||
slug: 'capacity-to-allocations',
|
||||
title: 'Capacity to allocations',
|
||||
summary:
|
||||
'Joining a commitment you bought to a deal you sold — the availability book, the matcher, and the allocation the ledger is built on.',
|
||||
track: 'platform',
|
||||
visibility: 'code',
|
||||
durationSeconds: 26,
|
||||
sortOrder: 3,
|
||||
},
|
||||
{
|
||||
slug: 'importing-your-book',
|
||||
title: 'Importing your book',
|
||||
summary:
|
||||
'Getting off the spreadsheet — CSV, Excel, Notion or a bounded Google Sheets range, with a dry run you review before anything is written.',
|
||||
track: 'platform',
|
||||
visibility: 'code',
|
||||
durationSeconds: 28,
|
||||
sortOrder: 4,
|
||||
},
|
||||
{
|
||||
slug: 'piggy-and-its-boundary',
|
||||
title: 'Piggy, and what it will not do',
|
||||
summary:
|
||||
'The docked agent reads through scoped, page-specific PIG tools — and has no shell, no filesystem, and no ability to write CRM records.',
|
||||
track: 'platform',
|
||||
visibility: 'code',
|
||||
durationSeconds: 28,
|
||||
sortOrder: 5,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Where the served files live. The same variable the API route reads.
|
||||
*
|
||||
* The default is resolved from this file's location, not from `process.cwd()`,
|
||||
* because pnpm runs the seed with the working directory at `packages/db` while
|
||||
* the server runs at the repository root — so a relative default would mean
|
||||
* two different directories, and the seed would write rows for files the API
|
||||
* cannot find.
|
||||
*/
|
||||
function mediaDirectory(): string {
|
||||
const configured = process.env.PIG_MEDIA_DIR?.trim();
|
||||
if (configured && configured.length > 0) return resolve(configured);
|
||||
return resolve(import.meta.dirname, '../../../../media');
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the one file belonging to a slug.
|
||||
*
|
||||
* Two files for one slug is an error rather than a choice: picking the newest
|
||||
* would silently publish whichever render happened to finish last, and the
|
||||
* operator has an old file to delete.
|
||||
*/
|
||||
function mediaFileFor(
|
||||
slug: string,
|
||||
filenames: readonly string[],
|
||||
): { filename: string | null; ambiguous: readonly string[] } {
|
||||
const matches = filenames.filter(
|
||||
(name) => isLearnMediaFilename(name) && name.startsWith(`${slug}.`),
|
||||
);
|
||||
/*
|
||||
* Reported and skipped, not thrown.
|
||||
*
|
||||
* This runs from the middle of seedDemo(), so throwing on a duplicate took
|
||||
* out every later section — facts, activities, the lot — and left the
|
||||
* operator working out why `pnpm db:demo` died on two MP4s sharing a slug.
|
||||
* A stale render is a condition of one directory entry; its blast radius
|
||||
* should be that entry. Missing files are already handled this way.
|
||||
*/
|
||||
if (matches.length > 1) return { filename: null, ambiguous: matches };
|
||||
return { filename: matches[0] ?? null, ambiguous: [] };
|
||||
}
|
||||
|
||||
export async function seedHostedLearn(): Promise<{
|
||||
present: number;
|
||||
added: number;
|
||||
missing: number;
|
||||
}> {
|
||||
const directory = mediaDirectory();
|
||||
let filenames: string[] = [];
|
||||
try {
|
||||
filenames = await readdir(directory);
|
||||
} catch {
|
||||
// No directory is the normal state of a fresh checkout, not a failure.
|
||||
console.log(` (no media directory at ${directory} — skipping PIG-hosted learn videos)`);
|
||||
return { present: 0, added: 0, missing: HOSTED_LEARN_MANIFEST.length };
|
||||
}
|
||||
|
||||
const [owner] = await db.select({ id: users.id }).from(users).limit(1);
|
||||
let present = 0;
|
||||
let added = 0;
|
||||
const absent: string[] = [];
|
||||
|
||||
for (const entry of HOSTED_LEARN_MANIFEST) {
|
||||
const { filename, ambiguous } = mediaFileFor(entry.slug, filenames);
|
||||
if (ambiguous.length > 0) {
|
||||
console.warn(
|
||||
` ! ${entry.slug}: ${ambiguous.length} files match (${ambiguous.join(', ')}) — ` +
|
||||
'content-addressed names mean one is a stale render. Delete it and re-run. Skipped.',
|
||||
);
|
||||
absent.push(entry.slug);
|
||||
continue;
|
||||
}
|
||||
if (!filename) {
|
||||
absent.push(entry.slug);
|
||||
continue;
|
||||
}
|
||||
present += 1;
|
||||
|
||||
const inserted = await db
|
||||
.insert(learnResources)
|
||||
.values({
|
||||
track: entry.track,
|
||||
title: entry.title,
|
||||
summary: entry.summary,
|
||||
// The same path the resolver builds, from the same constant — so a
|
||||
// seeded row and a row created through the API are indistinguishable.
|
||||
url: `${LEARN_MEDIA_PATH_PREFIX}${filename}`,
|
||||
provider: 'pig',
|
||||
externalId: filename,
|
||||
visibility: entry.visibility,
|
||||
durationSeconds: entry.durationSeconds,
|
||||
sortOrder: entry.sortOrder,
|
||||
addedByUserId: owner?.id ?? null,
|
||||
})
|
||||
.onConflictDoNothing({
|
||||
target: [learnResources.track, learnResources.provider, learnResources.externalId],
|
||||
})
|
||||
.returning({ id: learnResources.id });
|
||||
if (inserted.length) added += 1;
|
||||
}
|
||||
|
||||
if (absent.length) {
|
||||
console.log(
|
||||
` (no file yet in ${directory} for: ${absent.join(', ')} — ` +
|
||||
'drop <slug>.<hash>.mp4 there and run this again)',
|
||||
);
|
||||
}
|
||||
|
||||
// Rows whose file has gone: reported, never deleted. See the note above.
|
||||
const orphans = (
|
||||
await db
|
||||
.select({ externalId: learnResources.externalId, title: learnResources.title })
|
||||
.from(learnResources)
|
||||
.where(eq(learnResources.provider, 'pig'))
|
||||
).filter((row) => !filenames.includes(row.externalId));
|
||||
for (const orphan of orphans) {
|
||||
console.log(` ! "${orphan.title}" points at ${orphan.externalId}, which is not on disk`);
|
||||
}
|
||||
|
||||
return { present, added, missing: absent.length };
|
||||
}
|
||||
|
||||
async function clearHostedLearn() {
|
||||
const removed = await db
|
||||
.delete(learnResources)
|
||||
.where(eq(learnResources.provider, 'pig'))
|
||||
.returning({ id: learnResources.id });
|
||||
console.log(`Removed ${removed.length} PIG-hosted learn resource(s). Files on disk are untouched.`);
|
||||
}
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const run = argv.includes('--clear')
|
||||
? clear
|
||||
: argv.includes('--clear-hosted')
|
||||
? clearHostedLearn
|
||||
// Insert only the self-hosted rows: the command to run after generating a
|
||||
// video, when reseeding the whole demo book would be beside the point.
|
||||
: argv.includes('--hosted')
|
||||
? seedHostedLearn
|
||||
: seedDemo;
|
||||
|
||||
run()
|
||||
.then(() => process.exit(0))
|
||||
.catch((error) => {
|
||||
console.error('Demo seed failed:', error);
|
||||
|
||||
Reference in New Issue
Block a user