Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s
12316 lines
432 KiB
TypeScript
12316 lines
432 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 { 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 = 62
|
|
|
|
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)`
|
|
|
|
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,
|
|
outputReferences: event.outputReferences,
|
|
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,
|
|
outputReference: event.outputReference,
|
|
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 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.executionTiming = 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: '钉钉远程消息与受控任务'
|
|
},
|
|
{
|
|
channel: 'telegram',
|
|
name: 'Telegram',
|
|
description: 'Telegram 远程消息与受控任务'
|
|
}
|
|
]
|
|
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))
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
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)
|
|
// Older pending manifests grouped tasks without their conversation owner.
|
|
database.prepare(`UPDATE supervision_review_sources SET conversation_id = ?
|
|
WHERE source IN (SELECT 'task:' || id FROM tasks WHERE conversation_id = ?)
|
|
AND run_id IN (SELECT id FROM supervision_runs WHERE status IN ('running', 'paused', 'failed'))`)
|
|
.run(conversationId, 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
|
|
if (deleted) {
|
|
const reviews = this.supervisionReviewStore()
|
|
for (const row of database.prepare('SELECT DISTINCT run_id FROM supervision_review_sources WHERE conversation_id = ?').all(conversationId)) {
|
|
reviews.omitDeletedConversations(String(row.run_id))
|
|
}
|
|
}
|
|
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' | 'terminal'
|
|
): 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 ? IN ('delivered', 'terminal') OR attempts + 1 >= 5
|
|
THEN json_remove(message_json, '$.attachments')
|
|
ELSE message_json
|
|
END
|
|
WHERE id = ? AND state NOT IN ('delivered', 'terminal')`
|
|
)
|
|
.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 (state = 'terminal') ASC, attempts ASC, created_at ASC, id ASC
|
|
) AS position,
|
|
SUM(LENGTH(CAST(message_json AS BLOB))) OVER (
|
|
ORDER BY (state = 'terminal') ASC, attempts ASC, created_at ASC, id 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 (state = 'terminal') ASC, attempts ASC, created_at ASC, id 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
|
|
ORDER BY note_count DESC, t.name_key ASC`
|
|
)
|
|
.all() as MagicNoteTagRow[]
|
|
return rows.map(toMagicNoteTag)
|
|
}
|
|
|
|
createMagicNoteTag(input: { name: string }): MagicNoteTag {
|
|
const database = this.requireDatabase()
|
|
const now = new Date().toISOString()
|
|
const id = randomUUID()
|
|
const key = magicNoteTagKey(input.name)
|
|
try {
|
|
database.prepare(
|
|
`INSERT INTO magic_note_tags (id, name, name_key, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?)`
|
|
).run(id, input.name, key, now, now)
|
|
} catch (error) {
|
|
if (error instanceof Error && error.message.includes('UNIQUE')) {
|
|
throw new Error('标签已存在', { cause: error })
|
|
}
|
|
throw error
|
|
}
|
|
this.options.onMagicNotesChanged?.()
|
|
return { id, name: input.name, noteCount: 0 }
|
|
}
|
|
|
|
/**
|
|
* 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)
|
|
})
|
|
}
|
|
|
|
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('笔记不存在')
|
|
}
|
|
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 })
|
|
}
|
|
|
|
/**
|
|
* Opens a connection used only to checkpoint the WAL from a worker thread
|
|
* owned by DesktopStorageOwner. It never writes business rows.
|
|
*/
|
|
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 }
|
|
}
|
|
|
|
/**
|
|
* Runs multi-statement reads against one WAL snapshot. Used by the readonly
|
|
* worker, where the storage owner may commit between statements.
|
|
*/
|
|
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
|
|
}
|
|
}
|
|
|
|
/** 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))
|
|
}
|
|
|
|
/** 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 {
|
|
if (result.coverage && result.runId) this.supervisionReviewStore().assertComplete(result.runId, result.coverage.omittedSources ?? 0)
|
|
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 graph: { entities: Record<string, unknown>[]; relations: Record<string, unknown>[] } = { entities: [], relations: [] }
|
|
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)
|
|
}))
|
|
for (const entity of entities) graph.entities.push(entity)
|
|
for (const relation of relations) graph.relations.push(relation)
|
|
}
|
|
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(?) GROUP BY json_extract(value, '$.id')))),
|
|
'relations', json((SELECT json_group_array(json(value)) FROM (
|
|
SELECT value FROM json_each(?) GROUP BY json_extract(value, '$.id')))))
|
|
WHERE id = ?`).run(JSON.stringify(graph.entities), JSON.stringify(graph.relations), resultId)
|
|
this.supervisionReviewStore().commitCheckpoints(result.runId, result.request)
|
|
} else {
|
|
saveFacts(result)
|
|
database.prepare('UPDATE supervision_results SET graph_snapshot_json = ? WHERE id = ?').run(JSON.stringify(graph), resultId)
|
|
}
|
|
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)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
getSupervisionStories(scope: SupervisionRunRequest['scope']) {
|
|
const stories = this.supervisionStories()
|
|
return { stories: stories.list(scope), experiences: this.supervisionExperiences().list(scope.kind === 'projects' ? scope.projectIds : undefined),
|
|
unassigned: stories.unassignedCount(scope), canUndo: stories.canUndo() }
|
|
}
|
|
|
|
listSupervisionResults(limit = 20, target?: SupervisionTarget, resultId?: string) {
|
|
const owner = target?.type === 'conversation'
|
|
? this.requireDatabase().prepare('SELECT project_id AS projectId FROM conversations WHERE id = ?').get(target.conversationId) as { projectId: string | null } | undefined
|
|
: target?.type === 'task'
|
|
? this.requireDatabase().prepare('SELECT project_id AS projectId FROM tasks WHERE id = ?').get(target.taskId) as { projectId: string | null } | undefined
|
|
: 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 r.graph_snapshot_json, sr.scope_json, sr.time_range_json
|
|
FROM supervision_results r JOIN supervision_runs sr ON sr.id = r.run_id WHERE r.id = ?`).get(input.resultId) as {
|
|
graph_snapshot_json: string; scope_json: string; time_range_json: string
|
|
}
|
|
const snapshot = JSON.parse(row.graph_snapshot_json) as { entities: unknown[]; relations: Array<{ confirmation_state: string }> }
|
|
const scope = JSON.parse(row.scope_json) as SupervisionRunRequest['scope']
|
|
const range = JSON.parse(row.time_range_json) as SupervisionRunRequest['timeRange']
|
|
return {
|
|
storyLine: story, ...snapshot, relations: snapshot.relations.filter((relation) => relation.confirmation_state !== 'revoked'),
|
|
attention: supervisionAttention(database, scope.kind === 'projects' ? scope.projectIds : undefined,
|
|
new Date(range.from).toISOString(), new Date(range.to).toISOString()),
|
|
events: database.prepare('SELECT * FROM supervision_events WHERE result_id = ? ORDER BY occurred_at').all(input.resultId),
|
|
sources: database.prepare('SELECT id, title, occurred_at FROM supervision_sources WHERE result_id = ? ORDER BY rowid').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 id, title, occurred_at 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 = ?) ORDER BY rowid`).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', 'telegram')
|
|
);
|
|
`)
|
|
}
|
|
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', 'telegram')
|
|
);
|
|
`)
|
|
}
|
|
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 }
|
|
}
|
|
if (version.user_version < 61) {
|
|
database.exec('BEGIN IMMEDIATE')
|
|
try {
|
|
// SQLite's metadata-only CHECK relaxation preserves table rows, rowids,
|
|
// indexes and references without rebuilding potentially large conversations.
|
|
const previous = "channel IN ('weixin', 'wecom', 'dingtalk')"
|
|
const expanded = "channel IN ('weixin', 'wecom', 'dingtalk', 'telegram')"
|
|
const schemaVersion = database.prepare('PRAGMA schema_version').get() as { schema_version: number }
|
|
database.enableDefensive(false)
|
|
for (const table of ['projects', 'conversations']) {
|
|
const definition = database.prepare("SELECT sql FROM sqlite_schema WHERE type = 'table' AND name = ?").get(table) as { sql: string }
|
|
if (definition.sql.includes(expanded)) continue
|
|
if (!definition.sql.includes(previous)) throw new Error(`Unrecognized channel constraint: ${table}`)
|
|
database.exec('PRAGMA writable_schema = ON')
|
|
database.prepare("UPDATE sqlite_schema SET sql = ? WHERE type = 'table' AND name = ?")
|
|
.run(definition.sql.replace(previous, expanded), table)
|
|
}
|
|
database.exec(`PRAGMA schema_version = ${schemaVersion.schema_version + 1}; PRAGMA writable_schema = RESET;`)
|
|
// Reparse with writable_schema disabled so invalid definitions roll back.
|
|
database.prepare('SELECT channel FROM projects LIMIT 0').all()
|
|
database.prepare('SELECT channel FROM conversations LIMIT 0').all()
|
|
database.exec('PRAGMA user_version = 61; COMMIT;')
|
|
} catch (error) {
|
|
database.exec('ROLLBACK')
|
|
throw error
|
|
} finally {
|
|
database.exec('PRAGMA writable_schema = RESET')
|
|
database.enableDefensive(true)
|
|
}
|
|
}
|
|
if (version.user_version < 62) {
|
|
database.exec('BEGIN IMMEDIATE')
|
|
try {
|
|
database.exec(`
|
|
CREATE INDEX IF NOT EXISTS supervision_sources_result ON supervision_sources(result_id, occurred_at, source_type, id);
|
|
CREATE INDEX IF NOT EXISTS supervision_results_created ON supervision_results(created_at);
|
|
PRAGMA user_version = 62;
|
|
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)
|
|
}
|
|
|
|
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'
|