feat(p8): meeting-studio demo + smoke-calendar; P8 verification green

Task 8.7: apps/meeting-studio (Vite, port 5178) — schedule a meeting, invite
attendees, toggle per-attendee recording consent, generate transcript (BLOCKED
until unanimous consent — KG-14), summarize, see AI summary + action-item inbox.
scripts/smoke-calendar.mjs proves the P8 guarantees end to end: direct schedule,
consent gate BLOCKED→READY (KG-14), summary linked to a P7 ai_artifact, action
items, attendee-visibility exclusion (Scenario 6), meeting_request genesis,
simulated provider sync (idempotent).

P8 verification: 95 tests green, pnpm -r build all packages+demos, boundary
passes and fails on meeting-web->service, smoke-calendar PASS, prior smokes
(realtime/inbox/support/adapter/route/ai) PASS.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-01 18:22:24 +05:30
parent ca9498ce83
commit e11964b8bc
8 changed files with 289 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>IIOS P8 — Meeting Studio</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+23
View File
@@ -0,0 +1,23 @@
{
"name": "meeting-studio",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --port 5178",
"build": "vite build"
},
"dependencies": {
"@insignia/iios-meeting-web": "workspace:*",
"@insignia/iios-kernel-client": "workspace:*",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.7.3",
"vite": "^6.0.7"
}
}
+110
View File
@@ -0,0 +1,110 @@
import { useState } from 'react';
import { useMeetings, useMeeting, useSchedule, useConsent, useSummarize, useActionItems } from '@insignia/iios-meeting-web';
export const SERVICE = 'http://localhost:3200';
const box: React.CSSProperties = { border: '1px solid #e5e7eb', borderRadius: 8, padding: 16, marginBottom: 16 };
const CONSENT_COLORS: Record<string, string> = { GRANTED: '#16a34a', UNKNOWN: '#d97706', DENIED: '#dc2626', REVOKED: '#dc2626' };
export function App({ token: _token }: { token: string }) {
const { meetings, refresh } = useMeetings({ pollMs: 3000 });
const schedule = useSchedule();
const { setConsent, generateTranscript } = useConsent();
const summarize = useSummarize();
const [selected, setSelected] = useState<string | null>(null);
const [title, setTitle] = useState('Design review');
const [attendee, setAttendee] = useState('bob');
const [busy, setBusy] = useState('');
const { meeting, refresh: refreshMeeting } = useMeeting(selected);
const { actionItems, refresh: refreshItems } = useActionItems(selected);
const doSchedule = async () => {
setBusy('scheduling…');
const m = await schedule({
meetingType: 'INTERNAL',
title,
startAt: '2026-07-02T10:00:00.000Z',
attendees: [{ userId: attendee, displayName: attendee }],
});
setSelected(m.id);
await refresh();
setBusy('');
};
const refreshAll = async () => {
await refreshMeeting();
await refreshItems();
};
return (
<div style={{ padding: 24, fontFamily: 'sans-serif', maxWidth: 900, margin: '0 auto' }}>
<h2>IIOS P8 Meeting Studio</h2>
<p style={{ color: '#666' }}>
A meeting owns calendar semantics. No transcript/summary/action-item exists until <b>every</b> attendee grants recording consent (KG-14).
</p>
<div style={box}>
<h3>1 · Schedule a meeting</h3>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="title" />
<input value={attendee} onChange={(e) => setAttendee(e.target.value)} placeholder="attendee userId" style={{ width: 140 }} />
<button onClick={() => void doSchedule()}>Schedule</button>
{busy && <span style={{ color: '#666' }}>{busy}</span>}
</div>
<ul>
{meetings.map((m) => (
<li key={m.id}>
<button onClick={() => setSelected(m.id)} style={{ fontWeight: selected === m.id ? 700 : 400 }}>
{m.title} {m.status}
</button>
</li>
))}
</ul>
</div>
{meeting && (
<div style={box}>
<h3>2 · {meeting.title} consent & transcript</h3>
<table style={{ borderCollapse: 'collapse' }}>
<tbody>
{(meeting.participants ?? []).map((p) => (
<tr key={p.id}>
<td style={{ padding: '4px 8px' }}>{p.role} · {p.actorRefId?.slice(0, 8)}</td>
<td style={{ padding: '4px 8px', color: CONSENT_COLORS[p.recordingConsent] }}>{p.recordingConsent}</td>
<td style={{ padding: '4px 8px', color: '#888' }}>visibility {p.visibility}</td>
<td style={{ padding: '4px 8px' }}>
<button onClick={async () => { await setConsent(meeting.id, p.actorRefId!, 'GRANTED'); await refreshAll(); }}>Grant</button>
<button onClick={async () => { await setConsent(meeting.id, p.actorRefId!, 'REVOKED'); await refreshAll(); }} style={{ marginLeft: 4 }}>Revoke</button>
</td>
</tr>
))}
</tbody>
</table>
<div style={{ marginTop: 10, display: 'flex', gap: 8 }}>
<button onClick={async () => { await generateTranscript(meeting.id); await refreshAll(); setBusy('transcript attempted — grant all consent for READY'); }}>
Generate transcript
</button>
<button onClick={async () => { setBusy('summarizing…'); try { await summarize(meeting.id); setBusy(''); } catch (e) { setBusy(`blocked: ${(e as Error).message}`); } await refreshAll(); }}>
Summarize (needs consent)
</button>
</div>
{busy && <p style={{ color: '#666' }}>{busy}</p>}
</div>
)}
{meeting && (
<div style={box}>
<h3>3 · Action items (inbox follow-ups)</h3>
{actionItems.length === 0 && <p style={{ color: '#666' }}>None yet summarize a consented meeting.</p>}
<ul>
{actionItems.map((a) => (
<li key={a.id}>{a.actionItem?.title ?? a.actionItemId} {a.status}</li>
))}
</ul>
</div>
)}
</div>
);
}
+32
View File
@@ -0,0 +1,32 @@
import { createRoot } from 'react-dom/client';
import { MeetingProvider } from '@insignia/iios-meeting-web';
import { useEffect, useState } from 'react';
import { App, SERVICE } from './App';
const APP_ID = 'portal-demo';
async function devToken(userId: string): Promise<string> {
const r = await fetch(`${SERVICE}/v1/dev/token`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ appId: APP_ID, userId, name: userId }),
});
if (!r.ok) throw new Error(`devToken ${r.status} (service running with IIOS_DEV_TOKENS=1?)`);
return ((await r.json()) as { token: string }).token;
}
function Root() {
const [token, setToken] = useState<string | null>(null);
useEffect(() => {
void devToken('meeting-studio').then(setToken);
}, []);
if (!token) return <div style={{ padding: 16, fontFamily: 'sans-serif' }}>loading</div>;
return (
<MeetingProvider serviceUrl={SERVICE} token={token}>
<App token={token} />
</MeetingProvider>
);
}
const el = document.getElementById('root');
if (el) createRoot(el).render(<Root />);
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM"],
"jsx": "react-jsx",
"types": ["react", "react-dom"],
"noEmit": true
},
"include": ["src/**/*.ts", "src/**/*.tsx"]
}
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: { port: 5178 },
});