picocode-core 0.9.199 → 0.9.201
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/daemon-patch.js +35 -0
- package/src/daemon-projection.js +34 -0
- package/src/daemon-runtime.js +52 -16
- package/src/daemon-wire.js +35 -2
- package/src/remote-controller.js +46 -11
package/package.json
CHANGED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { isDeepStrictEqual } from 'node:util'
|
|
2
|
+
|
|
3
|
+
export function diffSnapshot(previous, next, path = [], patches = []) {
|
|
4
|
+
if (Object.is(previous, next)) return patches
|
|
5
|
+
if (typeof previous === 'string' && typeof next === 'string' && next.startsWith(previous)) {
|
|
6
|
+
patches.push({ op: 'appendText', path, value: next.slice(previous.length) })
|
|
7
|
+
} else if (Array.isArray(previous) && Array.isArray(next)) {
|
|
8
|
+
const common = Math.min(previous.length, next.length)
|
|
9
|
+
for (let i = 0; i < common; i++) diffSnapshot(previous[i], next[i], [...path, i], patches)
|
|
10
|
+
if (next.length < previous.length) patches.push({ op: 'truncate', path, length: next.length })
|
|
11
|
+
if (next.length > previous.length) patches.push({ op: 'append', path, value: next.slice(previous.length) })
|
|
12
|
+
} else if (previous && next && Object.getPrototypeOf(previous) === Object.prototype && Object.getPrototypeOf(next) === Object.prototype) {
|
|
13
|
+
for (const key of Object.keys(previous)) if (!(key in next)) patches.push({ op: 'delete', path: [...path, key] })
|
|
14
|
+
for (const key of Object.keys(next)) diffSnapshot(previous[key], next[key], [...path, key], patches)
|
|
15
|
+
} else if (!isDeepStrictEqual(previous, next)) patches.push({ op: 'set', path, value: next })
|
|
16
|
+
return patches
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function applyPatches(snapshot, patches) {
|
|
20
|
+
function update(value, path, patch) {
|
|
21
|
+
if (!path.length) {
|
|
22
|
+
if (patch.op === 'appendText') return value + patch.value
|
|
23
|
+
if (patch.op === 'append') return [...value, ...patch.value]
|
|
24
|
+
if (patch.op === 'truncate') return value.slice(0, patch.length)
|
|
25
|
+
return patch.value
|
|
26
|
+
}
|
|
27
|
+
const copy = Array.isArray(value) ? [...value] : { ...value }
|
|
28
|
+
const [key, ...rest] = path
|
|
29
|
+
if (!rest.length && patch.op === 'delete') delete copy[key]
|
|
30
|
+
else copy[key] = update(value[key], rest, patch)
|
|
31
|
+
return copy
|
|
32
|
+
}
|
|
33
|
+
for (const patch of patches) snapshot = update(snapshot, patch.path, patch)
|
|
34
|
+
return snapshot
|
|
35
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { plain } from './daemon-wire.js'
|
|
2
|
+
|
|
3
|
+
export const defaultTranscriptLimit = 100
|
|
4
|
+
const stateExcluded = new Set(['events', 'persisted', 'derived', 'attachments', 'rewindUndo'])
|
|
5
|
+
const derivedExcluded = new Set(['transcript', 'providerHistory', 'historyEventIds', 'loadedContext', 'toolItems', 'peerMessages', 'consumedPeerIds', 'pendingPeerMessages', 'deferredConsumedPeers', 'openToolCalls', 'trimmedToolIds', 'toolTrimVersions'])
|
|
6
|
+
const agentExcluded = new Set(['events', 'timeline', 'options', 'controller', 'done', 'resolve'])
|
|
7
|
+
const select = (value, excluded) => Object.fromEntries(Object.entries(value || {}).filter(([key]) => !excluded.has(key)))
|
|
8
|
+
|
|
9
|
+
export function createProjection() {
|
|
10
|
+
const clones = new WeakMap()
|
|
11
|
+
const derivedCache = new WeakMap()
|
|
12
|
+
const windows = new WeakMap()
|
|
13
|
+
function immutable(value) {
|
|
14
|
+
if (!value || typeof value !== 'object') return plain(value)
|
|
15
|
+
if (!clones.has(value)) clones.set(value, plain(value))
|
|
16
|
+
return clones.get(value)
|
|
17
|
+
}
|
|
18
|
+
return {
|
|
19
|
+
state(source, limit = defaultTranscriptLimit) {
|
|
20
|
+
const derived = source.derived || {}
|
|
21
|
+
if (!derivedCache.has(derived)) derivedCache.set(derived, plain(select(derived, derivedExcluded)))
|
|
22
|
+
const transcript = derived.transcript || []
|
|
23
|
+
let byLimit = windows.get(derived)
|
|
24
|
+
if (!byLimit) windows.set(derived, byLimit = new Map())
|
|
25
|
+
const offset = Math.max(0, transcript.length - limit)
|
|
26
|
+
if (!byLimit.has(limit)) byLimit.set(limit, transcript.slice(offset).map(immutable))
|
|
27
|
+
const attachments = new Map([...source.attachments || []].map(([key, value]) => [key, plain(Object.fromEntries(['kind', 'path', 'mediaType', 'hash', 'subject', 'root', 'url', 'selector', 'tag', 'component', 'fromLine', 'toLine', 'fromColumn', 'toColumn'].filter(k => value[k] !== undefined).map(k => [k, value[k]])))]))
|
|
28
|
+
return { ...plain(select(source, stateExcluded)), attachments, rewindUndo: !!source.rewindUndo, derived: { ...derivedCache.get(derived), transcript: byLimit.get(limit), transcriptOffset: offset, transcriptTotal: transcript.length } }
|
|
29
|
+
},
|
|
30
|
+
agents(values) {
|
|
31
|
+
return values.map(value => ({ ...plain(select(value, agentExcluded)), ...(value.timeline ? { timeline: value.timeline.map(immutable) } : {}), events: (value.events || []).filter(event => ['content', 'tool_executing', 'tool_complete', 'tool_error'].includes(event.type)).map(immutable) }))
|
|
32
|
+
},
|
|
33
|
+
}
|
|
34
|
+
}
|
package/src/daemon-runtime.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto'
|
|
2
2
|
import { plain } from './daemon-wire.js'
|
|
3
|
+
import { diffSnapshot } from './daemon-patch.js'
|
|
4
|
+
import { createProjection, defaultTranscriptLimit } from './daemon-projection.js'
|
|
3
5
|
import { forkSession, loadSession } from './session.js'
|
|
4
6
|
|
|
5
7
|
const events = ['change', 'derived', 'flash', 'input', 'question', 'turn', 'session', 'resumed', 'mcp', 'shells', 'git', 'project', 'image']
|
|
@@ -7,7 +9,11 @@ const queries = ['activity', 'shellRows', 'costSummary', 'speedApplies', 'effort
|
|
|
7
9
|
const busy = ctl => ctl.isWorking?.() || ctl.state.busy || ctl.state.compacting || ctl.agents?.list().some(a => ['running', 'queued'].includes(a.status))
|
|
8
10
|
const named = ctl => !!ctl.state.derived?.title?.trim()
|
|
9
11
|
|
|
10
|
-
|
|
12
|
+
const projections = new WeakMap()
|
|
13
|
+
|
|
14
|
+
export function snapshot(ctl, limit = defaultTranscriptLimit) {
|
|
15
|
+
if (!projections.has(ctl)) projections.set(ctl, createProjection())
|
|
16
|
+
const projection = projections.get(ctl)
|
|
11
17
|
const boot = ctl.boot
|
|
12
18
|
const services = {}
|
|
13
19
|
for (const [service, methods] of Object.entries({ shells: ['list', 'running'], wakeups: ['list', 'pending'], git: ['status'], mcp: ['list'], commands: ['list'], skills: ['list'] })) {
|
|
@@ -21,7 +27,7 @@ export function snapshot(ctl) {
|
|
|
21
27
|
}
|
|
22
28
|
const cached = {}
|
|
23
29
|
for (const method of queries) if (typeof ctl[method] === 'function') cached[method] = plain(ctl[method]())
|
|
24
|
-
return { state:
|
|
30
|
+
return { state: projection.state(ctl.state, limit), boot: plain(Object.fromEntries(Object.entries(boot).filter(([key]) => !['refs', 'shells', 'wakeups', 'git', 'mcp', 'memory', 'commands', 'skills', 'tracker'].includes(key)))), services, cached, agents: projection.agents(ctl.agents?.list() || []), methods: Object.keys(ctl).filter(k => typeof ctl[k] === 'function' && !['on', 'shutdown'].includes(k)) }
|
|
25
31
|
}
|
|
26
32
|
|
|
27
33
|
export function createDaemonRuntime({ createSession, onEmpty = () => {}, batchMs = 16 }) {
|
|
@@ -37,19 +43,38 @@ export function createDaemonRuntime({ createSession, onEmpty = () => {}, batchMs
|
|
|
37
43
|
async function createRecord(client, options) {
|
|
38
44
|
const created = await createSession(options)
|
|
39
45
|
const ctl = created.controller || created
|
|
40
|
-
const record = { id: randomUUID(), ctl, dispose: created.dispose, views: new Set([client]), events: [], timer: null, unsub: [] }
|
|
46
|
+
const record = { id: randomUUID(), ctl, dispose: created.dispose, views: new Set([client]), events: [], timer: null, unsub: [], baselines: new Map(), limits: new Map() }
|
|
41
47
|
sessions.set(record.id, record)
|
|
42
48
|
for (const type of events) record.unsub.push(ctl.on(type, payload => publish(record, type, payload)))
|
|
43
49
|
return record
|
|
44
50
|
}
|
|
51
|
+
function fullSnapshot(record, client) {
|
|
52
|
+
const value = snapshot(record.ctl, record.limits.get(client) ?? defaultTranscriptLimit)
|
|
53
|
+
const sequence = (record.baselines.get(client)?.sequence || 0) + 1
|
|
54
|
+
record.baselines.set(client, { snapshot: value, sequence })
|
|
55
|
+
return { snapshot: value, sequence }
|
|
56
|
+
}
|
|
57
|
+
function flush(record) {
|
|
58
|
+
clearTimeout(record.timer)
|
|
59
|
+
record.timer = null
|
|
60
|
+
const snapshots = new Map()
|
|
61
|
+
const notifications = record.events.splice(0)
|
|
62
|
+
for (const client of record.views) {
|
|
63
|
+
const baseline = record.baselines.get(client)
|
|
64
|
+
if (!baseline) continue
|
|
65
|
+
const limit = record.limits.get(client) ?? defaultTranscriptLimit
|
|
66
|
+
if (!snapshots.has(limit)) snapshots.set(limit, snapshot(record.ctl, limit))
|
|
67
|
+
const next = snapshots.get(limit)
|
|
68
|
+
const sequence = baseline.sequence + 1
|
|
69
|
+
const patches = diffSnapshot(baseline.snapshot, next)
|
|
70
|
+
record.baselines.set(client, { snapshot: next, sequence })
|
|
71
|
+
client.send({ type: 'update', id: record.id, patches, baseSequence: baseline.sequence, sequence, events: notifications })
|
|
72
|
+
}
|
|
73
|
+
for (const client of clients) client.send({ type: 'workspace' })
|
|
74
|
+
}
|
|
45
75
|
function publish(record, type, payload) {
|
|
46
|
-
if (
|
|
47
|
-
if (!record.timer) record.timer = setTimeout(() =>
|
|
48
|
-
record.timer = null
|
|
49
|
-
const update = { type: 'update', id: record.id, snapshot: snapshot(record.ctl), events: record.events.splice(0) }
|
|
50
|
-
for (const client of record.views) client.send(update)
|
|
51
|
-
for (const client of clients) client.send({ type: 'workspace' })
|
|
52
|
-
}, batchMs)
|
|
76
|
+
if (!['change', 'derived'].includes(type)) record.events.push({ type, payload: plain(type === 'project' ? null : payload) })
|
|
77
|
+
if (!record.timer) record.timer = setTimeout(() => flush(record), batchMs)
|
|
53
78
|
}
|
|
54
79
|
async function dispose(record) {
|
|
55
80
|
if (!sessions.delete(record.id)) return
|
|
@@ -63,6 +88,8 @@ export function createDaemonRuntime({ createSession, onEmpty = () => {}, batchMs
|
|
|
63
88
|
const record = sessions.get(id)
|
|
64
89
|
if (!record) return
|
|
65
90
|
record.views.delete(client)
|
|
91
|
+
record.baselines.delete(client)
|
|
92
|
+
record.limits.delete(client)
|
|
66
93
|
if (!record.views.size && !named(record.ctl)) await dispose(record)
|
|
67
94
|
}
|
|
68
95
|
return {
|
|
@@ -88,11 +115,19 @@ export function createDaemonRuntime({ createSession, onEmpty = () => {}, batchMs
|
|
|
88
115
|
})
|
|
89
116
|
if (op === 'create') {
|
|
90
117
|
const record = await createRecord(client, args[0] || {})
|
|
91
|
-
return { id: record.id,
|
|
118
|
+
return { id: record.id, ...fullSnapshot(record, client) }
|
|
92
119
|
}
|
|
93
120
|
const record = sessions.get(id)
|
|
94
121
|
if (!record) throw new Error('session is no longer running')
|
|
95
|
-
if (op === 'attach') { record.views.add(client); return { id,
|
|
122
|
+
if (op === 'attach') { record.views.add(client); return { id, ...fullSnapshot(record, client) } }
|
|
123
|
+
if (op === 'snapshot') { if (!record.views.has(client)) throw new Error('attach first'); return fullSnapshot(record, client) }
|
|
124
|
+
if (op === 'history') {
|
|
125
|
+
if (!record.views.has(client)) throw new Error('attach first')
|
|
126
|
+
const [limit] = args
|
|
127
|
+
if (!Number.isSafeInteger(limit) || limit < 1) throw new Error('history limit must be a positive safe integer')
|
|
128
|
+
record.limits.set(client, Math.max(record.limits.get(client) ?? defaultTranscriptLimit, limit))
|
|
129
|
+
return fullSnapshot(record, client)
|
|
130
|
+
}
|
|
96
131
|
if (op === 'switch') {
|
|
97
132
|
const [method, value] = args
|
|
98
133
|
if (!record.views.has(client)) throw new Error('attach to the session first')
|
|
@@ -102,7 +137,7 @@ export function createDaemonRuntime({ createSession, onEmpty = () => {}, batchMs
|
|
|
102
137
|
if (existing) {
|
|
103
138
|
existing.views.add(client)
|
|
104
139
|
if (existing !== record) await detach(client, id)
|
|
105
|
-
return { id: existing.id,
|
|
140
|
+
return { id: existing.id, ...fullSnapshot(existing, client) }
|
|
106
141
|
}
|
|
107
142
|
const saved = await loadSession(meta.file)
|
|
108
143
|
meta = { ...meta, header: saved.header }
|
|
@@ -125,7 +160,7 @@ export function createDaemonRuntime({ createSession, onEmpty = () => {}, batchMs
|
|
|
125
160
|
if (renamed === false || next.ctl.state.derived?.title !== name) throw new Error(`could not create session named "${name}"`)
|
|
126
161
|
}
|
|
127
162
|
await detach(client, id)
|
|
128
|
-
return { id: next.id,
|
|
163
|
+
return { id: next.id, ...fullSnapshot(next, client) }
|
|
129
164
|
} catch (error) { await dispose(next); throw error }
|
|
130
165
|
}
|
|
131
166
|
if (op === 'detach') { await detach(client, id); return }
|
|
@@ -150,11 +185,12 @@ export function createDaemonRuntime({ createSession, onEmpty = () => {}, batchMs
|
|
|
150
185
|
if (target === 'controller' && method === 'compact') {
|
|
151
186
|
Promise.resolve(object[method](...values)).catch(error => publish(record, 'flash', error.message))
|
|
152
187
|
publish(record, 'change')
|
|
153
|
-
return { result: undefined,
|
|
188
|
+
return { result: undefined, incremental: true }
|
|
154
189
|
}
|
|
155
190
|
const result = await object[method](...values)
|
|
156
191
|
publish(record, 'change')
|
|
157
|
-
|
|
192
|
+
flush(record)
|
|
193
|
+
return { result, incremental: true }
|
|
158
194
|
}
|
|
159
195
|
if (op === 'configure') {
|
|
160
196
|
const allowed = ['researchModel', 'shellModel', 'deliberationModel', 'participantAModel', 'participantBModel', 'researchAgentLimit', 'clouds', 'compactToolHistory', 'gitFooter', 'wideSidebar']
|
package/src/daemon-wire.js
CHANGED
|
@@ -32,12 +32,45 @@ export function wire(socket, receive, fail = () => {}) {
|
|
|
32
32
|
} catch (error) { fail(error); socket.destroy() }
|
|
33
33
|
})
|
|
34
34
|
socket.on('error', fail)
|
|
35
|
-
|
|
35
|
+
const updates = new Map()
|
|
36
|
+
let workspacePending = false
|
|
37
|
+
function write(message) {
|
|
38
|
+
if (socket.destroyed) return false
|
|
36
39
|
const body = serialize(plain(message))
|
|
37
|
-
if (body.length > limit
|
|
40
|
+
if (body.length > limit) {
|
|
41
|
+
const error = new Error(`daemon frame exceeds limit (${body.length} bytes)`)
|
|
42
|
+
fail(error)
|
|
43
|
+
socket.destroy(error)
|
|
44
|
+
return false
|
|
45
|
+
}
|
|
38
46
|
const header = Buffer.alloc(4)
|
|
39
47
|
header.writeUInt32BE(body.length)
|
|
40
48
|
socket.write(Buffer.concat([header, body]))
|
|
41
49
|
return true
|
|
42
50
|
}
|
|
51
|
+
socket.on('drain', () => {
|
|
52
|
+
for (const [id, update] of updates) {
|
|
53
|
+
if (socket.writableNeedDrain || socket.destroyed) break
|
|
54
|
+
updates.delete(id)
|
|
55
|
+
write(update)
|
|
56
|
+
}
|
|
57
|
+
if (workspacePending && !socket.writableNeedDrain && !socket.destroyed) {
|
|
58
|
+
workspacePending = false
|
|
59
|
+
write({ type: 'workspace' })
|
|
60
|
+
}
|
|
61
|
+
})
|
|
62
|
+
socket.on('close', () => updates.clear())
|
|
63
|
+
return message => {
|
|
64
|
+
if (socket.destroyed) return false
|
|
65
|
+
if (socket.writableNeedDrain && message.type === 'update') {
|
|
66
|
+
const previous = updates.get(message.id)
|
|
67
|
+
updates.set(message.id, { ...message, ...(message.patches && previous?.patches ? { baseSequence: previous.baseSequence, patches: [...previous.patches, ...message.patches] } : {}), events: [...(previous?.events || []), ...(message.events || [])] })
|
|
68
|
+
return true
|
|
69
|
+
}
|
|
70
|
+
if (socket.writableNeedDrain && message.type === 'workspace') {
|
|
71
|
+
workspacePending = true
|
|
72
|
+
return true
|
|
73
|
+
}
|
|
74
|
+
return write(message)
|
|
75
|
+
}
|
|
43
76
|
}
|
package/src/remote-controller.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { applyPatches } from './daemon-patch.js'
|
|
2
2
|
|
|
3
3
|
export async function createRemoteController(connection, { id, options = {}, localBoot = {} } = {}) {
|
|
4
4
|
const initial = await connection.request(id ? 'attach' : 'create', id, id ? [] : [options])
|
|
@@ -6,16 +6,21 @@ export async function createRemoteController(connection, { id, options = {}, loc
|
|
|
6
6
|
const state = {}
|
|
7
7
|
const boot = { refs: {}, ...localBoot }
|
|
8
8
|
const listeners = new Map()
|
|
9
|
+
const sequences = new Map([[id, initial.sequence]])
|
|
10
|
+
let recovering = false
|
|
9
11
|
let current
|
|
10
12
|
let previewId = null
|
|
11
13
|
let previewQueue = Promise.resolve()
|
|
12
14
|
const displayedId = () => previewId || id
|
|
13
15
|
const emit = (type, value) => { for (const fn of listeners.get(type) || []) fn(value) }
|
|
14
|
-
const call =
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
const call = (target, method, ...args) => {
|
|
17
|
+
const result = connection.request('call', id, [target, method, ...args]).then(reply => {
|
|
18
|
+
if (reply.snapshot) apply(reply.snapshot)
|
|
19
|
+
emit('change', state)
|
|
20
|
+
return reply.result
|
|
21
|
+
})
|
|
22
|
+
result.catch(error => emit('flash', error.message))
|
|
23
|
+
return result
|
|
19
24
|
}
|
|
20
25
|
function apply(snapshot) {
|
|
21
26
|
current = snapshot
|
|
@@ -56,12 +61,12 @@ export async function createRemoteController(connection, { id, options = {}, loc
|
|
|
56
61
|
if (nextId === id) { await clearPreview(); return }
|
|
57
62
|
const previous = id
|
|
58
63
|
const previousPreview = previewId
|
|
59
|
-
const next = await connection.request('attach', nextId)
|
|
64
|
+
const next = previousPreview === nextId ? null : await connection.request('attach', nextId)
|
|
60
65
|
previewId = null
|
|
61
66
|
if (previousPreview && previousPreview !== nextId) await connection.request('detach', previousPreview)
|
|
62
|
-
id =
|
|
67
|
+
id = nextId
|
|
63
68
|
controller.id = id
|
|
64
|
-
apply(next.snapshot)
|
|
69
|
+
if (next) { sequences.set(next.id, next.sequence); apply(next.snapshot) }
|
|
65
70
|
await connection.request('detach', previous)
|
|
66
71
|
emit('project', boot)
|
|
67
72
|
emit('session', state.session)
|
|
@@ -74,6 +79,7 @@ export async function createRemoteController(connection, { id, options = {}, loc
|
|
|
74
79
|
const next = await connection.request('switch', id, [method, value])
|
|
75
80
|
id = next.id
|
|
76
81
|
controller.id = id
|
|
82
|
+
sequences.set(next.id, next.sequence)
|
|
77
83
|
apply(next.snapshot)
|
|
78
84
|
if (method === 'newSession' && typeof value === 'string' && value.trim() && state.derived?.title !== value.trim()) {
|
|
79
85
|
const renamed = await call('controller', 'rename', value.trim())
|
|
@@ -83,7 +89,15 @@ export async function createRemoteController(connection, { id, options = {}, loc
|
|
|
83
89
|
emit('session', state.session)
|
|
84
90
|
emit('change', state)
|
|
85
91
|
}
|
|
86
|
-
controller.previewSteer = changes =>
|
|
92
|
+
controller.previewSteer = changes => call('controller', 'previewSteer', changes)
|
|
93
|
+
controller.loadHistory = async limit => {
|
|
94
|
+
const target = displayedId()
|
|
95
|
+
const reply = await connection.request('history', target, [Number.isFinite(limit) ? limit : Number.MAX_SAFE_INTEGER])
|
|
96
|
+
if (displayedId() !== target) return
|
|
97
|
+
sequences.set(target, reply.sequence)
|
|
98
|
+
apply(reply.snapshot)
|
|
99
|
+
emit('change', state)
|
|
100
|
+
}
|
|
87
101
|
function show(snapshot) {
|
|
88
102
|
apply(snapshot)
|
|
89
103
|
emit('project', boot)
|
|
@@ -96,6 +110,7 @@ export async function createRemoteController(connection, { id, options = {}, loc
|
|
|
96
110
|
const previous = previewId
|
|
97
111
|
const original = await connection.request('attach', id)
|
|
98
112
|
previewId = null
|
|
113
|
+
sequences.set(id, original.sequence)
|
|
99
114
|
show(original.snapshot)
|
|
100
115
|
await connection.request('detach', previous)
|
|
101
116
|
}
|
|
@@ -106,6 +121,7 @@ export async function createRemoteController(connection, { id, options = {}, loc
|
|
|
106
121
|
const next = await connection.request('attach', nextId)
|
|
107
122
|
const previous = previewId
|
|
108
123
|
previewId = nextId
|
|
124
|
+
sequences.set(nextId, next.sequence)
|
|
109
125
|
show(next.snapshot)
|
|
110
126
|
if (previous) await connection.request('detach', previous)
|
|
111
127
|
})
|
|
@@ -125,7 +141,26 @@ export async function createRemoteController(connection, { id, options = {}, loc
|
|
|
125
141
|
const unsubscribe = connection.on(message => {
|
|
126
142
|
if (message.type === 'disconnect') { state.busy = false; state.streaming = null; emit('flash', 'daemon disconnected; work was not restarted'); emit('change', state); return }
|
|
127
143
|
if (message.type !== 'update' || message.id !== displayedId()) return
|
|
128
|
-
|
|
144
|
+
if (message.patches) {
|
|
145
|
+
if (recovering) return
|
|
146
|
+
if (sequences.get(message.id) !== message.baseSequence) {
|
|
147
|
+
recovering = true
|
|
148
|
+
const recoveringId = displayedId()
|
|
149
|
+
connection.request('snapshot', recoveringId).then(reply => {
|
|
150
|
+
if (displayedId() !== recoveringId) return
|
|
151
|
+
sequences.set(recoveringId, reply.sequence)
|
|
152
|
+
apply(reply.snapshot)
|
|
153
|
+
emit('change', state)
|
|
154
|
+
}).catch(error => emit('flash', error.message)).finally(() => { recovering = false })
|
|
155
|
+
return
|
|
156
|
+
}
|
|
157
|
+
apply(applyPatches(current, message.patches))
|
|
158
|
+
sequences.set(message.id, message.sequence)
|
|
159
|
+
emit('derived', state.derived)
|
|
160
|
+
} else {
|
|
161
|
+
apply(message.snapshot)
|
|
162
|
+
emit('derived', state.derived)
|
|
163
|
+
}
|
|
129
164
|
for (const event of message.events) {
|
|
130
165
|
if (previewId && ['input', 'question', 'resumed', 'flash'].includes(event.type)) continue
|
|
131
166
|
emit(event.type, event.type === 'project' ? boot : event.payload)
|