Remove Ask/Execute modes and general product tool approvals so requests use the selected runtime, enabled capabilities, and execution account permissions. Migrate retired application metadata while preserving user content. Retire the Results page while retaining conversation images and stored artifacts. Add local and remote workspace file imports, timestamps and sorting, improve image discovery and saving, and refine workspace navigation and supervision responsiveness. Include the accompanying web-search changes, tests, probes, and product documentation. Remote imports require an updated Agent. Existing artifacts are not automatically copied into project directories. Validation evidence and outstanding failures remain documented in feature progress files; no full test suite or real-model calls were rerun for this commit. Release note: Run requests without switching work modes, import files into a selected local or remote workspace folder, sort workspace files by date, and keep using conversation images after removal of the Results page.
12403 lines
435 KiB
TypeScript
12403 lines
435 KiB
TypeScript
import { createHash, randomUUID } from 'node:crypto'
|
|
import { reviewScope, type ReviewBatch } from './review-checkpoint'
|
|
import { magicNoteCanvasAnalysisText } from '../../shared/magic-note-canvas-text'
|
|
import { knowledgeReferenceKey } from '../../shared/knowledge-reference'
|
|
import { appendConversationQuestionBlock } from '../../shared/conversation-question-blocks'
|
|
import { DatabaseSync } from 'node:sqlite'
|
|
import { Worker } from 'node:worker_threads'
|
|
import { ReadonlyQueryReader, readWithFallback } from '../readonly-query-reader'
|
|
import { ActivityHistoryRepository, migrateActivityHistoryOrder } from './activity-history-repository'
|
|
import { statSync } from 'node:fs'
|
|
import { MagicNoteStorage } from '../magic-notes/magic-note-storage'
|
|
import type { AssistantStorageProgress } from '../../shared/assistant-storage-contracts'
|
|
import {
|
|
builtInDefaultProjectSeedDescription,
|
|
builtInDefaultProjectSeedName,
|
|
activityHistorySnapshotSchema,
|
|
assistantIdSchema,
|
|
executionStatsInputSchema,
|
|
conversationAnsweredQuestionSchema,
|
|
conversationMessageSchema,
|
|
conversationSnapshotSchema,
|
|
conversationSetPinnedSchema,
|
|
expertCreateSchema,
|
|
persistedProjectExecutionSpaceSchema,
|
|
projectCreateSchema,
|
|
scheduleCreateSchema
|
|
} from '../../shared/assistant-contracts'
|
|
import type {
|
|
AssistantArtifact,
|
|
AssistantExpert,
|
|
AssistantHeartbeatConfig,
|
|
AssistantHeartbeatEntry,
|
|
AssistantHeartbeatRun,
|
|
AssistantMemory,
|
|
AssistantProject,
|
|
AssistantSchedule,
|
|
AssistantTask,
|
|
ExecutionStats,
|
|
ExecutionStatsInput,
|
|
ActivityHistoryPage,
|
|
ActivityHistorySnapshot,
|
|
ActivityHistorySummary,
|
|
ActivityHistoryUpdateResult,
|
|
ConversationQueueItem,
|
|
ConversationAnsweredQuestion,
|
|
ConversationBranchInput,
|
|
ConversationMessageBlock,
|
|
ConversationSubagentActivity,
|
|
ConversationToolActivity,
|
|
ConversationMessage,
|
|
ConversationSnapshot,
|
|
ConversationSetPinnedInput,
|
|
ExpertCreateInput,
|
|
ExpertUpdateInput,
|
|
HeartbeatCreateInput,
|
|
HeartbeatSummaryOutput,
|
|
HeartbeatUpdateInput,
|
|
LocalConversationSaveBatch,
|
|
MemoryCreateInput,
|
|
ModelUsageCallInput,
|
|
ProjectChannel,
|
|
ProjectCreateInput,
|
|
ScheduleCreateInput,
|
|
SystemModelUsageInput,
|
|
TokenUsageRecord,
|
|
TokenUsageSummary,
|
|
TokenUsageSystemSource,
|
|
TokenUsageTotals
|
|
} from '../../shared/assistant-contracts'
|
|
import { tokenUsageSystemSources, defaultHeartbeatIntervention } from '../../shared/assistant-contracts'
|
|
import type { StoredSupervisionResult, SupervisionCandidate } from './supervisor-service'
|
|
import { supervisionEntitySchema, supervisionResultViewSchema, type SupervisionTarget, type SupervisionGraphRequest, type SupervisionRunRequest, type SupervisionActivity } from '../../shared/supervision-contracts'
|
|
import type { AgentEvent } from '../../shared/contracts'
|
|
import type {
|
|
SshHostProjectReference
|
|
} from '../../shared/ssh-host-contracts'
|
|
import {
|
|
computerControlErrorCodeSchema,
|
|
computerControlRiskSchema,
|
|
type ComputerControlErrorCode,
|
|
type ComputerControlRisk
|
|
} from '../../shared/computer-control-contracts'
|
|
import {
|
|
channelResultMessageSchema,
|
|
type ChannelResultMessage
|
|
} from '../../shared/channel-contracts'
|
|
import {
|
|
agentRuntimeSelectionKey,
|
|
compactRuntimeSelectionLayer,
|
|
optionalAgentRuntimeSelectionSchema,
|
|
repairRuntimeSelectionLayer,
|
|
runtimeSelectionLayerSchema,
|
|
withoutLegacyAutoSelection,
|
|
type AgentRuntimeSelection,
|
|
type RuntimeResolutionSettings,
|
|
type RuntimeSelectionLayer
|
|
} from '../../shared/runtime-selection-contracts'
|
|
import {
|
|
MAGIC_NOTE_MAX_NOTE_EMBED_BYTES,
|
|
magicNoteSourceSchema,
|
|
type MagicNoteSource,
|
|
type MagicNoteCreateResult,
|
|
type MagicNoteComment,
|
|
type MagicNoteDetail,
|
|
type MagicNoteEntryCreateResult,
|
|
type MagicNoteEntry,
|
|
type MagicNoteRichContent,
|
|
type MagicNoteContent,
|
|
type MagicNoteSearchResult,
|
|
type MagicNoteSummary,
|
|
type MagicNoteTag,
|
|
type MagicNoteTagRenameResult,
|
|
magicNoteTagKey,
|
|
type MagicTodoItem,
|
|
type MagicTodoStatus,
|
|
type MagicTodoUpdateInput
|
|
} from '../../shared/magic-notes-contracts'
|
|
import {
|
|
AGENT_PROTOCOL_LIMITS,
|
|
agentIdentifierSchema,
|
|
positiveAgentSequenceSchema
|
|
} from '../../shared/agent-protocol/contracts'
|
|
import type { ComputerControlAuditEvent } from '../computer-control/audit'
|
|
import {
|
|
magicNoteChecklistItems,
|
|
magicNoteEmbeddedBytes,
|
|
magicNoteImageBytes,
|
|
magicNotePlainText,
|
|
magicNotePreview,
|
|
setMagicNoteChecklistCompletion,
|
|
validateMagicNoteContent
|
|
} from '../magic-notes/rich-content'
|
|
import { computeNextHeartbeatRun } from './heartbeat-recurrence'
|
|
import {
|
|
SubagentProgressStorage,
|
|
SUBAGENT_PROGRESS_STORAGE_SCHEMA_VERSION
|
|
} from './subagent-progress-storage'
|
|
|
|
export const ASSISTANT_DATABASE_SCHEMA_VERSION = 60
|
|
|
|
function withoutHistoricalWorkMode(value: unknown): unknown {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return value
|
|
const { workMode: obsolete, ...current } = value as Record<string, unknown>
|
|
void obsolete
|
|
return current
|
|
}
|
|
/**
|
|
* Messages startup recovery may have to end (schema 58): still streaming, or
|
|
* metadata naming an unfinished tool/subagent (pending, running, queued) or
|
|
* image operation (running, saving, cancelling). Metadata is always minified
|
|
* JSON.stringify output, so every such state appears literally; the exact
|
|
* checks run on these rows only, through the partial index
|
|
* messages_recovery_idx.
|
|
*/
|
|
export const MESSAGE_RECOVERY_CANDIDATE_SQL = `(state = 'streaming'
|
|
OR instr(metadata_json, '"state":"running"') > 0
|
|
OR instr(metadata_json, '"state":"pending"') > 0
|
|
OR instr(metadata_json, '"state":"queued"') > 0
|
|
OR instr(metadata_json, '"state":"saving"') > 0
|
|
OR instr(metadata_json, '"state":"cancelling"') > 0)`
|
|
/** Main's automatic checkpoint threshold while the checkpoint worker runs (16 MB of 4 KB pages). */
|
|
const WAL_SAFETY_CHECKPOINT_PAGES = 4_000
|
|
|
|
export type RemoteTaskEventInput = {
|
|
taskId: string
|
|
bindingId: string
|
|
operationId: string
|
|
semanticSequence: string
|
|
eventIndex: number
|
|
kind: string
|
|
payload: unknown
|
|
}
|
|
|
|
export type RemoteConversationTaskEventInput = Omit<
|
|
RemoteTaskEventInput,
|
|
'kind' | 'payload'
|
|
> & {
|
|
conversationId: string
|
|
assistantMessageId: string
|
|
runtimeSelection?: AgentRuntimeSelection
|
|
event:
|
|
| AgentEvent
|
|
| {
|
|
requestId: string
|
|
type: 'checkpoint' | 'remote-semantic-checkpoint'
|
|
remoteProvenance?: unknown
|
|
}
|
|
}
|
|
|
|
export type RemoteConversationTaskEventsBatchInput = Pick<
|
|
RemoteConversationTaskEventInput,
|
|
'taskId' | 'conversationId' | 'assistantMessageId' | 'runtimeSelection'
|
|
> & {
|
|
events: ReadonlyArray<
|
|
Pick<
|
|
RemoteConversationTaskEventInput,
|
|
'bindingId' | 'operationId' | 'semanticSequence' | 'eventIndex' | 'event'
|
|
>
|
|
>
|
|
}
|
|
|
|
/** Upper bound for one live remote persistence transaction. */
|
|
export const REMOTE_TASK_EVENT_BATCH_MAXIMUM_EVENTS = 512
|
|
|
|
export type RecoverableRemoteTask = {
|
|
taskId: string
|
|
projectId: string
|
|
conversationId: string
|
|
currentUserMessageId: string
|
|
currentAssistantMessageId: string
|
|
instructions: string
|
|
status: 'running' | 'waiting_approval' | 'interrupted'
|
|
}
|
|
|
|
type ProjectRow = {
|
|
id: string
|
|
name: string
|
|
description: string
|
|
root_path: string
|
|
runtime_selection_json: string | null
|
|
kind: AssistantProject['kind']
|
|
channel: ProjectChannel | null
|
|
built_in_default: number
|
|
status: AssistantProject['status']
|
|
created_at: string
|
|
updated_at: string
|
|
execution_space_project_id: string | null
|
|
execution_space_kind: string | null
|
|
execution_space_root_path: string | null
|
|
execution_space_ssh_host_id: string | null
|
|
}
|
|
|
|
const projectSelect = `
|
|
SELECT projects.*,
|
|
project_execution_spaces.project_id
|
|
AS execution_space_project_id,
|
|
project_execution_spaces.kind AS execution_space_kind,
|
|
project_execution_spaces.root_path
|
|
AS execution_space_root_path,
|
|
project_execution_spaces.ssh_host_id
|
|
AS execution_space_ssh_host_id
|
|
FROM projects
|
|
LEFT JOIN project_execution_spaces
|
|
ON project_execution_spaces.project_id = projects.id
|
|
`
|
|
|
|
type TaskRow = {
|
|
id: string
|
|
project_id: string | null
|
|
conversation_id: string | null
|
|
schedule_id: string | null
|
|
parent_task_id: string | null
|
|
expert_id: string | null
|
|
routing_mode: AssistantTask['routingMode'] | null
|
|
title: string
|
|
instructions: string
|
|
origin: AssistantTask['origin']
|
|
status: AssistantTask['status']
|
|
active_run_status?: 'pending' | 'running' | null
|
|
progress: number | null
|
|
created_at: string
|
|
started_at: string | null
|
|
completed_at: string | null
|
|
error: string | null
|
|
}
|
|
|
|
type ConversationRow = {
|
|
id: string
|
|
project_id: string | null
|
|
runtime_selection_json: string | null
|
|
knowledge_retrieval_mode: 'auto' | 'always' | null
|
|
context_state_json: string | null
|
|
title: string
|
|
channel: ProjectChannel | null
|
|
external_account_id: string | null
|
|
external_conversation_id: string | null
|
|
conversation_type: 'direct' | 'group' | null
|
|
account_display: string | null
|
|
branch_source_conversation_id: string | null
|
|
branch_source_title: string | null
|
|
pinned: number
|
|
updated_at: string
|
|
}
|
|
|
|
type MessageRow = {
|
|
id: string
|
|
conversation_id: string
|
|
role: ConversationSnapshot['messages'][number]['role']
|
|
content: string
|
|
state: ConversationSnapshot['messages'][number]['state']
|
|
metadata_json: string
|
|
created_at: string
|
|
}
|
|
|
|
type MagicNoteRow = {
|
|
id: string
|
|
project_id: string | null
|
|
title: string
|
|
pinned: number
|
|
revision: number
|
|
created_at: string
|
|
updated_at: string
|
|
entry_count: number
|
|
latest_plain_text: string | null
|
|
tags_json: string | null
|
|
}
|
|
|
|
type MagicNoteTagRow = {
|
|
id: string
|
|
name: string
|
|
note_count: number
|
|
}
|
|
|
|
/** Shared SELECT list for note summaries; the caller supplies FROM/WHERE. */
|
|
const MAGIC_NOTE_SUMMARY_COLUMNS = `n.*,
|
|
(SELECT COUNT(*) FROM magic_note_entries e
|
|
WHERE e.note_id = n.id) AS entry_count,
|
|
(SELECT plain_text FROM magic_note_entries e
|
|
WHERE e.note_id = n.id
|
|
ORDER BY e.created_at DESC, e.rowid DESC LIMIT 1) AS latest_plain_text,
|
|
(SELECT json_group_array(name) FROM (
|
|
SELECT t.name FROM magic_note_tag_links l
|
|
JOIN magic_note_tags t ON t.id = l.tag_id
|
|
WHERE l.note_id = n.id
|
|
ORDER BY l.position ASC, t.name_key ASC)) AS tags_json`
|
|
|
|
type MagicNoteEntryRow = {
|
|
id: string
|
|
note_id: string
|
|
content_json: string
|
|
source_json: string | null
|
|
plain_text: string
|
|
comments_json: string
|
|
analyzed_at: string | null
|
|
revision: number
|
|
created_at: string
|
|
updated_at: string
|
|
}
|
|
|
|
type MagicTodoRow = {
|
|
id: string
|
|
project_id: string | null
|
|
note_id: string | null
|
|
entry_id: string | null
|
|
note_title: string | null
|
|
source_index: number | null
|
|
source: MagicTodoItem['source']
|
|
title: string
|
|
instructions: string
|
|
completed: number
|
|
comments_json: string
|
|
analyzed_at: string | null
|
|
revision: number
|
|
created_at: string
|
|
updated_at: string
|
|
}
|
|
|
|
type MessageMetadata = {
|
|
createdAt?: number
|
|
queueItemId?: string
|
|
status?: string
|
|
terminalStatus?: ConversationMessage['terminalStatus']
|
|
reasoning?: ConversationSnapshot['messages'][number]['reasoning']
|
|
runtimeChecklist?: ConversationMessage['runtimeChecklist']
|
|
blocks?: ConversationSnapshot['messages'][number]['blocks']
|
|
displayCaptureTruncated?: boolean
|
|
contextCompression?: ConversationSnapshot['messages'][number]['contextCompression']
|
|
contextCompressions?: ConversationSnapshot['messages'][number]['contextCompressions']
|
|
tools?: ConversationSnapshot['messages'][number]['tools']
|
|
subagents?: ConversationSnapshot['messages'][number]['subagents']
|
|
sources?: string[]
|
|
sourceReferences?: ConversationSnapshot['messages'][number]['sourceReferences']
|
|
knowledgeRetrieval?: ConversationSnapshot['messages'][number]['knowledgeRetrieval']
|
|
artifactIds?: string[]
|
|
imageOperations?: ImageOperation[]
|
|
imageSourceArtifactIds?: string[]
|
|
imageContextNotice?: ConversationSnapshot['messages'][number]['imageContextNotice']
|
|
task?: ConversationSnapshot['messages'][number]['task']
|
|
attachments?: ConversationSnapshot['messages'][number]['attachments']
|
|
answeredQuestions?: ConversationSnapshot['messages'][number]['answeredQuestions']
|
|
}
|
|
|
|
const MAX_CHANNEL_OUTBOX_RETRY_BYTES = 20 * 1024 * 1024
|
|
const MAX_CHANNEL_OUTBOX_MEDIA_ENTRIES = 8
|
|
|
|
/** Rewrites pre-layer `{ provider, profileId }` and `{ provider: 'auto' }` rows. */
|
|
function migrateRuntimeSelectionLayers(database: DatabaseSync): void {
|
|
for (const table of ['projects', 'conversations'] as const) {
|
|
const rows = database
|
|
.prepare(
|
|
`SELECT id, runtime_selection_json FROM ${table}
|
|
WHERE runtime_selection_json IS NOT NULL`
|
|
)
|
|
.all() as Array<{ id: string; runtime_selection_json: string }>
|
|
const update = database.prepare(
|
|
`UPDATE ${table} SET runtime_selection_json = ? WHERE id = ?`
|
|
)
|
|
for (const row of rows) {
|
|
const next = serializeRuntimeSelection(
|
|
parseRuntimeSelection(row.runtime_selection_json)
|
|
)
|
|
if (next !== row.runtime_selection_json) update.run(next, row.id)
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Reads a stored layer; legacy concrete and `auto` selections upgrade on read. */
|
|
function parseRuntimeSelection(
|
|
value: string | null
|
|
): RuntimeSelectionLayer | undefined {
|
|
if (!value) {
|
|
return undefined
|
|
}
|
|
try {
|
|
const parsed = runtimeSelectionLayerSchema.safeParse(JSON.parse(value))
|
|
return parsed.success ? compactRuntimeSelectionLayer(parsed.data) : undefined
|
|
} catch {
|
|
return undefined
|
|
}
|
|
}
|
|
|
|
function serializeRuntimeSelection(
|
|
layer: RuntimeSelectionLayer | undefined
|
|
): string | null {
|
|
// Normalize legacy `{ provider, profileId }` input before storing it.
|
|
const parsed = runtimeSelectionLayerSchema.safeParse(layer)
|
|
const compact = parsed.success ? compactRuntimeSelectionLayer(parsed.data) : undefined
|
|
return compact ? JSON.stringify(compact) : null
|
|
}
|
|
|
|
/** Remote events record the concrete selection they ran with for context metrics keys. */
|
|
function remoteEventSelectionKey(
|
|
requestSelection: AgentRuntimeSelection | undefined,
|
|
conversationLayer: RuntimeSelectionLayer | undefined,
|
|
projectLayer: RuntimeSelectionLayer | undefined
|
|
): string {
|
|
if (requestSelection) return agentRuntimeSelectionKey(requestSelection)
|
|
const provider = conversationLayer?.provider ?? projectLayer?.provider ?? 'opencode'
|
|
const model = conversationLayer?.model ?? projectLayer?.model
|
|
return agentRuntimeSelectionKey(
|
|
model?.kind === 'profile'
|
|
? { provider, profileId: model.profileId }
|
|
: model?.kind === 'runtime-config' && provider !== 'model'
|
|
? { provider, runtimeConfig: true }
|
|
: { provider }
|
|
)
|
|
}
|
|
|
|
type ArtifactRow = {
|
|
id: string
|
|
project_id: string | null
|
|
task_id: string | null
|
|
kind: AssistantArtifact['kind']
|
|
title: string
|
|
mime_type: string
|
|
inline_content: string | null
|
|
byte_size: number
|
|
created_at: string
|
|
updated_at: string
|
|
}
|
|
|
|
type MemoryRow = {
|
|
id: string
|
|
scope: AssistantMemory['scope']
|
|
scope_id: string | null
|
|
type: AssistantMemory['type']
|
|
content: string
|
|
confidence: number
|
|
salience: number
|
|
status: AssistantMemory['status']
|
|
created_at: string
|
|
updated_at: string
|
|
}
|
|
|
|
type ScheduleRow = {
|
|
id: string
|
|
project_id: string | null
|
|
task_template_json: string
|
|
recurrence_json: string
|
|
next_run_at: string
|
|
enabled: number
|
|
last_run_at: string | null
|
|
created_at: string
|
|
updated_at: string
|
|
}
|
|
|
|
type ScheduleWithTaskRow = ScheduleRow & {
|
|
task_id: string
|
|
conversation_id: string
|
|
}
|
|
|
|
type ConversationQueueRow = {
|
|
id: string
|
|
conversation_id: string
|
|
source: ConversationQueueItem['source']
|
|
label: string
|
|
payload_json: string
|
|
schedule_run_id: string | null
|
|
schedule_id: string | null
|
|
task_id: string | null
|
|
status: 'pending' | 'dispatching'
|
|
created_at: string
|
|
}
|
|
|
|
export type ClaimedConversationQueueItem =
|
|
| {
|
|
source: 'user'
|
|
item: ConversationQueueItem & { source: 'user' }
|
|
payloadJson: string
|
|
}
|
|
| {
|
|
source: 'schedule'
|
|
item: ConversationQueueItem & { source: 'schedule' }
|
|
schedule: AssistantSchedule
|
|
runId: string
|
|
}
|
|
|
|
type ExpertRow = {
|
|
id: string
|
|
name: string
|
|
description: string
|
|
system_instructions: string
|
|
capability_policy_json: string
|
|
model_policy_json: string
|
|
enabled: number
|
|
created_at: string
|
|
updated_at: string
|
|
}
|
|
|
|
type HeartbeatConfigRow = {
|
|
id: string
|
|
project_id: string | null
|
|
scope_kind: AssistantHeartbeatConfig['scope']['kind']
|
|
name: string
|
|
timezone: string
|
|
recurrence_json: string
|
|
lookback_hours: number
|
|
retention_days: number
|
|
intervention?: string | null
|
|
enabled: number
|
|
next_run_at: string
|
|
last_run_at: string | null
|
|
last_status: AssistantHeartbeatRun['status'] | null
|
|
created_at: string
|
|
updated_at: string
|
|
}
|
|
|
|
type HeartbeatRunRow = {
|
|
id: string
|
|
config_id: string
|
|
trigger: AssistantHeartbeatRun['trigger']
|
|
scheduled_for: string
|
|
idempotency_key: string
|
|
status: AssistantHeartbeatRun['status']
|
|
attempt_count: number
|
|
next_attempt_at: string | null
|
|
lease_owner: string | null
|
|
lease_expires_at: string | null
|
|
started_at: string | null
|
|
completed_at: string | null
|
|
error: string | null
|
|
entry_id: string | null
|
|
created_at: string
|
|
updated_at: string
|
|
}
|
|
|
|
type HeartbeatEntryRow = {
|
|
id: string
|
|
config_id: string
|
|
run_id: string
|
|
scheduled_for: string
|
|
summary: string
|
|
highlights_json: string
|
|
artifact_id: string | null
|
|
proposed_memory_ids_json: string
|
|
follow_up_task_ids_json: string
|
|
created_at: string
|
|
}
|
|
|
|
type TokenUsageRecordRow = {
|
|
request_id: string
|
|
project_id: string | null
|
|
project_name: string | null
|
|
conversation_id: string | null
|
|
conversation_title: string | null
|
|
system_source: string | null
|
|
runtime: string
|
|
provider: string
|
|
model: string
|
|
call_count: number
|
|
input_tokens: number
|
|
output_tokens: number
|
|
cache_read_tokens: number
|
|
cache_write_tokens: number
|
|
cache_input_tokens: number
|
|
}
|
|
|
|
type ComputerControlActionRow = {
|
|
command_id: string
|
|
task_id: string
|
|
conversation_id: string
|
|
lease_id: string
|
|
action: ComputerControlAuditEvent['action']
|
|
risk: ComputerControlRisk
|
|
outcome: ComputerControlAuditEvent['outcome']
|
|
error_code: ComputerControlErrorCode | null
|
|
occurred_at: number
|
|
text_length: number | null
|
|
text_digest: string | null
|
|
}
|
|
|
|
export type PersistedComputerControlAuditEvent =
|
|
ComputerControlAuditEvent
|
|
|
|
export type ClaimedHeartbeatRun = {
|
|
config: AssistantHeartbeatConfig
|
|
run: AssistantHeartbeatRun
|
|
leaseOwner: string
|
|
acquired: boolean
|
|
}
|
|
|
|
export type SshProjectExecutionSpace = {
|
|
kind: 'ssh'
|
|
hostId: string
|
|
remoteRootPath: string
|
|
}
|
|
|
|
export type SshProjectWrite = {
|
|
project: ProjectCreateInput
|
|
executionSpace: SshProjectExecutionSpace
|
|
assertCurrent: () => void
|
|
}
|
|
|
|
export type HeartbeatInputSnapshot = {
|
|
previousSummary?: string
|
|
evidence?: ReviewBatch['evidence']
|
|
scope: AssistantHeartbeatConfig['scope']
|
|
conversations: Array<{
|
|
id: string
|
|
projectId: string
|
|
title: string
|
|
updatedAt: string
|
|
messages: Array<{
|
|
id: string
|
|
role: 'user' | 'assistant'
|
|
content: string
|
|
createdAt: string
|
|
sourceReferences?: ConversationSnapshot['messages'][number]['sourceReferences']
|
|
}>
|
|
}>
|
|
tasks: Array<{
|
|
id: string
|
|
projectId?: string
|
|
title: string
|
|
status: AssistantTask['status']
|
|
createdAt: string
|
|
completedAt?: string
|
|
}>
|
|
confirmedMemories: Array<{
|
|
id: string
|
|
projectId?: string
|
|
type: AssistantMemory['type']
|
|
content: string
|
|
scope: AssistantMemory['scope']
|
|
createdAt: string
|
|
updatedAt: string
|
|
}>
|
|
}
|
|
|
|
function toProject(row: ProjectRow): AssistantProject {
|
|
if (!row.execution_space_project_id) {
|
|
throw new Error(`项目 ${row.id} 缺少执行空间配置`)
|
|
}
|
|
const executionSpace =
|
|
persistedProjectExecutionSpaceSchema.safeParse(
|
|
row.execution_space_kind === 'local'
|
|
? {
|
|
kind: 'local',
|
|
rootPath: row.execution_space_root_path
|
|
}
|
|
: row.execution_space_kind === 'ssh'
|
|
? {
|
|
kind: 'ssh',
|
|
hostId: row.execution_space_ssh_host_id,
|
|
remoteRootPath: row.execution_space_root_path
|
|
}
|
|
: { kind: row.execution_space_kind }
|
|
)
|
|
if (!executionSpace.success) {
|
|
throw new Error(`项目 ${row.id} 的执行空间配置无效`)
|
|
}
|
|
const runtimeSelection = parseRuntimeSelection(row.runtime_selection_json)
|
|
const rootPath =
|
|
executionSpace.data.kind === 'local'
|
|
? executionSpace.data.rootPath
|
|
: executionSpace.data.remoteRootPath
|
|
return {
|
|
id: row.id,
|
|
name: row.name,
|
|
description: row.description,
|
|
rootPath,
|
|
executionSpace: executionSpace.data,
|
|
runtimeSelection,
|
|
kind: row.kind,
|
|
channel: row.channel ?? undefined,
|
|
builtInDefault: row.built_in_default === 1,
|
|
status: row.status,
|
|
createdAt: row.created_at,
|
|
updatedAt: row.updated_at
|
|
}
|
|
}
|
|
|
|
function readProject(
|
|
database: DatabaseSync,
|
|
projectId: string
|
|
): AssistantProject {
|
|
const row = database
|
|
.prepare(`${projectSelect} WHERE projects.id = ?`)
|
|
.get(projectId) as ProjectRow | undefined
|
|
if (!row) {
|
|
throw new Error('项目不存在')
|
|
}
|
|
return toProject(row)
|
|
}
|
|
|
|
const taskSelect = `SELECT tasks.*,
|
|
(SELECT status FROM schedule_runs
|
|
WHERE schedule_id = tasks.schedule_id
|
|
AND status IN ('pending', 'running')
|
|
ORDER BY CASE status WHEN 'running' THEN 0 ELSE 1 END
|
|
LIMIT 1) AS active_run_status
|
|
FROM tasks`
|
|
|
|
// Match toTask's live statuses: pending schedule runs normalize to queued.
|
|
const activeVisibleTaskSelect = `${taskSelect}
|
|
WHERE visible = 1 AND (
|
|
active_run_status = 'running'
|
|
OR (active_run_status IS NULL
|
|
AND status IN ('running', 'waiting_approval'))
|
|
)`
|
|
|
|
function toTask(row: TaskRow): AssistantTask {
|
|
return {
|
|
id: row.id,
|
|
projectId: row.project_id ?? undefined,
|
|
conversationId: row.conversation_id ?? undefined,
|
|
scheduleId: row.schedule_id ?? undefined,
|
|
parentTaskId: row.parent_task_id ?? undefined,
|
|
expertId: row.expert_id ?? undefined,
|
|
routingMode: row.routing_mode ?? undefined,
|
|
title: row.title,
|
|
instructions: row.instructions,
|
|
origin: row.origin,
|
|
status:
|
|
row.active_run_status === 'pending'
|
|
? 'queued'
|
|
: row.active_run_status === 'running'
|
|
? row.status === 'waiting_approval'
|
|
? 'waiting_approval'
|
|
: 'running'
|
|
: row.origin === 'schedule' && row.status === 'queued'
|
|
? 'idle'
|
|
: row.status,
|
|
progress: row.progress ?? undefined,
|
|
createdAt: row.created_at,
|
|
startedAt: row.started_at ?? undefined,
|
|
completedAt: row.completed_at ?? undefined,
|
|
error: row.error ?? undefined
|
|
}
|
|
}
|
|
|
|
function toMagicNoteEntry(row: MagicNoteEntryRow, content: MagicNoteContent): MagicNoteEntry {
|
|
return {
|
|
id: row.id,
|
|
noteId: row.note_id,
|
|
content,
|
|
...(row.source_json ? { source: magicNoteSourceSchema.parse(JSON.parse(row.source_json)) } : {}),
|
|
plainText: row.plain_text,
|
|
comments: JSON.parse(row.comments_json) as MagicNoteComment[],
|
|
analyzedAt: row.analyzed_at ?? undefined,
|
|
revision: row.revision,
|
|
createdAt: row.created_at,
|
|
updatedAt: row.updated_at
|
|
}
|
|
}
|
|
|
|
function toMagicTodo(row: MagicTodoRow): MagicTodoItem {
|
|
if (
|
|
row.source !== 'note' ||
|
|
!row.note_id ||
|
|
!row.entry_id ||
|
|
row.source_index === null ||
|
|
!row.note_title
|
|
) {
|
|
throw new Error('待办来源数据无效')
|
|
}
|
|
return {
|
|
id: row.id,
|
|
noteId: row.note_id,
|
|
entryId: row.entry_id,
|
|
noteTitle: row.note_title,
|
|
sourceIndex: row.source_index,
|
|
source: 'note',
|
|
title: row.title,
|
|
instructions: row.instructions,
|
|
completed: row.completed === 1,
|
|
comments: JSON.parse(row.comments_json) as MagicNoteComment[],
|
|
analyzedAt: row.analyzed_at ?? undefined,
|
|
revision: row.revision,
|
|
createdAt: row.created_at,
|
|
updatedAt: row.updated_at
|
|
}
|
|
}
|
|
|
|
function toMagicNoteSummary(row: MagicNoteRow): MagicNoteSummary {
|
|
return {
|
|
id: row.id,
|
|
title: row.title,
|
|
preview: magicNotePreview(row.latest_plain_text ?? ''),
|
|
entryCount: row.entry_count,
|
|
pinned: row.pinned === 1,
|
|
tags: parseMagicNoteTags(row.tags_json),
|
|
revision: row.revision,
|
|
createdAt: row.created_at,
|
|
updatedAt: row.updated_at
|
|
}
|
|
}
|
|
|
|
/** AND-filter: a note must link to every requested tag key. */
|
|
function magicNoteTagFilter(tags: string[] | undefined): {
|
|
clause: string
|
|
params: string[]
|
|
} {
|
|
const keys = [...new Set((tags ?? []).map(magicNoteTagKey).filter(Boolean))]
|
|
if (keys.length === 0) return { clause: '', params: [] }
|
|
return {
|
|
clause: `(SELECT COUNT(DISTINCT t.name_key) FROM magic_note_tag_links l
|
|
JOIN magic_note_tags t ON t.id = l.tag_id
|
|
WHERE l.note_id = n.id AND t.name_key IN (${keys.map(() => '?').join(', ')})) = ${keys.length}`,
|
|
params: keys
|
|
}
|
|
}
|
|
|
|
function parseMagicNoteTags(value: string | null): string[] {
|
|
if (!value) return []
|
|
const parsed: unknown = JSON.parse(value)
|
|
return Array.isArray(parsed)
|
|
? parsed.filter((item): item is string => typeof item === 'string')
|
|
: []
|
|
}
|
|
|
|
function toMagicNoteTag(row: MagicNoteTagRow): MagicNoteTag {
|
|
return { id: row.id, name: row.name, noteCount: row.note_count }
|
|
}
|
|
|
|
function toArtifact(row: ArtifactRow): AssistantArtifact {
|
|
return {
|
|
id: row.id,
|
|
projectId: row.project_id ?? undefined,
|
|
taskId: row.task_id ?? undefined,
|
|
kind: row.kind,
|
|
title: row.title,
|
|
mimeType: row.mime_type,
|
|
content: row.inline_content ?? undefined,
|
|
byteSize: row.byte_size,
|
|
createdAt: row.created_at,
|
|
updatedAt: row.updated_at
|
|
}
|
|
}
|
|
|
|
function toMemory(row: MemoryRow): AssistantMemory {
|
|
return {
|
|
id: row.id,
|
|
scope: row.scope,
|
|
scopeId: row.scope_id ?? undefined,
|
|
type: row.type,
|
|
content: row.content,
|
|
confidence: row.confidence,
|
|
salience: row.salience,
|
|
status: row.status,
|
|
createdAt: row.created_at,
|
|
updatedAt: row.updated_at
|
|
}
|
|
}
|
|
|
|
function toSchedule(row: ScheduleWithTaskRow): AssistantSchedule {
|
|
const template = JSON.parse(row.task_template_json) as {
|
|
title: string
|
|
prompt: string
|
|
runtimeSelection?: unknown
|
|
}
|
|
const runtimeSelection = optionalAgentRuntimeSelectionSchema.safeParse(
|
|
withoutLegacyAutoSelection(template.runtimeSelection)
|
|
)
|
|
const recurrence = JSON.parse(row.recurrence_json) as {
|
|
type: AssistantSchedule['recurrence']
|
|
}
|
|
return {
|
|
id: row.id,
|
|
projectId: row.project_id ?? undefined,
|
|
taskId: row.task_id,
|
|
conversationId: row.conversation_id,
|
|
title: template.title,
|
|
prompt: template.prompt,
|
|
runtimeSelection: runtimeSelection.success
|
|
? runtimeSelection.data
|
|
: undefined,
|
|
recurrence: recurrence.type,
|
|
nextRunAt: row.next_run_at,
|
|
enabled: row.enabled === 1,
|
|
lastRunAt: row.last_run_at ?? undefined,
|
|
createdAt: row.created_at,
|
|
updatedAt: row.updated_at
|
|
}
|
|
}
|
|
|
|
function toConversationQueueItem(
|
|
row: ConversationQueueRow
|
|
): ConversationQueueItem {
|
|
let hasAttachments = false
|
|
if (row.source === 'user' && row.payload_json) {
|
|
try {
|
|
const payload = JSON.parse(row.payload_json)
|
|
const input = payload?.input ?? payload
|
|
hasAttachments = Array.isArray(input?.attachments) && input.attachments.length > 0
|
|
} catch {
|
|
// Invalid queued payloads are reported when previewed or dispatched.
|
|
}
|
|
}
|
|
return {
|
|
hasAttachments,
|
|
id: row.id,
|
|
conversationId: row.conversation_id,
|
|
source: row.source,
|
|
label: row.label,
|
|
createdAt: row.created_at,
|
|
scheduleRunId: row.schedule_run_id ?? undefined,
|
|
scheduleId: row.schedule_id ?? undefined,
|
|
taskId: row.task_id ?? undefined
|
|
}
|
|
}
|
|
|
|
function toExpert(row: ExpertRow): AssistantExpert {
|
|
let routingKeywords: string[]
|
|
let modelProfileId: string | undefined
|
|
try {
|
|
const policy = JSON.parse(row.capability_policy_json) as {
|
|
routingKeywords?: unknown
|
|
}
|
|
routingKeywords = expertCreateSchema.parse({
|
|
name: row.name,
|
|
description: row.description,
|
|
systemInstructions: row.system_instructions,
|
|
routingKeywords: Array.isArray(policy.routingKeywords)
|
|
? policy.routingKeywords
|
|
: []
|
|
}).routingKeywords
|
|
} catch {
|
|
routingKeywords = []
|
|
}
|
|
try {
|
|
const policy = JSON.parse(row.model_policy_json) as {
|
|
modelProfileId?: unknown
|
|
}
|
|
modelProfileId = expertCreateSchema
|
|
.pick({ modelProfileId: true })
|
|
.parse({
|
|
modelProfileId: policy.modelProfileId
|
|
}).modelProfileId
|
|
} catch {
|
|
modelProfileId = undefined
|
|
}
|
|
return {
|
|
id: row.id,
|
|
name: row.name,
|
|
description: row.description,
|
|
systemInstructions: row.system_instructions,
|
|
...(modelProfileId ? { modelProfileId } : {}),
|
|
routingKeywords,
|
|
enabled: row.enabled === 1,
|
|
createdAt: row.created_at,
|
|
updatedAt: row.updated_at
|
|
}
|
|
}
|
|
|
|
function toHeartbeatConfig(
|
|
row: HeartbeatConfigRow,
|
|
projectIds: string[] = []
|
|
): AssistantHeartbeatConfig {
|
|
return {
|
|
id: row.id,
|
|
scope:
|
|
row.scope_kind === 'projects'
|
|
? { kind: 'projects', projectIds }
|
|
: { kind: 'global' },
|
|
name: row.name,
|
|
timezone: row.timezone,
|
|
recurrence: JSON.parse(
|
|
row.recurrence_json
|
|
) as AssistantHeartbeatConfig['recurrence'],
|
|
enabled: row.enabled === 1,
|
|
lookbackHours: row.lookback_hours,
|
|
retentionDays: row.retention_days,
|
|
intervention: row.intervention === 'memory' ? 'memory' : 'suggest',
|
|
nextRunAt: row.next_run_at,
|
|
lastRunAt: row.last_run_at ?? undefined,
|
|
lastStatus: row.last_status ?? undefined,
|
|
createdAt: row.created_at,
|
|
updatedAt: row.updated_at
|
|
}
|
|
}
|
|
|
|
function toHeartbeatRun(row: HeartbeatRunRow): AssistantHeartbeatRun {
|
|
return {
|
|
id: row.id,
|
|
configId: row.config_id,
|
|
trigger: row.trigger,
|
|
scheduledFor: row.scheduled_for,
|
|
status: row.status,
|
|
attemptCount: row.attempt_count,
|
|
nextAttemptAt: row.next_attempt_at ?? undefined,
|
|
startedAt: row.started_at ?? undefined,
|
|
completedAt: row.completed_at ?? undefined,
|
|
error: row.error ?? undefined,
|
|
entryId: row.entry_id ?? undefined,
|
|
createdAt: row.created_at,
|
|
updatedAt: row.updated_at
|
|
}
|
|
}
|
|
|
|
function toHeartbeatEntry(
|
|
row: HeartbeatEntryRow
|
|
): AssistantHeartbeatEntry {
|
|
return {
|
|
id: row.id,
|
|
configId: row.config_id,
|
|
runId: row.run_id,
|
|
scheduledFor: row.scheduled_for,
|
|
summary: row.summary,
|
|
highlights: JSON.parse(row.highlights_json) as string[],
|
|
artifactId: row.artifact_id ?? undefined,
|
|
proposedMemoryIds: JSON.parse(
|
|
row.proposed_memory_ids_json
|
|
) as string[],
|
|
followUpTaskIds: JSON.parse(
|
|
row.follow_up_task_ids_json
|
|
) as string[],
|
|
createdAt: row.created_at
|
|
}
|
|
}
|
|
|
|
function validateUsageText(
|
|
value: string,
|
|
label: string,
|
|
maximumLength: number
|
|
): string {
|
|
if (typeof value !== 'string') {
|
|
throw new TypeError(`${label} must be a string`)
|
|
}
|
|
const normalized = value.trim()
|
|
if (
|
|
normalized.length === 0 ||
|
|
normalized.length > maximumLength
|
|
) {
|
|
throw new RangeError(
|
|
`${label} must contain between 1 and ${maximumLength} characters`
|
|
)
|
|
}
|
|
return normalized
|
|
}
|
|
|
|
function validateTokenCount(value: number, label: string): number {
|
|
if (!Number.isSafeInteger(value) || value < 0) {
|
|
throw new RangeError(
|
|
`${label} must be a nonnegative safe integer`
|
|
)
|
|
}
|
|
return value
|
|
}
|
|
|
|
const computerControlActions = [
|
|
'observe',
|
|
'activate',
|
|
'replace_text',
|
|
'select_option',
|
|
'scroll'
|
|
] as const
|
|
const computerControlOutcomes = [
|
|
'completed',
|
|
'denied',
|
|
'failed',
|
|
'outcome_unknown'
|
|
] as const
|
|
function validateComputerControlId(
|
|
value: string,
|
|
label: string,
|
|
maximumLength: number,
|
|
opaque = false
|
|
): string {
|
|
if (typeof value !== 'string') {
|
|
throw new TypeError(`${label} must be a string`)
|
|
}
|
|
if (
|
|
value.length < (opaque ? 16 : 1) ||
|
|
value.length > maximumLength ||
|
|
(opaque
|
|
? !/^[A-Za-z0-9_-]+$/.test(value)
|
|
: [...value].some((character) => {
|
|
const code = character.charCodeAt(0)
|
|
return code <= 31 || code === 127
|
|
}))
|
|
) {
|
|
throw new RangeError(`Invalid ${label}`)
|
|
}
|
|
return value
|
|
}
|
|
|
|
function validateComputerControlEnum<T extends string>(
|
|
value: string,
|
|
label: string,
|
|
allowed: readonly T[]
|
|
): T {
|
|
if (!allowed.includes(value as T)) {
|
|
throw new RangeError(`Invalid ${label}`)
|
|
}
|
|
return value as T
|
|
}
|
|
|
|
type ValidatedRemoteTaskEvent = Omit<
|
|
RemoteTaskEventInput,
|
|
'kind' | 'payload'
|
|
> & {
|
|
kind: string
|
|
payloadJson: string
|
|
}
|
|
|
|
function validateRemoteTaskEvent(
|
|
input: RemoteTaskEventInput
|
|
): ValidatedRemoteTaskEvent {
|
|
const taskId = agentIdentifierSchema.parse(input.taskId)
|
|
const bindingId = agentIdentifierSchema.parse(input.bindingId)
|
|
const operationId = agentIdentifierSchema.parse(input.operationId)
|
|
const semanticSequence = positiveAgentSequenceSchema.parse(
|
|
input.semanticSequence
|
|
)
|
|
if (
|
|
!Number.isSafeInteger(input.eventIndex) ||
|
|
input.eventIndex < 0 ||
|
|
input.eventIndex >= AGENT_PROTOCOL_LIMITS.runPendingEvents
|
|
) {
|
|
throw new RangeError(
|
|
`eventIndex must be between 0 and ${
|
|
AGENT_PROTOCOL_LIMITS.runPendingEvents - 1
|
|
}`
|
|
)
|
|
}
|
|
if (typeof input.kind !== 'string') {
|
|
throw new TypeError('kind must be a string')
|
|
}
|
|
const kind = input.kind.slice(0, 64)
|
|
const payloadJson = JSON.stringify(withoutHistoricalWorkMode(input.payload))
|
|
if (typeof payloadJson !== 'string') {
|
|
throw new TypeError('Task event payload must be JSON serializable')
|
|
}
|
|
if (
|
|
Buffer.byteLength(payloadJson, 'utf8') >
|
|
AGENT_PROTOCOL_LIMITS.maximumEventPayloadBytes
|
|
) {
|
|
throw new RangeError('Task event payload exceeds the event size limit')
|
|
}
|
|
return {
|
|
taskId,
|
|
bindingId,
|
|
operationId,
|
|
semanticSequence,
|
|
eventIndex: input.eventIndex,
|
|
kind,
|
|
payloadJson
|
|
}
|
|
}
|
|
|
|
function toComputerControlAuditEvent(
|
|
row: ComputerControlActionRow
|
|
): PersistedComputerControlAuditEvent {
|
|
return {
|
|
timestamp: row.occurred_at,
|
|
taskId: row.task_id,
|
|
conversationId: row.conversation_id,
|
|
leaseId: row.lease_id,
|
|
commandId: row.command_id,
|
|
action: row.action,
|
|
risk: row.risk,
|
|
outcome: row.outcome,
|
|
...(row.error_code ? { errorCode: row.error_code } : {}),
|
|
...(row.text_length === null
|
|
? {}
|
|
: { textLength: row.text_length }),
|
|
...(row.text_digest === null
|
|
? {}
|
|
: { textDigest: row.text_digest })
|
|
}
|
|
}
|
|
|
|
const interruptedTaskError = '应用退出时任务仍在运行'
|
|
const interruptedMessageStatus = '上次运行意外中断,可以重新发送问题'
|
|
|
|
function interruptActiveTools(
|
|
tools: MessageMetadata['tools']
|
|
): MessageMetadata['tools'] {
|
|
return tools?.map((tool) =>
|
|
tool.state === 'pending' || tool.state === 'running'
|
|
? { ...tool, state: 'interrupted' as const }
|
|
: tool
|
|
)
|
|
}
|
|
|
|
function interruptActiveSubagents(
|
|
subagents: MessageMetadata['subagents']
|
|
): MessageMetadata['subagents'] {
|
|
return subagents?.map((subagent) =>
|
|
subagent.state === 'queued' || subagent.state === 'running'
|
|
? { ...subagent, state: 'cancelled' as const }
|
|
: subagent
|
|
)
|
|
}
|
|
|
|
function interruptActiveToolBlocks(
|
|
blocks: MessageMetadata['blocks']
|
|
): MessageMetadata['blocks'] {
|
|
return blocks?.map((block) =>
|
|
block.type === 'tool' &&
|
|
(block.tool.state === 'pending' || block.tool.state === 'running')
|
|
? {
|
|
...block,
|
|
tool: { ...block.tool, state: 'interrupted' as const }
|
|
}
|
|
: block
|
|
)
|
|
}
|
|
|
|
const conversationContextStateSchema = conversationSnapshotSchema.pick({
|
|
knowledgeLibraryIds: true,
|
|
knowledgeRetrievalMode: true,
|
|
storyGraphEnabled: true,
|
|
contextMetrics: true,
|
|
contextCompressionState: true
|
|
})
|
|
|
|
function parseConversationContextState(
|
|
value: string | null
|
|
): Pick<
|
|
ConversationSnapshot,
|
|
'knowledgeLibraryIds' | 'knowledgeRetrievalMode' | 'storyGraphEnabled' | 'contextMetrics' | 'contextCompressionState'
|
|
> {
|
|
if (!value) {
|
|
return {}
|
|
}
|
|
try {
|
|
const parsed = conversationContextStateSchema.safeParse(
|
|
JSON.parse(value)
|
|
)
|
|
return parsed.success ? parsed.data : {}
|
|
} catch {
|
|
return {}
|
|
}
|
|
}
|
|
|
|
function serializeConversationContextState(
|
|
conversation: Pick<
|
|
ConversationSnapshot,
|
|
'knowledgeLibraryIds' | 'knowledgeRetrievalMode' | 'storyGraphEnabled' | 'contextMetrics' | 'contextCompressionState'
|
|
>
|
|
): string | null {
|
|
return conversation.knowledgeLibraryIds !== undefined ||
|
|
conversation.knowledgeRetrievalMode !== undefined ||
|
|
conversation.storyGraphEnabled !== undefined ||
|
|
conversation.contextMetrics ||
|
|
conversation.contextCompressionState
|
|
? JSON.stringify({
|
|
knowledgeLibraryIds: conversation.knowledgeLibraryIds,
|
|
knowledgeRetrievalMode: conversation.knowledgeRetrievalMode,
|
|
storyGraphEnabled: conversation.storyGraphEnabled,
|
|
contextMetrics: conversation.contextMetrics,
|
|
contextCompressionState: conversation.contextCompressionState
|
|
})
|
|
: null
|
|
}
|
|
|
|
function toConversationSnapshot(
|
|
conversation: ConversationRow,
|
|
messages: MessageRow[]
|
|
): ConversationSnapshot {
|
|
return {
|
|
id: conversation.id,
|
|
projectId: conversation.project_id ?? undefined,
|
|
runtimeSelection: parseRuntimeSelection(
|
|
conversation.runtime_selection_json
|
|
),
|
|
...parseConversationContextState(conversation.context_state_json),
|
|
knowledgeRetrievalMode:
|
|
conversation.knowledge_retrieval_mode ?? undefined,
|
|
...(conversation.channel &&
|
|
conversation.conversation_type &&
|
|
conversation.account_display
|
|
? {
|
|
remote: {
|
|
channel: conversation.channel,
|
|
accountDisplay: conversation.account_display,
|
|
conversationType: conversation.conversation_type
|
|
}
|
|
}
|
|
: {}),
|
|
...(conversation.branch_source_conversation_id &&
|
|
conversation.branch_source_title
|
|
? {
|
|
branch: {
|
|
sourceConversationId:
|
|
conversation.branch_source_conversation_id,
|
|
sourceTitle: conversation.branch_source_title
|
|
}
|
|
}
|
|
: {}),
|
|
title: conversation.title,
|
|
pinned: conversation.pinned === 1,
|
|
updatedAt: Date.parse(conversation.updated_at),
|
|
// Startup recovery is the only authority for interruption: it persists
|
|
// the terminal state once. Deriving it while reading would also hit
|
|
// messages that are streaming right now, because the renderer keeps
|
|
// saving them during a live run.
|
|
messages: messages.map((message) => {
|
|
const metadata = JSON.parse(
|
|
message.metadata_json
|
|
) as MessageMetadata
|
|
return {
|
|
id: message.id,
|
|
queueItemId: metadata.queueItemId,
|
|
role: message.role,
|
|
content: message.content,
|
|
reasoning: metadata.reasoning,
|
|
runtimeChecklist: metadata.runtimeChecklist,
|
|
blocks: metadata.blocks,
|
|
displayCaptureTruncated:
|
|
metadata.displayCaptureTruncated,
|
|
createdAt:
|
|
metadata.createdAt ?? Date.parse(message.created_at),
|
|
state: message.state,
|
|
terminalStatus: metadata.terminalStatus,
|
|
status: metadata.status,
|
|
contextCompression: metadata.contextCompression,
|
|
contextCompressions: metadata.contextCompressions,
|
|
tools: metadata.tools,
|
|
subagents: metadata.subagents,
|
|
sources: metadata.sources,
|
|
sourceReferences: metadata.sourceReferences,
|
|
knowledgeRetrieval: metadata.knowledgeRetrieval,
|
|
artifactIds: metadata.artifactIds,
|
|
imageOperations: metadata.imageOperations,
|
|
imageSourceArtifactIds: metadata.imageSourceArtifactIds,
|
|
imageContextNotice: metadata.imageContextNotice,
|
|
task: metadata.task,
|
|
attachments: metadata.attachments,
|
|
answeredQuestions: metadata.answeredQuestions
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
function serializeConversationMessageMetadata(
|
|
message: ConversationMessage
|
|
): string {
|
|
return JSON.stringify({
|
|
createdAt: message.createdAt,
|
|
queueItemId: message.queueItemId,
|
|
status: message.status,
|
|
terminalStatus: message.terminalStatus,
|
|
reasoning: message.reasoning,
|
|
runtimeChecklist: message.runtimeChecklist,
|
|
blocks: message.blocks,
|
|
displayCaptureTruncated: message.displayCaptureTruncated,
|
|
contextCompression: message.contextCompression,
|
|
contextCompressions: message.contextCompressions,
|
|
tools: message.tools,
|
|
subagents: message.subagents,
|
|
sources: message.sources,
|
|
sourceReferences: message.sourceReferences,
|
|
knowledgeRetrieval: message.knowledgeRetrieval,
|
|
artifactIds: message.artifactIds,
|
|
imageOperations: message.imageOperations,
|
|
imageSourceArtifactIds: message.imageSourceArtifactIds,
|
|
imageContextNotice: message.imageContextNotice,
|
|
task: message.task,
|
|
attachments: message.attachments,
|
|
answeredQuestions: message.answeredQuestions
|
|
})
|
|
}
|
|
|
|
function appendRecoveredMessageBlock(
|
|
blocks: ConversationMessageBlock[] | undefined,
|
|
type: 'text' | 'reasoning',
|
|
delta: string
|
|
): ConversationMessageBlock[] | undefined {
|
|
if (!blocks || !delta) {
|
|
return blocks
|
|
}
|
|
const current = [...blocks]
|
|
const previous = current.at(-1)
|
|
if (previous?.type === type) {
|
|
current[current.length - 1] = {
|
|
...previous,
|
|
content: `${previous.content}${delta}`
|
|
}
|
|
} else {
|
|
current.push({
|
|
id: randomUUID(),
|
|
type,
|
|
content: delta
|
|
})
|
|
}
|
|
return current
|
|
}
|
|
|
|
function upsertRecoveredToolBlock(
|
|
blocks: ConversationMessageBlock[] | undefined,
|
|
tool: ConversationToolActivity
|
|
): ConversationMessageBlock[] | undefined {
|
|
if (!blocks) {
|
|
return blocks
|
|
}
|
|
const index = tool.callId
|
|
? blocks.findIndex(
|
|
(block) =>
|
|
block.type === 'tool' &&
|
|
block.tool.callId === tool.callId
|
|
)
|
|
: -1
|
|
if (index >= 0) {
|
|
return blocks.map((block, blockIndex) =>
|
|
blockIndex === index && block.type === 'tool'
|
|
? { ...block, tool }
|
|
: block
|
|
)
|
|
}
|
|
return [
|
|
...blocks,
|
|
{ id: randomUUID(), type: 'tool' as const, tool }
|
|
]
|
|
}
|
|
|
|
function upsertRecoveredSubagentBlock(
|
|
blocks: ConversationMessageBlock[] | undefined,
|
|
childTaskId: string,
|
|
runtimeCallId?: string
|
|
): ConversationMessageBlock[] | undefined {
|
|
if (
|
|
!blocks ||
|
|
blocks.some(
|
|
(block) =>
|
|
block.type === 'subagent' &&
|
|
block.childTaskId === childTaskId
|
|
)
|
|
) {
|
|
return blocks
|
|
}
|
|
const provisionalIndex = runtimeCallId
|
|
? blocks.findIndex(
|
|
(block) =>
|
|
block.type === 'tool' &&
|
|
block.tool.callId === runtimeCallId
|
|
)
|
|
: -1
|
|
if (provisionalIndex >= 0) {
|
|
return blocks.map((block, index) =>
|
|
index === provisionalIndex
|
|
? {
|
|
id: block.id,
|
|
type: 'subagent' as const,
|
|
childTaskId
|
|
}
|
|
: block
|
|
)
|
|
}
|
|
return [
|
|
...blocks,
|
|
{ id: randomUUID(), type: 'subagent' as const, childTaskId }
|
|
]
|
|
}
|
|
|
|
function terminalizeRecoveredToolBlocks(
|
|
blocks: ConversationMessageBlock[] | undefined,
|
|
state: 'failed' | 'cancelled' | 'interrupted'
|
|
): ConversationMessageBlock[] | undefined {
|
|
return blocks?.map((block) =>
|
|
block.type === 'tool' &&
|
|
(block.tool.state === 'pending' || block.tool.state === 'running')
|
|
? { ...block, tool: { ...block.tool, state } }
|
|
: block
|
|
)
|
|
}
|
|
|
|
function failedToolRepresentsError(
|
|
tools: ConversationToolActivity[] | undefined,
|
|
errorMessage: string
|
|
): boolean {
|
|
return Boolean(
|
|
tools?.some(
|
|
(tool) =>
|
|
tool.state === 'failed' &&
|
|
((tool.error && errorMessage.includes(tool.error)) ||
|
|
(tool.callId && errorMessage.includes(tool.callId)))
|
|
)
|
|
)
|
|
}
|
|
|
|
function reduceRecoveredAgentEvent(
|
|
message: ConversationMessage,
|
|
event: RemoteConversationTaskEventInput['event']
|
|
): ConversationMessage {
|
|
let next: ConversationMessage = message
|
|
if (event.type === 'text') {
|
|
const delta = event.delta
|
|
const blocks = appendRecoveredMessageBlock(
|
|
message.blocks,
|
|
'text',
|
|
delta
|
|
)
|
|
next = {
|
|
...message,
|
|
content: `${message.content}${delta}`,
|
|
blocks,
|
|
status: undefined
|
|
}
|
|
} else if (event.type === 'reasoning') {
|
|
const reasoning = message.reasoning ?? ''
|
|
const delta = event.delta
|
|
const blocks = appendRecoveredMessageBlock(
|
|
message.blocks,
|
|
'reasoning',
|
|
delta
|
|
)
|
|
next = {
|
|
...message,
|
|
reasoning: `${reasoning}${delta}`,
|
|
blocks,
|
|
status: undefined
|
|
}
|
|
} else if (event.type === 'question') {
|
|
next = {
|
|
...message,
|
|
blocks: appendConversationQuestionBlock(message.blocks, event.questionId)
|
|
}
|
|
} else if (event.type === 'checklist') {
|
|
next = { ...message, runtimeChecklist: event.checklist }
|
|
} else if (event.type === 'status') {
|
|
next = { ...message, status: event.message }
|
|
} else if (event.type === 'context-compression') {
|
|
const scope = event.scope ?? 'conversation'
|
|
const marker = {
|
|
state:
|
|
event.state === 'started'
|
|
? ('compressing' as const)
|
|
: event.state,
|
|
scope,
|
|
estimatedBeforeTokens: event.estimatedBeforeTokens,
|
|
estimatedAfterTokens: event.estimatedAfterTokens,
|
|
compressionCount: event.compressionCount
|
|
}
|
|
const current =
|
|
message.contextCompressions ??
|
|
(message.contextCompression ? [message.contextCompression] : [])
|
|
next = {
|
|
...message,
|
|
contextCompression: undefined,
|
|
contextCompressions: [
|
|
...current.filter(
|
|
(compression) =>
|
|
(compression.scope ?? 'conversation') !== scope
|
|
),
|
|
marker
|
|
].slice(-2)
|
|
}
|
|
} else if (event.type === 'tool') {
|
|
const callId = event.callId.slice(0, 256)
|
|
const tool: ConversationToolActivity = {
|
|
callId,
|
|
name: event.name,
|
|
state: event.state,
|
|
summary: event.summary,
|
|
input: event.input,
|
|
output: event.output,
|
|
error: event.error
|
|
}
|
|
const tools = [...(message.tools ?? [])]
|
|
const index = tools.findIndex(
|
|
(candidate) => candidate.callId === callId
|
|
)
|
|
if (index >= 0) {
|
|
tools[index] = tool
|
|
} else {
|
|
tools.push(tool)
|
|
}
|
|
const blocks = upsertRecoveredToolBlock(message.blocks, tool)
|
|
next = {
|
|
...message,
|
|
tools,
|
|
blocks
|
|
}
|
|
} else if (event.type === 'subagent') {
|
|
const commonSubagent = {
|
|
childTaskId: event.childTaskId,
|
|
routingMode: event.routingMode,
|
|
runtimeCallId: event.runtimeCallId,
|
|
state: event.state,
|
|
reason: event.reason,
|
|
progress: event.progress,
|
|
output: event.output,
|
|
error: event.error
|
|
}
|
|
const subagent: ConversationSubagentActivity =
|
|
'actor' in event
|
|
? { ...commonSubagent, actor: event.actor }
|
|
: {
|
|
...commonSubagent,
|
|
expertId: event.expertId,
|
|
expertName: event.expertName
|
|
}
|
|
const subagents = [...(message.subagents ?? [])]
|
|
const index = subagents.findIndex(
|
|
(candidate) => candidate.childTaskId === event.childTaskId
|
|
)
|
|
if (index >= 0) {
|
|
subagents[index] = subagent
|
|
} else {
|
|
subagents.push(subagent)
|
|
}
|
|
const blocks = upsertRecoveredSubagentBlock(
|
|
message.blocks,
|
|
event.childTaskId,
|
|
event.runtimeCallId
|
|
)
|
|
next = {
|
|
...message,
|
|
subagents,
|
|
tools: event.runtimeCallId
|
|
? message.tools?.filter(
|
|
(tool) => tool.callId !== event.runtimeCallId
|
|
)
|
|
: message.tools,
|
|
blocks
|
|
}
|
|
} else if (event.type === 'artifact') {
|
|
next = {
|
|
...message,
|
|
artifactIds: [
|
|
...new Set([...(message.artifactIds ?? []), event.artifactId])
|
|
].slice(-8),
|
|
imageContextNotice: event.imageContextNotice,
|
|
status: '图片已生成,正在保存结果'
|
|
}
|
|
} else if (event.type === 'knowledge-retrieval') {
|
|
next = {
|
|
...message,
|
|
knowledgeRetrieval: {
|
|
mode: event.mode,
|
|
state: event.state,
|
|
libraryCount: event.libraryCount,
|
|
resultCount: event.resultCount,
|
|
durationMs: event.durationMs,
|
|
usedChannels: event.usedChannels,
|
|
warnings: event.warnings
|
|
},
|
|
status:
|
|
event.state === 'searching'
|
|
? '正在检索已启用的知识库'
|
|
: undefined
|
|
}
|
|
} else if (event.type === 'source-references') {
|
|
const key = knowledgeReferenceKey
|
|
const incoming = [
|
|
...new Map(
|
|
event.references.map((reference) => [key(reference), reference])
|
|
).values()
|
|
]
|
|
const incomingKeys = new Set(incoming.map(key))
|
|
next = {
|
|
...message,
|
|
sourceReferences: [
|
|
...incoming,
|
|
...(message.sourceReferences ?? []).filter(
|
|
(reference) => !incomingKeys.has(key(reference))
|
|
)
|
|
].slice(0, 20)
|
|
}
|
|
} else if (event.type === 'done' || event.type === 'error') {
|
|
const represented =
|
|
event.type === 'error' &&
|
|
failedToolRepresentsError(message.tools, event.message)
|
|
const toolState =
|
|
event.type === 'error'
|
|
? event.status === 'cancelled'
|
|
? ('cancelled' as const)
|
|
: ('failed' as const)
|
|
: ('interrupted' as const)
|
|
next = {
|
|
...message,
|
|
state: event.type === 'error' ? 'error' : 'complete',
|
|
terminalStatus: event.type === 'error'
|
|
? event.status === 'cancelled' ? 'cancelled' : 'failed'
|
|
: undefined,
|
|
status:
|
|
event.type === 'error' && (event.status === 'cancelled' || !represented)
|
|
? event.message
|
|
: undefined,
|
|
contextCompression:
|
|
event.type === 'error' &&
|
|
message.contextCompression?.state === 'compressing'
|
|
? { ...message.contextCompression, state: 'failed' }
|
|
: message.contextCompression,
|
|
contextCompressions:
|
|
event.type === 'error'
|
|
? message.contextCompressions?.map((compression) =>
|
|
compression.state === 'compressing'
|
|
? { ...compression, state: 'failed' as const }
|
|
: compression
|
|
)
|
|
: message.contextCompressions,
|
|
tools: message.tools?.map((tool) =>
|
|
tool.state === 'pending' || tool.state === 'running'
|
|
? { ...tool, state: toolState }
|
|
: tool
|
|
),
|
|
subagents: message.subagents?.map((subagent) =>
|
|
subagent.state === 'queued' || subagent.state === 'running'
|
|
? {
|
|
...subagent,
|
|
state:
|
|
event.type === 'error'
|
|
? event.status === 'cancelled'
|
|
? ('cancelled' as const)
|
|
: ('failed' as const)
|
|
: ('failed' as const),
|
|
...(event.type === 'error' &&
|
|
event.status !== 'cancelled'
|
|
? { error: event.message.slice(0, 1_000) }
|
|
: event.type === 'done'
|
|
? {
|
|
error:
|
|
'父请求已结束,但子 Agent 未报告完成状态。'
|
|
}
|
|
: {})
|
|
}
|
|
: subagent
|
|
),
|
|
blocks: terminalizeRecoveredToolBlocks(
|
|
message.blocks,
|
|
toolState
|
|
)
|
|
}
|
|
}
|
|
return conversationMessageSchema.parse(next)
|
|
}
|
|
|
|
/**
|
|
* Projects the assistant-message fields that remote event persistence reads
|
|
* back from storage before reducing the next remote event.
|
|
*/
|
|
function storedRemoteAssistantMessage(
|
|
id: string,
|
|
content: string,
|
|
state: ConversationMessage['state'],
|
|
createdAt: number,
|
|
metadata: MessageMetadata
|
|
): ConversationMessage {
|
|
return {
|
|
id,
|
|
role: 'assistant',
|
|
content,
|
|
state,
|
|
createdAt,
|
|
status: metadata.status,
|
|
terminalStatus: metadata.terminalStatus,
|
|
reasoning: metadata.reasoning,
|
|
runtimeChecklist: metadata.runtimeChecklist,
|
|
blocks: metadata.blocks,
|
|
displayCaptureTruncated: metadata.displayCaptureTruncated,
|
|
contextCompression: metadata.contextCompression,
|
|
contextCompressions: metadata.contextCompressions,
|
|
tools: metadata.tools,
|
|
subagents: metadata.subagents,
|
|
sources: metadata.sources,
|
|
sourceReferences: metadata.sourceReferences,
|
|
knowledgeRetrieval: metadata.knowledgeRetrieval,
|
|
artifactIds: metadata.artifactIds,
|
|
task: metadata.task,
|
|
attachments: metadata.attachments,
|
|
answeredQuestions: metadata.answeredQuestions
|
|
}
|
|
}
|
|
function stripRemoteEventProvenance(
|
|
event: RemoteConversationTaskEventInput['event']
|
|
): RemoteConversationTaskEventInput['event'] {
|
|
if (!('remoteProvenance' in event)) {
|
|
return event
|
|
}
|
|
const publicEvent = { ...event }
|
|
delete publicEvent.remoteProvenance
|
|
return publicEvent
|
|
}
|
|
|
|
type NormalizedSshProjectWrite = Omit<
|
|
SshProjectWrite,
|
|
'project' | 'executionSpace'
|
|
> & {
|
|
project: ProjectCreateInput
|
|
executionSpace: SshProjectExecutionSpace
|
|
}
|
|
|
|
function normalizeSshProjectWrite(
|
|
write: SshProjectWrite
|
|
): NormalizedSshProjectWrite {
|
|
const project = projectCreateSchema.parse(write.project)
|
|
const executionSpace =
|
|
persistedProjectExecutionSpaceSchema.parse(write.executionSpace)
|
|
if (executionSpace.kind !== 'ssh') {
|
|
throw new Error('远程项目必须使用 SSH 执行空间')
|
|
}
|
|
if (project.rootPath !== executionSpace.remoteRootPath) {
|
|
throw new Error('项目目录与远程执行空间目录不匹配')
|
|
}
|
|
if (typeof write.assertCurrent !== 'function') {
|
|
throw new TypeError('缺少 SSH Host 事务前置校验')
|
|
}
|
|
return {
|
|
project,
|
|
executionSpace: {
|
|
kind: 'ssh',
|
|
hostId: executionSpace.hostId,
|
|
remoteRootPath: executionSpace.remoteRootPath
|
|
},
|
|
assertCurrent: write.assertCurrent
|
|
}
|
|
}
|
|
|
|
function insertSshExecutionSpace(
|
|
database: DatabaseSync,
|
|
projectId: string,
|
|
executionSpace: SshProjectExecutionSpace
|
|
): void {
|
|
database
|
|
.prepare(
|
|
`INSERT INTO project_execution_spaces
|
|
(project_id, kind, root_path, ssh_host_id)
|
|
VALUES (?, 'ssh', ?, ?)`
|
|
)
|
|
.run(
|
|
projectId,
|
|
executionSpace.remoteRootPath,
|
|
executionSpace.hostId
|
|
)
|
|
}
|
|
|
|
function updateSshExecutionSpace(
|
|
database: DatabaseSync,
|
|
projectId: string,
|
|
executionSpace: SshProjectExecutionSpace
|
|
): number {
|
|
return Number(
|
|
database
|
|
.prepare(
|
|
`UPDATE project_execution_spaces
|
|
SET root_path = ?, ssh_host_id = ?
|
|
WHERE project_id = ? AND kind = 'ssh'`
|
|
)
|
|
.run(
|
|
executionSpace.remoteRootPath,
|
|
executionSpace.hostId,
|
|
projectId
|
|
).changes
|
|
)
|
|
}
|
|
|
|
function nextProjectUpdatedAt(current: string): string {
|
|
const now = Date.now()
|
|
const currentTimestamp = Date.parse(current)
|
|
return new Date(
|
|
Number.isFinite(currentTimestamp)
|
|
? Math.max(now, currentTimestamp + 1)
|
|
: now
|
|
).toISOString()
|
|
}
|
|
|
|
function prepareProjectDeletionStatements(database: DatabaseSync) {
|
|
return {
|
|
notifications: database.prepare(
|
|
`DELETE FROM notifications
|
|
WHERE task_id IN (
|
|
SELECT id FROM tasks WHERE project_id = ?
|
|
) OR schedule_id IN (
|
|
SELECT id FROM schedules WHERE project_id = ?
|
|
)`
|
|
),
|
|
delegationOutbox: database.prepare(
|
|
`DELETE FROM delegation_outbox
|
|
WHERE task_id IN (
|
|
SELECT id FROM tasks WHERE project_id = ?
|
|
)`
|
|
),
|
|
memories: database.prepare(
|
|
`DELETE FROM memory_items
|
|
WHERE (scope = 'project' AND scope_id = ?)
|
|
OR (scope = 'conversation' AND scope_id IN (
|
|
SELECT id FROM conversations WHERE project_id = ?
|
|
))`
|
|
),
|
|
heartbeatArtifacts: database.prepare(
|
|
`DELETE FROM artifacts
|
|
WHERE id IN (
|
|
SELECT e.artifact_id
|
|
FROM heartbeat_entries e
|
|
JOIN heartbeat_configs c ON c.id = e.config_id
|
|
JOIN heartbeat_config_projects hp ON hp.config_id = c.id
|
|
WHERE hp.project_id = ?
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM heartbeat_config_projects other
|
|
WHERE other.config_id = c.id
|
|
AND other.project_id <> ?
|
|
)
|
|
)`
|
|
),
|
|
heartbeatConfigs: database.prepare(
|
|
`DELETE FROM heartbeat_configs
|
|
WHERE scope_kind = 'projects'
|
|
AND EXISTS (
|
|
SELECT 1 FROM heartbeat_config_projects hp
|
|
WHERE hp.config_id = heartbeat_configs.id
|
|
AND hp.project_id = ?
|
|
)
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM heartbeat_config_projects other
|
|
WHERE other.config_id = heartbeat_configs.id
|
|
AND other.project_id <> ?
|
|
)`
|
|
),
|
|
artifacts: database.prepare(
|
|
'DELETE FROM artifacts WHERE project_id = ?'
|
|
),
|
|
tasks: database.prepare('DELETE FROM tasks WHERE project_id = ?'),
|
|
conversations: database.prepare(
|
|
'DELETE FROM conversations WHERE project_id = ?'
|
|
),
|
|
schedules: database.prepare(
|
|
'DELETE FROM schedules WHERE project_id = ?'
|
|
),
|
|
project: database.prepare('DELETE FROM projects WHERE id = ?')
|
|
}
|
|
}
|
|
|
|
function deleteProjectRecords(
|
|
database: DatabaseSync,
|
|
projectId: string,
|
|
statements = prepareProjectDeletionStatements(database)
|
|
): void {
|
|
statements.notifications.run(projectId, projectId)
|
|
statements.delegationOutbox.run(projectId)
|
|
statements.memories.run(projectId, projectId)
|
|
statements.heartbeatArtifacts.run(projectId, projectId)
|
|
statements.heartbeatConfigs.run(projectId, projectId)
|
|
statements.artifacts.run(projectId)
|
|
statements.tasks.run(projectId)
|
|
statements.conversations.run(projectId)
|
|
statements.schedules.run(projectId)
|
|
const result = statements.project.run(projectId)
|
|
if (result.changes !== 1) {
|
|
throw new Error('项目不存在')
|
|
}
|
|
}
|
|
|
|
/** Only Main leases can open a clock. Task state changes cannot revive a released lease. */
|
|
class ExecutionTiming {
|
|
private readonly active = new Set<string>()
|
|
private readonly listeners = new Set<() => void>()
|
|
private notificationPending = false
|
|
|
|
constructor(private readonly database: DatabaseSync) {}
|
|
|
|
onChanged(listener: () => void): () => void {
|
|
this.listeners.add(listener)
|
|
return () => { this.listeners.delete(listener) }
|
|
}
|
|
|
|
changed(): void {
|
|
if (this.notificationPending) return
|
|
this.notificationPending = true
|
|
queueMicrotask(() => {
|
|
this.notificationPending = false
|
|
for (const listener of this.listeners) listener()
|
|
})
|
|
}
|
|
|
|
start(taskId: string): void {
|
|
const task = this.database.prepare(`SELECT project_id, conversation_id, parent_task_id,
|
|
visible, origin, schedule_id, status FROM tasks WHERE id = ?`).get(taskId)
|
|
if (!task) throw new Error('Cannot time a request before its task exists')
|
|
if (task.origin === 'subagent' || task.schedule_id !== null || (task.origin === 'schedule' && task.visible === 1)) return
|
|
const result = this.database.prepare(`INSERT INTO execution_timing
|
|
(task_id, project_id, conversation_id, owner_id, running_since)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
ON CONFLICT(task_id) DO UPDATE SET running_since = excluded.running_since
|
|
WHERE execution_timing.running_since IS NULL AND excluded.running_since IS NOT NULL`)
|
|
.run(taskId, task.project_id ?? null, task.conversation_id ?? null,
|
|
task.parent_task_id ?? (task.visible ? taskId : null), task.status === 'running' ? Date.now() : null)
|
|
this.active.add(taskId)
|
|
if (result.changes) this.changed()
|
|
}
|
|
|
|
state(taskId: string, status: string): void {
|
|
if (!this.active.has(taskId)) return
|
|
const running = status === 'running'
|
|
const now = Date.now()
|
|
const result = running
|
|
? this.database.prepare(`UPDATE execution_timing SET running_since = ?
|
|
WHERE task_id = ? AND running_since IS NULL`).run(now, taskId)
|
|
: this.database.prepare(`UPDATE execution_timing
|
|
SET duration_ms = duration_ms + MAX(0, ? - running_since), running_since = NULL
|
|
WHERE task_id = ? AND running_since IS NOT NULL`).run(now, taskId)
|
|
if (result.changes) this.changed()
|
|
}
|
|
|
|
end(taskId: string, incomplete = false): void {
|
|
this.state(taskId, 'stopped')
|
|
this.active.delete(taskId)
|
|
if (incomplete && this.database.prepare(`UPDATE execution_timing SET incomplete = 1
|
|
WHERE task_id = ? AND incomplete = 0`).run(taskId).changes) this.changed()
|
|
}
|
|
|
|
snapshot(scope: ExecutionStatsInput): ExecutionStats {
|
|
const conversation = 'conversationId' in scope
|
|
const scopeId = conversation ? scope.conversationId : scope.projectId
|
|
const column = conversation ? 'conversation_id' : 'project_id'
|
|
const asOf = Date.now()
|
|
const result: ExecutionStats = {
|
|
durationMs: 0, runningCount: 0, asOf,
|
|
incomplete: Boolean(this.database.prepare(`SELECT 1 FROM conversations
|
|
WHERE ${conversation ? 'id' : 'project_id'} = ? AND timing_incomplete = 1 LIMIT 1`).get(scopeId)),
|
|
taskDurations: []
|
|
}
|
|
const cards = new Map<string, ExecutionStats['taskDurations'][number]>()
|
|
for (const row of this.database.prepare(`SELECT owner_id, duration_ms, running_since, incomplete
|
|
FROM execution_timing WHERE ${column} = ?`).iterate(scopeId)) {
|
|
const runningCount = row.running_since === null ? 0 : 1
|
|
const durationMs = Number(row.duration_ms) + (runningCount ? Math.max(0, asOf - Number(row.running_since)) : 0)
|
|
const incomplete = row.incomplete === 1
|
|
result.durationMs += durationMs
|
|
result.runningCount += runningCount
|
|
result.incomplete ||= incomplete
|
|
if (!conversation && row.owner_id) {
|
|
const id = String(row.owner_id)
|
|
const card = cards.get(id) ?? { id, durationMs: 0, runningCount: 0, incomplete: false }
|
|
card.durationMs += durationMs
|
|
card.runningCount += runningCount
|
|
card.incomplete ||= incomplete
|
|
cards.set(id, card)
|
|
}
|
|
}
|
|
result.taskDurations = [...cards.values()]
|
|
return result
|
|
}
|
|
}
|
|
|
|
export class AssistantDatabase {
|
|
private database?: DatabaseSync
|
|
private activityHistoryRepository?: { database: DatabaseSync; repository: ActivityHistoryRepository }
|
|
private executionTiming?: ExecutionTiming
|
|
private readonlyReader?: ReadonlyQueryReader
|
|
private supervisionWorker?: ReadonlyQueryReader
|
|
private checkpointWorker?: Worker
|
|
private readonlyWorkerPath?: string
|
|
private foldedSearchConnection?: DatabaseSync
|
|
private readonly noteStorage: MagicNoteStorage
|
|
private readonly dirtyMagicNotes = new Set<string>()
|
|
private readonly pendingMagicNoteCleanup = new Set<string | undefined>()
|
|
private subagentProgress?: SubagentProgressStorage
|
|
private channelEventWrites = 0
|
|
private channelOutboxWrites = 0
|
|
private modelUsageNotificationQueued = false
|
|
|
|
constructor(
|
|
private readonly databasePath: string,
|
|
private readonly options: {
|
|
onMagicNotesChanged?: () => void
|
|
onMagicTodosChanged?: () => void
|
|
onModelUsageChanged?: () => void
|
|
} = {}
|
|
) {
|
|
this.noteStorage = new MagicNoteStorage(databasePath)
|
|
}
|
|
|
|
private notifyMagicTodosChanged(): void {
|
|
this.options.onMagicTodosChanged?.()
|
|
}
|
|
|
|
hasAttachmentOwner(conversationId: string, kind: string, ownerId: string): boolean {
|
|
const database = this.requireDatabase()
|
|
if (kind === 'draft' || kind === 'parsing') return Boolean(database.prepare('SELECT 1 FROM conversations WHERE id = ?').get(conversationId))
|
|
if (kind === 'message') return Boolean(database.prepare('SELECT 1 FROM messages WHERE id = ? AND conversation_id = ?').get(ownerId, conversationId))
|
|
if (kind === 'queue') return Boolean(database.prepare('SELECT 1 FROM conversation_queue_items WHERE id = ? AND conversation_id = ?').get(ownerId, conversationId))
|
|
return false
|
|
}
|
|
|
|
// Called by the existing startup worker before the business connection opens.
|
|
upgradeStorage(
|
|
onProgress: (progress: AssistantStorageProgress) => void,
|
|
isCancelled: () => boolean,
|
|
upgrade: { migrateNotes: boolean; reclaimSpace: boolean }
|
|
): void {
|
|
const database = new DatabaseSync(this.databasePath, { timeout: 5_000 })
|
|
try {
|
|
database.exec('PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;')
|
|
this.migrate(database, onProgress, isCancelled)
|
|
this.database = database
|
|
if (upgrade.migrateNotes) this.repairMagicNoteStorage(onProgress, isCancelled)
|
|
if (isCancelled()) throw new DOMException('Upgrade cancelled', 'AbortError')
|
|
if (upgrade.reclaimSpace) {
|
|
const free = database.prepare('PRAGMA freelist_count').get() as { freelist_count: number }
|
|
if (free.freelist_count > 0) {
|
|
onProgress({ stage: 'compacting', processed: 0, total: 0, bytesBefore: statSync(this.databasePath).size })
|
|
database.exec('PRAGMA wal_checkpoint(TRUNCATE); VACUUM;')
|
|
}
|
|
}
|
|
database.exec('PRAGMA wal_checkpoint(TRUNCATE)')
|
|
} finally {
|
|
this.database = undefined
|
|
database.close()
|
|
}
|
|
}
|
|
|
|
private hydrateMagicNoteEntry(row: MagicNoteEntryRow): MagicNoteEntry {
|
|
return toMagicNoteEntry(row, this.noteStorage.read(row.note_id, row.id).content)
|
|
}
|
|
|
|
private writeMagicNoteBody(noteId: string, id: string, content: MagicNoteContent, revision: number, updatedAt: string): string {
|
|
this.dirtyMagicNotes.add(noteId)
|
|
this.noteStorage.write({ noteId, id, content, revision, updatedAt })
|
|
return JSON.stringify({ storage: 'file', version: content.version, revision })
|
|
}
|
|
|
|
private reconcileMagicNoteFiles(noteId?: string, inspectedAssets?: ReadonlySet<string>): void {
|
|
const database = this.database!
|
|
// SQL and authoritative bodies are already committed. Cleanup must never
|
|
// turn an accepted save/delete/reset into a retryable UI write failure.
|
|
if (noteId) this.dirtyMagicNotes.delete(noteId)
|
|
this.pendingMagicNoteCleanup.add(noteId)
|
|
try {
|
|
if (noteId === undefined) {
|
|
const notes = database.prepare('SELECT id FROM magic_notes').all() as Array<{ id: string }>
|
|
if (notes.length) this.noteStorage.reconcileNotes(new Set(notes.map((note) => note.id)))
|
|
else this.noteStorage.clear()
|
|
} else if (database.prepare('SELECT id FROM magic_notes WHERE id = ?').get(noteId)) {
|
|
const entries = database.prepare('SELECT id FROM magic_note_entries WHERE note_id = ? ORDER BY created_at, rowid').all(noteId) as Array<{ id: string }>
|
|
this.noteStorage.reconcile(noteId, entries.map((entry) => entry.id), inspectedAssets)
|
|
} else {
|
|
this.noteStorage.deleteNote(noteId)
|
|
}
|
|
this.pendingMagicNoteCleanup.delete(noteId)
|
|
} catch (error) {
|
|
console.warn('Magic note file cleanup deferred', { noteId, error })
|
|
}
|
|
}
|
|
|
|
private repairMagicNoteStorage(
|
|
onProgress?: (progress: AssistantStorageProgress) => void,
|
|
isCancelled: () => boolean = () => false,
|
|
noteIds?: ReadonlySet<string>
|
|
): void {
|
|
const database = this.database!
|
|
const notes = noteIds
|
|
? [...noteIds].flatMap((id) => {
|
|
const row = database.prepare('SELECT id FROM magic_notes WHERE id = ?').get(id) as { id: string } | undefined
|
|
if (!row) this.reconcileMagicNoteFiles(id)
|
|
return row ? [row] : []
|
|
})
|
|
: database.prepare('SELECT id FROM magic_notes').all() as Array<{ id: string }>
|
|
const total = (database.prepare('SELECT COUNT(*) AS count FROM magic_note_entries').get() as { count: number }).count
|
|
const bytesBefore = this.databasePath === ':memory:' ? 0 : statSync(this.databasePath).size
|
|
let processed = 0
|
|
let migrated = false
|
|
for (const note of notes) {
|
|
let referencedAssets: Set<string> | undefined = new Set<string>()
|
|
const entries = database.prepare('SELECT id FROM magic_note_entries WHERE note_id = ? ORDER BY created_at, rowid').all(note.id) as Array<{ id: string }>
|
|
for (const { id } of entries) {
|
|
if (isCancelled()) throw new DOMException('Upgrade cancelled', 'AbortError')
|
|
database.exec('BEGIN IMMEDIATE')
|
|
try {
|
|
// One payload at a time: a canvas may contain a large PDF.
|
|
const row = database.prepare('SELECT * FROM magic_note_entries WHERE id = ?').get(id) as MagicNoteEntryRow
|
|
const pointer = JSON.parse(row.content_json) as { storage?: string; revision?: number }
|
|
const inspected = pointer.storage === 'file' ? this.noteStorage.inspect(note.id, id) : undefined
|
|
if (pointer.storage !== 'file') {
|
|
const content = JSON.parse(row.content_json) as MagicNoteContent
|
|
this.noteStorage.write({ noteId: note.id, id, content, revision: row.revision, updatedAt: row.updated_at })
|
|
if (JSON.stringify(this.noteStorage.read(note.id, id).content) !== JSON.stringify(content)) {
|
|
throw new Error(`Note migration verification failed: ${id}`)
|
|
}
|
|
database.prepare('UPDATE magic_note_entries SET content_json = ?, plain_text = ?, image_bytes = ? WHERE id = ?').run(
|
|
JSON.stringify({ storage: 'file', version: content.version, revision: row.revision }),
|
|
magicNotePlainText(content), magicNoteEmbeddedBytes(content), id
|
|
)
|
|
migrated = true
|
|
} else if (inspected!.revision !== pointer.revision) {
|
|
const saved = this.noteStorage.read(note.id, id)
|
|
database.prepare(`UPDATE magic_note_entries SET content_json = ?, plain_text = ?, image_bytes = ?,
|
|
revision = MAX(revision, ?), updated_at = ?, comments_json = '[]', analyzed_at = NULL WHERE id = ?`).run(
|
|
JSON.stringify({ storage: 'file', version: saved.content.version, revision: saved.revision }),
|
|
magicNotePlainText(saved.content), magicNoteEmbeddedBytes(saved.content), saved.revision, saved.updatedAt, id
|
|
)
|
|
database.prepare('UPDATE magic_notes SET revision = revision + 1, updated_at = ? WHERE id = ?').run(saved.updatedAt, note.id)
|
|
this.syncMagicNoteTodos(database, note.id, id, saved.content, saved.updatedAt)
|
|
}
|
|
const assets = (inspected ?? this.noteStorage.inspect(note.id, id)).referencedAssets
|
|
if (!assets) referencedAssets = undefined
|
|
else if (referencedAssets) for (const asset of assets) referencedAssets.add(asset)
|
|
database.exec('COMMIT')
|
|
} catch (error) {
|
|
database.exec('ROLLBACK')
|
|
throw error
|
|
}
|
|
processed++
|
|
if (migrated) onProgress?.({ stage: 'converting', processed, total, bytesBefore })
|
|
}
|
|
this.reconcileMagicNoteFiles(note.id, referencedAssets)
|
|
}
|
|
if (!noteIds) this.reconcileMagicNoteFiles()
|
|
this.dirtyMagicNotes.clear()
|
|
}
|
|
|
|
initialize(defaultRootPath: string): void {
|
|
if (this.database) {
|
|
return
|
|
}
|
|
const database = new DatabaseSync(this.databasePath, {
|
|
enableForeignKeyConstraints: true,
|
|
timeout: 5_000
|
|
})
|
|
try {
|
|
database.exec(`
|
|
PRAGMA journal_mode = WAL;
|
|
PRAGMA foreign_keys = ON;
|
|
PRAGMA busy_timeout = 5000;
|
|
`)
|
|
this.migrate(database)
|
|
database.prepare("UPDATE supervision_runs SET status = 'failed', error = 'Application stopped before supervision finished', completed_at = ? WHERE status = 'running'")
|
|
.run(new Date().toISOString())
|
|
database.prepare("UPDATE heartbeat_runs SET projection_status = 'failed', projection_error = 'Application stopped before supervision finished', projection_completed_at = ? WHERE status IN ('completed', 'no_change') AND projection_status = 'running'")
|
|
.run(new Date().toISOString())
|
|
this.database = database
|
|
this.repairMagicNoteStorage()
|
|
this.subagentProgress = new SubagentProgressStorage(database)
|
|
this.channelEventWrites = (
|
|
database
|
|
.prepare('SELECT COUNT(*) AS count FROM channel_events')
|
|
.get() as { count: number }
|
|
).count % 128
|
|
this.channelOutboxWrites = (
|
|
database
|
|
.prepare('SELECT COUNT(*) AS count FROM channel_outbox')
|
|
.get() as { count: number }
|
|
).count % 128
|
|
const count = database
|
|
.prepare('SELECT COUNT(*) AS count FROM projects')
|
|
.get() as { count: number }
|
|
if (count.count === 0) {
|
|
this.createLocalProject(
|
|
{
|
|
name: builtInDefaultProjectSeedName,
|
|
description: builtInDefaultProjectSeedDescription,
|
|
rootPath: defaultRootPath,
|
|
},
|
|
true
|
|
)
|
|
}
|
|
const expertCount = database
|
|
.prepare('SELECT COUNT(*) AS count FROM experts')
|
|
.get() as { count: number }
|
|
if (expertCount.count === 0) {
|
|
this.createExpert({
|
|
name: '研究分析专家',
|
|
description: '负责资料分析、证据整理和结论验证',
|
|
systemInstructions:
|
|
'Act as a rigorous research analyst. Separate evidence, assumptions, and conclusions. Cite provided sources and identify uncertainty.',
|
|
routingKeywords: [
|
|
'研究',
|
|
'调研',
|
|
'分析证据',
|
|
'资料分析',
|
|
'research',
|
|
'evidence',
|
|
'investigate'
|
|
]
|
|
})
|
|
this.createExpert({
|
|
name: '文档写作专家',
|
|
description: '负责结构化写作、编辑和内容润色',
|
|
systemInstructions:
|
|
'Act as a professional document editor. Produce clear structure, concise language, and actionable content appropriate to the user context.',
|
|
routingKeywords: [
|
|
'写作',
|
|
'撰写',
|
|
'润色',
|
|
'文档',
|
|
'write',
|
|
'draft',
|
|
'edit'
|
|
]
|
|
})
|
|
this.createExpert({
|
|
name: '项目规划专家',
|
|
description: '负责目标拆解、风险分析和执行计划',
|
|
systemInstructions:
|
|
'Act as a project planning specialist. Decompose goals into verifiable steps, dependencies, risks, owners, and acceptance criteria.',
|
|
routingKeywords: [
|
|
'规划',
|
|
'计划',
|
|
'拆解',
|
|
'里程碑',
|
|
'plan',
|
|
'roadmap',
|
|
'milestone'
|
|
]
|
|
})
|
|
}
|
|
const recoveredAt = new Date().toISOString()
|
|
database.exec('BEGIN IMMEDIATE')
|
|
try {
|
|
// A process interruption has no known end time. Retain only closed segments.
|
|
database.exec(`UPDATE execution_timing SET running_since = NULL, incomplete = 1
|
|
WHERE running_since IS NOT NULL`)
|
|
this.executionTiming = new ExecutionTiming(database)
|
|
database
|
|
.prepare(
|
|
`UPDATE schedule_runs
|
|
SET status = 'pending'
|
|
WHERE status = 'running'
|
|
AND NOT EXISTS (SELECT 1 FROM tasks WHERE tasks.id = schedule_runs.id)`
|
|
)
|
|
.run()
|
|
database
|
|
.prepare(
|
|
`UPDATE conversation_queue_items
|
|
SET status = 'pending'
|
|
WHERE status = 'dispatching'`
|
|
)
|
|
.run()
|
|
database
|
|
.prepare(
|
|
`DELETE FROM conversation_queue_items
|
|
WHERE source = 'user'
|
|
AND EXISTS (
|
|
SELECT 1
|
|
FROM messages
|
|
WHERE messages.conversation_id =
|
|
conversation_queue_items.conversation_id
|
|
AND messages.role = 'user'
|
|
AND json_extract(
|
|
messages.metadata_json,
|
|
'$.queueItemId'
|
|
) = conversation_queue_items.id
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM json_each(messages.metadata_json, '$.attachments') attachment
|
|
WHERE json_extract(attachment.value, '$.resourceId') IS NOT NULL
|
|
)
|
|
)`
|
|
)
|
|
.run()
|
|
// This scans every message; with no user queue item it cannot match anything.
|
|
if (database.prepare("SELECT 1 FROM conversation_queue_items WHERE source = 'user' LIMIT 1").get()) {
|
|
database.exec(`DELETE FROM messages WHERE json_extract(metadata_json, '$.queueItemId') IN (
|
|
SELECT id FROM conversation_queue_items WHERE source = 'user'
|
|
) AND EXISTS (
|
|
SELECT 1 FROM json_each(messages.metadata_json, '$.attachments') attachment
|
|
WHERE json_extract(attachment.value, '$.resourceId') IS NOT NULL
|
|
)`)
|
|
}
|
|
database.exec(`
|
|
INSERT OR IGNORE INTO conversation_queue_items
|
|
(id, conversation_id, source, label, payload_json,
|
|
schedule_run_id, schedule_id, task_id, status, created_at)
|
|
SELECT sr.id, t.conversation_id, 'schedule', t.title, '{}',
|
|
sr.id, sr.schedule_id, t.id, 'pending', sr.scheduled_for
|
|
FROM schedule_runs sr
|
|
INNER JOIN tasks t ON t.schedule_id = sr.schedule_id
|
|
WHERE sr.status = 'pending'
|
|
AND t.conversation_id IS NOT NULL;
|
|
`)
|
|
const interruptedTasks = database
|
|
.prepare(
|
|
`SELECT id, error
|
|
FROM tasks
|
|
WHERE status IN ('running', 'waiting_approval')
|
|
AND remote_recoverable = 0`
|
|
)
|
|
.all() as Array<{ id: string; error: string | null }>
|
|
const updateTask = database.prepare(
|
|
`UPDATE tasks
|
|
SET status = 'interrupted', completed_at = ?, error = ?
|
|
WHERE id = ?`
|
|
)
|
|
const insertTaskEvent = database.prepare(
|
|
`INSERT INTO task_events
|
|
(task_id, run_id, kind, payload_json, created_at)
|
|
VALUES (?, NULL, 'status', ?, ?)`
|
|
)
|
|
for (const task of interruptedTasks) {
|
|
const error = task.error ?? interruptedTaskError
|
|
updateTask.run(recoveredAt, error, task.id)
|
|
insertTaskEvent.run(
|
|
task.id,
|
|
JSON.stringify({ status: 'interrupted', error }),
|
|
recoveredAt
|
|
)
|
|
}
|
|
|
|
const recoverableMessages = database
|
|
.prepare(
|
|
// The partial index messages_recovery_idx holds exactly the rows
|
|
// matching the first term (a superset of those updated below), so
|
|
// startup no longer reads every message's metadata.
|
|
`SELECT id, state, metadata_json
|
|
FROM messages
|
|
WHERE ${MESSAGE_RECOVERY_CANDIDATE_SQL}
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM tasks
|
|
WHERE tasks.id = messages.request_id
|
|
AND tasks.remote_recoverable = 1
|
|
AND tasks.status IN (
|
|
'running', 'waiting_approval', 'interrupted'
|
|
)
|
|
)`
|
|
)
|
|
.all() as Array<{
|
|
id: string
|
|
state: MessageRow['state']
|
|
metadata_json: string
|
|
}>
|
|
const updateMessage = database.prepare(
|
|
`UPDATE messages
|
|
SET state = ?, metadata_json = ?
|
|
WHERE id = ?`
|
|
)
|
|
for (const message of recoverableMessages) {
|
|
const metadata = JSON.parse(
|
|
message.metadata_json
|
|
) as MessageMetadata
|
|
const hasActiveActivity = Boolean(
|
|
metadata.tools?.some(
|
|
(tool) =>
|
|
tool.state === 'pending' || tool.state === 'running'
|
|
) ||
|
|
metadata.blocks?.some(
|
|
(block) =>
|
|
block.type === 'tool' &&
|
|
(block.tool.state === 'pending' ||
|
|
block.tool.state === 'running')
|
|
) ||
|
|
metadata.subagents?.some(
|
|
(subagent) =>
|
|
subagent.state === 'queued' ||
|
|
subagent.state === 'running'
|
|
)
|
|
)
|
|
if (
|
|
message.state !== 'streaming' &&
|
|
!hasActiveActivity
|
|
) {
|
|
continue
|
|
}
|
|
updateMessage.run(
|
|
message.state === 'streaming' ? 'error' : message.state,
|
|
JSON.stringify({
|
|
...metadata,
|
|
status:
|
|
message.state === 'streaming'
|
|
? interruptedMessageStatus
|
|
: metadata.status,
|
|
tools: interruptActiveTools(metadata.tools),
|
|
blocks: interruptActiveToolBlocks(metadata.blocks),
|
|
subagents: interruptActiveSubagents(
|
|
metadata.subagents
|
|
)
|
|
}),
|
|
message.id
|
|
)
|
|
}
|
|
database.exec('COMMIT')
|
|
} catch (error) {
|
|
database.exec('ROLLBACK')
|
|
throw error
|
|
}
|
|
const startedSchedules = database.prepare(
|
|
`SELECT id FROM schedule_runs WHERE status = 'running'`
|
|
).all() as Array<{ id: string }>
|
|
for (const run of startedSchedules) {
|
|
this.completeTaskScheduleRun(run.id)
|
|
}
|
|
} catch (error) {
|
|
database.close()
|
|
this.database = undefined
|
|
throw error
|
|
}
|
|
}
|
|
|
|
close(): void {
|
|
this.stopWalCheckpointWorker()
|
|
this.executionTiming = undefined
|
|
this.readonlyReader?.close()
|
|
this.readonlyReader = undefined
|
|
this.supervisionWorker?.close()
|
|
this.supervisionWorker = undefined
|
|
this.foldedSearchConnection = undefined
|
|
this.activityHistoryRepository = undefined
|
|
this.database?.close()
|
|
this.database = undefined
|
|
this.subagentProgress = undefined
|
|
if (this.databasePath === ':memory:') this.noteStorage.clear()
|
|
}
|
|
|
|
clearAssistantData(): void {
|
|
const database = this.requireDatabase()
|
|
database.exec('BEGIN IMMEDIATE')
|
|
try {
|
|
for (const table of [
|
|
'magic_todos',
|
|
'magic_note_tag_links',
|
|
'magic_note_tags',
|
|
'magic_note_entries',
|
|
'magic_notes',
|
|
'heartbeat_configs',
|
|
'supervision_event_sources',
|
|
'supervision_event_entities',
|
|
'supervision_entity_changes',
|
|
'supervision_relations',
|
|
'supervision_events',
|
|
'supervision_entities',
|
|
'supervision_sources',
|
|
'supervision_results',
|
|
'supervision_runs',
|
|
'story_lines',
|
|
'delegation_outbox',
|
|
'delegations',
|
|
'notifications',
|
|
'conversation_queue_items',
|
|
'schedule_runs',
|
|
'schedules',
|
|
'memory_items',
|
|
'artifacts',
|
|
'computer_control_actions',
|
|
'task_events',
|
|
'runs',
|
|
'model_usage_calls',
|
|
'channel_outbox',
|
|
'channel_events',
|
|
'tasks',
|
|
'messages',
|
|
'conversations',
|
|
'activity_history_records'
|
|
]) {
|
|
database.exec(`DELETE FROM ${table}`)
|
|
}
|
|
database.exec(
|
|
`UPDATE activity_history
|
|
SET record_order_json = '[]', legacy_history_may_be_incomplete = 0
|
|
WHERE singleton = 1`
|
|
)
|
|
database.exec('COMMIT')
|
|
} catch (error) {
|
|
database.exec('ROLLBACK')
|
|
throw error
|
|
}
|
|
this.dirtyMagicNotes.clear()
|
|
this.activityHistoryRepository?.repository.invalidate()
|
|
this.pendingMagicNoteCleanup.clear()
|
|
this.reconcileMagicNoteFiles()
|
|
this.options.onMagicNotesChanged?.()
|
|
this.executionTiming?.changed()
|
|
}
|
|
|
|
listProjects(includeArchived = false): AssistantProject[] {
|
|
const database = this.requireDatabase()
|
|
const rows = database
|
|
.prepare(
|
|
includeArchived
|
|
? `${projectSelect}
|
|
ORDER BY projects.created_at ASC, projects.rowid ASC`
|
|
: `${projectSelect}
|
|
WHERE projects.status = 'active'
|
|
ORDER BY projects.created_at ASC, projects.rowid ASC`
|
|
)
|
|
.all() as ProjectRow[]
|
|
return rows.map(toProject)
|
|
}
|
|
|
|
ensureChannelProjects(
|
|
defaultRootPath: string,
|
|
_defaultModelProfileId?: string
|
|
): AssistantProject[] {
|
|
// Kept for callers; channel projects no longer pin the current default model.
|
|
void _defaultModelProfileId
|
|
const database = this.requireDatabase()
|
|
const definitions: ReadonlyArray<{
|
|
channel: ProjectChannel
|
|
name: string
|
|
description: string
|
|
}> = [
|
|
{
|
|
channel: 'weixin',
|
|
name: '微信 ClawBot',
|
|
description: '个人微信远程消息与受控任务'
|
|
},
|
|
{
|
|
channel: 'wecom',
|
|
name: '企业微信',
|
|
description: '企业微信远程消息与受控任务'
|
|
},
|
|
{
|
|
channel: 'dingtalk',
|
|
name: '钉钉',
|
|
description: '钉钉远程消息与受控任务'
|
|
}
|
|
]
|
|
const find = database.prepare(
|
|
'SELECT id FROM projects WHERE channel = ?'
|
|
)
|
|
const insert = database.prepare(
|
|
`INSERT INTO projects
|
|
(id, name, description, root_path,
|
|
runtime_selection_json, kind, channel, status, created_at,
|
|
updated_at)
|
|
VALUES (?, ?, ?, ?, ?, 'channel', ?, 'active', ?, ?)`
|
|
)
|
|
const insertExecutionSpace = database.prepare(
|
|
`INSERT INTO project_execution_spaces
|
|
(project_id, kind, root_path)
|
|
VALUES (?, 'local', ?)`
|
|
)
|
|
|
|
database.exec('BEGIN IMMEDIATE')
|
|
try {
|
|
for (const definition of definitions) {
|
|
if (find.get(definition.channel)) {
|
|
continue
|
|
}
|
|
const now = new Date().toISOString()
|
|
const projectId = randomUUID()
|
|
insert.run(
|
|
projectId,
|
|
definition.name,
|
|
definition.description,
|
|
defaultRootPath,
|
|
// Unattended channels start on the direct model; the model follows global settings.
|
|
JSON.stringify({ provider: 'model' }),
|
|
definition.channel,
|
|
now,
|
|
now
|
|
)
|
|
insertExecutionSpace.run(projectId, defaultRootPath)
|
|
}
|
|
database.exec('COMMIT')
|
|
} catch (error) {
|
|
database.exec('ROLLBACK')
|
|
throw error
|
|
}
|
|
|
|
return definitions.map((definition) => {
|
|
const row = find.get(definition.channel) as
|
|
| { id: string }
|
|
| undefined
|
|
if (!row) {
|
|
throw new Error(`未能创建${definition.name}通道项目`)
|
|
}
|
|
return this.getProject(row.id)
|
|
})
|
|
}
|
|
|
|
createProject(input: ProjectCreateInput): AssistantProject {
|
|
return this.createLocalProject(input, false)
|
|
}
|
|
|
|
private createLocalProject(
|
|
input: ProjectCreateInput,
|
|
builtInDefault: boolean
|
|
): AssistantProject {
|
|
const database = this.requireDatabase()
|
|
const id = randomUUID()
|
|
const now = new Date().toISOString()
|
|
database.exec('BEGIN IMMEDIATE')
|
|
try {
|
|
database
|
|
.prepare(
|
|
`INSERT INTO projects
|
|
(id, name, description, root_path,
|
|
runtime_selection_json, kind, channel, built_in_default,
|
|
status, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, 'user', NULL, ?, 'active', ?, ?)`
|
|
)
|
|
.run(
|
|
id,
|
|
input.name,
|
|
input.description,
|
|
input.rootPath,
|
|
serializeRuntimeSelection(input.runtimeSelection),
|
|
builtInDefault ? 1 : 0,
|
|
now,
|
|
now
|
|
)
|
|
database
|
|
.prepare(
|
|
`INSERT INTO project_execution_spaces
|
|
(project_id, kind, root_path)
|
|
VALUES (?, 'local', ?)`
|
|
)
|
|
.run(id, input.rootPath)
|
|
database.exec('COMMIT')
|
|
} catch (error) {
|
|
database.exec('ROLLBACK')
|
|
throw error
|
|
}
|
|
return this.getProject(id)
|
|
}
|
|
|
|
updateProject(
|
|
projectId: string,
|
|
input: ProjectCreateInput
|
|
): AssistantProject {
|
|
const database = this.requireDatabase()
|
|
const current = this.getProject(projectId)
|
|
if (
|
|
current.kind === 'channel' &&
|
|
input.rootPath.trim().length === 0
|
|
) {
|
|
throw new Error('通道项目必须设置默认工作目录')
|
|
}
|
|
if (current.executionSpace.kind !== 'local') {
|
|
throw new Error('远程项目必须通过远程执行空间验证流程更新')
|
|
}
|
|
database.exec('BEGIN IMMEDIATE')
|
|
try {
|
|
const result = database
|
|
.prepare(
|
|
`UPDATE projects
|
|
SET name = ?, description = ?, root_path = ?,
|
|
runtime_selection_json = ?,
|
|
updated_at = ?
|
|
WHERE id = ?`
|
|
)
|
|
.run(
|
|
current.kind === 'channel' ? current.name : input.name,
|
|
input.description,
|
|
input.rootPath,
|
|
// The settings form always sends the full layer; absent means follow global.
|
|
serializeRuntimeSelection(input.runtimeSelection),
|
|
new Date().toISOString(),
|
|
projectId
|
|
)
|
|
if (result.changes !== 1) {
|
|
throw new Error('项目不存在')
|
|
}
|
|
const executionSpaceResult = database
|
|
.prepare(
|
|
`UPDATE project_execution_spaces
|
|
SET root_path = ?
|
|
WHERE project_id = ? AND kind = 'local'`
|
|
)
|
|
.run(input.rootPath, projectId)
|
|
if (executionSpaceResult.changes !== 1) {
|
|
throw new Error('项目执行空间配置无效')
|
|
}
|
|
database.exec('COMMIT')
|
|
} catch (error) {
|
|
database.exec('ROLLBACK')
|
|
throw error
|
|
}
|
|
return this.getProject(projectId)
|
|
}
|
|
|
|
createSshProject(
|
|
write: SshProjectWrite
|
|
): AssistantProject {
|
|
const normalized = normalizeSshProjectWrite(write)
|
|
const database = this.requireDatabase()
|
|
const id = randomUUID()
|
|
const now = new Date().toISOString()
|
|
database.exec('BEGIN IMMEDIATE')
|
|
let transactionActive = true
|
|
try {
|
|
normalized.assertCurrent()
|
|
database
|
|
.prepare(
|
|
`INSERT INTO projects
|
|
(id, name, description, root_path,
|
|
runtime_selection_json, kind, channel, built_in_default,
|
|
status, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, 'user', NULL, 0, 'active', ?, ?)`
|
|
)
|
|
.run(
|
|
id,
|
|
normalized.project.name,
|
|
normalized.project.description,
|
|
normalized.executionSpace.remoteRootPath,
|
|
serializeRuntimeSelection(normalized.project.runtimeSelection),
|
|
now,
|
|
now
|
|
)
|
|
insertSshExecutionSpace(
|
|
database,
|
|
id,
|
|
normalized.executionSpace
|
|
)
|
|
const project = readProject(database, id)
|
|
database.exec('COMMIT')
|
|
transactionActive = false
|
|
return project
|
|
} catch (error) {
|
|
if (transactionActive && database.isTransaction) {
|
|
database.exec('ROLLBACK')
|
|
}
|
|
throw error
|
|
}
|
|
}
|
|
|
|
updateSshProject(
|
|
projectId: string,
|
|
expectedUpdatedAt: string,
|
|
write: SshProjectWrite
|
|
): AssistantProject {
|
|
const normalized = normalizeSshProjectWrite(write)
|
|
const database = this.requireDatabase()
|
|
database.exec('BEGIN IMMEDIATE')
|
|
let transactionActive = true
|
|
try {
|
|
const current = database
|
|
.prepare(
|
|
`SELECT projects.kind, projects.channel, projects.updated_at,
|
|
project_execution_spaces.kind AS execution_space_kind,
|
|
project_execution_spaces.ssh_host_id,
|
|
project_execution_spaces.root_path
|
|
FROM projects
|
|
LEFT JOIN project_execution_spaces
|
|
ON project_execution_spaces.project_id = projects.id
|
|
WHERE projects.id = ?`
|
|
)
|
|
.get(projectId) as
|
|
| {
|
|
kind: AssistantProject['kind']
|
|
channel: ProjectChannel | null
|
|
updated_at: string
|
|
execution_space_kind: string | null
|
|
ssh_host_id: string | null
|
|
root_path: string | null
|
|
}
|
|
| undefined
|
|
if (!current) {
|
|
throw new Error('项目不存在')
|
|
}
|
|
if (current.kind === 'channel' || current.channel !== null) {
|
|
throw new Error('系统通道项目不支持远程执行空间')
|
|
}
|
|
if (
|
|
current.execution_space_kind !== 'ssh' ||
|
|
current.ssh_host_id === null ||
|
|
current.root_path === null
|
|
) {
|
|
throw new Error('仅支持更新已有远程项目')
|
|
}
|
|
if (
|
|
current.ssh_host_id !== normalized.executionSpace.hostId ||
|
|
current.root_path !==
|
|
normalized.executionSpace.remoteRootPath
|
|
) {
|
|
throw new Error('远程项目执行位置不能通过此更新修改')
|
|
}
|
|
if (current.updated_at !== expectedUpdatedAt) {
|
|
throw new Error('项目已被其他操作更新,请重新加载后重试')
|
|
}
|
|
normalized.assertCurrent()
|
|
const updatedAt = nextProjectUpdatedAt(current.updated_at)
|
|
const projectResult = database
|
|
.prepare(
|
|
`UPDATE projects
|
|
SET name = ?, description = ?, root_path = ?,
|
|
runtime_selection_json = ?,
|
|
updated_at = ?
|
|
WHERE id = ? AND kind = 'user' AND channel IS NULL
|
|
AND updated_at = ?`
|
|
)
|
|
.run(
|
|
normalized.project.name,
|
|
normalized.project.description,
|
|
normalized.executionSpace.remoteRootPath,
|
|
serializeRuntimeSelection(normalized.project.runtimeSelection),
|
|
updatedAt,
|
|
projectId,
|
|
expectedUpdatedAt
|
|
)
|
|
if (projectResult.changes !== 1) {
|
|
throw new Error('项目已被其他操作更新,请重新加载后重试')
|
|
}
|
|
const executionResult = updateSshExecutionSpace(
|
|
database,
|
|
projectId,
|
|
normalized.executionSpace
|
|
)
|
|
if (executionResult !== 1) {
|
|
throw new Error('项目执行空间配置无效')
|
|
}
|
|
const project = readProject(database, projectId)
|
|
database.exec('COMMIT')
|
|
transactionActive = false
|
|
return project
|
|
} catch (error) {
|
|
if (transactionActive && database.isTransaction) {
|
|
database.exec('ROLLBACK')
|
|
}
|
|
throw error
|
|
}
|
|
}
|
|
|
|
listProjectsReferencingSshHost(
|
|
sshHostId: string
|
|
): SshHostProjectReference[] {
|
|
return (
|
|
this.requireDatabase()
|
|
.prepare(
|
|
`SELECT projects.id, projects.name
|
|
FROM project_execution_spaces
|
|
INNER JOIN projects
|
|
ON projects.id = project_execution_spaces.project_id
|
|
WHERE project_execution_spaces.kind = 'ssh'
|
|
AND project_execution_spaces.ssh_host_id = ?
|
|
ORDER BY projects.created_at ASC, projects.rowid ASC`
|
|
)
|
|
.all(sshHostId) as SshHostProjectReference[]
|
|
)
|
|
}
|
|
|
|
listSshHostProjectReferences(): Array<{
|
|
hostId: string
|
|
} & SshHostProjectReference> {
|
|
return this.requireDatabase()
|
|
.prepare(
|
|
`SELECT project_execution_spaces.ssh_host_id AS "hostId",
|
|
projects.id, projects.name
|
|
FROM project_execution_spaces
|
|
INNER JOIN projects
|
|
ON projects.id = project_execution_spaces.project_id
|
|
WHERE project_execution_spaces.kind = 'ssh'
|
|
AND project_execution_spaces.ssh_host_id IS NOT NULL
|
|
ORDER BY projects.created_at ASC, projects.rowid ASC`
|
|
)
|
|
.all() as Array<{ hostId: string } & SshHostProjectReference>
|
|
}
|
|
|
|
deleteProjectsReferencingSshHost(
|
|
sshHostId: string
|
|
): SshHostProjectReference[] {
|
|
const database = this.requireDatabase()
|
|
database.exec('BEGIN IMMEDIATE')
|
|
try {
|
|
const projects = this.listProjectsReferencingSshHost(sshHostId)
|
|
if (projects.length === 0) {
|
|
database.exec('COMMIT')
|
|
return []
|
|
}
|
|
const activeLocalProjectCount = database
|
|
.prepare(
|
|
`SELECT COUNT(*) AS count
|
|
FROM projects
|
|
INNER JOIN project_execution_spaces
|
|
ON project_execution_spaces.project_id = projects.id
|
|
WHERE projects.kind = 'user'
|
|
AND projects.status = 'active'
|
|
AND project_execution_spaces.kind = 'local'`
|
|
)
|
|
.get() as { count: number }
|
|
const deletedActiveProjectCount = database
|
|
.prepare(
|
|
`SELECT COUNT(*) AS count
|
|
FROM projects
|
|
INNER JOIN project_execution_spaces
|
|
ON project_execution_spaces.project_id = projects.id
|
|
WHERE projects.kind = 'user'
|
|
AND projects.status = 'active'
|
|
AND project_execution_spaces.kind = 'ssh'
|
|
AND project_execution_spaces.ssh_host_id = ?`
|
|
)
|
|
.get(sshHostId) as { count: number }
|
|
if (
|
|
deletedActiveProjectCount.count > 0 &&
|
|
activeLocalProjectCount.count < 1
|
|
) {
|
|
throw new Error('至少需要保留一个可用项目')
|
|
}
|
|
const statements = prepareProjectDeletionStatements(database)
|
|
for (const project of projects) {
|
|
deleteProjectRecords(database, project.id, statements)
|
|
}
|
|
database.exec('COMMIT')
|
|
return projects
|
|
} catch (error) {
|
|
database.exec('ROLLBACK')
|
|
throw error
|
|
}
|
|
}
|
|
|
|
setProjectArchived(projectId: string, archived: boolean): void {
|
|
const database = this.requireDatabase()
|
|
if (this.getProject(projectId).kind === 'channel') {
|
|
throw new Error('系统通道项目不能归档')
|
|
}
|
|
const result = database
|
|
.prepare(
|
|
`UPDATE projects
|
|
SET status = ?, updated_at = ?
|
|
WHERE id = ?`
|
|
)
|
|
.run(
|
|
archived ? 'archived' : 'active',
|
|
new Date().toISOString(),
|
|
projectId
|
|
)
|
|
if (result.changes !== 1) {
|
|
throw new Error('项目不存在')
|
|
}
|
|
}
|
|
|
|
deleteProject(
|
|
projectId: string,
|
|
confirmation: string,
|
|
options: { allowActiveTasks?: boolean } = {}
|
|
): void {
|
|
const database = this.requireDatabase()
|
|
database.exec('BEGIN IMMEDIATE')
|
|
try {
|
|
const project = database
|
|
.prepare('SELECT name, kind, status FROM projects WHERE id = ?')
|
|
.get(projectId) as
|
|
| {
|
|
name: string
|
|
kind: AssistantProject['kind']
|
|
status: AssistantProject['status']
|
|
}
|
|
| undefined
|
|
if (!project) {
|
|
throw new Error('项目不存在')
|
|
}
|
|
if (project.kind === 'channel') {
|
|
throw new Error('系统通道项目不能删除')
|
|
}
|
|
if (confirmation !== project.name) {
|
|
throw new Error('项目名称确认不匹配')
|
|
}
|
|
const activeLocalProjectCount = database
|
|
.prepare(
|
|
`SELECT COUNT(*) AS count
|
|
FROM projects
|
|
INNER JOIN project_execution_spaces
|
|
ON project_execution_spaces.project_id = projects.id
|
|
WHERE projects.kind = 'user'
|
|
AND projects.status = 'active'
|
|
AND project_execution_spaces.kind = 'local'`
|
|
)
|
|
.get() as { count: number }
|
|
const projectIsActiveLocal =
|
|
project.status === 'active' &&
|
|
database
|
|
.prepare(
|
|
`SELECT kind FROM project_execution_spaces
|
|
WHERE project_id = ?`
|
|
)
|
|
.get(projectId)?.kind === 'local'
|
|
if (
|
|
project.status === 'active' &&
|
|
activeLocalProjectCount.count <
|
|
(projectIsActiveLocal ? 2 : 1)
|
|
) {
|
|
throw new Error('至少需要保留一个可用项目')
|
|
}
|
|
const activeTaskCount = database
|
|
.prepare(
|
|
`SELECT COUNT(*) AS count FROM tasks
|
|
WHERE project_id = ?
|
|
AND visible = 1
|
|
AND (
|
|
status IN ('running', 'waiting_approval')
|
|
OR (
|
|
origin != 'schedule'
|
|
AND status IN ('queued', 'paused')
|
|
)
|
|
)`
|
|
)
|
|
.get(projectId) as { count: number }
|
|
if (
|
|
activeTaskCount.count > 0 &&
|
|
options.allowActiveTasks !== true
|
|
) {
|
|
throw new Error('项目仍有进行中的任务,请先停止任务')
|
|
}
|
|
|
|
deleteProjectRecords(database, projectId)
|
|
database.exec('COMMIT')
|
|
this.executionTiming?.changed()
|
|
} catch (error) {
|
|
database.exec('ROLLBACK')
|
|
throw error
|
|
}
|
|
}
|
|
|
|
listConversations(): ConversationSnapshot[] {
|
|
return this.readConversationList()
|
|
}
|
|
|
|
listConversationSummaries(detailIds: string[] = []): import('../../shared/assistant-contracts').ConversationListSnapshot[] {
|
|
return this.readConversationList(new Set(detailIds))
|
|
}
|
|
|
|
/**
|
|
* Conversation summaries on the readonly worker (PERF-15). Every write runs
|
|
* synchronously on Main, so once no transaction is open, everything written
|
|
* before this call is committed and visible to the worker. Inside an open
|
|
* transaction the synchronous path keeps read-your-writes semantics.
|
|
*/
|
|
listConversationSummariesAsync(detailIds: string[] = [], signal?: AbortSignal): Promise<import('../../shared/assistant-contracts').ConversationListSnapshot[]> {
|
|
const database = this.requireDatabase()
|
|
return readWithFallback(database.isTransaction ? undefined : this.readonlyQueryReader(),
|
|
'listConversationSummaries', [detailIds], signal, () => this.listConversationSummaries(detailIds))
|
|
}
|
|
|
|
/** Full conversation history on the readonly worker; same rules as listConversationSummariesAsync. */
|
|
getConversationAsync(conversationId: string, signal?: AbortSignal): Promise<ConversationSnapshot> {
|
|
const database = this.requireDatabase()
|
|
return readWithFallback(database.isTransaction ? undefined : this.readonlyQueryReader(),
|
|
'getConversation', [conversationId], signal, () => this.getConversation(conversationId))
|
|
}
|
|
|
|
private listConversationRows(): ConversationRow[] {
|
|
const database = this.requireDatabase()
|
|
return database
|
|
.prepare(
|
|
`SELECT id, project_id, runtime_selection_json,
|
|
knowledge_retrieval_mode, context_state_json, title, channel,
|
|
external_account_id, external_conversation_id,
|
|
conversation_type, account_display,
|
|
branch_source_conversation_id, branch_source_title,
|
|
pinned, updated_at
|
|
FROM conversations
|
|
WHERE status = 'active'
|
|
ORDER BY pinned DESC, updated_at DESC`
|
|
)
|
|
.all() as ConversationRow[]
|
|
}
|
|
|
|
private readConversationList(detailIds?: ReadonlySet<string>): import('../../shared/assistant-contracts').ConversationListSnapshot[] {
|
|
const database = this.requireDatabase()
|
|
const conversations = this.listConversationRows()
|
|
type MessageSummaryRow = {
|
|
conversation_id: string; count: number
|
|
firstRole: ConversationMessage['role'] | null; latestMessageAt: string | null
|
|
}
|
|
// Batched reads (PERF-15): one aggregate for every summary and one ordered
|
|
// scan for every detailed conversation, instead of 1-2 statements each.
|
|
const summaries = new Map<string, MessageSummaryRow>()
|
|
if (detailIds) {
|
|
for (const row of database.prepare(
|
|
`SELECT m.conversation_id AS conversation_id, count(*) AS count,
|
|
max(m.created_at) AS latestMessageAt,
|
|
(SELECT f.role FROM messages AS f INDEXED BY messages_summary_idx WHERE f.conversation_id = m.conversation_id
|
|
ORDER BY f.sequence LIMIT 1) AS firstRole
|
|
FROM messages AS m INDEXED BY messages_summary_idx JOIN conversations c ON c.id = m.conversation_id
|
|
WHERE c.status = 'active'
|
|
GROUP BY m.conversation_id`
|
|
).all() as MessageSummaryRow[]) summaries.set(row.conversation_id, row)
|
|
}
|
|
const summaryOf = (id: string): MessageSummaryRow => summaries.get(id) ??
|
|
{ conversation_id: id, count: 0, firstRole: null, latestMessageAt: null }
|
|
const detailed = conversations.filter(conversation =>
|
|
!detailIds || detailIds.has(conversation.id))
|
|
const messagesByConversation = new Map<string, MessageRow[]>()
|
|
if (detailed.length > 0) {
|
|
for (const row of database.prepare(
|
|
`SELECT id, conversation_id, role, content, state, metadata_json, created_at
|
|
FROM messages
|
|
WHERE conversation_id IN (SELECT value FROM json_each(?))
|
|
ORDER BY conversation_id, sequence ASC`
|
|
).all(JSON.stringify(detailed.map(conversation => conversation.id))) as MessageRow[]) {
|
|
const list = messagesByConversation.get(row.conversation_id)
|
|
if (list) list.push(row)
|
|
else messagesByConversation.set(row.conversation_id, [row])
|
|
}
|
|
}
|
|
const detailedIds = new Set(detailed.map(conversation => conversation.id))
|
|
return conversations.map((conversation) => {
|
|
if (!detailedIds.has(conversation.id)) {
|
|
const summary = summaryOf(conversation.id)
|
|
return {
|
|
...toConversationSnapshot(conversation, []),
|
|
messageSummary: {
|
|
count: summary.count,
|
|
firstRole: summary.firstRole ?? undefined,
|
|
latestMessageAt: summary.latestMessageAt === null ? undefined : Date.parse(summary.latestMessageAt)
|
|
}
|
|
}
|
|
}
|
|
return toConversationSnapshot(conversation, messagesByConversation.get(conversation.id) ?? [])
|
|
})
|
|
}
|
|
|
|
/** Registers the Unicode case-folding matcher used by conversation search. */
|
|
private foldedSearchDatabase(): DatabaseSync {
|
|
const database = this.requireDatabase()
|
|
if (this.foldedSearchConnection !== database) {
|
|
// Same folding as the renderer's search (String#toLocaleLowerCase), which
|
|
// SQLite's ASCII-only lower()/LIKE cannot reproduce for non-ASCII text.
|
|
database.function('goodbuddy_folded_includes', { deterministic: true }, (text, needle) =>
|
|
typeof text === 'string' && typeof needle === 'string' &&
|
|
text.toLocaleLowerCase().includes(needle) ? 1 : 0)
|
|
this.foldedSearchConnection = database
|
|
}
|
|
return database
|
|
}
|
|
|
|
searchConversations(query: string): string[] {
|
|
const normalized = query.trim().toLocaleLowerCase()
|
|
if (!normalized) return []
|
|
// Read only searchable text, never tool/subagent/image metadata. One query
|
|
// with the same Unicode case folding as the renderer's existing search.
|
|
return (this.foldedSearchDatabase().prepare(
|
|
`SELECT c.id FROM conversations c
|
|
WHERE c.status = 'active' AND (
|
|
goodbuddy_folded_includes(c.title, ?1)
|
|
OR EXISTS (SELECT 1 FROM messages m WHERE m.conversation_id = c.id
|
|
AND goodbuddy_folded_includes(m.content, ?1)))
|
|
ORDER BY c.pinned DESC, c.updated_at DESC`
|
|
).all(normalized) as { id: string }[]).map(row => row.id)
|
|
}
|
|
|
|
/** Conversation search on the readonly worker; falls back to the synchronous query. */
|
|
searchConversationsAsync(query: string, signal?: AbortSignal): Promise<string[]> {
|
|
this.requireDatabase()
|
|
if (!query.trim()) return Promise.resolve([])
|
|
return readWithFallback(this.readonlyQueryReader(), 'searchConversations', [query], signal,
|
|
() => this.searchConversations(query))
|
|
}
|
|
|
|
getConversation(conversationId: string): ConversationSnapshot {
|
|
const database = this.requireDatabase()
|
|
const conversation = database
|
|
.prepare(
|
|
`SELECT id, project_id, runtime_selection_json,
|
|
knowledge_retrieval_mode, context_state_json, title, channel,
|
|
external_account_id, external_conversation_id,
|
|
conversation_type, account_display,
|
|
branch_source_conversation_id, branch_source_title,
|
|
pinned, updated_at
|
|
FROM conversations
|
|
WHERE id = ? AND status = 'active'`
|
|
)
|
|
.get(conversationId) as ConversationRow | undefined
|
|
if (!conversation) {
|
|
throw new Error('对话不存在')
|
|
}
|
|
const messages = database
|
|
.prepare(
|
|
`SELECT id, conversation_id, role, content, state, metadata_json,
|
|
created_at
|
|
FROM (
|
|
SELECT id, conversation_id, role, content, state,
|
|
metadata_json, created_at, sequence
|
|
FROM messages
|
|
WHERE conversation_id = ?
|
|
ORDER BY sequence DESC
|
|
)
|
|
ORDER BY sequence ASC`
|
|
)
|
|
.all(conversationId) as MessageRow[]
|
|
return toConversationSnapshot(conversation, messages)
|
|
}
|
|
|
|
setConversationPinned(input: ConversationSetPinnedInput): void {
|
|
const { conversationId, pinned } = conversationSetPinnedSchema.parse(input)
|
|
const result = this.requireDatabase().prepare(
|
|
`UPDATE conversations SET pinned = ? WHERE id = ? AND status = 'active'`
|
|
).run(pinned ? 1 : 0, conversationId)
|
|
if (result.changes !== 1) {
|
|
throw new Error('对话不存在')
|
|
}
|
|
}
|
|
|
|
isConversationStoryGraphEnabled(conversationId: string): boolean {
|
|
const row = this.requireDatabase().prepare(
|
|
"SELECT context_state_json FROM conversations WHERE id = ? AND status = 'active'"
|
|
).get(conversationId) as { context_state_json: string | null } | undefined
|
|
return Boolean(row && parseConversationContextState(row.context_state_json).storyGraphEnabled !== false)
|
|
}
|
|
|
|
setConversationStoryGraphEnabled(conversationId: string, enabled: boolean): void {
|
|
const result = this.requireDatabase().prepare(
|
|
`UPDATE conversations
|
|
SET context_state_json = json_set(COALESCE(context_state_json, '{}'), '$.storyGraphEnabled', json(?))
|
|
WHERE id = ? AND status = 'active'`
|
|
).run(JSON.stringify(enabled), conversationId)
|
|
if (result.changes !== 1) throw new Error('对话不存在')
|
|
}
|
|
|
|
/**
|
|
* Clears references to deleted model connections in every project and
|
|
* conversation so they fall back to the next layer instead of failing.
|
|
* Also rewrites legacy stored shapes to the layered form.
|
|
*/
|
|
repairConversationRuntimeSelections(
|
|
settings: Pick<RuntimeResolutionSettings, 'modelProfiles'>
|
|
): number {
|
|
const database = this.requireDatabase()
|
|
const projects = database
|
|
.prepare(
|
|
`SELECT id, runtime_selection_json
|
|
FROM projects
|
|
WHERE runtime_selection_json IS NOT NULL`
|
|
)
|
|
.all() as Array<{ id: string; runtime_selection_json: string }>
|
|
const conversations = database
|
|
.prepare(
|
|
`SELECT id, runtime_selection_json
|
|
FROM conversations
|
|
WHERE runtime_selection_json IS NOT NULL`
|
|
)
|
|
.all() as Array<{ id: string; runtime_selection_json: string }>
|
|
const update = database.prepare(
|
|
`UPDATE conversations
|
|
SET runtime_selection_json = ?
|
|
WHERE id = ?`
|
|
)
|
|
const updateProject = database.prepare(
|
|
`UPDATE projects
|
|
SET runtime_selection_json = ?, updated_at = ?
|
|
WHERE id = ?`
|
|
)
|
|
const repairedJson = (stored: string): string | null | false => {
|
|
const next = serializeRuntimeSelection(
|
|
repairRuntimeSelectionLayer(parseRuntimeSelection(stored), settings)
|
|
)
|
|
return next === stored ? false : next
|
|
}
|
|
let repaired = 0
|
|
database.exec('BEGIN IMMEDIATE')
|
|
try {
|
|
for (const project of projects) {
|
|
const next = repairedJson(project.runtime_selection_json)
|
|
if (next === false) continue
|
|
updateProject.run(next, new Date().toISOString(), project.id)
|
|
repaired += 1
|
|
}
|
|
for (const conversation of conversations) {
|
|
const next = repairedJson(conversation.runtime_selection_json)
|
|
if (next === false) continue
|
|
update.run(next, conversation.id)
|
|
repaired += 1
|
|
}
|
|
database.exec('COMMIT')
|
|
} catch (error) {
|
|
database.exec('ROLLBACK')
|
|
throw error
|
|
}
|
|
return repaired
|
|
}
|
|
|
|
replaceConversations(
|
|
conversations: ConversationSnapshot[]
|
|
): void {
|
|
const database = this.requireDatabase()
|
|
database.exec('BEGIN IMMEDIATE')
|
|
try {
|
|
database.exec(`
|
|
DELETE FROM messages
|
|
WHERE conversation_id IN (
|
|
SELECT id FROM conversations WHERE channel IS NULL
|
|
);
|
|
DELETE FROM conversations WHERE channel IS NULL;
|
|
`)
|
|
const insertConversation = database.prepare(
|
|
`INSERT INTO conversations
|
|
(id, project_id, runtime_selection_json, knowledge_retrieval_mode,
|
|
context_state_json, title,
|
|
branch_source_conversation_id, branch_source_title, status,
|
|
created_at, updated_at, timing_incomplete)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, 1)`
|
|
)
|
|
const insertMessage = database.prepare(
|
|
`INSERT INTO messages
|
|
(id, conversation_id, request_id, role, content, state, sequence,
|
|
metadata_json, created_at)
|
|
VALUES (?, ?, NULL, ?, ?, ?, ?, ?, ?)`
|
|
)
|
|
for (const conversation of conversations.slice(0, 100)) {
|
|
if (conversation.remote) {
|
|
continue
|
|
}
|
|
const updatedAt = new Date(conversation.updatedAt).toISOString()
|
|
insertConversation.run(
|
|
conversation.id,
|
|
conversation.projectId ?? null,
|
|
serializeRuntimeSelection(conversation.runtimeSelection),
|
|
conversation.knowledgeRetrievalMode ?? null,
|
|
serializeConversationContextState(conversation),
|
|
conversation.title,
|
|
conversation.branch?.sourceConversationId ?? null,
|
|
conversation.branch?.sourceTitle ?? null,
|
|
updatedAt,
|
|
updatedAt
|
|
)
|
|
for (const [sequence, message] of conversation.messages
|
|
.entries()) {
|
|
insertMessage.run(
|
|
message.id,
|
|
conversation.id,
|
|
message.role,
|
|
message.content,
|
|
message.state,
|
|
sequence,
|
|
serializeConversationMessageMetadata(message),
|
|
new Date(message.createdAt).toISOString()
|
|
)
|
|
}
|
|
}
|
|
database.exec('COMMIT')
|
|
this.executionTiming?.changed()
|
|
} catch (error) {
|
|
database.exec('ROLLBACK')
|
|
throw error
|
|
}
|
|
}
|
|
|
|
saveLocalConversations(batch: LocalConversationSaveBatch): void {
|
|
// The renderer saves every 500 ms and retries until acknowledged.
|
|
this.relaxedWrite(() => this.saveLocalConversationsNow(batch))
|
|
}
|
|
|
|
private saveLocalConversationsNow(batch: LocalConversationSaveBatch): void {
|
|
const database = this.requireDatabase()
|
|
const findConversation = database.prepare(
|
|
'SELECT channel, context_state_json FROM conversations WHERE id = ?'
|
|
)
|
|
const insertConversation = database.prepare(
|
|
`INSERT INTO conversations
|
|
(id, project_id, runtime_selection_json, knowledge_retrieval_mode,
|
|
context_state_json, title,
|
|
branch_source_conversation_id, branch_source_title, status,
|
|
created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?)`
|
|
)
|
|
const updateConversation = database.prepare(
|
|
`UPDATE conversations
|
|
SET project_id = ?, runtime_selection_json = ?,
|
|
knowledge_retrieval_mode = ?, context_state_json = ?,
|
|
title = ?, branch_source_conversation_id = ?,
|
|
branch_source_title = ?, status = 'active',
|
|
updated_at = MAX(updated_at, ?)
|
|
WHERE id = ? AND channel IS NULL`
|
|
)
|
|
const findMessage = database.prepare(
|
|
`SELECT conversation_id, role, metadata_json,
|
|
EXISTS(
|
|
SELECT 1 FROM tasks
|
|
WHERE tasks.id = messages.request_id
|
|
AND tasks.remote_recoverable = 1
|
|
) AS remote_recoverable
|
|
FROM messages
|
|
WHERE id = ?`
|
|
)
|
|
const nextSequence = database.prepare(
|
|
`SELECT COALESCE(MAX(sequence), -1) + 1 AS sequence
|
|
FROM messages
|
|
WHERE conversation_id = ?`
|
|
)
|
|
const insertMessage = database.prepare(
|
|
`INSERT INTO messages
|
|
(id, conversation_id, request_id, role, content, state, sequence,
|
|
metadata_json, created_at)
|
|
VALUES (?, ?, NULL, ?, ?, ?, ?, ?, ?)`
|
|
)
|
|
const updateMessage = database.prepare(
|
|
`UPDATE messages
|
|
SET content = ?, state = ?, metadata_json = ?
|
|
WHERE id = ?`
|
|
)
|
|
|
|
database.exec('BEGIN IMMEDIATE')
|
|
try {
|
|
for (const save of batch) {
|
|
const { header } = save
|
|
const existingConversation = findConversation.get(
|
|
header.id
|
|
) as { channel: ProjectChannel | null; context_state_json: string | null } | undefined
|
|
const updatedAt = new Date(header.updatedAt).toISOString()
|
|
if (existingConversation?.channel) {
|
|
throw new Error('本地对话 ID 与远程对话冲突')
|
|
}
|
|
if (existingConversation) {
|
|
const result = updateConversation.run(
|
|
header.projectId ?? null,
|
|
serializeRuntimeSelection(header.runtimeSelection),
|
|
header.knowledgeRetrievalMode ?? null,
|
|
// The explicit switch owns this preference; a queued older header must not undo it.
|
|
serializeConversationContextState({ ...header,
|
|
storyGraphEnabled: parseConversationContextState(existingConversation.context_state_json).storyGraphEnabled }),
|
|
header.title,
|
|
header.branch?.sourceConversationId ?? null,
|
|
header.branch?.sourceTitle ?? null,
|
|
updatedAt,
|
|
header.id
|
|
)
|
|
if (result.changes !== 1) {
|
|
throw new Error('无法更新本地对话')
|
|
}
|
|
} else {
|
|
insertConversation.run(
|
|
header.id,
|
|
header.projectId ?? null,
|
|
serializeRuntimeSelection(header.runtimeSelection),
|
|
header.knowledgeRetrievalMode ?? null,
|
|
serializeConversationContextState(header),
|
|
header.title,
|
|
header.branch?.sourceConversationId ?? null,
|
|
header.branch?.sourceTitle ?? null,
|
|
updatedAt,
|
|
updatedAt
|
|
)
|
|
}
|
|
|
|
let sequence = (
|
|
nextSequence.get(header.id) as { sequence: number }
|
|
).sequence
|
|
for (const message of save.messages) {
|
|
const existingMessage = findMessage.get(message.id) as
|
|
| {
|
|
conversation_id: string
|
|
role: MessageRow['role']
|
|
metadata_json: string
|
|
remote_recoverable: number
|
|
}
|
|
| undefined
|
|
if (existingMessage) {
|
|
if (existingMessage.conversation_id !== header.id) {
|
|
throw new Error('消息 ID 已属于其他对话')
|
|
}
|
|
if (existingMessage.role !== message.role) {
|
|
throw new Error('消息角色不能更改')
|
|
}
|
|
if (existingMessage.remote_recoverable === 1) {
|
|
continue
|
|
}
|
|
updateMessage.run(
|
|
message.content,
|
|
message.state,
|
|
serializeConversationMessageMetadata({
|
|
...message,
|
|
...(() => {
|
|
const stored = JSON.parse(existingMessage.metadata_json) as MessageMetadata
|
|
return {
|
|
imageOperations: stored.imageOperations,
|
|
imageSourceArtifactIds: stored.imageSourceArtifactIds,
|
|
artifactIds: stored.imageOperations?.some(operation => operation.artifactIds.length) ? [...new Set([
|
|
...(message.artifactIds ?? []),
|
|
...(stored.imageOperations?.flatMap(operation => operation.artifactIds) ?? [])
|
|
])].slice(-8) : message.artifactIds
|
|
}
|
|
})()
|
|
}),
|
|
message.id
|
|
)
|
|
continue
|
|
}
|
|
insertMessage.run(
|
|
message.id,
|
|
header.id,
|
|
message.role,
|
|
message.content,
|
|
message.state,
|
|
sequence,
|
|
serializeConversationMessageMetadata(message),
|
|
new Date(message.createdAt).toISOString()
|
|
)
|
|
sequence += 1
|
|
}
|
|
}
|
|
database.exec('COMMIT')
|
|
} catch (error) {
|
|
database.exec('ROLLBACK')
|
|
throw error
|
|
}
|
|
}
|
|
|
|
branchLocalConversation(
|
|
input: ConversationBranchInput
|
|
): ConversationSnapshot {
|
|
const database = this.requireDatabase()
|
|
const destinationConversationId = randomUUID()
|
|
const now = new Date().toISOString()
|
|
database.exec('BEGIN IMMEDIATE')
|
|
try {
|
|
const source = database
|
|
.prepare(
|
|
`SELECT id, project_id, runtime_selection_json,
|
|
knowledge_retrieval_mode, context_state_json, title
|
|
FROM conversations
|
|
WHERE id = ? AND status = 'active' AND channel IS NULL`
|
|
)
|
|
.get(input.sourceConversationId) as
|
|
| {
|
|
id: string
|
|
project_id: string | null
|
|
runtime_selection_json: string | null
|
|
knowledge_retrieval_mode: 'auto' | 'always' | null
|
|
context_state_json: string | null
|
|
title: string
|
|
}
|
|
| undefined
|
|
if (!source) {
|
|
throw new Error('只能从现有的本地会话创建分支')
|
|
}
|
|
const activeTask = database
|
|
.prepare(
|
|
`SELECT 1
|
|
FROM tasks
|
|
WHERE conversation_id = ?
|
|
AND status IN ('running', 'waiting_approval')
|
|
LIMIT 1`
|
|
)
|
|
.get(source.id)
|
|
if (activeTask) {
|
|
throw new Error('当前会话仍有正在运行的任务,请等待完成后再创建分支')
|
|
}
|
|
const queuedItem = database
|
|
.prepare(
|
|
`SELECT 1
|
|
FROM conversation_queue_items
|
|
WHERE conversation_id = ?
|
|
AND status IN ('pending', 'dispatching')
|
|
LIMIT 1`
|
|
)
|
|
.get(source.id)
|
|
if (queuedItem) {
|
|
throw new Error('当前会话仍有待发送内容,请处理后再创建分支')
|
|
}
|
|
const messageStates = database
|
|
.prepare(
|
|
`SELECT state
|
|
FROM messages
|
|
WHERE conversation_id = ?
|
|
ORDER BY sequence ASC`
|
|
)
|
|
.all(source.id) as Array<{ state: MessageRow['state'] }>
|
|
if (
|
|
messageStates.some((message) => message.state === 'streaming')
|
|
) {
|
|
throw new Error('当前会话仍在生成内容,请等待完成后再创建分支')
|
|
}
|
|
database
|
|
.prepare(
|
|
`INSERT INTO conversations
|
|
(id, project_id, runtime_selection_json,
|
|
knowledge_retrieval_mode, context_state_json,
|
|
title, branch_source_conversation_id, branch_source_title,
|
|
status, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?)`
|
|
)
|
|
.run(
|
|
destinationConversationId,
|
|
source.project_id,
|
|
source.runtime_selection_json,
|
|
source.knowledge_retrieval_mode,
|
|
serializeConversationContextState({
|
|
knowledgeLibraryIds:
|
|
parseConversationContextState(source.context_state_json)
|
|
.knowledgeLibraryIds
|
|
}),
|
|
input.title,
|
|
source.id,
|
|
source.title,
|
|
now,
|
|
now
|
|
)
|
|
const copiedMessages = database
|
|
.prepare(
|
|
`INSERT INTO messages
|
|
(id, conversation_id, request_id, role, content, state,
|
|
sequence, metadata_json, created_at)
|
|
SELECT lower(
|
|
hex(randomblob(4)) || '-' ||
|
|
hex(randomblob(2)) || '-4' ||
|
|
substr(hex(randomblob(2)), 2) || '-' ||
|
|
substr('89ab', (random() & 3) + 1, 1) ||
|
|
substr(hex(randomblob(2)), 2) || '-' ||
|
|
hex(randomblob(6))
|
|
),
|
|
?, NULL, role, content, state,
|
|
ROW_NUMBER() OVER (ORDER BY sequence) - 1,
|
|
json_remove(
|
|
metadata_json,
|
|
'$.artifactIds',
|
|
'$.subagents',
|
|
'$.task',
|
|
'$.queueItemId'
|
|
),
|
|
created_at
|
|
FROM messages
|
|
WHERE conversation_id = ?
|
|
ORDER BY sequence`
|
|
)
|
|
.run(destinationConversationId, source.id)
|
|
if (copiedMessages.changes !== messageStates.length) {
|
|
throw new Error('分支消息复制不完整')
|
|
}
|
|
database.exec('COMMIT')
|
|
} catch (error) {
|
|
database.exec('ROLLBACK')
|
|
throw error
|
|
}
|
|
return this.getConversation(destinationConversationId)
|
|
}
|
|
|
|
deleteLocalConversation(conversationId: string): boolean {
|
|
const database = this.requireDatabase()
|
|
database.exec('BEGIN IMMEDIATE')
|
|
try {
|
|
const conversation = database
|
|
.prepare('SELECT channel FROM conversations WHERE id = ?')
|
|
.get(conversationId) as
|
|
| { channel: ProjectChannel | null }
|
|
| undefined
|
|
if (!conversation) {
|
|
database.exec('COMMIT')
|
|
return false
|
|
}
|
|
if (conversation.channel) {
|
|
throw new Error('远程对话不能作为本地对话删除')
|
|
}
|
|
const activeTask = database
|
|
.prepare(
|
|
`SELECT 1
|
|
FROM tasks
|
|
WHERE conversation_id = ?
|
|
AND status IN ('running', 'waiting_approval')
|
|
LIMIT 1`
|
|
)
|
|
.get(conversationId)
|
|
if (activeTask) {
|
|
throw new Error('对话仍有正在运行的任务,请先停止任务')
|
|
}
|
|
database
|
|
.prepare(
|
|
`DELETE FROM notifications
|
|
WHERE task_id IN (
|
|
SELECT id FROM tasks WHERE conversation_id = ?
|
|
) OR schedule_id IN (
|
|
SELECT schedule_id
|
|
FROM tasks
|
|
WHERE conversation_id = ? AND schedule_id IS NOT NULL
|
|
)`
|
|
)
|
|
.run(conversationId, conversationId)
|
|
database
|
|
.prepare(
|
|
`DELETE FROM schedules
|
|
WHERE id IN (
|
|
SELECT schedule_id
|
|
FROM tasks
|
|
WHERE conversation_id = ? AND schedule_id IS NOT NULL
|
|
)`
|
|
)
|
|
.run(conversationId)
|
|
database
|
|
.prepare(
|
|
`DELETE FROM delegation_outbox
|
|
WHERE task_id IN (
|
|
SELECT id FROM tasks WHERE conversation_id = ?
|
|
)`
|
|
)
|
|
.run(conversationId)
|
|
database
|
|
.prepare(
|
|
`DELETE FROM artifacts
|
|
WHERE kind = 'markdown'
|
|
AND task_id IN (
|
|
SELECT id
|
|
FROM tasks
|
|
WHERE conversation_id = ?
|
|
AND (
|
|
origin = 'user'
|
|
OR (
|
|
origin = 'delegation'
|
|
AND conversation_id NOT LIKE 'delegation:%'
|
|
)
|
|
)
|
|
)`
|
|
)
|
|
.run(conversationId)
|
|
database
|
|
.prepare('DELETE FROM tasks WHERE conversation_id = ?')
|
|
.run(conversationId)
|
|
const deleted = database
|
|
.prepare(
|
|
'DELETE FROM conversations WHERE id = ? AND channel IS NULL'
|
|
)
|
|
.run(conversationId).changes === 1
|
|
database.exec('COMMIT')
|
|
if (deleted) this.executionTiming?.changed()
|
|
return deleted
|
|
} catch (error) {
|
|
database.exec('ROLLBACK')
|
|
throw error
|
|
}
|
|
}
|
|
|
|
getOrCreateRemoteConversation(input: {
|
|
projectId: string
|
|
channel: ProjectChannel
|
|
accountId: string
|
|
externalConversationId: string
|
|
conversationType: 'direct' | 'group'
|
|
title: string
|
|
accountDisplay: string
|
|
runtimeSelection?: RuntimeSelectionLayer
|
|
}): ConversationSnapshot {
|
|
const database = this.requireDatabase()
|
|
const existing = database
|
|
.prepare(
|
|
`SELECT id
|
|
FROM conversations
|
|
WHERE channel = ?
|
|
AND external_account_id = ?
|
|
AND external_conversation_id = ?`
|
|
)
|
|
.get(
|
|
input.channel,
|
|
input.accountId,
|
|
input.externalConversationId
|
|
) as { id: string } | undefined
|
|
if (existing) {
|
|
database
|
|
.prepare(
|
|
`UPDATE conversations
|
|
SET project_id = ?, title = ?, conversation_type = ?,
|
|
account_display = ?,
|
|
runtime_selection_json = COALESCE(?, runtime_selection_json),
|
|
status = 'active', updated_at = ?
|
|
WHERE id = ?`
|
|
)
|
|
.run(
|
|
input.projectId,
|
|
input.title,
|
|
input.conversationType,
|
|
input.accountDisplay,
|
|
serializeRuntimeSelection(input.runtimeSelection),
|
|
new Date().toISOString(),
|
|
existing.id
|
|
)
|
|
return this.getConversation(existing.id)
|
|
}
|
|
|
|
const id = randomUUID()
|
|
const now = new Date().toISOString()
|
|
database
|
|
.prepare(
|
|
`INSERT INTO conversations
|
|
(id, project_id, runtime_selection_json, title, status,
|
|
channel, external_account_id, external_conversation_id,
|
|
conversation_type, account_display, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, 'active', ?, ?, ?, ?, ?, ?, ?)`
|
|
)
|
|
.run(
|
|
id,
|
|
input.projectId,
|
|
serializeRuntimeSelection(input.runtimeSelection),
|
|
input.title,
|
|
input.channel,
|
|
input.accountId,
|
|
input.externalConversationId,
|
|
input.conversationType,
|
|
input.accountDisplay,
|
|
now,
|
|
now
|
|
)
|
|
return this.getConversation(id)
|
|
}
|
|
|
|
appendConversationMessage(input: {
|
|
conversationId: string
|
|
role: 'user' | 'assistant'
|
|
content: string
|
|
state?: 'complete' | 'error'
|
|
status?: string
|
|
attachments?: ConversationSnapshot['messages'][number]['attachments']
|
|
artifactIds?: string[]
|
|
task?: ConversationSnapshot['messages'][number]['task']
|
|
}): string {
|
|
const database = this.requireDatabase()
|
|
const messageId = randomUUID()
|
|
const now = Date.now()
|
|
database.exec('BEGIN IMMEDIATE')
|
|
try {
|
|
const conversation = database
|
|
.prepare(
|
|
`SELECT id
|
|
FROM conversations
|
|
WHERE id = ? AND status = 'active'`
|
|
)
|
|
.get(input.conversationId)
|
|
if (!conversation) {
|
|
throw new Error('对话不存在或不可用')
|
|
}
|
|
const sequence = database
|
|
.prepare(
|
|
`SELECT COALESCE(MAX(sequence), -1) + 1 AS sequence
|
|
FROM messages
|
|
WHERE conversation_id = ?`
|
|
)
|
|
.get(input.conversationId) as { sequence: number }
|
|
database
|
|
.prepare(
|
|
`INSERT INTO messages
|
|
(id, conversation_id, request_id, role, content, state,
|
|
sequence, metadata_json, created_at)
|
|
VALUES (?, ?, NULL, ?, ?, ?, ?, ?, ?)`
|
|
)
|
|
.run(
|
|
messageId,
|
|
input.conversationId,
|
|
input.role,
|
|
input.content,
|
|
input.state ?? 'complete',
|
|
sequence.sequence,
|
|
JSON.stringify({
|
|
createdAt: now,
|
|
...(input.status ? { status: input.status } : {}),
|
|
...(input.attachments?.length
|
|
? { attachments: input.attachments }
|
|
: {}),
|
|
...(input.artifactIds?.length
|
|
? { artifactIds: input.artifactIds }
|
|
: {}),
|
|
...(input.task ? { task: input.task } : {})
|
|
}),
|
|
new Date(now).toISOString()
|
|
)
|
|
database
|
|
.prepare(
|
|
'UPDATE conversations SET updated_at = ? WHERE id = ?'
|
|
)
|
|
.run(new Date(now).toISOString(), input.conversationId)
|
|
database.exec('COMMIT')
|
|
return messageId
|
|
} catch (error) {
|
|
database.exec('ROLLBACK')
|
|
throw error
|
|
}
|
|
}
|
|
|
|
appendRemoteConversationMessage(input: {
|
|
conversationId: string
|
|
role: 'user' | 'assistant'
|
|
content: string
|
|
status?: string
|
|
attachments?: ConversationSnapshot['messages'][number]['attachments']
|
|
artifactIds?: string[]
|
|
}): string {
|
|
return this.appendConversationMessage(input)
|
|
}
|
|
|
|
claimChannelEvent(
|
|
channel: string,
|
|
accountId: string,
|
|
eventId: string
|
|
): boolean {
|
|
const database = this.requireDatabase()
|
|
const result = database
|
|
.prepare(
|
|
`INSERT OR IGNORE INTO channel_events
|
|
(channel, account_id, event_id, claimed_at)
|
|
VALUES (?, ?, ?, ?)`
|
|
)
|
|
.run(channel, accountId, eventId, Date.now())
|
|
if (result.changes === 1) {
|
|
this.channelEventWrites += 1
|
|
if (this.channelEventWrites % 128 === 0) {
|
|
database.exec(`
|
|
DELETE FROM channel_events
|
|
WHERE rowid IN (
|
|
SELECT rowid FROM channel_events
|
|
ORDER BY claimed_at ASC, rowid ASC
|
|
LIMIT MAX(
|
|
(SELECT COUNT(*) FROM channel_events) - 10000,
|
|
0
|
|
)
|
|
);
|
|
`)
|
|
}
|
|
}
|
|
return result.changes === 1
|
|
}
|
|
|
|
releaseChannelEvent(
|
|
channel: string,
|
|
accountId: string,
|
|
eventId: string
|
|
): void {
|
|
this.requireDatabase()
|
|
.prepare(
|
|
`DELETE FROM channel_events
|
|
WHERE channel = ? AND account_id = ? AND event_id = ?`
|
|
)
|
|
.run(channel, accountId, eventId)
|
|
}
|
|
|
|
enqueueChannelResult(message: ChannelResultMessage): {
|
|
id: string
|
|
message: ChannelResultMessage
|
|
state: 'pending'
|
|
attempts: number
|
|
createdAt: number
|
|
} {
|
|
const parsed = channelResultMessageSchema.parse(message)
|
|
if (parsed.attachments?.length) {
|
|
const pendingMedia = (
|
|
this.requireDatabase()
|
|
.prepare(
|
|
`SELECT COUNT(*) AS count
|
|
FROM channel_outbox
|
|
WHERE state != 'delivered'
|
|
AND attempts < 5
|
|
AND json_type(message_json, '$.attachments') = 'array'`
|
|
)
|
|
.get() as { count: number }
|
|
).count
|
|
if (pendingMedia >= MAX_CHANNEL_OUTBOX_MEDIA_ENTRIES) {
|
|
throw new Error('媒体结果等待发送过多,请恢复通道连接后重试')
|
|
}
|
|
}
|
|
const entry = {
|
|
id: randomUUID(),
|
|
message: parsed,
|
|
state: 'pending' as const,
|
|
attempts: 0,
|
|
createdAt: Date.now()
|
|
}
|
|
this.requireDatabase()
|
|
.prepare(
|
|
`INSERT INTO channel_outbox
|
|
(id, channel, event_id, message_json, state, attempts, created_at)
|
|
VALUES (?, ?, ?, ?, 'pending', 0, ?)`
|
|
)
|
|
.run(
|
|
entry.id,
|
|
parsed.channel,
|
|
parsed.eventId,
|
|
JSON.stringify(parsed),
|
|
entry.createdAt
|
|
)
|
|
this.channelOutboxWrites += 1
|
|
if (this.channelOutboxWrites % 128 === 0) {
|
|
this.requireDatabase().exec(`
|
|
DELETE FROM channel_outbox
|
|
WHERE rowid IN (
|
|
SELECT rowid FROM channel_outbox
|
|
ORDER BY
|
|
CASE state WHEN 'delivered' THEN 0 ELSE 1 END,
|
|
created_at ASC,
|
|
rowid ASC
|
|
LIMIT MAX(
|
|
(SELECT COUNT(*) FROM channel_outbox) - 10000,
|
|
0
|
|
)
|
|
);
|
|
`)
|
|
}
|
|
return structuredClone(entry)
|
|
}
|
|
|
|
markChannelResult(
|
|
id: string,
|
|
state: 'delivered' | 'failed'
|
|
): void {
|
|
this.requireDatabase()
|
|
.prepare(
|
|
`UPDATE channel_outbox
|
|
SET state = CASE
|
|
WHEN ? = 'failed' AND attempts + 1 >= 5
|
|
THEN 'terminal'
|
|
ELSE ?
|
|
END,
|
|
attempts = attempts + 1,
|
|
message_json = CASE
|
|
WHEN ? = 'delivered' OR attempts + 1 >= 5
|
|
THEN json_remove(message_json, '$.attachments')
|
|
ELSE message_json
|
|
END
|
|
WHERE id = ?`
|
|
)
|
|
.run(state, state, state, id)
|
|
}
|
|
|
|
listUndeliveredChannelResults(
|
|
channel?: string,
|
|
limit = 100
|
|
): Array<{
|
|
id: string
|
|
message: ChannelResultMessage
|
|
state: 'pending' | 'failed' | 'terminal'
|
|
attempts: number
|
|
createdAt: number
|
|
}> {
|
|
const safeLimit = Math.min(
|
|
Math.max(Number.isSafeInteger(limit) ? limit : 100, 1),
|
|
1_000
|
|
)
|
|
const rows = this.requireDatabase()
|
|
.prepare(
|
|
`WITH pending AS (
|
|
SELECT id, message_json, state, attempts, created_at,
|
|
ROW_NUMBER() OVER (
|
|
ORDER BY attempts ASC, created_at ASC
|
|
) AS position,
|
|
SUM(LENGTH(CAST(message_json AS BLOB))) OVER (
|
|
ORDER BY attempts ASC, created_at ASC
|
|
) AS cumulative_bytes
|
|
FROM channel_outbox
|
|
WHERE state != 'delivered'
|
|
${channel === undefined ? '' : 'AND channel = ?'}
|
|
)
|
|
SELECT id, message_json, state, attempts, created_at
|
|
FROM pending
|
|
WHERE position = 1 OR cumulative_bytes <= ?
|
|
ORDER BY attempts ASC, created_at ASC
|
|
LIMIT ?`
|
|
)
|
|
.all(
|
|
...(channel === undefined
|
|
? [MAX_CHANNEL_OUTBOX_RETRY_BYTES, safeLimit]
|
|
: [
|
|
channel,
|
|
MAX_CHANNEL_OUTBOX_RETRY_BYTES,
|
|
safeLimit
|
|
])
|
|
) as Array<{
|
|
id: string
|
|
message_json: string
|
|
state: 'pending' | 'failed' | 'terminal'
|
|
attempts: number
|
|
created_at: number
|
|
}>
|
|
return rows.map((row) => ({
|
|
id: row.id,
|
|
message: channelResultMessageSchema.parse(
|
|
JSON.parse(row.message_json)
|
|
),
|
|
state: row.state,
|
|
attempts: row.attempts,
|
|
createdAt: row.created_at
|
|
}))
|
|
}
|
|
|
|
/**
|
|
* Lists notes, pinned first. `tags` keeps only notes carrying every listed tag
|
|
* (compared by key), so selecting more tags narrows the result.
|
|
*/
|
|
listMagicNotes(input: { tags?: string[]; limit?: number } = {}): MagicNoteSummary[] {
|
|
const database = this.requireDatabase()
|
|
const { clause, params } = magicNoteTagFilter(input.tags)
|
|
const rows = database
|
|
.prepare(
|
|
`SELECT ${MAGIC_NOTE_SUMMARY_COLUMNS}
|
|
FROM magic_notes n
|
|
${clause ? `WHERE ${clause}` : ''}
|
|
ORDER BY n.pinned DESC, n.updated_at DESC, n.rowid DESC
|
|
LIMIT ?`
|
|
)
|
|
.all(...params, input.limit ?? 200) as MagicNoteRow[]
|
|
return rows.map(toMagicNoteSummary)
|
|
}
|
|
|
|
listMagicNoteTags(): MagicNoteTag[] {
|
|
const rows = this.requireDatabase()
|
|
.prepare(
|
|
`SELECT t.id, t.name, COUNT(l.note_id) AS note_count
|
|
FROM magic_note_tags t
|
|
LEFT JOIN magic_note_tag_links l ON l.tag_id = t.id
|
|
GROUP BY t.id
|
|
HAVING note_count > 0
|
|
ORDER BY note_count DESC, t.name_key ASC`
|
|
)
|
|
.all() as MagicNoteTagRow[]
|
|
return rows.map(toMagicNoteTag)
|
|
}
|
|
|
|
/**
|
|
* Renames a tag across every note. When the new name matches another tag,
|
|
* the two merge: links move to the existing tag and the old tag is removed.
|
|
* Affected notes get a new revision so open editors detect the change.
|
|
*/
|
|
renameMagicNoteTag(input: { tagId: string; name: string }): MagicNoteTagRenameResult {
|
|
const database = this.requireDatabase()
|
|
const now = new Date().toISOString()
|
|
const key = magicNoteTagKey(input.name)
|
|
let targetId = input.tagId
|
|
let merged = false
|
|
database.exec('BEGIN IMMEDIATE')
|
|
try {
|
|
const current = database
|
|
.prepare('SELECT id FROM magic_note_tags WHERE id = ?')
|
|
.get(input.tagId) as { id: string } | undefined
|
|
if (!current) throw new Error('标签不存在')
|
|
const other = database
|
|
.prepare('SELECT id FROM magic_note_tags WHERE name_key = ? AND id <> ?')
|
|
.get(key, input.tagId) as { id: string } | undefined
|
|
const noteIds = (
|
|
database
|
|
.prepare('SELECT note_id FROM magic_note_tag_links WHERE tag_id = ?')
|
|
.all(input.tagId) as Array<{ note_id: string }>
|
|
).map((row) => row.note_id)
|
|
if (other) {
|
|
merged = true
|
|
targetId = other.id
|
|
// Notes that already had both tags keep the target's position.
|
|
database
|
|
.prepare(
|
|
`INSERT OR IGNORE INTO magic_note_tag_links (note_id, tag_id, position, created_at)
|
|
SELECT note_id, ?, position, created_at FROM magic_note_tag_links WHERE tag_id = ?`
|
|
)
|
|
.run(other.id, input.tagId)
|
|
database.prepare('DELETE FROM magic_note_tags WHERE id = ?').run(input.tagId)
|
|
database
|
|
.prepare('UPDATE magic_note_tags SET updated_at = ? WHERE id = ?')
|
|
.run(now, other.id)
|
|
} else {
|
|
database
|
|
.prepare('UPDATE magic_note_tags SET name = ?, name_key = ?, updated_at = ? WHERE id = ?')
|
|
.run(input.name, key, now, input.tagId)
|
|
}
|
|
this.touchMagicNotes(database, noteIds, now)
|
|
database.exec('COMMIT')
|
|
} catch (error) {
|
|
database.exec('ROLLBACK')
|
|
throw error
|
|
}
|
|
const tag = this.listMagicNoteTags().find((item) => item.id === targetId)
|
|
this.options.onMagicNotesChanged?.()
|
|
return { tag: tag ?? { id: targetId, name: input.name, noteCount: 0 }, merged }
|
|
}
|
|
|
|
/** Removes a tag from every note. Notes and their entries are kept. */
|
|
deleteMagicNoteTag(tagId: string): void {
|
|
const database = this.requireDatabase()
|
|
const now = new Date().toISOString()
|
|
database.exec('BEGIN IMMEDIATE')
|
|
try {
|
|
const noteIds = (
|
|
database
|
|
.prepare('SELECT note_id FROM magic_note_tag_links WHERE tag_id = ?')
|
|
.all(tagId) as Array<{ note_id: string }>
|
|
).map((row) => row.note_id)
|
|
const result = database.prepare('DELETE FROM magic_note_tags WHERE id = ?').run(tagId)
|
|
if (result.changes !== 1) throw new Error('标签不存在')
|
|
this.touchMagicNotes(database, noteIds, now)
|
|
database.exec('COMMIT')
|
|
} catch (error) {
|
|
database.exec('ROLLBACK')
|
|
throw error
|
|
}
|
|
this.options.onMagicNotesChanged?.()
|
|
}
|
|
|
|
private touchMagicNotes(database: DatabaseSync, noteIds: string[], now: string): void {
|
|
const statement = database.prepare(
|
|
'UPDATE magic_notes SET revision = revision + 1, updated_at = ? WHERE id = ?'
|
|
)
|
|
for (const noteId of noteIds) statement.run(now, noteId)
|
|
}
|
|
|
|
/** Replaces a note's tags, creating missing tags and removing orphans. */
|
|
private replaceMagicNoteTags(
|
|
database: DatabaseSync,
|
|
noteId: string,
|
|
tags: string[],
|
|
now: string
|
|
): void {
|
|
database.prepare('DELETE FROM magic_note_tag_links WHERE note_id = ?').run(noteId)
|
|
const findTag = database.prepare('SELECT id FROM magic_note_tags WHERE name_key = ?')
|
|
const insertTag = database.prepare(
|
|
`INSERT INTO magic_note_tags (id, name, name_key, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?)`
|
|
)
|
|
const insertLink = database.prepare(
|
|
`INSERT OR IGNORE INTO magic_note_tag_links (note_id, tag_id, position, created_at)
|
|
VALUES (?, ?, ?, ?)`
|
|
)
|
|
tags.forEach((name, position) => {
|
|
const key = magicNoteTagKey(name)
|
|
let tag = findTag.get(key) as { id: string } | undefined
|
|
if (!tag) {
|
|
tag = { id: randomUUID() }
|
|
insertTag.run(tag.id, name, key, now, now)
|
|
}
|
|
insertLink.run(noteId, tag.id, position, now)
|
|
})
|
|
this.deleteOrphanMagicNoteTags(database)
|
|
}
|
|
|
|
private deleteOrphanMagicNoteTags(database: DatabaseSync): void {
|
|
database.exec(
|
|
`DELETE FROM magic_note_tags
|
|
WHERE NOT EXISTS (SELECT 1 FROM magic_note_tag_links l WHERE l.tag_id = magic_note_tags.id)`
|
|
)
|
|
}
|
|
|
|
getMagicNote(noteId: string): MagicNoteDetail {
|
|
const database = this.requireDatabase()
|
|
const row = database
|
|
.prepare(
|
|
`SELECT ${MAGIC_NOTE_SUMMARY_COLUMNS}
|
|
FROM magic_notes n
|
|
WHERE n.id = ?`
|
|
)
|
|
.get(noteId) as MagicNoteRow | undefined
|
|
if (!row) {
|
|
throw new Error('笔记不存在')
|
|
}
|
|
const entryRows = database
|
|
.prepare(
|
|
`SELECT * FROM magic_note_entries
|
|
WHERE note_id = ?
|
|
ORDER BY created_at ASC, rowid ASC`
|
|
)
|
|
.all(noteId) as MagicNoteEntryRow[]
|
|
return {
|
|
...toMagicNoteSummary(row),
|
|
entries: entryRows.map((entry) => this.hydrateMagicNoteEntry(entry))
|
|
}
|
|
}
|
|
|
|
getMagicNoteContext(noteId: string): {
|
|
id: string
|
|
title: string
|
|
} {
|
|
const row = this.requireDatabase()
|
|
.prepare(
|
|
`SELECT id, project_id, title
|
|
FROM magic_notes
|
|
WHERE id = ?`
|
|
)
|
|
.get(noteId) as
|
|
| { id: string; project_id: string | null; title: string }
|
|
| undefined
|
|
if (!row) {
|
|
throw new Error('笔记不存在')
|
|
}
|
|
return {
|
|
id: row.id,
|
|
title: row.title
|
|
}
|
|
}
|
|
|
|
createMagicNote(input: {
|
|
title: string
|
|
content?: MagicNoteContent
|
|
source?: MagicNoteSource
|
|
tags?: string[]
|
|
}): MagicNoteCreateResult {
|
|
const id = randomUUID()
|
|
const entryId = input.content ? randomUUID() : undefined
|
|
const source = input.source ? magicNoteSourceSchema.parse(input.source) : undefined
|
|
if (source && !input.content) throw new Error('Source requires initial content')
|
|
const now = new Date().toISOString()
|
|
const database = this.requireDatabase()
|
|
if (input.content) {
|
|
input = { ...input, content: validateMagicNoteContent(input.content) }
|
|
}
|
|
const embeddedBytes = input.content
|
|
? magicNoteEmbeddedBytes(input.content)
|
|
: 0
|
|
if (
|
|
input.content?.version !== 2 &&
|
|
embeddedBytes > MAGIC_NOTE_MAX_NOTE_EMBED_BYTES
|
|
) {
|
|
throw new Error('一篇笔记中的图片、视频和附件总大小不能超过 64 MB')
|
|
}
|
|
database.exec('BEGIN IMMEDIATE')
|
|
try {
|
|
database
|
|
.prepare(
|
|
`INSERT INTO magic_notes
|
|
(id, project_id, title, pinned, revision, created_at, updated_at)
|
|
VALUES (?, ?, ?, 0, ?, ?, ?)`
|
|
)
|
|
.run(id, null, input.title, input.content ? 1 : 0, now, now)
|
|
if (input.tags?.length) {
|
|
this.replaceMagicNoteTags(database, id, input.tags, now)
|
|
}
|
|
if (input.content && entryId) {
|
|
const pointer = this.writeMagicNoteBody(id, entryId, input.content, 0, now)
|
|
database
|
|
.prepare(
|
|
`INSERT INTO magic_note_entries
|
|
(id, note_id, content_json, plain_text, comments_json,
|
|
actions_json, analyzed_at, revision, created_at, updated_at,
|
|
image_bytes, source_json)
|
|
VALUES (?, ?, ?, ?, '[]', '[]', NULL, 0, ?, ?, ?, ?)`
|
|
)
|
|
.run(
|
|
entryId,
|
|
id,
|
|
pointer,
|
|
magicNotePlainText(input.content),
|
|
now,
|
|
now,
|
|
embeddedBytes,
|
|
source ? JSON.stringify(source) : null
|
|
)
|
|
this.syncMagicNoteTodos(
|
|
database,
|
|
id,
|
|
entryId,
|
|
input.content,
|
|
now
|
|
)
|
|
}
|
|
database.exec('COMMIT')
|
|
} catch (error) {
|
|
database.exec('ROLLBACK')
|
|
throw error
|
|
}
|
|
this.reconcileMagicNoteFiles(id)
|
|
const detail = this.getMagicNote(id)
|
|
this.options.onMagicNotesChanged?.()
|
|
if (input.content) {
|
|
this.notifyMagicTodosChanged()
|
|
}
|
|
return { ...detail, ...(entryId ? { createdEntryId: entryId } : {}) }
|
|
}
|
|
|
|
updateMagicNote(input: {
|
|
noteId: string
|
|
title?: string
|
|
pinned?: boolean
|
|
tags?: string[]
|
|
expectedRevision: number
|
|
}): MagicNoteDetail {
|
|
const now = new Date().toISOString()
|
|
const database = this.requireDatabase()
|
|
database.exec('BEGIN IMMEDIATE')
|
|
try {
|
|
const result = database
|
|
.prepare(
|
|
`UPDATE magic_notes
|
|
SET title = COALESCE(?, title),
|
|
pinned = COALESCE(?, pinned),
|
|
revision = revision + 1,
|
|
updated_at = ?
|
|
WHERE id = ? AND revision = ?`
|
|
)
|
|
.run(
|
|
input.title ?? null,
|
|
input.pinned === undefined ? null : input.pinned ? 1 : 0,
|
|
now,
|
|
input.noteId,
|
|
input.expectedRevision
|
|
)
|
|
if (result.changes !== 1) {
|
|
throw new Error('笔记已被更新,请刷新后重试')
|
|
}
|
|
if (input.tags !== undefined) {
|
|
this.replaceMagicNoteTags(database, input.noteId, input.tags, now)
|
|
}
|
|
database.exec('COMMIT')
|
|
} catch (error) {
|
|
database.exec('ROLLBACK')
|
|
throw error
|
|
}
|
|
const detail = this.getMagicNote(input.noteId)
|
|
this.options.onMagicNotesChanged?.()
|
|
return detail
|
|
}
|
|
|
|
deleteMagicNote(noteId: string): void {
|
|
const database = this.requireDatabase()
|
|
database.exec('BEGIN IMMEDIATE')
|
|
try {
|
|
const result = database
|
|
.prepare('DELETE FROM magic_notes WHERE id = ?')
|
|
.run(noteId)
|
|
if (result.changes !== 1) {
|
|
throw new Error('笔记不存在')
|
|
}
|
|
this.deleteOrphanMagicNoteTags(database)
|
|
database.exec('COMMIT')
|
|
} catch (error) {
|
|
database.exec('ROLLBACK')
|
|
throw error
|
|
}
|
|
this.reconcileMagicNoteFiles(noteId)
|
|
this.options.onMagicNotesChanged?.()
|
|
this.notifyMagicTodosChanged()
|
|
}
|
|
|
|
createMagicNoteEntry(input: {
|
|
noteId: string
|
|
content: MagicNoteContent
|
|
plainText: string
|
|
source?: MagicNoteSource
|
|
}): MagicNoteEntryCreateResult {
|
|
const source = input.source ? magicNoteSourceSchema.parse(input.source) : undefined
|
|
const database = this.requireDatabase()
|
|
const entryId = randomUUID()
|
|
const content = validateMagicNoteContent(input.content)
|
|
input = { ...input, content, plainText: magicNotePlainText(content) }
|
|
const now = new Date().toISOString()
|
|
database.exec('BEGIN IMMEDIATE')
|
|
try {
|
|
this.assertMagicNoteEmbedBudget(input.noteId, input.content)
|
|
const noteResult = database
|
|
.prepare(
|
|
`UPDATE magic_notes
|
|
SET revision = revision + 1, updated_at = ?
|
|
WHERE id = ?`
|
|
)
|
|
.run(now, input.noteId)
|
|
if (noteResult.changes !== 1) {
|
|
throw new Error('笔记不存在')
|
|
}
|
|
const pointer = this.writeMagicNoteBody(input.noteId, entryId, input.content, 0, now)
|
|
database
|
|
.prepare(
|
|
`INSERT INTO magic_note_entries
|
|
(id, note_id, content_json, plain_text, comments_json,
|
|
actions_json, analyzed_at, revision, created_at, updated_at,
|
|
image_bytes, source_json)
|
|
VALUES (?, ?, ?, ?, '[]', '[]', NULL, 0, ?, ?, ?, ?)`
|
|
)
|
|
.run(
|
|
entryId,
|
|
input.noteId,
|
|
pointer,
|
|
input.plainText,
|
|
now,
|
|
now,
|
|
magicNoteEmbeddedBytes(input.content),
|
|
source ? JSON.stringify(source) : null
|
|
)
|
|
this.syncMagicNoteTodos(
|
|
database,
|
|
input.noteId,
|
|
entryId,
|
|
input.content,
|
|
now
|
|
)
|
|
database.exec('COMMIT')
|
|
} catch (error) {
|
|
database.exec('ROLLBACK')
|
|
throw error
|
|
}
|
|
this.reconcileMagicNoteFiles(input.noteId)
|
|
const detail = this.getMagicNote(input.noteId)
|
|
this.options.onMagicNotesChanged?.()
|
|
this.notifyMagicTodosChanged()
|
|
return { ...detail, createdEntryId: entryId }
|
|
}
|
|
|
|
updateMagicNoteEntry(input: {
|
|
entryId: string
|
|
content: MagicNoteContent
|
|
plainText: string
|
|
expectedRevision: number
|
|
}): MagicNoteDetail {
|
|
const database = this.requireDatabase()
|
|
const existing = database
|
|
.prepare('SELECT * FROM magic_note_entries WHERE id = ?')
|
|
.get(input.entryId) as MagicNoteEntryRow | undefined
|
|
if (!existing) {
|
|
throw new Error('记录不存在')
|
|
}
|
|
const content = validateMagicNoteContent(input.content)
|
|
input = { ...input, content, plainText: magicNotePlainText(content) }
|
|
const previous = this.hydrateMagicNoteEntry(existing)
|
|
const preserveComments = content.version === 2 && previous.content.version === 2 && (
|
|
JSON.stringify(content) === JSON.stringify(previous.content) ||
|
|
(magicNoteCanvasAnalysisText(content) === magicNoteCanvasAnalysisText(previous.content) &&
|
|
previous.comments.every((comment) => comment.inputMode === 'text-fallback' || comment.inputMode === 'text'))
|
|
)
|
|
const now = new Date().toISOString()
|
|
database.exec('BEGIN IMMEDIATE')
|
|
try {
|
|
const current = database.prepare('SELECT revision FROM magic_note_entries WHERE id = ?').get(input.entryId) as { revision: number } | undefined
|
|
if (current?.revision !== input.expectedRevision) throw new Error('记录已被更新,请刷新后重试')
|
|
this.assertMagicNoteEmbedBudget(
|
|
existing.note_id,
|
|
input.content,
|
|
input.entryId
|
|
)
|
|
const pointer = this.writeMagicNoteBody(existing.note_id, input.entryId, input.content, input.expectedRevision + 1, now)
|
|
const result = database
|
|
.prepare(
|
|
`UPDATE magic_note_entries
|
|
SET content_json = ?, plain_text = ?, comments_json = ?,
|
|
analyzed_at = ?, revision = revision + 1, updated_at = ?,
|
|
image_bytes = ?
|
|
WHERE id = ? AND revision = ?`
|
|
)
|
|
.run(
|
|
pointer,
|
|
input.plainText,
|
|
preserveComments ? existing.comments_json : '[]',
|
|
preserveComments ? existing.analyzed_at : null,
|
|
now,
|
|
magicNoteEmbeddedBytes(input.content),
|
|
input.entryId,
|
|
input.expectedRevision
|
|
)
|
|
if (result.changes !== 1) {
|
|
throw new Error('记录已被更新,请刷新后重试')
|
|
}
|
|
database
|
|
.prepare(
|
|
`UPDATE magic_notes
|
|
SET revision = revision + 1, updated_at = ?
|
|
WHERE id = ?`
|
|
)
|
|
.run(now, existing.note_id)
|
|
this.syncMagicNoteTodos(
|
|
database,
|
|
existing.note_id,
|
|
input.entryId,
|
|
input.content,
|
|
now,
|
|
previous.content
|
|
)
|
|
database.exec('COMMIT')
|
|
} catch (error) {
|
|
database.exec('ROLLBACK')
|
|
throw error
|
|
}
|
|
this.reconcileMagicNoteFiles(existing.note_id)
|
|
const detail = this.getMagicNote(existing.note_id)
|
|
this.options.onMagicNotesChanged?.()
|
|
this.notifyMagicTodosChanged()
|
|
return detail
|
|
}
|
|
|
|
deleteMagicNoteEntry(entryId: string): MagicNoteDetail {
|
|
const database = this.requireDatabase()
|
|
const existing = database
|
|
.prepare('SELECT note_id FROM magic_note_entries WHERE id = ?')
|
|
.get(entryId) as { note_id: string } | undefined
|
|
if (!existing) {
|
|
throw new Error('记录不存在')
|
|
}
|
|
const now = new Date().toISOString()
|
|
database.exec('BEGIN IMMEDIATE')
|
|
try {
|
|
database
|
|
.prepare('DELETE FROM magic_note_entries WHERE id = ?')
|
|
.run(entryId)
|
|
database
|
|
.prepare(
|
|
`UPDATE magic_notes
|
|
SET revision = revision + 1, updated_at = ?
|
|
WHERE id = ?`
|
|
)
|
|
.run(now, existing.note_id)
|
|
database.exec('COMMIT')
|
|
} catch (error) {
|
|
database.exec('ROLLBACK')
|
|
throw error
|
|
}
|
|
this.reconcileMagicNoteFiles(existing.note_id)
|
|
const detail = this.getMagicNote(existing.note_id)
|
|
this.options.onMagicNotesChanged?.()
|
|
this.notifyMagicTodosChanged()
|
|
return detail
|
|
}
|
|
|
|
getMagicNoteEntry(entryId: string): MagicNoteEntry {
|
|
const row = this.requireDatabase()
|
|
.prepare('SELECT * FROM magic_note_entries WHERE id = ?')
|
|
.get(entryId) as MagicNoteEntryRow | undefined
|
|
if (!row) {
|
|
throw new Error('记录不存在')
|
|
}
|
|
return this.hydrateMagicNoteEntry(row)
|
|
}
|
|
|
|
saveMagicNoteAnalysis(input: {
|
|
entryId: string
|
|
expectedRevision: number
|
|
comments: MagicNoteComment[]
|
|
}): MagicNoteDetail {
|
|
const database = this.requireDatabase()
|
|
const existing = database
|
|
.prepare(
|
|
`SELECT note_id, comments_json
|
|
FROM magic_note_entries
|
|
WHERE id = ?`
|
|
)
|
|
.get(input.entryId) as
|
|
| { note_id: string; comments_json: string }
|
|
| undefined
|
|
if (!existing) {
|
|
throw new Error('记录不存在')
|
|
}
|
|
const now = new Date().toISOString()
|
|
const comments = [
|
|
...(JSON.parse(existing.comments_json) as MagicNoteComment[]),
|
|
...input.comments.map((comment) => ({
|
|
...comment,
|
|
analyzedAt: now
|
|
}))
|
|
]
|
|
const result = database
|
|
.prepare(
|
|
`UPDATE magic_note_entries
|
|
SET comments_json = ?, analyzed_at = ?, revision = revision + 1,
|
|
updated_at = ?
|
|
WHERE id = ? AND revision = ?`
|
|
)
|
|
.run(
|
|
JSON.stringify(comments),
|
|
now,
|
|
now,
|
|
input.entryId,
|
|
input.expectedRevision
|
|
)
|
|
if (result.changes !== 1) {
|
|
throw new Error('记录已被更新,请重新分析')
|
|
}
|
|
const detail = this.getMagicNote(existing.note_id)
|
|
this.options.onMagicNotesChanged?.()
|
|
return detail
|
|
}
|
|
|
|
listMagicTodos(): MagicTodoItem[] {
|
|
return (
|
|
this.requireDatabase()
|
|
.prepare(
|
|
`SELECT t.*, n.title AS note_title
|
|
FROM magic_todos t
|
|
LEFT JOIN magic_notes n ON n.id = t.note_id
|
|
WHERE t.source = 'note'
|
|
ORDER BY t.completed ASC, t.updated_at DESC, t.rowid DESC
|
|
LIMIT 500`
|
|
)
|
|
.all() as MagicTodoRow[]
|
|
).map(toMagicTodo)
|
|
}
|
|
|
|
getMagicTodoStatus(): MagicTodoStatus {
|
|
const row = this.requireDatabase()
|
|
.prepare(
|
|
`SELECT COUNT(*) AS incomplete_count
|
|
FROM magic_todos
|
|
WHERE source = 'note' AND completed = 0`
|
|
)
|
|
.get() as { incomplete_count: number }
|
|
return { incompleteCount: row.incomplete_count }
|
|
}
|
|
|
|
searchMagicNoteSummaries(query: string, limit = 200): MagicNoteSummary[] {
|
|
const pattern = `%${query.replace(/[\\%_]/gu, '\\$&')}%`
|
|
const rows = this.requireDatabase().prepare(
|
|
`SELECT ${MAGIC_NOTE_SUMMARY_COLUMNS}
|
|
FROM magic_notes n
|
|
WHERE n.title LIKE ? ESCAPE '\\'
|
|
OR EXISTS (SELECT 1 FROM magic_note_entries e
|
|
WHERE e.note_id = n.id AND e.plain_text LIKE ? ESCAPE '\\')
|
|
OR EXISTS (SELECT 1 FROM magic_note_tag_links l
|
|
JOIN magic_note_tags t ON t.id = l.tag_id
|
|
WHERE l.note_id = n.id AND t.name LIKE ? ESCAPE '\\')
|
|
ORDER BY n.pinned DESC, n.updated_at DESC, n.rowid DESC
|
|
LIMIT ?`
|
|
).all(pattern, pattern, pattern, limit) as MagicNoteRow[]
|
|
return rows.map(toMagicNoteSummary)
|
|
}
|
|
|
|
searchMagicNotes(query: string, limit: number): MagicNoteSearchResult[] {
|
|
const pattern = `%${query.replace(/[\\%_]/gu, '\\$&')}%`
|
|
return (
|
|
this.requireDatabase()
|
|
.prepare(
|
|
`SELECT n.id AS note_id, n.title AS note_title,
|
|
e.id AS entry_id, COALESCE(e.plain_text, '') AS plain_text,
|
|
COALESCE(e.updated_at, n.updated_at) AS updated_at
|
|
FROM magic_notes n
|
|
LEFT JOIN magic_note_entries e ON e.note_id = n.id
|
|
WHERE n.title LIKE ? ESCAPE '\\'
|
|
OR e.plain_text LIKE ? ESCAPE '\\'
|
|
ORDER BY COALESCE(e.updated_at, n.updated_at) DESC,
|
|
COALESCE(e.rowid, n.rowid) DESC
|
|
LIMIT ?`
|
|
)
|
|
.all(pattern, pattern, limit) as Array<{
|
|
note_id: string
|
|
note_title: string
|
|
entry_id: string | null
|
|
plain_text: string
|
|
updated_at: string
|
|
}>
|
|
).map((row) => ({
|
|
noteId: row.note_id,
|
|
noteTitle: row.note_title.slice(0, 100),
|
|
entryId: row.entry_id ?? undefined,
|
|
content: row.plain_text.slice(0, 12_000),
|
|
updatedAt: row.updated_at
|
|
}))
|
|
}
|
|
|
|
getMagicTodo(todoId: string): MagicTodoItem {
|
|
const row = this.requireDatabase()
|
|
.prepare(
|
|
`SELECT t.*, n.title AS note_title
|
|
FROM magic_todos t
|
|
LEFT JOIN magic_notes n ON n.id = t.note_id
|
|
WHERE t.id = ?`
|
|
)
|
|
.get(todoId) as MagicTodoRow | undefined
|
|
if (!row) {
|
|
throw new Error('待办不存在')
|
|
}
|
|
return toMagicTodo(row)
|
|
}
|
|
|
|
updateMagicTodo(input: MagicTodoUpdateInput): MagicTodoItem {
|
|
const database = this.requireDatabase()
|
|
const now = new Date().toISOString()
|
|
database.exec('BEGIN IMMEDIATE')
|
|
try {
|
|
const existing = database
|
|
.prepare(
|
|
`SELECT t.note_id, t.entry_id, t.source_index, t.completed,
|
|
t.revision AS todo_revision,
|
|
e.content_json, e.revision AS entry_revision
|
|
FROM magic_todos t
|
|
INNER JOIN magic_note_entries e ON e.id = t.entry_id
|
|
WHERE t.id = ? AND t.source = 'note'`
|
|
)
|
|
.get(input.todoId) as
|
|
| {
|
|
note_id: string
|
|
entry_id: string
|
|
source_index: number
|
|
completed: number
|
|
todo_revision: number
|
|
content_json: string
|
|
entry_revision: number
|
|
}
|
|
| undefined
|
|
if (!existing) {
|
|
throw new Error('待办不存在')
|
|
}
|
|
if (existing.todo_revision !== input.expectedRevision) {
|
|
throw new Error('待办已被更新,请刷新后重试')
|
|
}
|
|
if (Boolean(existing.completed) === input.completed) {
|
|
database.exec('COMMIT')
|
|
return this.getMagicTodo(input.todoId)
|
|
}
|
|
|
|
const previousContent = this.noteStorage.read(existing.note_id, existing.entry_id).content
|
|
const content = setMagicNoteChecklistCompletion(
|
|
previousContent,
|
|
existing.source_index,
|
|
input.completed
|
|
)
|
|
const pointer = this.writeMagicNoteBody(existing.note_id, existing.entry_id, content, existing.entry_revision + 1, now)
|
|
const result = database
|
|
.prepare(
|
|
`UPDATE magic_note_entries
|
|
SET content_json = ?, plain_text = ?, comments_json = '[]',
|
|
analyzed_at = NULL, revision = revision + 1, updated_at = ?
|
|
WHERE id = ? AND revision = ?`
|
|
)
|
|
.run(
|
|
pointer,
|
|
magicNotePlainText(content),
|
|
now,
|
|
existing.entry_id,
|
|
existing.entry_revision
|
|
)
|
|
if (result.changes !== 1) {
|
|
throw new Error('记录已被更新,请刷新后重试')
|
|
}
|
|
database
|
|
.prepare(
|
|
`UPDATE magic_notes
|
|
SET revision = revision + 1, updated_at = ?
|
|
WHERE id = ?`
|
|
)
|
|
.run(now, existing.note_id)
|
|
this.syncMagicNoteTodos(
|
|
database,
|
|
existing.note_id,
|
|
existing.entry_id,
|
|
content,
|
|
now,
|
|
previousContent
|
|
)
|
|
database.exec('COMMIT')
|
|
} catch (error) {
|
|
database.exec('ROLLBACK')
|
|
throw error
|
|
}
|
|
const todo = this.getMagicTodo(input.todoId)
|
|
this.options.onMagicNotesChanged?.()
|
|
this.notifyMagicTodosChanged()
|
|
return todo
|
|
}
|
|
|
|
saveMagicTodoAnalysis(input: {
|
|
todoId: string
|
|
expectedRevision: number
|
|
sourceEntryRevision?: number
|
|
comments: MagicNoteComment[]
|
|
}): MagicTodoItem {
|
|
const database = this.requireDatabase()
|
|
const existing = database
|
|
.prepare('SELECT comments_json, entry_id FROM magic_todos WHERE id = ?')
|
|
.get(input.todoId) as { comments_json: string; entry_id: string } | undefined
|
|
if (!existing) {
|
|
throw new Error('待办不存在')
|
|
}
|
|
const source = this.getMagicNoteEntry(existing.entry_id)
|
|
if ((source.content.version === 2 || input.sourceEntryRevision !== undefined) &&
|
|
source.revision !== input.sourceEntryRevision) {
|
|
throw new Error('来源记录已被更新,请重新分析')
|
|
}
|
|
const now = new Date().toISOString()
|
|
const comments = [
|
|
...(JSON.parse(existing.comments_json) as MagicNoteComment[]),
|
|
...input.comments.map((comment) => ({
|
|
...comment,
|
|
analyzedAt: now
|
|
}))
|
|
]
|
|
const result = database
|
|
.prepare(
|
|
`UPDATE magic_todos
|
|
SET comments_json = ?, analyzed_at = ?,
|
|
revision = revision + 1, updated_at = ?
|
|
WHERE id = ? AND revision = ?`
|
|
)
|
|
.run(
|
|
JSON.stringify(comments),
|
|
now,
|
|
now,
|
|
input.todoId,
|
|
input.expectedRevision
|
|
)
|
|
if (result.changes !== 1) {
|
|
throw new Error('待办已被更新,请刷新后重试')
|
|
}
|
|
const todo = this.getMagicTodo(input.todoId)
|
|
this.options.onMagicNotesChanged?.()
|
|
return todo
|
|
}
|
|
|
|
listPendingDelegationResults(): Array<{
|
|
taskId: string
|
|
result: {
|
|
status: 'completed' | 'failed'
|
|
output?: string
|
|
error?: string
|
|
}
|
|
}> {
|
|
const rows = this.requireDatabase()
|
|
.prepare(
|
|
`SELECT task_id, result_json
|
|
FROM delegation_outbox
|
|
WHERE status = 'pending'
|
|
ORDER BY created_at
|
|
LIMIT 100`
|
|
)
|
|
.all() as Array<{ task_id: string; result_json: string }>
|
|
return rows.map((row) => ({
|
|
taskId: row.task_id,
|
|
result: JSON.parse(row.result_json) as {
|
|
status: 'completed' | 'failed'
|
|
output?: string
|
|
error?: string
|
|
}
|
|
}))
|
|
}
|
|
|
|
getDelegationDeliveryStatus(
|
|
taskId: string
|
|
): 'pending' | 'delivered' | undefined {
|
|
const row = this.requireDatabase()
|
|
.prepare(
|
|
`SELECT status FROM delegation_outbox WHERE task_id = ?`
|
|
)
|
|
.get(taskId) as { status: 'pending' | 'delivered' } | undefined
|
|
return row?.status
|
|
}
|
|
|
|
saveDelegationResult(
|
|
taskId: string,
|
|
result: {
|
|
status: 'completed' | 'failed'
|
|
output?: string
|
|
error?: string
|
|
}
|
|
): void {
|
|
const now = new Date().toISOString()
|
|
this.requireDatabase()
|
|
.prepare(
|
|
`INSERT INTO delegation_outbox
|
|
(task_id, result_json, status, created_at, updated_at)
|
|
VALUES (?, ?, 'pending', ?, ?)
|
|
ON CONFLICT(task_id) DO UPDATE SET
|
|
result_json = excluded.result_json,
|
|
status = 'pending',
|
|
updated_at = excluded.updated_at`
|
|
)
|
|
.run(taskId, JSON.stringify(result), now, now)
|
|
}
|
|
|
|
markDelegationDelivered(taskId: string): void {
|
|
const database = this.requireDatabase()
|
|
database
|
|
.prepare(
|
|
`UPDATE delegation_outbox
|
|
SET status = 'delivered', result_json = '{}', updated_at = ?
|
|
WHERE task_id = ?`
|
|
)
|
|
.run(new Date().toISOString(), taskId)
|
|
database
|
|
.prepare(
|
|
`DELETE FROM delegation_outbox
|
|
WHERE task_id IN (
|
|
SELECT task_id FROM delegation_outbox
|
|
WHERE status = 'delivered'
|
|
ORDER BY updated_at DESC
|
|
LIMIT -1 OFFSET 1000
|
|
)`
|
|
)
|
|
.run()
|
|
}
|
|
|
|
listTasks(limit = 100): AssistantTask[] {
|
|
const safeLimit = Math.max(1, Math.min(500, Math.trunc(limit)))
|
|
const rows = this.requireDatabase()
|
|
.prepare(
|
|
`${taskSelect}
|
|
WHERE visible = 1
|
|
AND (
|
|
id IN (SELECT id FROM tasks WHERE visible = 1
|
|
ORDER BY created_at DESC LIMIT ?)
|
|
OR id IN (SELECT id FROM (${activeVisibleTaskSelect}))
|
|
)
|
|
ORDER BY created_at DESC`
|
|
)
|
|
.all(safeLimit) as TaskRow[]
|
|
return rows.map(toTask)
|
|
}
|
|
|
|
getExecutionStats(input: ExecutionStatsInput): ExecutionStats {
|
|
this.requireDatabase()
|
|
return this.executionTiming!.snapshot(executionStatsInputSchema.parse(input))
|
|
}
|
|
|
|
onExecutionStatsChanged(listener: () => void): () => void {
|
|
return this.executionTiming!.onChanged(listener)
|
|
}
|
|
|
|
startExecutionTiming(taskId: string): void {
|
|
this.executionTiming!.start(taskId)
|
|
}
|
|
|
|
endExecutionTiming(taskId: string, incomplete = false): void {
|
|
this.executionTiming!.end(taskId, incomplete)
|
|
}
|
|
|
|
openReadOnly(): void {
|
|
if (this.database) throw new Error('Database already open')
|
|
this.database = new DatabaseSync(this.databasePath, { readOnly: true, timeout: 5_000 })
|
|
}
|
|
|
|
/** Worker-only connection: no startup recovery, schema migration or Main object sharing. */
|
|
openSupervisionWorker(): void {
|
|
if (this.database) throw new Error('Database already open')
|
|
this.database = new DatabaseSync(this.databasePath, { timeout: 5_000 })
|
|
this.database.exec('PRAGMA foreign_keys = ON; PRAGMA wal_autocheckpoint = 0')
|
|
}
|
|
|
|
/**
|
|
* Opens a connection used only to checkpoint the WAL from a worker thread
|
|
* (PERF-15). It never writes rows; see enableWalCheckpointWorker.
|
|
*/
|
|
openCheckpointer(): void {
|
|
if (this.database) throw new Error('Database already open')
|
|
this.database = new DatabaseSync(this.databasePath, { timeout: 5_000 })
|
|
this.database.exec('PRAGMA busy_timeout = 5000; PRAGMA wal_autocheckpoint = 0')
|
|
}
|
|
|
|
/** One PASSIVE checkpoint: copies committed WAL frames without blocking writers. */
|
|
checkpointWal(): { busy: number; log: number; checkpointed: number } {
|
|
if (!this.database) throw new Error('Database is not open')
|
|
return this.database.prepare('PRAGMA wal_checkpoint(PASSIVE)').get() as
|
|
{ busy: number; log: number; checkpointed: number }
|
|
}
|
|
|
|
/**
|
|
* Moves WAL checkpoints off Main (PERF-15). SQLite's automatic checkpoint
|
|
* runs inside the commit that crosses 1,000 WAL pages, so on a large
|
|
* database one ordinary write now and then blocked Main for 100-250 ms
|
|
* while pages were copied and synced. A worker thread checkpoints on its
|
|
* own connection instead. While it runs, Main's automatic checkpoint stays
|
|
* as a safety net at a higher threshold: under sustained writes a passive
|
|
* checkpoint never catches up exactly, so the WAL could not reset; Main's
|
|
* checkpoint then finds almost every frame already copied and only
|
|
* finishes the rest. The default threshold comes back if the worker stops.
|
|
*/
|
|
enableWalCheckpointWorker(workerPath: string, intervalMs = 250): void {
|
|
const database = this.database
|
|
if (!database || this.checkpointWorker || this.databasePath === ':memory:') return
|
|
let worker: Worker
|
|
try {
|
|
worker = new Worker(workerPath, {
|
|
workerData: { kind: 'checkpoint', databasePath: this.databasePath, intervalMs }
|
|
})
|
|
} catch {
|
|
return
|
|
}
|
|
this.checkpointWorker = worker
|
|
worker.unref()
|
|
const restore = (): void => {
|
|
if (this.checkpointWorker !== worker) return
|
|
this.checkpointWorker = undefined
|
|
if (this.database === database) database.exec('PRAGMA wal_autocheckpoint = 1000')
|
|
}
|
|
worker.on('message', (message: { ready?: boolean }) => {
|
|
if (message.ready && this.checkpointWorker === worker && this.database === database) {
|
|
database.exec(`PRAGMA wal_autocheckpoint = ${WAL_SAFETY_CHECKPOINT_PAGES}`)
|
|
}
|
|
})
|
|
worker.once('error', restore)
|
|
worker.once('exit', restore)
|
|
}
|
|
|
|
private stopWalCheckpointWorker(): void {
|
|
const worker = this.checkpointWorker
|
|
if (!worker) return
|
|
this.checkpointWorker = undefined
|
|
this.database?.exec('PRAGMA wal_autocheckpoint = 1000')
|
|
void worker.terminate()
|
|
}
|
|
|
|
/**
|
|
* Runs multi-statement reads against one WAL snapshot. Used by the readonly
|
|
* worker, where Main may commit between statements; on Main itself the
|
|
* event loop already serializes reads and writes.
|
|
*/
|
|
readSnapshot<T>(read: () => T): T {
|
|
const database = this.requireDatabase()
|
|
if (database.isTransaction) return read()
|
|
database.exec('BEGIN')
|
|
try {
|
|
const result = read()
|
|
database.exec('COMMIT')
|
|
return result
|
|
} catch (error) {
|
|
database.exec('ROLLBACK')
|
|
throw error
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Routes heavy read-only queries (story graph, conversation search) through a
|
|
* read-only worker thread. In-memory databases keep the synchronous path.
|
|
*/
|
|
enableReadonlyWorker(workerPath: string): void {
|
|
this.readonlyWorkerPath = workerPath
|
|
}
|
|
|
|
private readonlyQueryReader(): ReadonlyQueryReader | undefined {
|
|
if (!this.readonlyWorkerPath || this.databasePath === ':memory:') return undefined
|
|
this.readonlyReader ??= new ReadonlyQueryReader('assistant', this.databasePath, this.readonlyWorkerPath)
|
|
return this.readonlyReader
|
|
}
|
|
|
|
/** Test hook for the worker crash and cancellation paths. */
|
|
get readonlyWorkerForTest(): ReadonlyQueryReader | undefined {
|
|
return this.readonlyQueryReader()
|
|
}
|
|
|
|
/** Activity history storage and queries (PERF-15); the SQL lives in the repository. */
|
|
activityHistory(): ActivityHistoryRepository {
|
|
const database = this.requireDatabase()
|
|
if (this.activityHistoryRepository?.database !== database) {
|
|
this.activityHistoryRepository = { database, repository: new ActivityHistoryRepository(database) }
|
|
}
|
|
return this.activityHistoryRepository.repository
|
|
}
|
|
|
|
/** The whole stored list (tests, diagnostics); the renderer reads pages. */
|
|
getActivityHistory(): ActivityHistorySnapshot {
|
|
return this.activityHistory().snapshot()
|
|
}
|
|
|
|
/** Persists a whole newest-first list; used for the legacy localStorage migration. */
|
|
replaceActivityHistory(input: unknown): void {
|
|
this.activityHistory().replace(input)
|
|
}
|
|
|
|
/** Applies incremental changes in one transaction (PERF-15). */
|
|
updateActivityHistory(input: unknown): ActivityHistoryUpdateResult {
|
|
return this.relaxedWrite(() => this.activityHistory().update(input))
|
|
}
|
|
|
|
/**
|
|
* Runs a high-frequency local write with `synchronous = NORMAL` instead of
|
|
* FULL (PERF-15). In WAL mode NORMAL skips the fsync per commit; the commit
|
|
* is still atomic and survives an application crash, and the next FULL
|
|
* commit or checkpoint makes it durable. A power loss can only drop a
|
|
* suffix of such local writes, never a FULL commit or anything before it,
|
|
* because the WAL is synced as a whole. Writes another party may rely on
|
|
* (remote acknowledgements, channel delivery) keep using FULL.
|
|
*/
|
|
private relaxedWrite<T>(body: () => T): T {
|
|
const database = this.requireDatabase()
|
|
// Inside a caller's transaction the caller's commit decides durability.
|
|
if (database.isTransaction) return body()
|
|
const previous = (database.prepare('PRAGMA synchronous').get() as { synchronous: number }).synchronous
|
|
if (previous <= 1) return body()
|
|
database.exec('PRAGMA synchronous = NORMAL')
|
|
try {
|
|
return body()
|
|
} finally {
|
|
database.exec(`PRAGMA synchronous = ${previous}`)
|
|
}
|
|
}
|
|
|
|
/** Deletes every activity record in one transaction. */
|
|
clearActivityHistory(): void {
|
|
this.activityHistory().clear()
|
|
}
|
|
|
|
getActivityHistoryPage(input: unknown): ActivityHistoryPage {
|
|
return this.activityHistory().page(input)
|
|
}
|
|
|
|
getActivityHistorySummary(input: unknown): ActivityHistorySummary {
|
|
return this.readSnapshot(() => this.activityHistory().summary(input))
|
|
}
|
|
|
|
/** Pages and the summary on the readonly worker; same rules as listConversationSummariesAsync. */
|
|
getActivityHistoryPageAsync(input: unknown): Promise<ActivityHistoryPage> {
|
|
const database = this.requireDatabase()
|
|
return readWithFallback(database.isTransaction ? undefined : this.readonlyQueryReader(),
|
|
'activityHistoryPage', [input], undefined, () => this.getActivityHistoryPage(input))
|
|
}
|
|
|
|
getActivityHistorySummaryAsync(input: unknown): Promise<ActivityHistorySummary> {
|
|
const database = this.requireDatabase()
|
|
return readWithFallback(database.isTransaction ? undefined : this.readonlyQueryReader(),
|
|
'activityHistorySummary', [input], undefined, () => this.getActivityHistorySummary(input))
|
|
}
|
|
|
|
/** Ends records left running by requests that are no longer active (see the repository). */
|
|
reconcileActivityHistory(input: unknown): number {
|
|
return this.activityHistory().reconcile(input)
|
|
}
|
|
|
|
createTask(input: {
|
|
id: string
|
|
projectId?: string
|
|
conversationId?: string
|
|
parentTaskId?: string
|
|
expertId?: string
|
|
routingMode?: AssistantTask['routingMode']
|
|
title: string
|
|
instructions: string
|
|
origin?: AssistantTask['origin']
|
|
status?: Exclude<AssistantTask['status'], 'idle'>
|
|
visible?: boolean
|
|
remoteRecovery?: {
|
|
recoverable: boolean
|
|
currentUserMessageId?: string
|
|
currentAssistantMessageId?: string
|
|
}
|
|
}): AssistantTask {
|
|
const remoteRecovery = input.remoteRecovery
|
|
const taskId = remoteRecovery?.recoverable
|
|
? assistantIdSchema.parse(input.id)
|
|
: input.id
|
|
const currentUserMessageId =
|
|
remoteRecovery?.currentUserMessageId === undefined
|
|
? undefined
|
|
: assistantIdSchema.parse(remoteRecovery.currentUserMessageId)
|
|
const currentAssistantMessageId =
|
|
remoteRecovery?.currentAssistantMessageId === undefined
|
|
? undefined
|
|
: assistantIdSchema.parse(
|
|
remoteRecovery.currentAssistantMessageId
|
|
)
|
|
if (
|
|
remoteRecovery?.recoverable &&
|
|
(!input.projectId ||
|
|
!input.conversationId ||
|
|
!currentUserMessageId ||
|
|
!currentAssistantMessageId)
|
|
) {
|
|
throw new Error(
|
|
'可恢复远程任务必须绑定项目、对话及当前消息'
|
|
)
|
|
}
|
|
const now = new Date().toISOString()
|
|
const status = input.status ?? 'running'
|
|
const database = this.requireDatabase()
|
|
database.exec('BEGIN IMMEDIATE')
|
|
try {
|
|
if (remoteRecovery) {
|
|
const executionSpace = input.projectId
|
|
? (database
|
|
.prepare(
|
|
`SELECT kind
|
|
FROM project_execution_spaces
|
|
WHERE project_id = ?`
|
|
)
|
|
.get(input.projectId) as
|
|
| { kind: 'local' | 'ssh' }
|
|
| undefined)
|
|
: undefined
|
|
if (executionSpace?.kind !== 'ssh') {
|
|
throw new Error('远程恢复元数据仅适用于 SSH 项目')
|
|
}
|
|
}
|
|
database
|
|
.prepare(
|
|
`INSERT INTO tasks
|
|
(id, project_id, conversation_id, parent_task_id, expert_id,
|
|
routing_mode, title, instructions, origin, status, priority,
|
|
progress, created_at, started_at, visible,
|
|
remote_recoverable, current_user_message_id,
|
|
current_assistant_message_id)
|
|
VALUES (?, ?, ?, COALESCE((
|
|
SELECT parent.id FROM schedule_runs sr
|
|
JOIN tasks parent ON parent.schedule_id = sr.schedule_id
|
|
WHERE sr.id = ?
|
|
), ?), ?, ?, ?, ?, ?, ?, 0, NULL, ?, ?, ?,
|
|
?, ?, ?)`
|
|
)
|
|
.run(
|
|
taskId,
|
|
input.projectId ?? null,
|
|
input.conversationId ?? null,
|
|
taskId,
|
|
input.parentTaskId ?? null,
|
|
input.expertId ?? null,
|
|
input.routingMode ?? null,
|
|
input.title,
|
|
input.instructions,
|
|
input.origin ?? 'user',
|
|
status,
|
|
now,
|
|
status === 'running' ? now : null,
|
|
Number(input.visible ?? true),
|
|
Number(remoteRecovery?.recoverable ?? false),
|
|
remoteRecovery?.recoverable
|
|
? currentUserMessageId ?? null
|
|
: null,
|
|
remoteRecovery?.recoverable
|
|
? currentAssistantMessageId ?? null
|
|
: null
|
|
)
|
|
if (
|
|
remoteRecovery?.recoverable &&
|
|
input.projectId &&
|
|
input.conversationId &&
|
|
currentUserMessageId &&
|
|
currentAssistantMessageId
|
|
) {
|
|
const conversation = database
|
|
.prepare(
|
|
`SELECT project_id
|
|
FROM conversations
|
|
WHERE id = ? AND channel IS NULL AND status = 'active'`
|
|
)
|
|
.get(input.conversationId) as
|
|
| { project_id: string | null }
|
|
| undefined
|
|
if (!conversation) {
|
|
throw new Error('可恢复远程任务的本地对话不存在')
|
|
}
|
|
if (conversation.project_id !== input.projectId) {
|
|
throw new Error('可恢复远程任务的项目与对话不匹配')
|
|
}
|
|
const findMessage = database.prepare(
|
|
`SELECT conversation_id, request_id, role, sequence
|
|
FROM messages WHERE id = ?`
|
|
)
|
|
let user = findMessage.get(currentUserMessageId) as
|
|
| {
|
|
conversation_id: string
|
|
request_id: string | null
|
|
role: MessageRow['role']
|
|
sequence: number
|
|
}
|
|
| undefined
|
|
let assistant = findMessage.get(currentAssistantMessageId) as
|
|
| {
|
|
conversation_id: string
|
|
request_id: string | null
|
|
role: MessageRow['role']
|
|
sequence: number
|
|
}
|
|
| undefined
|
|
for (const [message, role] of [
|
|
[user, 'user'],
|
|
[assistant, 'assistant']
|
|
] as const) {
|
|
if (
|
|
message &&
|
|
(message.conversation_id !== input.conversationId ||
|
|
message.role !== role)
|
|
) {
|
|
throw new Error('可恢复远程任务的消息归属或角色无效')
|
|
}
|
|
}
|
|
if (
|
|
assistant?.request_id &&
|
|
assistant.request_id !== taskId
|
|
) {
|
|
throw new Error('助理消息已由其他任务管理')
|
|
}
|
|
const maximumSequence = (
|
|
database
|
|
.prepare(
|
|
`SELECT COALESCE(MAX(sequence), -1) AS sequence
|
|
FROM messages WHERE conversation_id = ?`
|
|
)
|
|
.get(input.conversationId) as { sequence: number }
|
|
).sequence
|
|
if (user && assistant) {
|
|
if (
|
|
assistant.sequence !== user.sequence + 1 ||
|
|
assistant.sequence !== maximumSequence
|
|
) {
|
|
throw new Error('可恢复远程任务的当前消息顺序无效')
|
|
}
|
|
} else if (user) {
|
|
if (user.sequence !== maximumSequence) {
|
|
throw new Error('可恢复远程任务的用户消息不是当前消息')
|
|
}
|
|
database
|
|
.prepare(
|
|
`INSERT INTO messages
|
|
(id, conversation_id, request_id, role, content, state,
|
|
sequence, metadata_json, created_at)
|
|
VALUES (?, ?, ?, 'assistant', '', 'streaming', ?, ?, ?)`
|
|
)
|
|
.run(
|
|
currentAssistantMessageId,
|
|
input.conversationId,
|
|
taskId,
|
|
user.sequence + 1,
|
|
JSON.stringify({ createdAt: Date.parse(now), blocks: [] }),
|
|
now
|
|
)
|
|
assistant = findMessage.get(currentAssistantMessageId) as
|
|
typeof assistant
|
|
} else if (assistant) {
|
|
if (assistant.sequence !== maximumSequence) {
|
|
throw new Error('可恢复远程任务的助理消息不是当前消息')
|
|
}
|
|
database
|
|
.prepare(
|
|
`UPDATE messages
|
|
SET sequence = sequence + 1
|
|
WHERE conversation_id = ? AND sequence >= ?`
|
|
)
|
|
.run(input.conversationId, assistant.sequence)
|
|
database
|
|
.prepare(
|
|
`INSERT INTO messages
|
|
(id, conversation_id, request_id, role, content, state,
|
|
sequence, metadata_json, created_at)
|
|
VALUES (?, ?, NULL, 'user', ?, 'complete', ?, ?, ?)`
|
|
)
|
|
.run(
|
|
currentUserMessageId,
|
|
input.conversationId,
|
|
input.instructions,
|
|
assistant.sequence,
|
|
JSON.stringify({ createdAt: Date.parse(now) }),
|
|
now
|
|
)
|
|
user = findMessage.get(currentUserMessageId) as typeof user
|
|
} else {
|
|
database
|
|
.prepare(
|
|
`INSERT INTO messages
|
|
(id, conversation_id, request_id, role, content, state,
|
|
sequence, metadata_json, created_at)
|
|
VALUES (?, ?, NULL, 'user', ?, 'complete', ?, ?, ?),
|
|
(?, ?, ?, 'assistant', '', 'streaming', ?, ?, ?)`
|
|
)
|
|
.run(
|
|
currentUserMessageId,
|
|
input.conversationId,
|
|
input.instructions,
|
|
maximumSequence + 1,
|
|
JSON.stringify({ createdAt: Date.parse(now) }),
|
|
now,
|
|
currentAssistantMessageId,
|
|
input.conversationId,
|
|
taskId,
|
|
maximumSequence + 2,
|
|
JSON.stringify({ createdAt: Date.parse(now), blocks: [] }),
|
|
now
|
|
)
|
|
}
|
|
const claim = database
|
|
.prepare(
|
|
`UPDATE messages
|
|
SET request_id = ?
|
|
WHERE id = ? AND conversation_id = ? AND role = 'assistant'
|
|
AND (request_id IS NULL OR request_id = ?)`
|
|
)
|
|
.run(
|
|
taskId,
|
|
currentAssistantMessageId,
|
|
input.conversationId,
|
|
taskId
|
|
)
|
|
if (claim.changes !== 1) {
|
|
throw new Error('无法管理可恢复远程任务的助理消息')
|
|
}
|
|
database
|
|
.prepare(
|
|
`UPDATE conversations
|
|
SET updated_at = MAX(updated_at, ?)
|
|
WHERE id = ?`
|
|
)
|
|
.run(now, input.conversationId)
|
|
}
|
|
database
|
|
.prepare(
|
|
`INSERT INTO task_events
|
|
(task_id, run_id, kind, payload_json, created_at)
|
|
VALUES (?, NULL, ?, ?, ?)`
|
|
)
|
|
.run(
|
|
taskId,
|
|
status,
|
|
'{}',
|
|
now
|
|
)
|
|
database.exec('COMMIT')
|
|
} catch (error) {
|
|
database.exec('ROLLBACK')
|
|
throw error
|
|
}
|
|
return this.getTask(taskId)
|
|
}
|
|
|
|
listRecoverableRemoteTasks(): RecoverableRemoteTask[] {
|
|
return this.requireDatabase()
|
|
.prepare(
|
|
`SELECT id AS taskId, project_id AS projectId,
|
|
conversation_id AS conversationId,
|
|
current_user_message_id AS currentUserMessageId,
|
|
current_assistant_message_id AS currentAssistantMessageId,
|
|
instructions, status
|
|
FROM tasks
|
|
WHERE remote_recoverable = 1
|
|
AND status IN ('running', 'waiting_approval', 'interrupted')
|
|
AND project_id IS NOT NULL
|
|
AND conversation_id IS NOT NULL
|
|
AND current_user_message_id IS NOT NULL
|
|
AND current_assistant_message_id IS NOT NULL
|
|
ORDER BY created_at ASC, rowid ASC`
|
|
)
|
|
.all()
|
|
.map((record) => {
|
|
const candidate = record as RecoverableRemoteTask
|
|
assistantIdSchema.parse(candidate.taskId)
|
|
assistantIdSchema.parse(candidate.projectId)
|
|
assistantIdSchema.parse(candidate.conversationId)
|
|
assistantIdSchema.parse(candidate.currentUserMessageId)
|
|
assistantIdSchema.parse(candidate.currentAssistantMessageId)
|
|
if (
|
|
!['running', 'waiting_approval', 'interrupted'].includes(
|
|
candidate.status
|
|
)
|
|
) {
|
|
throw new Error('可恢复远程任务元数据无效')
|
|
}
|
|
return candidate
|
|
})
|
|
}
|
|
|
|
recordRemoteTaskQuestionArrival(taskIdInput: string, questionId: string): boolean {
|
|
const taskId = assistantIdSchema.parse(taskIdInput)
|
|
const database = this.requireDatabase()
|
|
const row = database.prepare(
|
|
`SELECT m.id, m.metadata_json
|
|
FROM tasks t JOIN messages m
|
|
ON m.id = t.current_assistant_message_id
|
|
AND m.conversation_id = t.conversation_id AND m.request_id = t.id
|
|
WHERE t.id = ? AND t.remote_recoverable = 1 AND m.role = 'assistant'`
|
|
).get(taskId) as { id: string; metadata_json: string } | undefined
|
|
if (!row) return false
|
|
const metadata = JSON.parse(row.metadata_json) as MessageMetadata
|
|
const blocks = appendConversationQuestionBlock(metadata.blocks, questionId)
|
|
if (blocks !== metadata.blocks) {
|
|
metadata.blocks = blocks
|
|
database.prepare('UPDATE messages SET metadata_json = ? WHERE id = ?')
|
|
.run(JSON.stringify(metadata), row.id)
|
|
}
|
|
return true
|
|
}
|
|
|
|
recordRemoteTaskQuestionAnswer(
|
|
taskIdInput: string,
|
|
answerInput: ConversationAnsweredQuestion
|
|
): boolean {
|
|
const taskId = assistantIdSchema.parse(taskIdInput)
|
|
const answer = conversationAnsweredQuestionSchema.parse(answerInput)
|
|
const database = this.requireDatabase()
|
|
database.exec('BEGIN IMMEDIATE')
|
|
try {
|
|
const row = database.prepare(
|
|
`SELECT m.id, m.conversation_id, m.metadata_json
|
|
FROM tasks t JOIN messages m
|
|
ON m.id = t.current_assistant_message_id
|
|
AND m.conversation_id = t.conversation_id AND m.request_id = t.id
|
|
WHERE t.id = ? AND t.remote_recoverable = 1 AND m.role = 'assistant'`
|
|
).get(taskId) as {
|
|
id: string; conversation_id: string; metadata_json: string
|
|
} | undefined
|
|
if (!row) {
|
|
database.exec('COMMIT')
|
|
return false
|
|
}
|
|
const metadata = JSON.parse(row.metadata_json) as MessageMetadata
|
|
metadata.answeredQuestions = [
|
|
...(metadata.answeredQuestions ?? []).filter(item => item.questionId !== answer.questionId),
|
|
answer
|
|
]
|
|
database.prepare('UPDATE messages SET metadata_json = ? WHERE id = ?')
|
|
.run(JSON.stringify(metadata), row.id)
|
|
database.prepare(
|
|
'UPDATE conversations SET updated_at = MAX(updated_at, ?) WHERE id = ?'
|
|
).run(new Date().toISOString(), row.conversation_id)
|
|
database.exec('COMMIT')
|
|
return true
|
|
} catch (error) {
|
|
database.exec('ROLLBACK')
|
|
throw error
|
|
}
|
|
}
|
|
|
|
endRecoverableRemoteTask(
|
|
taskIdInput: string,
|
|
messageInput: string,
|
|
status: 'failed' | 'cancelled'
|
|
): void {
|
|
const taskId = assistantIdSchema.parse(taskIdInput)
|
|
const message = messageInput.trim().slice(0, 4_000)
|
|
if (!message) {
|
|
throw new Error('远程任务终止原因不能为空')
|
|
}
|
|
const database = this.requireDatabase()
|
|
const now = new Date().toISOString()
|
|
database.exec('BEGIN IMMEDIATE')
|
|
try {
|
|
const owner = database
|
|
.prepare(
|
|
`SELECT conversation_id, current_assistant_message_id
|
|
FROM tasks
|
|
WHERE id = ? AND remote_recoverable = 1
|
|
AND status IN ('running', 'waiting_approval', 'interrupted')`
|
|
)
|
|
.get(taskId) as
|
|
| {
|
|
conversation_id: string | null
|
|
current_assistant_message_id: string | null
|
|
}
|
|
| undefined
|
|
if (
|
|
!owner?.conversation_id ||
|
|
!owner.current_assistant_message_id
|
|
) {
|
|
throw new Error('没有可终止的远程恢复任务')
|
|
}
|
|
const row = database
|
|
.prepare(
|
|
`SELECT id, role, content, state, metadata_json, created_at
|
|
FROM messages
|
|
WHERE id = ? AND conversation_id = ? AND request_id = ?`
|
|
)
|
|
.get(
|
|
owner.current_assistant_message_id,
|
|
owner.conversation_id,
|
|
taskId
|
|
) as
|
|
| {
|
|
id: string
|
|
role: MessageRow['role']
|
|
content: string
|
|
state: ConversationMessage['state']
|
|
metadata_json: string
|
|
created_at: string
|
|
}
|
|
| undefined
|
|
if (!row || row.role !== 'assistant') {
|
|
throw new Error('远程恢复任务的助理消息不存在')
|
|
}
|
|
const metadata = JSON.parse(row.metadata_json) as MessageMetadata
|
|
const current = conversationMessageSchema.parse({
|
|
id: row.id,
|
|
role: 'assistant',
|
|
content: row.content,
|
|
state: row.state,
|
|
createdAt: metadata.createdAt ?? Date.parse(row.created_at),
|
|
status: metadata.status,
|
|
terminalStatus: metadata.terminalStatus,
|
|
reasoning: metadata.reasoning,
|
|
runtimeChecklist: metadata.runtimeChecklist,
|
|
blocks: metadata.blocks,
|
|
displayCaptureTruncated:
|
|
metadata.displayCaptureTruncated,
|
|
contextCompression: metadata.contextCompression,
|
|
contextCompressions: metadata.contextCompressions,
|
|
tools: metadata.tools,
|
|
subagents: metadata.subagents,
|
|
sources: metadata.sources,
|
|
sourceReferences: metadata.sourceReferences,
|
|
knowledgeRetrieval: metadata.knowledgeRetrieval,
|
|
artifactIds: metadata.artifactIds,
|
|
task: metadata.task,
|
|
attachments: metadata.attachments,
|
|
answeredQuestions: metadata.answeredQuestions
|
|
})
|
|
const ended = reduceRecoveredAgentEvent(current, {
|
|
requestId: taskId,
|
|
type: 'error',
|
|
status,
|
|
message
|
|
})
|
|
database
|
|
.prepare(
|
|
`UPDATE messages
|
|
SET content = ?, state = ?, metadata_json = ?
|
|
WHERE id = ? AND conversation_id = ? AND request_id = ?`
|
|
)
|
|
.run(
|
|
ended.content,
|
|
ended.state,
|
|
serializeConversationMessageMetadata(ended),
|
|
owner.current_assistant_message_id,
|
|
owner.conversation_id,
|
|
taskId
|
|
)
|
|
database
|
|
.prepare(
|
|
`UPDATE tasks
|
|
SET status = ?, error = ?, completed_at = ?
|
|
WHERE id = ?`
|
|
)
|
|
.run(status, message, now, taskId)
|
|
database
|
|
.prepare(
|
|
`INSERT INTO task_events
|
|
(task_id, run_id, kind, payload_json, created_at)
|
|
VALUES (?, NULL, 'error', ?, ?)`
|
|
)
|
|
.run(
|
|
taskId,
|
|
JSON.stringify({
|
|
requestId: taskId,
|
|
type: 'error',
|
|
status,
|
|
message
|
|
}),
|
|
now
|
|
)
|
|
database
|
|
.prepare(
|
|
`UPDATE conversations SET updated_at = ? WHERE id = ?`
|
|
)
|
|
.run(now, owner.conversation_id)
|
|
this.executionTiming?.state(taskId, status)
|
|
database.exec('COMMIT')
|
|
} catch (error) {
|
|
database.exec('ROLLBACK')
|
|
throw error
|
|
}
|
|
}
|
|
|
|
upsertModelUsageCall(input: ModelUsageCallInput): void {
|
|
const requestId = validateUsageText(
|
|
input.requestId,
|
|
'requestId',
|
|
256
|
|
)
|
|
const callId = validateUsageText(input.callId, 'callId', 256)
|
|
const runtime = validateUsageText(input.runtime, 'runtime', 100)
|
|
const provider = validateUsageText(
|
|
input.provider,
|
|
'provider',
|
|
100
|
|
)
|
|
const model = validateUsageText(input.model, 'model', 500)
|
|
const inputTokens = validateTokenCount(input.input, 'input')
|
|
const outputTokens = validateTokenCount(input.output, 'output')
|
|
const cacheReadTokens = validateTokenCount(
|
|
input.cacheRead,
|
|
'cacheRead'
|
|
)
|
|
const cacheWriteTokens = validateTokenCount(
|
|
input.cacheWrite,
|
|
'cacheWrite'
|
|
)
|
|
const now = new Date().toISOString()
|
|
this.requireDatabase()
|
|
.prepare(
|
|
`INSERT INTO model_usage_calls
|
|
(request_id, call_id, runtime, provider, model, input_tokens,
|
|
output_tokens, cache_read_tokens, cache_write_tokens,
|
|
created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(request_id, call_id) DO UPDATE SET
|
|
runtime = excluded.runtime,
|
|
provider = excluded.provider,
|
|
model = excluded.model,
|
|
input_tokens = excluded.input_tokens,
|
|
output_tokens = excluded.output_tokens,
|
|
cache_read_tokens = excluded.cache_read_tokens,
|
|
cache_write_tokens = excluded.cache_write_tokens,
|
|
updated_at = excluded.updated_at`
|
|
)
|
|
.run(
|
|
requestId,
|
|
callId,
|
|
runtime,
|
|
provider,
|
|
model,
|
|
inputTokens,
|
|
outputTokens,
|
|
cacheReadTokens,
|
|
cacheWriteTokens,
|
|
now,
|
|
now
|
|
)
|
|
this.notifyModelUsageChanged()
|
|
}
|
|
|
|
/**
|
|
* Records usage for background work that has no request of its own, such as
|
|
* knowledge graph extraction or remote embeddings. Calls are grouped into one
|
|
* hidden task per source, bucket and UTC day so the foreign key to tasks holds.
|
|
*/
|
|
recordSystemModelUsage(input: SystemModelUsageInput): void {
|
|
const day = new Date().toISOString().slice(0, 10)
|
|
const taskId = `${input.source}:${input.bucket}:${day}`
|
|
const now = new Date().toISOString()
|
|
this.requireDatabase()
|
|
.prepare(
|
|
`INSERT OR IGNORE INTO tasks
|
|
(id, project_id, conversation_id, title, instructions, origin,
|
|
status, priority, created_at, started_at, completed_at,
|
|
visible)
|
|
VALUES (?, NULL, ?, ?, ?, 'assistant', 'completed', 0, ?, ?, ?, 0)`
|
|
)
|
|
.run(
|
|
taskId,
|
|
taskId,
|
|
input.bucket === 'embedding' ? '知识库向量化' : '知识图谱抽取',
|
|
'后台知识库模型用量汇总',
|
|
now,
|
|
now,
|
|
now
|
|
)
|
|
this.upsertModelUsageCall({
|
|
...input,
|
|
requestId: taskId,
|
|
callId: `system-call:${randomUUID()}`
|
|
})
|
|
}
|
|
|
|
private notifyModelUsageChanged(): void {
|
|
if (this.modelUsageNotificationQueued || !this.options.onModelUsageChanged) {
|
|
return
|
|
}
|
|
this.modelUsageNotificationQueued = true
|
|
// Coalesce bursts of per-call upserts into one renderer refresh.
|
|
const timer = setTimeout(() => {
|
|
this.modelUsageNotificationQueued = false
|
|
this.options.onModelUsageChanged?.()
|
|
}, 500)
|
|
timer.unref?.()
|
|
}
|
|
|
|
getTokenUsageSummary(): TokenUsageSummary {
|
|
// OpenCode reports uncached input for every provider, including OpenAI.
|
|
const cacheInputSql = `usage.input_tokens + CASE
|
|
WHEN usage.runtime = 'opencode'
|
|
OR LOWER(usage.provider) LIKE '%anthropic%'
|
|
THEN usage.cache_read_tokens + usage.cache_write_tokens
|
|
ELSE 0
|
|
END`
|
|
const systemSourceSql = `CASE
|
|
WHEN tasks.conversation_id LIKE 'heartbeat:%' THEN 'heartbeat'
|
|
WHEN tasks.conversation_id LIKE 'supervision:%' THEN 'supervision'
|
|
WHEN tasks.conversation_id LIKE 'knowledge:%' THEN 'knowledge'
|
|
WHEN tasks.conversation_id LIKE 'delegation:%' THEN 'delegation'
|
|
WHEN tasks.origin = 'assistant' AND tasks.conversation_id IS NULL
|
|
THEN 'magic-notes'
|
|
END`
|
|
const database = this.requireDatabase()
|
|
// Conversation and system rows are limited separately so frequent chat
|
|
// usage cannot hide background usage (or the reverse).
|
|
const selectRows = (system: boolean): TokenUsageRecordRow[] =>
|
|
database
|
|
.prepare(
|
|
`SELECT
|
|
usage.request_id,
|
|
tasks.project_id,
|
|
projects.name AS project_name,
|
|
tasks.conversation_id,
|
|
conversations.title AS conversation_title,
|
|
${systemSourceSql} AS system_source,
|
|
usage.runtime,
|
|
usage.provider,
|
|
usage.model,
|
|
COUNT(*) AS call_count,
|
|
SUM(usage.input_tokens) AS input_tokens,
|
|
SUM(usage.output_tokens) AS output_tokens,
|
|
SUM(usage.cache_read_tokens) AS cache_read_tokens,
|
|
SUM(usage.cache_write_tokens) AS cache_write_tokens,
|
|
SUM(${cacheInputSql}) AS cache_input_tokens
|
|
FROM model_usage_calls usage
|
|
JOIN tasks ON tasks.id = usage.request_id
|
|
LEFT JOIN projects ON projects.id = tasks.project_id
|
|
LEFT JOIN conversations
|
|
ON conversations.id = tasks.conversation_id
|
|
WHERE (${systemSourceSql}) IS ${system ? 'NOT ' : ''}NULL
|
|
GROUP BY
|
|
usage.request_id,
|
|
tasks.project_id,
|
|
projects.name,
|
|
tasks.conversation_id,
|
|
conversations.title,
|
|
usage.runtime,
|
|
usage.provider,
|
|
usage.model
|
|
ORDER BY MAX(usage.updated_at) DESC
|
|
LIMIT 500`
|
|
)
|
|
.all() as TokenUsageRecordRow[]
|
|
const knownSources = new Set<string>(tokenUsageSystemSources)
|
|
const records: TokenUsageRecord[] = [
|
|
...selectRows(false),
|
|
...selectRows(true)
|
|
].map((row) => ({
|
|
requestId: row.request_id,
|
|
...(row.system_source && knownSources.has(row.system_source)
|
|
? { systemSource: row.system_source as TokenUsageSystemSource }
|
|
: {}),
|
|
projectId: row.project_id ?? undefined,
|
|
projectName: row.project_name ?? undefined,
|
|
conversationId: row.conversation_id ?? undefined,
|
|
conversationTitle: row.conversation_title ?? undefined,
|
|
runtime: row.runtime,
|
|
provider: row.provider,
|
|
model: row.model,
|
|
callCount: row.call_count,
|
|
input: row.input_tokens,
|
|
output: row.output_tokens,
|
|
cacheRead: row.cache_read_tokens,
|
|
cacheWrite: row.cache_write_tokens,
|
|
cacheInput: row.cache_input_tokens,
|
|
totalTokens: row.input_tokens + row.output_tokens
|
|
}))
|
|
type TotalRow = {
|
|
is_system: number | null
|
|
call_count: number
|
|
input_tokens: number
|
|
output_tokens: number
|
|
cache_read_tokens: number
|
|
cache_write_tokens: number
|
|
cache_input_tokens: number
|
|
}
|
|
// Usage rows always reference a task (foreign key), so the join is lossless.
|
|
const totalRows = database
|
|
.prepare(
|
|
`SELECT
|
|
(${systemSourceSql}) IS NOT NULL AS is_system,
|
|
COUNT(*) AS call_count,
|
|
COALESCE(SUM(usage.input_tokens), 0) AS input_tokens,
|
|
COALESCE(SUM(usage.output_tokens), 0) AS output_tokens,
|
|
COALESCE(SUM(usage.cache_read_tokens), 0) AS cache_read_tokens,
|
|
COALESCE(SUM(usage.cache_write_tokens), 0) AS cache_write_tokens,
|
|
COALESCE(SUM(${cacheInputSql}), 0) AS cache_input_tokens
|
|
FROM model_usage_calls usage
|
|
LEFT JOIN tasks ON tasks.id = usage.request_id
|
|
GROUP BY is_system`
|
|
)
|
|
.all() as TotalRow[]
|
|
const toTotals = (rows: TotalRow[]): TokenUsageTotals => {
|
|
const sum = (key: Exclude<keyof TotalRow, 'is_system'>): number =>
|
|
rows.reduce((total, row) => total + row[key], 0)
|
|
return {
|
|
callCount: sum('call_count'),
|
|
input: sum('input_tokens'),
|
|
output: sum('output_tokens'),
|
|
cacheRead: sum('cache_read_tokens'),
|
|
cacheWrite: sum('cache_write_tokens'),
|
|
cacheInput: sum('cache_input_tokens'),
|
|
totalTokens: sum('input_tokens') + sum('output_tokens')
|
|
}
|
|
}
|
|
return {
|
|
totals: toTotals(totalRows),
|
|
conversationTotals: toTotals(totalRows.filter((row) => !row.is_system)),
|
|
systemTotals: toTotals(totalRows.filter((row) => row.is_system)),
|
|
records
|
|
}
|
|
}
|
|
|
|
updateTaskStatus(
|
|
taskId: string,
|
|
status: AssistantTask['status'],
|
|
error?: string
|
|
): void {
|
|
const storedStatus = status === 'idle' ? 'queued' : status
|
|
const terminal = [
|
|
'completed',
|
|
'failed',
|
|
'cancelled',
|
|
'interrupted'
|
|
].includes(storedStatus)
|
|
const now = new Date().toISOString()
|
|
const result = this.requireDatabase()
|
|
.prepare(
|
|
`UPDATE tasks
|
|
SET status = ?, error = ?,
|
|
started_at = CASE
|
|
WHEN ? = 'running' AND started_at IS NULL THEN ?
|
|
ELSE started_at
|
|
END,
|
|
completed_at = CASE
|
|
WHEN ? THEN ?
|
|
WHEN ? IN ('queued', 'running', 'waiting_approval', 'paused')
|
|
THEN NULL
|
|
ELSE completed_at
|
|
END
|
|
WHERE id = ?`
|
|
)
|
|
.run(
|
|
storedStatus,
|
|
error ?? null,
|
|
storedStatus,
|
|
now,
|
|
terminal ? 1 : 0,
|
|
now,
|
|
storedStatus,
|
|
taskId
|
|
)
|
|
if (result.changes !== 1) {
|
|
throw new Error('任务不存在')
|
|
}
|
|
this.appendTaskEvent(taskId, 'status', { status, error })
|
|
this.executionTiming?.state(taskId, status)
|
|
}
|
|
|
|
resolveAssistantSuggestionTask(
|
|
taskId: string,
|
|
status: 'completed' | 'cancelled'
|
|
): void {
|
|
const completedAt = new Date().toISOString()
|
|
const result = this.requireDatabase()
|
|
.prepare(
|
|
`UPDATE tasks
|
|
SET status = ?, error = NULL, completed_at = ?
|
|
WHERE id = ? AND origin = 'assistant' AND status = 'paused'`
|
|
)
|
|
.run(status, completedAt, taskId)
|
|
if (result.changes !== 1) {
|
|
throw new Error('待处理的智能心跳建议不存在或状态已变化')
|
|
}
|
|
this.appendTaskEvent(taskId, 'status', { status })
|
|
}
|
|
|
|
appendTaskEvent(
|
|
taskId: string,
|
|
kind: string,
|
|
payload: unknown
|
|
): void {
|
|
// Local run events stream many times per second. Lifecycle events stay
|
|
// FULL; their commit also makes every earlier relaxed event durable.
|
|
if (kind === 'status' || kind === 'done' || kind === 'error') {
|
|
this.appendTaskEventNow(taskId, kind, payload)
|
|
} else {
|
|
this.relaxedWrite(() => this.appendTaskEventNow(taskId, kind, payload))
|
|
}
|
|
}
|
|
|
|
private appendTaskEventNow(
|
|
taskId: string,
|
|
kind: string,
|
|
payload: unknown
|
|
): void {
|
|
const database = this.requireDatabase()
|
|
const payloadJson = this.subagentProgress!.serialize(taskId, kind, withoutHistoricalWorkMode(payload))
|
|
const result = database
|
|
.prepare(
|
|
`INSERT INTO task_events
|
|
(task_id, run_id, kind, payload_json, created_at)
|
|
VALUES (?, NULL, ?, ?, ?)`
|
|
)
|
|
.run(
|
|
taskId,
|
|
kind.slice(0, 64),
|
|
payloadJson,
|
|
new Date().toISOString()
|
|
)
|
|
this.subagentProgress!.inserted(
|
|
taskId, kind, Number(result.lastInsertRowid), payloadJson
|
|
)
|
|
}
|
|
|
|
appendRemoteTaskEventOnce(input: RemoteTaskEventInput): boolean {
|
|
const database = this.requireDatabase()
|
|
return this.insertRemoteTaskEventOnce(
|
|
database,
|
|
validateRemoteTaskEvent(input)
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Appends many remote semantic events in ONE task-event transaction.
|
|
* Returns one flag per input event: true when the event was newly stored,
|
|
* false when the same provenance was already stored (replay dedupe).
|
|
*/
|
|
appendRemoteTaskEventsOnce(
|
|
events: readonly RemoteTaskEventInput[]
|
|
): boolean[] {
|
|
if (events.length === 0) {
|
|
return []
|
|
}
|
|
if (events.length > REMOTE_TASK_EVENT_BATCH_MAXIMUM_EVENTS) {
|
|
throw new RangeError(
|
|
`Remote task event batch must contain at most ${REMOTE_TASK_EVENT_BATCH_MAXIMUM_EVENTS} events`
|
|
)
|
|
}
|
|
const validated = events.map(validateRemoteTaskEvent)
|
|
const database = this.requireDatabase()
|
|
database.exec('BEGIN IMMEDIATE')
|
|
try {
|
|
const inserted = validated.map((event) =>
|
|
this.insertRemoteTaskEventOnce(database, event)
|
|
)
|
|
database.exec('COMMIT')
|
|
return inserted
|
|
} catch (error) {
|
|
database.exec('ROLLBACK')
|
|
throw error
|
|
}
|
|
}
|
|
|
|
appendRemoteConversationTaskEventOnce(
|
|
input: RemoteConversationTaskEventInput
|
|
): boolean {
|
|
return this.appendRemoteConversationTaskEventsBatch({
|
|
taskId: input.taskId,
|
|
conversationId: input.conversationId,
|
|
assistantMessageId: input.assistantMessageId,
|
|
runtimeSelection: input.runtimeSelection,
|
|
events: [input]
|
|
})[0]!
|
|
}
|
|
|
|
/**
|
|
* Persists remote semantic events of one recoverable task in ONE
|
|
* transaction. The assistant message and conversation row are read at most
|
|
* once and written at most once per batch, while events are still reduced
|
|
* one by one in their original order. Already stored events (same remote
|
|
* provenance) are skipped and reported as false.
|
|
*/
|
|
appendRemoteConversationTaskEventsBatch(
|
|
input: RemoteConversationTaskEventsBatchInput
|
|
): boolean[] {
|
|
if (input.events.length === 0) {
|
|
return []
|
|
}
|
|
if (input.events.length > REMOTE_TASK_EVENT_BATCH_MAXIMUM_EVENTS) {
|
|
throw new RangeError(
|
|
`Remote task event batch must contain at most ${REMOTE_TASK_EVENT_BATCH_MAXIMUM_EVENTS} events`
|
|
)
|
|
}
|
|
const conversationId = assistantIdSchema.parse(input.conversationId)
|
|
const assistantMessageId = assistantIdSchema.parse(
|
|
input.assistantMessageId
|
|
)
|
|
const requestRuntimeSelection =
|
|
optionalAgentRuntimeSelectionSchema.parse(input.runtimeSelection)
|
|
const prepared = input.events.map((item) => {
|
|
const publicEvent = stripRemoteEventProvenance(item.event)
|
|
if (publicEvent.requestId !== input.taskId) {
|
|
throw new Error('远程事件的请求 ID 与任务不匹配')
|
|
}
|
|
return {
|
|
publicEvent,
|
|
event: validateRemoteTaskEvent({
|
|
taskId: input.taskId,
|
|
bindingId: item.bindingId,
|
|
operationId: item.operationId,
|
|
semanticSequence: item.semanticSequence,
|
|
eventIndex: item.eventIndex,
|
|
kind: publicEvent.type,
|
|
payload: publicEvent
|
|
})
|
|
}
|
|
})
|
|
const taskId = prepared[0]!.event.taskId
|
|
const database = this.requireDatabase()
|
|
database.exec('BEGIN IMMEDIATE')
|
|
try {
|
|
const owner = database
|
|
.prepare(
|
|
`SELECT project_id, conversation_id,
|
|
current_assistant_message_id
|
|
FROM tasks
|
|
WHERE id = ? AND remote_recoverable = 1`
|
|
)
|
|
.get(taskId) as
|
|
| {
|
|
project_id: string | null
|
|
conversation_id: string | null
|
|
current_assistant_message_id: string | null
|
|
}
|
|
| undefined
|
|
if (
|
|
!owner ||
|
|
owner.conversation_id !== conversationId ||
|
|
owner.current_assistant_message_id !== assistantMessageId
|
|
) {
|
|
throw new Error('远程事件与可恢复任务的消息归属不匹配')
|
|
}
|
|
const inserted: boolean[] = []
|
|
let message: ConversationMessage | undefined
|
|
let lastReduced: ConversationMessage | undefined
|
|
let conversationState:
|
|
| {
|
|
contextState: ReturnType<typeof parseConversationContextState>
|
|
runtimeSelectionKey: ReturnType<typeof remoteEventSelectionKey>
|
|
}
|
|
| undefined
|
|
const loadMessage = (): ConversationMessage => {
|
|
if (message) {
|
|
return message
|
|
}
|
|
const row = database
|
|
.prepare(
|
|
`SELECT id, role, content, state, metadata_json, created_at
|
|
FROM messages
|
|
WHERE id = ? AND conversation_id = ? AND request_id = ?`
|
|
)
|
|
.get(
|
|
assistantMessageId,
|
|
conversationId,
|
|
taskId
|
|
) as
|
|
| {
|
|
id: string
|
|
role: MessageRow['role']
|
|
content: string
|
|
state: ConversationMessage['state']
|
|
metadata_json: string
|
|
created_at: string
|
|
}
|
|
| undefined
|
|
if (!row || row.role !== 'assistant') {
|
|
throw new Error('远程事件的助理消息不存在或不受任务管理')
|
|
}
|
|
const metadata = JSON.parse(row.metadata_json) as MessageMetadata
|
|
message = conversationMessageSchema.parse(
|
|
storedRemoteAssistantMessage(
|
|
row.id,
|
|
row.content,
|
|
row.state,
|
|
metadata.createdAt ?? Date.parse(row.created_at),
|
|
metadata
|
|
)
|
|
)
|
|
return message
|
|
}
|
|
const loadConversationState = (): NonNullable<
|
|
typeof conversationState
|
|
> => {
|
|
if (conversationState) {
|
|
return conversationState
|
|
}
|
|
const conversation = database
|
|
.prepare(
|
|
`SELECT c.runtime_selection_json, c.context_state_json,
|
|
p.runtime_selection_json AS project_runtime_selection_json
|
|
FROM conversations c
|
|
LEFT JOIN projects p ON p.id = c.project_id
|
|
WHERE c.id = ?`
|
|
)
|
|
.get(conversationId) as
|
|
| {
|
|
runtime_selection_json: string | null
|
|
project_runtime_selection_json: string | null
|
|
context_state_json: string | null
|
|
}
|
|
| undefined
|
|
if (!conversation) {
|
|
throw new Error('远程事件的对话不存在')
|
|
}
|
|
conversationState = {
|
|
contextState: parseConversationContextState(
|
|
conversation.context_state_json
|
|
),
|
|
runtimeSelectionKey: remoteEventSelectionKey(
|
|
requestRuntimeSelection,
|
|
parseRuntimeSelection(conversation.runtime_selection_json),
|
|
parseRuntimeSelection(
|
|
conversation.project_runtime_selection_json
|
|
)
|
|
)
|
|
}
|
|
return conversationState
|
|
}
|
|
for (const { event, publicEvent } of prepared) {
|
|
if (!this.insertRemoteTaskEventOnce(database, event)) {
|
|
inserted.push(false)
|
|
continue
|
|
}
|
|
inserted.push(true)
|
|
if (publicEvent.type === 'remote-semantic-checkpoint') {
|
|
this.terminalizeRemoteTaskAtCheckpoint(database, event)
|
|
continue
|
|
}
|
|
const reduced = reduceRecoveredAgentEvent(loadMessage(), publicEvent)
|
|
lastReduced = reduced
|
|
// Match sequential single-event writes exactly: each of them re-read
|
|
// only the stored metadata fields before reducing the next event.
|
|
message = storedRemoteAssistantMessage(
|
|
reduced.id,
|
|
reduced.content,
|
|
reduced.state,
|
|
reduced.createdAt,
|
|
reduced
|
|
)
|
|
const state = loadConversationState()
|
|
if (publicEvent.type === 'context-metrics') {
|
|
state.contextState = {
|
|
...state.contextState,
|
|
contextMetrics: {
|
|
runtimeSelectionKey: state.runtimeSelectionKey,
|
|
contextTokens: publicEvent.contextTokens,
|
|
source: publicEvent.source,
|
|
basis: 'model-call'
|
|
}
|
|
}
|
|
} else if (
|
|
publicEvent.type === 'context-compression' &&
|
|
publicEvent.scope !== 'agent-run'
|
|
) {
|
|
const estimatedAfterTokens =
|
|
publicEvent.estimatedAfterTokens
|
|
state.contextState = {
|
|
...state.contextState,
|
|
...(publicEvent.conversationState
|
|
? {
|
|
contextCompressionState:
|
|
publicEvent.conversationState
|
|
}
|
|
: {}),
|
|
...(publicEvent.state === 'completed' &&
|
|
estimatedAfterTokens !== undefined
|
|
? {
|
|
contextMetrics: {
|
|
runtimeSelectionKey: state.runtimeSelectionKey,
|
|
contextTokens: estimatedAfterTokens,
|
|
source: 'estimated' as const,
|
|
basis: 'conversation' as const
|
|
}
|
|
}
|
|
: {})
|
|
}
|
|
}
|
|
}
|
|
if (lastReduced) {
|
|
const messageUpdate = database
|
|
.prepare(
|
|
`UPDATE messages
|
|
SET content = ?, state = ?, metadata_json = ?
|
|
WHERE id = ? AND conversation_id = ? AND request_id = ?`
|
|
)
|
|
.run(
|
|
lastReduced.content,
|
|
lastReduced.state,
|
|
serializeConversationMessageMetadata(lastReduced),
|
|
assistantMessageId,
|
|
conversationId,
|
|
taskId
|
|
)
|
|
if (messageUpdate.changes !== 1) {
|
|
throw new Error('远程事件的助理消息写入失败')
|
|
}
|
|
}
|
|
if (conversationState) {
|
|
database
|
|
.prepare(
|
|
`UPDATE conversations
|
|
SET context_state_json = ?, updated_at = ?
|
|
WHERE id = ?`
|
|
)
|
|
.run(
|
|
serializeConversationContextState(
|
|
conversationContextStateSchema.parse(
|
|
conversationState.contextState
|
|
)
|
|
),
|
|
new Date().toISOString(),
|
|
conversationId
|
|
)
|
|
}
|
|
database.exec('COMMIT')
|
|
return inserted
|
|
} catch (error) {
|
|
database.exec('ROLLBACK')
|
|
throw error
|
|
}
|
|
}
|
|
|
|
appendRemoteTaskEventsBatch(
|
|
events: readonly RemoteTaskEventInput[]
|
|
): string {
|
|
if (
|
|
events.length < 1 ||
|
|
events.length > AGENT_PROTOCOL_LIMITS.runPendingEvents
|
|
) {
|
|
throw new RangeError(
|
|
`Remote task event batch must contain between 1 and ${AGENT_PROTOCOL_LIMITS.runPendingEvents} events`
|
|
)
|
|
}
|
|
const validated = events.map(validateRemoteTaskEvent)
|
|
const first = validated[0]!
|
|
let previous = first
|
|
for (let index = 1; index < validated.length; index += 1) {
|
|
const current = validated[index]!
|
|
if (
|
|
current.taskId !== first.taskId ||
|
|
current.bindingId !== first.bindingId ||
|
|
current.operationId !== first.operationId
|
|
) {
|
|
throw new Error(
|
|
'Remote task event batch must belong to one task operation'
|
|
)
|
|
}
|
|
const sequenceOrder =
|
|
BigInt(current.semanticSequence) -
|
|
BigInt(previous.semanticSequence)
|
|
if (
|
|
sequenceOrder < 0n ||
|
|
(sequenceOrder === 0n &&
|
|
current.eventIndex <= previous.eventIndex)
|
|
) {
|
|
throw new Error(
|
|
'Remote task event batch must be in semantic sequence order'
|
|
)
|
|
}
|
|
previous = current
|
|
}
|
|
|
|
const database = this.requireDatabase()
|
|
database.exec('BEGIN IMMEDIATE')
|
|
try {
|
|
for (const event of validated) {
|
|
this.insertRemoteTaskEventOnce(database, event)
|
|
}
|
|
const highestCommittedSequence =
|
|
this.queryHighestCommittedRemoteTaskEventSequence(
|
|
database,
|
|
first.bindingId,
|
|
first.operationId
|
|
)
|
|
database.exec('COMMIT')
|
|
return highestCommittedSequence
|
|
} catch (error) {
|
|
database.exec('ROLLBACK')
|
|
throw error
|
|
}
|
|
}
|
|
|
|
getHighestCommittedRemoteTaskEventSequence(
|
|
bindingId: string,
|
|
operationId: string
|
|
): string {
|
|
const normalizedBindingId = agentIdentifierSchema.parse(bindingId)
|
|
const normalizedOperationId =
|
|
agentIdentifierSchema.parse(operationId)
|
|
return this.queryHighestCommittedRemoteTaskEventSequence(
|
|
this.requireDatabase(),
|
|
normalizedBindingId,
|
|
normalizedOperationId
|
|
)
|
|
}
|
|
|
|
getHighestCommittedRemoteTaskEventSequenceForTask(
|
|
taskIdInput: string
|
|
): string {
|
|
const taskId = assistantIdSchema.parse(taskIdInput)
|
|
const row = this.requireDatabase()
|
|
.prepare(
|
|
`SELECT MAX(CAST(remote_semantic_sequence AS INTEGER)) AS sequence
|
|
FROM task_events
|
|
WHERE task_id = ?
|
|
AND kind = 'remote-semantic-checkpoint'`
|
|
)
|
|
.get(taskId) as { sequence: number | null }
|
|
return row.sequence === null ? '0' : String(row.sequence)
|
|
}
|
|
|
|
hasRemoteResponseTextAfterToolFailure(taskIdInput: string): boolean {
|
|
const taskId = assistantIdSchema.parse(taskIdInput)
|
|
// Use event order, not display blocks: tool blocks are updated in place,
|
|
// and display capture can be truncated before the final response arrives.
|
|
const rows = this.requireDatabase().prepare(
|
|
`SELECT kind, payload_json FROM task_events
|
|
WHERE task_id = ? AND remote_semantic_sequence IS NOT NULL
|
|
AND (kind = 'text' OR
|
|
(kind = 'tool' AND json_extract(payload_json, '$.state') = 'failed'))
|
|
ORDER BY CAST(remote_semantic_sequence AS INTEGER) DESC, remote_event_index DESC`
|
|
).iterate(taskId)
|
|
for (const row of rows) {
|
|
if (row.kind === 'tool') return false
|
|
const event = JSON.parse(row.payload_json as string) as { delta?: string }
|
|
if (event.delta?.trim()) return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
getRemoteTaskActivityStates(taskIdInput: string): {
|
|
tools: Map<string, ConversationToolActivity['state']>
|
|
subagents: Map<string, Pick<ConversationSubagentActivity, 'state' | 'error'>>
|
|
} {
|
|
const taskId = assistantIdSchema.parse(taskIdInput)
|
|
const tools = new Map<string, ConversationToolActivity['state']>()
|
|
const subagents = new Map<string, Pick<ConversationSubagentActivity, 'state' | 'error'>>()
|
|
// Terminal message projection changes unfinished activities. Only their
|
|
// original remote events can supply states for deterministic terminal replay.
|
|
const rows = this.requireDatabase().prepare(
|
|
`SELECT kind, json_extract(payload_json, '$.callId') AS call_id,
|
|
json_extract(payload_json, '$.childTaskId') AS child_task_id,
|
|
json_extract(payload_json, '$.state') AS state,
|
|
json_extract(payload_json, '$.error') AS error
|
|
FROM task_events
|
|
WHERE task_id = ? AND remote_semantic_sequence IS NOT NULL
|
|
AND kind IN ('tool', 'subagent')
|
|
ORDER BY CAST(remote_semantic_sequence AS INTEGER), remote_event_index`
|
|
).iterate(taskId)
|
|
for (const row of rows) {
|
|
if (row.kind === 'tool') {
|
|
tools.set(row.call_id as string, row.state as ConversationToolActivity['state'])
|
|
} else {
|
|
subagents.set(row.child_task_id as string, {
|
|
state: row.state as ConversationSubagentActivity['state'],
|
|
error: row.error === null ? undefined : row.error as string
|
|
})
|
|
}
|
|
}
|
|
return { tools, subagents }
|
|
}
|
|
|
|
private queryHighestCommittedRemoteTaskEventSequence(
|
|
database: DatabaseSync,
|
|
bindingId: string,
|
|
operationId: string
|
|
): string {
|
|
const row = database
|
|
.prepare(
|
|
`SELECT MAX(CAST(remote_semantic_sequence AS INTEGER)) AS sequence
|
|
FROM task_events
|
|
WHERE remote_binding_id = ?
|
|
AND remote_operation_id = ?`
|
|
)
|
|
.get(bindingId, operationId) as {
|
|
sequence: number | null
|
|
}
|
|
return row.sequence === null ? '0' : String(row.sequence)
|
|
}
|
|
|
|
private terminalizeRemoteTaskAtCheckpoint(
|
|
database: DatabaseSync,
|
|
event: ValidatedRemoteTaskEvent
|
|
): void {
|
|
const terminal = database
|
|
.prepare(
|
|
`SELECT kind, payload_json
|
|
FROM task_events
|
|
WHERE task_id = ?
|
|
AND remote_binding_id = ?
|
|
AND remote_operation_id = ?
|
|
AND remote_semantic_sequence = ?
|
|
AND remote_event_index < ?
|
|
AND kind IN ('done', 'error')
|
|
ORDER BY remote_event_index DESC
|
|
LIMIT 1`
|
|
)
|
|
.get(
|
|
event.taskId,
|
|
event.bindingId,
|
|
event.operationId,
|
|
event.semanticSequence,
|
|
event.eventIndex
|
|
) as
|
|
| { kind: 'done' | 'error'; payload_json: string }
|
|
| undefined
|
|
const terminalPayload = terminal
|
|
? (JSON.parse(terminal.payload_json) as AgentEvent)
|
|
: undefined
|
|
const taskStatus =
|
|
terminal?.kind === 'done'
|
|
? 'completed'
|
|
: terminalPayload?.type === 'error'
|
|
? terminalPayload.status === 'cancelled'
|
|
? 'cancelled'
|
|
: 'failed'
|
|
: undefined
|
|
if (taskStatus === undefined) {
|
|
return
|
|
}
|
|
const taskError =
|
|
terminalPayload?.type === 'error'
|
|
? terminalPayload.message.slice(0, 4_000)
|
|
: null
|
|
const taskUpdate = database
|
|
.prepare(
|
|
`UPDATE tasks
|
|
SET status = ?, error = ?, completed_at = ?
|
|
WHERE id = ? AND remote_recoverable = 1`
|
|
)
|
|
.run(
|
|
taskStatus,
|
|
taskError,
|
|
new Date().toISOString(),
|
|
event.taskId
|
|
)
|
|
if (taskUpdate.changes !== 1) {
|
|
throw new Error('远程终态无法更新所属任务')
|
|
}
|
|
this.executionTiming?.state(event.taskId, taskStatus)
|
|
}
|
|
|
|
private insertRemoteTaskEventOnce(
|
|
database: DatabaseSync,
|
|
event: ValidatedRemoteTaskEvent
|
|
): boolean {
|
|
const findExisting = (): {
|
|
id: number; task_id: string; kind: string; payload_json: string
|
|
} | undefined => database
|
|
.prepare(
|
|
`SELECT id, task_id, kind, payload_json
|
|
FROM task_events
|
|
WHERE remote_binding_id = ?
|
|
AND remote_operation_id = ?
|
|
AND remote_semantic_sequence = ?
|
|
AND remote_event_index = ?`
|
|
)
|
|
.get(
|
|
event.bindingId,
|
|
event.operationId,
|
|
event.semanticSequence,
|
|
event.eventIndex
|
|
) as
|
|
| {
|
|
id: number
|
|
task_id: string
|
|
kind: string
|
|
payload_json: string
|
|
}
|
|
| undefined
|
|
// Duplicate replay must not recreate a completed task's released write cache.
|
|
let existing = findExisting()
|
|
if (!existing) {
|
|
const payloadJson = this.subagentProgress!.serialize(
|
|
event.taskId, event.kind, JSON.parse(event.payloadJson)
|
|
)
|
|
const result = database
|
|
.prepare(
|
|
`INSERT OR IGNORE INTO task_events
|
|
(task_id, run_id, kind, payload_json, created_at,
|
|
remote_binding_id, remote_operation_id,
|
|
remote_semantic_sequence, remote_event_index)
|
|
VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?)`
|
|
)
|
|
.run(
|
|
event.taskId,
|
|
event.kind,
|
|
payloadJson,
|
|
new Date().toISOString(),
|
|
event.bindingId,
|
|
event.operationId,
|
|
event.semanticSequence,
|
|
event.eventIndex
|
|
)
|
|
if (result.changes === 1) {
|
|
this.subagentProgress!.inserted(
|
|
event.taskId, event.kind, Number(result.lastInsertRowid), payloadJson
|
|
)
|
|
return true
|
|
}
|
|
existing = findExisting()
|
|
}
|
|
if (
|
|
existing?.task_id === event.taskId &&
|
|
existing.kind === event.kind &&
|
|
(existing.payload_json === event.payloadJson ||
|
|
(event.kind === 'subagent' && this.subagentProgress!.matches(
|
|
event.taskId, existing.id, JSON.parse(event.payloadJson)
|
|
)))
|
|
) {
|
|
return false
|
|
}
|
|
throw new Error(
|
|
'Remote task event provenance conflicts with persisted event'
|
|
)
|
|
}
|
|
|
|
persistComputerControlAudit(
|
|
event: ComputerControlAuditEvent
|
|
): void {
|
|
if (!event || typeof event !== 'object') {
|
|
throw new TypeError('Computer control audit event is required')
|
|
}
|
|
const taskId = validateComputerControlId(
|
|
event.taskId,
|
|
'taskId',
|
|
128
|
|
)
|
|
const conversationId = validateComputerControlId(
|
|
event.conversationId,
|
|
'conversationId',
|
|
128
|
|
)
|
|
const leaseId = validateComputerControlId(
|
|
event.leaseId,
|
|
'leaseId',
|
|
160,
|
|
true
|
|
)
|
|
const commandId = validateComputerControlId(
|
|
event.commandId,
|
|
'commandId',
|
|
160,
|
|
true
|
|
)
|
|
const action = validateComputerControlEnum(
|
|
event.action,
|
|
'action',
|
|
computerControlActions
|
|
)
|
|
const risk = computerControlRiskSchema.parse(event.risk)
|
|
const outcome = validateComputerControlEnum(
|
|
event.outcome,
|
|
'outcome',
|
|
computerControlOutcomes
|
|
)
|
|
if (
|
|
!Number.isSafeInteger(event.timestamp) ||
|
|
event.timestamp < 0
|
|
) {
|
|
throw new RangeError('Invalid timestamp')
|
|
}
|
|
const errorCode =
|
|
event.errorCode === undefined
|
|
? undefined
|
|
: computerControlErrorCodeSchema.parse(event.errorCode)
|
|
if (
|
|
(outcome === 'completed' && errorCode !== undefined) ||
|
|
(outcome !== 'completed' && errorCode === undefined)
|
|
) {
|
|
throw new RangeError('Invalid outcome and errorCode combination')
|
|
}
|
|
const hasTextMetadata =
|
|
event.textLength !== undefined ||
|
|
event.textDigest !== undefined
|
|
if (
|
|
(action === 'replace_text') !== hasTextMetadata ||
|
|
(hasTextMetadata &&
|
|
(!Number.isInteger(event.textLength) ||
|
|
event.textLength! < 0 ||
|
|
event.textLength! > 4_096 ||
|
|
typeof event.textDigest !== 'string' ||
|
|
!/^[0-9a-f]{64}$/.test(event.textDigest)))
|
|
) {
|
|
throw new RangeError('Invalid redacted text metadata')
|
|
}
|
|
|
|
const database = this.requireDatabase()
|
|
const createdAt = new Date().toISOString()
|
|
database.exec('BEGIN IMMEDIATE')
|
|
try {
|
|
const inserted = database
|
|
.prepare(
|
|
`INSERT OR IGNORE INTO computer_control_actions
|
|
(command_id, task_id, conversation_id, lease_id, action, risk,
|
|
outcome, error_code, occurred_at, text_length, text_digest,
|
|
created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
)
|
|
.run(
|
|
commandId,
|
|
taskId,
|
|
conversationId,
|
|
leaseId,
|
|
action,
|
|
risk,
|
|
outcome,
|
|
errorCode ?? null,
|
|
event.timestamp,
|
|
event.textLength ?? null,
|
|
event.textDigest ?? null,
|
|
createdAt
|
|
)
|
|
if (inserted.changes === 1) {
|
|
database
|
|
.prepare(
|
|
`INSERT INTO task_events
|
|
(task_id, run_id, kind, payload_json, created_at)
|
|
VALUES (?, NULL, 'computer_control', ?, ?)`
|
|
)
|
|
.run(
|
|
taskId,
|
|
JSON.stringify({
|
|
commandId,
|
|
action,
|
|
risk,
|
|
outcome,
|
|
...(errorCode ? { errorCode } : {}),
|
|
...(event.textLength === undefined
|
|
? {}
|
|
: { textLength: event.textLength }),
|
|
...(event.textDigest === undefined
|
|
? {}
|
|
: { textDigest: event.textDigest })
|
|
}),
|
|
createdAt
|
|
)
|
|
database
|
|
.prepare(
|
|
`DELETE FROM computer_control_actions
|
|
WHERE command_id IN (
|
|
SELECT command_id FROM computer_control_actions
|
|
ORDER BY occurred_at DESC, created_at DESC
|
|
LIMIT -1 OFFSET 10000
|
|
)`
|
|
)
|
|
.run()
|
|
}
|
|
database.exec('COMMIT')
|
|
} catch (error) {
|
|
database.exec('ROLLBACK')
|
|
throw error
|
|
}
|
|
}
|
|
|
|
listRecentComputerControlAudit(
|
|
limit = 100
|
|
): PersistedComputerControlAuditEvent[] {
|
|
if (!Number.isInteger(limit) || limit < 1 || limit > 500) {
|
|
throw new RangeError(
|
|
'Computer control audit limit must be between 1 and 500'
|
|
)
|
|
}
|
|
const rows = this.requireDatabase()
|
|
.prepare(
|
|
`SELECT command_id, task_id, conversation_id, lease_id, action,
|
|
risk, outcome, error_code, occurred_at, text_length,
|
|
text_digest
|
|
FROM computer_control_actions
|
|
ORDER BY occurred_at DESC, created_at DESC
|
|
LIMIT ?`
|
|
)
|
|
.all(limit) as ComputerControlActionRow[]
|
|
return rows.map(toComputerControlAuditEvent)
|
|
}
|
|
|
|
listArtifacts(projectId?: string, limit = 100): AssistantArtifact[] {
|
|
const safeLimit = Math.max(1, Math.min(500, Math.trunc(limit)))
|
|
const columns = `id, project_id, task_id, kind, title, mime_type,
|
|
CASE WHEN kind = 'image' THEN NULL
|
|
ELSE inline_content END AS inline_content,
|
|
byte_size, created_at, updated_at`
|
|
const visibleArtifact = `NOT (
|
|
kind = 'markdown' AND EXISTS (
|
|
SELECT 1
|
|
FROM tasks
|
|
WHERE tasks.id = artifacts.task_id
|
|
AND tasks.conversation_id IS NOT NULL
|
|
AND (
|
|
tasks.origin = 'user' OR (
|
|
tasks.origin = 'delegation'
|
|
AND tasks.conversation_id NOT LIKE 'delegation:%'
|
|
)
|
|
)
|
|
)
|
|
)`
|
|
const rows = projectId
|
|
? this.requireDatabase()
|
|
.prepare(
|
|
`SELECT ${columns} FROM artifacts
|
|
WHERE project_id = ? AND ${visibleArtifact}
|
|
ORDER BY created_at DESC
|
|
LIMIT ?`
|
|
)
|
|
.all(projectId, safeLimit)
|
|
: this.requireDatabase()
|
|
.prepare(
|
|
`SELECT ${columns} FROM artifacts
|
|
WHERE ${visibleArtifact}
|
|
ORDER BY created_at DESC
|
|
LIMIT ?`
|
|
)
|
|
.all(safeLimit)
|
|
return (rows as ArtifactRow[]).map(toArtifact)
|
|
}
|
|
|
|
getArtifact(artifactId: string): AssistantArtifact {
|
|
const row = this.requireDatabase()
|
|
.prepare(
|
|
`SELECT id, project_id, task_id, kind, title, mime_type,
|
|
inline_content, byte_size, created_at, updated_at
|
|
FROM artifacts
|
|
WHERE id = ?`
|
|
)
|
|
.get(artifactId) as ArtifactRow | undefined
|
|
if (!row) {
|
|
throw new Error('成果不存在')
|
|
}
|
|
return toArtifact(row)
|
|
}
|
|
|
|
createTextArtifact(input: {
|
|
projectId?: string
|
|
taskId?: string
|
|
title: string
|
|
content: string
|
|
}): AssistantArtifact {
|
|
return this.createInlineArtifact({
|
|
...input,
|
|
kind: 'markdown',
|
|
mimeType: 'text/markdown'
|
|
})
|
|
}
|
|
|
|
saveConversationImageOperation(
|
|
operation: ImageOperation,
|
|
image?: { mimeType: 'image/png' | 'image/jpeg' | 'image/webp'; data: string }
|
|
): ImageOperation {
|
|
const database = this.requireDatabase()
|
|
database.exec('BEGIN IMMEDIATE')
|
|
try {
|
|
const row = database.prepare(`SELECT m.metadata_json, c.project_id FROM messages m
|
|
JOIN conversations c ON c.id = m.conversation_id
|
|
WHERE m.id = ? AND m.conversation_id = ?`).get(operation.messageId, operation.conversationId) as
|
|
{ metadata_json: string; project_id: string | null } | undefined
|
|
if (!row) throw new Error('Image operation conversation or message no longer exists')
|
|
const metadata = JSON.parse(row.metadata_json) as MessageMetadata
|
|
const previous = metadata.imageOperations?.find(item => item.id === operation.id)
|
|
if (previous?.state === 'completed') {
|
|
database.exec('COMMIT')
|
|
return previous
|
|
}
|
|
let saved = operation
|
|
if (image) {
|
|
const artifact = this.createImageArtifact({
|
|
projectId: row.project_id ?? undefined,
|
|
title: operation.input.prompt.slice(0, 120),
|
|
mimeType: image.mimeType,
|
|
base64: image.data
|
|
})
|
|
saved = { ...operation, state: 'completed', artifactIds: [artifact.id] }
|
|
metadata.artifactIds = [...new Set([...(metadata.artifactIds ?? []), artifact.id])].slice(-8)
|
|
}
|
|
metadata.imageOperations = [...(metadata.imageOperations ?? []).filter(item => item.id !== saved.id), saved]
|
|
database.prepare('UPDATE messages SET metadata_json = ? WHERE id = ? AND conversation_id = ?')
|
|
.run(JSON.stringify(metadata), saved.messageId, saved.conversationId)
|
|
database.prepare('UPDATE conversations SET updated_at = MAX(updated_at, ?) WHERE id = ?')
|
|
.run(new Date(saved.updatedAt).toISOString(), saved.conversationId)
|
|
database.exec('COMMIT')
|
|
return saved
|
|
} catch (error) {
|
|
database.exec('ROLLBACK')
|
|
throw error
|
|
}
|
|
}
|
|
|
|
saveConversationImageSources(input: {
|
|
conversationId: string
|
|
messageId: string
|
|
images: readonly { name: string; mediaType: 'image/png' | 'image/jpeg'; data: string }[]
|
|
}): string[] {
|
|
if (input.images.length > 8) throw new Error('At most eight source images are supported')
|
|
const database = this.requireDatabase()
|
|
database.exec('BEGIN IMMEDIATE')
|
|
try {
|
|
const row = database.prepare(`SELECT m.metadata_json, c.project_id FROM messages m
|
|
JOIN conversations c ON c.id = m.conversation_id
|
|
WHERE m.id = ? AND m.conversation_id = ?`).get(input.messageId, input.conversationId) as
|
|
{ metadata_json: string; project_id: string | null } | undefined
|
|
if (!row) throw new Error('Image source conversation or message no longer exists')
|
|
const metadata = JSON.parse(row.metadata_json) as MessageMetadata
|
|
if (!metadata.imageSourceArtifactIds?.length) {
|
|
metadata.imageSourceArtifactIds = input.images.map(image => this.createImageArtifact({
|
|
projectId: row.project_id ?? undefined, title: image.name,
|
|
mimeType: image.mediaType, base64: image.data
|
|
}).id)
|
|
database.prepare('UPDATE messages SET metadata_json = ? WHERE id = ?').run(JSON.stringify(metadata), input.messageId)
|
|
}
|
|
database.exec('COMMIT')
|
|
return metadata.imageSourceArtifactIds
|
|
} catch (error) {
|
|
database.exec('ROLLBACK')
|
|
throw error
|
|
}
|
|
}
|
|
|
|
markUnfinishedImageOperationsUnconfirmed(): void {
|
|
const database = this.requireDatabase()
|
|
database.exec('BEGIN IMMEDIATE')
|
|
try {
|
|
// Candidates come from messages_recovery_idx instead of a scan of every message.
|
|
const rows = database.prepare(`SELECT id, metadata_json FROM messages
|
|
WHERE ${MESSAGE_RECOVERY_CANDIDATE_SQL} AND json_type(metadata_json, '$.imageOperations') = 'array'`)
|
|
.all() as { id: string; metadata_json: string }[]
|
|
for (const row of rows) {
|
|
const metadata = JSON.parse(row.metadata_json) as MessageMetadata
|
|
let changed = false
|
|
metadata.imageOperations = metadata.imageOperations?.map(operation => {
|
|
if (!['running', 'saving', 'cancelling'].includes(operation.state)) return operation
|
|
changed = true
|
|
return { ...operation, state: 'unconfirmed', updatedAt: Date.now() }
|
|
})
|
|
if (changed) database.prepare('UPDATE messages SET metadata_json = ? WHERE id = ?').run(JSON.stringify(metadata), row.id)
|
|
}
|
|
database.exec('COMMIT')
|
|
} catch (error) {
|
|
database.exec('ROLLBACK')
|
|
throw error
|
|
}
|
|
}
|
|
|
|
createImageArtifact(input: {
|
|
projectId?: string
|
|
taskId?: string
|
|
title: string
|
|
mimeType: 'image/png' | 'image/jpeg' | 'image/gif' | 'image/webp'
|
|
base64: string
|
|
}): AssistantArtifact {
|
|
return this.createInlineArtifact({
|
|
projectId: input.projectId,
|
|
taskId: input.taskId,
|
|
kind: 'image',
|
|
title: input.title,
|
|
mimeType: input.mimeType,
|
|
content: `data:${input.mimeType};base64,${input.base64}`
|
|
})
|
|
}
|
|
|
|
createInlineArtifact(input: {
|
|
projectId?: string
|
|
taskId?: string
|
|
kind: AssistantArtifact['kind']
|
|
title: string
|
|
mimeType: string
|
|
content: string
|
|
}): AssistantArtifact {
|
|
const id = randomUUID()
|
|
const now = new Date().toISOString()
|
|
const byteSize = Buffer.byteLength(input.content)
|
|
if (byteSize > 5 * 1024 * 1024) {
|
|
throw new Error('成果内容超过 5MB 限制')
|
|
}
|
|
this.requireDatabase()
|
|
.prepare(
|
|
`INSERT INTO artifacts
|
|
(id, project_id, task_id, run_id, kind, title, mime_type,
|
|
storage_kind, storage_path, inline_content, checksum, byte_size,
|
|
preview_json, created_at, updated_at)
|
|
VALUES (?, ?, ?, NULL, ?, ?, ?,
|
|
'inline', NULL, ?, NULL, ?, '{}', ?, ?)`
|
|
)
|
|
.run(
|
|
id,
|
|
input.projectId ?? null,
|
|
input.taskId ?? null,
|
|
input.kind,
|
|
input.title.slice(0, 240),
|
|
input.mimeType.slice(0, 128),
|
|
input.content,
|
|
byteSize,
|
|
now,
|
|
now
|
|
)
|
|
const row = this.requireDatabase()
|
|
.prepare('SELECT * FROM artifacts WHERE id = ?')
|
|
.get(id) as ArtifactRow
|
|
return toArtifact(row)
|
|
}
|
|
|
|
listMemories(scopeId?: string): AssistantMemory[] {
|
|
const rows = scopeId
|
|
? this.requireDatabase()
|
|
.prepare(
|
|
`SELECT * FROM memory_items
|
|
WHERE scope = 'global' OR scope_id = ?
|
|
ORDER BY status = 'confirmed' DESC, updated_at DESC
|
|
LIMIT 500`
|
|
)
|
|
.all(scopeId)
|
|
: this.requireDatabase()
|
|
.prepare(
|
|
`SELECT * FROM memory_items
|
|
ORDER BY status = 'confirmed' DESC, updated_at DESC
|
|
LIMIT 500`
|
|
)
|
|
.all()
|
|
return (rows as MemoryRow[]).map(toMemory)
|
|
}
|
|
|
|
createMemory(input: MemoryCreateInput): AssistantMemory {
|
|
if (input.scope !== 'global' && !input.scopeId) {
|
|
throw new Error('项目或会话记忆必须指定作用域')
|
|
}
|
|
const id = randomUUID()
|
|
const now = new Date().toISOString()
|
|
this.requireDatabase()
|
|
.prepare(
|
|
`INSERT INTO memory_items
|
|
(id, scope, scope_id, type, content, source_conversation_id,
|
|
source_message_id, confidence, salience, status, expires_at,
|
|
created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, NULL, NULL, 1, 1, 'confirmed', NULL, ?, ?)`
|
|
)
|
|
.run(
|
|
id,
|
|
input.scope,
|
|
input.scopeId ?? null,
|
|
input.type,
|
|
input.content,
|
|
now,
|
|
now
|
|
)
|
|
return this.getMemory(id)
|
|
}
|
|
|
|
setMemoryStatus(
|
|
memoryId: string,
|
|
status: AssistantMemory['status']
|
|
): void {
|
|
const result = this.requireDatabase()
|
|
.prepare(
|
|
`UPDATE memory_items
|
|
SET status = ?, updated_at = ?
|
|
WHERE id = ?`
|
|
)
|
|
.run(status, new Date().toISOString(), memoryId)
|
|
if (result.changes !== 1) {
|
|
throw new Error('记忆不存在')
|
|
}
|
|
}
|
|
|
|
removeMemory(memoryId: string): void {
|
|
const result = this.requireDatabase()
|
|
.prepare('DELETE FROM memory_items WHERE id = ?')
|
|
.run(memoryId)
|
|
if (result.changes !== 1) {
|
|
throw new Error('记忆不存在')
|
|
}
|
|
}
|
|
|
|
listConversationQueueItems(
|
|
conversationId?: string
|
|
): ConversationQueueItem[] {
|
|
const database = this.requireDatabase()
|
|
const rows = conversationId
|
|
? database
|
|
.prepare(
|
|
`SELECT *
|
|
FROM conversation_queue_items
|
|
WHERE conversation_id = ? AND status = 'pending'
|
|
ORDER BY created_at ASC, rowid ASC
|
|
LIMIT 100`
|
|
)
|
|
.all(conversationId)
|
|
: database
|
|
.prepare(
|
|
`SELECT *
|
|
FROM conversation_queue_items
|
|
WHERE status = 'pending'
|
|
ORDER BY created_at ASC, rowid ASC
|
|
LIMIT 500`
|
|
)
|
|
.all()
|
|
return (rows as ConversationQueueRow[]).map(
|
|
toConversationQueueItem
|
|
)
|
|
}
|
|
|
|
listPendingScheduleQueueConversationIds(limit = 4): string[] {
|
|
const safeLimit = Math.max(1, Math.min(20, Math.trunc(limit)))
|
|
const rows = this.requireDatabase()
|
|
.prepare(
|
|
`SELECT conversation_id
|
|
FROM conversation_queue_items
|
|
WHERE source = 'schedule' AND status = 'pending'
|
|
GROUP BY conversation_id
|
|
ORDER BY MIN(created_at) ASC, MIN(rowid) ASC
|
|
LIMIT ?`
|
|
)
|
|
.all(safeLimit) as Array<{ conversation_id: string }>
|
|
return rows.map((row) => row.conversation_id)
|
|
}
|
|
|
|
listPendingConversationQueueIds(): string[] {
|
|
const rows = this.requireDatabase()
|
|
.prepare(
|
|
`SELECT conversation_id
|
|
FROM conversation_queue_items
|
|
WHERE status = 'pending'
|
|
GROUP BY conversation_id
|
|
ORDER BY MIN(created_at) ASC, MIN(rowid) ASC`
|
|
)
|
|
.all() as Array<{ conversation_id: string }>
|
|
return rows.map((row) => row.conversation_id)
|
|
}
|
|
|
|
getConversationQueueItem(
|
|
itemId: string
|
|
): ConversationQueueItem | undefined {
|
|
const row = this.requireDatabase()
|
|
.prepare(
|
|
`SELECT *
|
|
FROM conversation_queue_items
|
|
WHERE id = ?`
|
|
)
|
|
.get(itemId) as ConversationQueueRow | undefined
|
|
return row ? toConversationQueueItem(row) : undefined
|
|
}
|
|
|
|
getConversationUserQueuePayloadJson(itemId: string): string | undefined {
|
|
const row = this.requireDatabase()
|
|
.prepare(
|
|
`SELECT payload_json
|
|
FROM conversation_queue_items
|
|
WHERE id = ? AND source = 'user'`
|
|
)
|
|
.get(itemId) as { payload_json: string } | undefined
|
|
return row?.payload_json
|
|
}
|
|
|
|
isConversationUserQueueItemDispatching(itemId: string): boolean {
|
|
return Boolean(
|
|
this.requireDatabase()
|
|
.prepare(
|
|
`SELECT 1
|
|
FROM conversation_queue_items
|
|
WHERE id = ? AND status = 'dispatching'`
|
|
)
|
|
.get(itemId)
|
|
)
|
|
}
|
|
|
|
enqueueConversationUserInput(input: {
|
|
conversationId: string
|
|
label: string
|
|
payloadJson: string
|
|
}): ConversationQueueItem {
|
|
const database = this.requireDatabase()
|
|
const id = randomUUID()
|
|
const now = new Date().toISOString()
|
|
const label = input.label.trim().slice(0, 200)
|
|
if (!label) {
|
|
throw new Error('待发送消息标题不能为空')
|
|
}
|
|
database.exec('BEGIN IMMEDIATE')
|
|
try {
|
|
const conversation = database
|
|
.prepare(
|
|
`SELECT 1
|
|
FROM conversations
|
|
WHERE id = ? AND status = 'active' AND channel IS NULL`
|
|
)
|
|
.get(input.conversationId)
|
|
if (!conversation) {
|
|
throw new Error('对话不存在或不能加入发送队列')
|
|
}
|
|
const count = database
|
|
.prepare(
|
|
`SELECT COUNT(*) AS count
|
|
FROM conversation_queue_items
|
|
WHERE conversation_id = ?
|
|
AND status IN ('pending', 'dispatching')`
|
|
)
|
|
.get(input.conversationId) as { count: number }
|
|
if (count.count >= 20) {
|
|
throw new Error('当前对话最多保留 20 条待执行项')
|
|
}
|
|
database
|
|
.prepare(
|
|
`INSERT INTO conversation_queue_items
|
|
(id, conversation_id, source, label, payload_json,
|
|
schedule_run_id, schedule_id, task_id, status, created_at)
|
|
VALUES (?, ?, 'user', ?, ?, NULL, NULL, NULL, 'pending', ?)`
|
|
)
|
|
.run(
|
|
id,
|
|
input.conversationId,
|
|
label,
|
|
input.payloadJson,
|
|
now
|
|
)
|
|
database.exec('COMMIT')
|
|
} catch (error) {
|
|
database.exec('ROLLBACK')
|
|
throw error
|
|
}
|
|
return this.getConversationQueueItem(id)!
|
|
}
|
|
|
|
claimConversationQueueItem(
|
|
conversationId: string,
|
|
preferredItemId?: string
|
|
): ClaimedConversationQueueItem | undefined {
|
|
const database = this.requireDatabase()
|
|
database.exec('BEGIN IMMEDIATE')
|
|
try {
|
|
const row = database
|
|
.prepare(
|
|
preferredItemId
|
|
? `SELECT *
|
|
FROM conversation_queue_items
|
|
WHERE id = ? AND conversation_id = ?
|
|
AND status = 'pending'`
|
|
: `SELECT *
|
|
FROM conversation_queue_items
|
|
WHERE conversation_id = ? AND status = 'pending'
|
|
ORDER BY created_at ASC, rowid ASC
|
|
LIMIT 1`
|
|
)
|
|
.get(
|
|
...(preferredItemId
|
|
? [preferredItemId, conversationId]
|
|
: [conversationId])
|
|
) as ConversationQueueRow | undefined
|
|
if (!row) {
|
|
database.exec('COMMIT')
|
|
return undefined
|
|
}
|
|
const claimed = database
|
|
.prepare(
|
|
`UPDATE conversation_queue_items
|
|
SET status = 'dispatching'
|
|
WHERE id = ? AND status = 'pending'`
|
|
)
|
|
.run(row.id)
|
|
if (claimed.changes !== 1) {
|
|
database.exec('COMMIT')
|
|
return undefined
|
|
}
|
|
const item = toConversationQueueItem({
|
|
...row,
|
|
status: 'dispatching'
|
|
})
|
|
if (row.source === 'user') {
|
|
database.exec('COMMIT')
|
|
return {
|
|
source: 'user',
|
|
item: { ...item, source: 'user' },
|
|
payloadJson: row.payload_json
|
|
}
|
|
}
|
|
if (!row.schedule_run_id) {
|
|
throw new Error('定时任务队列项缺少运行记录')
|
|
}
|
|
const scheduleRow = database
|
|
.prepare(
|
|
`SELECT s.*, t.id AS task_id,
|
|
t.conversation_id AS conversation_id
|
|
FROM schedule_runs sr
|
|
INNER JOIN schedules s ON s.id = sr.schedule_id
|
|
INNER JOIN tasks t ON t.schedule_id = s.id
|
|
WHERE sr.id = ? AND sr.status = 'pending'`
|
|
)
|
|
.get(row.schedule_run_id) as ScheduleWithTaskRow | undefined
|
|
if (!scheduleRow) {
|
|
throw new Error('定时任务队列项已失效')
|
|
}
|
|
const scheduleClaim = database
|
|
.prepare(
|
|
`UPDATE schedule_runs
|
|
SET status = 'running'
|
|
WHERE id = ? AND status = 'pending'`
|
|
)
|
|
.run(row.schedule_run_id)
|
|
if (scheduleClaim.changes !== 1) {
|
|
throw new Error('定时任务运行记录无法认领')
|
|
}
|
|
database.exec('COMMIT')
|
|
return {
|
|
source: 'schedule',
|
|
item: { ...item, source: 'schedule' },
|
|
schedule: toSchedule(scheduleRow),
|
|
runId: row.schedule_run_id
|
|
}
|
|
} catch (error) {
|
|
database.exec('ROLLBACK')
|
|
throw error
|
|
}
|
|
}
|
|
|
|
completeConversationUserQueueItem(itemId: string): void {
|
|
const result = this.requireDatabase()
|
|
.prepare(
|
|
`DELETE FROM conversation_queue_items
|
|
WHERE id = ? AND status = 'dispatching'`
|
|
)
|
|
.run(itemId)
|
|
if (result.changes !== 1) {
|
|
throw new Error('待发送消息不存在或状态已变化')
|
|
}
|
|
}
|
|
|
|
releaseConversationUserQueueItem(itemId: string): void {
|
|
const database = this.requireDatabase()
|
|
database.exec('BEGIN IMMEDIATE')
|
|
try {
|
|
const result = database
|
|
.prepare(
|
|
`UPDATE conversation_queue_items
|
|
SET status = 'pending'
|
|
WHERE id = ? AND status = 'dispatching'`
|
|
)
|
|
.run(itemId)
|
|
if (
|
|
result.changes !== 1 &&
|
|
!database
|
|
.prepare(
|
|
`SELECT 1
|
|
FROM conversation_queue_items
|
|
WHERE id = ? AND status = 'pending'`
|
|
)
|
|
.get(itemId)
|
|
) {
|
|
throw new Error('待发送消息不存在或状态已变化')
|
|
}
|
|
database.prepare(
|
|
`UPDATE schedule_runs SET status = 'pending'
|
|
WHERE id = (SELECT schedule_run_id FROM conversation_queue_items WHERE id = ?)
|
|
AND status = 'running'`
|
|
).run(itemId)
|
|
database.exec('COMMIT')
|
|
} catch (error) {
|
|
database.exec('ROLLBACK')
|
|
throw error
|
|
}
|
|
}
|
|
|
|
removeConversationUserQueueItem(itemId: string): void {
|
|
const result = this.requireDatabase()
|
|
.prepare(
|
|
`DELETE FROM conversation_queue_items
|
|
WHERE id = ? AND source = 'user' AND status = 'pending'`
|
|
)
|
|
.run(itemId)
|
|
if (result.changes !== 1) {
|
|
throw new Error('待发送消息不存在或状态已变化')
|
|
}
|
|
}
|
|
|
|
cancelConversationQueueItem(
|
|
itemId: string
|
|
): ConversationQueueItem {
|
|
const row = this.requireDatabase()
|
|
.prepare(
|
|
`SELECT *
|
|
FROM conversation_queue_items
|
|
WHERE id = ? AND status = 'pending'`
|
|
)
|
|
.get(itemId) as ConversationQueueRow | undefined
|
|
if (!row) {
|
|
throw new Error('待执行项不存在或状态已变化')
|
|
}
|
|
const item = toConversationQueueItem(row)
|
|
if (row.source === 'user') {
|
|
this.removeConversationUserQueueItem(itemId)
|
|
return item
|
|
}
|
|
if (!row.schedule_run_id) {
|
|
throw new Error('定时任务队列项缺少运行记录')
|
|
}
|
|
this.completeScheduleRun(row.schedule_run_id, 'cancelled')
|
|
return item
|
|
}
|
|
|
|
listSchedules(projectId?: string): AssistantSchedule[] {
|
|
const rows = projectId
|
|
? this.requireDatabase()
|
|
.prepare(
|
|
`SELECT s.*, t.id AS task_id,
|
|
t.conversation_id AS conversation_id
|
|
FROM schedules s
|
|
INNER JOIN tasks t ON t.schedule_id = s.id
|
|
WHERE s.project_id = ?
|
|
ORDER BY s.next_run_at`
|
|
)
|
|
.all(projectId)
|
|
: this.requireDatabase()
|
|
.prepare(
|
|
`SELECT s.*, t.id AS task_id,
|
|
t.conversation_id AS conversation_id
|
|
FROM schedules s
|
|
INNER JOIN tasks t ON t.schedule_id = s.id
|
|
ORDER BY s.next_run_at`
|
|
)
|
|
.all()
|
|
return (rows as ScheduleWithTaskRow[]).map(toSchedule)
|
|
}
|
|
|
|
createSchedule(input: ScheduleCreateInput): AssistantSchedule {
|
|
const parsed = scheduleCreateSchema.parse(input)
|
|
const database = this.requireDatabase()
|
|
const scheduleId = randomUUID()
|
|
const taskId = randomUUID()
|
|
const now = new Date().toISOString()
|
|
database.exec('BEGIN IMMEDIATE')
|
|
try {
|
|
let projectId = parsed.projectId ?? null
|
|
let conversationId = parsed.conversationId
|
|
if (conversationId) {
|
|
const conversation = database
|
|
.prepare(
|
|
`SELECT c.id, c.project_id, c.runtime_selection_json
|
|
FROM conversations c
|
|
LEFT JOIN projects p ON p.id = c.project_id
|
|
WHERE c.id = ?
|
|
AND c.status = 'active'
|
|
AND c.channel IS NULL
|
|
AND (p.id IS NULL OR p.status = 'active')`
|
|
)
|
|
.get(conversationId) as
|
|
| {
|
|
id: string
|
|
project_id: string | null
|
|
runtime_selection_json: string | null
|
|
}
|
|
| undefined
|
|
if (!conversation) {
|
|
throw new Error('所选对话不存在或不可用于任务')
|
|
}
|
|
if (
|
|
projectId &&
|
|
projectId !== conversation.project_id
|
|
) {
|
|
throw new Error('任务项目与所选对话不一致')
|
|
}
|
|
projectId = conversation.project_id
|
|
} else {
|
|
if (projectId) {
|
|
const project = database
|
|
.prepare(
|
|
`SELECT runtime_selection_json
|
|
FROM projects
|
|
WHERE id = ? AND status = 'active' AND kind = 'user'`
|
|
)
|
|
.get(projectId) as
|
|
| { runtime_selection_json: string | null }
|
|
| undefined
|
|
if (!project) {
|
|
throw new Error('任务项目不存在或不可用')
|
|
}
|
|
}
|
|
conversationId = randomUUID()
|
|
database
|
|
.prepare(
|
|
`INSERT INTO conversations
|
|
(id, project_id, runtime_selection_json, title,
|
|
status, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, 'active', ?, ?)`
|
|
)
|
|
.run(
|
|
conversationId,
|
|
projectId,
|
|
null,
|
|
parsed.title,
|
|
now,
|
|
now
|
|
)
|
|
}
|
|
database
|
|
.prepare(
|
|
`INSERT INTO schedules
|
|
(id, project_id, task_template_json, timezone, recurrence_json,
|
|
next_run_at, missed_run_policy, enabled, last_run_at,
|
|
created_at, updated_at)
|
|
VALUES (?, ?, ?, 'UTC', ?, ?, 'run_once', ?, ?, ?, ?)`
|
|
)
|
|
.run(
|
|
scheduleId,
|
|
projectId,
|
|
JSON.stringify({
|
|
title: parsed.title,
|
|
prompt: parsed.prompt
|
|
}),
|
|
JSON.stringify({ type: parsed.recurrence }),
|
|
parsed.runImmediately ? now : parsed.nextRunAt,
|
|
parsed.runImmediately ? 0 : 1,
|
|
// Immediate creation consumes the once trigger even if its queue item
|
|
// is cancelled. Further execution must use an explicit manual run.
|
|
parsed.runImmediately ? now : null,
|
|
now,
|
|
now
|
|
)
|
|
database
|
|
.prepare(
|
|
`INSERT INTO tasks
|
|
(id, project_id, conversation_id, schedule_id,
|
|
parent_task_id, expert_id, routing_mode, title,
|
|
instructions, origin, status, progress,
|
|
created_at, started_at, completed_at, error)
|
|
VALUES (?, ?, ?, ?, NULL, NULL, NULL, ?, ?,
|
|
'schedule', 'queued', NULL, ?, NULL, NULL, NULL)`
|
|
)
|
|
.run(
|
|
taskId,
|
|
projectId,
|
|
conversationId,
|
|
scheduleId,
|
|
parsed.title,
|
|
parsed.prompt,
|
|
now
|
|
)
|
|
if (parsed.runImmediately) {
|
|
// Commit the task and its only automatic occurrence together. The timer
|
|
// must never see an enabled immediate schedule between create and queue.
|
|
const runId = randomUUID()
|
|
database.prepare(
|
|
`INSERT INTO schedule_runs
|
|
(id, schedule_id, scheduled_for, task_id, status)
|
|
VALUES (?, ?, ?, ?, 'pending')`
|
|
).run(runId, scheduleId, now, taskId)
|
|
database.prepare(
|
|
`INSERT INTO conversation_queue_items
|
|
(id, conversation_id, source, label, payload_json,
|
|
schedule_run_id, schedule_id, task_id, status, created_at)
|
|
VALUES (?, ?, 'schedule', ?, '{}', ?, ?, ?, 'pending', ?)`
|
|
).run(runId, conversationId, parsed.title, runId, scheduleId, taskId, now)
|
|
}
|
|
database.exec('COMMIT')
|
|
} catch (error) {
|
|
database.exec('ROLLBACK')
|
|
throw error
|
|
}
|
|
return this.getSchedule(scheduleId)
|
|
}
|
|
|
|
setScheduleEnabled(scheduleId: string, enabled: boolean): void {
|
|
const database = this.requireDatabase()
|
|
const now = new Date().toISOString()
|
|
database.exec('BEGIN IMMEDIATE')
|
|
try {
|
|
const schedule = database
|
|
.prepare(
|
|
`SELECT recurrence_json, last_run_at
|
|
FROM schedules
|
|
WHERE id = ?`
|
|
)
|
|
.get(scheduleId) as
|
|
| {
|
|
recurrence_json: string
|
|
last_run_at: string | null
|
|
}
|
|
| undefined
|
|
if (!schedule) {
|
|
throw new Error('定时任务不存在')
|
|
}
|
|
const recurrence = JSON.parse(schedule.recurrence_json) as {
|
|
type?: string
|
|
}
|
|
if (
|
|
enabled &&
|
|
recurrence.type === 'once' &&
|
|
schedule.last_run_at
|
|
) {
|
|
throw new Error('已执行的一次性计划不能恢复自动运行')
|
|
}
|
|
const result = database
|
|
.prepare(
|
|
`UPDATE schedules
|
|
SET enabled = ?, updated_at = ?
|
|
WHERE id = ?`
|
|
)
|
|
.run(enabled ? 1 : 0, now, scheduleId)
|
|
if (result.changes !== 1) {
|
|
throw new Error('定时任务不存在')
|
|
}
|
|
database
|
|
.prepare(
|
|
`UPDATE tasks
|
|
SET status = ?, completed_at = NULL,
|
|
error = CASE WHEN ? = 1 THEN NULL ELSE error END
|
|
WHERE schedule_id = ?
|
|
AND status NOT IN ('running', 'waiting_approval')`
|
|
)
|
|
.run(
|
|
enabled ? 'queued' : 'paused',
|
|
enabled ? 1 : 0,
|
|
scheduleId
|
|
)
|
|
database.exec('COMMIT')
|
|
} catch (error) {
|
|
database.exec('ROLLBACK')
|
|
throw error
|
|
}
|
|
}
|
|
|
|
removeSchedule(scheduleId: string): void {
|
|
const database = this.requireDatabase()
|
|
const now = new Date().toISOString()
|
|
database.exec('BEGIN IMMEDIATE')
|
|
try {
|
|
const activeRun = database
|
|
.prepare(
|
|
`SELECT 1
|
|
FROM schedule_runs
|
|
WHERE schedule_id = ? AND status = 'running'
|
|
LIMIT 1`
|
|
)
|
|
.get(scheduleId)
|
|
if (activeRun) {
|
|
throw new Error('定时任务正在运行,完成后才能删除计划')
|
|
}
|
|
// Preserve older requests' proven run ownership before the cascade removes it.
|
|
database.prepare(`
|
|
UPDATE tasks AS request SET parent_task_id = (
|
|
SELECT parent.id FROM schedule_runs sr
|
|
JOIN tasks parent ON parent.schedule_id = sr.schedule_id
|
|
WHERE sr.id = request.id AND sr.schedule_id = ?
|
|
)
|
|
WHERE request.parent_task_id IS NULL
|
|
AND request.id IN (SELECT id FROM schedule_runs WHERE schedule_id = ?)
|
|
`).run(scheduleId, scheduleId)
|
|
database
|
|
.prepare(
|
|
`UPDATE tasks
|
|
SET schedule_id = NULL,
|
|
status = CASE
|
|
WHEN status IN ('running', 'waiting_approval')
|
|
THEN status
|
|
ELSE 'completed'
|
|
END,
|
|
completed_at = CASE
|
|
WHEN status IN ('running', 'waiting_approval')
|
|
THEN completed_at
|
|
ELSE ?
|
|
END
|
|
WHERE schedule_id = ?`
|
|
)
|
|
.run(now, scheduleId)
|
|
const result = database
|
|
.prepare('DELETE FROM schedules WHERE id = ?')
|
|
.run(scheduleId)
|
|
if (result.changes !== 1) {
|
|
throw new Error('定时任务不存在')
|
|
}
|
|
database.exec('COMMIT')
|
|
} catch (error) {
|
|
database.exec('ROLLBACK')
|
|
throw error
|
|
}
|
|
}
|
|
|
|
queueDueSchedules(
|
|
now = new Date(),
|
|
limit = 100
|
|
): ConversationQueueItem[] {
|
|
const database = this.requireDatabase()
|
|
const nowIso = now.toISOString()
|
|
const safeLimit = Math.max(1, Math.min(100, Math.trunc(limit)))
|
|
database.exec('BEGIN IMMEDIATE')
|
|
try {
|
|
const due = database
|
|
.prepare(
|
|
`SELECT s.*, t.id AS task_id,
|
|
t.conversation_id AS conversation_id
|
|
FROM schedules s
|
|
INNER JOIN tasks t ON t.schedule_id = s.id
|
|
WHERE s.enabled = 1 AND s.next_run_at <= ?
|
|
AND (
|
|
s.project_id IS NULL OR EXISTS (
|
|
SELECT 1 FROM projects p
|
|
WHERE p.id = s.project_id
|
|
AND p.status = 'active'
|
|
AND p.kind = 'user'
|
|
)
|
|
)
|
|
AND NOT EXISTS (
|
|
SELECT 1
|
|
FROM schedule_runs active
|
|
WHERE active.schedule_id = s.id
|
|
AND active.status IN ('pending', 'running')
|
|
)
|
|
ORDER BY s.next_run_at
|
|
LIMIT ?`
|
|
)
|
|
.all(nowIso, safeLimit) as ScheduleWithTaskRow[]
|
|
const insertRun = database.prepare(
|
|
`INSERT OR IGNORE INTO schedule_runs
|
|
(id, schedule_id, scheduled_for, task_id, status)
|
|
VALUES (?, ?, ?, ?, 'pending')`
|
|
)
|
|
const insertQueueItem = database.prepare(
|
|
`INSERT INTO conversation_queue_items
|
|
(id, conversation_id, source, label, payload_json,
|
|
schedule_run_id, schedule_id, task_id, status, created_at)
|
|
VALUES (?, ?, 'schedule', ?, '{}', ?, ?, ?, 'pending', ?)`
|
|
)
|
|
const queued: ConversationQueueItem[] = []
|
|
for (const row of due) {
|
|
const schedule = toSchedule(row)
|
|
const runId = randomUUID()
|
|
if (
|
|
insertRun.run(
|
|
runId,
|
|
schedule.id,
|
|
schedule.nextRunAt,
|
|
schedule.taskId
|
|
).changes === 1
|
|
) {
|
|
insertQueueItem.run(
|
|
runId,
|
|
schedule.conversationId,
|
|
schedule.title,
|
|
runId,
|
|
schedule.id,
|
|
schedule.taskId,
|
|
schedule.nextRunAt
|
|
)
|
|
queued.push({
|
|
id: runId,
|
|
conversationId: schedule.conversationId,
|
|
source: 'schedule',
|
|
label: schedule.title,
|
|
createdAt: schedule.nextRunAt,
|
|
scheduleRunId: runId,
|
|
scheduleId: schedule.id,
|
|
taskId: schedule.taskId
|
|
})
|
|
}
|
|
}
|
|
database.exec('COMMIT')
|
|
return queued
|
|
} catch (error) {
|
|
database.exec('ROLLBACK')
|
|
throw error
|
|
}
|
|
}
|
|
|
|
queueScheduleNow(scheduleId: string): ConversationQueueItem {
|
|
const schedule = this.getSchedule(scheduleId)
|
|
const database = this.requireDatabase()
|
|
const runId = randomUUID()
|
|
const baseTime = Date.now()
|
|
database.exec('BEGIN IMMEDIATE')
|
|
try {
|
|
const activeRun = database
|
|
.prepare(
|
|
`SELECT 1
|
|
FROM schedule_runs
|
|
WHERE schedule_id = ?
|
|
AND status IN ('pending', 'running')
|
|
LIMIT 1`
|
|
)
|
|
.get(scheduleId)
|
|
if (activeRun) {
|
|
throw new Error('定时任务已有一次运行正在进行')
|
|
}
|
|
let inserted = false
|
|
for (let offset = 0; offset < 10 && !inserted; offset += 1) {
|
|
inserted =
|
|
database
|
|
.prepare(
|
|
`INSERT INTO schedule_runs
|
|
(id, schedule_id, scheduled_for, task_id, status)
|
|
VALUES (?, ?, ?, ?, 'pending')
|
|
ON CONFLICT(schedule_id, scheduled_for) DO NOTHING`
|
|
)
|
|
.run(
|
|
runId,
|
|
scheduleId,
|
|
new Date(baseTime + offset).toISOString(),
|
|
schedule.taskId
|
|
).changes === 1
|
|
}
|
|
if (!inserted) {
|
|
throw new Error('定时任务正在启动,请稍后重试')
|
|
}
|
|
const createdAt = new Date(baseTime).toISOString()
|
|
database
|
|
.prepare(
|
|
`INSERT INTO conversation_queue_items
|
|
(id, conversation_id, source, label, payload_json,
|
|
schedule_run_id, schedule_id, task_id, status, created_at)
|
|
VALUES (?, ?, 'schedule', ?, '{}', ?, ?, ?, 'pending', ?)`
|
|
)
|
|
.run(
|
|
runId,
|
|
schedule.conversationId,
|
|
schedule.title,
|
|
runId,
|
|
schedule.id,
|
|
schedule.taskId,
|
|
createdAt
|
|
)
|
|
database.exec('COMMIT')
|
|
} catch (error) {
|
|
database.exec('ROLLBACK')
|
|
throw error
|
|
}
|
|
return this.getConversationQueueItem(runId)!
|
|
}
|
|
|
|
completeTaskScheduleRun(taskId: string): void {
|
|
const run = this.requireDatabase().prepare(
|
|
`SELECT t.status, t.remote_recoverable FROM schedule_runs sr
|
|
INNER JOIN tasks t ON t.id = sr.id
|
|
WHERE sr.id = ? AND sr.status = 'running'`
|
|
).get(taskId) as { status: string; remote_recoverable: number } | undefined
|
|
if (run && !(run.status === 'interrupted' && run.remote_recoverable === 1) &&
|
|
['completed', 'failed', 'cancelled', 'interrupted'].includes(run.status)) {
|
|
this.completeScheduleRun(taskId,
|
|
run.status === 'completed' || run.status === 'cancelled' ? run.status : 'failed')
|
|
}
|
|
}
|
|
|
|
completeScheduleRun(
|
|
runId: string,
|
|
status: 'completed' | 'failed' | 'cancelled',
|
|
now = new Date()
|
|
): void {
|
|
const database = this.requireDatabase()
|
|
const nowIso = now.toISOString()
|
|
database.exec('BEGIN IMMEDIATE')
|
|
try {
|
|
const row = database
|
|
.prepare(
|
|
`SELECT s.*, sr.scheduled_for,
|
|
t.id AS task_id,
|
|
t.conversation_id AS conversation_id
|
|
FROM schedule_runs sr
|
|
INNER JOIN schedules s ON s.id = sr.schedule_id
|
|
INNER JOIN tasks t ON t.schedule_id = s.id
|
|
WHERE sr.id = ?
|
|
AND sr.status IN ('pending', 'running')`
|
|
)
|
|
.get(runId) as
|
|
| (ScheduleWithTaskRow & { scheduled_for: string })
|
|
| undefined
|
|
if (!row) {
|
|
throw new Error('定时任务运行记录不存在或已完成')
|
|
}
|
|
database
|
|
.prepare(
|
|
`UPDATE schedule_runs
|
|
SET status = ?
|
|
WHERE id = ?`
|
|
)
|
|
.run(status, runId)
|
|
database
|
|
.prepare(
|
|
`DELETE FROM conversation_queue_items
|
|
WHERE schedule_run_id = ?`
|
|
)
|
|
.run(runId)
|
|
const schedule = toSchedule(row)
|
|
if (row.scheduled_for === row.next_run_at) {
|
|
const next = new Date(row.scheduled_for)
|
|
if (schedule.recurrence === 'daily') {
|
|
const intervals =
|
|
Math.floor(
|
|
(now.getTime() - next.getTime()) /
|
|
(24 * 60 * 60 * 1_000)
|
|
) + 1
|
|
next.setUTCDate(next.getUTCDate() + intervals)
|
|
} else if (schedule.recurrence === 'weekly') {
|
|
const intervals =
|
|
Math.floor(
|
|
(now.getTime() - next.getTime()) /
|
|
(7 * 24 * 60 * 60 * 1_000)
|
|
) + 1
|
|
next.setUTCDate(next.getUTCDate() + intervals * 7)
|
|
}
|
|
database
|
|
.prepare(
|
|
`UPDATE schedules
|
|
SET enabled = CASE WHEN ? = 1 THEN 0 ELSE enabled END,
|
|
next_run_at = ?,
|
|
last_run_at = CASE
|
|
WHEN ? = 'cancelled' THEN last_run_at
|
|
ELSE ?
|
|
END,
|
|
updated_at = ?
|
|
WHERE id = ? AND next_run_at = ?`
|
|
)
|
|
.run(
|
|
schedule.recurrence === 'once' ? 1 : 0,
|
|
schedule.recurrence === 'once'
|
|
? schedule.nextRunAt
|
|
: next.toISOString(),
|
|
status,
|
|
nowIso,
|
|
nowIso,
|
|
schedule.id,
|
|
row.scheduled_for
|
|
)
|
|
} else {
|
|
if (status !== 'cancelled') {
|
|
database
|
|
.prepare(
|
|
`UPDATE schedules
|
|
SET last_run_at = ?, updated_at = ?
|
|
WHERE id = ?`
|
|
)
|
|
.run(nowIso, nowIso, schedule.id)
|
|
}
|
|
}
|
|
const updatedSchedule = database
|
|
.prepare('SELECT enabled FROM schedules WHERE id = ?')
|
|
.get(schedule.id) as { enabled: number }
|
|
const finalTaskStatus =
|
|
status === 'failed'
|
|
? 'failed'
|
|
: status === 'cancelled' &&
|
|
schedule.recurrence === 'once'
|
|
? 'cancelled'
|
|
: updatedSchedule.enabled === 1
|
|
? 'queued'
|
|
: schedule.recurrence === 'once'
|
|
? 'completed'
|
|
: 'paused'
|
|
database
|
|
.prepare(
|
|
`UPDATE tasks
|
|
SET status = ?,
|
|
completed_at = CASE
|
|
WHEN ? IN ('completed', 'failed', 'cancelled') THEN ?
|
|
ELSE NULL
|
|
END
|
|
WHERE id = ?`
|
|
)
|
|
.run(
|
|
finalTaskStatus,
|
|
finalTaskStatus,
|
|
nowIso,
|
|
row.task_id
|
|
)
|
|
database
|
|
.prepare(
|
|
`INSERT INTO task_events
|
|
(task_id, run_id, kind, payload_json, created_at)
|
|
VALUES (?, NULL, 'status', ?, ?)`
|
|
)
|
|
.run(
|
|
row.task_id,
|
|
JSON.stringify({
|
|
status:
|
|
finalTaskStatus === 'queued'
|
|
? 'idle'
|
|
: finalTaskStatus
|
|
}),
|
|
nowIso
|
|
)
|
|
database.exec('COMMIT')
|
|
} catch (error) {
|
|
database.exec('ROLLBACK')
|
|
throw error
|
|
}
|
|
}
|
|
|
|
private assertHeartbeatProjectIds(projectIds: string[]): void {
|
|
if (projectIds.length === 0) {
|
|
return
|
|
}
|
|
const placeholders = projectIds.map(() => '?').join(', ')
|
|
const count = this.requireDatabase()
|
|
.prepare(
|
|
`SELECT COUNT(*) AS count FROM projects
|
|
WHERE id IN (${placeholders}) AND status = 'active'`
|
|
)
|
|
.get(...projectIds) as { count: number }
|
|
if (count.count !== projectIds.length) {
|
|
throw new Error('Heartbeat projects must exist and be active')
|
|
}
|
|
}
|
|
|
|
private getHeartbeatProjectBindings(
|
|
configIds: string[]
|
|
): Map<string, string[]> {
|
|
const bindings = new Map<string, string[]>()
|
|
for (const configId of configIds) {
|
|
bindings.set(configId, [])
|
|
}
|
|
if (configIds.length === 0) {
|
|
return bindings
|
|
}
|
|
const placeholders = configIds.map(() => '?').join(', ')
|
|
const rows = this.requireDatabase()
|
|
.prepare(
|
|
`SELECT config_id, project_id
|
|
FROM heartbeat_config_projects
|
|
WHERE config_id IN (${placeholders})
|
|
ORDER BY rowid`
|
|
)
|
|
.all(...configIds) as Array<{
|
|
config_id: string
|
|
project_id: string
|
|
}>
|
|
for (const row of rows) {
|
|
bindings.get(row.config_id)?.push(row.project_id)
|
|
}
|
|
return bindings
|
|
}
|
|
|
|
private insertHeartbeatProjectBindings(
|
|
configId: string,
|
|
projectIds: string[]
|
|
): void {
|
|
const insertProject = this.requireDatabase().prepare(
|
|
`INSERT INTO heartbeat_config_projects (config_id, project_id)
|
|
VALUES (?, ?)`
|
|
)
|
|
for (const projectId of projectIds) {
|
|
insertProject.run(configId, projectId)
|
|
}
|
|
}
|
|
|
|
listHeartbeatConfigs(projectId?: string): AssistantHeartbeatConfig[] {
|
|
const database = this.requireDatabase()
|
|
const rows = projectId
|
|
? database
|
|
.prepare(
|
|
`SELECT c.* FROM heartbeat_configs c
|
|
WHERE EXISTS (
|
|
SELECT 1 FROM heartbeat_config_projects p
|
|
WHERE p.config_id = c.id AND p.project_id = ?
|
|
)
|
|
ORDER BY created_at DESC
|
|
LIMIT 100`
|
|
)
|
|
.all(projectId)
|
|
: database
|
|
.prepare(
|
|
`SELECT * FROM heartbeat_configs
|
|
ORDER BY created_at DESC
|
|
LIMIT 100`
|
|
)
|
|
.all()
|
|
const typedRows = rows as HeartbeatConfigRow[]
|
|
const bindings = this.getHeartbeatProjectBindings(
|
|
typedRows.map((row) => row.id)
|
|
)
|
|
return typedRows.map((row) =>
|
|
toHeartbeatConfig(row, bindings.get(row.id))
|
|
)
|
|
}
|
|
|
|
getHeartbeatConfig(configId: string): AssistantHeartbeatConfig {
|
|
const database = this.requireDatabase()
|
|
const row = database
|
|
.prepare('SELECT * FROM heartbeat_configs WHERE id = ?')
|
|
.get(configId) as HeartbeatConfigRow | undefined
|
|
if (!row) {
|
|
throw new Error('Heartbeat configuration not found')
|
|
}
|
|
return toHeartbeatConfig(
|
|
row,
|
|
this.getHeartbeatProjectBindings([configId]).get(configId)
|
|
)
|
|
}
|
|
|
|
createHeartbeatConfig(
|
|
input: HeartbeatCreateInput,
|
|
now = new Date()
|
|
): AssistantHeartbeatConfig {
|
|
const id = randomUUID()
|
|
const timestamp = now.toISOString()
|
|
const nextRunAt = computeNextHeartbeatRun(
|
|
input.recurrence,
|
|
input.timezone,
|
|
now
|
|
).toISOString()
|
|
const database = this.requireDatabase()
|
|
const projectIds =
|
|
input.scope.kind === 'projects' ? input.scope.projectIds : []
|
|
this.assertHeartbeatProjectIds(projectIds)
|
|
database.exec('BEGIN IMMEDIATE')
|
|
try {
|
|
database.prepare(
|
|
`INSERT INTO heartbeat_configs
|
|
(id, project_id, scope_kind, name, timezone, recurrence_json,
|
|
lookback_hours, retention_days, enabled, next_run_at,
|
|
last_run_at, last_status, created_at, updated_at, intervention)
|
|
VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, ?, ?, ?)`
|
|
).run(
|
|
id,
|
|
input.scope.kind,
|
|
input.name,
|
|
input.timezone,
|
|
JSON.stringify(input.recurrence),
|
|
input.lookbackHours,
|
|
input.retentionDays,
|
|
input.enabled ? 1 : 0,
|
|
nextRunAt,
|
|
timestamp,
|
|
timestamp,
|
|
input.intervention ?? defaultHeartbeatIntervention
|
|
)
|
|
this.insertHeartbeatProjectBindings(id, projectIds)
|
|
database.exec('COMMIT')
|
|
} catch (error) {
|
|
database.exec('ROLLBACK')
|
|
throw error
|
|
}
|
|
return this.getHeartbeatConfig(id)
|
|
}
|
|
|
|
updateHeartbeatConfig(
|
|
configId: string,
|
|
input: HeartbeatUpdateInput,
|
|
now = new Date()
|
|
): AssistantHeartbeatConfig {
|
|
const timestamp = now.toISOString()
|
|
const nextRunAt = computeNextHeartbeatRun(
|
|
input.recurrence,
|
|
input.timezone,
|
|
now
|
|
).toISOString()
|
|
const database = this.requireDatabase()
|
|
const projectIds =
|
|
input.scope.kind === 'projects' ? input.scope.projectIds : []
|
|
this.assertHeartbeatProjectIds(projectIds)
|
|
database.exec('BEGIN IMMEDIATE')
|
|
try {
|
|
const result = database.prepare(
|
|
`UPDATE heartbeat_configs
|
|
SET project_id = NULL, scope_kind = ?, name = ?, timezone = ?,
|
|
recurrence_json = ?, lookback_hours = ?,
|
|
retention_days = ?, enabled = ?, next_run_at = ?,
|
|
updated_at = ?, intervention = COALESCE(?, intervention)
|
|
WHERE id = ?`
|
|
).run(
|
|
input.scope.kind,
|
|
input.name,
|
|
input.timezone,
|
|
JSON.stringify(input.recurrence),
|
|
input.lookbackHours,
|
|
input.retentionDays,
|
|
input.enabled ? 1 : 0,
|
|
nextRunAt,
|
|
timestamp,
|
|
input.intervention ?? null,
|
|
configId
|
|
)
|
|
if (result.changes !== 1) {
|
|
throw new Error('Heartbeat configuration not found')
|
|
}
|
|
database
|
|
.prepare(
|
|
'DELETE FROM heartbeat_config_projects WHERE config_id = ?'
|
|
)
|
|
.run(configId)
|
|
this.insertHeartbeatProjectBindings(configId, projectIds)
|
|
database.exec('COMMIT')
|
|
} catch (error) {
|
|
database.exec('ROLLBACK')
|
|
throw error
|
|
}
|
|
return this.getHeartbeatConfig(configId)
|
|
}
|
|
|
|
setHeartbeatPaused(configId: string, paused: boolean): void {
|
|
const result = this.requireDatabase()
|
|
.prepare(
|
|
`UPDATE heartbeat_configs
|
|
SET enabled = ?, updated_at = ?
|
|
WHERE id = ?`
|
|
)
|
|
.run(paused ? 0 : 1, new Date().toISOString(), configId)
|
|
if (result.changes !== 1) {
|
|
throw new Error('Heartbeat configuration not found')
|
|
}
|
|
}
|
|
|
|
removeHeartbeatConfig(configId: string): void {
|
|
const database = this.requireDatabase()
|
|
database.exec('BEGIN IMMEDIATE')
|
|
try {
|
|
const artifactRows = database
|
|
.prepare(
|
|
`SELECT artifact_id FROM heartbeat_entries
|
|
WHERE config_id = ? AND artifact_id IS NOT NULL`
|
|
)
|
|
.all(configId) as Array<{ artifact_id: string }>
|
|
const result = database
|
|
.prepare('DELETE FROM heartbeat_configs WHERE id = ?')
|
|
.run(configId)
|
|
if (result.changes !== 1) {
|
|
throw new Error('Heartbeat configuration not found')
|
|
}
|
|
const deleteArtifact = database.prepare(
|
|
'DELETE FROM artifacts WHERE id = ?'
|
|
)
|
|
for (const row of artifactRows) {
|
|
deleteArtifact.run(row.artifact_id)
|
|
}
|
|
database.exec('COMMIT')
|
|
} catch (error) {
|
|
database.exec('ROLLBACK')
|
|
throw error
|
|
}
|
|
}
|
|
|
|
listHeartbeatRuns(
|
|
configId?: string,
|
|
limit = 50
|
|
): AssistantHeartbeatRun[] {
|
|
const safeLimit = Math.max(1, Math.min(200, Math.trunc(limit)))
|
|
const rows = configId
|
|
? this.requireDatabase()
|
|
.prepare(
|
|
`SELECT * FROM heartbeat_runs
|
|
WHERE config_id = ?
|
|
ORDER BY created_at DESC
|
|
LIMIT ?`
|
|
)
|
|
.all(configId, safeLimit)
|
|
: this.requireDatabase()
|
|
.prepare(
|
|
`SELECT * FROM heartbeat_runs
|
|
ORDER BY created_at DESC
|
|
LIMIT ?`
|
|
)
|
|
.all(safeLimit)
|
|
return (rows as HeartbeatRunRow[]).map(toHeartbeatRun)
|
|
}
|
|
|
|
getHeartbeatRun(runId: string): AssistantHeartbeatRun {
|
|
const row = this.requireDatabase()
|
|
.prepare('SELECT * FROM heartbeat_runs WHERE id = ?')
|
|
.get(runId) as HeartbeatRunRow | undefined
|
|
if (!row) {
|
|
throw new Error('Heartbeat run not found')
|
|
}
|
|
return toHeartbeatRun(row)
|
|
}
|
|
|
|
listHeartbeatEntries(
|
|
configId?: string,
|
|
limit = 50
|
|
): AssistantHeartbeatEntry[] {
|
|
const safeLimit = Math.max(1, Math.min(200, Math.trunc(limit)))
|
|
const rows = configId
|
|
? this.requireDatabase()
|
|
.prepare(
|
|
`SELECT * FROM heartbeat_entries
|
|
WHERE config_id = ?
|
|
ORDER BY created_at DESC
|
|
LIMIT ?`
|
|
)
|
|
.all(configId, safeLimit)
|
|
: this.requireDatabase()
|
|
.prepare(
|
|
`SELECT * FROM heartbeat_entries
|
|
ORDER BY created_at DESC
|
|
LIMIT ?`
|
|
)
|
|
.all(safeLimit)
|
|
return (rows as HeartbeatEntryRow[]).map(toHeartbeatEntry)
|
|
}
|
|
|
|
claimDueHeartbeats(
|
|
leaseOwner: string,
|
|
now = new Date(),
|
|
leaseMilliseconds = 5 * 60_000
|
|
): ClaimedHeartbeatRun[] {
|
|
const database = this.requireDatabase()
|
|
const nowIso = now.toISOString()
|
|
const leaseExpiresAt = new Date(
|
|
now.getTime() + leaseMilliseconds
|
|
).toISOString()
|
|
const claimed: ClaimedHeartbeatRun[] = []
|
|
database.exec('BEGIN IMMEDIATE')
|
|
try {
|
|
const retryRows = database
|
|
.prepare(
|
|
`SELECT r.id AS run_id, r.config_id, r.attempt_count
|
|
FROM heartbeat_runs r
|
|
JOIN heartbeat_configs c ON c.id = r.config_id
|
|
WHERE c.enabled = 1
|
|
AND r.attempt_count < 3
|
|
AND (
|
|
(r.status = 'failed' AND r.next_attempt_at <= ?)
|
|
OR
|
|
(r.status = 'claimed' AND r.lease_expires_at <= ?)
|
|
)
|
|
ORDER BY COALESCE(r.next_attempt_at, r.lease_expires_at)
|
|
LIMIT 1`
|
|
)
|
|
.all(nowIso, nowIso) as Array<{
|
|
run_id: string
|
|
config_id: string
|
|
attempt_count: number
|
|
}>
|
|
for (const joined of retryRows) {
|
|
const result = database
|
|
.prepare(
|
|
`UPDATE heartbeat_runs
|
|
SET status = 'claimed', attempt_count = attempt_count + 1,
|
|
next_attempt_at = NULL, lease_owner = ?,
|
|
lease_expires_at = ?, started_at = ?,
|
|
completed_at = NULL, error = NULL, updated_at = ?
|
|
WHERE id = ? AND attempt_count = ?`
|
|
)
|
|
.run(
|
|
leaseOwner,
|
|
leaseExpiresAt,
|
|
nowIso,
|
|
nowIso,
|
|
joined.run_id,
|
|
joined.attempt_count
|
|
)
|
|
if (result.changes === 1) {
|
|
const run = database
|
|
.prepare('SELECT * FROM heartbeat_runs WHERE id = ?')
|
|
.get(joined.run_id) as HeartbeatRunRow
|
|
const config = database
|
|
.prepare('SELECT * FROM heartbeat_configs WHERE id = ?')
|
|
.get(joined.config_id) as HeartbeatConfigRow
|
|
claimed.push({
|
|
run: toHeartbeatRun(run),
|
|
config: this.getHeartbeatConfig(config.id),
|
|
leaseOwner,
|
|
acquired: true
|
|
})
|
|
}
|
|
}
|
|
|
|
const remaining = Math.max(0, 1 - claimed.length)
|
|
const configRows = database
|
|
.prepare(
|
|
`SELECT * FROM heartbeat_configs
|
|
WHERE enabled = 1 AND next_run_at <= ?
|
|
ORDER BY next_run_at
|
|
LIMIT ?`
|
|
)
|
|
.all(nowIso, remaining) as HeartbeatConfigRow[]
|
|
for (const row of configRows) {
|
|
const scheduledFor = row.next_run_at
|
|
const lag = now.getTime() - Date.parse(scheduledFor)
|
|
const nextRunAt = computeNextHeartbeatRun(
|
|
JSON.parse(
|
|
row.recurrence_json
|
|
) as AssistantHeartbeatConfig['recurrence'],
|
|
row.timezone,
|
|
now
|
|
).toISOString()
|
|
if (lag > 2 * 60 * 60_000) {
|
|
database
|
|
.prepare(
|
|
`INSERT OR IGNORE INTO heartbeat_runs
|
|
(id, config_id, trigger, scheduled_for,
|
|
idempotency_key, status, attempt_count,
|
|
next_attempt_at, lease_owner, lease_expires_at,
|
|
started_at, completed_at, error, entry_id,
|
|
created_at, updated_at)
|
|
VALUES (?, ?, 'scheduled', ?, ?, 'skipped', 0,
|
|
NULL, NULL, NULL, NULL, ?, ?, NULL, ?, ?)`
|
|
)
|
|
.run(
|
|
randomUUID(),
|
|
row.id,
|
|
scheduledFor,
|
|
`scheduled:${row.id}:${scheduledFor}`,
|
|
nowIso,
|
|
'Missed by more than 2 hours',
|
|
nowIso,
|
|
nowIso
|
|
)
|
|
database
|
|
.prepare(
|
|
`UPDATE heartbeat_configs
|
|
SET next_run_at = ?, last_run_at = ?,
|
|
last_status = 'skipped', updated_at = ?
|
|
WHERE id = ? AND next_run_at = ?`
|
|
)
|
|
.run(nextRunAt, nowIso, nowIso, row.id, scheduledFor)
|
|
continue
|
|
}
|
|
const runId = randomUUID()
|
|
const insert = database
|
|
.prepare(
|
|
`INSERT OR IGNORE INTO heartbeat_runs
|
|
(id, config_id, trigger, scheduled_for,
|
|
idempotency_key, status, attempt_count,
|
|
next_attempt_at, lease_owner, lease_expires_at,
|
|
started_at, completed_at, error, entry_id,
|
|
created_at, updated_at)
|
|
VALUES (?, ?, 'scheduled', ?, ?, 'claimed', 1,
|
|
NULL, ?, ?, ?, NULL, NULL, NULL, ?, ?)`
|
|
)
|
|
.run(
|
|
runId,
|
|
row.id,
|
|
scheduledFor,
|
|
`scheduled:${row.id}:${scheduledFor}`,
|
|
leaseOwner,
|
|
leaseExpiresAt,
|
|
nowIso,
|
|
nowIso,
|
|
nowIso
|
|
)
|
|
database
|
|
.prepare(
|
|
`UPDATE heartbeat_configs
|
|
SET next_run_at = ?, last_run_at = ?,
|
|
last_status = 'claimed', updated_at = ?
|
|
WHERE id = ? AND next_run_at = ?`
|
|
)
|
|
.run(nextRunAt, nowIso, nowIso, row.id, scheduledFor)
|
|
if (insert.changes === 1) {
|
|
const run = database
|
|
.prepare('SELECT * FROM heartbeat_runs WHERE id = ?')
|
|
.get(runId) as HeartbeatRunRow
|
|
claimed.push({
|
|
run: toHeartbeatRun(run),
|
|
config: this.getHeartbeatConfig(row.id),
|
|
leaseOwner,
|
|
acquired: true
|
|
})
|
|
}
|
|
}
|
|
database.exec('COMMIT')
|
|
return claimed
|
|
} catch (error) {
|
|
database.exec('ROLLBACK')
|
|
throw error
|
|
}
|
|
}
|
|
|
|
claimHeartbeatNow(
|
|
configId: string,
|
|
idempotencyKey: string,
|
|
leaseOwner: string,
|
|
now = new Date(),
|
|
leaseMilliseconds = 5 * 60_000
|
|
): ClaimedHeartbeatRun {
|
|
const database = this.requireDatabase()
|
|
const config = this.getHeartbeatConfig(configId)
|
|
const nowIso = now.toISOString()
|
|
const runId = randomUUID()
|
|
const scopedIdempotencyKey = `manual:${configId}:${idempotencyKey}`
|
|
database.exec('BEGIN IMMEDIATE')
|
|
try {
|
|
const duplicate = database
|
|
.prepare(
|
|
`SELECT * FROM heartbeat_runs
|
|
WHERE config_id = ? AND idempotency_key = ?`
|
|
)
|
|
.get(configId, scopedIdempotencyKey) as
|
|
| HeartbeatRunRow
|
|
| undefined
|
|
if (duplicate) {
|
|
database.exec('COMMIT')
|
|
return {
|
|
config,
|
|
run: toHeartbeatRun(duplicate),
|
|
leaseOwner,
|
|
acquired: false
|
|
}
|
|
}
|
|
database
|
|
.prepare(
|
|
`UPDATE heartbeat_runs
|
|
SET status = 'failed', next_attempt_at = NULL,
|
|
lease_owner = NULL, lease_expires_at = NULL,
|
|
completed_at = ?, error = ?, updated_at = ?
|
|
WHERE config_id = ? AND status = 'claimed'
|
|
AND lease_expires_at <= ?`
|
|
)
|
|
.run(
|
|
nowIso,
|
|
'Expired run superseded by a new manual heartbeat',
|
|
nowIso,
|
|
configId,
|
|
nowIso
|
|
)
|
|
const active = database
|
|
.prepare(
|
|
`SELECT * FROM heartbeat_runs
|
|
WHERE config_id = ? AND status = 'claimed'
|
|
AND lease_expires_at > ?
|
|
ORDER BY created_at DESC LIMIT 1`
|
|
)
|
|
.get(configId, nowIso) as HeartbeatRunRow | undefined
|
|
if (active) {
|
|
database.exec('COMMIT')
|
|
return {
|
|
config,
|
|
run: toHeartbeatRun(active),
|
|
leaseOwner,
|
|
acquired: false
|
|
}
|
|
}
|
|
database
|
|
.prepare(
|
|
`INSERT INTO heartbeat_runs
|
|
(id, config_id, trigger, scheduled_for, idempotency_key,
|
|
status, attempt_count, next_attempt_at, lease_owner,
|
|
lease_expires_at, started_at, completed_at, error,
|
|
entry_id, created_at, updated_at)
|
|
VALUES (?, ?, 'manual', ?, ?, 'claimed', 1, NULL, ?, ?,
|
|
?, NULL, NULL, NULL, ?, ?)`
|
|
)
|
|
.run(
|
|
runId,
|
|
configId,
|
|
nowIso,
|
|
scopedIdempotencyKey,
|
|
leaseOwner,
|
|
new Date(
|
|
now.getTime() + leaseMilliseconds
|
|
).toISOString(),
|
|
nowIso,
|
|
nowIso,
|
|
nowIso
|
|
)
|
|
database
|
|
.prepare(
|
|
`UPDATE heartbeat_configs
|
|
SET last_run_at = ?, last_status = 'claimed', updated_at = ?
|
|
WHERE id = ?`
|
|
)
|
|
.run(nowIso, nowIso, configId)
|
|
const run = database
|
|
.prepare('SELECT * FROM heartbeat_runs WHERE id = ?')
|
|
.get(runId) as HeartbeatRunRow
|
|
database.exec('COMMIT')
|
|
return {
|
|
config,
|
|
run: toHeartbeatRun(run),
|
|
leaseOwner,
|
|
acquired: true
|
|
}
|
|
} catch (error) {
|
|
database.exec('ROLLBACK')
|
|
throw error
|
|
}
|
|
}
|
|
|
|
renewHeartbeatLease(claim: ClaimedHeartbeatRun, leaseMilliseconds: number, now = new Date()): void {
|
|
const timestamp = now.toISOString()
|
|
const result = this.requireDatabase().prepare(`UPDATE heartbeat_runs
|
|
SET lease_expires_at = ?, updated_at = ?
|
|
WHERE id = ? AND status = 'claimed' AND lease_owner = ?
|
|
AND attempt_count = ? AND lease_expires_at > ?`).run(
|
|
new Date(now.getTime() + leaseMilliseconds).toISOString(), timestamp,
|
|
claim.run.id, claim.leaseOwner, claim.run.attemptCount, timestamp
|
|
)
|
|
if (result.changes !== 1) throw new Error('Heartbeat lease is no longer active')
|
|
}
|
|
|
|
buildHeartbeatInput(
|
|
config: Pick<AssistantHeartbeatConfig, 'scope' | 'lookbackHours'>,
|
|
now = new Date(),
|
|
timeRange?: { from: string; to: string }
|
|
): HeartbeatInputSnapshot {
|
|
const database = this.requireDatabase()
|
|
const since = timeRange
|
|
? new Date(timeRange.from).toISOString()
|
|
: new Date(now.getTime() - config.lookbackHours * 60 * 60_000).toISOString()
|
|
const projectIds =
|
|
config.scope.kind === 'projects' ? config.scope.projectIds : []
|
|
const until = timeRange ? new Date(timeRange.to).toISOString() : undefined
|
|
if (timeRange) this.assertHeartbeatProjectIds(projectIds)
|
|
const timeParameters = until ? [since, until] : [since]
|
|
const conversationTimeFilter = until
|
|
? `EXISTS (SELECT 1 FROM messages m WHERE m.conversation_id = c.id
|
|
AND m.created_at >= ? AND m.created_at <= ? AND m.role IN ('user', 'assistant'))`
|
|
: 'c.updated_at >= ?'
|
|
const taskTimeFilter = until
|
|
? '((t.created_at >= ? AND t.created_at <= ?) OR (t.completed_at >= ? AND t.completed_at <= ?))'
|
|
: 't.created_at >= ?'
|
|
const taskTimeParameters = until ? [...timeParameters, ...timeParameters] : timeParameters
|
|
const projectPlaceholders = projectIds.map(() => '?').join(', ')
|
|
const conversations = (
|
|
config.scope.kind === 'projects'
|
|
? database
|
|
.prepare(
|
|
`SELECT c.id, c.project_id, c.title, c.updated_at
|
|
FROM conversations c
|
|
WHERE c.status = 'active'
|
|
AND c.project_id IN (${projectPlaceholders})
|
|
AND ${conversationTimeFilter}
|
|
ORDER BY c.updated_at DESC LIMIT 20`
|
|
)
|
|
.all(...projectIds, ...timeParameters)
|
|
: database
|
|
.prepare(
|
|
`SELECT c.id, c.project_id, c.title, c.updated_at
|
|
FROM conversations c
|
|
JOIN projects p ON p.id = c.project_id
|
|
WHERE c.status = 'active' AND p.status = 'active'
|
|
AND ${conversationTimeFilter}
|
|
ORDER BY c.updated_at DESC LIMIT 20`
|
|
)
|
|
.all(...timeParameters)
|
|
) as Array<{
|
|
id: string
|
|
project_id: string
|
|
title: string
|
|
updated_at: string
|
|
}>
|
|
const messageStatement = database.prepare(
|
|
`SELECT id, role, content, metadata_json, created_at FROM (
|
|
SELECT id, role, content, metadata_json, created_at, sequence
|
|
FROM messages
|
|
WHERE conversation_id = ? AND created_at >= ?
|
|
${until ? 'AND created_at <= ?' : ''}
|
|
AND role IN ('user', 'assistant')
|
|
ORDER BY sequence DESC LIMIT 20
|
|
) ORDER BY sequence`
|
|
)
|
|
const tasks = (
|
|
config.scope.kind === 'projects'
|
|
? database
|
|
.prepare(
|
|
`SELECT id, project_id, title, status, created_at,
|
|
completed_at
|
|
FROM tasks t
|
|
WHERE project_id IN (${projectPlaceholders})
|
|
AND visible = 1 AND ${taskTimeFilter}
|
|
ORDER BY created_at DESC LIMIT 100`
|
|
)
|
|
.all(...projectIds, ...taskTimeParameters)
|
|
: database
|
|
.prepare(
|
|
`SELECT t.id, t.project_id, t.title, t.status,
|
|
t.created_at, t.completed_at
|
|
FROM tasks t
|
|
LEFT JOIN projects p ON p.id = t.project_id
|
|
WHERE t.visible = 1 AND ${taskTimeFilter}
|
|
AND (t.project_id IS NULL OR p.status = 'active')
|
|
ORDER BY t.created_at DESC LIMIT 100`
|
|
)
|
|
.all(...taskTimeParameters)
|
|
) as Array<{
|
|
id: string
|
|
project_id: string | null
|
|
title: string
|
|
status: AssistantTask['status']
|
|
created_at: string
|
|
completed_at: string | null
|
|
}>
|
|
const memories = (
|
|
config.scope.kind === 'projects'
|
|
? database
|
|
.prepare(
|
|
`SELECT id, scope_id, type, content, scope, created_at, updated_at
|
|
FROM memory_items
|
|
WHERE status = 'confirmed'
|
|
AND (scope = 'global' OR
|
|
(scope = 'project' AND
|
|
scope_id IN (${projectPlaceholders})))
|
|
ORDER BY updated_at DESC LIMIT 100`
|
|
)
|
|
.all(...projectIds)
|
|
: database
|
|
.prepare(
|
|
`SELECT id, scope_id, type, content, scope, created_at, updated_at
|
|
FROM memory_items
|
|
WHERE status = 'confirmed' AND scope = 'global'
|
|
ORDER BY updated_at DESC LIMIT 100`
|
|
)
|
|
.all()
|
|
) as Array<{
|
|
id: string
|
|
scope_id: string | null
|
|
type: AssistantMemory['type']
|
|
content: string
|
|
scope: AssistantMemory['scope']
|
|
created_at: string
|
|
updated_at: string
|
|
}>
|
|
return {
|
|
scope: config.scope,
|
|
conversations: conversations.map((conversation) => ({
|
|
id: conversation.id,
|
|
projectId: conversation.project_id,
|
|
title: conversation.title,
|
|
updatedAt: conversation.updated_at,
|
|
messages: (
|
|
messageStatement.all(conversation.id, ...timeParameters) as Array<{
|
|
id: string
|
|
role: 'user' | 'assistant'
|
|
content: string
|
|
metadata_json: string
|
|
created_at: string
|
|
}>
|
|
).map((message) => ({
|
|
id: message.id,
|
|
role: message.role,
|
|
content: message.content,
|
|
createdAt: message.created_at,
|
|
sourceReferences: (JSON.parse(message.metadata_json) as MessageMetadata).sourceReferences
|
|
}))
|
|
})),
|
|
tasks: tasks.map((task) => ({
|
|
id: task.id,
|
|
projectId: task.project_id ?? undefined,
|
|
title: task.title,
|
|
status: task.status,
|
|
createdAt: task.created_at,
|
|
completedAt: task.completed_at ?? undefined
|
|
})),
|
|
confirmedMemories: memories.map((memory) => ({
|
|
id: memory.id,
|
|
projectId: memory.scope_id ?? undefined,
|
|
type: memory.type,
|
|
content: memory.content,
|
|
scope: memory.scope,
|
|
createdAt: memory.created_at,
|
|
updatedAt: memory.updated_at
|
|
}))
|
|
}
|
|
}
|
|
|
|
collectIncrementalReview(request: SupervisionRunRequest, stage: ReviewBatch['stage'], budget = 48_000): ReviewBatch {
|
|
const database = this.requireDatabase()
|
|
const scope = reviewScope(request.scope)
|
|
const projects = request.scope.kind === 'projects' ? request.scope.projectIds : []
|
|
const from = new Date(request.timeRange.from).toISOString()
|
|
const to = new Date(request.timeRange.to).toISOString()
|
|
this.assertHeartbeatProjectIds(projects)
|
|
database.function('review_revision', { deterministic: true }, (value) =>
|
|
createHash('sha256').update(String(value)).digest('hex'))
|
|
// Filter processed revisions before LIMIT. Only dirty source portions cross into JS.
|
|
const rows = database.prepare(`WITH sources AS (
|
|
SELECT 'message:' || m.id AS source, 'conversation' AS type, c.id AS owner,
|
|
c.title AS title, m.created_at AS occurred,
|
|
m.content AS body,
|
|
json_array(m.review_revision, c.project_id, c.title, m.role, m.created_at) AS context,
|
|
json_object('messageId', m.id, 'role', m.role, 'projectId', c.project_id) AS locator
|
|
FROM messages m JOIN conversations c ON c.id = m.conversation_id JOIN projects p ON p.id = c.project_id
|
|
WHERE c.status = 'active' AND p.status = 'active' AND m.role IN ('user', 'assistant')
|
|
AND m.created_at >= ? AND m.created_at <= ?
|
|
AND (? = 'global' OR c.project_id IN (SELECT value FROM json_each(?)))
|
|
UNION ALL
|
|
SELECT 'task:' || t.id, 'task', t.id, t.title,
|
|
CASE WHEN t.completed_at >= ? AND t.completed_at <= ? THEN t.completed_at ELSE t.created_at END,
|
|
json_object('title', t.title, 'status', t.status, 'createdAt', t.created_at, 'completedAt', t.completed_at),
|
|
json_array(t.project_id, t.title, t.status, t.created_at, t.completed_at), json_object('projectId', t.project_id)
|
|
FROM tasks t LEFT JOIN projects p ON p.id = t.project_id
|
|
WHERE t.visible = 1 AND (t.project_id IS NULL OR p.status = 'active')
|
|
AND ((t.created_at >= ? AND t.created_at <= ?) OR (t.completed_at >= ? AND t.completed_at <= ?))
|
|
AND (? = 'global' OR t.project_id IN (SELECT value FROM json_each(?)))
|
|
UNION ALL
|
|
SELECT 'knowledge:' || m.id || ':' || ref.key, 'knowledge',
|
|
COALESCE(json_extract(ref.value, '$.chunkId'), json_extract(ref.value, '$.documentId'),
|
|
json_extract(ref.value, '$.external.sourceUrl'), json_extract(ref.value, '$.libraryId')),
|
|
COALESCE(json_extract(ref.value, '$.documentName'), json_extract(ref.value, '$.sourceName'), ''),
|
|
m.created_at, COALESCE(json_extract(ref.value, '$.snippet'), ''),
|
|
json_array(c.project_id, ref.value, m.created_at), ref.value
|
|
FROM messages m JOIN conversations c ON c.id = m.conversation_id JOIN projects p ON p.id = c.project_id
|
|
JOIN json_each(m.metadata_json, '$.sourceReferences') ref
|
|
WHERE c.status = 'active' AND p.status = 'active' AND m.role IN ('user', 'assistant')
|
|
AND m.created_at >= ? AND m.created_at <= ?
|
|
AND (? = 'global' OR c.project_id IN (SELECT value FROM json_each(?)))
|
|
), revisions AS (
|
|
SELECT *, review_revision(context) AS revision FROM sources
|
|
), pending AS (
|
|
SELECT s.*, CASE WHEN c.revision = s.revision THEN c.processed_offset ELSE 0 END AS start
|
|
FROM revisions s LEFT JOIN review_checkpoints c ON c.stage = ? AND c.scope = ? AND c.source = s.source
|
|
WHERE c.revision IS NULL OR c.revision != s.revision OR c.processed_offset < c.source_length
|
|
) SELECT source, type, owner, substr(title, 1, 240) AS title, occurred, revision, start, locator,
|
|
substr(body, start + 1, 2000) AS content, MIN(length(body), start + 2000) AS finish, length(body) AS length
|
|
FROM pending WHERE length(body) > 0 ORDER BY occurred, source LIMIT 100`).all(
|
|
from, to, request.scope.kind, JSON.stringify(projects), from, to,
|
|
from, to, from, to,
|
|
request.scope.kind, JSON.stringify(projects), from, to,
|
|
request.scope.kind, JSON.stringify(projects), stage, scope
|
|
) as Array<{ source: string; type: 'conversation' | 'task' | 'knowledge'; owner: string; title: string; occurred: string; revision: string; start: number; finish: number; length: number; content: string; locator: string }>
|
|
const batch: ReviewBatch = { stage, scope, evidence: [], checkpoints: [] }
|
|
for (const row of rows) {
|
|
if (row.content.length > budget) break
|
|
budget -= row.content.length
|
|
batch.evidence.push({ id: `${row.source}:${row.start}`, sourceType: row.type,
|
|
sourceId: row.owner, title: row.title, content: row.content, occurredAt: row.occurred,
|
|
locator: { ...JSON.parse(row.locator), source: row.source, start: row.start, end: row.finish } })
|
|
batch.checkpoints.push({ source: row.source, revision: row.revision, offset: row.finish, length: row.length })
|
|
}
|
|
return batch
|
|
}
|
|
|
|
resolveReviewScope(scope: SupervisionRunRequest['scope']): SupervisionRunRequest['scope'] {
|
|
const canonical = reviewScope(scope)
|
|
const rows = this.requireDatabase().prepare('SELECT scope_json FROM story_lines ORDER BY created_at, id').all()
|
|
for (const row of rows) {
|
|
const existing = JSON.parse(String(row.scope_json)) as SupervisionRunRequest['scope']
|
|
if (reviewScope(existing) === canonical) return existing
|
|
}
|
|
return JSON.parse(canonical) as SupervisionRunRequest['scope']
|
|
}
|
|
|
|
reviewSummary(scope: SupervisionRunRequest['scope'], stage: ReviewBatch['stage']): string | undefined {
|
|
const row = stage === 'supervisor' ? this.requireDatabase().prepare(`SELECT r.summary AS summary
|
|
FROM supervision_results r JOIN supervision_runs s ON s.id = r.run_id
|
|
WHERE s.scope_json = ? ORDER BY r.created_at DESC, r.rowid DESC LIMIT 1`).get(JSON.stringify(this.resolveReviewScope(scope)))
|
|
: this.requireDatabase().prepare(`SELECT substr(e.summary, 1, 2000) AS summary FROM heartbeat_entries e
|
|
JOIN heartbeat_runs r ON r.id = e.run_id WHERE r.activity_scope_json = ? ORDER BY e.created_at DESC, e.rowid DESC LIMIT 1`).get(JSON.stringify(scope))
|
|
return row?.summary as string | undefined
|
|
}
|
|
|
|
reviewBackground(scope: SupervisionRunRequest['scope']): ReviewBatch['evidence'] {
|
|
const projects = scope.kind === 'projects' ? scope.projectIds : []
|
|
const rows = this.requireDatabase().prepare(`SELECT id, substr(content, 1, 500) AS content, updated_at
|
|
FROM memory_items WHERE status = 'confirmed' AND (scope = 'global' OR
|
|
(scope = 'project' AND scope_id IN (SELECT value FROM json_each(?))))
|
|
ORDER BY updated_at DESC, id LIMIT 4`).all(JSON.stringify(projects)) as Array<{ id: string; content: string; updated_at: string }>
|
|
return rows.map((row) => ({ id: `memory:${row.id}`, sourceType: 'memory', sourceId: row.id,
|
|
title: 'Current confirmed background', content: row.content.slice(0, 500), occurredAt: row.updated_at,
|
|
locator: { temporalRole: 'current-background' } }))
|
|
}
|
|
|
|
private saveReviewCheckpoints(batch: ReviewBatch): void {
|
|
const statement = this.requireDatabase().prepare(`INSERT INTO review_checkpoints
|
|
(stage, scope, source, revision, processed_offset, source_length) VALUES (?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(stage, scope, source) DO UPDATE SET revision = excluded.revision,
|
|
processed_offset = CASE WHEN review_checkpoints.revision = excluded.revision
|
|
THEN MAX(review_checkpoints.processed_offset, excluded.processed_offset) ELSE excluded.processed_offset END,
|
|
source_length = excluded.source_length`)
|
|
for (const item of batch.checkpoints) statement.run(batch.stage, batch.scope, item.source, item.revision, item.offset, item.length)
|
|
}
|
|
|
|
noChangeSupervisionRun(id: string): void {
|
|
this.requireDatabase().prepare("UPDATE supervision_runs SET status = 'no_change', completed_at = ? WHERE id = ? AND status = 'running'")
|
|
.run(new Date().toISOString(), id)
|
|
}
|
|
|
|
noChangeHeartbeatRun(claim: ClaimedHeartbeatRun, now: Date): AssistantHeartbeatRun {
|
|
const database = this.requireDatabase()
|
|
const timestamp = now.toISOString()
|
|
database.exec('BEGIN IMMEDIATE')
|
|
try {
|
|
const updated = database.prepare(`UPDATE heartbeat_runs SET status = 'no_change', completed_at = ?,
|
|
lease_owner = NULL, lease_expires_at = NULL, updated_at = ? WHERE id = ? AND status = 'claimed'
|
|
AND lease_owner = ? AND lease_expires_at > ?`).run(timestamp, timestamp, claim.run.id, claim.leaseOwner, timestamp)
|
|
if (updated.changes !== 1) throw new Error('Heartbeat lease is no longer active')
|
|
database.prepare("UPDATE heartbeat_configs SET last_status = 'no_change', updated_at = ? WHERE id = ?").run(timestamp, claim.config.id)
|
|
database.exec('COMMIT')
|
|
} catch (error) {
|
|
database.exec('ROLLBACK')
|
|
throw error
|
|
}
|
|
this.pruneHeartbeatHistory(claim.config.id, now)
|
|
return this.getHeartbeatRun(claim.run.id)
|
|
}
|
|
|
|
/** Finish the heartbeat's own work: it has handed the trigger to the supervisor. */
|
|
completeHeartbeatTrigger(claim: ClaimedHeartbeatRun, now: Date): AssistantHeartbeatRun {
|
|
const database = this.requireDatabase()
|
|
const timestamp = now.toISOString()
|
|
database.exec('BEGIN IMMEDIATE')
|
|
try {
|
|
const updated = database.prepare(`UPDATE heartbeat_runs SET status = 'completed', completed_at = ?, error = NULL,
|
|
lease_owner = NULL, lease_expires_at = NULL, updated_at = ? WHERE id = ? AND status = 'claimed'
|
|
AND lease_owner = ? AND lease_expires_at > ?`).run(timestamp, timestamp, claim.run.id, claim.leaseOwner, timestamp)
|
|
if (updated.changes !== 1) throw new Error('Heartbeat lease is no longer active')
|
|
database.prepare("UPDATE heartbeat_configs SET last_status = 'completed', updated_at = ? WHERE id = ?").run(timestamp, claim.config.id)
|
|
database.exec('COMMIT')
|
|
} catch (error) {
|
|
database.exec('ROLLBACK')
|
|
throw error
|
|
}
|
|
this.pruneHeartbeatHistory(claim.config.id, now)
|
|
return this.getHeartbeatRun(claim.run.id)
|
|
}
|
|
|
|
markHeartbeatNoChange(runId: string, configId: string): void {
|
|
const database = this.requireDatabase()
|
|
database.prepare("UPDATE heartbeat_runs SET status = 'no_change' WHERE id = ? AND status = 'completed'").run(runId)
|
|
database.prepare("UPDATE heartbeat_configs SET last_status = 'no_change' WHERE id = ?").run(configId)
|
|
}
|
|
|
|
setHeartbeatSuggestionStatus(runId: string, status: 'running' | 'completed' | 'failed' | 'skipped', error?: string): void {
|
|
this.requireDatabase().prepare('UPDATE heartbeat_runs SET suggestion_status = ?, suggestion_error = ? WHERE id = ?')
|
|
.run(status, error?.slice(0, 4000) ?? null, runId)
|
|
}
|
|
|
|
supervisionRunForHeartbeat(heartbeatRunId: string): string | undefined {
|
|
return this.requireDatabase().prepare(`SELECT s.id FROM supervision_runs s JOIN supervision_results r ON r.run_id = s.id
|
|
WHERE s.heartbeat_run_id = ? AND s.status = 'completed' LIMIT 1`).get(heartbeatRunId)?.id as string | undefined
|
|
}
|
|
|
|
getHeartbeatEntry(entryId?: string): AssistantHeartbeatEntry | undefined {
|
|
if (!entryId) return undefined
|
|
const row = this.requireDatabase().prepare(
|
|
'SELECT * FROM heartbeat_entries WHERE id = ?'
|
|
).get(entryId) as HeartbeatEntryRow | undefined
|
|
return row ? toHeartbeatEntry(row) : undefined
|
|
}
|
|
|
|
listSupervisionCandidates(request: StoredSupervisionResult['request'], batch?: { projectId: string; crossProject: boolean }): SupervisionCandidate[] {
|
|
// The timeline is shared: a batch joins knowledge from its own project, or any project when cross-project is on.
|
|
const rows = (batch
|
|
? timelineCandidates(this.requireDatabase(), batch.projectId, batch.crossProject)
|
|
: this.requireDatabase().prepare(`SELECT e.id, e.canonical_label AS label, e.description
|
|
FROM supervision_entities e JOIN story_lines s ON s.id = e.story_line_id
|
|
WHERE s.scope_json = ? AND e.confirmation_state != 'revoked'
|
|
ORDER BY e.updated_at DESC, e.id LIMIT 100`).all(JSON.stringify(request.scope))) as SupervisionCandidate[]
|
|
return rows.map(candidate => {
|
|
if (supervisionEntitySchema.shape.persistedId.safeParse(candidate.id).success) return candidate
|
|
// Released databases contain text primary keys. Keep those keys and their
|
|
// graph references intact; use a stable UUID only inside the review contract.
|
|
const hash = createHash('sha256').update(JSON.stringify(['supervision-entity', request.scope, candidate.id])).digest('hex')
|
|
return { ...candidate, storageId: candidate.id,
|
|
id: `${hash.slice(0, 8)}-${hash.slice(8, 12)}-8${hash.slice(13, 16)}-8${hash.slice(17, 20)}-${hash.slice(20, 32)}` }
|
|
})
|
|
}
|
|
|
|
startSupervisionRun(request: SupervisionRunRequest, heartbeatRunId?: string): string {
|
|
const id = randomUUID()
|
|
const timestamp = new Date().toISOString()
|
|
this.requireDatabase().prepare(`INSERT INTO supervision_runs
|
|
(id, trigger, scope_json, time_range_json, status, started_at, created_at, heartbeat_run_id)
|
|
VALUES (?, ?, ?, ?, 'running', ?, ?, ?)`).run(id, request.trigger,
|
|
JSON.stringify(request.scope), JSON.stringify(request.timeRange), timestamp, timestamp, heartbeatRunId ?? null)
|
|
return id
|
|
}
|
|
|
|
failSupervisionRun(id: string, error: string): void {
|
|
this.requireDatabase().prepare("UPDATE supervision_runs SET status = 'failed', error = ?, completed_at = ? WHERE id = ? AND status = 'running'")
|
|
.run(error.slice(0, 4000), new Date().toISOString(), id)
|
|
}
|
|
|
|
setHeartbeatProjection(id: string, status: 'running' | 'completed' | 'failed', scope?: SupervisionRunRequest['scope'], error?: string): void {
|
|
this.requireDatabase().prepare(`UPDATE heartbeat_runs SET projection_status = ?, projection_error = ?,
|
|
projection_completed_at = ?, activity_scope_json = COALESCE(?, activity_scope_json) WHERE id = ?`)
|
|
.run(status, error?.slice(0, 4000) ?? null, status === 'running' ? null : new Date().toISOString(),
|
|
scope ? JSON.stringify(scope) : null, id)
|
|
}
|
|
|
|
listSupervisionActivity(limit = 50, offset = 0, configId?: string): SupervisionActivity[] {
|
|
const rows = this.requireDatabase().prepare(`
|
|
SELECT s.id AS id, 'supervision' AS kind, s.trigger, s.status, s.scope_json AS scope,
|
|
COALESCE(s.started_at, s.created_at) AS startedAt, s.completed_at AS completedAt,
|
|
s.time_range_json AS timeRange, s.error, r.summary, r.id AS resultId,
|
|
NULL AS heartbeatStatus, s.status AS supervisionStatus, s.id AS supervisionRunId,
|
|
NULL AS suggestionStatus, NULL AS suggestionError, 0 AS suggestionCount
|
|
FROM supervision_runs s LEFT JOIN supervision_results r ON r.run_id = s.id
|
|
WHERE ? IS NULL AND (s.heartbeat_run_id IS NULL OR NOT EXISTS (SELECT 1 FROM heartbeat_runs h WHERE h.id = s.heartbeat_run_id))
|
|
UNION ALL
|
|
SELECT h.id, 'heartbeat', h.trigger,
|
|
CASE WHEN h.status = 'claimed' THEN 'running'
|
|
WHEN s.status = 'cancelled' THEN 'cancelled'
|
|
WHEN h.status = 'failed' OR s.status = 'failed' OR h.projection_status = 'failed' THEN 'failed'
|
|
WHEN s.status = 'running' OR (h.status IN ('completed', 'no_change') AND h.projection_status = 'running') THEN 'running'
|
|
WHEN s.status = 'paused' THEN 'paused'
|
|
WHEN s.status = 'completed' THEN 'completed'
|
|
ELSE h.status END,
|
|
COALESCE(s.scope_json, h.activity_scope_json), COALESCE(h.started_at, h.created_at),
|
|
CASE WHEN s.status = 'cancelled' THEN s.completed_at
|
|
WHEN s.status = 'running' OR h.status = 'claimed' OR h.projection_status = 'running' THEN NULL
|
|
ELSE COALESCE(s.completed_at, h.projection_completed_at, h.completed_at) END,
|
|
s.time_range_json, COALESCE(s.error, h.projection_error, h.error), COALESCE(r.summary, e.summary), r.id,
|
|
h.status, s.status, s.id, h.suggestion_status, h.suggestion_error,
|
|
(SELECT COUNT(*) FROM supervision_suggestions g WHERE g.heartbeat_run_id = h.id)
|
|
FROM heartbeat_runs h LEFT JOIN supervision_runs s ON s.heartbeat_run_id = h.id
|
|
LEFT JOIN supervision_results r ON r.run_id = s.id LEFT JOIN heartbeat_entries e ON e.id = h.entry_id
|
|
WHERE ? IS NULL OR h.config_id = ?
|
|
ORDER BY startedAt DESC, id DESC LIMIT ? OFFSET ?
|
|
`).all(configId ?? null, configId ?? null, configId ?? null, Math.max(1, Math.min(100, Math.trunc(limit))), Math.max(0, Math.trunc(offset)))
|
|
return rows.map((row) => ({ ...row,
|
|
reviewProgress: row.supervisionRunId && this.requireDatabase().prepare('SELECT run_id FROM supervision_review_runs WHERE run_id = ?').get(String(row.supervisionRunId))
|
|
? this.supervisionReviewStore().progress(String(row.supervisionRunId)) : undefined,
|
|
scope: row.scope ? JSON.parse(String(row.scope)) : null,
|
|
timeRange: row.timeRange ? JSON.parse(String(row.timeRange)) : null
|
|
})) as SupervisionActivity[]
|
|
}
|
|
|
|
saveSupervisionResult(result: StoredSupervisionResult): void {
|
|
const database = this.requireDatabase()
|
|
const timestamp = new Date().toISOString()
|
|
database.exec('BEGIN IMMEDIATE')
|
|
try {
|
|
let story = database.prepare(
|
|
'SELECT id FROM story_lines WHERE scope_json = ? LIMIT 1'
|
|
).get(JSON.stringify(result.request.scope)) as { id: string } | undefined
|
|
if (!story) {
|
|
story = { id: randomUUID() }
|
|
database.prepare(
|
|
'INSERT INTO story_lines (id, scope_json, title, created_at, updated_at) VALUES (?, ?, ?, ?, ?)'
|
|
).run(story.id, JSON.stringify(result.request.scope), '工作故事线', timestamp, timestamp)
|
|
}
|
|
const runId = result.runId ?? randomUUID()
|
|
const resultId = randomUUID()
|
|
if (result.runId) {
|
|
const updated = database.prepare("UPDATE supervision_runs SET status = 'completed', completed_at = ? WHERE id = ? AND status = 'running'")
|
|
.run(timestamp, runId)
|
|
if (updated.changes !== 1) throw new Error('Supervision run is not running')
|
|
} else database.prepare(`INSERT INTO supervision_runs
|
|
(id, trigger, scope_json, time_range_json, status, started_at, completed_at, created_at)
|
|
VALUES (?, ?, ?, ?, 'completed', ?, ?, ?)`).run(
|
|
runId, result.request.trigger, JSON.stringify(result.request.scope),
|
|
JSON.stringify(result.request.timeRange), timestamp, timestamp, timestamp
|
|
)
|
|
database.prepare(`INSERT INTO supervision_results
|
|
(id, run_id, summary, change_digest, open_items_json, coverage_json, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)`).run(
|
|
resultId, runId, result.output.summary, result.output.changeDigest,
|
|
JSON.stringify(result.output.openItems), JSON.stringify(result.coverage ?? { evidence: result.evidence.length }), timestamp
|
|
)
|
|
const saveFacts = (result: StoredSupervisionResult) => {
|
|
// Model IDs are local to one batch, never persistent cross-run identities.
|
|
const sourceIds = new Map(result.evidence.map((source) => [source.id, randomUUID()]))
|
|
const candidates = new Map((result.candidates ?? []).map(entity => [entity.id, entity.storageId ?? entity.id]))
|
|
// Candidates come from the shared timeline, so a reused entity may first have appeared under another scope.
|
|
const currentCandidate = database.prepare("SELECT id FROM supervision_entities WHERE id = ? AND confirmation_state != 'revoked'")
|
|
const entityIds = new Map<string, string>()
|
|
for (const entity of result.output.entities) {
|
|
const storedId = entity.persistedId ? candidates.get(entity.persistedId) : undefined
|
|
if (entityIds.has(entity.id) || (entity.persistedId &&
|
|
(!storedId || !currentCandidate.get(storedId) || [...entityIds.values()].includes(storedId)))) {
|
|
throw new Error('监督者实体身份不属于本次范围或重复')
|
|
}
|
|
entityIds.set(entity.id, storedId ?? randomUUID())
|
|
}
|
|
const persistentId = (ids: Map<string, string>, id: string): string => {
|
|
const mapped = ids.get(id)
|
|
if (!mapped) throw new Error(`Unknown supervision reference: ${id}`)
|
|
return mapped
|
|
}
|
|
const sourceReferences = (ids: string[]): string =>
|
|
JSON.stringify(ids.map((id) => persistentId(sourceIds, id)))
|
|
const insertSource = database.prepare(`INSERT INTO supervision_sources
|
|
(id, result_id, source_type, source_id, title, occurred_at, content, locator_json, availability, source_key, source_revision)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'available', ?, ?)`)
|
|
for (const source of result.evidence) {
|
|
const key = typeof source.locator?.source === 'string' ? source.locator.source : null
|
|
const revision = typeof source.locator?.revision === 'string' ? source.locator.revision : null
|
|
insertSource.run(persistentId(sourceIds, source.id), resultId, source.sourceType, source.sourceId, source.title, source.occurredAt, source.content, source.locator ? JSON.stringify(source.locator) : null, key, revision)
|
|
}
|
|
// A reused entity keeps the story line it first appeared in; the graph reads it through its events.
|
|
const insertEntity = database.prepare(`INSERT INTO supervision_entities
|
|
(id, story_line_id, canonical_label, description, confirmation_state, updated_at, source_reference_ids_json)
|
|
VALUES (?, ?, ?, ?, 'automatic', ?, ?)
|
|
ON CONFLICT(id) DO UPDATE SET canonical_label = excluded.canonical_label,
|
|
description = excluded.description, updated_at = excluded.updated_at,
|
|
source_reference_ids_json = excluded.source_reference_ids_json
|
|
WHERE supervision_entities.confirmation_state = 'automatic'`)
|
|
for (const entity of result.output.entities) {
|
|
insertEntity.run(persistentId(entityIds, entity.id), story.id, entity.label, entity.description, timestamp, sourceReferences(entity.sourceReferenceIds))
|
|
}
|
|
const insertChange = database.prepare(`INSERT INTO supervision_entity_changes
|
|
(id, entity_id, result_id, change_type, description, confirmation_state, source_reference_ids_json)
|
|
VALUES (?, ?, ?, ?, ?, 'automatic', ?)`)
|
|
for (const change of result.output.entityChanges) insertChange.run(
|
|
randomUUID(), persistentId(entityIds, change.entityId), resultId, change.changeType, change.description, sourceReferences(change.sourceReferenceIds)
|
|
)
|
|
const insertEvent = database.prepare(`INSERT INTO supervision_events
|
|
(id, story_line_id, result_id, occurred_at, title, description, event_type)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)`)
|
|
for (const event of result.output.events) {
|
|
const eventId = randomUUID()
|
|
insertEvent.run(eventId, story.id, resultId, event.occurredAt, event.title, event.description, event.eventType)
|
|
for (const sourceId of event.sourceReferenceIds) database.prepare(
|
|
'INSERT INTO supervision_event_sources (event_id, source_id) VALUES (?, ?)'
|
|
).run(eventId, persistentId(sourceIds, sourceId))
|
|
for (const entityId of event.entityIds) database.prepare(
|
|
'INSERT OR IGNORE INTO supervision_event_entities (event_id, entity_id, change_type) VALUES (?, ?, ?)'
|
|
).run(eventId, persistentId(entityIds, entityId), 'associated')
|
|
}
|
|
const insertRelation = database.prepare(`INSERT INTO supervision_relations
|
|
(id, story_line_id, from_entity_id, to_entity_id, relation_type, reason, confirmation_state, source_reference_ids_json)
|
|
VALUES (?, ?, ?, ?, ?, ?, 'automatic', ?)
|
|
ON CONFLICT(story_line_id, from_entity_id, to_entity_id, relation_type)
|
|
DO UPDATE SET reason = excluded.reason, source_reference_ids_json = excluded.source_reference_ids_json
|
|
WHERE supervision_relations.confirmation_state = 'automatic'`)
|
|
for (const relation of result.output.relations) insertRelation.run(
|
|
randomUUID(), story.id, persistentId(entityIds, relation.fromEntityId), persistentId(entityIds, relation.toEntityId), relation.relationType, relation.reason, sourceReferences(relation.sourceReferenceIds)
|
|
)
|
|
const entities = result.output.entities.map((entity) => ({
|
|
...database.prepare('SELECT * FROM supervision_entities WHERE id = ?').get(persistentId(entityIds, entity.id)),
|
|
source_reference_ids_json: sourceReferences(entity.sourceReferenceIds)
|
|
}))
|
|
const relations = result.output.relations.map((relation) => ({ ...database.prepare(`SELECT * FROM supervision_relations
|
|
WHERE story_line_id = ? AND from_entity_id = ? AND to_entity_id = ? AND relation_type = ?`).get(
|
|
story.id, persistentId(entityIds, relation.fromEntityId), persistentId(entityIds, relation.toEntityId), relation.relationType),
|
|
source_reference_ids_json: sourceReferences(relation.sourceReferenceIds)
|
|
}))
|
|
database.prepare(`UPDATE supervision_results SET graph_snapshot_json = json_object(
|
|
'entities', json((SELECT json_group_array(json(value)) FROM (
|
|
SELECT value FROM json_each(supervision_results.graph_snapshot_json, '$.entities')
|
|
UNION ALL SELECT value FROM json_each(?)
|
|
))),
|
|
'relations', json((SELECT json_group_array(json(value)) FROM (
|
|
SELECT value FROM json_each(supervision_results.graph_snapshot_json, '$.relations')
|
|
UNION ALL SELECT value FROM json_each(?)
|
|
)))) WHERE id = ?`).run(JSON.stringify(entities), JSON.stringify(relations), resultId)
|
|
}
|
|
if (result.coverage && result.runId) {
|
|
for (let offset = 0;; offset += 10) {
|
|
const batches = this.supervisionReviewStore().batches(result.runId, 10, offset)
|
|
if (!batches.length) break
|
|
for (const batch of batches) saveFacts({ ...result, evidence: batch.evidence, output: batch.output })
|
|
}
|
|
// Explicit persisted identities may appear in several leaves. Keep one graph identity;
|
|
// each leaf's full description and references remain independently readable.
|
|
database.prepare(`UPDATE supervision_results SET graph_snapshot_json = json_object(
|
|
'entities', json((SELECT json_group_array(json(value)) FROM (
|
|
SELECT value FROM json_each(supervision_results.graph_snapshot_json, '$.entities') GROUP BY json_extract(value, '$.id')))),
|
|
'relations', json((SELECT json_group_array(json(value)) FROM (
|
|
SELECT value FROM json_each(supervision_results.graph_snapshot_json, '$.relations') GROUP BY json_extract(value, '$.id')))))
|
|
WHERE id = ?`).run(resultId)
|
|
this.supervisionReviewStore().commitCheckpoints(result.runId, result.request)
|
|
} else saveFacts(result)
|
|
placeTimelineEvents(database, resultId)
|
|
supersedeReextractedEvents(database, resultId)
|
|
if (result.batch) this.saveReviewCheckpoints(result.batch)
|
|
database.exec('COMMIT')
|
|
} catch (error) {
|
|
database.exec('ROLLBACK')
|
|
throw error
|
|
}
|
|
}
|
|
|
|
readStoryGraph(name: import('../../shared/story-graph-tools').StoryGraphToolName, input: unknown, projectId?: string, signal?: AbortSignal): Record<string, unknown> {
|
|
return readStoryGraph(this.requireDatabase(), name, input, projectId, signal)
|
|
}
|
|
|
|
/** Story graph read on the readonly worker; falls back to the synchronous read. */
|
|
readStoryGraphAsync(name: import('../../shared/story-graph-tools').StoryGraphToolName, input: unknown, projectId?: string, signal?: AbortSignal): Promise<Record<string, unknown>> {
|
|
this.requireDatabase()
|
|
return readWithFallback(this.readonlyQueryReader(), 'readStoryGraph', [name, input, projectId], signal,
|
|
() => this.readStoryGraph(name, input, projectId, signal))
|
|
}
|
|
|
|
getSupervisionResult(id: string): { id: string; summary: string } | undefined {
|
|
return this.requireDatabase().prepare('SELECT id, summary FROM supervision_results WHERE id = ?').get(id) as { id: string; summary: string } | undefined
|
|
}
|
|
|
|
listSupervisionResults(limit = 20, target?: SupervisionTarget, resultId?: string) {
|
|
const owner = target?.type === 'conversation' ? this.getConversation(target.conversationId)
|
|
: target?.type === 'task' ? this.getTask(target.taskId) : undefined
|
|
const sourceType = target?.type ?? null
|
|
const sourceId = target?.type === 'conversation' ? target.conversationId : target?.taskId ?? null
|
|
return this.requireDatabase().prepare(`SELECT id, run_id AS runId, summary,
|
|
(SELECT sl.id FROM story_lines sl JOIN supervision_runs sr ON sr.scope_json = sl.scope_json WHERE sr.id = run_id) AS storyLineId,
|
|
change_digest AS changeDigest, open_items_json AS openItems, coverage_json AS coverage,
|
|
(SELECT id FROM supervision_sources WHERE result_id = supervision_results.id
|
|
AND (? IS NULL OR (source_type = ? AND source_id = ?)) ORDER BY occurred_at, source_type, id LIMIT 1) AS sourceId,
|
|
(SELECT scope_json FROM supervision_runs WHERE id = run_id) AS scope,
|
|
(SELECT time_range_json FROM supervision_runs WHERE id = run_id) AS timeRange,
|
|
created_at AS createdAt FROM supervision_results
|
|
WHERE (? IS NULL OR id = ?) AND (? IS NULL OR (EXISTS (SELECT 1 FROM supervision_sources s WHERE s.result_id = supervision_results.id AND s.source_type = ? AND s.source_id = ?)
|
|
AND EXISTS (SELECT 1 FROM supervision_runs sr WHERE sr.id = run_id AND
|
|
(json_extract(sr.scope_json, '$.kind') = 'global' OR EXISTS
|
|
(SELECT 1 FROM json_each(sr.scope_json, '$.projectIds') WHERE value = ?)))))
|
|
ORDER BY created_at DESC, rowid DESC LIMIT ?`).all(sourceType, sourceType, sourceId, resultId ?? null, resultId ?? null, sourceType, sourceType, sourceId, owner?.projectId ?? null, limit).map((row) => {
|
|
const result = row as Record<string, unknown>
|
|
return supervisionResultViewSchema.parse({ ...result, coverage: JSON.parse(String(result.coverage)), scope: JSON.parse(String(result.scope)), timeRange: JSON.parse(String(result.timeRange)), openItems: JSON.parse(String(result.openItems)) })
|
|
})
|
|
}
|
|
|
|
getSupervisionGraph(input: SupervisionGraphRequest = {}): Record<string, unknown> {
|
|
const database = this.requireDatabase()
|
|
let { storyLineId } = input
|
|
if (input.resultId) {
|
|
const result = database.prepare(`SELECT sl.id FROM supervision_results r JOIN supervision_runs sr ON sr.id = r.run_id
|
|
JOIN story_lines sl ON sl.scope_json = sr.scope_json WHERE r.id = ?`).get(input.resultId) as { id: string } | undefined
|
|
if (!result || (storyLineId && storyLineId !== result.id)) throw new Error('监督结果与故事线不匹配')
|
|
storyLineId = result.id
|
|
}
|
|
const story = storyLineId
|
|
? database.prepare('SELECT * FROM story_lines WHERE id = ?').get(storyLineId)
|
|
: database.prepare(`SELECT * FROM story_lines ORDER BY COALESCE(
|
|
(SELECT MAX(r.created_at) FROM supervision_results r
|
|
JOIN supervision_runs sr ON sr.id = r.run_id WHERE sr.scope_json = story_lines.scope_json), updated_at) DESC LIMIT 1`).get()
|
|
if (!story) return { storyLine: null, events: [], entities: [], relations: [], sources: [], eventEntities: [], eventSources: [] }
|
|
const id = (story as { id: string }).id
|
|
if (input.resultId) {
|
|
const row = database.prepare('SELECT graph_snapshot_json FROM supervision_results WHERE id = ?').get(input.resultId) as { graph_snapshot_json: string }
|
|
const snapshot = JSON.parse(row.graph_snapshot_json) as { entities: unknown[]; relations: Array<{ confirmation_state: string }> }
|
|
return {
|
|
storyLine: story, ...snapshot, relations: snapshot.relations.filter((relation) => relation.confirmation_state !== 'revoked'),
|
|
events: database.prepare('SELECT * FROM supervision_events WHERE result_id = ? ORDER BY occurred_at').all(input.resultId),
|
|
sources: database.prepare('SELECT * FROM supervision_sources WHERE result_id = ?').all(input.resultId),
|
|
eventEntities: database.prepare(`SELECT ee.event_id, ee.entity_id FROM supervision_event_entities ee JOIN supervision_events e ON e.id = ee.event_id WHERE e.result_id = ?`).all(input.resultId),
|
|
eventSources: database.prepare(`SELECT es.event_id, es.source_id FROM supervision_event_sources es JOIN supervision_events e ON e.id = es.event_id WHERE e.result_id = ?`).all(input.resultId)
|
|
}
|
|
}
|
|
// The story line is a scope over the one shared timeline: current events of its projects,
|
|
// whichever review extracted them, with their entities, relations and sources.
|
|
const scope = JSON.parse(String((story as { scope_json: string }).scope_json)) as SupervisionRunRequest['scope']
|
|
const projects = scope.kind === 'projects' ? JSON.stringify(scope.projectIds) : null
|
|
database.exec('DROP TABLE IF EXISTS temp.story_events')
|
|
// Events whose sources carry no project stay with the story line that extracted them.
|
|
database.prepare(`CREATE TEMP TABLE story_events AS SELECT id FROM supervision_events
|
|
WHERE superseded_by IS NULL AND (? IS NULL OR project_id IN (SELECT value FROM json_each(?))
|
|
OR (COALESCE(project_id, '') = '' AND story_line_id = ?))`).run(projects, projects, id)
|
|
try {
|
|
return {
|
|
storyLine: story,
|
|
events: database.prepare('SELECT * FROM supervision_events WHERE id IN (SELECT id FROM temp.story_events) ORDER BY COALESCE(started_at, occurred_at)').all(),
|
|
entities: database.prepare(`SELECT * FROM supervision_entities WHERE id IN (SELECT ee.entity_id FROM supervision_event_entities ee
|
|
WHERE ee.event_id IN (SELECT id FROM temp.story_events)) OR (story_line_id = ? AND NOT EXISTS (SELECT 1 FROM supervision_event_entities ee WHERE ee.entity_id = supervision_entities.id))
|
|
ORDER BY canonical_label`).all(id),
|
|
relations: database.prepare(`SELECT r.* FROM supervision_relations r WHERE r.confirmation_state != 'revoked' AND (r.story_line_id = ? OR (
|
|
r.from_entity_id IN (SELECT entity_id FROM supervision_event_entities WHERE event_id IN (SELECT id FROM temp.story_events))
|
|
AND r.to_entity_id IN (SELECT entity_id FROM supervision_event_entities WHERE event_id IN (SELECT id FROM temp.story_events))))
|
|
GROUP BY r.from_entity_id, r.to_entity_id, r.relation_type`).all(id),
|
|
eventEntities: database.prepare('SELECT event_id, entity_id FROM supervision_event_entities WHERE event_id IN (SELECT id FROM temp.story_events)').all(),
|
|
eventSources: database.prepare('SELECT event_id, source_id FROM supervision_event_sources WHERE event_id IN (SELECT id FROM temp.story_events)').all(),
|
|
sources: database.prepare(`SELECT * FROM supervision_sources WHERE id IN (SELECT source_id FROM supervision_event_sources
|
|
WHERE event_id IN (SELECT id FROM temp.story_events)) OR result_id IN (SELECT r.id FROM supervision_results r
|
|
JOIN supervision_runs sr ON sr.id = r.run_id WHERE sr.scope_json = ?)`).all(String((story as { scope_json: string }).scope_json)),
|
|
attention: this.storyAttention(scope)
|
|
}
|
|
} finally { database.exec('DROP TABLE IF EXISTS temp.story_events') }
|
|
}
|
|
|
|
/** Message turns and text per hour across the span of the scope's current events. */
|
|
private storyAttention(scope: SupervisionRunRequest['scope']) {
|
|
const database = this.requireDatabase()
|
|
const projects = scope.kind === 'projects' ? scope.projectIds : undefined
|
|
const span = database.prepare(`SELECT MIN(COALESCE(started_at, occurred_at)) AS a, MAX(COALESCE(ended_at, occurred_at)) AS b
|
|
FROM supervision_events WHERE superseded_by IS NULL AND (? IS NULL OR project_id IN (SELECT value FROM json_each(?)))`)
|
|
.get(projects ? 1 : null, JSON.stringify(projects ?? [])) as { a: string | null; b: string | null }
|
|
return span.a && span.b ? supervisionAttention(database, projects, span.a, span.b) : []
|
|
}
|
|
|
|
getSupervisionSource(sourceId: string): Record<string, unknown> | undefined {
|
|
return this.requireDatabase().prepare(`SELECT id, result_id AS resultId,
|
|
source_type AS sourceType, source_id AS sourceId, title, occurred_at AS occurredAt,
|
|
content, locator_json AS locatorJson, availability FROM supervision_sources WHERE id = ?`).get(sourceId) as
|
|
Record<string, unknown> | undefined
|
|
}
|
|
|
|
applySupervisionEntityAction(input: {
|
|
resultId?: string
|
|
entityId: string
|
|
action: 'confirm' | 'revise' | 'revoke'
|
|
label?: string
|
|
description?: string
|
|
}): void {
|
|
const database = this.requireDatabase()
|
|
const entity = database.prepare('SELECT id FROM supervision_entities WHERE id = ?').get(input.entityId)
|
|
if (!entity) throw new Error('监督者实体不存在')
|
|
const state = input.action === 'confirm' ? 'confirmed' : input.action === 'revoke' ? 'revoked' : 'revised'
|
|
this.updateSupervisionSnapshot(input.resultId, 'entities', input.entityId, (snapshot) => {
|
|
database.prepare(`UPDATE supervision_entities SET
|
|
canonical_label = COALESCE(?, canonical_label),
|
|
description = COALESCE(?, description), confirmation_state = ?, updated_at = ?
|
|
WHERE id = ?`).run(input.label ?? snapshot?.canonical_label ?? null, input.description ?? snapshot?.description ?? null, state, new Date().toISOString(), input.entityId)
|
|
return database.prepare('SELECT * FROM supervision_entities WHERE id = ?').get(input.entityId)!
|
|
})
|
|
}
|
|
|
|
applySupervisionRelationAction(input: {
|
|
resultId?: string
|
|
relationId: string
|
|
action: 'confirm' | 'revoke'
|
|
}): void {
|
|
const database = this.requireDatabase()
|
|
const relation = database.prepare('SELECT id FROM supervision_relations WHERE id = ?').get(input.relationId)
|
|
if (!relation) throw new Error('监督者关系不存在')
|
|
this.updateSupervisionSnapshot(input.resultId, 'relations', input.relationId, (snapshot) => {
|
|
if (input.action === 'revoke') {
|
|
database.prepare("UPDATE supervision_relations SET confirmation_state = 'revoked' WHERE id = ?").run(input.relationId)
|
|
} else {
|
|
database.prepare(`UPDATE supervision_relations SET confirmation_state = 'confirmed', reason = COALESCE(?, reason)
|
|
WHERE id = ?`).run(snapshot?.reason ?? null, input.relationId)
|
|
}
|
|
return database.prepare('SELECT * FROM supervision_relations WHERE id = ?').get(input.relationId)!
|
|
})
|
|
}
|
|
|
|
private updateSupervisionSnapshot(resultId: string | undefined, kind: 'entities' | 'relations', id: string,
|
|
update: (snapshot?: Record<string, import('node:sqlite').SQLInputValue>) => Record<string, unknown>): void {
|
|
const database = this.requireDatabase()
|
|
database.exec('BEGIN IMMEDIATE')
|
|
try {
|
|
const row = resultId ? database.prepare('SELECT graph_snapshot_json FROM supervision_results WHERE id = ?').get(resultId) : undefined
|
|
const snapshot = row ? JSON.parse(String(row.graph_snapshot_json)) as Record<'entities' | 'relations', Array<Record<string, import('node:sqlite').SQLInputValue>>> : undefined
|
|
const selected = snapshot?.[kind].find((item) => item.id === id)
|
|
if (resultId && !selected) throw new Error('监督对象与结果不匹配')
|
|
const updated = update(selected)
|
|
if (snapshot && resultId) {
|
|
snapshot[kind] = snapshot[kind].map((item) => item.id === id ? { ...updated, source_reference_ids_json: item.source_reference_ids_json } as typeof item : item)
|
|
database.prepare('UPDATE supervision_results SET graph_snapshot_json = ? WHERE id = ?').run(JSON.stringify(snapshot), resultId)
|
|
}
|
|
database.exec('COMMIT')
|
|
} catch (error) {
|
|
database.exec('ROLLBACK')
|
|
throw error
|
|
}
|
|
}
|
|
|
|
completeHeartbeatRun(
|
|
claim: ClaimedHeartbeatRun,
|
|
output: HeartbeatSummaryOutput,
|
|
now = new Date(),
|
|
batch?: ReviewBatch
|
|
): AssistantHeartbeatRun {
|
|
const database = this.requireDatabase()
|
|
const timestamp = now.toISOString()
|
|
database.exec('BEGIN IMMEDIATE')
|
|
try {
|
|
const active = database
|
|
.prepare(
|
|
`SELECT id FROM heartbeat_runs
|
|
WHERE id = ? AND status = 'claimed' AND lease_owner = ?
|
|
AND lease_expires_at > ?`
|
|
)
|
|
.get(
|
|
claim.run.id,
|
|
claim.leaseOwner,
|
|
timestamp
|
|
) as { id: string } | undefined
|
|
if (!active) {
|
|
throw new Error('Heartbeat lease is no longer active')
|
|
}
|
|
const artifactId = randomUUID()
|
|
const entryId = randomUUID()
|
|
const summaryContent = [
|
|
`# ${claim.config.name}`,
|
|
'',
|
|
output.summary,
|
|
...(output.highlights.length
|
|
? ['', '## Highlights', ...output.highlights.map((item) => `- ${item}`)]
|
|
: [])
|
|
].join('\n')
|
|
database
|
|
.prepare(
|
|
`INSERT INTO artifacts
|
|
(id, project_id, task_id, run_id, kind, title, mime_type,
|
|
storage_kind, storage_path, inline_content, checksum,
|
|
byte_size, preview_json, created_at, updated_at)
|
|
VALUES (?, ?, NULL, NULL, 'markdown', ?, 'text/markdown',
|
|
'inline', NULL, ?, NULL, ?, ?, ?, ?)`
|
|
)
|
|
.run(
|
|
artifactId,
|
|
null,
|
|
`Heartbeat: ${claim.config.name}`.slice(0, 240),
|
|
summaryContent,
|
|
Buffer.byteLength(summaryContent),
|
|
JSON.stringify({
|
|
heartbeat: {
|
|
configId: claim.config.id,
|
|
scope: claim.config.scope
|
|
}
|
|
}),
|
|
timestamp,
|
|
timestamp
|
|
)
|
|
const proposedMemoryIds: string[] = []
|
|
const insertMemory = database.prepare(
|
|
`INSERT INTO memory_items
|
|
(id, scope, scope_id, type, content, source_conversation_id,
|
|
source_message_id, confidence, salience, status, expires_at,
|
|
created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, NULL, NULL, ?, ?, 'proposed',
|
|
NULL, ?, ?)`
|
|
)
|
|
const findExistingMemory = database.prepare(
|
|
`SELECT id FROM memory_items
|
|
WHERE scope = ?
|
|
AND ((? IS NULL AND scope_id IS NULL) OR scope_id = ?)
|
|
AND content = ? COLLATE NOCASE
|
|
AND status IN ('proposed', 'confirmed')
|
|
LIMIT 1`
|
|
)
|
|
for (const memory of output.proposedMemories) {
|
|
const scopeId =
|
|
memory.scope === 'project' ? memory.projectId : null
|
|
const existing = findExistingMemory.get(
|
|
memory.scope,
|
|
scopeId,
|
|
scopeId,
|
|
memory.content
|
|
) as { id: string } | undefined
|
|
if (existing) {
|
|
continue
|
|
}
|
|
const memoryId = randomUUID()
|
|
insertMemory.run(
|
|
memoryId,
|
|
memory.scope,
|
|
scopeId,
|
|
memory.type,
|
|
memory.content,
|
|
memory.confidence,
|
|
memory.salience,
|
|
timestamp,
|
|
timestamp
|
|
)
|
|
proposedMemoryIds.push(memoryId)
|
|
}
|
|
const followUpTaskIds: string[] = []
|
|
const insertTask = database.prepare(
|
|
`INSERT INTO tasks
|
|
(id, project_id, conversation_id, schedule_id, title,
|
|
instructions, origin, status, priority,
|
|
progress, created_at, started_at, completed_at, error)
|
|
VALUES (?, ?, NULL, NULL, ?, ?, 'assistant', 'paused', 0,
|
|
NULL, ?, NULL, NULL, NULL)`
|
|
)
|
|
for (const task of output.followUpTasks) {
|
|
const taskId = randomUUID()
|
|
insertTask.run(
|
|
taskId,
|
|
task.projectId ?? null,
|
|
task.title,
|
|
task.instructions,
|
|
timestamp
|
|
)
|
|
followUpTaskIds.push(taskId)
|
|
}
|
|
database
|
|
.prepare(
|
|
`INSERT INTO heartbeat_entries
|
|
(id, config_id, run_id, scheduled_for, summary,
|
|
highlights_json, artifact_id, proposed_memory_ids_json,
|
|
follow_up_task_ids_json, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
)
|
|
.run(
|
|
entryId,
|
|
claim.config.id,
|
|
claim.run.id,
|
|
claim.run.scheduledFor,
|
|
output.summary,
|
|
JSON.stringify(output.highlights),
|
|
artifactId,
|
|
JSON.stringify(proposedMemoryIds),
|
|
JSON.stringify(followUpTaskIds),
|
|
timestamp
|
|
)
|
|
database
|
|
.prepare(
|
|
`UPDATE heartbeat_runs
|
|
SET status = 'completed', completed_at = ?, error = NULL,
|
|
entry_id = ?, lease_owner = NULL,
|
|
lease_expires_at = NULL, updated_at = ?
|
|
WHERE id = ?`
|
|
)
|
|
.run(timestamp, entryId, timestamp, claim.run.id)
|
|
database
|
|
.prepare(
|
|
`UPDATE heartbeat_configs
|
|
SET last_status = 'completed', updated_at = ?
|
|
WHERE id = ?`
|
|
)
|
|
.run(timestamp, claim.config.id)
|
|
if (batch) this.saveReviewCheckpoints(batch)
|
|
database.exec('COMMIT')
|
|
} catch (error) {
|
|
database.exec('ROLLBACK')
|
|
throw error
|
|
}
|
|
this.pruneHeartbeatHistory(claim.config.id, now)
|
|
return this.getHeartbeatRun(claim.run.id)
|
|
}
|
|
|
|
failHeartbeatRun(
|
|
claim: ClaimedHeartbeatRun,
|
|
error: string,
|
|
now = new Date()
|
|
): AssistantHeartbeatRun {
|
|
const database = this.requireDatabase()
|
|
const timestamp = now.toISOString()
|
|
const retryDelays = [60_000, 5 * 60_000]
|
|
const nextAttemptAt =
|
|
claim.run.attemptCount < 3
|
|
? new Date(
|
|
now.getTime() +
|
|
retryDelays[
|
|
Math.min(
|
|
claim.run.attemptCount - 1,
|
|
retryDelays.length - 1
|
|
)
|
|
]!
|
|
).toISOString()
|
|
: null
|
|
database.exec('BEGIN IMMEDIATE')
|
|
try {
|
|
const result = database
|
|
.prepare(
|
|
`UPDATE heartbeat_runs
|
|
SET status = 'failed', next_attempt_at = ?,
|
|
completed_at = ?, error = ?, lease_owner = NULL,
|
|
lease_expires_at = NULL, updated_at = ?
|
|
WHERE id = ? AND config_id = ?
|
|
AND status = 'claimed' AND lease_owner = ?
|
|
AND lease_expires_at > ?`
|
|
)
|
|
.run(
|
|
nextAttemptAt,
|
|
timestamp,
|
|
error.slice(0, 2_000),
|
|
timestamp,
|
|
claim.run.id,
|
|
claim.config.id,
|
|
claim.leaseOwner,
|
|
timestamp
|
|
)
|
|
if (result.changes !== 1) {
|
|
throw new Error('Heartbeat lease is no longer active')
|
|
}
|
|
const configResult = database
|
|
.prepare(
|
|
`UPDATE heartbeat_configs
|
|
SET last_status = 'failed', updated_at = ?
|
|
WHERE id = ?`
|
|
)
|
|
.run(timestamp, claim.config.id)
|
|
if (configResult.changes !== 1) {
|
|
throw new Error('Heartbeat config no longer exists')
|
|
}
|
|
database.exec('COMMIT')
|
|
} catch (transactionError) {
|
|
database.exec('ROLLBACK')
|
|
throw transactionError
|
|
}
|
|
return this.getHeartbeatRun(claim.run.id)
|
|
}
|
|
|
|
pruneHeartbeatHistory(configId: string, now = new Date()): void {
|
|
const database = this.requireDatabase()
|
|
const config = this.getHeartbeatConfig(configId)
|
|
const cutoff = new Date(
|
|
now.getTime() - config.retentionDays * 24 * 60 * 60_000
|
|
).toISOString()
|
|
database.exec('BEGIN IMMEDIATE')
|
|
try {
|
|
const artifacts = database
|
|
.prepare(
|
|
`SELECT artifact_id FROM heartbeat_entries
|
|
WHERE config_id = ? AND created_at < ?
|
|
AND artifact_id IS NOT NULL`
|
|
)
|
|
.all(configId, cutoff) as Array<{ artifact_id: string }>
|
|
database
|
|
.prepare(
|
|
`DELETE FROM heartbeat_entries
|
|
WHERE config_id = ? AND created_at < ?`
|
|
)
|
|
.run(configId, cutoff)
|
|
database
|
|
.prepare(
|
|
`DELETE FROM heartbeat_runs
|
|
WHERE config_id = ? AND created_at < ?
|
|
AND status IN ('completed', 'failed', 'skipped', 'no_change')`
|
|
)
|
|
.run(configId, cutoff)
|
|
const deleteArtifact = database.prepare(
|
|
'DELETE FROM artifacts WHERE id = ?'
|
|
)
|
|
for (const artifact of artifacts) {
|
|
deleteArtifact.run(artifact.artifact_id)
|
|
}
|
|
database.exec('COMMIT')
|
|
} catch (error) {
|
|
database.exec('ROLLBACK')
|
|
throw error
|
|
}
|
|
}
|
|
|
|
listExperts(): AssistantExpert[] {
|
|
return (
|
|
this.requireDatabase()
|
|
.prepare(
|
|
`SELECT * FROM experts
|
|
WHERE enabled = 1
|
|
ORDER BY name`
|
|
)
|
|
.all() as ExpertRow[]
|
|
).map(toExpert)
|
|
}
|
|
|
|
createExpert(input: ExpertCreateInput): AssistantExpert {
|
|
const normalized = expertCreateSchema.parse(input)
|
|
const id = randomUUID()
|
|
const now = new Date().toISOString()
|
|
this.requireDatabase()
|
|
.prepare(
|
|
`INSERT INTO experts
|
|
(id, name, description, system_instructions,
|
|
capability_policy_json, model_policy_json, enabled,
|
|
created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?)`
|
|
)
|
|
.run(
|
|
id,
|
|
normalized.name,
|
|
normalized.description,
|
|
normalized.systemInstructions,
|
|
JSON.stringify({
|
|
routingKeywords: normalized.routingKeywords
|
|
}),
|
|
JSON.stringify({
|
|
modelProfileId: normalized.modelProfileId
|
|
}),
|
|
now,
|
|
now
|
|
)
|
|
return this.getExpert(id)
|
|
}
|
|
|
|
updateExpert(
|
|
expertId: string,
|
|
input: ExpertUpdateInput
|
|
): AssistantExpert {
|
|
const normalized = expertCreateSchema.parse(input)
|
|
const result = this.requireDatabase()
|
|
.prepare(
|
|
`UPDATE experts
|
|
SET name = ?, description = ?, system_instructions = ?,
|
|
capability_policy_json = ?,
|
|
model_policy_json = ?,
|
|
updated_at = ?
|
|
WHERE id = ? AND enabled = 1`
|
|
)
|
|
.run(
|
|
normalized.name,
|
|
normalized.description,
|
|
normalized.systemInstructions,
|
|
JSON.stringify({
|
|
routingKeywords: normalized.routingKeywords
|
|
}),
|
|
JSON.stringify({
|
|
modelProfileId: normalized.modelProfileId
|
|
}),
|
|
new Date().toISOString(),
|
|
expertId
|
|
)
|
|
if (result.changes === 0) {
|
|
throw new Error('专家不存在或已停用')
|
|
}
|
|
return this.getExpert(expertId)
|
|
}
|
|
|
|
removeExpert(expertId: string): void {
|
|
const result = this.requireDatabase()
|
|
.prepare(
|
|
`UPDATE experts
|
|
SET enabled = 0, updated_at = ?
|
|
WHERE id = ? AND enabled = 1`
|
|
)
|
|
.run(new Date().toISOString(), expertId)
|
|
if (result.changes === 0) {
|
|
throw new Error('专家不存在或已停用')
|
|
}
|
|
}
|
|
|
|
getExpert(expertId: string): AssistantExpert {
|
|
const row = this.requireDatabase()
|
|
.prepare('SELECT * FROM experts WHERE id = ? AND enabled = 1')
|
|
.get(expertId) as ExpertRow | undefined
|
|
if (!row) {
|
|
throw new Error('专家不存在或已停用')
|
|
}
|
|
return toExpert(row)
|
|
}
|
|
|
|
getProject(projectId: string): AssistantProject {
|
|
return readProject(this.requireDatabase(), projectId)
|
|
}
|
|
|
|
getTask(taskId: string): AssistantTask {
|
|
const row = this.requireDatabase()
|
|
.prepare(`${taskSelect} WHERE tasks.id = ?`)
|
|
.get(taskId) as TaskRow | undefined
|
|
if (!row) {
|
|
throw new Error('任务不存在')
|
|
}
|
|
return toTask(row)
|
|
}
|
|
|
|
private getMemory(memoryId: string): AssistantMemory {
|
|
const row = this.requireDatabase()
|
|
.prepare('SELECT * FROM memory_items WHERE id = ?')
|
|
.get(memoryId) as MemoryRow | undefined
|
|
if (!row) {
|
|
throw new Error('记忆不存在')
|
|
}
|
|
return toMemory(row)
|
|
}
|
|
|
|
private getSchedule(scheduleId: string): AssistantSchedule {
|
|
const row = this.requireDatabase()
|
|
.prepare(
|
|
`SELECT s.*, t.id AS task_id,
|
|
t.conversation_id AS conversation_id
|
|
FROM schedules s
|
|
INNER JOIN tasks t ON t.schedule_id = s.id
|
|
WHERE s.id = ?`
|
|
)
|
|
.get(scheduleId) as ScheduleWithTaskRow | undefined
|
|
if (!row) {
|
|
throw new Error('定时任务不存在')
|
|
}
|
|
return toSchedule(row)
|
|
}
|
|
|
|
private syncMagicNoteTodos(
|
|
database: DatabaseSync,
|
|
noteId: string,
|
|
entryId: string,
|
|
content: MagicNoteContent,
|
|
now: string,
|
|
previousContent?: MagicNoteContent
|
|
): void {
|
|
const note = database
|
|
.prepare('SELECT id FROM magic_notes WHERE id = ?')
|
|
.get(noteId) as { id: string } | undefined
|
|
if (!note) {
|
|
throw new Error('笔记不存在')
|
|
}
|
|
const items = magicNoteChecklistItems(content)
|
|
const existing = database
|
|
.prepare(
|
|
`SELECT id, project_id, note_id, source_index, title, completed, comments_json
|
|
FROM magic_todos
|
|
WHERE entry_id = ? AND source = 'note'`
|
|
)
|
|
.all(entryId) as Array<{
|
|
id: string
|
|
project_id: string | null
|
|
note_id: string | null
|
|
source_index: number
|
|
title: string
|
|
completed: number
|
|
comments_json: string
|
|
}>
|
|
const canvasChanged = previousContent !== undefined &&
|
|
(previousContent.version === 2 || content.version === 2) &&
|
|
JSON.stringify(previousContent) !== JSON.stringify(content)
|
|
const analysisTextChanged = canvasChanged && (
|
|
previousContent?.version !== 2 || content.version !== 2 ||
|
|
magicNoteCanvasAnalysisText(previousContent) !== magicNoteCanvasAnalysisText(content)
|
|
)
|
|
const unmatched = new Set(existing)
|
|
const byExactPosition = new Map(
|
|
existing.map((todo) => [
|
|
`${todo.source_index}\u0000${todo.title}`,
|
|
todo
|
|
])
|
|
)
|
|
const byPosition = new Map(
|
|
existing.map((todo) => [todo.source_index, todo])
|
|
)
|
|
const byTitle = new Map<string, typeof existing>()
|
|
const unmatchedTitleCounts = new Map<string, number>()
|
|
for (const todo of existing) {
|
|
const titleMatches = byTitle.get(todo.title) ?? []
|
|
titleMatches.push(todo)
|
|
byTitle.set(todo.title, titleMatches)
|
|
unmatchedTitleCounts.set(
|
|
todo.title,
|
|
(unmatchedTitleCounts.get(todo.title) ?? 0) + 1
|
|
)
|
|
}
|
|
const matches: Array<{
|
|
item: (typeof items)[number]
|
|
matched?: (typeof existing)[number]
|
|
}> = items.map((item) => ({ item }))
|
|
const assign = (
|
|
match: (typeof matches)[number],
|
|
todo: (typeof existing)[number] | undefined
|
|
): void => {
|
|
if (!todo || !unmatched.delete(todo)) {
|
|
return
|
|
}
|
|
match.matched = todo
|
|
unmatchedTitleCounts.set(
|
|
todo.title,
|
|
(unmatchedTitleCounts.get(todo.title) ?? 1) - 1
|
|
)
|
|
}
|
|
for (const match of matches) {
|
|
assign(
|
|
match,
|
|
byExactPosition.get(
|
|
`${match.item.sourceIndex}\u0000${match.item.title}`
|
|
)
|
|
)
|
|
}
|
|
for (const match of matches.filter((candidate) => !candidate.matched)) {
|
|
if (unmatchedTitleCounts.get(match.item.title) === 1) {
|
|
assign(
|
|
match,
|
|
byTitle
|
|
.get(match.item.title)
|
|
?.find((todo) => unmatched.has(todo))
|
|
)
|
|
}
|
|
}
|
|
for (const match of matches.filter((candidate) => !candidate.matched)) {
|
|
assign(match, byPosition.get(match.item.sourceIndex))
|
|
}
|
|
const deleteTodo = database.prepare(
|
|
'DELETE FROM magic_todos WHERE id = ?'
|
|
)
|
|
for (const todo of unmatched) {
|
|
deleteTodo.run(todo.id)
|
|
}
|
|
const parkSourceIndex = database.prepare(
|
|
`UPDATE magic_todos
|
|
SET source_index = -source_index - 1
|
|
WHERE id = ?`
|
|
)
|
|
for (const { item, matched } of matches) {
|
|
if (matched && matched.source_index !== item.sourceIndex) {
|
|
parkSourceIndex.run(matched.id)
|
|
}
|
|
}
|
|
const insertTodo = database.prepare(
|
|
`INSERT INTO magic_todos
|
|
(id, project_id, note_id, entry_id, source_index, source,
|
|
title, instructions, completed, comments_json, analyzed_at,
|
|
revision, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, 'note', ?, '', ?, '[]', NULL, 0, ?, ?)`
|
|
)
|
|
const updateTodo = database.prepare(
|
|
`UPDATE magic_todos
|
|
SET project_id = ?, note_id = ?, source_index = ?, title = ?,
|
|
completed = ?,
|
|
comments_json = CASE WHEN ? THEN '[]' ELSE comments_json END,
|
|
analyzed_at = CASE WHEN ? THEN NULL ELSE analyzed_at END,
|
|
revision = revision + 1, updated_at = ?
|
|
WHERE id = ?`
|
|
)
|
|
for (const { item, matched } of matches) {
|
|
if (!matched) {
|
|
insertTodo.run(
|
|
randomUUID(),
|
|
null,
|
|
noteId,
|
|
entryId,
|
|
item.sourceIndex,
|
|
item.title,
|
|
Number(item.completed),
|
|
now,
|
|
now
|
|
)
|
|
continue
|
|
}
|
|
const titleChanged = matched.title !== item.title
|
|
const comments = JSON.parse(matched.comments_json) as MagicNoteComment[]
|
|
const sourceChanged = canvasChanged && comments.length > 0 && (
|
|
analysisTextChanged || comments.some((comment) =>
|
|
comment.inputMode !== 'text-fallback' && comment.inputMode !== 'text')
|
|
)
|
|
const completionChanged =
|
|
Boolean(matched.completed) !== item.completed
|
|
const positionChanged =
|
|
matched.source_index !== item.sourceIndex
|
|
const scopeChanged =
|
|
matched.project_id !== null ||
|
|
matched.note_id !== noteId
|
|
if (
|
|
!titleChanged &&
|
|
!completionChanged &&
|
|
!positionChanged &&
|
|
!scopeChanged &&
|
|
!sourceChanged
|
|
) {
|
|
continue
|
|
}
|
|
updateTodo.run(
|
|
null,
|
|
noteId,
|
|
item.sourceIndex,
|
|
item.title,
|
|
Number(item.completed),
|
|
Number(titleChanged || sourceChanged),
|
|
Number(titleChanged || sourceChanged),
|
|
now,
|
|
matched.id
|
|
)
|
|
}
|
|
}
|
|
|
|
private assertMagicNoteEmbedBudget(
|
|
noteId: string,
|
|
content: MagicNoteContent,
|
|
excludedEntryId?: string
|
|
): void {
|
|
if (content.version === 2) return
|
|
const existing = this.requireDatabase()
|
|
.prepare(
|
|
`SELECT COALESCE(SUM(image_bytes), 0) AS image_bytes
|
|
FROM magic_note_entries
|
|
WHERE note_id = ? AND id <> ? AND json_extract(content_json, '$.version') = 1`
|
|
)
|
|
.get(noteId, excludedEntryId ?? '') as { image_bytes: number }
|
|
if (
|
|
existing.image_bytes + magicNoteEmbeddedBytes(content) >
|
|
MAGIC_NOTE_MAX_NOTE_EMBED_BYTES
|
|
) {
|
|
throw new Error('一篇笔记中的图片、视频和附件总大小不能超过 64 MB')
|
|
}
|
|
}
|
|
|
|
private migrate(
|
|
database: DatabaseSync,
|
|
onProgress?: (progress: AssistantStorageProgress) => void,
|
|
isCancelled?: () => boolean
|
|
): void {
|
|
const version = database
|
|
.prepare('PRAGMA user_version')
|
|
.get() as { user_version: number }
|
|
if (version.user_version > ASSISTANT_DATABASE_SCHEMA_VERSION) {
|
|
throw new Error(
|
|
`当前 GoodBuddy 不支持助理数据库版本 ${version.user_version},请升级应用后重试`
|
|
)
|
|
}
|
|
if (version.user_version === ASSISTANT_DATABASE_SCHEMA_VERSION) {
|
|
return
|
|
}
|
|
if (version.user_version < 1) {
|
|
database.exec(`
|
|
BEGIN IMMEDIATE;
|
|
CREATE TABLE projects (
|
|
id TEXT PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
description TEXT NOT NULL DEFAULT '',
|
|
root_path TEXT NOT NULL DEFAULT '',
|
|
default_work_mode TEXT NOT NULL
|
|
CHECK(default_work_mode IN ('ask', 'execute')),
|
|
runtime_selection_json TEXT,
|
|
built_in_default INTEGER NOT NULL DEFAULT 0
|
|
CHECK(built_in_default IN (0, 1)),
|
|
status TEXT NOT NULL CHECK(status IN ('active', 'archived')),
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
CREATE TABLE conversations (
|
|
id TEXT PRIMARY KEY,
|
|
project_id TEXT REFERENCES projects(id) ON DELETE SET NULL,
|
|
runtime_selection_json TEXT,
|
|
knowledge_retrieval_mode TEXT
|
|
CHECK(
|
|
knowledge_retrieval_mode IS NULL OR
|
|
knowledge_retrieval_mode IN ('auto', 'always')
|
|
),
|
|
context_state_json TEXT,
|
|
work_mode TEXT NOT NULL DEFAULT 'ask'
|
|
CHECK(work_mode IN ('ask', 'execute')),
|
|
title TEXT NOT NULL,
|
|
branch_source_conversation_id TEXT,
|
|
branch_source_title TEXT,
|
|
status TEXT NOT NULL DEFAULT 'active'
|
|
CHECK(status IN ('active', 'archived')),
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
CREATE TABLE messages (
|
|
id TEXT PRIMARY KEY,
|
|
conversation_id TEXT NOT NULL
|
|
REFERENCES conversations(id) ON DELETE CASCADE,
|
|
request_id TEXT,
|
|
role TEXT NOT NULL CHECK(role IN ('user', 'assistant', 'system', 'tool')),
|
|
content TEXT NOT NULL,
|
|
state TEXT NOT NULL CHECK(state IN ('streaming', 'complete', 'error')),
|
|
sequence INTEGER NOT NULL,
|
|
metadata_json TEXT NOT NULL DEFAULT '{}',
|
|
created_at TEXT NOT NULL,
|
|
UNIQUE(conversation_id, sequence)
|
|
);
|
|
CREATE INDEX conversations_branch_source_idx
|
|
ON conversations(branch_source_conversation_id)
|
|
WHERE branch_source_conversation_id IS NOT NULL;
|
|
CREATE TABLE tasks (
|
|
id TEXT PRIMARY KEY,
|
|
project_id TEXT REFERENCES projects(id) ON DELETE SET NULL,
|
|
conversation_id TEXT,
|
|
schedule_id TEXT,
|
|
title TEXT NOT NULL,
|
|
instructions TEXT NOT NULL,
|
|
origin TEXT NOT NULL
|
|
CHECK(origin IN ('user', 'assistant', 'schedule', 'delegation', 'subagent')),
|
|
status TEXT NOT NULL
|
|
CHECK(status IN ('queued', 'running', 'waiting_approval', 'paused',
|
|
'completed', 'failed', 'cancelled', 'interrupted')),
|
|
priority INTEGER NOT NULL DEFAULT 0,
|
|
work_mode TEXT NOT NULL DEFAULT 'execute'
|
|
CHECK(work_mode IN ('ask', 'execute')),
|
|
progress REAL,
|
|
created_at TEXT NOT NULL,
|
|
started_at TEXT,
|
|
completed_at TEXT,
|
|
error TEXT,
|
|
remote_recoverable INTEGER NOT NULL DEFAULT 0
|
|
CHECK(remote_recoverable IN (0, 1)),
|
|
current_user_message_id TEXT,
|
|
current_assistant_message_id TEXT
|
|
);
|
|
CREATE TABLE runs (
|
|
id TEXT PRIMARY KEY,
|
|
task_id TEXT REFERENCES tasks(id) ON DELETE CASCADE,
|
|
parent_run_id TEXT REFERENCES runs(id) ON DELETE CASCADE,
|
|
kind TEXT NOT NULL
|
|
CHECK(kind IN ('interactive', 'background', 'scheduled', 'delegated', 'subagent')),
|
|
status TEXT NOT NULL,
|
|
prompt TEXT NOT NULL,
|
|
execution_snapshot_json TEXT NOT NULL,
|
|
checkpoint_json TEXT,
|
|
created_at TEXT NOT NULL,
|
|
started_at TEXT,
|
|
completed_at TEXT,
|
|
error TEXT
|
|
);
|
|
CREATE TABLE task_events (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
|
run_id TEXT REFERENCES runs(id) ON DELETE CASCADE,
|
|
kind TEXT NOT NULL,
|
|
payload_json TEXT NOT NULL,
|
|
created_at TEXT NOT NULL
|
|
);
|
|
CREATE INDEX task_events_task_idx ON task_events(task_id, id);
|
|
CREATE TABLE artifacts (
|
|
id TEXT PRIMARY KEY,
|
|
project_id TEXT REFERENCES projects(id) ON DELETE SET NULL,
|
|
task_id TEXT REFERENCES tasks(id) ON DELETE SET NULL,
|
|
run_id TEXT REFERENCES runs(id) ON DELETE SET NULL,
|
|
kind TEXT NOT NULL,
|
|
title TEXT NOT NULL,
|
|
mime_type TEXT NOT NULL,
|
|
storage_kind TEXT NOT NULL
|
|
CHECK(storage_kind IN ('inline', 'managed_file', 'reference')),
|
|
storage_path TEXT,
|
|
inline_content TEXT,
|
|
checksum TEXT,
|
|
byte_size INTEGER NOT NULL,
|
|
preview_json TEXT NOT NULL DEFAULT '{}',
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
CREATE TABLE memory_items (
|
|
id TEXT PRIMARY KEY,
|
|
scope TEXT NOT NULL CHECK(scope IN ('global', 'project', 'conversation')),
|
|
scope_id TEXT,
|
|
type TEXT NOT NULL CHECK(type IN ('preference', 'fact', 'summary', 'procedure')),
|
|
content TEXT NOT NULL,
|
|
source_conversation_id TEXT,
|
|
source_message_id TEXT,
|
|
confidence REAL NOT NULL,
|
|
salience REAL NOT NULL,
|
|
status TEXT NOT NULL CHECK(status IN ('proposed', 'confirmed', 'rejected')),
|
|
expires_at TEXT,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
CREATE TABLE schedules (
|
|
id TEXT PRIMARY KEY,
|
|
project_id TEXT REFERENCES projects(id) ON DELETE CASCADE,
|
|
task_template_json TEXT NOT NULL,
|
|
timezone TEXT NOT NULL,
|
|
recurrence_json TEXT NOT NULL,
|
|
next_run_at TEXT NOT NULL,
|
|
missed_run_policy TEXT NOT NULL
|
|
CHECK(missed_run_policy IN ('skip', 'run_once', 'catch_up')),
|
|
enabled INTEGER NOT NULL,
|
|
last_run_at TEXT,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
CREATE TABLE schedule_runs (
|
|
id TEXT PRIMARY KEY,
|
|
schedule_id TEXT NOT NULL REFERENCES schedules(id) ON DELETE CASCADE,
|
|
scheduled_for TEXT NOT NULL,
|
|
task_id TEXT REFERENCES tasks(id) ON DELETE SET NULL,
|
|
status TEXT NOT NULL,
|
|
UNIQUE(schedule_id, scheduled_for)
|
|
);
|
|
CREATE TABLE notifications (
|
|
id TEXT PRIMARY KEY,
|
|
task_id TEXT,
|
|
schedule_id TEXT,
|
|
title TEXT NOT NULL,
|
|
body TEXT NOT NULL,
|
|
status TEXT NOT NULL CHECK(status IN ('pending', 'shown', 'dismissed', 'opened')),
|
|
created_at TEXT NOT NULL,
|
|
shown_at TEXT
|
|
);
|
|
CREATE TABLE experts (
|
|
id TEXT PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
description TEXT NOT NULL,
|
|
system_instructions TEXT NOT NULL,
|
|
capability_policy_json TEXT NOT NULL,
|
|
model_policy_json TEXT NOT NULL,
|
|
enabled INTEGER NOT NULL,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
CREATE TABLE delegations (
|
|
id TEXT PRIMARY KEY,
|
|
run_id TEXT NOT NULL REFERENCES runs(id) ON DELETE CASCADE,
|
|
endpoint_id TEXT NOT NULL,
|
|
remote_job_id TEXT,
|
|
status TEXT NOT NULL,
|
|
request_digest TEXT NOT NULL,
|
|
result_json TEXT,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
PRAGMA user_version = 1;
|
|
COMMIT;
|
|
`)
|
|
}
|
|
if (version.user_version < 2) {
|
|
database.exec(`
|
|
BEGIN IMMEDIATE;
|
|
CREATE TABLE IF NOT EXISTS delegation_outbox (
|
|
task_id TEXT PRIMARY KEY,
|
|
result_json TEXT NOT NULL,
|
|
status TEXT NOT NULL CHECK(status IN ('pending', 'delivered')),
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
CREATE INDEX IF NOT EXISTS delegation_outbox_status_idx
|
|
ON delegation_outbox(status, updated_at);
|
|
PRAGMA user_version = 2;
|
|
COMMIT;
|
|
`)
|
|
}
|
|
if (version.user_version < 3) {
|
|
database.exec(`
|
|
BEGIN IMMEDIATE;
|
|
CREATE TABLE IF NOT EXISTS heartbeat_configs (
|
|
id TEXT PRIMARY KEY,
|
|
project_id TEXT REFERENCES projects(id) ON DELETE SET NULL,
|
|
name TEXT NOT NULL,
|
|
timezone TEXT NOT NULL,
|
|
recurrence_json TEXT NOT NULL,
|
|
lookback_hours INTEGER NOT NULL
|
|
CHECK(lookback_hours BETWEEN 1 AND 720),
|
|
retention_days INTEGER NOT NULL
|
|
CHECK(retention_days BETWEEN 1 AND 365),
|
|
enabled INTEGER NOT NULL CHECK(enabled IN (0, 1)),
|
|
next_run_at TEXT NOT NULL,
|
|
last_run_at TEXT,
|
|
last_status TEXT
|
|
CHECK(last_status IN ('claimed', 'completed', 'failed', 'skipped')),
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
CREATE INDEX IF NOT EXISTS heartbeat_configs_due_idx
|
|
ON heartbeat_configs(enabled, next_run_at);
|
|
CREATE TABLE IF NOT EXISTS heartbeat_runs (
|
|
id TEXT PRIMARY KEY,
|
|
config_id TEXT NOT NULL
|
|
REFERENCES heartbeat_configs(id) ON DELETE CASCADE,
|
|
trigger TEXT NOT NULL CHECK(trigger IN ('scheduled', 'manual')),
|
|
scheduled_for TEXT NOT NULL,
|
|
idempotency_key TEXT NOT NULL,
|
|
status TEXT NOT NULL
|
|
CHECK(status IN ('claimed', 'completed', 'failed', 'skipped')),
|
|
attempt_count INTEGER NOT NULL
|
|
CHECK(attempt_count BETWEEN 0 AND 3),
|
|
next_attempt_at TEXT,
|
|
lease_owner TEXT,
|
|
lease_expires_at TEXT,
|
|
started_at TEXT,
|
|
completed_at TEXT,
|
|
error TEXT,
|
|
entry_id TEXT,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL,
|
|
UNIQUE(config_id, idempotency_key)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS heartbeat_runs_claim_idx
|
|
ON heartbeat_runs(status, next_attempt_at, lease_expires_at);
|
|
CREATE INDEX IF NOT EXISTS heartbeat_runs_history_idx
|
|
ON heartbeat_runs(config_id, created_at DESC);
|
|
CREATE TABLE IF NOT EXISTS heartbeat_entries (
|
|
id TEXT PRIMARY KEY,
|
|
config_id TEXT NOT NULL
|
|
REFERENCES heartbeat_configs(id) ON DELETE CASCADE,
|
|
run_id TEXT NOT NULL UNIQUE
|
|
REFERENCES heartbeat_runs(id) ON DELETE CASCADE,
|
|
scheduled_for TEXT NOT NULL,
|
|
summary TEXT NOT NULL,
|
|
highlights_json TEXT NOT NULL,
|
|
artifact_id TEXT REFERENCES artifacts(id) ON DELETE SET NULL,
|
|
proposed_memory_ids_json TEXT NOT NULL,
|
|
follow_up_task_ids_json TEXT NOT NULL,
|
|
created_at TEXT NOT NULL
|
|
);
|
|
CREATE INDEX IF NOT EXISTS heartbeat_entries_history_idx
|
|
ON heartbeat_entries(config_id, created_at DESC);
|
|
PRAGMA user_version = 3;
|
|
COMMIT;
|
|
`)
|
|
}
|
|
if (version.user_version < 4) {
|
|
database.exec(`
|
|
BEGIN IMMEDIATE;
|
|
CREATE TABLE IF NOT EXISTS model_usage_calls (
|
|
request_id TEXT NOT NULL
|
|
REFERENCES tasks(id) ON DELETE CASCADE,
|
|
call_id TEXT NOT NULL,
|
|
runtime TEXT NOT NULL,
|
|
provider TEXT NOT NULL,
|
|
model TEXT NOT NULL,
|
|
input_tokens INTEGER NOT NULL CHECK(input_tokens >= 0),
|
|
output_tokens INTEGER NOT NULL CHECK(output_tokens >= 0),
|
|
cache_read_tokens INTEGER NOT NULL CHECK(cache_read_tokens >= 0),
|
|
cache_write_tokens INTEGER NOT NULL CHECK(cache_write_tokens >= 0),
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL,
|
|
PRIMARY KEY(request_id, call_id)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS model_usage_calls_request_idx
|
|
ON model_usage_calls(request_id);
|
|
CREATE INDEX IF NOT EXISTS model_usage_calls_dimensions_idx
|
|
ON model_usage_calls(runtime, provider, model);
|
|
PRAGMA user_version = 4;
|
|
COMMIT;
|
|
`)
|
|
}
|
|
if (version.user_version < 5) {
|
|
database.exec(`
|
|
BEGIN IMMEDIATE;
|
|
CREATE INDEX IF NOT EXISTS tasks_status_idx
|
|
ON tasks(status);
|
|
CREATE INDEX IF NOT EXISTS messages_state_idx
|
|
ON messages(state);
|
|
PRAGMA user_version = 5;
|
|
COMMIT;
|
|
`)
|
|
}
|
|
if (version.user_version < 6) {
|
|
database.exec(`
|
|
BEGIN IMMEDIATE;
|
|
CREATE TABLE IF NOT EXISTS computer_control_actions (
|
|
command_id TEXT PRIMARY KEY
|
|
CHECK(length(command_id) BETWEEN 16 AND 160),
|
|
task_id TEXT NOT NULL
|
|
REFERENCES tasks(id) ON DELETE CASCADE
|
|
CHECK(length(task_id) BETWEEN 1 AND 128),
|
|
conversation_id TEXT NOT NULL
|
|
CHECK(length(conversation_id) BETWEEN 1 AND 128),
|
|
lease_id TEXT NOT NULL
|
|
CHECK(length(lease_id) BETWEEN 16 AND 160),
|
|
action TEXT NOT NULL
|
|
CHECK(action IN ('observe', 'activate', 'replace_text',
|
|
'select_option', 'scroll')),
|
|
risk TEXT NOT NULL
|
|
CHECK(risk IN ('observe', 'navigate', 'input', 'commit',
|
|
'forbidden')),
|
|
outcome TEXT NOT NULL
|
|
CHECK(outcome IN ('completed', 'denied', 'failed',
|
|
'outcome_unknown')),
|
|
error_code TEXT
|
|
CHECK(error_code IS NULL OR error_code IN (
|
|
'invalid_request', 'driver_unavailable', 'driver_timeout',
|
|
'lease_not_found', 'lease_expired', 'lease_mismatch',
|
|
'observation_not_found', 'observation_stale',
|
|
'observation_consumed', 'element_not_found',
|
|
'window_not_foreground', 'element_identity_changed',
|
|
'focus_failed', 'forbidden', 'approval_denied',
|
|
'approval_timeout', 'cancelled', 'command_id_conflict',
|
|
'outcome_unknown', 'internal_error')),
|
|
occurred_at INTEGER NOT NULL
|
|
CHECK(occurred_at BETWEEN 0 AND 9007199254740991),
|
|
text_length INTEGER
|
|
CHECK(text_length IS NULL OR
|
|
text_length BETWEEN 0 AND 4096),
|
|
text_digest TEXT
|
|
CHECK(text_digest IS NULL OR (
|
|
length(text_digest) = 64 AND
|
|
text_digest NOT GLOB '*[^0-9a-f]*')),
|
|
created_at TEXT NOT NULL,
|
|
CHECK(
|
|
(outcome = 'completed' AND error_code IS NULL) OR
|
|
(outcome <> 'completed' AND error_code IS NOT NULL)
|
|
),
|
|
CHECK(
|
|
(action = 'replace_text' AND text_length IS NOT NULL AND
|
|
text_digest IS NOT NULL) OR
|
|
(action <> 'replace_text' AND text_length IS NULL AND
|
|
text_digest IS NULL)
|
|
)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS computer_control_actions_recent_idx
|
|
ON computer_control_actions(occurred_at DESC, created_at DESC);
|
|
CREATE INDEX IF NOT EXISTS computer_control_actions_task_idx
|
|
ON computer_control_actions(task_id, occurred_at DESC);
|
|
PRAGMA user_version = 6;
|
|
COMMIT;
|
|
`)
|
|
}
|
|
if (version.user_version < 7) {
|
|
const taskColumns = new Set(
|
|
(database.prepare('PRAGMA table_info(tasks)').all() as Array<{
|
|
name: string
|
|
}>).map((column) => column.name)
|
|
)
|
|
database.exec('BEGIN IMMEDIATE')
|
|
try {
|
|
if (!taskColumns.has('parent_task_id')) {
|
|
database.exec(`ALTER TABLE tasks ADD COLUMN parent_task_id TEXT
|
|
REFERENCES tasks(id) ON DELETE CASCADE`)
|
|
}
|
|
if (!taskColumns.has('expert_id')) {
|
|
database.exec(`ALTER TABLE tasks ADD COLUMN expert_id TEXT
|
|
REFERENCES experts(id) ON DELETE SET NULL`)
|
|
}
|
|
if (!taskColumns.has('routing_mode')) {
|
|
database.exec(`ALTER TABLE tasks ADD COLUMN routing_mode TEXT
|
|
CHECK(routing_mode IS NULL OR routing_mode IN ('manual', 'smart'))`)
|
|
}
|
|
database.exec(`
|
|
CREATE INDEX IF NOT EXISTS tasks_parent_task_idx
|
|
ON tasks(parent_task_id, created_at);
|
|
CREATE INDEX IF NOT EXISTS tasks_expert_idx
|
|
ON tasks(expert_id, created_at);
|
|
PRAGMA user_version = 7;
|
|
COMMIT;
|
|
`)
|
|
} catch (error) {
|
|
database.exec('ROLLBACK')
|
|
throw error
|
|
}
|
|
}
|
|
if (version.user_version < 8) {
|
|
const conversationColumns = new Set(
|
|
(
|
|
database.prepare('PRAGMA table_info(conversations)').all() as Array<{
|
|
name: string
|
|
}>
|
|
).map((column) => column.name)
|
|
)
|
|
database.exec('BEGIN IMMEDIATE')
|
|
try {
|
|
if (!conversationColumns.has('runtime_selection_json')) {
|
|
database.exec(
|
|
'ALTER TABLE conversations ADD COLUMN runtime_selection_json TEXT'
|
|
)
|
|
}
|
|
database.exec('PRAGMA user_version = 8; COMMIT;')
|
|
} catch (error) {
|
|
database.exec('ROLLBACK')
|
|
throw error
|
|
}
|
|
}
|
|
if (version.user_version < 9) {
|
|
database.exec(`
|
|
BEGIN IMMEDIATE;
|
|
CREATE TABLE IF NOT EXISTS magic_notes (
|
|
id TEXT PRIMARY KEY,
|
|
project_id TEXT REFERENCES projects(id) ON DELETE CASCADE,
|
|
title TEXT NOT NULL,
|
|
pinned INTEGER NOT NULL DEFAULT 0 CHECK(pinned IN (0, 1)),
|
|
revision INTEGER NOT NULL DEFAULT 0,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
CREATE TABLE IF NOT EXISTS magic_note_entries (
|
|
id TEXT PRIMARY KEY,
|
|
note_id TEXT NOT NULL REFERENCES magic_notes(id) ON DELETE CASCADE,
|
|
content_json TEXT NOT NULL,
|
|
plain_text TEXT NOT NULL,
|
|
comments_json TEXT NOT NULL DEFAULT '[]',
|
|
actions_json TEXT NOT NULL DEFAULT '[]',
|
|
analyzed_at TEXT,
|
|
revision INTEGER NOT NULL DEFAULT 0,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
CREATE INDEX IF NOT EXISTS magic_notes_scope_updated_idx
|
|
ON magic_notes(project_id, pinned DESC, updated_at DESC);
|
|
CREATE INDEX IF NOT EXISTS magic_note_entries_note_created_idx
|
|
ON magic_note_entries(note_id, created_at ASC);
|
|
PRAGMA user_version = 9;
|
|
COMMIT;
|
|
`)
|
|
}
|
|
if (version.user_version < 10) {
|
|
database.exec('BEGIN IMMEDIATE')
|
|
try {
|
|
database.exec(`
|
|
CREATE TABLE IF NOT EXISTS magic_todos (
|
|
id TEXT PRIMARY KEY,
|
|
project_id TEXT REFERENCES projects(id) ON DELETE CASCADE,
|
|
note_id TEXT REFERENCES magic_notes(id) ON DELETE CASCADE,
|
|
entry_id TEXT REFERENCES magic_note_entries(id) ON DELETE CASCADE,
|
|
source_index INTEGER,
|
|
source TEXT NOT NULL CHECK(source IN ('note', 'manual')),
|
|
title TEXT NOT NULL,
|
|
instructions TEXT NOT NULL DEFAULT '',
|
|
completed INTEGER NOT NULL DEFAULT 0 CHECK(completed IN (0, 1)),
|
|
comments_json TEXT NOT NULL DEFAULT '[]',
|
|
analyzed_at TEXT,
|
|
revision INTEGER NOT NULL DEFAULT 0,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL,
|
|
CHECK(
|
|
(source = 'note' AND note_id IS NOT NULL AND
|
|
entry_id IS NOT NULL AND source_index IS NOT NULL) OR
|
|
(source = 'manual' AND note_id IS NULL AND
|
|
entry_id IS NULL AND source_index IS NULL)
|
|
),
|
|
UNIQUE(entry_id, source_index)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS magic_todos_scope_status_idx
|
|
ON magic_todos(project_id, completed, updated_at DESC);
|
|
CREATE INDEX IF NOT EXISTS magic_todos_note_idx
|
|
ON magic_todos(note_id, entry_id, source_index);
|
|
`)
|
|
const entries = database
|
|
.prepare(
|
|
`SELECT id, note_id, content_json, updated_at
|
|
FROM magic_note_entries`
|
|
)
|
|
.iterate() as Iterable<{
|
|
id: string
|
|
note_id: string
|
|
content_json: string
|
|
updated_at: string
|
|
}>
|
|
for (const entry of entries) {
|
|
this.syncMagicNoteTodos(
|
|
database,
|
|
entry.note_id,
|
|
entry.id,
|
|
JSON.parse(entry.content_json) as MagicNoteContent,
|
|
entry.updated_at
|
|
)
|
|
}
|
|
database.exec('PRAGMA user_version = 10')
|
|
database.exec('COMMIT')
|
|
} catch (error) {
|
|
database.exec('ROLLBACK')
|
|
throw error
|
|
}
|
|
}
|
|
if (version.user_version < 11) {
|
|
const hasVisibilityColumn = (
|
|
database.prepare('PRAGMA table_info(tasks)').all() as Array<{
|
|
name: string
|
|
}>
|
|
).some((column) => column.name === 'visible')
|
|
database.exec('BEGIN IMMEDIATE')
|
|
try {
|
|
if (!hasVisibilityColumn) {
|
|
database.exec(`
|
|
ALTER TABLE tasks
|
|
ADD COLUMN visible INTEGER NOT NULL DEFAULT 1
|
|
CHECK(visible IN (0, 1));
|
|
`)
|
|
}
|
|
database.exec('PRAGMA user_version = 11')
|
|
database.exec('COMMIT')
|
|
} catch (error) {
|
|
database.exec('ROLLBACK')
|
|
throw error
|
|
}
|
|
}
|
|
if (version.user_version < 12) {
|
|
const hasImageBytesColumn = (
|
|
database
|
|
.prepare('PRAGMA table_info(magic_note_entries)')
|
|
.all() as Array<{ name: string }>
|
|
).some((column) => column.name === 'image_bytes')
|
|
database.exec('BEGIN IMMEDIATE')
|
|
try {
|
|
if (!hasImageBytesColumn) {
|
|
database.exec(`
|
|
ALTER TABLE magic_note_entries
|
|
ADD COLUMN image_bytes INTEGER NOT NULL DEFAULT 0
|
|
CHECK(image_bytes >= 0);
|
|
`)
|
|
}
|
|
const entries = database
|
|
.prepare('SELECT id, content_json FROM magic_note_entries')
|
|
.iterate() as Iterable<{
|
|
id: string
|
|
content_json: string
|
|
}>
|
|
const updateImageBytes = database.prepare(
|
|
`UPDATE magic_note_entries
|
|
SET image_bytes = ?
|
|
WHERE id = ?`
|
|
)
|
|
for (const entry of entries) {
|
|
updateImageBytes.run(
|
|
magicNoteImageBytes(
|
|
JSON.parse(entry.content_json) as MagicNoteContent
|
|
),
|
|
entry.id
|
|
)
|
|
}
|
|
database.exec('PRAGMA user_version = 12')
|
|
database.exec('COMMIT')
|
|
} catch (error) {
|
|
database.exec('ROLLBACK')
|
|
throw error
|
|
}
|
|
}
|
|
if (version.user_version < 13) {
|
|
const projectColumns = new Set(
|
|
(
|
|
database.prepare('PRAGMA table_info(projects)').all() as Array<{
|
|
name: string
|
|
}>
|
|
).map((column) => column.name)
|
|
)
|
|
database.exec('BEGIN IMMEDIATE')
|
|
try {
|
|
if (!projectColumns.has('kind')) {
|
|
database.exec(`
|
|
ALTER TABLE projects
|
|
ADD COLUMN kind TEXT NOT NULL DEFAULT 'user'
|
|
CHECK(kind IN ('user', 'channel'));
|
|
`)
|
|
}
|
|
if (!projectColumns.has('channel')) {
|
|
database.exec(`
|
|
ALTER TABLE projects
|
|
ADD COLUMN channel TEXT
|
|
CHECK(
|
|
channel IS NULL OR
|
|
channel IN ('weixin', 'wecom', 'dingtalk')
|
|
);
|
|
`)
|
|
}
|
|
database.exec(`
|
|
CREATE UNIQUE INDEX IF NOT EXISTS projects_channel_unique
|
|
ON projects(channel)
|
|
WHERE channel IS NOT NULL;
|
|
PRAGMA user_version = 13;
|
|
COMMIT;
|
|
`)
|
|
} catch (error) {
|
|
database.exec('ROLLBACK')
|
|
throw error
|
|
}
|
|
}
|
|
if (version.user_version < 14) {
|
|
const conversationColumns = new Set(
|
|
(
|
|
database
|
|
.prepare('PRAGMA table_info(conversations)')
|
|
.all() as Array<{ name: string }>
|
|
).map((column) => column.name)
|
|
)
|
|
database.exec('BEGIN IMMEDIATE')
|
|
try {
|
|
if (!conversationColumns.has('channel')) {
|
|
database.exec(`
|
|
ALTER TABLE conversations ADD COLUMN channel TEXT
|
|
CHECK(
|
|
channel IS NULL OR
|
|
channel IN ('weixin', 'wecom', 'dingtalk')
|
|
);
|
|
`)
|
|
}
|
|
if (!conversationColumns.has('external_account_id')) {
|
|
database.exec(`
|
|
ALTER TABLE conversations
|
|
ADD COLUMN external_account_id TEXT;
|
|
`)
|
|
}
|
|
if (!conversationColumns.has('external_conversation_id')) {
|
|
database.exec(`
|
|
ALTER TABLE conversations
|
|
ADD COLUMN external_conversation_id TEXT;
|
|
`)
|
|
}
|
|
if (!conversationColumns.has('conversation_type')) {
|
|
database.exec(`
|
|
ALTER TABLE conversations ADD COLUMN conversation_type TEXT
|
|
CHECK(
|
|
conversation_type IS NULL OR
|
|
conversation_type IN ('direct', 'group')
|
|
);
|
|
`)
|
|
}
|
|
if (!conversationColumns.has('account_display')) {
|
|
database.exec(`
|
|
ALTER TABLE conversations ADD COLUMN account_display TEXT;
|
|
`)
|
|
}
|
|
database.exec(`
|
|
CREATE UNIQUE INDEX IF NOT EXISTS
|
|
conversations_remote_identity_unique
|
|
ON conversations(
|
|
channel,
|
|
external_account_id,
|
|
external_conversation_id
|
|
)
|
|
WHERE channel IS NOT NULL;
|
|
PRAGMA user_version = 14;
|
|
COMMIT;
|
|
`)
|
|
} catch (error) {
|
|
database.exec('ROLLBACK')
|
|
throw error
|
|
}
|
|
}
|
|
if (version.user_version < 15) {
|
|
database.exec('BEGIN IMMEDIATE')
|
|
try {
|
|
database.exec(`
|
|
CREATE TABLE IF NOT EXISTS channel_events (
|
|
channel TEXT NOT NULL,
|
|
account_id TEXT NOT NULL DEFAULT 'default',
|
|
event_id TEXT NOT NULL,
|
|
claimed_at INTEGER NOT NULL,
|
|
PRIMARY KEY(channel, account_id, event_id)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS channel_events_claimed_at
|
|
ON channel_events(claimed_at);
|
|
CREATE TABLE IF NOT EXISTS channel_outbox (
|
|
id TEXT PRIMARY KEY,
|
|
channel TEXT NOT NULL,
|
|
event_id TEXT NOT NULL,
|
|
message_json TEXT NOT NULL,
|
|
state TEXT NOT NULL
|
|
CHECK(state IN ('pending', 'delivered', 'failed')),
|
|
attempts INTEGER NOT NULL,
|
|
created_at INTEGER NOT NULL
|
|
);
|
|
CREATE INDEX IF NOT EXISTS channel_outbox_state_created
|
|
ON channel_outbox(state, created_at);
|
|
PRAGMA user_version = 15;
|
|
COMMIT;
|
|
`)
|
|
} catch (error) {
|
|
database.exec('ROLLBACK')
|
|
throw error
|
|
}
|
|
}
|
|
if (version.user_version < 16) {
|
|
database.exec('BEGIN IMMEDIATE')
|
|
try {
|
|
const projectColumns = new Set(
|
|
(
|
|
database.prepare('PRAGMA table_info(projects)').all() as Array<{
|
|
name: string
|
|
}>
|
|
).map((column) => column.name)
|
|
)
|
|
if (!projectColumns.has('runtime_selection_json')) {
|
|
database.exec(`
|
|
ALTER TABLE projects ADD COLUMN runtime_selection_json TEXT;
|
|
`)
|
|
}
|
|
database.exec(`
|
|
UPDATE projects
|
|
SET runtime_selection_json = '{"provider":"auto"}'
|
|
WHERE kind = 'channel' AND runtime_selection_json IS NULL;
|
|
PRAGMA user_version = 16;
|
|
COMMIT;
|
|
`)
|
|
} catch (error) {
|
|
database.exec('ROLLBACK')
|
|
throw error
|
|
}
|
|
}
|
|
if (version.user_version < 17) {
|
|
database.exec('BEGIN IMMEDIATE')
|
|
try {
|
|
database.exec(`
|
|
UPDATE magic_notes SET project_id = NULL
|
|
WHERE project_id IS NOT NULL;
|
|
UPDATE magic_todos SET project_id = NULL
|
|
WHERE source = 'note' AND project_id IS NOT NULL;
|
|
`)
|
|
const manualTodos = database
|
|
.prepare(
|
|
`SELECT id, title, instructions, completed, comments_json,
|
|
analyzed_at, revision, created_at, updated_at
|
|
FROM magic_todos
|
|
WHERE source = 'manual'
|
|
ORDER BY created_at ASC, rowid ASC`
|
|
)
|
|
.all() as Array<{
|
|
id: string
|
|
title: string
|
|
instructions: string
|
|
completed: number
|
|
comments_json: string
|
|
analyzed_at: string | null
|
|
revision: number
|
|
created_at: string
|
|
updated_at: string
|
|
}>
|
|
if (manualTodos.length > 0) {
|
|
const noteId = randomUUID()
|
|
const createdAt = manualTodos[0]!.created_at
|
|
const updatedAt = manualTodos.at(-1)!.updated_at
|
|
database
|
|
.prepare(
|
|
`INSERT INTO magic_notes
|
|
(id, project_id, title, pinned, revision,
|
|
created_at, updated_at)
|
|
VALUES (?, NULL, '迁入的待办', 0, 0, ?, ?)`
|
|
)
|
|
.run(noteId, createdAt, updatedAt)
|
|
const insertEntry = database.prepare(
|
|
`INSERT INTO magic_note_entries
|
|
(id, note_id, content_json, plain_text, comments_json,
|
|
actions_json, analyzed_at, revision, created_at, updated_at,
|
|
image_bytes)
|
|
VALUES (?, ?, ?, ?, ?, '[]', ?, ?, ?, ?, 0)`
|
|
)
|
|
const updateMigratedTodo = database.prepare(
|
|
`UPDATE magic_todos
|
|
SET instructions = ?, comments_json = ?, analyzed_at = ?,
|
|
revision = ?
|
|
WHERE entry_id = ? AND source = 'note'`
|
|
)
|
|
const deleteManualTodo = database.prepare(
|
|
`DELETE FROM magic_todos
|
|
WHERE id = ? AND source = 'manual'`
|
|
)
|
|
for (const todo of manualTodos) {
|
|
const entryId = randomUUID()
|
|
const content: MagicNoteRichContent = {
|
|
version: 1,
|
|
ops: [
|
|
{ insert: todo.title },
|
|
{
|
|
insert: '\n',
|
|
attributes: {
|
|
list: todo.completed ? 'checked' : 'unchecked'
|
|
}
|
|
},
|
|
...(todo.instructions
|
|
? [
|
|
{ insert: todo.instructions },
|
|
{ insert: '\n' }
|
|
]
|
|
: [])
|
|
]
|
|
}
|
|
insertEntry.run(
|
|
entryId,
|
|
noteId,
|
|
JSON.stringify(content),
|
|
[todo.title, todo.instructions].filter(Boolean).join('\n'),
|
|
todo.comments_json,
|
|
todo.analyzed_at,
|
|
todo.revision,
|
|
todo.created_at,
|
|
todo.updated_at
|
|
)
|
|
this.syncMagicNoteTodos(
|
|
database,
|
|
noteId,
|
|
entryId,
|
|
content,
|
|
todo.updated_at
|
|
)
|
|
updateMigratedTodo.run(
|
|
todo.instructions,
|
|
todo.comments_json,
|
|
todo.analyzed_at,
|
|
todo.revision,
|
|
entryId
|
|
)
|
|
deleteManualTodo.run(todo.id)
|
|
}
|
|
}
|
|
database.exec('PRAGMA user_version = 17')
|
|
database.exec('COMMIT')
|
|
} catch (error) {
|
|
database.exec('ROLLBACK')
|
|
throw error
|
|
}
|
|
}
|
|
if (version.user_version < 18) {
|
|
database.exec('BEGIN IMMEDIATE')
|
|
try {
|
|
const conversationColumns = new Set(
|
|
(
|
|
database
|
|
.prepare('PRAGMA table_info(conversations)')
|
|
.all() as Array<{ name: string }>
|
|
).map((column) => column.name)
|
|
)
|
|
if (!conversationColumns.has('knowledge_retrieval_mode')) {
|
|
database.exec(`
|
|
ALTER TABLE conversations
|
|
ADD COLUMN knowledge_retrieval_mode TEXT
|
|
CHECK(
|
|
knowledge_retrieval_mode IS NULL OR
|
|
knowledge_retrieval_mode IN ('auto', 'always')
|
|
);
|
|
`)
|
|
}
|
|
database.exec('PRAGMA user_version = 18; COMMIT;')
|
|
} catch (error) {
|
|
database.exec('ROLLBACK')
|
|
throw error
|
|
}
|
|
}
|
|
if (version.user_version < 19) {
|
|
database.exec('BEGIN IMMEDIATE')
|
|
try {
|
|
const eventColumns = new Set(
|
|
(
|
|
database
|
|
.prepare('PRAGMA table_info(channel_events)')
|
|
.all() as Array<{ name: string }>
|
|
).map((column) => column.name)
|
|
)
|
|
if (!eventColumns.has('account_id')) {
|
|
database.exec(`
|
|
ALTER TABLE channel_events RENAME TO channel_events_legacy;
|
|
DROP INDEX IF EXISTS channel_events_claimed_at;
|
|
CREATE TABLE channel_events (
|
|
channel TEXT NOT NULL,
|
|
account_id TEXT NOT NULL DEFAULT 'default',
|
|
event_id TEXT NOT NULL,
|
|
claimed_at INTEGER NOT NULL,
|
|
PRIMARY KEY(channel, account_id, event_id)
|
|
);
|
|
INSERT INTO channel_events
|
|
(channel, account_id, event_id, claimed_at)
|
|
SELECT channel, 'default', event_id, claimed_at
|
|
FROM channel_events_legacy;
|
|
DROP TABLE channel_events_legacy;
|
|
CREATE INDEX channel_events_claimed_at
|
|
ON channel_events(claimed_at);
|
|
`)
|
|
}
|
|
database.exec('PRAGMA user_version = 19; COMMIT;')
|
|
} catch (error) {
|
|
database.exec('ROLLBACK')
|
|
throw error
|
|
}
|
|
}
|
|
if (version.user_version < 20) {
|
|
database.exec('BEGIN IMMEDIATE')
|
|
try {
|
|
const conversationColumns = new Set(
|
|
(
|
|
database
|
|
.prepare('PRAGMA table_info(conversations)')
|
|
.all() as Array<{ name: string }>
|
|
).map((column) => column.name)
|
|
)
|
|
if (!conversationColumns.has('context_state_json')) {
|
|
database.exec(`
|
|
ALTER TABLE conversations
|
|
ADD COLUMN context_state_json TEXT;
|
|
`)
|
|
}
|
|
database.exec('PRAGMA user_version = 20; COMMIT;')
|
|
} catch (error) {
|
|
database.exec('ROLLBACK')
|
|
throw error
|
|
}
|
|
}
|
|
if (version.user_version < 21) {
|
|
database.exec('BEGIN IMMEDIATE')
|
|
try {
|
|
const heartbeatColumns = new Set(
|
|
(
|
|
database
|
|
.prepare('PRAGMA table_info(heartbeat_configs)')
|
|
.all() as Array<{ name: string }>
|
|
).map((column) => column.name)
|
|
)
|
|
if (!heartbeatColumns.has('scope_kind')) {
|
|
database.exec(`
|
|
ALTER TABLE heartbeat_configs
|
|
ADD COLUMN scope_kind TEXT NOT NULL DEFAULT 'global'
|
|
CHECK(scope_kind IN ('global', 'projects'));
|
|
`)
|
|
}
|
|
database.exec(`
|
|
CREATE TABLE IF NOT EXISTS heartbeat_config_projects (
|
|
config_id TEXT NOT NULL
|
|
REFERENCES heartbeat_configs(id) ON DELETE CASCADE,
|
|
project_id TEXT NOT NULL
|
|
REFERENCES projects(id) ON DELETE CASCADE,
|
|
PRIMARY KEY(config_id, project_id)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS heartbeat_config_projects_project_idx
|
|
ON heartbeat_config_projects(project_id, config_id);
|
|
INSERT OR IGNORE INTO heartbeat_config_projects
|
|
(config_id, project_id)
|
|
SELECT id, project_id
|
|
FROM heartbeat_configs
|
|
WHERE project_id IS NOT NULL;
|
|
UPDATE heartbeat_configs
|
|
SET scope_kind = CASE
|
|
WHEN EXISTS (
|
|
SELECT 1 FROM heartbeat_config_projects p
|
|
WHERE p.config_id = heartbeat_configs.id
|
|
) THEN 'projects'
|
|
ELSE 'global'
|
|
END;
|
|
PRAGMA user_version = 21;
|
|
COMMIT;
|
|
`)
|
|
} catch (error) {
|
|
database.exec('ROLLBACK')
|
|
throw error
|
|
}
|
|
}
|
|
if (version.user_version < 22) {
|
|
database.exec('BEGIN IMMEDIATE')
|
|
try {
|
|
const schedules = database
|
|
.prepare(
|
|
`SELECT id, project_id, task_template_json, enabled,
|
|
created_at, updated_at
|
|
FROM schedules
|
|
ORDER BY created_at ASC, id ASC`
|
|
)
|
|
.all() as Array<
|
|
Pick<
|
|
ScheduleRow,
|
|
| 'id'
|
|
| 'project_id'
|
|
| 'task_template_json'
|
|
| 'enabled'
|
|
| 'created_at'
|
|
| 'updated_at'
|
|
>
|
|
>
|
|
const findTask = database.prepare(
|
|
`SELECT id, conversation_id
|
|
FROM tasks
|
|
WHERE schedule_id = ?
|
|
ORDER BY created_at ASC, id ASC
|
|
LIMIT 1`
|
|
)
|
|
const findConversation = database.prepare(
|
|
`SELECT id, runtime_selection_json
|
|
FROM conversations
|
|
WHERE id = ?
|
|
AND project_id IS ?
|
|
AND status = 'active'
|
|
AND channel IS NULL`
|
|
)
|
|
const projectRuntime = database.prepare(
|
|
'SELECT runtime_selection_json FROM projects WHERE id = ?'
|
|
)
|
|
const insertConversation = database.prepare(
|
|
`INSERT INTO conversations
|
|
(id, project_id, runtime_selection_json, work_mode, title,
|
|
status, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, 'active', ?, ?)`
|
|
)
|
|
const insertTask = database.prepare(
|
|
`INSERT INTO tasks
|
|
(id, project_id, conversation_id, schedule_id,
|
|
parent_task_id, expert_id, routing_mode, title,
|
|
instructions, origin, status, progress, created_at,
|
|
started_at, completed_at, error)
|
|
VALUES (?, ?, ?, ?, NULL, NULL, NULL, ?, ?,
|
|
'schedule', ?, NULL, ?, NULL, NULL, NULL)`
|
|
)
|
|
const updateTaskConversation = database.prepare(
|
|
`UPDATE tasks
|
|
SET conversation_id = ?, project_id = ?,
|
|
parent_task_id = NULL, title = ?, instructions = ?,
|
|
origin = 'schedule', status = ?, work_mode = ?,
|
|
completed_at = NULL, error = NULL, visible = 1
|
|
WHERE id = ?`
|
|
)
|
|
const attachHistoricalTasks = database.prepare(
|
|
`UPDATE tasks
|
|
SET parent_task_id = ?, visible = 0
|
|
WHERE id IN (
|
|
SELECT task_id
|
|
FROM schedule_runs
|
|
WHERE schedule_id = ? AND task_id IS NOT NULL
|
|
)
|
|
AND id != ?
|
|
AND parent_task_id IS NULL
|
|
AND schedule_id IS NULL`
|
|
)
|
|
const bindUnclaimedRuns = database.prepare(
|
|
`UPDATE schedule_runs
|
|
SET task_id = ?
|
|
WHERE schedule_id = ? AND task_id IS NULL`
|
|
)
|
|
const freezeScheduleRuntime = database.prepare(
|
|
`UPDATE schedules
|
|
SET task_template_json = ?
|
|
WHERE id = ?`
|
|
)
|
|
|
|
for (const schedule of schedules) {
|
|
let template: {
|
|
title?: unknown
|
|
prompt?: unknown
|
|
workMode?: 'ask' | 'plan' | 'execute'
|
|
} = {}
|
|
try {
|
|
template = JSON.parse(
|
|
schedule.task_template_json
|
|
) as typeof template
|
|
} catch {
|
|
// Keep malformed legacy schedules migratable. Normal reads will
|
|
// continue to reject an invalid template if it is edited or run.
|
|
}
|
|
const title =
|
|
typeof template.title === 'string' &&
|
|
template.title.trim()
|
|
? template.title.trim().slice(0, 120)
|
|
: '定时任务'
|
|
const prompt =
|
|
typeof template.prompt === 'string' &&
|
|
template.prompt.trim()
|
|
? template.prompt.trim().slice(0, 100_000)
|
|
: title
|
|
const workMode = template.workMode === 'execute' ? 'execute' : 'ask'
|
|
const existing = findTask.get(schedule.id) as
|
|
| { id: string; conversation_id: string | null }
|
|
| undefined
|
|
const existingConversation = existing?.conversation_id
|
|
? (findConversation.get(
|
|
existing.conversation_id,
|
|
schedule.project_id
|
|
) as
|
|
| {
|
|
id: string
|
|
runtime_selection_json: string | null
|
|
}
|
|
| undefined)
|
|
: undefined
|
|
const conversationId = existingConversation
|
|
? existing!.conversation_id!
|
|
: randomUUID()
|
|
let runtimeSelectionJson =
|
|
existingConversation?.runtime_selection_json ?? null
|
|
if (!existingConversation) {
|
|
runtimeSelectionJson = schedule.project_id
|
|
? (
|
|
projectRuntime.get(schedule.project_id) as
|
|
| {
|
|
runtime_selection_json: string | null
|
|
}
|
|
| undefined
|
|
)?.runtime_selection_json ?? null
|
|
: null
|
|
insertConversation.run(
|
|
conversationId,
|
|
schedule.project_id,
|
|
runtimeSelectionJson,
|
|
workMode,
|
|
title,
|
|
schedule.created_at,
|
|
schedule.updated_at
|
|
)
|
|
}
|
|
const taskId = existing?.id ?? randomUUID()
|
|
if (existing) {
|
|
updateTaskConversation.run(
|
|
conversationId,
|
|
schedule.project_id,
|
|
title,
|
|
prompt,
|
|
schedule.enabled === 1 ? 'queued' : 'paused',
|
|
workMode,
|
|
taskId
|
|
)
|
|
} else {
|
|
insertTask.run(
|
|
taskId,
|
|
schedule.project_id,
|
|
conversationId,
|
|
schedule.id,
|
|
title,
|
|
prompt,
|
|
schedule.enabled === 1 ? 'queued' : 'paused',
|
|
schedule.created_at
|
|
)
|
|
}
|
|
freezeScheduleRuntime.run(
|
|
JSON.stringify({
|
|
title,
|
|
prompt,
|
|
workMode,
|
|
runtimeSelection:
|
|
parseRuntimeSelection(runtimeSelectionJson)
|
|
}),
|
|
schedule.id
|
|
)
|
|
attachHistoricalTasks.run(taskId, schedule.id, taskId)
|
|
bindUnclaimedRuns.run(taskId, schedule.id)
|
|
}
|
|
|
|
database.exec(`
|
|
UPDATE tasks AS duplicate
|
|
SET parent_task_id = (
|
|
SELECT stable.id
|
|
FROM tasks stable
|
|
WHERE stable.schedule_id = duplicate.schedule_id
|
|
ORDER BY stable.created_at ASC, stable.id ASC
|
|
LIMIT 1
|
|
),
|
|
schedule_id = NULL,
|
|
visible = 0
|
|
WHERE duplicate.schedule_id IS NOT NULL
|
|
AND duplicate.id <> (
|
|
SELECT stable.id
|
|
FROM tasks stable
|
|
WHERE stable.schedule_id = duplicate.schedule_id
|
|
ORDER BY stable.created_at ASC, stable.id ASC
|
|
LIMIT 1
|
|
);
|
|
UPDATE tasks
|
|
SET visible = 0
|
|
WHERE schedule_id IS NULL
|
|
AND origin IN ('user', 'schedule', 'delegation', 'subagent');
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_tasks_schedule
|
|
ON tasks(schedule_id)
|
|
WHERE schedule_id IS NOT NULL;
|
|
CREATE INDEX IF NOT EXISTS idx_schedule_runs_status
|
|
ON schedule_runs(status, scheduled_for);
|
|
PRAGMA user_version = 22;
|
|
COMMIT;
|
|
`)
|
|
} catch (error) {
|
|
database.exec('ROLLBACK')
|
|
throw error
|
|
}
|
|
}
|
|
if (version.user_version < 23) {
|
|
database.exec('BEGIN IMMEDIATE')
|
|
try {
|
|
database.exec(`
|
|
CREATE TABLE IF NOT EXISTS conversation_queue_items (
|
|
id TEXT PRIMARY KEY,
|
|
conversation_id TEXT NOT NULL
|
|
REFERENCES conversations(id) ON DELETE CASCADE,
|
|
source TEXT NOT NULL CHECK(source IN ('user', 'schedule')),
|
|
label TEXT NOT NULL,
|
|
payload_json TEXT NOT NULL DEFAULT '{}',
|
|
schedule_run_id TEXT UNIQUE
|
|
REFERENCES schedule_runs(id) ON DELETE CASCADE,
|
|
schedule_id TEXT
|
|
REFERENCES schedules(id) ON DELETE CASCADE,
|
|
task_id TEXT
|
|
REFERENCES tasks(id) ON DELETE SET NULL,
|
|
status TEXT NOT NULL
|
|
CHECK(status IN ('pending', 'dispatching')),
|
|
created_at TEXT NOT NULL
|
|
);
|
|
CREATE INDEX IF NOT EXISTS conversation_queue_pending_idx
|
|
ON conversation_queue_items(
|
|
conversation_id, status, created_at, id
|
|
);
|
|
CREATE INDEX IF NOT EXISTS conversation_queue_schedule_pending_idx
|
|
ON conversation_queue_items(
|
|
source, status, created_at, conversation_id
|
|
);
|
|
CREATE INDEX IF NOT EXISTS conversation_queue_status_created_idx
|
|
ON conversation_queue_items(
|
|
status, created_at, conversation_id
|
|
);
|
|
INSERT OR IGNORE INTO conversation_queue_items
|
|
(id, conversation_id, source, label, payload_json,
|
|
schedule_run_id, schedule_id, task_id, status, created_at)
|
|
SELECT sr.id, t.conversation_id, 'schedule', t.title, '{}',
|
|
sr.id, sr.schedule_id, t.id, 'pending', sr.scheduled_for
|
|
FROM schedule_runs sr
|
|
INNER JOIN tasks t ON t.schedule_id = sr.schedule_id
|
|
WHERE sr.status IN ('pending', 'running')
|
|
AND t.conversation_id IS NOT NULL;
|
|
PRAGMA user_version = 23;
|
|
COMMIT;
|
|
`)
|
|
} catch (error) {
|
|
database.exec('ROLLBACK')
|
|
throw error
|
|
}
|
|
}
|
|
if (version.user_version < 24) {
|
|
database.exec('BEGIN IMMEDIATE')
|
|
try {
|
|
database.exec(`
|
|
ALTER TABLE channel_outbox
|
|
RENAME TO channel_outbox_legacy;
|
|
DROP INDEX IF EXISTS channel_outbox_state_created;
|
|
CREATE TABLE channel_outbox (
|
|
id TEXT PRIMARY KEY,
|
|
channel TEXT NOT NULL,
|
|
event_id TEXT NOT NULL,
|
|
message_json TEXT NOT NULL,
|
|
state TEXT NOT NULL
|
|
CHECK(
|
|
state IN (
|
|
'pending', 'delivered', 'failed', 'terminal'
|
|
)
|
|
),
|
|
attempts INTEGER NOT NULL,
|
|
created_at INTEGER NOT NULL
|
|
);
|
|
INSERT INTO channel_outbox
|
|
(id, channel, event_id, message_json, state, attempts,
|
|
created_at)
|
|
SELECT id, channel, event_id,
|
|
CASE
|
|
WHEN state = 'failed' AND attempts >= 5
|
|
THEN json_remove(message_json, '$.attachments')
|
|
ELSE message_json
|
|
END,
|
|
CASE
|
|
WHEN state = 'failed' AND attempts >= 5
|
|
THEN 'terminal'
|
|
ELSE state
|
|
END,
|
|
attempts, created_at
|
|
FROM channel_outbox_legacy;
|
|
DROP TABLE channel_outbox_legacy;
|
|
CREATE INDEX channel_outbox_state_created
|
|
ON channel_outbox(state, created_at);
|
|
PRAGMA user_version = 24;
|
|
COMMIT;
|
|
`)
|
|
} catch (error) {
|
|
database.exec('ROLLBACK')
|
|
throw error
|
|
}
|
|
}
|
|
if (version.user_version < 25) {
|
|
const projectColumns = new Set(
|
|
(
|
|
database.prepare('PRAGMA table_info(projects)').all() as Array<{
|
|
name: string
|
|
}>
|
|
).map((column) => column.name)
|
|
)
|
|
database.exec('BEGIN IMMEDIATE')
|
|
try {
|
|
if (!projectColumns.has('built_in_default')) {
|
|
database.exec(`
|
|
ALTER TABLE projects
|
|
ADD COLUMN built_in_default INTEGER NOT NULL DEFAULT 0
|
|
CHECK(built_in_default IN (0, 1));
|
|
`)
|
|
}
|
|
database.exec('UPDATE projects SET built_in_default = 0')
|
|
const legacyCandidates = database
|
|
.prepare(
|
|
`SELECT id
|
|
FROM projects
|
|
WHERE name = ?
|
|
AND description = ?
|
|
AND kind = 'user'
|
|
AND channel IS NULL
|
|
AND status = 'active'
|
|
AND default_work_mode = 'ask'
|
|
AND runtime_selection_json IS NULL
|
|
AND created_at = updated_at
|
|
LIMIT 2`
|
|
)
|
|
.all(
|
|
builtInDefaultProjectSeedName,
|
|
builtInDefaultProjectSeedDescription
|
|
) as Array<{ id: string }>
|
|
const originalProject = database
|
|
.prepare(
|
|
`SELECT id
|
|
FROM projects
|
|
WHERE rowid = 1`
|
|
)
|
|
.get() as { id: string } | undefined
|
|
if (
|
|
legacyCandidates.length === 1 &&
|
|
legacyCandidates[0]!.id === originalProject?.id
|
|
) {
|
|
database
|
|
.prepare(
|
|
`UPDATE projects
|
|
SET built_in_default = 1
|
|
WHERE id = ?`
|
|
)
|
|
.run(legacyCandidates[0]!.id)
|
|
}
|
|
database.exec(`
|
|
CREATE UNIQUE INDEX IF NOT EXISTS
|
|
projects_built_in_default_unique
|
|
ON projects(built_in_default)
|
|
WHERE built_in_default = 1;
|
|
PRAGMA user_version = 25;
|
|
COMMIT;
|
|
`)
|
|
} catch (error) {
|
|
database.exec('ROLLBACK')
|
|
throw error
|
|
}
|
|
}
|
|
if (version.user_version < 26) {
|
|
const conversationColumns = new Set(
|
|
(
|
|
database.prepare('PRAGMA table_info(conversations)').all() as Array<{
|
|
name: string
|
|
}>
|
|
).map((column) => column.name)
|
|
)
|
|
database.exec('BEGIN IMMEDIATE')
|
|
try {
|
|
if (!conversationColumns.has('branch_source_conversation_id')) {
|
|
database.exec(`
|
|
ALTER TABLE conversations
|
|
ADD COLUMN branch_source_conversation_id TEXT;
|
|
`)
|
|
}
|
|
if (!conversationColumns.has('branch_source_title')) {
|
|
database.exec(`
|
|
ALTER TABLE conversations
|
|
ADD COLUMN branch_source_title TEXT;
|
|
`)
|
|
}
|
|
database.exec(`
|
|
CREATE INDEX IF NOT EXISTS conversations_branch_source_idx
|
|
ON conversations(branch_source_conversation_id)
|
|
WHERE branch_source_conversation_id IS NOT NULL;
|
|
PRAGMA user_version = 26;
|
|
COMMIT;
|
|
`)
|
|
} catch (error) {
|
|
database.exec('ROLLBACK')
|
|
throw error
|
|
}
|
|
}
|
|
if (version.user_version < 27) {
|
|
database.exec('BEGIN IMMEDIATE')
|
|
try {
|
|
database.exec(`
|
|
CREATE TABLE IF NOT EXISTS project_execution_spaces (
|
|
project_id TEXT PRIMARY KEY
|
|
REFERENCES projects(id) ON DELETE CASCADE,
|
|
kind TEXT NOT NULL CHECK(kind IN ('local', 'ssh')),
|
|
root_path TEXT NOT NULL
|
|
CHECK(length(root_path) <= 4096),
|
|
ssh_host_id TEXT,
|
|
host_revision INTEGER,
|
|
host_key_generation INTEGER,
|
|
remote_username TEXT,
|
|
workspace_identity TEXT,
|
|
agent_installation_id_at_validation TEXT,
|
|
agent_protocol_major INTEGER,
|
|
trust_attestation_revision INTEGER,
|
|
validated_at TEXT,
|
|
CHECK(
|
|
(
|
|
kind = 'local' AND
|
|
ssh_host_id IS NULL AND
|
|
host_revision IS NULL AND
|
|
host_key_generation IS NULL AND
|
|
remote_username IS NULL AND
|
|
workspace_identity IS NULL AND
|
|
agent_installation_id_at_validation IS NULL AND
|
|
agent_protocol_major IS NULL AND
|
|
trust_attestation_revision IS NULL AND
|
|
validated_at IS NULL
|
|
) OR (
|
|
kind = 'ssh' AND
|
|
ssh_host_id IS NOT NULL AND
|
|
length(ssh_host_id) = 36 AND
|
|
length(trim(root_path)) > 0 AND
|
|
(
|
|
(
|
|
host_revision IS NULL AND
|
|
host_key_generation IS NULL AND
|
|
remote_username IS NULL AND
|
|
workspace_identity IS NULL AND
|
|
agent_installation_id_at_validation IS NULL AND
|
|
agent_protocol_major IS NULL AND
|
|
trust_attestation_revision IS NULL AND
|
|
validated_at IS NULL
|
|
) OR (
|
|
host_revision IS NOT NULL AND
|
|
typeof(host_revision) = 'integer' AND
|
|
host_revision >= 0 AND
|
|
host_key_generation IS NOT NULL AND
|
|
typeof(host_key_generation) = 'integer' AND
|
|
host_key_generation > 0 AND
|
|
remote_username IS NOT NULL AND
|
|
length(trim(remote_username)) BETWEEN 1 AND 1000 AND
|
|
workspace_identity IS NOT NULL AND
|
|
length(trim(workspace_identity)) BETWEEN 1 AND 1000 AND
|
|
agent_installation_id_at_validation IS NOT NULL AND
|
|
length(agent_installation_id_at_validation)
|
|
BETWEEN 1 AND 1000 AND
|
|
agent_protocol_major IS NOT NULL AND
|
|
typeof(agent_protocol_major) = 'integer' AND
|
|
agent_protocol_major > 0 AND
|
|
trust_attestation_revision IS NOT NULL AND
|
|
typeof(trust_attestation_revision) = 'integer' AND
|
|
trust_attestation_revision >= 0 AND
|
|
validated_at IS NOT NULL
|
|
)
|
|
)
|
|
)
|
|
)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS
|
|
project_execution_spaces_ssh_host_idx
|
|
ON project_execution_spaces(ssh_host_id, project_id)
|
|
WHERE kind = 'ssh';
|
|
CREATE TABLE IF NOT EXISTS project_runtime_validations (
|
|
project_id TEXT PRIMARY KEY
|
|
REFERENCES projects(id) ON DELETE CASCADE,
|
|
runtime_selection_key TEXT NOT NULL
|
|
CHECK(
|
|
length(trim(runtime_selection_key)) BETWEEN 1 AND 1000
|
|
),
|
|
runtime_bundle_digest TEXT NOT NULL
|
|
CHECK(length(runtime_bundle_digest) BETWEEN 1 AND 1000),
|
|
runtime_adapter_digest TEXT NOT NULL
|
|
CHECK(length(runtime_adapter_digest) BETWEEN 1 AND 1000),
|
|
confinement_policy_digest TEXT NOT NULL
|
|
CHECK(
|
|
length(confinement_policy_digest) BETWEEN 1 AND 1000
|
|
),
|
|
approval_bridge_version TEXT NOT NULL
|
|
CHECK(length(approval_bridge_version) BETWEEN 1 AND 1000),
|
|
agent_installation_id_at_validation TEXT NOT NULL
|
|
CHECK(
|
|
length(agent_installation_id_at_validation)
|
|
BETWEEN 1 AND 1000
|
|
),
|
|
validated_at TEXT NOT NULL,
|
|
UNIQUE(project_id, runtime_selection_key)
|
|
);
|
|
INSERT OR IGNORE INTO project_execution_spaces
|
|
(project_id, kind, root_path)
|
|
SELECT id, 'local', root_path
|
|
FROM projects;
|
|
PRAGMA user_version = 27;
|
|
COMMIT;
|
|
`)
|
|
} catch (error) {
|
|
database.exec('ROLLBACK')
|
|
throw error
|
|
}
|
|
}
|
|
if (version.user_version < 31) {
|
|
database.exec('BEGIN IMMEDIATE')
|
|
try {
|
|
database.exec(`
|
|
DROP TRIGGER IF EXISTS
|
|
project_execution_spaces_validate_insert;
|
|
DROP TRIGGER IF EXISTS
|
|
project_execution_spaces_validate_update;
|
|
DROP TRIGGER IF EXISTS
|
|
project_runtime_validations_validate_insert;
|
|
DROP TRIGGER IF EXISTS
|
|
project_runtime_validations_validate_update;
|
|
DROP INDEX IF EXISTS
|
|
project_execution_spaces_ssh_host_idx;
|
|
|
|
ALTER TABLE project_execution_spaces
|
|
RENAME TO legacy_project_execution_spaces;
|
|
|
|
CREATE TABLE project_execution_spaces (
|
|
project_id TEXT PRIMARY KEY
|
|
REFERENCES projects(id) ON DELETE CASCADE,
|
|
kind TEXT NOT NULL CHECK(kind IN ('local', 'ssh')),
|
|
root_path TEXT NOT NULL
|
|
CHECK(length(root_path) <= 4096),
|
|
ssh_host_id TEXT,
|
|
CHECK(
|
|
(
|
|
kind = 'local' AND
|
|
ssh_host_id IS NULL
|
|
) OR (
|
|
kind = 'ssh' AND
|
|
ssh_host_id IS NOT NULL AND
|
|
length(ssh_host_id) = 36 AND
|
|
length(trim(root_path)) > 0
|
|
)
|
|
)
|
|
);
|
|
|
|
INSERT INTO project_execution_spaces
|
|
(project_id, kind, root_path, ssh_host_id)
|
|
SELECT project_id, kind, root_path, ssh_host_id
|
|
FROM legacy_project_execution_spaces;
|
|
|
|
DROP TABLE project_runtime_validations;
|
|
DROP TABLE legacy_project_execution_spaces;
|
|
|
|
CREATE INDEX project_execution_spaces_ssh_host_idx
|
|
ON project_execution_spaces(ssh_host_id, project_id)
|
|
WHERE kind = 'ssh';
|
|
|
|
PRAGMA user_version = 31;
|
|
COMMIT;
|
|
`)
|
|
} catch (error) {
|
|
database.exec('ROLLBACK')
|
|
throw error
|
|
}
|
|
}
|
|
if (version.user_version < 32) {
|
|
const taskColumns = new Set(
|
|
(
|
|
database.prepare('PRAGMA table_info(tasks)').all() as Array<{
|
|
name: string
|
|
}>
|
|
).map((column) => column.name)
|
|
)
|
|
const taskEventColumns = new Set(
|
|
(
|
|
database.prepare('PRAGMA table_info(task_events)').all() as Array<{
|
|
name: string
|
|
}>
|
|
).map((column) => column.name)
|
|
)
|
|
database.exec('BEGIN IMMEDIATE')
|
|
try {
|
|
if (!taskColumns.has('remote_recoverable')) {
|
|
database.exec(`
|
|
ALTER TABLE tasks
|
|
ADD COLUMN remote_recoverable INTEGER NOT NULL DEFAULT 0
|
|
CHECK(remote_recoverable IN (0, 1));
|
|
`)
|
|
}
|
|
if (!taskColumns.has('current_user_message_id')) {
|
|
database.exec(`
|
|
ALTER TABLE tasks
|
|
ADD COLUMN current_user_message_id TEXT;
|
|
`)
|
|
}
|
|
if (!taskColumns.has('current_assistant_message_id')) {
|
|
database.exec(`
|
|
ALTER TABLE tasks
|
|
ADD COLUMN current_assistant_message_id TEXT;
|
|
`)
|
|
}
|
|
if (!taskEventColumns.has('remote_binding_id')) {
|
|
database.exec(`
|
|
ALTER TABLE task_events
|
|
ADD COLUMN remote_binding_id TEXT;
|
|
`)
|
|
}
|
|
if (!taskEventColumns.has('remote_operation_id')) {
|
|
database.exec(`
|
|
ALTER TABLE task_events
|
|
ADD COLUMN remote_operation_id TEXT;
|
|
`)
|
|
}
|
|
if (!taskEventColumns.has('remote_semantic_sequence')) {
|
|
database.exec(`
|
|
ALTER TABLE task_events
|
|
ADD COLUMN remote_semantic_sequence TEXT;
|
|
`)
|
|
}
|
|
if (!taskEventColumns.has('remote_event_index')) {
|
|
database.exec(`
|
|
ALTER TABLE task_events
|
|
ADD COLUMN remote_event_index INTEGER
|
|
CHECK(
|
|
(
|
|
remote_event_index IS NULL AND
|
|
remote_binding_id IS NULL AND
|
|
remote_operation_id IS NULL AND
|
|
remote_semantic_sequence IS NULL
|
|
) OR (
|
|
typeof(remote_event_index) = 'integer' AND
|
|
remote_event_index BETWEEN 0 AND 999 AND
|
|
remote_binding_id IS NOT NULL AND
|
|
length(remote_binding_id) BETWEEN 1 AND 128 AND
|
|
remote_operation_id IS NOT NULL AND
|
|
length(remote_operation_id) BETWEEN 1 AND 128 AND
|
|
remote_semantic_sequence IS NOT NULL AND
|
|
length(remote_semantic_sequence) BETWEEN 1 AND 16 AND
|
|
remote_semantic_sequence NOT GLOB '*[^0-9]*' AND
|
|
substr(remote_semantic_sequence, 1, 1)
|
|
BETWEEN '1' AND '9' AND
|
|
CAST(remote_semantic_sequence AS INTEGER)
|
|
BETWEEN 1 AND 9007199254740991
|
|
)
|
|
);
|
|
`)
|
|
}
|
|
database.exec(`
|
|
CREATE UNIQUE INDEX IF NOT EXISTS
|
|
task_events_remote_provenance_unique
|
|
ON task_events(
|
|
remote_binding_id,
|
|
remote_operation_id,
|
|
remote_semantic_sequence,
|
|
remote_event_index
|
|
)
|
|
WHERE remote_binding_id IS NOT NULL;
|
|
CREATE INDEX IF NOT EXISTS tasks_remote_recovery_idx
|
|
ON tasks(remote_recoverable, status, created_at)
|
|
WHERE remote_recoverable = 1;
|
|
PRAGMA user_version = 32;
|
|
COMMIT;
|
|
`)
|
|
} catch (error) {
|
|
database.exec('ROLLBACK')
|
|
throw error
|
|
}
|
|
}
|
|
if (version.user_version < 33) {
|
|
database.exec(`
|
|
BEGIN IMMEDIATE;
|
|
CREATE TABLE IF NOT EXISTS activity_history (
|
|
singleton INTEGER PRIMARY KEY CHECK(singleton = 1),
|
|
records_json TEXT NOT NULL,
|
|
legacy_history_may_be_incomplete INTEGER NOT NULL DEFAULT 0
|
|
CHECK(legacy_history_may_be_incomplete IN (0, 1))
|
|
);
|
|
INSERT OR IGNORE INTO activity_history
|
|
(singleton, records_json, legacy_history_may_be_incomplete)
|
|
VALUES (1, '[]', 0);
|
|
PRAGMA user_version = 33;
|
|
COMMIT;
|
|
`)
|
|
}
|
|
if (version.user_version < 34) {
|
|
database.exec(`
|
|
BEGIN IMMEDIATE;
|
|
CREATE INDEX IF NOT EXISTS task_events_subagent_idx
|
|
ON task_events(task_id, id) WHERE kind = 'subagent';
|
|
PRAGMA user_version = 34;
|
|
COMMIT;
|
|
`)
|
|
}
|
|
if (version.user_version < SUBAGENT_PROGRESS_STORAGE_SCHEMA_VERSION) {
|
|
// Startup's worker has already compacted full and schema-34 delta events.
|
|
database.exec(`PRAGMA user_version = ${SUBAGENT_PROGRESS_STORAGE_SCHEMA_VERSION}`)
|
|
}
|
|
if (version.user_version < 36) {
|
|
database.exec('BEGIN IMMEDIATE')
|
|
try {
|
|
database.exec(`
|
|
ALTER TABLE conversations ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0
|
|
CHECK(pinned IN (0, 1));
|
|
PRAGMA user_version = 36;
|
|
COMMIT;
|
|
`)
|
|
} catch (error) {
|
|
database.exec('ROLLBACK')
|
|
throw error
|
|
}
|
|
}
|
|
if (version.user_version < 37) {
|
|
// v2 canvas JSON must not be opened by clients that assume Quill v1.
|
|
database.exec('BEGIN IMMEDIATE; PRAGMA user_version = 37; COMMIT;')
|
|
}
|
|
if (version.user_version < 38) {
|
|
database.exec('BEGIN IMMEDIATE; PRAGMA user_version = 38; COMMIT;')
|
|
}
|
|
if (version.user_version < 39) {
|
|
// Older clients reject the persisted file-backed attachment metadata.
|
|
database.exec('BEGIN IMMEDIATE; PRAGMA user_version = 39; COMMIT;')
|
|
}
|
|
if (version.user_version < 40) {
|
|
database.exec(`
|
|
BEGIN IMMEDIATE;
|
|
CREATE TABLE IF NOT EXISTS story_lines (
|
|
id TEXT PRIMARY KEY, scope_json TEXT NOT NULL UNIQUE, title TEXT NOT NULL,
|
|
created_at TEXT NOT NULL, updated_at TEXT NOT NULL
|
|
);
|
|
CREATE TABLE IF NOT EXISTS supervision_runs (
|
|
id TEXT PRIMARY KEY, trigger TEXT NOT NULL, scope_json TEXT NOT NULL,
|
|
time_range_json TEXT NOT NULL, status TEXT NOT NULL, error TEXT,
|
|
started_at TEXT, completed_at TEXT, created_at TEXT NOT NULL
|
|
);
|
|
CREATE TABLE IF NOT EXISTS supervision_results (
|
|
id TEXT PRIMARY KEY, run_id TEXT NOT NULL REFERENCES supervision_runs(id) ON DELETE CASCADE,
|
|
summary TEXT NOT NULL, change_digest TEXT NOT NULL, open_items_json TEXT NOT NULL,
|
|
coverage_json TEXT NOT NULL, created_at TEXT NOT NULL
|
|
);
|
|
CREATE TABLE IF NOT EXISTS supervision_sources (
|
|
id TEXT PRIMARY KEY, result_id TEXT NOT NULL REFERENCES supervision_results(id) ON DELETE CASCADE,
|
|
source_type TEXT NOT NULL, source_id TEXT NOT NULL, title TEXT NOT NULL,
|
|
occurred_at TEXT NOT NULL, content TEXT NOT NULL,
|
|
availability TEXT NOT NULL
|
|
);
|
|
CREATE TABLE IF NOT EXISTS supervision_entities (
|
|
id TEXT PRIMARY KEY, story_line_id TEXT NOT NULL REFERENCES story_lines(id) ON DELETE CASCADE,
|
|
canonical_label TEXT NOT NULL, description TEXT NOT NULL,
|
|
confirmation_state TEXT NOT NULL, updated_at TEXT NOT NULL
|
|
);
|
|
CREATE TABLE IF NOT EXISTS supervision_entity_changes (
|
|
id TEXT PRIMARY KEY, entity_id TEXT NOT NULL REFERENCES supervision_entities(id),
|
|
result_id TEXT NOT NULL REFERENCES supervision_results(id) ON DELETE CASCADE,
|
|
change_type TEXT NOT NULL, description TEXT NOT NULL, confirmation_state TEXT NOT NULL
|
|
);
|
|
CREATE TABLE IF NOT EXISTS supervision_events (
|
|
id TEXT PRIMARY KEY, story_line_id TEXT NOT NULL REFERENCES story_lines(id) ON DELETE CASCADE,
|
|
result_id TEXT NOT NULL REFERENCES supervision_results(id) ON DELETE CASCADE,
|
|
occurred_at TEXT NOT NULL, title TEXT NOT NULL, description TEXT NOT NULL, event_type TEXT NOT NULL
|
|
);
|
|
CREATE TABLE IF NOT EXISTS supervision_event_sources (
|
|
event_id TEXT NOT NULL REFERENCES supervision_events(id) ON DELETE CASCADE,
|
|
source_id TEXT NOT NULL REFERENCES supervision_sources(id), PRIMARY KEY(event_id, source_id)
|
|
);
|
|
CREATE TABLE IF NOT EXISTS supervision_event_entities (
|
|
event_id TEXT NOT NULL REFERENCES supervision_events(id) ON DELETE CASCADE,
|
|
entity_id TEXT NOT NULL REFERENCES supervision_entities(id), change_type TEXT NOT NULL,
|
|
PRIMARY KEY(event_id, entity_id)
|
|
);
|
|
CREATE TABLE IF NOT EXISTS supervision_relations (
|
|
id TEXT PRIMARY KEY, story_line_id TEXT NOT NULL REFERENCES story_lines(id) ON DELETE CASCADE,
|
|
from_entity_id TEXT NOT NULL REFERENCES supervision_entities(id),
|
|
to_entity_id TEXT NOT NULL REFERENCES supervision_entities(id), relation_type TEXT NOT NULL,
|
|
reason TEXT NOT NULL, confirmation_state TEXT NOT NULL,
|
|
UNIQUE(story_line_id, from_entity_id, to_entity_id, relation_type)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS supervision_events_time ON supervision_events(story_line_id, occurred_at);
|
|
PRAGMA user_version = 40;
|
|
COMMIT;
|
|
`)
|
|
}
|
|
if (version.user_version < 41) {
|
|
const columns = database.prepare('PRAGMA table_info(supervision_sources)').all() as Array<{ name: string }>
|
|
database.exec('BEGIN IMMEDIATE')
|
|
if (!columns.some((column) => column.name === 'locator_json')) {
|
|
database.exec('ALTER TABLE supervision_sources ADD COLUMN locator_json TEXT')
|
|
}
|
|
database.exec('PRAGMA user_version = 41; COMMIT;')
|
|
}
|
|
if (version.user_version < 42) {
|
|
database.exec('BEGIN IMMEDIATE')
|
|
try {
|
|
for (const table of ['supervision_entities', 'supervision_entity_changes', 'supervision_relations']) {
|
|
const columns = database.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>
|
|
if (!columns.some((column) => column.name === 'source_reference_ids_json')) {
|
|
database.exec(`ALTER TABLE ${table} ADD COLUMN source_reference_ids_json TEXT NOT NULL DEFAULT '[]'`)
|
|
}
|
|
}
|
|
database.exec('PRAGMA user_version = 42; COMMIT;')
|
|
} catch (error) {
|
|
database.exec('ROLLBACK')
|
|
throw error
|
|
}
|
|
}
|
|
if (version.user_version < 43) {
|
|
database.exec('BEGIN IMMEDIATE')
|
|
try {
|
|
database.exec("ALTER TABLE supervision_results ADD COLUMN graph_snapshot_json TEXT NOT NULL DEFAULT '{\"entities\":[],\"relations\":[]}'")
|
|
const results = database.prepare('SELECT id FROM supervision_results').all() as Array<{ id: string }>
|
|
for (const result of results) {
|
|
// Recover membership from stored references, not labels or local model IDs.
|
|
const entities = database.prepare(`SELECT DISTINCT e.* FROM supervision_entities e WHERE
|
|
EXISTS (SELECT 1 FROM supervision_event_entities ee JOIN supervision_events ev ON ev.id = ee.event_id
|
|
WHERE ee.entity_id = e.id AND ev.result_id = ?)
|
|
OR EXISTS (SELECT 1 FROM supervision_entity_changes c WHERE c.entity_id = e.id AND c.result_id = ?)
|
|
OR EXISTS (SELECT 1 FROM json_each(e.source_reference_ids_json) ref JOIN supervision_sources s ON s.id = ref.value WHERE s.result_id = ?)`)
|
|
.all(result.id, result.id, result.id) as Array<{ id: string }>
|
|
const ids = entities.map((entity) => entity.id)
|
|
const placeholders = ids.map(() => '?').join(',')
|
|
const relations = ids.length ? database.prepare(`SELECT * FROM supervision_relations
|
|
WHERE from_entity_id IN (${placeholders}) AND to_entity_id IN (${placeholders})`).all(...ids, ...ids) : []
|
|
database.prepare('UPDATE supervision_results SET graph_snapshot_json = ? WHERE id = ?')
|
|
.run(JSON.stringify({ entities, relations }), result.id)
|
|
}
|
|
database.exec('PRAGMA user_version = 43; COMMIT;')
|
|
} catch (error) {
|
|
database.exec('ROLLBACK')
|
|
throw error
|
|
}
|
|
}
|
|
if (version.user_version < 44) {
|
|
database.exec('BEGIN IMMEDIATE')
|
|
try {
|
|
const bytesBefore = onProgress && this.databasePath !== ':memory:'
|
|
? statSync(this.databasePath).size
|
|
: 0
|
|
onProgress?.({ stage: 'scanning', processed: 0, total: 0, bytesBefore })
|
|
if (isCancelled?.()) throw new DOMException('Upgrade cancelled', 'AbortError')
|
|
const row = database.prepare(
|
|
'SELECT records_json, legacy_history_may_be_incomplete FROM activity_history WHERE singleton = 1'
|
|
).get() as { records_json: string; legacy_history_may_be_incomplete: number }
|
|
onProgress?.({ stage: 'converting', processed: 0, total: 0, bytesBefore })
|
|
if (isCancelled?.()) throw new DOMException('Upgrade cancelled', 'AbortError')
|
|
const snapshot = activityHistorySnapshotSchema.parse({
|
|
records: JSON.parse(row.records_json),
|
|
legacyHistoryMayBeIncomplete: row.legacy_history_may_be_incomplete === 1
|
|
})
|
|
database.exec(`
|
|
CREATE TABLE activity_history_records (
|
|
record_key TEXT PRIMARY KEY NOT NULL,
|
|
record_json TEXT NOT NULL,
|
|
order_seq INTEGER NOT NULL DEFAULT 0
|
|
);
|
|
ALTER TABLE activity_history RENAME COLUMN records_json TO record_order_json;
|
|
`)
|
|
// Writes order_seq directly; schema 57 then only adds the indexed columns.
|
|
new ActivityHistoryRepository(database).writeSnapshot(snapshot, onProgress || isCancelled ? (processed) => {
|
|
onProgress?.({ stage: 'converting', processed, total: snapshot.records.length, bytesBefore })
|
|
if (isCancelled?.()) throw new DOMException('Upgrade cancelled', 'AbortError')
|
|
} : undefined)
|
|
onProgress?.({ stage: 'converting', processed: snapshot.records.length, total: snapshot.records.length, bytesBefore })
|
|
if (isCancelled?.()) throw new DOMException('Upgrade cancelled', 'AbortError')
|
|
database.exec('PRAGMA user_version = 44; COMMIT;')
|
|
} catch (error) {
|
|
database.exec('ROLLBACK')
|
|
throw error
|
|
}
|
|
}
|
|
if (version.user_version < 45) {
|
|
database.exec('BEGIN IMMEDIATE')
|
|
try {
|
|
for (const [table, column] of [
|
|
['supervision_runs', 'heartbeat_run_id'], ['heartbeat_runs', 'activity_scope_json'],
|
|
['heartbeat_runs', 'projection_status'], ['heartbeat_runs', 'projection_error'],
|
|
['heartbeat_runs', 'projection_completed_at']
|
|
]) {
|
|
const columns = database.prepare(`PRAGMA table_info(${table})`).all()
|
|
if (!columns.some((item) => item.name === column)) database.exec(`ALTER TABLE ${table} ADD COLUMN ${column} TEXT`)
|
|
}
|
|
database.exec(`
|
|
CREATE UNIQUE INDEX IF NOT EXISTS supervision_runs_heartbeat ON supervision_runs(heartbeat_run_id);
|
|
CREATE INDEX IF NOT EXISTS supervision_runs_started ON supervision_runs(started_at DESC);
|
|
CREATE INDEX IF NOT EXISTS supervision_results_run ON supervision_results(run_id);
|
|
PRAGMA user_version = 45;
|
|
COMMIT;
|
|
`)
|
|
} catch (error) {
|
|
database.exec('ROLLBACK')
|
|
throw error
|
|
}
|
|
}
|
|
if (version.user_version < 46) {
|
|
database.exec('PRAGMA foreign_keys = OFF; BEGIN IMMEDIATE;')
|
|
try {
|
|
for (const table of ['heartbeat_configs', 'heartbeat_runs']) {
|
|
const definition = database.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?").get(table) as { sql: string }
|
|
const indexes = database.prepare("SELECT sql FROM sqlite_master WHERE type = 'index' AND tbl_name = ? AND sql IS NOT NULL").all(table) as Array<{ sql: string }>
|
|
database.exec(definition.sql.replace(table, `${table}_incremental`).replaceAll("'failed', 'skipped'", "'failed', 'skipped', 'no_change'"))
|
|
database.exec(`INSERT INTO ${table}_incremental SELECT * FROM ${table}; DROP TABLE ${table}; ALTER TABLE ${table}_incremental RENAME TO ${table};`)
|
|
for (const index of indexes) database.exec(index.sql)
|
|
}
|
|
database.exec(`
|
|
CREATE TABLE review_checkpoints (
|
|
stage TEXT NOT NULL, scope TEXT NOT NULL, source TEXT NOT NULL,
|
|
revision TEXT NOT NULL, processed_offset INTEGER NOT NULL, source_length INTEGER NOT NULL,
|
|
PRIMARY KEY(stage, scope, source)
|
|
);
|
|
ALTER TABLE messages ADD COLUMN review_revision TEXT NOT NULL DEFAULT '';
|
|
CREATE TRIGGER messages_review_insert AFTER INSERT ON messages BEGIN
|
|
UPDATE messages SET review_revision = hex(randomblob(16)) WHERE id = NEW.id;
|
|
END;
|
|
CREATE TRIGGER messages_review_update AFTER UPDATE OF content ON messages
|
|
WHEN OLD.content IS NOT NEW.content
|
|
BEGIN
|
|
UPDATE messages SET review_revision = hex(randomblob(16)) WHERE id = NEW.id;
|
|
END;
|
|
CREATE TRIGGER messages_review_delete AFTER DELETE ON messages BEGIN
|
|
DELETE FROM review_checkpoints WHERE source = 'message:' || OLD.id;
|
|
DELETE FROM review_checkpoints WHERE source LIKE 'knowledge:' || OLD.id || ':%';
|
|
END;
|
|
CREATE TRIGGER tasks_review_delete AFTER DELETE ON tasks BEGIN
|
|
DELETE FROM review_checkpoints WHERE source = 'task:' || OLD.id;
|
|
END;
|
|
PRAGMA user_version = 46;
|
|
COMMIT;`)
|
|
} catch (error) {
|
|
database.exec('ROLLBACK')
|
|
throw error
|
|
} finally {
|
|
database.exec('PRAGMA foreign_keys = ON')
|
|
}
|
|
}
|
|
if (version.user_version < 47) {
|
|
database.exec('BEGIN IMMEDIATE')
|
|
try {
|
|
database.exec(supervisionReviewMigration)
|
|
database.exec('PRAGMA user_version = 47; COMMIT;')
|
|
} catch (error) { database.exec('ROLLBACK'); throw error }
|
|
}
|
|
if (version.user_version < 48) {
|
|
database.exec('BEGIN IMMEDIATE')
|
|
try {
|
|
database.exec('ALTER TABLE magic_note_entries ADD COLUMN source_json TEXT;')
|
|
database.exec('PRAGMA user_version = 48; COMMIT;')
|
|
} catch (error) { database.exec('ROLLBACK'); throw error }
|
|
}
|
|
if (version.user_version < 49) {
|
|
database.exec('BEGIN IMMEDIATE')
|
|
try {
|
|
migrateRuntimeSelectionLayers(database)
|
|
database.exec('PRAGMA user_version = 49; COMMIT;')
|
|
} catch (error) { database.exec('ROLLBACK'); throw error }
|
|
}
|
|
if (version.user_version < 50) {
|
|
database.exec('BEGIN IMMEDIATE')
|
|
try {
|
|
// The heartbeat no longer writes its own report. It triggers the shared
|
|
// incremental review and, per plan, derives suggestions from the published graph.
|
|
const configColumns = database.prepare('PRAGMA table_info(heartbeat_configs)').all()
|
|
if (!configColumns.some((item) => item.name === 'intervention')) {
|
|
database.exec("ALTER TABLE heartbeat_configs ADD COLUMN intervention TEXT NOT NULL DEFAULT 'suggest'")
|
|
}
|
|
const runColumns = database.prepare('PRAGMA table_info(heartbeat_runs)').all()
|
|
for (const column of ['suggestion_status', 'suggestion_error']) {
|
|
if (!runColumns.some((item) => item.name === column)) database.exec(`ALTER TABLE heartbeat_runs ADD COLUMN ${column} TEXT`)
|
|
}
|
|
database.exec(`
|
|
CREATE TABLE IF NOT EXISTS supervision_suggestions (
|
|
id TEXT PRIMARY KEY,
|
|
result_id TEXT REFERENCES supervision_results(id) ON DELETE SET NULL,
|
|
heartbeat_run_id TEXT,
|
|
scope_json TEXT NOT NULL,
|
|
kind TEXT NOT NULL CHECK (kind IN ('open_item', 'conflict', 'convention', 'revision')),
|
|
fingerprint TEXT NOT NULL,
|
|
title TEXT NOT NULL,
|
|
detail TEXT NOT NULL,
|
|
source_reference_ids_json TEXT NOT NULL DEFAULT '[]',
|
|
entity_id TEXT,
|
|
relation_id TEXT,
|
|
memory_id TEXT,
|
|
task_id TEXT,
|
|
evidence_key TEXT NOT NULL DEFAULT '',
|
|
status TEXT NOT NULL CHECK (status IN ('pending', 'accepted', 'dismissed')),
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
CREATE INDEX IF NOT EXISTS supervision_suggestions_status ON supervision_suggestions(status, created_at DESC);
|
|
CREATE INDEX IF NOT EXISTS supervision_suggestions_fingerprint ON supervision_suggestions(scope_json, fingerprint);
|
|
`)
|
|
// Carry pending memory proposals from old heartbeat reports forward as
|
|
// unconfirmed conventions. Nothing is confirmed or deleted.
|
|
const timestamp = new Date().toISOString()
|
|
const proposals = database.prepare(`SELECT DISTINCT m.id, m.scope, m.scope_id, m.content FROM heartbeat_entries e
|
|
JOIN json_each(e.proposed_memory_ids_json) j JOIN memory_items m ON m.id = j.value
|
|
WHERE m.status = 'proposed'`).all() as Array<{ id: string; scope: string; scope_id: string | null; content: string }>
|
|
const insert = database.prepare(`INSERT INTO supervision_suggestions
|
|
(id, result_id, heartbeat_run_id, scope_json, kind, fingerprint, title, detail, memory_id, status, created_at, updated_at)
|
|
VALUES (?, NULL, NULL, ?, 'convention', ?, ?, ?, ?, 'pending', ?, ?)`)
|
|
for (const proposal of proposals) {
|
|
const scope = proposal.scope === 'project' && proposal.scope_id
|
|
? { kind: 'projects', projectIds: [proposal.scope_id] } : { kind: 'global' }
|
|
insert.run(randomUUID(), JSON.stringify(scope), `memory:${proposal.id}`, proposal.content.slice(0, 200),
|
|
proposal.content, proposal.id, timestamp, timestamp)
|
|
}
|
|
database.exec('PRAGMA user_version = 50; COMMIT;')
|
|
} catch (error) { database.exec('ROLLBACK'); throw error }
|
|
}
|
|
if (version.user_version < 51) {
|
|
database.exec('BEGIN IMMEDIATE')
|
|
try {
|
|
// Manual note tags. Names compare by name_key (NFC, trimmed, lower-case);
|
|
// a tag with no remaining links is removed by the write that orphaned it.
|
|
database.exec(`
|
|
CREATE TABLE IF NOT EXISTS magic_note_tags (
|
|
id TEXT PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
name_key TEXT NOT NULL UNIQUE,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
CREATE TABLE IF NOT EXISTS magic_note_tag_links (
|
|
note_id TEXT NOT NULL REFERENCES magic_notes(id) ON DELETE CASCADE,
|
|
tag_id TEXT NOT NULL REFERENCES magic_note_tags(id) ON DELETE CASCADE,
|
|
position INTEGER NOT NULL,
|
|
created_at TEXT NOT NULL,
|
|
PRIMARY KEY (note_id, tag_id)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS magic_note_tag_links_tag_idx ON magic_note_tag_links(tag_id, note_id);
|
|
`)
|
|
database.exec('PRAGMA user_version = 51; COMMIT;')
|
|
} catch (error) { database.exec('ROLLBACK'); throw error }
|
|
}
|
|
if (version.user_version < 52) {
|
|
database.exec('BEGIN IMMEDIATE')
|
|
try {
|
|
// Execution stats poll every few seconds. Filtering lifecycle and remote
|
|
// rows through task_events_task_idx reads every large stream payload row.
|
|
database.exec(`
|
|
CREATE INDEX IF NOT EXISTS task_events_lifecycle_idx
|
|
ON task_events(task_id, id) WHERE kind IN ('status', 'done', 'error');
|
|
CREATE INDEX IF NOT EXISTS task_events_remote_task_idx
|
|
ON task_events(task_id) WHERE remote_operation_id IS NOT NULL;
|
|
`)
|
|
database.exec('PRAGMA user_version = 52; COMMIT;')
|
|
} catch (error) { database.exec('ROLLBACK'); throw error }
|
|
}
|
|
if (version.user_version < 53) {
|
|
database.exec('BEGIN IMMEDIATE')
|
|
try {
|
|
migrateSupervisionTimeline(database)
|
|
database.exec('PRAGMA user_version = 53; COMMIT;')
|
|
} catch (error) { database.exec('ROLLBACK'); throw error }
|
|
}
|
|
if (version.user_version < 54) {
|
|
database.exec('BEGIN IMMEDIATE')
|
|
try {
|
|
// Stories start empty: existing events are assigned by the next review, without rereading sources.
|
|
database.exec(supervisionStoriesMigration)
|
|
database.exec('PRAGMA user_version = 54; COMMIT;')
|
|
} catch (error) { database.exec('ROLLBACK'); throw error }
|
|
}
|
|
if (version.user_version < 55) {
|
|
database.exec('BEGIN IMMEDIATE')
|
|
try {
|
|
// Experiences start empty: the next review extracts them from existing stories, without rereading sources.
|
|
database.exec(supervisionExperiencesMigration)
|
|
database.exec('PRAGMA user_version = 55; COMMIT;')
|
|
} catch (error) { database.exec('ROLLBACK'); throw error }
|
|
}
|
|
if (version.user_version < 56) {
|
|
database.exec('BEGIN IMMEDIATE')
|
|
try {
|
|
// Story-level suggestions: stalled stories and experiences that may apply elsewhere.
|
|
// The kind CHECK widens by rebuilding the table; existing rows are copied unchanged.
|
|
database.exec(`
|
|
CREATE TABLE supervision_suggestions_v56 (
|
|
id TEXT PRIMARY KEY,
|
|
result_id TEXT REFERENCES supervision_results(id) ON DELETE SET NULL,
|
|
heartbeat_run_id TEXT,
|
|
scope_json TEXT NOT NULL,
|
|
kind TEXT NOT NULL CHECK (kind IN ('open_item', 'conflict', 'convention', 'revision', 'stalled', 'experience')),
|
|
fingerprint TEXT NOT NULL,
|
|
title TEXT NOT NULL,
|
|
detail TEXT NOT NULL,
|
|
source_reference_ids_json TEXT NOT NULL DEFAULT '[]',
|
|
entity_id TEXT,
|
|
relation_id TEXT,
|
|
memory_id TEXT,
|
|
task_id TEXT,
|
|
evidence_key TEXT NOT NULL DEFAULT '',
|
|
status TEXT NOT NULL CHECK (status IN ('pending', 'accepted', 'dismissed')),
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL,
|
|
story_id TEXT,
|
|
experience_id TEXT
|
|
);
|
|
INSERT INTO supervision_suggestions_v56 (id, result_id, heartbeat_run_id, scope_json, kind, fingerprint, title, detail,
|
|
source_reference_ids_json, entity_id, relation_id, memory_id, task_id, evidence_key, status, created_at, updated_at)
|
|
SELECT id, result_id, heartbeat_run_id, scope_json, kind, fingerprint, title, detail,
|
|
source_reference_ids_json, entity_id, relation_id, memory_id, task_id, evidence_key, status, created_at, updated_at
|
|
FROM supervision_suggestions;
|
|
DROP TABLE supervision_suggestions;
|
|
ALTER TABLE supervision_suggestions_v56 RENAME TO supervision_suggestions;
|
|
CREATE INDEX IF NOT EXISTS supervision_suggestions_status ON supervision_suggestions(status, created_at DESC);
|
|
CREATE INDEX IF NOT EXISTS supervision_suggestions_fingerprint ON supervision_suggestions(scope_json, fingerprint);
|
|
`)
|
|
database.exec('PRAGMA user_version = 56; COMMIT;')
|
|
} catch (error) { database.exec('ROLLBACK'); throw error }
|
|
}
|
|
if (version.user_version < 57) {
|
|
database.exec('BEGIN IMMEDIATE')
|
|
try {
|
|
// Activity history order and indexed columns (PERF-15): pages read by index,
|
|
// and a move to the front no longer rewrites the order list.
|
|
migrateActivityHistoryOrder(database)
|
|
database.exec('PRAGMA user_version = 57; COMMIT;')
|
|
} catch (error) { database.exec('ROLLBACK'); throw error }
|
|
}
|
|
if (version.user_version < 58) {
|
|
// Startup recovery reads candidate messages by index instead of scanning all (PERF-15).
|
|
database.exec(`
|
|
BEGIN IMMEDIATE;
|
|
CREATE INDEX IF NOT EXISTS messages_recovery_idx ON messages(id)
|
|
WHERE ${MESSAGE_RECOVERY_CANDIDATE_SQL};
|
|
PRAGMA user_version = 58;
|
|
COMMIT;
|
|
`)
|
|
}
|
|
if (version.user_version < 59) {
|
|
database.exec('BEGIN IMMEDIATE')
|
|
try {
|
|
const columns = database.prepare('PRAGMA table_info(conversations)').all()
|
|
if (!columns.some(column => column.name === 'timing_incomplete')) {
|
|
database.exec(`
|
|
CREATE TABLE execution_timing (
|
|
task_id TEXT PRIMARY KEY REFERENCES tasks(id) ON DELETE CASCADE,
|
|
project_id TEXT, conversation_id TEXT, owner_id TEXT,
|
|
duration_ms INTEGER NOT NULL DEFAULT 0,
|
|
running_since INTEGER,
|
|
incomplete INTEGER NOT NULL DEFAULT 0
|
|
);
|
|
CREATE INDEX execution_timing_project ON execution_timing(project_id);
|
|
CREATE INDEX execution_timing_conversation ON execution_timing(conversation_id);
|
|
INSERT INTO execution_timing (task_id, project_id, conversation_id, owner_id, incomplete)
|
|
SELECT id, project_id, conversation_id, id, 1 FROM tasks WHERE visible = 1 AND origin != 'subagent';
|
|
ALTER TABLE conversations ADD COLUMN timing_incomplete INTEGER NOT NULL DEFAULT 0;
|
|
UPDATE conversations SET timing_incomplete = 1;
|
|
CREATE INDEX conversations_timing_incomplete ON conversations(project_id) WHERE timing_incomplete = 1;
|
|
CREATE INDEX messages_summary_idx ON messages(conversation_id, sequence, role, created_at);
|
|
`)
|
|
}
|
|
database.exec('PRAGMA user_version = 59; COMMIT;')
|
|
} catch (error) { database.exec('ROLLBACK'); throw error }
|
|
}
|
|
if (version.user_version < 60) {
|
|
// Run after the historical migrations that still require the SQL columns,
|
|
// and before startup recovery parses messages with the current contracts.
|
|
const targets = [
|
|
['conversations', 'context_state_json'],
|
|
['schedules', 'task_template_json'],
|
|
['conversation_queue_items', 'payload_json'],
|
|
['task_events', 'payload_json'],
|
|
['messages', 'metadata_json']
|
|
] as const
|
|
const bytesBefore = this.databasePath === ':memory:' ? 0 : statSync(this.databasePath).size
|
|
let processed = 0
|
|
for (const [table, column] of targets) {
|
|
const select = database.prepare(`SELECT rowid AS position, ${column} AS payload
|
|
FROM ${table} WHERE rowid > ? AND ${column} IS NOT NULL ORDER BY rowid LIMIT 32`)
|
|
const update = database.prepare(`UPDATE ${table} SET ${column} = ? WHERE rowid = ?`)
|
|
let after = 0
|
|
for (;;) {
|
|
if (isCancelled?.()) throw new DOMException('Upgrade cancelled', 'AbortError')
|
|
const rows = select.all(after) as Array<{ position: number; payload: string }>
|
|
if (!rows.length) break
|
|
database.exec('BEGIN IMMEDIATE')
|
|
try {
|
|
for (const row of rows) {
|
|
let payload: Record<string, unknown>
|
|
let original: string
|
|
try {
|
|
const value: unknown = JSON.parse(row.payload)
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Expected object')
|
|
payload = value as Record<string, unknown>
|
|
original = JSON.stringify(payload)
|
|
if (table === 'messages') {
|
|
if (payload.subagents !== undefined) {
|
|
if (!Array.isArray(payload.subagents) || payload.subagents.some(
|
|
item => !item || typeof item !== 'object' || Array.isArray(item)
|
|
)) throw new Error('Expected subagent objects')
|
|
payload.subagents = payload.subagents.map(withoutHistoricalWorkMode)
|
|
}
|
|
} else {
|
|
delete payload.workMode
|
|
if (table === 'conversation_queue_items' && payload.input !== undefined) {
|
|
if (!payload.input || typeof payload.input !== 'object' || Array.isArray(payload.input)) {
|
|
throw new Error('Expected queue input object')
|
|
}
|
|
payload.input = withoutHistoricalWorkMode(payload.input)
|
|
}
|
|
}
|
|
} catch {
|
|
throw new Error(`Invalid execution metadata: ${table}.${column} row ${row.position}`)
|
|
}
|
|
const json = JSON.stringify(payload)
|
|
if (json !== original) update.run(json, row.position)
|
|
after = row.position
|
|
}
|
|
database.exec('COMMIT')
|
|
} catch (error) { database.exec('ROLLBACK'); throw error }
|
|
processed += rows.length
|
|
onProgress?.({ stage: 'converting', processed, total: 0, bytesBefore })
|
|
}
|
|
}
|
|
if (isCancelled?.()) throw new DOMException('Upgrade cancelled', 'AbortError')
|
|
database.exec('BEGIN IMMEDIATE')
|
|
try {
|
|
database.exec(`
|
|
ALTER TABLE projects DROP COLUMN default_work_mode;
|
|
ALTER TABLE conversations DROP COLUMN work_mode;
|
|
ALTER TABLE tasks DROP COLUMN work_mode;
|
|
PRAGMA user_version = 60;
|
|
COMMIT;
|
|
`)
|
|
} catch (error) { database.exec('ROLLBACK'); throw error }
|
|
}
|
|
}
|
|
|
|
supervisionExperiences(): SupervisionExperienceStore {
|
|
return new SupervisionExperienceStore(this.requireDatabase())
|
|
}
|
|
|
|
supervisionStories(): SupervisionStoryStore {
|
|
return new SupervisionStoryStore(this.requireDatabase())
|
|
}
|
|
|
|
supervisionReviewStore(signal?: AbortSignal): SupervisionReviewStore {
|
|
return new SupervisionReviewStore(this.requireDatabase(), signal)
|
|
}
|
|
|
|
async initializeSupervisionReview(runId: string, state: import('./supervision-review-store').ReviewState, signal: AbortSignal): Promise<void> {
|
|
const store = this.supervisionReviewStore()
|
|
store.prepareInitialization(runId, state)
|
|
await this.runSupervisionWorker('initialize', [runId, state], signal)
|
|
}
|
|
|
|
async resumeSupervisionReview(runId: string, signal: AbortSignal): Promise<void> {
|
|
await this.runSupervisionWorker('resume', [runId], signal)
|
|
}
|
|
|
|
private async runSupervisionWorker(op: 'initialize' | 'resume', args: unknown[], signal: AbortSignal): Promise<void> {
|
|
if (this.databasePath === ':memory:') {
|
|
const store = this.supervisionReviewStore()
|
|
if (op === 'resume') store.resume(String(args[0]), signal)
|
|
else store.initializeSources(String(args[0]), args[1] as import('./supervision-review-store').ReviewState, signal)
|
|
return
|
|
}
|
|
// Fail explicitly when packaging/startup is broken; never freeze Main as a fallback.
|
|
if (!this.readonlyWorkerPath) throw new Error('Supervision worker is not configured')
|
|
this.supervisionWorker ??= new ReadonlyQueryReader('supervision', this.databasePath, this.readonlyWorkerPath)
|
|
await this.supervisionWorker.call(op, args, signal)
|
|
}
|
|
|
|
async supervisionContext(request: SupervisionRunRequest, signal?: AbortSignal): Promise<{ summary: string | undefined; background: ReviewBatch['evidence'] }> {
|
|
const reader = this.readonlyQueryReader()
|
|
if (reader) return reader.call('reviewContext', [request], signal)
|
|
if (this.databasePath !== ':memory:') throw new Error('Supervision worker is not configured')
|
|
return { summary: this.reviewSummary(request.scope, 'supervisor'), background: this.reviewBackground(request.scope) }
|
|
}
|
|
|
|
async supervisionCandidates(request: SupervisionRunRequest): Promise<ReturnType<AssistantDatabase['listSupervisionCandidates']>> {
|
|
const reader = this.readonlyQueryReader()
|
|
if (reader) return reader.call('reviewCandidates', [request])
|
|
if (this.databasePath !== ':memory:') throw new Error('Supervision worker is not configured')
|
|
return this.listSupervisionCandidates(request)
|
|
}
|
|
|
|
supervisionSuggestions(): SupervisionSuggestionStore {
|
|
return new SupervisionSuggestionStore(this.requireDatabase())
|
|
}
|
|
|
|
/**
|
|
* Act on a suggestion. Open items become paused follow-up tasks; conventions
|
|
* become confirmed long-term background; conflicts and revisions are marked reviewed.
|
|
*/
|
|
resolveSupervisionSuggestion(id: string, action: 'accept' | 'dismiss'): SupervisionSuggestion {
|
|
const store = this.supervisionSuggestions()
|
|
const suggestion = store.get(id)
|
|
if (!suggestion) throw new Error('此建议已处理或不存在')
|
|
if (suggestion.status !== 'pending') throw new Error('此建议已处理或不存在')
|
|
const projectId = suggestion.scope.kind === 'projects' && suggestion.scope.projectIds.length === 1
|
|
? suggestion.scope.projectIds[0] : undefined
|
|
if (action === 'dismiss') {
|
|
if (suggestion.memoryId) this.setMemoryStatus(suggestion.memoryId, 'rejected')
|
|
store.resolve(id, 'dismissed')
|
|
return store.get(id)!
|
|
}
|
|
// A stalled story is resumed through a paused follow-up task, like an open item.
|
|
if (suggestion.kind === 'open_item' || suggestion.kind === 'stalled') {
|
|
const task = this.createTask({ id: randomUUID(), projectId, title: suggestion.title.slice(0, 200), instructions: suggestion.detail,
|
|
origin: 'assistant', status: 'paused' })
|
|
store.resolve(id, 'accepted', { taskId: task.id })
|
|
} else if (suggestion.kind === 'convention') {
|
|
let memoryId = suggestion.memoryId ?? undefined
|
|
if (memoryId) this.setMemoryStatus(memoryId, 'confirmed')
|
|
else memoryId = this.createMemory({ scope: projectId ? 'project' : 'global', scopeId: projectId, type: 'preference',
|
|
content: suggestion.detail || suggestion.title }).id
|
|
if (suggestion.entityId) this.applySupervisionEntityAction({ entityId: suggestion.entityId, action: 'confirm' })
|
|
store.resolve(id, 'accepted', { memoryId })
|
|
} else store.resolve(id, 'accepted')
|
|
return store.get(id)!
|
|
}
|
|
|
|
private requireDatabase(): DatabaseSync {
|
|
if (!this.database) {
|
|
throw new Error('助手数据库尚未初始化')
|
|
}
|
|
if (this.dirtyMagicNotes.size && !this.database.isTransaction) {
|
|
this.repairMagicNoteStorage(undefined, undefined, this.dirtyMagicNotes)
|
|
}
|
|
if (!this.database.isTransaction) {
|
|
for (const noteId of [...this.pendingMagicNoteCleanup]) this.reconcileMagicNoteFiles(noteId)
|
|
}
|
|
return this.database
|
|
}
|
|
}
|
|
import type { ImageOperation } from '../../shared/image-generation-contracts'
|
|
import { SupervisionReviewStore, supervisionReviewMigration } from './supervision-review-store'
|
|
import { SupervisionStoryStore, supervisionStoriesMigration } from './supervision-stories'
|
|
import { SupervisionExperienceStore, supervisionExperiencesMigration } from './supervision-experiences'
|
|
import { migrateSupervisionTimeline, placeTimelineEvents, supersedeReextractedEvents, supervisionAttention, timelineCandidates } from './supervision-timeline'
|
|
import { SupervisionSuggestionStore } from './supervision-suggestions'
|
|
import type { SupervisionSuggestion } from '../../shared/supervision-contracts'
|
|
import { readStoryGraph } from './story-graph-reader'
|