mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-12 21:02:17 +02:00
merge dev into feature/apps-v1
Union resolutions — dev's changes kept, apps-v1 additions appended: - use_case.ts: dev's meeting_prep + our app_llm_generate/app_copilot_run - ipc.ts: dev's HttpAuthRequestSchema import + our AppSummarySchema - shared/index.ts: dev's channels export + our rowboatApp export - builtin-tools app-navigation: dev's read-view/open-item actions + our open-app - chat-conversation: dev's read-view/open-item labels + our open-app labels - sidebar: dev's nav-workspaces tour id + our Apps entry - App.tsx: dev's open-item handler + our open-app handler
This commit is contained in:
commit
dc28b0b868
186 changed files with 29957 additions and 1357 deletions
|
|
@ -107,6 +107,10 @@ export const GmailAttachmentSchema = z.object({
|
|||
mimeType: z.string().optional(),
|
||||
sizeBytes: z.number().int().nonnegative().optional(),
|
||||
savedPath: z.string(),
|
||||
// Gmail identifiers used to fetch the attachment on demand when it hasn't
|
||||
// been downloaded to disk yet (e.g. attachments on search results).
|
||||
messageId: z.string().optional(),
|
||||
attachmentId: z.string().optional(),
|
||||
});
|
||||
|
||||
export type GmailAttachment = z.infer<typeof GmailAttachmentSchema>;
|
||||
|
|
@ -124,6 +128,14 @@ export const GmailThreadMessageSchema = z.object({
|
|||
bodyHeight: z.number().int().positive().optional(),
|
||||
attachments: z.array(GmailAttachmentSchema).optional(),
|
||||
messageIdHeader: z.string().optional(),
|
||||
// Set on the unsent draft message within a thread (used by the Drafts view).
|
||||
isDraft: z.boolean().optional(),
|
||||
// The draft's own stored In-Reply-To / References headers. Only set on
|
||||
// draft messages: a Drafts-view pseudo-thread contains just the draft, so
|
||||
// the composer can't rebuild the reply chain from thread messages and must
|
||||
// reuse what the draft already carries.
|
||||
inReplyToHeader: z.string().optional(),
|
||||
referencesHeader: z.string().optional(),
|
||||
});
|
||||
|
||||
export type GmailThreadMessage = z.infer<typeof GmailThreadMessageSchema>;
|
||||
|
|
@ -134,6 +146,9 @@ export const GmailThreadSchema = EmailBlockSchema.extend({
|
|||
unread: z.boolean().optional(),
|
||||
importance: z.enum(['important', 'other']).optional(),
|
||||
gmail_draft: z.string().optional(),
|
||||
// Gmail-side draft id, present on entries returned by the Drafts list so the
|
||||
// composer can update/delete that exact draft.
|
||||
draftId: z.string().optional(),
|
||||
messages: z.array(GmailThreadMessageSchema),
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,15 @@ export const BrowserStateSchema = z.object({
|
|||
tabs: z.array(BrowserTabStateSchema),
|
||||
});
|
||||
|
||||
// HTTP basic/proxy auth challenge raised by a page in the embedded browser.
|
||||
// Defined once here so main, preload, and renderer share one shape.
|
||||
export const HttpAuthRequestSchema = z.object({
|
||||
requestId: z.string(),
|
||||
host: z.string(),
|
||||
isProxy: z.boolean(),
|
||||
realm: z.string().optional(),
|
||||
});
|
||||
|
||||
export const BrowserPageElementSchema = z.object({
|
||||
index: z.number().int().positive(),
|
||||
tagName: z.string(),
|
||||
|
|
@ -134,6 +143,7 @@ export const BrowserControlResultSchema = z.object({
|
|||
|
||||
export type BrowserTabState = z.infer<typeof BrowserTabStateSchema>;
|
||||
export type BrowserState = z.infer<typeof BrowserStateSchema>;
|
||||
export type HttpAuthRequest = z.infer<typeof HttpAuthRequestSchema>;
|
||||
export type BrowserPageElement = z.infer<typeof BrowserPageElementSchema>;
|
||||
export type BrowserPageSnapshot = z.infer<typeof BrowserPageSnapshotSchema>;
|
||||
export type BrowserControlAction = z.infer<typeof BrowserControlActionSchema>;
|
||||
|
|
|
|||
54
apps/x/packages/shared/src/channels.ts
Normal file
54
apps/x/packages/shared/src/channels.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { z } from "zod";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mobile messaging channels (WhatsApp / Telegram bridges).
|
||||
//
|
||||
// Each channel links the user's OWN account/bot to the local app: WhatsApp
|
||||
// connects as a linked device (QR pairing), Telegram long-polls the user's
|
||||
// own bot token. There is no central relay — the desktop app must be running.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const WhatsAppChannelConfig = z.object({
|
||||
enabled: z.boolean(),
|
||||
// Extra sender phone numbers (digits only, no '+') allowed to drive the
|
||||
// bridge. The linked account itself (self-chat) is always allowed.
|
||||
allowFrom: z.array(z.string()),
|
||||
});
|
||||
|
||||
export const TelegramChannelConfig = z.object({
|
||||
enabled: z.boolean(),
|
||||
botToken: z.string(),
|
||||
// Telegram chat ids (numeric strings) allowed to drive the bridge.
|
||||
// Unknown senders are told their chat id so it can be added here.
|
||||
allowFrom: z.array(z.string()),
|
||||
});
|
||||
|
||||
export const ChannelsConfig = z.object({
|
||||
whatsapp: WhatsAppChannelConfig,
|
||||
telegram: TelegramChannelConfig,
|
||||
});
|
||||
|
||||
export const DEFAULT_CHANNELS_CONFIG: z.infer<typeof ChannelsConfig> = {
|
||||
whatsapp: { enabled: false, allowFrom: [] },
|
||||
telegram: { enabled: false, botToken: "", allowFrom: [] },
|
||||
};
|
||||
|
||||
export const WhatsAppChannelStatus = z.object({
|
||||
state: z.enum(["disabled", "starting", "qr", "connected", "error"]),
|
||||
// PNG data URL of the pairing QR (present while state === "qr").
|
||||
qrDataUrl: z.string().optional(),
|
||||
// Linked account phone number (digits) once connected.
|
||||
self: z.string().optional(),
|
||||
error: z.string().optional(),
|
||||
});
|
||||
|
||||
export const TelegramChannelStatus = z.object({
|
||||
state: z.enum(["disabled", "starting", "polling", "error"]),
|
||||
botUsername: z.string().optional(),
|
||||
error: z.string().optional(),
|
||||
});
|
||||
|
||||
export const ChannelsStatus = z.object({
|
||||
whatsapp: WhatsAppChannelStatus,
|
||||
telegram: TelegramChannelStatus,
|
||||
});
|
||||
|
|
@ -53,6 +53,11 @@ export const CodeRunEvent = z.discriminatedUnion("type", [
|
|||
priority: z.string().optional(),
|
||||
})),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("usage"),
|
||||
used: z.number().nonnegative(),
|
||||
size: z.number().positive(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("permission"),
|
||||
ask: PermissionAsk,
|
||||
|
|
|
|||
|
|
@ -19,5 +19,6 @@ export * as browserControl from './browser-control.js';
|
|||
export * as billing from './billing.js';
|
||||
export * as notificationSettings from './notification-settings.js';
|
||||
export * as codeSessions from './code-sessions.js';
|
||||
export * as channels from './channels.js';
|
||||
export * as rowboatApp from './rowboat-app.js';
|
||||
export { PrefixLogger };
|
||||
|
|
|
|||
|
|
@ -13,16 +13,19 @@ import {
|
|||
BackgroundTaskSummarySchema,
|
||||
TriggersSchema,
|
||||
} from './background-task.js';
|
||||
import { UserMessageContent } from './message.js';
|
||||
import { UserMessage, UserMessageContent } from './message.js';
|
||||
import { RequestedAgent, type TurnEvent } from './turns.js';
|
||||
import type { SessionBusEvent, SessionIndexEntry, SessionState } from './sessions.js';
|
||||
import { RowboatApiConfig } from './rowboat-account.js';
|
||||
import { ZListToolkitsResponse } from './composio.js';
|
||||
import { AppSummarySchema } from './rowboat-app.js';
|
||||
import { BrowserStateSchema } from './browser-control.js';
|
||||
import { BrowserStateSchema, HttpAuthRequestSchema } from './browser-control.js';
|
||||
import { BillingInfoSchema } from './billing.js';
|
||||
import { EmailBlockSchema, GmailThreadSchema } from './blocks.js';
|
||||
import { PermissionDecision, ApprovalPolicy, CodingAgent } from './code-mode.js';
|
||||
import { NotificationSettingsSchema } from './notification-settings.js';
|
||||
import { CodeProject, CodeSession, CodeSessionMode, CodeSessionStatus, GitRepoInfo, GitStatusFile, CodeAgentModelOptions } from './code-sessions.js';
|
||||
import { ChannelsConfig, ChannelsStatus } from './channels.js';
|
||||
|
||||
// ============================================================================
|
||||
// Runtime Validation Schemas (Single Source of Truth)
|
||||
|
|
@ -206,6 +209,56 @@ const ipcSchemas = {
|
|||
error: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
'gmail:saveDraft': {
|
||||
req: z.object({
|
||||
// Existing Gmail draft to update; omitted on first save (creates a new one).
|
||||
draftId: z.string().min(1).optional(),
|
||||
threadId: z.string().min(1).optional(),
|
||||
// Recipients may be blank for a draft (unlike a send).
|
||||
to: z.string().optional(),
|
||||
cc: z.string().optional(),
|
||||
bcc: z.string().optional(),
|
||||
subject: z.string(),
|
||||
bodyHtml: z.string(),
|
||||
bodyText: z.string(),
|
||||
inReplyTo: z.string().optional(),
|
||||
references: z.string().optional(),
|
||||
attachments: z
|
||||
.array(
|
||||
z.object({
|
||||
filename: z.string(),
|
||||
mimeType: z.string(),
|
||||
contentBase64: z.string(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
}),
|
||||
res: z.object({
|
||||
draftId: z.string().optional(),
|
||||
error: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
'gmail:deleteDraft': {
|
||||
req: z.object({ draftId: z.string().min(1) }),
|
||||
res: z.object({ ok: z.boolean(), error: z.string().optional() }),
|
||||
},
|
||||
'gmail:getDrafts': {
|
||||
req: z.object({}),
|
||||
res: z.object({
|
||||
threads: z.array(GmailThreadSchema),
|
||||
error: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
'gmail:search': {
|
||||
req: z.object({
|
||||
query: z.string(),
|
||||
limit: z.number().int().positive().optional(),
|
||||
}),
|
||||
res: z.object({
|
||||
threads: z.array(GmailThreadSchema),
|
||||
error: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
'gmail:getConnectionStatus': {
|
||||
req: z.object({}),
|
||||
res: z.object({
|
||||
|
|
@ -236,7 +289,15 @@ const ipcSchemas = {
|
|||
res: z.object({ ok: z.boolean(), error: z.string().optional() }),
|
||||
},
|
||||
'gmail:markThreadRead': {
|
||||
req: z.object({ threadId: z.string().min(1) }),
|
||||
req: z.object({ threadId: z.string().min(1), read: z.boolean().optional() }),
|
||||
res: z.object({ ok: z.boolean(), error: z.string().optional() }),
|
||||
},
|
||||
'gmail:downloadAttachment': {
|
||||
req: z.object({
|
||||
messageId: z.string().min(1),
|
||||
savedPath: z.string().min(1),
|
||||
attachmentId: z.string().optional(),
|
||||
}),
|
||||
res: z.object({ ok: z.boolean(), error: z.string().optional() }),
|
||||
},
|
||||
'gmail:saveMessageHeight': {
|
||||
|
|
@ -376,6 +437,90 @@ const ipcSchemas = {
|
|||
req: z.null(),
|
||||
res: z.null(),
|
||||
},
|
||||
// ── New runtime: sessions + turns (session-design.md) ────────────────────
|
||||
// Turn-mutating calls return quickly; the renderer follows progress through
|
||||
// the sessions:events feed and the shared reduceTurn reducer.
|
||||
'sessions:create': {
|
||||
req: z.object({ title: z.string().optional() }),
|
||||
res: z.object({ sessionId: z.string() }),
|
||||
},
|
||||
'sessions:list': {
|
||||
req: z.object({}),
|
||||
res: z.object({ sessions: z.array(z.custom<SessionIndexEntry>()) }),
|
||||
},
|
||||
'sessions:get': {
|
||||
req: z.object({ sessionId: z.string() }),
|
||||
res: z.custom<SessionState>(),
|
||||
},
|
||||
'sessions:getTurn': {
|
||||
// Events are strictly validated at the repository read; typed via
|
||||
// z.custom to avoid re-validating potentially large logs per IPC hop.
|
||||
req: z.object({ turnId: z.string() }),
|
||||
res: z.custom<{ turnId: string; events: Array<z.infer<typeof TurnEvent>> }>(),
|
||||
},
|
||||
'sessions:sendMessage': {
|
||||
req: z.object({
|
||||
sessionId: z.string(),
|
||||
input: UserMessage,
|
||||
config: z.object({
|
||||
agent: RequestedAgent,
|
||||
autoPermission: z.boolean().optional(),
|
||||
maxModelCalls: z.number().int().positive().optional(),
|
||||
}),
|
||||
}),
|
||||
res: z.object({ turnId: z.string() }),
|
||||
},
|
||||
'sessions:respondToPermission': {
|
||||
req: z.object({
|
||||
turnId: z.string(),
|
||||
toolCallId: z.string(),
|
||||
decision: z.enum(['allow', 'deny']),
|
||||
metadata: z.json().optional(),
|
||||
}),
|
||||
res: z.object({ success: z.literal(true) }),
|
||||
},
|
||||
'sessions:respondToAskHuman': {
|
||||
req: z.object({
|
||||
turnId: z.string(),
|
||||
toolCallId: z.string(),
|
||||
answer: z.string(),
|
||||
}),
|
||||
res: z.object({ success: z.literal(true) }),
|
||||
},
|
||||
'sessions:stopTurn': {
|
||||
req: z.object({
|
||||
turnId: z.string(),
|
||||
reason: z.string().optional(),
|
||||
}),
|
||||
res: z.object({ success: z.literal(true) }),
|
||||
},
|
||||
'sessions:resumeTurn': {
|
||||
req: z.object({ sessionId: z.string() }),
|
||||
res: z.object({ success: z.literal(true) }),
|
||||
},
|
||||
'sessions:setTitle': {
|
||||
req: z.object({ sessionId: z.string(), title: z.string() }),
|
||||
res: z.object({ success: z.literal(true) }),
|
||||
},
|
||||
'sessions:downloadLog': {
|
||||
// Concatenates the session's turn logs into one JSONL for debugging.
|
||||
req: z.object({ sessionId: z.string() }),
|
||||
res: z.object({
|
||||
success: z.boolean(),
|
||||
error: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
'sessions:delete': {
|
||||
req: z.object({ sessionId: z.string() }),
|
||||
res: z.object({ success: z.literal(true) }),
|
||||
},
|
||||
'sessions:events': {
|
||||
// Typed via z.custom so the renderer's `on` handler is typed without
|
||||
// runtime validation (the broadcast path bypasses preload validation,
|
||||
// like runs:events).
|
||||
req: z.custom<SessionBusEvent>(),
|
||||
res: z.null(),
|
||||
},
|
||||
'services:events': {
|
||||
req: ServiceEvent,
|
||||
res: z.null(),
|
||||
|
|
@ -506,6 +651,12 @@ const ipcSchemas = {
|
|||
}),
|
||||
res: z.null(),
|
||||
},
|
||||
// Bring the main app window to the foreground (e.g. the assistant navigated
|
||||
// the UI during a call while the user was in another app).
|
||||
'app:focusMainWindow': {
|
||||
req: z.null(),
|
||||
res: z.object({}),
|
||||
},
|
||||
'app:takeMeetingNotes': {
|
||||
req: z.object({
|
||||
// Pass the raw calendar event JSON through; renderer adapts to its existing flow.
|
||||
|
|
@ -807,6 +958,28 @@ const ipcSchemas = {
|
|||
success: z.literal(true),
|
||||
}),
|
||||
},
|
||||
// ── Mobile channels (WhatsApp / Telegram bridge) ─────────────
|
||||
'channels:getConfig': {
|
||||
req: z.null(),
|
||||
res: ChannelsConfig,
|
||||
},
|
||||
'channels:setConfig': {
|
||||
req: ChannelsConfig,
|
||||
res: z.object({ success: z.literal(true) }),
|
||||
},
|
||||
'channels:getStatus': {
|
||||
req: z.null(),
|
||||
res: ChannelsStatus,
|
||||
},
|
||||
'channels:whatsappLogout': {
|
||||
req: z.null(),
|
||||
res: z.object({ success: z.literal(true) }),
|
||||
},
|
||||
// Push: main → renderer status updates (QR rotation, connect/disconnect).
|
||||
'channels:status': {
|
||||
req: ChannelsStatus,
|
||||
res: z.null(),
|
||||
},
|
||||
'slack:getConfig': {
|
||||
req: z.null(),
|
||||
res: z.object({
|
||||
|
|
@ -1276,6 +1449,34 @@ const ipcSchemas = {
|
|||
mimeType: z.string(),
|
||||
}),
|
||||
},
|
||||
// Streaming TTS: main starts the synthesis and pushes audio chunks over
|
||||
// 'voice:tts-chunk' as they arrive, so playback can begin on the first
|
||||
// chunk instead of after the full body (~0.5-1s earlier first-audio).
|
||||
'voice:synthesizeStreamStart': {
|
||||
req: z.object({
|
||||
requestId: z.string(),
|
||||
text: z.string(),
|
||||
}),
|
||||
res: z.object({
|
||||
ok: z.boolean(),
|
||||
error: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
'voice:synthesizeStreamCancel': {
|
||||
req: z.object({ requestId: z.string() }),
|
||||
res: z.object({}),
|
||||
},
|
||||
// Push channel: main → renderer with streaming TTS audio. `done: true`
|
||||
// (possibly with a final chunk) ends the stream; `error` aborts it.
|
||||
'voice:tts-chunk': {
|
||||
req: z.object({
|
||||
requestId: z.string(),
|
||||
chunkBase64: z.string().optional(),
|
||||
done: z.boolean(),
|
||||
error: z.string().optional(),
|
||||
}),
|
||||
res: z.null(),
|
||||
},
|
||||
// Ensures the OS-level microphone permission is settled before capturing.
|
||||
// On first-ever use (macOS) the permission is 'not-determined'; resolving
|
||||
// the native prompt up front prevents the in-flight getUserMedia from
|
||||
|
|
@ -1286,6 +1487,79 @@ const ipcSchemas = {
|
|||
granted: z.boolean(),
|
||||
}),
|
||||
},
|
||||
// Same as ensureMicAccess but for the camera — settles the macOS TCC
|
||||
// permission before video mode calls getUserMedia({ video: true }).
|
||||
'voice:ensureCameraAccess': {
|
||||
req: z.null(),
|
||||
res: z.object({
|
||||
granted: z.boolean(),
|
||||
}),
|
||||
},
|
||||
// Video-mode popout: show/hide the small always-on-top window (user +
|
||||
// mascot tiles) that floats over everything for the duration of a screen
|
||||
// share, Meet-style.
|
||||
'video:setPopout': {
|
||||
req: z.object({ show: z.boolean() }),
|
||||
res: z.object({}),
|
||||
},
|
||||
// Main-window renderer pushes the current call state; the main process
|
||||
// caches it and relays to the popout window (replayed on popout load).
|
||||
'video:popoutState': {
|
||||
req: z.object({
|
||||
ttsState: z.enum(['idle', 'synthesizing', 'speaking']),
|
||||
status: z.enum(['listening', 'thinking', 'speaking']).nullable(),
|
||||
cameraOn: z.boolean(),
|
||||
screenSharing: z.boolean(),
|
||||
// Live transcript of the in-progress utterance.
|
||||
interimText: z.string().nullable(),
|
||||
}),
|
||||
res: z.object({}),
|
||||
},
|
||||
// Popout window → fetch the latest cached call state on mount. The
|
||||
// did-finish-load replay can race the React listener registration, and the
|
||||
// popout must never guess (a wrong camera-on default flashes the user's
|
||||
// video before the first state push corrects it).
|
||||
'video:getPopoutState': {
|
||||
req: z.null(),
|
||||
res: z.object({
|
||||
state: z
|
||||
.object({
|
||||
ttsState: z.enum(['idle', 'synthesizing', 'speaking']),
|
||||
status: z.enum(['listening', 'thinking', 'speaking']).nullable(),
|
||||
cameraOn: z.boolean(),
|
||||
screenSharing: z.boolean(),
|
||||
interimText: z.string().nullable(),
|
||||
})
|
||||
.nullable(),
|
||||
}),
|
||||
},
|
||||
// Popout control bar → main process → relayed to the app window, which
|
||||
// executes the action on the live call. 'expand' additionally focuses the
|
||||
// main app window (handled in the main process).
|
||||
'video:popoutAction': {
|
||||
req: z.object({
|
||||
action: z.enum(['toggle-camera', 'toggle-share', 'stop-speaking', 'end-call', 'expand']),
|
||||
}),
|
||||
res: z.object({}),
|
||||
},
|
||||
// Push channel: main → popout window with the latest call state.
|
||||
'video:popout-state': {
|
||||
req: z.object({
|
||||
ttsState: z.enum(['idle', 'synthesizing', 'speaking']),
|
||||
status: z.enum(['listening', 'thinking', 'speaking']).nullable(),
|
||||
cameraOn: z.boolean(),
|
||||
screenSharing: z.boolean(),
|
||||
interimText: z.string().nullable(),
|
||||
}),
|
||||
res: z.null(),
|
||||
},
|
||||
// Push channel: main → app window with a popout control-bar action.
|
||||
'video:popout-action': {
|
||||
req: z.object({
|
||||
action: z.enum(['toggle-camera', 'toggle-share', 'stop-speaking', 'end-call', 'expand']),
|
||||
}),
|
||||
res: z.null(),
|
||||
},
|
||||
'meeting:checkScreenPermission': {
|
||||
req: z.null(),
|
||||
res: z.object({
|
||||
|
|
@ -1306,6 +1580,47 @@ const ipcSchemas = {
|
|||
notes: z.string(),
|
||||
}),
|
||||
},
|
||||
// Resolve a meeting's attendees against the knowledge base — returns each
|
||||
// attendee's existing person note (or null). Deterministic, no LLM; powers
|
||||
// the ambient "Next up" prep card.
|
||||
'meeting-prep:resolve': {
|
||||
req: z.object({
|
||||
attendees: z.array(z.object({
|
||||
email: z.string().optional(),
|
||||
displayName: z.string().optional(),
|
||||
self: z.boolean().optional(),
|
||||
})),
|
||||
// When provided, the response includes any pre-generated prep note for
|
||||
// this calendar event (matched by the eventId stamped in frontmatter).
|
||||
eventId: z.string().optional(),
|
||||
}),
|
||||
res: z.object({
|
||||
attendees: z.array(z.object({
|
||||
label: z.string(),
|
||||
email: z.string().optional(),
|
||||
displayName: z.string().optional(),
|
||||
note: z.object({
|
||||
path: z.string(),
|
||||
name: z.string(),
|
||||
role: z.string().optional(),
|
||||
organization: z.string().optional(),
|
||||
markdown: z.string(),
|
||||
}).nullable(),
|
||||
})),
|
||||
organizations: z.array(z.object({
|
||||
path: z.string(),
|
||||
name: z.string(),
|
||||
markdown: z.string(),
|
||||
})),
|
||||
// The pre-generated prep note (brief + path), if one exists for eventId.
|
||||
prepNote: z.object({
|
||||
path: z.string(),
|
||||
brief: z.string(),
|
||||
}).nullable(),
|
||||
matchedCount: z.number().int().nonnegative(),
|
||||
unmatchedCount: z.number().int().nonnegative(),
|
||||
}),
|
||||
},
|
||||
// Inline task schedule classification
|
||||
'export:note': {
|
||||
req: z.object({
|
||||
|
|
@ -1598,6 +1913,30 @@ const ipcSchemas = {
|
|||
req: BrowserStateSchema,
|
||||
res: z.null(),
|
||||
},
|
||||
// HTTP basic/proxy auth challenge from a page in the embedded browser
|
||||
// (main → renderer push). The renderer shows a credential prompt and
|
||||
// answers via browser:httpAuthResponse.
|
||||
'browser:httpAuthRequest': {
|
||||
req: HttpAuthRequestSchema,
|
||||
res: z.null(),
|
||||
},
|
||||
// Main → renderer: a pending auth challenge was resolved without the
|
||||
// renderer answering (timed out, or its tab/window was destroyed), so the
|
||||
// renderer must drop the corresponding dialog from its queue.
|
||||
'browser:httpAuthResolved': {
|
||||
req: z.object({ requestId: z.string() }),
|
||||
res: z.null(),
|
||||
},
|
||||
// Renderer → main. Omit username to cancel the challenge; provide it (even
|
||||
// empty, for token-style auth) to submit credentials.
|
||||
'browser:httpAuthResponse': {
|
||||
req: z.object({
|
||||
requestId: z.string(),
|
||||
username: z.string().optional(),
|
||||
password: z.string().optional(),
|
||||
}),
|
||||
res: z.object({ ok: z.boolean() }),
|
||||
},
|
||||
// Billing channels
|
||||
'billing:getInfo': {
|
||||
req: z.null(),
|
||||
|
|
|
|||
|
|
@ -44,8 +44,20 @@ export const UserAttachmentPart = z.object({
|
|||
lineNumber: z.number().int().min(1).optional(), // 1-indexed line in source file (for editor-context references)
|
||||
});
|
||||
|
||||
// Any single part of a user message (text or attachment)
|
||||
export const UserContentPart = z.union([UserTextPart, UserAttachmentPart]);
|
||||
// An inline image within a content array (e.g. a live webcam frame from
|
||||
// video mode). Unlike attachments, image parts carry their data inline as
|
||||
// base64 and are sent to the model as real multimodal image parts rather
|
||||
// than a file-path reference.
|
||||
export const UserImagePart = z.object({
|
||||
type: z.literal("image"),
|
||||
data: z.string(), // base64-encoded image bytes (no data: prefix)
|
||||
mediaType: z.string(), // MIME type ("image/jpeg")
|
||||
source: z.enum(["camera", "screen"]).optional(),
|
||||
capturedAt: z.string().optional(), // ISO timestamp of capture
|
||||
});
|
||||
|
||||
// Any single part of a user message (text, attachment, or inline image)
|
||||
export const UserContentPart = z.union([UserTextPart, UserAttachmentPart, UserImagePart]);
|
||||
|
||||
// Named type for user message content — used everywhere instead of repeating the union
|
||||
export const UserMessageContent = z.union([z.string(), z.array(UserContentPart)]);
|
||||
|
|
|
|||
201
apps/x/packages/shared/src/sessions.test.ts
Normal file
201
apps/x/packages/shared/src/sessions.test.ts
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
SessionCorruptionError,
|
||||
SessionCreated,
|
||||
SessionEvent,
|
||||
SessionTurnAppended,
|
||||
reduceSession,
|
||||
sessionIndexEntry,
|
||||
} from "./sessions.js";
|
||||
|
||||
type SEvent = z.infer<typeof SessionEvent>;
|
||||
|
||||
const SESSION_ID = "2026-07-02T09-00-00Z-0000042-000";
|
||||
const MODEL = { provider: "openai", model: "gpt-test" };
|
||||
|
||||
function sessionCreated(
|
||||
overrides: Partial<z.infer<typeof SessionCreated>> = {},
|
||||
): z.infer<typeof SessionCreated> {
|
||||
return {
|
||||
type: "session_created",
|
||||
schemaVersion: 1,
|
||||
sessionId: SESSION_ID,
|
||||
ts: "2026-07-02T09:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function turnAppended(
|
||||
sessionSeq: number,
|
||||
turnId: string,
|
||||
overrides: Partial<z.infer<typeof SessionTurnAppended>> = {},
|
||||
): z.infer<typeof SessionTurnAppended> {
|
||||
return {
|
||||
type: "turn_appended",
|
||||
sessionId: SESSION_ID,
|
||||
ts: `2026-07-02T09:0${sessionSeq}:00Z`,
|
||||
turnId,
|
||||
sessionSeq,
|
||||
agentId: "copilot",
|
||||
model: MODEL,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function titleChanged(title: string, ts = "2026-07-02T09:05:00Z") {
|
||||
return {
|
||||
type: "title_changed" as const,
|
||||
sessionId: SESSION_ID,
|
||||
ts,
|
||||
title,
|
||||
};
|
||||
}
|
||||
|
||||
function expectCorruption(events: SEvent[], match: string | RegExp): void {
|
||||
expect(() => reduceSession(events)).toThrowError(SessionCorruptionError);
|
||||
expect(() => reduceSession(events)).toThrowError(match);
|
||||
}
|
||||
|
||||
describe("schemas", () => {
|
||||
it("session events round-trip through the SessionEvent schema", () => {
|
||||
const events: SEvent[] = [
|
||||
sessionCreated({ title: "Hello" }),
|
||||
turnAppended(1, "turn-1"),
|
||||
titleChanged("Renamed"),
|
||||
];
|
||||
for (const event of events) {
|
||||
expect(SessionEvent.parse(event)).toEqual(event);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects non-positive sessionSeq at the schema level", () => {
|
||||
expect(() => SessionEvent.parse(turnAppended(0, "turn-0"))).toThrowError();
|
||||
});
|
||||
});
|
||||
|
||||
describe("reduceSession", () => {
|
||||
it("reduces a created-only log", () => {
|
||||
const state = reduceSession([sessionCreated()]);
|
||||
expect(state.definition.sessionId).toBe(SESSION_ID);
|
||||
expect(state.turns).toEqual([]);
|
||||
expect(state.latestTurnId).toBeUndefined();
|
||||
expect(state.title).toBeUndefined();
|
||||
expect(state.createdAt).toBe("2026-07-02T09:00:00Z");
|
||||
expect(state.updatedAt).toBe("2026-07-02T09:00:00Z");
|
||||
});
|
||||
|
||||
it("folds turns in order with denormalized metadata", () => {
|
||||
const state = reduceSession([
|
||||
sessionCreated(),
|
||||
turnAppended(1, "turn-1"),
|
||||
turnAppended(2, "turn-2", { agentId: "researcher", model: { provider: "anthropic", model: "claude-x" } }),
|
||||
]);
|
||||
expect(state.turns).toHaveLength(2);
|
||||
expect(state.turns[0]).toMatchObject({ turnId: "turn-1", sessionSeq: 1, agentId: "copilot" });
|
||||
expect(state.turns[1]).toMatchObject({
|
||||
turnId: "turn-2",
|
||||
sessionSeq: 2,
|
||||
agentId: "researcher",
|
||||
model: { provider: "anthropic", model: "claude-x" },
|
||||
});
|
||||
expect(state.latestTurnId).toBe("turn-2");
|
||||
expect(state.updatedAt).toBe("2026-07-02T09:02:00Z");
|
||||
});
|
||||
|
||||
it("folds titles: creation default then last change wins", () => {
|
||||
const withDefault = reduceSession([sessionCreated({ title: "First message…" })]);
|
||||
expect(withDefault.title).toBe("First message…");
|
||||
|
||||
const renamed = reduceSession([
|
||||
sessionCreated({ title: "First message…" }),
|
||||
titleChanged("Better title"),
|
||||
titleChanged("Best title", "2026-07-02T09:06:00Z"),
|
||||
]);
|
||||
expect(renamed.title).toBe("Best title");
|
||||
expect(renamed.updatedAt).toBe("2026-07-02T09:06:00Z");
|
||||
});
|
||||
|
||||
it("rejects an empty log", () => {
|
||||
expectCorruption([], /empty/);
|
||||
});
|
||||
|
||||
it("rejects a log not starting with session_created", () => {
|
||||
expectCorruption([turnAppended(1, "turn-1")], /first event must be session_created/);
|
||||
});
|
||||
|
||||
it("rejects duplicate session_created", () => {
|
||||
expectCorruption([sessionCreated(), sessionCreated()], /duplicate session_created/);
|
||||
});
|
||||
|
||||
it("rejects mismatched session ids", () => {
|
||||
expectCorruption(
|
||||
[sessionCreated(), { ...turnAppended(1, "turn-1"), sessionId: "other" }],
|
||||
/does not match session/,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects unsupported schema versions", () => {
|
||||
const bad = { ...sessionCreated(), schemaVersion: 2 } as unknown as SEvent;
|
||||
expectCorruption([bad], /unsupported session schema version/);
|
||||
});
|
||||
|
||||
it("rejects unknown event types", () => {
|
||||
const bad = { type: "wat", sessionId: SESSION_ID, ts: "t" } as unknown as SEvent;
|
||||
expectCorruption([sessionCreated(), bad], /unknown session event type/);
|
||||
});
|
||||
|
||||
it("rejects a first turn not at sessionSeq 1", () => {
|
||||
expectCorruption([sessionCreated(), turnAppended(2, "turn-2")], /out of order; expected 1/);
|
||||
});
|
||||
|
||||
it("rejects gapped sessionSeq", () => {
|
||||
expectCorruption(
|
||||
[sessionCreated(), turnAppended(1, "turn-1"), turnAppended(3, "turn-3")],
|
||||
/out of order; expected 2/,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects duplicate sessionSeq", () => {
|
||||
expectCorruption(
|
||||
[sessionCreated(), turnAppended(1, "turn-1"), turnAppended(1, "turn-1b")],
|
||||
/out of order; expected 2/,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects duplicate turn ids", () => {
|
||||
expectCorruption(
|
||||
[sessionCreated(), turnAppended(1, "turn-1"), turnAppended(2, "turn-1")],
|
||||
/duplicate turnId/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sessionIndexEntry", () => {
|
||||
it("projects an entry with last-turn metadata and the provided status", () => {
|
||||
const state = reduceSession([
|
||||
sessionCreated({ title: "T" }),
|
||||
turnAppended(1, "turn-1"),
|
||||
turnAppended(2, "turn-2", { agentId: "researcher" }),
|
||||
]);
|
||||
expect(sessionIndexEntry(state, "suspended")).toEqual({
|
||||
sessionId: SESSION_ID,
|
||||
title: "T",
|
||||
createdAt: "2026-07-02T09:00:00Z",
|
||||
updatedAt: "2026-07-02T09:02:00Z",
|
||||
turnCount: 2,
|
||||
lastAgentId: "researcher",
|
||||
lastModel: MODEL,
|
||||
latestTurnId: "turn-2",
|
||||
latestTurnStatus: "suspended",
|
||||
});
|
||||
});
|
||||
|
||||
it("projects an empty session with status none", () => {
|
||||
const state = reduceSession([sessionCreated()]);
|
||||
const entry = sessionIndexEntry(state, "none");
|
||||
expect(entry.turnCount).toBe(0);
|
||||
expect(entry.lastAgentId).toBeUndefined();
|
||||
expect(entry.latestTurnStatus).toBe("none");
|
||||
});
|
||||
});
|
||||
206
apps/x/packages/shared/src/sessions.ts
Normal file
206
apps/x/packages/shared/src/sessions.ts
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
import { z } from "zod";
|
||||
import {
|
||||
ModelDescriptor,
|
||||
type TurnStatus,
|
||||
type TurnStreamEvent,
|
||||
} from "./turns.js";
|
||||
|
||||
// Durable session contract for the session layer (see
|
||||
// packages/core/docs/session-design.md). A session is an append-only chain
|
||||
// of turn references plus presentation metadata; conversation content lives
|
||||
// exclusively in turn files. Pure module shared by core and renderer.
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Durable events
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const SessionCreated = z.object({
|
||||
type: z.literal("session_created"),
|
||||
schemaVersion: z.literal(1),
|
||||
sessionId: z.string(),
|
||||
ts: z.string(),
|
||||
title: z.string().optional(),
|
||||
});
|
||||
|
||||
// agentId/model are denormalized from the turn so the session index can fold
|
||||
// without opening turn files; the turn file stays authoritative.
|
||||
export const SessionTurnAppended = z.object({
|
||||
type: z.literal("turn_appended"),
|
||||
sessionId: z.string(),
|
||||
ts: z.string(),
|
||||
turnId: z.string(),
|
||||
sessionSeq: z.number().int().positive(),
|
||||
agentId: z.string(),
|
||||
model: ModelDescriptor,
|
||||
});
|
||||
|
||||
export const SessionTitleChanged = z.object({
|
||||
type: z.literal("title_changed"),
|
||||
sessionId: z.string(),
|
||||
ts: z.string(),
|
||||
title: z.string(),
|
||||
});
|
||||
|
||||
export const SessionEvent = z.discriminatedUnion("type", [
|
||||
SessionCreated,
|
||||
SessionTurnAppended,
|
||||
SessionTitleChanged,
|
||||
]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Derived session state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class SessionCorruptionError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "SessionCorruptionError";
|
||||
}
|
||||
}
|
||||
|
||||
export interface SessionTurnRef {
|
||||
turnId: string;
|
||||
sessionSeq: number;
|
||||
agentId: string;
|
||||
model: z.infer<typeof ModelDescriptor>;
|
||||
ts: string;
|
||||
}
|
||||
|
||||
export interface SessionState {
|
||||
definition: z.infer<typeof SessionCreated>;
|
||||
title?: string;
|
||||
turns: SessionTurnRef[];
|
||||
latestTurnId?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
function fail(message: string): never {
|
||||
throw new SessionCorruptionError(message);
|
||||
}
|
||||
|
||||
export function reduceSession(
|
||||
events: Array<z.infer<typeof SessionEvent>>,
|
||||
): SessionState {
|
||||
if (events.length === 0) {
|
||||
fail("session log is empty");
|
||||
}
|
||||
const [first, ...rest] = events;
|
||||
if (first.type !== "session_created") {
|
||||
fail(`first event must be session_created, got ${first.type}`);
|
||||
}
|
||||
if (first.schemaVersion !== 1) {
|
||||
fail(`unsupported session schema version: ${String(first.schemaVersion)}`);
|
||||
}
|
||||
|
||||
const state: SessionState = {
|
||||
definition: first,
|
||||
title: first.title,
|
||||
turns: [],
|
||||
createdAt: first.ts,
|
||||
updatedAt: first.ts,
|
||||
};
|
||||
|
||||
for (const event of rest) {
|
||||
if (event.sessionId !== first.sessionId) {
|
||||
fail(
|
||||
`event sessionId ${event.sessionId} does not match session ${first.sessionId}`,
|
||||
);
|
||||
}
|
||||
switch (event.type) {
|
||||
case "session_created":
|
||||
fail("duplicate session_created event");
|
||||
break;
|
||||
case "turn_appended": {
|
||||
const expectedSeq = state.turns.length + 1;
|
||||
if (event.sessionSeq !== expectedSeq) {
|
||||
fail(
|
||||
`sessionSeq ${event.sessionSeq} out of order; expected ${expectedSeq}`,
|
||||
);
|
||||
}
|
||||
if (state.turns.some((t) => t.turnId === event.turnId)) {
|
||||
fail(`duplicate turnId in session: ${event.turnId}`);
|
||||
}
|
||||
state.turns.push({
|
||||
turnId: event.turnId,
|
||||
sessionSeq: event.sessionSeq,
|
||||
agentId: event.agentId,
|
||||
model: event.model,
|
||||
ts: event.ts,
|
||||
});
|
||||
state.latestTurnId = event.turnId;
|
||||
break;
|
||||
}
|
||||
case "title_changed":
|
||||
state.title = event.title;
|
||||
break;
|
||||
default: {
|
||||
const unknown: never = event;
|
||||
fail(
|
||||
`unknown session event type: ${(unknown as { type: string }).type}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
state.updatedAt = event.ts;
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session index (in-memory projection; never a source of truth)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// "none" = the session has no turns yet. The remaining values are the latest
|
||||
// turn's derived status (deriveTurnStatus in turns.ts).
|
||||
export type SessionLatestTurnStatus = "none" | TurnStatus;
|
||||
|
||||
export interface SessionIndexEntry {
|
||||
sessionId: string;
|
||||
title?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
turnCount: number;
|
||||
lastAgentId?: string;
|
||||
lastModel?: z.infer<typeof ModelDescriptor>;
|
||||
latestTurnId?: string;
|
||||
latestTurnStatus: SessionLatestTurnStatus;
|
||||
// Set when the session (or its latest turn) failed to load/validate; the
|
||||
// entry is surfaced in an errored state instead of aborting the startup
|
||||
// scan.
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// What the renderer's single feed consumer receives over IPC: live turn
|
||||
// stream events (durable events + ephemeral deltas) and index updates.
|
||||
// entry: null signals deletion.
|
||||
export type SessionBusEvent =
|
||||
| {
|
||||
kind: "turn-event";
|
||||
sessionId: string;
|
||||
turnId: string;
|
||||
event: TurnStreamEvent;
|
||||
}
|
||||
| {
|
||||
kind: "index-changed";
|
||||
sessionId: string;
|
||||
entry: SessionIndexEntry | null;
|
||||
};
|
||||
|
||||
export function sessionIndexEntry(
|
||||
state: SessionState,
|
||||
latestTurnStatus: SessionLatestTurnStatus,
|
||||
): SessionIndexEntry {
|
||||
const lastTurn = state.turns[state.turns.length - 1];
|
||||
return {
|
||||
sessionId: state.definition.sessionId,
|
||||
title: state.title,
|
||||
createdAt: state.createdAt,
|
||||
updatedAt: state.updatedAt,
|
||||
turnCount: state.turns.length,
|
||||
lastAgentId: lastTurn?.agentId,
|
||||
lastModel: lastTurn?.model,
|
||||
latestTurnId: state.latestTurnId,
|
||||
latestTurnStatus,
|
||||
};
|
||||
}
|
||||
1421
apps/x/packages/shared/src/turns.test.ts
Normal file
1421
apps/x/packages/shared/src/turns.test.ts
Normal file
File diff suppressed because it is too large
Load diff
1111
apps/x/packages/shared/src/turns.ts
Normal file
1111
apps/x/packages/shared/src/turns.ts
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue