diff --git a/.gitignore b/.gitignore index 957ea9bc6b..beece82369 100644 --- a/.gitignore +++ b/.gitignore @@ -85,6 +85,11 @@ __pycache__/ *.pyo # Generated platform companion build metadata (do not commit) packages/sdk/langs/python/platforms/**/*.egg-info/ +# Generated python packaging metadata (do not commit) +packages/sdk/langs/python/**/*.egg-info/ + +# Local runtime state for collaboration examples +examples/collaboration/fastapi/.superdoc-state/ # Generated artifacts — run `pnpm run generate:all` to produce packages/document-api/generated/ diff --git a/apps/cli/src/__tests__/cli.test.ts b/apps/cli/src/__tests__/cli.test.ts index b04f77b4c7..e420844ead 100644 --- a/apps/cli/src/__tests__/cli.test.ts +++ b/apps/cli/src/__tests__/cli.test.ts @@ -1860,6 +1860,39 @@ describe('superdoc CLI', () => { expect(unsupportedTokenEnvelope.error.code).toBe('VALIDATION_ERROR'); }); + test('open rejects primitive --collaboration-json with --on-missing', async () => { + const result = await runCli(['open', SAMPLE_DOC, '--collaboration-json', '"oops"', '--on-missing', 'blank']); + expect(result.code).toBe(1); + const envelope = parseJsonOutput(result); + expect(envelope.error.code).toBe('VALIDATION_ERROR'); + }); + + test('open rejects array --collaboration-json', async () => { + const result = await runCli(['open', SAMPLE_DOC, '--collaboration-json', '[1, 2]']); + expect(result.code).toBe(1); + const envelope = parseJsonOutput(result); + expect(envelope.error.code).toBe('VALIDATION_ERROR'); + }); + + test('open with collaboration does not require a doc path', async () => { + const result = await runCli([ + 'open', + '--collaboration-json', + JSON.stringify({ + providerType: 'hocuspocus', + url: 'ws://127.0.0.1:9', + syncTimeoutMs: 1, + }), + '--session', + 'collab-no-doc', + ]); + + expect(result.code).toBe(1); + const envelope = parseJsonOutput(result); + // Verify we no longer fail argument validation for a missing document path. + expect(envelope.error.code).not.toBe('MISSING_REQUIRED'); + }); + test('open with --session is idempotent for the same session id', async () => { const firstOpen = await runCli(['open', SAMPLE_DOC, '--session', 'draft-a']); expect(firstOpen.code).toBe(0); @@ -2118,4 +2151,30 @@ describe('superdoc CLI', () => { const closeResult = await runCli(['close', '--discard']); expect(closeResult.code).toBe(0); }); + + test('open with --user-name and --user-email succeeds', async () => { + const openResult = await runCli([ + 'open', + SAMPLE_DOC, + '--user-name', + 'Review Bot', + '--user-email', + 'bot@example.com', + ]); + expect(openResult.code).toBe(0); + + const envelope = parseJsonOutput>(openResult); + expect(envelope.data.active).toBe(true); + + const closeResult = await runCli(['close', '--discard']); + expect(closeResult.code).toBe(0); + }); + + test('open with --user-name only (no --user-email) succeeds', async () => { + const openResult = await runCli(['open', SAMPLE_DOC, '--user-name', 'Bot']); + expect(openResult.code).toBe(0); + + const closeResult = await runCli(['close', '--discard']); + expect(closeResult.code).toBe(0); + }); }); diff --git a/apps/cli/src/__tests__/lib/context.test.ts b/apps/cli/src/__tests__/lib/context.test.ts new file mode 100644 index 0000000000..ae316faa94 --- /dev/null +++ b/apps/cli/src/__tests__/lib/context.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, test } from 'bun:test'; +import { normalizeContextMetadata, type ContextMetadata } from '../../lib/context'; + +function makeMetadata(overrides: Partial = {}): ContextMetadata { + return { + contextId: 'test-session', + projectRoot: '/tmp/test', + source: 'path', + sourcePath: '/tmp/test/doc.docx', + workingDocPath: '/tmp/test/working.docx', + dirty: false, + revision: 0, + sessionType: 'local', + openedAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + ...overrides, + }; +} + +describe('normalizeContextMetadata', () => { + describe('user normalization', () => { + test('preserves valid user', () => { + const metadata = makeMetadata({ user: { name: 'Bot', email: 'bot@co.com' } }); + const result = normalizeContextMetadata(metadata); + expect(result.user).toEqual({ name: 'Bot', email: 'bot@co.com' }); + }); + + test('strips non-object user', () => { + const metadata = makeMetadata({ user: 42 as any }); + const result = normalizeContextMetadata(metadata); + expect(result.user).toBeUndefined(); + }); + + test('strips user with non-string name', () => { + const metadata = makeMetadata({ user: { name: 123, email: 'a@b.com' } as any }); + const result = normalizeContextMetadata(metadata); + expect(result.user).toBeUndefined(); + }); + + test('strips user with empty name', () => { + const metadata = makeMetadata({ user: { name: '', email: 'a@b.com' } }); + const result = normalizeContextMetadata(metadata); + expect(result.user).toBeUndefined(); + }); + + test('strips user with non-string email', () => { + const metadata = makeMetadata({ user: { name: 'Bot', email: 123 } as any }); + const result = normalizeContextMetadata(metadata); + expect(result.user).toBeUndefined(); + }); + + test('preserves user with empty email', () => { + const metadata = makeMetadata({ user: { name: 'Bot', email: '' } }); + const result = normalizeContextMetadata(metadata); + expect(result.user).toEqual({ name: 'Bot', email: '' }); + }); + + test('strips array user', () => { + const metadata = makeMetadata({ user: ['Bot'] as any }); + const result = normalizeContextMetadata(metadata); + expect(result.user).toBeUndefined(); + }); + + test('strips string user', () => { + const metadata = makeMetadata({ user: 'Bot' as any }); + const result = normalizeContextMetadata(metadata); + expect(result.user).toBeUndefined(); + }); + + test('preserves undefined user', () => { + const metadata = makeMetadata(); + const result = normalizeContextMetadata(metadata); + expect(result.user).toBeUndefined(); + }); + }); + + describe('session type normalization', () => { + test('normalizes unknown session type to local', () => { + const metadata = makeMetadata({ sessionType: 'unknown' as any }); + const result = normalizeContextMetadata(metadata); + expect(result.sessionType).toBe('local'); + }); + + test('preserves collab session type with valid collaboration', () => { + const metadata = makeMetadata({ + sessionType: 'collab', + collaboration: { + providerType: 'hocuspocus', + url: 'ws://localhost:4000', + documentId: 'test-doc', + }, + }); + const result = normalizeContextMetadata(metadata); + expect(result.sessionType).toBe('collab'); + expect(result.collaboration).toBeDefined(); + }); + + test('falls back to local when collab profile is missing', () => { + const metadata = makeMetadata({ sessionType: 'collab' }); + const result = normalizeContextMetadata(metadata); + expect(result.sessionType).toBe('local'); + expect(result.collaboration).toBeUndefined(); + }); + }); +}); diff --git a/apps/cli/src/__tests__/lib/operation-runtime-metadata.test.ts b/apps/cli/src/__tests__/lib/operation-runtime-metadata.test.ts index 611d100aa2..13d9e060c8 100644 --- a/apps/cli/src/__tests__/lib/operation-runtime-metadata.test.ts +++ b/apps/cli/src/__tests__/lib/operation-runtime-metadata.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'bun:test'; -import { CLI_OPERATION_METADATA, type CliOperationId } from '../../cli'; +import { CLI_OPERATION_METADATA, CLI_OPERATION_OPTION_SPECS, type CliOperationId } from '../../cli'; import { getOperationRuntimeMetadata } from '../../lib/operation-runtime-metadata'; describe('operation runtime metadata', () => { @@ -42,4 +42,18 @@ describe('operation runtime metadata', () => { expect(describeCommand.context.supportsStateless).toBe(true); expect(describeCommand.context.supportsSession).toBe(false); }); + + test('doc.open metadata includes userName and userEmail params', () => { + const openMeta = CLI_OPERATION_METADATA['doc.open']; + const paramNames = openMeta.params.map((p) => p.name); + expect(paramNames).toContain('userName'); + expect(paramNames).toContain('userEmail'); + }); + + test('doc.open option specs include user-name and user-email flags', () => { + const openOptions = CLI_OPERATION_OPTION_SPECS['doc.open']; + const optionNames = openOptions.map((o) => o.name); + expect(optionNames).toContain('user-name'); + expect(optionNames).toContain('user-email'); + }); }); diff --git a/apps/cli/src/cli/cli-only-operation-definitions.ts b/apps/cli/src/cli/cli-only-operation-definitions.ts index 309a0660ac..18f7347ff5 100644 --- a/apps/cli/src/cli/cli-only-operation-definitions.ts +++ b/apps/cli/src/cli/cli-only-operation-definitions.ts @@ -65,6 +65,14 @@ export const CLI_ONLY_OPERATION_DEFINITIONS: Record = { { name: 'collabUrl', kind: 'flag', flag: 'collab-url', type: 'string' }, { name: 'contentOverride', kind: 'flag', flag: 'content-override', type: 'string' }, { name: 'overrideType', kind: 'flag', flag: 'override-type', type: 'string' }, + { name: 'onMissing', kind: 'flag', flag: 'on-missing', type: 'string' }, + { name: 'bootstrapSettlingMs', kind: 'flag', flag: 'bootstrap-settling-ms', type: 'number' }, + USER_NAME_PARAM, + USER_EMAIL_PARAM, ], constraints: null, }, diff --git a/apps/cli/src/commands/open.ts b/apps/cli/src/commands/open.ts index 3b5d8ecc2f..183c603b8d 100644 --- a/apps/cli/src/commands/open.ts +++ b/apps/cli/src/commands/open.ts @@ -1,4 +1,4 @@ -import { getBooleanOption, getStringOption, resolveDocArg, resolveJsonInput } from '../lib/args'; +import { getBooleanOption, getNumberOption, getStringOption, resolveDocArg, resolveJsonInput } from '../lib/args'; import { parseCollaborationInput, resolveCollaborationProfile } from '../lib/collaboration'; import { getProjectRoot, @@ -17,6 +17,7 @@ import { generateSessionId } from '../lib/session'; import type { CommandContext, CommandExecution } from '../lib/types'; const VALID_OVERRIDE_TYPES = new Set(['markdown', 'html', 'text']); +const VALID_ON_MISSING = new Set(['seedFromDoc', 'blank', 'error']); export async function runOpen(tokens: string[], context: CommandContext): Promise { const { parsed, help } = parseOperationArgs('doc.open', tokens, { @@ -51,6 +52,10 @@ export async function runOpen(tokens: string[], context: CommandContext): Promis const collabDocumentId = getStringOption(parsed, 'collab-document-id'); const contentOverride = getStringOption(parsed, 'content-override'); const overrideType = getStringOption(parsed, 'override-type'); + const onMissing = getStringOption(parsed, 'on-missing'); + const bootstrapSettlingMs = getNumberOption(parsed, 'bootstrap-settling-ms'); + const userName = getStringOption(parsed, 'user-name'); + const userEmail = getStringOption(parsed, 'user-email'); // Validate contentOverride / overrideType co-requirement. // Use != null checks so that intentional empty-string overrides are honored. @@ -67,6 +72,13 @@ export async function runOpen(tokens: string[], context: CommandContext): Promis ); } + if (onMissing != null && !VALID_ON_MISSING.has(onMissing)) { + throw new CliError( + 'INVALID_ARGUMENT', + `open: --on-missing must be one of: seedFromDoc, blank, error. Got "${onMissing}".`, + ); + } + if (collaborationPayload != null && (collabUrl || collabDocumentId)) { throw new CliError( 'INVALID_ARGUMENT', @@ -84,12 +96,21 @@ export async function runOpen(tokens: string[], context: CommandContext): Promis let collaborationInput; if (collaborationPayload != null) { - collaborationInput = parseCollaborationInput(collaborationPayload); + if (typeof collaborationPayload !== 'object' || Array.isArray(collaborationPayload)) { + throw new CliError('VALIDATION_ERROR', 'open: --collaboration-json must be a JSON object.'); + } + const payload = collaborationPayload as Record; + if (onMissing != null && !('onMissing' in payload)) payload.onMissing = onMissing; + if (bootstrapSettlingMs != null && !('bootstrapSettlingMs' in payload)) + payload.bootstrapSettlingMs = bootstrapSettlingMs; + collaborationInput = parseCollaborationInput(payload); } else if (collabUrl) { collaborationInput = parseCollaborationInput({ providerType: 'hocuspocus', url: collabUrl, documentId: collabDocumentId, + ...(onMissing != null ? { onMissing } : {}), + ...(bootstrapSettlingMs != null ? { bootstrapSettlingMs } : {}), }); } else if (collabDocumentId) { throw new CliError('MISSING_REQUIRED', 'open: --collab-document-id requires --collab-url.'); @@ -98,6 +119,16 @@ export async function runOpen(tokens: string[], context: CommandContext): Promis const collaboration = collaborationInput ? resolveCollaborationProfile(collaborationInput, sessionId) : undefined; const sessionType = collaboration ? 'collab' : 'local'; + if (!collaboration && (onMissing != null || bootstrapSettlingMs != null)) { + throw new CliError( + 'INVALID_ARGUMENT', + 'open: --on-missing and --bootstrap-settling-ms require collaboration mode (--collaboration-json or --collab-url).', + ); + } + + // Build user identity when either flag is provided. + const user = userName != null || userEmail != null ? { name: userName ?? 'CLI', email: userEmail ?? '' } : undefined; + // Build editor open options from override params const editorOpenOptions: Record = {}; if (contentOverride != null && overrideType) { @@ -141,13 +172,10 @@ export async function runOpen(tokens: string[], context: CommandContext): Promis ); } - if (collaboration && doc == null) { - throw new CliError('MISSING_REQUIRED', 'open: a document path is required when using collaboration.'); - } - const opened = collaboration - ? await openCollaborativeDocument(doc!, context.io, collaboration) - : await openDocument(doc, context.io, { editorOpenOptions }); + ? await openCollaborativeDocument(doc, context.io, collaboration, { user }) + : await openDocument(doc, context.io, { editorOpenOptions, user }); + const bootstrap = 'bootstrap' in opened ? opened.bootstrap : undefined; let adoptedToHostPool = false; try { const output = await exportToPath(opened.editor, paths.workingDocPath, true); @@ -163,6 +191,7 @@ export async function runOpen(tokens: string[], context: CommandContext): Promis sourceSnapshot, sessionType, collaboration, + user, }); await writeContextMetadata(paths, metadata); @@ -187,6 +216,7 @@ export async function runOpen(tokens: string[], context: CommandContext): Promis dirty: metadata.dirty, sessionType: metadata.sessionType, collaboration: metadata.collaboration, + bootstrap, openedAt: metadata.openedAt, updatedAt: metadata.updatedAt, }, diff --git a/apps/cli/src/host/collab-session-pool.ts b/apps/cli/src/host/collab-session-pool.ts index 28e20f9732..1580683ac0 100644 --- a/apps/cli/src/host/collab-session-pool.ts +++ b/apps/cli/src/host/collab-session-pool.ts @@ -1,7 +1,7 @@ import type { CollaborationProfile } from '../lib/collaboration'; import { openCollaborativeDocument, type OpenedDocument } from '../lib/document'; import { CliError } from '../lib/errors'; -import type { CliIO } from '../lib/types'; +import type { CliIO, UserIdentity } from '../lib/types'; /** Metadata describing a document editing session and its optional collaboration configuration. */ export interface CollaborationSessionMetadata { @@ -10,6 +10,7 @@ export interface CollaborationSessionMetadata { collaboration?: CollaborationProfile; sourcePath?: string; workingDocPath: string; + user?: UserIdentity; } type SessionFingerprint = { @@ -24,9 +25,10 @@ type PooledSessionHandle = { }; type OpenCollaborativeDocumentFn = ( - docPath: string, + docPath: string | undefined, io: CliIO, profile: CollaborationProfile, + options?: { user?: UserIdentity }, ) => Promise; function profileToKey(profile: CollaborationProfile): string { @@ -36,6 +38,8 @@ function profileToKey(profile: CollaborationProfile): string { documentId: profile.documentId, tokenEnv: profile.tokenEnv ?? null, syncTimeoutMs: profile.syncTimeoutMs ?? null, + onMissing: profile.onMissing ?? null, + bootstrapSettlingMs: profile.bootstrapSettlingMs ?? null, }); } @@ -120,7 +124,7 @@ export class InMemoryCollaborationSessionPool implements CollaborationSessionPoo // Safe to assert: buildFingerprint above already validated metadata.collaboration const profile = metadata.collaboration!; - const opened = await this.openCollaborative(docPath, io, profile); + const opened = await this.openCollaborative(docPath, io, profile, { user: metadata.user }); const created: PooledSessionHandle = { opened, fingerprint, diff --git a/apps/cli/src/lib/__tests__/bootstrap.test.ts b/apps/cli/src/lib/__tests__/bootstrap.test.ts new file mode 100644 index 0000000000..d341d0dd67 --- /dev/null +++ b/apps/cli/src/lib/__tests__/bootstrap.test.ts @@ -0,0 +1,356 @@ +import { describe, expect, test } from 'bun:test'; +import { Doc as YDoc, XmlElement } from 'yjs'; +import { + DEFAULT_BOOTSTRAP_SETTLING_MS, + DEFAULT_BOOTSTRAP_JITTER_MS, + detectRoomState, + resolveBootstrapDecision, + writeBootstrapMarker, + claimBootstrap, + detectBootstrapRace, + type BootstrapMarker, +} from '../bootstrap'; + +// --------------------------------------------------------------------------- +// detectRoomState +// --------------------------------------------------------------------------- + +describe('detectRoomState', () => { + test('returns "empty" for a fresh ydoc', () => { + const ydoc = new YDoc(); + expect(detectRoomState(ydoc)).toBe('empty'); + }); + + test('returns "populated" when XML fragment has content', () => { + const ydoc = new YDoc(); + const fragment = ydoc.getXmlFragment('supereditor'); + fragment.insert(0, [new XmlElement('p')]); + expect(detectRoomState(ydoc)).toBe('populated'); + }); + + test('returns "populated" when meta map has finalized bootstrap marker', () => { + const ydoc = new YDoc(); + ydoc.getMap('meta').set('bootstrap', { version: 1, source: 'doc' }); + expect(detectRoomState(ydoc)).toBe('populated'); + }); + + test('returns "populated" when meta map has non-bootstrap entries', () => { + const ydoc = new YDoc(); + ydoc.getMap('meta').set('docx', 'some-content'); + expect(detectRoomState(ydoc)).toBe('populated'); + }); + + test('returns "empty" when meta map only has a pending bootstrap marker (stale claim recovery)', () => { + const ydoc = new YDoc(); + ydoc.getMap('meta').set('bootstrap', { + version: 1, + clientId: 999, + seededAt: new Date().toISOString(), + source: 'pending', + }); + expect(detectRoomState(ydoc)).toBe('empty'); + }); + + test('returns "populated" when meta has pending marker plus other keys', () => { + const ydoc = new YDoc(); + const metaMap = ydoc.getMap('meta'); + metaMap.set('bootstrap', { version: 1, source: 'pending' }); + metaMap.set('docx', 'content'); + expect(detectRoomState(ydoc)).toBe('populated'); + }); +}); + +// --------------------------------------------------------------------------- +// resolveBootstrapDecision +// --------------------------------------------------------------------------- + +describe('resolveBootstrapDecision', () => { + test('populated room always joins', () => { + expect(resolveBootstrapDecision('populated', 'seedFromDoc', true)).toEqual({ action: 'join' }); + expect(resolveBootstrapDecision('populated', 'seedFromDoc', false)).toEqual({ action: 'join' }); + expect(resolveBootstrapDecision('populated', 'blank', true)).toEqual({ action: 'join' }); + expect(resolveBootstrapDecision('populated', 'error', true)).toEqual({ action: 'join' }); + }); + + test('empty + seedFromDoc + hasDoc -> seed from doc', () => { + expect(resolveBootstrapDecision('empty', 'seedFromDoc', true)).toEqual({ action: 'seed', source: 'doc' }); + }); + + test('empty + seedFromDoc + no doc -> seed from blank', () => { + expect(resolveBootstrapDecision('empty', 'seedFromDoc', false)).toEqual({ action: 'seed', source: 'blank' }); + }); + + test('empty + blank -> seed from blank regardless of hasDoc', () => { + expect(resolveBootstrapDecision('empty', 'blank', true)).toEqual({ action: 'seed', source: 'blank' }); + expect(resolveBootstrapDecision('empty', 'blank', false)).toEqual({ action: 'seed', source: 'blank' }); + }); + + test('empty + error -> error', () => { + const result = resolveBootstrapDecision('empty', 'error', true); + expect(result.action).toBe('error'); + expect((result as { reason: string }).reason).toContain('onMissing'); + }); +}); + +// --------------------------------------------------------------------------- +// writeBootstrapMarker +// --------------------------------------------------------------------------- + +describe('writeBootstrapMarker', () => { + test('writes marker to meta map with correct shape', () => { + const ydoc = new YDoc(); + writeBootstrapMarker(ydoc, 'doc'); + + const marker = ydoc.getMap('meta').get('bootstrap') as BootstrapMarker; + expect(marker).toBeDefined(); + expect(marker.version).toBe(1); + expect(marker.clientId).toBe(ydoc.clientID); + expect(marker.source).toBe('doc'); + expect(typeof marker.seededAt).toBe('string'); + }); + + test('finalized marker makes detectRoomState return populated', () => { + const ydoc = new YDoc(); + writeBootstrapMarker(ydoc, 'doc'); + expect(detectRoomState(ydoc)).toBe('populated'); + }); +}); + +// --------------------------------------------------------------------------- +// claimBootstrap +// --------------------------------------------------------------------------- + +describe('claimBootstrap', () => { + test('returns granted when this client owns the marker', async () => { + const ydoc = new YDoc(); + const result = await claimBootstrap(ydoc, 0, 0); + expect(result.granted).toBe(true); + + const marker = ydoc.getMap('meta').get('bootstrap') as BootstrapMarker; + expect(marker.clientId).toBe(ydoc.clientID); + }); + + test('claim marker has source "pending"', async () => { + const ydoc = new YDoc(); + await claimBootstrap(ydoc, 0, 0); + + const marker = ydoc.getMap('meta').get('bootstrap') as BootstrapMarker; + expect(marker.source).toBe('pending'); + }); + + test('returns denied with competitor info when another client overwrites during settling', async () => { + const ydoc = new YDoc(); + const otherClientId = ydoc.clientID + 1; + const metaMap = ydoc.getMap('meta'); + + const promise = claimBootstrap(ydoc, 20, 0); + + // Overwrite with the other client's marker during the settling window + setTimeout(() => { + metaMap.set('bootstrap', { + version: 1, + clientId: otherClientId, + seededAt: new Date().toISOString(), + source: 'pending', + }); + }, 2); + + const result = await promise; + expect(result.granted).toBe(false); + if (!result.granted) { + expect(result.competitor.observedOtherClientId).toBe(otherClientId); + expect(result.competitor.observedSource).toBe('pending'); + expect(typeof result.competitor.observedAt).toBe('string'); + } + }); + + test('observe detects late-arriving marker after sleep ends', async () => { + // Simulates network latency: the competing marker arrives just before + // the final read, but the observe handler catches it reactively. + const ydoc = new YDoc(); + const otherClientId = ydoc.clientID + 1; + const metaMap = ydoc.getMap('meta'); + + const promise = claimBootstrap(ydoc, 5, 0); + + // Overwrite at ~4ms — very close to when the sleep ends + setTimeout(() => { + metaMap.set('bootstrap', { + version: 1, + clientId: otherClientId, + seededAt: new Date().toISOString(), + source: 'pending', + }); + }, 4); + + const result = await promise; + expect(result.granted).toBe(false); + }); + + test('returns denied gracefully when marker is removed during settling', async () => { + const ydoc = new YDoc(); + const metaMap = ydoc.getMap('meta'); + + const promise = claimBootstrap(ydoc, 20, 0); + + // Another process deletes the bootstrap key during settling + setTimeout(() => { + metaMap.delete('bootstrap'); + }, 2); + + const result = await promise; + expect(result.granted).toBe(false); + if (!result.granted) { + expect(result.competitor.observedOtherClientId).toBe(0); + expect(result.competitor.observedSource).toBe('unknown'); + } + }); + + test('jitter=0 disables random delay', async () => { + const ydoc = new YDoc(); + const before = Date.now(); + await claimBootstrap(ydoc, 0, 0); + const elapsed = Date.now() - before; + // With jitter=0 and settling=0, should complete almost instantly + expect(elapsed).toBeLessThan(50); + }); + + test('stale pending marker does not block subsequent bootstrap detection', async () => { + // Simulates: claimer crashes after writing pending marker + const ydoc = new YDoc(); + ydoc.getMap('meta').set('bootstrap', { + version: 1, + clientId: 999, // some crashed client + seededAt: new Date().toISOString(), + source: 'pending', + }); + + // A new client arrives — room should still look empty + expect(detectRoomState(ydoc)).toBe('empty'); + + // So the new client can proceed to seed + const decision = resolveBootstrapDecision('empty', 'seedFromDoc', true); + expect(decision).toEqual({ action: 'seed', source: 'doc' }); + }); + + test('concurrent claimers: second claimer re-detects and joins after first seeds', async () => { + // Simulates the full claim -> re-detect -> join path for a race loser + const ydoc = new YDoc(); + const otherClientId = ydoc.clientID + 1; + const metaMap = ydoc.getMap('meta'); + + // First claimer won and finalized the marker + metaMap.set('bootstrap', { + version: 1, + clientId: otherClientId, + seededAt: new Date().toISOString(), + source: 'doc', + }); + + // Second client checks — room is now populated + expect(detectRoomState(ydoc)).toBe('populated'); + const decision = resolveBootstrapDecision('populated', 'seedFromDoc', true); + expect(decision).toEqual({ action: 'join' }); + }); +}); + +// --------------------------------------------------------------------------- +// claim loser always yields +// --------------------------------------------------------------------------- + +describe('claim loser always yields', () => { + test('loser yields even when winner marker is still pending (room looks empty)', () => { + // After a failed claim, the loser sees the room with only a pending + // marker (the winner hasn't finalized yet). detectRoomState returns + // 'empty' but the loser must NOT re-seed — they must yield. + // This tests the contract that document.ts enforces: claim loser -> join. + const ydoc = new YDoc(); + ydoc.getMap('meta').set('bootstrap', { + version: 1, + clientId: ydoc.clientID + 1, // winner + seededAt: new Date().toISOString(), + source: 'pending', + }); + + // Room looks empty because the only marker is pending + expect(detectRoomState(ydoc)).toBe('empty'); + + // But resolveBootstrapDecision would return 'seed' — which is WHY + // document.ts must force 'join' after a failed claim regardless of + // re-detected state. The decision matrix alone is not enough. + const naiveDecision = resolveBootstrapDecision('empty', 'seedFromDoc', true); + expect(naiveDecision.action).toBe('seed'); + // ^ This is the bug the unconditional yield prevents + }); +}); + +// --------------------------------------------------------------------------- +// detectBootstrapRace +// --------------------------------------------------------------------------- + +describe('detectBootstrapRace', () => { + test('returns raceSuspected: false when no competing marker arrives', async () => { + const ydoc = new YDoc(); + writeBootstrapMarker(ydoc, 'doc'); + + const result = await detectBootstrapRace(ydoc, 10); + expect(result.raceSuspected).toBe(false); + }); + + test('returns raceSuspected: true with competitor info when another finalized marker arrives', async () => { + const ydoc = new YDoc(); + const otherClientId = ydoc.clientID + 1; + writeBootstrapMarker(ydoc, 'doc'); + + const promise = detectBootstrapRace(ydoc, 20); + + // Another client's finalized marker arrives during observation + setTimeout(() => { + ydoc.getMap('meta').set('bootstrap', { + version: 1, + clientId: otherClientId, + seededAt: new Date().toISOString(), + source: 'doc', + }); + }, 5); + + const result = await promise; + expect(result.raceSuspected).toBe(true); + if (result.raceSuspected) { + expect(result.competitor.observedOtherClientId).toBe(otherClientId); + expect(result.competitor.observedSource).toBe('doc'); + expect(typeof result.competitor.observedAt).toBe('string'); + } + }); + + test('ignores changes to non-bootstrap meta keys', async () => { + const ydoc = new YDoc(); + writeBootstrapMarker(ydoc, 'doc'); + + const promise = detectBootstrapRace(ydoc, 20); + + // Unrelated meta key changes should not trigger false positive + setTimeout(() => { + ydoc.getMap('meta').set('docx', 'some-content'); + }, 5); + + const result = await promise; + expect(result.raceSuspected).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +describe('DEFAULT_BOOTSTRAP_SETTLING_MS', () => { + test('is a positive number', () => { + expect(DEFAULT_BOOTSTRAP_SETTLING_MS).toBeGreaterThan(0); + }); +}); + +describe('DEFAULT_BOOTSTRAP_JITTER_MS', () => { + test('is a positive number', () => { + expect(DEFAULT_BOOTSTRAP_JITTER_MS).toBeGreaterThan(0); + }); +}); diff --git a/apps/cli/src/lib/bootstrap.ts b/apps/cli/src/lib/bootstrap.ts new file mode 100644 index 0000000000..26b6a71203 --- /dev/null +++ b/apps/cli/src/lib/bootstrap.ts @@ -0,0 +1,277 @@ +import { Doc as YDoc, type YMapEvent } from 'yjs'; +import type { OnMissing } from './collaboration'; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** + * Default time (ms) to wait for competing bootstrap claims to propagate + * through the Yjs provider. 1500ms covers most real-world deployments + * including cross-region sync (~200-400ms RTT with margin). + */ +export const DEFAULT_BOOTSTRAP_SETTLING_MS = 1500; + +/** + * Default upper bound (ms) for the random jitter applied before writing a + * bootstrap claim. Jitter desynchronizes concurrent clients so one claim + * has time to propagate before the other is written. + */ +export const DEFAULT_BOOTSTRAP_JITTER_MS = 150; + +/** + * Time (ms) to observe the meta map after seeding for evidence that another + * client also seeded (competing finalized markers). + */ +const POST_SEED_OBSERVE_MS = 200; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type RoomState = 'populated' | 'empty'; + +export type BootstrapDecision = + | { action: 'seed'; source: 'doc' | 'blank' } + | { action: 'join' } + | { action: 'error'; reason: string }; + +export type BootstrapMarker = { + version: 1; + clientId: number; + seededAt: string; + source: string; +}; + +/** + * Debug snapshot captured when a competing bootstrap marker is observed. + * Surfaced in CLI output so operators can diagnose race conditions. + */ +export type ObservedCompetitor = { + observedOtherClientId: number; + observedSource: string; + observedAt: string; +}; + +/** + * Result of a bootstrap claim attempt. + * + * `granted` — this client won the claim. + * `denied` — another client's marker was observed; includes debug details. + */ +export type ClaimResult = { granted: true } | { granted: false; competitor: ObservedCompetitor }; + +/** + * Result of post-seed race detection. When `raceSuspected` is true, a + * competing finalized marker was observed shortly after seeding, which + * strongly suggests (but cannot prove) that two clients both seeded. + * + * This is best-effort detection — absence of a competitor does not guarantee + * exactly-once seeding. Network latency can hide competing markers beyond + * the observation window. + */ +export type RaceDetectionResult = { raceSuspected: false } | { raceSuspected: true; competitor: ObservedCompetitor }; + +// --------------------------------------------------------------------------- +// Room state detection +// --------------------------------------------------------------------------- + +export function detectRoomState(ydoc: YDoc): RoomState { + const fragment = ydoc.getXmlFragment('supereditor'); + if (fragment.length > 0) return 'populated'; + + const metaMap = ydoc.getMap('meta'); + // A pending-only bootstrap marker does NOT count as populated — the + // claimer may have crashed before seeding actual content. Only + // finalized markers (source !== 'pending') or other meta keys count. + for (const [key, value] of metaMap.entries()) { + if (key === 'bootstrap') { + const marker = value as Record | undefined; + if (marker && marker.source !== 'pending') return 'populated'; + continue; + } + return 'populated'; + } + + return 'empty'; +} + +// --------------------------------------------------------------------------- +// Bootstrap decision +// --------------------------------------------------------------------------- + +export function resolveBootstrapDecision( + roomState: RoomState, + onMissing: OnMissing, + hasDoc: boolean, +): BootstrapDecision { + if (roomState === 'populated') return { action: 'join' }; + + switch (onMissing) { + case 'seedFromDoc': + return { action: 'seed', source: hasDoc ? 'doc' : 'blank' }; + case 'blank': + return { action: 'seed', source: 'blank' }; + case 'error': + return { action: 'error', reason: 'Collaboration room is empty and onMissing is set to "error".' }; + } +} + +// --------------------------------------------------------------------------- +// Bootstrap marker +// --------------------------------------------------------------------------- + +export function writeBootstrapMarker(ydoc: YDoc, source: string): void { + const metaMap = ydoc.getMap('meta'); + const marker: BootstrapMarker = { + version: 1, + clientId: ydoc.clientID, + seededAt: new Date().toISOString(), + source, + }; + metaMap.set('bootstrap', marker); +} + +// --------------------------------------------------------------------------- +// Helpers shared by claim + race detection +// --------------------------------------------------------------------------- + +function readBootstrapMarker(ydoc: YDoc): BootstrapMarker | undefined { + return ydoc.getMap('meta').get('bootstrap') as BootstrapMarker | undefined; +} + +function snapshotCompetitor(marker: BootstrapMarker): ObservedCompetitor { + return { + observedOtherClientId: marker.clientId, + observedSource: marker.source, + observedAt: new Date().toISOString(), + }; +} + +/** + * Observe the meta map's `bootstrap` key for changes by another client. + * Returns a disposable that captures the first competing marker seen. + * + * Filters on the `bootstrap` key only (ignores unrelated meta writes). + */ +function observeCompetitor(ydoc: YDoc): { + getCompetitor(): ObservedCompetitor | null; + dispose(): void; +} { + const myClientId = ydoc.clientID; + const metaMap = ydoc.getMap('meta'); + let competitor: ObservedCompetitor | null = null; + + const handler = (event: YMapEvent) => { + if (!event.keysChanged.has('bootstrap')) return; + const marker = metaMap.get('bootstrap') as BootstrapMarker | undefined; + if (marker && marker.clientId !== myClientId && !competitor) { + competitor = snapshotCompetitor(marker); + } + }; + metaMap.observe(handler); + + return { + getCompetitor: () => competitor, + dispose: () => metaMap.unobserve(handler), + }; +} + +function sleep(ms: number): Promise { + if (ms <= 0) return Promise.resolve(); + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +// --------------------------------------------------------------------------- +// Bootstrap claim +// --------------------------------------------------------------------------- + +/** + * Attempt to claim bootstrap ownership for this client. + * + * Writes a pending marker, applies random jitter + settling delay, then + * checks whether this client still owns the marker. An `observe` handler + * reactively detects competing markers that arrive at any point — not just + * at the end of the settling window. + * + * **Guarantee level: best-effort.** If network propagation takes longer + * than `jitterMs + settlingMs`, two clients can both believe they won. + * Use `detectBootstrapRace()` after seeding to surface suspected races. + * + * @param ydoc - The Yjs document shared across the collaboration room. + * @param settlingMs - Time to wait for competing claims to propagate. + * @param jitterMs - Upper bound for random delay before writing the claim. + * Desynchronizes concurrent clients. Pass 0 to disable. + * @returns Claim result with debug info when denied. + */ +export async function claimBootstrap( + ydoc: YDoc, + settlingMs: number, + jitterMs: number = DEFAULT_BOOTSTRAP_JITTER_MS, +): Promise { + // Random jitter reduces perfect-collision starts between concurrent clients. + await sleep(Math.floor(Math.random() * jitterMs)); + + const metaMap = ydoc.getMap('meta'); + metaMap.set('bootstrap', { + version: 1, + clientId: ydoc.clientID, + seededAt: new Date().toISOString(), + source: 'pending', + }); + + const observer = observeCompetitor(ydoc); + try { + await sleep(settlingMs); + + const competitor = observer.getCompetitor(); + if (competitor) return { granted: false, competitor }; + + const marker = readBootstrapMarker(ydoc); + if (marker?.clientId === ydoc.clientID) { + return { granted: true }; + } + + // Marker was overwritten or unexpectedly removed — claim denied. + return { + granted: false, + competitor: marker + ? snapshotCompetitor(marker) + : { observedOtherClientId: 0, observedSource: 'unknown', observedAt: new Date().toISOString() }, + }; + } finally { + observer.dispose(); + } +} + +// --------------------------------------------------------------------------- +// Post-seed race detection +// --------------------------------------------------------------------------- + +/** + * Observe the bootstrap marker briefly after seeding to detect whether + * another client also finalized a seed (suggesting a dual-seed race). + * + * **Guarantee level: best-effort.** A `raceSuspected: false` result does + * NOT guarantee exactly-once seeding — competing markers may arrive after + * the observation window closes. + * + * @param ydoc - The Yjs document shared across the collaboration room. + * @param observeMs - How long to watch for competing finalized markers. + * @returns Race detection result with debug info when suspected. + */ +export async function detectBootstrapRace( + ydoc: YDoc, + observeMs: number = POST_SEED_OBSERVE_MS, +): Promise { + const observer = observeCompetitor(ydoc); + try { + await sleep(observeMs); + + const competitor = observer.getCompetitor(); + if (competitor) return { raceSuspected: true, competitor }; + return { raceSuspected: false }; + } finally { + observer.dispose(); + } +} diff --git a/apps/cli/src/lib/collaboration.ts b/apps/cli/src/lib/collaboration.ts index 6cff8e1cb6..80c00806d7 100644 --- a/apps/cli/src/lib/collaboration.ts +++ b/apps/cli/src/lib/collaboration.ts @@ -5,6 +5,7 @@ import { CliError } from './errors'; import { isRecord } from './guards'; export type CollaborationProviderType = 'hocuspocus' | 'y-websocket'; +export type OnMissing = 'seedFromDoc' | 'blank' | 'error'; export type CollaborationInput = { providerType: CollaborationProviderType; @@ -12,6 +13,8 @@ export type CollaborationInput = { documentId?: string; tokenEnv?: string; syncTimeoutMs?: number; + onMissing?: OnMissing; + bootstrapSettlingMs?: number; }; export type CollaborationProfile = { @@ -20,6 +23,8 @@ export type CollaborationProfile = { documentId: string; tokenEnv?: string; syncTimeoutMs?: number; + onMissing?: OnMissing; + bootstrapSettlingMs?: number; }; type SyncableProvider = { @@ -87,13 +92,29 @@ export function parseCollaborationInput(value: unknown): CollaborationInput { throw new CliError('VALIDATION_ERROR', 'collaboration.params is not supported in v1.'); } - const allowedKeys = new Set(['providerType', 'url', 'documentId', 'tokenEnv', 'syncTimeoutMs']); + const allowedKeys = new Set([ + 'providerType', + 'url', + 'documentId', + 'tokenEnv', + 'syncTimeoutMs', + 'onMissing', + 'bootstrapSettlingMs', + ]); for (const key of Object.keys(value)) { if (!allowedKeys.has(key)) { throw new CliError('VALIDATION_ERROR', `collaboration.${key} is not supported.`); } } + let onMissing: OnMissing | undefined; + if (value.onMissing != null) { + if (value.onMissing !== 'seedFromDoc' && value.onMissing !== 'blank' && value.onMissing !== 'error') { + throw new CliError('VALIDATION_ERROR', 'collaboration.onMissing must be "seedFromDoc", "blank", or "error".'); + } + onMissing = value.onMissing; + } + return { providerType: normalizeProviderType(value.providerType, 'collaboration.providerType'), url: expectNonEmptyString(value.url, 'collaboration.url').trim(), @@ -101,6 +122,8 @@ export function parseCollaborationInput(value: unknown): CollaborationInput { value.documentId != null ? expectNonEmptyString(value.documentId, 'collaboration.documentId') : undefined, tokenEnv: expectOptionalEnvVarName(value.tokenEnv, 'collaboration.tokenEnv'), syncTimeoutMs: expectOptionalPositiveNumber(value.syncTimeoutMs, 'collaboration.syncTimeoutMs'), + onMissing, + bootstrapSettlingMs: expectOptionalPositiveNumber(value.bootstrapSettlingMs, 'collaboration.bootstrapSettlingMs'), }; } @@ -112,6 +135,8 @@ export function resolveCollaborationProfile(input: CollaborationInput, sessionId documentId, tokenEnv: input.tokenEnv, syncTimeoutMs: input.syncTimeoutMs, + onMissing: input.onMissing, + bootstrapSettlingMs: input.bootstrapSettlingMs, }; } diff --git a/apps/cli/src/lib/context.ts b/apps/cli/src/lib/context.ts index 4aff3fa7af..26aec64011 100644 --- a/apps/cli/src/lib/context.ts +++ b/apps/cli/src/lib/context.ts @@ -7,7 +7,7 @@ import { CliError } from './errors'; import { asRecord, pathExists } from './guards'; import type { CollaborationProfile } from './collaboration'; import { validateSessionId } from './session'; -import type { CliIO } from './types'; +import type { CliIO, UserIdentity } from './types'; const CONTEXT_VERSION = 'v1'; const ACTIVE_SESSION_FILENAME = 'active-session'; @@ -32,6 +32,7 @@ export type ContextMetadata = { revision: number; sessionType: SessionType; collaboration?: CollaborationProfile; + user?: UserIdentity; openedAt: string; updatedAt: string; lastSavedAt?: string; @@ -121,6 +122,14 @@ function normalizeSessionType(value: unknown): SessionType { return 'local'; } +function normalizeUser(value: unknown): UserIdentity | undefined { + const record = asRecord(value); + if (!record) return undefined; + if (typeof record.name !== 'string' || record.name.length === 0) return undefined; + if (typeof record.email !== 'string') return undefined; + return { name: record.name, email: record.email }; +} + function normalizeCollaborationProfile(value: unknown): CollaborationProfile | undefined { const record = asRecord(value); if (!record) return undefined; @@ -130,6 +139,8 @@ function normalizeCollaborationProfile(value: unknown): CollaborationProfile | u const documentId = record.documentId; const tokenEnv = record.tokenEnv; const syncTimeoutMs = record.syncTimeoutMs; + const onMissing = record.onMissing; + const bootstrapSettlingMs = record.bootstrapSettlingMs; if (providerType !== 'hocuspocus' && providerType !== 'y-websocket') return undefined; if (typeof url !== 'string' || url.length === 0) return undefined; @@ -141,6 +152,15 @@ function normalizeCollaborationProfile(value: unknown): CollaborationProfile | u ) { return undefined; } + if (onMissing != null && onMissing !== 'seedFromDoc' && onMissing !== 'blank' && onMissing !== 'error') { + return undefined; + } + if ( + bootstrapSettlingMs != null && + (typeof bootstrapSettlingMs !== 'number' || !Number.isFinite(bootstrapSettlingMs) || bootstrapSettlingMs <= 0) + ) { + return undefined; + } return { providerType, @@ -148,18 +168,22 @@ function normalizeCollaborationProfile(value: unknown): CollaborationProfile | u documentId, tokenEnv: typeof tokenEnv === 'string' ? tokenEnv : undefined, syncTimeoutMs: typeof syncTimeoutMs === 'number' ? syncTimeoutMs : undefined, + onMissing: onMissing as CollaborationProfile['onMissing'], + bootstrapSettlingMs: typeof bootstrapSettlingMs === 'number' ? bootstrapSettlingMs : undefined, }; } -function normalizeContextMetadata(metadata: ContextMetadata): ContextMetadata { +export function normalizeContextMetadata(metadata: ContextMetadata): ContextMetadata { const sessionType = normalizeSessionType(metadata.sessionType); const collaboration = normalizeCollaborationProfile(metadata.collaboration); + const user = normalizeUser(metadata.user); if (sessionType === 'collab' && collaboration) { return { ...metadata, sessionType, collaboration, + user, }; } @@ -167,6 +191,7 @@ function normalizeContextMetadata(metadata: ContextMetadata): ContextMetadata { ...metadata, sessionType: 'local', collaboration: undefined, + user, }; } @@ -658,6 +683,7 @@ export function createInitialContextMetadata( sourceSnapshot?: SourceSnapshot; sessionType?: SessionType; collaboration?: CollaborationProfile; + user?: UserIdentity; }, ): ContextMetadata { const timestamp = nowIso(io); @@ -673,6 +699,7 @@ export function createInitialContextMetadata( revision: 0, sessionType, collaboration: sessionType === 'collab' ? input.collaboration : undefined, + user: input.user, openedAt: timestamp, updatedAt: timestamp, sourceSnapshot: input.sourceSnapshot, diff --git a/apps/cli/src/lib/document.ts b/apps/cli/src/lib/document.ts index a46705f1bd..aa1e437e31 100644 --- a/apps/cli/src/lib/document.ts +++ b/apps/cli/src/lib/document.ts @@ -8,10 +8,21 @@ import { markdownToPmDoc } from '@superdoc/super-editor/markdown'; import { createDocumentApi, type DocumentApi } from '@superdoc/document-api'; import type { CollaborationProfile } from './collaboration'; import { createCollaborationRuntime } from './collaboration'; +import { + DEFAULT_BOOTSTRAP_SETTLING_MS, + detectRoomState, + resolveBootstrapDecision, + claimBootstrap, + writeBootstrapMarker, + detectBootstrapRace, + type RoomState, + type ObservedCompetitor, + type RaceDetectionResult, +} from './bootstrap'; import { CliError } from './errors'; import { pathExists } from './guards'; import type { ContextMetadata } from './context'; -import type { CliIO, DocumentSourceMeta, ExecutionMode } from './types'; +import type { CliIO, DocumentSourceMeta, ExecutionMode, UserIdentity } from './types'; import type { CollaborationSessionPool } from '../host/collab-session-pool'; export type EditorWithDoc = Editor & { @@ -30,6 +41,10 @@ interface OpenDocumentOptions { collaborationProvider?: unknown; /** Options passed through to Editor.open() (e.g., markdown/html/plainText for content override). */ editorOpenOptions?: Record; + /** When set, overrides Editor's auto-detected isNewFile flag. */ + isNewFile?: boolean; + /** Optional user identity for attribution (comments, tracked changes, collaboration presence). */ + user?: UserIdentity; } export interface FileOutputMeta { @@ -159,10 +174,13 @@ export async function openDocument( const isTest = process.env.NODE_ENV === 'test'; editor = await EditorRuntime.open(Buffer.from(source), { documentId: options.documentId ?? meta.path ?? 'blank.docx', - user: { id: 'cli', name: 'CLI' }, + user: options.user + ? { name: options.user.name, email: options.user.email, image: null } + : { id: 'cli', name: 'CLI' }, ...(isTest ? { telemetry: { enabled: false } } : {}), ydoc: options.ydoc, ...(options.collaborationProvider != null ? { collaborationProvider: options.collaborationProvider } : {}), + ...(options.isNewFile != null ? { isNewFile: options.isNewFile } : {}), ...passThroughEditorOpts, }); } catch (error) { @@ -226,24 +244,82 @@ export async function openDocument( }; } +/** + * Describes the outcome of the bootstrap flow for a collaborative document. + * + * `raceSuspected` is a best-effort signal — when true, a competing finalized + * marker was observed shortly after seeding, strongly suggesting (but not + * proving) that two clients both seeded. `false` does not guarantee + * exactly-once seeding. + */ +export type BootstrapResult = { + roomState: RoomState; + bootstrapApplied: boolean; + bootstrapSource?: 'doc' | 'blank'; + raceSuspected?: boolean; + raceCompetitor?: ObservedCompetitor; +}; + export async function openCollaborativeDocument( - doc: string, + doc: string | undefined, io: CliIO, profile: CollaborationProfile, -): Promise { + options: { user?: UserIdentity } = {}, +): Promise { const runtime = createCollaborationRuntime(profile); try { await runtime.waitForSync(); - const opened = await openDocument(doc, io, { + + const onMissing = profile.onMissing ?? 'seedFromDoc'; + let finalRoomState = detectRoomState(runtime.ydoc); + let decision = resolveBootstrapDecision(finalRoomState, onMissing, doc != null); + + if (decision.action === 'seed') { + const claim = await claimBootstrap(runtime.ydoc, profile.bootstrapSettlingMs ?? DEFAULT_BOOTSTRAP_SETTLING_MS); + if (!claim.granted) { + // Another client won the claim race — unconditionally yield. + // Even if the winner's marker is still pending (detectRoomState + // returns 'empty'), the winner will finalize shortly. Re-seeding + // here would produce a dual-seed race. + finalRoomState = detectRoomState(runtime.ydoc); + decision = { action: 'join' }; + } + } + + if (decision.action === 'error') { + throw new CliError('COLLABORATION_ROOM_EMPTY', decision.reason); + } + + const shouldSeed = decision.action === 'seed'; + // When joining an existing room, skip local doc reading — content + // comes from the Yjs document, not from the local file path. + const docForEditor = shouldSeed ? doc : undefined; + const opened = await openDocument(docForEditor, io, { documentId: profile.documentId, ydoc: runtime.ydoc, collaborationProvider: runtime.provider, + isNewFile: shouldSeed, + user: options.user, }); + let raceDetection: RaceDetectionResult | undefined; + if (shouldSeed) { + writeBootstrapMarker(runtime.ydoc, decision.source); + raceDetection = await detectBootstrapRace(runtime.ydoc); + } + + const bootstrap: BootstrapResult = { + roomState: finalRoomState, + bootstrapApplied: shouldSeed, + bootstrapSource: shouldSeed ? decision.source : undefined, + raceSuspected: raceDetection?.raceSuspected, + raceCompetitor: raceDetection?.raceSuspected ? raceDetection.competitor : undefined, + }; return { editor: opened.editor, meta: opened.meta, + bootstrap, dispose() { try { opened.dispose(); @@ -261,7 +337,10 @@ export async function openCollaborativeDocument( export async function openSessionDocument( doc: string, io: CliIO, - metadata: Pick, + metadata: Pick< + ContextMetadata, + 'contextId' | 'sessionType' | 'collaboration' | 'sourcePath' | 'workingDocPath' | 'user' + >, options: { sessionId?: string; executionMode?: ExecutionMode; @@ -269,7 +348,7 @@ export async function openSessionDocument( } = {}, ): Promise { if (metadata.sessionType !== 'collab') { - return openDocument(doc, io); + return openDocument(doc, io, { user: metadata.user }); } if (!metadata.collaboration) { @@ -288,12 +367,13 @@ export async function openSessionDocument( collaboration: metadata.collaboration, sourcePath: metadata.sourcePath, workingDocPath: metadata.workingDocPath, + user: metadata.user, }; return options.collabSessionPool.acquire(sessionId, doc, metadataForPool, io); } - return openCollaborativeDocument(doc, io, metadata.collaboration); + return openCollaborativeDocument(doc, io, metadata.collaboration, { user: metadata.user }); } export async function getFileChecksum(path: string): Promise { diff --git a/apps/cli/src/lib/errors.ts b/apps/cli/src/lib/errors.ts index 7717890fea..054d4d570d 100644 --- a/apps/cli/src/lib/errors.ts +++ b/apps/cli/src/lib/errors.ts @@ -20,6 +20,7 @@ export type CliErrorCode = | 'DIRTY_SESSION_EXISTS' | 'SOURCE_DRIFT_DETECTED' | 'COLLABORATION_SYNC_TIMEOUT' + | 'COLLABORATION_ROOM_EMPTY' | 'TRACK_CHANGE_NOT_FOUND' | 'TRACK_CHANGE_MODE_UNSUPPORTED' | 'TRACK_CHANGE_COMMAND_UNAVAILABLE' diff --git a/apps/cli/src/lib/mutation-orchestrator.ts b/apps/cli/src/lib/mutation-orchestrator.ts index d65d19a5de..cc998a40ab 100644 --- a/apps/cli/src/lib/mutation-orchestrator.ts +++ b/apps/cli/src/lib/mutation-orchestrator.ts @@ -249,7 +249,7 @@ export async function executeMutationOperation(request: DocOperationRequest): Pr } // --- Session + local --- - const opened = await openDocument(paths.workingDocPath, context.io); + const opened = await openDocument(paths.workingDocPath, context.io, { user: metadata.user }); try { const result = invokeOperation(opened.editor, operationId, input, invokeOptions); const document: DocumentPayload = { diff --git a/apps/cli/src/lib/read-orchestrator.ts b/apps/cli/src/lib/read-orchestrator.ts index acfded47d0..233cfe613b 100644 --- a/apps/cli/src/lib/read-orchestrator.ts +++ b/apps/cli/src/lib/read-orchestrator.ts @@ -151,7 +151,7 @@ export async function executeReadOperation(request: DocOperationRequest): Promis } } - const opened = await openDocument(paths.workingDocPath, context.io); + const opened = await openDocument(paths.workingDocPath, context.io, { user: metadata.user }); try { const result = invokeOperation(opened.editor, operationId, input); const document: DocumentPayload = { diff --git a/apps/cli/src/lib/types.ts b/apps/cli/src/lib/types.ts index 688cc41515..d4657a58a0 100644 --- a/apps/cli/src/lib/types.ts +++ b/apps/cli/src/lib/types.ts @@ -47,6 +47,9 @@ export type Selector = DocumentApiSelector; export type Query = DocumentApiQuery; export type FindOutput = DocumentApiFindOutput; +/** User identity for attribution in comments, tracked changes, and collaboration presence. */ +export type UserIdentity = { name: string; email: string }; + export type OutputMode = 'json' | 'pretty'; export type ExecutionMode = 'oneshot' | 'host'; diff --git a/apps/docs/document-engine/cli.mdx b/apps/docs/document-engine/cli.mdx index 060d92cfd2..c082728c13 100644 --- a/apps/docs/document-engine/cli.mdx +++ b/apps/docs/document-engine/cli.mdx @@ -68,13 +68,21 @@ superdoc replace \ For commands that do not support tracked mode, the CLI returns `TRACK_CHANGE_COMMAND_UNAVAILABLE`. +## User identity + +By default, the CLI attributes edits to a generic "CLI" user. Pass `--user-name` and `--user-email` on `open` to identify your automation in comments, tracked changes, and collaboration presence: + +```bash +superdoc open contract.docx --user-name "Review Bot" --user-email "bot@example.com" +``` + ## Commands ### Lifecycle | Command | Description | | --- | --- | -| `superdoc open ` | Open a document and create an editing session | +| `superdoc open ` | Open a document and create an editing session. Supports `--user-name` and `--user-email` to set the editing identity. In collaboration mode, also supports `--on-missing` and `--bootstrap-settling-ms`. | | `superdoc save` | Save the current session to disk | | `superdoc close` | Close the active session and clean up resources | diff --git a/apps/docs/document-engine/sdk-collaboration-sessions.mdx b/apps/docs/document-engine/sdk-collaboration-sessions.mdx index 055b466ea6..632d2be592 100644 --- a/apps/docs/document-engine/sdk-collaboration-sessions.mdx +++ b/apps/docs/document-engine/sdk-collaboration-sessions.mdx @@ -7,7 +7,7 @@ keywords: "sdk collaboration, yjs session, superdoc session, liveblocks sdk, hoc Use this guide when you are combining: -- The SuperDoc SDK (`@superdoc-dev/sdk` or `superdoc-sdk`) for headless/document automation +- The SuperDoc SDK (`@superdoc-dev/sdk` or `superdoc-sdk`) for headless document automation - SuperDoc real-time collaboration (Yjs providers like Liveblocks, Hocuspocus, or SuperDoc Yjs) ## Two session types @@ -17,7 +17,93 @@ These are different concepts: - **Collaboration session**: a shared Yjs room/document (`ydoc` + provider) used by browser editors. - **SDK session**: a SuperDoc Document Engine editing session created by `doc.open`. -The SDK does not directly attach to a provider object. It operates through Document Engine sessions. +When you pass collaboration params to `doc.open`, the SDK bridges both — it creates a Document Engine session that is connected to the live Yjs room. Edits made through the SDK appear in every connected browser editor, and vice versa. + +## Connecting to a collaboration session + +Pass `collabUrl` and `collabDocumentId` to `doc.open`. No file path is required — the document state comes from the running session. + + + + ```typescript + import { SuperDocClient } from '@superdoc-dev/sdk'; + + const client = new SuperDocClient(); + await client.connect(); + + await client.doc.open({ + collabUrl: 'ws://localhost:4000', + collabDocumentId: 'my-doc-room', + }); + + // Query and mutate through the shared Yjs document + const info = await client.doc.info(); + console.log('Revision:', info.revision); + + await client.doc.insert({ + target: { type: 'end' }, + content: 'Inserted by the Node SDK', + }); + + await client.doc.save(); + await client.doc.close(); + await client.dispose(); + ``` + + + ```python + import asyncio + + from superdoc import AsyncSuperDocClient + + + async def main(): + async with AsyncSuperDocClient() as client: + await client.doc.open({ + "collabUrl": "ws://localhost:4000", + "collabDocumentId": "my-doc-room", + }) + + # Query and mutate through the shared Yjs document + info = await client.doc.info({}) + print("Revision:", info.get("revision")) + + await client.doc.insert({ + "target": {"type": "end"}, + "content": "Inserted by the Python SDK", + }) + + await client.doc.save({"inPlace": True}) + await client.doc.close({}) + + + asyncio.run(main()) + ``` + + + +### Parameters + +| Parameter | Type | Description | +|---|---|---| +| `collabUrl` | `string` | WebSocket URL of the collaboration provider (e.g., `ws://localhost:4000`). | +| `collabDocumentId` | `string` | Document/room identifier on the provider. Optional — defaults to the session ID. | +| `doc` | `string` | Optional file path. Used to seed the room when it is empty. Ignored when joining a room that already has content. | +| `onMissing` | `string` | What to do when the room is empty. `seedFromDoc` (default) seeds from `doc` or a blank document. `blank` always seeds blank. `error` fails instead of seeding. | +| `bootstrapSettlingMs` | `number` | How long to wait (in ms) for other clients before seeding an empty room. Default: 300. | + +You can also pass a full provider configuration object via `collaboration` (JSON) instead of the shorthand params. See the CLI reference for the schema. + +### Empty room handling + +When you open a collaboration session, the SDK checks whether the room already has content: + +- **Room has content** — the SDK joins. Your `doc` path is ignored; content comes from the room. +- **Room is empty** — the SDK seeds the room from your `doc` file (or a blank document if no file was provided). + +Use `onMissing` to change this behavior. Set it to `error` if you only want to join existing rooms and never seed. + +When multiple clients open an empty room at the same time, the SDK uses a claim-then-verify protocol so only one client seeds. The others wait and join. ## SuperDoc JS collaboration contract @@ -41,29 +127,77 @@ new SuperDoc({ See [Collaboration Configuration](/modules/collaboration/configuration) for details. -## Using SDK sessions with collaboration-enabled documents - -The SDK can work against a document that is also edited collaboratively, but interaction is through SDK/CLI session APIs. - -```ts -import { createSuperDocClient } from "@superdoc-dev/sdk"; - -const client = createSuperDocClient(); -await client.connect(); +## SDK sessions alongside browser editors -await client.doc.open({ doc: "./contract.docx" }); +A common pattern: browser editors handle the real-time UX, while the SDK runs automated operations against the same document. -const sessions = await client.doc.session.list(); -console.log(sessions); ``` - -You can target a specific active SDK session: - -```ts -await client.doc.session.setDefault({ id: "session-id" }); -await client.doc.info(); +┌──────────────┐ ┌─────────────────┐ ┌──────────────┐ +│ Browser │◄───►│ Yjs Provider │◄───►│ Browser │ +│ Editor A │ │ (Hocuspocus) │ │ Editor B │ +└──────────────┘ └────────┬────────┘ └──────────────┘ + │ + ▼ + ┌─────────────────┐ + │ SDK Client │ + │ (doc.open + │ + │ collabUrl) │ + └─────────────────┘ ``` +All three participants share the same Yjs document. Edits from the SDK appear in real time in both browser editors. + +## Managing multiple sessions + +You can open multiple SDK sessions against different collaboration rooms: + + + + ```typescript + await client.doc.open({ + collabUrl: 'ws://localhost:4000', + collabDocumentId: 'room-a', + sessionId: 'session-a', + }); + + await client.doc.open({ + collabUrl: 'ws://localhost:4000', + collabDocumentId: 'room-b', + sessionId: 'session-b', + }); + + // Switch between sessions + await client.doc.session.setDefault({ id: 'session-a' }); + await client.doc.info(); + + await client.doc.session.setDefault({ id: 'session-b' }); + await client.doc.info(); + ``` + + + ```python + await client.doc.open({ + "collabUrl": "ws://localhost:4000", + "collabDocumentId": "room-a", + "sessionId": "session-a", + }) + + await client.doc.open({ + "collabUrl": "ws://localhost:4000", + "collabDocumentId": "room-b", + "sessionId": "session-b", + }) + + # Switch between sessions + await client.doc.session.setDefault({"id": "session-a"}) + await client.doc.info({}) + + await client.doc.session.setDefault({"id": "session-b"}) + await client.doc.info({}) + ``` + + + ## Provider choice is unchanged Provider setup still happens in your app using SuperDoc JS: @@ -72,10 +206,23 @@ Provider setup still happens in your app using SuperDoc JS: - [Hocuspocus](/guides/collaboration/hocuspocus) - [SuperDoc Yjs](/guides/collaboration/superdoc-yjs) -The SDK integration pattern does not change by provider. +The SDK connection pattern is the same regardless of which provider your browser editors use — point `collabUrl` at the provider's WebSocket endpoint. + +## User identity in collaboration + +When you set `user` on the SDK client, that identity carries through to the collaboration session. Other participants see your automation's name in presence indicators, tracked changes, and comments — not a generic "CLI" label. + +```typescript +const client = new SuperDocClient({ + user: { name: 'Review Bot', email: 'bot@example.com' }, +}); +``` + +See [User identity](/document-engine/sdks#user-identity) for more detail. ## Practical guidance - Use collaboration providers for multi-user real-time editing UX. - Use SDK sessions for backend automation, workflows, and deterministic operations. -- Keep the distinction explicit in your architecture: provider state vs SDK/CLI session state. +- Both SDKs (Node and Python) support collaboration connections with the same parameters. +- No file path is required when connecting to an existing collaboration session — the document state comes from the shared Yjs room. diff --git a/apps/docs/document-engine/sdks.mdx b/apps/docs/document-engine/sdks.mdx index 455d98abfe..04a2058607 100644 --- a/apps/docs/document-engine/sdks.mdx +++ b/apps/docs/document-engine/sdks.mdx @@ -171,6 +171,90 @@ const { createSuperDocClient } = require('@superdoc-dev/sdk'); Set `defaultChangeMode: 'tracked'` (Node) or `default_change_mode='tracked'` (Python) to make mutations use tracked changes by default. If you pass `changeMode` on a specific call, that explicit value overrides the default. +## User identity + +By default the SDK attributes edits to a generic "CLI" user. Set `user` on the client to identify your automation in comments, tracked changes, and collaboration presence: + + + + ```typescript + const client = new SuperDocClient({ + user: { name: 'Review Bot', email: 'bot@example.com' }, + }); + ``` + + + ```python + client = AsyncSuperDocClient(user={"name": "Review Bot", "email": "bot@example.com"}) + ``` + + + +The `user` is injected into every `doc.open` call. If you pass `userName` or `userEmail` on a specific `doc.open`, those per-call values take precedence. + +## Real-time collaboration + +Both SDKs can connect to a running Yjs collaboration session. Pass `collabUrl` (and optionally `collabDocumentId`) to `doc.open` — the SDK joins the session and edits the shared document state. + + + + ```typescript + import { SuperDocClient } from '@superdoc-dev/sdk'; + + const client = new SuperDocClient(); + await client.connect(); + + // Join a Hocuspocus collaboration session + await client.doc.open({ + collabUrl: 'ws://localhost:4000', + collabDocumentId: 'my-doc-room', + }); + + // Edits go through the shared Yjs document + await client.doc.insert({ + target: { type: 'end' }, + content: 'Added by the SDK', + }); + + await client.doc.save(); + await client.doc.close(); + await client.dispose(); + ``` + + + ```python + import asyncio + + from superdoc import AsyncSuperDocClient + + + async def main(): + async with AsyncSuperDocClient() as client: + # Join a Hocuspocus collaboration session + await client.doc.open({ + "collabUrl": "ws://localhost:4000", + "collabDocumentId": "my-doc-room", + }) + + # Edits go through the shared Yjs document + await client.doc.insert({ + "target": {"type": "end"}, + "content": "Added by the SDK", + }) + + await client.doc.save({"inPlace": True}) + await client.doc.close({}) + + + asyncio.run(main()) + ``` + + + +No `doc` path is needed — the document state comes from the collaboration session. If you pass a `doc` path, it is used to seed the room when empty. When the room already has content, the local file is ignored. + +See [SDK + Collaboration Sessions](/document-engine/sdk-collaboration-sessions) for more detail on how SDK sessions and collaboration sessions interact. + {/* SDK_OPERATIONS_START */} ## Available operations diff --git a/packages/collaboration-yjs/src/builder.d.ts b/packages/collaboration-yjs/src/builder.d.ts new file mode 100644 index 0000000000..37d9f384b5 --- /dev/null +++ b/packages/collaboration-yjs/src/builder.d.ts @@ -0,0 +1,17 @@ +import { SuperDocCollaboration } from './collaboration/index.js'; +import type { AutoSaveFn, AuthenticateFn, BeforeChangeFn, ChangeFn, ConfigureFn, Extension, LoadFn } from './types.js'; +export declare class CollaborationBuilder { + #private; + withName(name: string): this; + withDocumentExpiryMs(ms: number): this; + withDebounce(ms: number): this; + onConfigure(userFunction: ConfigureFn): this; + onAuthenticate(userFunction: AuthenticateFn): this; + onLoad(userFunction: LoadFn): this; + onAutoSave(userFunction: AutoSaveFn): this; + onBeforeChange(userFunction: BeforeChangeFn): this; + onChange(userFunction: ChangeFn): this; + useExtensions(exts: Extension[]): this; + build(): SuperDocCollaboration; +} +//# sourceMappingURL=builder.d.ts.map diff --git a/packages/collaboration-yjs/src/collaboration/collaboration.d.ts b/packages/collaboration-yjs/src/collaboration/collaboration.d.ts new file mode 100644 index 0000000000..ccc2d135f4 --- /dev/null +++ b/packages/collaboration-yjs/src/collaboration/collaboration.d.ts @@ -0,0 +1,12 @@ +import { DocumentManager } from '../document-manager/manager.js'; +import type { CollaborationWebSocket, ServiceConfig, SocketRequest } from '../types/service-types.js'; +export declare class SuperDocCollaboration { + #private; + readonly config: ServiceConfig; + readonly documentManager: DocumentManager; + constructor(config: ServiceConfig); + get name(): string; + welcome(socket: CollaborationWebSocket, request: SocketRequest): Promise; + has(documentId: string): boolean; +} +//# sourceMappingURL=collaboration.d.ts.map diff --git a/packages/collaboration-yjs/src/collaboration/helpers.d.ts b/packages/collaboration-yjs/src/collaboration/helpers.d.ts new file mode 100644 index 0000000000..c2f4cf3977 --- /dev/null +++ b/packages/collaboration-yjs/src/collaboration/helpers.d.ts @@ -0,0 +1,5 @@ +import type { CollaborationParams, SocketRequest } from '../types/service-types.js'; +import type { SuperDocCollaboration } from './collaboration.js'; +export declare const generateParams: (request: SocketRequest, instance?: SuperDocCollaboration) => CollaborationParams; +export declare function parseCookie(rawCookie?: string): Record; +//# sourceMappingURL=helpers.d.ts.map diff --git a/packages/collaboration-yjs/src/collaboration/index.d.ts b/packages/collaboration-yjs/src/collaboration/index.d.ts new file mode 100644 index 0000000000..c9d40cd175 --- /dev/null +++ b/packages/collaboration-yjs/src/collaboration/index.d.ts @@ -0,0 +1,2 @@ +export * from './collaboration.js'; +//# sourceMappingURL=index.d.ts.map diff --git a/packages/collaboration-yjs/src/connection-handler/handler.d.ts b/packages/collaboration-yjs/src/connection-handler/handler.d.ts new file mode 100644 index 0000000000..9b4b359536 --- /dev/null +++ b/packages/collaboration-yjs/src/connection-handler/handler.d.ts @@ -0,0 +1,25 @@ +import type { CollaborationParams, CollaborationWebSocket, Hooks, SocketRequest } from '../types/service-types.js'; +import type { DocumentManager } from '../document-manager/manager.js'; +interface ConnectionHandlerConfig { + documentManager: DocumentManager; + hooks?: Hooks; +} +/** + * Handles WebSocket connections for collaborative document editing. + * This class manages the connection lifecycle, including authentication, + * setting up the document, and handling incoming messages. + * It also provides methods to close the connection gracefully. + */ +export declare class ConnectionHandler { + #private; + documentManager: DocumentManager; + constructor({ documentManager, hooks }: ConnectionHandlerConfig); + handle( + socket: CollaborationWebSocket, + request: SocketRequest, + params: CollaborationParams + ): Promise; + hangUp(socket: CollaborationWebSocket, errorMessage: string, code?: number): void; +} +export {}; +//# sourceMappingURL=handler.d.ts.map diff --git a/packages/collaboration-yjs/src/connection-handler/index.d.ts b/packages/collaboration-yjs/src/connection-handler/index.d.ts new file mode 100644 index 0000000000..4517f03ca3 --- /dev/null +++ b/packages/collaboration-yjs/src/connection-handler/index.d.ts @@ -0,0 +1,2 @@ +export * from './handler.js'; +//# sourceMappingURL=index.d.ts.map diff --git a/packages/collaboration-yjs/src/document-manager/index.d.ts b/packages/collaboration-yjs/src/document-manager/index.d.ts new file mode 100644 index 0000000000..b98543b326 --- /dev/null +++ b/packages/collaboration-yjs/src/document-manager/index.d.ts @@ -0,0 +1,2 @@ +export * from './manager.js'; +//# sourceMappingURL=index.d.ts.map diff --git a/packages/collaboration-yjs/src/document-manager/manager.d.ts b/packages/collaboration-yjs/src/document-manager/manager.d.ts new file mode 100644 index 0000000000..7abd2464d1 --- /dev/null +++ b/packages/collaboration-yjs/src/document-manager/manager.d.ts @@ -0,0 +1,16 @@ +import { SharedSuperDoc } from '../shared-doc/index.js'; +import type { CollaborationParams, CollaborationWebSocket, ServiceConfig } from '../types/service-types.js'; +/** + * DocumentManager is responsible for managing Yjs documents. + * It handles document retrieval and debouncing updates. + */ +export declare class DocumentManager { + #private; + debounceMs: number; + constructor(config: ServiceConfig); + get(documentId: string): SharedSuperDoc | null; + getDocument(documentId: string, userParams: CollaborationParams): Promise; + releaseConnection(documentId: string, socket: CollaborationWebSocket): void; + has(documentId: string): boolean; +} +//# sourceMappingURL=manager.d.ts.map diff --git a/packages/collaboration-yjs/src/index.d.ts b/packages/collaboration-yjs/src/index.d.ts new file mode 100644 index 0000000000..ab42d955f6 --- /dev/null +++ b/packages/collaboration-yjs/src/index.d.ts @@ -0,0 +1,17 @@ +export { CollaborationBuilder } from './builder.js'; +export { SuperDocCollaboration } from './collaboration/index.js'; +export type { + CollaborationParams, + CollaborationWebSocket, + SocketRequest, + UserContext, + ServiceConfig, + Hooks, + ConfigureFn, + AuthenticateFn, + LoadFn, + BeforeChangeFn, + ChangeFn, + AutoSaveFn, +} from './types/service-types.js'; +//# sourceMappingURL=index.d.ts.map diff --git a/packages/collaboration-yjs/src/internal-logger/logger.d.ts b/packages/collaboration-yjs/src/internal-logger/logger.d.ts new file mode 100644 index 0000000000..aa6adce5af --- /dev/null +++ b/packages/collaboration-yjs/src/internal-logger/logger.d.ts @@ -0,0 +1,10 @@ +declare const COLORS: { + ConnectionHandler: string; + DocumentManager: string; + SuperDocCollaboration: string; + reset: string; +}; +export type Logger = (...args: unknown[]) => void; +export declare function createLogger(label: keyof typeof COLORS | string): Logger; +export {}; +//# sourceMappingURL=logger.d.ts.map diff --git a/packages/collaboration-yjs/src/shared-doc/callback.d.ts b/packages/collaboration-yjs/src/shared-doc/callback.d.ts new file mode 100644 index 0000000000..e66df21faf --- /dev/null +++ b/packages/collaboration-yjs/src/shared-doc/callback.d.ts @@ -0,0 +1,4 @@ +import type { SharedSuperDoc } from './shared-doc.js'; +export declare const isCallbackSet: boolean; +export declare const callbackHandler: (doc: SharedSuperDoc) => void; +//# sourceMappingURL=callback.d.ts.map diff --git a/packages/collaboration-yjs/src/shared-doc/constants.d.ts b/packages/collaboration-yjs/src/shared-doc/constants.d.ts new file mode 100644 index 0000000000..e6c4d53fa7 --- /dev/null +++ b/packages/collaboration-yjs/src/shared-doc/constants.d.ts @@ -0,0 +1,5 @@ +export declare const messageSync = 0; +export declare const messageAwareness = 1; +export declare const wsReadyStateConnecting = 0; +export declare const wsReadyStateOpen = 1; +//# sourceMappingURL=constants.d.ts.map diff --git a/packages/collaboration-yjs/src/shared-doc/index.d.ts b/packages/collaboration-yjs/src/shared-doc/index.d.ts new file mode 100644 index 0000000000..151daf8e59 --- /dev/null +++ b/packages/collaboration-yjs/src/shared-doc/index.d.ts @@ -0,0 +1,5 @@ +export * from './shared-doc.js'; +export * from './callback.js'; +export * from './utils.js'; +export * from './constants.js'; +//# sourceMappingURL=index.d.ts.map diff --git a/packages/collaboration-yjs/src/shared-doc/shared-doc.d.ts b/packages/collaboration-yjs/src/shared-doc/shared-doc.d.ts new file mode 100644 index 0000000000..d358482895 --- /dev/null +++ b/packages/collaboration-yjs/src/shared-doc/shared-doc.d.ts @@ -0,0 +1,13 @@ +import { Awareness } from 'y-protocols/awareness'; +import { Doc as YDoc } from 'yjs'; +import type { CollaborationWebSocket } from '../types/service-types.js'; +export declare class SharedSuperDoc extends YDoc { + name: string; + conns: Map>; + awareness: Awareness; + whenInitialized: Promise; + constructor(name: string); +} +export declare const send: (doc: SharedSuperDoc, conn: CollaborationWebSocket, message: Uint8Array) => void; +export declare const setupConnection: (conn: CollaborationWebSocket, doc: SharedSuperDoc) => void; +//# sourceMappingURL=shared-doc.d.ts.map diff --git a/packages/collaboration-yjs/src/shared-doc/utils.d.ts b/packages/collaboration-yjs/src/shared-doc/utils.d.ts new file mode 100644 index 0000000000..645e6b578d --- /dev/null +++ b/packages/collaboration-yjs/src/shared-doc/utils.d.ts @@ -0,0 +1,2 @@ +export declare const debouncer: (cb: ((...args: any) => void) | null) => void; +//# sourceMappingURL=utils.d.ts.map diff --git a/packages/collaboration-yjs/src/types.d.ts b/packages/collaboration-yjs/src/types.d.ts new file mode 100644 index 0000000000..0c28c4b4b8 --- /dev/null +++ b/packages/collaboration-yjs/src/types.d.ts @@ -0,0 +1,6 @@ +export * from './types/service-types.js'; +export * from './connection-handler/index.js'; +export * from './collaboration/index.js'; +export * from './document-manager/index.js'; +export * from './shared-doc/index.js'; +//# sourceMappingURL=types.d.ts.map diff --git a/packages/collaboration-yjs/src/types/service-types.d.ts b/packages/collaboration-yjs/src/types/service-types.d.ts new file mode 100644 index 0000000000..61fcb2949a --- /dev/null +++ b/packages/collaboration-yjs/src/types/service-types.d.ts @@ -0,0 +1,56 @@ +import type { IncomingHttpHeaders } from 'node:http'; +import type { SharedSuperDoc } from '../shared-doc/shared-doc.js'; +export type UserContext = Record | null | undefined; +export interface CollaborationParams { + documentId: string; + token?: string; + params?: Record; + headers?: IncomingHttpHeaders; + cookies?: Record; + connection?: Record; + instance?: unknown; + document?: SharedSuperDoc; + userContext?: UserContext; + [key: string]: unknown; +} +export interface CollaborationWebSocket { + readyState: number; + send(data: Uint8Array | ArrayBufferLike | string, options?: unknown, cb?: (err?: Error | null) => void): void; + close(code?: number, reason?: string | Buffer): void; + on(event: string, listener: (...args: unknown[]) => void): void; +} +export interface SocketRequest { + url: string; + params: Record; + headers?: IncomingHttpHeaders; +} +export type HookFn = ( + params: CollaborationParams +) => Promise | HookFnResponse | void; +export type ConfigureFn = (config: ServiceConfig) => void; +export type AuthenticateFn = HookFn; +export type LoadFn = HookFn; +export type BeforeChangeFn = HookFn; +export type ChangeFn = HookFn; +export type AutoSaveFn = HookFn; +export interface Hooks { + configure?: ConfigureFn; + authenticate?: AuthenticateFn; + load?: LoadFn; + beforeChange?: BeforeChangeFn; + change?: ChangeFn; + autoSave?: AutoSaveFn; +} +/** + * Extension type for Yjs extensions (e.g., y-websocket, y-indexeddb). + * Allows any key-value pairs to support various extension configurations. + */ +export type Extension = Record; +export interface ServiceConfig { + name?: string; + debounce?: number; + documentExpiryMs?: number; + hooks?: Hooks; + extensions?: Extension[]; +} +//# sourceMappingURL=service-types.d.ts.map diff --git a/packages/collaboration-yjs/tsconfig.json b/packages/collaboration-yjs/tsconfig.json index faa296f4bb..7c9398a513 100644 --- a/packages/collaboration-yjs/tsconfig.json +++ b/packages/collaboration-yjs/tsconfig.json @@ -3,6 +3,7 @@ "compilerOptions": { "rootDir": "src", "outDir": "dist", + "composite": true, "declaration": true, "declarationMap": true, "types": ["node"] diff --git a/packages/sdk/codegen/src/__tests__/cross-lang-parity.test.ts b/packages/sdk/codegen/src/__tests__/cross-lang-parity.test.ts index c404efca91..dfa5fee475 100644 --- a/packages/sdk/codegen/src/__tests__/cross-lang-parity.test.ts +++ b/packages/sdk/codegen/src/__tests__/cross-lang-parity.test.ts @@ -431,52 +431,32 @@ describe('Constraint validation parity', () => { }); // -------------------------------------------------------------------------- -// Python session targeting and collab guard +// Python collaboration support (collab params accepted, not rejected) // -------------------------------------------------------------------------- -describe('Python session targeting and collab guard', () => { - test('doc.session.setDefault is session-bound (derives sessionId)', async () => { - const result = await callPython({ - action: 'isSessionBound', - operationId: 'doc.session.setDefault', - }); - expect(result).toBe(true); - }); - - test('doc.open is NOT session-bound', async () => { - const result = await callPython({ - action: 'isSessionBound', +describe('Python collaboration support', () => { + test('doc.open accepts collabUrl and collabDocumentId params', async () => { + const result = (await callPython({ + action: 'assertCollabAccepted', operationId: 'doc.open', - }); - expect(result).toBe(false); - }); + params: { + doc: './test.docx', + collabUrl: 'ws://localhost:4000', + collabDocumentId: 'test-doc-id', + }, + })) as { accepted: boolean; collabParamsPresent: boolean }; - test('all doc-backed session ops are session-bound', async () => { - const sessionOps = [ - 'doc.status', - 'doc.save', - 'doc.close', - 'doc.info', - 'doc.find', - 'doc.session.save', - 'doc.session.close', - 'doc.session.setDefault', - ]; - for (const opId of sessionOps) { - const result = await callPython({ - action: 'isSessionBound', - operationId: opId, - }); - expect(result).toBe(true); - } + expect(result.accepted).toBe(true); + expect(result.collabParamsPresent).toBe(true); }); - test('collab session rejected for session-bound op', async () => { + test('doc.open accepts params without collab fields', async () => { const result = (await callPython({ - action: 'assertCollabRejection', - operationId: 'doc.session.setDefault', - sessionId: 'test-collab-session', - })) as { rejected: boolean; code?: string }; - expect(result).toEqual({ rejected: true, code: 'NOT_SUPPORTED' }); + action: 'assertCollabAccepted', + operationId: 'doc.open', + params: { doc: './test.docx' }, + })) as { accepted: boolean }; + + expect(result.accepted).toBe(true); }); }); diff --git a/packages/sdk/codegen/src/generate-python.mjs b/packages/sdk/codegen/src/generate-python.mjs index 7a79ab9e0d..977dfbe3bc 100644 --- a/packages/sdk/codegen/src/generate-python.mjs +++ b/packages/sdk/codegen/src/generate-python.mjs @@ -249,31 +249,18 @@ function generateClientPy(contract) { return [ '# Auto-generated by packages/sdk/codegen/src/generate-python.mjs', + '# Operation tree and TypedDicts only — lifecycle classes live in client.py.', '', 'from __future__ import annotations', '', 'from typing import Any, Literal, TypedDict', '', - 'from ..runtime import SuperDocSyncRuntime, SuperDocAsyncRuntime', - '', sharedTypes, '', syncClasses, '', asyncClasses, '', - '', - 'class SuperDocClient:', - ' def __init__(self, *, env=None, default_change_mode=None):', - ' self._runtime = SuperDocSyncRuntime(env=env, default_change_mode=default_change_mode)', - ' self.doc = _SyncDocApi(self._runtime)', - '', - '', - 'class AsyncSuperDocClient:', - ' def __init__(self, *, env=None, default_change_mode=None):', - ' self._runtime = SuperDocAsyncRuntime(env=env, default_change_mode=default_change_mode)', - ' self.doc = _AsyncDocApi(self._runtime)', - '', ].join('\n'); } @@ -290,7 +277,7 @@ export async function generatePythonSdk(contract) { writeGeneratedFile(path.join(PYTHON_GENERATED_DIR, 'client.py'), clientContent), writeGeneratedFile( path.join(PYTHON_GENERATED_DIR, '__init__.py'), - 'from .client import SuperDocClient, AsyncSuperDocClient\n', + 'from .client import _SyncDocApi, _AsyncDocApi\n', ), ]); } diff --git a/packages/sdk/langs/node/src/runtime/__tests__/transport-common.test.ts b/packages/sdk/langs/node/src/runtime/__tests__/transport-common.test.ts index fa3753611d..e42ce0466f 100644 --- a/packages/sdk/langs/node/src/runtime/__tests__/transport-common.test.ts +++ b/packages/sdk/langs/node/src/runtime/__tests__/transport-common.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from 'bun:test'; import { buildOperationArgv, resolveInvocation, type OperationSpec } from '../transport-common.js'; +import { CONTRACT } from '../../generated/contract.js'; const makeOp = (overrides: Partial = {}): OperationSpec => ({ operationId: 'doc.test', @@ -160,4 +161,101 @@ describe('buildOperationArgv', () => { const argv = buildOperationArgv(op, { doc: 'test.docx' }, {}, undefined, 'tracked'); expect(argv).not.toContain('--change-mode'); }); + + test('injects user-name and user-email into doc.open argv when user is set', () => { + const op = makeOp({ + operationId: 'doc.open', + commandTokens: ['open'], + params: [ + { name: 'doc', kind: 'doc', type: 'string' }, + { name: 'userName', kind: 'flag', flag: 'user-name', type: 'string' }, + { name: 'userEmail', kind: 'flag', flag: 'user-email', type: 'string' }, + ], + }); + const argv = buildOperationArgv(op, { doc: 'test.docx' }, {}, undefined, undefined, { + name: 'Bot', + email: 'bot@co.com', + }); + expect(argv).toContain('--user-name'); + expect(argv[argv.indexOf('--user-name') + 1]).toBe('Bot'); + expect(argv).toContain('--user-email'); + expect(argv[argv.indexOf('--user-email') + 1]).toBe('bot@co.com'); + }); + + test('does not inject user flags when user is not set', () => { + const op = makeOp({ + operationId: 'doc.open', + commandTokens: ['open'], + params: [ + { name: 'doc', kind: 'doc', type: 'string' }, + { name: 'userName', kind: 'flag', flag: 'user-name', type: 'string' }, + { name: 'userEmail', kind: 'flag', flag: 'user-email', type: 'string' }, + ], + }); + const argv = buildOperationArgv(op, { doc: 'test.docx' }, {}, undefined); + expect(argv).not.toContain('--user-name'); + expect(argv).not.toContain('--user-email'); + }); + + test('does not inject user flags for non-doc.open operations', () => { + const op = makeOp({ + operationId: 'doc.find', + commandTokens: ['find'], + params: [{ name: 'query', kind: 'flag', type: 'string' }], + }); + const argv = buildOperationArgv(op, { query: 'test' }, {}, undefined, undefined, { + name: 'Bot', + email: 'bot@co.com', + }); + expect(argv).not.toContain('--user-name'); + expect(argv).not.toContain('--user-email'); + }); + + test('per-call userName/userEmail override client-level user defaults', () => { + const op = makeOp({ + operationId: 'doc.open', + commandTokens: ['open'], + params: [ + { name: 'doc', kind: 'doc', type: 'string' }, + { name: 'userName', kind: 'flag', flag: 'user-name', type: 'string' }, + { name: 'userEmail', kind: 'flag', flag: 'user-email', type: 'string' }, + ], + }); + const argv = buildOperationArgv( + op, + { doc: 'test.docx', userName: 'Override', userEmail: 'override@co.com' }, + {}, + undefined, + undefined, + { name: 'Bot', email: 'bot@co.com' }, + ); + expect(argv).toContain('--user-name'); + expect(argv[argv.indexOf('--user-name') + 1]).toBe('Override'); + expect(argv).toContain('--user-email'); + expect(argv[argv.indexOf('--user-email') + 1]).toBe('override@co.com'); + // Should only appear once each + expect(argv.filter((v) => v === '--user-name').length).toBe(1); + expect(argv.filter((v) => v === '--user-email').length).toBe(1); + }); +}); + +describe('buildOperationArgv with real generated contract', () => { + const realOpenOp = CONTRACT.operations['doc.open'] as OperationSpec; + + test('generated doc.open spec includes userName and userEmail params', () => { + expect(realOpenOp).toBeDefined(); + expect(realOpenOp.params.some((p) => p.name === 'userName')).toBe(true); + expect(realOpenOp.params.some((p) => p.name === 'userEmail')).toBe(true); + }); + + test('user identity emits --user-name and --user-email with real doc.open spec', () => { + const argv = buildOperationArgv(realOpenOp, { doc: 'test.docx' }, {}, undefined, undefined, { + name: 'Bot', + email: 'bot@co.com', + }); + expect(argv).toContain('--user-name'); + expect(argv[argv.indexOf('--user-name') + 1]).toBe('Bot'); + expect(argv).toContain('--user-email'); + expect(argv[argv.indexOf('--user-email') + 1]).toBe('bot@co.com'); + }); }); diff --git a/packages/sdk/langs/node/src/runtime/host.ts b/packages/sdk/langs/node/src/runtime/host.ts index af3721d63b..205b373e19 100644 --- a/packages/sdk/langs/node/src/runtime/host.ts +++ b/packages/sdk/langs/node/src/runtime/host.ts @@ -7,6 +7,7 @@ import { type InvokeOptions, type OperationSpec, type SuperDocClientOptions, + type UserIdentity, } from './transport-common.js'; import { SuperDocCliError } from './errors.js'; @@ -47,6 +48,7 @@ export class HostTransport { private readonly watchdogTimeoutMs: number; private readonly maxQueueDepth: number; private readonly defaultChangeMode?: ChangeMode; + private readonly user?: UserIdentity; private child: ChildProcessWithoutNullStreams | null = null; private stdoutReader: ReadlineInterface | null = null; @@ -71,6 +73,7 @@ export class HostTransport { }); } this.defaultChangeMode = options.defaultChangeMode; + this.user = options.user; } async connect(): Promise { @@ -112,7 +115,14 @@ export class HostTransport { ): Promise { await this.ensureConnected(); - const argv = buildOperationArgv(operation, params, options, this.requestTimeoutMs, this.defaultChangeMode); + const argv = buildOperationArgv( + operation, + params, + options, + this.requestTimeoutMs, + this.defaultChangeMode, + this.user, + ); const stdinBase64 = options.stdinBytes ? Buffer.from(options.stdinBytes).toString('base64') : ''; const watchdogTimeout = this.resolveWatchdogTimeout(options.timeoutMs); diff --git a/packages/sdk/langs/node/src/runtime/transport-common.ts b/packages/sdk/langs/node/src/runtime/transport-common.ts index 6df51438de..aa6950c55e 100644 --- a/packages/sdk/langs/node/src/runtime/transport-common.ts +++ b/packages/sdk/langs/node/src/runtime/transport-common.ts @@ -22,6 +22,11 @@ export interface InvokeOptions { export type ChangeMode = 'direct' | 'tracked'; +export interface UserIdentity { + name: string; + email?: string; +} + export interface SuperDocClientOptions { env?: Record; startupTimeoutMs?: number; @@ -30,6 +35,7 @@ export interface SuperDocClientOptions { watchdogTimeoutMs?: number; maxQueueDepth?: number; defaultChangeMode?: ChangeMode; + user?: UserIdentity; } export interface CliInvocation { @@ -68,13 +74,24 @@ export function buildOperationArgv( options: InvokeOptions, runtimeTimeoutMs: number | undefined, defaultChangeMode?: ChangeMode, + user?: UserIdentity, ): string[] { // Inject defaultChangeMode into params BEFORE encoding — single source of truth. - const normalizedParams = + let normalizedParams: Record = defaultChangeMode != null && params.changeMode == null && operation.params.some((p) => p.name === 'changeMode') ? { ...params, changeMode: defaultChangeMode } : params; + // Inject user identity for doc.open when not already specified. + if (user != null && operation.operationId === 'doc.open') { + if (normalizedParams.userName == null && user.name) { + normalizedParams = { ...normalizedParams, userName: user.name }; + } + if (normalizedParams.userEmail == null && user.email) { + normalizedParams = { ...normalizedParams, userEmail: user.email }; + } + } + const argv: string[] = [...operation.commandTokens]; for (const spec of operation.params) { diff --git a/packages/sdk/langs/python/README.md b/packages/sdk/langs/python/README.md index 7ada58924e..34840f5dcc 100644 --- a/packages/sdk/langs/python/README.md +++ b/packages/sdk/langs/python/README.md @@ -19,30 +19,91 @@ The package installs a platform-specific CLI companion package automatically via ## Quick start ```python -import asyncio +from superdoc import SuperDocClient -from superdoc import AsyncSuperDocClient +with SuperDocClient() as client: + client.doc.open({"doc": "./contract.docx"}) + info = client.doc.info({}) + print(info["counts"]) -async def main(): - client = AsyncSuperDocClient() + results = client.doc.find({"type": "text", "pattern": "termination"}) + target = results["items"][0]["context"]["textRanges"][0] - await client.doc.open({"doc": "./contract.docx"}) + client.doc.replace({"target": target, "text": "expiration"}) + client.doc.save({"inPlace": True}) + client.doc.close({}) +``` - info = await client.doc.info({}) - print(info["counts"]) +### Async - results = await client.doc.find({"type": "text", "pattern": "termination"}) - target = results["items"][0]["context"]["textRanges"][0] +```python +import asyncio +from superdoc import AsyncSuperDocClient - await client.doc.replace({"target": target, "text": "expiration"}) - await client.doc.save({"inPlace": True}) - await client.doc.close({}) +async def main(): + async with AsyncSuperDocClient() as client: + await client.doc.open({"doc": "./contract.docx"}) + + info = await client.doc.info({}) + print(info["counts"]) + + results = await client.doc.find({"type": "text", "pattern": "termination"}) + target = results["items"][0]["context"]["textRanges"][0] + await client.doc.replace({"target": target, "text": "expiration"}) + await client.doc.save({"inPlace": True}) + await client.doc.close({}) asyncio.run(main()) ``` +## Client lifecycle + +The SDK uses a persistent host process for all operations. The host is started on first use and reused across calls, avoiding per-operation subprocess overhead. + +### Context managers (recommended) + +```python +# Sync +with SuperDocClient() as client: + client.doc.find({"query": "test"}) + +# Async +async with AsyncSuperDocClient() as client: + await client.doc.find({"query": "test"}) +``` + +The context manager calls `connect()` on entry and `dispose()` on exit (including on exception). + +### Explicit lifecycle + +```python +client = SuperDocClient() +client.connect() # Optional — first invoke() auto-connects +result = client.doc.find({"query": "test"}) +client.dispose() # Shuts down the host process +``` + +`connect()` is optional. If not called explicitly, the first operation triggers a lazy connection to the host process. + +### Configuration + +```python +client = SuperDocClient( + startup_timeout_ms=10_000, # Max time for host handshake (default: 5000) + shutdown_timeout_ms=5_000, # Max time for graceful shutdown (default: 5000) + request_timeout_ms=60_000, # Per-operation timeout passed to CLI (default: None) + watchdog_timeout_ms=30_000, # Client-side safety timer per request (default: 30000) + default_change_mode="tracked", # Auto-inject changeMode for mutations (default: None) + env={"SUPERDOC_CLI_BIN": "/path/to/superdoc"}, # Environment overrides +) +``` + +### Thread safety + +Client instances are serialized: one operation at a time per client. For parallelism, use multiple client instances. Do not share a single client across threads. + ## API ### Client @@ -56,9 +117,9 @@ client = SuperDocClient() All document operations are on `client.doc`: ```python -await client.doc.open(params) -await client.doc.find(params) -await client.doc.insert(params) +client.doc.open(params) +client.doc.find(params) +client.doc.insert(params) # ... etc ``` @@ -77,6 +138,22 @@ await client.doc.insert(params) | **Session** | `session.list`, `session.save`, `session.close`, `session.set_default` | | **Introspection** | `status`, `describe`, `describe_command` | +### Collaboration + +The Python SDK supports realtime collaboration through the same host transport as the Node SDK. Pass collaboration parameters to `doc.open`: + +```python +with SuperDocClient() as client: + client.doc.open({ + "doc": "./contract.docx", + "collabUrl": "ws://localhost:4000", + "collabDocumentId": "my-doc-id", + }) + # Operations now use the collaborative session + client.doc.find({"query": "test"}) + client.doc.close({}) +``` + ## Troubleshooting ### Custom CLI binary @@ -87,6 +164,14 @@ If you need to use a custom-built CLI binary (e.g. a newer version or a patched export SUPERDOC_CLI_BIN=/path/to/superdoc ``` +### Debug logging + +Enable transport-level debug logging to diagnose connectivity issues: + +```bash +export SUPERDOC_DEBUG=1 +``` + ### Air-gapped / private index environments Mirror both `superdoc-sdk` and the `superdoc-sdk-cli-*` package for your platform to your private index. For example, on macOS ARM64: diff --git a/packages/sdk/langs/python/superdoc/__init__.py b/packages/sdk/langs/python/superdoc/__init__.py index 19fcb31f31..5462891c64 100644 --- a/packages/sdk/langs/python/superdoc/__init__.py +++ b/packages/sdk/langs/python/superdoc/__init__.py @@ -1,5 +1,5 @@ +from .client import AsyncSuperDocClient, SuperDocClient from .errors import SuperDocError -from .generated.client import AsyncSuperDocClient, SuperDocClient from .skill_api import get_skill, install_skill, list_skills from .tools_api import ( choose_tools, diff --git a/packages/sdk/langs/python/superdoc/client.py b/packages/sdk/langs/python/superdoc/client.py new file mode 100644 index 0000000000..2dd6755fb7 --- /dev/null +++ b/packages/sdk/langs/python/superdoc/client.py @@ -0,0 +1,109 @@ +"""Hand-written SuperDoc client classes with lifecycle and context-manager support. + +These classes compose the generated operation tree (_SyncDocApi / _AsyncDocApi) +with explicit connect/dispose lifecycle semantics. The generated code in +generated/client.py contains only TypedDicts and operation methods. +""" + +from __future__ import annotations + +from typing import Dict, Literal, Optional + +from .generated.client import _AsyncDocApi, _SyncDocApi +from .runtime import SuperDocAsyncRuntime, SuperDocSyncRuntime + +UserIdentity = Dict[str, str] + + +class SuperDocClient: + """Synchronous SuperDoc client with persistent host transport.""" + + doc: _SyncDocApi + + def __init__( + self, + *, + env: dict[str, str] | None = None, + startup_timeout_ms: int = 5_000, + shutdown_timeout_ms: int = 5_000, + request_timeout_ms: int | None = None, + watchdog_timeout_ms: int = 30_000, + default_change_mode: Literal['direct', 'tracked'] | None = None, + user: UserIdentity | None = None, + ) -> None: + self._runtime = SuperDocSyncRuntime( + env=env, + startup_timeout_ms=startup_timeout_ms, + shutdown_timeout_ms=shutdown_timeout_ms, + request_timeout_ms=request_timeout_ms, + watchdog_timeout_ms=watchdog_timeout_ms, + default_change_mode=default_change_mode, + user=user, + ) + self.doc = _SyncDocApi(self._runtime) + + def connect(self) -> None: + """Explicitly connect to the host process. + + Optional — the first invoke() call will auto-connect if needed. + """ + self._runtime.connect() + + def dispose(self) -> None: + """Gracefully shut down the host process.""" + self._runtime.dispose() + + def __enter__(self) -> SuperDocClient: + self.connect() + return self + + def __exit__(self, *exc: object) -> None: + self.dispose() + + +class AsyncSuperDocClient: + """Asynchronous SuperDoc client with persistent host transport.""" + + doc: _AsyncDocApi + + def __init__( + self, + *, + env: dict[str, str] | None = None, + startup_timeout_ms: int = 5_000, + shutdown_timeout_ms: int = 5_000, + request_timeout_ms: int | None = None, + watchdog_timeout_ms: int = 30_000, + max_queue_depth: int = 100, + default_change_mode: Literal['direct', 'tracked'] | None = None, + user: UserIdentity | None = None, + ) -> None: + self._runtime = SuperDocAsyncRuntime( + env=env, + startup_timeout_ms=startup_timeout_ms, + shutdown_timeout_ms=shutdown_timeout_ms, + request_timeout_ms=request_timeout_ms, + watchdog_timeout_ms=watchdog_timeout_ms, + max_queue_depth=max_queue_depth, + default_change_mode=default_change_mode, + user=user, + ) + self.doc = _AsyncDocApi(self._runtime) + + async def connect(self) -> None: + """Explicitly connect to the host process. + + Optional — the first invoke() call will auto-connect if needed. + """ + await self._runtime.connect() + + async def dispose(self) -> None: + """Gracefully shut down the host process.""" + await self._runtime.dispose() + + async def __aenter__(self) -> AsyncSuperDocClient: + await self.connect() + return self + + async def __aexit__(self, *exc: object) -> None: + await self.dispose() diff --git a/packages/sdk/langs/python/superdoc/errors.py b/packages/sdk/langs/python/superdoc/errors.py index e453597584..2162cc6abe 100644 --- a/packages/sdk/langs/python/superdoc/errors.py +++ b/packages/sdk/langs/python/superdoc/errors.py @@ -1,3 +1,16 @@ +"""SuperDoc SDK error types and host transport error codes.""" + +# Host transport error codes — used by transport.py and protocol.py. +HOST_DISCONNECTED = 'HOST_DISCONNECTED' +HOST_TIMEOUT = 'HOST_TIMEOUT' +HOST_QUEUE_FULL = 'HOST_QUEUE_FULL' +HOST_HANDSHAKE_FAILED = 'HOST_HANDSHAKE_FAILED' +HOST_PROTOCOL_ERROR = 'HOST_PROTOCOL_ERROR' + +# JSON-RPC error code emitted by the CLI for operation timeouts. +JSON_RPC_TIMEOUT_CODE = -32011 + + class SuperDocError(Exception): def __init__(self, message: str, code: str, details=None, exit_code=None): super().__init__(message) diff --git a/packages/sdk/langs/python/superdoc/protocol.py b/packages/sdk/langs/python/superdoc/protocol.py new file mode 100644 index 0000000000..1a7766d2eb --- /dev/null +++ b/packages/sdk/langs/python/superdoc/protocol.py @@ -0,0 +1,303 @@ +"""Pure stateless helpers for JSON-RPC 2.0 protocol and CLI argv construction. + +This module has NO I/O and NO state — all functions are pure and trivially testable. +""" + +from __future__ import annotations + +import base64 +import json +from dataclasses import dataclass +from typing import Any, Dict, List, Literal, Optional, Tuple, Union + +from .errors import ( + HOST_HANDSHAKE_FAILED, + HOST_PROTOCOL_ERROR, + JSON_RPC_TIMEOUT_CODE, + SuperDocError, +) + +ChangeMode = Literal['direct', 'tracked'] + +HOST_PROTOCOL_VERSION = '1.0' +REQUIRED_FEATURES = ('cli.invoke', 'host.shutdown') + + +# --------------------------------------------------------------------------- +# JSON-RPC message types +# --------------------------------------------------------------------------- + +@dataclass(frozen=True) +class JsonRpcResponse: + id: int + result: Any + + +@dataclass(frozen=True) +class JsonRpcError: + id: int + error: Dict[str, Any] + + +@dataclass(frozen=True) +class JsonRpcNotification: + method: str + params: Any + + +@dataclass(frozen=True) +class InvalidFrame: + raw: str + + +ParsedMessage = Union[JsonRpcResponse, JsonRpcError, JsonRpcNotification, InvalidFrame] + + +# --------------------------------------------------------------------------- +# JSON-RPC encoding / decoding +# --------------------------------------------------------------------------- + +def encode_jsonrpc_request(request_id: int, method: str, params: Any = None) -> str: + """Serialize a JSON-RPC 2.0 request as a newline-terminated string.""" + payload = {'jsonrpc': '2.0', 'id': request_id, 'method': method} + if params is not None: + payload['params'] = params + return json.dumps(payload, separators=(',', ':')) + '\n' + + +def parse_jsonrpc_line(line: str) -> ParsedMessage: + """Parse a single line of JSON-RPC stdout into a typed message.""" + stripped = line.strip() + if not stripped: + return InvalidFrame(raw=line) + + try: + parsed = json.loads(stripped) + except (json.JSONDecodeError, ValueError): + return InvalidFrame(raw=line) + + if not isinstance(parsed, dict) or parsed.get('jsonrpc') != '2.0': + return InvalidFrame(raw=line) + + # Notification: has method but no id. + if 'method' in parsed and 'id' not in parsed: + return JsonRpcNotification(method=parsed['method'], params=parsed.get('params')) + + raw_id = parsed.get('id') + if not isinstance(raw_id, int): + return InvalidFrame(raw=line) + + if 'error' in parsed: + return JsonRpcError(id=raw_id, error=parsed['error']) + + return JsonRpcResponse(id=raw_id, result=parsed.get('result')) + + +# --------------------------------------------------------------------------- +# Error mapping +# --------------------------------------------------------------------------- + +def map_jsonrpc_error(raw_error: Dict[str, Any]) -> SuperDocError: + """Normalize a JSON-RPC error object into a SuperDocError. + + Mirrors the Node SDK's mapJsonRpcError logic exactly: + 1. If error.data.cliCode exists → use it as the error code. + 2. If error.code == -32011 → TIMEOUT. + 3. Otherwise → COMMAND_FAILED. + """ + if not isinstance(raw_error, dict): + return SuperDocError( + 'Host returned an unknown JSON-RPC error.', + code=HOST_PROTOCOL_ERROR, + details={'error': raw_error}, + ) + + data = raw_error.get('data') + if isinstance(data, dict): + cli_code = data.get('cliCode') + cli_message = data.get('message') + exit_code = data.get('exitCode') + + if isinstance(cli_code, str): + return SuperDocError( + cli_message if isinstance(cli_message, str) else raw_error.get('message', 'Command failed.'), + code=cli_code, + details=data.get('details'), + exit_code=exit_code if isinstance(exit_code, int) else None, + ) + + error_code = raw_error.get('code') + message = raw_error.get('message', 'Unknown JSON-RPC error.') + + if error_code == JSON_RPC_TIMEOUT_CODE: + return SuperDocError(message, code='TIMEOUT', details=data) + + return SuperDocError(message, code='COMMAND_FAILED', details=data) + + +# --------------------------------------------------------------------------- +# Capability handshake validation +# --------------------------------------------------------------------------- + +def validate_capabilities(response: Any) -> None: + """Validate a host.capabilities response. Raises SuperDocError on failure.""" + if not isinstance(response, dict): + raise SuperDocError( + 'Host capabilities response is invalid.', + code=HOST_HANDSHAKE_FAILED, + details={'response': response}, + ) + + protocol_version = response.get('protocolVersion') + if protocol_version != HOST_PROTOCOL_VERSION: + raise SuperDocError( + 'Host protocol version is unsupported.', + code=HOST_HANDSHAKE_FAILED, + details={'expected': HOST_PROTOCOL_VERSION, 'actual': protocol_version}, + ) + + features = response.get('features') + if not isinstance(features, list) or not all(isinstance(f, str) for f in features): + raise SuperDocError( + 'Host capabilities.features must be a string array.', + code=HOST_HANDSHAKE_FAILED, + details={'features': features}, + ) + + for required in REQUIRED_FEATURES: + if required not in features: + raise SuperDocError( + f'Host does not support required feature: {required}', + code=HOST_HANDSHAKE_FAILED, + details={'features': features}, + ) + + +# --------------------------------------------------------------------------- +# CLI argv construction +# --------------------------------------------------------------------------- + +def _encode_param(args: List[str], spec: Dict[str, Any], value: Any) -> None: + """Encode a single operation parameter into CLI argv flags.""" + if value is None: + if spec.get('required'): + raise SuperDocError(f"Missing required parameter: {spec['name']}", code='INVALID_ARGUMENT') + return + + kind = spec['kind'] + param_type = spec['type'] + + if kind == 'doc': + args.append(str(value)) + return + + flag = f"--{spec.get('flag') or spec['name']}" + + if param_type == 'boolean': + args.extend([flag, 'true' if value else 'false']) + return + + if param_type == 'string[]': + if not isinstance(value, list): + raise SuperDocError(f"Parameter {spec['name']} must be a list.", code='INVALID_ARGUMENT') + for item in value: + args.extend([flag, str(item)]) + return + + if param_type == 'json': + args.extend([flag, json.dumps(value)]) + return + + args.extend([flag, str(value)]) + + +def normalize_default_change_mode(default_change_mode: Optional[str]) -> Optional[ChangeMode]: + """Validate and normalize the default_change_mode option.""" + if default_change_mode is None: + return None + if default_change_mode in ('direct', 'tracked'): + return default_change_mode # type: ignore[return-value] + raise SuperDocError( + 'default_change_mode must be "direct" or "tracked".', + code='INVALID_ARGUMENT', + details={'defaultChangeMode': default_change_mode}, + ) + + +def apply_default_change_mode( + operation: Dict[str, Any], payload: Dict[str, Any], default_change_mode: Optional[ChangeMode] +) -> Dict[str, Any]: + """Inject default change mode into params if applicable.""" + if default_change_mode is None: + return payload + if payload.get('changeMode') is not None: + return payload + supports = any(spec.get('name') == 'changeMode' for spec in operation.get('params', [])) + if not supports: + return payload + return {**payload, 'changeMode': default_change_mode} + + +def apply_default_user( + operation: Dict[str, Any], payload: Dict[str, Any], user: Optional[Dict[str, str]] +) -> Dict[str, Any]: + """Inject default user identity into params for doc.open when not already specified.""" + if user is None: + return payload + if operation.get('operationId') != 'doc.open': + return payload + result = dict(payload) + if result.get('userName') is None and user.get('name'): + result['userName'] = user['name'] + if result.get('userEmail') is None and user.get('email'): + result['userEmail'] = user['email'] + return result + + +def build_operation_argv( + operation: Dict[str, Any], + params: Dict[str, Any], + timeout_ms: Optional[int] = None, + default_change_mode: Optional[ChangeMode] = None, + user: Optional[Dict[str, str]] = None, +) -> List[str]: + """Build the CLI argument vector for an operation invocation.""" + payload = apply_default_change_mode(operation, params, default_change_mode) + payload = apply_default_user(operation, payload, user) + argv: List[str] = list(operation['commandTokens']) + for spec in operation['params']: + _encode_param(argv, spec, payload.get(spec['name'])) + if timeout_ms is not None: + argv.extend(['--timeout-ms', str(timeout_ms)]) + argv.extend(['--output', 'json']) + return argv + + +def build_cli_invoke_payload(argv: List[str], stdin_bytes: Optional[bytes] = None) -> Dict[str, Any]: + """Build the params dict for a cli.invoke JSON-RPC request.""" + payload: Dict[str, Any] = {'argv': argv} + payload['stdinBase64'] = base64.b64encode(stdin_bytes).decode('ascii') if stdin_bytes else '' + return payload + + +def resolve_watchdog_timeout( + watchdog_timeout_ms: int, + timeout_ms_override: Optional[int] = None, + request_timeout_ms: Optional[int] = None, +) -> int: + """Compute the effective watchdog timeout for a single request.""" + if timeout_ms_override is not None: + return max(watchdog_timeout_ms, timeout_ms_override + 1_000) + if request_timeout_ms is not None: + return max(watchdog_timeout_ms, request_timeout_ms + 1_000) + return watchdog_timeout_ms + + +def resolve_invocation(cli_bin: str) -> Tuple[str, List[str]]: + """Determine how to invoke the CLI binary (bare, via node, via bun).""" + lower = cli_bin.lower() + if lower.endswith('.js'): + return 'node', [cli_bin] + if lower.endswith('.ts'): + return 'bun', [cli_bin] + return cli_bin, [] diff --git a/packages/sdk/langs/python/superdoc/runtime.py b/packages/sdk/langs/python/superdoc/runtime.py index 7a8d096776..e303754502 100644 --- a/packages/sdk/langs/python/superdoc/runtime.py +++ b/packages/sdk/langs/python/superdoc/runtime.py @@ -1,378 +1,119 @@ +"""SuperDoc runtime — thin layer over host transport. + +Resolves the CLI binary, holds a transport instance, and delegates invoke(). +All protocol, process lifecycle, and I/O logic lives in transport.py and +protocol.py. The default_change_mode is passed to the transport, which applies +it during argv construction. +""" + from __future__ import annotations -import asyncio -import hashlib -import json import os -import subprocess -from typing import Any, Dict, Literal, Mapping, Optional, Tuple +from typing import Any, Dict, Optional from .embedded_cli import resolve_embedded_cli_path -from .errors import SuperDocError -from .generated.contract import CONTRACT, OPERATION_INDEX - -ChangeMode = Literal['direct', 'tracked'] - - -def _resolve_invocation(cli_bin: str) -> Tuple[str, list]: - lower = cli_bin.lower() - if lower.endswith('.js'): - return 'node', [cli_bin] - if lower.endswith('.ts'): - return 'bun', [cli_bin] - return cli_bin, [] - - -def _encode_param(args: list, spec: Dict[str, Any], value: Any) -> None: - if value is None: - if spec.get('required'): - raise SuperDocError(f"Missing required parameter: {spec['name']}", code='INVALID_ARGUMENT') - return - - kind = spec['kind'] - param_type = spec['type'] - - if kind == 'doc': - args.append(str(value)) - return - - flag = f"--{spec.get('flag') or spec['name']}" - - if param_type == 'boolean': - # Explicit true/false — matches current CLI operation-executor.ts. - args.extend([flag, 'true' if value else 'false']) - return - - if param_type == 'string[]': - if not isinstance(value, list): - raise SuperDocError(f"Parameter {spec['name']} must be a list.", code='INVALID_ARGUMENT') - for item in value: - args.extend([flag, str(item)]) - return - - if param_type == 'json': - args.extend([flag, json.dumps(value)]) - return - - args.extend([flag, str(value)]) - - -def _normalize_default_change_mode(default_change_mode: Optional[str]) -> Optional[ChangeMode]: - if default_change_mode is None: - return None - if default_change_mode in ('direct', 'tracked'): - return default_change_mode - raise SuperDocError( - 'default_change_mode must be "direct" or "tracked".', - code='INVALID_ARGUMENT', - details={'defaultChangeMode': default_change_mode}, - ) - - -def _apply_default_change_mode( - operation: Dict[str, Any], payload: Dict[str, Any], default_change_mode: Optional[ChangeMode] -) -> Dict[str, Any]: - if default_change_mode is None: - return payload - - if payload.get('changeMode') is not None: - return payload - - supports_change_mode = any(spec.get('name') == 'changeMode' for spec in operation.get('params', [])) - if not supports_change_mode: - return payload - - return {**payload, 'changeMode': default_change_mode} - - -def _extract_envelope_candidates(text: str) -> list: - """Build a list of JSON parse candidates from a CLI output stream.""" - candidates: list = [] - stripped = text.strip() - if not stripped: - return candidates - candidates.append(stripped) - lines = stripped.splitlines() - for index, line in enumerate(lines): - if not line.strip().startswith('{'): - continue - candidates.append('\n'.join(lines[index:]).strip()) - return candidates - - -def _try_parse_candidates(candidates: list) -> Optional[Dict[str, Any]]: - """Try to parse a JSON envelope from a list of candidates.""" - for candidate in candidates: - if not candidate: - continue - try: - parsed = json.loads(candidate) - if isinstance(parsed, dict) and 'ok' in parsed: - return parsed - except Exception: - continue - return None - - -def _parse_envelope(stdout: str, stderr: str) -> Dict[str, Any]: - if not stdout.strip() and not stderr.strip(): - raise SuperDocError('CLI returned no JSON envelope.', code='COMMAND_FAILED', details={'stdout': stdout, 'stderr': stderr}) - - # Try stdout first (where successful responses go), then stderr (where - # errors go). Previous code used `stdout or stderr` which silently - # discarded stderr whenever stdout was non-empty — even if stdout - # contained only telemetry noise. - result = _try_parse_candidates(_extract_envelope_candidates(stdout)) - if result is not None: - return result - - result = _try_parse_candidates(_extract_envelope_candidates(stderr)) - if result is not None: - return result - - raise SuperDocError( - 'CLI returned invalid JSON envelope.', - code='JSON_PARSE_ERROR', - details={'stdout': stdout, 'stderr': stderr, 'message': 'Failed to parse envelope JSON.'}, - ) - - -# Explicit exception set — these ops have sessionId but are NOT auto-targeted. -# doc.open: requires explicit doc+session coordination, never auto-resolves session. -_SESSION_BOUND_EXCEPTIONS = {'doc.open'} - - -def _derive_session_bound_ids(): - ops = CONTRACT.get('operations', {}) - return { - op_id for op_id, op in ops.items() - if op_id not in _SESSION_BOUND_EXCEPTIONS - and any( - isinstance(p, dict) and p.get('name') == 'sessionId' - for p in op.get('params', []) - ) - } - - -_SESSION_BOUND_OPERATION_IDS = _derive_session_bound_ids() - - -def _normalized_version(version: str) -> Optional[Tuple[int, int, int]]: - if not isinstance(version, str): - return None - - core = version.split('-', 1)[0] - parts = core.split('.') - if len(parts) < 3: - return None - - try: - return int(parts[0]), int(parts[1]), int(parts[2]) - except Exception: - return None - - -def _ensure_cli_version_compatible(envelope: Dict[str, Any]) -> None: - cli = CONTRACT.get('cli', {}) - min_version = cli.get('minVersion') - if not isinstance(min_version, str): - return - - meta = envelope.get('meta') - if not isinstance(meta, dict): - return - - cli_version = meta.get('version') - if not isinstance(cli_version, str): - return - if cli_version == '0.0.0': - return - - parsed_cli = _normalized_version(cli_version) - parsed_min = _normalized_version(min_version) - if not parsed_cli or not parsed_min: - return - - if parsed_cli < parsed_min: - raise SuperDocError( - f"CLI version {cli_version} is older than minimum required {min_version}.", - code='CLI_VERSION_UNSUPPORTED', - details={'cliVersion': cli_version, 'minVersion': min_version}, - ) - - -def _get_state_root(env: Mapping[str, str]) -> str: - override = env.get('SUPERDOC_CLI_STATE_DIR') or os.environ.get('SUPERDOC_CLI_STATE_DIR') - if override: - return os.path.abspath(override) - return os.path.join(os.path.expanduser('~'), '.superdoc-cli', 'state', 'v1') - - -def _read_text(path: str) -> Optional[str]: - try: - with open(path, 'r', encoding='utf-8') as handle: - return handle.read() - except Exception: - return None - - -def _read_json(path: str) -> Optional[Dict[str, Any]]: - raw = _read_text(path) - if raw is None: - return None - - try: - parsed = json.loads(raw) - except Exception: - return None - - if not isinstance(parsed, dict): - return None - return parsed - - -def _resolve_active_session_id(env: Mapping[str, str]) -> Optional[str]: - project_root = os.path.abspath(os.getcwd()) - project_hash = hashlib.sha256(project_root.encode('utf-8')).hexdigest()[:16] - active_path = os.path.join(_get_state_root(env), 'projects', project_hash, 'active-session') - raw = _read_text(active_path) - if not raw: - return None - session_id = raw.strip() - return session_id or None - - -def _is_collab_session(session_id: str, env: Mapping[str, str]) -> bool: - metadata_path = os.path.join(_get_state_root(env), 'contexts', session_id, 'metadata.json') - metadata = _read_json(metadata_path) - if not metadata: - return False - return metadata.get('sessionType') == 'collab' - - -def _target_session_id(operation_id: str, params: Dict[str, Any], env: Mapping[str, str]) -> Optional[str]: - if operation_id == 'doc.session.close': - value = params.get('sessionId') - if isinstance(value, str) and value: - return value - return None - - if operation_id not in _SESSION_BOUND_OPERATION_IDS: - return None - - if params.get('doc') is not None: - return None - - value = params.get('sessionId') - if isinstance(value, str) and value: - return value - return _resolve_active_session_id(env) - - -def _reject_python_collaboration(operation_id: str, params: Dict[str, Any], env: Mapping[str, str]) -> None: - if operation_id == 'doc.open': - for field in ('collaboration', 'collabUrl', 'collabDocumentId'): - if params.get(field) is not None: - raise SuperDocError( - 'Collaboration is not supported in the Python SDK.', - code='NOT_SUPPORTED', - details={'operation': operation_id, 'field': field}, - ) - return - - session_id = _target_session_id(operation_id, params, env) - if not session_id: - return - - if _is_collab_session(session_id, env): - raise SuperDocError( - 'Collaboration sessions are not supported in the Python SDK.', - code='NOT_SUPPORTED', - details={'operation': operation_id, 'sessionId': session_id}, - ) +from .generated.contract import OPERATION_INDEX +from .protocol import normalize_default_change_mode +from .transport import AsyncHostTransport, SyncHostTransport class SuperDocSyncRuntime: - def __init__(self, *, env: Optional[Mapping[str, str]] = None, default_change_mode: Optional[str] = None): + """Synchronous runtime backed by a persistent host transport.""" + + def __init__( + self, + *, + env: Optional[Dict[str, str]] = None, + startup_timeout_ms: int = 5_000, + shutdown_timeout_ms: int = 5_000, + request_timeout_ms: Optional[int] = None, + watchdog_timeout_ms: int = 30_000, + default_change_mode: Optional[str] = None, + user: Optional[Dict[str, str]] = None, + ) -> None: self._env = dict(env or {}) - self._cli_bin = self._env.get('SUPERDOC_CLI_BIN') or os.environ.get('SUPERDOC_CLI_BIN') or resolve_embedded_cli_path() - self._default_change_mode = _normalize_default_change_mode(default_change_mode) - - def invoke(self, operation_id: str, params: Optional[Dict[str, Any]] = None, *, timeout_ms: Optional[int] = None, stdin_bytes: Optional[bytes] = None) -> Dict[str, Any]: - operation = OPERATION_INDEX[operation_id] - command, prefix = _resolve_invocation(self._cli_bin) - - args: list = [*prefix, *operation['commandTokens']] - payload = _apply_default_change_mode(operation, params or {}, self._default_change_mode) - _reject_python_collaboration(operation_id, payload, self._env) - for spec in operation['params']: - _encode_param(args, spec, payload.get(spec['name'])) - - if timeout_ms is not None: - args.extend(['--timeout-ms', str(timeout_ms)]) - args.extend(['--output', 'json']) - - completed = subprocess.run( - [command, *args], - input=stdin_bytes, - capture_output=True, - env={**os.environ, **self._env}, - check=False, + cli_bin = self._env.get('SUPERDOC_CLI_BIN') or os.environ.get('SUPERDOC_CLI_BIN') or resolve_embedded_cli_path() + self._default_change_mode = normalize_default_change_mode(default_change_mode) + self._transport = SyncHostTransport( + cli_bin, + env=self._env, + startup_timeout_ms=startup_timeout_ms, + shutdown_timeout_ms=shutdown_timeout_ms, + request_timeout_ms=request_timeout_ms, + watchdog_timeout_ms=watchdog_timeout_ms, + default_change_mode=self._default_change_mode, + user=user, ) - envelope = _parse_envelope(completed.stdout.decode('utf-8', errors='replace'), completed.stderr.decode('utf-8', errors='replace')) - _ensure_cli_version_compatible(envelope) - if envelope.get('ok'): - return envelope['data'] + def connect(self) -> None: + self._transport.connect() + + def dispose(self) -> None: + self._transport.dispose() - error = envelope.get('error', {}) - raise SuperDocError( - error.get('message', 'Unknown CLI error'), - code=error.get('code', 'COMMAND_FAILED'), - details=error.get('details'), - exit_code=completed.returncode, + def invoke( + self, + operation_id: str, + params: Optional[Dict[str, Any]] = None, + *, + timeout_ms: Optional[int] = None, + stdin_bytes: Optional[bytes] = None, + ) -> Dict[str, Any]: + operation = OPERATION_INDEX[operation_id] + return self._transport.invoke( + operation, params or {}, + timeout_ms=timeout_ms, + stdin_bytes=stdin_bytes, ) class SuperDocAsyncRuntime: - def __init__(self, *, env: Optional[Mapping[str, str]] = None, default_change_mode: Optional[str] = None): + """Asynchronous runtime backed by a persistent host transport.""" + + def __init__( + self, + *, + env: Optional[Dict[str, str]] = None, + startup_timeout_ms: int = 5_000, + shutdown_timeout_ms: int = 5_000, + request_timeout_ms: Optional[int] = None, + watchdog_timeout_ms: int = 30_000, + max_queue_depth: int = 100, + default_change_mode: Optional[str] = None, + user: Optional[Dict[str, str]] = None, + ) -> None: self._env = dict(env or {}) - self._cli_bin = self._env.get('SUPERDOC_CLI_BIN') or os.environ.get('SUPERDOC_CLI_BIN') or resolve_embedded_cli_path() - self._default_change_mode = _normalize_default_change_mode(default_change_mode) - - async def invoke(self, operation_id: str, params: Optional[Dict[str, Any]] = None, *, timeout_ms: Optional[int] = None, stdin_bytes: Optional[bytes] = None) -> Dict[str, Any]: - operation = OPERATION_INDEX[operation_id] - command, prefix = _resolve_invocation(self._cli_bin) - - args: list = [*prefix, *operation['commandTokens']] - payload = _apply_default_change_mode(operation, params or {}, self._default_change_mode) - _reject_python_collaboration(operation_id, payload, self._env) - for spec in operation['params']: - _encode_param(args, spec, payload.get(spec['name'])) - - if timeout_ms is not None: - args.extend(['--timeout-ms', str(timeout_ms)]) - args.extend(['--output', 'json']) - - process = await asyncio.create_subprocess_exec( - command, - *args, - stdin=asyncio.subprocess.PIPE, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - env={**os.environ, **self._env}, + cli_bin = self._env.get('SUPERDOC_CLI_BIN') or os.environ.get('SUPERDOC_CLI_BIN') or resolve_embedded_cli_path() + self._default_change_mode = normalize_default_change_mode(default_change_mode) + self._transport = AsyncHostTransport( + cli_bin, + env=self._env, + startup_timeout_ms=startup_timeout_ms, + shutdown_timeout_ms=shutdown_timeout_ms, + request_timeout_ms=request_timeout_ms, + watchdog_timeout_ms=watchdog_timeout_ms, + max_queue_depth=max_queue_depth, + default_change_mode=self._default_change_mode, + user=user, ) - stdout, stderr = await process.communicate(stdin_bytes) - envelope = _parse_envelope(stdout.decode('utf-8', errors='replace'), stderr.decode('utf-8', errors='replace')) - _ensure_cli_version_compatible(envelope) - if envelope.get('ok'): - return envelope['data'] + async def connect(self) -> None: + await self._transport.connect() - error = envelope.get('error', {}) - raise SuperDocError( - error.get('message', 'Unknown CLI error'), - code=error.get('code', 'COMMAND_FAILED'), - details=error.get('details'), - exit_code=process.returncode, + async def dispose(self) -> None: + await self._transport.dispose() + + async def invoke( + self, + operation_id: str, + params: Optional[Dict[str, Any]] = None, + *, + timeout_ms: Optional[int] = None, + stdin_bytes: Optional[bytes] = None, + ) -> Dict[str, Any]: + operation = OPERATION_INDEX[operation_id] + return await self._transport.invoke( + operation, params or {}, + timeout_ms=timeout_ms, + stdin_bytes=stdin_bytes, ) diff --git a/packages/sdk/langs/python/superdoc/test_parity_helper.py b/packages/sdk/langs/python/superdoc/test_parity_helper.py index 065f24ea64..40f77d291e 100644 --- a/packages/sdk/langs/python/superdoc/test_parity_helper.py +++ b/packages/sdk/langs/python/superdoc/test_parity_helper.py @@ -45,36 +45,30 @@ def main() -> None: result = infer_document_features(command['infoResult']) print(json.dumps({'ok': True, 'result': result})) - elif action == 'isSessionBound': - from superdoc.runtime import _SESSION_BOUND_OPERATION_IDS - operation_id = command['operationId'] - result = operation_id in _SESSION_BOUND_OPERATION_IDS - print(json.dumps({'ok': True, 'result': result})) - - elif action == 'assertCollabRejection': - import os - import tempfile - from superdoc.runtime import _reject_python_collaboration - from superdoc.errors import SuperDocError + elif action == 'assertCollabAccepted': + # Verify collab params pass through to the runtime without + # SDK-level rejection. We build the argv from the operation spec + # to confirm nothing throws. + from superdoc.protocol import build_operation_argv + from superdoc.generated.contract import OPERATION_INDEX operation_id = command['operationId'] - session_id = command['sessionId'] - - # Create a temp state dir with a collab metadata.json - with tempfile.TemporaryDirectory() as tmpdir: - ctx_dir = os.path.join(tmpdir, 'contexts', session_id) - os.makedirs(ctx_dir, exist_ok=True) - meta_path = os.path.join(ctx_dir, 'metadata.json') - with open(meta_path, 'w') as f: - json.dump({'sessionType': 'collab'}, f) - - env = {'SUPERDOC_CLI_STATE_DIR': tmpdir} - params = {'sessionId': session_id} - try: - _reject_python_collaboration(operation_id, params, env) - print(json.dumps({'ok': True, 'result': {'rejected': False}})) - except SuperDocError as exc: - print(json.dumps({'ok': True, 'result': {'rejected': True, 'code': exc.code}})) + params = command.get('params', {}) + operation = OPERATION_INDEX[operation_id] + try: + argv = build_operation_argv(operation, params) + # Verify collab param values survived into argv. + # Flag names are kebab-case (--collab-url), so check values. + argv_str = ' '.join(argv) + collab_params_present = any( + str(params[key]) in argv_str + for key in ('collabUrl', 'collabDocumentId') + if params.get(key) is not None + ) + print(json.dumps({'ok': True, 'result': {'accepted': True, 'collabParamsPresent': collab_params_present}})) + except Exception as exc: + code = getattr(exc, 'code', None) or 'UNKNOWN' + print(json.dumps({'ok': True, 'result': {'accepted': False, 'code': code, 'message': str(exc)}})) else: print(json.dumps({'ok': False, 'error': f'Unknown action: {action}'})) diff --git a/packages/sdk/langs/python/superdoc/transport.py b/packages/sdk/langs/python/superdoc/transport.py new file mode 100644 index 0000000000..fec79e9c8e --- /dev/null +++ b/packages/sdk/langs/python/superdoc/transport.py @@ -0,0 +1,725 @@ +"""Host transport layer — persistent CLI process over JSON-RPC 2.0 stdio. + +Contains SyncHostTransport and AsyncHostTransport. Both spawn `superdoc host --stdio` +as a long-lived child process and communicate via newline-delimited JSON-RPC. + +Process lifecycle, stdin/stdout I/O, pending request tracking, and timeouts live here. +Protocol encoding/decoding and error mapping are delegated to protocol.py. +""" + +from __future__ import annotations + +import asyncio +import enum +import logging +import os +import subprocess +import threading +from typing import Any, Dict, List, Optional + +from .errors import ( + HOST_DISCONNECTED, + HOST_HANDSHAKE_FAILED, + HOST_PROTOCOL_ERROR, + HOST_QUEUE_FULL, + HOST_TIMEOUT, + SuperDocError, +) +from .protocol import ( + ChangeMode, + InvalidFrame, + JsonRpcError, + JsonRpcNotification, + JsonRpcResponse, + build_cli_invoke_payload, + build_operation_argv, + encode_jsonrpc_request, + map_jsonrpc_error, + parse_jsonrpc_line, + resolve_invocation, + resolve_watchdog_timeout, + validate_capabilities, +) + +logger = logging.getLogger('superdoc.transport') + +# Opt-in debug logging via SUPERDOC_DEBUG=1 or SUPERDOC_LOG_LEVEL=debug. +# Only configures the named logger — never mutates root logging config. +_log_level = os.environ.get('SUPERDOC_LOG_LEVEL', '').lower() +if os.environ.get('SUPERDOC_DEBUG') == '1' or _log_level == 'debug': + logger.setLevel(logging.DEBUG) + if not logger.handlers: + _handler = logging.StreamHandler() + _handler.setFormatter(logging.Formatter('%(asctime)s %(levelname)s %(name)s: %(message)s')) + logger.addHandler(_handler) + + +class _State(enum.Enum): + DISCONNECTED = 'DISCONNECTED' + CONNECTING = 'CONNECTING' + CONNECTED = 'CONNECTED' + DISPOSING = 'DISPOSING' + + +# --------------------------------------------------------------------------- +# SyncHostTransport +# --------------------------------------------------------------------------- + +class SyncHostTransport: + """Synchronous blocking host transport. + + Writes one JSON-RPC request to stdin, then reads stdout lines one at a time + (skipping notifications and invalid frames) until the matching response ID + is found. Uses a threading.Timer kill-switch for watchdog timeout. + + Thread-safety: a threading.Lock serializes concurrent invoke() calls. + """ + + def __init__( + self, + cli_bin: str, + *, + env: Optional[Dict[str, str]] = None, + startup_timeout_ms: int = 5_000, + shutdown_timeout_ms: int = 5_000, + request_timeout_ms: Optional[int] = None, + watchdog_timeout_ms: int = 30_000, + default_change_mode: Optional[ChangeMode] = None, + user: Optional[Dict[str, str]] = None, + ) -> None: + self._cli_bin = cli_bin + self._env = env or {} + self._startup_timeout_ms = startup_timeout_ms + self._shutdown_timeout_ms = shutdown_timeout_ms + self._request_timeout_ms = request_timeout_ms + self._watchdog_timeout_ms = watchdog_timeout_ms + self._default_change_mode = default_change_mode + self._user = user + + self._process: Optional[subprocess.Popen] = None + self._state = _State.DISCONNECTED + self._next_request_id = 1 + self._lock = threading.Lock() + + # -- Lifecycle ----------------------------------------------------------- + + def connect(self) -> None: + """Ensure the host process is running and handshake is complete.""" + with self._lock: + self._ensure_connected() + + def dispose(self) -> None: + """Gracefully shut down the host process.""" + with self._lock: + if self._state == _State.DISCONNECTED or self._state == _State.DISPOSING: + return + self._state = _State.DISPOSING + + process = self._process + if process is None: + self._state = _State.DISCONNECTED + return + + # Send host.shutdown request (best-effort). + try: + self._write_request('host.shutdown', {}) + except Exception: + pass + + # Wait for graceful exit. + try: + process.wait(timeout=self._shutdown_timeout_ms / 1000) + logger.debug('Host exited gracefully (pid=%s).', process.pid) + except subprocess.TimeoutExpired: + process.kill() + try: + process.wait(timeout=2) + except subprocess.TimeoutExpired: + pass + logger.debug('Host force-killed after shutdown timeout (pid=%s).', process.pid) + + self._cleanup() + + @property + def state(self) -> str: + return self._state.value + + # -- Invocation ---------------------------------------------------------- + + def invoke( + self, + operation: Dict[str, Any], + params: Dict[str, Any], + *, + timeout_ms: Optional[int] = None, + stdin_bytes: Optional[bytes] = None, + ) -> Any: + """Invoke a CLI operation over the host transport.""" + with self._lock: + if self._state == _State.DISPOSING: + raise SuperDocError( + 'Host is disposing.', + code=HOST_DISCONNECTED, + ) + self._ensure_connected() + + argv = build_operation_argv( + operation, params, + timeout_ms=timeout_ms, + default_change_mode=self._default_change_mode, + user=self._user, + ) + payload = build_cli_invoke_payload(argv, stdin_bytes) + watchdog = resolve_watchdog_timeout( + self._watchdog_timeout_ms, timeout_ms, self._request_timeout_ms, + ) + + result = self._send_request('cli.invoke', payload, watchdog) + + if not isinstance(result, dict): + raise SuperDocError( + 'Host returned invalid cli.invoke result.', + code=HOST_PROTOCOL_ERROR, + details={'result': result}, + ) + + return result.get('data') + + # -- Internal ------------------------------------------------------------ + + def _ensure_connected(self) -> None: + """Spawn and handshake if not already connected. Must be called under lock.""" + if self._state == _State.CONNECTED and self._process and self._process.poll() is None: + return + if self._state == _State.CONNECTING: + return + self._start_host() + + def _start_host(self) -> None: + """Spawn the host process and perform capability handshake.""" + self._state = _State.CONNECTING + + command, prefix_args = resolve_invocation(self._cli_bin) + args = [command, *prefix_args, 'host', '--stdio'] + + try: + self._process = subprocess.Popen( + args, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + env={**os.environ, **self._env}, + ) + logger.debug('Host spawned (pid=%s, bin=%s).', self._process.pid, self._cli_bin) + except Exception as exc: + self._state = _State.DISCONNECTED + raise SuperDocError( + f'Failed to start host process: {exc}', + code=HOST_HANDSHAKE_FAILED, + details={'message': str(exc)}, + ) from exc + + # Handshake. + try: + capabilities = self._send_request( + 'host.capabilities', {}, self._startup_timeout_ms, + ) + validate_capabilities(capabilities) + logger.debug( + 'Handshake complete (version=%s, features=%s).', + capabilities.get('protocolVersion') if isinstance(capabilities, dict) else '?', + capabilities.get('features') if isinstance(capabilities, dict) else '?', + ) + except SuperDocError: + self._kill_and_reset() + raise + except Exception as exc: + self._kill_and_reset() + raise SuperDocError( + 'Host handshake failed.', + code=HOST_HANDSHAKE_FAILED, + details={'message': str(exc)}, + ) from exc + + self._state = _State.CONNECTED + + def _send_request(self, method: str, params: Any, watchdog_ms: int) -> Any: + """Write a JSON-RPC request and block-read until matching response.""" + process = self._process + if not process or not process.stdin or not process.stdout: + raise SuperDocError('Host process is not available.', code=HOST_DISCONNECTED) + + request_id = self._next_request_id + self._next_request_id += 1 + + line = encode_jsonrpc_request(request_id, method, params) + logger.debug('Request #%d: method=%s', request_id, method) + + # Watchdog kill-switch: a background timer kills the process if readline blocks too long. + timed_out = threading.Event() + + def _watchdog_fire(): + timed_out.set() + try: + process.kill() + except Exception: + pass + + timer = threading.Timer(watchdog_ms / 1000, _watchdog_fire) + timer.daemon = True + timer.start() + + try: + process.stdin.write(line.encode('utf-8')) + process.stdin.flush() + except Exception as exc: + timer.cancel() + self._kill_and_reset() + raise SuperDocError( + 'Failed to write request to host process.', + code=HOST_DISCONNECTED, + details={'method': method}, + ) from exc + + # Blocking read loop — skip notifications and invalid frames until matching response. + try: + while True: + raw = process.stdout.readline() + if not raw: + # EOF — process died. + timer.cancel() + if timed_out.is_set(): + self._kill_and_reset() + raise SuperDocError( + f'Host watchdog timed out waiting for {method}.', + code=HOST_TIMEOUT, + details={'method': method, 'timeout_ms': watchdog_ms}, + ) + exit_code = process.poll() + self._kill_and_reset() + raise SuperDocError( + 'Host process disconnected.', + code=HOST_DISCONNECTED, + details={'exit_code': exit_code, 'signal': None}, + ) + + decoded = raw.decode('utf-8', errors='replace') + msg = parse_jsonrpc_line(decoded) + + if isinstance(msg, JsonRpcNotification): + logger.debug('Notification received: method=%s', msg.method) + continue + + if isinstance(msg, InvalidFrame): + continue + + if isinstance(msg, JsonRpcError) and msg.id == request_id: + timer.cancel() + logger.debug('Response #%d: error', request_id) + raise map_jsonrpc_error(msg.error) + + if isinstance(msg, JsonRpcResponse) and msg.id == request_id: + timer.cancel() + logger.debug('Response #%d: ok', request_id) + return msg.result + + # Response for a different ID — should not happen in sync transport. + # Skip it. + continue + + except SuperDocError: + raise + except Exception as exc: + timer.cancel() + if timed_out.is_set(): + self._kill_and_reset() + raise SuperDocError( + f'Host watchdog timed out waiting for {method}.', + code=HOST_TIMEOUT, + details={'method': method, 'timeout_ms': watchdog_ms}, + ) from exc + self._kill_and_reset() + raise SuperDocError( + 'Host process disconnected.', + code=HOST_DISCONNECTED, + details={'message': str(exc)}, + ) from exc + + def _write_request(self, method: str, params: Any) -> int: + """Write a JSON-RPC request without waiting for a response.""" + process = self._process + if not process or not process.stdin: + raise SuperDocError('Host process is not available.', code=HOST_DISCONNECTED) + request_id = self._next_request_id + self._next_request_id += 1 + line = encode_jsonrpc_request(request_id, method, params) + process.stdin.write(line.encode('utf-8')) + process.stdin.flush() + return request_id + + def _kill_and_reset(self) -> None: + """Kill the host process and reset to DISCONNECTED.""" + process = self._process + if process: + try: + process.kill() + except Exception: + pass + try: + process.wait(timeout=2) + except Exception: + pass + self._cleanup() + + def _cleanup(self) -> None: + """Clear all process state. Transition to DISCONNECTED.""" + self._process = None + self._state = _State.DISCONNECTED + + +# --------------------------------------------------------------------------- +# AsyncHostTransport +# --------------------------------------------------------------------------- + +class AsyncHostTransport: + """Asynchronous host transport with a background reader task. + + Maintains a dict[int, asyncio.Future] of pending requests. A background + asyncio.Task reads stdout lines and dispatches each response to its matching + future by request ID. + """ + + def __init__( + self, + cli_bin: str, + *, + env: Optional[Dict[str, str]] = None, + startup_timeout_ms: int = 5_000, + shutdown_timeout_ms: int = 5_000, + request_timeout_ms: Optional[int] = None, + watchdog_timeout_ms: int = 30_000, + max_queue_depth: int = 100, + default_change_mode: Optional[ChangeMode] = None, + user: Optional[Dict[str, str]] = None, + ) -> None: + self._cli_bin = cli_bin + self._env = env or {} + self._startup_timeout_ms = startup_timeout_ms + self._shutdown_timeout_ms = shutdown_timeout_ms + self._request_timeout_ms = request_timeout_ms + self._watchdog_timeout_ms = watchdog_timeout_ms + self._max_queue_depth = max_queue_depth + self._default_change_mode = default_change_mode + self._user = user + + self._process: Optional[asyncio.subprocess.Process] = None + self._reader_task: Optional[asyncio.Task] = None + self._pending: Dict[int, asyncio.Future] = {} + self._state = _State.DISCONNECTED + self._next_request_id = 1 + self._connecting: Optional[asyncio.Future] = None + self._stopping = False + + # -- Lifecycle ----------------------------------------------------------- + + async def connect(self) -> None: + """Ensure the host process is running and handshake is complete.""" + await self._ensure_connected() + + async def dispose(self) -> None: + """Gracefully shut down the host process.""" + if self._state == _State.DISCONNECTED or self._state == _State.DISPOSING: + return + + self._stopping = True + self._state = _State.DISPOSING + + process = self._process + if process is None: + self._state = _State.DISCONNECTED + self._stopping = False + return + + # Send host.shutdown (best-effort). + try: + await self._send_request('host.shutdown', {}, self._shutdown_timeout_ms) + except Exception: + pass + + # Wait for process exit with timeout. + try: + await asyncio.wait_for(process.wait(), timeout=self._shutdown_timeout_ms / 1000) + logger.debug('Host exited gracefully (pid=%s).', process.pid) + except asyncio.TimeoutError: + process.kill() + try: + await asyncio.wait_for(process.wait(), timeout=2) + except asyncio.TimeoutError: + pass + logger.debug('Host force-killed after shutdown timeout (pid=%s).', process.pid) + + await self._cleanup(None) + self._stopping = False + + @property + def state(self) -> str: + return self._state.value + + # -- Invocation ---------------------------------------------------------- + + async def invoke( + self, + operation: Dict[str, Any], + params: Dict[str, Any], + *, + timeout_ms: Optional[int] = None, + stdin_bytes: Optional[bytes] = None, + ) -> Any: + """Invoke a CLI operation over the host transport.""" + if self._state == _State.DISPOSING: + raise SuperDocError('Host is disposing.', code=HOST_DISCONNECTED) + + await self._ensure_connected() + + argv = build_operation_argv( + operation, params, + timeout_ms=timeout_ms, + default_change_mode=self._default_change_mode, + user=self._user, + ) + payload = build_cli_invoke_payload(argv, stdin_bytes) + watchdog = resolve_watchdog_timeout( + self._watchdog_timeout_ms, timeout_ms, self._request_timeout_ms, + ) + + result = await self._send_request('cli.invoke', payload, watchdog) + + if not isinstance(result, dict): + raise SuperDocError( + 'Host returned invalid cli.invoke result.', + code=HOST_PROTOCOL_ERROR, + details={'result': result}, + ) + + return result.get('data') + + # -- Internal ------------------------------------------------------------ + + async def _ensure_connected(self) -> None: + """Lazy connect: spawn and handshake if not already connected.""" + if self._state == _State.CONNECTED and self._process and self._process.returncode is None: + return + + if self._connecting is not None: + await self._connecting + return + + # Use a Task for single-flight connect. All concurrent callers await + # the same task, so the exception is always consumed — no "Future + # exception was never retrieved" noise. + self._connecting = asyncio.ensure_future(self._start_host()) + try: + await self._connecting + finally: + self._connecting = None + + async def _start_host(self) -> None: + """Spawn the host process and perform capability handshake.""" + self._state = _State.CONNECTING + + command, prefix_args = resolve_invocation(self._cli_bin) + args = [*prefix_args, 'host', '--stdio'] + + try: + self._process = await asyncio.create_subprocess_exec( + command, *args, + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.DEVNULL, + env={**os.environ, **self._env}, + ) + logger.debug('Host spawned (pid=%s, bin=%s).', self._process.pid, self._cli_bin) + except Exception as exc: + self._state = _State.DISCONNECTED + raise SuperDocError( + f'Failed to start host process: {exc}', + code=HOST_HANDSHAKE_FAILED, + details={'message': str(exc)}, + ) from exc + + # Start background reader task. + self._reader_task = asyncio.ensure_future(self._reader_loop()) + + # Handshake. + try: + capabilities = await self._send_request( + 'host.capabilities', {}, self._startup_timeout_ms, + ) + validate_capabilities(capabilities) + logger.debug( + 'Handshake complete (version=%s, features=%s).', + capabilities.get('protocolVersion') if isinstance(capabilities, dict) else '?', + capabilities.get('features') if isinstance(capabilities, dict) else '?', + ) + except SuperDocError: + await self._kill_and_reset() + raise + except Exception as exc: + await self._kill_and_reset() + raise SuperDocError( + 'Host handshake failed.', + code=HOST_HANDSHAKE_FAILED, + details={'message': str(exc)}, + ) from exc + + self._state = _State.CONNECTED + + async def _reader_loop(self) -> None: + """Background task: read stdout lines and dispatch to pending futures.""" + process = self._process + if not process or not process.stdout: + return + + try: + while True: + raw = await process.stdout.readline() + if not raw: + # EOF — process died. + break + + decoded = raw.decode('utf-8', errors='replace') + msg = parse_jsonrpc_line(decoded) + + if isinstance(msg, JsonRpcNotification): + logger.debug('Notification received: method=%s', msg.method) + continue + + if isinstance(msg, InvalidFrame): + continue + + if isinstance(msg, JsonRpcError): + future = self._pending.pop(msg.id, None) + if future and not future.done(): + future.set_exception(map_jsonrpc_error(msg.error)) + continue + + if isinstance(msg, JsonRpcResponse): + future = self._pending.pop(msg.id, None) + if future and not future.done(): + future.set_result(msg.result) + continue + + except asyncio.CancelledError: + return + except Exception as exc: + logger.debug('Reader loop error: %s', exc) + + # Reader exited (EOF or error) — reject all pending futures. + if not self._stopping: + exit_code = process.returncode + error = SuperDocError( + 'Host process disconnected.', + code=HOST_DISCONNECTED, + details={'exit_code': exit_code, 'signal': None}, + ) + self._reject_all_pending(error) + self._state = _State.DISCONNECTED + + async def _send_request(self, method: str, params: Any, watchdog_ms: int) -> Any: + """Send a JSON-RPC request and await the matching response future.""" + process = self._process + if not process or not process.stdin: + raise SuperDocError('Host process is not available.', code=HOST_DISCONNECTED) + + if len(self._pending) >= self._max_queue_depth: + raise SuperDocError( + 'Host request queue is full.', + code=HOST_QUEUE_FULL, + details={'max_queue_depth': self._max_queue_depth}, + ) + + request_id = self._next_request_id + self._next_request_id += 1 + + line = encode_jsonrpc_request(request_id, method, params) + logger.debug('Request #%d: method=%s', request_id, method) + + loop = asyncio.get_running_loop() + future: asyncio.Future = loop.create_future() + self._pending[request_id] = future + + try: + process.stdin.write(line.encode('utf-8')) + await process.stdin.drain() + except Exception as exc: + self._pending.pop(request_id, None) + if not future.done(): + future.cancel() + await self._kill_and_reset() + raise SuperDocError( + 'Failed to write request to host process.', + code=HOST_DISCONNECTED, + details={'method': method}, + ) from exc + + # Await response with watchdog timeout. + try: + result = await asyncio.wait_for(future, timeout=watchdog_ms / 1000) + logger.debug('Response #%d: ok', request_id) + return result + except asyncio.TimeoutError: + self._pending.pop(request_id, None) + logger.debug('Timeout #%d: method=%s, timeout_ms=%d', request_id, method, watchdog_ms) + # Kill the process — all other pending requests will fail via reader EOF. + await self._kill_and_reset() + raise SuperDocError( + f'Host watchdog timed out waiting for {method}.', + code=HOST_TIMEOUT, + details={'method': method, 'timeout_ms': watchdog_ms}, + ) + + def _reject_all_pending(self, error: SuperDocError) -> None: + """Reject all pending futures with the given error.""" + pending = list(self._pending.values()) + self._pending.clear() + for future in pending: + if not future.done(): + future.set_exception(error) + + async def _kill_and_reset(self) -> None: + """Kill the host process and reset to DISCONNECTED.""" + await self._cleanup( + SuperDocError('Host process disconnected.', code=HOST_DISCONNECTED), + ) + + async def _cleanup(self, error: Optional[SuperDocError]) -> None: + """Cancel reader, kill process, reject pending, reset state.""" + if self._reader_task and not self._reader_task.done(): + self._reader_task.cancel() + try: + await self._reader_task + except (asyncio.CancelledError, Exception): + pass + self._reader_task = None + + process = self._process + if process: + try: + process.kill() + except Exception: + pass + try: + await asyncio.wait_for(process.wait(), timeout=2) + except (asyncio.TimeoutError, Exception): + pass + self._process = None + + if error: + self._reject_all_pending(error) + else: + # Dispose path — reject remaining with generic disconnect. + self._reject_all_pending( + SuperDocError('Host process was disposed.', code=HOST_DISCONNECTED), + ) + + self._state = _State.DISCONNECTED diff --git a/packages/sdk/langs/python/superdoc_sdk.egg-info/PKG-INFO b/packages/sdk/langs/python/superdoc_sdk.egg-info/PKG-INFO deleted file mode 100644 index b82210040a..0000000000 --- a/packages/sdk/langs/python/superdoc_sdk.egg-info/PKG-INFO +++ /dev/null @@ -1,7 +0,0 @@ -Metadata-Version: 2.4 -Name: superdoc-sdk -Version: 1.0.0a4 -Summary: SuperDoc SDK (CLI-backed) -Author: SuperDoc -License-Expression: AGPL-3.0 -Requires-Python: >=3.9 diff --git a/packages/sdk/langs/python/superdoc_sdk.egg-info/SOURCES.txt b/packages/sdk/langs/python/superdoc_sdk.egg-info/SOURCES.txt deleted file mode 100644 index d854f528f5..0000000000 --- a/packages/sdk/langs/python/superdoc_sdk.egg-info/SOURCES.txt +++ /dev/null @@ -1,26 +0,0 @@ -pyproject.toml -superdoc/__init__.py -superdoc/embedded_cli.py -superdoc/errors.py -superdoc/runtime.py -superdoc/skill_api.py -superdoc/test_parity_helper.py -superdoc/tools_api.py -superdoc/_vendor/__init__.py -superdoc/_vendor/cli/__init__.py -superdoc/generated/__init__.py -superdoc/generated/client.py -superdoc/generated/contract.py -superdoc/skills/__init__.py -superdoc/skills/editing-docx.md -superdoc/tools/catalog.json -superdoc/tools/tool-name-map.json -superdoc/tools/tools-policy.json -superdoc/tools/tools.anthropic.json -superdoc/tools/tools.generic.json -superdoc/tools/tools.openai.json -superdoc/tools/tools.vercel.json -superdoc_sdk.egg-info/PKG-INFO -superdoc_sdk.egg-info/SOURCES.txt -superdoc_sdk.egg-info/dependency_links.txt -superdoc_sdk.egg-info/top_level.txt \ No newline at end of file diff --git a/packages/sdk/langs/python/superdoc_sdk.egg-info/dependency_links.txt b/packages/sdk/langs/python/superdoc_sdk.egg-info/dependency_links.txt deleted file mode 100644 index 8b13789179..0000000000 --- a/packages/sdk/langs/python/superdoc_sdk.egg-info/dependency_links.txt +++ /dev/null @@ -1 +0,0 @@ - diff --git a/packages/sdk/langs/python/superdoc_sdk.egg-info/top_level.txt b/packages/sdk/langs/python/superdoc_sdk.egg-info/top_level.txt deleted file mode 100644 index 9c5371f4dd..0000000000 --- a/packages/sdk/langs/python/superdoc_sdk.egg-info/top_level.txt +++ /dev/null @@ -1 +0,0 @@ -superdoc diff --git a/packages/sdk/langs/python/tests/mock_host.py b/packages/sdk/langs/python/tests/mock_host.py new file mode 100644 index 0000000000..8e8e59e6e0 --- /dev/null +++ b/packages/sdk/langs/python/tests/mock_host.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +"""Mock host process for transport tests. + +Speaks JSON-RPC 2.0 over stdio. Behavior is configured via a JSON `scenario` +object passed as the first CLI argument (base64-encoded). + +Scenario fields: + handshake: "ok" | "bad_version" | "missing_features" | "invalid" | "timeout" + responses: list of response configs for sequential cli.invoke calls: + - {"data": ...} → success response + - {"error": {...}} → JSON-RPC error response + - {"delay_ms": N, ...} → delay before responding + - {"crash": true} → exit immediately (simulates crash) + - {"notification": {...}} → send a notification before the real response + - {"malformed": true} → send invalid JSON before the real response + - {"partial": true, ...} → write response in two chunks with a small delay +""" + +from __future__ import annotations + +import base64 +import json +import sys +import time + + +def main() -> None: + scenario_b64 = sys.argv[1] if len(sys.argv) > 1 else '' + scenario = json.loads(base64.b64decode(scenario_b64)) if scenario_b64 else {} + + handshake_mode = scenario.get('handshake', 'ok') + responses = list(scenario.get('responses', [])) + response_index = 0 + + while True: + raw = sys.stdin.readline() + if not raw: + break + + try: + request = json.loads(raw.strip()) + except (json.JSONDecodeError, ValueError): + continue + + request_id = request.get('id') + method = request.get('method') + + if method == 'host.capabilities': + if handshake_mode == 'ok': + _send_response(request_id, { + 'protocolVersion': '1.0', + 'features': ['cli.invoke', 'host.shutdown', 'host.describe'], + }) + elif handshake_mode == 'bad_version': + _send_response(request_id, { + 'protocolVersion': '2.0', + 'features': ['cli.invoke', 'host.shutdown'], + }) + elif handshake_mode == 'missing_features': + _send_response(request_id, { + 'protocolVersion': '1.0', + 'features': ['host.describe'], + }) + elif handshake_mode == 'invalid': + _send_response(request_id, 'not-an-object') + elif handshake_mode == 'timeout': + # Just don't respond — let the transport timeout. + time.sleep(30) + sys.exit(0) + continue + + if method == 'host.shutdown': + _send_response(request_id, {}) + sys.exit(0) + + if method == 'cli.invoke': + if response_index >= len(responses): + _send_response(request_id, {'data': None}) + continue + + config = responses[response_index] + response_index += 1 + + if config.get('crash'): + sys.exit(1) + + delay_ms = config.get('delay_ms', 0) + if delay_ms > 0: + time.sleep(delay_ms / 1000) + + if config.get('notification'): + notif = { + 'jsonrpc': '2.0', + 'method': config['notification'].get('method', 'event.test'), + 'params': config['notification'].get('params', {}), + } + sys.stdout.write(json.dumps(notif) + '\n') + sys.stdout.flush() + + if config.get('malformed'): + sys.stdout.write('this is not json{{{\n') + sys.stdout.flush() + + if 'error' in config: + _send_error(request_id, config['error']) + elif config.get('partial'): + # Write response in two chunks to test line-buffering. + result = config.get('data', None) + full = json.dumps({ + 'jsonrpc': '2.0', 'id': request_id, 'result': {'data': result}, + }) + mid = len(full) // 2 + sys.stdout.write(full[:mid]) + sys.stdout.flush() + time.sleep(0.05) + sys.stdout.write(full[mid:] + '\n') + sys.stdout.flush() + else: + _send_response(request_id, {'data': config.get('data', None)}) + continue + + # Unknown method — respond with error. + _send_error(request_id, {'code': -32601, 'message': f'Method not found: {method}'}) + + +def _send_response(request_id: int, result) -> None: + msg = json.dumps({'jsonrpc': '2.0', 'id': request_id, 'result': result}) + sys.stdout.write(msg + '\n') + sys.stdout.flush() + + +def _send_error(request_id: int, error: dict) -> None: + msg = json.dumps({'jsonrpc': '2.0', 'id': request_id, 'error': error}) + sys.stdout.write(msg + '\n') + sys.stdout.flush() + + +if __name__ == '__main__': + main() diff --git a/packages/sdk/langs/python/tests/test_parse_envelope.py b/packages/sdk/langs/python/tests/test_parse_envelope.py deleted file mode 100644 index ec93c2cfcb..0000000000 --- a/packages/sdk/langs/python/tests/test_parse_envelope.py +++ /dev/null @@ -1,82 +0,0 @@ -""" -Failing tests that expose: _parse_envelope masks real CLI errors. - -When stdout contains non-JSON noise (telemetry, warnings, debug output) and -stderr contains the actual error message, _parse_envelope uses `stdout or stderr` -which picks stdout (truthy non-empty string), never sees stderr, and raises a -generic JSON_PARSE_ERROR instead of surfacing the real error. -""" - -import pytest -import sys -import os - -sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) - -from superdoc.runtime import _parse_envelope -from superdoc.errors import SuperDocError - - -class TestParseEnvelopeSurfacesStderrErrors: - """These tests FAIL because _parse_envelope ignores stderr when stdout is non-empty.""" - - def test_stderr_error_not_masked_when_stdout_has_noise(self): - """ - Bug: When the CLI prints telemetry/noise to stdout and the real error to - stderr, _parse_envelope picks stdout (because `stdout or stderr` returns - stdout when it's truthy), fails to parse it as JSON, and raises a generic - JSON_PARSE_ERROR — masking the real error message in stderr. - - Expected: The error from stderr should be surfaced, not a generic parse error. - """ - stdout = "Telemetry: initialized in 42ms\nSome debug noise\n" - stderr = '{"ok": false, "error": {"code": "FILE_NOT_FOUND", "message": "Document not found: contract.docx"}, "meta": {"version": "1.0.0"}}' - - # This SHOULD return the parsed stderr envelope with the real error. - # Instead, it raises JSON_PARSE_ERROR because it only looks at stdout. - result = _parse_envelope(stdout, stderr) - - assert result['ok'] is False - assert result['error']['code'] == 'FILE_NOT_FOUND' - assert 'contract.docx' in result['error']['message'] - - def test_stderr_error_accessible_when_stdout_is_partial_json(self): - """ - Bug: stdout contains a partial/corrupt JSON object (e.g. truncated output), - stderr contains the real error envelope. _parse_envelope tries to parse - stdout, fails, and never falls back to stderr. - - Expected: Should try stderr when stdout parsing fails. - """ - stdout = '{"ok": true, "data": {"coun' # truncated - stderr = '{"ok": false, "error": {"code": "TIMEOUT", "message": "Operation timed out after 30000ms"}, "meta": {"version": "1.0.0"}}' - - result = _parse_envelope(stdout, stderr) - - assert result['ok'] is False - assert result['error']['code'] == 'TIMEOUT' - - def test_real_error_code_preserved_not_replaced_with_json_parse_error(self): - """ - Bug: Even when the real error is available in stderr, the raised exception - always has code='JSON_PARSE_ERROR' because _parse_envelope never looks at - stderr when stdout is non-empty. - - Expected: The exception should carry the real error code from stderr. - """ - stdout = "Warning: deprecated API version\n" - stderr = '{"ok": false, "error": {"code": "VALIDATION_ERROR", "message": "Invalid query: missing required field select"}, "meta": {"version": "1.0.0"}}' - - # _parse_envelope should NOT raise here — it should return the stderr envelope. - # But because it only looks at stdout, it raises JSON_PARSE_ERROR. - try: - result = _parse_envelope(stdout, stderr) - # If we get here, verify we got the right error - assert result['error']['code'] == 'VALIDATION_ERROR' - except SuperDocError as exc: - # This is the bug: we get JSON_PARSE_ERROR instead of the real error - pytest.fail( - f"_parse_envelope raised JSON_PARSE_ERROR instead of returning the " - f"stderr envelope. Got code='{exc.code}', but stderr contains " - f"VALIDATION_ERROR. The real CLI error is masked." - ) diff --git a/packages/sdk/langs/python/tests/test_protocol.py b/packages/sdk/langs/python/tests/test_protocol.py new file mode 100644 index 0000000000..8fe6068acf --- /dev/null +++ b/packages/sdk/langs/python/tests/test_protocol.py @@ -0,0 +1,396 @@ +"""Unit tests for superdoc.protocol — pure function tests, no subprocess needed.""" + +from __future__ import annotations + +import json +import sys +import os + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) + +import pytest + +from superdoc.protocol import ( + apply_default_change_mode, + apply_default_user, + build_cli_invoke_payload, + build_operation_argv, + encode_jsonrpc_request, + map_jsonrpc_error, + normalize_default_change_mode, + parse_jsonrpc_line, + resolve_invocation, + resolve_watchdog_timeout, + validate_capabilities, + InvalidFrame, + JsonRpcError, + JsonRpcNotification, + JsonRpcResponse, +) +from superdoc.errors import SuperDocError + + +# --------------------------------------------------------------------------- +# JSON-RPC encoding / decoding round-trips +# --------------------------------------------------------------------------- + +class TestEncodeJsonRpcRequest: + def test_basic_request(self): + line = encode_jsonrpc_request(1, 'host.capabilities', {}) + parsed = json.loads(line.strip()) + assert parsed['jsonrpc'] == '2.0' + assert parsed['id'] == 1 + assert parsed['method'] == 'host.capabilities' + assert parsed['params'] == {} + + def test_no_params(self): + line = encode_jsonrpc_request(42, 'test.method') + parsed = json.loads(line.strip()) + assert 'params' not in parsed + + def test_newline_terminated(self): + line = encode_jsonrpc_request(1, 'test', {}) + assert line.endswith('\n') + + +class TestParseJsonRpcLine: + def test_valid_response(self): + line = '{"jsonrpc":"2.0","id":1,"result":{"data":"hello"}}' + msg = parse_jsonrpc_line(line) + assert isinstance(msg, JsonRpcResponse) + assert msg.id == 1 + assert msg.result == {'data': 'hello'} + + def test_valid_error(self): + line = '{"jsonrpc":"2.0","id":2,"error":{"code":-32600,"message":"Invalid Request"}}' + msg = parse_jsonrpc_line(line) + assert isinstance(msg, JsonRpcError) + assert msg.id == 2 + assert msg.error['code'] == -32600 + + def test_notification(self): + line = '{"jsonrpc":"2.0","method":"event.change","params":{"doc":"x"}}' + msg = parse_jsonrpc_line(line) + assert isinstance(msg, JsonRpcNotification) + assert msg.method == 'event.change' + assert msg.params == {'doc': 'x'} + + def test_invalid_json(self): + msg = parse_jsonrpc_line('not json at all') + assert isinstance(msg, InvalidFrame) + + def test_empty_line(self): + msg = parse_jsonrpc_line('') + assert isinstance(msg, InvalidFrame) + + def test_non_jsonrpc(self): + msg = parse_jsonrpc_line('{"foo":"bar"}') + assert isinstance(msg, InvalidFrame) + + def test_missing_id(self): + # Has jsonrpc 2.0 but no id and no method — invalid. + msg = parse_jsonrpc_line('{"jsonrpc":"2.0","result":"ok"}') + assert isinstance(msg, InvalidFrame) + + def test_non_integer_id(self): + msg = parse_jsonrpc_line('{"jsonrpc":"2.0","id":"abc","result":"ok"}') + assert isinstance(msg, InvalidFrame) + + def test_round_trip(self): + line = encode_jsonrpc_request(7, 'cli.invoke', {'argv': ['doc', 'find']}) + # The encoded line is a request (has method+id). Since it has an int id + # and no 'error' key, it parses as a JsonRpcResponse with result=None. + # This is fine — the transport never receives its own requests. + msg = parse_jsonrpc_line(line) + assert isinstance(msg, (JsonRpcResponse, InvalidFrame)) + + +# --------------------------------------------------------------------------- +# Error mapping +# --------------------------------------------------------------------------- + +class TestMapJsonRpcError: + def test_cli_code_passthrough(self): + raw = {'code': -32000, 'message': 'failed', 'data': {'cliCode': 'FILE_NOT_FOUND', 'message': 'Not found'}} + err = map_jsonrpc_error(raw) + assert err.code == 'FILE_NOT_FOUND' + assert 'Not found' in str(err) + + def test_timeout_code(self): + raw = {'code': -32011, 'message': 'Operation timed out', 'data': {'timeout': 30000}} + err = map_jsonrpc_error(raw) + assert err.code == 'TIMEOUT' + + def test_fallback_command_failed(self): + raw = {'code': -32603, 'message': 'Internal error'} + err = map_jsonrpc_error(raw) + assert err.code == 'COMMAND_FAILED' + + def test_non_dict_error(self): + err = map_jsonrpc_error('not a dict') + assert err.code == 'HOST_PROTOCOL_ERROR' + + def test_cli_code_with_exit_code(self): + raw = {'code': -32000, 'message': 'fail', 'data': {'cliCode': 'VALIDATION_ERROR', 'exitCode': 1, 'details': {'field': 'x'}}} + err = map_jsonrpc_error(raw) + assert err.code == 'VALIDATION_ERROR' + assert err.exit_code == 1 + assert err.details == {'field': 'x'} + + +# --------------------------------------------------------------------------- +# Capability validation +# --------------------------------------------------------------------------- + +class TestValidateCapabilities: + def test_valid_capabilities(self): + validate_capabilities({ + 'protocolVersion': '1.0', + 'features': ['cli.invoke', 'host.shutdown', 'host.describe'], + }) + + def test_bad_version(self): + with pytest.raises(SuperDocError) as exc_info: + validate_capabilities({'protocolVersion': '2.0', 'features': ['cli.invoke', 'host.shutdown']}) + assert exc_info.value.code == 'HOST_HANDSHAKE_FAILED' + assert exc_info.value.details['expected'] == '1.0' + assert exc_info.value.details['actual'] == '2.0' + + def test_missing_features(self): + with pytest.raises(SuperDocError) as exc_info: + validate_capabilities({'protocolVersion': '1.0', 'features': ['host.describe']}) + assert exc_info.value.code == 'HOST_HANDSHAKE_FAILED' + + def test_non_dict_response(self): + with pytest.raises(SuperDocError) as exc_info: + validate_capabilities('not-an-object') + assert exc_info.value.code == 'HOST_HANDSHAKE_FAILED' + + def test_features_not_array(self): + with pytest.raises(SuperDocError) as exc_info: + validate_capabilities({'protocolVersion': '1.0', 'features': 'not-a-list'}) + assert exc_info.value.code == 'HOST_HANDSHAKE_FAILED' + + +# --------------------------------------------------------------------------- +# Argv construction +# --------------------------------------------------------------------------- + +class TestBuildOperationArgv: + def _make_op(self, params=None): + return { + 'commandTokens': ['doc', 'find'], + 'params': params or [ + {'name': 'query', 'kind': 'flag', 'type': 'string'}, + {'name': 'type', 'kind': 'flag', 'type': 'string'}, + {'name': 'limit', 'kind': 'flag', 'type': 'number'}, + ], + } + + def test_basic_argv(self): + argv = build_operation_argv(self._make_op(), {'query': 'hello'}) + assert argv[:2] == ['doc', 'find'] + assert '--query' in argv + assert 'hello' in argv + assert argv[-2:] == ['--output', 'json'] + + def test_timeout_appended(self): + argv = build_operation_argv(self._make_op(), {}, timeout_ms=5000) + assert '--timeout-ms' in argv + idx = argv.index('--timeout-ms') + assert argv[idx + 1] == '5000' + + def test_default_change_mode_injected(self): + op = self._make_op(params=[ + {'name': 'query', 'kind': 'flag', 'type': 'string'}, + {'name': 'changeMode', 'kind': 'flag', 'type': 'string'}, + ]) + argv = build_operation_argv(op, {'query': 'x'}, default_change_mode='tracked') + assert '--changeMode' in argv + idx = argv.index('--changeMode') + assert argv[idx + 1] == 'tracked' + + def test_boolean_encoding(self): + op = self._make_op(params=[{'name': 'verbose', 'kind': 'flag', 'type': 'boolean'}]) + argv = build_operation_argv(op, {'verbose': True}) + idx = argv.index('--verbose') + assert argv[idx + 1] == 'true' + + def test_string_array_encoding(self): + op = self._make_op(params=[{'name': 'tags', 'kind': 'flag', 'type': 'string[]'}]) + argv = build_operation_argv(op, {'tags': ['a', 'b']}) + tag_indices = [i for i, v in enumerate(argv) if v == '--tags'] + assert len(tag_indices) == 2 + + def test_json_flag_encoding(self): + op = self._make_op(params=[{'name': 'config', 'kind': 'jsonFlag', 'type': 'json'}]) + argv = build_operation_argv(op, {'config': {'key': 'val'}}) + idx = argv.index('--config') + assert json.loads(argv[idx + 1]) == {'key': 'val'} + + def test_doc_positional(self): + op = self._make_op(params=[{'name': 'doc', 'kind': 'doc', 'type': 'string'}]) + argv = build_operation_argv(op, {'doc': '/path/to/file.docx'}) + assert argv[2] == '/path/to/file.docx' + + +# --------------------------------------------------------------------------- +# CLI invoke payload +# --------------------------------------------------------------------------- + +class TestBuildCliInvokePayload: + def test_no_stdin(self): + payload = build_cli_invoke_payload(['doc', 'find']) + assert payload['argv'] == ['doc', 'find'] + assert payload['stdinBase64'] == '' + + def test_with_stdin(self): + payload = build_cli_invoke_payload(['doc', 'open'], stdin_bytes=b'hello') + import base64 + assert base64.b64decode(payload['stdinBase64']) == b'hello' + + +# --------------------------------------------------------------------------- +# Watchdog timeout resolution +# --------------------------------------------------------------------------- + +class TestResolveWatchdogTimeout: + def test_default(self): + assert resolve_watchdog_timeout(30_000) == 30_000 + + def test_override_larger(self): + assert resolve_watchdog_timeout(30_000, timeout_ms_override=40_000) == 41_000 + + def test_override_smaller(self): + assert resolve_watchdog_timeout(30_000, timeout_ms_override=10_000) == 30_000 + + def test_request_timeout(self): + assert resolve_watchdog_timeout(30_000, request_timeout_ms=40_000) == 41_000 + + +# --------------------------------------------------------------------------- +# Misc helpers +# --------------------------------------------------------------------------- + +class TestNormalizeDefaultChangeMode: + def test_none(self): + assert normalize_default_change_mode(None) is None + + def test_valid_direct(self): + assert normalize_default_change_mode('direct') == 'direct' + + def test_valid_tracked(self): + assert normalize_default_change_mode('tracked') == 'tracked' + + def test_invalid(self): + with pytest.raises(SuperDocError) as exc_info: + normalize_default_change_mode('bogus') + assert exc_info.value.code == 'INVALID_ARGUMENT' + + +class TestResolveInvocation: + def test_bare_binary(self): + cmd, prefix = resolve_invocation('/usr/bin/superdoc') + assert cmd == '/usr/bin/superdoc' + assert prefix == [] + + def test_js_file(self): + cmd, prefix = resolve_invocation('/path/to/cli.js') + assert cmd == 'node' + assert prefix == ['/path/to/cli.js'] + + def test_ts_file(self): + cmd, prefix = resolve_invocation('/path/to/cli.ts') + assert cmd == 'bun' + assert prefix == ['/path/to/cli.ts'] + + +# --------------------------------------------------------------------------- +# User identity injection +# --------------------------------------------------------------------------- + +class TestApplyDefaultUser: + def _make_open_op(self): + return { + 'operationId': 'doc.open', + 'commandTokens': ['open'], + 'params': [ + {'name': 'doc', 'kind': 'doc', 'type': 'string'}, + {'name': 'userName', 'kind': 'flag', 'flag': 'user-name', 'type': 'string'}, + {'name': 'userEmail', 'kind': 'flag', 'flag': 'user-email', 'type': 'string'}, + ], + } + + def _make_find_op(self): + return { + 'operationId': 'doc.find', + 'commandTokens': ['find'], + 'params': [{'name': 'query', 'kind': 'flag', 'type': 'string'}], + } + + def test_injects_user_into_doc_open(self): + op = self._make_open_op() + result = apply_default_user(op, {'doc': 'test.docx'}, {'name': 'Bot', 'email': 'bot@co.com'}) + assert result['userName'] == 'Bot' + assert result['userEmail'] == 'bot@co.com' + + def test_no_injection_when_user_is_none(self): + op = self._make_open_op() + result = apply_default_user(op, {'doc': 'test.docx'}, None) + assert 'userName' not in result + assert 'userEmail' not in result + + def test_no_injection_for_non_open_operations(self): + op = self._make_find_op() + result = apply_default_user(op, {'query': 'test'}, {'name': 'Bot', 'email': 'bot@co.com'}) + assert 'userName' not in result + assert 'userEmail' not in result + + def test_per_call_overrides_client_defaults(self): + op = self._make_open_op() + result = apply_default_user( + op, + {'doc': 'test.docx', 'userName': 'Override', 'userEmail': 'override@co.com'}, + {'name': 'Bot', 'email': 'bot@co.com'}, + ) + assert result['userName'] == 'Override' + assert result['userEmail'] == 'override@co.com' + + def test_build_operation_argv_includes_user(self): + op = self._make_open_op() + argv = build_operation_argv(op, {'doc': 'test.docx'}, user={'name': 'Bot', 'email': 'bot@co.com'}) + assert '--user-name' in argv + idx = argv.index('--user-name') + assert argv[idx + 1] == 'Bot' + assert '--user-email' in argv + idx = argv.index('--user-email') + assert argv[idx + 1] == 'bot@co.com' + + +# --------------------------------------------------------------------------- +# Integration tests with real generated contract +# --------------------------------------------------------------------------- + +class TestRealContractUserInjection: + """Verify user identity injection against the real generated OPERATION_INDEX.""" + + @pytest.fixture(autouse=True) + def _load_contract(self): + from superdoc.generated.contract import OPERATION_INDEX + self._op_index = OPERATION_INDEX + + def test_generated_doc_open_has_user_params(self): + op = self._op_index['doc.open'] + param_names = [p['name'] for p in op['params']] + assert 'userName' in param_names + assert 'userEmail' in param_names + + def test_user_identity_emits_flags_with_real_spec(self): + op = self._op_index['doc.open'] + argv = build_operation_argv(op, {'doc': 'test.docx'}, user={'name': 'Bot', 'email': 'bot@co.com'}) + assert '--user-name' in argv + idx = argv.index('--user-name') + assert argv[idx + 1] == 'Bot' + assert '--user-email' in argv + idx = argv.index('--user-email') + assert argv[idx + 1] == 'bot@co.com' diff --git a/packages/sdk/langs/python/tests/test_transport.py b/packages/sdk/langs/python/tests/test_transport.py new file mode 100644 index 0000000000..d71bafa186 --- /dev/null +++ b/packages/sdk/langs/python/tests/test_transport.py @@ -0,0 +1,495 @@ +"""Transport reliability tests using the mock host fixture. + +Tests spawn the mock_host.py script instead of a real CLI binary to exercise +handshake, timeout, disconnect, reconnect, and notification interleaving scenarios. +""" + +from __future__ import annotations + +import asyncio +import base64 +import json +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) + +import pytest + +from superdoc.errors import ( + HOST_DISCONNECTED, + HOST_HANDSHAKE_FAILED, + HOST_PROTOCOL_ERROR, + HOST_QUEUE_FULL, + HOST_TIMEOUT, + SuperDocError, +) +from superdoc.transport import AsyncHostTransport, SyncHostTransport + +MOCK_HOST = os.path.join(os.path.dirname(__file__), 'mock_host.py') + +# A minimal operation spec for testing. +_TEST_OP = { + 'commandTokens': ['doc', 'find'], + 'params': [{'name': 'query', 'kind': 'flag', 'type': 'string'}], +} + + +def _mock_cli_bin(scenario: dict) -> str: + """Create a wrapper script that the transport invokes as if it were the CLI binary. + + The transport calls ` host --stdio`. The wrapper ignores those args + and runs mock_host.py with the base64-encoded scenario instead. + """ + # Encode scenario as base64. + scenario_b64 = base64.b64encode(json.dumps(scenario).encode()).decode() + # Create a temporary wrapper script. + import tempfile + wrapper = tempfile.NamedTemporaryFile(mode='w', suffix='.sh', delete=False, prefix='mock_cli_') + wrapper.write(f'#!/bin/sh\nexec python3 {MOCK_HOST} {scenario_b64}\n') + wrapper.close() + os.chmod(wrapper.name, 0o755) + return wrapper.name + + +def _cleanup_wrapper(path: str) -> None: + try: + os.unlink(path) + except Exception: + pass + + +# --------------------------------------------------------------------------- +# Sync transport tests +# --------------------------------------------------------------------------- + +class TestSyncHandshake: + def test_handshake_success(self): + cli = _mock_cli_bin({'handshake': 'ok'}) + try: + transport = SyncHostTransport(cli, startup_timeout_ms=5_000) + transport.connect() + assert transport.state == 'CONNECTED' + transport.dispose() + finally: + _cleanup_wrapper(cli) + + def test_handshake_bad_version(self): + cli = _mock_cli_bin({'handshake': 'bad_version'}) + try: + transport = SyncHostTransport(cli, startup_timeout_ms=5_000) + with pytest.raises(SuperDocError) as exc_info: + transport.connect() + assert exc_info.value.code == HOST_HANDSHAKE_FAILED + assert transport.state == 'DISCONNECTED' + finally: + _cleanup_wrapper(cli) + + def test_handshake_missing_features(self): + cli = _mock_cli_bin({'handshake': 'missing_features'}) + try: + transport = SyncHostTransport(cli, startup_timeout_ms=5_000) + with pytest.raises(SuperDocError) as exc_info: + transport.connect() + assert exc_info.value.code == HOST_HANDSHAKE_FAILED + finally: + _cleanup_wrapper(cli) + + +class TestSyncInvoke: + def test_normal_request_response(self): + cli = _mock_cli_bin({ + 'handshake': 'ok', + 'responses': [{'data': {'items': [1, 2, 3]}}], + }) + try: + transport = SyncHostTransport(cli, startup_timeout_ms=5_000) + transport.connect() + result = transport.invoke(_TEST_OP, {'query': 'test'}) + assert result == {'items': [1, 2, 3]} + transport.dispose() + finally: + _cleanup_wrapper(cli) + + def test_cli_error_passthrough(self): + cli = _mock_cli_bin({ + 'handshake': 'ok', + 'responses': [{ + 'error': { + 'code': -32000, + 'message': 'File not found', + 'data': {'cliCode': 'FILE_NOT_FOUND', 'message': 'Not found'}, + }, + }], + }) + try: + transport = SyncHostTransport(cli, startup_timeout_ms=5_000) + transport.connect() + with pytest.raises(SuperDocError) as exc_info: + transport.invoke(_TEST_OP, {'query': 'test'}) + assert exc_info.value.code == 'FILE_NOT_FOUND' + transport.dispose() + finally: + _cleanup_wrapper(cli) + + def test_notification_interleaving(self): + """Mock sends a notification before the real response — verify correct routing.""" + cli = _mock_cli_bin({ + 'handshake': 'ok', + 'responses': [{ + 'notification': {'method': 'event.remoteChange', 'params': {'doc': 'x'}}, + 'data': {'found': True}, + }], + }) + try: + transport = SyncHostTransport(cli, startup_timeout_ms=5_000) + transport.connect() + result = transport.invoke(_TEST_OP, {'query': 'test'}) + assert result == {'found': True} + transport.dispose() + finally: + _cleanup_wrapper(cli) + + def test_malformed_frame_skipped(self): + """Mock sends malformed JSON before the real response — verify it's skipped.""" + cli = _mock_cli_bin({ + 'handshake': 'ok', + 'responses': [{ + 'malformed': True, + 'data': {'ok': True}, + }], + }) + try: + transport = SyncHostTransport(cli, startup_timeout_ms=5_000) + transport.connect() + result = transport.invoke(_TEST_OP, {'query': 'test'}) + assert result == {'ok': True} + transport.dispose() + finally: + _cleanup_wrapper(cli) + + +class TestSyncTimeout: + def test_watchdog_timeout(self): + """Mock delays past watchdog — verify HOST_TIMEOUT.""" + cli = _mock_cli_bin({ + 'handshake': 'ok', + 'responses': [{'delay_ms': 5000, 'data': 'too late'}], + }) + try: + transport = SyncHostTransport(cli, startup_timeout_ms=5_000, watchdog_timeout_ms=500) + transport.connect() + with pytest.raises(SuperDocError) as exc_info: + transport.invoke(_TEST_OP, {'query': 'test'}) + assert exc_info.value.code == HOST_TIMEOUT + assert transport.state == 'DISCONNECTED' + finally: + _cleanup_wrapper(cli) + + +class TestSyncDisconnect: + def test_host_crash_mid_request(self): + """Mock crashes during request — verify HOST_DISCONNECTED.""" + cli = _mock_cli_bin({ + 'handshake': 'ok', + 'responses': [{'crash': True}], + }) + try: + transport = SyncHostTransport(cli, startup_timeout_ms=5_000) + transport.connect() + with pytest.raises(SuperDocError) as exc_info: + transport.invoke(_TEST_OP, {'query': 'test'}) + assert exc_info.value.code == HOST_DISCONNECTED + assert transport.state == 'DISCONNECTED' + finally: + _cleanup_wrapper(cli) + + def test_reconnect_after_failure(self): + """After a crash, the next invoke() should re-spawn and succeed.""" + # First scenario: crash on first invoke. + cli1 = _mock_cli_bin({ + 'handshake': 'ok', + 'responses': [{'crash': True}], + }) + try: + transport = SyncHostTransport(cli1, startup_timeout_ms=5_000) + transport.connect() + with pytest.raises(SuperDocError): + transport.invoke(_TEST_OP, {'query': 'test'}) + assert transport.state == 'DISCONNECTED' + finally: + _cleanup_wrapper(cli1) + + # Swap to a working mock for reconnect. + cli2 = _mock_cli_bin({ + 'handshake': 'ok', + 'responses': [{'data': {'reconnected': True}}], + }) + try: + transport._cli_bin = cli2 + result = transport.invoke(_TEST_OP, {'query': 'test'}) + assert result == {'reconnected': True} + assert transport.state == 'CONNECTED' + transport.dispose() + finally: + _cleanup_wrapper(cli2) + + +class TestSyncDispose: + def test_graceful_dispose(self): + cli = _mock_cli_bin({'handshake': 'ok'}) + try: + transport = SyncHostTransport(cli, startup_timeout_ms=5_000) + transport.connect() + assert transport.state == 'CONNECTED' + transport.dispose() + assert transport.state == 'DISCONNECTED' + finally: + _cleanup_wrapper(cli) + + def test_dispose_idempotent(self): + cli = _mock_cli_bin({'handshake': 'ok'}) + try: + transport = SyncHostTransport(cli, startup_timeout_ms=5_000) + transport.connect() + transport.dispose() + transport.dispose() # Should be no-op. + assert transport.state == 'DISCONNECTED' + finally: + _cleanup_wrapper(cli) + + def test_reuse_after_dispose(self): + """Call dispose(), then invoke() — verify lazy reconnect works.""" + cli = _mock_cli_bin({ + 'handshake': 'ok', + 'responses': [{'data': {'first': True}}, {'data': {'second': True}}], + }) + try: + transport = SyncHostTransport(cli, startup_timeout_ms=5_000) + transport.connect() + r1 = transport.invoke(_TEST_OP, {'query': 'a'}) + assert r1 == {'first': True} + transport.dispose() + assert transport.state == 'DISCONNECTED' + finally: + _cleanup_wrapper(cli) + + # After dispose, swap to a fresh mock and invoke again. + cli2 = _mock_cli_bin({ + 'handshake': 'ok', + 'responses': [{'data': {'reused': True}}], + }) + try: + transport._cli_bin = cli2 + r2 = transport.invoke(_TEST_OP, {'query': 'b'}) + assert r2 == {'reused': True} + transport.dispose() + finally: + _cleanup_wrapper(cli2) + + +class TestSyncPartialLine: + def test_partial_line_buffering(self): + """Mock writes response in two chunks — verify readline buffers correctly.""" + cli = _mock_cli_bin({ + 'handshake': 'ok', + 'responses': [{'partial': True, 'data': {'buffered': True}}], + }) + try: + transport = SyncHostTransport(cli, startup_timeout_ms=5_000) + transport.connect() + result = transport.invoke(_TEST_OP, {'query': 'test'}) + assert result == {'buffered': True} + transport.dispose() + finally: + _cleanup_wrapper(cli) + + +class TestSyncLifecycle: + def test_connect_invoke_dispose(self): + """Verify the full connect → invoke → dispose cycle leaves state DISCONNECTED.""" + cli = _mock_cli_bin({ + 'handshake': 'ok', + 'responses': [{'data': {'x': 1}}], + }) + try: + transport = SyncHostTransport(cli, startup_timeout_ms=5_000) + transport.connect() + result = transport.invoke(_TEST_OP, {'query': 'q'}) + assert result == {'x': 1} + transport.dispose() + assert transport.state == 'DISCONNECTED' + finally: + _cleanup_wrapper(cli) + + +# --------------------------------------------------------------------------- +# Async transport tests +# --------------------------------------------------------------------------- + +class TestAsyncHandshake: + @pytest.mark.asyncio + async def test_handshake_success(self): + cli = _mock_cli_bin({'handshake': 'ok'}) + try: + transport = AsyncHostTransport(cli, startup_timeout_ms=5_000) + await transport.connect() + assert transport.state == 'CONNECTED' + await transport.dispose() + finally: + _cleanup_wrapper(cli) + + @pytest.mark.asyncio + async def test_handshake_bad_version(self): + cli = _mock_cli_bin({'handshake': 'bad_version'}) + try: + transport = AsyncHostTransport(cli, startup_timeout_ms=5_000) + with pytest.raises(SuperDocError) as exc_info: + await transport.connect() + assert exc_info.value.code == HOST_HANDSHAKE_FAILED + assert transport.state == 'DISCONNECTED' + finally: + _cleanup_wrapper(cli) + + +class TestAsyncInvoke: + @pytest.mark.asyncio + async def test_normal_request_response(self): + cli = _mock_cli_bin({ + 'handshake': 'ok', + 'responses': [{'data': {'items': [4, 5, 6]}}], + }) + try: + transport = AsyncHostTransport(cli, startup_timeout_ms=5_000) + await transport.connect() + result = await transport.invoke(_TEST_OP, {'query': 'test'}) + assert result == {'items': [4, 5, 6]} + await transport.dispose() + finally: + _cleanup_wrapper(cli) + + @pytest.mark.asyncio + async def test_notification_interleaving(self): + cli = _mock_cli_bin({ + 'handshake': 'ok', + 'responses': [{ + 'notification': {'method': 'event.test'}, + 'data': {'async_ok': True}, + }], + }) + try: + transport = AsyncHostTransport(cli, startup_timeout_ms=5_000) + await transport.connect() + result = await transport.invoke(_TEST_OP, {'query': 'test'}) + assert result == {'async_ok': True} + await transport.dispose() + finally: + _cleanup_wrapper(cli) + + +class TestAsyncTimeout: + @pytest.mark.asyncio + async def test_watchdog_timeout(self): + cli = _mock_cli_bin({ + 'handshake': 'ok', + 'responses': [{'delay_ms': 5000, 'data': 'too late'}], + }) + try: + transport = AsyncHostTransport(cli, startup_timeout_ms=5_000, watchdog_timeout_ms=500) + await transport.connect() + with pytest.raises(SuperDocError) as exc_info: + await transport.invoke(_TEST_OP, {'query': 'test'}) + assert exc_info.value.code == HOST_TIMEOUT + assert transport.state == 'DISCONNECTED' + finally: + _cleanup_wrapper(cli) + + +class TestAsyncQueueDepth: + @pytest.mark.asyncio + async def test_queue_full(self): + cli = _mock_cli_bin({ + 'handshake': 'ok', + 'responses': [{'delay_ms': 5000, 'data': 'slow'}] * 5, + }) + try: + transport = AsyncHostTransport(cli, startup_timeout_ms=5_000, max_queue_depth=2, watchdog_timeout_ms=10_000) + await transport.connect() + + # Fill the queue with slow requests. + tasks = [ + asyncio.ensure_future(transport.invoke(_TEST_OP, {'query': f'q{i}'})) + for i in range(2) + ] + # Give the event loop a chance to start the requests. + await asyncio.sleep(0.1) + + # The third should be rejected. + with pytest.raises(SuperDocError) as exc_info: + await transport.invoke(_TEST_OP, {'query': 'overflow'}) + assert exc_info.value.code == HOST_QUEUE_FULL + + # Clean up. + for t in tasks: + t.cancel() + await transport.dispose() + finally: + _cleanup_wrapper(cli) + + +class TestAsyncDisconnect: + @pytest.mark.asyncio + async def test_host_crash(self): + cli = _mock_cli_bin({ + 'handshake': 'ok', + 'responses': [{'crash': True}], + }) + try: + transport = AsyncHostTransport(cli, startup_timeout_ms=5_000) + await transport.connect() + with pytest.raises(SuperDocError) as exc_info: + await transport.invoke(_TEST_OP, {'query': 'test'}) + assert exc_info.value.code in (HOST_DISCONNECTED, HOST_TIMEOUT) + finally: + _cleanup_wrapper(cli) + + +class TestAsyncDispose: + @pytest.mark.asyncio + async def test_graceful_dispose(self): + cli = _mock_cli_bin({'handshake': 'ok'}) + try: + transport = AsyncHostTransport(cli, startup_timeout_ms=5_000) + await transport.connect() + assert transport.state == 'CONNECTED' + await transport.dispose() + assert transport.state == 'DISCONNECTED' + finally: + _cleanup_wrapper(cli) + + @pytest.mark.asyncio + async def test_reuse_after_dispose(self): + cli = _mock_cli_bin({ + 'handshake': 'ok', + 'responses': [{'data': {'v': 1}}], + }) + try: + transport = AsyncHostTransport(cli, startup_timeout_ms=5_000) + await transport.connect() + r1 = await transport.invoke(_TEST_OP, {'query': 'a'}) + assert r1 == {'v': 1} + await transport.dispose() + finally: + _cleanup_wrapper(cli) + + cli2 = _mock_cli_bin({ + 'handshake': 'ok', + 'responses': [{'data': {'v': 2}}], + }) + try: + transport._cli_bin = cli2 + r2 = await transport.invoke(_TEST_OP, {'query': 'b'}) + assert r2 == {'v': 2} + await transport.dispose() + finally: + _cleanup_wrapper(cli2) diff --git a/packages/super-editor/src/core/Editor.ts b/packages/super-editor/src/core/Editor.ts index fae9ff7f51..8c689ca1a3 100644 --- a/packages/super-editor/src/core/Editor.ts +++ b/packages/super-editor/src/core/Editor.ts @@ -127,6 +127,12 @@ export interface OpenOptions { /** Font data from docx */ fonts?: Record; + + /** + * Optional override for "new file" semantics on this open call. + * When omitted, Editor infers the value from the source type. + */ + isNewFile?: boolean; } /** @@ -715,6 +721,7 @@ export class Editor extends EventEmitter { try { // Merge options with defaults const resolvedMode = options?.mode ?? this.options.mode ?? 'docx'; + const explicitIsNewFile = options?.isNewFile; const resolvedOptions = { ...this.options, mode: resolvedMode, @@ -739,6 +746,7 @@ export class Editor extends EventEmitter { resolvedOptions.mediaFiles = mediaFiles; resolvedOptions.fonts = fonts; resolvedOptions.fileSource = buffer; + resolvedOptions.isNewFile = explicitIsNewFile ?? false; this.#sourcePath = source; } else { // Browser: fetch the file @@ -753,6 +761,7 @@ export class Editor extends EventEmitter { resolvedOptions.mediaFiles = mediaFiles; resolvedOptions.fonts = fonts; resolvedOptions.fileSource = blob; + resolvedOptions.isNewFile = explicitIsNewFile ?? false; // In browser, path is just a suggested filename this.#sourcePath = source.split('/').pop() || null; } @@ -773,6 +782,7 @@ export class Editor extends EventEmitter { resolvedOptions.mediaFiles = mediaFiles; resolvedOptions.fonts = fonts; resolvedOptions.fileSource = source as File | Blob | Buffer; + resolvedOptions.isNewFile = explicitIsNewFile ?? false; this.#sourcePath = null; } else { // Unknown object type - try to load it anyway @@ -781,6 +791,7 @@ export class Editor extends EventEmitter { resolvedOptions.mediaFiles = mediaFiles; resolvedOptions.fonts = fonts; resolvedOptions.fileSource = source as File | Blob | Buffer; + resolvedOptions.isNewFile = explicitIsNewFile ?? false; this.#sourcePath = null; } } else { @@ -809,7 +820,7 @@ export class Editor extends EventEmitter { resolvedOptions.mediaFiles = mediaFiles; resolvedOptions.fonts = fonts; resolvedOptions.fileSource = fileSource; - resolvedOptions.isNewFile = true; + resolvedOptions.isNewFile = explicitIsNewFile ?? true; this.#sourcePath = null; } else { // Use pre-parsed content from options if provided, otherwise create minimal structure @@ -817,7 +828,8 @@ export class Editor extends EventEmitter { resolvedOptions.mediaFiles = options?.mediaFiles ?? {}; resolvedOptions.fonts = options?.fonts ?? {}; resolvedOptions.fileSource = null; - resolvedOptions.isNewFile = !options?.content; // Only mark as new if no content provided + // Pre-parsed content means "existing document", otherwise this is a new blank file. + resolvedOptions.isNewFile = explicitIsNewFile ?? !options?.content; this.#sourcePath = null; } } @@ -2851,6 +2863,7 @@ export class Editor extends EventEmitter { content, mediaFiles, fonts, + isNewFile, // Everything else is EditorOptions ...editorConfig } = resolvedConfig; @@ -2865,6 +2878,7 @@ export class Editor extends EventEmitter { content, mediaFiles, fonts, + isNewFile, }; // Use new API mode for static factory diff --git a/packages/super-editor/src/extensions/collaboration/collaboration.js b/packages/super-editor/src/extensions/collaboration/collaboration.js index 53b12f9912..b6c523b324 100644 --- a/packages/super-editor/src/extensions/collaboration/collaboration.js +++ b/packages/super-editor/src/extensions/collaboration/collaboration.js @@ -144,6 +144,12 @@ export const initializeMetaMap = (ydoc, editor) => { const metaMap = ydoc.getMap('meta'); metaMap.set('docx', editor.options.content); metaMap.set('fonts', editor.options.fonts); + metaMap.set('bootstrap', { + version: 1, + clientId: ydoc.clientID, + seededAt: new Date().toISOString(), + source: 'browser', + }); const mediaMap = ydoc.getMap('media'); Object.entries(editor.options.mediaFiles).forEach(([key, value]) => { diff --git a/packages/superdoc/package.json b/packages/superdoc/package.json index ebdbf669d4..7cf7fceb5d 100644 --- a/packages/superdoc/package.json +++ b/packages/superdoc/package.json @@ -60,8 +60,8 @@ "module": "./dist/superdoc.es.js", "scripts": { "dev": "concurrently -k -n VITE,WORD -c cyan,magenta \"vite\" \"node ../../devtools/word-benchmark-sidecar/server.js --stay-alive-on-reuse\"", - "dev:collab": "concurrently -k -n VITE,COLLAB,WORD -c cyan,green,magenta \"vite\" \"node src/dev/collab-server.js\" \"node ../../devtools/word-benchmark-sidecar/server.js --stay-alive-on-reuse\"", - "collab-server": "node src/dev/collab-server.js", + "dev:collab": "concurrently -k -n VITE,COLLAB,WORD -c cyan,green,magenta \"vite\" \"pnpm run collab-server\" \"node ../../devtools/word-benchmark-sidecar/server.js --stay-alive-on-reuse\"", + "collab-server": "pnpm --filter @superdoc-dev/superdoc-yjs-collaboration build && tsx src/dev/collab-server.ts", "word-benchmark-sidecar": "node ../../devtools/word-benchmark-sidecar/server.js", "build": "vite build && pnpm run build:umd", "build:dev": "SUPERDOC_SKIP_DTS=1 vite build", @@ -97,6 +97,7 @@ "@hocuspocus/provider": "catalog:", "@hocuspocus/server": "catalog:", "@superdoc/common": "workspace:*", + "@superdoc-dev/superdoc-yjs-collaboration": "workspace:*", "@superdoc/super-editor": "workspace:*", "concurrently": "catalog:", "@vitejs/plugin-vue": "catalog:", @@ -114,6 +115,7 @@ "vite-plugin-dts": "catalog:", "vite-plugin-node-polyfills": "catalog:", "vitest": "catalog:", + "ws": "^8.18.3", "xml-js": "catalog:" } } diff --git a/packages/superdoc/src/dev/collab-server.js b/packages/superdoc/src/dev/collab-server.js deleted file mode 100644 index 1757c8025b..0000000000 --- a/packages/superdoc/src/dev/collab-server.js +++ /dev/null @@ -1,16 +0,0 @@ -import { Hocuspocus } from '@hocuspocus/server'; - -const PORT = 3050; - -const server = new Hocuspocus({ - port: PORT, - async onConnect({ documentName }) { - console.log(`[collab] Connected: ${documentName}`); - }, - async onDisconnect({ documentName }) { - console.log(`[collab] Disconnected: ${documentName}`); - }, -}); - -server.listen(); -console.log(`[collab] Hocuspocus server running on ws://localhost:${PORT}`); diff --git a/packages/superdoc/src/dev/collab-server.test.ts b/packages/superdoc/src/dev/collab-server.test.ts new file mode 100644 index 0000000000..3c5213739b --- /dev/null +++ b/packages/superdoc/src/dev/collab-server.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test } from 'vitest'; +import { validateUpgradePath } from './collab-server'; + +describe('validateUpgradePath', () => { + test('accepts valid collaboration path with document id', () => { + expect(validateUpgradePath('/v1/collaboration/superdoc-dev-room')).toEqual({ + ok: true, + documentId: 'superdoc-dev-room', + }); + }); + + test('decodes encoded document id', () => { + expect(validateUpgradePath('/v1/collaboration/room%2Fchild')).toEqual({ + ok: true, + documentId: 'room/child', + }); + }); + + test('rejects unknown path with 404', () => { + expect(validateUpgradePath('/v1/other/room')).toEqual({ + ok: false, + statusCode: 404, + }); + }); + + test('rejects missing document id with 400', () => { + expect(validateUpgradePath('/v1/collaboration/')).toEqual({ + ok: false, + statusCode: 400, + }); + }); + + test('rejects malformed percent-encoding with 400', () => { + expect(validateUpgradePath('/v1/collaboration/%E0%A4%A')).toEqual({ + ok: false, + statusCode: 400, + }); + }); +}); diff --git a/packages/superdoc/src/dev/collab-server.ts b/packages/superdoc/src/dev/collab-server.ts new file mode 100644 index 0000000000..40c43b078a --- /dev/null +++ b/packages/superdoc/src/dev/collab-server.ts @@ -0,0 +1,121 @@ +import { createServer } from 'node:http'; +import { resolve as resolvePath } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { WebSocketServer } from 'ws'; +import { + CollaborationBuilder, + type CollaborationWebSocket, + type SocketRequest, +} from '@superdoc-dev/superdoc-yjs-collaboration'; +import { Doc as YDoc, encodeStateAsUpdate } from 'yjs'; + +const PORT = 8081; +const BASE_PATH = '/v1/collaboration'; + +const collaboration = new CollaborationBuilder() + .withName('superdoc-dev-collab') + .withDebounce(500) + // Zero security / zero persistence: always start from an empty Yjs doc. + .onLoad(() => encodeStateAsUpdate(new YDoc())) + .build(); + +type UpgradeValidationResult = { ok: true; documentId: string } | { ok: false; statusCode: 400 | 404 }; + +export function validateUpgradePath(pathname: string): UpgradeValidationResult { + const prefix = `${BASE_PATH}/`; + if (!pathname.startsWith(prefix)) { + return { ok: false, statusCode: 404 }; + } + + const encodedDocumentId = pathname.slice(prefix.length); + if (!encodedDocumentId) { + return { ok: false, statusCode: 400 }; + } + + try { + const documentId = decodeURIComponent(encodedDocumentId); + if (!documentId) { + return { ok: false, statusCode: 400 }; + } + return { ok: true, documentId }; + } catch { + return { ok: false, statusCode: 400 }; + } +} + +function createUpgradeRequest( + requestUrl: string, + documentId: string, + headers?: SocketRequest['headers'], +): SocketRequest { + return { + url: requestUrl, + params: { documentId }, + headers, + }; +} + +function writeUpgradeError( + socket: { write: (chunk: string) => void; destroy: () => void }, + statusCode: 400 | 404, +): void { + const statusText = statusCode === 404 ? 'Not Found' : 'Bad Request'; + socket.write(`HTTP/1.1 ${statusCode} ${statusText}\r\n\r\n`); + socket.destroy(); +} + +export function createCollabServer(): ReturnType { + const server = createServer((request, response) => { + if (request.url === '/health') { + response.writeHead(200, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ ok: true })); + return; + } + + response.writeHead(404, { 'content-type': 'text/plain' }); + response.end('Not Found'); + }); + + const wss = new WebSocketServer({ noServer: true }); + + server.on('upgrade', (request, socket, head) => { + const host = request.headers.host ?? `localhost:${PORT}`; + const requestUrl = request.url ?? '/'; + const url = new URL(requestUrl, `http://${host}`); + const validation = validateUpgradePath(url.pathname); + + if (!validation.ok) { + writeUpgradeError(socket, validation.statusCode); + return; + } + + wss.handleUpgrade(request, socket, head, (ws) => { + const collaborationSocket: CollaborationWebSocket = ws; + const upgradeRequest = createUpgradeRequest(requestUrl, validation.documentId, request.headers); + collaboration.welcome(collaborationSocket, upgradeRequest).catch((error: unknown) => { + console.error('[collab] welcome failed:', error); + try { + ws.close(1011, 'collaboration init failed'); + } catch { + // no-op + } + }); + }); + }); + + return server; +} + +function isDirectExecution(): boolean { + const entryPath = process.argv[1]; + if (!entryPath) return false; + return resolvePath(entryPath) === fileURLToPath(import.meta.url); +} + +if (isDirectExecution()) { + const server = createCollabServer(); + server.listen(PORT, '127.0.0.1', () => { + console.log(`[collab] SuperDoc Yjs server running on ws://localhost:${PORT}${BASE_PATH}/:documentId`); + console.log('[collab] Example room: superdoc-dev-room'); + }); +} diff --git a/packages/superdoc/src/dev/components/SuperdocDev.vue b/packages/superdoc/src/dev/components/SuperdocDev.vue index eaae727aef..f581979636 100644 --- a/packages/superdoc/src/dev/components/SuperdocDev.vue +++ b/packages/superdoc/src/dev/components/SuperdocDev.vue @@ -15,7 +15,7 @@ import * as pdfjsLib from 'pdfjs-dist/build/pdf.mjs'; import SidebarSearch from './sidebar/SidebarSearch.vue'; import SidebarFieldAnnotations from './sidebar/SidebarFieldAnnotations.vue'; import SidebarLayout from './sidebar/SidebarLayout.vue'; -import { HocuspocusProvider } from '@hocuspocus/provider'; +import { WebsocketProvider } from 'y-websocket'; import * as Y from 'yjs'; // note: @@ -43,6 +43,8 @@ const userRole = urlParams.get('role') || 'editor'; const useLayoutEngine = ref(urlParams.get('layout') !== '0'); const useWebLayout = ref(urlParams.get('view') === 'web'); const useCollaboration = urlParams.get('collab') === '1'; +const collabRoom = urlParams.get('room') || 'superdoc-dev-room'; +const collabUrl = 'ws://localhost:8081/v1/collaboration'; const useWordOverlay = ref(urlParams.get('wordOverlay') !== '0'); const wordOverlayOpacity = ref(Number.isFinite(overlayOpacityFromUrl) ? clampOpacity(overlayOpacityFromUrl) : 0.45); const wordOverlayBlendMode = ref(urlParams.get('wordOverlayBlend') || 'difference'); @@ -59,7 +61,14 @@ let wordOverlayLayoutUnsubscribe = null; // Collaboration state const ydocRef = shallowRef(null); const providerRef = shallowRef(null); -const collabReady = ref(false); +const yjsChangeEvents = ref([]); +const yjsProviderStatus = ref(useCollaboration ? 'connecting' : 'disabled'); +const yjsActivityStatus = ref(useCollaboration ? 'connecting' : 'disabled'); +const YJS_EVENT_LOG_LIMIT = 250; +const YJS_CHANGE_ROWS_LIMIT = 60; +const seenServerActivityEventIds = new Set(); +let removeYjsObservers = null; +let closeActivityStream = null; const superdocLogo = SuperdocLogo; const uploadedFileName = ref(''); const uploadDisplayName = computed(() => uploadedFileName.value || 'No file chosen'); @@ -246,6 +255,320 @@ const readFileAsText = (file) => { }); }; +const createClientEventId = () => `client-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; + +const summarizeValue = (value) => { + if (value === null) return 'null'; + if (value === undefined) return 'undefined'; + if (value instanceof Uint8Array) return `Uint8Array(${value.byteLength})`; + if (value instanceof ArrayBuffer) return `ArrayBuffer(${value.byteLength})`; + if (typeof value === 'string') { + if (value.length <= 80) return JSON.stringify(value); + return `${JSON.stringify(value.slice(0, 80))}... (${value.length} chars)`; + } + if (typeof value === 'number' || typeof value === 'boolean') { + return String(value); + } + if (Array.isArray(value)) { + return `Array(${value.length})`; + } + if (typeof value === 'object') { + const name = value.constructor?.name || 'Object'; + return name; + } + return String(value); +}; + +const summarizeDelta = (delta) => + delta.map((part) => { + if (typeof part.insert === 'string') { + return { + insert: part.insert.length > 60 ? `${part.insert.slice(0, 60)}...` : part.insert, + chars: part.insert.length, + }; + } + if (part.insert != null) { + return { insert: summarizeValue(part.insert) }; + } + if (part.delete != null) { + return { delete: part.delete }; + } + if (part.retain != null) { + return { retain: part.retain }; + } + return { op: 'unknown' }; + }); + +const summarizeOrigin = (origin) => { + if (origin == null) return null; + if (typeof origin === 'string') return origin; + if (typeof origin === 'number' || typeof origin === 'boolean') return String(origin); + if (typeof origin === 'object') { + if (typeof origin.event === 'string') { + return origin.event; + } + const constructorName = origin.constructor?.name; + if (constructorName && constructorName !== 'Object') { + return constructorName; + } + return 'Object'; + } + return String(origin); +}; + +const appendYjsEvent = (event) => { + yjsChangeEvents.value = [event, ...yjsChangeEvents.value].slice(0, YJS_EVENT_LOG_LIMIT); +}; + +const toActivityItemsFromRows = (rows) => + rows.map((row) => { + const rootPath = typeof row.path === 'string' ? row.path.split('.')[0] : null; + const changedKeys = rootPath && rootPath !== '(root)' && rootPath.length > 0 ? [rootPath] : []; + const rowAction = row.action === 'add' ? 'added' : row.action === 'delete' ? 'deleted' : 'modified'; + const valueSummary = row.newValue ?? row.oldValue ?? null; + return { + changedKeys, + entryKey: row.key ?? null, + type: rowAction, + valueSummary, + targetType: row.targetType ?? null, + }; + }); + +const clearYjsChanges = () => { + yjsChangeEvents.value = []; + seenServerActivityEventIds.clear(); +}; + +const rowsFromDeepEvents = (events) => { + const rows = []; + for (const event of events) { + const path = Array.isArray(event.path) && event.path.length > 0 ? event.path.join('.') : '(root)'; + const targetType = event.target?.constructor?.name ?? 'UnknownType'; + + if (event.keysChanged instanceof Set && event.changes?.keys instanceof Map && event.keysChanged.size > 0) { + for (const key of event.keysChanged) { + const keyChange = event.changes.keys.get(key); + const action = keyChange?.action ?? 'changed'; + const row = { + path, + key, + action, + targetType, + oldValue: summarizeValue(keyChange?.oldValue), + }; + if (action !== 'delete' && typeof event.target?.get === 'function') { + row.newValue = summarizeValue(event.target.get(key)); + } + rows.push(row); + if (rows.length >= YJS_CHANGE_ROWS_LIMIT) { + return rows; + } + } + continue; + } + + if (Array.isArray(event.changes?.delta) && event.changes.delta.length > 0) { + rows.push({ + path, + key: null, + action: 'delta', + targetType, + delta: summarizeDelta(event.changes.delta), + }); + if (rows.length >= YJS_CHANGE_ROWS_LIMIT) { + return rows; + } + continue; + } + + rows.push({ + path, + key: null, + action: 'changed', + targetType, + }); + if (rows.length >= YJS_CHANGE_ROWS_LIMIT) { + return rows; + } + } + return rows; +}; + +const attachYjsDebugObservers = (ydoc, provider) => { + if (typeof removeYjsObservers === 'function') { + removeYjsObservers(); + } + + const onAfterTransaction = (transaction) => { + const events = []; + if (transaction.changedParentTypes instanceof Map) { + for (const changedEvents of transaction.changedParentTypes.values()) { + if (Array.isArray(changedEvents) && changedEvents.length > 0) { + events.push(...changedEvents); + } + } + } + const rows = rowsFromDeepEvents(events); + const origin = summarizeOrigin(transaction.origin); + const hasMeaningfulRows = rows.length > 0; + if (!hasMeaningfulRows) { + return; + } + + const activityItems = toActivityItemsFromRows(rows); + appendYjsEvent({ + id: createClientEventId(), + source: 'client', + at: new Date().toISOString(), + local: Boolean(transaction.local), + origin, + summary: + rows.length > 0 + ? `transaction (${rows.length} change row${rows.length === 1 ? '' : 's'})` + : 'transaction (no observable rows)', + changedKeys: Array.from(new Set(activityItems.flatMap((item) => item.changedKeys ?? []))), + entryKey: activityItems[0]?.entryKey ?? null, + changeType: activityItems[0]?.type ?? null, + valueSummary: activityItems[0]?.valueSummary ?? null, + activityItems, + changes: rows, + }); + }; + + const onProviderStatus = (event) => { + const status = event?.status ?? 'unknown'; + yjsProviderStatus.value = status; + appendYjsEvent({ + id: createClientEventId(), + source: 'client', + at: new Date().toISOString(), + local: null, + origin: 'provider', + summary: `provider status: ${status}`, + changes: [], + }); + }; + + const onProviderSync = (isSynced) => { + appendYjsEvent({ + id: createClientEventId(), + source: 'client', + at: new Date().toISOString(), + local: null, + origin: 'provider', + summary: `provider sync: ${Boolean(isSynced)}`, + changes: [], + }); + }; + + ydoc.on('afterTransaction', onAfterTransaction); + provider.on('status', onProviderStatus); + provider.on('sync', onProviderSync); + + removeYjsObservers = () => { + ydoc.off('afterTransaction', onAfterTransaction); + provider.off?.('status', onProviderStatus); + provider.off?.('sync', onProviderSync); + removeYjsObservers = null; + }; +}; + +const toCollaborationHttpBaseUrl = () => { + const url = new URL(collabUrl); + url.protocol = url.protocol === 'wss:' ? 'https:' : 'http:'; + return url.toString().replace(/\/$/, ''); +}; + +const addServerActivityEvent = (payload) => { + const eventId = payload?.id ?? null; + if (eventId) { + if (seenServerActivityEventIds.has(eventId)) return; + seenServerActivityEventIds.add(eventId); + if (seenServerActivityEventIds.size > 2_000) { + seenServerActivityEventIds.clear(); + seenServerActivityEventIds.add(eventId); + } + } + + appendYjsEvent({ + id: eventId ?? createClientEventId(), + source: 'server', + at: payload?.receivedAt ?? new Date().toISOString(), + local: null, + origin: 'yjs-hub', + summary: payload?.type === 'ydoc:update:v1' ? `server update (${payload.bytes ?? 0} bytes)` : 'server activity', + by: payload?.by ?? null, + actors: Array.isArray(payload?.actors) ? payload.actors : [], + customAttributions: Array.isArray(payload?.customAttributions) ? payload.customAttributions : [], + guess: payload?.guess ?? null, + clocks: Array.isArray(payload?.clocks) ? payload.clocks : [], + changedKeys: Array.isArray(payload?.changedKeys) ? payload.changedKeys : [], + entryKey: payload?.entryKey ?? null, + changeType: payload?.changeType ?? null, + valueSummary: payload?.valueSummary ?? null, + activityItems: Array.isArray(payload?.activityItems) ? payload.activityItems : [], + changes: [], + }); +}; + +const attachServerActivityStream = async () => { + if (!useCollaboration) return; + if (typeof closeActivityStream === 'function') { + closeActivityStream(); + } + + const baseUrl = `${toCollaborationHttpBaseUrl()}/${encodeURIComponent(collabRoom)}/activity`; + yjsActivityStatus.value = 'connecting'; + + try { + const recentResponse = await fetch(`${baseUrl}/recent`); + if (recentResponse.ok) { + const recentPayload = await recentResponse.json(); + if (Array.isArray(recentPayload?.events)) { + recentPayload.events.forEach((event) => addServerActivityEvent(event)); + } + } + } catch (error) { + console.warn('[collab] failed to load recent activity events:', error); + } + + const stream = new EventSource(`${baseUrl}/stream`); + + const onActivity = (event) => { + try { + const payload = JSON.parse(event.data); + addServerActivityEvent(payload); + yjsActivityStatus.value = 'open'; + } catch (error) { + console.warn('[collab] failed to parse activity stream payload:', error); + } + }; + + const onOpen = () => { + yjsActivityStatus.value = 'open'; + }; + + const onError = () => { + if (stream.readyState === EventSource.CLOSED) { + yjsActivityStatus.value = 'closed'; + return; + } + yjsActivityStatus.value = 'error'; + }; + + stream.addEventListener('activity', onActivity); + stream.onopen = onOpen; + stream.onerror = onError; + + closeActivityStream = () => { + stream.removeEventListener('activity', onActivity); + stream.close(); + yjsActivityStatus.value = 'closed'; + closeActivityStream = null; + }; +}; + const init = async () => { // If the dev shell re-initializes (e.g. on file upload), tear down the previous instance first. detachWordOverlayListener(); @@ -736,30 +1059,36 @@ const toggleCommentsPanel = () => { onMounted(async () => { // Initialize collaboration if enabled via ?collab=1 if (useCollaboration) { + clearYjsChanges(); const ydoc = new Y.Doc(); - const provider = new HocuspocusProvider({ - url: 'ws://localhost:3050', - name: urlParams.get('room') || 'superdoc-dev-room', - document: ydoc, + const provider = new WebsocketProvider(collabUrl, collabRoom, ydoc, { + params: { + userId: user.email || user.name, + }, }); ydocRef.value = ydoc; providerRef.value = provider; + attachYjsDebugObservers(ydoc, provider); + await attachServerActivityStream(); // Wait for sync before loading document await new Promise((resolve) => { - provider.on('synced', () => { - collabReady.value = true; + let settled = false; + const settle = () => { + if (settled) return; + settled = true; + provider.off?.('sync', settle); resolve(); - }); + }; + + provider.on('sync', settle); + // Fallback timeout in case sync doesn't fire - setTimeout(() => { - collabReady.value = true; - resolve(); - }, 3000); + setTimeout(settle, 3000); }); - console.log('[collab] Provider synced, initializing SuperDoc'); + console.log(`[collab] Provider ready (${collabUrl}/${collabRoom}), initializing SuperDoc`); } // Initialize SuperDoc - it will automatically create a blank document @@ -770,6 +1099,13 @@ onBeforeUnmount(() => { detachWordOverlayListener(); removeWordOverlay(); + if (typeof removeYjsObservers === 'function') { + removeYjsObservers(); + } + if (typeof closeActivityStream === 'function') { + closeActivityStream(); + } + // Ensure SuperDoc tears down global listeners (e.g., PresentationEditor input bridge) superdoc.value?.destroy?.(); superdoc.value = null; @@ -847,18 +1183,30 @@ const activeSidebar = computed( const activeSidebarComponent = computed(() => activeSidebar.value?.component ?? null); const activeSidebarLabel = computed(() => activeSidebar.value?.label ?? 'None'); const activeSidebarProps = computed(() => { - if (activeSidebarId.value !== 'layout') return {}; - return { - useWordOverlay: useWordOverlay.value, - isGeneratingWordBaseline: isGeneratingWordBaseline.value, - generatedCount: generatedWordScreenshots.value.length, - wordOverlayOpacity: wordOverlayOpacity.value, - wordOverlayOpacityLabel: wordOverlayOpacityLabel.value, - wordOverlayBlendMode: wordOverlayBlendMode.value, - wordBaselineStatus: wordBaselineStatus.value, - wordBaselineError: wordBaselineError.value, - wordOverlayAvailable: wordOverlayAvailable.value, - }; + if (activeSidebarId.value === 'layout') { + return { + useWordOverlay: useWordOverlay.value, + isGeneratingWordBaseline: isGeneratingWordBaseline.value, + generatedCount: generatedWordScreenshots.value.length, + wordOverlayOpacity: wordOverlayOpacity.value, + wordOverlayOpacityLabel: wordOverlayOpacityLabel.value, + wordOverlayBlendMode: wordOverlayBlendMode.value, + wordBaselineStatus: wordBaselineStatus.value, + wordBaselineError: wordBaselineError.value, + wordOverlayAvailable: wordOverlayAvailable.value, + }; + } + + if (activeSidebarId.value === 'yjs-changes') { + return { + events: yjsChangeEvents.value, + providerStatus: yjsProviderStatus.value, + activityStatus: yjsActivityStatus.value, + collabRoom, + }; + } + + return {}; }); const showSidebarMenu = ref(false); const closeSidebarMenu = () => { @@ -1073,6 +1421,7 @@ if (scrollTestMode.value) { @clear-generated-baseline="clearGeneratedWordBaseline" @update:word-overlay-opacity="setWordOverlayOpacity" @update:word-overlay-blend-mode="setWordOverlayBlendMode" + @clear-yjs-events="clearYjsChanges" /> diff --git a/packages/superdoc/src/ws.d.ts b/packages/superdoc/src/ws.d.ts new file mode 100644 index 0000000000..00d18667dd --- /dev/null +++ b/packages/superdoc/src/ws.d.ts @@ -0,0 +1,9 @@ +declare module 'ws' { + import type { IncomingMessage } from 'node:http'; + import type { Duplex } from 'node:stream'; + + export class WebSocketServer { + constructor(options?: { noServer?: boolean }); + handleUpgrade(request: IncomingMessage, socket: Duplex, head: Buffer, callback: (websocket: any) => void): void; + } +} diff --git a/packages/superdoc/tsconfig.types.json b/packages/superdoc/tsconfig.types.json index 03aee813fd..47d7bdf366 100644 --- a/packages/superdoc/tsconfig.types.json +++ b/packages/superdoc/tsconfig.types.json @@ -4,5 +4,9 @@ "composite": true, "outDir": "dist-types" }, - "references": [{ "path": "../super-editor/tsconfig.types.json" }, { "path": "../../shared/common/tsconfig.json" }] + "references": [ + { "path": "../super-editor/tsconfig.types.json" }, + { "path": "../collaboration-yjs/tsconfig.json" }, + { "path": "../../shared/common/tsconfig.json" } + ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 468f0d4d6d..fed69e2994 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1251,6 +1251,9 @@ importers: '@hocuspocus/server': specifier: 'catalog:' version: 2.15.3(y-protocols@1.0.7(yjs@13.6.19))(yjs@13.6.19) + '@superdoc-dev/superdoc-yjs-collaboration': + specifier: workspace:* + version: link:../collaboration-yjs '@superdoc/common': specifier: workspace:* version: link:../../shared/common @@ -1305,6 +1308,9 @@ importers: vitest: specifier: 'catalog:' version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.8)(esbuild@0.27.2)(happy-dom@20.4.0)(jiti@2.6.1)(jsdom@27.3.0(canvas@3.2.1))(tsx@4.21.0)(yaml@2.8.2) + ws: + specifier: ^8.18.3 + version: 8.19.0 xml-js: specifier: 'catalog:' version: 1.6.11