162 lines
8.8 KiB
JavaScript
162 lines
8.8 KiB
JavaScript
import { createHash } from 'node:crypto'
|
||
import { mkdir, readFile, writeFile, rename } from 'node:fs/promises'
|
||
import { join } from 'node:path'
|
||
import { ChromeGatewayClient } from './chrome-bridge.mjs'
|
||
|
||
const digest = value => createHash('sha256').update(JSON.stringify(value)).digest('hex')
|
||
const identifier = value => typeof value === 'string' && /^[a-zA-Z0-9_.:-]{1,160}$/.test(value)
|
||
|
||
// Reserved BEFORE the RPC: a crash/timeout is indeterminate, never safe to resend.
|
||
// Journal stores only hashes and delivery state, no prompt, token, or result.
|
||
export class DeliveryJournal {
|
||
constructor(directory) { this.directory = directory; this.writes = Promise.resolve() }
|
||
async reserve(deviceId, taskId, envelope) {
|
||
if (!identifier(deviceId) || !identifier(taskId)) throw new Error('Geçersiz görev kimliği.')
|
||
await mkdir(this.directory, { recursive: true })
|
||
const file = join(this.directory, `${digest([deviceId, taskId])}.json`)
|
||
const hash = digest(envelope)
|
||
try {
|
||
await writeFile(file, JSON.stringify({ hash, state: 'unknown' }), { flag: 'wx', mode: 0o600, flush: true })
|
||
return { file, hash, fresh: true }
|
||
} catch (error) {
|
||
if (error.code !== 'EEXIST') throw error
|
||
let previous
|
||
try { previous = JSON.parse(await readFile(file, 'utf8')) }
|
||
catch { return { file, hash, fresh: false, state: 'unknown' } }
|
||
if (previous.hash !== hash) throw new Error('Bu görev kimliği farklı bir içerikle kullanılmış.')
|
||
return { file, hash, fresh: false, state: previous.state }
|
||
}
|
||
}
|
||
async accepted(reservation) {
|
||
return this.update(reservation, 'accepted')
|
||
}
|
||
update(reservation, state) {
|
||
const operation = this.writes.then(async () => {
|
||
const previous = JSON.parse(await readFile(reservation.file, 'utf8'))
|
||
if (['complete', 'error', 'interrupted'].includes(previous.state)) return
|
||
const temporary = `${reservation.file}.tmp`
|
||
await writeFile(temporary, JSON.stringify({ hash: reservation.hash, state }), { mode: 0o600, flush: true })
|
||
await rename(temporary, reservation.file)
|
||
})
|
||
this.writes = operation.catch(() => {})
|
||
return operation
|
||
}
|
||
}
|
||
|
||
export class MobileGatewayClient extends ChromeGatewayClient {
|
||
constructor(resolveBackend, emit, { deviceId, journal, authorize, webSocket } = {}) {
|
||
super(resolveBackend, emit, webSocket)
|
||
this.deviceId = deviceId
|
||
this.journal = journal
|
||
this.authorize = authorize
|
||
this.approval = null
|
||
this.model = null
|
||
this.chain = Promise.resolve()
|
||
this.tasks = new Map()
|
||
}
|
||
|
||
onEvent(event) {
|
||
if (['task.start', 'task.complete'].includes(event?.type)) {
|
||
const task = this.tasks.get(event.payload?.task_id)
|
||
if (!task || task.runtimeId !== event.session_id || task.terminal) return
|
||
const state = event.type === 'task.start' ? 'running' : event.payload.status
|
||
if (!['running', 'complete', 'error', 'interrupted'].includes(state)) return
|
||
task.terminal = state !== 'running'
|
||
task.state = state
|
||
if (task.terminal) this.journal.update(task.reservation, state).catch(() => {})
|
||
this.emit({ event: event.type, taskId: task.taskId, sessionId: task.sessionId, status: state,
|
||
text: typeof event.payload.text === 'string' ? event.payload.text.slice(0, 100000) : '' })
|
||
return
|
||
}
|
||
if (!this.selected || event?.session_id !== this.selected.runtimeId) return
|
||
if (event.type === 'approval.request') {
|
||
this.approval = this.approvalView(event.payload)
|
||
this.emit({ event: 'approval.request', sessionId: this.selected.storedId, approval: this.approval })
|
||
return
|
||
}
|
||
if (event.type === 'message.complete') this.approval = null
|
||
super.onEvent(event)
|
||
}
|
||
|
||
approvalView(payload) {
|
||
if (!identifier(payload?.request_id) || typeof payload?.command !== 'string') return null
|
||
// Retain full backend-redacted operation; never approve a clipped preview.
|
||
if (payload.command.length > 48000) return null
|
||
return { requestId: payload.request_id, command: payload.command,
|
||
reason: typeof payload.reason === 'string' ? payload.reason : '',
|
||
choices: (payload.choices || []).filter(x => ['once', 'deny'].includes(x)) }
|
||
}
|
||
|
||
handle(message) {
|
||
const result = this.chain.then(() => this.perform(message))
|
||
this.chain = result.catch(() => {})
|
||
return result
|
||
}
|
||
|
||
async perform(message) {
|
||
if (!message || !identifier(message.id)) throw new Error('Geçersiz istek.')
|
||
await this.authorize()
|
||
const params = message.params || {}
|
||
if (message.action === 'session.open') {
|
||
const list = await super.handle({ id: message.id, action: 'sessions.list' })
|
||
if (!list.sessions.some(row => row.id === params.sessionId)) throw new Error('Bu IDE konuşması erişilebilir değil.')
|
||
const result = await super.handle(message)
|
||
this.model = result.model
|
||
const resumed = await this.rpc('session.resume', { session_id: params.sessionId, cols: 96,
|
||
source: 'desktop', defer_history: true, omit_messages: true, ...this.scope() })
|
||
this.approval = this.approvalView(resumed.pending_approval)
|
||
return { ...result, approval: this.approval }
|
||
}
|
||
if (message.action === 'task.submit') {
|
||
const capabilities = await this.rpc('gateway.capabilities', {})
|
||
if (!capabilities.client_task_tracking) throw new Error('IDE sürümü mobil görev takibini desteklemiyor. IDE güncellemesi gerekli.')
|
||
if (this.selected?.storedId !== params.sessionId || !params.model || params.model !== this.model)
|
||
throw new Error('Konuşma veya iş modeli değişti. Hedefi tekrar seçin.')
|
||
if (typeof params.text !== 'string' || !params.text.trim() || params.text.length > 48000)
|
||
throw new Error('Görev boş olamaz ve 48.000 karakteri aşamaz.')
|
||
if (!['research', 'execute'].includes(params.intent)) throw new Error('Görev türü gerekli.')
|
||
// A model change by the desktop between selection and delivery fails closed.
|
||
const info = await this.rpc('session.resume', { session_id: params.sessionId, cols: 96,
|
||
source: 'desktop', defer_history: true, omit_messages: true, ...this.scope() })
|
||
if (info.info?.model !== params.model || info.session_id !== this.selected.runtimeId)
|
||
throw new Error('IDE hedefi değişti. Konuşmayı yeniden seçin.')
|
||
const envelope = { sessionId: params.sessionId, profile: this.backend.profile || 'default',
|
||
model: params.model, text: params.text, intent: params.intent }
|
||
const reservation = await this.journal.reserve(this.deviceId, params.taskId, envelope)
|
||
const correlation = digest([this.deviceId, params.taskId])
|
||
if (!this.tasks.has(correlation)) this.tasks.set(correlation, { taskId: params.taskId, sessionId: params.sessionId,
|
||
runtimeId: this.selected.runtimeId, reservation, terminal: false, state: reservation.state || 'unknown' })
|
||
if (!reservation.fresh) {
|
||
// Recover a terminal event missed while the phone/companion was closed.
|
||
// Never resubmit even if the bounded replay window no longer has it.
|
||
const replay = await this.rpc('session.events.since', { session_id: this.selected.runtimeId, last_seen: 0 })
|
||
for (const frame of replay.events || []) if (frame.params?.payload?.task_id === correlation) this.onEvent(frame.params)
|
||
await this.journal.writes
|
||
const state = this.tasks.get(correlation).state
|
||
return { taskId: params.taskId, state, duplicate: true, historyRequired: !['complete', 'error', 'interrupted'].includes(state) }
|
||
}
|
||
await this.authorize()
|
||
await this.rpc('prompt.submit', { session_id: this.selected.runtimeId, text: params.text, queued: true,
|
||
client_task_id: correlation })
|
||
await this.journal.accepted(reservation)
|
||
if (!this.tasks.get(correlation).terminal) this.tasks.get(correlation).state = 'accepted'
|
||
return { taskId: params.taskId, state: 'accepted' }
|
||
}
|
||
if (message.action === 'approval.respond') {
|
||
if (this.selected?.storedId !== params.sessionId || !this.approval ||
|
||
params.requestId !== this.approval.requestId || !['once', 'deny'].includes(params.choice) ||
|
||
!this.approval.choices.includes(params.choice)) throw new Error('Onay artık geçerli değil. IDE durumunu yenileyin.')
|
||
const result = await this.rpc('approval.respond', { session_id: this.selected.runtimeId,
|
||
request_id: params.requestId, choice: params.choice, all: false })
|
||
if (!result.resolved) throw new Error('İşlem onayı daha önce yanıtlanmış veya sona ermiş.')
|
||
this.approval = null
|
||
return { resolved: true }
|
||
}
|
||
if (!['status', 'sessions.list', 'session.history', 'message.stop'].includes(message.action))
|
||
throw new Error('Bu mobil işlem desteklenmiyor.')
|
||
if (message.action === 'session.history' && this.selected?.storedId !== params.sessionId)
|
||
throw new Error('Önce konuşmayı seçin.')
|
||
return super.handle(message)
|
||
}
|
||
}
|