-
Notifications
You must be signed in to change notification settings - Fork 5.5k
Effect persistence layer #83
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
550069f
e691b4a
13dea39
a976a82
56066ce
5d75a58
d6e0cd8
bce0df0
34d456b
85af69c
9112818
aa2d6e7
1db852a
40d49f1
2cf729e
ee2211b
ba0a08f
5ba2218
10d6321
cdea401
72c33f7
40f9885
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| import path from "node:path"; | ||
|
|
||
| export interface PersistenceConfig { | ||
| dbPath: string; | ||
| legacyProjectsJsonPath?: string; | ||
| } | ||
|
|
||
| export function resolvePersistenceConfig(input: PersistenceConfig): PersistenceConfig { | ||
| const resolved: PersistenceConfig = { | ||
| dbPath: path.resolve(input.dbPath), | ||
| }; | ||
| if (input.legacyProjectsJsonPath) { | ||
| resolved.legacyProjectsJsonPath = path.resolve(input.legacyProjectsJsonPath); | ||
| } | ||
| return resolved; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| import { | ||
| type AppSettings, | ||
| type AppSettingsUpdateInput, | ||
| appSettingsSchema, | ||
| appSettingsUpdateInputSchema, | ||
| } from "@t3tools/contracts"; | ||
|
|
||
| export function resolveAppSettings(metadataValue: unknown): AppSettings { | ||
| const parsed = appSettingsSchema.safeParse(metadataValue); | ||
| if (parsed.success) { | ||
| return parsed.data; | ||
| } | ||
| return appSettingsSchema.parse({}); | ||
| } | ||
|
|
||
| export function buildUpdatedAppSettings( | ||
| current: AppSettings, | ||
| rawPatch: AppSettingsUpdateInput, | ||
| ): AppSettings { | ||
| const patch = appSettingsUpdateInputSchema.parse(rawPatch); | ||
| return appSettingsSchema.parse({ | ||
| ...current, | ||
| ...patch, | ||
| }); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,39 @@ | ||
| import { | ||
| type ProviderSendTurnInput, | ||
| type StateMessage, | ||
| stateMessageSchema, | ||
| } from "@t3tools/contracts"; | ||
|
|
||
| export function messageDocId(threadId: string, messageId: string): string { | ||
| return `message:${threadId}:${messageId}`; | ||
| } | ||
|
|
||
| export function buildUserTurnMessage(input: { | ||
| turn: ProviderSendTurnInput; | ||
| threadId: string; | ||
| messageId: string; | ||
| createdAt: string; | ||
| }): StateMessage { | ||
| const text = input.turn.clientMessageText ?? input.turn.input ?? ""; | ||
| const inputAttachments = input.turn.attachments ?? []; | ||
| const attachments = | ||
| inputAttachments.length > 0 | ||
| ? inputAttachments.map((attachment, index) => ({ | ||
| type: "image" as const, | ||
| id: `${input.messageId}:image:${index + 1}`, | ||
| name: attachment.name, | ||
| mimeType: attachment.mimeType, | ||
| sizeBytes: attachment.sizeBytes, | ||
| })) | ||
| : undefined; | ||
| return stateMessageSchema.parse({ | ||
| id: input.messageId, | ||
| threadId: input.threadId, | ||
| role: "user", | ||
| text, | ||
| ...(attachments ? { attachments } : {}), | ||
| createdAt: input.createdAt, | ||
| updatedAt: input.createdAt, | ||
| streaming: false, | ||
| }); | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,24 @@ | ||||||||||||||||||||||||||||||||||||||
| import fs from "node:fs"; | ||||||||||||||||||||||||||||||||||||||
| import path from "node:path"; | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| export function normalizeCwd(rawCwd: string): string { | ||||||||||||||||||||||||||||||||||||||
| const resolved = path.resolve(rawCwd.trim()); | ||||||||||||||||||||||||||||||||||||||
| const normalized = path.normalize(resolved); | ||||||||||||||||||||||||||||||||||||||
| if (process.platform === "win32") { | ||||||||||||||||||||||||||||||||||||||
| return normalized.toLowerCase(); | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
| return normalized; | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| export function isDirectory(cwd: string): boolean { | ||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||
| return fs.statSync(cwd).isDirectory(); | ||||||||||||||||||||||||||||||||||||||
| } catch { | ||||||||||||||||||||||||||||||||||||||
| return false; | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| export function inferProjectName(cwd: string): string { | ||||||||||||||||||||||||||||||||||||||
| const name = path.basename(cwd); | ||||||||||||||||||||||||||||||||||||||
| return name.length > 0 ? name : "project"; | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+21
to
+24
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟢 Low
Suggested change
🤖 Prompt for AI |
||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,42 @@ | ||
| import type { ProviderEvent } from "@t3tools/contracts"; | ||
|
|
||
| export function asObject(value: unknown): Record<string, unknown> | undefined { | ||
| if (!value || typeof value !== "object") { | ||
| return undefined; | ||
| } | ||
| return value as Record<string, unknown>; | ||
| } | ||
|
|
||
| export function asString(value: unknown): string | undefined { | ||
| return typeof value === "string" && value.length > 0 ? value : undefined; | ||
| } | ||
|
|
||
| export function normalizeProviderItemType(value: string | undefined): string | undefined { | ||
| if (!value) return undefined; | ||
| const normalized = value.trim(); | ||
| if (normalized.length === 0) return undefined; | ||
| return normalized.replace(/[_\-\s]+/g, "").toLowerCase(); | ||
| } | ||
|
|
||
| export function parseThreadIdFromEventPayload(payload: unknown): string | null { | ||
| const record = asObject(payload); | ||
| const threadId = asString(record?.threadId) ?? asString(record?.thread_id); | ||
| if (threadId) return threadId; | ||
| const thread = asObject(record?.thread); | ||
| return asString(thread?.id) ?? null; | ||
| } | ||
|
|
||
| export function parseTurnIdFromEvent(event: ProviderEvent): string | null { | ||
| if (event.turnId) return event.turnId; | ||
| const payload = asObject(event.payload); | ||
| const turn = asObject(payload?.turn); | ||
| return asString(turn?.id) ?? null; | ||
| } | ||
|
|
||
| export function parseAssistantItemId(event: ProviderEvent): string | null { | ||
| const payload = asObject(event.payload); | ||
| const item = asObject(payload?.item); | ||
| const itemType = asString(item?.type); | ||
| if (itemType !== "agentMessage") return null; | ||
| return asString(item?.id) ?? event.itemId ?? null; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,41 @@ | ||
| import { | ||
| type StateBootstrapResult, | ||
| type StateBootstrapThread, | ||
| type StateCatchUpResult, | ||
| type StateEvent, | ||
| type StateListMessagesResult, | ||
| type StateMessage, | ||
| type StateProject, | ||
| stateBootstrapResultSchema, | ||
| stateCatchUpResultSchema, | ||
| stateListMessagesResultSchema, | ||
| } from "@t3tools/contracts"; | ||
|
|
||
| export function buildStateBootstrapResult(input: { | ||
| projects: StateProject[]; | ||
| threads: StateBootstrapThread[]; | ||
| lastStateSeq: number; | ||
| }): StateBootstrapResult { | ||
| return stateBootstrapResultSchema.parse(input); | ||
| } | ||
|
|
||
| export function buildStateCatchUpResult(input: { | ||
| events: StateEvent[]; | ||
| lastStateSeq: number; | ||
| }): StateCatchUpResult { | ||
| return stateCatchUpResultSchema.parse(input); | ||
| } | ||
|
|
||
| export function buildStateListMessagesResult(input: { | ||
| messages: StateMessage[]; | ||
| total: number; | ||
| offset: number; | ||
| pageSize: number; | ||
| }): StateListMessagesResult { | ||
| const nextOffset = input.offset + input.pageSize; | ||
| return stateListMessagesResultSchema.parse({ | ||
| messages: input.messages, | ||
| total: input.total, | ||
| nextOffset: nextOffset < input.total ? nextOffset : null, | ||
| }); | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,117 @@ | ||||||||||||
| import type { StateThread } from "@t3tools/contracts"; | ||||||||||||
|
|
||||||||||||
| const MAX_TERMINAL_COUNT = 4; | ||||||||||||
| const DEFAULT_TERMINAL_ID = "default"; | ||||||||||||
|
|
||||||||||||
| export function normalizeTerminalIds(ids: readonly string[]): string[] { | ||||||||||||
| const normalized = [ | ||||||||||||
| ...new Set(ids.map((id) => id.trim()).filter((id) => id.length > 0)), | ||||||||||||
| ].slice(0, MAX_TERMINAL_COUNT); | ||||||||||||
| if (normalized.length > 0) { | ||||||||||||
| return normalized; | ||||||||||||
| } | ||||||||||||
| return [DEFAULT_TERMINAL_ID]; | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| function normalizeRunningTerminalIds( | ||||||||||||
| runningTerminalIds: readonly string[], | ||||||||||||
| terminalIds: readonly string[], | ||||||||||||
| ): string[] { | ||||||||||||
| if (runningTerminalIds.length === 0) { | ||||||||||||
| return []; | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| const validTerminalIds = new Set(terminalIds); | ||||||||||||
| return [...new Set(runningTerminalIds)] | ||||||||||||
| .map((id) => id.trim()) | ||||||||||||
| .filter((id) => id.length > 0 && validTerminalIds.has(id)) | ||||||||||||
|
Comment on lines
+25
to
+27
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Medium
Suggested change
🤖 Prompt for AI |
||||||||||||
| .slice(0, MAX_TERMINAL_COUNT); | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| export function fallbackGroupId(terminalId: string): string { | ||||||||||||
| return `group-${terminalId}`; | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| function assignUniqueGroupId(groupId: string, usedGroupIds: Set<string>): string { | ||||||||||||
| if (!usedGroupIds.has(groupId)) { | ||||||||||||
| usedGroupIds.add(groupId); | ||||||||||||
| return groupId; | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| let suffix = 2; | ||||||||||||
| while (usedGroupIds.has(`${groupId}-${suffix}`)) { | ||||||||||||
| suffix += 1; | ||||||||||||
| } | ||||||||||||
| const uniqueGroupId = `${groupId}-${suffix}`; | ||||||||||||
| usedGroupIds.add(uniqueGroupId); | ||||||||||||
| return uniqueGroupId; | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| function normalizeTerminalGroups( | ||||||||||||
| groups: StateThread["terminalGroups"], | ||||||||||||
| terminalIds: readonly string[], | ||||||||||||
| ): StateThread["terminalGroups"] { | ||||||||||||
| const validTerminalIds = new Set(terminalIds); | ||||||||||||
| const assignedTerminalIds = new Set<string>(); | ||||||||||||
| const usedGroupIds = new Set<string>(); | ||||||||||||
| const normalizedGroups: StateThread["terminalGroups"] = []; | ||||||||||||
|
|
||||||||||||
| for (const group of groups) { | ||||||||||||
| const groupTerminalIds = [ | ||||||||||||
| ...new Set(group.terminalIds.map((id) => id.trim()).filter((id) => id.length > 0)), | ||||||||||||
| ].filter((terminalId) => { | ||||||||||||
| if (!validTerminalIds.has(terminalId)) return false; | ||||||||||||
| if (assignedTerminalIds.has(terminalId)) return false; | ||||||||||||
| return true; | ||||||||||||
| }); | ||||||||||||
| if (groupTerminalIds.length === 0) continue; | ||||||||||||
| for (const terminalId of groupTerminalIds) { | ||||||||||||
| assignedTerminalIds.add(terminalId); | ||||||||||||
| } | ||||||||||||
| const baseGroupId = | ||||||||||||
| group.id.trim().length > 0 | ||||||||||||
| ? group.id.trim() | ||||||||||||
| : fallbackGroupId(groupTerminalIds[0] ?? DEFAULT_TERMINAL_ID); | ||||||||||||
| normalizedGroups.push({ | ||||||||||||
| id: assignUniqueGroupId(baseGroupId, usedGroupIds), | ||||||||||||
| terminalIds: groupTerminalIds, | ||||||||||||
| }); | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| for (const terminalId of terminalIds) { | ||||||||||||
| if (assignedTerminalIds.has(terminalId)) continue; | ||||||||||||
| normalizedGroups.push({ | ||||||||||||
| id: assignUniqueGroupId(fallbackGroupId(terminalId), usedGroupIds), | ||||||||||||
| terminalIds: [terminalId], | ||||||||||||
| }); | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| if (normalizedGroups.length > 0) { | ||||||||||||
| return normalizedGroups; | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| return [{ id: fallbackGroupId(DEFAULT_TERMINAL_ID), terminalIds: [DEFAULT_TERMINAL_ID] }]; | ||||||||||||
| } | ||||||||||||
|
|
||||||||||||
| export function normalizeThread(thread: StateThread): StateThread { | ||||||||||||
| const terminalIds = normalizeTerminalIds(thread.terminalIds); | ||||||||||||
| const runningTerminalIds = normalizeRunningTerminalIds(thread.runningTerminalIds, terminalIds); | ||||||||||||
| const activeTerminalId = terminalIds.includes(thread.activeTerminalId) | ||||||||||||
| ? thread.activeTerminalId | ||||||||||||
| : (terminalIds[0] ?? DEFAULT_TERMINAL_ID); | ||||||||||||
| const terminalGroups = normalizeTerminalGroups(thread.terminalGroups, terminalIds); | ||||||||||||
| const activeGroupId = | ||||||||||||
| terminalGroups.find((group) => group.id === thread.activeTerminalGroupId)?.id ?? | ||||||||||||
| terminalGroups.find((group) => group.terminalIds.includes(activeTerminalId))?.id ?? | ||||||||||||
| terminalGroups[0]?.id ?? | ||||||||||||
| fallbackGroupId(activeTerminalId); | ||||||||||||
|
|
||||||||||||
| return { | ||||||||||||
| ...thread, | ||||||||||||
| terminalIds, | ||||||||||||
| runningTerminalIds, | ||||||||||||
| activeTerminalId, | ||||||||||||
| terminalGroups, | ||||||||||||
| activeTerminalGroupId: activeGroupId, | ||||||||||||
| }; | ||||||||||||
| } | ||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,46 @@ | ||
| import { describe, expect, test } from "vitest"; | ||
|
|
||
| import { mergeTurnSummaryFiles, summarizeUnifiedDiff } from "./turnSummaries"; | ||
|
|
||
| describe("turnSummaries domain helpers", () => { | ||
| test("summarizeUnifiedDiff returns per-file diff stats", () => { | ||
| const diff = [ | ||
| "diff --git a/src/example.ts b/src/example.ts", | ||
| "index 1111111..2222222 100644", | ||
| "--- a/src/example.ts", | ||
| "+++ b/src/example.ts", | ||
| "@@ -1,2 +1,3 @@", | ||
| " line1", | ||
| "-line2", | ||
| "+line2-updated", | ||
| "+line3", | ||
| "", | ||
| ].join("\n"); | ||
|
|
||
| expect(summarizeUnifiedDiff(diff)).toEqual([ | ||
| { | ||
| path: "src/example.ts", | ||
| kind: "change", | ||
| additions: 2, | ||
| deletions: 1, | ||
| }, | ||
| ]); | ||
| }); | ||
|
|
||
| test("mergeTurnSummaryFiles merges by path while preserving prior fields", () => { | ||
| const existing = [ | ||
| { path: "a.ts", kind: "modified" as const, additions: 1, deletions: 2 }, | ||
| { path: "b.ts", kind: "deleted" as const, additions: 0, deletions: 4 }, | ||
| ]; | ||
| const incoming = [ | ||
| { path: "a.ts", additions: 3, deletions: 5 }, | ||
| { path: "c.ts", kind: "added" as const, additions: 7, deletions: 0 }, | ||
| ]; | ||
|
|
||
| expect(mergeTurnSummaryFiles(existing, incoming)).toEqual([ | ||
| { path: "a.ts", kind: "modified", additions: 3, deletions: 5 }, | ||
| { path: "b.ts", kind: "deleted", additions: 0, deletions: 4 }, | ||
| { path: "c.ts", kind: "added", additions: 7, deletions: 0 }, | ||
| ]); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 Medium
domain/projects.ts:5On POSIX, directory names can contain leading/trailing whitespace (e.g.,"repo "), so.trim()may corrupt valid paths. If this is intentional input sanitization, consider documenting that assumption; otherwise, consider removing.trim().🤖 Prompt for AI