diff --git a/backend/src/lib/ecosystem-phase1.test.ts b/backend/src/lib/ecosystem-phase1.test.ts new file mode 100644 index 0000000..b8d0990 --- /dev/null +++ b/backend/src/lib/ecosystem-phase1.test.ts @@ -0,0 +1,94 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + buildPhase1NoteImport, + loadLatestTranscriptArtifact, + persistPhase1NoteOutputs, +} from './ecosystem-phase1.js'; + +describe('ecosystem phase1 note import', () => { + afterEach(() => { + delete process.env.BYTELYST_ECOSYSTEM_DIR; + }); + + it('loads the latest transcript and persists a linked note artifact + events', async () => { + const root = await mkdtemp(join(tmpdir(), 'notelett-phase1-')); + process.env.BYTELYST_ECOSYSTEM_DIR = root; + + const transcriptArtifact = { + id: 'art_transcript_123', + title: 'Standup capture', + summary: 'Transcript summary', + sourceSurface: 'desktop', + createdAt: '2026-04-03T18:15:00.000Z', + tags: ['voice'], + ownership: { userId: 'user_saravana', orgId: null }, + provenance: { + originProductId: 'lysnrai', + originActionId: 'capture_123', + sessionId: 'sess_123', + runId: null, + approvalId: null, + correlationId: 'corr_123', + lineage: [ + { + stepType: 'captured', + productId: 'lysnrai', + actorType: 'user', + timestamp: '2026-04-03T18:15:00.000Z', + }, + ], + }, + payload: { + transcriptText: 'Transcript body', + transcriptSource: 'microphone', + language: 'en', + durationMs: 1000, + }, + }; + + await mkdir(join(root, 'indexes'), { recursive: true }); + await writeFile( + join(root, 'indexes', 'latest-transcript.json'), + `${JSON.stringify(transcriptArtifact, null, 2)}\n`, + 'utf-8' + ); + + const loaded = await loadLatestTranscriptArtifact(root); + const generated = buildPhase1NoteImport({ + transcriptArtifact: loaded, + workspaceId: 'ws_phase1', + userId: 'user_saravana', + now: '2026-04-03T18:17:00.000Z', + }); + + await persistPhase1NoteOutputs({ + ecosystemNoteArtifact: generated.ecosystemNoteArtifact, + createdEvent: generated.createdEvent, + linkedEvent: generated.linkedEvent, + root, + }); + + const savedNoteArtifact = JSON.parse( + await readFile(join(root, 'artifacts', 'note', `${generated.ecosystemNoteArtifact.id}.json`), 'utf-8') + ); + const savedLinkedEvent = JSON.parse( + await readFile(join(root, 'events', 'artifact.linked', `${generated.linkedEvent.eventId}.json`), 'utf-8') + ); + + expect(generated.note.sourceUri).toBe('art_transcript_123'); + expect(generated.ecosystemNoteArtifact.links).toEqual([ + { + relation: 'summarizes', + targetArtifactId: 'art_transcript_123', + }, + ]); + expect(savedNoteArtifact.payload.noteFormat).toBe('markdown'); + expect(savedLinkedEvent.payload.targetArtifactId).toBe('art_transcript_123'); + expect(savedLinkedEvent.payload.relation).toBe('summarizes'); + + await rm(root, { recursive: true, force: true }); + }); +}); diff --git a/backend/src/lib/ecosystem-phase1.ts b/backend/src/lib/ecosystem-phase1.ts new file mode 100644 index 0000000..6f26b9a --- /dev/null +++ b/backend/src/lib/ecosystem-phase1.ts @@ -0,0 +1,293 @@ +import { randomUUID } from 'node:crypto'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { dirname, join } from 'node:path'; +import type { NoteArtifactDoc } from '../modules/note-artifacts/types.js'; +import type { NoteDoc } from '../modules/notes/types.js'; + +export const DEFAULT_PHASE1_ROOT = join(homedir(), '.bytelyst', 'ecosystem', 'phase1'); + +type TranscriptArtifact = { + id: string; + title: string | null; + summary: string | null; + sourceSurface: string; + createdAt: string; + tags: string[]; + ownership: { + userId: string; + orgId?: string | null; + }; + provenance: { + originProductId: string; + originActionId?: string | null; + sessionId?: string | null; + runId?: string | null; + approvalId?: string | null; + correlationId?: string | null; + lineage: Array<{ + stepType: string; + productId: string; + actorType: 'user' | 'agent' | 'system'; + timestamp: string; + }>; + }; + payload: { + transcriptText: string; + transcriptSource: string; + language: string; + durationMs: number; + }; +}; + +type EcosystemEvent = { + eventId: string; + eventName: string; + eventVersion: 1; + occurredAt: string; + productId: string; + sourceSurface: string; + userId: string | null; + orgId?: string | null; + sessionId?: string | null; + runId?: string | null; + artifactId?: string | null; + actor: { + actorType: 'user' | 'agent' | 'system' | 'device'; + actorId?: string | null; + }; + trace: { + correlationId: string | null; + causationId: string | null; + parentEventId: string | null; + }; + payload: Record; +}; + +type NoteArtifactEnvelope = { + id: string; + artifactType: 'note'; + schemaVersion: 1; + productId: 'notelett'; + sourceSurface: string; + title: string; + summary: string; + createdAt: string; + updatedAt: string; + createdBy: { + actorType: 'agent'; + actorId: string; + }; + ownership: { + userId: string; + orgId?: string | null; + }; + visibility: { + scope: 'private'; + }; + status: string; + tags: string[]; + links: Array<{ + relation: 'summarizes'; + targetArtifactId: string; + }>; + provenance: TranscriptArtifact['provenance']; + payload: { + noteFormat: 'markdown'; + body: string; + excerpt: string; + }; +}; + +export function getPhase1Root(): string { + return process.env.BYTELYST_ECOSYSTEM_DIR ?? DEFAULT_PHASE1_ROOT; +} + +export async function loadLatestTranscriptArtifact(root = getPhase1Root()): Promise { + const raw = await readFile(join(root, 'indexes', 'latest-transcript.json'), 'utf-8'); + return JSON.parse(raw) as TranscriptArtifact; +} + +export function buildPhase1NoteImport(params: { + transcriptArtifact: TranscriptArtifact; + workspaceId: string; + userId: string; + now?: string; +}): { + note: NoteDoc; + noteArtifactDoc: NoteArtifactDoc; + ecosystemNoteArtifact: NoteArtifactEnvelope; + createdEvent: EcosystemEvent; + linkedEvent: EcosystemEvent; +} { + const { transcriptArtifact, workspaceId, userId } = params; + const now = params.now ?? new Date().toISOString(); + const noteId = `note_phase1_${randomUUID().replace(/-/g, '').slice(0, 12)}`; + const correlationId = transcriptArtifact.provenance.correlationId ?? `corr_${randomUUID().slice(0, 12)}`; + const runId = `run_note_${randomUUID().replace(/-/g, '').slice(0, 10)}`; + const title = transcriptArtifact.title || 'Imported transcript note'; + const body = `# ${title}\n\n${transcriptArtifact.payload.transcriptText}`; + const excerpt = transcriptArtifact.summary || transcriptArtifact.payload.transcriptText.slice(0, 160); + const noteArtifactId = `art_note_${randomUUID().replace(/-/g, '').slice(0, 12)}`; + + const note: NoteDoc = { + id: noteId, + productId: 'notelett', + workspaceId, + userId, + title, + body, + status: 'draft', + tags: Array.from(new Set([...transcriptArtifact.tags, 'ecosystem', 'phase1'])), + links: [transcriptArtifact.id], + sourceType: 'ecosystem-transcript', + sourceUri: transcriptArtifact.id, + createdAt: now, + updatedAt: now, + createdBy: userId, + updatedBy: userId, + agentId: 'phase1-transcript-importer', + }; + + const noteArtifactDoc: NoteArtifactDoc = { + id: `na_${randomUUID().replace(/-/g, '').slice(0, 12)}`, + productId: 'notelett', + workspaceId, + userId, + noteId, + artifactType: 'summary', + title: 'Imported transcript artifact', + description: `Transcript source ${transcriptArtifact.id}`, + blobPath: join(getPhase1Root(), 'artifacts', 'note', `${noteArtifactId}.json`), + contentType: 'application/json', + createdAt: now, + createdBy: userId, + updatedAt: now, + updatedBy: userId, + }; + + const ecosystemNoteArtifact: NoteArtifactEnvelope = { + id: noteArtifactId, + artifactType: 'note', + schemaVersion: 1, + productId: 'notelett', + sourceSurface: 'backend', + title, + summary: excerpt, + createdAt: now, + updatedAt: now, + createdBy: { + actorType: 'agent', + actorId: 'phase1-transcript-importer', + }, + ownership: { + userId, + orgId: transcriptArtifact.ownership.orgId ?? null, + }, + visibility: { + scope: 'private', + }, + status: note.status, + tags: note.tags, + links: [ + { + relation: 'summarizes', + targetArtifactId: transcriptArtifact.id, + }, + ], + provenance: { + ...transcriptArtifact.provenance, + runId, + lineage: [ + ...transcriptArtifact.provenance.lineage, + { + stepType: 'note-created', + productId: 'notelett', + actorType: 'agent', + timestamp: now, + }, + ], + }, + payload: { + noteFormat: 'markdown', + body, + excerpt, + }, + }; + + const createdEvent: EcosystemEvent = { + eventId: `evt_note_created_${randomUUID().replace(/-/g, '').slice(0, 12)}`, + eventName: 'artifact.created', + eventVersion: 1, + occurredAt: now, + productId: 'notelett', + sourceSurface: 'backend', + userId, + orgId: transcriptArtifact.ownership.orgId ?? null, + sessionId: transcriptArtifact.provenance.sessionId ?? null, + runId, + artifactId: ecosystemNoteArtifact.id, + actor: { + actorType: 'agent', + actorId: 'phase1-transcript-importer', + }, + trace: { + correlationId, + causationId: null, + parentEventId: null, + }, + payload: { + artifactType: 'note', + title, + status: note.status, + }, + }; + + const linkedEvent: EcosystemEvent = { + eventId: `evt_note_linked_${randomUUID().replace(/-/g, '').slice(0, 12)}`, + eventName: 'artifact.linked', + eventVersion: 1, + occurredAt: now, + productId: 'notelett', + sourceSurface: 'backend', + userId, + orgId: transcriptArtifact.ownership.orgId ?? null, + sessionId: transcriptArtifact.provenance.sessionId ?? null, + runId, + artifactId: ecosystemNoteArtifact.id, + actor: { + actorType: 'agent', + actorId: 'phase1-transcript-importer', + }, + trace: { + correlationId, + causationId: createdEvent.eventId, + parentEventId: createdEvent.eventId, + }, + payload: { + sourceArtifactId: ecosystemNoteArtifact.id, + targetArtifactId: transcriptArtifact.id, + relation: 'summarizes', + }, + }; + + return { note, noteArtifactDoc, ecosystemNoteArtifact, createdEvent, linkedEvent }; +} + +export async function persistPhase1NoteOutputs(params: { + ecosystemNoteArtifact: NoteArtifactEnvelope; + createdEvent: EcosystemEvent; + linkedEvent: EcosystemEvent; + root?: string; +}): Promise { + const root = params.root ?? getPhase1Root(); + await writeJson(join(root, 'artifacts', 'note', `${params.ecosystemNoteArtifact.id}.json`), params.ecosystemNoteArtifact); + await writeJson(join(root, 'events', 'artifact.created', `${params.createdEvent.eventId}.json`), params.createdEvent); + await writeJson(join(root, 'events', 'artifact.linked', `${params.linkedEvent.eventId}.json`), params.linkedEvent); + await writeJson(join(root, 'indexes', 'latest-note.json'), params.ecosystemNoteArtifact); +} + +async function writeJson(path: string, payload: unknown): Promise { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, `${JSON.stringify(payload, null, 2)}\n`, 'utf-8'); +} diff --git a/backend/src/modules/ecosystem-phase1/routes.ts b/backend/src/modules/ecosystem-phase1/routes.ts new file mode 100644 index 0000000..05f030c --- /dev/null +++ b/backend/src/modules/ecosystem-phase1/routes.ts @@ -0,0 +1,44 @@ +import type { FastifyInstance } from 'fastify'; +import { z } from 'zod'; +import { BadRequestError } from '@bytelyst/errors'; +import { requireWriter } from '../../lib/auth.js'; +import { createNote } from '../notes/repository.js'; +import { createNoteArtifact } from '../note-artifacts/repository.js'; +import { + buildPhase1NoteImport, + loadLatestTranscriptArtifact, + persistPhase1NoteOutputs, +} from '../../lib/ecosystem-phase1.js'; + +const ImportLatestTranscriptSchema = z.object({ + workspaceId: z.string().min(1).max(128), +}); + +export async function ecosystemPhase1Routes(app: FastifyInstance) { + app.post('/ecosystem/phase1/import-latest-transcript', async req => { + const auth = await requireWriter(req); + const parsed = ImportLatestTranscriptSchema.safeParse(req.body ?? {}); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map(issue => issue.message).join('; ')); + } + + const transcriptArtifact = await loadLatestTranscriptArtifact(); + const generated = buildPhase1NoteImport({ + transcriptArtifact, + workspaceId: parsed.data.workspaceId, + userId: auth.sub, + }); + + const note = await createNote(generated.note); + const noteArtifactDoc = await createNoteArtifact(generated.noteArtifactDoc); + await persistPhase1NoteOutputs(generated); + + return { + note, + noteArtifact: noteArtifactDoc, + ecosystemArtifactId: generated.ecosystemNoteArtifact.id, + linkedTranscriptArtifactId: transcriptArtifact.id, + events: [generated.createdEvent.eventId, generated.linkedEvent.eventId], + }; + }); +} diff --git a/backend/src/server.ts b/backend/src/server.ts index a8f2c43..b252219 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -1,6 +1,7 @@ import { createServiceApp, registerOptionalJwtContext, startService } from '@bytelyst/fastify-core'; import { jwtVerify } from 'jose'; import { noteAgentActionRoutes } from './modules/note-agent-actions/routes.js'; +import { ecosystemPhase1Routes } from './modules/ecosystem-phase1/routes.js'; import { noteArtifactRoutes } from './modules/note-artifacts/routes.js'; import { noteRoutes } from './modules/notes/routes.js'; import { noteRelationshipRoutes } from './modules/note-relationships/routes.js'; @@ -51,6 +52,7 @@ async function registerApiPlugin(plugin: unknown) { } await registerApiPlugin(noteAgentActionRoutes); +await registerApiPlugin(ecosystemPhase1Routes); await registerApiPlugin(noteArtifactRoutes); await registerApiPlugin(noteRoutes); await registerApiPlugin(noteRelationshipRoutes);