diff --git a/CHANGELOG.md b/CHANGELOG.md index 7baab69..03d1217 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,13 +6,15 @@ Delivered changes are recorded here. Version tags use `v` followed by a semantic ### Added +- Initial TypeScript platform archive, generic single-agent Harness, supervised IPC worker and streaming model adapter, with confirmation, interaction validation, cancellation, restart reconciliation and immutable report archives. +- Native Windows core tests, strict type checking and an explicit opt-in DeepSeek streaming/tool-call smoke test. Application UI, retrieval and full research acceptance remain pending. - Research workspace specification covering conversation, execution state, live artifact revisions, evidence navigation, responsive layouts and application acceptance scenarios. - Minimal OpenLab Harness specification and ADR 0002 selecting a self-developed first runtime, AG-UI at the Platform gateway and assistant-ui for conversation. ### Changed - Product, architecture, logging, architecture diagram and bilingual README now describe Platform-owned conversations, steps, human interactions and artifacts independently of runtime, model, tools and UI. -- OpenCode becomes an optional future adapter; ADR 0001 preserves the historical selection with a supersession notice. This revision delivers documentation only, with no application code or new runtime dependency. +- OpenCode becomes an optional future adapter; ADR 0001 preserves the historical selection with a supersession notice. ## [0.0.1] - 2026-10-24 diff --git a/README.md b/README.md index e01ee73..d5527be 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,8 @@ OpenLab is a local-first research workspace being designed to connect open resea **v0.0.1 establishes repository governance and design documents. There is no runnable research application or installer in this release.** The commands below validate this repository; they do not launch OpenLab. Screenshots and research demonstrations will be added when the corresponding functionality is available. +Current development source adds the platform archive, generic Harness and supervised worker. See [development](docs/development.md) and the [implementation plan](docs/implementation-plan.md) for implemented scope and remaining application gates. + | Capability | Status | | --- | --- | | Contribution, issue, review, and release processes | Available in this repository | diff --git a/README.zh-CN.md b/README.zh-CN.md index 3d24ded..afbfe02 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -1,5 +1,7 @@ # OpenLab +当前开发源码已加入平台档案、通用 Harness 和受监督的 worker。已实现范围、验证结果与剩余应用验收项见[开发说明](docs/development.md)和[实施计划](docs/implementation-plan.md)。v0.0.1 发布范围仍为仓库基础设施。 + OpenLab 烧瓶与波浪标识 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 628a190..59b5f95 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -15,6 +15,8 @@ OpenLab's Apache-2.0 license applies to OpenLab-authored material. Referenced or The npm lockfile records exact development dependencies and integrity hashes. Their license metadata is available in the installed packages. CI downloads actionlint (MIT) and Gitleaks (MIT) from their official releases; pinned versions, archive URLs, and SHA-256 digests are recorded in [the tool manifest](scripts/tools.json). These executables are cached locally and are not included in the source release. +The initial application core uses Node.js 24's built-in APIs, including SQLite and child processes, without an additional runtime npm dependency. Development adds TypeScript 7.0.2 (Apache-2.0) and `@types/node` 24.19.2 (MIT); exact transitive packages and integrity hashes are in the lockfile. Their package license metadata was inspected on 2026-10-24. The DeepSeek API is an external user-configured service; no DeepSeek implementation is bundled. + GitHub Actions used by the workflows retain their upstream licenses and are pinned to immutable commit SHAs. Any future application distribution must include an updated dependency and license inventory. ## Planned application components and design references diff --git a/app/contracts.ts b/app/contracts.ts new file mode 100644 index 0000000..4299035 --- /dev/null +++ b/app/contracts.ts @@ -0,0 +1,56 @@ +export type Json = null | boolean | number | string | Json[] | { [key: string]: Json }; +export type ObjectData = { [key: string]: Json }; +export type RunState = 'queued' | 'running' | 'awaiting_confirmation' | 'awaiting_input' | 'awaiting_permission' | 'cancelling' | 'succeeded' | 'failed' | 'cancelled' | 'interrupted'; +export const terminal = new Set(['succeeded', 'failed', 'cancelled', 'interrupted']); +export interface ResearchEvent { runId: string; seq: number; at: string; kind: string; data: ObjectData } +export interface Limits { turns: number; durationMs: number; callMs: number; tokens: number } +export interface Run { + id: string; projectId: string; conversationId: string; requestKey: string; question: string; + state: RunState; phase: string; createdAt: string; config: ObjectData; limits: Limits; + plan?: { version: number; text: string; tools: string[]; confirmed: boolean }; + interaction?: Interaction; decisions: Record; parentRunId?: string; + error?: string; +} +export interface Interaction { + id: string; version: number; kind: 'confirmation' | 'question' | 'permission'; prompt: string; + input: 'text' | 'single_choice' | 'multiple_choice' | 'boolean'; options?: string[]; scope?: ObjectData; +} +export interface Decision { interactionId: string; version: number; value: Json } +export interface ToolCall { id: string; name: string; arguments: string } +export interface Message { role: 'system' | 'user' | 'assistant' | 'tool'; content: string; tool_calls?: { id: string; type: 'function'; function: { name: string; arguments: string } }[]; tool_call_id?: string } +export interface ToolDefinition { name: string; description: string; parameters: ObjectData } +export type ModelChunk = { type: 'text'; text: string } | { type: 'tool'; call: ToolCall } | { type: 'usage'; input: number; output: number }; +export interface ModelProvider { + stream(messages: Message[], tools: ToolDefinition[], signal: AbortSignal): AsyncIterable; +} +export interface ToolExecutor { + definitions: ToolDefinition[]; + execute(call: ToolCall, signal: AbortSignal): Promise; +} +export interface HarnessHost { + emit(kind: string, data: ObjectData): Promise; + wait(interaction: Omit, signal: AbortSignal): Promise; +} +export interface RuntimeCapabilities { streaming: boolean; cancellation: boolean; questions: boolean; permissions: boolean; checkpoints: false } +export interface RuntimeAdapter { + capabilities(): RuntimeCapabilities; + start(run: Run, host: HarnessHost): Promise; + cancel(runId: string): Promise; + close(): Promise; +} +export class DomainError extends Error { + status: number; + constructor(message: string, status = 400) { super(message); this.name = 'DomainError'; this.status = status; } +} +export function object(value: unknown): ObjectData { + if (!value || typeof value !== 'object' || Array.isArray(value)) throw new DomainError('Expected an object'); + return value as ObjectData; +} +export function text(value: unknown, name = 'text', max = 100_000): string { + if (typeof value !== 'string' || !value.trim() || value.length > max) throw new DomainError(`Invalid ${name}`); + return value; +} +export function id(value: string): string { + if (!/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)) throw new DomainError('Invalid identifier'); + return value; +} diff --git a/app/harness.ts b/app/harness.ts new file mode 100644 index 0000000..f59476f --- /dev/null +++ b/app/harness.ts @@ -0,0 +1,82 @@ +import { randomUUID } from 'node:crypto'; +import { DomainError } from './contracts.ts'; +import type { HarnessHost, Limits, Message, ModelProvider, ToolExecutor } from './contracts.ts'; + +export interface ContextStrategy { assemble(messages: Message[]): { messages: Message[]; trimmed: number } } +export const fullContext: ContextStrategy = { assemble: messages => ({ messages, trimmed: 0 }) }; + +// This loop knows nothing about SQLite, HTTP, scholarly APIs or the browser. +export async function executeHarness(options: { + question: string; instructions: string; model: ModelProvider; tools: ToolExecutor; + host: HarnessHost; limits: Limits; signal: AbortSignal; context?: ContextStrategy; +}): Promise { + const { host, model, tools, limits } = options; + const signal = AbortSignal.any([options.signal, AbortSignal.timeout(limits.durationMs)]); + let turns = 0; + let used = 0; + let unknownUsage = false; + const messages: Message[] = [ + { role: 'system', content: options.instructions }, + { role: 'user', content: options.question }, + ]; + const call = async (planning: boolean): Promise => { + signal.throwIfAborted(); + if (turns >= limits.turns || used >= limits.tokens) throw new DomainError('Run usage/turn limit reached'); + turns++; + const assembled = (options.context ?? fullContext).assemble(messages); + if (assembled.trimmed) await host.emit('context_trimmed', { count: assembled.trimmed }); + const messageId = randomUUID(); + const stepId = randomUUID(); + await host.emit('step_started', { stepId, label: planning ? 'Planning' : 'Research', turn: turns }); + await host.emit('message_start', { messageId, role: 'assistant', stepId }); + const reply: Message = { role: 'assistant', content: '' }; + let usageReported = false; + for await (const chunk of model.stream(assembled.messages, planning ? [] : tools.definitions, AbortSignal.any([signal, AbortSignal.timeout(limits.callMs)]))) { + signal.throwIfAborted(); + if (chunk.type === 'text') { + reply.content += chunk.text; + await host.emit('message_delta', { messageId, text: chunk.text, stepId }); + } else if (chunk.type === 'tool') { + if (planning) throw new DomainError('Model requested tools during planning'); + if (!chunk.call.id || reply.tool_calls?.some(c => c.id === chunk.call.id)) throw new DomainError('Invalid or duplicate tool call identifier'); + (reply.tool_calls ??= []).push({ id: chunk.call.id, type: 'function', function: { name: chunk.call.name, arguments: chunk.call.arguments } }); + } else { + if (!Number.isSafeInteger(chunk.input) || !Number.isSafeInteger(chunk.output) || chunk.input < 0 || chunk.output < 0 || usageReported) throw new DomainError('Invalid model usage'); + usageReported = true; + used += chunk.input + chunk.output; + await host.emit('usage', { input: chunk.input, output: chunk.output, totalReported: used, unknownUsage }); + } + } + if (!usageReported) { + unknownUsage = true; + await host.emit('usage', { input: null, output: null, totalReported: used, unknownUsage: true }); + } + if (!reply.content.trim() && !reply.tool_calls?.length) throw new DomainError('Empty model response'); + await host.emit('message_end', { messageId, content: reply.content, stepId }); + await host.emit('step_finished', { stepId }); + messages.push(reply); + return reply; + }; + messages.push({ role: 'user', content: 'First propose a concise research brief and plan in the user language. State evidence limitations. Do not research yet. Research tools require user confirmation.' }); + const plan = await call(true); + const confirmed = await host.wait({ kind: 'confirmation', input: 'boolean', prompt: plan.content, scope: { tools: tools.definitions.map(t => t.name) } }, signal); + if (confirmed !== true) throw new DomainError('Research plan was not confirmed'); + messages.push({ role: 'user', content: 'The displayed plan and listed tools are confirmed. Execute it within scope. Save a report with evidence and limitations using the report tool before finishing.' }); + while (true) { + const reply = await call(false); + if (!reply.tool_calls?.length) return; + for (const tool of reply.tool_calls) { + signal.throwIfAborted(); + const call = { id: tool.id, name: tool.function.name, arguments: tool.function.arguments }; + if (!tools.definitions.some(t => t.name === call.name)) throw new DomainError('Model requested an unregistered tool'); + const stepId = randomUUID(); + await host.emit('tool_intent', { stepId, callId: call.id, name: call.name, arguments: call.arguments }); + // Only the tool host can validate and authorize arguments. Retrieved text + // and model output cannot expand the tool registry or its capabilities. + const result = await tools.execute(call, signal); + signal.throwIfAborted(); + await host.emit('tool_result', { stepId, callId: call.id, result }); + messages.push({ role: 'tool', tool_call_id: call.id, content: JSON.stringify(result) }); + } + } +} diff --git a/app/model.ts b/app/model.ts new file mode 100644 index 0000000..cff2343 --- /dev/null +++ b/app/model.ts @@ -0,0 +1,105 @@ +import { DomainError, object, text } from './contracts.ts'; +import type { Message, ModelChunk, ModelProvider, ObjectData, ToolCall, ToolDefinition } from './contracts.ts'; + +export interface ProviderConfig { baseUrl: string; model: string; apiKey: string; maxOutputTokens?: number; extra?: ObjectData } +// SSE lines and UTF-8 characters may cross network chunks. A completed stream +// needs [DONE] and a finish reason, not just a closed socket. +export async function* sseData(response: Response): AsyncIterable { + if (!response.body) throw new DomainError('Provider response has no body'); + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + let lines: string[] = []; + try { + while (true) { + const part = await reader.read(); + buffer += decoder.decode(part.value, { stream: !part.done }); + let end: number; + while ((end = buffer.indexOf('\n')) !== -1) { + const line = buffer.slice(0, end).replace(/\r$/, ''); + buffer = buffer.slice(end + 1); + if (!line) { if (lines.length) yield lines.join('\n'); lines = []; } + else if (line.startsWith('data:')) lines.push(line.slice(5).replace(/^ /, '')); + } + if (buffer.length > 2_000_000) throw new DomainError('Provider event too large'); + if (part.done) break; + } + if (buffer || lines.length) throw new DomainError('Truncated provider event stream'); + } finally { await reader.cancel().catch(() => {}); reader.releaseLock(); } +} + +export class CompatibleModel implements ModelProvider { + config: ProviderConfig; + fetcher: typeof fetch; + constructor(config: ProviderConfig, fetcher = fetch) { + const url = new URL(config.baseUrl); + if (url.protocol !== 'https:' && !(url.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname))) throw new DomainError('Model endpoint must use HTTPS or explicit loopback HTTP'); + if (url.username || url.password || url.search || url.hash) throw new DomainError('Invalid model endpoint'); + this.config = config; + this.fetcher = fetcher; + } + async *stream(messages: Message[], tools: ToolDefinition[], signal: AbortSignal): AsyncIterable { + const response = await this.fetcher(`${this.config.baseUrl.replace(/\/$/, '')}/chat/completions`, { + method: 'POST', signal, redirect: 'error', + headers: { 'content-type': 'application/json', authorization: `Bearer ${this.config.apiKey}` }, + body: JSON.stringify({ ...this.config.extra, model: this.config.model, messages, stream: true, stream_options: { include_usage: true }, max_tokens: this.config.maxOutputTokens ?? 4096, + ...(tools.length ? { tools: tools.map(tool => ({ type: 'function', function: tool })) } : {}), + }), + }); + // Provider bodies may echo the Authorization header. Never archive raw errors. + if (!response.ok) { + await response.body?.cancel(); + const retry = response.headers.get('retry-after'); + throw new DomainError(`Model request failed (HTTP ${response.status}${retry ? '; retry guidance available' : ''})`, 502); + } + if (!response.headers.get('content-type')?.includes('text/event-stream')) throw new DomainError('Model endpoint did not return SSE'); + const calls = new Map(); + let finished = false; + let done = false; + let bytes = 0; + for await (const payload of sseData(response)) { + signal.throwIfAborted(); + if (payload === '[DONE]') { done = true; break; } + bytes += payload.length; + if (bytes > 4_000_000) throw new DomainError('Model output limit exceeded'); + const chunk = object(JSON.parse(payload)); + if (chunk.error) throw new DomainError('Provider reported a stream error'); + if (chunk.usage) { + const usage = object(chunk.usage); + yield { type: 'usage', input: Number(usage.prompt_tokens), output: Number(usage.completion_tokens) }; + } + if (!Array.isArray(chunk.choices)) throw new DomainError('Malformed model chunk'); + for (const raw of chunk.choices) { + const choice = object(raw); + if (choice.index !== 0) throw new DomainError('Unexpected model choice'); + const delta = object(choice.delta ?? {}); + if (delta.content !== undefined && delta.content !== null) { + if (typeof delta.content !== 'string') throw new DomainError('Invalid model text'); + if (delta.content) yield { type: 'text', text: delta.content }; + } + if (Array.isArray(delta.tool_calls)) for (const value of delta.tool_calls) { + const fragment = object(value); + const index = Number(fragment.index); + if (!Number.isSafeInteger(index) || index < 0 || index > 31) throw new DomainError('Invalid tool index'); + const call = calls.get(index) ?? { id: '', name: '', arguments: '' }; + if (fragment.id) call.id += text(fragment.id); + if (fragment.function) { + const fn = object(fragment.function); + if (typeof fn.name === 'string') call.name += fn.name; + if (typeof fn.arguments === 'string') call.arguments += fn.arguments; + } + calls.set(index, call); + } + if (choice.finish_reason) { + if (!['stop', 'tool_calls'].includes(String(choice.finish_reason))) throw new DomainError(`Model stopped before completion: ${String(choice.finish_reason)}`); + finished = true; + } + } + } + if (!done || !finished) throw new DomainError('Model stream ended without confirmed completion'); + for (const call of calls.values()) { + text(call.id); text(call.name); object(JSON.parse(call.arguments)); + yield { type: 'tool', call }; + } + } +} diff --git a/app/platform.ts b/app/platform.ts new file mode 100644 index 0000000..5f30480 --- /dev/null +++ b/app/platform.ts @@ -0,0 +1,187 @@ +import { randomUUID } from 'node:crypto'; +import { EventEmitter } from 'node:events'; +import { DomainError, terminal, text } from './contracts.ts'; +import type { Decision, HarnessHost, Interaction, Json, Limits, ObjectData, Run, RunState, RuntimeAdapter } from './contracts.ts'; +import { Store } from './store.ts'; + +const defaultLimits: Limits = { turns: 12, durationMs: 600_000, callMs: 90_000, tokens: 60_000 }; +const states = new Map([['confirmation', 'awaiting_confirmation'], ['question', 'awaiting_input'], ['permission', 'awaiting_permission']]); +function validateDecision(interaction: Interaction, value: Json): void { + if (interaction.input === 'boolean') { if (typeof value !== 'boolean') throw new DomainError('Expected approve or deny'); } + else if (interaction.input === 'text') text(value, 'answer', 10_000); + else if (interaction.input === 'single_choice') { if (typeof value !== 'string' || !interaction.options?.includes(value)) throw new DomainError('Choose a listed option'); } + else if (!Array.isArray(value) || !value.length || new Set(value).size !== value.length || value.some(v => typeof v !== 'string' || !interaction.options?.includes(v))) throw new DomainError('Choose listed options without duplicates'); +} +export class Platform { + store: Store; + runtime: RuntimeAdapter; + notifications = new EventEmitter(); + tasks = new Map>(); + waiters = new Map void>(); + closed = false; + constructor(store: Store, runtime: RuntimeAdapter) { + this.store = store; this.runtime = runtime; + // Workers are IPC children: loss of their control channel terminates them. + // No session is resumed or billed automatically after a process restart. + for (const run of store.list('run')) if (!terminal.has(run.state)) this.event(run.id, 'interrupted', { reason: 'Control plane restarted; explicit retry required' }, r => { r.state = 'interrupted'; delete r.interaction; }); + } + project(name: string): ObjectData { + const project = { id: randomUUID(), name: text(name, 'project name', 200), archived: false, createdAt: new Date().toISOString() }; + this.store.put('project', project.id, project.id, project); + return project; + } + updateProject(projectId: string, changes: ObjectData): ObjectData { + const project = this.store.get('project', projectId); + if (changes.name !== undefined) project.name = text(changes.name, 'project name', 200); + if (changes.archived !== undefined) { + if (typeof changes.archived !== 'boolean') throw new DomainError('Invalid archive flag'); + if (changes.archived && this.store.list('run', projectId).some(r => !terminal.has(r.state))) throw new DomainError('Cancel active runs before archiving', 409); + project.archived = changes.archived; + } + this.store.put('project', projectId, projectId, project); + return project; + } + conversation(projectId: string, name = 'Research'): ObjectData { + const project = this.store.get('project', projectId); + if (project.archived) throw new DomainError('Reopen the project first', 409); + const conversation = { id: randomUUID(), projectId, name: text(name, 'conversation name', 200), createdAt: new Date().toISOString() }; + this.store.put('conversation', conversation.id, projectId, conversation); + return conversation; + } + createRun(conversationId: string, input: { question: string; requestKey: string; config: ObjectData; limits?: Partial; parentRunId?: string }): Run { + if (this.closed) throw new DomainError('Platform is shutting down', 503); + const conversation = this.store.get('conversation', conversationId); + const projectId = String(conversation.projectId); + if (this.store.get('project', projectId).archived) throw new DomainError('Reopen the project first', 409); + text(input.question, 'question', 20_000); text(input.requestKey, 'request key', 200); + const configKeys = new Set(['providerId', 'model', 'skillId', 'skillRevision', 'runtimeId', 'retrievalId', 'language']); + for (const [key, value] of Object.entries(input.config)) { + if (!configKeys.has(key) || typeof value !== 'string' || value.length > 500) throw new DomainError('Run configuration accepts public selection identifiers only'); + } + const runs = this.store.list('run', projectId).filter(r => r.conversationId === conversationId); + const previous = runs.find(r => r.requestKey === input.requestKey); + if (previous) { + if (previous.question !== input.question || JSON.stringify(previous.config) !== JSON.stringify(input.config)) throw new DomainError('Request key already used for different input', 409); + return previous; + } + if (runs.some(r => !terminal.has(r.state))) throw new DomainError('Conversation already has an active run', 409); + if (input.parentRunId && !runs.some(r => r.id === input.parentRunId && terminal.has(r.state))) throw new DomainError('Retry parent must be a terminal run in this conversation'); + const limits = { ...defaultLimits, ...input.limits }; + for (const [key, value] of Object.entries(limits)) if (!Number.isSafeInteger(value) || value < 1 || value > defaultLimits[key as keyof Limits]) throw new DomainError(`Invalid ${key} limit`); + const run: Run = { id: randomUUID(), projectId, conversationId, requestKey: input.requestKey, question: input.question, config: input.config, limits, state: 'queued', phase: 'planning', decisions: {}, createdAt: new Date().toISOString(), ...(input.parentRunId ? { parentRunId: input.parentRunId } : {}) }; + this.store.transaction(() => { + this.store.saveRun(run); + const messageId = randomUUID(); + this.store.put('message', messageId, projectId, { id: messageId, projectId, conversationId, runId: run.id, role: 'user', content: run.question }); + }); + this.event(run.id, 'run_created', { conversationId }); + // Start after returning the persisted identity. Browser retries and stream + // subscriptions never enter this path again. + const task = Promise.resolve().then(() => this.execute(run.id)); + this.tasks.set(run.id, task); + void task.then(() => this.tasks.delete(run.id), () => this.tasks.delete(run.id)); + return run; + } + event(runId: string, kind: string, data: ObjectData, mutate?: (run: Run) => void): void { + const event = this.store.append(runId, kind, data, mutate); + this.notifications.emit(runId, event); + } + async execute(runId: string): Promise { + try { + const current = this.store.get('run', runId); + if (current.state !== 'queued') return; + this.event(runId, 'run_started', {}, r => { r.state = 'running'; }); + const host: HarnessHost = { + emit: async (kind, data) => { + const run = this.store.get('run', runId); + if (terminal.has(run.state) || run.state === 'cancelling') throw new DomainError('Run is stopping', 409); + this.event(runId, kind, data, current => { + if (kind === 'message_end') { + const messageId = text(data.messageId); + this.store.put('message', messageId, current.projectId, { id: messageId, projectId: current.projectId, conversationId: current.conversationId, runId, role: 'assistant', content: data.content ?? '', stepId: data.stepId ?? null }); + } + }); + }, + wait: (interaction, signal) => this.wait(runId, interaction, signal), + }; + await this.runtime.start(this.store.get('run', runId), host); + const run = this.store.get('run', runId); + if (!terminal.has(run.state)) this.finish(runId, run.state === 'cancelling' ? 'cancelled' : 'succeeded'); + } catch (error) { + // The adapter has observed worker termination before settling start(). + try { + const run = this.store.get('run', runId); + if (!terminal.has(run.state)) this.finish(runId, run.state === 'cancelling' ? 'cancelled' : 'failed', error instanceof DomainError ? error.message : 'Execution failed; check provider connectivity and configuration'); + } catch { + // A persistence failure prevents further dispatch and publication. Once + // storage is restored restart reconciliation will classify the run. + await this.runtime.cancel(runId); + } + } + } + finish(runId: string, state: RunState, error?: string): void { + this.event(runId, 'run_finished', { state, error: error ?? null }, r => { r.state = state; r.phase = 'finished'; if (error) r.error = error; delete r.interaction; }); + this.waiters.delete(runId); + } + wait(runId: string, input: Omit, signal: AbortSignal): Promise { + signal.throwIfAborted(); + const run = this.store.get('run', runId); + if (run.state !== 'running' || this.waiters.has(runId)) throw new DomainError('Run cannot request another interaction', 409); + if (input.kind !== 'confirmation' && !run.plan?.confirmed) throw new DomainError('Plan must be confirmed first'); + const interaction: Interaction = { ...input, id: randomUUID(), version: (run.plan?.version ?? 0) + 1 }; + return new Promise((resolve, reject) => { + const cleanup = () => { signal.removeEventListener('abort', abort); this.waiters.delete(runId); }; + const abort = () => { cleanup(); reject(new DomainError('Execution interrupted')); }; + this.waiters.set(runId, value => { cleanup(); resolve(value); }); + signal.addEventListener('abort', abort, { once: true }); + try { + this.event(runId, 'interaction_requested', { interaction: interaction as unknown as Json }, r => { + r.interaction = interaction; r.state = states.get(input.kind)!; + if (input.kind === 'confirmation') r.plan = { version: interaction.version, text: interaction.prompt, tools: Array.isArray(input.scope?.tools) ? input.scope.tools.map(String) : [], confirmed: false }; + }); + } catch (error) { cleanup(); reject(error); } + }); + } + respond(runId: string, decision: Decision): Decision { + const run = this.store.get('run', runId); + if (terminal.has(run.state) || run.state === 'cancelling') throw new DomainError('Run no longer accepts decisions', 409); + const previous = run.decisions[decision.interactionId]; + if (previous) { + if (previous.version === decision.version && JSON.stringify(previous.value) === JSON.stringify(decision.value)) return previous; + throw new DomainError('Interaction already answered differently', 409); + } + const interaction = run.interaction; + if (!interaction || interaction.id !== decision.interactionId || interaction.version !== decision.version || !this.waiters.has(runId)) throw new DomainError('Stale interaction', 409); + validateDecision(interaction, decision.value); + this.event(runId, 'interaction_answered', { decision: decision as unknown as Json, kind: interaction.kind }, r => { + r.decisions[decision.interactionId] = decision; + r.state = 'running'; + if (interaction.kind === 'confirmation' && r.plan) { r.plan.confirmed = decision.value === true; r.phase = 'research'; } + delete r.interaction; + }); + this.waiters.get(runId)!(decision.value); + return decision; + } + confirm(runId: string, version: number): Decision { + const run = this.store.get('run', runId); + if (!run.interaction || run.interaction.kind !== 'confirmation' || run.plan?.version !== version) throw new DomainError('Review the current plan version', 409); + return this.respond(runId, { interactionId: run.interaction.id, version, value: true }); + } + async cancel(runId: string): Promise { + const run = this.store.get('run', runId); + if (terminal.has(run.state)) return run; + if (run.state !== 'cancelling') this.event(runId, 'cancel_requested', {}, r => { r.state = 'cancelling'; }); + await this.runtime.cancel(runId); + await this.tasks.get(runId); + const latest = this.store.get('run', runId); + if (!terminal.has(latest.state)) this.finish(runId, 'cancelled'); + return this.store.get('run', runId); + } + async close(): Promise { + this.closed = true; + await Promise.all(this.store.list('run').filter(r => !terminal.has(r.state)).map(r => this.cancel(r.id))); + await this.runtime.close(); + await Promise.all(this.tasks.values()); + this.store.close(); + } +} diff --git a/app/runtime.ts b/app/runtime.ts new file mode 100644 index 0000000..f294841 --- /dev/null +++ b/app/runtime.ts @@ -0,0 +1,69 @@ +import { fork } from 'node:child_process'; +import { DomainError, object, text } from './contracts.ts'; +import type { HarnessHost, Interaction, Json, ObjectData, Run, RuntimeAdapter, ToolCall, ToolDefinition } from './contracts.ts'; +import type { ProviderConfig } from './model.ts'; + +export interface WorkerConfiguration { provider: ProviderConfig; definitions: ToolDefinition[]; instructions: string } +export class WorkerRuntime implements RuntimeAdapter { + configurations: (run: Run) => Promise; + executeTool: (run: Run, call: ToolCall, host: HarnessHost, signal: AbortSignal) => Promise; + sessions = new Map Promise }>(); + constructor(configurations: WorkerRuntime['configurations'], executeTool: WorkerRuntime['executeTool']) { this.configurations = configurations; this.executeTool = executeTool; } + capabilities() { return { streaming: true, cancellation: true, questions: true, permissions: true, checkpoints: false as const }; } + async start(run: Run, host: HarnessHost): Promise { + const abort = new AbortController(); + let child: ReturnType | undefined; + let stopResolve: () => void = () => {}; + const stopped = new Promise(resolve => { stopResolve = resolve; }); + this.sessions.set(run.id, { abort, stop: async () => { + abort.abort(); + if (!child) return; + if (child.connected) child.send({ type: 'cancel' }); + const timeout = setTimeout(() => child?.kill(), 1500); + try { await stopped; } finally { clearTimeout(timeout); } + } }); + try { + const configuration = await this.configurations(run); + abort.signal.throwIfAborted(); + // Do not inherit user skills, NODE_OPTIONS, provider keys or hook settings. + const env: Record = {}; + for (const name of ['SystemRoot', 'WINDIR', 'TEMP', 'TMP', 'PATH']) if (process.env[name]) env[name] = process.env[name]!; + child = fork(new URL('./worker.ts', import.meta.url), [], { env, execArgv: [], stdio: ['ignore', 'ignore', 'ignore', 'ipc'], windowsHide: true }); + const worker = child; + let outcome: { success: boolean; error?: string } | undefined; + const completion = new Promise((resolve, reject) => { + worker.on('error', () => { stopResolve(); reject(new DomainError('Runtime process could not start')); }); + worker.on('exit', () => { + abort.abort(); + stopResolve(); + if (outcome?.success) resolve(); + else reject(new DomainError(outcome?.error ?? 'Runtime process lost; explicit retry required')); + }); + worker.on('message', async (raw: unknown) => { + try { + const message = object(raw); + if (message.type === 'finished') { outcome = { success: true }; return; } + if (message.type === 'failed') { outcome = { success: false, error: text(message.error) }; return; } + const requestId = text(message.requestId); + try { + abort.signal.throwIfAborted(); + const data = object(message.data); + let value: Json = null; + if (message.action === 'emit') await host.emit(text(data.kind), object(data.data)); + else if (message.action === 'wait') value = await host.wait(data as unknown as Omit, abort.signal); + else if (message.action === 'tool') value = await this.executeTool(run, data as unknown as ToolCall, host, abort.signal); + else throw new DomainError('Unknown runtime request'); + if (worker.connected) worker.send({ type: 'response', requestId, value }); + } catch (error) { + if (worker.connected) worker.send({ type: 'response', requestId, error: error instanceof DomainError ? error.message : 'Host operation failed' }); + } + } catch { outcome = { success: false, error: 'Malformed runtime event' }; worker.kill(); } + }); + }); + worker.send({ type: 'start', run, ...configuration }); + await completion; + } finally { this.sessions.delete(run.id); stopResolve(); } + } + async cancel(runId: string): Promise { await this.sessions.get(runId)?.stop(); } + async close(): Promise { await Promise.all([...this.sessions.keys()].map(runId => this.cancel(runId))); } +} diff --git a/app/store.ts b/app/store.ts new file mode 100644 index 0000000..3abd7e1 --- /dev/null +++ b/app/store.ts @@ -0,0 +1,173 @@ +import { DatabaseSync } from 'node:sqlite'; +import { randomUUID, createHash } from 'node:crypto'; +import { mkdirSync, realpathSync, existsSync, lstatSync, writeFileSync, readFileSync, renameSync, openSync, fsyncSync, closeSync } from 'node:fs'; +import { resolve, join } from 'node:path'; +import { DomainError, id, object, text, terminal } from './contracts.ts'; +import type { Json, ObjectData, ResearchEvent, Run } from './contracts.ts'; + +export interface RecordEntry { id: string; kind: string; projectId: string; data: ObjectData } +export const hash = (bytes: string | Buffer) => createHash('sha256').update(bytes).digest('hex'); +const kinds = new Set(['project', 'conversation', 'run', 'message', 'source', 'artifact', 'revision', 'material']); +export class Store { + db: DatabaseSync; + root: string; + constructor(root: string) { + mkdirSync(root, { recursive: true }); + this.root = realpathSync(root); + this.db = new DatabaseSync(join(this.root, 'openlab.sqlite')); + this.db.exec('PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON; PRAGMA synchronous=FULL;'); + const version = this.db.prepare('PRAGMA user_version').get() as { user_version: number }; + if (version.user_version > 1) { this.db.close(); throw new DomainError('This archive needs a newer OpenLab version'); } + this.db.exec(`CREATE TABLE IF NOT EXISTS records (id TEXT PRIMARY KEY, kind TEXT NOT NULL, project_id TEXT NOT NULL, data TEXT NOT NULL); + CREATE INDEX IF NOT EXISTS records_project ON records(project_id, kind); + CREATE TABLE IF NOT EXISTS events (run_id TEXT NOT NULL, seq INTEGER NOT NULL, at TEXT NOT NULL, kind TEXT NOT NULL, data TEXT NOT NULL, PRIMARY KEY(run_id, seq)); + PRAGMA user_version=1;`); + } + transaction(fn: () => T): T { + this.db.exec('BEGIN IMMEDIATE'); + try { const result = fn(); this.db.exec('COMMIT'); return result; } + catch (error) { this.db.exec('ROLLBACK'); throw error; } + } + put(kind: string, recordId: string, projectId: string, data: ObjectData): void { + id(recordId); id(projectId); + if (!kinds.has(kind)) throw new DomainError('Invalid record kind'); + this.db.prepare('INSERT INTO records VALUES (?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET data=excluded.data').run(recordId, kind, projectId, JSON.stringify(data)); + } + get(kind: string, recordId: string): T { + const row = this.db.prepare('SELECT data FROM records WHERE id=? AND kind=?').get(id(recordId), kind) as { data: string } | undefined; + if (!row) throw new DomainError(`${kind} not found`, 404); + return JSON.parse(row.data) as T; + } + list(kind: string, projectId?: string): T[] { + const rows = projectId + ? this.db.prepare('SELECT data FROM records WHERE kind=? AND project_id=? ORDER BY rowid').all(kind, id(projectId)) + : this.db.prepare('SELECT data FROM records WHERE kind=? ORDER BY rowid').all(kind); + return rows.map(row => JSON.parse(row.data as string) as T); + } + saveRun(run: Run): void { this.put('run', run.id, run.projectId, run as unknown as ObjectData); } + append(runId: string, kind: string, data: ObjectData, mutate?: (run: Run) => void): ResearchEvent { + return this.transaction(() => this.appendInTransaction(runId, kind, data, mutate)); + } + private appendInTransaction(runId: string, kind: string, data: ObjectData, mutate?: (run: Run) => void): ResearchEvent { + const run = this.get('run', runId); + mutate?.(run); + this.saveRun(run); + const row = this.db.prepare('SELECT COALESCE(MAX(seq), 0)+1 AS seq FROM events WHERE run_id=?').get(runId) as { seq: number }; + const event = { runId, seq: row.seq, at: new Date().toISOString(), kind, data }; + this.db.prepare('INSERT INTO events VALUES (?, ?, ?, ?, ?)').run(runId, event.seq, event.at, kind, JSON.stringify(data)); + return event; + } + events(runId: string, cursor = 0): ResearchEvent[] { + this.get('run', runId); + if (!Number.isSafeInteger(cursor) || cursor < 0) throw new DomainError('Invalid cursor'); + const rows = this.db.prepare('SELECT * FROM events WHERE run_id=? AND seq>? ORDER BY seq').all(runId, cursor); + return rows.map(row => ({ runId, seq: row.seq as number, at: row.at as string, kind: row.kind as string, data: JSON.parse(row.data as string) as ObjectData })); + } + // IDs are generated by the platform. Check every managed directory before use, + // including after restart, so a replaced directory cannot redirect file access. + file(projectId: string, category: 'artifacts' | 'materials', recordId: string, leaf: string): string { + id(projectId); id(recordId); + if (!/^[a-zA-Z0-9.-]+$/.test(leaf) || leaf.includes('..')) throw new DomainError('Invalid filename'); + let path = this.root; + for (const segment of [projectId, category, recordId]) { + path = join(path, segment); + if (existsSync(path)) { + if (lstatSync(path).isSymbolicLink() || !lstatSync(path).isDirectory()) throw new DomainError('Unsafe project path'); + } else mkdirSync(path); + } + const target = join(path, leaf); + if (existsSync(target) && lstatSync(target).isSymbolicLink()) throw new DomainError('Unsafe file path'); + return target; + } + writeImmutable(path: string, bytes: Buffer | string): void { + const temp = `${path}.${randomUUID()}.tmp`; + writeFileSync(temp, bytes, { flag: 'wx' }); + const fd = openSync(temp, 'r+'); + try { fsyncSync(fd); } finally { closeSync(fd); } + if (existsSync(path)) throw new DomainError('Immutable file already exists', 409); + renameSync(temp, path); + } + artifact(run: Run, content: string, stepId: string, commit: boolean, artifactId = randomUUID()): ObjectData { + text(content, 'report', 2_000_000); id(stepId); id(artifactId); + const previous = this.list('artifact', run.projectId).find(a => a.id === artifactId); + if (previous && previous.runId !== run.id) throw new DomainError('Artifact belongs to another run'); + const revisionId = randomUUID(); + const digest = hash(content); + const path = this.file(run.projectId, 'artifacts', artifactId, `${revisionId}.md`); + this.writeImmutable(path, content); + const revision: ObjectData = { id: revisionId, projectId: run.projectId, artifactId, runId: run.id, stepId, hash: digest, bytes: Buffer.byteLength(content), committed: commit, createdAt: new Date().toISOString() }; + this.transaction(() => { + this.put('revision', revisionId, run.projectId, revision); + this.put('artifact', artifactId, run.projectId, { ...previous, id: artifactId, projectId: run.projectId, runId: run.id, latest: revisionId, draft: !commit }); + this.appendInTransaction(run.id, 'artifact_saved', { artifactId, revisionId, stepId, committed: commit, hash: digest }); + }); + return revision; + } + readRevision(revisionId: string): { revision: ObjectData; content: string } { + const revision = this.get('revision', revisionId); + const content = readFileSync(this.file(String(revision.projectId), 'artifacts', String(revision.artifactId), `${id(revisionId)}.md`), 'utf8'); + if (hash(content) !== revision.hash) throw new DomainError('Artifact checksum mismatch', 500); + return { revision, content }; + } + export(projectId: string): ObjectData { + this.get('project', projectId); + const rows = this.db.prepare('SELECT * FROM records WHERE project_id=? ORDER BY rowid').all(id(projectId)); + const records: RecordEntry[] = rows.map(row => ({ id: row.id as string, kind: row.kind as string, projectId, data: JSON.parse(row.data as string) as ObjectData })); + const runs = records.filter(r => r.kind === 'run'); + if (runs.some(r => !terminal.has((r.data as unknown as Run).state))) throw new DomainError('Finish or cancel active runs before export', 409); + const files: ObjectData = {}; + for (const record of records.filter(r => r.kind === 'revision')) files[record.id] = this.readRevision(record.id).content; + return { schemaVersion: 1, projectId, records: records as unknown as Json, events: runs.flatMap(r => this.events(r.id)) as unknown as Json, files }; + } + import(value: unknown): string { + const archive = object(value); + if (archive.schemaVersion !== 1 || !Array.isArray(archive.records) || !Array.isArray(archive.events)) throw new DomainError('Unsupported archive'); + const files = object(archive.files); + const records = archive.records.map(value => { + const record = object(value); + id(text(record.id)); id(text(record.projectId)); + if (!kinds.has(String(record.kind))) throw new DomainError('Invalid archive record'); + object(record.data); + return record as unknown as RecordEntry; + }); + const originalProject = text(archive.projectId); + if (records.filter(r => r.kind === 'project').length !== 1 || !records.some(r => r.kind === 'project' && r.id === originalProject) || records.some(r => r.projectId !== originalProject || r.data.id !== r.id)) throw new DomainError('Invalid archive ownership'); + const mapping = new Map(records.map(r => [r.id, randomUUID()])); + if (mapping.size !== records.length) throw new DomainError('Duplicate archive identifiers'); + const projectId = mapping.get(originalProject)!; + const rewrite = (value: Json): Json => { + if (typeof value === 'string') return mapping.get(value) ?? value; + if (Array.isArray(value)) return value.map(rewrite); + if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, rewrite(item)])); + return value; + }; + for (const record of records) { + if (record.kind === 'run' && !terminal.has((record.data as unknown as Run).state)) throw new DomainError('Archive contains executable run'); + if (record.kind === 'revision') { + const content = text(files[record.id], 'artifact', 2_000_000); + if (hash(content) !== record.data.hash || Buffer.byteLength(content) !== record.data.bytes || !records.some(r => r.kind === 'artifact' && r.id === record.data.artifactId)) throw new DomainError('Archive checksum or reference mismatch'); + } + } + const events = archive.events.map(value => { + const event = object(value); + if (!records.some(r => r.kind === 'run' && r.id === event.runId) || !Number.isSafeInteger(event.seq) || Number(event.seq) < 1) throw new DomainError('Invalid archive event'); + text(event.at); text(event.kind); object(event.data); + return rewrite(event) as unknown as ResearchEvent; + }); + this.transaction(() => { + for (const record of records) { + const data = rewrite(record.data) as ObjectData; + data.originalId = record.id; + if (record.kind === 'revision') { + // Report bytes remain exact. IDs inside prose remain historical citations. + this.writeImmutable(this.file(projectId, 'artifacts', String(data.artifactId), `${String(data.id)}.md`), String(files[record.id])); + } + if (record.kind === 'run') { data.imported = true; delete data.interaction; } + this.put(record.kind, String(data.id), projectId, data); + } + for (const event of events) this.db.prepare('INSERT INTO events VALUES (?, ?, ?, ?, ?)').run(event.runId, event.seq, event.at, event.kind, JSON.stringify(event.data)); + }); + return projectId; + } + close(): void { this.db.close(); } +} diff --git a/app/tests/harness.test.ts b/app/tests/harness.test.ts new file mode 100644 index 0000000..4d18d6b --- /dev/null +++ b/app/tests/harness.test.ts @@ -0,0 +1,71 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { executeHarness } from '../harness.ts'; +import type { HarnessHost, Json, Message, ModelChunk, ModelProvider, ObjectData, ToolDefinition } from '../contracts.ts'; + +const limits = { turns: 4, tokens: 100, durationMs: 2000, callMs: 1000 }; +const definition: ToolDefinition = { name: 'search', description: 'Search fixture', parameters: { type: 'object' } }; +function host(events: { kind: string; data: ObjectData }[], answer: Json = true): HarnessHost { + return { emit: async (kind, data) => { events.push({ kind, data }); }, wait: async () => answer }; +} +class ScriptedModel implements ModelProvider { + calls = 0; + scripts: ModelChunk[][]; + observed: { messages: Message[]; tools: ToolDefinition[] }[] = []; + constructor(scripts: ModelChunk[][]) { this.scripts = scripts; } + async *stream(messages: Message[], tools: ToolDefinition[]) { + this.observed.push({ messages: structuredClone(messages), tools }); + yield* this.scripts[this.calls++] ?? [{ type: 'text' as const, text: 'Done' }]; + } +} +const plan: ModelChunk[] = [{ type: 'text', text: 'Plan' }, { type: 'usage', input: 5, output: 2 }]; +const search: ModelChunk[] = [{ type: 'tool', call: { id: 'call-1', name: 'search', arguments: '{"query":"经济"}' } }]; + +test('generic loop accepts substitute model, tool, context and sink implementations', async () => { + const events: { kind: string; data: ObjectData }[] = []; + const model = new ScriptedModel([plan, search, [{ type: 'text', text: 'Evidence limitations' }]]); + const calls: unknown[] = []; + await executeHarness({ question: 'Economics', instructions: 'Research', model, host: host(events), signal: new AbortController().signal, limits, + tools: { definitions: [definition], execute: async call => { calls.push(call); return { sources: [], limitation: 'empty retrieval' }; } }, + context: { assemble: messages => ({ messages, trimmed: 1 }) }, + }); + assert.equal(model.observed[0].tools.length, 0); + assert.equal(calls.length, 1); + assert.match(model.observed[2].messages.at(-1)!.content, /empty retrieval/); + assert.ok(events.some(e => e.kind === 'context_trimmed')); + assert.ok(events.some(e => e.kind === 'usage' && e.data.unknownUsage === true)); + assert.ok(events.findIndex(e => e.kind === 'tool_intent') < events.findIndex(e => e.kind === 'tool_result')); +}); + +test('denied plan, planning tool calls, unregistered tools and malformed usage stop dispatch', async () => { + for (const [scripts, answer, pattern] of [ + [[plan], false, /not confirmed/], + [[search], true, /during planning/], + [[plan, [{ type: 'tool', call: { id: 'call-2', name: 'shell', arguments: '{}' } }]], true, /unregistered/], + [[[...plan, { type: 'usage', input: -1, output: 0 }]], true, /Invalid model usage/], + ] as [ModelChunk[][], Json, RegExp][]) { + let calls = 0; + await assert.rejects(executeHarness({ question: 'Test', instructions: 'Research', model: new ScriptedModel(scripts), host: host([], answer), signal: new AbortController().signal, limits, + tools: { definitions: [definition], execute: async () => { calls++; return null; } }, + }), pattern); + assert.equal(calls, 0); + } +}); + +test('turn and reported token budgets stop new model calls', async () => { + for (const budget of [{ ...limits, turns: 1 }, { ...limits, tokens: 7 }]) { + const model = new ScriptedModel([plan, search]); + await assert.rejects(executeHarness({ question: 'Test', instructions: 'Research', model, host: host([]), signal: new AbortController().signal, limits: budget, + tools: { definitions: [definition], execute: async () => null }, + }), /limit reached/); + assert.equal(model.calls, 1); + } +}); + +test('event persistence failure prevents the next model/tool dispatch', async () => { + const model = new ScriptedModel([plan, search]); + await assert.rejects(executeHarness({ question: 'Test', instructions: 'Research', model, host: { ...host([]), emit: async () => { throw new Error('disk unavailable'); } }, signal: new AbortController().signal, limits, + tools: { definitions: [definition], execute: async () => { throw new Error('must not execute'); } }, + }), /disk unavailable/); + assert.equal(model.calls, 0); +}); diff --git a/app/tests/helpers.ts b/app/tests/helpers.ts new file mode 100644 index 0000000..34fb681 --- /dev/null +++ b/app/tests/helpers.ts @@ -0,0 +1,34 @@ +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { setTimeout as delay } from 'node:timers/promises'; +import { Store } from '../store.ts'; +import { Platform } from '../platform.ts'; +import type { HarnessHost, Run, RuntimeAdapter } from '../contracts.ts'; + +export class SubstituteRuntime implements RuntimeAdapter { + action: (run: Run, host: HarnessHost, signal: AbortSignal) => Promise; + controllers = new Map(); + constructor(action: SubstituteRuntime['action'] = async () => {}) { this.action = action; } + capabilities() { return { streaming: true, cancellation: true, questions: true, permissions: true, checkpoints: false as const }; } + async start(run: Run, host: HarnessHost) { + const controller = new AbortController(); this.controllers.set(run.id, controller); + try { await this.action(run, host, controller.signal); } finally { this.controllers.delete(run.id); } + } + async cancel(runId: string) { this.controllers.get(runId)?.abort(); } + async close() { for (const controller of this.controllers.values()) controller.abort(); } +} +export async function until(read: () => T, condition: (value: T) => boolean, timeout = 5000): Promise { + const end = Date.now() + timeout; + while (Date.now() < end) { const value = read(); if (condition(value)) return value; await delay(10); } + throw new Error('Timed out waiting for expected state'); +} +export function fixture(runtime: RuntimeAdapter = new SubstituteRuntime()) { + const root = mkdtempSync(join(tmpdir(), 'OpenLab 中文 space ')); + const store = new Store(root); + const platform = new Platform(store, runtime); + const projectId = String(platform.project('经济与人文')['id']); + const conversationId = String(platform.conversation(projectId)['id']); + const create = (question = 'What does the evidence show?', requestKey = 'once') => platform.createRun(conversationId, { question, requestKey, config: { providerId: 'fixture', skillId: 'fixture' } }); + return { root, store, platform, projectId, conversationId, create, cleanup: async () => { await platform.close(); rmSync(root, { recursive: true, force: true }); } }; +} diff --git a/app/tests/model.test.ts b/app/tests/model.test.ts new file mode 100644 index 0000000..f8d2a11 --- /dev/null +++ b/app/tests/model.test.ts @@ -0,0 +1,33 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { CompatibleModel } from '../model.ts'; +import { DomainError } from '../contracts.ts'; +import type { ModelChunk } from '../contracts.ts'; + +const chunk = (delta: unknown, finish_reason: string | null = null) => `data: ${JSON.stringify({ choices: [{ index: 0, delta, finish_reason }] })}\r\n\r\n`; +const usage = 'data: {"choices":[],"usage":{"prompt_tokens":12,"completion_tokens":4}}\r\n\r\n'; +async function collect(body: string, status = 200, headers = { 'content-type': 'text/event-stream' }): Promise { + const bytes = new TextEncoder().encode(body); + const response = () => Promise.resolve(new Response(new ReadableStream({ start(controller) { for (let i = 0; i < bytes.length; i += 3) controller.enqueue(bytes.slice(i, i + 3)); controller.close(); } }), { status, headers })); + const model = new CompatibleModel({ baseUrl: 'http://127.0.0.1:1234', model: 'fixture', apiKey: 'test-value' }, response as typeof fetch); + const result = []; + for await (const item of model.stream([{ role: 'user', content: '研究' }], [], new AbortController().signal)) result.push(item); + return result; +} +test('SSE preserves fragmented UTF-8, whitespace, tool arguments and usage', async () => { + const result = await collect(chunk({ content: '中文' }) + chunk({ content: ' ' }) + chunk({ tool_calls: [{ index: 0, id: 'tool-1', function: { name: 'search', arguments: '{"query":' } }] }) + chunk({ tool_calls: [{ index: 0, function: { arguments: '"中文"}' } }] }, 'tool_calls') + usage + 'data: [DONE]\r\n\r\n'); + assert.deepEqual(result.filter(c => c.type === 'text').map(c => c.text), ['中文', ' ']); + assert.deepEqual(result.at(-1), { type: 'tool', call: { id: 'tool-1', name: 'search', arguments: '{"query":"中文"}' } }); + assert.deepEqual(result.find(c => c.type === 'usage'), { type: 'usage', input: 12, output: 4 }); +}); +test('truncated streams, invalid JSON arguments, output limits and JSON responses fail', async () => { + await assert.rejects(collect(chunk({ content: 'Partial' })), /without confirmed completion/); + await assert.rejects(collect(chunk({ content: 'Partial' }, 'length') + 'data: [DONE]\n\n'), /before completion/); + await assert.rejects(collect('data: incomplete'), /Truncated/); + await assert.rejects(collect(chunk({ tool_calls: [{ index: 0, id: 'a', function: { name: 'search', arguments: 'invalid' } }] }, 'tool_calls') + 'data: [DONE]\n\n'), SyntaxError); + await assert.rejects(collect('{}', 200, { 'content-type': 'application/json' }), /did not return SSE/); +}); +test('provider errors retain status but never archive bodies or credentials', async () => { + await assert.rejects(collect('secret provider body', 401), error => error instanceof DomainError && /401/.test(error.message) && !error.message.includes('secret')); + assert.throws(() => new CompatibleModel({ baseUrl: 'http://example.org', model: 'x', apiKey: 'x' }), /HTTPS/); +}); diff --git a/app/tests/platform.test.ts b/app/tests/platform.test.ts new file mode 100644 index 0000000..17352be --- /dev/null +++ b/app/tests/platform.test.ts @@ -0,0 +1,127 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; +import { fixture, SubstituteRuntime, until } from './helpers.ts'; +import { Store } from '../store.ts'; +import { Platform } from '../platform.ts'; +import type { Run } from '../contracts.ts'; + +test('plan confirmation gates work and creation keys do not dispatch twice', async () => { + let work = 0; + const f = fixture(new SubstituteRuntime(async (_run, host, signal) => { + await host.wait({ kind: 'confirmation', input: 'boolean', prompt: 'Search metadata', scope: { tools: ['search'] } }, signal); + work++; + })); + try { + const run = f.create(); + assert.equal(f.create().id, run.id); + assert.throws(() => f.create('Different question'), /different input/); + const waiting = await until(() => f.store.get('run', run.id), r => r.state === 'awaiting_confirmation'); + assert.equal(work, 0); + assert.throws(() => f.platform.confirm(run.id, 99), /current plan/); + f.platform.confirm(run.id, waiting.plan!.version); + await f.platform.tasks.get(run.id); + assert.equal(work, 1); + assert.equal(f.store.get('run', run.id).state, 'succeeded'); + assert.throws(() => f.platform.confirm(run.id, waiting.plan!.version), /current plan/); + } finally { await f.cleanup(); } +}); + +test('questions validate answers, duplicate decisions are idempotent and denial is retained', async () => { + let permissions: unknown; + const f = fixture(new SubstituteRuntime(async (_run, host, signal) => { + await host.wait({ kind: 'confirmation', input: 'boolean', prompt: 'Plan', scope: { tools: [] } }, signal); + await host.wait({ kind: 'question', input: 'single_choice', options: ['economics', 'humanities'], prompt: 'Discipline?' }, signal); + permissions = await host.wait({ kind: 'permission', input: 'boolean', prompt: 'Read content?', scope: { sourceId: 'fixture' } }, signal); + })); + try { + const run = f.create(); + const read = () => f.store.get('run', run.id); + const plan = await until(read, r => r.state === 'awaiting_confirmation'); + f.platform.confirm(run.id, plan.plan!.version); + const question = await until(read, r => r.state === 'awaiting_input'); + const decision = { interactionId: question.interaction!.id, version: question.interaction!.version, value: 'economics' }; + assert.throws(() => f.platform.respond(run.id, { ...decision, value: 'invalid' }), /listed option/); + assert.throws(() => f.platform.respond(run.id, { ...decision, version: 99 }), /Stale/); + f.platform.respond(run.id, decision); + assert.deepEqual(f.platform.respond(run.id, decision), decision); + assert.throws(() => f.platform.respond(run.id, { ...decision, value: 'humanities' }), /differently/); + const permission = await until(read, r => r.state === 'awaiting_permission'); + f.platform.respond(run.id, { interactionId: permission.interaction!.id, version: permission.interaction!.version, value: false }); + await f.platform.tasks.get(run.id); + assert.equal(permissions, false); + assert.equal(f.store.events(run.id).filter(e => e.kind === 'interaction_answered').length, 3); + assert.throws(() => f.platform.respond(run.id, decision), /no longer/); + } finally { await f.cleanup(); } +}); + +test('cancellation is recorded before termination and blocks late decisions', async () => { + const f = fixture(new SubstituteRuntime(async (_run, host, signal) => { + await host.wait({ kind: 'confirmation', input: 'boolean', prompt: 'Plan' }, signal); + })); + try { + const run = f.create(); + await until(() => f.store.get('run', run.id), r => r.state === 'awaiting_confirmation'); + assert.equal((await f.platform.cancel(run.id)).state, 'cancelled'); + const events = f.store.events(run.id); + assert.ok(events.findIndex(e => e.kind === 'cancel_requested') < events.findIndex(e => e.kind === 'run_finished')); + assert.equal((await f.platform.cancel(run.id)).state, 'cancelled'); + } finally { await f.cleanup(); } +}); + +test('one active run per conversation, explicit linked retry and completed cancellation race', async () => { + const f = fixture(); + try { + const first = f.create(); + assert.throws(() => f.create('Next', 'next'), /active run/); + await f.platform.tasks.get(first.id); + assert.equal((await f.platform.cancel(first.id)).state, 'succeeded'); + const retry = f.platform.createRun(f.conversationId, { question: 'Retry', requestKey: 'retry', config: {}, parentRunId: first.id }); + assert.equal(retry.parentRunId, first.id); + await f.platform.tasks.get(retry.id); + assert.throws(() => f.platform.createRun(f.conversationId, { question: 'Retry', requestKey: 'bad-parent', config: {}, parentRunId: randomUUID() }), /Retry parent/); + } finally { await f.cleanup(); } +}); + +test('restart reconciles lost runs without executing, reopening history offline', async () => { + const f = fixture(); + try { + const run = f.create(); + await f.platform.tasks.get(run.id); + f.store.append(run.id, 'synthetic_loss', {}, r => { r.state = 'running'; }); + f.store.close(); + const reopened = new Store(f.root); + let started = 0; + const platform = new Platform(reopened, new SubstituteRuntime(async () => { started++; })); + assert.equal(reopened.get('run', run.id).state, 'interrupted'); + assert.equal(started, 0); + assert.equal(reopened.list('message', f.projectId)[0].content, run.question); + await platform.close(); + // Restore the fixture handle for cleanup after the explicit restart test. + f.platform.store = new Store(f.root); + } finally { await f.cleanup(); } +}); + +test('events and state roll back together on persistence error', async () => { + const f = fixture(); + try { + const run = f.create(); + await f.platform.tasks.get(run.id); + const before = f.store.events(run.id).length; + assert.throws(() => f.store.append(run.id, 'bad', {}, r => { r.state = 'failed'; throw new Error('disk failure'); }), /disk failure/); + assert.equal(f.store.get('run', run.id).state, 'succeeded'); + assert.equal(f.store.events(run.id).length, before); + assert.deepEqual(f.store.events(run.id, before), []); + } finally { await f.cleanup(); } +}); + +test('archiving blocks execution and can be reversed', async () => { + const f = fixture(); + try { + f.platform.updateProject(f.projectId, { name: 'Renamed', archived: true }); + assert.throws(() => f.create(), /Reopen/); + f.platform.updateProject(f.projectId, { archived: false }); + const run = f.create(); await f.platform.tasks.get(run.id); + assert.equal(f.store.get('project', f.projectId).name, 'Renamed'); + } finally { await f.cleanup(); } +}); diff --git a/app/tests/runtime.test.ts b/app/tests/runtime.test.ts new file mode 100644 index 0000000..fd71852 --- /dev/null +++ b/app/tests/runtime.test.ts @@ -0,0 +1,33 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createServer } from 'node:http'; +import { once } from 'node:events'; +import { WorkerRuntime } from '../runtime.ts'; +import { fixture, until } from './helpers.ts'; +import type { Run } from '../contracts.ts'; + +test('native child worker uses isolated configuration and confirms cancellation at process exit', async () => { + let requests = 0; + const server = createServer(async (req, res) => { + requests++; + const chunks = []; for await (const chunk of req) chunks.push(chunk); + const body = JSON.parse(Buffer.concat(chunks).toString()); + assert.equal(body.tools, undefined); + res.writeHead(200, { 'content-type': 'text/event-stream' }); + res.end('data: {"choices":[{"index":0,"delta":{"content":"原生 Windows 研究计划"},"finish_reason":"stop"}]}\n\ndata: [DONE]\n\n'); + }); + server.listen(0, '127.0.0.1'); await once(server, 'listening'); + const address = server.address() as { port: number }; + const runtime = new WorkerRuntime(async () => ({ provider: { baseUrl: `http://127.0.0.1:${address.port}`, model: 'fixture', apiKey: 'test-only' }, definitions: [], instructions: 'Fixture research' }), async () => { throw new Error('No tools before confirmation'); }); + const f = fixture(runtime); + try { + const run = f.create(); + await until(() => f.store.get('run', run.id), r => r.state === 'awaiting_confirmation', 10_000); + assert.equal(requests, 1); + assert.equal(runtime.sessions.size, 1); + await f.platform.cancel(run.id); + assert.equal(runtime.sessions.size, 0); + assert.equal(f.store.get('run', run.id).state, 'cancelled'); + assert.equal(requests, 1); + } finally { await f.cleanup(); server.close(); await once(server, 'close'); } +}); diff --git a/app/tests/store.test.ts b/app/tests/store.test.ts new file mode 100644 index 0000000..4683a3a --- /dev/null +++ b/app/tests/store.test.ts @@ -0,0 +1,69 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; +import { writeFileSync, symlinkSync } from 'node:fs'; +import { join } from 'node:path'; +import { fixture } from './helpers.ts'; +import type { ObjectData } from '../contracts.ts'; + +test('Unicode paths, exact immutable revisions, checksums and export/import round trip', async () => { + const f = fixture(); + try { + const run = f.create('中文文献的相互矛盾证据'); await f.platform.tasks.get(run.id); + const artifactId = randomUUID(); + const first = f.store.artifact(run, '# 草稿\n\nEvidence remains unverified.', randomUUID(), false, artifactId); + const second = f.store.artifact(run, '# Final\n\nMetadata is not checked evidence.', randomUUID(), true, artifactId); + assert.notEqual(first.hash, second.hash); + assert.match(f.store.readRevision(String(first.id)).content, /草稿/); + const archive = f.store.export(f.projectId); + const importedId = f.store.import(archive); + const imported = f.store.export(importedId); + assert.deepEqual(Object.values(imported.files as ObjectData), Object.values(archive.files as ObjectData)); + assert.equal(f.store.list('run', importedId)[0].imported, true); + assert.notEqual(importedId, f.projectId); + const serialized = JSON.stringify(archive); + assert.ok(!serialized.includes('apiKey')); + assert.ok(!serialized.includes(f.root.replaceAll('\\', '\\\\'))); + } finally { await f.cleanup(); } +}); + +test('modified report bytes and corrupted exports are rejected before import', async () => { + const f = fixture(); + try { + const run = f.create(); await f.platform.tasks.get(run.id); + const revision = f.store.artifact(run, 'Evidence', randomUUID(), true); + const archive = f.store.export(f.projectId); + (archive.files as ObjectData)[String(revision.id)] = 'tampered'; + assert.throws(() => f.store.import(archive), /checksum/); + assert.equal(f.store.list('project').length, 1); + writeFileSync(f.store.file(f.projectId, 'artifacts', String(revision.artifactId), `${String(revision.id)}.md`), 'tampered'); + assert.throws(() => f.store.readRevision(String(revision.id)), /checksum/); + } finally { await f.cleanup(); } +}); + +test('archives reject traversal, future schemas, duplicate identifiers and executable runs', async () => { + const f = fixture(); + try { + const run = f.create(); await f.platform.tasks.get(run.id); + assert.throws(() => f.store.file(f.projectId, 'artifacts', randomUUID(), '../escape'), /filename/); + assert.throws(() => f.store.file('../escape', 'artifacts', randomUUID(), 'file'), /identifier/); + const archive = f.store.export(f.projectId); + assert.throws(() => f.store.import({ ...archive, schemaVersion: 2 }), /Unsupported/); + const duplicate = structuredClone(archive); + (duplicate.records as unknown[]).push((duplicate.records as unknown[])[0]); + assert.throws(() => f.store.import(duplicate), /ownership|Duplicate/); + const active = structuredClone(archive); + const records = active.records as unknown as { kind: string; data: ObjectData }[]; + records.find(r => r.kind === 'run')!.data.state = 'running'; + assert.throws(() => f.store.import(active), /executable/); + } finally { await f.cleanup(); } +}); + +test('managed directory junctions cannot redirect files outside a project', async () => { + const f = fixture(); + try { + const projectId = f.projectId; + symlinkSync(f.root, join(f.root, projectId), process.platform === 'win32' ? 'junction' : 'dir'); + assert.throws(() => f.store.file(projectId, 'artifacts', randomUUID(), 'revision.md'), /Unsafe/); + } finally { await f.cleanup(); } +}); diff --git a/app/worker.ts b/app/worker.ts new file mode 100644 index 0000000..62f019b --- /dev/null +++ b/app/worker.ts @@ -0,0 +1,43 @@ +import { randomUUID } from 'node:crypto'; +import { executeHarness } from './harness.ts'; +import { CompatibleModel } from './model.ts'; +import { DomainError } from './contracts.ts'; +import type { Json, ObjectData, Run, ToolDefinition } from './contracts.ts'; +import type { ProviderConfig } from './model.ts'; + +const abort = new AbortController(); +const pending = new Map void; reject: (error: Error) => void }>(); +function rpc(action: string, data: ObjectData, signal = abort.signal): Promise { + abort.signal.throwIfAborted(); + return new Promise((resolve, reject) => { + const requestId = randomUUID(); + const cancel = () => { pending.delete(requestId); reject(new DomainError('Execution interrupted')); }; + const cleanup = () => signal.removeEventListener('abort', cancel); + signal.addEventListener('abort', cancel, { once: true }); + pending.set(requestId, { resolve: value => { cleanup(); resolve(value); }, reject: error => { cleanup(); reject(error); } }); + process.send!({ requestId, action, data }); + }); +} +process.on('disconnect', () => process.exit(1)); +process.on('message', async (message: { type: string; requestId?: string; value?: Json; error?: string; run: Run; provider: ProviderConfig; definitions: ToolDefinition[]; instructions: string }) => { + if (message.type === 'response') { + const item = pending.get(message.requestId!); + pending.delete(message.requestId!); + if (message.error) item?.reject(new DomainError(message.error)); + else item?.resolve(message.value ?? null); + } else if (message.type === 'cancel') { + abort.abort(); + for (const item of pending.values()) item.reject(new DomainError('Cancelled')); + pending.clear(); + } else if (message.type === 'start') { + try { + await executeHarness({ question: message.run.question, instructions: message.instructions, model: new CompatibleModel(message.provider), limits: message.run.limits, signal: abort.signal, + host: { emit: async (kind, data) => { await rpc('emit', { kind, data }); }, wait: (interaction, signal) => rpc('wait', interaction as unknown as ObjectData, signal) }, + tools: { definitions: message.definitions, execute: (call, signal) => rpc('tool', call as unknown as ObjectData, signal) }, + }); + process.send!({ type: 'finished' }, () => process.disconnect()); + } catch (error) { + process.send!({ type: 'failed', error: error instanceof DomainError ? error.message : 'Worker model request failed' }, () => process.disconnect()); + } + } +}); diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..8e871e5 --- /dev/null +++ b/docs/development.md @@ -0,0 +1,35 @@ +# Application development + +The v0.0.1 release remains foundation-only. Current source starts the first application milestone with a platform archive, generic Harness and supervised worker. There is no browser application or installer yet. Follow the [implementation plan](implementation-plan.md) for remaining gates. + +## Run checks + +Use Node.js 24 and the committed npm lockfile: + +```sh +npm ci +npm run check +``` + +The check includes strict TypeScript validation, documentation validation and Node's test runner. Tests use temporary directories, controlled model responses and a substitute runtime; they require no provider credential or network service. Native Windows tests cover Unicode/space-containing paths and a separately spawned worker. GitHub CI runs the same checks on Windows and Linux. + +## Implemented core + +- `app/contracts.ts` defines platform and replaceable model/tool/runtime boundaries. +- `app/harness.ts` implements the sequential loop, planning without tools, confirmation, context hooks, event acknowledgements, limits and cancellation signals. +- `app/platform.ts` owns projects, conversations, creation keys, persisted interactions, state transitions and restart reconciliation. +- `app/store.ts` owns SQLite metadata/events and ordinary immutable report files. File bytes are saved and synced before a revision becomes visible. Events and corresponding metadata changes share a transaction. Archives contain public research records and exact report bytes, with validated hashes and newly allocated identities on import. +- `app/runtime.ts` supervises an IPC child with an explicit environment and selected provider configuration. `app/worker.ts` hosts the generic loop. Loss of the parent channel terminates the worker; this process boundary is not a sandbox. +- `app/model.ts` parses an OpenAI-compatible Chat Completions SSE stream, including fragmented tool calls and reported usage. Incomplete streams and output-limit finishes fail rather than establish success. No automatic model retry is performed; a failed call requires explicit retry to avoid ambiguous duplicate billing. + +SQLite schema version 1 is the first development schema; no migration from a shipped application schema is required. A future schema change must back up existing archives and add migration/round-trip tests. Imported runs are historical, terminal and never dispatched. Imported records retain `originalId` for resolving exact report citations without rewriting report bytes. + +## Optional live model check + +`npm run test:live-model` uses `OPENLAB_SMOKE_API_KEY` to test DeepSeek Flash Chinese text streaming and a structured tool call. Run it explicitly; ordinary tests and CI do not use network credentials. The script prints only test results, public endpoint/model information and usage. It writes the same sanitized record to the ignored `.cache/live-model-smoke.json`. + +On Windows, keep credentials encrypted with user-scoped Windows protection and pass the decrypted value only in the launching process environment. Do not put credentials in commands committed to the repository, project metadata, exports or test fixtures. The application credential/settings interface remains a later implementation item. + +## Remaining application gates + +Retrieval, generic pinned skill installation, protected provider settings, HTTP authentication, AG-UI projection, the React/assistant-ui workspace, attachments and full research acceptance remain pending. Core fixture tests and the narrow live model probes do not establish completion of those gates. See [verification](verification.md) for recorded outcomes. diff --git a/docs/harness.md b/docs/harness.md index 3616b37..3bafb27 100644 --- a/docs/harness.md +++ b/docs/harness.md @@ -1,6 +1,6 @@ # OpenLab Harness: first runtime specification -Status: accepted implementation design, updated 2026-10-24. The repository contains no implemented Harness, SDK or runtime security boundary. [ADR 0002](decisions/0002-workspace-and-harness.md) selects this first runtime and supersedes the earlier OpenCode-first decision. +Status: accepted implementation design, updated 2026-10-24. Current source implements an initial generic Harness and supervised worker; see [development](development.md) for its verified scope and remaining application gates. No runtime sandbox is provided. [ADR 0002](decisions/0002-workspace-and-harness.md) selects this first runtime and supersedes the earlier OpenCode-first decision. ## Responsibility and first milestone diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md new file mode 100644 index 0000000..f24b540 --- /dev/null +++ b/docs/implementation-plan.md @@ -0,0 +1,23 @@ +# First application implementation plan + +This plan implements the accepted [product](product.md), [workspace](workspace.md) and [Harness](harness.md) specifications. A checked item requires recorded evidence; fixture tests alone do not establish live provider compatibility. + +## Iterations + +1. Preserve the accepted workspace/Harness design through a checked and merged PR. +2. Implement the platform archive and generic Harness: typed contracts, SQLite transactions, immutable artifact revisions, validated interactions, idempotent commands, cancellation, supervised execution and restart reconciliation. Exercise native Windows paths and substitute implementations. +3. Add provider and retrieval adapters, pinned skill installation, protected credentials, an authenticated loopback API, replay and AG-UI projection. Validate with controlled HTTP services and record live retrieval separately. +4. Build the React workspace with assistant-ui, project/history navigation, settings, decisions, artifacts and sources. Check desktop, drawers, keyboard operation, English and Chinese. +5. Complete the economics/humanities acceptance matrix, archive portability, limits and failure cases. Record live model compatibility only with configured user credentials. Update the documentation and release identity after the release gates pass. + +Each iteration uses a short-lived branch, local checks, a PR, passing required CI and squash merge. Update this file and the verification record with delivered scope and unresolved gates before proceeding. Later roadmap phases (compute, multiple agents, institutional deployment and ecosystem infrastructure) are outside the first application release. + +## Evidence ledger + +| Iteration | Result | +| --- | --- | +| Design baseline | PR #6 merged; Windows/Linux required CI passed | +| Platform and Harness | Source implemented; 28 tests and type checking passed on native Windows; PR checks pending | +| Services and adapters | Pending | +| Research workspace | Pending | +| Application acceptance | Pending | diff --git a/docs/verification.md b/docs/verification.md index 70549d0..60e1489 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -22,6 +22,12 @@ The Chinese vision is byte-identical to the committed file at the start of this ## Baseline checks +### Initial application core, 2026-10-24 + +On native Windows x64 with Node.js 24.19.0, strict TypeScript validation and 28 tests passed (19 application-core tests and nine governance tests). Core cases include confirmation before dispatch, substitute model/tool/context/sink and RuntimeAdapter implementations, validated questions and denied permission, duplicate/stale decisions, usage/turn limits, worker cancellation, restart reconciliation, SQLite transaction rollback, Unicode/space-containing paths, junction rejection, immutable revisions, corrupted archive rejection and offline export/import. These do not establish browser UI or full literature research acceptance. + +Two opt-in calls to `https://api.deepseek.com` with `deepseek-flash` passed Chinese text streaming and a structured `record_probe` tool call. The provider reported input/output usage of 19/8 and 285/39 tokens respectively. The selected credential was stored locally with Windows user-scoped protection and was excluded from source and verification output. The sanitized machine record remains in the local ignored cache; `scripts/live-model-smoke.mjs` supplies the repeatable procedure. This is narrow model-interface evidence, not a completed research task or a claim about all DeepSeek models. + - `npm ci`: install development dependencies from the committed lockfile. - `npm run check`: Markdown style, internal links and anchors, YAML parsing, required files, Issue Form fields, immutable Actions references, version consistency, label configuration, and release-policy tests. - `npm run check:actions`: validate workflow syntax and expression semantics with pinned actionlint. @@ -39,4 +45,4 @@ The implementation is accepted when the foundation PR passes required checks and Static validation checks local document links, YAML structure and Issue Form fields. GitHub's rendering and form behavior should also be inspected on the published default branch. Badges and remote resources depend on their services and are not fetched during PR lint. -No application, OpenLab Harness or external-runtime execution, scientific result, sandbox, research export, or diagnostic bundle has been implemented or tested by this foundation release or the documentation revision above. Native Windows runtime feasibility, assistant-ui/AG-UI compatibility, full-text verification, retrieval reliability, and Supervisor-Skills licensing resolution remain explicit application gates in the [product](product.md), [architecture](architecture.md), [Harness](harness.md), and [third-party record](../THIRD_PARTY_NOTICES.md). +The foundation release and historical documentation revision above supplied no application execution. Current development core verification is recorded separately above. A complete research application, scientific result, sandbox, diagnostic bundle, assistant-ui/AG-UI compatibility, full-text verification, retrieval reliability and Supervisor-Skills licensing resolution remain explicit application gates in the [product](product.md), [architecture](architecture.md), [Harness](harness.md), and [third-party record](../THIRD_PARTY_NOTICES.md). diff --git a/package-lock.json b/package-lock.json index 6e26b54..1a785ea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,9 @@ "version": "0.0.1", "license": "Apache-2.0", "devDependencies": { + "@types/node": "24.19.2", "markdownlint-cli2": "0.24.0", + "typescript": "7.0.2", "yaml": "2.9.1" }, "engines": { @@ -91,6 +93,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "24.19.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.2.tgz", + "integrity": "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, "node_modules/@types/unist": { "version": "2.0.11", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", @@ -98,6 +110,346 @@ "dev": true, "license": "MIT" }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/ansi-regex": { "version": "6.4.0", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.4.0.tgz", @@ -1399,6 +1751,41 @@ "node": ">=8.0" } }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, "node_modules/uc.micro": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-3.0.0.tgz", @@ -1406,6 +1793,13 @@ "dev": true, "license": "MIT" }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + }, "node_modules/unicorn-magic": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.4.1.tgz", diff --git a/package.json b/package.json index bf1c1b2..7792533 100644 --- a/package.json +++ b/package.json @@ -2,18 +2,23 @@ "name": "openlab-repository", "version": "0.0.1", "private": true, + "type": "module", "description": "Repository governance and documentation tooling for OpenLab", "license": "Apache-2.0", - "overrides": { "katex": "0.18.2" }, + "overrides": { + "katex": "0.18.2" + }, "engines": { "node": ">=24 <25" }, "scripts": { "brand:generate": "node scripts/generate-brand.mjs", - "check": "npm run lint:markdown && npm run check:repository && npm test", + "check": "npm run lint:markdown && npm run check:repository && npm run typecheck && npm test", + "typecheck": "tsc --noEmit", "lint:markdown": "markdownlint-cli2", "check:repository": "node scripts/check-repository.mjs", - "test": "node --test scripts/tests/*.test.mjs", + "test": "node --test scripts/tests/*.test.mjs app/tests/*.test.ts", + "test:live-model": "node scripts/live-model-smoke.mjs", "check:actions": "node scripts/tool.mjs actionlint", "check:secrets": "node scripts/tool.mjs gitleaks", "github:sync": "node scripts/github.mjs sync", @@ -21,7 +26,9 @@ "release:check": "node scripts/release.mjs check" }, "devDependencies": { + "@types/node": "24.19.2", "markdownlint-cli2": "0.24.0", + "typescript": "7.0.2", "yaml": "2.9.1" } } diff --git a/scripts/live-model-smoke.mjs b/scripts/live-model-smoke.mjs new file mode 100644 index 0000000..d7d64a9 --- /dev/null +++ b/scripts/live-model-smoke.mjs @@ -0,0 +1,28 @@ +// Opt-in network verification. Credentials arrive through the environment and +// are never printed or saved in the verification report. +import { CompatibleModel } from '../app/model.ts'; +import { writeFileSync } from 'node:fs'; + +const apiKey = process.env.OPENLAB_SMOKE_API_KEY; +if (!apiKey) throw new Error('Set OPENLAB_SMOKE_API_KEY for the opt-in live test'); +const provider = new CompatibleModel({ baseUrl: 'https://api.deepseek.com', model: 'deepseek-flash', apiKey, maxOutputTokens: 256, extra: { thinking: { type: 'disabled' } } }); +const tool = { name: 'record_probe', description: 'Record the supplied verification word.', parameters: { type: 'object', properties: { word: { type: 'string' } }, required: ['word'], additionalProperties: false } }; +const results = { at: new Date().toISOString(), node: process.version, platform: `${process.platform}/${process.arch}`, endpoint: 'https://api.deepseek.com', model: 'deepseek-flash', tests: [] }; +for (const [name, prompt, tools] of [ + ['Chinese streaming', '请用中文写一句简短的文献研究说明,不超过二十个字。', []], + ['Structured tool calling', 'Call record_probe with word=OpenLab. Do not answer in text.', [tool]], +]) { + try { + const chunks = []; + for await (const chunk of provider.stream([{ role: 'user', content: prompt }], tools, AbortSignal.timeout(45_000))) chunks.push(chunk); + const calls = chunks.filter(c => c.type === 'tool'); + const passed = tools.length ? calls.length === 1 && calls[0].call.name === tool.name && JSON.parse(calls[0].call.arguments).word === 'OpenLab' : chunks.some(c => c.type === 'text' && /[\u3400-\u9fff]/u.test(c.text)); + results.tests.push({ name, passed, textChunks: chunks.filter(c => c.type === 'text').length, tools: calls.map(c => c.call.name), usage: chunks.filter(c => c.type === 'usage') }); + } catch (error) { + // Only safe, known error classes are reported. Network errors are generic. + results.tests.push({ name, passed: false, error: error.name === 'DomainError' ? error.message : 'Connection or response parsing failed' }); + } +} +writeFileSync('.cache/live-model-smoke.json', JSON.stringify(results, null, 2) + '\n'); +console.log(JSON.stringify(results, null, 2)); +if (results.tests.some(test => !test.passed)) process.exitCode = 1; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..4a8d27f --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2023", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "noEmit": true, + "allowImportingTsExtensions": true, + "erasableSyntaxOnly": true, + "verbatimModuleSyntax": true, + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["app/**/*.ts"] +}